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 +4 -4
- data/CHANGELOG.md +80 -1
- data/README.md +251 -9
- data/ext/yrby/src/lexical_html.rs +1316 -0
- data/ext/yrby/src/lib.rs +224 -38
- data/ext/yrby/src/prosemirror_html.rs +736 -267
- data/ext/yrby/src/read.rs +71 -4
- data/ext/yrby/src/render_rules.rs +529 -0
- data/lib/y/lexxy.rb +100 -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 +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 93704595e7dd617d4eccd0645a3dbc99bc798e11ec2591c86f47f0da08381d2c
|
|
4
|
+
data.tar.gz: 3f4fb67f5fc0368fe31a74cdae2104b9cea8bc44e349b398efc20f84fca7061a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
## [
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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.
|
|
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.
|
|
201
|
-
|
|
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
|
|