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.
- checksums.yaml +4 -4
- data/.github/workflows/docs.yml +63 -0
- data/.github/workflows/links.yml +97 -0
- data/.github/workflows/rake.yml +4 -0
- data/.github/workflows/release.yml +5 -0
- data/.gitignore +4 -0
- data/.rubocop.yml +19 -1
- data/.rubocop_todo.yml +119 -183
- data/CLAUDE.md +78 -0
- data/Gemfile +8 -4
- data/README.adoc +2 -0
- data/Rakefile +3 -3
- data/docs/Gemfile +10 -0
- data/docs/INDEX.adoc +45 -0
- data/docs/_advanced/index.adoc +15 -0
- data/docs/_advanced/schema.adoc +112 -0
- data/docs/_advanced/step-map.adoc +66 -0
- data/docs/_advanced/steps.adoc +88 -0
- data/docs/_advanced/test-builder.adoc +61 -0
- data/docs/_advanced/transform.adoc +92 -0
- data/docs/_config.yml +174 -0
- data/docs/_features/html-input.adoc +69 -0
- data/docs/_features/html-output.adoc +45 -0
- data/docs/_features/index.adoc +15 -0
- data/docs/_features/marks.adoc +86 -0
- data/docs/_features/node-types.adoc +124 -0
- data/docs/_features/user-mentions.adoc +47 -0
- data/docs/_guides/custom-nodes.adoc +107 -0
- data/docs/_guides/index.adoc +13 -0
- data/docs/_guides/round-trip-html.adoc +91 -0
- data/docs/_guides/serialization.adoc +109 -0
- data/docs/_pages/index.adoc +67 -0
- data/docs/_reference/document-api.adoc +49 -0
- data/docs/_reference/index.adoc +14 -0
- data/docs/_reference/node-api.adoc +79 -0
- data/docs/_reference/schema-api.adoc +95 -0
- data/docs/_reference/transform-api.adoc +77 -0
- data/docs/_understanding/document-model.adoc +65 -0
- data/docs/_understanding/fragment.adoc +52 -0
- data/docs/_understanding/index.adoc +14 -0
- data/docs/_understanding/resolved-position.adoc +53 -0
- data/docs/_understanding/slice.adoc +54 -0
- data/docs/lychee.toml +63 -0
- data/lib/prosereflect/attribute/base.rb +4 -6
- data/lib/prosereflect/attribute/bold.rb +2 -4
- data/lib/prosereflect/attribute/href.rb +1 -3
- data/lib/prosereflect/attribute/id.rb +7 -7
- data/lib/prosereflect/attribute.rb +4 -7
- data/lib/prosereflect/blockquote.rb +19 -11
- data/lib/prosereflect/bullet_list.rb +36 -29
- data/lib/prosereflect/code_block.rb +23 -27
- data/lib/prosereflect/code_block_wrapper.rb +12 -13
- data/lib/prosereflect/document.rb +14 -22
- data/lib/prosereflect/fragment.rb +249 -0
- data/lib/prosereflect/hard_break.rb +6 -6
- data/lib/prosereflect/heading.rb +14 -15
- data/lib/prosereflect/horizontal_rule.rb +23 -14
- data/lib/prosereflect/image.rb +32 -23
- data/lib/prosereflect/input/html.rb +179 -104
- data/lib/prosereflect/input.rb +7 -0
- data/lib/prosereflect/list_item.rb +11 -12
- data/lib/prosereflect/mark/base.rb +9 -11
- data/lib/prosereflect/mark/bold.rb +1 -3
- data/lib/prosereflect/mark/code.rb +1 -3
- data/lib/prosereflect/mark/italic.rb +1 -3
- data/lib/prosereflect/mark/link.rb +1 -3
- data/lib/prosereflect/mark/strike.rb +1 -3
- data/lib/prosereflect/mark/subscript.rb +1 -3
- data/lib/prosereflect/mark/superscript.rb +1 -3
- data/lib/prosereflect/mark/underline.rb +1 -3
- data/lib/prosereflect/mark.rb +9 -5
- data/lib/prosereflect/node.rb +171 -33
- data/lib/prosereflect/ordered_list.rb +17 -14
- data/lib/prosereflect/output/html.rb +279 -50
- data/lib/prosereflect/output.rb +7 -0
- data/lib/prosereflect/paragraph.rb +11 -13
- data/lib/prosereflect/parser.rb +56 -66
- data/lib/prosereflect/resolved_pos.rb +256 -0
- data/lib/prosereflect/schema/attribute.rb +57 -0
- data/lib/prosereflect/schema/content_match.rb +656 -0
- data/lib/prosereflect/schema/fragment.rb +166 -0
- data/lib/prosereflect/schema/mark.rb +121 -0
- data/lib/prosereflect/schema/mark_type.rb +130 -0
- data/lib/prosereflect/schema/node.rb +236 -0
- data/lib/prosereflect/schema/node_type.rb +274 -0
- data/lib/prosereflect/schema/schema_main.rb +190 -0
- data/lib/prosereflect/schema/spec.rb +92 -0
- data/lib/prosereflect/schema.rb +39 -0
- data/lib/prosereflect/table.rb +12 -13
- data/lib/prosereflect/table_cell.rb +13 -13
- data/lib/prosereflect/table_header.rb +17 -17
- data/lib/prosereflect/table_row.rb +12 -12
- data/lib/prosereflect/text.rb +35 -11
- data/lib/prosereflect/transform/attr_step.rb +157 -0
- data/lib/prosereflect/transform/insert_step.rb +115 -0
- data/lib/prosereflect/transform/mapping.rb +82 -0
- data/lib/prosereflect/transform/mark_step.rb +269 -0
- data/lib/prosereflect/transform/replace_around_step.rb +181 -0
- data/lib/prosereflect/transform/replace_step.rb +157 -0
- data/lib/prosereflect/transform/slice.rb +91 -0
- data/lib/prosereflect/transform/step.rb +89 -0
- data/lib/prosereflect/transform/step_map.rb +126 -0
- data/lib/prosereflect/transform/structure.rb +120 -0
- data/lib/prosereflect/transform/transform.rb +341 -0
- data/lib/prosereflect/transform.rb +26 -0
- data/lib/prosereflect/user.rb +15 -15
- data/lib/prosereflect/version.rb +1 -1
- data/lib/prosereflect.rb +30 -17
- data/prosereflect.gemspec +17 -16
- data/spec/fixtures/documents/formatted_text.yaml +14 -0
- data/spec/fixtures/documents/heading_paragraph.yaml +16 -0
- data/spec/fixtures/documents/lists_doc.yaml +32 -0
- data/spec/fixtures/documents/mixed_content.yaml +40 -0
- data/spec/fixtures/documents/nested_doc.yaml +20 -0
- data/spec/fixtures/documents/simple_doc.yaml +6 -0
- data/spec/fixtures/documents/table_doc.yaml +32 -0
- data/spec/fixtures/documents/transform_test.yaml +14 -0
- data/spec/fixtures/schema/custom_schema.rb +37 -0
- data/spec/fixtures/schema/test_schema.rb +46 -0
- data/spec/fixtures/test_builder/helpers.rb +212 -0
- data/spec/prosereflect/document_spec.rb +332 -330
- data/spec/prosereflect/fragment_spec.rb +273 -0
- data/spec/prosereflect/hard_break_spec.rb +125 -125
- data/spec/prosereflect/input/html_spec.rb +718 -522
- data/spec/prosereflect/node_spec.rb +311 -182
- data/spec/prosereflect/output/html_spec.rb +105 -105
- data/spec/prosereflect/output/whitespace_spec.rb +248 -0
- data/spec/prosereflect/paragraph_spec.rb +275 -274
- data/spec/prosereflect/parser/round_trip_spec.rb +472 -0
- data/spec/prosereflect/parser_spec.rb +185 -180
- data/spec/prosereflect/resolved_pos_spec.rb +74 -0
- data/spec/prosereflect/schema/conftest.rb +68 -0
- data/spec/prosereflect/schema/content_match_spec.rb +237 -0
- data/spec/prosereflect/schema/mark_spec.rb +274 -0
- data/spec/prosereflect/schema/mark_type_spec.rb +86 -0
- data/spec/prosereflect/schema/node_type_spec.rb +142 -0
- data/spec/prosereflect/schema/schema_spec.rb +194 -0
- data/spec/prosereflect/table_cell_spec.rb +183 -183
- data/spec/prosereflect/table_row_spec.rb +149 -149
- data/spec/prosereflect/table_spec.rb +320 -318
- data/spec/prosereflect/test_builder/marks_spec.rb +127 -0
- data/spec/prosereflect/text_spec.rb +133 -132
- data/spec/prosereflect/transform/equivalence_spec.rb +487 -0
- data/spec/prosereflect/transform/mapping_spec.rb +226 -0
- data/spec/prosereflect/transform/replace_spec.rb +832 -0
- data/spec/prosereflect/transform/replace_step_spec.rb +157 -0
- data/spec/prosereflect/transform/slice_spec.rb +48 -0
- data/spec/prosereflect/transform/step_map_spec.rb +70 -0
- data/spec/prosereflect/transform/step_spec.rb +211 -0
- data/spec/prosereflect/transform/structure_spec.rb +98 -0
- data/spec/prosereflect/transform/transform_spec.rb +238 -0
- data/spec/prosereflect/user_spec.rb +31 -28
- data/spec/prosereflect_spec.rb +28 -26
- data/spec/spec_helper.rb +7 -6
- data/spec/support/matchers.rb +6 -6
- data/spec/support/shared_examples.rb +49 -49
- metadata +96 -5
- 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
|