yrby 0.5.0 → 0.6.1

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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +74 -1
  3. data/Cargo.lock +29 -0
  4. data/README.md +286 -30
  5. data/ext/yrby/Cargo.toml +3 -0
  6. data/ext/yrby/crates/html-core/Cargo.toml +15 -0
  7. data/ext/yrby/crates/html-core/src/lib.rs +535 -0
  8. data/ext/yrby/crates/lexical-html/Cargo.toml +16 -0
  9. data/ext/yrby/{src/lexical_html.rs → crates/lexical-html/src/lib.rs} +615 -288
  10. data/ext/yrby/crates/prosemirror-html/Cargo.toml +16 -0
  11. data/ext/yrby/crates/prosemirror-html/src/lib.rs +1369 -0
  12. data/ext/yrby/src/lib.rs +208 -78
  13. data/ext/yrby/src/read.rs +3 -3
  14. data/ext/yrby/target/debug/build/clang-sys-e3ae45bd384f74c3/out/common.rs +355 -0
  15. data/ext/yrby/target/debug/build/clang-sys-e3ae45bd384f74c3/out/dynamic.rs +276 -0
  16. data/ext/yrby/target/debug/build/clang-sys-e3ae45bd384f74c3/out/macros.rs +49 -0
  17. data/ext/yrby/target/debug/build/rb-sys-4407948463231c4f/out/bindings-0.9.128-mri-arm64-darwin23-3.4.7.rs +8934 -0
  18. data/ext/yrby/target/debug/build/rb-sys-dbeea42737529c2d/out/bindings-0.9.128-mri-arm64-darwin23-3.4.7.rs +8934 -0
  19. data/ext/yrby/target/debug/build/serde-58ea0ee887cc2602/out/private.rs +6 -0
  20. data/ext/yrby/target/debug/build/serde_core-41f407c21c1f205e/out/private.rs +5 -0
  21. data/ext/yrby/target/debug/build/thiserror-0f1416a82ff26f22/out/private.rs +5 -0
  22. data/lib/generators/yrby/install/install_generator.rb +44 -0
  23. data/lib/generators/yrby/install/templates/document_channel.rb +36 -0
  24. data/lib/generators/yrby/tables/tables_generator.rb +41 -0
  25. data/lib/generators/yrby/tables/templates/create_y_tables.rb +24 -0
  26. data/lib/y/lexxy.rb +121 -0
  27. data/lib/y/rendering.rb +282 -0
  28. data/lib/y/tiptap.rb +63 -0
  29. data/lib/y/version.rb +1 -1
  30. data/lib/y.rb +4 -0
  31. metadata +23 -4
  32. data/ext/yrby/src/prosemirror_html.rs +0 -896
@@ -0,0 +1,1369 @@
1
+ //! Native HTML rendering of ProseMirror/Tiptap documents from the yrs collab
2
+ //! structure — no Node process, no headless editor.
3
+ //!
4
+ //! The y-prosemirror binding stores a document in a Y.XmlFragment: block nodes
5
+ //! are Y.XmlElement (the tag is the node type, its attributes are the node
6
+ //! attrs), and text is Y.XmlText whose per-run formatting attributes are the
7
+ //! marks. Node and mark names come from the editor's schema, so this accepts
8
+ //! both spellings in use: Tiptap's camelCase (`bulletList`, `bold`) and the
9
+ //! prosemirror-schema-basic snake_case (`bullet_list`, `strong`).
10
+ //!
11
+ //! This renders **core ProseMirror**: the prosemirror-schema-basic node set
12
+ //! plus the prosemirror-tables family — paragraphs, headings, blockquotes,
13
+ //! code blocks, bullet/ordered lists, images, hard breaks, horizontal rules,
14
+ //! and tables (semantic `<table><tbody>…`, without the `<colgroup>`/
15
+ //! `min-width` styling editor views inject). Tiptap's extension nodes — task
16
+ //! lists, mentions, the details family — live in the Ruby layer as the
17
+ //! `Y::Tiptap` renderer's rule set, built on the same extension API apps use
18
+ //! (`Y::ProseMirror` is the core base class). Output follows
19
+ //! `ueberdosis/tiptap-php`, and with `Y::Tiptap`'s rules it matches Tiptap's
20
+ //! own `getHTML()` byte for byte on the captured fixtures — that guarantee
21
+ //! is held at the Ruby layer; the native tests pin core output as goldens
22
+ //! where a fixture contains Tiptap-only nodes.
23
+ //!
24
+ //! Marks stay native on purpose: mark serialization is text-run machinery
25
+ //! (nesting order, textStyle's CSS, code's exclusivity), which the rule
26
+ //! system can't express. The built-in set covers schema-basic's marks and
27
+ //! Tiptap's — nesting outermost-first: link, a textStyle span, bold, italic,
28
+ //! strike, underline, highlight, then subscript/superscript. `code` excludes
29
+ //! the other formatting marks, so a code run is `<code>` alone — though a
30
+ //! link still wraps it (see `render_run`).
31
+ //!
32
+ //! Custom nodes and marks: apps register rules by node type and mark name
33
+ //! (see `render_rules`). A node rule is consulted before the built-in arms, so
34
+ //! it can extend the schema or override a built-in; a mark rule claims its
35
+ //! mark from the built-in wraps and wraps outside everything, link included.
36
+ //! Declarative rules render here; callback rules emit `Segment::Deferred` for
37
+ //! the caller to fill in after the render.
38
+
39
+ // README examples are living code: compile-checked on every cargo test.
40
+ #[cfg(doctest)]
41
+ #[doc = include_str!("../README.md")]
42
+ mod readme_examples {}
43
+
44
+ // The full rules surface, re-exported: depend on this crate alone;
45
+ // yrs-html-core is an internal implementation crate.
46
+ pub use yrs_html_core::*;
47
+ use yrs::types::text::YChange;
48
+ use yrs::types::Attrs;
49
+ use yrs::{
50
+ Any, GetString, Out, ReadTxn, Text, Xml, XmlElementRef, XmlFragment, XmlFragmentRef, XmlOut,
51
+ XmlTextRef,
52
+ };
53
+
54
+ // The block tree is walked on a heap stack, so depth can't overflow the
55
+ // native call stack; this caps the work a pathological document can demand.
56
+ const MAX_DEPTH: usize = 1024;
57
+
58
+ /// A unit of block work on the traversal stack. `Open` renders a node (pushing its
59
+ /// block children as more work); `Close` and `CloseOwned` emit a container's
60
+ /// end tag once its children are done (built-in containers close with fixed
61
+ /// strings; rule containers close with their computed tag). `EndDeferred` seals
62
+ /// a callback node: it pops the emitter frame its children rendered into and
63
+ /// emits the deferred segment.
64
+ enum Work {
65
+ Open(XmlElementRef, usize),
66
+ Close(&'static str),
67
+ CloseOwned(String),
68
+ EndDeferred {
69
+ node_type: String,
70
+ attrs_json: String,
71
+ child_types: Vec<String>,
72
+ },
73
+ }
74
+
75
+ /// Render a ProseMirror/Tiptap-shaped XML root to segments, or `None` when the
76
+ /// root isn't ProseMirror-shaped. ProseMirror blocks are plain Y.XmlElement
77
+ /// tags; a Lexical root (Y.XmlText children carrying a `__type`) is a
78
+ /// different schema and returns `None` instead of a garbled render.
79
+ pub fn render_segments<T: ReadTxn>(
80
+ txn: &T,
81
+ fragment: &XmlFragmentRef,
82
+ rules: &Rules,
83
+ ) -> Option<Vec<Segment>> {
84
+ if !is_prosemirror_shaped(txn, fragment) {
85
+ return None;
86
+ }
87
+ let mut em = Emitter::new();
88
+ for node in fragment.children(txn) {
89
+ match node {
90
+ XmlOut::Element(e) => render_block_tree(txn, &e, &mut em, rules),
91
+ // y-prosemirror never writes a bare text run at the root, but a
92
+ // crafted doc can; render it rather than drop it.
93
+ XmlOut::Text(t) => render_text_runs(txn, &t, &mut em, rules),
94
+ // Fragments can't nest as children in yrs, so this arm shouldn't
95
+ // be reachable; it exists because the match must be exhaustive,
96
+ // and escaping the text is the safe degradation.
97
+ XmlOut::Fragment(f) => em.push_str(&escape_text(&f.get_string(txn))),
98
+ }
99
+ }
100
+ Some(em.into_segments())
101
+ }
102
+
103
+ /// Rule-free rendering to a plain string — the simplest way to use this
104
+ /// crate standalone, and the fixture-parity surface the tests pin. With no
105
+ /// callback rules, segments always flatten.
106
+ pub fn render<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> Option<String> {
107
+ render_segments(txn, fragment, &Rules::empty()).map(|segs| {
108
+ yrs_html_core::flatten(segs)
109
+ .into_html()
110
+ .expect("no callback rules registered")
111
+ })
112
+ }
113
+
114
+ /// The node types the built-in arms cover (both Tiptap and schema-basic
115
+ /// spellings). Everything else needs a rule.
116
+ pub fn is_builtin(ty: &str) -> bool {
117
+ matches!(
118
+ ty,
119
+ "paragraph"
120
+ | "heading"
121
+ | "codeBlock"
122
+ | "code_block"
123
+ | "blockquote"
124
+ | "bulletList"
125
+ | "bullet_list"
126
+ | "orderedList"
127
+ | "ordered_list"
128
+ | "listItem"
129
+ | "list_item"
130
+ | "table"
131
+ | "tableRow"
132
+ | "table_row"
133
+ | "tableHeader"
134
+ | "table_header"
135
+ | "tableCell"
136
+ | "table_cell"
137
+ | "horizontalRule"
138
+ | "horizontal_rule"
139
+ | "image"
140
+ | "hardBreak"
141
+ | "hard_break"
142
+ )
143
+ }
144
+
145
+ /// Walk the document and record what each node type actually looks like —
146
+ /// the discovery aid behind `Y::ProseMirror#node_types`. It records facts:
147
+ /// counts, attribute names, child element types, and whether text runs were
148
+ /// seen. The inline-vs-blocks distinction itself is editor-schema knowledge
149
+ /// the storage never serializes, so the rule author reads it off the child
150
+ /// types and text flags.
151
+ pub fn collect_node_types<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> Option<TypeMap> {
152
+ if !is_prosemirror_shaped(txn, fragment) {
153
+ return None;
154
+ }
155
+ let mut map = TypeMap::new();
156
+ for node in fragment.children(txn) {
157
+ if let XmlOut::Element(e) = node {
158
+ observe(txn, &e, &mut map, 0);
159
+ }
160
+ }
161
+ Some(map)
162
+ }
163
+
164
+ fn observe<T: ReadTxn>(txn: &T, e: &XmlElementRef, map: &mut TypeMap, depth: usize) {
165
+ let ty = e.tag().to_string();
166
+ let info = map.entry(ty.clone()).or_default();
167
+ info.count += 1;
168
+ for (key, _) in e.attributes(txn) {
169
+ info.attrs.insert(key.to_string());
170
+ }
171
+ if depth >= MAX_DEPTH {
172
+ return;
173
+ }
174
+ for child in e.children(txn) {
175
+ match child {
176
+ XmlOut::Text(_) => map.get_mut(&ty).expect("just inserted").text = true,
177
+ XmlOut::Element(el) => {
178
+ map.get_mut(&ty)
179
+ .expect("just inserted")
180
+ .children
181
+ .insert(el.tag().to_string());
182
+ observe(txn, &el, map, depth + 1);
183
+ }
184
+ XmlOut::Fragment(_) => {}
185
+ }
186
+ }
187
+ }
188
+
189
+ /// A root is ProseMirror-shaped when it's empty or its first child is a block
190
+ /// element with no `__type` (Lexical stamps `__type` on every node; ProseMirror
191
+ /// uses the node type as the element tag).
192
+ fn is_prosemirror_shaped<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> bool {
193
+ match fragment.children(txn).next() {
194
+ Some(XmlOut::Element(e)) => e.get_attribute(txn, "__type").is_none(),
195
+ Some(_) => false, // Lexical stores blocks as XmlText
196
+ None => true, // empty document
197
+ }
198
+ }
199
+
200
+ /// Walk one top-level block and everything under it on a heap stack.
201
+ fn render_block_tree<T: ReadTxn>(txn: &T, root: &XmlElementRef, em: &mut Emitter, rules: &Rules) {
202
+ let mut stack: Vec<Work> = vec![Work::Open(root.clone(), 0)];
203
+ while let Some(work) = stack.pop() {
204
+ match work {
205
+ Work::Close(tag) => em.push_str(tag),
206
+ Work::CloseOwned(tag) => em.push_str(&tag),
207
+ Work::EndDeferred {
208
+ node_type,
209
+ attrs_json,
210
+ child_types,
211
+ } => {
212
+ let content = em.end_frame();
213
+ em.emit_deferred(node_type, attrs_json, child_types, content);
214
+ }
215
+ Work::Open(node, depth) => open_block(txn, &node, depth, em, &mut stack, rules),
216
+ }
217
+ }
218
+ }
219
+
220
+ /// Render one block. A registered rule wins over the built-in arms (so apps
221
+ /// can extend the schema or override a built-in). Text blocks (paragraph,
222
+ /// heading, code) render in full on the spot; container blocks emit their
223
+ /// opening tag and defer their children (and matching `Close`) to the stack.
224
+ fn open_block<T: ReadTxn>(
225
+ txn: &T,
226
+ e: &XmlElementRef,
227
+ depth: usize,
228
+ em: &mut Emitter,
229
+ stack: &mut Vec<Work>,
230
+ rules: &Rules,
231
+ ) {
232
+ if let Some(rule) = rules.nodes.get(e.tag().as_ref()) {
233
+ open_rule_block(txn, e, rule, depth, em, stack, rules);
234
+ return;
235
+ }
236
+ match e.tag().as_ref() {
237
+ "paragraph" => {
238
+ em.push_str("<p>");
239
+ render_inline(txn, e, 0, em, rules);
240
+ em.push_str("</p>");
241
+ }
242
+ "heading" => {
243
+ let level = num_attr(txn, e, "level").unwrap_or(1).clamp(1, 6);
244
+ let tag = ['1', '2', '3', '4', '5', '6'][(level - 1) as usize];
245
+ em.push_str("<h");
246
+ em.push(tag);
247
+ em.push('>');
248
+ render_inline(txn, e, 0, em, rules);
249
+ em.push_str("</h");
250
+ em.push(tag);
251
+ em.push('>');
252
+ }
253
+ "codeBlock" | "code_block" => {
254
+ em.push_str("<pre><code");
255
+ if let Some(lang) = str_attr(txn, e, "language").filter(|l| !l.is_empty()) {
256
+ em.push_str(" class=\"language-");
257
+ em.push_str(&escape_attr(&lang));
258
+ em.push('"');
259
+ }
260
+ em.push('>');
261
+ em.push_str(&escape_text(&code_text(txn, e)));
262
+ em.push_str("</code></pre>");
263
+ }
264
+ "blockquote" => open_container(
265
+ txn,
266
+ e,
267
+ depth,
268
+ "<blockquote>",
269
+ "</blockquote>",
270
+ em,
271
+ stack,
272
+ rules,
273
+ ),
274
+ "bulletList" | "bullet_list" => {
275
+ open_container(txn, e, depth, "<ul>", "</ul>", em, stack, rules)
276
+ }
277
+ "orderedList" | "ordered_list" => {
278
+ match num_attr(txn, e, "start") {
279
+ Some(start) if start != 1 => {
280
+ em.push_str("<ol start=\"");
281
+ em.push_str(&start.to_string());
282
+ em.push_str("\">");
283
+ }
284
+ _ => em.push_str("<ol>"),
285
+ }
286
+ push_block_children(txn, e, depth, "</ol>", em, stack, rules);
287
+ }
288
+ "listItem" | "list_item" => {
289
+ open_container(txn, e, depth, "<li>", "</li>", em, stack, rules)
290
+ }
291
+ "table" => {
292
+ em.push_str("<table><tbody>");
293
+ push_block_children(txn, e, depth, "</tbody></table>", em, stack, rules);
294
+ }
295
+ "tableRow" | "table_row" => {
296
+ open_container(txn, e, depth, "<tr>", "</tr>", em, stack, rules)
297
+ }
298
+ "tableHeader" | "table_header" => open_cell(txn, e, depth, "th", "</th>", em, stack, rules),
299
+ "tableCell" | "table_cell" => open_cell(txn, e, depth, "td", "</td>", em, stack, rules),
300
+ "horizontalRule" | "horizontal_rule" => em.push_str("<hr>"),
301
+ "image" => render_image(txn, e, em),
302
+ "hardBreak" | "hard_break" => em.push_str("<br>"),
303
+ // Unknown block: keep its content rather than dropping it. If it holds
304
+ // child blocks, render them with no wrapper; otherwise treat it as a
305
+ // text block.
306
+ _ => {
307
+ if has_element_child(txn, e) {
308
+ // Renders its direct text runs, then the children, with no
309
+ // invented wrapper tags.
310
+ push_block_children(txn, e, depth, "", em, stack, rules);
311
+ } else {
312
+ em.begin_frame();
313
+ render_inline(txn, e, 0, em, rules);
314
+ let inline = em.end_frame();
315
+ if !inline.is_empty() {
316
+ em.push_str("<p>");
317
+ em.append(inline);
318
+ em.push_str("</p>");
319
+ }
320
+ }
321
+ }
322
+ }
323
+ }
324
+
325
+ /// Render a block through a registered rule. Declarative rules emit the tag,
326
+ /// resolved attributes, and template text here; callback rules capture their
327
+ /// children into a frame and defer the markup to the caller.
328
+ #[allow(clippy::too_many_arguments)]
329
+ fn open_rule_block<T: ReadTxn>(
330
+ txn: &T,
331
+ e: &XmlElementRef,
332
+ rule: &NodeRule,
333
+ depth: usize,
334
+ em: &mut Emitter,
335
+ stack: &mut Vec<Work>,
336
+ rules: &Rules,
337
+ ) {
338
+ let ty = e.tag().to_string();
339
+ let (tag, void, attrs, text, content) = match rule {
340
+ NodeRule::Callback { content } => {
341
+ em.begin_frame();
342
+ // Children render into the frame; EndDeferred seals it. Blocks go
343
+ // via the stack (pushed above the marker, so they complete
344
+ // first); inline content renders now.
345
+ stack.push(Work::EndDeferred {
346
+ node_type: ty,
347
+ attrs_json: xml_attrs_json(txn, e),
348
+ child_types: element_child_types(txn, e),
349
+ });
350
+ match content {
351
+ Content::Inline => render_inline(txn, e, 0, em, rules),
352
+ Content::Blocks => {
353
+ render_stray_text(txn, e, em, rules);
354
+ if depth < MAX_DEPTH {
355
+ for child in element_children(txn, e).into_iter().rev() {
356
+ stack.push(Work::Open(child, depth + 1));
357
+ }
358
+ }
359
+ }
360
+ Content::None => {}
361
+ }
362
+ return;
363
+ }
364
+ NodeRule::Declarative {
365
+ tag,
366
+ void,
367
+ attrs,
368
+ text,
369
+ content,
370
+ } => (tag, *void, attrs, text, *content),
371
+ };
372
+
373
+ em.push('<');
374
+ em.push_str(tag);
375
+ for (name, parts) in attrs {
376
+ if let Some(value) = resolve_parts(parts, |r| xml_ref_attr(txn, e, r)) {
377
+ em.push(' ');
378
+ em.push_str(name);
379
+ em.push_str("=\"");
380
+ em.push_str(&escape_attr(&value));
381
+ em.push('\"');
382
+ }
383
+ }
384
+ em.push('>');
385
+ if void {
386
+ return;
387
+ }
388
+ if let Some(text) = text {
389
+ if let Some(value) = resolve_parts(text, |r| xml_ref_attr(txn, e, r)) {
390
+ em.push_str(&escape_text(&value));
391
+ }
392
+ }
393
+ match content {
394
+ Content::Inline => {
395
+ render_inline(txn, e, 0, em, rules);
396
+ em.push_str("</");
397
+ em.push_str(tag);
398
+ em.push('>');
399
+ }
400
+ Content::Blocks => {
401
+ render_stray_text(txn, e, em, rules);
402
+ stack.push(Work::CloseOwned(format!("</{tag}>")));
403
+ if depth < MAX_DEPTH {
404
+ for child in element_children(txn, e).into_iter().rev() {
405
+ stack.push(Work::Open(child, depth + 1));
406
+ }
407
+ }
408
+ }
409
+ Content::None => {
410
+ em.push_str("</");
411
+ em.push_str(tag);
412
+ em.push('>');
413
+ }
414
+ }
415
+ }
416
+
417
+ #[allow(clippy::too_many_arguments)]
418
+ fn open_container<T: ReadTxn>(
419
+ txn: &T,
420
+ e: &XmlElementRef,
421
+ depth: usize,
422
+ open: &str,
423
+ close: &'static str,
424
+ em: &mut Emitter,
425
+ stack: &mut Vec<Work>,
426
+ rules: &Rules,
427
+ ) {
428
+ em.push_str(open);
429
+ push_block_children(txn, e, depth, close, em, stack, rules);
430
+ }
431
+
432
+ /// A table cell: `<th>`/`<td>` carrying colspan/rowspan (default 1, always
433
+ /// emitted, matching Tiptap).
434
+ #[allow(clippy::too_many_arguments)]
435
+ fn open_cell<T: ReadTxn>(
436
+ txn: &T,
437
+ e: &XmlElementRef,
438
+ depth: usize,
439
+ tag: &str,
440
+ close: &'static str,
441
+ em: &mut Emitter,
442
+ stack: &mut Vec<Work>,
443
+ rules: &Rules,
444
+ ) {
445
+ em.push('<');
446
+ em.push_str(tag);
447
+ em.push_str(" colspan=\"");
448
+ em.push_str(&num_attr(txn, e, "colspan").unwrap_or(1).to_string());
449
+ em.push_str("\" rowspan=\"");
450
+ em.push_str(&num_attr(txn, e, "rowspan").unwrap_or(1).to_string());
451
+ em.push_str("\">");
452
+ push_block_children(txn, e, depth, close, em, stack, rules);
453
+ }
454
+
455
+ /// Defer a node's child *elements* onto the stack, closing tag below them, so
456
+ /// they render in order and the tag closes after. Any direct text runs render
457
+ /// first: schema-valid documents never put bare text in a container, but a
458
+ /// crafted one can, and dropping it would lose content. Past `MAX_DEPTH` the
459
+ /// children are dropped but the tag still closes, keeping the output well
460
+ /// formed.
461
+ #[allow(clippy::too_many_arguments)]
462
+ fn push_block_children<T: ReadTxn>(
463
+ txn: &T,
464
+ e: &XmlElementRef,
465
+ depth: usize,
466
+ close: &'static str,
467
+ em: &mut Emitter,
468
+ stack: &mut Vec<Work>,
469
+ rules: &Rules,
470
+ ) {
471
+ render_stray_text(txn, e, em, rules);
472
+ stack.push(Work::Close(close));
473
+ if depth >= MAX_DEPTH {
474
+ return;
475
+ }
476
+ for child in element_children(txn, e).into_iter().rev() {
477
+ stack.push(Work::Open(child, depth + 1));
478
+ }
479
+ }
480
+
481
+ /// Direct text runs jammed into a container (schema-valid documents have
482
+ /// none); rendered rather than dropped.
483
+ fn render_stray_text<T: ReadTxn>(txn: &T, e: &XmlElementRef, em: &mut Emitter, rules: &Rules) {
484
+ for node in e.children(txn) {
485
+ if let XmlOut::Text(t) = node {
486
+ render_text_runs(txn, &t, em, rules);
487
+ }
488
+ }
489
+ }
490
+
491
+ fn element_children<T: ReadTxn>(txn: &T, e: &XmlElementRef) -> Vec<XmlElementRef> {
492
+ e.children(txn)
493
+ .filter_map(|c| match c {
494
+ XmlOut::Element(el) => Some(el),
495
+ _ => None,
496
+ })
497
+ .collect()
498
+ }
499
+
500
+ /// The node type (tag) of every element child, in document order — handed to
501
+ /// callback rules as `node.child_types` for the structural facts `attrs` and
502
+ /// the rendered content can't answer.
503
+ fn element_child_types<T: ReadTxn>(txn: &T, e: &XmlElementRef) -> Vec<String> {
504
+ element_children(txn, e)
505
+ .iter()
506
+ .map(|el| el.tag().to_string())
507
+ .collect()
508
+ }
509
+
510
+ /// Render a text block's inline content: text runs (with their marks) and
511
+ /// inline element nodes (hard breaks, inline images). A registered
512
+ /// rule wins over the built-in inline nodes here too. An unknown inline node
513
+ /// keeps its text instead of vanishing; `depth` caps that recursion on a
514
+ /// crafted nest of unknowns.
515
+ fn render_inline<T: ReadTxn>(
516
+ txn: &T,
517
+ e: &XmlElementRef,
518
+ depth: usize,
519
+ em: &mut Emitter,
520
+ rules: &Rules,
521
+ ) {
522
+ for node in e.children(txn) {
523
+ match node {
524
+ XmlOut::Text(t) => render_text_runs(txn, &t, em, rules),
525
+ XmlOut::Element(child) => {
526
+ if let Some(rule) = rules.nodes.get(child.tag().as_ref()) {
527
+ render_rule_inline(txn, &child, rule, depth, em, rules);
528
+ continue;
529
+ }
530
+ match child.tag().as_ref() {
531
+ "hardBreak" | "hard_break" => em.push_str("<br>"),
532
+ "image" => render_image(txn, &child, em),
533
+ _ => {
534
+ if depth < MAX_DEPTH {
535
+ render_inline(txn, &child, depth + 1, em, rules);
536
+ }
537
+ }
538
+ }
539
+ }
540
+ XmlOut::Fragment(_) => {}
541
+ }
542
+ }
543
+ }
544
+
545
+ /// A rule node in inline position (inside a text block). Unlike Lexical's
546
+ /// childless decorators, a ProseMirror inline node can hold real content, so
547
+ /// the content slot renders — as inline content, since there are no blocks
548
+ /// inside a text block (a blocks content slot behaves like inline here).
549
+ fn render_rule_inline<T: ReadTxn>(
550
+ txn: &T,
551
+ e: &XmlElementRef,
552
+ rule: &NodeRule,
553
+ depth: usize,
554
+ em: &mut Emitter,
555
+ rules: &Rules,
556
+ ) {
557
+ let (tag, void, attrs, text, content) = match rule {
558
+ NodeRule::Callback { content } => {
559
+ em.begin_frame();
560
+ if *content != Content::None && depth < MAX_DEPTH {
561
+ render_inline(txn, e, depth + 1, em, rules);
562
+ }
563
+ let captured = em.end_frame();
564
+ em.emit_deferred(
565
+ e.tag().to_string(),
566
+ xml_attrs_json(txn, e),
567
+ element_child_types(txn, e),
568
+ captured,
569
+ );
570
+ return;
571
+ }
572
+ NodeRule::Declarative {
573
+ tag,
574
+ void,
575
+ attrs,
576
+ text,
577
+ content,
578
+ } => (tag, *void, attrs, text, *content),
579
+ };
580
+ em.push('<');
581
+ em.push_str(tag);
582
+ for (name, parts) in attrs {
583
+ if let Some(value) = resolve_parts(parts, |r| xml_ref_attr(txn, e, r)) {
584
+ em.push(' ');
585
+ em.push_str(name);
586
+ em.push_str("=\"");
587
+ em.push_str(&escape_attr(&value));
588
+ em.push('\"');
589
+ }
590
+ }
591
+ em.push('>');
592
+ if void {
593
+ return;
594
+ }
595
+ if let Some(text) = text {
596
+ if let Some(value) = resolve_parts(text, |r| xml_ref_attr(txn, e, r)) {
597
+ em.push_str(&escape_text(&value));
598
+ }
599
+ }
600
+ if content != Content::None && depth < MAX_DEPTH {
601
+ render_inline(txn, e, depth + 1, em, rules);
602
+ }
603
+ em.push_str("</");
604
+ em.push_str(tag);
605
+ em.push('>');
606
+ }
607
+
608
+ /// Emit each formatted run of a Y.XmlText.
609
+ fn render_text_runs<T: ReadTxn>(txn: &T, t: &XmlTextRef, em: &mut Emitter, rules: &Rules) {
610
+ for d in t.diff(txn, YChange::identity) {
611
+ if let Out::Any(Any::String(s)) = &d.insert {
612
+ em.push_str(&render_run(s, d.attributes.as_deref(), rules));
613
+ }
614
+ }
615
+ }
616
+
617
+ /// Wrap one text run in its marks, nesting innermost-first:
618
+ /// subscript/superscript, highlight, underline, strike, italic, bold, a
619
+ /// textStyle span, then link on the outside. `code` renders alone among the
620
+ /// formatting marks (Tiptap's Code mark excludes them all), but a link still
621
+ /// wraps it — Tiptap can't produce code+link, prosemirror-schema-basic can,
622
+ /// and dropping the link would lose the href.
623
+ ///
624
+ /// A registered mark rule claims its stored name from the built-in wraps
625
+ /// (overriding it) and wraps outside everything; multiple custom marks nest
626
+ /// alphabetically by name, so output is deterministic regardless of
627
+ /// registration order. Overriding replaces only the markup: a claimed
628
+ /// `code` still excludes the other formatting marks, and a claimed
629
+ /// formatting mark stays excluded from a code run.
630
+ fn render_run(text: &str, marks: Option<&Attrs>, rules: &Rules) -> String {
631
+ let mut html = escape_text(text);
632
+ let Some(marks) = marks else {
633
+ return html;
634
+ };
635
+ if has(marks, &["code"], rules) {
636
+ html = wrap(html, "code");
637
+ } else if !marks.contains_key("code") {
638
+ // (A claimed `code` lands here too — on the run, but skipped by the
639
+ // else-if: the rule replaces the wrap, via wrap_custom_marks below,
640
+ // and code's exclusivity over the other formatting marks holds.)
641
+ if has(marks, &["subscript", "sub"], rules) {
642
+ html = wrap(html, "sub");
643
+ } else if has(marks, &["superscript", "sup"], rules) {
644
+ html = wrap(html, "sup");
645
+ }
646
+ if has(marks, &["highlight"], rules) {
647
+ html = wrap(html, "mark");
648
+ }
649
+ if has(marks, &["underline", "u"], rules) {
650
+ html = wrap(html, "u");
651
+ }
652
+ if has(marks, &["strike", "s"], rules) {
653
+ html = wrap(html, "s");
654
+ }
655
+ if has(marks, &["italic", "em"], rules) {
656
+ html = wrap(html, "em");
657
+ }
658
+ if has(marks, &["bold", "strong"], rules) {
659
+ html = wrap(html, "strong");
660
+ }
661
+ if !rules.marks.contains_key("textStyle") {
662
+ if let Some(Any::Map(style)) = marks.get("textStyle") {
663
+ let css = text_style_css(style);
664
+ if !css.is_empty() {
665
+ html = format!("<span style=\"{}\">{html}</span>", escape_attr(&css));
666
+ }
667
+ }
668
+ }
669
+ }
670
+ if !rules.marks.contains_key("link") {
671
+ if let Some(Any::Map(link)) = marks.get("link") {
672
+ html = wrap_link(html, link);
673
+ }
674
+ }
675
+ if !rules.marks.is_empty() {
676
+ html = wrap_custom_marks(html, marks, rules);
677
+ }
678
+ html
679
+ }
680
+
681
+ /// Apply the run's registered custom marks, outermost of everything. A code
682
+ /// run keeps its exclusivity under overrides too: only the `code` and `link`
683
+ /// claims themselves may wrap it, matching what the built-in wraps allow —
684
+ /// otherwise overriding a formatting mark would change which marks render on
685
+ /// a code run, not just their markup.
686
+ fn wrap_custom_marks(html: String, marks: &Attrs, rules: &Rules) -> String {
687
+ let code = marks.contains_key("code");
688
+ let mut names: Vec<&str> = rules
689
+ .marks
690
+ .keys()
691
+ .map(String::as_str)
692
+ .filter(|name| marks.contains_key(*name))
693
+ .filter(|name| !code || matches!(*name, "code" | "link"))
694
+ .collect();
695
+ names.sort_unstable();
696
+ let mut html = html;
697
+ for name in names {
698
+ html = wrap_custom_mark(html, &rules.marks[name], marks.get(name));
699
+ }
700
+ html
701
+ }
702
+
703
+ fn wrap_custom_mark(inner: String, rule: &MarkRule, value: Option<&Any>) -> String {
704
+ let mut out = String::from("<");
705
+ out.push_str(&rule.tag);
706
+ for (attr, parts) in &rule.attrs {
707
+ if let Some(v) = resolve_parts(parts, |r| mark_ref_attr(value, r)) {
708
+ out.push(' ');
709
+ out.push_str(attr);
710
+ out.push_str("=\"");
711
+ out.push_str(&escape_attr(&v));
712
+ out.push('"');
713
+ }
714
+ }
715
+ out.push('>');
716
+ out.push_str(&inner);
717
+ out.push_str("</");
718
+ out.push_str(&rule.tag);
719
+ out.push('>');
720
+ out
721
+ }
722
+
723
+ /// An attribute reference on a custom mark: y-prosemirror stores mark attrs
724
+ /// as a map under the mark's name (bool `true` when the mark has none).
725
+ fn mark_ref_attr(value: Option<&Any>, name: &str) -> Option<String> {
726
+ match value {
727
+ Some(Any::Map(map)) => any_attr_string(map.get(name)?),
728
+ _ => None,
729
+ }
730
+ }
731
+
732
+ /// The `style` string for a textStyle mark (Tiptap's Color/FontFamily/etc.
733
+ /// extensions all store their value as a textStyle attribute). Attributes are
734
+ /// camelCase CSS property names; unset ones sit in the map as explicit nulls.
735
+ /// Keys sort alphabetically, which is the order Tiptap serializes (color
736
+ /// before font-family). Hex colors convert to rgb() because that's how they
737
+ /// come back out of the browser's style attribute; other values pass through.
738
+ fn text_style_css(style: &std::collections::HashMap<String, Any>) -> String {
739
+ let mut pairs: Vec<_> = style
740
+ .iter()
741
+ .filter_map(|(k, v)| match v {
742
+ Any::String(s) => Some((k, s.as_ref())),
743
+ _ => None,
744
+ })
745
+ .collect();
746
+ pairs.sort_by(|a, b| a.0.cmp(b.0));
747
+ let mut css = String::new();
748
+ for (key, value) in pairs {
749
+ if !css.is_empty() {
750
+ css.push(' ');
751
+ }
752
+ // camelCase -> kebab-case: fontFamily -> font-family.
753
+ for ch in key.chars() {
754
+ if ch.is_ascii_uppercase() {
755
+ css.push('-');
756
+ css.push(ch.to_ascii_lowercase());
757
+ } else {
758
+ css.push(ch);
759
+ }
760
+ }
761
+ css.push_str(": ");
762
+ css.push_str(&hex_to_rgb(value).unwrap_or_else(|| value.to_string()));
763
+ css.push(';');
764
+ }
765
+ css
766
+ }
767
+
768
+ /// `#rgb`/`#rrggbb` -> `rgb(r, g, b)`, matching the browser's style-attribute
769
+ /// serialization. Anything else (named colors, rgb()/hsl(), fonts) is None.
770
+ fn hex_to_rgb(value: &str) -> Option<String> {
771
+ let hex = value.strip_prefix('#')?;
772
+ // len() and the slices below are byte-based; non-ASCII input would panic
773
+ // on a char boundary. Real hex never is, crafted input passes through.
774
+ if !hex.is_ascii() {
775
+ return None;
776
+ }
777
+ let (r, g, b) = match hex.len() {
778
+ 3 => {
779
+ let d = |i: usize| u8::from_str_radix(&hex[i..=i].repeat(2), 16);
780
+ (d(0).ok()?, d(1).ok()?, d(2).ok()?)
781
+ }
782
+ 6 => {
783
+ let d = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16);
784
+ (d(0).ok()?, d(2).ok()?, d(4).ok()?)
785
+ }
786
+ _ => return None,
787
+ };
788
+ Some(format!("rgb({r}, {g}, {b})"))
789
+ }
790
+
791
+ /// `<a>` with Tiptap's attribute order (target, rel, class, href, title),
792
+ /// skipping any that are absent or null.
793
+ fn wrap_link(inner: String, link: &std::collections::HashMap<String, Any>) -> String {
794
+ let mut out = String::from("<a");
795
+ for key in ["target", "rel", "class", "href", "title"] {
796
+ if let Some(Any::String(v)) = link.get(key) {
797
+ out.push(' ');
798
+ out.push_str(key);
799
+ out.push_str("=\"");
800
+ out.push_str(&escape_attr(v));
801
+ out.push('"');
802
+ }
803
+ }
804
+ out.push('>');
805
+ out.push_str(&inner);
806
+ out.push_str("</a>");
807
+ out
808
+ }
809
+
810
+ /// `<img>` with attribute order src, alt, title, skipping absent/null ones.
811
+ fn render_image<T: ReadTxn>(txn: &T, e: &XmlElementRef, em: &mut Emitter) {
812
+ em.push_str("<img");
813
+ for (attr, html) in [("src", "src"), ("alt", "alt"), ("title", "title")] {
814
+ if let Some(v) = str_attr(txn, e, attr) {
815
+ em.push(' ');
816
+ em.push_str(html);
817
+ em.push_str("=\"");
818
+ em.push_str(&escape_attr(&v));
819
+ em.push('"');
820
+ }
821
+ }
822
+ em.push('>');
823
+ }
824
+
825
+ fn wrap(inner: String, tag: &str) -> String {
826
+ format!("<{tag}>{inner}</{tag}>")
827
+ }
828
+
829
+ /// A mark is present and not claimed by a registered rule for that stored
830
+ /// name (a rule overrides the built-in wrap).
831
+ fn has(marks: &Attrs, keys: &[&str], rules: &Rules) -> bool {
832
+ keys.iter()
833
+ .any(|k| marks.contains_key(*k) && !rules.marks.contains_key(*k))
834
+ }
835
+
836
+ fn has_element_child<T: ReadTxn>(txn: &T, e: &XmlElementRef) -> bool {
837
+ e.children(txn).any(|c| matches!(c, XmlOut::Element(_)))
838
+ }
839
+
840
+ /// The concatenated text of a code block (no marks — code is plain text).
841
+ fn code_text<T: ReadTxn>(txn: &T, e: &XmlElementRef) -> String {
842
+ let mut s = String::new();
843
+ for node in e.children(txn) {
844
+ if let XmlOut::Text(t) = node {
845
+ for d in t.diff(txn, YChange::identity) {
846
+ if let Out::Any(Any::String(run)) = &d.insert {
847
+ s.push_str(run);
848
+ }
849
+ }
850
+ }
851
+ }
852
+ s
853
+ }
854
+
855
+ fn str_attr<T: ReadTxn>(txn: &T, e: &XmlElementRef, name: &str) -> Option<String> {
856
+ match e.get_attribute(txn, name) {
857
+ Some(Out::Any(Any::String(s))) => Some(s.to_string()),
858
+ _ => None,
859
+ }
860
+ }
861
+
862
+ fn num_attr<T: ReadTxn>(txn: &T, e: &XmlElementRef, name: &str) -> Option<i64> {
863
+ match e.get_attribute(txn, name) {
864
+ Some(Out::Any(Any::Number(n))) => Some(n as i64),
865
+ Some(Out::Any(Any::BigInt(n))) => Some(n),
866
+ _ => None,
867
+ }
868
+ }
869
+
870
+ /// Text-content escaping, matching the browser serializer: `&`, `<`, `>`.
871
+ fn escape_text(s: &str) -> String {
872
+ s.replace('&', "&amp;")
873
+ .replace('<', "&lt;")
874
+ .replace('>', "&gt;")
875
+ }
876
+
877
+ /// Attribute-value escaping: text escaping plus `"`.
878
+ fn escape_attr(s: &str) -> String {
879
+ escape_text(s).replace('"', "&quot;")
880
+ }
881
+
882
+ #[cfg(test)]
883
+ mod tests {
884
+ use super::*;
885
+ use std::collections::HashMap;
886
+ use std::sync::Arc;
887
+ use yrs::updates::decoder::Decode;
888
+ use yrs::{Doc, Transact, Update, XmlElementPrelim, XmlTextPrelim};
889
+
890
+ fn doc_from(bytes: &[u8]) -> Doc {
891
+ let doc = Doc::new();
892
+ doc.transact_mut()
893
+ .apply_update(Update::decode_v1(bytes).unwrap())
894
+ .unwrap();
895
+ doc
896
+ }
897
+
898
+ fn marks(keys: &[&str]) -> Attrs {
899
+ let mut a = Attrs::new();
900
+ for k in keys {
901
+ a.insert((*k).into(), Any::Bool(true));
902
+ }
903
+ a
904
+ }
905
+
906
+ fn run(text: &str, marks: Option<&Attrs>) -> String {
907
+ render_run(text, marks, &Rules::empty())
908
+ }
909
+
910
+ /// Core rendering of the captured Tiptap document, pinned as a golden
911
+ /// (`.core.html`). The task list renders through the unknown-container
912
+ /// fallback here (unwrapped children); the external truth — byte parity
913
+ /// with the editor's own `getHTML()` — is held at the Ruby layer, where
914
+ /// `Y::Tiptap` completes the schema. The fixture covers headings, every
915
+ /// mark and combination, links, escaping, blockquote, nested bullet and
916
+ /// ordered lists (with a `start`), a task list, code blocks with and
917
+ /// without a language, a hard break, a horizontal rule, an image, and
918
+ /// the trailing empty paragraph Tiptap keeps.
919
+ #[test]
920
+ fn core_rendering_of_the_tiptap_fixture_is_pinned() {
921
+ let doc = doc_from(include_bytes!("fixtures/prosemirror_tiptap.bin"));
922
+ let txn = doc.transact();
923
+ let frag = txn.get_xml_fragment("default").unwrap();
924
+ assert_eq!(
925
+ render(&txn, &frag).unwrap(),
926
+ include_str!("fixtures/prosemirror_tiptap.core.html")
927
+ );
928
+ }
929
+
930
+ /// A table renders as tiptap-php's semantic form — `<table><tbody>` with
931
+ /// colspan/rowspan cells — dropping the `<colgroup>`/`min-width` styling
932
+ /// Tiptap's editor view adds (and which isn't in the CRDT).
933
+ #[test]
934
+ fn renders_a_table_as_semantic_html() {
935
+ let doc = doc_from(include_bytes!("fixtures/prosemirror_table.bin"));
936
+ let txn = doc.transact();
937
+ let frag = txn.get_xml_fragment("default").unwrap();
938
+ assert_eq!(
939
+ render(&txn, &frag).unwrap(),
940
+ include_str!("fixtures/prosemirror_table.html")
941
+ );
942
+ }
943
+
944
+ #[test]
945
+ fn marks_nest_in_tiptaps_serializer_order() {
946
+ assert_eq!(run("x", None), "x");
947
+ assert_eq!(run("x", Some(&marks(&["bold"]))), "<strong>x</strong>");
948
+ assert_eq!(run("x", Some(&marks(&["italic"]))), "<em>x</em>");
949
+ // bold wraps italic.
950
+ assert_eq!(
951
+ run("x", Some(&marks(&["italic", "bold"]))),
952
+ "<strong><em>x</em></strong>"
953
+ );
954
+ // code excludes every other mark.
955
+ assert_eq!(run("x", Some(&marks(&["code", "bold"]))), "<code>x</code>");
956
+ // Full compatible stack, innermost sub to outermost bold.
957
+ assert_eq!(
958
+ run(
959
+ "x",
960
+ Some(&marks(&[
961
+ "bold",
962
+ "italic",
963
+ "strike",
964
+ "underline",
965
+ "highlight",
966
+ "subscript"
967
+ ]))
968
+ ),
969
+ "<strong><em><s><u><mark><sub>x</sub></mark></u></s></em></strong>"
970
+ );
971
+ // Escaping happens before wrapping.
972
+ assert_eq!(run("<&>", None), "&lt;&amp;&gt;");
973
+ }
974
+
975
+ #[test]
976
+ fn renders_a_link_run_with_tiptaps_attribute_order() {
977
+ let mut link = HashMap::new();
978
+ link.insert(
979
+ "href".to_string(),
980
+ Any::String("https://e.com?a=1&b=2".into()),
981
+ );
982
+ link.insert("target".to_string(), Any::String("_blank".into()));
983
+ link.insert("rel".to_string(), Any::String("noopener".into()));
984
+ let mut a = Attrs::new();
985
+ a.insert("link".into(), Any::Map(Arc::new(link)));
986
+ assert_eq!(
987
+ run("site", Some(&a)),
988
+ "<a target=\"_blank\" rel=\"noopener\" href=\"https://e.com?a=1&amp;b=2\">site</a>"
989
+ );
990
+
991
+ // class and title (Link's remaining attrs) keep Tiptap's serialized
992
+ // order: target, rel, class, href, title. Null-valued attrs skip.
993
+ let mut link = HashMap::new();
994
+ link.insert("href".to_string(), Any::String("https://d.example".into()));
995
+ link.insert("class".to_string(), Any::String("doc-link".into()));
996
+ link.insert("title".to_string(), Any::String("A Doc".into()));
997
+ link.insert("target".to_string(), Any::Null);
998
+ let mut a = Attrs::new();
999
+ a.insert("link".into(), Any::Map(Arc::new(link)));
1000
+ assert_eq!(
1001
+ run("the doc", Some(&a)),
1002
+ "<a class=\"doc-link\" href=\"https://d.example\" title=\"A Doc\">the doc</a>"
1003
+ );
1004
+ }
1005
+
1006
+ /// Core rendering of the captured mention document, pinned as a golden
1007
+ /// (`.core.html`). Mention is a Tiptap extension node — an atom with no
1008
+ /// children — so core renders it to nothing; parity with the editor's
1009
+ /// `getHTML()` is held at the Ruby layer by `Y::Tiptap`. The fixture
1010
+ /// holds one mention with a label, one with only an id, and a link
1011
+ /// carrying class and title.
1012
+ #[test]
1013
+ fn core_rendering_of_the_mention_fixture_is_pinned() {
1014
+ let doc = doc_from(include_bytes!("fixtures/prosemirror_mention.bin"));
1015
+ let txn = doc.transact();
1016
+ let frag = txn.get_xml_fragment("default").unwrap();
1017
+ assert_eq!(
1018
+ render(&txn, &frag).unwrap(),
1019
+ include_str!("fixtures/prosemirror_mention.core.html")
1020
+ );
1021
+ }
1022
+
1023
+ /// textStyle (captured from Tiptap's Color/FontFamily extensions): hex
1024
+ /// colors come back out of the browser as rgb(), rgb() strings pass
1025
+ /// through, font-family joins the same span, and the span wraps outside
1026
+ /// bold.
1027
+ #[test]
1028
+ fn renders_the_captured_textstyle_document_byte_for_byte() {
1029
+ let doc = doc_from(include_bytes!("fixtures/prosemirror_textstyle.bin"));
1030
+ let txn = doc.transact();
1031
+ let frag = txn.get_xml_fragment("default").unwrap();
1032
+ assert_eq!(
1033
+ render(&txn, &frag).unwrap(),
1034
+ include_str!("fixtures/prosemirror_textstyle.html")
1035
+ );
1036
+ }
1037
+
1038
+ #[test]
1039
+ fn text_style_converts_hex_and_kebab_cases_keys() {
1040
+ let mut style = HashMap::new();
1041
+ style.insert("color".to_string(), Any::String("#ff0000".into()));
1042
+ style.insert(
1043
+ "fontFamily".to_string(),
1044
+ Any::String("Georgia, serif".into()),
1045
+ );
1046
+ style.insert("fontSize".to_string(), Any::Null); // unset: skipped
1047
+ assert_eq!(
1048
+ text_style_css(&style),
1049
+ "color: rgb(255, 0, 0); font-family: Georgia, serif;"
1050
+ );
1051
+
1052
+ assert_eq!(hex_to_rgb("#0f8"), Some("rgb(0, 255, 136)".to_string()));
1053
+ assert_eq!(hex_to_rgb("rebeccapurple"), None);
1054
+ assert_eq!(hex_to_rgb("#12345"), None);
1055
+ // Multibyte input must pass through, not panic on a byte-slice
1056
+ // boundary ("日" is one char, three bytes — it enters the 3 arm).
1057
+ assert_eq!(hex_to_rgb("#日"), None);
1058
+ assert_eq!(hex_to_rgb("#日本"), None);
1059
+ }
1060
+
1061
+ /// A known container with bare text jammed directly into it (schema-valid
1062
+ /// documents never do this) keeps the text instead of dropping it.
1063
+ #[test]
1064
+ fn a_known_container_keeps_stray_direct_text() {
1065
+ let doc = Doc::new();
1066
+ let frag = doc.get_or_insert_xml_fragment("default");
1067
+ {
1068
+ let mut txn = doc.transact_mut();
1069
+ let bq = frag.push_back(&mut txn, XmlElementPrelim::empty("blockquote"));
1070
+ bq.push_back(&mut txn, XmlTextPrelim::new("stray"));
1071
+ let p = bq.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
1072
+ p.push_back(&mut txn, XmlTextPrelim::new("body"));
1073
+ }
1074
+ let txn = doc.transact();
1075
+ assert_eq!(
1076
+ render(&txn, &frag).unwrap(),
1077
+ "<blockquote>stray<p>body</p></blockquote>"
1078
+ );
1079
+ }
1080
+
1081
+ /// prosemirror-schema-basic's `code` mark has no excludes, so a code run
1082
+ /// can also carry a link; the link must survive. (Tiptap's Code mark
1083
+ /// excludes everything, so this shape only comes from schema-basic docs.)
1084
+ #[test]
1085
+ fn a_code_run_keeps_its_link() {
1086
+ let mut link = HashMap::new();
1087
+ link.insert("href".to_string(), Any::String("https://e.com".into()));
1088
+ let mut a = Attrs::new();
1089
+ a.insert("code".into(), Any::Map(Arc::new(HashMap::new())));
1090
+ a.insert("link".into(), Any::Map(Arc::new(link)));
1091
+ assert_eq!(
1092
+ run("x", Some(&a)),
1093
+ "<a href=\"https://e.com\"><code>x</code></a>"
1094
+ );
1095
+ }
1096
+
1097
+ /// An unknown block holding both text and child blocks keeps the text.
1098
+ #[test]
1099
+ fn an_unknown_block_with_mixed_content_keeps_its_text() {
1100
+ let doc = Doc::new();
1101
+ let frag = doc.get_or_insert_xml_fragment("default");
1102
+ {
1103
+ let mut txn = doc.transact_mut();
1104
+ let callout = frag.push_back(&mut txn, XmlElementPrelim::empty("callout"));
1105
+ callout.push_back(&mut txn, XmlTextPrelim::new("intro"));
1106
+ let p = callout.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
1107
+ p.push_back(&mut txn, XmlTextPrelim::new("body"));
1108
+ }
1109
+ let txn = doc.transact();
1110
+ assert_eq!(render(&txn, &frag).unwrap(), "intro<p>body</p>");
1111
+ }
1112
+
1113
+ /// An unknown inline node keeps its text instead of vanishing.
1114
+ #[test]
1115
+ fn an_unknown_inline_node_keeps_its_text() {
1116
+ let doc = Doc::new();
1117
+ let frag = doc.get_or_insert_xml_fragment("default");
1118
+ {
1119
+ let mut txn = doc.transact_mut();
1120
+ let p = frag.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
1121
+ p.push_back(&mut txn, XmlTextPrelim::new("see "));
1122
+ let custom = p.push_back(&mut txn, XmlElementPrelim::empty("customInline"));
1123
+ custom.push_back(&mut txn, XmlTextPrelim::new("kept"));
1124
+ p.push_back(&mut txn, XmlTextPrelim::new(" here"));
1125
+ }
1126
+ let txn = doc.transact();
1127
+ assert_eq!(render(&txn, &frag).unwrap(), "<p>see kept here</p>");
1128
+ }
1129
+
1130
+ /// The prosemirror-schema-basic spellings (snake_case nodes, `strong`/`em`
1131
+ /// marks) render the same as Tiptap's camelCase.
1132
+ #[test]
1133
+ fn accepts_prosemirror_basic_schema_names() {
1134
+ let doc = Doc::new();
1135
+ let frag = doc.get_or_insert_xml_fragment("default");
1136
+ {
1137
+ let mut txn = doc.transact_mut();
1138
+ let bq = frag.push_back(&mut txn, XmlElementPrelim::empty("blockquote"));
1139
+ let p = bq.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
1140
+ let t = p.push_back(&mut txn, XmlTextPrelim::new("hi bold it"));
1141
+ t.format(&mut txn, 3, 4, marks(&["strong"]));
1142
+ t.format(&mut txn, 8, 2, marks(&["em"]));
1143
+ }
1144
+ let txn = doc.transact();
1145
+ assert_eq!(
1146
+ render(&txn, &frag).unwrap(),
1147
+ "<blockquote><p>hi <strong>bold</strong> <em>it</em></p></blockquote>"
1148
+ );
1149
+ }
1150
+
1151
+ #[test]
1152
+ fn a_lexical_shaped_root_is_refused() {
1153
+ // A Lexical doc stores blocks as XmlText carrying `__type`. That's a
1154
+ // different schema; render must return None, not a garbled document.
1155
+ let doc = Doc::new();
1156
+ // Create both roots before opening the read transaction:
1157
+ // get_or_insert_* opens its own write transaction, which would deadlock
1158
+ // against a live read guard.
1159
+ let frag = doc.get_or_insert_xml_fragment("root");
1160
+ let empty = doc.get_or_insert_xml_fragment("empty");
1161
+ {
1162
+ let mut txn = doc.transact_mut();
1163
+ let block = frag.push_back(&mut txn, XmlTextPrelim::new("hello"));
1164
+ block.insert_attribute(&mut txn, "__type", "paragraph");
1165
+ }
1166
+ let txn = doc.transact();
1167
+ assert_eq!(render(&txn, &frag), None);
1168
+ // An empty root is fine.
1169
+ assert_eq!(render(&txn, &empty).as_deref(), Some(""));
1170
+ }
1171
+
1172
+ #[test]
1173
+ fn deeply_nested_blocks_do_not_overflow_the_stack() {
1174
+ // The walk is on the heap, so nesting that would blow a small native
1175
+ // stack under recursion renders fine. Built and rendered on a 512 KiB
1176
+ // thread so it can't pass just by having room to spare.
1177
+ std::thread::Builder::new()
1178
+ .stack_size(512 * 1024)
1179
+ .spawn(|| {
1180
+ let doc = Doc::new();
1181
+ let frag = doc.get_or_insert_xml_fragment("default");
1182
+ {
1183
+ let mut txn = doc.transact_mut();
1184
+ let mut cursor =
1185
+ frag.push_back(&mut txn, XmlElementPrelim::empty("blockquote"));
1186
+ for _ in 0..20_000 {
1187
+ cursor = cursor.push_back(&mut txn, XmlElementPrelim::empty("blockquote"));
1188
+ }
1189
+ let p = cursor.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
1190
+ p.push_back(&mut txn, XmlTextPrelim::new("deep"));
1191
+ }
1192
+ let txn = doc.transact();
1193
+ let html = render(&txn, &frag).expect("prosemirror-shaped");
1194
+ assert_eq!(
1195
+ html.matches("<blockquote>").count(),
1196
+ html.matches("</blockquote>").count()
1197
+ );
1198
+ assert!(html.contains("<p>deep</p>") || html.contains("<blockquote>"));
1199
+ })
1200
+ .unwrap()
1201
+ .join()
1202
+ .unwrap();
1203
+ }
1204
+
1205
+ #[test]
1206
+ fn escaping_matches_the_browser_serializer() {
1207
+ assert_eq!(escape_text(r#"<a & "b">"#), r#"&lt;a &amp; "b"&gt;"#);
1208
+ assert_eq!(
1209
+ escape_attr(r#"<a & "b">"#),
1210
+ r#"&lt;a &amp; &quot;b&quot;&gt;"#
1211
+ );
1212
+ }
1213
+
1214
+ /// A declarative node rule renders natively: tag, attribute refs resolved
1215
+ /// from the node's attrs, and a blocks content slot. Rules can also
1216
+ /// override a built-in (paragraph here).
1217
+ #[test]
1218
+ fn a_declarative_rule_renders_and_can_override_a_builtin() {
1219
+ let rules = Rules::parse(
1220
+ r#"{ "nodes": {
1221
+ "callout": { "tag": "aside",
1222
+ "attrs": [["class", [{"lit": "callout callout--"}, {"ref": "kind"}]]],
1223
+ "content": "blocks" },
1224
+ "paragraph": { "tag": "div", "attrs": [["class", [{"lit": "para"}]]] } } }"#,
1225
+ )
1226
+ .unwrap();
1227
+ let doc = Doc::new();
1228
+ let frag = doc.get_or_insert_xml_fragment("default");
1229
+ {
1230
+ let mut txn = doc.transact_mut();
1231
+ let callout = frag.push_back(&mut txn, XmlElementPrelim::empty("callout"));
1232
+ callout.insert_attribute(&mut txn, "kind", "warning");
1233
+ let p = callout.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
1234
+ p.push_back(&mut txn, XmlTextPrelim::new("careful"));
1235
+ }
1236
+ let txn = doc.transact();
1237
+ let segs = render_segments(&txn, &frag, &rules).unwrap();
1238
+ assert_eq!(
1239
+ yrs_html_core::flatten(segs).into_html().unwrap(),
1240
+ "<aside class=\"callout callout--warning\"><div class=\"para\">careful</div></aside>"
1241
+ );
1242
+ }
1243
+
1244
+ /// A callback rule defers: the node comes back as a Deferred segment with
1245
+ /// its attrs as JSON and its children already rendered.
1246
+ #[test]
1247
+ fn a_callback_rule_emits_a_deferred_segment_with_rendered_content() {
1248
+ let rules = Rules::parse(
1249
+ r#"{ "nodes": { "videoEmbed": { "callback": true, "content": "blocks" } } }"#,
1250
+ )
1251
+ .unwrap();
1252
+ let doc = Doc::new();
1253
+ let frag = doc.get_or_insert_xml_fragment("default");
1254
+ {
1255
+ let mut txn = doc.transact_mut();
1256
+ let intro = frag.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
1257
+ intro.push_back(&mut txn, XmlTextPrelim::new("watch:"));
1258
+ let video = frag.push_back(&mut txn, XmlElementPrelim::empty("videoEmbed"));
1259
+ video.insert_attribute(&mut txn, "src", "https://v.example/1");
1260
+ let caption = video.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
1261
+ caption.push_back(&mut txn, XmlTextPrelim::new("the caption"));
1262
+ }
1263
+ let txn = doc.transact();
1264
+ let segs = render_segments(&txn, &frag, &rules).unwrap();
1265
+ assert_eq!(segs.len(), 2);
1266
+ assert!(matches!(&segs[0], Segment::Html(s) if s == "<p>watch:</p>"));
1267
+ let Segment::Deferred {
1268
+ node_type,
1269
+ attrs_json,
1270
+ child_types,
1271
+ content,
1272
+ } = &segs[1]
1273
+ else {
1274
+ panic!("expected a deferred segment");
1275
+ };
1276
+ assert_eq!(node_type, "videoEmbed");
1277
+ assert_eq!(attrs_json, r#"{"src":"https://v.example/1"}"#);
1278
+ assert_eq!(child_types, &["paragraph"]);
1279
+ assert!(matches!(&content[0], Segment::Html(s) if s == "<p>the caption</p>"));
1280
+ }
1281
+
1282
+ /// Discovery reports facts per type, and an unknown type annotates as
1283
+ /// unhandled (null) — the signal a rule author filters for.
1284
+ #[test]
1285
+ fn node_type_discovery_reports_unknown_types_as_unhandled() {
1286
+ let doc = Doc::new();
1287
+ let frag = doc.get_or_insert_xml_fragment("default");
1288
+ {
1289
+ let mut txn = doc.transact_mut();
1290
+ let callout = frag.push_back(&mut txn, XmlElementPrelim::empty("callout"));
1291
+ callout.insert_attribute(&mut txn, "kind", "warning");
1292
+ let p = callout.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
1293
+ p.push_back(&mut txn, XmlTextPrelim::new("body"));
1294
+ }
1295
+ let txn = doc.transact();
1296
+ let map = collect_node_types(&txn, &frag).unwrap();
1297
+ let json = yrs_html_core::type_map_json(&map, |ty| {
1298
+ if is_builtin(ty) {
1299
+ Some("builtin")
1300
+ } else {
1301
+ None
1302
+ }
1303
+ });
1304
+ let v: serde_json::Value = serde_json::from_str(&json).unwrap();
1305
+ assert_eq!(v["callout"]["handled"], serde_json::Value::Null);
1306
+ assert_eq!(v["callout"]["attrs"][0], "kind");
1307
+ assert_eq!(v["callout"]["children"][0], "paragraph");
1308
+ assert_eq!(v["paragraph"]["handled"], "builtin");
1309
+ assert_eq!(v["paragraph"]["text"], true);
1310
+ }
1311
+
1312
+ /// A custom mark claims its stored name from the built-ins and wraps
1313
+ /// outside everything, attribute refs resolving against the mark's map.
1314
+ #[test]
1315
+ fn a_custom_mark_overrides_the_builtin_and_wraps_outermost() {
1316
+ let rules = Rules::parse(
1317
+ r#"{ "marks": {
1318
+ "comment": { "tag": "span", "attrs": [["data-comment-id", [{"ref": "id"}]]] },
1319
+ "bold": { "tag": "b" } } }"#,
1320
+ )
1321
+ .unwrap();
1322
+ let mut comment = HashMap::new();
1323
+ comment.insert("id".to_string(), Any::String("c1".into()));
1324
+ let mut a = Attrs::new();
1325
+ a.insert("bold".into(), Any::Bool(true));
1326
+ a.insert("italic".into(), Any::Bool(true));
1327
+ a.insert("comment".into(), Any::Map(Arc::new(comment)));
1328
+ assert_eq!(
1329
+ render_run("x", Some(&a), &rules),
1330
+ "<span data-comment-id=\"c1\"><b><em>x</em></b></span>"
1331
+ );
1332
+
1333
+ // Overriding code replaces only its tag: the other
1334
+ // formatting marks stay excluded.
1335
+ let kbd = Rules::parse(r#"{ "marks": { "code": { "tag": "kbd" } } }"#).unwrap();
1336
+ let mut a = Attrs::new();
1337
+ a.insert("code".into(), Any::Bool(true));
1338
+ a.insert("bold".into(), Any::Bool(true));
1339
+ assert_eq!(render_run("x", Some(&a), &kbd), "<kbd>x</kbd>");
1340
+ }
1341
+
1342
+ /// The exclusivity holds from the other direction too: a claimed
1343
+ /// formatting mark must not sneak onto a code run through the custom
1344
+ /// wrap when the built-in wrap would have excluded it.
1345
+ #[test]
1346
+ fn a_claimed_formatting_mark_stays_excluded_on_a_code_run() {
1347
+ let rules = Rules::parse(r#"{ "marks": { "bold": { "tag": "b" } } }"#).unwrap();
1348
+ let mut a = Attrs::new();
1349
+ a.insert("code".into(), Any::Bool(true));
1350
+ a.insert("bold".into(), Any::Bool(true));
1351
+ assert_eq!(render_run("x", Some(&a), &rules), "<code>x</code>");
1352
+
1353
+ // The link claim is the exception, matching the built-in behavior
1354
+ // (schema-basic can produce code+link; the href must survive).
1355
+ let link_rule =
1356
+ Rules::parse(r#"{ "marks": { "link": { "tag": "a", "attrs": [["href", [{"ref": "href"}]]] }, "bold": { "tag": "b" } } }"#)
1357
+ .unwrap();
1358
+ let mut link = HashMap::new();
1359
+ link.insert("href".to_string(), Any::String("https://e.com".into()));
1360
+ let mut a = Attrs::new();
1361
+ a.insert("code".into(), Any::Bool(true));
1362
+ a.insert("bold".into(), Any::Bool(true));
1363
+ a.insert("link".into(), Any::Map(Arc::new(link)));
1364
+ assert_eq!(
1365
+ render_run("x", Some(&a), &link_rule),
1366
+ "<a href=\"https://e.com\"><code>x</code></a>"
1367
+ );
1368
+ }
1369
+ }