yrby 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,896 @@
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
+ //! Output follows `ueberdosis/tiptap-php` (the maintained PHP renderer for
12
+ //! ProseMirror JSON) and matches Tiptap's own `getHTML()` byte for byte on the
13
+ //! captured fixtures — including mentions — with one deliberate exception: a
14
+ //! table renders as the semantic `<table><tbody>…`, without the
15
+ //! `<colgroup>`/`min-width` styling Tiptap's editor view injects (tiptap-php
16
+ //! drops it too). The details family follows tiptap-php's renderHTML, since
17
+ //! that Tiptap extension is Pro-only.
18
+ //!
19
+ //! Marks nest in a fixed order (outermost first): link, bold, italic, strike,
20
+ //! underline, highlight, then subscript/superscript. `code` excludes every
21
+ //! other mark, so a code run is just `<code>`.
22
+
23
+ use yrs::types::text::YChange;
24
+ use yrs::types::Attrs;
25
+ use yrs::{
26
+ Any, GetString, Out, ReadTxn, Text, Xml, XmlElementRef, XmlFragment, XmlFragmentRef, XmlOut,
27
+ XmlTextRef,
28
+ };
29
+
30
+ // The block tree is walked on a heap stack, so depth costs memory, not native
31
+ // stack frames; this caps the work a pathological document can demand.
32
+ const MAX_DEPTH: usize = 1024;
33
+
34
+ /// A pending block on the traversal stack. `Open` renders a node (pushing its
35
+ /// block children as more work); `Close` emits a container's end tag once its
36
+ /// children are done. Container end tags are all fixed strings.
37
+ enum Work {
38
+ Open(XmlElementRef, usize),
39
+ Close(&'static str),
40
+ }
41
+
42
+ /// Render a ProseMirror/Tiptap-shaped XML root to HTML, or `None` when the root
43
+ /// isn't ProseMirror-shaped. ProseMirror blocks are plain Y.XmlElement tags; a
44
+ /// Lexical root (Y.XmlText children carrying a `__type`) is a different schema
45
+ /// and returns `None` instead of a garbled render.
46
+ pub fn render<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> Option<String> {
47
+ if !is_prosemirror_shaped(txn, fragment) {
48
+ return None;
49
+ }
50
+ let mut out = String::new();
51
+ for node in fragment.children(txn) {
52
+ match node {
53
+ XmlOut::Element(e) => render_block_tree(txn, &e, &mut out),
54
+ // y-prosemirror never writes a bare text run at the root, but a
55
+ // crafted doc can; render it rather than drop it.
56
+ XmlOut::Text(t) => render_text_runs(txn, &t, &mut out),
57
+ // Fragments can't nest as children in yrs, so this arm shouldn't
58
+ // be reachable; it exists because the match must be exhaustive,
59
+ // and escaping the text is the safe degradation.
60
+ XmlOut::Fragment(f) => out.push_str(&escape_text(&f.get_string(txn))),
61
+ }
62
+ }
63
+ Some(out)
64
+ }
65
+
66
+ /// A root is ProseMirror-shaped when it's empty or its first child is a block
67
+ /// element with no `__type` (Lexical stamps `__type` on every node; ProseMirror
68
+ /// uses the node type as the element tag).
69
+ fn is_prosemirror_shaped<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> bool {
70
+ match fragment.children(txn).next() {
71
+ Some(XmlOut::Element(e)) => e.get_attribute(txn, "__type").is_none(),
72
+ Some(_) => false, // Lexical stores blocks as XmlText
73
+ None => true, // empty document
74
+ }
75
+ }
76
+
77
+ /// Walk one top-level block and everything under it on a heap stack.
78
+ fn render_block_tree<T: ReadTxn>(txn: &T, root: &XmlElementRef, out: &mut String) {
79
+ let mut stack: Vec<Work> = vec![Work::Open(root.clone(), 0)];
80
+ while let Some(work) = stack.pop() {
81
+ match work {
82
+ Work::Close(tag) => out.push_str(tag),
83
+ Work::Open(node, depth) => open_block(txn, &node, depth, out, &mut stack),
84
+ }
85
+ }
86
+ }
87
+
88
+ /// Render one block. Text blocks (paragraph, heading, code) render in full on
89
+ /// the spot; container blocks emit their opening tag and defer their children
90
+ /// (and matching `Close`) to the stack.
91
+ fn open_block<T: ReadTxn>(
92
+ txn: &T,
93
+ e: &XmlElementRef,
94
+ depth: usize,
95
+ out: &mut String,
96
+ stack: &mut Vec<Work>,
97
+ ) {
98
+ match e.tag().as_ref() {
99
+ "paragraph" => {
100
+ out.push_str("<p>");
101
+ out.push_str(&render_inline(txn, e, 0));
102
+ out.push_str("</p>");
103
+ }
104
+ "heading" => {
105
+ let level = num_attr(txn, e, "level").unwrap_or(1).clamp(1, 6);
106
+ let tag = ['1', '2', '3', '4', '5', '6'][(level - 1) as usize];
107
+ out.push_str("<h");
108
+ out.push(tag);
109
+ out.push('>');
110
+ out.push_str(&render_inline(txn, e, 0));
111
+ out.push_str("</h");
112
+ out.push(tag);
113
+ out.push('>');
114
+ }
115
+ "codeBlock" | "code_block" => {
116
+ out.push_str("<pre><code");
117
+ if let Some(lang) = str_attr(txn, e, "language").filter(|l| !l.is_empty()) {
118
+ out.push_str(" class=\"language-");
119
+ out.push_str(&escape_attr(&lang));
120
+ out.push('"');
121
+ }
122
+ out.push('>');
123
+ out.push_str(&escape_text(&code_text(txn, e)));
124
+ out.push_str("</code></pre>");
125
+ }
126
+ "blockquote" => open_container(txn, e, depth, "<blockquote>", "</blockquote>", out, stack),
127
+ "bulletList" | "bullet_list" => open_container(txn, e, depth, "<ul>", "</ul>", out, stack),
128
+ "orderedList" | "ordered_list" => {
129
+ match num_attr(txn, e, "start") {
130
+ Some(start) if start != 1 => {
131
+ out.push_str("<ol start=\"");
132
+ out.push_str(&start.to_string());
133
+ out.push_str("\">");
134
+ }
135
+ _ => out.push_str("<ol>"),
136
+ }
137
+ push_block_children(txn, e, depth, "</ol>", out, stack);
138
+ }
139
+ "listItem" | "list_item" => open_container(txn, e, depth, "<li>", "</li>", out, stack),
140
+ "taskList" | "task_list" => open_container(
141
+ txn,
142
+ e,
143
+ depth,
144
+ "<ul data-type=\"taskList\">",
145
+ "</ul>",
146
+ out,
147
+ stack,
148
+ ),
149
+ "taskItem" | "task_item" => {
150
+ let checked = bool_attr(txn, e, "checked");
151
+ out.push_str("<li data-checked=\"");
152
+ out.push_str(if checked { "true" } else { "false" });
153
+ out.push_str("\" data-type=\"taskItem\"><label><input type=\"checkbox\"");
154
+ if checked {
155
+ out.push_str(" checked=\"checked\"");
156
+ }
157
+ out.push_str("><span></span></label><div>");
158
+ push_block_children(txn, e, depth, "</div></li>", out, stack);
159
+ }
160
+ "table" => {
161
+ out.push_str("<table><tbody>");
162
+ push_block_children(txn, e, depth, "</tbody></table>", out, stack);
163
+ }
164
+ "tableRow" | "table_row" => open_container(txn, e, depth, "<tr>", "</tr>", out, stack),
165
+ "tableHeader" | "table_header" => open_cell(txn, e, depth, "th", "</th>", out, stack),
166
+ "tableCell" | "table_cell" => open_cell(txn, e, depth, "td", "</td>", out, stack),
167
+ // The details family follows tiptap-php (the extension is Tiptap Pro,
168
+ // so there's no free getHTML() to capture against).
169
+ "details" => {
170
+ let open = if bool_attr(txn, e, "open") {
171
+ "<details open=\"open\">"
172
+ } else {
173
+ "<details>"
174
+ };
175
+ open_container(txn, e, depth, open, "</details>", out, stack);
176
+ }
177
+ "detailsSummary" | "details_summary" => {
178
+ out.push_str("<summary>");
179
+ out.push_str(&render_inline(txn, e, 0));
180
+ out.push_str("</summary>");
181
+ }
182
+ "detailsContent" | "details_content" => open_container(
183
+ txn,
184
+ e,
185
+ depth,
186
+ "<div data-type=\"detailsContent\">",
187
+ "</div>",
188
+ out,
189
+ stack,
190
+ ),
191
+ "horizontalRule" | "horizontal_rule" => out.push_str("<hr>"),
192
+ "image" => render_image(txn, e, out),
193
+ "hardBreak" | "hard_break" => out.push_str("<br>"),
194
+ // Unknown block: keep its content rather than dropping it. If it holds
195
+ // child blocks, render them with no wrapper; otherwise treat it as a
196
+ // text block.
197
+ _ => {
198
+ if has_element_child(txn, e) {
199
+ // Renders its direct text runs, then the children, with no
200
+ // invented wrapper tags.
201
+ push_block_children(txn, e, depth, "", out, stack);
202
+ } else {
203
+ let inline = render_inline(txn, e, 0);
204
+ if !inline.is_empty() {
205
+ out.push_str("<p>");
206
+ out.push_str(&inline);
207
+ out.push_str("</p>");
208
+ }
209
+ }
210
+ }
211
+ }
212
+ }
213
+
214
+ fn open_container<T: ReadTxn>(
215
+ txn: &T,
216
+ e: &XmlElementRef,
217
+ depth: usize,
218
+ open: &str,
219
+ close: &'static str,
220
+ out: &mut String,
221
+ stack: &mut Vec<Work>,
222
+ ) {
223
+ out.push_str(open);
224
+ push_block_children(txn, e, depth, close, out, stack);
225
+ }
226
+
227
+ /// A table cell: `<th>`/`<td>` carrying colspan/rowspan (default 1, always
228
+ /// emitted, matching Tiptap).
229
+ fn open_cell<T: ReadTxn>(
230
+ txn: &T,
231
+ e: &XmlElementRef,
232
+ depth: usize,
233
+ tag: &str,
234
+ close: &'static str,
235
+ out: &mut String,
236
+ stack: &mut Vec<Work>,
237
+ ) {
238
+ out.push('<');
239
+ out.push_str(tag);
240
+ out.push_str(" colspan=\"");
241
+ out.push_str(&num_attr(txn, e, "colspan").unwrap_or(1).to_string());
242
+ out.push_str("\" rowspan=\"");
243
+ out.push_str(&num_attr(txn, e, "rowspan").unwrap_or(1).to_string());
244
+ out.push_str("\">");
245
+ push_block_children(txn, e, depth, close, out, stack);
246
+ }
247
+
248
+ /// Defer a node's child *elements* onto the stack, closing tag below them, so
249
+ /// they render in order and the tag closes after. Any direct text runs render
250
+ /// to `out` first: schema-valid documents never put bare text in a container,
251
+ /// but a crafted one can, and dropping it would lose content. Past `MAX_DEPTH`
252
+ /// the children are dropped but the tag still closes, keeping the output well
253
+ /// formed.
254
+ fn push_block_children<T: ReadTxn>(
255
+ txn: &T,
256
+ e: &XmlElementRef,
257
+ depth: usize,
258
+ close: &'static str,
259
+ out: &mut String,
260
+ stack: &mut Vec<Work>,
261
+ ) {
262
+ for node in e.children(txn) {
263
+ if let XmlOut::Text(t) = node {
264
+ render_text_runs(txn, &t, out);
265
+ }
266
+ }
267
+ stack.push(Work::Close(close));
268
+ if depth >= MAX_DEPTH {
269
+ return;
270
+ }
271
+ let children: Vec<_> = e
272
+ .children(txn)
273
+ .filter_map(|c| match c {
274
+ XmlOut::Element(el) => Some(el),
275
+ _ => None,
276
+ })
277
+ .collect();
278
+ for child in children.into_iter().rev() {
279
+ stack.push(Work::Open(child, depth + 1));
280
+ }
281
+ }
282
+
283
+ /// Render a text block's inline content: text runs (with their marks) and
284
+ /// inline element nodes (hard breaks, mentions, inline images). An unknown
285
+ /// inline node keeps its text instead of vanishing; `depth` caps that
286
+ /// recursion on a crafted nest of unknowns.
287
+ fn render_inline<T: ReadTxn>(txn: &T, e: &XmlElementRef, depth: usize) -> String {
288
+ let mut out = String::new();
289
+ for node in e.children(txn) {
290
+ match node {
291
+ XmlOut::Text(t) => render_text_runs(txn, &t, &mut out),
292
+ XmlOut::Element(child) => match child.tag().as_ref() {
293
+ "hardBreak" | "hard_break" => out.push_str("<br>"),
294
+ "image" => render_image(txn, &child, &mut out),
295
+ "mention" => render_mention(txn, &child, &mut out),
296
+ _ => {
297
+ if depth < MAX_DEPTH {
298
+ out.push_str(&render_inline(txn, &child, depth + 1));
299
+ }
300
+ }
301
+ },
302
+ XmlOut::Fragment(_) => {}
303
+ }
304
+ }
305
+ out
306
+ }
307
+
308
+ /// A mention, as Tiptap's Mention extension serializes it (no app-configured
309
+ /// HTMLAttributes): `data-type`, `data-id`, `data-label` when present, the
310
+ /// suggestion char, and `@label` (falling back to `@id`) as the text.
311
+ fn render_mention<T: ReadTxn>(txn: &T, e: &XmlElementRef, out: &mut String) {
312
+ let id = str_attr(txn, e, "id");
313
+ let label = str_attr(txn, e, "label");
314
+ let char = str_attr(txn, e, "mentionSuggestionChar").unwrap_or_else(|| "@".to_string());
315
+ out.push_str("<span data-type=\"mention\"");
316
+ if let Some(id) = &id {
317
+ out.push_str(" data-id=\"");
318
+ out.push_str(&escape_attr(id));
319
+ out.push('"');
320
+ }
321
+ if let Some(label) = &label {
322
+ out.push_str(" data-label=\"");
323
+ out.push_str(&escape_attr(label));
324
+ out.push('"');
325
+ }
326
+ out.push_str(" data-mention-suggestion-char=\"");
327
+ out.push_str(&escape_attr(&char));
328
+ out.push_str("\">");
329
+ out.push_str(&escape_text(&char));
330
+ out.push_str(&escape_text(&label.or(id).unwrap_or_default()));
331
+ out.push_str("</span>");
332
+ }
333
+
334
+ /// Emit each formatted run of a Y.XmlText.
335
+ fn render_text_runs<T: ReadTxn>(txn: &T, t: &XmlTextRef, out: &mut String) {
336
+ for d in t.diff(txn, YChange::identity) {
337
+ if let Out::Any(Any::String(s)) = &d.insert {
338
+ out.push_str(&render_run(s, d.attributes.as_deref()));
339
+ }
340
+ }
341
+ }
342
+
343
+ /// Wrap one text run in its marks, nesting innermost-first:
344
+ /// subscript/superscript, highlight, underline, strike, italic, bold, a
345
+ /// textStyle span, then link on the outside. `code` renders alone among the
346
+ /// formatting marks (Tiptap's Code mark excludes them all), but a link still
347
+ /// wraps it — Tiptap can't produce code+link, prosemirror-schema-basic can,
348
+ /// and dropping the link would lose the href.
349
+ fn render_run(text: &str, marks: Option<&Attrs>) -> String {
350
+ let mut html = escape_text(text);
351
+ let Some(marks) = marks else {
352
+ return html;
353
+ };
354
+ if has(marks, &["code"]) {
355
+ html = wrap(html, "code");
356
+ } else {
357
+ if has(marks, &["subscript", "sub"]) {
358
+ html = wrap(html, "sub");
359
+ } else if has(marks, &["superscript", "sup"]) {
360
+ html = wrap(html, "sup");
361
+ }
362
+ if has(marks, &["highlight"]) {
363
+ html = wrap(html, "mark");
364
+ }
365
+ if has(marks, &["underline", "u"]) {
366
+ html = wrap(html, "u");
367
+ }
368
+ if has(marks, &["strike", "s"]) {
369
+ html = wrap(html, "s");
370
+ }
371
+ if has(marks, &["italic", "em"]) {
372
+ html = wrap(html, "em");
373
+ }
374
+ if has(marks, &["bold", "strong"]) {
375
+ html = wrap(html, "strong");
376
+ }
377
+ if let Some(Any::Map(style)) = marks.get("textStyle") {
378
+ let css = text_style_css(style);
379
+ if !css.is_empty() {
380
+ html = format!("<span style=\"{}\">{html}</span>", escape_attr(&css));
381
+ }
382
+ }
383
+ }
384
+ if let Some(Any::Map(link)) = marks.get("link") {
385
+ html = wrap_link(html, link);
386
+ }
387
+ html
388
+ }
389
+
390
+ /// The `style` string for a textStyle mark (Tiptap's Color/FontFamily/etc.
391
+ /// extensions all store their value as a textStyle attribute). Attributes are
392
+ /// camelCase CSS property names; unset ones sit in the map as explicit nulls.
393
+ /// Keys sort alphabetically, which is the order Tiptap serializes (color
394
+ /// before font-family). Hex colors convert to rgb() because that's how they
395
+ /// come back out of the browser's style attribute; other values pass through.
396
+ fn text_style_css(style: &std::collections::HashMap<String, Any>) -> String {
397
+ let mut pairs: Vec<_> = style
398
+ .iter()
399
+ .filter_map(|(k, v)| match v {
400
+ Any::String(s) => Some((k, s.as_ref())),
401
+ _ => None,
402
+ })
403
+ .collect();
404
+ pairs.sort_by(|a, b| a.0.cmp(b.0));
405
+ let mut css = String::new();
406
+ for (key, value) in pairs {
407
+ if !css.is_empty() {
408
+ css.push(' ');
409
+ }
410
+ // camelCase -> kebab-case: fontFamily -> font-family.
411
+ for ch in key.chars() {
412
+ if ch.is_ascii_uppercase() {
413
+ css.push('-');
414
+ css.push(ch.to_ascii_lowercase());
415
+ } else {
416
+ css.push(ch);
417
+ }
418
+ }
419
+ css.push_str(": ");
420
+ css.push_str(&hex_to_rgb(value).unwrap_or_else(|| value.to_string()));
421
+ css.push(';');
422
+ }
423
+ css
424
+ }
425
+
426
+ /// `#rgb`/`#rrggbb` -> `rgb(r, g, b)`, matching the browser's style-attribute
427
+ /// serialization. Anything else (named colors, rgb()/hsl(), fonts) is None.
428
+ fn hex_to_rgb(value: &str) -> Option<String> {
429
+ let hex = value.strip_prefix('#')?;
430
+ // len() and the slices below are byte-based; non-ASCII input would panic
431
+ // on a char boundary. Real hex never is, crafted input passes through.
432
+ if !hex.is_ascii() {
433
+ return None;
434
+ }
435
+ let (r, g, b) = match hex.len() {
436
+ 3 => {
437
+ let d = |i: usize| u8::from_str_radix(&hex[i..=i].repeat(2), 16);
438
+ (d(0).ok()?, d(1).ok()?, d(2).ok()?)
439
+ }
440
+ 6 => {
441
+ let d = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16);
442
+ (d(0).ok()?, d(2).ok()?, d(4).ok()?)
443
+ }
444
+ _ => return None,
445
+ };
446
+ Some(format!("rgb({r}, {g}, {b})"))
447
+ }
448
+
449
+ /// `<a>` with Tiptap's attribute order (target, rel, class, href, title),
450
+ /// skipping any that are absent or null.
451
+ fn wrap_link(inner: String, link: &std::collections::HashMap<String, Any>) -> String {
452
+ let mut out = String::from("<a");
453
+ for key in ["target", "rel", "class", "href", "title"] {
454
+ if let Some(Any::String(v)) = link.get(key) {
455
+ out.push(' ');
456
+ out.push_str(key);
457
+ out.push_str("=\"");
458
+ out.push_str(&escape_attr(v));
459
+ out.push('"');
460
+ }
461
+ }
462
+ out.push('>');
463
+ out.push_str(&inner);
464
+ out.push_str("</a>");
465
+ out
466
+ }
467
+
468
+ /// `<img>` with attribute order src, alt, title, skipping absent/null ones.
469
+ fn render_image<T: ReadTxn>(txn: &T, e: &XmlElementRef, out: &mut String) {
470
+ out.push_str("<img");
471
+ for (attr, html) in [("src", "src"), ("alt", "alt"), ("title", "title")] {
472
+ if let Some(v) = str_attr(txn, e, attr) {
473
+ out.push(' ');
474
+ out.push_str(html);
475
+ out.push_str("=\"");
476
+ out.push_str(&escape_attr(&v));
477
+ out.push('"');
478
+ }
479
+ }
480
+ out.push('>');
481
+ }
482
+
483
+ fn wrap(inner: String, tag: &str) -> String {
484
+ format!("<{tag}>{inner}</{tag}>")
485
+ }
486
+
487
+ fn has(marks: &Attrs, keys: &[&str]) -> bool {
488
+ keys.iter().any(|k| marks.contains_key(*k))
489
+ }
490
+
491
+ fn has_element_child<T: ReadTxn>(txn: &T, e: &XmlElementRef) -> bool {
492
+ e.children(txn).any(|c| matches!(c, XmlOut::Element(_)))
493
+ }
494
+
495
+ /// The concatenated text of a code block (no marks — code is plain text).
496
+ fn code_text<T: ReadTxn>(txn: &T, e: &XmlElementRef) -> String {
497
+ let mut s = String::new();
498
+ for node in e.children(txn) {
499
+ if let XmlOut::Text(t) = node {
500
+ for d in t.diff(txn, YChange::identity) {
501
+ if let Out::Any(Any::String(run)) = &d.insert {
502
+ s.push_str(run);
503
+ }
504
+ }
505
+ }
506
+ }
507
+ s
508
+ }
509
+
510
+ fn str_attr<T: ReadTxn>(txn: &T, e: &XmlElementRef, name: &str) -> Option<String> {
511
+ match e.get_attribute(txn, name) {
512
+ Some(Out::Any(Any::String(s))) => Some(s.to_string()),
513
+ _ => None,
514
+ }
515
+ }
516
+
517
+ fn num_attr<T: ReadTxn>(txn: &T, e: &XmlElementRef, name: &str) -> Option<i64> {
518
+ match e.get_attribute(txn, name) {
519
+ Some(Out::Any(Any::Number(n))) => Some(n as i64),
520
+ Some(Out::Any(Any::BigInt(n))) => Some(n),
521
+ _ => None,
522
+ }
523
+ }
524
+
525
+ fn bool_attr<T: ReadTxn>(txn: &T, e: &XmlElementRef, name: &str) -> bool {
526
+ matches!(e.get_attribute(txn, name), Some(Out::Any(Any::Bool(true))))
527
+ }
528
+
529
+ /// Text-content escaping, matching the browser serializer: `&`, `<`, `>`.
530
+ fn escape_text(s: &str) -> String {
531
+ s.replace('&', "&amp;")
532
+ .replace('<', "&lt;")
533
+ .replace('>', "&gt;")
534
+ }
535
+
536
+ /// Attribute-value escaping: text escaping plus `"`.
537
+ fn escape_attr(s: &str) -> String {
538
+ escape_text(s).replace('"', "&quot;")
539
+ }
540
+
541
+ #[cfg(test)]
542
+ mod tests {
543
+ use super::*;
544
+ use std::collections::HashMap;
545
+ use std::sync::Arc;
546
+ use yrs::updates::decoder::Decode;
547
+ use yrs::{Doc, Transact, Update, XmlElementPrelim, XmlTextPrelim};
548
+
549
+ fn doc_from(bytes: &[u8]) -> Doc {
550
+ let doc = Doc::new();
551
+ doc.transact_mut()
552
+ .apply_update(Update::decode_v1(bytes).unwrap())
553
+ .unwrap();
554
+ doc
555
+ }
556
+
557
+ fn marks(keys: &[&str]) -> Attrs {
558
+ let mut a = Attrs::new();
559
+ for k in keys {
560
+ a.insert((*k).into(), Any::Bool(true));
561
+ }
562
+ a
563
+ }
564
+
565
+ /// The core proof: a document captured from a real Tiptap editor renders to
566
+ /// exactly the editor's own `getHTML()`. The fixture covers headings,
567
+ /// every mark and combination, links, escaping, blockquote, nested bullet
568
+ /// and ordered lists (with a `start`), a task list, code blocks with and
569
+ /// without a language, a hard break, a horizontal rule, an image, and the
570
+ /// trailing empty paragraph Tiptap keeps.
571
+ #[test]
572
+ fn renders_the_captured_tiptap_document_byte_for_byte() {
573
+ let doc = doc_from(include_bytes!("fixtures/prosemirror_tiptap.bin"));
574
+ let txn = doc.transact();
575
+ let frag = txn.get_xml_fragment("default").unwrap();
576
+ assert_eq!(
577
+ render(&txn, &frag).unwrap(),
578
+ include_str!("fixtures/prosemirror_tiptap.html")
579
+ );
580
+ }
581
+
582
+ /// A table renders as tiptap-php's semantic form — `<table><tbody>` with
583
+ /// colspan/rowspan cells — dropping the `<colgroup>`/`min-width` styling
584
+ /// Tiptap's editor view adds (and which isn't in the CRDT).
585
+ #[test]
586
+ fn renders_a_table_as_semantic_html() {
587
+ let doc = doc_from(include_bytes!("fixtures/prosemirror_table.bin"));
588
+ let txn = doc.transact();
589
+ let frag = txn.get_xml_fragment("default").unwrap();
590
+ assert_eq!(
591
+ render(&txn, &frag).unwrap(),
592
+ include_str!("fixtures/prosemirror_table.html")
593
+ );
594
+ }
595
+
596
+ #[test]
597
+ fn marks_nest_in_tiptaps_serializer_order() {
598
+ assert_eq!(render_run("x", None), "x");
599
+ assert_eq!(
600
+ render_run("x", Some(&marks(&["bold"]))),
601
+ "<strong>x</strong>"
602
+ );
603
+ assert_eq!(render_run("x", Some(&marks(&["italic"]))), "<em>x</em>");
604
+ // bold wraps italic.
605
+ assert_eq!(
606
+ render_run("x", Some(&marks(&["italic", "bold"]))),
607
+ "<strong><em>x</em></strong>"
608
+ );
609
+ // code excludes every other mark.
610
+ assert_eq!(
611
+ render_run("x", Some(&marks(&["code", "bold"]))),
612
+ "<code>x</code>"
613
+ );
614
+ // Full compatible stack, innermost sub to outermost bold.
615
+ assert_eq!(
616
+ render_run(
617
+ "x",
618
+ Some(&marks(&[
619
+ "bold",
620
+ "italic",
621
+ "strike",
622
+ "underline",
623
+ "highlight",
624
+ "subscript"
625
+ ]))
626
+ ),
627
+ "<strong><em><s><u><mark><sub>x</sub></mark></u></s></em></strong>"
628
+ );
629
+ // Escaping happens before wrapping.
630
+ assert_eq!(render_run("<&>", None), "&lt;&amp;&gt;");
631
+ }
632
+
633
+ #[test]
634
+ fn renders_a_link_run_with_tiptaps_attribute_order() {
635
+ let mut link = HashMap::new();
636
+ link.insert(
637
+ "href".to_string(),
638
+ Any::String("https://e.com?a=1&b=2".into()),
639
+ );
640
+ link.insert("target".to_string(), Any::String("_blank".into()));
641
+ link.insert("rel".to_string(), Any::String("noopener".into()));
642
+ let mut a = Attrs::new();
643
+ a.insert("link".into(), Any::Map(Arc::new(link)));
644
+ assert_eq!(
645
+ render_run("site", Some(&a)),
646
+ "<a target=\"_blank\" rel=\"noopener\" href=\"https://e.com?a=1&amp;b=2\">site</a>"
647
+ );
648
+
649
+ // class and title (Link's remaining attrs) keep Tiptap's serialized
650
+ // order: target, rel, class, href, title. Null-valued attrs skip.
651
+ let mut link = HashMap::new();
652
+ link.insert("href".to_string(), Any::String("https://d.example".into()));
653
+ link.insert("class".to_string(), Any::String("doc-link".into()));
654
+ link.insert("title".to_string(), Any::String("A Doc".into()));
655
+ link.insert("target".to_string(), Any::Null);
656
+ let mut a = Attrs::new();
657
+ a.insert("link".into(), Any::Map(Arc::new(link)));
658
+ assert_eq!(
659
+ render_run("the doc", Some(&a)),
660
+ "<a class=\"doc-link\" href=\"https://d.example\" title=\"A Doc\">the doc</a>"
661
+ );
662
+ }
663
+
664
+ /// Mentions (captured from Tiptap's Mention extension): the fixture holds
665
+ /// one mention with a label, one with only an id, and a link carrying
666
+ /// class and title.
667
+ #[test]
668
+ fn renders_the_captured_mention_document_byte_for_byte() {
669
+ let doc = doc_from(include_bytes!("fixtures/prosemirror_mention.bin"));
670
+ let txn = doc.transact();
671
+ let frag = txn.get_xml_fragment("default").unwrap();
672
+ assert_eq!(
673
+ render(&txn, &frag).unwrap(),
674
+ include_str!("fixtures/prosemirror_mention.html")
675
+ );
676
+ }
677
+
678
+ /// textStyle (captured from Tiptap's Color/FontFamily extensions): hex
679
+ /// colors come back out of the browser as rgb(), rgb() strings pass
680
+ /// through, font-family joins the same span, and the span wraps outside
681
+ /// bold.
682
+ #[test]
683
+ fn renders_the_captured_textstyle_document_byte_for_byte() {
684
+ let doc = doc_from(include_bytes!("fixtures/prosemirror_textstyle.bin"));
685
+ let txn = doc.transact();
686
+ let frag = txn.get_xml_fragment("default").unwrap();
687
+ assert_eq!(
688
+ render(&txn, &frag).unwrap(),
689
+ include_str!("fixtures/prosemirror_textstyle.html")
690
+ );
691
+ }
692
+
693
+ #[test]
694
+ fn text_style_converts_hex_and_kebab_cases_keys() {
695
+ let mut style = HashMap::new();
696
+ style.insert("color".to_string(), Any::String("#ff0000".into()));
697
+ style.insert(
698
+ "fontFamily".to_string(),
699
+ Any::String("Georgia, serif".into()),
700
+ );
701
+ style.insert("fontSize".to_string(), Any::Null); // unset: skipped
702
+ assert_eq!(
703
+ text_style_css(&style),
704
+ "color: rgb(255, 0, 0); font-family: Georgia, serif;"
705
+ );
706
+
707
+ assert_eq!(hex_to_rgb("#0f8"), Some("rgb(0, 255, 136)".to_string()));
708
+ assert_eq!(hex_to_rgb("rebeccapurple"), None);
709
+ assert_eq!(hex_to_rgb("#12345"), None);
710
+ // Multibyte input must pass through, not panic on a byte-slice
711
+ // boundary ("日" is one char, three bytes — it enters the 3 arm).
712
+ assert_eq!(hex_to_rgb("#日"), None);
713
+ assert_eq!(hex_to_rgb("#日本"), None);
714
+ }
715
+
716
+ /// A known container with bare text jammed directly into it (schema-valid
717
+ /// documents never do this) keeps the text instead of dropping it.
718
+ #[test]
719
+ fn a_known_container_keeps_stray_direct_text() {
720
+ let doc = Doc::new();
721
+ let frag = doc.get_or_insert_xml_fragment("default");
722
+ {
723
+ let mut txn = doc.transact_mut();
724
+ let bq = frag.push_back(&mut txn, XmlElementPrelim::empty("blockquote"));
725
+ bq.push_back(&mut txn, XmlTextPrelim::new("stray"));
726
+ let p = bq.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
727
+ p.push_back(&mut txn, XmlTextPrelim::new("body"));
728
+ }
729
+ let txn = doc.transact();
730
+ assert_eq!(
731
+ render(&txn, &frag).unwrap(),
732
+ "<blockquote>stray<p>body</p></blockquote>"
733
+ );
734
+ }
735
+
736
+ /// The details family follows tiptap-php's renderHTML (the Tiptap
737
+ /// extension is Pro-only, so tiptap-php is the reference).
738
+ #[test]
739
+ fn renders_the_details_family_per_tiptap_php() {
740
+ let doc = Doc::new();
741
+ let frag = doc.get_or_insert_xml_fragment("default");
742
+ {
743
+ let mut txn = doc.transact_mut();
744
+ let details = frag.push_back(&mut txn, XmlElementPrelim::empty("details"));
745
+ details.insert_attribute(&mut txn, "open", true);
746
+ let summary = details.push_back(&mut txn, XmlElementPrelim::empty("detailsSummary"));
747
+ summary.push_back(&mut txn, XmlTextPrelim::new("More info"));
748
+ let content = details.push_back(&mut txn, XmlElementPrelim::empty("detailsContent"));
749
+ let p = content.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
750
+ p.push_back(&mut txn, XmlTextPrelim::new("The body."));
751
+
752
+ let closed = frag.push_back(&mut txn, XmlElementPrelim::empty("details"));
753
+ closed.push_back(&mut txn, XmlElementPrelim::empty("detailsSummary"));
754
+ }
755
+ let txn = doc.transact();
756
+ assert_eq!(
757
+ render(&txn, &frag).unwrap(),
758
+ "<details open=\"open\"><summary>More info</summary>\
759
+ <div data-type=\"detailsContent\"><p>The body.</p></div></details>\
760
+ <details><summary></summary></details>"
761
+ );
762
+ }
763
+
764
+ /// prosemirror-schema-basic's `code` mark has no excludes, so a code run
765
+ /// can also carry a link; the link must survive. (Tiptap's Code mark
766
+ /// excludes everything, so this shape only comes from schema-basic docs.)
767
+ #[test]
768
+ fn a_code_run_keeps_its_link() {
769
+ let mut link = HashMap::new();
770
+ link.insert("href".to_string(), Any::String("https://e.com".into()));
771
+ let mut a = Attrs::new();
772
+ a.insert("code".into(), Any::Map(Arc::new(HashMap::new())));
773
+ a.insert("link".into(), Any::Map(Arc::new(link)));
774
+ assert_eq!(
775
+ render_run("x", Some(&a)),
776
+ "<a href=\"https://e.com\"><code>x</code></a>"
777
+ );
778
+ }
779
+
780
+ /// An unknown block holding both text and child blocks keeps the text.
781
+ #[test]
782
+ fn an_unknown_block_with_mixed_content_keeps_its_text() {
783
+ let doc = Doc::new();
784
+ let frag = doc.get_or_insert_xml_fragment("default");
785
+ {
786
+ let mut txn = doc.transact_mut();
787
+ let callout = frag.push_back(&mut txn, XmlElementPrelim::empty("callout"));
788
+ callout.push_back(&mut txn, XmlTextPrelim::new("intro"));
789
+ let p = callout.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
790
+ p.push_back(&mut txn, XmlTextPrelim::new("body"));
791
+ }
792
+ let txn = doc.transact();
793
+ assert_eq!(render(&txn, &frag).unwrap(), "intro<p>body</p>");
794
+ }
795
+
796
+ /// An unknown inline node keeps its text instead of vanishing.
797
+ #[test]
798
+ fn an_unknown_inline_node_keeps_its_text() {
799
+ let doc = Doc::new();
800
+ let frag = doc.get_or_insert_xml_fragment("default");
801
+ {
802
+ let mut txn = doc.transact_mut();
803
+ let p = frag.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
804
+ p.push_back(&mut txn, XmlTextPrelim::new("see "));
805
+ let custom = p.push_back(&mut txn, XmlElementPrelim::empty("customInline"));
806
+ custom.push_back(&mut txn, XmlTextPrelim::new("kept"));
807
+ p.push_back(&mut txn, XmlTextPrelim::new(" here"));
808
+ }
809
+ let txn = doc.transact();
810
+ assert_eq!(render(&txn, &frag).unwrap(), "<p>see kept here</p>");
811
+ }
812
+
813
+ /// The prosemirror-schema-basic spellings (snake_case nodes, `strong`/`em`
814
+ /// marks) render the same as Tiptap's camelCase.
815
+ #[test]
816
+ fn accepts_prosemirror_basic_schema_names() {
817
+ let doc = Doc::new();
818
+ let frag = doc.get_or_insert_xml_fragment("default");
819
+ {
820
+ let mut txn = doc.transact_mut();
821
+ let bq = frag.push_back(&mut txn, XmlElementPrelim::empty("blockquote"));
822
+ let p = bq.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
823
+ let t = p.push_back(&mut txn, XmlTextPrelim::new("hi bold it"));
824
+ t.format(&mut txn, 3, 4, marks(&["strong"]));
825
+ t.format(&mut txn, 8, 2, marks(&["em"]));
826
+ }
827
+ let txn = doc.transact();
828
+ assert_eq!(
829
+ render(&txn, &frag).unwrap(),
830
+ "<blockquote><p>hi <strong>bold</strong> <em>it</em></p></blockquote>"
831
+ );
832
+ }
833
+
834
+ #[test]
835
+ fn a_lexical_shaped_root_is_refused() {
836
+ // A Lexical doc stores blocks as XmlText carrying `__type`. That's a
837
+ // different schema; render must return None, not a garbled document.
838
+ let doc = Doc::new();
839
+ // Create both roots before opening the read transaction:
840
+ // get_or_insert_* opens its own write transaction, which would deadlock
841
+ // against a live read guard.
842
+ let frag = doc.get_or_insert_xml_fragment("root");
843
+ let empty = doc.get_or_insert_xml_fragment("empty");
844
+ {
845
+ let mut txn = doc.transact_mut();
846
+ let block = frag.push_back(&mut txn, XmlTextPrelim::new("hello"));
847
+ block.insert_attribute(&mut txn, "__type", "paragraph");
848
+ }
849
+ let txn = doc.transact();
850
+ assert_eq!(render(&txn, &frag), None);
851
+ // An empty root is fine.
852
+ assert_eq!(render(&txn, &empty).as_deref(), Some(""));
853
+ }
854
+
855
+ #[test]
856
+ fn deeply_nested_blocks_do_not_overflow_the_stack() {
857
+ // The walk is on the heap, so nesting that would blow a small native
858
+ // stack under recursion renders fine. Built and rendered on a 512 KiB
859
+ // thread so it can't pass just by having room to spare.
860
+ std::thread::Builder::new()
861
+ .stack_size(512 * 1024)
862
+ .spawn(|| {
863
+ let doc = Doc::new();
864
+ let frag = doc.get_or_insert_xml_fragment("default");
865
+ {
866
+ let mut txn = doc.transact_mut();
867
+ let mut cursor =
868
+ frag.push_back(&mut txn, XmlElementPrelim::empty("blockquote"));
869
+ for _ in 0..20_000 {
870
+ cursor = cursor.push_back(&mut txn, XmlElementPrelim::empty("blockquote"));
871
+ }
872
+ let p = cursor.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
873
+ p.push_back(&mut txn, XmlTextPrelim::new("deep"));
874
+ }
875
+ let txn = doc.transact();
876
+ let html = render(&txn, &frag).expect("prosemirror-shaped");
877
+ assert_eq!(
878
+ html.matches("<blockquote>").count(),
879
+ html.matches("</blockquote>").count()
880
+ );
881
+ assert!(html.contains("<p>deep</p>") || html.contains("<blockquote>"));
882
+ })
883
+ .unwrap()
884
+ .join()
885
+ .unwrap();
886
+ }
887
+
888
+ #[test]
889
+ fn escaping_matches_the_browser_serializer() {
890
+ assert_eq!(escape_text(r#"<a & "b">"#), r#"&lt;a &amp; "b"&gt;"#);
891
+ assert_eq!(
892
+ escape_attr(r#"<a & "b">"#),
893
+ r#"&lt;a &amp; &quot;b&quot;&gt;"#
894
+ );
895
+ }
896
+ }