yrby 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bb0de1a9048f2aa942aad3bafed3269a029ed28ea178a72cb0f532e163cbb5f5
4
- data.tar.gz: 6949735cd0edca033e78ef060333b91c40af2e3f900a79da46885d6630e6bc32
3
+ metadata.gz: 93704595e7dd617d4eccd0645a3dbc99bc798e11ec2591c86f47f0da08381d2c
4
+ data.tar.gz: 3f4fb67f5fc0368fe31a74cdae2104b9cea8bc44e349b398efc20f84fca7061a
5
5
  SHA512:
6
- metadata.gz: 3f0a9aeea9e3efdd443f7da526eaf692226015a5a6be037527e1600b74cb2529e123fbcf0f03d91282ad4b60e96cfd5d81d51d149ec1f42b06f27664f4cf3ffb
7
- data.tar.gz: 5532beea7978401cf18a2970eac4eefdc037c075bc6de2fb56b92978b3dc5e8b3c5759b9b6d9aabfb879ba92517e3792810ab14fabf9ef4461da30bfa2bd4c39
6
+ metadata.gz: fbe8957a8298efdb2991fa2de86a184e3395cb4315f7e467c2ee1dc1c37e750404468e9a2e71bf55967a44ca281424e4310fc4d985503171ca50a10bb3341e90
7
+ data.tar.gz: a29fd67645ef0b5312e34213eaf13c42e0eb8628a4e1da1d6af08ff6a68b77673f0f6e32c74f52da5797a3b802573ca78559e4a94a0c36da5b22331102a0ade6
data/CHANGELOG.md CHANGED
@@ -4,7 +4,86 @@ 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.0] - 2026-07-11
8
+
9
+ ### Added
10
+
11
+ - **Custom render rules for `Y::Lexical` and `Y::ProseMirror`.** Both
12
+ renderers now take a block registering rules per node type —
13
+ `rules.node "callout", tag: "aside"` for markup-as-data, a Ruby block for
14
+ logic — plus `nodes:`/`marks:` keywords as the equivalent data form, to
15
+ render node types the pinned schemas don't know or to override how a
16
+ built-in renders.
17
+ Declarative rules (`tag`/`attrs`/`text`/`contains`, with templates mixing
18
+ literals and attribute references) render natively at full speed. Callback
19
+ rules run a Ruby block per node, receiving its type, attributes,
20
+ already-rendered children, and `child_types` (its element/block children by
21
+ type — the structural facts behind gallery counts and nested-list classes).
22
+ The block runs after the document read has finished, never while the doc is
23
+ locked, so it can safely read or write the same doc. Blocks are proven
24
+ sufficient for whole schemas: the gem's own editor schemas (`Y::Lexxy`,
25
+ `Y::Tiptap`) ship through this API, and the fixture tests hold their
26
+ output byte-identical to a live editor's. With no callback rules the
27
+ render path is unchanged, byte for byte. See "Custom nodes and marks" in
28
+ the README.
29
+ - **`Y::Lexical#node_types` / `Y::ProseMirror#node_types` — schema
30
+ discovery.** Ask a real document which node types it holds and what they
31
+ look like: counts, attribute names as stored, child types, whether text
32
+ runs appear, and whether a builtin or one of your rules already handles
33
+ each ("handled" nil marks what still needs a rule). Editors store names
34
+ you'd never guess; this is how you find them.
35
+ - `Y::RenderRules.escape_text` / `escape_attr` — the exact escaping the
36
+ native renderers use, for blocks that build markup from stored values
37
+ (ERB's `html_escape` also rewrites apostrophes, which breaks byte parity
38
+ with editor output).
39
+
40
+ ### Changed
41
+
42
+ - **Lexical rendering is now two classes: `Y::Lexical` (core Lexical) and
43
+ `Y::Lexxy` (core plus the Lexxy schema as render rules).** Stock Lexical
44
+ has no canonical serializer, so the editor-specific class carries the
45
+ editor's name — `Y::Lexxy.new(doc).to_html` is the byte-parity call for
46
+ Lexxy/Rails apps, and `Y::Lexical` is the base any other Lexical editor
47
+ extends with its own rules. The
48
+ native side renders core structure — paragraphs, headings, quotes, code,
49
+ lists, tables, links, the full text-format model. Lexxy's own node types
50
+ (attachments, galleries, `early_escape_code`, `horizontal_divider`) and its
51
+ decorations of core nodes (the table figure wrapper, header-cell styling,
52
+ the nested-list-item class) are rules applied beneath the app's, on the
53
+ same extension API — the gem's Lexxy support is the API's first consumer.
54
+ Output is unchanged: the fixture tests still hold `to_html` byte-identical
55
+ to a live editor's serialized value, now through the extension path. An
56
+ unknown Lexical container also degrades better: its block children render
57
+ without an invented wrapper instead of being dropped.
58
+ - **ProseMirror rendering gets the same split: `Y::ProseMirror` (core
59
+ ProseMirror) and `Y::Tiptap` (core plus Tiptap's extension nodes as render
60
+ rules).** `Y::Tiptap.new(doc).to_html` is the byte-parity call for Tiptap
61
+ apps. The native side renders prosemirror-schema-basic plus the
62
+ prosemirror-tables family; Tiptap's extension nodes — task lists, mentions,
63
+ the details family — are `Y::Tiptap::NODES` rules. Marks stay native in the
64
+ base class: mark rendering (nesting order, `textStyle` CSS, `code`
65
+ exclusivity) runs through text-run machinery node rules don't reach, so
66
+ `Y::ProseMirror` still renders Tiptap's full mark set and `rules.mark`
67
+ overrides individual marks. Output through `Y::Tiptap` is unchanged, held
68
+ byte-identical to a live editor's `getHTML()` by the fixture tests.
69
+
70
+ ## [0.5.0] - 2026-07-08
71
+
72
+ ### Added
73
+
74
+ - **`Y::Lexical` — render Lexical/Lexxy documents to HTML.**
75
+ `Y::Lexical.new(doc).to_html` turns a Lexxy document into HTML on the server,
76
+ with no Node process or headless editor. The output is identical to the HTML
77
+ a `lexxy-editor` submits to Rails; the tests check it byte-for-byte against a
78
+ document captured from a real editor. It covers the whole Lexxy 0.9.x node
79
+ set: headings, every text format, links, bullet/numbered/check/nested lists,
80
+ quotes, code blocks, horizontal rules, tables with header cells, image galleries, and
81
+ ActionText attachments. Unknown nodes fall back to a plain paragraph, and a
82
+ root that isn't Lexical (a ProseMirror document, say) returns `nil`. This is
83
+ what `tiptap-php` does for ProseMirror JSON, applied to the Yjs structure.
84
+ - `read_xml` now pulls text out of attachments too: a mention's text goes
85
+ inline, and an upload adds its caption, alt text, or filename. Before, both
86
+ were dropped.
8
87
 
9
88
  ## [0.4.0] - 2026-07-07
10
89
 
data/README.md CHANGED
@@ -129,6 +129,27 @@ The rest of the dev setup, plus the demo, is in [CONTRIBUTING.md](CONTRIBUTING.m
129
129
  and the test/load suites.
130
130
  - [CHANGELOG.md](CHANGELOG.md) and [CONTRIBUTING.md](CONTRIBUTING.md).
131
131
 
132
+ ## Editors
133
+
134
+ yrby syncs opaque Yjs updates, so it works with any editor that has a Yjs
135
+ binding. The demo app runs four, and CI drives each one in real Chrome:
136
+ concurrent typing with every keystroke accounted for, remote cursors,
137
+ local-only undo, and byte parity between the server-side renderers and the
138
+ editor's own serializer. Each page is a working integration to copy from:
139
+
140
+ | Editor | Yjs binding | Demo code |
141
+ |---|---|---|
142
+ | [Tiptap](https://tiptap.dev) (v2) | `@tiptap/extension-collaboration` | [`app.js`](examples/actioncable-demo/frontend/src/app.js) |
143
+ | [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) |
144
+ | [Rhino Editor](https://github.com/KonnorRogers/rhino-editor) (Tiptap 3) | `@tiptap/extension-collaboration` + `-caret` | [`rhino.js`](examples/actioncable-demo/frontend/src/rhino.js) |
145
+ | [CodeMirror 6](https://codemirror.net) | `y-codemirror.next` | [`codemirror.js`](examples/actioncable-demo/frontend/src/codemirror.js) |
146
+
147
+ The demo also syncs plain Yjs shapes with no editor at all — a whiteboard
148
+ on a `Y.Map`, a kanban board on a `Y.Array`, a co-filled form — over the
149
+ same channel. The demo README's "Using this in your own app" section has
150
+ the integration recipe, and its `NoteMaterializer` shows how to render a
151
+ document to ActionText server-side with `Y::Tiptap` or `Y::Lexxy`.
152
+
132
153
  ## Usage
133
154
 
134
155
  ### Doc (Low-Level Document Sync)
@@ -157,6 +178,17 @@ doc.handle_sync_message(data) # => [msg_type, sync_type, response]; answers
157
178
  # SyncStep2 (never serves pending structs)
158
179
  ```
159
180
 
181
+ ### Reading document contents
182
+
183
+ Reconstruct a document server-side — search, exports, emails, SSR — with no
184
+ Node process:
185
+
186
+ ```ruby
187
+ doc.read_text("prosemirror") # => plain text of a Y.Text root, or nil
188
+ doc.read_xml("root") # => text of an XML root, one block per line
189
+ doc.read_map("state") # => a Y.Map root as a JSON string; JSON.parse it
190
+ ```
191
+
160
192
  ### Pending structs and gap-free state
161
193
 
162
194
  If a doc applies an update whose causally-prior update is missing (a "gappy"
@@ -177,18 +209,24 @@ guarantees keep serving safe:
177
209
 
178
210
  ### Rendering to HTML
179
211
 
180
- `Y::ProseMirror` renders a ProseMirror or Tiptap document to HTML on the
181
- server, with no Node process or headless editor:
212
+ Schema-pinned renderers turn a collaborative document into HTML on the
213
+ server, with no Node process or headless editor. Each is an editor-specific
214
+ class (byte-for-byte with that editor's own serializer) built on a core base
215
+ any other editor extends with rules: `Y::Tiptap` on `Y::ProseMirror` for
216
+ ProseMirror documents, and `Y::Lexxy` (the
217
+ [Lexxy](https://github.com/basecamp/lexxy) editor) on `Y::Lexical`. Each
218
+ returns `nil` for a root that belongs to the other schema.
219
+
220
+ #### `Y::Tiptap` (and `Y::ProseMirror`, its base)
182
221
 
183
222
  ```ruby
184
- prosemirror = Y::ProseMirror.new(doc)
185
- prosemirror.to_html # the "default" fragment (Tiptap's default root)
186
- prosemirror.to_html("content") # or another XML root
223
+ tiptap = Y::Tiptap.new(doc)
224
+ tiptap.to_html # the "default" fragment (Tiptap's default root)
225
+ tiptap.to_html("content") # or another XML root
187
226
  ```
188
227
 
189
228
  The output matches Tiptap's own `getHTML()`, checked byte-for-byte in the tests
190
- against a document captured from a real editor. So you can render, search, or
191
- email a collaborative document without running a browser. It follows
229
+ against a document captured from a real editor. It follows
192
230
  [`tiptap-php`](https://github.com/ueberdosis/tiptap-php) and reads both name
193
231
  styles editors use — Tiptap's `bulletList`/`bold` and prosemirror-schema-basic's
194
232
  `bullet_list`/`strong`.
@@ -197,8 +235,212 @@ It covers paragraphs, headings, blockquotes, bullet/ordered/task lists, code
197
235
  blocks, links, images, mentions, details, hard breaks, horizontal rules,
198
236
  tables, text styles (color, font family), and every text mark. A table renders
199
237
  as semantic `<table><tbody>`, without the column-width styling Tiptap's editor
200
- view adds. A root that isn't ProseMirror — a Lexical document, say — returns
201
- `nil`.
238
+ view adds.
239
+
240
+ The support is layered like the Lexical side: `Y::ProseMirror` covers core
241
+ ProseMirror natively — prosemirror-schema-basic plus the prosemirror-tables
242
+ family — and Tiptap's extension nodes (task lists, mentions, the details
243
+ family) are `Y::Tiptap`'s rule set (`Y::Tiptap::NODES`), built on the
244
+ extension API below. Marks stay in the base: mark rendering (nesting order,
245
+ `textStyle` CSS, `code` exclusivity) runs through native text-run machinery
246
+ that node rules don't reach, so `Y::ProseMirror` renders Tiptap's mark set
247
+ as-is and `rules.mark` overrides individual marks.
248
+
249
+ #### `Y::Lexxy` (and `Y::Lexical`, its base)
250
+
251
+ ```ruby
252
+ lexxy = Y::Lexxy.new(doc)
253
+ lexxy.to_html # the "root" fragment (Lexical's default root name)
254
+ lexxy.to_html("notepad") # or another XML root
255
+ ```
256
+
257
+ The HTML is identical to what a `lexxy-editor` submits to Rails (its `value`).
258
+ The tests check this byte-for-byte against a document captured from a real
259
+ editor. Stock Lexical has no canonical serializer — every editor configures
260
+ its own — so the editor-specific class carries the editor's name, and
261
+ `Y::Lexical` is the core-Lexical base: paragraphs, headings, quotes, code,
262
+ lists, tables, links, and the full text-format model, for any other Lexical
263
+ editor to extend with rules.
264
+
265
+ It handles the whole Lexxy 0.9.x node set: paragraphs, headings, every text
266
+ format and their combinations, links, the four list types and nesting,
267
+ blockquotes, code blocks, tabs and soft breaks, horizontal rules, tables with
268
+ header cells, image galleries, and ActionText attachments (uploads and
269
+ mentions both emit `<action-text-attachment>` elements that ActionText can
270
+ re-render).
271
+
272
+ Internally that support is layered: `Y::Lexical` covers core Lexical
273
+ structure natively, and everything Lexxy adds — its node types (attachments,
274
+ galleries) and its decorations of core nodes (the table wrapper, header-cell
275
+ styling, nested-list classes) — is `Y::Lexxy`'s rule set
276
+ (`Y::Lexxy::NODES`), built on the extension API below. The gem's own Lexxy
277
+ support is the API's first consumer: an app rule for one of those types
278
+ simply replaces it.
279
+
280
+ In both renderers an unknown node keeps its content — text and nested blocks
281
+ fall back to readable markup rather than disappearing.
282
+
283
+ #### Custom nodes and marks
284
+
285
+ The built-in schemas are pinned to what Tiptap and Lexxy ship, but apps add
286
+ their own node types. Both renderers take rules for them. A rule is checked
287
+ before the built-in schema, so it can add a node type or replace how a
288
+ built-in renders.
289
+
290
+ Rules register in a block — one `rules.node` call per type. A declarative
291
+ rule is markup as data, rendered natively:
292
+
293
+ ```ruby
294
+ tiptap = Y::Tiptap.new(doc) do |rules|
295
+ rules.node "callout", tag: "aside",
296
+ attrs: { "class" => ["callout callout--", :kind] },
297
+ contains: :blocks
298
+ end
299
+ ```
300
+
301
+ `tag` names the element. `attrs` values are templates: a string is a literal,
302
+ a symbol reads that attribute off the node, an array concatenates both kinds;
303
+ an attribute that resolves empty is left out. `text` (same template form)
304
+ emits literal text content. `contains` declares what lives inside the node — `:inline` (formatted text,
305
+ the default), `:blocks` (child block nodes — a container), or `:none` (a
306
+ leaf). `void: true` skips the closing tag.
307
+
308
+ You don't have to guess any of those names or shapes. Editors store types
309
+ and attributes under names you'd never predict (Rhino's strike mark is
310
+ `rhino-strike`; Lexical prefixes its own props `__`), so ask a real
311
+ document instead — make one in your editor using your custom node, then:
312
+
313
+ ```ruby
314
+ Y::Tiptap.new(doc).node_types
315
+ # => { "callout" => { "count" => 2, "attrs" => ["kind"],
316
+ # "children" => ["paragraph"], "text" => false,
317
+ # "handled" => nil },
318
+ # "paragraph" => { ..., "handled" => "builtin" } }
319
+ ```
320
+
321
+ `handled` nil marks the types that still need a rule; `attrs` are the stored
322
+ names your templates and blocks will read; `children` plus `text` is how you
323
+ pick `contains:` (child block types → `:blocks`; text → `:inline`).
324
+
325
+ When markup-as-data isn't enough, give the node a block:
326
+
327
+ ```ruby
328
+ lexical = Y::Lexical.new(doc) do |rules|
329
+ rules.node "video_embed" do |node|
330
+ src = ERB::Util.html_escape(node.attrs["__src"])
331
+ %(<video controls src="#{src}"></video>)
332
+ end
333
+ end
334
+ ```
335
+
336
+ The block gets the node's type, its stored attributes, `node.content` — the
337
+ children, already rendered to HTML — and `node.child_types`, the node's
338
+ element/block children by type, in document order. `child_types` answers the
339
+ structural questions attributes can't: how many images a gallery holds, or
340
+ whether a list item carries a nested list. Whatever the block returns is
341
+ spliced into the output as-is: it's trusted HTML, so escape any values you
342
+ interpolate. To set the content mode for a callback, give the node both —
343
+ `rules.node "embed", contains: :blocks do |node| ... end`.
344
+
345
+ Callbacks never run while the document is locked. The render finishes first
346
+ (inside one read transaction, GVL released), then the blocks run and their
347
+ output is spliced in — so a callback can safely read or even write the same
348
+ doc. With no callback rules, `to_html` skips the splicing entirely.
349
+
350
+ Blocks are the escape hatch for everything the declarative form can't say,
351
+ and they're proven sufficient: `Y::Lexxy` and `Y::Tiptap` are themselves
352
+ built on this API (`lib/y/lexxy.rb`, `lib/y/tiptap.rb`) — simple nodes as
353
+ declarative hashes, everything with logic as plain methods mapped by node
354
+ type (a `Method` responds to `call` like any lambda) — and the fixture tests
355
+ hold their output byte-identical to a live editor's.
356
+
357
+ The ProseMirror side also takes custom marks:
358
+
359
+ ```ruby
360
+ tiptap = Y::Tiptap.new(doc) do |rules|
361
+ rules.mark "comment", tag: "span", attrs: { "data-comment-id" => :id }
362
+ end
363
+ ```
364
+
365
+ Symbol refs resolve against the mark's own attributes. A custom mark wraps
366
+ outside every built-in mark; several on one run nest alphabetically. A rule
367
+ for a built-in mark name (`"bold"`) replaces its built-in tag.
368
+
369
+ ##### Worked examples
370
+
371
+ A video-embed node from an app's Tiptap extension — a type the pinned schema
372
+ has never heard of:
373
+
374
+ ```ruby
375
+ tiptap = Y::Tiptap.new(doc) do |rules|
376
+ rules.node "videoEmbed" do |node|
377
+ src = ERB::Util.html_escape(node.attrs["src"])
378
+ title = ERB::Util.html_escape(node.attrs["title"] || "Video")
379
+ %(<figure class="video"><iframe src="#{src}" title="#{title}" allowfullscreen></iframe></figure>)
380
+ end
381
+ end
382
+ ```
383
+
384
+ Resolving mentions against the database. Blocks run after the document read
385
+ has finished, so hitting ActiveRecord (or the doc itself) inside one is safe:
386
+
387
+ ```ruby
388
+ tiptap = Y::Tiptap.new(doc) do |rules|
389
+ rules.node "mention" do |node|
390
+ user = User.find_by(id: node.attrs["id"])
391
+ next "<span>@unknown</span>" unless user
392
+
393
+ %(<a class="mention" href="/users/#{user.id}">@#{ERB::Util.html_escape(user.handle)}</a>)
394
+ end
395
+ end
396
+ ```
397
+
398
+ Overriding a shipped rule — rendering Lexxy uploads as real image markup
399
+ instead of the `<action-text-attachment>` elements ActionText re-renders:
400
+
401
+ ```ruby
402
+ lexxy = Y::Lexxy.new(doc) do |rules|
403
+ rules.node "action_text_attachment" do |node|
404
+ src = ERB::Util.html_escape(node.attrs["src"])
405
+ alt = ERB::Util.html_escape(node.attrs["altText"].to_s)
406
+ caption = node.attrs["caption"].to_s
407
+ html = %(<img src="#{src}" alt="#{alt}" loading="lazy">)
408
+ html += "<figcaption>#{ERB::Util.html_escape(caption)}</figcaption>" unless caption.empty?
409
+ "<figure>#{html}</figure>"
410
+ end
411
+ end
412
+ ```
413
+
414
+ Markup that depends on structure — `node.child_types` lists the node's
415
+ element/block children in document order, so a layout container can size
416
+ itself by its column count while the columns themselves stay declarative:
417
+
418
+ ```ruby
419
+ tiptap = Y::Tiptap.new(doc) do |rules|
420
+ rules.node "columns", contains: :blocks do |node|
421
+ %(<div class="columns columns--#{node.child_types.length}">#{node.content}</div>)
422
+ end
423
+ rules.node "column", tag: "div", attrs: { "class" => "column" }, contains: :blocks
424
+ end
425
+ ```
426
+
427
+ Content-aware overrides — dropping the empty paragraphs an editor keeps
428
+ around the cursor, since `node.content` arrives already rendered:
429
+
430
+ ```ruby
431
+ lexical = Y::Lexical.new(doc) do |rules|
432
+ rules.node "paragraph" do |node|
433
+ node.content.empty? ? "" : "<p>#{node.content}</p>"
434
+ end
435
+ end
436
+ ```
437
+
438
+ For a larger reference, the gem's own editor schemas ship this way — see
439
+ `Y::Lexxy::NODES` in `lib/y/lexxy.rb` (declarative hashes for the simple
440
+ nodes, a plain method per node that needs logic — galleries, list items,
441
+ header cells, both attachment types — mapped with `method(:name)`) and
442
+ `Y::Tiptap::NODES` in `lib/y/tiptap.rb` (task lists, mentions, the details
443
+ family).
202
444
 
203
445
  ### Protocol codec (module functions)
204
446