yrby 0.4.0 → 0.5.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: 3c53c83185ba046ddbcb6b232953af9aa2f52f883120aff8bb2fed8867f247df
4
+ data.tar.gz: 6a09a5541cca754d6ff838feb82838ce881d8fea144fed692031f7c957c9850b
5
5
  SHA512:
6
- metadata.gz: 3f0a9aeea9e3efdd443f7da526eaf692226015a5a6be037527e1600b74cb2529e123fbcf0f03d91282ad4b60e96cfd5d81d51d149ec1f42b06f27664f4cf3ffb
7
- data.tar.gz: 5532beea7978401cf18a2970eac4eefdc037c075bc6de2fb56b92978b3dc5e8b3c5759b9b6d9aabfb879ba92517e3792810ab14fabf9ef4461da30bfa2bd4c39
6
+ metadata.gz: d44fb5bb2a723b6a0dcf7ac6fd3bc248bd58f1401fe2e62009a214148f4a7b1ef1586d0d25585b39d291667eff04d6c66e911c7cdb5dec4968a0f3192b70a85c
7
+ data.tar.gz: 74da78ebc395e44cbdc9f32dcc51a239b7ea312de24f5ac64ec47db41bc784b47a53425afc83b948d36c9bf1b11922a3f9f3aa6615dcbac72a77759abe046ccc
data/CHANGELOG.md CHANGED
@@ -6,6 +6,24 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.0] - 2026-07-08
10
+
11
+ ### Added
12
+
13
+ - **`Y::Lexical` — render Lexical/Lexxy documents to HTML.**
14
+ `Y::Lexical.new(doc).to_html` turns a Lexxy document into HTML on the server,
15
+ with no Node process or headless editor. The output is identical to the HTML
16
+ a `lexxy-editor` submits to Rails; the tests check it byte-for-byte against a
17
+ document captured from a real editor. It covers the whole Lexxy 0.9.x node
18
+ set: headings, every text format, links, bullet/numbered/check/nested lists,
19
+ quotes, code blocks, horizontal rules, tables with header cells, image galleries, and
20
+ ActionText attachments. Unknown nodes fall back to a plain paragraph, and a
21
+ root that isn't Lexical (a ProseMirror document, say) returns `nil`. This is
22
+ what `tiptap-php` does for ProseMirror JSON, applied to the Yjs structure.
23
+ - `read_xml` now pulls text out of attachments too: a mention's text goes
24
+ inline, and an upload adds its caption, alt text, or filename. Before, both
25
+ were dropped.
26
+
9
27
  ## [0.4.0] - 2026-07-07
10
28
 
11
29
  ### Added
data/README.md CHANGED
@@ -157,6 +157,17 @@ doc.handle_sync_message(data) # => [msg_type, sync_type, response]; answers
157
157
  # SyncStep2 (never serves pending structs)
158
158
  ```
159
159
 
160
+ ### Reading document contents
161
+
162
+ Reconstruct a document server-side — search, exports, emails, SSR — with no
163
+ Node process:
164
+
165
+ ```ruby
166
+ doc.read_text("prosemirror") # => plain text of a Y.Text root, or nil
167
+ doc.read_xml("root") # => text of an XML root, one block per line
168
+ doc.read_map("state") # => a Y.Map root as a JSON string; JSON.parse it
169
+ ```
170
+
160
171
  ### Pending structs and gap-free state
161
172
 
162
173
  If a doc applies an update whose causally-prior update is missing (a "gappy"
@@ -177,8 +188,13 @@ guarantees keep serving safe:
177
188
 
178
189
  ### Rendering to HTML
179
190
 
180
- `Y::ProseMirror` renders a ProseMirror or Tiptap document to HTML on the
181
- server, with no Node process or headless editor:
191
+ Two schema-pinned renderers turn a collaborative document into HTML on the
192
+ server, with no Node process or headless editor: `Y::ProseMirror` for
193
+ ProseMirror/Tiptap documents and `Y::Lexical` for
194
+ [Lexxy](https://github.com/basecamp/lexxy) (Lexical) documents. Each returns
195
+ `nil` for a root that belongs to the other schema.
196
+
197
+ #### `Y::ProseMirror`
182
198
 
183
199
  ```ruby
184
200
  prosemirror = Y::ProseMirror.new(doc)
@@ -187,8 +203,7 @@ prosemirror.to_html("content") # or another XML root
187
203
  ```
188
204
 
189
205
  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
206
+ against a document captured from a real editor. It follows
192
207
  [`tiptap-php`](https://github.com/ueberdosis/tiptap-php) and reads both name
193
208
  styles editors use — Tiptap's `bulletList`/`bold` and prosemirror-schema-basic's
194
209
  `bullet_list`/`strong`.
@@ -197,8 +212,29 @@ It covers paragraphs, headings, blockquotes, bullet/ordered/task lists, code
197
212
  blocks, links, images, mentions, details, hard breaks, horizontal rules,
198
213
  tables, text styles (color, font family), and every text mark. A table renders
199
214
  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`.
215
+ view adds.
216
+
217
+ #### `Y::Lexical`
218
+
219
+ ```ruby
220
+ lexical = Y::Lexical.new(doc)
221
+ lexical.to_html # the "root" fragment (Lexical's default root name)
222
+ lexical.to_html("notepad") # or another XML root
223
+ ```
224
+
225
+ The HTML is identical to what a `lexxy-editor` submits to Rails (its `value`).
226
+ The tests check this byte-for-byte against a document captured from a real
227
+ editor.
228
+
229
+ It handles the whole Lexxy 0.9.x node set: paragraphs, headings, every text
230
+ format and their combinations, links, the four list types and nesting,
231
+ blockquotes, code blocks, tabs and soft breaks, horizontal rules, tables with
232
+ header cells, image galleries, and ActionText attachments (uploads and
233
+ mentions both emit `<action-text-attachment>` elements that ActionText can
234
+ re-render).
235
+
236
+ In both renderers an unknown node keeps its content — text falls back to a
237
+ plain paragraph rather than disappearing.
202
238
 
203
239
  ### Protocol codec (module functions)
204
240
 
@@ -0,0 +1,993 @@
1
+ //! Native HTML rendering of Lexical/Lexxy documents from the yrs collab
2
+ //! structure — no Node process, no headless editor.
3
+ //!
4
+ //! Pinned to Lexxy (37signals' Rails editor, 0.9.x). The output matches what
5
+ //! `lexxy-editor` produces as its `value`: Lexical's `$generateHtmlFromNodes`,
6
+ //! run through Lexxy's text export and DOMPurify. The tests check it byte for
7
+ //! byte against a captured fixture. That's the HTML a Lexxy form submits to
8
+ //! Rails, so you can store it as ActionText content straight from the CRDT.
9
+ //!
10
+ //! Prior art: `ueberdosis/tiptap-php` renders ProseMirror JSON to HTML in pure
11
+ //! PHP — a schema-pinned renderer outside the JS runtime. This works from the
12
+ //! collab (Yjs) structure rather than JSON.
13
+ //!
14
+ //! Storage model (verified against bytes captured from a live Lexxy editor):
15
+ //! - Blocks are `Y.XmlText` with a `__type` attribute (`paragraph`, `heading`
16
+ //! (+`__tag`), `quote`, `list`/`listitem`, `early_escape_code` (+`__language`),
17
+ //! `wrapped_table_node`/`tablerow`/`tablecell`, `link`/`autolink` (inline)).
18
+ //! - Text runs are preceded by an embedded `Y.Map` carrying per-run metadata
19
+ //! (`__type: "text" | "code-highlight" | "tab"`, `__format` bitmask).
20
+ //! - `linebreak` is a bare metadata map; `tab` is a map followed by a "\t" run.
21
+ //! - Decorator nodes are `Y.XmlElement`s: `horizontal_divider`,
22
+ //! `action_text_attachment` (uploads), `custom_action_text_attachment`
23
+ //! (mentions/embeds), with their fields as plain attributes.
24
+ //!
25
+ //! Text-format rendering replicates Lexxy's `exportTextNodeDOM` exactly:
26
+ //! inner tag `strong` (bold) / `em` (italic, when not bold); outer tag `code` /
27
+ //! `mark` / `sub` / `sup`; an `<i>` wrap only when bold+italic combine (the
28
+ //! `em` slot is taken); `<s>` / `<u>` wraps always; `<span>`s are unwrapped, so
29
+ //! unformatted text is bare. A run's `__style` (Lexxy's highlight colors)
30
+ //! survives on the createDOM tag, filtered to color/background-color the way
31
+ //! Lexxy's sanitize allows; on a plain or s/u-only run it dies with the
32
+ //! unwrapped span. Case-transform format bits are never rendered (their
33
+ //! text-transform style is outside the sanitize whitelist).
34
+
35
+ use yrs::types::text::YChange;
36
+ use yrs::{
37
+ Any, GetString, Map, Out, ReadTxn, Text, Xml, XmlElementRef, XmlFragment, XmlFragmentRef,
38
+ XmlOut, XmlTextRef,
39
+ };
40
+
41
+ // Lexical text format bitmask (lexical 0.44).
42
+ const FMT_BOLD: u32 = 1;
43
+ const FMT_ITALIC: u32 = 1 << 1;
44
+ const FMT_STRIKETHROUGH: u32 = 1 << 2;
45
+ const FMT_UNDERLINE: u32 = 1 << 3;
46
+ const FMT_CODE: u32 = 1 << 4;
47
+ const FMT_SUBSCRIPT: u32 = 1 << 5;
48
+ const FMT_SUPERSCRIPT: u32 = 1 << 6;
49
+ const FMT_HIGHLIGHT: u32 = 1 << 7;
50
+
51
+ // Nesting caps. The block tree is walked on an explicit heap stack (no native
52
+ // recursion), so these don't prevent a stack overflow — they just bound how
53
+ // deep the renderer descends. Real docs nest a handful of levels deep (the
54
+ // torture fixture peaks at ~8); past 1024 a subtree is dropped, but its
55
+ // enclosing tags still close. Inline links are still walked recursively (their
56
+ // body is inline content, never blocks), so they carry the same cap.
57
+ const MAX_BLOCK_DEPTH: usize = 1024;
58
+ const MAX_INLINE_DEPTH: usize = 1024;
59
+
60
+ /// A unit of pending block work on the explicit traversal stack. `Open` renders
61
+ /// a block node (pushing its own children as more work); `Close` emits a
62
+ /// literal end tag once a container's children have all been processed. Every
63
+ /// container's closing tag is a fixed string, so `Close` can borrow `'static`.
64
+ enum Work {
65
+ Open(XmlTextRef, usize),
66
+ Close(&'static str),
67
+ }
68
+
69
+ /// Render a Lexical/Lexxy-shaped XML root to HTML, or `None` when the root
70
+ /// isn't Lexical-shaped. Lexical marks every node with a `__type` attribute; a
71
+ /// root whose children carry none — a ProseMirror document, say, whose blocks
72
+ /// are plain `<paragraph>` elements — is a foreign schema, and render returns
73
+ /// `None` for it rather than a lossy guess.
74
+ ///
75
+ /// A `__type` the renderer doesn't recognize is handled differently: the node
76
+ /// renders its text in a `<p>`, so a Lexxy release that adds one stays readable.
77
+ pub fn render<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> Option<String> {
78
+ if !is_lexical_shaped(txn, fragment) {
79
+ return None;
80
+ }
81
+ let mut out = String::new();
82
+ for node in fragment.children(txn) {
83
+ match node {
84
+ XmlOut::Text(t) => render_block_tree(txn, &t, &mut out),
85
+ XmlOut::Element(e) => render_decorator(txn, &e, &mut out),
86
+ XmlOut::Fragment(f) => out.push_str(&escape_text(&f.get_string(txn))),
87
+ }
88
+ }
89
+ Some(out)
90
+ }
91
+
92
+ /// A root is Lexical-shaped when it is empty or at least one child carries the
93
+ /// `__type` attribute Lexical stamps on every node.
94
+ fn is_lexical_shaped<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> bool {
95
+ let mut any_child = false;
96
+ for node in fragment.children(txn) {
97
+ any_child = true;
98
+ let typed = match &node {
99
+ XmlOut::Text(t) => t.get_attribute(txn, "__type").is_some(),
100
+ XmlOut::Element(e) => e.get_attribute(txn, "__type").is_some(),
101
+ XmlOut::Fragment(_) => false,
102
+ };
103
+ if typed {
104
+ return true;
105
+ }
106
+ }
107
+ !any_child // an empty document renders to an empty string
108
+ }
109
+
110
+ /// The `__type` attribute of a block/inline `Y.XmlText`.
111
+ fn node_type<T: ReadTxn>(txn: &T, t: &XmlTextRef) -> String {
112
+ match t.get_attribute(txn, "__type") {
113
+ Some(Out::Any(Any::String(s))) => s.to_string(),
114
+ _ => String::new(),
115
+ }
116
+ }
117
+
118
+ /// A string attribute of a block (e.g. `__tag`, `__language`, `__url`).
119
+ fn str_attr<T: ReadTxn>(txn: &T, t: &XmlTextRef, name: &str) -> Option<String> {
120
+ match t.get_attribute(txn, name) {
121
+ Some(Out::Any(Any::String(s))) => Some(s.to_string()),
122
+ _ => None,
123
+ }
124
+ }
125
+
126
+ /// Walk a top-level block and everything under it on an explicit heap stack,
127
+ /// appending HTML to `out`. Container blocks (lists, tables, cells, list
128
+ /// items) push their children back as more work plus a `Close` for their end
129
+ /// tag; leaf blocks (paragraphs, headings, quotes, code) render in full on the
130
+ /// spot. The stack lives on the heap, so nesting depth can't overflow the
131
+ /// native call stack.
132
+ fn render_block_tree<T: ReadTxn>(txn: &T, root: &XmlTextRef, out: &mut String) {
133
+ let mut stack: Vec<Work> = vec![Work::Open(root.clone(), 0)];
134
+ while let Some(work) = stack.pop() {
135
+ match work {
136
+ Work::Close(tag) => out.push_str(tag),
137
+ Work::Open(node, depth) => open_block(txn, &node, depth, out, &mut stack),
138
+ }
139
+ }
140
+ }
141
+
142
+ /// Render one block. A container emits its opening tag now and defers its
143
+ /// children (and matching `Close`) to the stack; a leaf renders completely.
144
+ fn open_block<T: ReadTxn>(
145
+ txn: &T,
146
+ t: &XmlTextRef,
147
+ depth: usize,
148
+ out: &mut String,
149
+ stack: &mut Vec<Work>,
150
+ ) {
151
+ let ty = node_type(txn, t);
152
+ match ty.as_str() {
153
+ "paragraph" | "provisonal_paragraph" => {
154
+ // (sic: "provisonal" is Lexxy's spelling.) A provisional paragraph
155
+ // is a cursor-placement placeholder; empty ones export to nothing.
156
+ let inline = render_inline(txn, t, 0);
157
+ if inline.is_empty() {
158
+ if ty == "paragraph" {
159
+ out.push_str("<p><br></p>");
160
+ }
161
+ } else {
162
+ out.push_str("<p>");
163
+ out.push_str(&inline);
164
+ out.push_str("</p>");
165
+ }
166
+ }
167
+ "heading" => {
168
+ let tag = match str_attr(txn, t, "__tag").as_deref() {
169
+ Some(tag @ ("h1" | "h2" | "h3" | "h4" | "h5" | "h6")) => tag.to_string(),
170
+ _ => "h1".to_string(),
171
+ };
172
+ out.push('<');
173
+ out.push_str(&tag);
174
+ out.push('>');
175
+ out.push_str(&render_inline(txn, t, 0));
176
+ out.push_str("</");
177
+ out.push_str(&tag);
178
+ out.push('>');
179
+ }
180
+ "quote" => {
181
+ out.push_str("<blockquote>");
182
+ out.push_str(&render_inline(txn, t, 0));
183
+ out.push_str("</blockquote>");
184
+ }
185
+ "code" | "early_escape_code" => {
186
+ // Lexxy replaces Lexical's CodeNode with its own type; both shapes
187
+ // are accepted. Code highlighting is derived state: token runs
188
+ // flatten to plain text; linebreaks are <br>, tabs a wrapped \t.
189
+ out.push_str("<pre");
190
+ if let Some(lang) = str_attr(txn, t, "__language").filter(|l| !l.is_empty()) {
191
+ out.push_str(" data-language=\"");
192
+ out.push_str(&escape_attr(&lang));
193
+ out.push('"');
194
+ }
195
+ out.push('>');
196
+ out.push_str(&render_inline(txn, t, 0));
197
+ out.push_str("</pre>");
198
+ }
199
+ "list" => {
200
+ let tag = match str_attr(txn, t, "__tag").as_deref() {
201
+ Some("ol") => "ol",
202
+ _ => "ul",
203
+ };
204
+ out.push('<');
205
+ out.push_str(tag);
206
+ out.push('>');
207
+ // Direct inline content is crafted-only (Lexxy puts none here);
208
+ // keep it rather than drop it. Safe from double-render: this skips
209
+ // block children, and push_block_children skips inline content.
210
+ out.push_str(&render_inline(txn, t, 0));
211
+ let close = if tag == "ol" { "</ol>" } else { "</ul>" };
212
+ push_block_children(txn, t, depth, close, false, stack);
213
+ }
214
+ "listitem" => open_listitem(txn, t, depth, out, stack),
215
+ "image_gallery" => {
216
+ // Adjacent previewable images grouped by Lexxy's gallery node.
217
+ // The class carries the image count (ActionText's convention).
218
+ let count = t
219
+ .diff(txn, YChange::identity)
220
+ .into_iter()
221
+ .filter(|d| matches!(d.insert, Out::YXmlElement(_)))
222
+ .count();
223
+ out.push_str("<div class=\"attachment-gallery attachment-gallery--");
224
+ out.push_str(&count.to_string());
225
+ out.push_str("\">");
226
+ out.push_str(&render_inline(txn, t, 0));
227
+ out.push_str("</div>");
228
+ }
229
+ "table" | "wrapped_table_node" => {
230
+ out.push_str("<figure class=\"lexxy-content__table-wrapper\"><table><tbody>");
231
+ out.push_str(&render_inline(txn, t, 0));
232
+ push_block_children(txn, t, depth, "</tbody></table></figure>", false, stack);
233
+ }
234
+ "tablerow" => {
235
+ out.push_str("<tr>");
236
+ out.push_str(&render_inline(txn, t, 0));
237
+ push_block_children(txn, t, depth, "</tr>", false, stack);
238
+ }
239
+ "tablecell" => {
240
+ let header = matches!(
241
+ t.get_attribute(txn, "__headerState"),
242
+ Some(Out::Any(Any::Number(n))) if n > 0.0
243
+ ) || matches!(
244
+ t.get_attribute(txn, "__headerState"),
245
+ Some(Out::Any(Any::BigInt(n))) if n > 0
246
+ );
247
+ let close = if header {
248
+ // Class + background match Lexxy's own header-cell export.
249
+ out.push_str(
250
+ "<th class=\"lexxy-content__table-cell--header\" \
251
+ style=\"background-color: rgb(242, 243, 245);\">",
252
+ );
253
+ "</th>"
254
+ } else {
255
+ out.push_str("<td>");
256
+ "</td>"
257
+ };
258
+ out.push_str(&render_inline(txn, t, 0));
259
+ push_block_children(txn, t, depth, close, false, stack);
260
+ }
261
+ // A block type this renderer doesn't know: degrade to a readable
262
+ // paragraph instead of dropping content.
263
+ _ => {
264
+ let inline = render_inline(txn, t, 0);
265
+ if !inline.is_empty() {
266
+ out.push_str("<p>");
267
+ out.push_str(&inline);
268
+ out.push_str("</p>");
269
+ }
270
+ }
271
+ }
272
+ }
273
+
274
+ /// Defer a container's block children onto the stack, with its closing tag
275
+ /// below them, so the children render in order and the tag closes after. Past
276
+ /// `MAX_BLOCK_DEPTH` the children are dropped (the container still closes, so
277
+ /// the output stays well formed). `only_lists` keeps just nested-list children,
278
+ /// for list items whose inline content the caller has already emitted.
279
+ fn push_block_children<T: ReadTxn>(
280
+ txn: &T,
281
+ t: &XmlTextRef,
282
+ depth: usize,
283
+ close: &'static str,
284
+ only_lists: bool,
285
+ stack: &mut Vec<Work>,
286
+ ) {
287
+ stack.push(Work::Close(close));
288
+ if depth >= MAX_BLOCK_DEPTH {
289
+ return;
290
+ }
291
+ // Reversed: the stack is LIFO, so the last pushed child is rendered first.
292
+ for child in block_children(txn, t).into_iter().rev() {
293
+ if only_lists && node_type(txn, &child) != "list" {
294
+ continue;
295
+ }
296
+ stack.push(Work::Open(child, depth + 1));
297
+ }
298
+ }
299
+
300
+ /// `<li>`: attribute order follows Lexxy's export — checked items put
301
+ /// `aria-checked` before `value`; items holding a nested list append the
302
+ /// `lexxy-nested-listitem` class after `value`. Inline content renders now;
303
+ /// the nested list (if any) is deferred to the stack.
304
+ fn open_listitem<T: ReadTxn>(
305
+ txn: &T,
306
+ t: &XmlTextRef,
307
+ depth: usize,
308
+ out: &mut String,
309
+ stack: &mut Vec<Work>,
310
+ ) {
311
+ let value = match t.get_attribute(txn, "__value") {
312
+ Some(Out::Any(Any::Number(n))) => n as i64,
313
+ Some(Out::Any(Any::BigInt(n))) => n,
314
+ _ => 1,
315
+ };
316
+ let checked = match t.get_attribute(txn, "__checked") {
317
+ Some(Out::Any(Any::Bool(b))) => Some(b),
318
+ _ => None,
319
+ };
320
+ let has_nested_list = block_children(txn, t)
321
+ .iter()
322
+ .any(|c| node_type(txn, c) == "list");
323
+
324
+ out.push_str("<li");
325
+ if let Some(c) = checked {
326
+ out.push_str(" aria-checked=\"");
327
+ out.push_str(if c { "true" } else { "false" });
328
+ out.push('"');
329
+ }
330
+ out.push_str(" value=\"");
331
+ out.push_str(&value.to_string());
332
+ out.push('"');
333
+ if has_nested_list {
334
+ out.push_str(" class=\"lexxy-nested-listitem\"");
335
+ }
336
+ out.push('>');
337
+ // Inline content first, then any nested list blocks (Lexical stores the
338
+ // nested list as a child of the item).
339
+ out.push_str(&render_inline(txn, t, 0));
340
+ push_block_children(txn, t, depth, "</li>", true, stack);
341
+ }
342
+
343
+ /// The `Y.XmlText` children of a block that are themselves blocks (list items,
344
+ /// nested lists, table rows/cells, cell paragraphs) — inline types excluded.
345
+ fn block_children<T: ReadTxn>(txn: &T, t: &XmlTextRef) -> Vec<XmlTextRef> {
346
+ let mut out = Vec::new();
347
+ for d in t.diff(txn, YChange::identity) {
348
+ if let Out::YXmlText(child) = d.insert {
349
+ if !is_inline_type(&node_type(txn, &child)) {
350
+ out.push(child);
351
+ }
352
+ }
353
+ }
354
+ out
355
+ }
356
+
357
+ fn is_inline_type(ty: &str) -> bool {
358
+ matches!(ty, "link" | "autolink")
359
+ }
360
+
361
+ /// Render a block's inline content: formatted text runs, linebreaks, tabs,
362
+ /// links, and inline decorators. Nested block children are NOT rendered here
363
+ /// (the block renderers handle them), so a list item's text doesn't duplicate
364
+ /// its nested list. `depth` counts inline-link nesting only (a link's body is
365
+ /// itself inline content); past `MAX_INLINE_DEPTH` a link renders as flat text,
366
+ /// capping the recursion.
367
+ fn render_inline<T: ReadTxn>(txn: &T, t: &XmlTextRef, depth: usize) -> String {
368
+ let mut out = String::new();
369
+ // Format carries from the metadata Map that precedes each run. Lexxy always
370
+ // emits one Map immediately before its run, so this is exact; a run with no
371
+ // preceding Map would inherit the previous run's format (wrong, but only
372
+ // reachable from a doc Lexxy didn't produce).
373
+ let mut format: u32 = 0;
374
+ let mut style = String::new();
375
+ let mut is_tab = false;
376
+ for d in t.diff(txn, YChange::identity) {
377
+ match d.insert {
378
+ Out::YMap(m) => {
379
+ let ty = match m.get(txn, "__type") {
380
+ Some(Out::Any(Any::String(s))) => s.to_string(),
381
+ _ => String::new(),
382
+ };
383
+ match ty.as_str() {
384
+ "linebreak" => out.push_str("<br>"),
385
+ "tab" => {
386
+ is_tab = true;
387
+ format = 0;
388
+ style.clear();
389
+ }
390
+ // "text", "code-highlight", and anything metadata-shaped:
391
+ // read the format bits and style for the run that follows.
392
+ _ => {
393
+ is_tab = false;
394
+ format = match m.get(txn, "__format") {
395
+ Some(Out::Any(Any::Number(n))) => n as u32,
396
+ Some(Out::Any(Any::BigInt(n))) => n as u32,
397
+ _ => 0,
398
+ };
399
+ style = match m.get(txn, "__style") {
400
+ Some(Out::Any(Any::String(s))) => s.to_string(),
401
+ _ => String::new(),
402
+ };
403
+ }
404
+ }
405
+ }
406
+ Out::Any(Any::String(s)) => {
407
+ if is_tab {
408
+ // Lexxy exports a tab as a literal \t in a span.
409
+ out.push_str("<span>\t</span>");
410
+ is_tab = false;
411
+ } else {
412
+ out.push_str(&render_run(&s, format, &style));
413
+ }
414
+ }
415
+ Out::YXmlText(child) => {
416
+ let ty = node_type(txn, &child);
417
+ if is_inline_type(&ty) {
418
+ render_link(txn, &child, depth, &mut out);
419
+ }
420
+ // Nested blocks are rendered by their parent block's renderer.
421
+ }
422
+ Out::YXmlElement(e) => render_inline_decorator(txn, &e, &mut out),
423
+ _ => {}
424
+ }
425
+ }
426
+ out
427
+ }
428
+
429
+ /// One text run with Lexical's format bitmask, as Lexxy's exporter wraps it.
430
+ fn render_run(text: &str, format: u32, style: &str) -> String {
431
+ let mut html = escape_text(text);
432
+ // A run's __style survives export only on a real createDOM tag (colors
433
+ // from Lexxy's highlight dropdown). The style attribute rides the OUTER
434
+ // tag when one exists, else the inner tag; a plain or s/u-only run's span
435
+ // is unwrapped, and the style dies with it — all captured behavior.
436
+ let css = lexxy_style(style);
437
+ let outer_tag = if format & FMT_CODE != 0 {
438
+ Some("code")
439
+ } else if format & FMT_HIGHLIGHT != 0 {
440
+ Some("mark")
441
+ } else if format & FMT_SUBSCRIPT != 0 {
442
+ Some("sub")
443
+ } else if format & FMT_SUPERSCRIPT != 0 {
444
+ Some("sup")
445
+ } else {
446
+ None
447
+ };
448
+
449
+ // Inner semantic tag (createDOM): strong for bold, em for italic-only.
450
+ // A plain run's span is unwrapped, leaving bare text.
451
+ let inner_style = if outer_tag.is_none() {
452
+ css.as_deref()
453
+ } else {
454
+ None
455
+ };
456
+ if format & FMT_BOLD != 0 {
457
+ html = format_wrap(html, "strong", inner_style);
458
+ } else if format & FMT_ITALIC != 0 {
459
+ html = format_wrap(html, "em", inner_style);
460
+ }
461
+ // Outer semantic tag (createDOM): first match wins.
462
+ if let Some(tag) = outer_tag {
463
+ html = format_wrap(html, tag, css.as_deref());
464
+ }
465
+ // Lexxy's wrap pass: <i> only when italic couldn't claim the inner tag
466
+ // (bold took it); <b> never fires (bold always claims strong); <s>/<u>
467
+ // always wrap.
468
+ if format & FMT_ITALIC != 0 && format & FMT_BOLD != 0 {
469
+ html = format_wrap(html, "i", None);
470
+ }
471
+ if format & FMT_STRIKETHROUGH != 0 {
472
+ html = format_wrap(html, "s", None);
473
+ }
474
+ if format & FMT_UNDERLINE != 0 {
475
+ html = format_wrap(html, "u", None);
476
+ }
477
+ html
478
+ }
479
+
480
+ fn format_wrap(inner: String, tag: &str, style: Option<&str>) -> String {
481
+ match style {
482
+ Some(css) => format!("<{tag} style=\"{}\">{inner}</{tag}>", escape_attr(css)),
483
+ None => format!("<{tag}>{inner}</{tag}>"),
484
+ }
485
+ }
486
+
487
+ /// Filter a run's `__style` to what Lexxy's sanitize lets through — `color`
488
+ /// and `background-color` — keeping source order, serialized the way the
489
+ /// sanitize hook rebuilds it: `prop: value;` with no separator between
490
+ /// properties. Everything else (text-transform, white-space, smuggled
491
+ /// properties) is stripped, matching the captured value output.
492
+ fn lexxy_style(style: &str) -> Option<String> {
493
+ let mut css = String::new();
494
+ for decl in style.split(';') {
495
+ let Some((prop, value)) = decl.split_once(':') else {
496
+ continue;
497
+ };
498
+ let (prop, value) = (prop.trim(), value.trim());
499
+ if (prop == "color" || prop == "background-color") && !value.is_empty() {
500
+ css.push_str(prop);
501
+ css.push_str(": ");
502
+ css.push_str(value);
503
+ css.push(';');
504
+ }
505
+ }
506
+ if css.is_empty() {
507
+ None
508
+ } else {
509
+ Some(css)
510
+ }
511
+ }
512
+
513
+ /// `link` / `autolink`: Lexxy's sanitize keeps only `href` and `title`
514
+ /// (`target`/`rel` are stored in the doc but stripped from exported HTML).
515
+ fn render_link<T: ReadTxn>(txn: &T, t: &XmlTextRef, depth: usize, out: &mut String) {
516
+ out.push_str("<a");
517
+ if let Some(url) = str_attr(txn, t, "__url") {
518
+ out.push_str(" href=\"");
519
+ out.push_str(&escape_attr(&url));
520
+ out.push('"');
521
+ }
522
+ if let Some(title) = str_attr(txn, t, "__title").filter(|s| !s.is_empty()) {
523
+ out.push_str(" title=\"");
524
+ out.push_str(&escape_attr(&title));
525
+ out.push('"');
526
+ }
527
+ out.push('>');
528
+ if depth < MAX_INLINE_DEPTH {
529
+ out.push_str(&render_inline(txn, t, depth + 1));
530
+ } else {
531
+ // A link chain nested past the cap (only crafted input reaches here):
532
+ // keep its text, drop any further link structure.
533
+ out.push_str(&escape_text(&t.get_string(txn)));
534
+ }
535
+ out.push_str("</a>");
536
+ }
537
+
538
+ /// Root-level decorators: horizontal rule and upload attachments.
539
+ fn render_decorator<T: ReadTxn>(txn: &T, e: &XmlElementRef, out: &mut String) {
540
+ match elem_type(txn, e).as_str() {
541
+ "horizontal_divider" => out.push_str("<hr>"),
542
+ "action_text_attachment" => render_upload_attachment(txn, e, out),
543
+ "custom_action_text_attachment" => render_custom_attachment(txn, e, out),
544
+ _ => {} // unknown decorator: nothing extractable
545
+ }
546
+ }
547
+
548
+ /// Inline decorators (inside a paragraph): mention/embed attachments.
549
+ fn render_inline_decorator<T: ReadTxn>(txn: &T, e: &XmlElementRef, out: &mut String) {
550
+ match elem_type(txn, e).as_str() {
551
+ "custom_action_text_attachment" => render_custom_attachment(txn, e, out),
552
+ "action_text_attachment" => render_upload_attachment(txn, e, out),
553
+ "horizontal_divider" => out.push_str("<hr>"),
554
+ _ => {}
555
+ }
556
+ }
557
+
558
+ fn elem_type<T: ReadTxn>(txn: &T, e: &XmlElementRef) -> String {
559
+ match e.get_attribute(txn, "__type") {
560
+ Some(Out::Any(Any::String(s))) => s.to_string(),
561
+ _ => String::new(),
562
+ }
563
+ }
564
+
565
+ fn elem_str<T: ReadTxn>(txn: &T, e: &XmlElementRef, name: &str) -> Option<String> {
566
+ match e.get_attribute(txn, name) {
567
+ Some(Out::Any(Any::String(s))) => Some(s.to_string()),
568
+ _ => None,
569
+ }
570
+ }
571
+
572
+ /// A numeric attribute rendered the way JavaScript would print it (integers
573
+ /// without a trailing `.0`). Assumes normal-magnitude, finite values — the
574
+ /// only numbers here are file sizes and pixel dimensions. A value past i64 or
575
+ /// a non-finite one would format differently from `String(n)` (e.g. `1e21`,
576
+ /// `Infinity`), but no real attachment carries one.
577
+ fn elem_num<T: ReadTxn>(txn: &T, e: &XmlElementRef, name: &str) -> Option<String> {
578
+ match e.get_attribute(txn, name) {
579
+ Some(Out::Any(Any::Number(n))) => {
580
+ if n.fract() == 0.0 {
581
+ Some(format!("{}", n as i64))
582
+ } else {
583
+ Some(format!("{n}"))
584
+ }
585
+ }
586
+ Some(Out::Any(Any::BigInt(n))) => Some(format!("{n}")),
587
+ _ => None,
588
+ }
589
+ }
590
+
591
+ fn push_attr(out: &mut String, name: &str, value: &str) {
592
+ out.push(' ');
593
+ out.push_str(name);
594
+ out.push_str("=\"");
595
+ out.push_str(&escape_attr(value));
596
+ out.push('"');
597
+ }
598
+
599
+ /// An upload attachment, in the exact shape ActionText round-trips: attribute
600
+ /// order and presence mirror Lexxy's `exportDOM` (nulls omitted, `previewable`
601
+ /// only when true, `presentation="gallery"` always).
602
+ fn render_upload_attachment<T: ReadTxn>(txn: &T, e: &XmlElementRef, out: &mut String) {
603
+ out.push_str("<action-text-attachment");
604
+ if let Some(v) = elem_str(txn, e, "sgid") {
605
+ push_attr(out, "sgid", &v);
606
+ }
607
+ if matches!(
608
+ e.get_attribute(txn, "previewable"),
609
+ Some(Out::Any(Any::Bool(true)))
610
+ ) {
611
+ push_attr(out, "previewable", "true");
612
+ }
613
+ if let Some(v) = elem_str(txn, e, "src") {
614
+ push_attr(out, "url", &v);
615
+ }
616
+ if let Some(v) = elem_str(txn, e, "altText") {
617
+ push_attr(out, "alt", &v);
618
+ }
619
+ if let Some(v) = elem_str(txn, e, "caption") {
620
+ push_attr(out, "caption", &v);
621
+ }
622
+ if let Some(v) = elem_str(txn, e, "contentType") {
623
+ push_attr(out, "content-type", &v);
624
+ }
625
+ if let Some(v) = elem_str(txn, e, "fileName") {
626
+ push_attr(out, "filename", &v);
627
+ }
628
+ if let Some(v) = elem_num(txn, e, "fileSize") {
629
+ push_attr(out, "filesize", &v);
630
+ }
631
+ if let Some(v) = elem_num(txn, e, "width") {
632
+ push_attr(out, "width", &v);
633
+ }
634
+ if let Some(v) = elem_num(txn, e, "height") {
635
+ push_attr(out, "height", &v);
636
+ }
637
+ push_attr(out, "presentation", "gallery");
638
+ out.push_str("></action-text-attachment>");
639
+ }
640
+
641
+ /// A content attachment (mention, embed): `content` carries the escaped inner
642
+ /// HTML; `plainText` is not exported.
643
+ fn render_custom_attachment<T: ReadTxn>(txn: &T, e: &XmlElementRef, out: &mut String) {
644
+ out.push_str("<action-text-attachment");
645
+ if let Some(v) = elem_str(txn, e, "sgid") {
646
+ push_attr(out, "sgid", &v);
647
+ }
648
+ if let Some(v) = elem_str(txn, e, "innerHtml") {
649
+ push_attr(out, "content", &v);
650
+ }
651
+ if let Some(v) = elem_str(txn, e, "contentType") {
652
+ push_attr(out, "content-type", &v);
653
+ }
654
+ out.push_str("></action-text-attachment>");
655
+ }
656
+
657
+ /// Text-content escaping, matching what the browser's serializer emits:
658
+ /// `&`, `<`, `>` escaped; quotes left alone in text.
659
+ fn escape_text(s: &str) -> String {
660
+ s.replace('&', "&amp;")
661
+ .replace('<', "&lt;")
662
+ .replace('>', "&gt;")
663
+ }
664
+
665
+ /// Attribute-value escaping: text escaping plus `"`.
666
+ fn escape_attr(s: &str) -> String {
667
+ escape_text(s).replace('"', "&quot;")
668
+ }
669
+
670
+ #[cfg(test)]
671
+ mod tests {
672
+ use super::*;
673
+ use yrs::updates::decoder::Decode;
674
+ use yrs::{Doc, Transact, Update};
675
+
676
+ /// Rendering the captured full-schema document must match Lexxy's own
677
+ /// serializer byte for byte. The pair was captured together from one live
678
+ /// editor session: `lexxy_full.bin` is the synced Yjs state, `lexxy_full.html`
679
+ /// is `lexxy-editor.value` for the same document. It covers every block and
680
+ /// format the schema map handles.
681
+ #[test]
682
+ fn renders_the_captured_lexxy_document_byte_for_byte() {
683
+ let bytes = include_bytes!("fixtures/lexxy_full.bin");
684
+ let expected = include_str!("fixtures/lexxy_full.html");
685
+ let doc = Doc::new();
686
+ doc.transact_mut()
687
+ .apply_update(Update::decode_v1(bytes).unwrap())
688
+ .unwrap();
689
+ let txn = doc.transact();
690
+ let frag = txn.get_xml_fragment("root").unwrap();
691
+ assert_eq!(render(&txn, &frag).unwrap(), expected.trim_end());
692
+ }
693
+
694
+ /// A second live-Lexxy pair that stresses nesting: blocks inside table
695
+ /// cells, five-level mixed lists, formatted links in headings, the full
696
+ /// format stack, unicode and escaping edge cases, whitespace-only
697
+ /// paragraphs. The load-bearing structures are probed by name below.
698
+ /// One non-obvious parity fact worth stating: Lexxy's sanitized export
699
+ /// drops colSpan/rowSpan, so matching it byte-for-byte means not emitting
700
+ /// them either.
701
+ #[test]
702
+ fn renders_the_captured_torture_document_byte_for_byte() {
703
+ let bytes = include_bytes!("fixtures/lexxy_torture.bin");
704
+ let expected = include_str!("fixtures/lexxy_torture.html");
705
+ let doc = Doc::new();
706
+ doc.transact_mut()
707
+ .apply_update(Update::decode_v1(bytes).unwrap())
708
+ .unwrap();
709
+ let txn = doc.transact();
710
+ let frag = txn.get_xml_fragment("root").unwrap();
711
+ let html = render(&txn, &frag).unwrap();
712
+ assert_eq!(html, expected.trim_end());
713
+
714
+ // Byte-for-byte already proves these; name the load-bearing ones so
715
+ // a regression fails with the nested structure it broke.
716
+ for (what, probe) in [
717
+ ("list nested in a table cell", "<td><ul><li"),
718
+ ("quote in a table cell", "<td><blockquote>"),
719
+ (
720
+ "code block in a table cell",
721
+ "<td><pre data-language=\"javascript\">cell();</pre></td>",
722
+ ),
723
+ ("mention inside a header cell", "sgid=\"SGID_M_TABLE\""),
724
+ (
725
+ "five-level mixed list nesting",
726
+ "<ol><li value=\"1\"><s><i><strong>Level five</strong></i></s></li></ol>",
727
+ ),
728
+ (
729
+ "the full format stack on one run",
730
+ "<u><s><i><code><strong>g</strong></code></i></s></u>",
731
+ ),
732
+ (
733
+ "a titled link in a heading wrapping formatted runs",
734
+ "<a href=\"https://spec.example.com\" title=\"The Spec\">v<i><strong>2</strong></i><code>.final</code></a>",
735
+ ),
736
+ (
737
+ "already-escaped-looking text escapes again",
738
+ "<strong>a &amp;&amp; b &lt; c &gt; d \"q\" '&amp;amp;'</strong>",
739
+ ),
740
+ ("emoji, CJK and RTL text", "🚀🎉 你好世界 العربية café"),
741
+ (
742
+ "an all-null upload keeps its normalized empty attrs",
743
+ "sgid=\"SGID_UP_MIN\" url=\"http://localhost:4111/files/min.bin\" alt=\"\" caption=\"\"",
744
+ ),
745
+ (
746
+ "whitespace-only paragraphs",
747
+ "<p><span>\t</span></p><p><br></p><p><br></p>",
748
+ ),
749
+ ] {
750
+ assert!(html.contains(probe), "{what}: missing {probe}");
751
+ }
752
+ }
753
+
754
+ /// Highlight colors (captured live): a run's __style survives on the
755
+ /// createDOM tag — the outer tag when present, else the inner — filtered
756
+ /// to color/background-color; plain and s/u-only runs lose it with their
757
+ /// unwrapped span. The fixture's byte-for-byte match pins both the keeps
758
+ /// and the drops.
759
+ #[test]
760
+ fn renders_the_captured_styles_document_byte_for_byte() {
761
+ let bytes = include_bytes!("fixtures/lexxy_styles.bin");
762
+ let expected = include_str!("fixtures/lexxy_styles.html");
763
+ let doc = Doc::new();
764
+ doc.transact_mut()
765
+ .apply_update(Update::decode_v1(bytes).unwrap())
766
+ .unwrap();
767
+ let txn = doc.transact();
768
+ let frag = txn.get_xml_fragment("root").unwrap();
769
+ assert_eq!(render(&txn, &frag).unwrap(), expected.trim_end());
770
+ }
771
+
772
+ #[test]
773
+ fn run_styles_ride_the_createdom_tag() {
774
+ // Outer tag takes the style over the inner one.
775
+ assert_eq!(
776
+ render_run("x", 128, "background-color: var(--highlight-bg-2);"),
777
+ "<mark style=\"background-color: var(--highlight-bg-2);\">x</mark>"
778
+ );
779
+ assert_eq!(
780
+ render_run("x", 1 | 128, "background-color: red;"),
781
+ "<mark style=\"background-color: red;\"><strong>x</strong></mark>"
782
+ );
783
+ // No outer tag: the inner takes it.
784
+ assert_eq!(
785
+ render_run("x", 1, "color: red;"),
786
+ "<strong style=\"color: red;\">x</strong>"
787
+ );
788
+ // Plain and s/u-only runs lose the style with their unwrapped span.
789
+ assert_eq!(render_run("x", 0, "color: red;"), "x");
790
+ assert_eq!(render_run("x", 4, "color: red;"), "<s>x</s>");
791
+ // Two properties: source order, no separator between them.
792
+ assert_eq!(
793
+ render_run("x", 128, "color: red; background-color: blue;"),
794
+ "<mark style=\"color: red;background-color: blue;\">x</mark>"
795
+ );
796
+ // Disallowed properties are stripped; quotes in values escape.
797
+ assert_eq!(
798
+ render_run("x", 128, "font-size: 40px; color: r\"ed;"),
799
+ "<mark style=\"color: r&quot;ed;\">x</mark>"
800
+ );
801
+ }
802
+
803
+ /// A known container with inline content jammed directly into it
804
+ /// (Lexxy never does this) keeps the content instead of dropping it.
805
+ #[test]
806
+ fn a_known_container_keeps_stray_inline_content() {
807
+ use yrs::{XmlFragment, XmlTextPrelim};
808
+ let doc = Doc::new();
809
+ let frag = doc.get_or_insert_xml_fragment("root");
810
+ {
811
+ let mut txn = doc.transact_mut();
812
+ let list = frag.push_back(&mut txn, XmlTextPrelim::new("stray"));
813
+ list.insert_attribute(&mut txn, "__type", "list");
814
+ list.insert_attribute(&mut txn, "__tag", "ul");
815
+ let li = list.insert_embed(&mut txn, 5, XmlTextPrelim::new("item"));
816
+ li.insert_attribute(&mut txn, "__type", "listitem");
817
+ }
818
+ let txn = doc.transact();
819
+ let frag = txn.get_xml_fragment("root").unwrap();
820
+ let html = render(&txn, &frag).unwrap();
821
+
822
+ assert!(html.contains("stray"), "stray inline text kept: {html}");
823
+ assert!(html.contains("<li value=\"1\">item</li>"), "{html}");
824
+ }
825
+
826
+ /// An image gallery (adjacent previewable images grouped by Lexxy's
827
+ /// gallery node) renders as ActionText's classed div. Captured live, like
828
+ /// the other pairs: three image attachments between two paragraphs.
829
+ #[test]
830
+ fn renders_the_captured_gallery_document_byte_for_byte() {
831
+ let bytes = include_bytes!("fixtures/lexxy_gallery.bin");
832
+ let expected = include_str!("fixtures/lexxy_gallery.html");
833
+ let doc = Doc::new();
834
+ doc.transact_mut()
835
+ .apply_update(Update::decode_v1(bytes).unwrap())
836
+ .unwrap();
837
+ let txn = doc.transact();
838
+ let frag = txn.get_xml_fragment("root").unwrap();
839
+ let html = render(&txn, &frag).unwrap();
840
+ assert_eq!(html, expected.trim_end());
841
+ assert!(html.contains("<div class=\"attachment-gallery attachment-gallery--3\">"));
842
+ assert_eq!(html.matches("<action-text-attachment").count(), 3);
843
+ }
844
+
845
+ #[test]
846
+ fn format_runs_match_lexxys_export_algorithm() {
847
+ // Singles take their semantic tag; the span for plain text unwraps.
848
+ assert_eq!(render_run("x", 0, ""), "x");
849
+ assert_eq!(render_run("x", 1, ""), "<strong>x</strong>");
850
+ assert_eq!(render_run("x", 2, ""), "<em>x</em>");
851
+ assert_eq!(render_run("x", 4, ""), "<s>x</s>");
852
+ assert_eq!(render_run("x", 8, ""), "<u>x</u>");
853
+ assert_eq!(render_run("x", 16, ""), "<code>x</code>");
854
+ assert_eq!(render_run("x", 32, ""), "<sub>x</sub>");
855
+ assert_eq!(render_run("x", 64, ""), "<sup>x</sup>");
856
+ assert_eq!(render_run("x", 128, ""), "<mark>x</mark>");
857
+ // bold+italic: bold claims the inner tag, italic falls back to <i>.
858
+ assert_eq!(render_run("x", 3, ""), "<i><strong>x</strong></i>");
859
+ // Wrap order: u outside s outside the semantic core.
860
+ assert_eq!(render_run("x", 4 | 8, ""), "<u><s>x</s></u>");
861
+ assert_eq!(render_run("x", 1 | 8, ""), "<u><strong>x</strong></u>");
862
+ // Outer tag composes with the inner one.
863
+ assert_eq!(
864
+ render_run("x", 1 | 16, ""),
865
+ "<code><strong>x</strong></code>"
866
+ );
867
+ assert_eq!(render_run("x", 2 | 128, ""), "<mark><em>x</em></mark>");
868
+ // Everything at once: u(s(i(outer(inner)))).
869
+ assert_eq!(
870
+ render_run("x", 1 | 2 | 4 | 8 | 16, ""),
871
+ "<u><s><i><code><strong>x</strong></code></i></s></u>"
872
+ );
873
+ }
874
+
875
+ #[test]
876
+ fn a_prosemirror_shaped_root_is_refused_not_mangled() {
877
+ // ProseMirror blocks are plain XmlElements with no __type — a
878
+ // different schema. render() must return None, never a lossy or empty
879
+ // rendering that looks like success.
880
+ use yrs::{XmlElementPrelim, XmlFragment, XmlTextPrelim};
881
+ let doc = Doc::new();
882
+ // Create BOTH roots before opening the read transaction:
883
+ // get_or_insert_* opens a write transaction internally and would
884
+ // deadlock against a live read guard (the read_text lesson).
885
+ let frag = doc.get_or_insert_xml_fragment("pm");
886
+ let empty = doc.get_or_insert_xml_fragment("empty");
887
+ {
888
+ let mut txn = doc.transact_mut();
889
+ let p = frag.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
890
+ p.push_back(&mut txn, XmlTextPrelim::new("Body"));
891
+ }
892
+ let txn = doc.transact();
893
+ assert_eq!(render(&txn, &frag), None);
894
+
895
+ // An empty root is fine: an empty document, not a foreign schema.
896
+ assert_eq!(render(&txn, &empty).as_deref(), Some(""));
897
+ }
898
+
899
+ /// Build a chain of `depth` nested lists: list > listitem > list > … each
900
+ /// list holding one item whose only child is the next list down. Mirrors
901
+ /// the real storage model (block children are XmlText embedded in XmlText).
902
+ fn nested_list_doc(depth: usize) -> Doc {
903
+ use yrs::{XmlFragment, XmlTextPrelim};
904
+ let doc = Doc::new();
905
+ let root = doc.get_or_insert_xml_fragment("root");
906
+ let mut txn = doc.transact_mut();
907
+ let top = root.push_back(&mut txn, XmlTextPrelim::new(""));
908
+ top.insert_attribute(&mut txn, "__type", "list");
909
+ top.insert_attribute(&mut txn, "__tag", "ul");
910
+ let mut cursor = top;
911
+ for _ in 0..depth {
912
+ let li = cursor.insert_embed(&mut txn, 0, XmlTextPrelim::new(""));
913
+ li.insert_attribute(&mut txn, "__type", "listitem");
914
+ let inner = li.insert_embed(&mut txn, 0, XmlTextPrelim::new(""));
915
+ inner.insert_attribute(&mut txn, "__type", "list");
916
+ inner.insert_attribute(&mut txn, "__tag", "ul");
917
+ cursor = inner;
918
+ }
919
+ drop(txn);
920
+ doc
921
+ }
922
+
923
+ #[test]
924
+ fn deeply_nested_blocks_do_not_overflow_the_stack() {
925
+ // Nesting this deep would overflow the native call stack (tens of
926
+ // thousands of frames) under recursion; on the heap it renders fine.
927
+ // Runs on a 512 KiB thread so it can't pass just by having room to spare.
928
+ let handle = std::thread::Builder::new()
929
+ .stack_size(512 * 1024)
930
+ .spawn(|| {
931
+ let doc = nested_list_doc(20_000);
932
+ let txn = doc.transact();
933
+ let frag = txn.get_xml_fragment("root").unwrap();
934
+ let html = render(&txn, &frag).expect("lexical-shaped");
935
+ // Well formed: every opened list/item is closed.
936
+ assert_eq!(html.matches("<ul>").count(), html.matches("</ul>").count());
937
+ assert_eq!(html.matches("<li ").count(), html.matches("</li>").count());
938
+ html.len()
939
+ })
940
+ .unwrap();
941
+ assert!(handle.join().unwrap() > 0);
942
+ }
943
+
944
+ #[test]
945
+ fn nesting_past_the_cap_truncates_but_stays_well_formed() {
946
+ // Past MAX_BLOCK_DEPTH the deepest content is dropped, but every
947
+ // enclosing tag still closes, so the output remains balanced HTML.
948
+ let doc = nested_list_doc(MAX_BLOCK_DEPTH + 50);
949
+ let txn = doc.transact();
950
+ let frag = txn.get_xml_fragment("root").unwrap();
951
+ let html = render(&txn, &frag).unwrap();
952
+
953
+ assert_eq!(html.matches("<ul>").count(), html.matches("</ul>").count());
954
+ assert_eq!(html.matches("<li ").count(), html.matches("</li>").count());
955
+ // Capped, not fully rendered: fewer levels than were authored.
956
+ assert!(html.matches("<ul>").count() <= MAX_BLOCK_DEPTH + 1);
957
+ }
958
+
959
+ #[test]
960
+ fn deeply_nested_links_do_not_overflow_the_stack() {
961
+ // Links recurse (their body is inline content), so they carry their own
962
+ // depth cap. A link-in-link chain renders as nested <a>s up to the cap,
963
+ // then bare text.
964
+ use yrs::{XmlFragment, XmlTextPrelim};
965
+ let doc = Doc::new();
966
+ let root = doc.get_or_insert_xml_fragment("root");
967
+ {
968
+ let mut txn = doc.transact_mut();
969
+ let p = root.push_back(&mut txn, XmlTextPrelim::new(""));
970
+ p.insert_attribute(&mut txn, "__type", "paragraph");
971
+ let mut cursor = p;
972
+ for _ in 0..(MAX_INLINE_DEPTH + 20) {
973
+ let link = cursor.insert_embed(&mut txn, 0, XmlTextPrelim::new(""));
974
+ link.insert_attribute(&mut txn, "__type", "link");
975
+ link.insert_attribute(&mut txn, "__url", "https://x.example");
976
+ cursor = link;
977
+ }
978
+ }
979
+ let txn = doc.transact();
980
+ let frag = txn.get_xml_fragment("root").unwrap();
981
+ let html = render(&txn, &frag).unwrap();
982
+ assert_eq!(html.matches("<a").count(), html.matches("</a>").count());
983
+ }
984
+
985
+ #[test]
986
+ fn escaping_matches_the_browser_serializer() {
987
+ assert_eq!(escape_text(r#"<a & "b">"#), r#"&lt;a &amp; "b"&gt;"#);
988
+ assert_eq!(
989
+ escape_attr(r#"<a & "b">"#),
990
+ r#"&lt;a &amp; &quot;b&quot;&gt;"#
991
+ );
992
+ }
993
+ }
data/ext/yrby/src/lib.rs CHANGED
@@ -6,6 +6,7 @@ use yrs::updates::decoder::Decode;
6
6
  use yrs::updates::encoder::Encode;
7
7
  use yrs::{Doc, GetString, ReadTxn, Transact};
8
8
 
9
+ mod lexical_html;
9
10
  mod prosemirror_html;
10
11
  mod protocol;
11
12
  mod read;
@@ -31,6 +32,7 @@ struct RbDoc(Doc);
31
32
  fn assert_thread_safe() {
32
33
  fn is_send_sync<T: Send + Sync>() {}
33
34
  is_send_sync::<Doc>();
35
+ is_send_sync::<RbLexical>();
34
36
  is_send_sync::<RbProseMirror>();
35
37
  }
36
38
 
@@ -331,6 +333,56 @@ impl RbDoc {
331
333
  }
332
334
  }
333
335
 
336
+ // ============================================================================
337
+ // Y::Lexical — schema-pinned rendering of Lexical/Lexxy documents
338
+ // ============================================================================
339
+
340
+ /// A Lexical/Lexxy view over a `Y::Doc`. The schema knowledge (Lexxy 0.9.x
341
+ /// node set and serializer semantics) lives here rather than on the
342
+ /// schema-agnostic `Doc`. Holds a cheap clone of the doc (yrs `Doc` is an Arc
343
+ /// handle), so it reads live state.
344
+ ///
345
+ /// Thread safety matches `Y::Doc`: every method opens its own transaction
346
+ /// inside `nogvl` and holds no lock across the GVL boundary.
347
+ #[magnus::wrap(class = "Y::Lexical", free_immediately, size)]
348
+ struct RbLexical(Doc);
349
+
350
+ impl RbLexical {
351
+ /// `Y::Lexical.new(doc)`
352
+ fn new(doc: &RbDoc) -> Self {
353
+ RbLexical(doc.0.clone())
354
+ }
355
+
356
+ /// Render the document's XML root (default `"root"`, Lexical's standard
357
+ /// collab root name) to HTML, natively — no Node process or headless
358
+ /// editor. Output matches Lexxy's own serializer (the HTML a lexxy-editor
359
+ /// submits to Rails) byte-for-byte on the reference fixture; see
360
+ /// `lexical_html.rs` for the schema coverage and caveats. Returns nil when
361
+ /// the root is missing or not Lexical-shaped, e.g. a ProseMirror document.
362
+ fn to_html(&self, args: &[Value]) -> Result<Option<String>, Error> {
363
+ if args.len() > 1 {
364
+ let ruby = Ruby::get().unwrap();
365
+ return Err(Error::new(
366
+ ruby.exception_arg_error(),
367
+ format!(
368
+ "wrong number of arguments (given {}, expected 0..1)",
369
+ args.len()
370
+ ),
371
+ ));
372
+ }
373
+ let name: String = match args.first() {
374
+ Some(arg) => TryConvert::try_convert(*arg)?,
375
+ None => "root".to_string(),
376
+ };
377
+ let doc = &self.0;
378
+ Ok(nogvl(move || {
379
+ let txn = doc.transact();
380
+ let fragment = txn.get_xml_fragment(name.as_str())?;
381
+ lexical_html::render(&txn, &fragment)
382
+ }))
383
+ }
384
+ }
385
+
334
386
  // ============================================================================
335
387
  // Y::ProseMirror — schema-pinned rendering of ProseMirror/Tiptap documents
336
388
  // ============================================================================
@@ -460,6 +512,10 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
460
512
  "handle_sync_message",
461
513
  method!(RbDoc::handle_sync_message, 1),
462
514
  )?;
515
+ // Y::Lexical: schema-pinned Lexical/Lexxy rendering over a Doc.
516
+ let lexical_class = module.define_class("Lexical", ruby.class_object())?;
517
+ lexical_class.define_singleton_method("new", function!(RbLexical::new, 1))?;
518
+ lexical_class.define_method("to_html", method!(RbLexical::to_html, -1))?;
463
519
  // Y::ProseMirror: schema-pinned ProseMirror/Tiptap rendering over a Doc.
464
520
  let prosemirror_class = module.define_class("ProseMirror", ruby.class_object())?;
465
521
  prosemirror_class.define_singleton_method("new", function!(RbProseMirror::new, 1))?;
data/ext/yrby/src/read.rs CHANGED
@@ -31,11 +31,15 @@ pub fn xml_blocks_text<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> String
31
31
  XmlOut::Text(t) => walk_lexical_block(txn, &t, &mut out),
32
32
  XmlOut::Element(e) => {
33
33
  // ProseMirror blocks have a tag but no `__type`; get_string recurses
34
- // them (tags kept, caller strips). A Lexical decorator (horizontal
35
- // rule, image) is an XmlElement *with* a `__type` and no extractable
36
- // text -- skip it rather than emit its `<UNDEFINED …>` serialization.
34
+ // them (tags kept, caller strips). A Lexical decorator is an
35
+ // XmlElement *with* a `__type`: attachments carry readable text
36
+ // (a mention's plain text, an upload's caption); the rest
37
+ // (horizontal rule) have none — skip rather than emit their
38
+ // `<UNDEFINED …>` serialization.
37
39
  if e.get_attribute(txn, "__type").is_none() {
38
40
  out.push(e.get_string(txn));
41
+ } else if let Some(text) = lexical_decorator_text(txn, &e) {
42
+ out.push(text);
39
43
  }
40
44
  }
41
45
  XmlOut::Fragment(f) => out.push(f.get_string(txn)),
@@ -62,6 +66,26 @@ fn lexical_type<T: ReadTxn>(txn: &T, t: &XmlTextRef) -> String {
62
66
  }
63
67
  }
64
68
 
69
+ /// The readable text of a Lexical decorator element, if it has any: a mention
70
+ /// or embed attachment contributes its plain text; an upload attachment its
71
+ /// caption, alt text, or filename. Dividers and unknown decorators yield None.
72
+ fn lexical_decorator_text<T: ReadTxn>(txn: &T, e: &yrs::XmlElementRef) -> Option<String> {
73
+ let attr = |name: &str| match e.get_attribute(txn, name) {
74
+ Some(Out::Any(Any::String(s))) if !s.is_empty() => Some(s.to_string()),
75
+ _ => None,
76
+ };
77
+ match e.get_attribute(txn, "__type") {
78
+ Some(Out::Any(Any::String(ty))) => match ty.as_ref() {
79
+ "custom_action_text_attachment" => attr("plainText"),
80
+ "action_text_attachment" => attr("caption")
81
+ .or_else(|| attr("altText"))
82
+ .or_else(|| attr("fileName")),
83
+ _ => None,
84
+ },
85
+ _ => None,
86
+ }
87
+ }
88
+
65
89
  /// The `__type` of an embedded Lexical `Y.Map`. Two kinds appear inside a
66
90
  /// block: text-node metadata (`"text"`) and node maps like the LineBreakNode
67
91
  /// (`"linebreak"`). Structure confirmed from live-editor bytes (see the
@@ -120,7 +144,13 @@ fn walk_lexical_block<T: ReadTxn>(txn: &T, t: &XmlTextRef, out: &mut Vec<String>
120
144
  }
121
145
  }
122
146
  }
123
- _ => {} // decorator embeds we don't read for text
147
+ // An inline decorator (a mention attachment) joins the line.
148
+ Out::YXmlElement(e) => {
149
+ if let Some(text) = lexical_decorator_text(txn, &e) {
150
+ line.push_str(&text);
151
+ }
152
+ }
153
+ _ => {} // other embeds carry no text
124
154
  }
125
155
  }
126
156
  if !line.is_empty() {
@@ -332,6 +362,43 @@ mod tests {
332
362
  assert_eq!(xml_blocks_text(&txn, &frag), "foo\nbarbaz");
333
363
  }
334
364
 
365
+ #[test]
366
+ fn lexxy_full_schema_doc_extracts_attachment_text_too() {
367
+ // The full-schema capture (see lexical_html.rs): attachments must now
368
+ // contribute readable text — a mention's plain text inline, an
369
+ // upload's caption as its own line — while the divider stays silent.
370
+ use yrs::updates::decoder::Decode;
371
+ use yrs::{Transact, Update};
372
+ let bytes = include_bytes!("fixtures/lexxy_full.bin");
373
+ let doc = Doc::new();
374
+ doc.transact_mut()
375
+ .apply_update(Update::decode_v1(bytes).unwrap())
376
+ .unwrap();
377
+ let txn = doc.transact();
378
+ let frag = txn.get_xml_fragment("root").unwrap();
379
+ let text = xml_blocks_text(&txn, &frag);
380
+
381
+ assert!(
382
+ text.contains("Mention: @Alice done."),
383
+ "inline mention joins its line:\n{text}"
384
+ );
385
+ assert!(
386
+ text.contains("The team, 2026"),
387
+ "upload caption becomes a line"
388
+ );
389
+ assert!(
390
+ !text.contains("UNDEFINED"),
391
+ "no decorator serialization leaks"
392
+ );
393
+ assert!(
394
+ !text.contains('\u{2504}'),
395
+ "the divider glyph is not emitted"
396
+ );
397
+ for expected in ["Heading H6", "Done item", "def hello", "after-empty"] {
398
+ assert!(text.contains(expected), "missing {expected:?}");
399
+ }
400
+ }
401
+
335
402
  #[test]
336
403
  fn lexical_complex_doc_extracts_all_nested_text() {
337
404
  // A real Lexxy/Lexical doc with every block type: headings, formatted
data/lib/y/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Y
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yrby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - JP Camara
@@ -83,6 +83,7 @@ files:
83
83
  - README.md
84
84
  - ext/yrby/Cargo.toml
85
85
  - ext/yrby/extconf.rb
86
+ - ext/yrby/src/lexical_html.rs
86
87
  - ext/yrby/src/lib.rs
87
88
  - ext/yrby/src/prosemirror_html.rs
88
89
  - ext/yrby/src/protocol.rs