prosereflect 0.1.1 → 0.3.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 (158) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/docs.yml +63 -0
  3. data/.github/workflows/links.yml +97 -0
  4. data/.github/workflows/rake.yml +4 -0
  5. data/.github/workflows/release.yml +5 -0
  6. data/.gitignore +4 -0
  7. data/.rubocop.yml +19 -1
  8. data/.rubocop_todo.yml +119 -183
  9. data/CLAUDE.md +78 -0
  10. data/Gemfile +8 -4
  11. data/README.adoc +2 -0
  12. data/Rakefile +3 -3
  13. data/docs/Gemfile +10 -0
  14. data/docs/INDEX.adoc +45 -0
  15. data/docs/_advanced/index.adoc +15 -0
  16. data/docs/_advanced/schema.adoc +112 -0
  17. data/docs/_advanced/step-map.adoc +66 -0
  18. data/docs/_advanced/steps.adoc +88 -0
  19. data/docs/_advanced/test-builder.adoc +61 -0
  20. data/docs/_advanced/transform.adoc +92 -0
  21. data/docs/_config.yml +174 -0
  22. data/docs/_features/html-input.adoc +69 -0
  23. data/docs/_features/html-output.adoc +45 -0
  24. data/docs/_features/index.adoc +15 -0
  25. data/docs/_features/marks.adoc +86 -0
  26. data/docs/_features/node-types.adoc +124 -0
  27. data/docs/_features/user-mentions.adoc +47 -0
  28. data/docs/_guides/custom-nodes.adoc +107 -0
  29. data/docs/_guides/index.adoc +13 -0
  30. data/docs/_guides/round-trip-html.adoc +91 -0
  31. data/docs/_guides/serialization.adoc +109 -0
  32. data/docs/_pages/index.adoc +67 -0
  33. data/docs/_reference/document-api.adoc +49 -0
  34. data/docs/_reference/index.adoc +14 -0
  35. data/docs/_reference/node-api.adoc +79 -0
  36. data/docs/_reference/schema-api.adoc +95 -0
  37. data/docs/_reference/transform-api.adoc +77 -0
  38. data/docs/_understanding/document-model.adoc +65 -0
  39. data/docs/_understanding/fragment.adoc +52 -0
  40. data/docs/_understanding/index.adoc +14 -0
  41. data/docs/_understanding/resolved-position.adoc +53 -0
  42. data/docs/_understanding/slice.adoc +54 -0
  43. data/docs/lychee.toml +63 -0
  44. data/lib/prosereflect/attribute/base.rb +4 -6
  45. data/lib/prosereflect/attribute/bold.rb +2 -4
  46. data/lib/prosereflect/attribute/href.rb +1 -3
  47. data/lib/prosereflect/attribute/id.rb +7 -7
  48. data/lib/prosereflect/attribute.rb +4 -7
  49. data/lib/prosereflect/blockquote.rb +19 -11
  50. data/lib/prosereflect/bullet_list.rb +36 -29
  51. data/lib/prosereflect/code_block.rb +23 -27
  52. data/lib/prosereflect/code_block_wrapper.rb +12 -13
  53. data/lib/prosereflect/document.rb +14 -22
  54. data/lib/prosereflect/fragment.rb +249 -0
  55. data/lib/prosereflect/hard_break.rb +6 -6
  56. data/lib/prosereflect/heading.rb +14 -15
  57. data/lib/prosereflect/horizontal_rule.rb +23 -14
  58. data/lib/prosereflect/image.rb +32 -23
  59. data/lib/prosereflect/input/html.rb +179 -104
  60. data/lib/prosereflect/input.rb +7 -0
  61. data/lib/prosereflect/list_item.rb +11 -12
  62. data/lib/prosereflect/mark/base.rb +9 -11
  63. data/lib/prosereflect/mark/bold.rb +1 -3
  64. data/lib/prosereflect/mark/code.rb +1 -3
  65. data/lib/prosereflect/mark/italic.rb +1 -3
  66. data/lib/prosereflect/mark/link.rb +1 -3
  67. data/lib/prosereflect/mark/strike.rb +1 -3
  68. data/lib/prosereflect/mark/subscript.rb +1 -3
  69. data/lib/prosereflect/mark/superscript.rb +1 -3
  70. data/lib/prosereflect/mark/underline.rb +1 -3
  71. data/lib/prosereflect/mark.rb +9 -5
  72. data/lib/prosereflect/node.rb +171 -33
  73. data/lib/prosereflect/ordered_list.rb +17 -14
  74. data/lib/prosereflect/output/html.rb +279 -50
  75. data/lib/prosereflect/output.rb +7 -0
  76. data/lib/prosereflect/paragraph.rb +11 -13
  77. data/lib/prosereflect/parser.rb +56 -66
  78. data/lib/prosereflect/resolved_pos.rb +256 -0
  79. data/lib/prosereflect/schema/attribute.rb +57 -0
  80. data/lib/prosereflect/schema/content_match.rb +656 -0
  81. data/lib/prosereflect/schema/fragment.rb +166 -0
  82. data/lib/prosereflect/schema/mark.rb +121 -0
  83. data/lib/prosereflect/schema/mark_type.rb +130 -0
  84. data/lib/prosereflect/schema/node.rb +236 -0
  85. data/lib/prosereflect/schema/node_type.rb +274 -0
  86. data/lib/prosereflect/schema/schema_main.rb +190 -0
  87. data/lib/prosereflect/schema/spec.rb +92 -0
  88. data/lib/prosereflect/schema.rb +39 -0
  89. data/lib/prosereflect/table.rb +12 -13
  90. data/lib/prosereflect/table_cell.rb +13 -13
  91. data/lib/prosereflect/table_header.rb +17 -17
  92. data/lib/prosereflect/table_row.rb +12 -12
  93. data/lib/prosereflect/text.rb +35 -11
  94. data/lib/prosereflect/transform/attr_step.rb +157 -0
  95. data/lib/prosereflect/transform/insert_step.rb +115 -0
  96. data/lib/prosereflect/transform/mapping.rb +82 -0
  97. data/lib/prosereflect/transform/mark_step.rb +269 -0
  98. data/lib/prosereflect/transform/replace_around_step.rb +181 -0
  99. data/lib/prosereflect/transform/replace_step.rb +157 -0
  100. data/lib/prosereflect/transform/slice.rb +91 -0
  101. data/lib/prosereflect/transform/step.rb +89 -0
  102. data/lib/prosereflect/transform/step_map.rb +126 -0
  103. data/lib/prosereflect/transform/structure.rb +120 -0
  104. data/lib/prosereflect/transform/transform.rb +341 -0
  105. data/lib/prosereflect/transform.rb +26 -0
  106. data/lib/prosereflect/user.rb +15 -15
  107. data/lib/prosereflect/version.rb +1 -1
  108. data/lib/prosereflect.rb +30 -17
  109. data/prosereflect.gemspec +17 -16
  110. data/spec/fixtures/documents/formatted_text.yaml +14 -0
  111. data/spec/fixtures/documents/heading_paragraph.yaml +16 -0
  112. data/spec/fixtures/documents/lists_doc.yaml +32 -0
  113. data/spec/fixtures/documents/mixed_content.yaml +40 -0
  114. data/spec/fixtures/documents/nested_doc.yaml +20 -0
  115. data/spec/fixtures/documents/simple_doc.yaml +6 -0
  116. data/spec/fixtures/documents/table_doc.yaml +32 -0
  117. data/spec/fixtures/documents/transform_test.yaml +14 -0
  118. data/spec/fixtures/schema/custom_schema.rb +37 -0
  119. data/spec/fixtures/schema/test_schema.rb +46 -0
  120. data/spec/fixtures/test_builder/helpers.rb +212 -0
  121. data/spec/prosereflect/document_spec.rb +332 -330
  122. data/spec/prosereflect/fragment_spec.rb +273 -0
  123. data/spec/prosereflect/hard_break_spec.rb +125 -125
  124. data/spec/prosereflect/input/html_spec.rb +718 -522
  125. data/spec/prosereflect/node_spec.rb +311 -182
  126. data/spec/prosereflect/output/html_spec.rb +105 -105
  127. data/spec/prosereflect/output/whitespace_spec.rb +248 -0
  128. data/spec/prosereflect/paragraph_spec.rb +275 -274
  129. data/spec/prosereflect/parser/round_trip_spec.rb +472 -0
  130. data/spec/prosereflect/parser_spec.rb +185 -180
  131. data/spec/prosereflect/resolved_pos_spec.rb +74 -0
  132. data/spec/prosereflect/schema/conftest.rb +68 -0
  133. data/spec/prosereflect/schema/content_match_spec.rb +237 -0
  134. data/spec/prosereflect/schema/mark_spec.rb +274 -0
  135. data/spec/prosereflect/schema/mark_type_spec.rb +86 -0
  136. data/spec/prosereflect/schema/node_type_spec.rb +142 -0
  137. data/spec/prosereflect/schema/schema_spec.rb +194 -0
  138. data/spec/prosereflect/table_cell_spec.rb +183 -183
  139. data/spec/prosereflect/table_row_spec.rb +149 -149
  140. data/spec/prosereflect/table_spec.rb +320 -318
  141. data/spec/prosereflect/test_builder/marks_spec.rb +127 -0
  142. data/spec/prosereflect/text_spec.rb +133 -132
  143. data/spec/prosereflect/transform/equivalence_spec.rb +487 -0
  144. data/spec/prosereflect/transform/mapping_spec.rb +226 -0
  145. data/spec/prosereflect/transform/replace_spec.rb +832 -0
  146. data/spec/prosereflect/transform/replace_step_spec.rb +157 -0
  147. data/spec/prosereflect/transform/slice_spec.rb +48 -0
  148. data/spec/prosereflect/transform/step_map_spec.rb +70 -0
  149. data/spec/prosereflect/transform/step_spec.rb +211 -0
  150. data/spec/prosereflect/transform/structure_spec.rb +98 -0
  151. data/spec/prosereflect/transform/transform_spec.rb +238 -0
  152. data/spec/prosereflect/user_spec.rb +31 -28
  153. data/spec/prosereflect_spec.rb +28 -26
  154. data/spec/spec_helper.rb +7 -6
  155. data/spec/support/matchers.rb +6 -6
  156. data/spec/support/shared_examples.rb +49 -49
  157. metadata +96 -5
  158. data/spec/prosereflect/version_spec.rb +0 -11
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosereflect
4
+ class Schema
5
+ # Lightweight Fragment class for schema validation
6
+ # This is a minimal implementation focused on the operations needed by ContentMatch
7
+ class Fragment
8
+ attr_reader :content
9
+
10
+ def initialize(content = [])
11
+ @content = content.is_a?(Array) ? content : [content].compact
12
+ end
13
+
14
+ def size
15
+ @content.sum(&:node_size)
16
+ end
17
+
18
+ def empty?
19
+ @content.empty?
20
+ end
21
+
22
+ def first
23
+ @content.first
24
+ end
25
+
26
+ def last
27
+ @content.last
28
+ end
29
+
30
+ def [](index)
31
+ @content[index]
32
+ end
33
+
34
+ def []=(index, value)
35
+ @content[index] = value
36
+ end
37
+
38
+ def length
39
+ @content.length
40
+ end
41
+
42
+ def each(&block)
43
+ @content.each(&block)
44
+ end
45
+
46
+ def <<(node)
47
+ @content << node
48
+ self
49
+ end
50
+
51
+ def append(other)
52
+ return self if other.empty?
53
+ return other if empty?
54
+
55
+ last_node = @content.last
56
+ first_other = other.first
57
+
58
+ if last_node.text? && first_other.text? && last_node.same_markup?(first_other)
59
+ merged = last_node.with_text(last_node.text + first_other.text)
60
+ new_content = @content[0...-1] + [merged] + other.content[1..]
61
+ Fragment.new(new_content)
62
+ else
63
+ Fragment.new(@content + other.content)
64
+ end
65
+ end
66
+
67
+ def cut(from = 0, to = nil)
68
+ to ||= size
69
+ return Fragment.empty if from.zero? && to == size
70
+
71
+ return Fragment.empty if to <= from
72
+
73
+ result = []
74
+ pos = 0
75
+ i = 0
76
+
77
+ while pos < to && i < @content.length
78
+ child = @content[i]
79
+ child_end = pos + child.node_size
80
+
81
+ if child_end > from
82
+ if pos < from || child_end > to
83
+ if child.text?
84
+ start_offset = [0, from - pos].max
85
+ end_offset = [child.text.length, to - pos].min
86
+ else
87
+ start_offset = [0, from - pos - 1].max
88
+ end_offset = [child.content.size, to - pos - 1].min
89
+ end
90
+ cut_child = child.cut(start_offset, end_offset)
91
+ result << cut_child if cut_child
92
+ else
93
+ result << child
94
+ end
95
+ end
96
+
97
+ pos = child_end
98
+ i += 1
99
+ end
100
+
101
+ Fragment.new(result)
102
+ end
103
+
104
+ def replace_child(index, replacement)
105
+ return self if @content[index] == replacement
106
+
107
+ new_content = @content.dup
108
+ new_content[index] = replacement
109
+ Fragment.new(new_content)
110
+ end
111
+
112
+ def eq?(other)
113
+ return false unless @content.length == other.content.length
114
+
115
+ @content.each_with_index.all? { |node, i| node.eq?(other.content[i]) }
116
+ end
117
+
118
+ def nodes_between(from, to, f, node_start = 0)
119
+ i = 0
120
+ pos = 0
121
+
122
+ while pos < to && i < @content.length
123
+ child = @content[i]
124
+ end_pos = pos + child.node_size
125
+
126
+ if end_pos > from && f.call(child, node_start + pos,
127
+ i) != false && child.content.size.positive?
128
+ child.nodes_between(
129
+ [0, from - pos].max,
130
+ [child.content.size, to - pos].min,
131
+ f,
132
+ node_start + pos + 1,
133
+ )
134
+ end
135
+
136
+ pos = end_pos
137
+ i += 1
138
+ end
139
+ end
140
+
141
+ def descendants(f)
142
+ nodes_between(0, size, f)
143
+ end
144
+
145
+ def to_s
146
+ "<#{@content.join(', ')}>"
147
+ end
148
+
149
+ class << self
150
+ def empty
151
+ @empty ||= new([])
152
+ end
153
+
154
+ def from(nodes)
155
+ return empty if nodes.nil? || (nodes.is_a?(Array) && nodes.empty?)
156
+
157
+ case nodes
158
+ when Fragment then nodes
159
+ when Array then new(nodes)
160
+ else new([nodes])
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosereflect
4
+ class Schema
5
+ # Lightweight Mark class for schema validation
6
+ class Mark
7
+ attr_reader :type, :attrs
8
+
9
+ def initialize(type:, attrs: {})
10
+ @type = type
11
+ @attrs = attrs || {}
12
+ end
13
+
14
+ def eq?(other)
15
+ return false unless other.is_a?(Mark)
16
+
17
+ @type == other.type && @attrs == other.attrs
18
+ end
19
+
20
+ def same_set?(other_marks)
21
+ return false unless other_marks.length == length
22
+
23
+ other_marks.each_with_index.all? { |m, i| self[i].eq?(m) }
24
+ end
25
+
26
+ def [](index)
27
+ @type.is_a?(Array) ? @type[index] : self
28
+ end
29
+
30
+ def length
31
+ 1
32
+ end
33
+
34
+ def is_in_set?(mark_set)
35
+ mark_set.any? { |m| m.type == @type }
36
+ end
37
+
38
+ # Add this mark to a set, respecting exclusion rules and rank ordering
39
+ # Follows prosemirror-py Mark.add_to_set logic
40
+ def add_to_set(mark_set)
41
+ copy = nil
42
+ placed = false
43
+
44
+ mark_set.each_with_index do |other, i|
45
+ if eq?(other)
46
+ # Self already in set, return unchanged
47
+ return mark_set
48
+ end
49
+
50
+ if @type.excludes?(other.type)
51
+ # This mark's type excludes the other's type
52
+ # Remove the other from the result by starting copy
53
+ copy ||= mark_set[0...i]
54
+ elsif other.type.excludes?(@type)
55
+ # Other's type excludes this mark's type - don't add
56
+ return mark_set
57
+ else
58
+ # No exclusion - insert before if other's rank is higher and not yet placed
59
+ if !placed && other.type.rank > @type.rank
60
+ copy ||= mark_set[0...i]
61
+ copy << self
62
+ placed = true
63
+ end
64
+ copy << other if copy
65
+ end
66
+ end
67
+
68
+ copy ||= mark_set.dup
69
+ copy << self unless placed
70
+ sort_marks(copy)
71
+ end
72
+
73
+ def remove_from_set(mark_set)
74
+ mark_set.reject { |m| m.type == @type }
75
+ end
76
+
77
+ def to_h
78
+ result = { "type" => @type.name }
79
+ result["attrs"] = @attrs unless @attrs.empty?
80
+ result
81
+ end
82
+
83
+ def to_s
84
+ "<Mark #{@type.name}>"
85
+ end
86
+
87
+ class << self
88
+ def set_from(marks)
89
+ return none if marks.nil? || marks.empty?
90
+
91
+ marks.filter_map { |m| m.is_a?(Mark) ? m : m }
92
+ end
93
+
94
+ def none
95
+ @none ||= []
96
+ end
97
+
98
+ def sort_marks(marks)
99
+ marks.sort_by { |m| m.type.rank }
100
+ end
101
+
102
+ # Class method for comparing two mark sets
103
+ # Returns true if both sets contain the same marks in the same order
104
+ def same_set(a, b)
105
+ return true if a == b
106
+ return false unless a.length == b.length
107
+
108
+ a.each_with_index.all? do |mark_a, i|
109
+ mark_a.eq?(b[i])
110
+ end
111
+ end
112
+ end
113
+
114
+ private
115
+
116
+ def sort_marks(marks)
117
+ marks.sort_by { |m| m.type.rank }
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosereflect
4
+ class Schema
5
+ class MarkType
6
+ attr_reader :name, :attrs, :rank, :schema, :spec
7
+ attr_accessor :excluded
8
+
9
+ def initialize(name:, attrs: {}, rank: 0, schema: nil, spec: nil,
10
+ inclusive: true)
11
+ @name = name
12
+ @attrs = attrs
13
+ @rank = rank
14
+ @schema = schema
15
+ @spec = spec
16
+ @inclusive = inclusive
17
+ @excluded = []
18
+ end
19
+
20
+ def self.from_spec(name, rank, schema, spec)
21
+ attrs = {}
22
+ if spec.respond_to?(:attrs)
23
+ spec_attrs = spec.attrs
24
+ attrs = if spec_attrs.is_a?(Hash)
25
+ # New format: {"attr_name" => {default: value}}
26
+ spec_attrs.each_with_object({}) do |(attr_name, attr_spec), hash|
27
+ if attr_spec.respond_to?(:name)
28
+ hash[attr_name] =
29
+ Attribute.new(name: attr_spec.name,
30
+ default: attr_spec.default, validate: nil)
31
+ else
32
+ # attr_spec is a Hash
33
+ attr_name = attr_spec[:name] || attr_spec["name"] || attr_name
34
+ default = attr_spec[:default] || attr_spec["default"]
35
+ hash[attr_name] =
36
+ Attribute.new(name: attr_name, default: default,
37
+ validate: nil)
38
+ end
39
+ end
40
+ else
41
+ # Old format: values are attribute objects
42
+ spec_attrs.transform_values do |a|
43
+ Attribute.new(name: a.name, default: a.default, validate: nil)
44
+ end
45
+ end
46
+ elsif spec.is_a?(Hash)
47
+ spec_attrs = spec[:attrs] || spec["attrs"] || {}
48
+ attrs = spec_attrs.transform_values do |v|
49
+ Attribute.new(name: v[:name] || v["name"],
50
+ default: v[:default] || v["default"])
51
+ end
52
+ end
53
+
54
+ new(
55
+ name: name,
56
+ attrs: attrs,
57
+ rank: rank,
58
+ schema: schema,
59
+ spec: spec,
60
+ inclusive: if spec.respond_to?(:inclusive)
61
+ spec.inclusive
62
+ elsif spec.key?(:inclusive)
63
+ spec[:inclusive]
64
+ elsif spec.key?("inclusive")
65
+ spec["inclusive"]
66
+ else
67
+ true
68
+ end,
69
+ )
70
+ end
71
+
72
+ def create(attrs = nil)
73
+ return instance if attrs.nil? && @instance
74
+
75
+ computed_attrs = compute_attrs(attrs)
76
+ Mark.new(type: self, attrs: computed_attrs)
77
+ end
78
+
79
+ def remove_from_set(mark_set)
80
+ mark_set.reject { |m| m.type == self }
81
+ end
82
+
83
+ def is_in_set?(mark_set)
84
+ mark_set.any? { |m| m.type == self }
85
+ end
86
+
87
+ def check_attrs(attrs)
88
+ attrs ||= {}
89
+ attrs.each do |attr_name, value|
90
+ unless @attrs.key?(attr_name)
91
+ raise Prosereflect::SchemaErrors::ValidationError,
92
+ "Unsupported attribute #{attr_name} for mark #{@name}"
93
+ end
94
+
95
+ attr_def = @attrs[attr_name]
96
+ attr_def&.validate_value(value)
97
+ end
98
+ end
99
+
100
+ def excludes?(other_mark_type)
101
+ @excluded.include?(other_mark_type)
102
+ end
103
+
104
+ def inclusive?
105
+ @inclusive
106
+ end
107
+
108
+ def instance
109
+ @instance ||= create(nil)
110
+ end
111
+
112
+ private
113
+
114
+ def compute_attrs(attrs)
115
+ built = {}
116
+ @attrs.each do |name, attr_def|
117
+ if attrs&.key?(name)
118
+ built[name] = attrs[name]
119
+ elsif attr_def.has_default?
120
+ built[name] = attr_def.default
121
+ else
122
+ raise Prosereflect::SchemaErrors::ValidationError,
123
+ "No value supplied for required attribute #{name} on mark #{@name}"
124
+ end
125
+ end
126
+ built
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosereflect
4
+ class Schema
5
+ # Lightweight Node class for schema validation
6
+ # This wraps the existing prosereflect Node and provides schema-aware methods
7
+ class Node
8
+ attr_reader :type, :attrs, :content, :marks
9
+
10
+ def initialize(type:, attrs: {}, content: nil, marks: [])
11
+ @type = type
12
+ @attrs = attrs || {}
13
+ @content = if content.is_a?(Fragment)
14
+ content
15
+ else
16
+ (content ? Fragment.new(content) : Fragment.empty)
17
+ end
18
+ @marks = if marks.is_a?(Array)
19
+ marks
20
+ else
21
+ (marks ? [marks] : [])
22
+ end
23
+ end
24
+
25
+ def node_size
26
+ size = 1 # Every node has at least size 1
27
+ @content.content.each do |child|
28
+ size += child.node_size
29
+ end
30
+ size
31
+ end
32
+
33
+ def text?
34
+ @type.text?
35
+ end
36
+
37
+ def is_text
38
+ text?
39
+ end
40
+
41
+ def is_block
42
+ @type.is_block?
43
+ end
44
+
45
+ def is_inline
46
+ @type.is_inline?
47
+ end
48
+
49
+ def is_leaf
50
+ @type.is_leaf?
51
+ end
52
+
53
+ def is_atom
54
+ @type.is_atom?
55
+ end
56
+
57
+ def same_markup?(other)
58
+ return false unless @type == other.type && @marks.length == other.marks.length
59
+
60
+ @marks.zip(other.marks).all? do |m1, m2|
61
+ m1.type == m2.type && m1.attrs == m2.attrs
62
+ end
63
+ end
64
+
65
+ def with_text(new_text)
66
+ TextNode.new(type: @type, attrs: @attrs, text: new_text, marks: @marks)
67
+ end
68
+
69
+ def cut(from = 0, to = nil)
70
+ to ||= node_size
71
+ return self if from.zero? && to == node_size
72
+
73
+ if text?
74
+ # For text nodes, cut by character offset
75
+ TextNode.new(
76
+ type: @type,
77
+ attrs: @attrs,
78
+ text: @attrs[:text][from...to],
79
+ marks: @marks,
80
+ )
81
+ else
82
+ # For non-text nodes, cut content
83
+ new_content = @content.cut(from - 1, to - 1)
84
+ Node.new(type: @type, attrs: @attrs, content: new_content,
85
+ marks: @marks)
86
+ end
87
+ end
88
+
89
+ def nodes_between(from, to, f, node_start = 0)
90
+ return unless to > from
91
+
92
+ if text?
93
+ f.call(self, node_start)
94
+ return
95
+ end
96
+
97
+ pos = 0
98
+ i = 0
99
+
100
+ while pos < to && i < @content.content.length
101
+ child = @content.content[i]
102
+ end_pos = pos + child.node_size
103
+
104
+ if end_pos > from
105
+ child_start = node_start + pos + 1
106
+ if f.call(child, child_start,
107
+ i) != false && child.content.size.positive?
108
+ child.nodes_between(
109
+ [0, from - pos].max,
110
+ [child.content.size, to - pos].min,
111
+ f,
112
+ child_start,
113
+ )
114
+ end
115
+ end
116
+
117
+ pos = end_pos
118
+ i += 1
119
+ end
120
+ end
121
+
122
+ def descendants(f)
123
+ nodes_between(0, node_size, f)
124
+ end
125
+
126
+ def eq?(other)
127
+ return false unless other.is_a?(Node)
128
+ return false unless @type == other.type
129
+
130
+ @attrs == other.attrs && @content.eq?(other.content) && same_marks?(other)
131
+ end
132
+
133
+ def same_marks?(other)
134
+ return true if @marks.nil? && other.marks.nil?
135
+ return false if @marks.nil? || other.marks.nil?
136
+ return false unless @marks.length == other.marks.length
137
+
138
+ @marks.each_with_index.all? do |m, i|
139
+ m.type == other.marks[i].type && m.attrs == other.marks[i].attrs
140
+ end
141
+ end
142
+
143
+ def to_h
144
+ result = { "type" => @type.name }
145
+
146
+ if @attrs && !@attrs.empty?
147
+ result["attrs"] = @attrs
148
+ end
149
+
150
+ if @marks && !@marks.empty?
151
+ result["marks"] = @marks.map(&:to_h)
152
+ end
153
+
154
+ if @content && !@content.empty?
155
+ result["content"] = @content.content.map(&:to_h)
156
+ end
157
+
158
+ result
159
+ end
160
+
161
+ def to_s
162
+ "<Node #{@type.name}>"
163
+ end
164
+
165
+ class << self
166
+ def from_json(schema, json)
167
+ type_name = json["type"]
168
+ type = schema.node_type(type_name)
169
+
170
+ attrs = json["attrs"] || {}
171
+ content_data = json["content"]
172
+
173
+ marks = if json["marks"].is_a?(Array)
174
+ json["marks"].map { |m| schema.mark_from_json(m) }
175
+ else
176
+ []
177
+ end
178
+
179
+ # Handle text nodes specially
180
+ if type.text?
181
+ text = json["text"] || ""
182
+ TextNode.new(type: type, attrs: attrs, text: text, marks: marks)
183
+ else
184
+ content = if content_data.is_a?(Array)
185
+ content_data.map { |c| from_json(schema, c) }
186
+ else
187
+ []
188
+ end
189
+ Node.new(type: type, attrs: attrs, content: Fragment.new(content),
190
+ marks: marks)
191
+ end
192
+ end
193
+ end
194
+ end
195
+
196
+ # Lightweight TextNode for schema validation
197
+ class TextNode < Node
198
+ attr_reader :text
199
+
200
+ def initialize(type:, attrs: {}, text: "", marks: [])
201
+ super(type: type, attrs: { text: text }, content: Fragment.empty, marks: marks)
202
+ @text = text
203
+ end
204
+
205
+ def node_size
206
+ @text.length + 1
207
+ end
208
+
209
+ def text_content
210
+ @text
211
+ end
212
+
213
+ def cut(from = 0, to = nil)
214
+ to ||= @text.length
215
+ TextNode.new(type: @type, attrs: { text: @text[from...to] },
216
+ text: @text[from...to], marks: @marks)
217
+ end
218
+
219
+ def eq?(other)
220
+ return false unless other.is_a?(TextNode)
221
+
222
+ @text == other.text && super
223
+ end
224
+
225
+ def to_h
226
+ result = { "type" => @type.name, "text" => @text }
227
+ result["marks"] = @marks.map(&:to_h) if @marks && !@marks.empty?
228
+ result
229
+ end
230
+
231
+ def to_s
232
+ "<TextNode \"#{@text[0, 20]}\">"
233
+ end
234
+ end
235
+ end
236
+ end