yrby 0.5.0 → 0.6.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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +74 -1
  3. data/Cargo.lock +29 -0
  4. data/README.md +286 -30
  5. data/ext/yrby/Cargo.toml +3 -0
  6. data/ext/yrby/crates/html-core/Cargo.toml +15 -0
  7. data/ext/yrby/crates/html-core/src/lib.rs +535 -0
  8. data/ext/yrby/crates/lexical-html/Cargo.toml +16 -0
  9. data/ext/yrby/{src/lexical_html.rs → crates/lexical-html/src/lib.rs} +615 -288
  10. data/ext/yrby/crates/prosemirror-html/Cargo.toml +16 -0
  11. data/ext/yrby/crates/prosemirror-html/src/lib.rs +1369 -0
  12. data/ext/yrby/src/lib.rs +208 -78
  13. data/ext/yrby/src/read.rs +3 -3
  14. data/ext/yrby/target/debug/build/clang-sys-e3ae45bd384f74c3/out/common.rs +355 -0
  15. data/ext/yrby/target/debug/build/clang-sys-e3ae45bd384f74c3/out/dynamic.rs +276 -0
  16. data/ext/yrby/target/debug/build/clang-sys-e3ae45bd384f74c3/out/macros.rs +49 -0
  17. data/ext/yrby/target/debug/build/rb-sys-4407948463231c4f/out/bindings-0.9.128-mri-arm64-darwin23-3.4.7.rs +8934 -0
  18. data/ext/yrby/target/debug/build/rb-sys-dbeea42737529c2d/out/bindings-0.9.128-mri-arm64-darwin23-3.4.7.rs +8934 -0
  19. data/ext/yrby/target/debug/build/serde-58ea0ee887cc2602/out/private.rs +6 -0
  20. data/ext/yrby/target/debug/build/serde_core-41f407c21c1f205e/out/private.rs +5 -0
  21. data/ext/yrby/target/debug/build/thiserror-0f1416a82ff26f22/out/private.rs +5 -0
  22. data/lib/generators/yrby/install/install_generator.rb +44 -0
  23. data/lib/generators/yrby/install/templates/document_channel.rb +36 -0
  24. data/lib/generators/yrby/tables/tables_generator.rb +41 -0
  25. data/lib/generators/yrby/tables/templates/create_y_tables.rb +24 -0
  26. data/lib/y/lexxy.rb +121 -0
  27. data/lib/y/rendering.rb +282 -0
  28. data/lib/y/tiptap.rb +63 -0
  29. data/lib/y/version.rb +1 -1
  30. data/lib/y.rb +4 -0
  31. metadata +23 -4
  32. data/ext/yrby/src/prosemirror_html.rs +0 -896
@@ -0,0 +1,6 @@
1
+ #[doc(hidden)]
2
+ pub mod __private228 {
3
+ #[doc(hidden)]
4
+ pub use crate::private::*;
5
+ }
6
+ use serde_core::__private228 as serde_core_private;
@@ -0,0 +1,5 @@
1
+ #[doc(hidden)]
2
+ pub mod __private228 {
3
+ #[doc(hidden)]
4
+ pub use crate::private::*;
5
+ }
@@ -0,0 +1,5 @@
1
+ #[doc(hidden)]
2
+ pub mod __private18 {
3
+ #[doc(hidden)]
4
+ pub use crate::private::*;
5
+ }
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "generators/yrby/tables/tables_generator"
5
+
6
+ module Yrby
7
+ module Generators
8
+ # `bin/rails generate yrby:install` — a DocumentChannel speaking the
9
+ # y-websocket protocol over the gem's document storage, plus the storage
10
+ # migration (via yrby:tables). The models ship in the gem; only the
11
+ # migration lands in the app.
12
+ class InstallGenerator < ::Rails::Generators::Base
13
+ source_root File.expand_path("templates", __dir__)
14
+
15
+ def create_channel
16
+ template "document_channel.rb", "app/channels/document_channel.rb"
17
+ end
18
+
19
+ def create_tables
20
+ invoke "yrby:tables"
21
+ end
22
+
23
+ def show_next_steps
24
+ say <<~NEXT
25
+
26
+ Next steps:
27
+
28
+ 1. Authorize document access: implement `authorized?` in
29
+ app/channels/document_channel.rb (it denies everyone until you do).
30
+ 2. bin/rails db:migrate
31
+ 3. Install the yrby-client npm package and connect an editor:
32
+
33
+ import { ActionCableProvider } from "yrby-client"
34
+ const provider = new ActionCableProvider(doc, consumer,
35
+ "DocumentChannel", { id: documentId })
36
+ provider.connect()
37
+
38
+ The README's Editors section links working integrations for
39
+ Tiptap, Lexxy, Rhino Editor, and CodeMirror.
40
+ NEXT
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Collaborative documents over Action Cable: one channel speaking the
4
+ # y-websocket protocol (sync plus presence). Storage is Y::Document +
5
+ # Y::DocumentUpdate; a document is created on its first change and its
6
+ # history goes with it when it is destroyed. Point on_load/on_change
7
+ # elsewhere to swap storage.
8
+ class DocumentChannel < ApplicationCable::Channel
9
+ include Y::ActionCable
10
+
11
+ # Rebuild a document from durable storage (nil means a brand-new document).
12
+ on_load { |key| Y::Document.load_state(key) }
13
+
14
+ # Record each CRDT delta durably. Runs before the change is acknowledged
15
+ # or broadcast; if this raises, the change is neither acked nor relayed,
16
+ # and yrby-client retries it.
17
+ on_change { |key, update| Y::Document.append(key, update) }
18
+
19
+ def subscribed
20
+ return reject unless authorized?(params[:id])
21
+
22
+ sync_subscribed(params[:id])
23
+ end
24
+
25
+ def receive(data) = sync_receive(data, params[:id])
26
+
27
+ private
28
+
29
+ # Everyone is denied until you fill this in. Wire it to your app's auth:
30
+ # identify current_user on the cable connection, then check they may read
31
+ # and write this document. Don't lean on on_change raising for access
32
+ # control — that path exists for store failures.
33
+ def authorized?(_document_key)
34
+ false
35
+ end
36
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module Yrby
7
+ module Generators
8
+ # `bin/rails generate yrby:tables` — the migration for the gem-owned
9
+ # document models (Y::Document + Y::DocumentUpdate). Invoked by
10
+ # yrby:install, and by other gems building on the same storage.
11
+ #
12
+ # Template notes (kept here, not in the emitted migration): state is
13
+ # 4.gigabytes - 1 (longblob on MySQL — a compacted snapshot is the whole
14
+ # document; a 16 MB cap would break compaction) and payload is
15
+ # 16.megabytes - 1 (one update can carry a big paste or a client's
16
+ # accumulated offline edits — the 64 KB default blob is too small).
17
+ # The partial unique index's WHERE only keeps
18
+ # key-only rows out of the index: uniqueness holds without it, since
19
+ # unique indexes treat NULLs as distinct on every supported database,
20
+ # and MySQL drops the predicate harmlessly. y_document_updates indexes
21
+ # (document_id, pending) instead of bare document_id: the prefix
22
+ # serves the tail and foreign-key lookups, and the pair serves the
23
+ # clean-row count every append runs.
24
+ class TablesGenerator < ::Rails::Generators::Base
25
+ include ActiveRecord::Generators::Migration
26
+
27
+ source_root File.expand_path("templates", __dir__)
28
+
29
+ def create_migration_file
30
+ migration_template "create_y_tables.rb",
31
+ File.join(db_migrate_path, "create_y_tables.rb")
32
+ end
33
+
34
+ private
35
+
36
+ def migration_version
37
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateYTables < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ create_table :y_documents do |t|
6
+ t.string :key, null: false, index: { unique: true }
7
+ t.references :record, polymorphic: true, null: true, index: false
8
+ t.string :name
9
+ t.binary :state, limit: 4.gigabytes - 1
10
+ t.timestamps
11
+ t.index %i[record_type record_id name], unique: true,
12
+ where: "record_type IS NOT NULL",
13
+ name: "index_y_documents_on_record_and_name"
14
+ end
15
+
16
+ create_table :y_document_updates do |t|
17
+ t.references :document, null: false, foreign_key: { to_table: :y_documents }, index: false
18
+ t.binary :payload, null: false, limit: 16.megabytes - 1
19
+ t.boolean :pending, null: false, default: false
20
+ t.datetime :created_at, null: false
21
+ t.index %i[document_id pending]
22
+ end
23
+ end
24
+ end
data/lib/y/lexxy.rb ADDED
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Y
4
+ # The Lexxy renderer: Y::Lexical (core Lexical) plus the Lexxy-specific
5
+ # schema, applied beneath the app's rules — an app rule for one of these
6
+ # types simply replaces it. This is the byte-parity class: the fixture
7
+ # tests hold `Y::Lexxy.new(doc).to_html` identical to a live editor's own
8
+ # serialized value.
9
+ #
10
+ # The schema doubles as the reference for augmenting a renderer: simple
11
+ # nodes are declarative hashes, nodes with logic are plain methods mapped
12
+ # in NODES.
13
+ class Lexxy < Lexical
14
+ # A cursor-placement placeholder; empty ones export to nothing.
15
+ def self.provisional_paragraph(node)
16
+ node.content.empty? ? "" : "<p>#{node.content}</p>"
17
+ end
18
+
19
+ # Adjacent previewable images; the class carries the image count
20
+ # (ActionText's convention).
21
+ def self.gallery(node)
22
+ %(<div class="attachment-gallery attachment-gallery--#{node.child_types.length}">#{node.content}</div>)
23
+ end
24
+
25
+ # Lexxy wraps tables in a styled figure.
26
+ def self.table(node)
27
+ %(<figure class="lexxy-content__table-wrapper"><table><tbody>#{node.content}</tbody></table></figure>)
28
+ end
29
+
30
+ # Class + background match Lexxy's own header-cell export.
31
+ def self.table_cell(node)
32
+ header = node.attrs["__headerState"].is_a?(Numeric) && node.attrs["__headerState"].positive?
33
+ return "<td>#{node.content}</td>" unless header
34
+
35
+ style = %(style="background-color: rgb(242, 243, 245);")
36
+ %(<th class="lexxy-content__table-cell--header" #{style}>#{node.content}</th>)
37
+ end
38
+
39
+ # Attribute order follows Lexxy's export — checked items put aria-checked
40
+ # before value; items holding a nested list append the
41
+ # lexxy-nested-listitem class after value.
42
+ def self.list_item(node)
43
+ out = +"<li"
44
+ checked = node.attrs["__checked"]
45
+ out << %( aria-checked="#{checked}") unless checked.nil?
46
+ out << %( value="#{node.attrs["__value"] || 1}")
47
+ out << %( class="lexxy-nested-listitem") if node.child_types.include?("list")
48
+ "#{out}>#{node.content}</li>"
49
+ end
50
+
51
+ # An upload, in the exact shape ActionText round-trips: attribute order
52
+ # and presence mirror Lexxy's exportDOM (nulls omitted, `previewable`
53
+ # only when true, `presentation="gallery"` always).
54
+ def self.upload(node)
55
+ tag = attachment_tag(node)
56
+ out = "<#{tag}"
57
+ out << attachment_attr(node, "sgid", "sgid")
58
+ out << %( previewable="true") if node.attrs["previewable"] == true
59
+ [%w[url src], %w[alt altText], %w[caption caption],
60
+ %w[content-type contentType], %w[filename fileName],
61
+ %w[filesize fileSize], %w[width width], %w[height height]]
62
+ .each { |html_name, stored| out << attachment_attr(node, html_name, stored) }
63
+ %(#{out} presentation="gallery"></#{tag}>)
64
+ end
65
+
66
+ # A content attachment (mention, embed): `content` carries the escaped
67
+ # inner HTML; `plainText` is not exported.
68
+ def self.mention(node)
69
+ tag = attachment_tag(node)
70
+ out = "<#{tag}"
71
+ out << attachment_attr(node, "sgid", "sgid")
72
+ out << attachment_attr(node, "content", "innerHtml")
73
+ out << attachment_attr(node, "content-type", "contentType")
74
+ "#{out}></#{tag}>"
75
+ end
76
+
77
+ # Lexxy's attachment tag is configurable (`Lexxy.configure`'s
78
+ # attachmentTagName, paired with ActionText::Attachment.tag_name on the
79
+ # Rails side), and each attachment node stores the tag it was created
80
+ # with. Emit the stored tag. The value is stored document data, not
81
+ # markup, so anything that doesn't look like a tag name falls back to
82
+ # ActionText's default.
83
+ def self.attachment_tag(node)
84
+ tag = node.attrs["tagName"].to_s
85
+ tag.match?(/\A[a-zA-Z][a-zA-Z0-9-]*\z/) ? tag : "action-text-attachment"
86
+ end
87
+
88
+ # An in-flight upload placeholder. Only the uploader's browser can
89
+ # complete it, so it is never finished content -- materialized output
90
+ # (Action Text bodies, search text) must not carry it.
91
+ def self.pending_upload(_node)
92
+ ""
93
+ end
94
+
95
+ # A stored nil (unset) is skipped; a stored empty string still emits.
96
+ def self.attachment_attr(node, html_name, stored)
97
+ return "" if node.attrs[stored].nil?
98
+
99
+ %( #{html_name}="#{RenderRules.escape_attr(node.attrs[stored])}")
100
+ end
101
+
102
+ NODES = {
103
+ # Lexxy's replacement for Lexical's CodeNode.
104
+ "early_escape_code" => { tag: "pre", attrs: { "data-language" => :language } },
105
+ "horizontal_divider" => { tag: "hr", void: true },
106
+ "provisonal_paragraph" => method(:provisional_paragraph), # (sic: Lexxy's spelling)
107
+ "image_gallery" => method(:gallery),
108
+ "table" => { contains: :blocks, render: method(:table) },
109
+ "wrapped_table_node" => { contains: :blocks, render: method(:table) },
110
+ "tablecell" => { contains: :blocks, render: method(:table_cell) },
111
+ "listitem" => { contains: :blocks, render: method(:list_item) },
112
+ "action_text_attachment" => method(:upload),
113
+ "action_text_attachment_upload" => method(:pending_upload),
114
+ "custom_action_text_attachment" => method(:mention)
115
+ }.freeze
116
+
117
+ def initialize(doc, nodes: {}, &)
118
+ super(doc, nodes: NODES.merge(nodes.transform_keys(&:to_s)), &)
119
+ end
120
+ end
121
+ end
@@ -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.1"
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