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