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
data/README.adoc CHANGED
@@ -80,7 +80,7 @@ tables = document.tables
80
80
  paragraphs = document.paragraphs
81
81
 
82
82
  # Access the first table
83
- first_table = document.first_table
83
+ first_table = document.find_first('table')
84
84
 
85
85
  # Access header row and data rows in a table
86
86
  header = first_table.header_row
@@ -119,7 +119,97 @@ tables = document.find_all('table')
119
119
  text_nodes = document.find_all('text')
120
120
 
121
121
  # Find child nodes of a specific type
122
- table_cells = table.find_children('table_cell')
122
+ table_cells = table.find_children(TableCell)
123
+ ----
124
+
125
+ === HTML Conversion
126
+
127
+ The gem provides functionality to convert between HTML and ProseMirror document models.
128
+
129
+ ==== From HTML
130
+
131
+ [source,ruby]
132
+ ----
133
+ require 'prosereflect'
134
+
135
+ # Parse from HTML string
136
+ html_content = '<p>This is a <strong>bold</strong> text in a paragraph.</p>'
137
+ document = Prosereflect::Input::Html.parse(html_content)
138
+
139
+ # Access the document structure
140
+ paragraph = document.paragraphs.first
141
+ text_content = paragraph.text_content # "This is a bold text in a paragraph."
142
+ ----
143
+
144
+ ==== User Mentions
145
+
146
+ The gem supports user mentions in documents, which can be useful for social features or collaborative editing.
147
+
148
+ [source,ruby]
149
+ ----
150
+ # Create a document with user mentions
151
+ document = Prosereflect::Document.create
152
+ paragraph = document.add_paragraph('Hello ')
153
+
154
+ # Add a user mention
155
+ user = Prosereflect::User.new
156
+ user.id = '123'
157
+ paragraph.add_child(user)
158
+
159
+ paragraph.add_text('!')
160
+
161
+ # Convert to HTML
162
+ html = Prosereflect::Output::Html.convert(document)
163
+ # => "<p>Hello <user-mention data-id=\"123\"></user-mention>!</p>"
164
+
165
+ # Parse HTML with user mentions
166
+ html_content = '<p>Hello <user-mention data-id="123"></user-mention>!</p>'
167
+ document = Prosereflect::Input::Html.parse(html_content)
168
+
169
+ # Access user mentions
170
+ user_mentions = document.find_all('user')
171
+ first_user = user_mentions.first
172
+ user_id = first_user.id # => "123"
173
+ ----
174
+
175
+ User mentions are represented as `<user-mention>` elements in HTML with a `data-id` attribute containing the user's identifier. When parsing HTML, these elements are converted to `User` nodes in the document model.
176
+
177
+ Common use cases:
178
+ - Mentioning users in comments or messages
179
+ - Tagging users in collaborative documents
180
+ - Tracking user references in content
181
+
182
+ ==== To HTML
183
+
184
+ [source,ruby]
185
+ ----
186
+ require 'prosereflect'
187
+
188
+ # Create a document
189
+ document = Prosereflect::Document.create
190
+ paragraph = document.add_paragraph('Plain text')
191
+ paragraph.add_text(' with bold', [Prosereflect::Mark::Bold.new])
192
+
193
+ # Convert to HTML
194
+ html = Prosereflect::Output::Html.convert(document)
195
+ # => "<html><body><p>Plain text<strong> with bold</strong></p></body></html>"
196
+ ----
197
+
198
+ ==== Round-trip Conversion
199
+
200
+ [source,ruby]
201
+ ----
202
+ # Start with HTML
203
+ original_html = '<p>This is <em>styled</em> text.</p>'
204
+
205
+ # Convert to document model
206
+ document = Prosereflect::Input::Html.parse(original_html)
207
+
208
+ # Modify the document if needed
209
+ document.paragraphs.first.add_text(' with additions')
210
+
211
+ # Convert back to HTML
212
+ modified_html = Prosereflect::Output::Html.convert(document)
123
213
  ----
124
214
 
125
215
  == Data model
@@ -145,12 +235,12 @@ objects.
145
235
  | +content |
146
236
  +-------------------+
147
237
  |
148
- +----+----+---------------------+
149
- | | |
150
- +---v---+ +---v----------+ +-------v--------+
151
- |Table | | Paragraph | | Text |
152
- | | | | | |
153
- +---+---+ +--------------+ +----------------+
238
+ +----+----+---------------------+-------------+
239
+ | | | |
240
+ +---v---+ +---v----------+ +-------v--------+ +-v-----+
241
+ |Table | | Paragraph | | Text | | User |
242
+ | | | | | | | |
243
+ +---+---+ +--------------+ +----------------+ +-------+
154
244
  |
155
245
  |
156
246
  +---v-----------+
@@ -193,14 +283,105 @@ Represents a text node.
193
283
 
194
284
  `text`:: The text content of the node
195
285
 
286
+ === User
287
+
288
+ Represents a user mention in the document.
289
+
290
+ `id`:: The unique identifier of the referenced user
291
+ `type`:: Always set to "user"
292
+ `content`:: Always empty (user mentions cannot have child nodes)
293
+
196
294
  === Table
197
295
 
198
296
  Represents a table structure.
199
297
 
200
- `rows`:: All table rows
201
- `header_row`:: The first row (assumed to be the header)
202
- `data_rows`:: All rows except the header
203
- `cell_at(row_index, col_index)`:: Access a specific cell by position
298
+ `rows`:: Collection of table rows
299
+ `header_row`:: First row if it contains header cells
300
+ `data_rows`:: All non-header rows
301
+
302
+ === Heading
303
+
304
+ Represents a heading element (h1-h6).
305
+
306
+ `level`:: The heading level (1-6)
307
+ `text_content`:: Returns the combined text content of all child text nodes
308
+ `content`:: Collection of child nodes (text, styled text, etc.)
309
+
310
+ === Image
311
+
312
+ Represents an image element.
313
+
314
+ `src`:: The image source URL
315
+ `alt`:: Alternative text description
316
+ `title`:: Image tooltip text
317
+ `width`:: Image width in pixels
318
+ `height`:: Image height in pixels
319
+
320
+ === HorizontalRule
321
+
322
+ Represents a horizontal rule (hr) element.
323
+
324
+ `style`:: Border style (solid, dashed, dotted)
325
+ `width`:: Rule width (px or %)
326
+ `thickness`:: Border thickness in pixels
327
+
328
+ === BulletList
329
+
330
+ Represents an unordered list.
331
+
332
+ `bullet_style`:: List style type (disc, circle, square)
333
+ `items`:: Collection of list items
334
+
335
+ === OrderedList
336
+
337
+ Represents an ordered list.
338
+
339
+ `start`:: Starting number for the list
340
+ `items`:: Collection of list items
341
+
342
+ === ListItem
343
+
344
+ Represents a list item within ordered or unordered lists.
345
+
346
+ `content`:: Collection of child nodes (can contain paragraphs, nested lists, etc.)
347
+ `text_content`:: Returns the combined text content
348
+
349
+ === Blockquote
350
+
351
+ Represents a blockquote element.
352
+
353
+ `citation`:: Optional citation URL
354
+ `blocks`:: Collection of content blocks within the quote
355
+
356
+ === CodeBlockWrapper
357
+
358
+ Container for code blocks with additional attributes.
359
+
360
+ `line_numbers`:: Whether to display line numbers
361
+ `highlight_lines`:: Array of line numbers to highlight
362
+ `code_blocks`:: Collection of code blocks
363
+
364
+ === CodeBlock
365
+
366
+ Represents a code block with syntax highlighting.
367
+
368
+ `content`:: The code content
369
+ `language`:: Programming language for syntax highlighting
370
+
371
+ === Mark
372
+
373
+ Base class for text formatting marks.
374
+
375
+ ==== Available Mark Types
376
+
377
+ `Bold`:: Bold text formatting
378
+ `Italic`:: Italic text formatting
379
+ `Code`:: Inline code formatting
380
+ `Link`:: Hyperlink with href attribute
381
+ `Strike`:: Strikethrough text
382
+ `Subscript`:: Subscript text
383
+ `Superscript`:: Superscript text
384
+ `Underline`:: Underlined text
204
385
 
205
386
  === TableRow
206
387
 
data/Rakefile CHANGED
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'bundler/gem_tasks'
4
- require 'rspec/core/rake_task'
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
5
 
6
6
  RSpec::Core::RakeTask.new(:spec)
7
7
 
8
- require 'rubocop/rake_task'
8
+ require "rubocop/rake_task"
9
9
 
10
10
  RuboCop::RakeTask.new
11
11
 
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosereflect
4
+ module Attribute
5
+ class Base < Lutaml::Model::Serializable
6
+ PM_TYPE = "attribute"
7
+
8
+ attribute :type, :string, default: lambda {
9
+ begin
10
+ self.class.const_get(:PM_TYPE)
11
+ rescue StandardError
12
+ "attribute"
13
+ end
14
+ }
15
+ attribute :value, :string
16
+
17
+ key_value do
18
+ map "type", to: :type, render_default: true
19
+ map "value", to: :value
20
+ end
21
+
22
+ def self.create(type, value)
23
+ new(type: type, value: value)
24
+ end
25
+
26
+ # Convert to hash for serialization
27
+ def to_h
28
+ { type => value }
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosereflect
4
+ module Attribute
5
+ class Bold < Base
6
+ PM_TYPE = "bold"
7
+
8
+ def initialize(options = {})
9
+ super
10
+ self.type = "bold"
11
+ end
12
+
13
+ def attrs
14
+ nil
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosereflect
4
+ module Attribute
5
+ class Href < Base
6
+ PM_TYPE = "href"
7
+
8
+ attribute :type, :string, default: -> { PM_TYPE }
9
+ attribute :href, :string
10
+
11
+ def initialize(options = {})
12
+ if options.is_a?(String)
13
+ super()
14
+ self.value = options
15
+ else
16
+ super
17
+ self.value = options[:href] if options[:href]
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosereflect
4
+ module Attribute
5
+ class Id < Base
6
+ PM_TYPE = "id"
7
+
8
+ attribute :type, :string, default: -> {
9
+ self.class.send(:const_get, "PM_TYPE")
10
+ }
11
+ attribute :id, :string
12
+
13
+ key_value do
14
+ map "type", to: :type, render_default: true
15
+ map "id", to: :id
16
+ end
17
+
18
+ # Convert to hash for serialization
19
+ def to_h
20
+ { "id" => id }
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosereflect
4
+ module Attribute
5
+ autoload :Base, "#{__dir__}/attribute/base"
6
+ autoload :Href, "#{__dir__}/attribute/href"
7
+ autoload :Id, "#{__dir__}/attribute/id"
8
+ autoload :Bold, "#{__dir__}/attribute/bold"
9
+ end
10
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosereflect
4
+ # It can contain other block-level content like paragraphs, lists, etc.
5
+ class Blockquote < Node
6
+ PM_TYPE = "blockquote"
7
+
8
+ attribute :type, :string, default: -> {
9
+ self.class.send(:const_get, "PM_TYPE")
10
+ }
11
+ attribute :citation, :string
12
+ attribute :attrs, :hash
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(attrs: attrs, content: [])
27
+ end
28
+
29
+ # Get all content blocks within the blockquote
30
+ def blocks
31
+ return [] unless content
32
+
33
+ content
34
+ end
35
+
36
+ # Add a content block to the blockquote
37
+ def add_block(content)
38
+ add_child(content)
39
+ end
40
+
41
+ # Add multiple content blocks at once
42
+ def add_blocks(blocks_content)
43
+ blocks_content.each do |block_content|
44
+ add_block(block_content)
45
+ end
46
+ end
47
+
48
+ # Get block at specific position
49
+ def block_at(index)
50
+ return nil if index.negative?
51
+
52
+ blocks[index]
53
+ end
54
+
55
+ # Update citation/source for the blockquote
56
+ def citation=(source)
57
+ self.attrs ||= {}
58
+ attrs["citation"] = source
59
+ end
60
+
61
+ # Get citation/source of the blockquote
62
+ def citation
63
+ attrs&.[]("citation")
64
+ end
65
+
66
+ # Check if blockquote has a citation
67
+ def citation?
68
+ !citation.nil? && !citation.empty?
69
+ end
70
+
71
+ # Remove citation
72
+ def remove_citation
73
+ self.attrs ||= {}
74
+ attrs.delete("citation")
75
+ end
76
+
77
+ def add_paragraph(text)
78
+ paragraph = Paragraph.new
79
+ paragraph.add_text(text)
80
+ add_child(paragraph)
81
+ paragraph
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosereflect
4
+ # BulletList class represents an unordered list in ProseMirror.
5
+ class BulletList < Node
6
+ PM_TYPE = "bullet_list"
7
+
8
+ attribute :type, :string, default: -> {
9
+ self.class.send(:const_get, "PM_TYPE")
10
+ }
11
+ attribute :bullet_style, :string
12
+ attribute :attrs, :hash
13
+
14
+ key_value do
15
+ map "type", to: :type, render_default: true
16
+ map "attrs", to: :attrs
17
+ map "content", to: :content
18
+ end
19
+
20
+ def initialize(attributes = {})
21
+ attributes[:content] ||= []
22
+ attributes[:attrs] ||= { "bullet_style" => nil }
23
+ super
24
+ end
25
+
26
+ def self.create(attrs = nil)
27
+ new(attrs: attrs)
28
+ end
29
+
30
+ def bullet_style=(value)
31
+ @bullet_style = value
32
+ self.attrs ||= {}
33
+ attrs["bullet_style"] = value
34
+ end
35
+
36
+ def bullet_style
37
+ @bullet_style || attrs&.[]("bullet_style")
38
+ end
39
+
40
+ def add_item(text)
41
+ item = ListItem.new
42
+ item.add_paragraph(text)
43
+ add_child(item)
44
+ item
45
+ end
46
+
47
+ def items
48
+ return [] unless content
49
+
50
+ content
51
+ end
52
+
53
+ # Add multiple items at once
54
+ def add_items(items_content)
55
+ items_content.each do |item_content|
56
+ add_item(item_content)
57
+ end
58
+ end
59
+
60
+ # Get item at specific position
61
+ def item_at(index)
62
+ return nil if index.negative?
63
+
64
+ items[index]
65
+ end
66
+
67
+ # Get text content with proper formatting
68
+ def text_content
69
+ return "" unless content
70
+
71
+ content.map do |item|
72
+ item.respond_to?(:text_content) ? item.text_content : ""
73
+ end.join("\n")
74
+ end
75
+
76
+ # Override to_h to exclude empty attrs
77
+ def to_h
78
+ hash = super
79
+ hash["attrs"] ||= {}
80
+ hash["attrs"]["bullet_style"] = bullet_style
81
+ hash
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosereflect
4
+ # CodeBlock class represents a code block in ProseMirror.
5
+ class CodeBlock < Node
6
+ PM_TYPE = "code_block"
7
+
8
+ attribute :type, :string, default: -> {
9
+ self.class.send(:const_get, "PM_TYPE")
10
+ }
11
+ attribute :language, :string
12
+ attribute :line_numbers, :boolean
13
+ attribute :attrs, :hash
14
+
15
+ key_value do
16
+ map "type", to: :type, render_default: true
17
+ map "attrs", to: :attrs
18
+ map "content", to: :content
19
+ end
20
+
21
+ def initialize(attributes = {})
22
+ attributes[:attrs] ||= {
23
+ "content" => nil,
24
+ "language" => nil,
25
+ }
26
+ super
27
+ end
28
+
29
+ def self.create(attrs = nil)
30
+ new(attrs: attrs)
31
+ end
32
+
33
+ def language=(value)
34
+ @language = value
35
+ self.attrs ||= {}
36
+ attrs["language"] = value
37
+ end
38
+
39
+ def language
40
+ @language || attrs&.[]("language")
41
+ end
42
+
43
+ def line_numbers=(value)
44
+ @line_numbers = value
45
+ self.attrs ||= {}
46
+ attrs["line_numbers"] = value
47
+ end
48
+
49
+ def line_numbers
50
+ @line_numbers || attrs&.[]("line_numbers") || false
51
+ end
52
+
53
+ def content=(value)
54
+ @content = value
55
+ self.attrs ||= {}
56
+ attrs["content"] = value
57
+ end
58
+
59
+ def content
60
+ @content || attrs&.[]("content")
61
+ end
62
+
63
+ attr_reader :highlight_lines_str
64
+
65
+ def highlight_lines=(lines)
66
+ @highlight_lines_str = if lines.is_a?(Array)
67
+ lines.join(",")
68
+ else
69
+ lines.to_s
70
+ end
71
+ end
72
+
73
+ def highlight_lines
74
+ return [] unless @highlight_lines_str
75
+
76
+ @highlight_lines_str.split(",").map(&:to_i)
77
+ end
78
+
79
+ def text_content
80
+ content.to_s
81
+ end
82
+
83
+ def to_h
84
+ hash = super
85
+ hash["attrs"] = {
86
+ "content" => content,
87
+ "language" => language,
88
+ }
89
+ hash["attrs"]["line_numbers"] = line_numbers if line_numbers
90
+ hash.delete("content")
91
+ hash
92
+ end
93
+
94
+ # Get code block attributes as a hash
95
+ def attributes
96
+ {
97
+ language: language,
98
+ line_numbers: line_numbers,
99
+ highlight_lines: highlight_lines,
100
+ }.compact
101
+ end
102
+
103
+ # Add a line of code
104
+ def add_line(text)
105
+ text_node = Text.new(text: text)
106
+ add_child(text_node)
107
+ end
108
+
109
+ # Add multiple lines of code
110
+ def add_lines(lines)
111
+ lines.each { |line| add_line(line) }
112
+ end
113
+
114
+ private
115
+
116
+ def normalize_content(content)
117
+ lines = content.split("\n")
118
+ return content if lines.empty?
119
+
120
+ min_indent = lines.reject(&:empty?)
121
+ .map { |line| line[/^\s*/].length }
122
+ .min || 0
123
+
124
+ normalized_lines = lines.map do |line|
125
+ if line.empty?
126
+ line
127
+ else
128
+ line[min_indent..] || ""
129
+ end
130
+ end
131
+
132
+ normalized_lines.join("\n")
133
+ end
134
+ end
135
+ end