Skip to main content

lexical_yjs_html/
lib.rs

1//! HTML for Lexical documents, rendered from the Yjs structure the editor
2//! syncs. No Node process and no headless editor. A server that holds the
3//! document bytes produces the markup itself.
4//!
5//! This renders core Lexical: paragraphs, headings, quotes, code blocks,
6//! lists and list items, tables, horizontal rules, links, and the whole
7//! text-format model. Lexxy's additions live in the Ruby layer, as the
8//! `Y::Lexxy` renderer's rule set: its own node types (attachments,
9//! galleries, `early_escape_code`, `horizontal_divider`) and its decorations
10//! of core nodes (the figure around a table, header-cell styling, the
11//! nested-list-item class). That rule set uses the same extension API
12//! applications use. `Y::Lexical` is the core base class. The byte-parity
13//! guarantee is held there too: the Ruby fixture tests and the
14//! headless-Chrome end-to-end run pin `Y::Lexxy#to_html` against a real
15//! editor's serialized value. The tests in this crate pin core output as
16//! regression goldens. Stock Lexical has no canonical serializer to capture
17//! from.
18//!
19//! Prior art: `ueberdosis/tiptap-php` renders ProseMirror JSON to HTML in
20//! plain PHP, outside any JavaScript runtime. This crate does the same job
21//! from the collaborative (Yjs) structure.
22//!
23//! How Lexical stores a document, checked against bytes captured from a live
24//! editor:
25//!
26//! - Blocks are `Y.XmlText` with a `__type` attribute: `paragraph`,
27//!   `heading` (plus `__tag`), `quote`, `code` (plus `__language`), `list`
28//!   and `listitem`, `table`, `tablerow` and `tablecell`, and the inline
29//!   `link` and `autolink`.
30//! - Each text run is preceded by an embedded `Y.Map` with its metadata: a
31//!   `__type` of `text`, `code-highlight`, or `tab`, and a `__format`
32//!   bitmask.
33//! - A `linebreak` is a bare metadata map. A `tab` is a map followed by a
34//!   `"\t"` run.
35//! - Decorator nodes are `Y.XmlElement`s whose fields are plain attributes.
36//!   `horizontalrule` is handled here. Application and Lexxy decorators come
37//!   in through rules.
38//!
39//! Text formatting follows the export pipeline Lexxy runs: Lexical's
40//! `$generateHtmlFromNodes`, then sanitize. That output is the only
41//! formatting truth that can be pinned from outside. The inner tag is
42//! `strong` for bold, or `em` for italic without bold. The outer tag is
43//! `code`, `mark`, `sub`, or `sup`. An `<i>` wraps only when bold and italic
44//! combine, because the `em` slot is taken. `<s>` and `<u>` wrap whenever
45//! present. `<span>`s are unwrapped, so unformatted text is bare. A run's
46//! `__style` (highlight colors) survives on the createDOM tag, filtered to
47//! color and background-color. On a plain run, or one with only strike or
48//! underline, it goes away with the span. The case-transform format bits
49//! never render; their text-transform style is outside the sanitize
50//! whitelist.
51//!
52//! A node type with no rule still renders its text and child blocks, just
53//! unwrapped.
54//!
55//! Custom nodes register rules by `__type` (see `yjs-html-core`). A rule is
56//! consulted before the built-in arms, so it can extend the schema or
57//! replace a built-in. Declarative rules render here. Callback rules emit
58//! `Segment::Deferred` for the caller to fill in after the render returns.
59
60// README examples are living code: compile-checked on every cargo test.
61#[cfg(doctest)]
62#[doc = include_str!("../README.md")]
63mod readme_examples {}
64
65// The full rules surface, re-exported: depend on this crate alone;
66// yjs-html-core is an internal implementation crate.
67pub use yjs_html_core::*;
68use yrs::types::text::YChange;
69use yrs::{
70    Any, GetString, Map, Out, ReadTxn, Text, Xml, XmlElementRef, XmlFragment, XmlFragmentRef,
71    XmlOut, XmlTextRef,
72};
73
74// Lexical text format bitmask (lexical 0.44).
75const FMT_BOLD: u32 = 1;
76const FMT_ITALIC: u32 = 1 << 1;
77const FMT_STRIKETHROUGH: u32 = 1 << 2;
78const FMT_UNDERLINE: u32 = 1 << 3;
79const FMT_CODE: u32 = 1 << 4;
80const FMT_SUBSCRIPT: u32 = 1 << 5;
81const FMT_SUPERSCRIPT: u32 = 1 << 6;
82const FMT_HIGHLIGHT: u32 = 1 << 7;
83
84// Nesting caps. The block tree is walked on an explicit heap stack (no native
85// recursion), so these don't prevent a stack overflow — they just bound how
86// deep the renderer descends. Real docs nest a handful of levels deep (the
87// torture fixture peaks at ~8); past 1024 a subtree is dropped, but its
88// enclosing tags still close. Inline links are still walked recursively (their
89// body is inline content, never blocks), so they carry the same cap.
90const MAX_BLOCK_DEPTH: usize = 1024;
91const MAX_INLINE_DEPTH: usize = 1024;
92
93/// A unit of block work on the explicit traversal stack. `Open`
94/// renders a block node (pushing its own children as more work); `Close` and
95/// `CloseOwned` emit an end tag once a container's children have all been
96/// processed (built-in containers close with fixed strings; rule containers
97/// close with their computed tag). `EndDeferred` seals a callback node: it pops
98/// the emitter frame its children rendered into and emits the deferred segment.
99enum Work {
100    Open(XmlTextRef, usize),
101    Close(&'static str),
102    CloseOwned(String),
103    EndDeferred {
104        node_type: String,
105        attrs_json: String,
106        child_types: Vec<String>,
107    },
108}
109
110/// Render a Lexical/Lexxy-shaped XML root, or `None` when the root isn't
111/// Lexical-shaped. Lexical marks every node with a `__type` attribute; a
112/// root whose children carry none — a ProseMirror document, say, whose blocks
113/// are plain `<paragraph>` elements — is a foreign schema, and render returns
114/// `None` for it rather than a lossy guess.
115///
116/// A `__type` the renderer doesn't recognize is handled differently: a
117/// registered rule renders it; otherwise a leaf's text renders in a `<p>`
118/// and a container's block children render without an invented wrapper, so
119/// an editor node the schema hasn't heard of stays readable.
120pub fn render_segments<T: ReadTxn>(
121    txn: &T,
122    fragment: &XmlFragmentRef,
123    rules: &Rules,
124) -> Option<Vec<Segment>> {
125    if !is_lexical_shaped(txn, fragment) {
126        return None;
127    }
128    let mut em = Emitter::new();
129    for node in fragment.children(txn) {
130        match node {
131            XmlOut::Text(t) => render_block_tree(txn, &t, &mut em, rules),
132            XmlOut::Element(e) => render_decorator(txn, &e, &mut em, rules),
133            // Fragments can't nest as children in yrs; escape the text as the
134            // safe degradation for an exhaustive match.
135            XmlOut::Fragment(f) => em.push_str(&escape_text(&f.get_string(txn))),
136        }
137    }
138    Some(em.into_segments())
139}
140
141/// Rule-free rendering to a plain string — the simplest way to use this
142/// crate standalone, and the fixture-parity surface the tests pin. With no
143/// callback rules, segments always flatten.
144pub fn render<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> Option<String> {
145    render_segments(txn, fragment, &Rules::empty()).map(|segs| {
146        yjs_html_core::flatten(segs)
147            .into_html()
148            .expect("no callback rules registered")
149    })
150}
151
152/// The node types a Lexical renderer's built-in arms cover (core Lexical;
153/// inline and decorator types included). Everything else needs a rule.
154pub fn is_builtin(ty: &str) -> bool {
155    matches!(
156        ty,
157        "paragraph"
158            | "heading"
159            | "quote"
160            | "code"
161            | "list"
162            | "listitem"
163            | "table"
164            | "tablerow"
165            | "tablecell"
166            | "link"
167            | "autolink"
168            | "linebreak"
169            | "tab"
170            | "text"
171            | "horizontalrule"
172    )
173}
174
175/// Walk the document and record what each node type actually looks like —
176/// the discovery aid behind `Y::Lexical#node_types`. It records facts:
177/// counts, attribute names (minus `__type`, which is the key), child types,
178/// and whether text runs were seen.
179pub fn collect_node_types<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> Option<TypeMap> {
180    if !is_lexical_shaped(txn, fragment) {
181        return None;
182    }
183    let mut map = TypeMap::new();
184    for node in fragment.children(txn) {
185        match node {
186            XmlOut::Text(t) => observe_text_node(txn, &t, &mut map, 0),
187            XmlOut::Element(e) => observe_element(txn, &e, &mut map),
188            XmlOut::Fragment(_) => {}
189        }
190    }
191    Some(map)
192}
193
194fn observe_text_node<T: ReadTxn>(txn: &T, t: &XmlTextRef, map: &mut TypeMap, depth: usize) {
195    let ty = node_type(txn, t);
196    let info = map.entry(ty.clone()).or_default();
197    info.count += 1;
198    for (key, _) in t.attributes(txn) {
199        if key != "__type" {
200            info.attrs.insert(key.to_string());
201        }
202    }
203    if depth >= MAX_BLOCK_DEPTH {
204        return;
205    }
206    let mut children = Vec::new();
207    for d in t.diff(txn, YChange::identity) {
208        match d.insert {
209            Out::Any(Any::String(_)) => {
210                map.get_mut(&ty).expect("just inserted").text = true;
211            }
212            Out::YXmlText(child) => {
213                let child_ty = node_type(txn, &child);
214                map.get_mut(&ty)
215                    .expect("just inserted")
216                    .children
217                    .insert(child_ty);
218                children.push(child);
219            }
220            Out::YXmlElement(child) => {
221                let child_ty = elem_type(txn, &child);
222                map.get_mut(&ty)
223                    .expect("just inserted")
224                    .children
225                    .insert(child_ty);
226                observe_element(txn, &child, map);
227            }
228            _ => {} // run-metadata maps are formatting, not children
229        }
230    }
231    for child in children {
232        observe_text_node(txn, &child, map, depth + 1);
233    }
234}
235
236fn observe_element<T: ReadTxn>(txn: &T, e: &XmlElementRef, map: &mut TypeMap) {
237    let ty = elem_type(txn, e);
238    let info = map.entry(ty).or_default();
239    info.count += 1;
240    for (key, _) in e.attributes(txn) {
241        if key != "__type" {
242            info.attrs.insert(key.to_string());
243        }
244    }
245}
246
247/// A root is Lexical-shaped when it is empty or at least one child carries the
248/// `__type` attribute Lexical stamps on every node.
249fn is_lexical_shaped<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> bool {
250    let mut any_child = false;
251    for node in fragment.children(txn) {
252        any_child = true;
253        let typed = match &node {
254            XmlOut::Text(t) => t.get_attribute(txn, "__type").is_some(),
255            XmlOut::Element(e) => e.get_attribute(txn, "__type").is_some(),
256            XmlOut::Fragment(_) => false,
257        };
258        if typed {
259            return true;
260        }
261    }
262    !any_child // an empty document renders to an empty string
263}
264
265/// The `__type` attribute of a block/inline `Y.XmlText`.
266fn node_type<T: ReadTxn>(txn: &T, t: &XmlTextRef) -> String {
267    match t.get_attribute(txn, "__type") {
268        Some(Out::Any(Any::String(s))) => s.to_string(),
269        _ => String::new(),
270    }
271}
272
273/// A string attribute of a block (e.g. `__tag`, `__language`, `__url`).
274fn str_attr<T: ReadTxn>(txn: &T, t: &XmlTextRef, name: &str) -> Option<String> {
275    match t.get_attribute(txn, name) {
276        Some(Out::Any(Any::String(s))) => Some(s.to_string()),
277        _ => None,
278    }
279}
280
281/// Walk a top-level block and everything under it on an explicit heap stack.
282/// Container blocks push their children back as more work plus a close for
283/// their end tag; leaf blocks render in full on the spot. The stack lives on
284/// the heap, so nesting depth can't overflow the native call stack.
285fn render_block_tree<T: ReadTxn>(txn: &T, root: &XmlTextRef, em: &mut Emitter, rules: &Rules) {
286    let mut stack: Vec<Work> = vec![Work::Open(root.clone(), 0)];
287    while let Some(work) = stack.pop() {
288        match work {
289            Work::Close(tag) => em.push_str(tag),
290            Work::CloseOwned(tag) => em.push_str(&tag),
291            Work::EndDeferred {
292                node_type,
293                attrs_json,
294                child_types,
295            } => {
296                let content = em.end_frame();
297                em.emit_deferred(node_type, attrs_json, child_types, content);
298            }
299            Work::Open(node, depth) => open_block(txn, &node, depth, em, &mut stack, rules),
300        }
301    }
302}
303
304/// Render one block. A registered rule wins over the built-in arms (so apps
305/// can extend the schema or override a built-in); a container emits its
306/// opening tag now and defers its children (and matching close) to the stack;
307/// a leaf renders completely.
308fn open_block<T: ReadTxn>(
309    txn: &T,
310    t: &XmlTextRef,
311    depth: usize,
312    em: &mut Emitter,
313    stack: &mut Vec<Work>,
314    rules: &Rules,
315) {
316    let ty = node_type(txn, t);
317    if let Some(rule) = rules.nodes.get(ty.as_str()) {
318        open_rule_block(txn, t, &ty, rule, depth, em, stack, rules);
319        return;
320    }
321    match ty.as_str() {
322        "paragraph" => {
323            // An empty paragraph exports with a <br>, as Lexical's own
324            // paragraph export does.
325            em.begin_frame();
326            render_inline(txn, t, 0, true, em, rules);
327            let inline = em.end_frame();
328            if inline.is_empty() {
329                em.push_str("<p><br></p>");
330            } else {
331                em.push_str("<p>");
332                em.append(inline);
333                em.push_str("</p>");
334            }
335        }
336        "heading" => {
337            let tag = match str_attr(txn, t, "__tag").as_deref() {
338                Some(tag @ ("h1" | "h2" | "h3" | "h4" | "h5" | "h6")) => tag.to_string(),
339                _ => "h1".to_string(),
340            };
341            em.push('<');
342            em.push_str(&tag);
343            em.push('>');
344            render_inline(txn, t, 0, true, em, rules);
345            em.push_str("</");
346            em.push_str(&tag);
347            em.push('>');
348        }
349        "quote" => {
350            em.push_str("<blockquote>");
351            render_inline(txn, t, 0, true, em, rules);
352            em.push_str("</blockquote>");
353        }
354        "code" => {
355            // Code highlighting is derived state: token runs flatten to
356            // plain text; linebreaks are <br>, tabs a wrapped \t.
357            em.push_str("<pre");
358            if let Some(lang) = str_attr(txn, t, "__language").filter(|l| !l.is_empty()) {
359                em.push_str(" data-language=\"");
360                em.push_str(&escape_attr(&lang));
361                em.push('"');
362            }
363            em.push('>');
364            render_inline(txn, t, 0, true, em, rules);
365            em.push_str("</pre>");
366        }
367        "list" => {
368            let tag = match str_attr(txn, t, "__tag").as_deref() {
369                Some("ol") => "ol",
370                _ => "ul",
371            };
372            em.push('<');
373            em.push_str(tag);
374            em.push('>');
375            // Direct inline content is crafted-only (Lexxy puts none here);
376            // keep it rather than drop it. Safe from double-render: this skips
377            // block children, and push_block_children skips inline content.
378            render_inline(txn, t, 0, false, em, rules);
379            let close = if tag == "ol" { "</ol>" } else { "</ul>" };
380            push_block_children(txn, t, depth, close, false, stack);
381        }
382        "listitem" => open_listitem(txn, t, depth, em, stack, rules),
383        "table" => {
384            em.push_str("<table><tbody>");
385            render_inline(txn, t, 0, false, em, rules);
386            push_block_children(txn, t, depth, "</tbody></table>", false, stack);
387        }
388        "tablerow" => {
389            em.push_str("<tr>");
390            render_inline(txn, t, 0, false, em, rules);
391            push_block_children(txn, t, depth, "</tr>", false, stack);
392        }
393        "tablecell" => {
394            let header = matches!(
395                t.get_attribute(txn, "__headerState"),
396                Some(Out::Any(Any::Number(n))) if n > 0.0
397            ) || matches!(
398                t.get_attribute(txn, "__headerState"),
399                Some(Out::Any(Any::BigInt(n))) if n > 0
400            );
401            let close = if header {
402                em.push_str("<th>");
403                "</th>"
404            } else {
405                em.push_str("<td>");
406                "</td>"
407            };
408            render_inline(txn, t, 0, false, em, rules);
409            push_block_children(txn, t, depth, close, false, stack);
410        }
411        // A block type this renderer doesn't know: degrade readably instead
412        // of dropping content. A container's block children render with no
413        // invented wrapper (a Lexxy table wrapper's rows still come out as
414        // rows); a leaf's text becomes a plain paragraph.
415        _ => {
416            if !block_children(txn, t).is_empty() {
417                render_inline(txn, t, 0, false, em, rules);
418                push_block_children(txn, t, depth, "", false, stack);
419            } else {
420                em.begin_frame();
421                render_inline(txn, t, 0, true, em, rules);
422                let inline = em.end_frame();
423                if !inline.is_empty() {
424                    em.push_str("<p>");
425                    em.append(inline);
426                    em.push_str("</p>");
427                }
428            }
429        }
430    }
431}
432
433/// Render a block through a registered rule. Declarative rules emit the tag,
434/// resolved attributes, and template text here; callback rules capture their
435/// children into a frame and defer the markup to the caller.
436#[allow(clippy::too_many_arguments)]
437fn open_rule_block<T: ReadTxn>(
438    txn: &T,
439    t: &XmlTextRef,
440    ty: &str,
441    rule: &NodeRule,
442    depth: usize,
443    em: &mut Emitter,
444    stack: &mut Vec<Work>,
445    rules: &Rules,
446) {
447    let (tag, void, attrs, text, content) = match rule {
448        NodeRule::Callback { content } => {
449            em.begin_frame();
450            // Children render into the frame; EndDeferred seals it. Blocks go
451            // via the stack (pushed above the marker, so they complete first);
452            // inline content renders now — in blocks mode too, since a block
453            // like a list item holds its own text alongside its nested blocks.
454            stack.push(Work::EndDeferred {
455                node_type: ty.to_string(),
456                attrs_json: xml_attrs_json(txn, t),
457                child_types: text_child_types(txn, t),
458            });
459            match content {
460                Content::Inline => render_inline(txn, t, 0, true, em, rules),
461                Content::Blocks => {
462                    render_inline(txn, t, 0, false, em, rules);
463                    for child in block_children(txn, t).into_iter().rev() {
464                        if depth < MAX_BLOCK_DEPTH {
465                            stack.push(Work::Open(child, depth + 1));
466                        }
467                    }
468                }
469                Content::None => {}
470            }
471            return;
472        }
473        NodeRule::Declarative {
474            tag,
475            void,
476            attrs,
477            text,
478            content,
479        } => (tag, *void, attrs, text, *content),
480    };
481
482    em.push('<');
483    em.push_str(tag);
484    for (name, parts) in attrs {
485        if let Some(value) = resolve_parts(parts, |r| xml_ref_attr(txn, t, r)) {
486            em.push(' ');
487            em.push_str(name);
488            em.push_str("=\"");
489            em.push_str(&escape_attr(&value));
490            em.push('\"');
491        }
492    }
493    em.push('>');
494    if void {
495        return;
496    }
497    if let Some(text) = text {
498        if let Some(value) = resolve_parts(text, |r| xml_ref_attr(txn, t, r)) {
499            em.push_str(&escape_text(&value));
500        }
501    }
502    match content {
503        Content::Inline => {
504            render_inline(txn, t, 0, true, em, rules);
505            em.push_str("</");
506            em.push_str(tag);
507            em.push('>');
508        }
509        Content::Blocks => {
510            render_inline(txn, t, 0, false, em, rules);
511            stack.push(Work::CloseOwned(format!("</{tag}>")));
512            if depth < MAX_BLOCK_DEPTH {
513                for child in block_children(txn, t).into_iter().rev() {
514                    stack.push(Work::Open(child, depth + 1));
515                }
516            }
517        }
518        Content::None => {
519            em.push_str("</");
520            em.push_str(tag);
521            em.push('>');
522        }
523    }
524}
525
526/// Defer a container's block children onto the stack, with its closing tag
527/// below them, so the children render in order and the tag closes after. Past
528/// `MAX_BLOCK_DEPTH` the children are dropped (the container still closes, so
529/// the output stays well formed). `only_lists` keeps just nested-list children,
530/// for list items whose inline content the caller has already emitted.
531fn push_block_children<T: ReadTxn>(
532    txn: &T,
533    t: &XmlTextRef,
534    depth: usize,
535    close: &'static str,
536    only_lists: bool,
537    stack: &mut Vec<Work>,
538) {
539    stack.push(Work::Close(close));
540    if depth >= MAX_BLOCK_DEPTH {
541        return;
542    }
543    // Reversed: the stack is LIFO, so the last pushed child is rendered first.
544    for child in block_children(txn, t).into_iter().rev() {
545        if only_lists && node_type(txn, &child) != "list" {
546            continue;
547        }
548        stack.push(Work::Open(child, depth + 1));
549    }
550}
551
552/// `<li>`: attribute order follows Lexxy's export — checked items put
553/// `aria-checked` before `value`; items holding a nested list append the
554/// `lexxy-nested-listitem` class after `value`. Inline content renders now;
555/// the nested list (if any) is deferred to the stack.
556fn open_listitem<T: ReadTxn>(
557    txn: &T,
558    t: &XmlTextRef,
559    depth: usize,
560    em: &mut Emitter,
561    stack: &mut Vec<Work>,
562    rules: &Rules,
563) {
564    let value = match t.get_attribute(txn, "__value") {
565        Some(Out::Any(Any::Number(n))) => n as i64,
566        Some(Out::Any(Any::BigInt(n))) => n,
567        _ => 1,
568    };
569    let checked = match t.get_attribute(txn, "__checked") {
570        Some(Out::Any(Any::Bool(b))) => Some(b),
571        _ => None,
572    };
573
574    em.push_str("<li");
575    if let Some(c) = checked {
576        em.push_str(" aria-checked=\"");
577        em.push_str(if c { "true" } else { "false" });
578        em.push('"');
579    }
580    em.push_str(" value=\"");
581    em.push_str(&value.to_string());
582    em.push('"');
583    em.push('>');
584    // Inline content first, then any nested list blocks (Lexical stores the
585    // nested list as a child of the item).
586    render_inline(txn, t, 0, true, em, rules);
587    push_block_children(txn, t, depth, "</li>", true, stack);
588}
589
590/// The `Y.XmlText` children of a block that are themselves blocks (list items,
591/// nested lists, table rows/cells, cell paragraphs) — inline types excluded.
592fn block_children<T: ReadTxn>(txn: &T, t: &XmlTextRef) -> Vec<XmlTextRef> {
593    let mut out = Vec::new();
594    for d in t.diff(txn, YChange::identity) {
595        if let Out::YXmlText(child) = d.insert {
596            if !is_inline_type(&node_type(txn, &child)) {
597                out.push(child);
598            }
599        }
600    }
601    out
602}
603
604fn is_inline_type(ty: &str) -> bool {
605    matches!(ty, "link" | "autolink")
606}
607
608/// The `__type` of every element/block child of a block, in document order —
609/// handed to callback rules as `node.child_types` (a gallery's image count,
610/// a list item's nested list). Text runs and metadata maps are not children.
611fn text_child_types<T: ReadTxn>(txn: &T, t: &XmlTextRef) -> Vec<String> {
612    let mut out = Vec::new();
613    for d in t.diff(txn, YChange::identity) {
614        match d.insert {
615            Out::YXmlText(child) => out.push(node_type(txn, &child)),
616            Out::YXmlElement(child) => out.push(elem_type(txn, &child)),
617            _ => {}
618        }
619    }
620    out
621}
622
623/// Render a block's inline content: formatted text runs, linebreaks, tabs,
624/// links, and inline decorators. A registered rule wins over the built-in
625/// link arm. Nested block children are NOT rendered here (the block
626/// renderers handle them), so a list item's text doesn't duplicate its
627/// nested list. `depth` counts inline-link nesting only (a link's body is
628/// itself inline content); past `MAX_INLINE_DEPTH` a link renders as flat
629/// text, capping the recursion.
630///
631/// `ruled_inline` says whether a non-core `Y.XmlText` child with a
632/// registered rule renders here as a custom inline node. Callers that walk
633/// their block children pass false — for them every non-link `Y.XmlText`
634/// child renders as a block, and rendering it here too would double it.
635/// Callers that don't (the leaf text blocks, list items, inline-content
636/// rules, link bodies) pass true: for them such a child would otherwise be
637/// dropped.
638fn render_inline<T: ReadTxn>(
639    txn: &T,
640    t: &XmlTextRef,
641    depth: usize,
642    ruled_inline: bool,
643    em: &mut Emitter,
644    rules: &Rules,
645) {
646    // Format carries from the metadata Map that precedes each run. Lexxy always
647    // emits one Map immediately before its run, so this is exact; a run with no
648    // preceding Map would inherit the previous run's format (wrong, but only
649    // reachable from a doc Lexxy didn't produce).
650    let mut format: u32 = 0;
651    let mut style = String::new();
652    let mut is_tab = false;
653    for d in t.diff(txn, YChange::identity) {
654        match d.insert {
655            Out::YMap(m) => {
656                let ty = match m.get(txn, "__type") {
657                    Some(Out::Any(Any::String(s))) => s.to_string(),
658                    _ => String::new(),
659                };
660                match ty.as_str() {
661                    "linebreak" => em.push_str("<br>"),
662                    "tab" => {
663                        is_tab = true;
664                        format = 0;
665                        style.clear();
666                    }
667                    // "text", "code-highlight", and anything metadata-shaped:
668                    // read the format bits and style for the run that follows.
669                    _ => {
670                        is_tab = false;
671                        format = match m.get(txn, "__format") {
672                            Some(Out::Any(Any::Number(n))) => n as u32,
673                            Some(Out::Any(Any::BigInt(n))) => n as u32,
674                            _ => 0,
675                        };
676                        style = match m.get(txn, "__style") {
677                            Some(Out::Any(Any::String(s))) => s.to_string(),
678                            _ => String::new(),
679                        };
680                    }
681                }
682            }
683            Out::Any(Any::String(s)) => {
684                if is_tab {
685                    // Lexxy exports a tab as a literal \t in a span.
686                    em.push_str("<span>\t</span>");
687                    is_tab = false;
688                } else {
689                    em.push_str(&render_run(&s, format, &style));
690                }
691            }
692            Out::YXmlText(child) => {
693                let ty = node_type(txn, &child);
694                if is_inline_type(&ty) {
695                    match rules.nodes.get(ty.as_str()) {
696                        Some(rule) => render_rule_inline(txn, &child, &ty, rule, depth, em, rules),
697                        None => render_link(txn, &child, depth, em, rules),
698                    }
699                } else if ruled_inline && !is_builtin(&ty) {
700                    if let Some(rule) = rules.nodes.get(ty.as_str()) {
701                        render_rule_inline(txn, &child, &ty, rule, depth, em, rules);
702                    }
703                }
704                // Nested blocks are rendered by their parent block's renderer.
705            }
706            Out::YXmlElement(e) => render_decorator(txn, &e, em, rules),
707            _ => {}
708        }
709    }
710}
711
712/// One text run with Lexical's format bitmask, as Lexxy's exporter wraps it.
713fn render_run(text: &str, format: u32, style: &str) -> String {
714    let mut html = escape_text(text);
715    // A run's __style survives export only on a real createDOM tag (colors
716    // from Lexxy's highlight dropdown). The style attribute rides the OUTER
717    // tag when one exists, else the inner tag; a plain or s/u-only run's span
718    // is unwrapped, and the style dies with it — all captured behavior.
719    let css = lexxy_style(style);
720    let outer_tag = if format & FMT_CODE != 0 {
721        Some("code")
722    } else if format & FMT_HIGHLIGHT != 0 {
723        Some("mark")
724    } else if format & FMT_SUBSCRIPT != 0 {
725        Some("sub")
726    } else if format & FMT_SUPERSCRIPT != 0 {
727        Some("sup")
728    } else {
729        None
730    };
731
732    // Inner semantic tag (createDOM): strong for bold, em for italic-only.
733    // A plain run's span is unwrapped, leaving bare text.
734    let inner_style = if outer_tag.is_none() {
735        css.as_deref()
736    } else {
737        None
738    };
739    if format & FMT_BOLD != 0 {
740        html = format_wrap(html, "strong", inner_style);
741    } else if format & FMT_ITALIC != 0 {
742        html = format_wrap(html, "em", inner_style);
743    }
744    // Outer semantic tag (createDOM): first match wins.
745    if let Some(tag) = outer_tag {
746        html = format_wrap(html, tag, css.as_deref());
747    }
748    // Lexxy's wrap pass: <i> only when italic couldn't claim the inner tag
749    // (bold took it); <b> never fires (bold always claims strong); <s>/<u>
750    // always wrap.
751    if format & FMT_ITALIC != 0 && format & FMT_BOLD != 0 {
752        html = format_wrap(html, "i", None);
753    }
754    if format & FMT_STRIKETHROUGH != 0 {
755        html = format_wrap(html, "s", None);
756    }
757    if format & FMT_UNDERLINE != 0 {
758        html = format_wrap(html, "u", None);
759    }
760    html
761}
762
763fn format_wrap(inner: String, tag: &str, style: Option<&str>) -> String {
764    match style {
765        Some(css) => format!("<{tag} style=\"{}\">{inner}</{tag}>", escape_attr(css)),
766        None => format!("<{tag}>{inner}</{tag}>"),
767    }
768}
769
770/// Filter a run's `__style` to what Lexxy's sanitize lets through — `color`
771/// and `background-color` — keeping source order, serialized the way the
772/// sanitize hook rebuilds it: `prop: value;` with no separator between
773/// properties. Everything else (text-transform, white-space, smuggled
774/// properties) is stripped, matching the captured value output.
775fn lexxy_style(style: &str) -> Option<String> {
776    let mut css = String::new();
777    for decl in style.split(';') {
778        let Some((prop, value)) = decl.split_once(':') else {
779            continue;
780        };
781        let (prop, value) = (prop.trim(), value.trim());
782        if (prop == "color" || prop == "background-color") && !value.is_empty() {
783            css.push_str(prop);
784            css.push_str(": ");
785            css.push_str(value);
786            css.push(';');
787        }
788    }
789    if css.is_empty() { None } else { Some(css) }
790}
791
792/// `link` / `autolink`: Lexxy's sanitize keeps only `href` and `title`
793/// (`target`/`rel` are stored in the doc but stripped from exported HTML).
794fn render_link<T: ReadTxn>(txn: &T, t: &XmlTextRef, depth: usize, em: &mut Emitter, rules: &Rules) {
795    em.push_str("<a");
796    if let Some(url) = str_attr(txn, t, "__url") {
797        em.push_str(" href=\"");
798        em.push_str(&escape_attr(&url));
799        em.push('"');
800    }
801    if let Some(title) = str_attr(txn, t, "__title").filter(|s| !s.is_empty()) {
802        em.push_str(" title=\"");
803        em.push_str(&escape_attr(&title));
804        em.push('"');
805    }
806    em.push('>');
807    if depth < MAX_INLINE_DEPTH {
808        render_inline(txn, t, depth + 1, true, em, rules);
809    } else {
810        // A link chain nested past the cap (only crafted input reaches here):
811        // keep its text, drop any further link structure.
812        em.push_str(&escape_text(&t.get_string(txn)));
813    }
814    em.push_str("</a>");
815}
816
817/// A rule node in inline position (a `Y.XmlText` child inside a text block):
818/// an overridden link, or a custom inline node the core schema doesn't know.
819/// Its body is inline content, so a blocks content slot behaves like inline
820/// here, and `depth` carries the inline nesting cap through.
821fn render_rule_inline<T: ReadTxn>(
822    txn: &T,
823    t: &XmlTextRef,
824    ty: &str,
825    rule: &NodeRule,
826    depth: usize,
827    em: &mut Emitter,
828    rules: &Rules,
829) {
830    let (tag, void, attrs, text, content) = match rule {
831        NodeRule::Callback { content } => {
832            em.begin_frame();
833            if *content != Content::None && depth < MAX_INLINE_DEPTH {
834                render_inline(txn, t, depth + 1, true, em, rules);
835            }
836            let captured = em.end_frame();
837            em.emit_deferred(
838                ty.to_string(),
839                xml_attrs_json(txn, t),
840                text_child_types(txn, t),
841                captured,
842            );
843            return;
844        }
845        NodeRule::Declarative {
846            tag,
847            void,
848            attrs,
849            text,
850            content,
851        } => (tag, *void, attrs, text, *content),
852    };
853    em.push('<');
854    em.push_str(tag);
855    for (name, parts) in attrs {
856        if let Some(value) = resolve_parts(parts, |r| xml_ref_attr(txn, t, r)) {
857            em.push(' ');
858            em.push_str(name);
859            em.push_str("=\"");
860            em.push_str(&escape_attr(&value));
861            em.push('\"');
862        }
863    }
864    em.push('>');
865    if void {
866        return;
867    }
868    if let Some(text) = text {
869        if let Some(value) = resolve_parts(text, |r| xml_ref_attr(txn, t, r)) {
870            em.push_str(&escape_text(&value));
871        }
872    }
873    if content != Content::None && depth < MAX_INLINE_DEPTH {
874        render_inline(txn, t, depth + 1, true, em, rules);
875    }
876    em.push_str("</");
877    em.push_str(tag);
878    em.push('>');
879}
880
881/// Decorator elements (root-level or inline): core Lexical's horizontal rule,
882/// plus whatever the registered rules cover — Lexxy's attachments arrive that
883/// way. Rules win here too, so custom decorators render or defer.
884fn render_decorator<T: ReadTxn>(txn: &T, e: &XmlElementRef, em: &mut Emitter, rules: &Rules) {
885    let ty = elem_type(txn, e);
886    if let Some(rule) = rules.nodes.get(ty.as_str()) {
887        render_rule_element(txn, e, &ty, rule, em);
888        return;
889    }
890    // Core Lexical's only decorator; unknown decorators render nothing
891    // (nothing extractable without a rule).
892    if ty == "horizontalrule" {
893        em.push_str("<hr>");
894    }
895}
896
897/// A decorator element rendered through a registered rule. Decorators are
898/// childless in practice, so declarative rules render tag + attrs + template
899/// text (content slots are ignored) and callback rules defer with empty
900/// content.
901fn render_rule_element<T: ReadTxn>(
902    txn: &T,
903    e: &XmlElementRef,
904    ty: &str,
905    rule: &NodeRule,
906    em: &mut Emitter,
907) {
908    let (tag, void, attrs, text) = match rule {
909        NodeRule::Callback { .. } => {
910            em.emit_deferred(
911                ty.to_string(),
912                xml_attrs_json(txn, e),
913                Vec::new(),
914                Vec::new(),
915            );
916            return;
917        }
918        NodeRule::Declarative {
919            tag,
920            void,
921            attrs,
922            text,
923            ..
924        } => (tag, *void, attrs, text),
925    };
926    em.push('<');
927    em.push_str(tag);
928    for (name, parts) in attrs {
929        if let Some(value) = resolve_parts(parts, |r| xml_ref_attr(txn, e, r)) {
930            em.push(' ');
931            em.push_str(name);
932            em.push_str("=\"");
933            em.push_str(&escape_attr(&value));
934            em.push('\"');
935        }
936    }
937    em.push('>');
938    if void {
939        return;
940    }
941    if let Some(text) = text {
942        if let Some(value) = resolve_parts(text, |r| xml_ref_attr(txn, e, r)) {
943            em.push_str(&escape_text(&value));
944        }
945    }
946    em.push_str("</");
947    em.push_str(tag);
948    em.push('>');
949}
950
951fn elem_type<T: ReadTxn>(txn: &T, e: &XmlElementRef) -> String {
952    match e.get_attribute(txn, "__type") {
953        Some(Out::Any(Any::String(s))) => s.to_string(),
954        _ => String::new(),
955    }
956}
957
958/// Text-content escaping, matching what the browser's serializer emits:
959/// `&`, `<`, `>` escaped; quotes left alone in text.
960fn escape_text(s: &str) -> String {
961    s.replace('&', "&amp;")
962        .replace('<', "&lt;")
963        .replace('>', "&gt;")
964}
965
966/// Attribute-value escaping: text escaping plus `"`.
967fn escape_attr(s: &str) -> String {
968    escape_text(s).replace('"', "&quot;")
969}
970
971#[cfg(test)]
972mod tests {
973    use super::*;
974    use yrs::updates::decoder::Decode;
975    use yrs::{Doc, Transact, Update};
976
977    /// Core rendering of the captured full-schema document, pinned as a
978    /// golden (`.core.html`). Stock Lexical has no canonical serializer to
979    /// capture against, so this is a self-pinned regression guard; the
980    /// external truth — byte parity with a real Lexxy editor's value — is
981    /// held at the Ruby layer, where `Y::Lexxy` completes the schema.
982    /// The pair was captured together from one live
983    /// editor session: `lexxy_full.bin` is the synced Yjs state, `lexxy_full.html`
984    /// is `lexxy-editor.value` for the same document. It covers every block and
985    /// format the schema map handles.
986    #[test]
987    fn core_rendering_of_the_full_fixture_is_pinned() {
988        let bytes = include_bytes!("fixtures/lexxy_full.bin");
989        let expected = include_str!("fixtures/lexxy_full.core.html");
990        let doc = Doc::new();
991        doc.transact_mut()
992            .apply_update(Update::decode_v1(bytes).unwrap())
993            .unwrap();
994        let txn = doc.transact();
995        let frag = txn.get_xml_fragment("root").unwrap();
996        assert_eq!(render(&txn, &frag).unwrap(), expected.trim_end());
997    }
998
999    /// A second live-Lexxy pair that stresses nesting: blocks inside table
1000    /// cells, five-level mixed lists, formatted links in headings, the full
1001    /// format stack, unicode and escaping edge cases, whitespace-only
1002    /// paragraphs. The load-bearing structures are probed by name below.
1003    /// Lexxy's sanitized export drops colSpan/rowSpan, so matching it
1004    /// byte-for-byte means not emitting them either.
1005    #[test]
1006    fn core_rendering_of_the_torture_fixture_is_pinned() {
1007        let bytes = include_bytes!("fixtures/lexxy_torture.bin");
1008        let expected = include_str!("fixtures/lexxy_torture.core.html");
1009        let doc = Doc::new();
1010        doc.transact_mut()
1011            .apply_update(Update::decode_v1(bytes).unwrap())
1012            .unwrap();
1013        let txn = doc.transact();
1014        let frag = txn.get_xml_fragment("root").unwrap();
1015        let html = render(&txn, &frag).unwrap();
1016        assert_eq!(html, expected.trim_end());
1017
1018        // The golden already proves these; name the load-bearing core
1019        // structures so a regression fails with the one it broke. (The
1020        // fixture's Lexxy-only nodes — attachments, its code type — are
1021        // covered at the Ruby layer, where the Lexxy rules render them.)
1022        for (what, probe) in [
1023            ("list nested in a table cell", "<td><ul><li"),
1024            ("quote in a table cell", "<td><blockquote>"),
1025            (
1026                "five-level mixed list nesting",
1027                "<ol><li value=\"1\"><s><i><strong>Level five</strong></i></s></li></ol>",
1028            ),
1029            (
1030                "the full format stack on one run",
1031                "<u><s><i><code><strong>g</strong></code></i></s></u>",
1032            ),
1033            (
1034                "a titled link in a heading wrapping formatted runs",
1035                "<a href=\"https://spec.example.com\" title=\"The Spec\">v<i><strong>2</strong></i><code>.final</code></a>",
1036            ),
1037            (
1038                "already-escaped-looking text escapes again",
1039                "<strong>a &amp;&amp; b &lt; c &gt; d \"q\" '&amp;amp;'</strong>",
1040            ),
1041            ("emoji, CJK and RTL text", "🚀🎉 你好世界 العربية café"),
1042            (
1043                "whitespace-only paragraphs",
1044                "<p><span>\t</span></p><p><br></p><p><br></p>",
1045            ),
1046        ] {
1047            assert!(html.contains(probe), "{what}: missing {probe}");
1048        }
1049    }
1050
1051    /// Highlight colors (captured live): a run's __style survives on the
1052    /// createDOM tag — the outer tag when present, else the inner — filtered
1053    /// to color/background-color; plain and s/u-only runs lose it with their
1054    /// unwrapped span. The fixture's byte-for-byte match pins both the keeps
1055    /// and the drops.
1056    #[test]
1057    fn renders_the_captured_styles_document_byte_for_byte() {
1058        let bytes = include_bytes!("fixtures/lexxy_styles.bin");
1059        let expected = include_str!("fixtures/lexxy_styles.html");
1060        let doc = Doc::new();
1061        doc.transact_mut()
1062            .apply_update(Update::decode_v1(bytes).unwrap())
1063            .unwrap();
1064        let txn = doc.transact();
1065        let frag = txn.get_xml_fragment("root").unwrap();
1066        assert_eq!(render(&txn, &frag).unwrap(), expected.trim_end());
1067    }
1068
1069    #[test]
1070    fn run_styles_ride_the_createdom_tag() {
1071        // Outer tag takes the style over the inner one.
1072        assert_eq!(
1073            render_run("x", 128, "background-color: var(--highlight-bg-2);"),
1074            "<mark style=\"background-color: var(--highlight-bg-2);\">x</mark>"
1075        );
1076        assert_eq!(
1077            render_run("x", 1 | 128, "background-color: red;"),
1078            "<mark style=\"background-color: red;\"><strong>x</strong></mark>"
1079        );
1080        // No outer tag: the inner takes it.
1081        assert_eq!(
1082            render_run("x", 1, "color: red;"),
1083            "<strong style=\"color: red;\">x</strong>"
1084        );
1085        // Plain and s/u-only runs lose the style with their unwrapped span.
1086        assert_eq!(render_run("x", 0, "color: red;"), "x");
1087        assert_eq!(render_run("x", 4, "color: red;"), "<s>x</s>");
1088        // Two properties: source order, no separator between them.
1089        assert_eq!(
1090            render_run("x", 128, "color: red; background-color: blue;"),
1091            "<mark style=\"color: red;background-color: blue;\">x</mark>"
1092        );
1093        // Disallowed properties are stripped; quotes in values escape.
1094        assert_eq!(
1095            render_run("x", 128, "font-size: 40px; color: r\"ed;"),
1096            "<mark style=\"color: r&quot;ed;\">x</mark>"
1097        );
1098    }
1099
1100    /// A known container with inline content jammed directly into it
1101    /// (Lexxy never does this) keeps the content instead of dropping it.
1102    #[test]
1103    fn a_known_container_keeps_stray_inline_content() {
1104        use yrs::{XmlFragment, XmlTextPrelim};
1105        let doc = Doc::new();
1106        let frag = doc.get_or_insert_xml_fragment("root");
1107        {
1108            let mut txn = doc.transact_mut();
1109            let list = frag.push_back(&mut txn, XmlTextPrelim::new("stray"));
1110            list.insert_attribute(&mut txn, "__type", "list");
1111            list.insert_attribute(&mut txn, "__tag", "ul");
1112            let li = list.insert_embed(&mut txn, 5, XmlTextPrelim::new("item"));
1113            li.insert_attribute(&mut txn, "__type", "listitem");
1114        }
1115        let txn = doc.transact();
1116        let frag = txn.get_xml_fragment("root").unwrap();
1117        let html = render(&txn, &frag).unwrap();
1118
1119        assert!(html.contains("stray"), "stray inline text kept: {html}");
1120        assert!(html.contains("<li value=\"1\">item</li>"), "{html}");
1121    }
1122
1123    /// Lexxy-only nodes (this fixture is a gallery of attachments between
1124    /// two paragraphs) are unknown to the core schema and degrade readably —
1125    /// the paragraphs survive, the gallery renders nothing rather than
1126    /// garbage. Y::Lexxy's rules render it fully; the Ruby fixture tests pin
1127    /// that byte for byte.
1128    #[test]
1129    fn core_degrades_lexxy_only_nodes_readably() {
1130        let bytes = include_bytes!("fixtures/lexxy_gallery.bin");
1131        let expected = include_str!("fixtures/lexxy_gallery.core.html");
1132        let doc = Doc::new();
1133        doc.transact_mut()
1134            .apply_update(Update::decode_v1(bytes).unwrap())
1135            .unwrap();
1136        let txn = doc.transact();
1137        let frag = txn.get_xml_fragment("root").unwrap();
1138        let html = render(&txn, &frag).unwrap();
1139        assert_eq!(html, expected.trim_end());
1140        assert!(html.contains("<p>Before gallery</p>"));
1141    }
1142
1143    #[test]
1144    fn format_runs_match_lexxys_export_algorithm() {
1145        // Singles take their semantic tag; the span for plain text unwraps.
1146        assert_eq!(render_run("x", 0, ""), "x");
1147        assert_eq!(render_run("x", 1, ""), "<strong>x</strong>");
1148        assert_eq!(render_run("x", 2, ""), "<em>x</em>");
1149        assert_eq!(render_run("x", 4, ""), "<s>x</s>");
1150        assert_eq!(render_run("x", 8, ""), "<u>x</u>");
1151        assert_eq!(render_run("x", 16, ""), "<code>x</code>");
1152        assert_eq!(render_run("x", 32, ""), "<sub>x</sub>");
1153        assert_eq!(render_run("x", 64, ""), "<sup>x</sup>");
1154        assert_eq!(render_run("x", 128, ""), "<mark>x</mark>");
1155        // bold+italic: bold claims the inner tag, italic falls back to <i>.
1156        assert_eq!(render_run("x", 3, ""), "<i><strong>x</strong></i>");
1157        // Wrap order: u outside s outside the semantic core.
1158        assert_eq!(render_run("x", 4 | 8, ""), "<u><s>x</s></u>");
1159        assert_eq!(render_run("x", 1 | 8, ""), "<u><strong>x</strong></u>");
1160        // Outer tag composes with the inner one.
1161        assert_eq!(
1162            render_run("x", 1 | 16, ""),
1163            "<code><strong>x</strong></code>"
1164        );
1165        assert_eq!(render_run("x", 2 | 128, ""), "<mark><em>x</em></mark>");
1166        // Everything at once: u(s(i(outer(inner)))).
1167        assert_eq!(
1168            render_run("x", 1 | 2 | 4 | 8 | 16, ""),
1169            "<u><s><i><code><strong>x</strong></code></i></s></u>"
1170        );
1171    }
1172
1173    #[test]
1174    fn a_prosemirror_shaped_root_is_refused_not_mangled() {
1175        // ProseMirror blocks are plain XmlElements with no __type — a
1176        // different schema. render() must return None, never a lossy or empty
1177        // rendering that looks like success.
1178        use yrs::{XmlElementPrelim, XmlFragment, XmlTextPrelim};
1179        let doc = Doc::new();
1180        // Create BOTH roots before opening the read transaction:
1181        // get_or_insert_* opens a write transaction internally and would
1182        // deadlock against a live read guard (the read_text lesson).
1183        let frag = doc.get_or_insert_xml_fragment("pm");
1184        let empty = doc.get_or_insert_xml_fragment("empty");
1185        {
1186            let mut txn = doc.transact_mut();
1187            let p = frag.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
1188            p.push_back(&mut txn, XmlTextPrelim::new("Body"));
1189        }
1190        let txn = doc.transact();
1191        assert_eq!(render(&txn, &frag), None);
1192
1193        // An empty root is fine: an empty document, not a foreign schema.
1194        assert_eq!(render(&txn, &empty).as_deref(), Some(""));
1195    }
1196
1197    /// Build a chain of `depth` nested lists: list > listitem > list > … each
1198    /// list holding one item whose only child is the next list down. Mirrors
1199    /// the real storage model (block children are XmlText embedded in XmlText).
1200    fn nested_list_doc(depth: usize) -> Doc {
1201        use yrs::{XmlFragment, XmlTextPrelim};
1202        let doc = Doc::new();
1203        let root = doc.get_or_insert_xml_fragment("root");
1204        let mut txn = doc.transact_mut();
1205        let top = root.push_back(&mut txn, XmlTextPrelim::new(""));
1206        top.insert_attribute(&mut txn, "__type", "list");
1207        top.insert_attribute(&mut txn, "__tag", "ul");
1208        let mut cursor = top;
1209        for _ in 0..depth {
1210            let li = cursor.insert_embed(&mut txn, 0, XmlTextPrelim::new(""));
1211            li.insert_attribute(&mut txn, "__type", "listitem");
1212            let inner = li.insert_embed(&mut txn, 0, XmlTextPrelim::new(""));
1213            inner.insert_attribute(&mut txn, "__type", "list");
1214            inner.insert_attribute(&mut txn, "__tag", "ul");
1215            cursor = inner;
1216        }
1217        drop(txn);
1218        doc
1219    }
1220
1221    #[test]
1222    fn deeply_nested_blocks_do_not_overflow_the_stack() {
1223        // Nesting this deep would overflow the native call stack (tens of
1224        // thousands of frames) under recursion; on the heap it renders fine.
1225        // Runs on a 512 KiB thread so it can't pass just by having room to spare.
1226        let handle = std::thread::Builder::new()
1227            .stack_size(512 * 1024)
1228            .spawn(|| {
1229                let doc = nested_list_doc(20_000);
1230                let txn = doc.transact();
1231                let frag = txn.get_xml_fragment("root").unwrap();
1232                let html = render(&txn, &frag).expect("lexical-shaped");
1233                // Well formed: every opened list/item is closed.
1234                assert_eq!(html.matches("<ul>").count(), html.matches("</ul>").count());
1235                assert_eq!(html.matches("<li ").count(), html.matches("</li>").count());
1236                html.len()
1237            })
1238            .unwrap();
1239        assert!(handle.join().unwrap() > 0);
1240    }
1241
1242    #[test]
1243    fn nesting_past_the_cap_truncates_but_stays_well_formed() {
1244        // Past MAX_BLOCK_DEPTH the deepest content is dropped, but every
1245        // enclosing tag still closes, so the output remains balanced HTML.
1246        let doc = nested_list_doc(MAX_BLOCK_DEPTH + 50);
1247        let txn = doc.transact();
1248        let frag = txn.get_xml_fragment("root").unwrap();
1249        let html = render(&txn, &frag).unwrap();
1250
1251        assert_eq!(html.matches("<ul>").count(), html.matches("</ul>").count());
1252        assert_eq!(html.matches("<li ").count(), html.matches("</li>").count());
1253        // Capped, not fully rendered: fewer levels than were authored.
1254        assert!(html.matches("<ul>").count() <= MAX_BLOCK_DEPTH + 1);
1255    }
1256
1257    #[test]
1258    fn deeply_nested_links_do_not_overflow_the_stack() {
1259        // Links recurse (their body is inline content), so they carry their own
1260        // depth cap. A link-in-link chain renders as nested <a>s up to the cap,
1261        // then bare text.
1262        use yrs::{XmlFragment, XmlTextPrelim};
1263        let doc = Doc::new();
1264        let root = doc.get_or_insert_xml_fragment("root");
1265        {
1266            let mut txn = doc.transact_mut();
1267            let p = root.push_back(&mut txn, XmlTextPrelim::new(""));
1268            p.insert_attribute(&mut txn, "__type", "paragraph");
1269            let mut cursor = p;
1270            for _ in 0..(MAX_INLINE_DEPTH + 20) {
1271                let link = cursor.insert_embed(&mut txn, 0, XmlTextPrelim::new(""));
1272                link.insert_attribute(&mut txn, "__type", "link");
1273                link.insert_attribute(&mut txn, "__url", "https://x.example");
1274                cursor = link;
1275            }
1276        }
1277        let txn = doc.transact();
1278        let frag = txn.get_xml_fragment("root").unwrap();
1279        let html = render(&txn, &frag).unwrap();
1280        assert_eq!(html.matches("<a").count(), html.matches("</a>").count());
1281    }
1282
1283    /// Rules reach inline position too: a rule for `link` overrides the
1284    /// built-in `<a>`, and a custom inline node (a `Y.XmlText` type the
1285    /// core schema doesn't know) renders through its rule instead of being
1286    /// dropped.
1287    #[test]
1288    fn rules_render_inline_nodes_and_override_links() {
1289        use yrs::{XmlFragment, XmlTextPrelim};
1290        let rules = Rules::parse(
1291            r#"{ "nodes": {
1292                 "link": { "tag": "a", "attrs": [["class", [{"lit": "app-link"}]],
1293                                                 ["href", [{"ref": "url"}]]] },
1294                 "keyword": { "tag": "kbd", "attrs": [["data-kind", [{"ref": "kind"}]]] } } }"#,
1295        )
1296        .unwrap();
1297        let doc = Doc::new();
1298        let root = doc.get_or_insert_xml_fragment("root");
1299        {
1300            let mut txn = doc.transact_mut();
1301            let p = root.push_back(&mut txn, XmlTextPrelim::new(""));
1302            p.insert_attribute(&mut txn, "__type", "paragraph");
1303            let link = p.insert_embed(&mut txn, 0, XmlTextPrelim::new("site"));
1304            link.insert_attribute(&mut txn, "__type", "link");
1305            link.insert_attribute(&mut txn, "__url", "https://x.example");
1306            let kw = p.insert_embed(&mut txn, 1, XmlTextPrelim::new("crdt"));
1307            kw.insert_attribute(&mut txn, "__type", "keyword");
1308            kw.insert_attribute(&mut txn, "__kind", "term");
1309        }
1310        let txn = doc.transact();
1311        let frag = txn.get_xml_fragment("root").unwrap();
1312        let segs = render_segments(&txn, &frag, &rules).unwrap();
1313        let html = yjs_html_core::flatten(segs).into_html().unwrap();
1314        assert_eq!(
1315            html,
1316            "<p><a class=\"app-link\" href=\"https://x.example\">site</a>\
1317             <kbd data-kind=\"term\">crdt</kbd></p>"
1318        );
1319    }
1320
1321    #[test]
1322    fn escaping_matches_the_browser_serializer() {
1323        assert_eq!(escape_text(r#"<a & "b">"#), r#"&lt;a &amp; "b"&gt;"#);
1324        assert_eq!(
1325            escape_attr(r#"<a & "b">"#),
1326            r#"&lt;a &amp; &quot;b&quot;&gt;"#
1327        );
1328    }
1329}