yrby 0.5.0-aarch64-linux → 0.6.1-aarch64-linux

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d57e5226ef4574d5add30b93378d79153bb6d99e566cdf4b46f1617afa4baba0
4
- data.tar.gz: 2318c5397a272498e47552c35f26f4596fd4f9691a48fbeec57d148eec68a528
3
+ metadata.gz: b673ec3d72751c36ecb0b8edc586c15f393e1136d5a1589f52bc3199bc872030
4
+ data.tar.gz: 1ff196e6cedb72da559c3c61a20cd54254c43ef08203a4d476819caf46af5384
5
5
  SHA512:
6
- metadata.gz: d312593acea3022d672651fe5a6175b65d1e398fe20c26bac4ccd85f0915e72ea7c14914faaeb8b01694024314f7c3490b39b2534082441e0602ee08efbed87d
7
- data.tar.gz: f8cc4678c3ec88c1c99d4fadd6efff8c6730dcce37408246303f9a3429d6e7751d1d197e3793c7955f394b69bf07ffd506b5febb47c30ccdbdc683d9d90c5dcc
6
+ metadata.gz: 81949023eb7db2147856515dadb883919a95fac2bb85bd3bc774ba64b37e1065cea942608a554f4a390cbb700a2df2992a378d98a039147329e6f58c1232201c
7
+ data.tar.gz: 5055fb7f8f94d381cb7ad1a12ed204059064a6dbced1ce1693e574491feb1a66d1a1fc124ca01b890af14f04a01ed04a35c5e0dedb03ab073c2c165da9697d5a
data/CHANGELOG.md CHANGED
@@ -4,7 +4,80 @@ All notable changes to this project are documented here. The format is based on
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project aims
5
5
  to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## [Unreleased]
7
+ ## [0.6.1] - 2026-08-04
8
+
9
+ ### Fixed
10
+
11
+ - `Y::Lexxy` emits the attachment tag the node was created with instead of
12
+ a hardcoded `<action-text-attachment>`. Lexxy makes the tag configurable
13
+ (`Lexxy.configure`'s `attachmentTagName`, paired with
14
+ `ActionText::Attachment.tag_name` in Rails), and each attachment node
15
+ stores its tag, so a custom-tag app's rendered HTML now matches its
16
+ editor. A stored value that doesn't look like a tag name falls back to
17
+ the default; documents from before the tag was stored render unchanged.
18
+
19
+ ## [0.6.0] - 2026-07-11
20
+
21
+ ### Added
22
+
23
+ - **Custom render rules for `Y::Lexical` and `Y::ProseMirror`.** Both
24
+ renderers now take a block registering rules per node type —
25
+ `rules.node "callout", tag: "aside"` for markup-as-data, a Ruby block for
26
+ logic — plus `nodes:`/`marks:` keywords as the equivalent data form, to
27
+ render node types the pinned schemas don't know or to override how a
28
+ built-in renders.
29
+ Declarative rules (`tag`/`attrs`/`text`/`contains`, with templates mixing
30
+ literals and attribute references) render natively at full speed. Callback
31
+ rules run a Ruby block per node, receiving its type, attributes,
32
+ already-rendered children, and `child_types` (its element/block children by
33
+ type — the structural facts behind gallery counts and nested-list classes).
34
+ The block runs after the document read has finished, never while the doc is
35
+ locked, so it can safely read or write the same doc. Blocks are proven
36
+ sufficient for whole schemas: the gem's own editor schemas (`Y::Lexxy`,
37
+ `Y::Tiptap`) ship through this API, and the fixture tests hold their
38
+ output byte-identical to a live editor's. With no callback rules the
39
+ render path is unchanged, byte for byte. See "Custom nodes and marks" in
40
+ the README.
41
+ - **`Y::Lexical#node_types` / `Y::ProseMirror#node_types` — schema
42
+ discovery.** Ask a real document which node types it holds and what they
43
+ look like: counts, attribute names as stored, child types, whether text
44
+ runs appear, and whether a builtin or one of your rules already handles
45
+ each ("handled" nil marks what still needs a rule). Editors store names
46
+ you'd never guess; this is how you find them.
47
+ - `Y::RenderRules.escape_text` / `escape_attr` — the exact escaping the
48
+ native renderers use, for blocks that build markup from stored values
49
+ (ERB's `html_escape` also rewrites apostrophes, which breaks byte parity
50
+ with editor output).
51
+
52
+ ### Changed
53
+
54
+ - **Lexical rendering is now two classes: `Y::Lexical` (core Lexical) and
55
+ `Y::Lexxy` (core plus the Lexxy schema as render rules).** Stock Lexical
56
+ has no canonical serializer, so the editor-specific class carries the
57
+ editor's name — `Y::Lexxy.new(doc).to_html` is the byte-parity call for
58
+ Lexxy/Rails apps, and `Y::Lexical` is the base any other Lexical editor
59
+ extends with its own rules. The
60
+ native side renders core structure — paragraphs, headings, quotes, code,
61
+ lists, tables, links, the full text-format model. Lexxy's own node types
62
+ (attachments, galleries, `early_escape_code`, `horizontal_divider`) and its
63
+ decorations of core nodes (the table figure wrapper, header-cell styling,
64
+ the nested-list-item class) are rules applied beneath the app's, on the
65
+ same extension API — the gem's Lexxy support is the API's first consumer.
66
+ Output is unchanged: the fixture tests still hold `to_html` byte-identical
67
+ to a live editor's serialized value, now through the extension path. An
68
+ unknown Lexical container also degrades better: its block children render
69
+ without an invented wrapper instead of being dropped.
70
+ - **ProseMirror rendering gets the same split: `Y::ProseMirror` (core
71
+ ProseMirror) and `Y::Tiptap` (core plus Tiptap's extension nodes as render
72
+ rules).** `Y::Tiptap.new(doc).to_html` is the byte-parity call for Tiptap
73
+ apps. The native side renders prosemirror-schema-basic plus the
74
+ prosemirror-tables family; Tiptap's extension nodes — task lists, mentions,
75
+ the details family — are `Y::Tiptap::NODES` rules. Marks stay native in the
76
+ base class: mark rendering (nesting order, `textStyle` CSS, `code`
77
+ exclusivity) runs through text-run machinery node rules don't reach, so
78
+ `Y::ProseMirror` still renders Tiptap's full mark set and `rules.mark`
79
+ overrides individual marks. Output through `Y::Tiptap` is unchanged, held
80
+ byte-identical to a live editor's `getHTML()` by the fixture tests.
8
81
 
9
82
  ## [0.5.0] - 2026-07-08
10
83
 
data/README.md CHANGED
@@ -5,14 +5,16 @@
5
5
  Collaborative editing for Rails, backed by [y-crdt](https://github.com/y-crdt/y-crdt)
6
6
  (the Rust library behind Y.js). Your Rails server speaks the y-websocket sync
7
7
  protocol directly, so there's no separate Node process hosting the Y.js
8
- documents.
8
+ documents. Pronounced "yer-bee".
9
+
10
+ ![Two people typing on separate lines of the same document, each keystroke synced through a Rails server, seen from a third browser with labeled carets](docs/images/collab.gif)
9
11
 
10
12
  ```ruby
11
13
  class DocumentChannel < ApplicationCable::Channel
12
- include Y::ActionCable::Sync
14
+ include Y::ActionCable
13
15
 
14
- on_load { |key| MyStore.load(key) }
15
- on_change { |key, update| MyStore.append(key, update) }
16
+ on_load { |key| Y::Document.load_state(key) }
17
+ on_change { |key, update| Y::Document.append(key, update) }
16
18
 
17
19
  def subscribed = sync_subscribed(params[:id])
18
20
  def receive(data) = sync_receive(data, params[:id])
@@ -59,7 +61,7 @@ and `Doc#read_map` reconstruct it server-side, in Ruby.
59
61
  The surface is intentionally small, but the focus is durability, resiliency, delivery
60
62
  guarantees, correctness, and thread safety.
61
63
 
62
- Towards that goal, `yrby` adds capabilities that stand out even in the Yjs ecosystem:
64
+ Towards that goal, `yrby` adds opinionated defaults on top of normal Yjs syncing:
63
65
 
64
66
  - Built-in update acknowledgement: the `ActionCableProvider` in `yrby-client` will continue to
65
67
  send updates until an ack is received from the server. [`yrby-actioncable`](https://rubygems.org/gems/yrby-actioncable)
@@ -101,8 +103,9 @@ Issues and PRs are welcome.
101
103
  # Core CRDT + protocol primitives:
102
104
  gem "yrby"
103
105
 
104
- # For the Rails/ActionCable server concern (Y::ActionCable::Sync):
105
- gem "yrby-actioncable"
106
+ # For the Rails side (the sync channel, document models, the generator).
107
+ # Formerly yrby-actioncable; that name stops at 0.3.1.
108
+ gem "yrby-rails"
106
109
  ```
107
110
 
108
111
  Requires Ruby 3.4 or newer. The release workflow builds precompiled gems for
@@ -129,6 +132,27 @@ The rest of the dev setup, plus the demo, is in [CONTRIBUTING.md](CONTRIBUTING.m
129
132
  and the test/load suites.
130
133
  - [CHANGELOG.md](CHANGELOG.md) and [CONTRIBUTING.md](CONTRIBUTING.md).
131
134
 
135
+ ## Editors
136
+
137
+ yrby syncs opaque Yjs updates, so it works with any editor that has a Yjs
138
+ binding. The demo app runs four, and CI drives each one in real Chrome:
139
+ concurrent typing with every keystroke accounted for, remote cursors,
140
+ local-only undo, and byte parity between the server-side renderers and the
141
+ editor's own serializer. Each page is a working integration to copy from:
142
+
143
+ | Editor | Yjs binding | Demo code |
144
+ |---|---|---|
145
+ | [Tiptap](https://tiptap.dev) (v2) | `@tiptap/extension-collaboration` | [`app.js`](examples/actioncable-demo/frontend/src/app.js) |
146
+ | [Lexxy](https://github.com/basecamp/lexxy) (Lexical) | [`lexxy-realtime`](https://www.npmjs.com/package/lexxy-realtime) | [`lexxy.js`](examples/actioncable-demo/frontend/src/lexxy.js) |
147
+ | [Rhino Editor](https://github.com/KonnorRogers/rhino-editor) (Tiptap 3) | `@tiptap/extension-collaboration` + `-caret` | [`rhino.js`](examples/actioncable-demo/frontend/src/rhino.js) |
148
+ | [CodeMirror 6](https://codemirror.net) | `y-codemirror.next` | [`codemirror.js`](examples/actioncable-demo/frontend/src/codemirror.js) |
149
+
150
+ The demo also syncs plain Yjs shapes with no editor at all — a whiteboard
151
+ on a `Y.Map`, a kanban board on a `Y.Array`, a co-filled form — over the
152
+ same channel. The demo README's "Using this in your own app" section has
153
+ the integration recipe, and its `NoteMaterializer` shows how to render a
154
+ document to ActionText server-side with `Y::Tiptap` or `Y::Lexxy`.
155
+
132
156
  ## Usage
133
157
 
134
158
  ### Doc (Low-Level Document Sync)
@@ -188,18 +212,20 @@ guarantees keep serving safe:
188
212
 
189
213
  ### Rendering to HTML
190
214
 
191
- Two schema-pinned renderers turn a collaborative document into HTML on the
192
- server, with no Node process or headless editor: `Y::ProseMirror` for
193
- ProseMirror/Tiptap documents and `Y::Lexical` for
194
- [Lexxy](https://github.com/basecamp/lexxy) (Lexical) documents. Each returns
195
- `nil` for a root that belongs to the other schema.
215
+ Schema-pinned renderers turn a collaborative document into HTML on the
216
+ server, with no Node process or headless editor. Each is an editor-specific
217
+ class (byte-for-byte with that editor's own serializer) built on a core base
218
+ any other editor extends with rules: `Y::Tiptap` on `Y::ProseMirror` for
219
+ ProseMirror documents, and `Y::Lexxy` (the
220
+ [Lexxy](https://github.com/basecamp/lexxy) editor) on `Y::Lexical`. Each
221
+ returns `nil` for a root that belongs to the other schema.
196
222
 
197
- #### `Y::ProseMirror`
223
+ #### `Y::Tiptap` (and `Y::ProseMirror`, its base)
198
224
 
199
225
  ```ruby
200
- prosemirror = Y::ProseMirror.new(doc)
201
- prosemirror.to_html # the "default" fragment (Tiptap's default root)
202
- prosemirror.to_html("content") # or another XML root
226
+ tiptap = Y::Tiptap.new(doc)
227
+ tiptap.to_html # the "default" fragment (Tiptap's default root)
228
+ tiptap.to_html("content") # or another XML root
203
229
  ```
204
230
 
205
231
  The output matches Tiptap's own `getHTML()`, checked byte-for-byte in the tests
@@ -214,17 +240,30 @@ tables, text styles (color, font family), and every text mark. A table renders
214
240
  as semantic `<table><tbody>`, without the column-width styling Tiptap's editor
215
241
  view adds.
216
242
 
217
- #### `Y::Lexical`
243
+ The support is layered like the Lexical side: `Y::ProseMirror` covers core
244
+ ProseMirror natively — prosemirror-schema-basic plus the prosemirror-tables
245
+ family — and Tiptap's extension nodes (task lists, mentions, the details
246
+ family) are `Y::Tiptap`'s rule set (`Y::Tiptap::NODES`), built on the
247
+ extension API below. Marks stay in the base: mark rendering (nesting order,
248
+ `textStyle` CSS, `code` exclusivity) runs through native text-run machinery
249
+ that node rules don't reach, so `Y::ProseMirror` renders Tiptap's mark set
250
+ as-is and `rules.mark` overrides individual marks.
251
+
252
+ #### `Y::Lexxy` (and `Y::Lexical`, its base)
218
253
 
219
254
  ```ruby
220
- lexical = Y::Lexical.new(doc)
221
- lexical.to_html # the "root" fragment (Lexical's default root name)
222
- lexical.to_html("notepad") # or another XML root
255
+ lexxy = Y::Lexxy.new(doc)
256
+ lexxy.to_html # the "root" fragment (Lexical's default root name)
257
+ lexxy.to_html("notepad") # or another XML root
223
258
  ```
224
259
 
225
260
  The HTML is identical to what a `lexxy-editor` submits to Rails (its `value`).
226
261
  The tests check this byte-for-byte against a document captured from a real
227
- editor.
262
+ editor. Stock Lexical has no canonical serializer — every editor configures
263
+ its own — so the editor-specific class carries the editor's name, and
264
+ `Y::Lexical` is the core-Lexical base: paragraphs, headings, quotes, code,
265
+ lists, tables, links, and the full text-format model, for any other Lexical
266
+ editor to extend with rules.
228
267
 
229
268
  It handles the whole Lexxy 0.9.x node set: paragraphs, headings, every text
230
269
  format and their combinations, links, the four list types and nesting,
@@ -233,8 +272,178 @@ header cells, image galleries, and ActionText attachments (uploads and
233
272
  mentions both emit `<action-text-attachment>` elements that ActionText can
234
273
  re-render).
235
274
 
236
- In both renderers an unknown node keeps its content — text falls back to a
237
- plain paragraph rather than disappearing.
275
+ Internally that support is layered: `Y::Lexical` covers core Lexical
276
+ structure natively, and everything Lexxy adds — its node types (attachments,
277
+ galleries) and its decorations of core nodes (the table wrapper, header-cell
278
+ styling, nested-list classes) — is `Y::Lexxy`'s rule set
279
+ (`Y::Lexxy::NODES`), built on the extension API below. The gem's own Lexxy
280
+ support is the API's first consumer: an app rule for one of those types
281
+ simply replaces it.
282
+
283
+ In both renderers an unknown node keeps its content — text and nested blocks
284
+ fall back to readable markup rather than disappearing.
285
+
286
+ #### Custom nodes and marks
287
+
288
+ The built-in schemas are pinned to what Tiptap and Lexxy ship, but apps add
289
+ their own node types. Both renderers take rules for them. A rule is checked
290
+ before the built-in schema, so it can add a node type or replace how a
291
+ built-in renders.
292
+
293
+ Rules register in a block — one `rules.node` call per type. A declarative
294
+ rule is markup as data, rendered natively:
295
+
296
+ ```ruby
297
+ tiptap = Y::Tiptap.new(doc) do |rules|
298
+ rules.node "callout", tag: "aside",
299
+ attrs: { "class" => ["callout callout--", :kind] },
300
+ contains: :blocks
301
+ end
302
+ ```
303
+
304
+ `tag` names the element. `attrs` values are templates: a string is a literal,
305
+ a symbol reads that attribute off the node, an array concatenates both kinds;
306
+ an attribute that resolves empty is left out. `text` (same template form)
307
+ emits literal text content. `contains` declares what lives inside the node — `:inline` (formatted text,
308
+ the default), `:blocks` (child block nodes — a container), or `:none` (a
309
+ leaf). `void: true` skips the closing tag.
310
+
311
+ You don't have to guess any of those names or shapes. Editors store types
312
+ and attributes under names you'd never predict (Rhino's strike mark is
313
+ `rhino-strike`; Lexical prefixes its own props `__`), so ask a real
314
+ document instead — make one in your editor using your custom node, then:
315
+
316
+ ```ruby
317
+ Y::Tiptap.new(doc).node_types
318
+ # => { "callout" => { "count" => 2, "attrs" => ["kind"],
319
+ # "children" => ["paragraph"], "text" => false,
320
+ # "handled" => nil },
321
+ # "paragraph" => { ..., "handled" => "builtin" } }
322
+ ```
323
+
324
+ `handled` nil marks the types that still need a rule; `attrs` are the stored
325
+ names your templates and blocks will read; `children` plus `text` is how you
326
+ pick `contains:` (child block types → `:blocks`; text → `:inline`).
327
+
328
+ When markup-as-data isn't enough, give the node a block:
329
+
330
+ ```ruby
331
+ lexical = Y::Lexical.new(doc) do |rules|
332
+ rules.node "video_embed" do |node|
333
+ src = ERB::Util.html_escape(node.attrs["__src"])
334
+ %(<video controls src="#{src}"></video>)
335
+ end
336
+ end
337
+ ```
338
+
339
+ The block gets the node's type, its stored attributes, `node.content` — the
340
+ children, already rendered to HTML — and `node.child_types`, the node's
341
+ element/block children by type, in document order. `child_types` answers the
342
+ structural questions attributes can't: how many images a gallery holds, or
343
+ whether a list item carries a nested list. Whatever the block returns is
344
+ spliced into the output as-is: it's trusted HTML, so escape any values you
345
+ interpolate. To set the content mode for a callback, give the node both —
346
+ `rules.node "embed", contains: :blocks do |node| ... end`.
347
+
348
+ Callbacks never run while the document is locked. The render finishes first
349
+ (inside one read transaction, GVL released), then the blocks run and their
350
+ output is spliced in — so a callback can safely read or even write the same
351
+ doc. With no callback rules, `to_html` skips the splicing entirely.
352
+
353
+ Blocks are the escape hatch for everything the declarative form can't say,
354
+ and they're proven sufficient: `Y::Lexxy` and `Y::Tiptap` are themselves
355
+ built on this API (`lib/y/lexxy.rb`, `lib/y/tiptap.rb`) — simple nodes as
356
+ declarative hashes, everything with logic as plain methods mapped by node
357
+ type (a `Method` responds to `call` like any lambda) — and the fixture tests
358
+ hold their output byte-identical to a live editor's.
359
+
360
+ The ProseMirror side also takes custom marks:
361
+
362
+ ```ruby
363
+ tiptap = Y::Tiptap.new(doc) do |rules|
364
+ rules.mark "comment", tag: "span", attrs: { "data-comment-id" => :id }
365
+ end
366
+ ```
367
+
368
+ Symbol refs resolve against the mark's own attributes. A custom mark wraps
369
+ outside every built-in mark; several on one run nest alphabetically. A rule
370
+ for a built-in mark name (`"bold"`) replaces its built-in tag.
371
+
372
+ ##### Worked examples
373
+
374
+ A video-embed node from an app's Tiptap extension — a type the pinned schema
375
+ has never heard of:
376
+
377
+ ```ruby
378
+ tiptap = Y::Tiptap.new(doc) do |rules|
379
+ rules.node "videoEmbed" do |node|
380
+ src = ERB::Util.html_escape(node.attrs["src"])
381
+ title = ERB::Util.html_escape(node.attrs["title"] || "Video")
382
+ %(<figure class="video"><iframe src="#{src}" title="#{title}" allowfullscreen></iframe></figure>)
383
+ end
384
+ end
385
+ ```
386
+
387
+ Resolving mentions against the database. Blocks run after the document read
388
+ has finished, so hitting ActiveRecord (or the doc itself) inside one is safe:
389
+
390
+ ```ruby
391
+ tiptap = Y::Tiptap.new(doc) do |rules|
392
+ rules.node "mention" do |node|
393
+ user = User.find_by(id: node.attrs["id"])
394
+ next "<span>@unknown</span>" unless user
395
+
396
+ %(<a class="mention" href="/users/#{user.id}">@#{ERB::Util.html_escape(user.handle)}</a>)
397
+ end
398
+ end
399
+ ```
400
+
401
+ Overriding a shipped rule — rendering Lexxy uploads as real image markup
402
+ instead of the `<action-text-attachment>` elements ActionText re-renders:
403
+
404
+ ```ruby
405
+ lexxy = Y::Lexxy.new(doc) do |rules|
406
+ rules.node "action_text_attachment" do |node|
407
+ src = ERB::Util.html_escape(node.attrs["src"])
408
+ alt = ERB::Util.html_escape(node.attrs["altText"].to_s)
409
+ caption = node.attrs["caption"].to_s
410
+ html = %(<img src="#{src}" alt="#{alt}" loading="lazy">)
411
+ html += "<figcaption>#{ERB::Util.html_escape(caption)}</figcaption>" unless caption.empty?
412
+ "<figure>#{html}</figure>"
413
+ end
414
+ end
415
+ ```
416
+
417
+ Markup that depends on structure — `node.child_types` lists the node's
418
+ element/block children in document order, so a layout container can size
419
+ itself by its column count while the columns themselves stay declarative:
420
+
421
+ ```ruby
422
+ tiptap = Y::Tiptap.new(doc) do |rules|
423
+ rules.node "columns", contains: :blocks do |node|
424
+ %(<div class="columns columns--#{node.child_types.length}">#{node.content}</div>)
425
+ end
426
+ rules.node "column", tag: "div", attrs: { "class" => "column" }, contains: :blocks
427
+ end
428
+ ```
429
+
430
+ Content-aware overrides — dropping the empty paragraphs an editor keeps
431
+ around the cursor, since `node.content` arrives already rendered:
432
+
433
+ ```ruby
434
+ lexical = Y::Lexical.new(doc) do |rules|
435
+ rules.node "paragraph" do |node|
436
+ node.content.empty? ? "" : "<p>#{node.content}</p>"
437
+ end
438
+ end
439
+ ```
440
+
441
+ For a larger reference, the gem's own editor schemas ship this way — see
442
+ `Y::Lexxy::NODES` in `lib/y/lexxy.rb` (declarative hashes for the simple
443
+ nodes, a plain method per node that needs logic — galleries, list items,
444
+ header cells, both attachment types — mapped with `method(:name)`) and
445
+ `Y::Tiptap::NODES` in `lib/y/tiptap.rb` (task lists, mentions, the details
446
+ family).
238
447
 
239
448
  ### Protocol codec (module functions)
240
449
 
@@ -251,25 +460,72 @@ Y.wrap_update(update_bytes) # => wrap a raw doc update as a sync Update frame
251
460
 
252
461
  ### ActionCable Integration
253
462
 
254
- `Y::ActionCable::Sync` (from the `yrby-actioncable` gem) is a channel
255
- concern that implements the full y-websocket protocol (document sync +
256
- awareness/presence) over ActionCable:
463
+ In a Rails app, one generator creates the channel and the migration:
464
+
465
+ ```bash
466
+ bin/rails generate yrby:install
467
+ bin/rails db:migrate
468
+ ```
469
+
470
+ The models ship in the gem, the way Action Text owns
471
+ `ActionText::RichText`:
472
+
473
+ - **`Y::Document`** — one row per document, addressed two ways: by `key`
474
+ (what a channel addresses — one opaque, unique string, sometimes
475
+ app-supplied, never parsed) and, optionally, by polymorphic `record` +
476
+ `name` (which model attribute it backs; `name` is the attribute name,
477
+ `"body"` — one document per attribute per record, the
478
+ ActionText::RichText scheme). Key-only documents leave the binding nil.
479
+ Either side can arrive first: `Y::Document.for(record, name)` finds or
480
+ creates the binding, derives a readable key (`post/1/body`), and adopts
481
+ a key-only row already holding that key, so a channel writing first and
482
+ a binding created later converge on one document. The row also holds
483
+ the merged `state` snapshot — CRDT state only; derived data (rendered
484
+ HTML, search text) is the application's job, typically in the channel's
485
+ on_change. `.load_state(key)` / `.append(key, update)` are the store
486
+ calls the generated channel uses.
487
+ - **`Y::DocumentUpdate`** — the uncompacted tail: one delta per row,
488
+ compacted into `state` and deleted once the tail reaches `compact_every`
489
+ (default 64). Loading reads the snapshot plus the current tail; an
490
+ empty tail returns `state` directly. Compaction serializes on a
491
+ per-document row lock and skips causally-gapped rows — they're
492
+ quarantined until they heal rather than compacted into state or
493
+ deleted. Destroying a document deletes its updates with it.
494
+
495
+ The migration creates `y_documents` and `y_document_updates`. To rename
496
+ them, edit the generated migration and point `Y::Document.table_name` /
497
+ `Y::DocumentUpdate.table_name` at the new names in an initializer.
498
+
499
+ Storage is swappable: the channel only needs `on_load` and `on_change`
500
+ answered, and they can point at anything.
501
+
502
+ `include Y::ActionCable` (from the `yrby-rails` gem) is the channel
503
+ integration: the y-websocket protocol (document sync +
504
+ awareness/presence) over ActionCable. (`include Y::ActionCable::Sync`
505
+ keeps working and has the same effect.)
257
506
 
258
507
  ```ruby
259
508
  # app/channels/document_channel.rb
260
509
  class DocumentChannel < ApplicationCable::Channel
261
- include Y::ActionCable::Sync
510
+ include Y::ActionCable
262
511
 
263
- on_load { |key| MyStore.load(key) } # source of truth
264
- on_change { |key, update| MyStore.append(key, update) } # durable record
512
+ on_load { |key| Y::Document.load_state(key) } # rebuild from storage
513
+ on_change { |key, update| Y::Document.append(key, update) } # record, then broadcast
265
514
 
266
515
  def subscribed
516
+ return reject unless authorized?(params[:id])
517
+
267
518
  sync_subscribed params[:id]
268
519
  end
269
520
 
270
521
  def receive(data)
271
522
  sync_receive(data, params[:id])
272
523
  end
524
+
525
+ private
526
+
527
+ # Everyone is denied until you wire this to your app's auth.
528
+ def authorized?(_document_key) = false
273
529
  end
274
530
  ```
275
531
 
@@ -365,7 +621,7 @@ It is up to you to durably record it:
365
621
 
366
622
  ```ruby
367
623
  class DocumentChannel < ApplicationCable::Channel
368
- include Y::ActionCable::Sync
624
+ include Y::ActionCable
369
625
 
370
626
  # ...
371
627
 
@@ -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/3.4/yrby.so CHANGED
Binary file
data/lib/y/4.0/yrby.so CHANGED
Binary file
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
metadata CHANGED
@@ -1,14 +1,14 @@
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.1
5
5
  platform: aarch64-linux
6
6
  authors:
7
7
  - JP Camara
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-09 00:00:00.000000000 Z
11
+ date: 2026-08-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest
@@ -55,7 +55,7 @@ dependencies:
55
55
  description: 'yrby is a thread-safe Ruby binding over the Rust y-crdt (yrs) library:
56
56
  CRDT documents, awareness/presence, and the y-websocket sync protocol primitives,
57
57
  with the GVL released during native work so documents sync in parallel. The ActionCable/Rails
58
- integration lives in the companion yrby-actioncable gem.'
58
+ integration lives in the companion yrby-rails gem.'
59
59
  email:
60
60
  - johnpcamara@gmail.com
61
61
  executables: []
@@ -65,9 +65,16 @@ files:
65
65
  - CHANGELOG.md
66
66
  - LICENSE
67
67
  - README.md
68
+ - lib/generators/yrby/install/install_generator.rb
69
+ - lib/generators/yrby/install/templates/document_channel.rb
70
+ - lib/generators/yrby/tables/tables_generator.rb
71
+ - lib/generators/yrby/tables/templates/create_y_tables.rb
68
72
  - lib/y.rb
69
73
  - lib/y/3.4/yrby.so
70
74
  - lib/y/4.0/yrby.so
75
+ - lib/y/lexxy.rb
76
+ - lib/y/rendering.rb
77
+ - lib/y/tiptap.rb
71
78
  - lib/y/version.rb
72
79
  - lib/yrby.rb
73
80
  homepage: https://github.com/jpcamara/yrby