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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +35 -0
- data/README.md +61 -15
- data/ext/yrby/src/lexical_html.rs +993 -0
- data/ext/yrby/src/lib.rs +112 -0
- data/ext/yrby/src/prosemirror_html.rs +896 -0
- data/ext/yrby/src/read.rs +71 -4
- data/lib/y/version.rb +1 -1
- metadata +3 -1
|
@@ -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('&', "&")
|
|
532
|
+
.replace('<', "<")
|
|
533
|
+
.replace('>', ">")
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/// Attribute-value escaping: text escaping plus `"`.
|
|
537
|
+
fn escape_attr(s: &str) -> String {
|
|
538
|
+
escape_text(s).replace('"', """)
|
|
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), "<&>");
|
|
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&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#"<a & "b">"#);
|
|
891
|
+
assert_eq!(
|
|
892
|
+
escape_attr(r#"<a & "b">"#),
|
|
893
|
+
r#"<a & "b">"#
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
}
|