prosereflect 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/rake.yml +4 -0
  3. data/.github/workflows/release.yml +5 -0
  4. data/.rubocop.yml +19 -1
  5. data/.rubocop_todo.yml +143 -174
  6. data/CLAUDE.md +78 -0
  7. data/Gemfile +8 -4
  8. data/README.adoc +193 -12
  9. data/Rakefile +3 -3
  10. data/lib/prosereflect/attribute/base.rb +32 -0
  11. data/lib/prosereflect/attribute/bold.rb +18 -0
  12. data/lib/prosereflect/attribute/href.rb +22 -0
  13. data/lib/prosereflect/attribute/id.rb +24 -0
  14. data/lib/prosereflect/attribute.rb +10 -0
  15. data/lib/prosereflect/blockquote.rb +84 -0
  16. data/lib/prosereflect/bullet_list.rb +84 -0
  17. data/lib/prosereflect/code_block.rb +135 -0
  18. data/lib/prosereflect/code_block_wrapper.rb +65 -0
  19. data/lib/prosereflect/document.rb +93 -26
  20. data/lib/prosereflect/hard_break.rb +13 -11
  21. data/lib/prosereflect/heading.rb +63 -0
  22. data/lib/prosereflect/horizontal_rule.rb +70 -0
  23. data/lib/prosereflect/image.rb +126 -0
  24. data/lib/prosereflect/input/html.rb +484 -0
  25. data/lib/prosereflect/input.rb +7 -0
  26. data/lib/prosereflect/list_item.rb +64 -0
  27. data/lib/prosereflect/mark/base.rb +47 -0
  28. data/lib/prosereflect/mark/bold.rb +13 -0
  29. data/lib/prosereflect/mark/code.rb +12 -0
  30. data/lib/prosereflect/mark/italic.rb +13 -0
  31. data/lib/prosereflect/mark/link.rb +16 -0
  32. data/lib/prosereflect/mark/strike.rb +13 -0
  33. data/lib/prosereflect/mark/subscript.rb +13 -0
  34. data/lib/prosereflect/mark/superscript.rb +13 -0
  35. data/lib/prosereflect/mark/underline.rb +13 -0
  36. data/lib/prosereflect/mark.rb +15 -0
  37. data/lib/prosereflect/node.rb +181 -32
  38. data/lib/prosereflect/ordered_list.rb +86 -0
  39. data/lib/prosereflect/output/html.rb +376 -0
  40. data/lib/prosereflect/output.rb +7 -0
  41. data/lib/prosereflect/paragraph.rb +29 -20
  42. data/lib/prosereflect/parser.rb +101 -33
  43. data/lib/prosereflect/table.rb +42 -12
  44. data/lib/prosereflect/table_cell.rb +36 -11
  45. data/lib/prosereflect/table_header.rb +92 -0
  46. data/lib/prosereflect/table_row.rb +34 -11
  47. data/lib/prosereflect/text.rb +15 -19
  48. data/lib/prosereflect/user.rb +63 -0
  49. data/lib/prosereflect/version.rb +1 -1
  50. data/lib/prosereflect.rb +27 -11
  51. data/prosereflect.gemspec +17 -15
  52. data/spec/prosereflect/document_spec.rb +477 -75
  53. data/spec/prosereflect/hard_break_spec.rb +226 -30
  54. data/spec/prosereflect/input/html_spec.rb +797 -0
  55. data/spec/prosereflect/node_spec.rb +307 -137
  56. data/spec/prosereflect/output/html_spec.rb +369 -0
  57. data/spec/prosereflect/paragraph_spec.rb +458 -82
  58. data/spec/prosereflect/parser_spec.rb +311 -93
  59. data/spec/prosereflect/table_cell_spec.rb +282 -71
  60. data/spec/prosereflect/table_row_spec.rb +218 -48
  61. data/spec/prosereflect/table_spec.rb +415 -82
  62. data/spec/prosereflect/text_spec.rb +231 -72
  63. data/spec/prosereflect/user_spec.rb +76 -0
  64. data/spec/prosereflect_spec.rb +30 -23
  65. data/spec/spec_helper.rb +6 -6
  66. data/spec/support/matchers.rb +6 -6
  67. data/spec/support/shared_examples.rb +79 -50
  68. metadata +53 -6
  69. data/debug_loading.rb +0 -34
  70. data/spec/prosereflect/version_spec.rb +0 -11
@@ -0,0 +1,376 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Prosereflect
6
+ module Output
7
+ class Html
8
+ class << self
9
+ # Convert a Prosereflect::Document to HTML
10
+ def convert(document)
11
+ builder = Nokogiri::HTML::Builder.new do |doc|
12
+ doc.div do
13
+ process_node(document, doc)
14
+ end
15
+ end
16
+
17
+ doc = Nokogiri::HTML(builder.to_html)
18
+ html = doc.at_css("div").children.to_html
19
+
20
+ code_blocks = {}
21
+ html.scan(%r{<code[^>]*>(.*?)</code>}m).each_with_index do |match, i|
22
+ code_content = match[0]
23
+ placeholder = "CODE_BLOCK_#{i}"
24
+ code_blocks[placeholder] = code_content
25
+ html.sub!(code_content, placeholder)
26
+ end
27
+
28
+ # Remove newlines and spaces
29
+ html = html.gsub(/\n\s*/, "")
30
+
31
+ code_blocks.each do |placeholder, content|
32
+ html.sub!(placeholder, content)
33
+ end
34
+
35
+ html
36
+ end
37
+
38
+ private
39
+
40
+ # Process a node and its children
41
+ def process_node(node, builder)
42
+ return unless node
43
+
44
+ case node.type
45
+ when "doc"
46
+ process_document(node, builder)
47
+ when "paragraph"
48
+ process_paragraph(node, builder)
49
+ when "heading"
50
+ process_heading(node, builder)
51
+ when "text"
52
+ process_text(node, builder)
53
+ when "table"
54
+ process_table(node, builder)
55
+ when "table_row"
56
+ process_table_row(node, builder)
57
+ when "table_cell"
58
+ process_table_cell(node, builder)
59
+ when "table_header"
60
+ process_table_header(node, builder)
61
+ when "hard_break"
62
+ builder.br
63
+ when "image"
64
+ process_image(node, builder)
65
+ when "user"
66
+ process_user(node, builder)
67
+ when "bullet_list"
68
+ process_bullet_list(node, builder)
69
+ when "ordered_list"
70
+ process_ordered_list(node, builder)
71
+ when "list_item"
72
+ process_list_item(node, builder)
73
+ when "blockquote"
74
+ process_blockquote(node, builder)
75
+ when "horizontal_rule"
76
+ process_horizontal_rule(node, builder)
77
+ when "code_block_wrapper"
78
+ process_code_block_wrapper(node, builder)
79
+ when "code_block"
80
+ process_code_block(node, builder)
81
+ else
82
+ # Default handling for unknown nodes - treat as a container
83
+ process_children(node, builder)
84
+ end
85
+ end
86
+
87
+ # Process the document node
88
+ def process_document(node, builder)
89
+ process_children(node, builder)
90
+ end
91
+
92
+ # Process a paragraph node
93
+ def process_paragraph(node, builder)
94
+ builder.p do
95
+ process_children(node, builder)
96
+ end
97
+ end
98
+
99
+ # Process a heading node
100
+ def process_heading(node, builder)
101
+ level = node.level || 1
102
+ builder.send("h#{level}") do
103
+ process_children(node, builder)
104
+ end
105
+ end
106
+
107
+ # Process a text node, applying marks
108
+ def process_text(node, builder)
109
+ return unless node.text
110
+
111
+ if node.marks && !node.marks.empty?
112
+ apply_marks(node.text, node.marks, builder)
113
+ else
114
+ builder.text node.text
115
+ end
116
+ end
117
+
118
+ # Apply marks to text
119
+ def apply_marks(text, marks, builder)
120
+ return builder.text(text) if marks.empty?
121
+
122
+ current_mark = marks.first
123
+ remaining_marks = marks[1..]
124
+
125
+ mark_type = if current_mark.is_a?(Hash)
126
+ current_mark["type"]
127
+ elsif current_mark.respond_to?(:type)
128
+ current_mark.type
129
+ else
130
+ "unknown"
131
+ end
132
+
133
+ case mark_type
134
+ when "bold"
135
+ builder.strong do
136
+ apply_marks(text, remaining_marks, builder)
137
+ end
138
+ when "italic"
139
+ builder.em do
140
+ apply_marks(text, remaining_marks, builder)
141
+ end
142
+ when "code"
143
+ builder.code do
144
+ apply_marks(text, remaining_marks, builder)
145
+ end
146
+ when "link"
147
+ href = find_href_attribute(current_mark)
148
+ if href
149
+ builder.a(href: href) do
150
+ apply_marks(text, remaining_marks, builder)
151
+ end
152
+ else
153
+ apply_marks(text, remaining_marks, builder)
154
+ end
155
+ when "strike"
156
+ builder.del do
157
+ apply_marks(text, remaining_marks, builder)
158
+ end
159
+ when "underline"
160
+ builder.u do
161
+ apply_marks(text, remaining_marks, builder)
162
+ end
163
+ when "subscript"
164
+ builder.sub do
165
+ apply_marks(text, remaining_marks, builder)
166
+ end
167
+ when "superscript"
168
+ builder.sup do
169
+ apply_marks(text, remaining_marks, builder)
170
+ end
171
+ else
172
+ # Unknown mark, just process inner content
173
+ apply_marks(text, remaining_marks, builder)
174
+ end
175
+ end
176
+
177
+ # Find href attribute in a link mark
178
+ def find_href_attribute(mark)
179
+ if mark.is_a?(Hash)
180
+ if mark["attrs"].is_a?(Hash)
181
+ return mark["attrs"]["href"]
182
+ elsif mark["attrs"].is_a?(Array)
183
+ href_attr = mark["attrs"].find { |a| a.is_a?(Prosereflect::Attribute::Href) || (a.is_a?(Hash) && a["type"] == "href") }
184
+ return href_attr["href"] if href_attr.is_a?(Hash) && href_attr["href"]
185
+ return href_attr.href if href_attr.respond_to?(:href)
186
+ end
187
+ elsif mark.respond_to?(:attrs)
188
+ attrs = mark.attrs
189
+ if attrs.is_a?(Hash)
190
+ return attrs["href"]
191
+ elsif attrs.is_a?(Array)
192
+ href_attr = attrs.find { |attr| attr.is_a?(Prosereflect::Attribute::Href) }
193
+ return href_attr&.href if href_attr
194
+
195
+ hash_attr = attrs.find { |attr| attr.is_a?(Hash) && attr["href"] }
196
+ return hash_attr["href"] if hash_attr
197
+ end
198
+ end
199
+ nil
200
+ end
201
+
202
+ # Process a table node
203
+ def process_table(node, builder)
204
+ builder.table do
205
+ rows = node.rows || node.content
206
+ return if rows.empty?
207
+
208
+ has_header = rows.first&.content&.any? do |cell|
209
+ cell.type == "table_header"
210
+ end
211
+
212
+ if has_header
213
+ builder.thead do
214
+ process_node(rows.first, builder)
215
+ end
216
+ rows = rows[1..]
217
+ end
218
+
219
+ builder.tbody do
220
+ rows.each do |row|
221
+ process_node(row, builder)
222
+ end
223
+ end
224
+ end
225
+ end
226
+
227
+ # Process a table row
228
+ def process_table_row(node, builder)
229
+ builder.tr do
230
+ process_children(node, builder)
231
+ end
232
+ end
233
+
234
+ # Process a table cell
235
+ def process_table_cell(node, builder)
236
+ builder.td do
237
+ if node.content&.size == 1 && node.content.first.type == "paragraph"
238
+ node.content.first.content&.each do |child|
239
+ process_node(child, builder)
240
+ end
241
+ else
242
+ process_children(node, builder)
243
+ end
244
+ end
245
+ end
246
+
247
+ # Process a table header cell
248
+ def process_table_header(node, builder)
249
+ attrs = {}
250
+ attrs[:scope] = node.scope if node.scope
251
+ attrs[:abbr] = node.abbr if node.abbr
252
+ attrs[:colspan] = node.colspan if node.colspan
253
+
254
+ builder.th(attrs) do
255
+ if node.content&.size == 1 && node.content.first.type == "paragraph"
256
+ node.content.first.content&.each do |child|
257
+ process_node(child, builder)
258
+ end
259
+ else
260
+ process_children(node, builder)
261
+ end
262
+ end
263
+ end
264
+
265
+ # Process an image node
266
+ def process_image(node, builder)
267
+ attrs = {
268
+ src: node.src,
269
+ alt: node.alt,
270
+ }
271
+ attrs[:title] = node.title if node.title
272
+ attrs[:width] = node.width if node.width
273
+ attrs[:height] = node.height if node.height
274
+
275
+ builder.img(attrs)
276
+ end
277
+
278
+ # Process a user mention node
279
+ def process_user(node, builder)
280
+ builder << "<user-mention data-id=\"#{node.id}\"></user-mention>"
281
+ end
282
+
283
+ # Process a bullet list node
284
+ def process_bullet_list(node, builder)
285
+ builder.ul do
286
+ node.content&.each do |child|
287
+ if child.type == "list_item"
288
+ process_node(child, builder)
289
+ else
290
+ builder.li do
291
+ process_node(child, builder)
292
+ end
293
+ end
294
+ end
295
+ end
296
+ end
297
+
298
+ # Process an ordered list node
299
+ def process_ordered_list(node, builder)
300
+ attrs = {}
301
+ attrs[:start] = node.start if node.start && node.start != 1
302
+
303
+ builder.ol(attrs) do
304
+ process_children(node, builder)
305
+ end
306
+ end
307
+
308
+ # Process a list item node
309
+ def process_list_item(node, builder)
310
+ builder.li do
311
+ process_children(node, builder)
312
+ end
313
+ end
314
+
315
+ # Process a blockquote node
316
+ def process_blockquote(node, builder)
317
+ attrs = {}
318
+ attrs[:cite] = node.citation if node.citation
319
+
320
+ builder.blockquote(attrs) do
321
+ node.blocks&.each do |block|
322
+ process_node(block, builder)
323
+ end
324
+ end
325
+ end
326
+
327
+ # Process a horizontal rule node
328
+ def process_horizontal_rule(node, builder)
329
+ attrs = {}
330
+ attrs[:style] = []
331
+ attrs[:style] << "border-style: #{node.style}" if node.style
332
+ attrs[:style] << "width: #{node.width}" if node.width
333
+ attrs[:style] << "border-width: #{node.thickness}px" if node.thickness
334
+ attrs[:style] = attrs[:style].join("; ") unless attrs[:style].empty?
335
+
336
+ builder.hr(attrs)
337
+ end
338
+
339
+ # Process a code block wrapper node
340
+ def process_code_block_wrapper(node, builder)
341
+ attrs = {}
342
+ if node.attrs
343
+ attrs["data-line-numbers"] = "true" if node.attrs["line_numbers"]
344
+ if node.attrs["highlight_lines"].is_a?(Array) && !node.attrs["highlight_lines"].empty? && node.attrs["highlight_lines"] != [0]
345
+ attrs["data-highlight-lines"] =
346
+ node.attrs["highlight_lines"].join(",")
347
+ end
348
+ end
349
+
350
+ builder.pre(attrs) do
351
+ process_children(node, builder)
352
+ end
353
+ end
354
+
355
+ # Process a code block node
356
+ def process_code_block(node, builder)
357
+ attrs = {}
358
+ attrs["class"] = "language-#{node.language}" if node.language
359
+
360
+ builder.code(attrs) do
361
+ builder.text node.content
362
+ end
363
+ end
364
+
365
+ # Process all children of a node
366
+ def process_children(node, builder)
367
+ return unless node.content
368
+
369
+ node.content.each do |child|
370
+ process_node(child, builder)
371
+ end
372
+ end
373
+ end
374
+ end
375
+ end
376
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosereflect
4
+ module Output
5
+ autoload :Html, "#{__dir__}/output/html"
6
+ end
7
+ end
@@ -1,36 +1,45 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'node'
4
- require_relative 'text'
5
- require_relative 'hard_break'
6
-
7
3
  module Prosereflect
8
4
  class Paragraph < Node
5
+ PM_TYPE = "paragraph"
6
+
7
+ attribute :type, :string, default: -> {
8
+ self.class.send(:const_get, "PM_TYPE")
9
+ }
10
+
11
+ key_value do
12
+ map "type", to: :type, render_default: true
13
+ map "content", to: :content
14
+ map "attrs", to: :attrs
15
+ map "marks", to: :marks
16
+ end
17
+
18
+ def initialize(params = {})
19
+ super
20
+ self.content ||= []
21
+ end
22
+
23
+ def self.create(attrs = nil)
24
+ new(attrs: attrs)
25
+ end
26
+
9
27
  def text_nodes
10
- content.select { |node| node.type == 'text' }
28
+ return [] unless content
29
+
30
+ content.grep(Text)
11
31
  end
12
32
 
13
33
  def text_content
14
- result = ''
34
+ return "" unless content
35
+
36
+ result = ""
15
37
  content.each do |node|
16
- result += if node.type == 'text'
17
- node.text_content
18
- elsif node.type == 'hard_break'
19
- "\n"
20
- else
21
- node.text_content
22
- end
38
+ result += node.text_content
23
39
  end
24
40
  result
25
41
  end
26
42
 
27
- # Create a new paragraph
28
- def self.create(attrs = nil)
29
- para = new({ 'type' => 'paragraph', 'content' => [] })
30
- para.instance_variable_set(:@attrs, attrs) if attrs
31
- para
32
- end
33
-
34
43
  # Add text to the paragraph
35
44
  def add_text(text, marks = nil)
36
45
  return if text.nil? || text.empty?
@@ -1,14 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'node'
4
- require_relative 'text'
5
- require_relative 'paragraph'
6
- require_relative 'table'
7
- require_relative 'table_row'
8
- require_relative 'table_cell'
9
- require_relative 'hard_break'
10
- require_relative 'document'
11
-
12
3
  module Prosereflect
13
4
  class Parser
14
5
  def self.parse(data)
@@ -20,37 +11,114 @@ module Prosereflect
20
11
  def self.parse_node(data)
21
12
  return nil unless data.is_a?(Hash)
22
13
 
23
- case data['type']
24
- when 'text'
25
- Text.new(data)
26
- when 'paragraph'
27
- Paragraph.new(data)
28
- when 'table'
29
- Table.new(data)
30
- when 'table_row'
31
- TableRow.new(data)
32
- when 'table_cell'
33
- TableCell.new(data)
34
- when 'hard_break'
35
- HardBreak.new(data)
36
- when 'doc', 'document'
37
- Document.new(data)
14
+ type = data["type"]
15
+ text = data["text"]
16
+ attrs = data["attrs"]
17
+ marks_data = data["marks"]
18
+
19
+ # Find the right class based on type
20
+ node_class = case type
21
+ when "doc"
22
+ Document
23
+ when "paragraph"
24
+ Paragraph
25
+ when "text"
26
+ Text
27
+ when "table"
28
+ Table
29
+ when "table_row"
30
+ TableRow
31
+ when "table_cell"
32
+ TableCell
33
+ when "table_header"
34
+ TableHeader
35
+ when "hard_break"
36
+ HardBreak
37
+ when "heading"
38
+ Heading
39
+ when "ordered_list"
40
+ OrderedList
41
+ when "bullet_list"
42
+ BulletList
43
+ when "list_item"
44
+ ListItem
45
+ when "blockquote"
46
+ Blockquote
47
+ when "horizontal_rule"
48
+ HorizontalRule
49
+ when "image"
50
+ Image
51
+ when "user"
52
+ User
53
+ else
54
+ Node
55
+ end
56
+
57
+ if type == "text"
58
+ node = Text.new(text: text)
38
59
  else
39
- Node.new(data)
60
+ node = node_class.create(attrs)
61
+
62
+ # Process content recursively
63
+ if data["content"].is_a?(Array)
64
+ data["content"].each do |content_data|
65
+ child_node = parse_node(content_data)
66
+ node.add_child(child_node) if child_node
67
+ end
68
+ end
69
+ end
70
+
71
+ # Handle special attributes for specific node types
72
+ case type
73
+ when "ordered_list"
74
+ node.start = attrs["start"].to_i if attrs && attrs["start"]
75
+ when "bullet_list"
76
+ node.bullet_style = attrs["bullet_style"] if attrs && attrs["bullet_style"]
77
+ when "blockquote"
78
+ node.citation = attrs["cite"] if attrs && attrs["cite"]
79
+ when "horizontal_rule"
80
+ if attrs
81
+ node.style = attrs["border_style"] if attrs["border_style"]
82
+ node.width = attrs["width"] if attrs["width"]
83
+ node.thickness = attrs["thickness"].to_i if attrs["thickness"]
84
+ end
85
+ when "image"
86
+ if attrs
87
+ node.src = attrs["src"] if attrs["src"]
88
+ node.alt = attrs["alt"] if attrs["alt"]
89
+ node.title = attrs["title"] if attrs["title"]
90
+ node.dimensions = [attrs["width"]&.to_i, attrs["height"]&.to_i]
91
+ end
92
+ when "table_header"
93
+ if attrs
94
+ node.scope = attrs["scope"] if attrs["scope"]
95
+ node.abbr = attrs["abbr"] if attrs["abbr"]
96
+ node.colspan = attrs["colspan"] if attrs["colspan"]
97
+ end
40
98
  end
99
+
100
+ node.marks = marks_data if marks_data && !marks_data.empty?
101
+
102
+ node
41
103
  end
42
104
 
43
105
  def self.parse_document(data)
44
- raise ArgumentError, 'Input must be a hash' if data.nil?
45
- raise ArgumentError, 'Input must be a hash' unless data.is_a?(Hash)
106
+ raise ArgumentError, "Input cannot be nil" if data.nil?
107
+ unless data.is_a?(Hash)
108
+ raise ArgumentError,
109
+ "Input must be a hash, got #{data.class}"
110
+ end
46
111
 
47
- if data['content']
48
- Document.new(data)
49
- elsif data['contents'] && data['contents']['en'] && data['contents']['en']['content']
50
- Document.new({ 'content' => data['contents']['en']['content'] })
51
- else
52
- Document.new
112
+ document = parse_node(data)
113
+
114
+ unless document.is_a?(Document)
115
+ # If the result isn't a Document, create one and add the node as content
116
+ doc = Document.create
117
+ doc.add_child(document) if document
118
+ document = doc
53
119
  end
120
+
121
+ document
54
122
  end
55
123
  end
56
124
  end
@@ -1,12 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'node'
4
- require_relative 'table_row'
5
-
6
3
  module Prosereflect
4
+ # TODO: support for table attributes
5
+ # Table class represents a ProseMirror table.
6
+ # It contains rows, each of which can contain cells.
7
7
  class Table < Node
8
+ PM_TYPE = "table"
9
+
10
+ attribute :type, :string, default: -> {
11
+ self.class.send(:const_get, "PM_TYPE")
12
+ }
13
+
14
+ key_value do
15
+ map "type", to: :type, render_default: true
16
+ map "content", to: :content
17
+ map "attrs", to: :attrs
18
+ end
19
+
20
+ def initialize(attributes = {})
21
+ attributes[:content] ||= []
22
+ super
23
+ end
24
+
25
+ def self.create(attrs = nil)
26
+ new(type: PM_TYPE, attrs: attrs, content: [])
27
+ end
28
+
8
29
  def rows
9
- content.select { |node| node.type == 'table_row' }
30
+ return [] unless content
31
+
32
+ content
10
33
  end
11
34
 
12
35
  def header_row
@@ -27,18 +50,13 @@ module Prosereflect
27
50
  data_row.cells[col_index]
28
51
  end
29
52
 
30
- # Create a new table
31
- def self.create(attrs = nil)
32
- table = new({ 'type' => 'table', 'content' => [] })
33
- table.instance_variable_set(:@attrs, attrs) if attrs
34
- table
35
- end
36
-
37
53
  # Add a header row to the table
38
54
  def add_header(header_cells)
39
55
  row = TableRow.create
40
56
  header_cells.each do |cell_content|
41
- row.add_cell(cell_content)
57
+ header = TableHeader.create
58
+ header.add_paragraph(cell_content)
59
+ row.add_child(header)
42
60
  end
43
61
  add_child(row)
44
62
  row
@@ -60,5 +78,17 @@ module Prosereflect
60
78
  add_row(row_data)
61
79
  end
62
80
  end
81
+
82
+ # Override to_h to handle empty content and attributes properly
83
+ def to_h
84
+ result = super
85
+ result["content"] ||= []
86
+ if result["attrs"]
87
+ result["attrs"] =
88
+ result["attrs"].is_a?(Hash) && result["attrs"][:attrs] ? result["attrs"][:attrs] : result["attrs"]
89
+ result.delete("attrs") if result["attrs"].empty?
90
+ end
91
+ result
92
+ end
63
93
  end
64
94
  end