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,993 @@
|
|
|
1
|
+
//! Native HTML rendering of Lexical/Lexxy documents from the yrs collab
|
|
2
|
+
//! structure — no Node process, no headless editor.
|
|
3
|
+
//!
|
|
4
|
+
//! Pinned to Lexxy (37signals' Rails editor, 0.9.x). The output matches what
|
|
5
|
+
//! `lexxy-editor` produces as its `value`: Lexical's `$generateHtmlFromNodes`,
|
|
6
|
+
//! run through Lexxy's text export and DOMPurify. The tests check it byte for
|
|
7
|
+
//! byte against a captured fixture. That's the HTML a Lexxy form submits to
|
|
8
|
+
//! Rails, so you can store it as ActionText content straight from the CRDT.
|
|
9
|
+
//!
|
|
10
|
+
//! Prior art: `ueberdosis/tiptap-php` renders ProseMirror JSON to HTML in pure
|
|
11
|
+
//! PHP — a schema-pinned renderer outside the JS runtime. This works from the
|
|
12
|
+
//! collab (Yjs) structure rather than JSON.
|
|
13
|
+
//!
|
|
14
|
+
//! Storage model (verified against bytes captured from a live Lexxy editor):
|
|
15
|
+
//! - Blocks are `Y.XmlText` with a `__type` attribute (`paragraph`, `heading`
|
|
16
|
+
//! (+`__tag`), `quote`, `list`/`listitem`, `early_escape_code` (+`__language`),
|
|
17
|
+
//! `wrapped_table_node`/`tablerow`/`tablecell`, `link`/`autolink` (inline)).
|
|
18
|
+
//! - Text runs are preceded by an embedded `Y.Map` carrying per-run metadata
|
|
19
|
+
//! (`__type: "text" | "code-highlight" | "tab"`, `__format` bitmask).
|
|
20
|
+
//! - `linebreak` is a bare metadata map; `tab` is a map followed by a "\t" run.
|
|
21
|
+
//! - Decorator nodes are `Y.XmlElement`s: `horizontal_divider`,
|
|
22
|
+
//! `action_text_attachment` (uploads), `custom_action_text_attachment`
|
|
23
|
+
//! (mentions/embeds), with their fields as plain attributes.
|
|
24
|
+
//!
|
|
25
|
+
//! Text-format rendering replicates Lexxy's `exportTextNodeDOM` exactly:
|
|
26
|
+
//! inner tag `strong` (bold) / `em` (italic, when not bold); outer tag `code` /
|
|
27
|
+
//! `mark` / `sub` / `sup`; an `<i>` wrap only when bold+italic combine (the
|
|
28
|
+
//! `em` slot is taken); `<s>` / `<u>` wraps always; `<span>`s are unwrapped, so
|
|
29
|
+
//! unformatted text is bare. A run's `__style` (Lexxy's highlight colors)
|
|
30
|
+
//! survives on the createDOM tag, filtered to color/background-color the way
|
|
31
|
+
//! Lexxy's sanitize allows; on a plain or s/u-only run it dies with the
|
|
32
|
+
//! unwrapped span. Case-transform format bits are never rendered (their
|
|
33
|
+
//! text-transform style is outside the sanitize whitelist).
|
|
34
|
+
|
|
35
|
+
use yrs::types::text::YChange;
|
|
36
|
+
use yrs::{
|
|
37
|
+
Any, GetString, Map, Out, ReadTxn, Text, Xml, XmlElementRef, XmlFragment, XmlFragmentRef,
|
|
38
|
+
XmlOut, XmlTextRef,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Lexical text format bitmask (lexical 0.44).
|
|
42
|
+
const FMT_BOLD: u32 = 1;
|
|
43
|
+
const FMT_ITALIC: u32 = 1 << 1;
|
|
44
|
+
const FMT_STRIKETHROUGH: u32 = 1 << 2;
|
|
45
|
+
const FMT_UNDERLINE: u32 = 1 << 3;
|
|
46
|
+
const FMT_CODE: u32 = 1 << 4;
|
|
47
|
+
const FMT_SUBSCRIPT: u32 = 1 << 5;
|
|
48
|
+
const FMT_SUPERSCRIPT: u32 = 1 << 6;
|
|
49
|
+
const FMT_HIGHLIGHT: u32 = 1 << 7;
|
|
50
|
+
|
|
51
|
+
// Nesting caps. The block tree is walked on an explicit heap stack (no native
|
|
52
|
+
// recursion), so these don't prevent a stack overflow — they just bound how
|
|
53
|
+
// deep the renderer descends. Real docs nest a handful of levels deep (the
|
|
54
|
+
// torture fixture peaks at ~8); past 1024 a subtree is dropped, but its
|
|
55
|
+
// enclosing tags still close. Inline links are still walked recursively (their
|
|
56
|
+
// body is inline content, never blocks), so they carry the same cap.
|
|
57
|
+
const MAX_BLOCK_DEPTH: usize = 1024;
|
|
58
|
+
const MAX_INLINE_DEPTH: usize = 1024;
|
|
59
|
+
|
|
60
|
+
/// A unit of pending block work on the explicit traversal stack. `Open` renders
|
|
61
|
+
/// a block node (pushing its own children as more work); `Close` emits a
|
|
62
|
+
/// literal end tag once a container's children have all been processed. Every
|
|
63
|
+
/// container's closing tag is a fixed string, so `Close` can borrow `'static`.
|
|
64
|
+
enum Work {
|
|
65
|
+
Open(XmlTextRef, usize),
|
|
66
|
+
Close(&'static str),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// Render a Lexical/Lexxy-shaped XML root to HTML, or `None` when the root
|
|
70
|
+
/// isn't Lexical-shaped. Lexical marks every node with a `__type` attribute; a
|
|
71
|
+
/// root whose children carry none — a ProseMirror document, say, whose blocks
|
|
72
|
+
/// are plain `<paragraph>` elements — is a foreign schema, and render returns
|
|
73
|
+
/// `None` for it rather than a lossy guess.
|
|
74
|
+
///
|
|
75
|
+
/// A `__type` the renderer doesn't recognize is handled differently: the node
|
|
76
|
+
/// renders its text in a `<p>`, so a Lexxy release that adds one stays readable.
|
|
77
|
+
pub fn render<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> Option<String> {
|
|
78
|
+
if !is_lexical_shaped(txn, fragment) {
|
|
79
|
+
return None;
|
|
80
|
+
}
|
|
81
|
+
let mut out = String::new();
|
|
82
|
+
for node in fragment.children(txn) {
|
|
83
|
+
match node {
|
|
84
|
+
XmlOut::Text(t) => render_block_tree(txn, &t, &mut out),
|
|
85
|
+
XmlOut::Element(e) => render_decorator(txn, &e, &mut out),
|
|
86
|
+
XmlOut::Fragment(f) => out.push_str(&escape_text(&f.get_string(txn))),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
Some(out)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// A root is Lexical-shaped when it is empty or at least one child carries the
|
|
93
|
+
/// `__type` attribute Lexical stamps on every node.
|
|
94
|
+
fn is_lexical_shaped<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> bool {
|
|
95
|
+
let mut any_child = false;
|
|
96
|
+
for node in fragment.children(txn) {
|
|
97
|
+
any_child = true;
|
|
98
|
+
let typed = match &node {
|
|
99
|
+
XmlOut::Text(t) => t.get_attribute(txn, "__type").is_some(),
|
|
100
|
+
XmlOut::Element(e) => e.get_attribute(txn, "__type").is_some(),
|
|
101
|
+
XmlOut::Fragment(_) => false,
|
|
102
|
+
};
|
|
103
|
+
if typed {
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
!any_child // an empty document renders to an empty string
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/// The `__type` attribute of a block/inline `Y.XmlText`.
|
|
111
|
+
fn node_type<T: ReadTxn>(txn: &T, t: &XmlTextRef) -> String {
|
|
112
|
+
match t.get_attribute(txn, "__type") {
|
|
113
|
+
Some(Out::Any(Any::String(s))) => s.to_string(),
|
|
114
|
+
_ => String::new(),
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/// A string attribute of a block (e.g. `__tag`, `__language`, `__url`).
|
|
119
|
+
fn str_attr<T: ReadTxn>(txn: &T, t: &XmlTextRef, name: &str) -> Option<String> {
|
|
120
|
+
match t.get_attribute(txn, name) {
|
|
121
|
+
Some(Out::Any(Any::String(s))) => Some(s.to_string()),
|
|
122
|
+
_ => None,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// Walk a top-level block and everything under it on an explicit heap stack,
|
|
127
|
+
/// appending HTML to `out`. Container blocks (lists, tables, cells, list
|
|
128
|
+
/// items) push their children back as more work plus a `Close` for their end
|
|
129
|
+
/// tag; leaf blocks (paragraphs, headings, quotes, code) render in full on the
|
|
130
|
+
/// spot. The stack lives on the heap, so nesting depth can't overflow the
|
|
131
|
+
/// native call stack.
|
|
132
|
+
fn render_block_tree<T: ReadTxn>(txn: &T, root: &XmlTextRef, out: &mut String) {
|
|
133
|
+
let mut stack: Vec<Work> = vec![Work::Open(root.clone(), 0)];
|
|
134
|
+
while let Some(work) = stack.pop() {
|
|
135
|
+
match work {
|
|
136
|
+
Work::Close(tag) => out.push_str(tag),
|
|
137
|
+
Work::Open(node, depth) => open_block(txn, &node, depth, out, &mut stack),
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/// Render one block. A container emits its opening tag now and defers its
|
|
143
|
+
/// children (and matching `Close`) to the stack; a leaf renders completely.
|
|
144
|
+
fn open_block<T: ReadTxn>(
|
|
145
|
+
txn: &T,
|
|
146
|
+
t: &XmlTextRef,
|
|
147
|
+
depth: usize,
|
|
148
|
+
out: &mut String,
|
|
149
|
+
stack: &mut Vec<Work>,
|
|
150
|
+
) {
|
|
151
|
+
let ty = node_type(txn, t);
|
|
152
|
+
match ty.as_str() {
|
|
153
|
+
"paragraph" | "provisonal_paragraph" => {
|
|
154
|
+
// (sic: "provisonal" is Lexxy's spelling.) A provisional paragraph
|
|
155
|
+
// is a cursor-placement placeholder; empty ones export to nothing.
|
|
156
|
+
let inline = render_inline(txn, t, 0);
|
|
157
|
+
if inline.is_empty() {
|
|
158
|
+
if ty == "paragraph" {
|
|
159
|
+
out.push_str("<p><br></p>");
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
out.push_str("<p>");
|
|
163
|
+
out.push_str(&inline);
|
|
164
|
+
out.push_str("</p>");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
"heading" => {
|
|
168
|
+
let tag = match str_attr(txn, t, "__tag").as_deref() {
|
|
169
|
+
Some(tag @ ("h1" | "h2" | "h3" | "h4" | "h5" | "h6")) => tag.to_string(),
|
|
170
|
+
_ => "h1".to_string(),
|
|
171
|
+
};
|
|
172
|
+
out.push('<');
|
|
173
|
+
out.push_str(&tag);
|
|
174
|
+
out.push('>');
|
|
175
|
+
out.push_str(&render_inline(txn, t, 0));
|
|
176
|
+
out.push_str("</");
|
|
177
|
+
out.push_str(&tag);
|
|
178
|
+
out.push('>');
|
|
179
|
+
}
|
|
180
|
+
"quote" => {
|
|
181
|
+
out.push_str("<blockquote>");
|
|
182
|
+
out.push_str(&render_inline(txn, t, 0));
|
|
183
|
+
out.push_str("</blockquote>");
|
|
184
|
+
}
|
|
185
|
+
"code" | "early_escape_code" => {
|
|
186
|
+
// Lexxy replaces Lexical's CodeNode with its own type; both shapes
|
|
187
|
+
// are accepted. Code highlighting is derived state: token runs
|
|
188
|
+
// flatten to plain text; linebreaks are <br>, tabs a wrapped \t.
|
|
189
|
+
out.push_str("<pre");
|
|
190
|
+
if let Some(lang) = str_attr(txn, t, "__language").filter(|l| !l.is_empty()) {
|
|
191
|
+
out.push_str(" data-language=\"");
|
|
192
|
+
out.push_str(&escape_attr(&lang));
|
|
193
|
+
out.push('"');
|
|
194
|
+
}
|
|
195
|
+
out.push('>');
|
|
196
|
+
out.push_str(&render_inline(txn, t, 0));
|
|
197
|
+
out.push_str("</pre>");
|
|
198
|
+
}
|
|
199
|
+
"list" => {
|
|
200
|
+
let tag = match str_attr(txn, t, "__tag").as_deref() {
|
|
201
|
+
Some("ol") => "ol",
|
|
202
|
+
_ => "ul",
|
|
203
|
+
};
|
|
204
|
+
out.push('<');
|
|
205
|
+
out.push_str(tag);
|
|
206
|
+
out.push('>');
|
|
207
|
+
// Direct inline content is crafted-only (Lexxy puts none here);
|
|
208
|
+
// keep it rather than drop it. Safe from double-render: this skips
|
|
209
|
+
// block children, and push_block_children skips inline content.
|
|
210
|
+
out.push_str(&render_inline(txn, t, 0));
|
|
211
|
+
let close = if tag == "ol" { "</ol>" } else { "</ul>" };
|
|
212
|
+
push_block_children(txn, t, depth, close, false, stack);
|
|
213
|
+
}
|
|
214
|
+
"listitem" => open_listitem(txn, t, depth, out, stack),
|
|
215
|
+
"image_gallery" => {
|
|
216
|
+
// Adjacent previewable images grouped by Lexxy's gallery node.
|
|
217
|
+
// The class carries the image count (ActionText's convention).
|
|
218
|
+
let count = t
|
|
219
|
+
.diff(txn, YChange::identity)
|
|
220
|
+
.into_iter()
|
|
221
|
+
.filter(|d| matches!(d.insert, Out::YXmlElement(_)))
|
|
222
|
+
.count();
|
|
223
|
+
out.push_str("<div class=\"attachment-gallery attachment-gallery--");
|
|
224
|
+
out.push_str(&count.to_string());
|
|
225
|
+
out.push_str("\">");
|
|
226
|
+
out.push_str(&render_inline(txn, t, 0));
|
|
227
|
+
out.push_str("</div>");
|
|
228
|
+
}
|
|
229
|
+
"table" | "wrapped_table_node" => {
|
|
230
|
+
out.push_str("<figure class=\"lexxy-content__table-wrapper\"><table><tbody>");
|
|
231
|
+
out.push_str(&render_inline(txn, t, 0));
|
|
232
|
+
push_block_children(txn, t, depth, "</tbody></table></figure>", false, stack);
|
|
233
|
+
}
|
|
234
|
+
"tablerow" => {
|
|
235
|
+
out.push_str("<tr>");
|
|
236
|
+
out.push_str(&render_inline(txn, t, 0));
|
|
237
|
+
push_block_children(txn, t, depth, "</tr>", false, stack);
|
|
238
|
+
}
|
|
239
|
+
"tablecell" => {
|
|
240
|
+
let header = matches!(
|
|
241
|
+
t.get_attribute(txn, "__headerState"),
|
|
242
|
+
Some(Out::Any(Any::Number(n))) if n > 0.0
|
|
243
|
+
) || matches!(
|
|
244
|
+
t.get_attribute(txn, "__headerState"),
|
|
245
|
+
Some(Out::Any(Any::BigInt(n))) if n > 0
|
|
246
|
+
);
|
|
247
|
+
let close = if header {
|
|
248
|
+
// Class + background match Lexxy's own header-cell export.
|
|
249
|
+
out.push_str(
|
|
250
|
+
"<th class=\"lexxy-content__table-cell--header\" \
|
|
251
|
+
style=\"background-color: rgb(242, 243, 245);\">",
|
|
252
|
+
);
|
|
253
|
+
"</th>"
|
|
254
|
+
} else {
|
|
255
|
+
out.push_str("<td>");
|
|
256
|
+
"</td>"
|
|
257
|
+
};
|
|
258
|
+
out.push_str(&render_inline(txn, t, 0));
|
|
259
|
+
push_block_children(txn, t, depth, close, false, stack);
|
|
260
|
+
}
|
|
261
|
+
// A block type this renderer doesn't know: degrade to a readable
|
|
262
|
+
// paragraph instead of dropping content.
|
|
263
|
+
_ => {
|
|
264
|
+
let inline = render_inline(txn, t, 0);
|
|
265
|
+
if !inline.is_empty() {
|
|
266
|
+
out.push_str("<p>");
|
|
267
|
+
out.push_str(&inline);
|
|
268
|
+
out.push_str("</p>");
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/// Defer a container's block children onto the stack, with its closing tag
|
|
275
|
+
/// below them, so the children render in order and the tag closes after. Past
|
|
276
|
+
/// `MAX_BLOCK_DEPTH` the children are dropped (the container still closes, so
|
|
277
|
+
/// the output stays well formed). `only_lists` keeps just nested-list children,
|
|
278
|
+
/// for list items whose inline content the caller has already emitted.
|
|
279
|
+
fn push_block_children<T: ReadTxn>(
|
|
280
|
+
txn: &T,
|
|
281
|
+
t: &XmlTextRef,
|
|
282
|
+
depth: usize,
|
|
283
|
+
close: &'static str,
|
|
284
|
+
only_lists: bool,
|
|
285
|
+
stack: &mut Vec<Work>,
|
|
286
|
+
) {
|
|
287
|
+
stack.push(Work::Close(close));
|
|
288
|
+
if depth >= MAX_BLOCK_DEPTH {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
// Reversed: the stack is LIFO, so the last pushed child is rendered first.
|
|
292
|
+
for child in block_children(txn, t).into_iter().rev() {
|
|
293
|
+
if only_lists && node_type(txn, &child) != "list" {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
stack.push(Work::Open(child, depth + 1));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/// `<li>`: attribute order follows Lexxy's export — checked items put
|
|
301
|
+
/// `aria-checked` before `value`; items holding a nested list append the
|
|
302
|
+
/// `lexxy-nested-listitem` class after `value`. Inline content renders now;
|
|
303
|
+
/// the nested list (if any) is deferred to the stack.
|
|
304
|
+
fn open_listitem<T: ReadTxn>(
|
|
305
|
+
txn: &T,
|
|
306
|
+
t: &XmlTextRef,
|
|
307
|
+
depth: usize,
|
|
308
|
+
out: &mut String,
|
|
309
|
+
stack: &mut Vec<Work>,
|
|
310
|
+
) {
|
|
311
|
+
let value = match t.get_attribute(txn, "__value") {
|
|
312
|
+
Some(Out::Any(Any::Number(n))) => n as i64,
|
|
313
|
+
Some(Out::Any(Any::BigInt(n))) => n,
|
|
314
|
+
_ => 1,
|
|
315
|
+
};
|
|
316
|
+
let checked = match t.get_attribute(txn, "__checked") {
|
|
317
|
+
Some(Out::Any(Any::Bool(b))) => Some(b),
|
|
318
|
+
_ => None,
|
|
319
|
+
};
|
|
320
|
+
let has_nested_list = block_children(txn, t)
|
|
321
|
+
.iter()
|
|
322
|
+
.any(|c| node_type(txn, c) == "list");
|
|
323
|
+
|
|
324
|
+
out.push_str("<li");
|
|
325
|
+
if let Some(c) = checked {
|
|
326
|
+
out.push_str(" aria-checked=\"");
|
|
327
|
+
out.push_str(if c { "true" } else { "false" });
|
|
328
|
+
out.push('"');
|
|
329
|
+
}
|
|
330
|
+
out.push_str(" value=\"");
|
|
331
|
+
out.push_str(&value.to_string());
|
|
332
|
+
out.push('"');
|
|
333
|
+
if has_nested_list {
|
|
334
|
+
out.push_str(" class=\"lexxy-nested-listitem\"");
|
|
335
|
+
}
|
|
336
|
+
out.push('>');
|
|
337
|
+
// Inline content first, then any nested list blocks (Lexical stores the
|
|
338
|
+
// nested list as a child of the item).
|
|
339
|
+
out.push_str(&render_inline(txn, t, 0));
|
|
340
|
+
push_block_children(txn, t, depth, "</li>", true, stack);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/// The `Y.XmlText` children of a block that are themselves blocks (list items,
|
|
344
|
+
/// nested lists, table rows/cells, cell paragraphs) — inline types excluded.
|
|
345
|
+
fn block_children<T: ReadTxn>(txn: &T, t: &XmlTextRef) -> Vec<XmlTextRef> {
|
|
346
|
+
let mut out = Vec::new();
|
|
347
|
+
for d in t.diff(txn, YChange::identity) {
|
|
348
|
+
if let Out::YXmlText(child) = d.insert {
|
|
349
|
+
if !is_inline_type(&node_type(txn, &child)) {
|
|
350
|
+
out.push(child);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
out
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
fn is_inline_type(ty: &str) -> bool {
|
|
358
|
+
matches!(ty, "link" | "autolink")
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/// Render a block's inline content: formatted text runs, linebreaks, tabs,
|
|
362
|
+
/// links, and inline decorators. Nested block children are NOT rendered here
|
|
363
|
+
/// (the block renderers handle them), so a list item's text doesn't duplicate
|
|
364
|
+
/// its nested list. `depth` counts inline-link nesting only (a link's body is
|
|
365
|
+
/// itself inline content); past `MAX_INLINE_DEPTH` a link renders as flat text,
|
|
366
|
+
/// capping the recursion.
|
|
367
|
+
fn render_inline<T: ReadTxn>(txn: &T, t: &XmlTextRef, depth: usize) -> String {
|
|
368
|
+
let mut out = String::new();
|
|
369
|
+
// Format carries from the metadata Map that precedes each run. Lexxy always
|
|
370
|
+
// emits one Map immediately before its run, so this is exact; a run with no
|
|
371
|
+
// preceding Map would inherit the previous run's format (wrong, but only
|
|
372
|
+
// reachable from a doc Lexxy didn't produce).
|
|
373
|
+
let mut format: u32 = 0;
|
|
374
|
+
let mut style = String::new();
|
|
375
|
+
let mut is_tab = false;
|
|
376
|
+
for d in t.diff(txn, YChange::identity) {
|
|
377
|
+
match d.insert {
|
|
378
|
+
Out::YMap(m) => {
|
|
379
|
+
let ty = match m.get(txn, "__type") {
|
|
380
|
+
Some(Out::Any(Any::String(s))) => s.to_string(),
|
|
381
|
+
_ => String::new(),
|
|
382
|
+
};
|
|
383
|
+
match ty.as_str() {
|
|
384
|
+
"linebreak" => out.push_str("<br>"),
|
|
385
|
+
"tab" => {
|
|
386
|
+
is_tab = true;
|
|
387
|
+
format = 0;
|
|
388
|
+
style.clear();
|
|
389
|
+
}
|
|
390
|
+
// "text", "code-highlight", and anything metadata-shaped:
|
|
391
|
+
// read the format bits and style for the run that follows.
|
|
392
|
+
_ => {
|
|
393
|
+
is_tab = false;
|
|
394
|
+
format = match m.get(txn, "__format") {
|
|
395
|
+
Some(Out::Any(Any::Number(n))) => n as u32,
|
|
396
|
+
Some(Out::Any(Any::BigInt(n))) => n as u32,
|
|
397
|
+
_ => 0,
|
|
398
|
+
};
|
|
399
|
+
style = match m.get(txn, "__style") {
|
|
400
|
+
Some(Out::Any(Any::String(s))) => s.to_string(),
|
|
401
|
+
_ => String::new(),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
Out::Any(Any::String(s)) => {
|
|
407
|
+
if is_tab {
|
|
408
|
+
// Lexxy exports a tab as a literal \t in a span.
|
|
409
|
+
out.push_str("<span>\t</span>");
|
|
410
|
+
is_tab = false;
|
|
411
|
+
} else {
|
|
412
|
+
out.push_str(&render_run(&s, format, &style));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
Out::YXmlText(child) => {
|
|
416
|
+
let ty = node_type(txn, &child);
|
|
417
|
+
if is_inline_type(&ty) {
|
|
418
|
+
render_link(txn, &child, depth, &mut out);
|
|
419
|
+
}
|
|
420
|
+
// Nested blocks are rendered by their parent block's renderer.
|
|
421
|
+
}
|
|
422
|
+
Out::YXmlElement(e) => render_inline_decorator(txn, &e, &mut out),
|
|
423
|
+
_ => {}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
out
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/// One text run with Lexical's format bitmask, as Lexxy's exporter wraps it.
|
|
430
|
+
fn render_run(text: &str, format: u32, style: &str) -> String {
|
|
431
|
+
let mut html = escape_text(text);
|
|
432
|
+
// A run's __style survives export only on a real createDOM tag (colors
|
|
433
|
+
// from Lexxy's highlight dropdown). The style attribute rides the OUTER
|
|
434
|
+
// tag when one exists, else the inner tag; a plain or s/u-only run's span
|
|
435
|
+
// is unwrapped, and the style dies with it — all captured behavior.
|
|
436
|
+
let css = lexxy_style(style);
|
|
437
|
+
let outer_tag = if format & FMT_CODE != 0 {
|
|
438
|
+
Some("code")
|
|
439
|
+
} else if format & FMT_HIGHLIGHT != 0 {
|
|
440
|
+
Some("mark")
|
|
441
|
+
} else if format & FMT_SUBSCRIPT != 0 {
|
|
442
|
+
Some("sub")
|
|
443
|
+
} else if format & FMT_SUPERSCRIPT != 0 {
|
|
444
|
+
Some("sup")
|
|
445
|
+
} else {
|
|
446
|
+
None
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
// Inner semantic tag (createDOM): strong for bold, em for italic-only.
|
|
450
|
+
// A plain run's span is unwrapped, leaving bare text.
|
|
451
|
+
let inner_style = if outer_tag.is_none() {
|
|
452
|
+
css.as_deref()
|
|
453
|
+
} else {
|
|
454
|
+
None
|
|
455
|
+
};
|
|
456
|
+
if format & FMT_BOLD != 0 {
|
|
457
|
+
html = format_wrap(html, "strong", inner_style);
|
|
458
|
+
} else if format & FMT_ITALIC != 0 {
|
|
459
|
+
html = format_wrap(html, "em", inner_style);
|
|
460
|
+
}
|
|
461
|
+
// Outer semantic tag (createDOM): first match wins.
|
|
462
|
+
if let Some(tag) = outer_tag {
|
|
463
|
+
html = format_wrap(html, tag, css.as_deref());
|
|
464
|
+
}
|
|
465
|
+
// Lexxy's wrap pass: <i> only when italic couldn't claim the inner tag
|
|
466
|
+
// (bold took it); <b> never fires (bold always claims strong); <s>/<u>
|
|
467
|
+
// always wrap.
|
|
468
|
+
if format & FMT_ITALIC != 0 && format & FMT_BOLD != 0 {
|
|
469
|
+
html = format_wrap(html, "i", None);
|
|
470
|
+
}
|
|
471
|
+
if format & FMT_STRIKETHROUGH != 0 {
|
|
472
|
+
html = format_wrap(html, "s", None);
|
|
473
|
+
}
|
|
474
|
+
if format & FMT_UNDERLINE != 0 {
|
|
475
|
+
html = format_wrap(html, "u", None);
|
|
476
|
+
}
|
|
477
|
+
html
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
fn format_wrap(inner: String, tag: &str, style: Option<&str>) -> String {
|
|
481
|
+
match style {
|
|
482
|
+
Some(css) => format!("<{tag} style=\"{}\">{inner}</{tag}>", escape_attr(css)),
|
|
483
|
+
None => format!("<{tag}>{inner}</{tag}>"),
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/// Filter a run's `__style` to what Lexxy's sanitize lets through — `color`
|
|
488
|
+
/// and `background-color` — keeping source order, serialized the way the
|
|
489
|
+
/// sanitize hook rebuilds it: `prop: value;` with no separator between
|
|
490
|
+
/// properties. Everything else (text-transform, white-space, smuggled
|
|
491
|
+
/// properties) is stripped, matching the captured value output.
|
|
492
|
+
fn lexxy_style(style: &str) -> Option<String> {
|
|
493
|
+
let mut css = String::new();
|
|
494
|
+
for decl in style.split(';') {
|
|
495
|
+
let Some((prop, value)) = decl.split_once(':') else {
|
|
496
|
+
continue;
|
|
497
|
+
};
|
|
498
|
+
let (prop, value) = (prop.trim(), value.trim());
|
|
499
|
+
if (prop == "color" || prop == "background-color") && !value.is_empty() {
|
|
500
|
+
css.push_str(prop);
|
|
501
|
+
css.push_str(": ");
|
|
502
|
+
css.push_str(value);
|
|
503
|
+
css.push(';');
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if css.is_empty() {
|
|
507
|
+
None
|
|
508
|
+
} else {
|
|
509
|
+
Some(css)
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/// `link` / `autolink`: Lexxy's sanitize keeps only `href` and `title`
|
|
514
|
+
/// (`target`/`rel` are stored in the doc but stripped from exported HTML).
|
|
515
|
+
fn render_link<T: ReadTxn>(txn: &T, t: &XmlTextRef, depth: usize, out: &mut String) {
|
|
516
|
+
out.push_str("<a");
|
|
517
|
+
if let Some(url) = str_attr(txn, t, "__url") {
|
|
518
|
+
out.push_str(" href=\"");
|
|
519
|
+
out.push_str(&escape_attr(&url));
|
|
520
|
+
out.push('"');
|
|
521
|
+
}
|
|
522
|
+
if let Some(title) = str_attr(txn, t, "__title").filter(|s| !s.is_empty()) {
|
|
523
|
+
out.push_str(" title=\"");
|
|
524
|
+
out.push_str(&escape_attr(&title));
|
|
525
|
+
out.push('"');
|
|
526
|
+
}
|
|
527
|
+
out.push('>');
|
|
528
|
+
if depth < MAX_INLINE_DEPTH {
|
|
529
|
+
out.push_str(&render_inline(txn, t, depth + 1));
|
|
530
|
+
} else {
|
|
531
|
+
// A link chain nested past the cap (only crafted input reaches here):
|
|
532
|
+
// keep its text, drop any further link structure.
|
|
533
|
+
out.push_str(&escape_text(&t.get_string(txn)));
|
|
534
|
+
}
|
|
535
|
+
out.push_str("</a>");
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/// Root-level decorators: horizontal rule and upload attachments.
|
|
539
|
+
fn render_decorator<T: ReadTxn>(txn: &T, e: &XmlElementRef, out: &mut String) {
|
|
540
|
+
match elem_type(txn, e).as_str() {
|
|
541
|
+
"horizontal_divider" => out.push_str("<hr>"),
|
|
542
|
+
"action_text_attachment" => render_upload_attachment(txn, e, out),
|
|
543
|
+
"custom_action_text_attachment" => render_custom_attachment(txn, e, out),
|
|
544
|
+
_ => {} // unknown decorator: nothing extractable
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/// Inline decorators (inside a paragraph): mention/embed attachments.
|
|
549
|
+
fn render_inline_decorator<T: ReadTxn>(txn: &T, e: &XmlElementRef, out: &mut String) {
|
|
550
|
+
match elem_type(txn, e).as_str() {
|
|
551
|
+
"custom_action_text_attachment" => render_custom_attachment(txn, e, out),
|
|
552
|
+
"action_text_attachment" => render_upload_attachment(txn, e, out),
|
|
553
|
+
"horizontal_divider" => out.push_str("<hr>"),
|
|
554
|
+
_ => {}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
fn elem_type<T: ReadTxn>(txn: &T, e: &XmlElementRef) -> String {
|
|
559
|
+
match e.get_attribute(txn, "__type") {
|
|
560
|
+
Some(Out::Any(Any::String(s))) => s.to_string(),
|
|
561
|
+
_ => String::new(),
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
fn elem_str<T: ReadTxn>(txn: &T, e: &XmlElementRef, name: &str) -> Option<String> {
|
|
566
|
+
match e.get_attribute(txn, name) {
|
|
567
|
+
Some(Out::Any(Any::String(s))) => Some(s.to_string()),
|
|
568
|
+
_ => None,
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/// A numeric attribute rendered the way JavaScript would print it (integers
|
|
573
|
+
/// without a trailing `.0`). Assumes normal-magnitude, finite values — the
|
|
574
|
+
/// only numbers here are file sizes and pixel dimensions. A value past i64 or
|
|
575
|
+
/// a non-finite one would format differently from `String(n)` (e.g. `1e21`,
|
|
576
|
+
/// `Infinity`), but no real attachment carries one.
|
|
577
|
+
fn elem_num<T: ReadTxn>(txn: &T, e: &XmlElementRef, name: &str) -> Option<String> {
|
|
578
|
+
match e.get_attribute(txn, name) {
|
|
579
|
+
Some(Out::Any(Any::Number(n))) => {
|
|
580
|
+
if n.fract() == 0.0 {
|
|
581
|
+
Some(format!("{}", n as i64))
|
|
582
|
+
} else {
|
|
583
|
+
Some(format!("{n}"))
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
Some(Out::Any(Any::BigInt(n))) => Some(format!("{n}")),
|
|
587
|
+
_ => None,
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
fn push_attr(out: &mut String, name: &str, value: &str) {
|
|
592
|
+
out.push(' ');
|
|
593
|
+
out.push_str(name);
|
|
594
|
+
out.push_str("=\"");
|
|
595
|
+
out.push_str(&escape_attr(value));
|
|
596
|
+
out.push('"');
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/// An upload attachment, in the exact shape ActionText round-trips: attribute
|
|
600
|
+
/// order and presence mirror Lexxy's `exportDOM` (nulls omitted, `previewable`
|
|
601
|
+
/// only when true, `presentation="gallery"` always).
|
|
602
|
+
fn render_upload_attachment<T: ReadTxn>(txn: &T, e: &XmlElementRef, out: &mut String) {
|
|
603
|
+
out.push_str("<action-text-attachment");
|
|
604
|
+
if let Some(v) = elem_str(txn, e, "sgid") {
|
|
605
|
+
push_attr(out, "sgid", &v);
|
|
606
|
+
}
|
|
607
|
+
if matches!(
|
|
608
|
+
e.get_attribute(txn, "previewable"),
|
|
609
|
+
Some(Out::Any(Any::Bool(true)))
|
|
610
|
+
) {
|
|
611
|
+
push_attr(out, "previewable", "true");
|
|
612
|
+
}
|
|
613
|
+
if let Some(v) = elem_str(txn, e, "src") {
|
|
614
|
+
push_attr(out, "url", &v);
|
|
615
|
+
}
|
|
616
|
+
if let Some(v) = elem_str(txn, e, "altText") {
|
|
617
|
+
push_attr(out, "alt", &v);
|
|
618
|
+
}
|
|
619
|
+
if let Some(v) = elem_str(txn, e, "caption") {
|
|
620
|
+
push_attr(out, "caption", &v);
|
|
621
|
+
}
|
|
622
|
+
if let Some(v) = elem_str(txn, e, "contentType") {
|
|
623
|
+
push_attr(out, "content-type", &v);
|
|
624
|
+
}
|
|
625
|
+
if let Some(v) = elem_str(txn, e, "fileName") {
|
|
626
|
+
push_attr(out, "filename", &v);
|
|
627
|
+
}
|
|
628
|
+
if let Some(v) = elem_num(txn, e, "fileSize") {
|
|
629
|
+
push_attr(out, "filesize", &v);
|
|
630
|
+
}
|
|
631
|
+
if let Some(v) = elem_num(txn, e, "width") {
|
|
632
|
+
push_attr(out, "width", &v);
|
|
633
|
+
}
|
|
634
|
+
if let Some(v) = elem_num(txn, e, "height") {
|
|
635
|
+
push_attr(out, "height", &v);
|
|
636
|
+
}
|
|
637
|
+
push_attr(out, "presentation", "gallery");
|
|
638
|
+
out.push_str("></action-text-attachment>");
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/// A content attachment (mention, embed): `content` carries the escaped inner
|
|
642
|
+
/// HTML; `plainText` is not exported.
|
|
643
|
+
fn render_custom_attachment<T: ReadTxn>(txn: &T, e: &XmlElementRef, out: &mut String) {
|
|
644
|
+
out.push_str("<action-text-attachment");
|
|
645
|
+
if let Some(v) = elem_str(txn, e, "sgid") {
|
|
646
|
+
push_attr(out, "sgid", &v);
|
|
647
|
+
}
|
|
648
|
+
if let Some(v) = elem_str(txn, e, "innerHtml") {
|
|
649
|
+
push_attr(out, "content", &v);
|
|
650
|
+
}
|
|
651
|
+
if let Some(v) = elem_str(txn, e, "contentType") {
|
|
652
|
+
push_attr(out, "content-type", &v);
|
|
653
|
+
}
|
|
654
|
+
out.push_str("></action-text-attachment>");
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/// Text-content escaping, matching what the browser's serializer emits:
|
|
658
|
+
/// `&`, `<`, `>` escaped; quotes left alone in text.
|
|
659
|
+
fn escape_text(s: &str) -> String {
|
|
660
|
+
s.replace('&', "&")
|
|
661
|
+
.replace('<', "<")
|
|
662
|
+
.replace('>', ">")
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/// Attribute-value escaping: text escaping plus `"`.
|
|
666
|
+
fn escape_attr(s: &str) -> String {
|
|
667
|
+
escape_text(s).replace('"', """)
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
#[cfg(test)]
|
|
671
|
+
mod tests {
|
|
672
|
+
use super::*;
|
|
673
|
+
use yrs::updates::decoder::Decode;
|
|
674
|
+
use yrs::{Doc, Transact, Update};
|
|
675
|
+
|
|
676
|
+
/// Rendering the captured full-schema document must match Lexxy's own
|
|
677
|
+
/// serializer byte for byte. The pair was captured together from one live
|
|
678
|
+
/// editor session: `lexxy_full.bin` is the synced Yjs state, `lexxy_full.html`
|
|
679
|
+
/// is `lexxy-editor.value` for the same document. It covers every block and
|
|
680
|
+
/// format the schema map handles.
|
|
681
|
+
#[test]
|
|
682
|
+
fn renders_the_captured_lexxy_document_byte_for_byte() {
|
|
683
|
+
let bytes = include_bytes!("fixtures/lexxy_full.bin");
|
|
684
|
+
let expected = include_str!("fixtures/lexxy_full.html");
|
|
685
|
+
let doc = Doc::new();
|
|
686
|
+
doc.transact_mut()
|
|
687
|
+
.apply_update(Update::decode_v1(bytes).unwrap())
|
|
688
|
+
.unwrap();
|
|
689
|
+
let txn = doc.transact();
|
|
690
|
+
let frag = txn.get_xml_fragment("root").unwrap();
|
|
691
|
+
assert_eq!(render(&txn, &frag).unwrap(), expected.trim_end());
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/// A second live-Lexxy pair that stresses nesting: blocks inside table
|
|
695
|
+
/// cells, five-level mixed lists, formatted links in headings, the full
|
|
696
|
+
/// format stack, unicode and escaping edge cases, whitespace-only
|
|
697
|
+
/// paragraphs. The load-bearing structures are probed by name below.
|
|
698
|
+
/// One non-obvious parity fact worth stating: Lexxy's sanitized export
|
|
699
|
+
/// drops colSpan/rowSpan, so matching it byte-for-byte means not emitting
|
|
700
|
+
/// them either.
|
|
701
|
+
#[test]
|
|
702
|
+
fn renders_the_captured_torture_document_byte_for_byte() {
|
|
703
|
+
let bytes = include_bytes!("fixtures/lexxy_torture.bin");
|
|
704
|
+
let expected = include_str!("fixtures/lexxy_torture.html");
|
|
705
|
+
let doc = Doc::new();
|
|
706
|
+
doc.transact_mut()
|
|
707
|
+
.apply_update(Update::decode_v1(bytes).unwrap())
|
|
708
|
+
.unwrap();
|
|
709
|
+
let txn = doc.transact();
|
|
710
|
+
let frag = txn.get_xml_fragment("root").unwrap();
|
|
711
|
+
let html = render(&txn, &frag).unwrap();
|
|
712
|
+
assert_eq!(html, expected.trim_end());
|
|
713
|
+
|
|
714
|
+
// Byte-for-byte already proves these; name the load-bearing ones so
|
|
715
|
+
// a regression fails with the nested structure it broke.
|
|
716
|
+
for (what, probe) in [
|
|
717
|
+
("list nested in a table cell", "<td><ul><li"),
|
|
718
|
+
("quote in a table cell", "<td><blockquote>"),
|
|
719
|
+
(
|
|
720
|
+
"code block in a table cell",
|
|
721
|
+
"<td><pre data-language=\"javascript\">cell();</pre></td>",
|
|
722
|
+
),
|
|
723
|
+
("mention inside a header cell", "sgid=\"SGID_M_TABLE\""),
|
|
724
|
+
(
|
|
725
|
+
"five-level mixed list nesting",
|
|
726
|
+
"<ol><li value=\"1\"><s><i><strong>Level five</strong></i></s></li></ol>",
|
|
727
|
+
),
|
|
728
|
+
(
|
|
729
|
+
"the full format stack on one run",
|
|
730
|
+
"<u><s><i><code><strong>g</strong></code></i></s></u>",
|
|
731
|
+
),
|
|
732
|
+
(
|
|
733
|
+
"a titled link in a heading wrapping formatted runs",
|
|
734
|
+
"<a href=\"https://spec.example.com\" title=\"The Spec\">v<i><strong>2</strong></i><code>.final</code></a>",
|
|
735
|
+
),
|
|
736
|
+
(
|
|
737
|
+
"already-escaped-looking text escapes again",
|
|
738
|
+
"<strong>a && b < c > d \"q\" '&amp;'</strong>",
|
|
739
|
+
),
|
|
740
|
+
("emoji, CJK and RTL text", "🚀🎉 你好世界 العربية café"),
|
|
741
|
+
(
|
|
742
|
+
"an all-null upload keeps its normalized empty attrs",
|
|
743
|
+
"sgid=\"SGID_UP_MIN\" url=\"http://localhost:4111/files/min.bin\" alt=\"\" caption=\"\"",
|
|
744
|
+
),
|
|
745
|
+
(
|
|
746
|
+
"whitespace-only paragraphs",
|
|
747
|
+
"<p><span>\t</span></p><p><br></p><p><br></p>",
|
|
748
|
+
),
|
|
749
|
+
] {
|
|
750
|
+
assert!(html.contains(probe), "{what}: missing {probe}");
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/// Highlight colors (captured live): a run's __style survives on the
|
|
755
|
+
/// createDOM tag — the outer tag when present, else the inner — filtered
|
|
756
|
+
/// to color/background-color; plain and s/u-only runs lose it with their
|
|
757
|
+
/// unwrapped span. The fixture's byte-for-byte match pins both the keeps
|
|
758
|
+
/// and the drops.
|
|
759
|
+
#[test]
|
|
760
|
+
fn renders_the_captured_styles_document_byte_for_byte() {
|
|
761
|
+
let bytes = include_bytes!("fixtures/lexxy_styles.bin");
|
|
762
|
+
let expected = include_str!("fixtures/lexxy_styles.html");
|
|
763
|
+
let doc = Doc::new();
|
|
764
|
+
doc.transact_mut()
|
|
765
|
+
.apply_update(Update::decode_v1(bytes).unwrap())
|
|
766
|
+
.unwrap();
|
|
767
|
+
let txn = doc.transact();
|
|
768
|
+
let frag = txn.get_xml_fragment("root").unwrap();
|
|
769
|
+
assert_eq!(render(&txn, &frag).unwrap(), expected.trim_end());
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
#[test]
|
|
773
|
+
fn run_styles_ride_the_createdom_tag() {
|
|
774
|
+
// Outer tag takes the style over the inner one.
|
|
775
|
+
assert_eq!(
|
|
776
|
+
render_run("x", 128, "background-color: var(--highlight-bg-2);"),
|
|
777
|
+
"<mark style=\"background-color: var(--highlight-bg-2);\">x</mark>"
|
|
778
|
+
);
|
|
779
|
+
assert_eq!(
|
|
780
|
+
render_run("x", 1 | 128, "background-color: red;"),
|
|
781
|
+
"<mark style=\"background-color: red;\"><strong>x</strong></mark>"
|
|
782
|
+
);
|
|
783
|
+
// No outer tag: the inner takes it.
|
|
784
|
+
assert_eq!(
|
|
785
|
+
render_run("x", 1, "color: red;"),
|
|
786
|
+
"<strong style=\"color: red;\">x</strong>"
|
|
787
|
+
);
|
|
788
|
+
// Plain and s/u-only runs lose the style with their unwrapped span.
|
|
789
|
+
assert_eq!(render_run("x", 0, "color: red;"), "x");
|
|
790
|
+
assert_eq!(render_run("x", 4, "color: red;"), "<s>x</s>");
|
|
791
|
+
// Two properties: source order, no separator between them.
|
|
792
|
+
assert_eq!(
|
|
793
|
+
render_run("x", 128, "color: red; background-color: blue;"),
|
|
794
|
+
"<mark style=\"color: red;background-color: blue;\">x</mark>"
|
|
795
|
+
);
|
|
796
|
+
// Disallowed properties are stripped; quotes in values escape.
|
|
797
|
+
assert_eq!(
|
|
798
|
+
render_run("x", 128, "font-size: 40px; color: r\"ed;"),
|
|
799
|
+
"<mark style=\"color: r"ed;\">x</mark>"
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/// A known container with inline content jammed directly into it
|
|
804
|
+
/// (Lexxy never does this) keeps the content instead of dropping it.
|
|
805
|
+
#[test]
|
|
806
|
+
fn a_known_container_keeps_stray_inline_content() {
|
|
807
|
+
use yrs::{XmlFragment, XmlTextPrelim};
|
|
808
|
+
let doc = Doc::new();
|
|
809
|
+
let frag = doc.get_or_insert_xml_fragment("root");
|
|
810
|
+
{
|
|
811
|
+
let mut txn = doc.transact_mut();
|
|
812
|
+
let list = frag.push_back(&mut txn, XmlTextPrelim::new("stray"));
|
|
813
|
+
list.insert_attribute(&mut txn, "__type", "list");
|
|
814
|
+
list.insert_attribute(&mut txn, "__tag", "ul");
|
|
815
|
+
let li = list.insert_embed(&mut txn, 5, XmlTextPrelim::new("item"));
|
|
816
|
+
li.insert_attribute(&mut txn, "__type", "listitem");
|
|
817
|
+
}
|
|
818
|
+
let txn = doc.transact();
|
|
819
|
+
let frag = txn.get_xml_fragment("root").unwrap();
|
|
820
|
+
let html = render(&txn, &frag).unwrap();
|
|
821
|
+
|
|
822
|
+
assert!(html.contains("stray"), "stray inline text kept: {html}");
|
|
823
|
+
assert!(html.contains("<li value=\"1\">item</li>"), "{html}");
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/// An image gallery (adjacent previewable images grouped by Lexxy's
|
|
827
|
+
/// gallery node) renders as ActionText's classed div. Captured live, like
|
|
828
|
+
/// the other pairs: three image attachments between two paragraphs.
|
|
829
|
+
#[test]
|
|
830
|
+
fn renders_the_captured_gallery_document_byte_for_byte() {
|
|
831
|
+
let bytes = include_bytes!("fixtures/lexxy_gallery.bin");
|
|
832
|
+
let expected = include_str!("fixtures/lexxy_gallery.html");
|
|
833
|
+
let doc = Doc::new();
|
|
834
|
+
doc.transact_mut()
|
|
835
|
+
.apply_update(Update::decode_v1(bytes).unwrap())
|
|
836
|
+
.unwrap();
|
|
837
|
+
let txn = doc.transact();
|
|
838
|
+
let frag = txn.get_xml_fragment("root").unwrap();
|
|
839
|
+
let html = render(&txn, &frag).unwrap();
|
|
840
|
+
assert_eq!(html, expected.trim_end());
|
|
841
|
+
assert!(html.contains("<div class=\"attachment-gallery attachment-gallery--3\">"));
|
|
842
|
+
assert_eq!(html.matches("<action-text-attachment").count(), 3);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
#[test]
|
|
846
|
+
fn format_runs_match_lexxys_export_algorithm() {
|
|
847
|
+
// Singles take their semantic tag; the span for plain text unwraps.
|
|
848
|
+
assert_eq!(render_run("x", 0, ""), "x");
|
|
849
|
+
assert_eq!(render_run("x", 1, ""), "<strong>x</strong>");
|
|
850
|
+
assert_eq!(render_run("x", 2, ""), "<em>x</em>");
|
|
851
|
+
assert_eq!(render_run("x", 4, ""), "<s>x</s>");
|
|
852
|
+
assert_eq!(render_run("x", 8, ""), "<u>x</u>");
|
|
853
|
+
assert_eq!(render_run("x", 16, ""), "<code>x</code>");
|
|
854
|
+
assert_eq!(render_run("x", 32, ""), "<sub>x</sub>");
|
|
855
|
+
assert_eq!(render_run("x", 64, ""), "<sup>x</sup>");
|
|
856
|
+
assert_eq!(render_run("x", 128, ""), "<mark>x</mark>");
|
|
857
|
+
// bold+italic: bold claims the inner tag, italic falls back to <i>.
|
|
858
|
+
assert_eq!(render_run("x", 3, ""), "<i><strong>x</strong></i>");
|
|
859
|
+
// Wrap order: u outside s outside the semantic core.
|
|
860
|
+
assert_eq!(render_run("x", 4 | 8, ""), "<u><s>x</s></u>");
|
|
861
|
+
assert_eq!(render_run("x", 1 | 8, ""), "<u><strong>x</strong></u>");
|
|
862
|
+
// Outer tag composes with the inner one.
|
|
863
|
+
assert_eq!(
|
|
864
|
+
render_run("x", 1 | 16, ""),
|
|
865
|
+
"<code><strong>x</strong></code>"
|
|
866
|
+
);
|
|
867
|
+
assert_eq!(render_run("x", 2 | 128, ""), "<mark><em>x</em></mark>");
|
|
868
|
+
// Everything at once: u(s(i(outer(inner)))).
|
|
869
|
+
assert_eq!(
|
|
870
|
+
render_run("x", 1 | 2 | 4 | 8 | 16, ""),
|
|
871
|
+
"<u><s><i><code><strong>x</strong></code></i></s></u>"
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
#[test]
|
|
876
|
+
fn a_prosemirror_shaped_root_is_refused_not_mangled() {
|
|
877
|
+
// ProseMirror blocks are plain XmlElements with no __type — a
|
|
878
|
+
// different schema. render() must return None, never a lossy or empty
|
|
879
|
+
// rendering that looks like success.
|
|
880
|
+
use yrs::{XmlElementPrelim, XmlFragment, XmlTextPrelim};
|
|
881
|
+
let doc = Doc::new();
|
|
882
|
+
// Create BOTH roots before opening the read transaction:
|
|
883
|
+
// get_or_insert_* opens a write transaction internally and would
|
|
884
|
+
// deadlock against a live read guard (the read_text lesson).
|
|
885
|
+
let frag = doc.get_or_insert_xml_fragment("pm");
|
|
886
|
+
let empty = doc.get_or_insert_xml_fragment("empty");
|
|
887
|
+
{
|
|
888
|
+
let mut txn = doc.transact_mut();
|
|
889
|
+
let p = frag.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
|
|
890
|
+
p.push_back(&mut txn, XmlTextPrelim::new("Body"));
|
|
891
|
+
}
|
|
892
|
+
let txn = doc.transact();
|
|
893
|
+
assert_eq!(render(&txn, &frag), None);
|
|
894
|
+
|
|
895
|
+
// An empty root is fine: an empty document, not a foreign schema.
|
|
896
|
+
assert_eq!(render(&txn, &empty).as_deref(), Some(""));
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/// Build a chain of `depth` nested lists: list > listitem > list > … each
|
|
900
|
+
/// list holding one item whose only child is the next list down. Mirrors
|
|
901
|
+
/// the real storage model (block children are XmlText embedded in XmlText).
|
|
902
|
+
fn nested_list_doc(depth: usize) -> Doc {
|
|
903
|
+
use yrs::{XmlFragment, XmlTextPrelim};
|
|
904
|
+
let doc = Doc::new();
|
|
905
|
+
let root = doc.get_or_insert_xml_fragment("root");
|
|
906
|
+
let mut txn = doc.transact_mut();
|
|
907
|
+
let top = root.push_back(&mut txn, XmlTextPrelim::new(""));
|
|
908
|
+
top.insert_attribute(&mut txn, "__type", "list");
|
|
909
|
+
top.insert_attribute(&mut txn, "__tag", "ul");
|
|
910
|
+
let mut cursor = top;
|
|
911
|
+
for _ in 0..depth {
|
|
912
|
+
let li = cursor.insert_embed(&mut txn, 0, XmlTextPrelim::new(""));
|
|
913
|
+
li.insert_attribute(&mut txn, "__type", "listitem");
|
|
914
|
+
let inner = li.insert_embed(&mut txn, 0, XmlTextPrelim::new(""));
|
|
915
|
+
inner.insert_attribute(&mut txn, "__type", "list");
|
|
916
|
+
inner.insert_attribute(&mut txn, "__tag", "ul");
|
|
917
|
+
cursor = inner;
|
|
918
|
+
}
|
|
919
|
+
drop(txn);
|
|
920
|
+
doc
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
#[test]
|
|
924
|
+
fn deeply_nested_blocks_do_not_overflow_the_stack() {
|
|
925
|
+
// Nesting this deep would overflow the native call stack (tens of
|
|
926
|
+
// thousands of frames) under recursion; on the heap it renders fine.
|
|
927
|
+
// Runs on a 512 KiB thread so it can't pass just by having room to spare.
|
|
928
|
+
let handle = std::thread::Builder::new()
|
|
929
|
+
.stack_size(512 * 1024)
|
|
930
|
+
.spawn(|| {
|
|
931
|
+
let doc = nested_list_doc(20_000);
|
|
932
|
+
let txn = doc.transact();
|
|
933
|
+
let frag = txn.get_xml_fragment("root").unwrap();
|
|
934
|
+
let html = render(&txn, &frag).expect("lexical-shaped");
|
|
935
|
+
// Well formed: every opened list/item is closed.
|
|
936
|
+
assert_eq!(html.matches("<ul>").count(), html.matches("</ul>").count());
|
|
937
|
+
assert_eq!(html.matches("<li ").count(), html.matches("</li>").count());
|
|
938
|
+
html.len()
|
|
939
|
+
})
|
|
940
|
+
.unwrap();
|
|
941
|
+
assert!(handle.join().unwrap() > 0);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
#[test]
|
|
945
|
+
fn nesting_past_the_cap_truncates_but_stays_well_formed() {
|
|
946
|
+
// Past MAX_BLOCK_DEPTH the deepest content is dropped, but every
|
|
947
|
+
// enclosing tag still closes, so the output remains balanced HTML.
|
|
948
|
+
let doc = nested_list_doc(MAX_BLOCK_DEPTH + 50);
|
|
949
|
+
let txn = doc.transact();
|
|
950
|
+
let frag = txn.get_xml_fragment("root").unwrap();
|
|
951
|
+
let html = render(&txn, &frag).unwrap();
|
|
952
|
+
|
|
953
|
+
assert_eq!(html.matches("<ul>").count(), html.matches("</ul>").count());
|
|
954
|
+
assert_eq!(html.matches("<li ").count(), html.matches("</li>").count());
|
|
955
|
+
// Capped, not fully rendered: fewer levels than were authored.
|
|
956
|
+
assert!(html.matches("<ul>").count() <= MAX_BLOCK_DEPTH + 1);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
#[test]
|
|
960
|
+
fn deeply_nested_links_do_not_overflow_the_stack() {
|
|
961
|
+
// Links recurse (their body is inline content), so they carry their own
|
|
962
|
+
// depth cap. A link-in-link chain renders as nested <a>s up to the cap,
|
|
963
|
+
// then bare text.
|
|
964
|
+
use yrs::{XmlFragment, XmlTextPrelim};
|
|
965
|
+
let doc = Doc::new();
|
|
966
|
+
let root = doc.get_or_insert_xml_fragment("root");
|
|
967
|
+
{
|
|
968
|
+
let mut txn = doc.transact_mut();
|
|
969
|
+
let p = root.push_back(&mut txn, XmlTextPrelim::new(""));
|
|
970
|
+
p.insert_attribute(&mut txn, "__type", "paragraph");
|
|
971
|
+
let mut cursor = p;
|
|
972
|
+
for _ in 0..(MAX_INLINE_DEPTH + 20) {
|
|
973
|
+
let link = cursor.insert_embed(&mut txn, 0, XmlTextPrelim::new(""));
|
|
974
|
+
link.insert_attribute(&mut txn, "__type", "link");
|
|
975
|
+
link.insert_attribute(&mut txn, "__url", "https://x.example");
|
|
976
|
+
cursor = link;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
let txn = doc.transact();
|
|
980
|
+
let frag = txn.get_xml_fragment("root").unwrap();
|
|
981
|
+
let html = render(&txn, &frag).unwrap();
|
|
982
|
+
assert_eq!(html.matches("<a").count(), html.matches("</a>").count());
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
#[test]
|
|
986
|
+
fn escaping_matches_the_browser_serializer() {
|
|
987
|
+
assert_eq!(escape_text(r#"<a & "b">"#), r#"<a & "b">"#);
|
|
988
|
+
assert_eq!(
|
|
989
|
+
escape_attr(r#"<a & "b">"#),
|
|
990
|
+
r#"<a & "b">"#
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
}
|