diff --git a/app/services/tiptap_service.rb b/app/services/tiptap_service.rb
new file mode 100644
index 000000000..9514dc11c
--- /dev/null
+++ b/app/services/tiptap_service.rb
@@ -0,0 +1,65 @@
+class TiptapService
+ class << self
+ def to_html(node, tags)
+ children(node[:content], tags)
+ end
+
+ private
+
+ def children(content, tags)
+ content.map { node_to_html(_1, tags) }.join
+ end
+
+ def node_to_html(node, tags)
+ case node
+ in type: 'paragraph', content:, **rest
+ "
#{children(content, tags)}
"
+ in type: 'heading', attrs: { level:, **attrs }, content:
+ "#{children(content, tags)}"
+ in type: 'bulletList', content:
+ "#{children(content, tags)}
"
+ in type: 'orderedList', content:
+ "#{children(content, tags)}
"
+ in type: 'listItem', content:
+ "#{children(content, tags)}"
+ in type: 'text', text:, **rest
+ if rest[:marks].present?
+ apply_marks(text, rest[:marks])
+ else
+ text
+ end
+ in type: 'mention', attrs: { id: }, **rest
+ if rest[:marks].present?
+ apply_marks(tags[id], rest[:marks])
+ else
+ tags[id]
+ end
+ end
+ end
+
+ def text_align(attrs)
+ if attrs.present? && attrs[:textAlign].present?
+ " style=\"text-align: #{attrs[:textAlign]}\""
+ else
+ ""
+ end
+ end
+
+ def apply_marks(text, marks)
+ marks.reduce(text) do |text, mark|
+ case mark
+ in type: 'bold'
+ "#{text}"
+ in type: 'italic'
+ "#{text}"
+ in type: 'underline'
+ "#{text}"
+ in type: 'strike'
+ "#{text}"
+ in type: 'highlight'
+ "#{text}"
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/tiptap_service_spec.rb b/spec/services/tiptap_service_spec.rb
new file mode 100644
index 000000000..37cdd6d0c
--- /dev/null
+++ b/spec/services/tiptap_service_spec.rb
@@ -0,0 +1,142 @@
+RSpec.describe TiptapService do
+ describe '.to_html' do
+ let(:json) do
+ {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ attrs: { textAlign: 'right' },
+ content: [
+ {
+ type: 'text',
+ text: 'Hello world!'
+ }
+ ]
+ },
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'Bonjour ',
+ marks: [{ type: 'italic' }, { type: 'strike' }]
+ },
+ {
+ type: 'mention',
+ attrs: { id: 'name' },
+ marks: [{ type: 'bold' }, { type: 'underline' }]
+ },
+ {
+ type: 'text',
+ text: ' '
+ },
+ {
+ type: 'text',
+ text: '!',
+ marks: [{ type: 'highlight' }]
+ }
+ ]
+ },
+ {
+ type: 'heading',
+ attrs: { level: 1 },
+ content: [{ type: 'text', text: 'Heading 1' }]
+ },
+ {
+ type: 'heading',
+ attrs: { level: 2, textAlign: 'center' },
+ content: [{ type: 'text', text: 'Heading 2' }]
+ },
+ {
+ type: 'heading',
+ attrs: { level: 3 },
+ content: [{ type: 'text', text: 'Heading 3' }]
+ },
+ {
+ type: 'bulletList',
+ content: [
+ {
+ type: 'listItem',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'Item 1'
+ }
+ ]
+ }
+ ]
+ },
+ {
+ type: 'listItem',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'Item 2'
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ type: 'orderedList',
+ content: [
+ {
+ type: 'listItem',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'Item 1'
+ }
+ ]
+ }
+ ]
+ },
+ {
+ type: 'listItem',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'Item 2'
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ end
+ let(:tags) { { 'name' => 'Paul' } }
+ let(:html) do
+ [
+ 'Hello world!
',
+ 'Bonjour Paul !
',
+ 'Heading 1
',
+ 'Heading 2
',
+ 'Heading 3
',
+ '',
+ 'Item 1
Item 2
'
+ ].join
+ end
+
+ it 'returns html' do
+ expect(described_class.to_html(json, tags)).to eq(html)
+ end
+ end
+end