prosereflect 0.1.0 → 0.1.1

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