yrby 0.5.0 → 0.6.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.
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Y
6
+ # Custom render rules for Y::Lexical and Y::ProseMirror.
7
+ #
8
+ # Both renderers accept a `nodes:` hash mapping a node type (Lexical's
9
+ # `__type`, ProseMirror's node name) to a rule; Y::ProseMirror also accepts
10
+ # `marks:`. A rule is consulted before the built-in schema, so it can add a
11
+ # custom node or override a built-in one.
12
+ #
13
+ # The usual way in is the block form — one `rules.node` call per type,
14
+ # keyword options for markup-as-data, a Ruby block for logic (see Builder
15
+ # below). `contains:` says what's inside the node: :inline (formatted
16
+ # text, the default), :blocks (child block nodes), or :none (a leaf). The
17
+ # nodes:/marks: keywords take the same rules as plain hashes, for shipping
18
+ # rule sets as data.
19
+ #
20
+ # Two kinds of rule:
21
+ #
22
+ # - Declarative (keyword options / a Hash): markup as data, rendered
23
+ # natively at full speed.
24
+ # Y::ProseMirror.new(doc) do |rules|
25
+ # rules.node "callout", tag: "aside",
26
+ # attrs: { "class" => ["callout callout--", :kind] },
27
+ # contains: :blocks
28
+ # end
29
+ # `tag` is the element; `attrs` values are templates — a String literal,
30
+ # a Symbol referencing one of the node's stored attributes, or an Array
31
+ # mixing both (an attribute that resolves empty is omitted); `text` is a
32
+ # template for literal text content; `void: true` emits no closing tag;
33
+ # `contains` declares what lives inside the node and renders there:
34
+ # :inline (default) for formatted text, :blocks for child block nodes
35
+ # (a container), :none for a leaf.
36
+ #
37
+ # - Callback (a block; in the hash form, a callable or `render:` plus
38
+ # `contains`):
39
+ # Y::Lexical.new(doc) do |rules|
40
+ # rules.node "video_embed" do |node|
41
+ # %(<video src="#{ERB::Util.html_escape(node.attrs["src"])}"></video>)
42
+ # end
43
+ # end
44
+ # The block runs after the document read has finished (never while the
45
+ # document is locked) and receives a RenderRules::Node with the node's
46
+ # type, stored attributes, children already rendered to HTML, and
47
+ # child_types (its element/block children by type). Its return value is
48
+ # spliced in verbatim — it is trusted HTML, so escape any attribute
49
+ # values you interpolate.
50
+ #
51
+ # Mark rules (ProseMirror only) are declarative: `tag` plus `attrs`
52
+ # templates whose Symbol refs resolve against the mark's own attributes. A
53
+ # custom mark wraps outside every built-in mark; several custom marks nest
54
+ # alphabetically. A rule for a built-in mark's stored name replaces its
55
+ # wrap (the markup changes, the semantics don't — an overridden code mark
56
+ # still excludes the other formatting).
57
+ module RenderRules
58
+ # What a callback receives. `attrs` keys are as stored (Lexical's own
59
+ # props keep their "__" prefix); `content` is the node's children,
60
+ # already rendered to an HTML string; `child_types` lists the node's
61
+ # element/block children by type, in document order — the structural
62
+ # facts attrs and content can't answer (a gallery's image count, whether
63
+ # a list item holds a nested list).
64
+ Node = Data.define(:type, :attrs, :content, :child_types)
65
+
66
+ module_function
67
+
68
+ # Compile the user-facing config into [rules_json, callbacks]. Structural
69
+ # validation happens in the native parser, which raises ArgumentError.
70
+ def compile(nodes, marks)
71
+ callbacks = {}
72
+ spec = {}
73
+ unless nodes.empty?
74
+ spec["nodes"] = nodes.to_h do |type, rule|
75
+ [type.to_s, compile_node(type.to_s, rule, callbacks)]
76
+ end
77
+ end
78
+ unless marks.empty?
79
+ spec["marks"] = marks.to_h do |name, rule|
80
+ [name.to_s, compile_mark(name.to_s, rule)]
81
+ end
82
+ end
83
+ [JSON.generate(spec), callbacks]
84
+ end
85
+
86
+ def compile_node(type, rule, callbacks)
87
+ if rule.respond_to?(:call)
88
+ callbacks[type] = rule
89
+ return { "callback" => true }
90
+ end
91
+ raise ArgumentError, "rule for #{type.inspect} must be a Hash or a callable" unless rule.is_a?(Hash)
92
+
93
+ if rule[:render]
94
+ callbacks[type] = rule[:render]
95
+ compiled = { "callback" => true }
96
+ compiled["content"] = rule[:contains].to_s if rule[:contains]
97
+ return compiled
98
+ end
99
+ compile_declarative_node(rule)
100
+ end
101
+
102
+ def compile_declarative_node(rule)
103
+ compiled = {}
104
+ compiled["tag"] = rule[:tag].to_s if rule[:tag]
105
+ compiled["void"] = true if rule[:void]
106
+ compiled["attrs"] = compile_attrs(rule[:attrs]) if rule[:attrs]
107
+ compiled["text"] = compile_parts(rule[:text]) if rule[:text]
108
+ compiled["content"] = rule[:contains].to_s if rule[:contains]
109
+ compiled
110
+ end
111
+
112
+ def compile_mark(name, rule)
113
+ raise ArgumentError, "mark rule for #{name.inspect} must be a Hash" unless rule.is_a?(Hash)
114
+
115
+ compiled = {}
116
+ compiled["tag"] = rule[:tag].to_s if rule[:tag]
117
+ compiled["attrs"] = compile_attrs(rule[:attrs]) if rule[:attrs]
118
+ compiled
119
+ end
120
+
121
+ def compile_attrs(attrs)
122
+ attrs.map { |name, template| [name.to_s, compile_parts(template)] }
123
+ end
124
+
125
+ # A template: String literal, Symbol attribute reference, or an Array of
126
+ # both.
127
+ def compile_parts(template)
128
+ Array(template).map do |part|
129
+ case part
130
+ when Symbol then { "ref" => part.to_s }
131
+ else { "lit" => part.to_s }
132
+ end
133
+ end
134
+ end
135
+
136
+ # The escaping the native renderers use, for blocks that build markup
137
+ # from stored values. Text content escapes `&`, `<`, `>` (quotes stay
138
+ # literal, matching the browser serializer); attribute values also
139
+ # escape `"`. Prefer these over ERB::Util.html_escape when byte parity
140
+ # with editor output matters — html_escape also rewrites apostrophes.
141
+ def escape_text(value)
142
+ value.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
143
+ end
144
+
145
+ def escape_attr(value)
146
+ escape_text(value).gsub('"', "&quot;")
147
+ end
148
+
149
+ # Yielded by Y::Lexical.new / Y::ProseMirror.new: register rules one
150
+ # call per node type. Keyword options are the markup-as-data; a block is
151
+ # the logic; both together say what a callback node contains and how to
152
+ # render it:
153
+ #
154
+ # Y::ProseMirror.new(doc) do |rules|
155
+ # rules.node "callout", tag: "aside", contains: :blocks
156
+ # rules.node "video" do |node|
157
+ # %(<video src="#{RenderRules.escape_attr(node.attrs["src"])}"></video>)
158
+ # end
159
+ # rules.node "columns", contains: :blocks do |node|
160
+ # %(<div class="cols--#{node.child_types.length}">#{node.content}</div>)
161
+ # end
162
+ # rules.mark "comment", tag: "span", attrs: { "data-comment-id" => :id }
163
+ # end
164
+ #
165
+ # It compiles to the same rule hashes the nodes:/marks: keywords take, so
166
+ # both forms mean the same thing; the keywords remain the data form for
167
+ # shipping rule sets (Y::Lexxy::NODES is one).
168
+ class Builder
169
+ attr_reader :nodes, :marks
170
+
171
+ def initialize(marks_allowed:)
172
+ @nodes = {}
173
+ @marks = {}
174
+ @marks_allowed = marks_allowed
175
+ end
176
+
177
+ def node(type, **options, &block)
178
+ @nodes[type.to_s] =
179
+ if block && options.empty?
180
+ block
181
+ elsif block
182
+ options.merge(render: block)
183
+ else
184
+ options
185
+ end
186
+ end
187
+
188
+ def mark(name, **options)
189
+ raise ArgumentError, "marks are ProseMirror-only" unless @marks_allowed
190
+
191
+ @marks[name.to_s] = options
192
+ end
193
+ end
194
+
195
+ # Resolve callback segments depth-first, so a callback's `node.content`
196
+ # is finished HTML even when callback nodes nest.
197
+ def splice(segments, callbacks)
198
+ segments.map do |segment|
199
+ next segment if segment.is_a?(String)
200
+
201
+ type, attrs_json, content, child_types = segment
202
+ node = Node.new(type: type, attrs: JSON.parse(attrs_json),
203
+ content: splice(content, callbacks),
204
+ child_types: child_types)
205
+ callbacks.fetch(type).call(node).to_s
206
+ end.join
207
+ end
208
+ end
209
+
210
+ # Y::Lexical and Y::ProseMirror are plain Ruby facades over the native
211
+ # renderers (Y::NativeLexical / Y::NativeProseMirror, private constants):
212
+ # they compile the rules config, hold the callbacks, and splice deferred
213
+ # segments after a render. The native handle does everything else.
214
+ class Lexical
215
+ # `Y::Lexical.new(doc, nodes: { "type" => rule })` — see Y::RenderRules
216
+ # for the rule forms. This is core Lexical only: paragraphs, headings,
217
+ # quotes, code, lists, tables, links, text formatting. Editor-specific
218
+ # nodes arrive as rules — Y::Lexxy subclasses this with the Lexxy schema;
219
+ # a different Lexical editor brings its own rule set the same way.
220
+ def initialize(doc, nodes: {})
221
+ builder = RenderRules::Builder.new(marks_allowed: false)
222
+ yield builder if block_given?
223
+ nodes = nodes.transform_keys(&:to_s).merge(builder.nodes)
224
+ rules_json, @render_callbacks = RenderRules.compile(nodes, {})
225
+ @native = NativeLexical.new(doc, rules_json)
226
+ end
227
+
228
+ def to_html(root = nil)
229
+ result = root.nil? ? @native.to_html : @native.to_html(root)
230
+ return result unless result.is_a?(Array)
231
+
232
+ RenderRules.splice(result, @render_callbacks)
233
+ end
234
+
235
+ # What node types this document actually contains — the discovery aid
236
+ # for writing rules. Facts per type: "count", "attrs" (names as stored),
237
+ # "children" (child node types), "text" (whether it holds text runs),
238
+ # and "handled" ("builtin", "rule", or nil — nil marks the types you
239
+ # still need a rule for). Children plus text is how you pick contains:.
240
+ def node_types(root = nil)
241
+ json = root.nil? ? @native.node_types : @native.node_types(root)
242
+ json && JSON.parse(json)
243
+ end
244
+ end
245
+
246
+ class ProseMirror
247
+ # `Y::ProseMirror.new(doc, nodes: {...}, marks: {...})` — see
248
+ # Y::RenderRules for the rule forms. This is core ProseMirror only:
249
+ # prosemirror-schema-basic plus the prosemirror-tables family, and the
250
+ # full mark set (marks are native — see `rules.mark` for overrides).
251
+ # Editor-specific nodes arrive as rules — Y::Tiptap subclasses this with
252
+ # Tiptap's extension nodes; a different ProseMirror editor brings its
253
+ # own rule set the same way.
254
+ def initialize(doc, nodes: {}, marks: {})
255
+ builder = RenderRules::Builder.new(marks_allowed: true)
256
+ yield builder if block_given?
257
+ nodes = nodes.transform_keys(&:to_s).merge(builder.nodes)
258
+ marks = marks.transform_keys(&:to_s).merge(builder.marks)
259
+ rules_json, @render_callbacks = RenderRules.compile(nodes, marks)
260
+ @native = NativeProseMirror.new(doc, rules_json)
261
+ end
262
+
263
+ def to_html(root = nil)
264
+ result = root.nil? ? @native.to_html : @native.to_html(root)
265
+ return result unless result.is_a?(Array)
266
+
267
+ RenderRules.splice(result, @render_callbacks)
268
+ end
269
+
270
+ # What node types this document actually contains — the discovery aid
271
+ # for writing rules. Facts per type: "count", "attrs" (names as stored),
272
+ # "children" (child node types), "text" (whether it holds text runs),
273
+ # and "handled" ("builtin", "rule", or nil — nil marks the types you
274
+ # still need a rule for). Children plus text is how you pick contains:.
275
+ def node_types(root = nil)
276
+ json = root.nil? ? @native.node_types : @native.node_types(root)
277
+ json && JSON.parse(json)
278
+ end
279
+ end
280
+
281
+ private_constant :NativeLexical, :NativeProseMirror
282
+ end
data/lib/y/tiptap.rb ADDED
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Y
4
+ # The Tiptap renderer: Y::ProseMirror (core ProseMirror — schema-basic plus
5
+ # tables) plus Tiptap's extension nodes, applied beneath the app's rules —
6
+ # an app rule for one of these types simply replaces it. This is the
7
+ # byte-parity class: the fixture tests hold `Y::Tiptap.new(doc).to_html`
8
+ # identical to a live editor's own `getHTML()`.
9
+ #
10
+ # Tiptap's marks (underline, highlight, sub/superscript, textStyle) render
11
+ # natively in the base class — mark serialization is text-run machinery
12
+ # the rule system can't express.
13
+ class Tiptap < ProseMirror
14
+ # Tiptap's TaskItem markup: the data-checked flag (false when unset), a
15
+ # label wrapping the checkbox, and the item body in a div.
16
+ def self.task_item(node)
17
+ checked = node.attrs["checked"] == true
18
+ out = %(<li data-checked="#{checked}" data-type="taskItem">)
19
+ out << %(<label><input type="checkbox")
20
+ out << %( checked="checked") if checked
21
+ out << "><span></span></label><div>"
22
+ "#{out}#{node.content}</div></li>"
23
+ end
24
+
25
+ # Tiptap's Mention extension (no app-configured HTMLAttributes): data
26
+ # attributes when present, the suggestion char, and @label (falling back
27
+ # to @id) as the text.
28
+ def self.mention(node)
29
+ char = node.attrs["mentionSuggestionChar"] || "@"
30
+ out = +%(<span data-type="mention")
31
+ %w[id label].each do |key|
32
+ next if node.attrs[key].nil?
33
+
34
+ out << %( data-#{key}="#{RenderRules.escape_attr(node.attrs[key])}")
35
+ end
36
+ out << %( data-mention-suggestion-char="#{RenderRules.escape_attr(char)}">)
37
+ out << RenderRules.escape_text(char)
38
+ out << RenderRules.escape_text(node.attrs["label"] || node.attrs["id"] || "")
39
+ "#{out}</span>"
40
+ end
41
+
42
+ # The details family follows tiptap-php's renderHTML (the Tiptap
43
+ # extension is Pro-only, so there's no free getHTML() to capture
44
+ # against).
45
+ def self.details(node)
46
+ open = node.attrs["open"] == true ? %( open="open") : ""
47
+ "<details#{open}>#{node.content}</details>"
48
+ end
49
+
50
+ NODES = {
51
+ "taskList" => { tag: "ul", attrs: { "data-type" => "taskList" }, contains: :blocks },
52
+ "taskItem" => { contains: :blocks, render: method(:task_item) },
53
+ "mention" => method(:mention),
54
+ "details" => { contains: :blocks, render: method(:details) },
55
+ "detailsSummary" => { tag: "summary" },
56
+ "detailsContent" => { tag: "div", attrs: { "data-type" => "detailsContent" }, contains: :blocks }
57
+ }.freeze
58
+
59
+ def initialize(doc, nodes: {}, marks: {}, &)
60
+ super(doc, nodes: NODES.merge(nodes.transform_keys(&:to_s)), marks: marks, &)
61
+ end
62
+ end
63
+ end
data/lib/y/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Y
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/y.rb CHANGED
@@ -12,6 +12,10 @@ rescue LoadError
12
12
  require_relative "y/yrby"
13
13
  end
14
14
 
15
+ require_relative "y/rendering"
16
+ require_relative "y/lexxy"
17
+ require_relative "y/tiptap"
18
+
15
19
  module Y
16
20
  # Doc, Error, and the protocol module functions are defined in the Rust
17
21
  # extension. The ActionCable integration (Y::ActionCable::Sync) lives in the
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yrby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - JP Camara
@@ -88,7 +88,11 @@ files:
88
88
  - ext/yrby/src/prosemirror_html.rs
89
89
  - ext/yrby/src/protocol.rs
90
90
  - ext/yrby/src/read.rs
91
+ - ext/yrby/src/render_rules.rs
91
92
  - lib/y.rb
93
+ - lib/y/lexxy.rb
94
+ - lib/y/rendering.rb
95
+ - lib/y/tiptap.rb
92
96
  - lib/y/version.rb
93
97
  - lib/yrby.rb
94
98
  homepage: https://github.com/jpcamara/yrby