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 +4 -4
- data/CHANGELOG.md +74 -1
- data/README.md +286 -30
- data/lib/generators/yrby/install/install_generator.rb +44 -0
- data/lib/generators/yrby/install/templates/document_channel.rb +36 -0
- data/lib/generators/yrby/tables/tables_generator.rb +41 -0
- data/lib/generators/yrby/tables/templates/create_y_tables.rb +24 -0
- data/lib/y/3.4/yrby.so +0 -0
- data/lib/y/4.0/yrby.so +0 -0
- data/lib/y/lexxy.rb +121 -0
- data/lib/y/rendering.rb +282 -0
- data/lib/y/tiptap.rb +63 -0
- data/lib/y/version.rb +1 -1
- data/lib/y.rb +4 -0
- metadata +10 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b673ec3d72751c36ecb0b8edc586c15f393e1136d5a1589f52bc3199bc872030
|
|
4
|
+
data.tar.gz: 1ff196e6cedb72da559c3c61a20cd54254c43ef08203a4d476819caf46af5384
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
## [
|
|
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
|
+

|
|
9
11
|
|
|
10
12
|
```ruby
|
|
11
13
|
class DocumentChannel < ApplicationCable::Channel
|
|
12
|
-
include Y::ActionCable
|
|
14
|
+
include Y::ActionCable
|
|
13
15
|
|
|
14
|
-
on_load { |key|
|
|
15
|
-
on_change { |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
|
|
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
|
|
105
|
-
|
|
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
|
-
|
|
192
|
-
server, with no Node process or headless editor
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|
510
|
+
include Y::ActionCable
|
|
262
511
|
|
|
263
|
-
on_load { |key|
|
|
264
|
-
on_change { |key, update|
|
|
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
|
|
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
|
data/lib/y/rendering.rb
ADDED
|
@@ -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("&", "&").gsub("<", "<").gsub(">", ">")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def escape_attr(value)
|
|
146
|
+
escape_text(value).gsub('"', """)
|
|
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
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.
|
|
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-
|
|
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-
|
|
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
|