yrby 0.5.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.
@@ -1,37 +1,53 @@
1
- //! Native HTML rendering of Lexical/Lexxy documents from the yrs collab
2
- //! structure — no Node process, no headless editor.
1
+ //! Native HTML rendering of Lexical documents from the yrs collab structure —
2
+ //! no Node process, no headless editor.
3
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.
4
+ //! This renders **core Lexical**: paragraphs, headings, quotes, code, lists
5
+ //! and list items, tables, horizontal rules, links, and the whole text-format
6
+ //! model. Everything Lexxy-specific its own node types (attachments,
7
+ //! galleries, `early_escape_code`, `horizontal_divider`) and its decorations
8
+ //! of core nodes (the table figure wrapper, header-cell styling, the
9
+ //! nested-list-item class) — lives in the Ruby layer as the `Y::Lexxy`
10
+ //! renderer's rule set, built on the same extension API apps use
11
+ //! (`Y::Lexical` is the core base class). The Lexxy byte-parity guarantee is
12
+ //! held there: the Ruby fixture tests and the live headless-Chrome e2e pin
13
+ //! `Y::Lexxy#to_html` against a real editor's own serialized value. The
14
+ //! native tests pin core output as regression goldens (stock Lexical has no
15
+ //! canonical serializer to capture against).
9
16
  //!
10
17
  //! Prior art: `ueberdosis/tiptap-php` renders ProseMirror JSON to HTML in pure
11
18
  //! PHP — a schema-pinned renderer outside the JS runtime. This works from the
12
19
  //! collab (Yjs) structure rather than JSON.
13
20
  //!
14
- //! Storage model (verified against bytes captured from a live Lexxy editor):
21
+ //! Storage model (verified against bytes captured from a live editor):
15
22
  //! - 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)).
23
+ //! (+`__tag`), `quote`, `code` (+`__language`), `list`/`listitem`,
24
+ //! `table`/`tablerow`/`tablecell`, `link`/`autolink` (inline)).
18
25
  //! - Text runs are preceded by an embedded `Y.Map` carrying per-run metadata
19
26
  //! (`__type: "text" | "code-highlight" | "tab"`, `__format` bitmask).
20
27
  //! - `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.
28
+ //! - Decorator nodes are `Y.XmlElement`s with their fields as plain
29
+ //! attributes (`horizontalrule` here; app/Lexxy decorators via rules).
24
30
  //!
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).
31
+ //! Text-format rendering follows the export pipeline Lexxy runs (Lexical's
32
+ //! `$generateHtmlFromNodes` + sanitize), which is the only externally
33
+ //! pinnable truth for formatting: inner tag `strong` (bold) / `em` (italic,
34
+ //! when not bold); outer tag `code` / `mark` / `sub` / `sup`; an `<i>` wrap
35
+ //! only when bold+italic combine (the `em` slot is taken); `<s>` / `<u>`
36
+ //! wraps always; `<span>`s are unwrapped, so unformatted text is bare. A
37
+ //! run's `__style` (highlight colors) survives on the createDOM tag,
38
+ //! filtered to color/background-color; on a plain or s/u-only run it dies
39
+ //! with the unwrapped span. Case-transform format bits are never rendered
40
+ //! (their text-transform style is outside the sanitize whitelist).
41
+ //!
42
+ //! Custom nodes: rules are registered by `__type` (see `render_rules`) and
43
+ //! consulted before the built-in arms, so they extend the schema or override
44
+ //! a built-in. Declarative rules render here; callback rules emit
45
+ //! `Segment::Deferred` for the caller to fill in after the render.
34
46
 
47
+ use crate::render_rules::{
48
+ resolve_parts, xml_attrs_json, xml_ref_attr, Content, Emitter, NodeRule, Rules, Segment,
49
+ TypeMap,
50
+ };
35
51
  use yrs::types::text::YChange;
36
52
  use yrs::{
37
53
  Any, GetString, Map, Out, ReadTxn, Text, Xml, XmlElementRef, XmlFragment, XmlFragmentRef,
@@ -57,36 +73,158 @@ const FMT_HIGHLIGHT: u32 = 1 << 7;
57
73
  const MAX_BLOCK_DEPTH: usize = 1024;
58
74
  const MAX_INLINE_DEPTH: usize = 1024;
59
75
 
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`.
76
+ /// A unit of block work on the explicit traversal stack. `Open`
77
+ /// renders a block node (pushing its own children as more work); `Close` and
78
+ /// `CloseOwned` emit an end tag once a container's children have all been
79
+ /// processed (built-in containers close with fixed strings; rule containers
80
+ /// close with their computed tag). `EndDeferred` seals a callback node: it pops
81
+ /// the emitter frame its children rendered into and emits the deferred segment.
64
82
  enum Work {
65
83
  Open(XmlTextRef, usize),
66
84
  Close(&'static str),
85
+ CloseOwned(String),
86
+ EndDeferred {
87
+ node_type: String,
88
+ attrs_json: String,
89
+ child_types: Vec<String>,
90
+ },
67
91
  }
68
92
 
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
93
+ /// Render a Lexical/Lexxy-shaped XML root, or `None` when the root isn't
94
+ /// Lexical-shaped. Lexical marks every node with a `__type` attribute; a
71
95
  /// root whose children carry none — a ProseMirror document, say, whose blocks
72
96
  /// are plain `<paragraph>` elements — is a foreign schema, and render returns
73
97
  /// `None` for it rather than a lossy guess.
74
98
  ///
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.
99
+ /// A `__type` the renderer doesn't recognize is handled differently: a
100
+ /// registered rule renders it; otherwise a leaf's text renders in a `<p>`
101
+ /// and a container's block children render without an invented wrapper, so
102
+ /// an editor node the schema hasn't heard of stays readable.
103
+ pub fn render_segments<T: ReadTxn>(
104
+ txn: &T,
105
+ fragment: &XmlFragmentRef,
106
+ rules: &Rules,
107
+ ) -> Option<Vec<Segment>> {
108
+ if !is_lexical_shaped(txn, fragment) {
109
+ return None;
110
+ }
111
+ let mut em = Emitter::new();
112
+ for node in fragment.children(txn) {
113
+ match node {
114
+ XmlOut::Text(t) => render_block_tree(txn, &t, &mut em, rules),
115
+ XmlOut::Element(e) => render_decorator(txn, &e, &mut em, rules),
116
+ // Fragments can't nest as children in yrs; escape the text as the
117
+ // safe degradation for an exhaustive match.
118
+ XmlOut::Fragment(f) => em.push_str(&escape_text(&f.get_string(txn))),
119
+ }
120
+ }
121
+ Some(em.into_segments())
122
+ }
123
+
124
+ /// Rule-free rendering to a plain string — the fixture-parity surface most
125
+ /// tests pin. With no callback rules, segments always flatten.
126
+ #[cfg(test)]
77
127
  pub fn render<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> Option<String> {
128
+ render_segments(txn, fragment, &Rules::empty()).map(|segs| {
129
+ crate::render_rules::flatten(segs)
130
+ .into_html()
131
+ .expect("no callback rules registered")
132
+ })
133
+ }
134
+
135
+ /// The node types a Lexical renderer's built-in arms cover (core Lexical;
136
+ /// inline and decorator types included). Everything else needs a rule.
137
+ pub fn is_builtin(ty: &str) -> bool {
138
+ matches!(
139
+ ty,
140
+ "paragraph"
141
+ | "heading"
142
+ | "quote"
143
+ | "code"
144
+ | "list"
145
+ | "listitem"
146
+ | "table"
147
+ | "tablerow"
148
+ | "tablecell"
149
+ | "link"
150
+ | "autolink"
151
+ | "linebreak"
152
+ | "tab"
153
+ | "text"
154
+ | "horizontalrule"
155
+ )
156
+ }
157
+
158
+ /// Walk the document and record what each node type actually looks like —
159
+ /// the discovery aid behind `Y::Lexical#node_types`. It records facts:
160
+ /// counts, attribute names (minus `__type`, which is the key), child types,
161
+ /// and whether text runs were seen.
162
+ pub fn collect_node_types<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> Option<TypeMap> {
78
163
  if !is_lexical_shaped(txn, fragment) {
79
164
  return None;
80
165
  }
81
- let mut out = String::new();
166
+ let mut map = TypeMap::new();
82
167
  for node in fragment.children(txn) {
83
168
  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))),
169
+ XmlOut::Text(t) => observe_text_node(txn, &t, &mut map, 0),
170
+ XmlOut::Element(e) => observe_element(txn, &e, &mut map),
171
+ XmlOut::Fragment(_) => {}
172
+ }
173
+ }
174
+ Some(map)
175
+ }
176
+
177
+ fn observe_text_node<T: ReadTxn>(txn: &T, t: &XmlTextRef, map: &mut TypeMap, depth: usize) {
178
+ let ty = node_type(txn, t);
179
+ let info = map.entry(ty.clone()).or_default();
180
+ info.count += 1;
181
+ for (key, _) in t.attributes(txn) {
182
+ if key != "__type" {
183
+ info.attrs.insert(key.to_string());
184
+ }
185
+ }
186
+ if depth >= MAX_BLOCK_DEPTH {
187
+ return;
188
+ }
189
+ let mut children = Vec::new();
190
+ for d in t.diff(txn, YChange::identity) {
191
+ match d.insert {
192
+ Out::Any(Any::String(_)) => {
193
+ map.get_mut(&ty).expect("just inserted").text = true;
194
+ }
195
+ Out::YXmlText(child) => {
196
+ let child_ty = node_type(txn, &child);
197
+ map.get_mut(&ty)
198
+ .expect("just inserted")
199
+ .children
200
+ .insert(child_ty);
201
+ children.push(child);
202
+ }
203
+ Out::YXmlElement(child) => {
204
+ let child_ty = elem_type(txn, &child);
205
+ map.get_mut(&ty)
206
+ .expect("just inserted")
207
+ .children
208
+ .insert(child_ty);
209
+ observe_element(txn, &child, map);
210
+ }
211
+ _ => {} // run-metadata maps are formatting, not children
212
+ }
213
+ }
214
+ for child in children {
215
+ observe_text_node(txn, &child, map, depth + 1);
216
+ }
217
+ }
218
+
219
+ fn observe_element<T: ReadTxn>(txn: &T, e: &XmlElementRef, map: &mut TypeMap) {
220
+ let ty = elem_type(txn, e);
221
+ let info = map.entry(ty).or_default();
222
+ info.count += 1;
223
+ for (key, _) in e.attributes(txn) {
224
+ if key != "__type" {
225
+ info.attrs.insert(key.to_string());
87
226
  }
88
227
  }
89
- Some(out)
90
228
  }
91
229
 
92
230
  /// A root is Lexical-shaped when it is empty or at least one child carries the
@@ -123,45 +261,59 @@ fn str_attr<T: ReadTxn>(txn: &T, t: &XmlTextRef, name: &str) -> Option<String> {
123
261
  }
124
262
  }
125
263
 
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) {
264
+ /// Walk a top-level block and everything under it on an explicit heap stack.
265
+ /// Container blocks push their children back as more work plus a close for
266
+ /// their end tag; leaf blocks render in full on the spot. The stack lives on
267
+ /// the heap, so nesting depth can't overflow the native call stack.
268
+ fn render_block_tree<T: ReadTxn>(txn: &T, root: &XmlTextRef, em: &mut Emitter, rules: &Rules) {
133
269
  let mut stack: Vec<Work> = vec![Work::Open(root.clone(), 0)];
134
270
  while let Some(work) = stack.pop() {
135
271
  match work {
136
- Work::Close(tag) => out.push_str(tag),
137
- Work::Open(node, depth) => open_block(txn, &node, depth, out, &mut stack),
272
+ Work::Close(tag) => em.push_str(tag),
273
+ Work::CloseOwned(tag) => em.push_str(&tag),
274
+ Work::EndDeferred {
275
+ node_type,
276
+ attrs_json,
277
+ child_types,
278
+ } => {
279
+ let content = em.end_frame();
280
+ em.emit_deferred(node_type, attrs_json, child_types, content);
281
+ }
282
+ Work::Open(node, depth) => open_block(txn, &node, depth, em, &mut stack, rules),
138
283
  }
139
284
  }
140
285
  }
141
286
 
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.
287
+ /// Render one block. A registered rule wins over the built-in arms (so apps
288
+ /// can extend the schema or override a built-in); a container emits its
289
+ /// opening tag now and defers its children (and matching close) to the stack;
290
+ /// a leaf renders completely.
144
291
  fn open_block<T: ReadTxn>(
145
292
  txn: &T,
146
293
  t: &XmlTextRef,
147
294
  depth: usize,
148
- out: &mut String,
295
+ em: &mut Emitter,
149
296
  stack: &mut Vec<Work>,
297
+ rules: &Rules,
150
298
  ) {
151
299
  let ty = node_type(txn, t);
300
+ if let Some(rule) = rules.nodes.get(ty.as_str()) {
301
+ open_rule_block(txn, t, &ty, rule, depth, em, stack, rules);
302
+ return;
303
+ }
152
304
  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);
305
+ "paragraph" => {
306
+ // An empty paragraph exports with a <br>, as Lexical's own
307
+ // paragraph export does.
308
+ em.begin_frame();
309
+ render_inline(txn, t, 0, true, em, rules);
310
+ let inline = em.end_frame();
157
311
  if inline.is_empty() {
158
- if ty == "paragraph" {
159
- out.push_str("<p><br></p>");
160
- }
312
+ em.push_str("<p><br></p>");
161
313
  } else {
162
- out.push_str("<p>");
163
- out.push_str(&inline);
164
- out.push_str("</p>");
314
+ em.push_str("<p>");
315
+ em.append(inline);
316
+ em.push_str("</p>");
165
317
  }
166
318
  }
167
319
  "heading" => {
@@ -169,71 +321,56 @@ fn open_block<T: ReadTxn>(
169
321
  Some(tag @ ("h1" | "h2" | "h3" | "h4" | "h5" | "h6")) => tag.to_string(),
170
322
  _ => "h1".to_string(),
171
323
  };
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('>');
324
+ em.push('<');
325
+ em.push_str(&tag);
326
+ em.push('>');
327
+ render_inline(txn, t, 0, true, em, rules);
328
+ em.push_str("</");
329
+ em.push_str(&tag);
330
+ em.push('>');
179
331
  }
180
332
  "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");
333
+ em.push_str("<blockquote>");
334
+ render_inline(txn, t, 0, true, em, rules);
335
+ em.push_str("</blockquote>");
336
+ }
337
+ "code" => {
338
+ // Code highlighting is derived state: token runs flatten to
339
+ // plain text; linebreaks are <br>, tabs a wrapped \t.
340
+ em.push_str("<pre");
190
341
  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('"');
342
+ em.push_str(" data-language=\"");
343
+ em.push_str(&escape_attr(&lang));
344
+ em.push('"');
194
345
  }
195
- out.push('>');
196
- out.push_str(&render_inline(txn, t, 0));
197
- out.push_str("</pre>");
346
+ em.push('>');
347
+ render_inline(txn, t, 0, true, em, rules);
348
+ em.push_str("</pre>");
198
349
  }
199
350
  "list" => {
200
351
  let tag = match str_attr(txn, t, "__tag").as_deref() {
201
352
  Some("ol") => "ol",
202
353
  _ => "ul",
203
354
  };
204
- out.push('<');
205
- out.push_str(tag);
206
- out.push('>');
355
+ em.push('<');
356
+ em.push_str(tag);
357
+ em.push('>');
207
358
  // Direct inline content is crafted-only (Lexxy puts none here);
208
359
  // keep it rather than drop it. Safe from double-render: this skips
209
360
  // block children, and push_block_children skips inline content.
210
- out.push_str(&render_inline(txn, t, 0));
361
+ render_inline(txn, t, 0, false, em, rules);
211
362
  let close = if tag == "ol" { "</ol>" } else { "</ul>" };
212
363
  push_block_children(txn, t, depth, close, false, stack);
213
364
  }
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);
365
+ "listitem" => open_listitem(txn, t, depth, em, stack, rules),
366
+ "table" => {
367
+ em.push_str("<table><tbody>");
368
+ render_inline(txn, t, 0, false, em, rules);
369
+ push_block_children(txn, t, depth, "</tbody></table>", false, stack);
233
370
  }
234
371
  "tablerow" => {
235
- out.push_str("<tr>");
236
- out.push_str(&render_inline(txn, t, 0));
372
+ em.push_str("<tr>");
373
+ render_inline(txn, t, 0, false, em, rules);
237
374
  push_block_children(txn, t, depth, "</tr>", false, stack);
238
375
  }
239
376
  "tablecell" => {
@@ -245,29 +382,127 @@ fn open_block<T: ReadTxn>(
245
382
  Some(Out::Any(Any::BigInt(n))) if n > 0
246
383
  );
247
384
  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
- );
385
+ em.push_str("<th>");
253
386
  "</th>"
254
387
  } else {
255
- out.push_str("<td>");
388
+ em.push_str("<td>");
256
389
  "</td>"
257
390
  };
258
- out.push_str(&render_inline(txn, t, 0));
391
+ render_inline(txn, t, 0, false, em, rules);
259
392
  push_block_children(txn, t, depth, close, false, stack);
260
393
  }
261
- // A block type this renderer doesn't know: degrade to a readable
262
- // paragraph instead of dropping content.
394
+ // A block type this renderer doesn't know: degrade readably instead
395
+ // of dropping content. A container's block children render with no
396
+ // invented wrapper (a Lexxy table wrapper's rows still come out as
397
+ // rows); a leaf's text becomes a plain paragraph.
263
398
  _ => {
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>");
399
+ if !block_children(txn, t).is_empty() {
400
+ render_inline(txn, t, 0, false, em, rules);
401
+ push_block_children(txn, t, depth, "", false, stack);
402
+ } else {
403
+ em.begin_frame();
404
+ render_inline(txn, t, 0, true, em, rules);
405
+ let inline = em.end_frame();
406
+ if !inline.is_empty() {
407
+ em.push_str("<p>");
408
+ em.append(inline);
409
+ em.push_str("</p>");
410
+ }
411
+ }
412
+ }
413
+ }
414
+ }
415
+
416
+ /// Render a block through a registered rule. Declarative rules emit the tag,
417
+ /// resolved attributes, and template text here; callback rules capture their
418
+ /// children into a frame and defer the markup to the caller.
419
+ #[allow(clippy::too_many_arguments)]
420
+ fn open_rule_block<T: ReadTxn>(
421
+ txn: &T,
422
+ t: &XmlTextRef,
423
+ ty: &str,
424
+ rule: &NodeRule,
425
+ depth: usize,
426
+ em: &mut Emitter,
427
+ stack: &mut Vec<Work>,
428
+ rules: &Rules,
429
+ ) {
430
+ let (tag, void, attrs, text, content) = match rule {
431
+ NodeRule::Callback { content } => {
432
+ em.begin_frame();
433
+ // Children render into the frame; EndDeferred seals it. Blocks go
434
+ // via the stack (pushed above the marker, so they complete first);
435
+ // inline content renders now — in blocks mode too, since a block
436
+ // like a list item holds its own text alongside its nested blocks.
437
+ stack.push(Work::EndDeferred {
438
+ node_type: ty.to_string(),
439
+ attrs_json: xml_attrs_json(txn, t),
440
+ child_types: text_child_types(txn, t),
441
+ });
442
+ match content {
443
+ Content::Inline => render_inline(txn, t, 0, true, em, rules),
444
+ Content::Blocks => {
445
+ render_inline(txn, t, 0, false, em, rules);
446
+ for child in block_children(txn, t).into_iter().rev() {
447
+ if depth < MAX_BLOCK_DEPTH {
448
+ stack.push(Work::Open(child, depth + 1));
449
+ }
450
+ }
451
+ }
452
+ Content::None => {}
453
+ }
454
+ return;
455
+ }
456
+ NodeRule::Declarative {
457
+ tag,
458
+ void,
459
+ attrs,
460
+ text,
461
+ content,
462
+ } => (tag, *void, attrs, text, *content),
463
+ };
464
+
465
+ em.push('<');
466
+ em.push_str(tag);
467
+ for (name, parts) in attrs {
468
+ if let Some(value) = resolve_parts(parts, |r| xml_ref_attr(txn, t, r)) {
469
+ em.push(' ');
470
+ em.push_str(name);
471
+ em.push_str("=\"");
472
+ em.push_str(&escape_attr(&value));
473
+ em.push('\"');
474
+ }
475
+ }
476
+ em.push('>');
477
+ if void {
478
+ return;
479
+ }
480
+ if let Some(text) = text {
481
+ if let Some(value) = resolve_parts(text, |r| xml_ref_attr(txn, t, r)) {
482
+ em.push_str(&escape_text(&value));
483
+ }
484
+ }
485
+ match content {
486
+ Content::Inline => {
487
+ render_inline(txn, t, 0, true, em, rules);
488
+ em.push_str("</");
489
+ em.push_str(tag);
490
+ em.push('>');
491
+ }
492
+ Content::Blocks => {
493
+ render_inline(txn, t, 0, false, em, rules);
494
+ stack.push(Work::CloseOwned(format!("</{tag}>")));
495
+ if depth < MAX_BLOCK_DEPTH {
496
+ for child in block_children(txn, t).into_iter().rev() {
497
+ stack.push(Work::Open(child, depth + 1));
498
+ }
269
499
  }
270
500
  }
501
+ Content::None => {
502
+ em.push_str("</");
503
+ em.push_str(tag);
504
+ em.push('>');
505
+ }
271
506
  }
272
507
  }
273
508
 
@@ -305,8 +540,9 @@ fn open_listitem<T: ReadTxn>(
305
540
  txn: &T,
306
541
  t: &XmlTextRef,
307
542
  depth: usize,
308
- out: &mut String,
543
+ em: &mut Emitter,
309
544
  stack: &mut Vec<Work>,
545
+ rules: &Rules,
310
546
  ) {
311
547
  let value = match t.get_attribute(txn, "__value") {
312
548
  Some(Out::Any(Any::Number(n))) => n as i64,
@@ -317,26 +553,20 @@ fn open_listitem<T: ReadTxn>(
317
553
  Some(Out::Any(Any::Bool(b))) => Some(b),
318
554
  _ => None,
319
555
  };
320
- let has_nested_list = block_children(txn, t)
321
- .iter()
322
- .any(|c| node_type(txn, c) == "list");
323
556
 
324
- out.push_str("<li");
557
+ em.push_str("<li");
325
558
  if let Some(c) = checked {
326
- out.push_str(" aria-checked=\"");
327
- out.push_str(if c { "true" } else { "false" });
328
- out.push('"');
559
+ em.push_str(" aria-checked=\"");
560
+ em.push_str(if c { "true" } else { "false" });
561
+ em.push('"');
329
562
  }
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('>');
563
+ em.push_str(" value=\"");
564
+ em.push_str(&value.to_string());
565
+ em.push('"');
566
+ em.push('>');
337
567
  // Inline content first, then any nested list blocks (Lexical stores the
338
568
  // nested list as a child of the item).
339
- out.push_str(&render_inline(txn, t, 0));
569
+ render_inline(txn, t, 0, true, em, rules);
340
570
  push_block_children(txn, t, depth, "</li>", true, stack);
341
571
  }
342
572
 
@@ -358,14 +588,44 @@ fn is_inline_type(ty: &str) -> bool {
358
588
  matches!(ty, "link" | "autolink")
359
589
  }
360
590
 
591
+ /// The `__type` of every element/block child of a block, in document order —
592
+ /// handed to callback rules as `node.child_types` (a gallery's image count,
593
+ /// a list item's nested list). Text runs and metadata maps are not children.
594
+ fn text_child_types<T: ReadTxn>(txn: &T, t: &XmlTextRef) -> Vec<String> {
595
+ let mut out = Vec::new();
596
+ for d in t.diff(txn, YChange::identity) {
597
+ match d.insert {
598
+ Out::YXmlText(child) => out.push(node_type(txn, &child)),
599
+ Out::YXmlElement(child) => out.push(elem_type(txn, &child)),
600
+ _ => {}
601
+ }
602
+ }
603
+ out
604
+ }
605
+
361
606
  /// 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();
607
+ /// links, and inline decorators. A registered rule wins over the built-in
608
+ /// link arm. Nested block children are NOT rendered here (the block
609
+ /// renderers handle them), so a list item's text doesn't duplicate its
610
+ /// nested list. `depth` counts inline-link nesting only (a link's body is
611
+ /// itself inline content); past `MAX_INLINE_DEPTH` a link renders as flat
612
+ /// text, capping the recursion.
613
+ ///
614
+ /// `ruled_inline` says whether a non-core `Y.XmlText` child with a
615
+ /// registered rule renders here as a custom inline node. Callers that walk
616
+ /// their block children pass false — for them every non-link `Y.XmlText`
617
+ /// child renders as a block, and rendering it here too would double it.
618
+ /// Callers that don't (the leaf text blocks, list items, inline-content
619
+ /// rules, link bodies) pass true: for them such a child would otherwise be
620
+ /// dropped.
621
+ fn render_inline<T: ReadTxn>(
622
+ txn: &T,
623
+ t: &XmlTextRef,
624
+ depth: usize,
625
+ ruled_inline: bool,
626
+ em: &mut Emitter,
627
+ rules: &Rules,
628
+ ) {
369
629
  // Format carries from the metadata Map that precedes each run. Lexxy always
370
630
  // emits one Map immediately before its run, so this is exact; a run with no
371
631
  // preceding Map would inherit the previous run's format (wrong, but only
@@ -381,7 +641,7 @@ fn render_inline<T: ReadTxn>(txn: &T, t: &XmlTextRef, depth: usize) -> String {
381
641
  _ => String::new(),
382
642
  };
383
643
  match ty.as_str() {
384
- "linebreak" => out.push_str("<br>"),
644
+ "linebreak" => em.push_str("<br>"),
385
645
  "tab" => {
386
646
  is_tab = true;
387
647
  format = 0;
@@ -406,24 +666,30 @@ fn render_inline<T: ReadTxn>(txn: &T, t: &XmlTextRef, depth: usize) -> String {
406
666
  Out::Any(Any::String(s)) => {
407
667
  if is_tab {
408
668
  // Lexxy exports a tab as a literal \t in a span.
409
- out.push_str("<span>\t</span>");
669
+ em.push_str("<span>\t</span>");
410
670
  is_tab = false;
411
671
  } else {
412
- out.push_str(&render_run(&s, format, &style));
672
+ em.push_str(&render_run(&s, format, &style));
413
673
  }
414
674
  }
415
675
  Out::YXmlText(child) => {
416
676
  let ty = node_type(txn, &child);
417
677
  if is_inline_type(&ty) {
418
- render_link(txn, &child, depth, &mut out);
678
+ match rules.nodes.get(ty.as_str()) {
679
+ Some(rule) => render_rule_inline(txn, &child, &ty, rule, depth, em, rules),
680
+ None => render_link(txn, &child, depth, em, rules),
681
+ }
682
+ } else if ruled_inline && !is_builtin(&ty) {
683
+ if let Some(rule) = rules.nodes.get(ty.as_str()) {
684
+ render_rule_inline(txn, &child, &ty, rule, depth, em, rules);
685
+ }
419
686
  }
420
687
  // Nested blocks are rendered by their parent block's renderer.
421
688
  }
422
- Out::YXmlElement(e) => render_inline_decorator(txn, &e, &mut out),
689
+ Out::YXmlElement(e) => render_decorator(txn, &e, em, rules),
423
690
  _ => {}
424
691
  }
425
692
  }
426
- out
427
693
  }
428
694
 
429
695
  /// One text run with Lexical's format bitmask, as Lexxy's exporter wraps it.
@@ -512,146 +778,168 @@ fn lexxy_style(style: &str) -> Option<String> {
512
778
 
513
779
  /// `link` / `autolink`: Lexxy's sanitize keeps only `href` and `title`
514
780
  /// (`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");
781
+ fn render_link<T: ReadTxn>(txn: &T, t: &XmlTextRef, depth: usize, em: &mut Emitter, rules: &Rules) {
782
+ em.push_str("<a");
517
783
  if let Some(url) = str_attr(txn, t, "__url") {
518
- out.push_str(" href=\"");
519
- out.push_str(&escape_attr(&url));
520
- out.push('"');
784
+ em.push_str(" href=\"");
785
+ em.push_str(&escape_attr(&url));
786
+ em.push('"');
521
787
  }
522
788
  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('"');
789
+ em.push_str(" title=\"");
790
+ em.push_str(&escape_attr(&title));
791
+ em.push('"');
526
792
  }
527
- out.push('>');
793
+ em.push('>');
528
794
  if depth < MAX_INLINE_DEPTH {
529
- out.push_str(&render_inline(txn, t, depth + 1));
795
+ render_inline(txn, t, depth + 1, true, em, rules);
530
796
  } else {
531
797
  // A link chain nested past the cap (only crafted input reaches here):
532
798
  // keep its text, drop any further link structure.
533
- out.push_str(&escape_text(&t.get_string(txn)));
799
+ em.push_str(&escape_text(&t.get_string(txn)));
534
800
  }
535
- out.push_str("</a>");
801
+ em.push_str("</a>");
536
802
  }
537
803
 
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
804
+ /// A rule node in inline position (a `Y.XmlText` child inside a text block):
805
+ /// an overridden link, or a custom inline node the core schema doesn't know.
806
+ /// Its body is inline content, so a blocks content slot behaves like inline
807
+ /// here, and `depth` carries the inline nesting cap through.
808
+ fn render_rule_inline<T: ReadTxn>(
809
+ txn: &T,
810
+ t: &XmlTextRef,
811
+ ty: &str,
812
+ rule: &NodeRule,
813
+ depth: usize,
814
+ em: &mut Emitter,
815
+ rules: &Rules,
816
+ ) {
817
+ let (tag, void, attrs, text, content) = match rule {
818
+ NodeRule::Callback { content } => {
819
+ em.begin_frame();
820
+ if *content != Content::None && depth < MAX_INLINE_DEPTH {
821
+ render_inline(txn, t, depth + 1, true, em, rules);
822
+ }
823
+ let captured = em.end_frame();
824
+ em.emit_deferred(
825
+ ty.to_string(),
826
+ xml_attrs_json(txn, t),
827
+ text_child_types(txn, t),
828
+ captured,
829
+ );
830
+ return;
831
+ }
832
+ NodeRule::Declarative {
833
+ tag,
834
+ void,
835
+ attrs,
836
+ text,
837
+ content,
838
+ } => (tag, *void, attrs, text, *content),
839
+ };
840
+ em.push('<');
841
+ em.push_str(tag);
842
+ for (name, parts) in attrs {
843
+ if let Some(value) = resolve_parts(parts, |r| xml_ref_attr(txn, t, r)) {
844
+ em.push(' ');
845
+ em.push_str(name);
846
+ em.push_str("=\"");
847
+ em.push_str(&escape_attr(&value));
848
+ em.push('\"');
849
+ }
545
850
  }
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
- _ => {}
851
+ em.push('>');
852
+ if void {
853
+ return;
555
854
  }
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(),
855
+ if let Some(text) = text {
856
+ if let Some(value) = resolve_parts(text, |r| xml_ref_attr(txn, t, r)) {
857
+ em.push_str(&escape_text(&value));
858
+ }
562
859
  }
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,
860
+ if content != Content::None && depth < MAX_INLINE_DEPTH {
861
+ render_inline(txn, t, depth + 1, true, em, rules);
569
862
  }
863
+ em.push_str("</");
864
+ em.push_str(tag);
865
+ em.push('>');
570
866
  }
571
867
 
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,
868
+ /// Decorator elements (root-level or inline): core Lexical's horizontal rule,
869
+ /// plus whatever the registered rules cover Lexxy's attachments arrive that
870
+ /// way. Rules win here too, so custom decorators render or defer.
871
+ fn render_decorator<T: ReadTxn>(txn: &T, e: &XmlElementRef, em: &mut Emitter, rules: &Rules) {
872
+ let ty = elem_type(txn, e);
873
+ if let Some(rule) = rules.nodes.get(ty.as_str()) {
874
+ render_rule_element(txn, e, &ty, rule, em);
875
+ return;
876
+ }
877
+ // Core Lexical's only decorator; unknown decorators render nothing
878
+ // (nothing extractable without a rule).
879
+ if ty == "horizontalrule" {
880
+ em.push_str("<hr>");
588
881
  }
589
882
  }
590
883
 
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);
884
+ /// A decorator element rendered through a registered rule. Decorators are
885
+ /// childless in practice, so declarative rules render tag + attrs + template
886
+ /// text (content slots are ignored) and callback rules defer with empty
887
+ /// content.
888
+ fn render_rule_element<T: ReadTxn>(
889
+ txn: &T,
890
+ e: &XmlElementRef,
891
+ ty: &str,
892
+ rule: &NodeRule,
893
+ em: &mut Emitter,
894
+ ) {
895
+ let (tag, void, attrs, text) = match rule {
896
+ NodeRule::Callback { .. } => {
897
+ em.emit_deferred(
898
+ ty.to_string(),
899
+ xml_attrs_json(txn, e),
900
+ Vec::new(),
901
+ Vec::new(),
902
+ );
903
+ return;
904
+ }
905
+ NodeRule::Declarative {
906
+ tag,
907
+ void,
908
+ attrs,
909
+ text,
910
+ ..
911
+ } => (tag, *void, attrs, text),
912
+ };
913
+ em.push('<');
914
+ em.push_str(tag);
915
+ for (name, parts) in attrs {
916
+ if let Some(value) = resolve_parts(parts, |r| xml_ref_attr(txn, e, r)) {
917
+ em.push(' ');
918
+ em.push_str(name);
919
+ em.push_str("=\"");
920
+ em.push_str(&escape_attr(&value));
921
+ em.push('\"');
922
+ }
630
923
  }
631
- if let Some(v) = elem_num(txn, e, "width") {
632
- push_attr(out, "width", &v);
924
+ em.push('>');
925
+ if void {
926
+ return;
633
927
  }
634
- if let Some(v) = elem_num(txn, e, "height") {
635
- push_attr(out, "height", &v);
928
+ if let Some(text) = text {
929
+ if let Some(value) = resolve_parts(text, |r| xml_ref_attr(txn, e, r)) {
930
+ em.push_str(&escape_text(&value));
931
+ }
636
932
  }
637
- push_attr(out, "presentation", "gallery");
638
- out.push_str("></action-text-attachment>");
933
+ em.push_str("</");
934
+ em.push_str(tag);
935
+ em.push('>');
639
936
  }
640
937
 
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);
938
+ fn elem_type<T: ReadTxn>(txn: &T, e: &XmlElementRef) -> String {
939
+ match e.get_attribute(txn, "__type") {
940
+ Some(Out::Any(Any::String(s))) => s.to_string(),
941
+ _ => String::new(),
653
942
  }
654
- out.push_str("></action-text-attachment>");
655
943
  }
656
944
 
657
945
  /// Text-content escaping, matching what the browser's serializer emits:
@@ -673,15 +961,19 @@ mod tests {
673
961
  use yrs::updates::decoder::Decode;
674
962
  use yrs::{Doc, Transact, Update};
675
963
 
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
964
+ /// Core rendering of the captured full-schema document, pinned as a
965
+ /// golden (`.core.html`). Stock Lexical has no canonical serializer to
966
+ /// capture against, so this is a self-pinned regression guard; the
967
+ /// external truth — byte parity with a real Lexxy editor's value — is
968
+ /// held at the Ruby layer, where `Y::Lexxy` completes the schema.
969
+ /// The pair was captured together from one live
678
970
  /// editor session: `lexxy_full.bin` is the synced Yjs state, `lexxy_full.html`
679
971
  /// is `lexxy-editor.value` for the same document. It covers every block and
680
972
  /// format the schema map handles.
681
973
  #[test]
682
- fn renders_the_captured_lexxy_document_byte_for_byte() {
974
+ fn core_rendering_of_the_full_fixture_is_pinned() {
683
975
  let bytes = include_bytes!("fixtures/lexxy_full.bin");
684
- let expected = include_str!("fixtures/lexxy_full.html");
976
+ let expected = include_str!("fixtures/lexxy_full.core.html");
685
977
  let doc = Doc::new();
686
978
  doc.transact_mut()
687
979
  .apply_update(Update::decode_v1(bytes).unwrap())
@@ -695,13 +987,12 @@ mod tests {
695
987
  /// cells, five-level mixed lists, formatted links in headings, the full
696
988
  /// format stack, unicode and escaping edge cases, whitespace-only
697
989
  /// 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.
990
+ /// Lexxy's sanitized export drops colSpan/rowSpan, so matching it
991
+ /// byte-for-byte means not emitting them either.
701
992
  #[test]
702
- fn renders_the_captured_torture_document_byte_for_byte() {
993
+ fn core_rendering_of_the_torture_fixture_is_pinned() {
703
994
  let bytes = include_bytes!("fixtures/lexxy_torture.bin");
704
- let expected = include_str!("fixtures/lexxy_torture.html");
995
+ let expected = include_str!("fixtures/lexxy_torture.core.html");
705
996
  let doc = Doc::new();
706
997
  doc.transact_mut()
707
998
  .apply_update(Update::decode_v1(bytes).unwrap())
@@ -711,16 +1002,13 @@ mod tests {
711
1002
  let html = render(&txn, &frag).unwrap();
712
1003
  assert_eq!(html, expected.trim_end());
713
1004
 
714
- // Byte-for-byte already proves these; name the load-bearing ones so
715
- // a regression fails with the nested structure it broke.
1005
+ // The golden already proves these; name the load-bearing core
1006
+ // structures so a regression fails with the one it broke. (The
1007
+ // fixture's Lexxy-only nodes — attachments, its code type — are
1008
+ // covered at the Ruby layer, where the Lexxy rules render them.)
716
1009
  for (what, probe) in [
717
1010
  ("list nested in a table cell", "<td><ul><li"),
718
1011
  ("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
1012
  (
725
1013
  "five-level mixed list nesting",
726
1014
  "<ol><li value=\"1\"><s><i><strong>Level five</strong></i></s></li></ol>",
@@ -738,10 +1026,6 @@ mod tests {
738
1026
  "<strong>a &amp;&amp; b &lt; c &gt; d \"q\" '&amp;amp;'</strong>",
739
1027
  ),
740
1028
  ("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
1029
  (
746
1030
  "whitespace-only paragraphs",
747
1031
  "<p><span>\t</span></p><p><br></p><p><br></p>",
@@ -823,13 +1107,15 @@ mod tests {
823
1107
  assert!(html.contains("<li value=\"1\">item</li>"), "{html}");
824
1108
  }
825
1109
 
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.
1110
+ /// Lexxy-only nodes (this fixture is a gallery of attachments between
1111
+ /// two paragraphs) are unknown to the core schema and degrade readably —
1112
+ /// the paragraphs survive, the gallery renders nothing rather than
1113
+ /// garbage. Y::Lexxy's rules render it fully; the Ruby fixture tests pin
1114
+ /// that byte for byte.
829
1115
  #[test]
830
- fn renders_the_captured_gallery_document_byte_for_byte() {
1116
+ fn core_degrades_lexxy_only_nodes_readably() {
831
1117
  let bytes = include_bytes!("fixtures/lexxy_gallery.bin");
832
- let expected = include_str!("fixtures/lexxy_gallery.html");
1118
+ let expected = include_str!("fixtures/lexxy_gallery.core.html");
833
1119
  let doc = Doc::new();
834
1120
  doc.transact_mut()
835
1121
  .apply_update(Update::decode_v1(bytes).unwrap())
@@ -838,8 +1124,7 @@ mod tests {
838
1124
  let frag = txn.get_xml_fragment("root").unwrap();
839
1125
  let html = render(&txn, &frag).unwrap();
840
1126
  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);
1127
+ assert!(html.contains("<p>Before gallery</p>"));
843
1128
  }
844
1129
 
845
1130
  #[test]
@@ -982,6 +1267,44 @@ mod tests {
982
1267
  assert_eq!(html.matches("<a").count(), html.matches("</a>").count());
983
1268
  }
984
1269
 
1270
+ /// Rules reach inline position too: a rule for `link` overrides the
1271
+ /// built-in `<a>`, and a custom inline node (a `Y.XmlText` type the
1272
+ /// core schema doesn't know) renders through its rule instead of being
1273
+ /// dropped.
1274
+ #[test]
1275
+ fn rules_render_inline_nodes_and_override_links() {
1276
+ use yrs::{XmlFragment, XmlTextPrelim};
1277
+ let rules = Rules::parse(
1278
+ r#"{ "nodes": {
1279
+ "link": { "tag": "a", "attrs": [["class", [{"lit": "app-link"}]],
1280
+ ["href", [{"ref": "url"}]]] },
1281
+ "keyword": { "tag": "kbd", "attrs": [["data-kind", [{"ref": "kind"}]]] } } }"#,
1282
+ )
1283
+ .unwrap();
1284
+ let doc = Doc::new();
1285
+ let root = doc.get_or_insert_xml_fragment("root");
1286
+ {
1287
+ let mut txn = doc.transact_mut();
1288
+ let p = root.push_back(&mut txn, XmlTextPrelim::new(""));
1289
+ p.insert_attribute(&mut txn, "__type", "paragraph");
1290
+ let link = p.insert_embed(&mut txn, 0, XmlTextPrelim::new("site"));
1291
+ link.insert_attribute(&mut txn, "__type", "link");
1292
+ link.insert_attribute(&mut txn, "__url", "https://x.example");
1293
+ let kw = p.insert_embed(&mut txn, 1, XmlTextPrelim::new("crdt"));
1294
+ kw.insert_attribute(&mut txn, "__type", "keyword");
1295
+ kw.insert_attribute(&mut txn, "__kind", "term");
1296
+ }
1297
+ let txn = doc.transact();
1298
+ let frag = txn.get_xml_fragment("root").unwrap();
1299
+ let segs = render_segments(&txn, &frag, &rules).unwrap();
1300
+ let html = crate::render_rules::flatten(segs).into_html().unwrap();
1301
+ assert_eq!(
1302
+ html,
1303
+ "<p><a class=\"app-link\" href=\"https://x.example\">site</a>\
1304
+ <kbd data-kind=\"term\">crdt</kbd></p>"
1305
+ );
1306
+ }
1307
+
985
1308
  #[test]
986
1309
  fn escaping_matches_the_browser_serializer() {
987
1310
  assert_eq!(escape_text(r#"<a & "b">"#), r#"&lt;a &amp; "b"&gt;"#);