yrby 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +74 -1
- data/Cargo.lock +29 -0
- data/README.md +286 -30
- data/ext/yrby/Cargo.toml +3 -0
- data/ext/yrby/crates/html-core/Cargo.toml +15 -0
- data/ext/yrby/crates/html-core/src/lib.rs +535 -0
- data/ext/yrby/crates/lexical-html/Cargo.toml +16 -0
- data/ext/yrby/{src/lexical_html.rs → crates/lexical-html/src/lib.rs} +615 -288
- data/ext/yrby/crates/prosemirror-html/Cargo.toml +16 -0
- data/ext/yrby/crates/prosemirror-html/src/lib.rs +1369 -0
- data/ext/yrby/src/lib.rs +208 -78
- data/ext/yrby/src/read.rs +3 -3
- data/ext/yrby/target/debug/build/clang-sys-e3ae45bd384f74c3/out/common.rs +355 -0
- data/ext/yrby/target/debug/build/clang-sys-e3ae45bd384f74c3/out/dynamic.rs +276 -0
- data/ext/yrby/target/debug/build/clang-sys-e3ae45bd384f74c3/out/macros.rs +49 -0
- data/ext/yrby/target/debug/build/rb-sys-4407948463231c4f/out/bindings-0.9.128-mri-arm64-darwin23-3.4.7.rs +8934 -0
- data/ext/yrby/target/debug/build/rb-sys-dbeea42737529c2d/out/bindings-0.9.128-mri-arm64-darwin23-3.4.7.rs +8934 -0
- data/ext/yrby/target/debug/build/serde-58ea0ee887cc2602/out/private.rs +6 -0
- data/ext/yrby/target/debug/build/serde_core-41f407c21c1f205e/out/private.rs +5 -0
- data/ext/yrby/target/debug/build/thiserror-0f1416a82ff26f22/out/private.rs +5 -0
- data/lib/generators/yrby/install/install_generator.rb +44 -0
- data/lib/generators/yrby/install/templates/document_channel.rb +36 -0
- data/lib/generators/yrby/tables/tables_generator.rb +41 -0
- data/lib/generators/yrby/tables/templates/create_y_tables.rb +24 -0
- data/lib/y/lexxy.rb +121 -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 +23 -4
- data/ext/yrby/src/prosemirror_html.rs +0 -896
|
@@ -1,37 +1,57 @@
|
|
|
1
|
-
//! Native HTML rendering of Lexical
|
|
2
|
-
//!
|
|
1
|
+
//! Native HTML rendering of Lexical documents from the yrs collab structure —
|
|
2
|
+
//! no Node process, no headless editor.
|
|
3
3
|
//!
|
|
4
|
-
//!
|
|
5
|
-
//!
|
|
6
|
-
//!
|
|
7
|
-
//!
|
|
8
|
-
//!
|
|
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).
|
|
9
16
|
//!
|
|
10
17
|
//! Prior art: `ueberdosis/tiptap-php` renders ProseMirror JSON to HTML in pure
|
|
11
18
|
//! PHP — a schema-pinned renderer outside the JS runtime. This works from the
|
|
12
19
|
//! collab (Yjs) structure rather than JSON.
|
|
13
20
|
//!
|
|
14
|
-
//! Storage model (verified against bytes captured from a live
|
|
21
|
+
//! Storage model (verified against bytes captured from a live editor):
|
|
15
22
|
//! - Blocks are `Y.XmlText` with a `__type` attribute (`paragraph`, `heading`
|
|
16
|
-
//! (+`__tag`), `quote`, `
|
|
17
|
-
//! `
|
|
23
|
+
//! (+`__tag`), `quote`, `code` (+`__language`), `list`/`listitem`,
|
|
24
|
+
//! `table`/`tablerow`/`tablecell`, `link`/`autolink` (inline)).
|
|
18
25
|
//! - Text runs are preceded by an embedded `Y.Map` carrying per-run metadata
|
|
19
26
|
//! (`__type: "text" | "code-highlight" | "tab"`, `__format` bitmask).
|
|
20
27
|
//! - `linebreak` is a bare metadata map; `tab` is a map followed by a "\t" run.
|
|
21
|
-
//! - Decorator nodes are `Y.XmlElement`s
|
|
22
|
-
//! `
|
|
23
|
-
//! (mentions/embeds), with their fields as plain attributes.
|
|
28
|
+
//! - Decorator nodes are `Y.XmlElement`s with their fields as plain
|
|
29
|
+
//! attributes (`horizontalrule` here; app/Lexxy decorators via rules).
|
|
24
30
|
//!
|
|
25
|
-
//! Text-format rendering
|
|
26
|
-
//!
|
|
27
|
-
//!
|
|
28
|
-
//! `
|
|
29
|
-
//!
|
|
30
|
-
//!
|
|
31
|
-
//!
|
|
32
|
-
//!
|
|
33
|
-
//!
|
|
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 `yrs-html-core`) 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
|
+
// README examples are living code: compile-checked on every cargo test.
|
|
48
|
+
#[cfg(doctest)]
|
|
49
|
+
#[doc = include_str!("../README.md")]
|
|
50
|
+
mod readme_examples {}
|
|
34
51
|
|
|
52
|
+
// The full rules surface, re-exported: depend on this crate alone;
|
|
53
|
+
// yrs-html-core is an internal implementation crate.
|
|
54
|
+
pub use yrs_html_core::*;
|
|
35
55
|
use yrs::types::text::YChange;
|
|
36
56
|
use yrs::{
|
|
37
57
|
Any, GetString, Map, Out, ReadTxn, Text, Xml, XmlElementRef, XmlFragment, XmlFragmentRef,
|
|
@@ -57,36 +77,158 @@ const FMT_HIGHLIGHT: u32 = 1 << 7;
|
|
|
57
77
|
const MAX_BLOCK_DEPTH: usize = 1024;
|
|
58
78
|
const MAX_INLINE_DEPTH: usize = 1024;
|
|
59
79
|
|
|
60
|
-
/// A unit of
|
|
61
|
-
/// a block node (pushing its own children as more work); `Close`
|
|
62
|
-
///
|
|
63
|
-
///
|
|
80
|
+
/// A unit of block work on the explicit traversal stack. `Open`
|
|
81
|
+
/// renders a block node (pushing its own children as more work); `Close` and
|
|
82
|
+
/// `CloseOwned` emit an end tag once a container's children have all been
|
|
83
|
+
/// processed (built-in containers close with fixed strings; rule containers
|
|
84
|
+
/// close with their computed tag). `EndDeferred` seals a callback node: it pops
|
|
85
|
+
/// the emitter frame its children rendered into and emits the deferred segment.
|
|
64
86
|
enum Work {
|
|
65
87
|
Open(XmlTextRef, usize),
|
|
66
88
|
Close(&'static str),
|
|
89
|
+
CloseOwned(String),
|
|
90
|
+
EndDeferred {
|
|
91
|
+
node_type: String,
|
|
92
|
+
attrs_json: String,
|
|
93
|
+
child_types: Vec<String>,
|
|
94
|
+
},
|
|
67
95
|
}
|
|
68
96
|
|
|
69
|
-
/// Render a Lexical/Lexxy-shaped XML root
|
|
70
|
-
///
|
|
97
|
+
/// Render a Lexical/Lexxy-shaped XML root, or `None` when the root isn't
|
|
98
|
+
/// Lexical-shaped. Lexical marks every node with a `__type` attribute; a
|
|
71
99
|
/// root whose children carry none — a ProseMirror document, say, whose blocks
|
|
72
100
|
/// are plain `<paragraph>` elements — is a foreign schema, and render returns
|
|
73
101
|
/// `None` for it rather than a lossy guess.
|
|
74
102
|
///
|
|
75
|
-
/// A `__type` the renderer doesn't recognize is handled differently:
|
|
76
|
-
///
|
|
103
|
+
/// A `__type` the renderer doesn't recognize is handled differently: a
|
|
104
|
+
/// registered rule renders it; otherwise a leaf's text renders in a `<p>`
|
|
105
|
+
/// and a container's block children render without an invented wrapper, so
|
|
106
|
+
/// an editor node the schema hasn't heard of stays readable.
|
|
107
|
+
pub fn render_segments<T: ReadTxn>(
|
|
108
|
+
txn: &T,
|
|
109
|
+
fragment: &XmlFragmentRef,
|
|
110
|
+
rules: &Rules,
|
|
111
|
+
) -> Option<Vec<Segment>> {
|
|
112
|
+
if !is_lexical_shaped(txn, fragment) {
|
|
113
|
+
return None;
|
|
114
|
+
}
|
|
115
|
+
let mut em = Emitter::new();
|
|
116
|
+
for node in fragment.children(txn) {
|
|
117
|
+
match node {
|
|
118
|
+
XmlOut::Text(t) => render_block_tree(txn, &t, &mut em, rules),
|
|
119
|
+
XmlOut::Element(e) => render_decorator(txn, &e, &mut em, rules),
|
|
120
|
+
// Fragments can't nest as children in yrs; escape the text as the
|
|
121
|
+
// safe degradation for an exhaustive match.
|
|
122
|
+
XmlOut::Fragment(f) => em.push_str(&escape_text(&f.get_string(txn))),
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
Some(em.into_segments())
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/// Rule-free rendering to a plain string — the simplest way to use this
|
|
129
|
+
/// crate standalone, and the fixture-parity surface the tests pin. With no
|
|
130
|
+
/// callback rules, segments always flatten.
|
|
77
131
|
pub fn render<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> Option<String> {
|
|
132
|
+
render_segments(txn, fragment, &Rules::empty()).map(|segs| {
|
|
133
|
+
yrs_html_core::flatten(segs)
|
|
134
|
+
.into_html()
|
|
135
|
+
.expect("no callback rules registered")
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/// The node types a Lexical renderer's built-in arms cover (core Lexical;
|
|
140
|
+
/// inline and decorator types included). Everything else needs a rule.
|
|
141
|
+
pub fn is_builtin(ty: &str) -> bool {
|
|
142
|
+
matches!(
|
|
143
|
+
ty,
|
|
144
|
+
"paragraph"
|
|
145
|
+
| "heading"
|
|
146
|
+
| "quote"
|
|
147
|
+
| "code"
|
|
148
|
+
| "list"
|
|
149
|
+
| "listitem"
|
|
150
|
+
| "table"
|
|
151
|
+
| "tablerow"
|
|
152
|
+
| "tablecell"
|
|
153
|
+
| "link"
|
|
154
|
+
| "autolink"
|
|
155
|
+
| "linebreak"
|
|
156
|
+
| "tab"
|
|
157
|
+
| "text"
|
|
158
|
+
| "horizontalrule"
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/// Walk the document and record what each node type actually looks like —
|
|
163
|
+
/// the discovery aid behind `Y::Lexical#node_types`. It records facts:
|
|
164
|
+
/// counts, attribute names (minus `__type`, which is the key), child types,
|
|
165
|
+
/// and whether text runs were seen.
|
|
166
|
+
pub fn collect_node_types<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> Option<TypeMap> {
|
|
78
167
|
if !is_lexical_shaped(txn, fragment) {
|
|
79
168
|
return None;
|
|
80
169
|
}
|
|
81
|
-
let mut
|
|
170
|
+
let mut map = TypeMap::new();
|
|
82
171
|
for node in fragment.children(txn) {
|
|
83
172
|
match node {
|
|
84
|
-
XmlOut::Text(t) =>
|
|
85
|
-
XmlOut::Element(e) =>
|
|
86
|
-
XmlOut::Fragment(
|
|
173
|
+
XmlOut::Text(t) => observe_text_node(txn, &t, &mut map, 0),
|
|
174
|
+
XmlOut::Element(e) => observe_element(txn, &e, &mut map),
|
|
175
|
+
XmlOut::Fragment(_) => {}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
Some(map)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
fn observe_text_node<T: ReadTxn>(txn: &T, t: &XmlTextRef, map: &mut TypeMap, depth: usize) {
|
|
182
|
+
let ty = node_type(txn, t);
|
|
183
|
+
let info = map.entry(ty.clone()).or_default();
|
|
184
|
+
info.count += 1;
|
|
185
|
+
for (key, _) in t.attributes(txn) {
|
|
186
|
+
if key != "__type" {
|
|
187
|
+
info.attrs.insert(key.to_string());
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if depth >= MAX_BLOCK_DEPTH {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
let mut children = Vec::new();
|
|
194
|
+
for d in t.diff(txn, YChange::identity) {
|
|
195
|
+
match d.insert {
|
|
196
|
+
Out::Any(Any::String(_)) => {
|
|
197
|
+
map.get_mut(&ty).expect("just inserted").text = true;
|
|
198
|
+
}
|
|
199
|
+
Out::YXmlText(child) => {
|
|
200
|
+
let child_ty = node_type(txn, &child);
|
|
201
|
+
map.get_mut(&ty)
|
|
202
|
+
.expect("just inserted")
|
|
203
|
+
.children
|
|
204
|
+
.insert(child_ty);
|
|
205
|
+
children.push(child);
|
|
206
|
+
}
|
|
207
|
+
Out::YXmlElement(child) => {
|
|
208
|
+
let child_ty = elem_type(txn, &child);
|
|
209
|
+
map.get_mut(&ty)
|
|
210
|
+
.expect("just inserted")
|
|
211
|
+
.children
|
|
212
|
+
.insert(child_ty);
|
|
213
|
+
observe_element(txn, &child, map);
|
|
214
|
+
}
|
|
215
|
+
_ => {} // run-metadata maps are formatting, not children
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
for child in children {
|
|
219
|
+
observe_text_node(txn, &child, map, depth + 1);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
fn observe_element<T: ReadTxn>(txn: &T, e: &XmlElementRef, map: &mut TypeMap) {
|
|
224
|
+
let ty = elem_type(txn, e);
|
|
225
|
+
let info = map.entry(ty).or_default();
|
|
226
|
+
info.count += 1;
|
|
227
|
+
for (key, _) in e.attributes(txn) {
|
|
228
|
+
if key != "__type" {
|
|
229
|
+
info.attrs.insert(key.to_string());
|
|
87
230
|
}
|
|
88
231
|
}
|
|
89
|
-
Some(out)
|
|
90
232
|
}
|
|
91
233
|
|
|
92
234
|
/// A root is Lexical-shaped when it is empty or at least one child carries the
|
|
@@ -123,45 +265,59 @@ fn str_attr<T: ReadTxn>(txn: &T, t: &XmlTextRef, name: &str) -> Option<String> {
|
|
|
123
265
|
}
|
|
124
266
|
}
|
|
125
267
|
|
|
126
|
-
/// Walk a top-level block and everything under it on an explicit heap stack
|
|
127
|
-
///
|
|
128
|
-
///
|
|
129
|
-
///
|
|
130
|
-
|
|
131
|
-
/// native call stack.
|
|
132
|
-
fn render_block_tree<T: ReadTxn>(txn: &T, root: &XmlTextRef, out: &mut String) {
|
|
268
|
+
/// Walk a top-level block and everything under it on an explicit heap stack.
|
|
269
|
+
/// Container blocks push their children back as more work plus a close for
|
|
270
|
+
/// their end tag; leaf blocks render in full on the spot. The stack lives on
|
|
271
|
+
/// the heap, so nesting depth can't overflow the native call stack.
|
|
272
|
+
fn render_block_tree<T: ReadTxn>(txn: &T, root: &XmlTextRef, em: &mut Emitter, rules: &Rules) {
|
|
133
273
|
let mut stack: Vec<Work> = vec![Work::Open(root.clone(), 0)];
|
|
134
274
|
while let Some(work) = stack.pop() {
|
|
135
275
|
match work {
|
|
136
|
-
Work::Close(tag) =>
|
|
137
|
-
Work::
|
|
276
|
+
Work::Close(tag) => em.push_str(tag),
|
|
277
|
+
Work::CloseOwned(tag) => em.push_str(&tag),
|
|
278
|
+
Work::EndDeferred {
|
|
279
|
+
node_type,
|
|
280
|
+
attrs_json,
|
|
281
|
+
child_types,
|
|
282
|
+
} => {
|
|
283
|
+
let content = em.end_frame();
|
|
284
|
+
em.emit_deferred(node_type, attrs_json, child_types, content);
|
|
285
|
+
}
|
|
286
|
+
Work::Open(node, depth) => open_block(txn, &node, depth, em, &mut stack, rules),
|
|
138
287
|
}
|
|
139
288
|
}
|
|
140
289
|
}
|
|
141
290
|
|
|
142
|
-
/// Render one block. A
|
|
143
|
-
///
|
|
291
|
+
/// Render one block. A registered rule wins over the built-in arms (so apps
|
|
292
|
+
/// can extend the schema or override a built-in); a container emits its
|
|
293
|
+
/// opening tag now and defers its children (and matching close) to the stack;
|
|
294
|
+
/// a leaf renders completely.
|
|
144
295
|
fn open_block<T: ReadTxn>(
|
|
145
296
|
txn: &T,
|
|
146
297
|
t: &XmlTextRef,
|
|
147
298
|
depth: usize,
|
|
148
|
-
|
|
299
|
+
em: &mut Emitter,
|
|
149
300
|
stack: &mut Vec<Work>,
|
|
301
|
+
rules: &Rules,
|
|
150
302
|
) {
|
|
151
303
|
let ty = node_type(txn, t);
|
|
304
|
+
if let Some(rule) = rules.nodes.get(ty.as_str()) {
|
|
305
|
+
open_rule_block(txn, t, &ty, rule, depth, em, stack, rules);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
152
308
|
match ty.as_str() {
|
|
153
|
-
"paragraph"
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
309
|
+
"paragraph" => {
|
|
310
|
+
// An empty paragraph exports with a <br>, as Lexical's own
|
|
311
|
+
// paragraph export does.
|
|
312
|
+
em.begin_frame();
|
|
313
|
+
render_inline(txn, t, 0, true, em, rules);
|
|
314
|
+
let inline = em.end_frame();
|
|
157
315
|
if inline.is_empty() {
|
|
158
|
-
|
|
159
|
-
out.push_str("<p><br></p>");
|
|
160
|
-
}
|
|
316
|
+
em.push_str("<p><br></p>");
|
|
161
317
|
} else {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
318
|
+
em.push_str("<p>");
|
|
319
|
+
em.append(inline);
|
|
320
|
+
em.push_str("</p>");
|
|
165
321
|
}
|
|
166
322
|
}
|
|
167
323
|
"heading" => {
|
|
@@ -169,71 +325,56 @@ fn open_block<T: ReadTxn>(
|
|
|
169
325
|
Some(tag @ ("h1" | "h2" | "h3" | "h4" | "h5" | "h6")) => tag.to_string(),
|
|
170
326
|
_ => "h1".to_string(),
|
|
171
327
|
};
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
328
|
+
em.push('<');
|
|
329
|
+
em.push_str(&tag);
|
|
330
|
+
em.push('>');
|
|
331
|
+
render_inline(txn, t, 0, true, em, rules);
|
|
332
|
+
em.push_str("</");
|
|
333
|
+
em.push_str(&tag);
|
|
334
|
+
em.push('>');
|
|
179
335
|
}
|
|
180
336
|
"quote" => {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
}
|
|
185
|
-
"code"
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
out.push_str("<pre");
|
|
337
|
+
em.push_str("<blockquote>");
|
|
338
|
+
render_inline(txn, t, 0, true, em, rules);
|
|
339
|
+
em.push_str("</blockquote>");
|
|
340
|
+
}
|
|
341
|
+
"code" => {
|
|
342
|
+
// Code highlighting is derived state: token runs flatten to
|
|
343
|
+
// plain text; linebreaks are <br>, tabs a wrapped \t.
|
|
344
|
+
em.push_str("<pre");
|
|
190
345
|
if let Some(lang) = str_attr(txn, t, "__language").filter(|l| !l.is_empty()) {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
346
|
+
em.push_str(" data-language=\"");
|
|
347
|
+
em.push_str(&escape_attr(&lang));
|
|
348
|
+
em.push('"');
|
|
194
349
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
350
|
+
em.push('>');
|
|
351
|
+
render_inline(txn, t, 0, true, em, rules);
|
|
352
|
+
em.push_str("</pre>");
|
|
198
353
|
}
|
|
199
354
|
"list" => {
|
|
200
355
|
let tag = match str_attr(txn, t, "__tag").as_deref() {
|
|
201
356
|
Some("ol") => "ol",
|
|
202
357
|
_ => "ul",
|
|
203
358
|
};
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
359
|
+
em.push('<');
|
|
360
|
+
em.push_str(tag);
|
|
361
|
+
em.push('>');
|
|
207
362
|
// Direct inline content is crafted-only (Lexxy puts none here);
|
|
208
363
|
// keep it rather than drop it. Safe from double-render: this skips
|
|
209
364
|
// block children, and push_block_children skips inline content.
|
|
210
|
-
|
|
365
|
+
render_inline(txn, t, 0, false, em, rules);
|
|
211
366
|
let close = if tag == "ol" { "</ol>" } else { "</ul>" };
|
|
212
367
|
push_block_children(txn, t, depth, close, false, stack);
|
|
213
368
|
}
|
|
214
|
-
"listitem" => open_listitem(txn, t, depth,
|
|
215
|
-
"
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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);
|
|
369
|
+
"listitem" => open_listitem(txn, t, depth, em, stack, rules),
|
|
370
|
+
"table" => {
|
|
371
|
+
em.push_str("<table><tbody>");
|
|
372
|
+
render_inline(txn, t, 0, false, em, rules);
|
|
373
|
+
push_block_children(txn, t, depth, "</tbody></table>", false, stack);
|
|
233
374
|
}
|
|
234
375
|
"tablerow" => {
|
|
235
|
-
|
|
236
|
-
|
|
376
|
+
em.push_str("<tr>");
|
|
377
|
+
render_inline(txn, t, 0, false, em, rules);
|
|
237
378
|
push_block_children(txn, t, depth, "</tr>", false, stack);
|
|
238
379
|
}
|
|
239
380
|
"tablecell" => {
|
|
@@ -245,32 +386,130 @@ fn open_block<T: ReadTxn>(
|
|
|
245
386
|
Some(Out::Any(Any::BigInt(n))) if n > 0
|
|
246
387
|
);
|
|
247
388
|
let close = if header {
|
|
248
|
-
|
|
249
|
-
out.push_str(
|
|
250
|
-
"<th class=\"lexxy-content__table-cell--header\" \
|
|
251
|
-
style=\"background-color: rgb(242, 243, 245);\">",
|
|
252
|
-
);
|
|
389
|
+
em.push_str("<th>");
|
|
253
390
|
"</th>"
|
|
254
391
|
} else {
|
|
255
|
-
|
|
392
|
+
em.push_str("<td>");
|
|
256
393
|
"</td>"
|
|
257
394
|
};
|
|
258
|
-
|
|
395
|
+
render_inline(txn, t, 0, false, em, rules);
|
|
259
396
|
push_block_children(txn, t, depth, close, false, stack);
|
|
260
397
|
}
|
|
261
|
-
// A block type this renderer doesn't know: degrade
|
|
262
|
-
//
|
|
398
|
+
// A block type this renderer doesn't know: degrade readably instead
|
|
399
|
+
// of dropping content. A container's block children render with no
|
|
400
|
+
// invented wrapper (a Lexxy table wrapper's rows still come out as
|
|
401
|
+
// rows); a leaf's text becomes a plain paragraph.
|
|
263
402
|
_ => {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
403
|
+
if !block_children(txn, t).is_empty() {
|
|
404
|
+
render_inline(txn, t, 0, false, em, rules);
|
|
405
|
+
push_block_children(txn, t, depth, "", false, stack);
|
|
406
|
+
} else {
|
|
407
|
+
em.begin_frame();
|
|
408
|
+
render_inline(txn, t, 0, true, em, rules);
|
|
409
|
+
let inline = em.end_frame();
|
|
410
|
+
if !inline.is_empty() {
|
|
411
|
+
em.push_str("<p>");
|
|
412
|
+
em.append(inline);
|
|
413
|
+
em.push_str("</p>");
|
|
414
|
+
}
|
|
269
415
|
}
|
|
270
416
|
}
|
|
271
417
|
}
|
|
272
418
|
}
|
|
273
419
|
|
|
420
|
+
/// Render a block through a registered rule. Declarative rules emit the tag,
|
|
421
|
+
/// resolved attributes, and template text here; callback rules capture their
|
|
422
|
+
/// children into a frame and defer the markup to the caller.
|
|
423
|
+
#[allow(clippy::too_many_arguments)]
|
|
424
|
+
fn open_rule_block<T: ReadTxn>(
|
|
425
|
+
txn: &T,
|
|
426
|
+
t: &XmlTextRef,
|
|
427
|
+
ty: &str,
|
|
428
|
+
rule: &NodeRule,
|
|
429
|
+
depth: usize,
|
|
430
|
+
em: &mut Emitter,
|
|
431
|
+
stack: &mut Vec<Work>,
|
|
432
|
+
rules: &Rules,
|
|
433
|
+
) {
|
|
434
|
+
let (tag, void, attrs, text, content) = match rule {
|
|
435
|
+
NodeRule::Callback { content } => {
|
|
436
|
+
em.begin_frame();
|
|
437
|
+
// Children render into the frame; EndDeferred seals it. Blocks go
|
|
438
|
+
// via the stack (pushed above the marker, so they complete first);
|
|
439
|
+
// inline content renders now — in blocks mode too, since a block
|
|
440
|
+
// like a list item holds its own text alongside its nested blocks.
|
|
441
|
+
stack.push(Work::EndDeferred {
|
|
442
|
+
node_type: ty.to_string(),
|
|
443
|
+
attrs_json: xml_attrs_json(txn, t),
|
|
444
|
+
child_types: text_child_types(txn, t),
|
|
445
|
+
});
|
|
446
|
+
match content {
|
|
447
|
+
Content::Inline => render_inline(txn, t, 0, true, em, rules),
|
|
448
|
+
Content::Blocks => {
|
|
449
|
+
render_inline(txn, t, 0, false, em, rules);
|
|
450
|
+
for child in block_children(txn, t).into_iter().rev() {
|
|
451
|
+
if depth < MAX_BLOCK_DEPTH {
|
|
452
|
+
stack.push(Work::Open(child, depth + 1));
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
Content::None => {}
|
|
457
|
+
}
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
NodeRule::Declarative {
|
|
461
|
+
tag,
|
|
462
|
+
void,
|
|
463
|
+
attrs,
|
|
464
|
+
text,
|
|
465
|
+
content,
|
|
466
|
+
} => (tag, *void, attrs, text, *content),
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
em.push('<');
|
|
470
|
+
em.push_str(tag);
|
|
471
|
+
for (name, parts) in attrs {
|
|
472
|
+
if let Some(value) = resolve_parts(parts, |r| xml_ref_attr(txn, t, r)) {
|
|
473
|
+
em.push(' ');
|
|
474
|
+
em.push_str(name);
|
|
475
|
+
em.push_str("=\"");
|
|
476
|
+
em.push_str(&escape_attr(&value));
|
|
477
|
+
em.push('\"');
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
em.push('>');
|
|
481
|
+
if void {
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if let Some(text) = text {
|
|
485
|
+
if let Some(value) = resolve_parts(text, |r| xml_ref_attr(txn, t, r)) {
|
|
486
|
+
em.push_str(&escape_text(&value));
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
match content {
|
|
490
|
+
Content::Inline => {
|
|
491
|
+
render_inline(txn, t, 0, true, em, rules);
|
|
492
|
+
em.push_str("</");
|
|
493
|
+
em.push_str(tag);
|
|
494
|
+
em.push('>');
|
|
495
|
+
}
|
|
496
|
+
Content::Blocks => {
|
|
497
|
+
render_inline(txn, t, 0, false, em, rules);
|
|
498
|
+
stack.push(Work::CloseOwned(format!("</{tag}>")));
|
|
499
|
+
if depth < MAX_BLOCK_DEPTH {
|
|
500
|
+
for child in block_children(txn, t).into_iter().rev() {
|
|
501
|
+
stack.push(Work::Open(child, depth + 1));
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
Content::None => {
|
|
506
|
+
em.push_str("</");
|
|
507
|
+
em.push_str(tag);
|
|
508
|
+
em.push('>');
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
274
513
|
/// Defer a container's block children onto the stack, with its closing tag
|
|
275
514
|
/// below them, so the children render in order and the tag closes after. Past
|
|
276
515
|
/// `MAX_BLOCK_DEPTH` the children are dropped (the container still closes, so
|
|
@@ -305,8 +544,9 @@ fn open_listitem<T: ReadTxn>(
|
|
|
305
544
|
txn: &T,
|
|
306
545
|
t: &XmlTextRef,
|
|
307
546
|
depth: usize,
|
|
308
|
-
|
|
547
|
+
em: &mut Emitter,
|
|
309
548
|
stack: &mut Vec<Work>,
|
|
549
|
+
rules: &Rules,
|
|
310
550
|
) {
|
|
311
551
|
let value = match t.get_attribute(txn, "__value") {
|
|
312
552
|
Some(Out::Any(Any::Number(n))) => n as i64,
|
|
@@ -317,26 +557,20 @@ fn open_listitem<T: ReadTxn>(
|
|
|
317
557
|
Some(Out::Any(Any::Bool(b))) => Some(b),
|
|
318
558
|
_ => None,
|
|
319
559
|
};
|
|
320
|
-
let has_nested_list = block_children(txn, t)
|
|
321
|
-
.iter()
|
|
322
|
-
.any(|c| node_type(txn, c) == "list");
|
|
323
560
|
|
|
324
|
-
|
|
561
|
+
em.push_str("<li");
|
|
325
562
|
if let Some(c) = checked {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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\"");
|
|
563
|
+
em.push_str(" aria-checked=\"");
|
|
564
|
+
em.push_str(if c { "true" } else { "false" });
|
|
565
|
+
em.push('"');
|
|
335
566
|
}
|
|
336
|
-
|
|
567
|
+
em.push_str(" value=\"");
|
|
568
|
+
em.push_str(&value.to_string());
|
|
569
|
+
em.push('"');
|
|
570
|
+
em.push('>');
|
|
337
571
|
// Inline content first, then any nested list blocks (Lexical stores the
|
|
338
572
|
// nested list as a child of the item).
|
|
339
|
-
|
|
573
|
+
render_inline(txn, t, 0, true, em, rules);
|
|
340
574
|
push_block_children(txn, t, depth, "</li>", true, stack);
|
|
341
575
|
}
|
|
342
576
|
|
|
@@ -358,14 +592,44 @@ fn is_inline_type(ty: &str) -> bool {
|
|
|
358
592
|
matches!(ty, "link" | "autolink")
|
|
359
593
|
}
|
|
360
594
|
|
|
595
|
+
/// The `__type` of every element/block child of a block, in document order —
|
|
596
|
+
/// handed to callback rules as `node.child_types` (a gallery's image count,
|
|
597
|
+
/// a list item's nested list). Text runs and metadata maps are not children.
|
|
598
|
+
fn text_child_types<T: ReadTxn>(txn: &T, t: &XmlTextRef) -> Vec<String> {
|
|
599
|
+
let mut out = Vec::new();
|
|
600
|
+
for d in t.diff(txn, YChange::identity) {
|
|
601
|
+
match d.insert {
|
|
602
|
+
Out::YXmlText(child) => out.push(node_type(txn, &child)),
|
|
603
|
+
Out::YXmlElement(child) => out.push(elem_type(txn, &child)),
|
|
604
|
+
_ => {}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
out
|
|
608
|
+
}
|
|
609
|
+
|
|
361
610
|
/// Render a block's inline content: formatted text runs, linebreaks, tabs,
|
|
362
|
-
/// links, and inline decorators.
|
|
363
|
-
///
|
|
364
|
-
///
|
|
365
|
-
///
|
|
366
|
-
///
|
|
367
|
-
|
|
368
|
-
|
|
611
|
+
/// links, and inline decorators. A registered rule wins over the built-in
|
|
612
|
+
/// link arm. Nested block children are NOT rendered here (the block
|
|
613
|
+
/// renderers handle them), so a list item's text doesn't duplicate its
|
|
614
|
+
/// nested list. `depth` counts inline-link nesting only (a link's body is
|
|
615
|
+
/// itself inline content); past `MAX_INLINE_DEPTH` a link renders as flat
|
|
616
|
+
/// text, capping the recursion.
|
|
617
|
+
///
|
|
618
|
+
/// `ruled_inline` says whether a non-core `Y.XmlText` child with a
|
|
619
|
+
/// registered rule renders here as a custom inline node. Callers that walk
|
|
620
|
+
/// their block children pass false — for them every non-link `Y.XmlText`
|
|
621
|
+
/// child renders as a block, and rendering it here too would double it.
|
|
622
|
+
/// Callers that don't (the leaf text blocks, list items, inline-content
|
|
623
|
+
/// rules, link bodies) pass true: for them such a child would otherwise be
|
|
624
|
+
/// dropped.
|
|
625
|
+
fn render_inline<T: ReadTxn>(
|
|
626
|
+
txn: &T,
|
|
627
|
+
t: &XmlTextRef,
|
|
628
|
+
depth: usize,
|
|
629
|
+
ruled_inline: bool,
|
|
630
|
+
em: &mut Emitter,
|
|
631
|
+
rules: &Rules,
|
|
632
|
+
) {
|
|
369
633
|
// Format carries from the metadata Map that precedes each run. Lexxy always
|
|
370
634
|
// emits one Map immediately before its run, so this is exact; a run with no
|
|
371
635
|
// preceding Map would inherit the previous run's format (wrong, but only
|
|
@@ -381,7 +645,7 @@ fn render_inline<T: ReadTxn>(txn: &T, t: &XmlTextRef, depth: usize) -> String {
|
|
|
381
645
|
_ => String::new(),
|
|
382
646
|
};
|
|
383
647
|
match ty.as_str() {
|
|
384
|
-
"linebreak" =>
|
|
648
|
+
"linebreak" => em.push_str("<br>"),
|
|
385
649
|
"tab" => {
|
|
386
650
|
is_tab = true;
|
|
387
651
|
format = 0;
|
|
@@ -406,24 +670,30 @@ fn render_inline<T: ReadTxn>(txn: &T, t: &XmlTextRef, depth: usize) -> String {
|
|
|
406
670
|
Out::Any(Any::String(s)) => {
|
|
407
671
|
if is_tab {
|
|
408
672
|
// Lexxy exports a tab as a literal \t in a span.
|
|
409
|
-
|
|
673
|
+
em.push_str("<span>\t</span>");
|
|
410
674
|
is_tab = false;
|
|
411
675
|
} else {
|
|
412
|
-
|
|
676
|
+
em.push_str(&render_run(&s, format, &style));
|
|
413
677
|
}
|
|
414
678
|
}
|
|
415
679
|
Out::YXmlText(child) => {
|
|
416
680
|
let ty = node_type(txn, &child);
|
|
417
681
|
if is_inline_type(&ty) {
|
|
418
|
-
|
|
682
|
+
match rules.nodes.get(ty.as_str()) {
|
|
683
|
+
Some(rule) => render_rule_inline(txn, &child, &ty, rule, depth, em, rules),
|
|
684
|
+
None => render_link(txn, &child, depth, em, rules),
|
|
685
|
+
}
|
|
686
|
+
} else if ruled_inline && !is_builtin(&ty) {
|
|
687
|
+
if let Some(rule) = rules.nodes.get(ty.as_str()) {
|
|
688
|
+
render_rule_inline(txn, &child, &ty, rule, depth, em, rules);
|
|
689
|
+
}
|
|
419
690
|
}
|
|
420
691
|
// Nested blocks are rendered by their parent block's renderer.
|
|
421
692
|
}
|
|
422
|
-
Out::YXmlElement(e) =>
|
|
693
|
+
Out::YXmlElement(e) => render_decorator(txn, &e, em, rules),
|
|
423
694
|
_ => {}
|
|
424
695
|
}
|
|
425
696
|
}
|
|
426
|
-
out
|
|
427
697
|
}
|
|
428
698
|
|
|
429
699
|
/// One text run with Lexical's format bitmask, as Lexxy's exporter wraps it.
|
|
@@ -512,146 +782,168 @@ fn lexxy_style(style: &str) -> Option<String> {
|
|
|
512
782
|
|
|
513
783
|
/// `link` / `autolink`: Lexxy's sanitize keeps only `href` and `title`
|
|
514
784
|
/// (`target`/`rel` are stored in the doc but stripped from exported HTML).
|
|
515
|
-
fn render_link<T: ReadTxn>(txn: &T, t: &XmlTextRef, depth: usize,
|
|
516
|
-
|
|
785
|
+
fn render_link<T: ReadTxn>(txn: &T, t: &XmlTextRef, depth: usize, em: &mut Emitter, rules: &Rules) {
|
|
786
|
+
em.push_str("<a");
|
|
517
787
|
if let Some(url) = str_attr(txn, t, "__url") {
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
788
|
+
em.push_str(" href=\"");
|
|
789
|
+
em.push_str(&escape_attr(&url));
|
|
790
|
+
em.push('"');
|
|
521
791
|
}
|
|
522
792
|
if let Some(title) = str_attr(txn, t, "__title").filter(|s| !s.is_empty()) {
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
793
|
+
em.push_str(" title=\"");
|
|
794
|
+
em.push_str(&escape_attr(&title));
|
|
795
|
+
em.push('"');
|
|
526
796
|
}
|
|
527
|
-
|
|
797
|
+
em.push('>');
|
|
528
798
|
if depth < MAX_INLINE_DEPTH {
|
|
529
|
-
|
|
799
|
+
render_inline(txn, t, depth + 1, true, em, rules);
|
|
530
800
|
} else {
|
|
531
801
|
// A link chain nested past the cap (only crafted input reaches here):
|
|
532
802
|
// keep its text, drop any further link structure.
|
|
533
|
-
|
|
803
|
+
em.push_str(&escape_text(&t.get_string(txn)));
|
|
534
804
|
}
|
|
535
|
-
|
|
805
|
+
em.push_str("</a>");
|
|
536
806
|
}
|
|
537
807
|
|
|
538
|
-
///
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
808
|
+
/// A rule node in inline position (a `Y.XmlText` child inside a text block):
|
|
809
|
+
/// an overridden link, or a custom inline node the core schema doesn't know.
|
|
810
|
+
/// Its body is inline content, so a blocks content slot behaves like inline
|
|
811
|
+
/// here, and `depth` carries the inline nesting cap through.
|
|
812
|
+
fn render_rule_inline<T: ReadTxn>(
|
|
813
|
+
txn: &T,
|
|
814
|
+
t: &XmlTextRef,
|
|
815
|
+
ty: &str,
|
|
816
|
+
rule: &NodeRule,
|
|
817
|
+
depth: usize,
|
|
818
|
+
em: &mut Emitter,
|
|
819
|
+
rules: &Rules,
|
|
820
|
+
) {
|
|
821
|
+
let (tag, void, attrs, text, content) = match rule {
|
|
822
|
+
NodeRule::Callback { content } => {
|
|
823
|
+
em.begin_frame();
|
|
824
|
+
if *content != Content::None && depth < MAX_INLINE_DEPTH {
|
|
825
|
+
render_inline(txn, t, depth + 1, true, em, rules);
|
|
826
|
+
}
|
|
827
|
+
let captured = em.end_frame();
|
|
828
|
+
em.emit_deferred(
|
|
829
|
+
ty.to_string(),
|
|
830
|
+
xml_attrs_json(txn, t),
|
|
831
|
+
text_child_types(txn, t),
|
|
832
|
+
captured,
|
|
833
|
+
);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
NodeRule::Declarative {
|
|
837
|
+
tag,
|
|
838
|
+
void,
|
|
839
|
+
attrs,
|
|
840
|
+
text,
|
|
841
|
+
content,
|
|
842
|
+
} => (tag, *void, attrs, text, *content),
|
|
843
|
+
};
|
|
844
|
+
em.push('<');
|
|
845
|
+
em.push_str(tag);
|
|
846
|
+
for (name, parts) in attrs {
|
|
847
|
+
if let Some(value) = resolve_parts(parts, |r| xml_ref_attr(txn, t, r)) {
|
|
848
|
+
em.push(' ');
|
|
849
|
+
em.push_str(name);
|
|
850
|
+
em.push_str("=\"");
|
|
851
|
+
em.push_str(&escape_attr(&value));
|
|
852
|
+
em.push('\"');
|
|
853
|
+
}
|
|
545
854
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
_ => {}
|
|
855
|
+
em.push('>');
|
|
856
|
+
if void {
|
|
857
|
+
return;
|
|
555
858
|
}
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
Some(Out::Any(Any::String(s))) => s.to_string(),
|
|
561
|
-
_ => String::new(),
|
|
859
|
+
if let Some(text) = text {
|
|
860
|
+
if let Some(value) = resolve_parts(text, |r| xml_ref_attr(txn, t, r)) {
|
|
861
|
+
em.push_str(&escape_text(&value));
|
|
862
|
+
}
|
|
562
863
|
}
|
|
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,
|
|
864
|
+
if content != Content::None && depth < MAX_INLINE_DEPTH {
|
|
865
|
+
render_inline(txn, t, depth + 1, true, em, rules);
|
|
569
866
|
}
|
|
867
|
+
em.push_str("</");
|
|
868
|
+
em.push_str(tag);
|
|
869
|
+
em.push('>');
|
|
570
870
|
}
|
|
571
871
|
|
|
572
|
-
///
|
|
573
|
-
///
|
|
574
|
-
///
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
}
|
|
586
|
-
Some(Out::Any(Any::BigInt(n))) => Some(format!("{n}")),
|
|
587
|
-
_ => None,
|
|
872
|
+
/// Decorator elements (root-level or inline): core Lexical's horizontal rule,
|
|
873
|
+
/// plus whatever the registered rules cover — Lexxy's attachments arrive that
|
|
874
|
+
/// way. Rules win here too, so custom decorators render or defer.
|
|
875
|
+
fn render_decorator<T: ReadTxn>(txn: &T, e: &XmlElementRef, em: &mut Emitter, rules: &Rules) {
|
|
876
|
+
let ty = elem_type(txn, e);
|
|
877
|
+
if let Some(rule) = rules.nodes.get(ty.as_str()) {
|
|
878
|
+
render_rule_element(txn, e, &ty, rule, em);
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
// Core Lexical's only decorator; unknown decorators render nothing
|
|
882
|
+
// (nothing extractable without a rule).
|
|
883
|
+
if ty == "horizontalrule" {
|
|
884
|
+
em.push_str("<hr>");
|
|
588
885
|
}
|
|
589
886
|
}
|
|
590
887
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
888
|
+
/// A decorator element rendered through a registered rule. Decorators are
|
|
889
|
+
/// childless in practice, so declarative rules render tag + attrs + template
|
|
890
|
+
/// text (content slots are ignored) and callback rules defer with empty
|
|
891
|
+
/// content.
|
|
892
|
+
fn render_rule_element<T: ReadTxn>(
|
|
893
|
+
txn: &T,
|
|
894
|
+
e: &XmlElementRef,
|
|
895
|
+
ty: &str,
|
|
896
|
+
rule: &NodeRule,
|
|
897
|
+
em: &mut Emitter,
|
|
898
|
+
) {
|
|
899
|
+
let (tag, void, attrs, text) = match rule {
|
|
900
|
+
NodeRule::Callback { .. } => {
|
|
901
|
+
em.emit_deferred(
|
|
902
|
+
ty.to_string(),
|
|
903
|
+
xml_attrs_json(txn, e),
|
|
904
|
+
Vec::new(),
|
|
905
|
+
Vec::new(),
|
|
906
|
+
);
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
NodeRule::Declarative {
|
|
910
|
+
tag,
|
|
911
|
+
void,
|
|
912
|
+
attrs,
|
|
913
|
+
text,
|
|
914
|
+
..
|
|
915
|
+
} => (tag, *void, attrs, text),
|
|
916
|
+
};
|
|
917
|
+
em.push('<');
|
|
918
|
+
em.push_str(tag);
|
|
919
|
+
for (name, parts) in attrs {
|
|
920
|
+
if let Some(value) = resolve_parts(parts, |r| xml_ref_attr(txn, e, r)) {
|
|
921
|
+
em.push(' ');
|
|
922
|
+
em.push_str(name);
|
|
923
|
+
em.push_str("=\"");
|
|
924
|
+
em.push_str(&escape_attr(&value));
|
|
925
|
+
em.push('\"');
|
|
926
|
+
}
|
|
630
927
|
}
|
|
631
|
-
|
|
632
|
-
|
|
928
|
+
em.push('>');
|
|
929
|
+
if void {
|
|
930
|
+
return;
|
|
633
931
|
}
|
|
634
|
-
if let Some(
|
|
635
|
-
|
|
932
|
+
if let Some(text) = text {
|
|
933
|
+
if let Some(value) = resolve_parts(text, |r| xml_ref_attr(txn, e, r)) {
|
|
934
|
+
em.push_str(&escape_text(&value));
|
|
935
|
+
}
|
|
636
936
|
}
|
|
637
|
-
|
|
638
|
-
|
|
937
|
+
em.push_str("</");
|
|
938
|
+
em.push_str(tag);
|
|
939
|
+
em.push('>');
|
|
639
940
|
}
|
|
640
941
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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);
|
|
942
|
+
fn elem_type<T: ReadTxn>(txn: &T, e: &XmlElementRef) -> String {
|
|
943
|
+
match e.get_attribute(txn, "__type") {
|
|
944
|
+
Some(Out::Any(Any::String(s))) => s.to_string(),
|
|
945
|
+
_ => String::new(),
|
|
653
946
|
}
|
|
654
|
-
out.push_str("></action-text-attachment>");
|
|
655
947
|
}
|
|
656
948
|
|
|
657
949
|
/// Text-content escaping, matching what the browser's serializer emits:
|
|
@@ -673,15 +965,19 @@ mod tests {
|
|
|
673
965
|
use yrs::updates::decoder::Decode;
|
|
674
966
|
use yrs::{Doc, Transact, Update};
|
|
675
967
|
|
|
676
|
-
///
|
|
677
|
-
///
|
|
968
|
+
/// Core rendering of the captured full-schema document, pinned as a
|
|
969
|
+
/// golden (`.core.html`). Stock Lexical has no canonical serializer to
|
|
970
|
+
/// capture against, so this is a self-pinned regression guard; the
|
|
971
|
+
/// external truth — byte parity with a real Lexxy editor's value — is
|
|
972
|
+
/// held at the Ruby layer, where `Y::Lexxy` completes the schema.
|
|
973
|
+
/// The pair was captured together from one live
|
|
678
974
|
/// editor session: `lexxy_full.bin` is the synced Yjs state, `lexxy_full.html`
|
|
679
975
|
/// is `lexxy-editor.value` for the same document. It covers every block and
|
|
680
976
|
/// format the schema map handles.
|
|
681
977
|
#[test]
|
|
682
|
-
fn
|
|
978
|
+
fn core_rendering_of_the_full_fixture_is_pinned() {
|
|
683
979
|
let bytes = include_bytes!("fixtures/lexxy_full.bin");
|
|
684
|
-
let expected = include_str!("fixtures/lexxy_full.html");
|
|
980
|
+
let expected = include_str!("fixtures/lexxy_full.core.html");
|
|
685
981
|
let doc = Doc::new();
|
|
686
982
|
doc.transact_mut()
|
|
687
983
|
.apply_update(Update::decode_v1(bytes).unwrap())
|
|
@@ -695,13 +991,12 @@ mod tests {
|
|
|
695
991
|
/// cells, five-level mixed lists, formatted links in headings, the full
|
|
696
992
|
/// format stack, unicode and escaping edge cases, whitespace-only
|
|
697
993
|
/// paragraphs. The load-bearing structures are probed by name below.
|
|
698
|
-
///
|
|
699
|
-
///
|
|
700
|
-
/// them either.
|
|
994
|
+
/// Lexxy's sanitized export drops colSpan/rowSpan, so matching it
|
|
995
|
+
/// byte-for-byte means not emitting them either.
|
|
701
996
|
#[test]
|
|
702
|
-
fn
|
|
997
|
+
fn core_rendering_of_the_torture_fixture_is_pinned() {
|
|
703
998
|
let bytes = include_bytes!("fixtures/lexxy_torture.bin");
|
|
704
|
-
let expected = include_str!("fixtures/lexxy_torture.html");
|
|
999
|
+
let expected = include_str!("fixtures/lexxy_torture.core.html");
|
|
705
1000
|
let doc = Doc::new();
|
|
706
1001
|
doc.transact_mut()
|
|
707
1002
|
.apply_update(Update::decode_v1(bytes).unwrap())
|
|
@@ -711,16 +1006,13 @@ mod tests {
|
|
|
711
1006
|
let html = render(&txn, &frag).unwrap();
|
|
712
1007
|
assert_eq!(html, expected.trim_end());
|
|
713
1008
|
|
|
714
|
-
//
|
|
715
|
-
// a regression fails with the
|
|
1009
|
+
// The golden already proves these; name the load-bearing core
|
|
1010
|
+
// structures so a regression fails with the one it broke. (The
|
|
1011
|
+
// fixture's Lexxy-only nodes — attachments, its code type — are
|
|
1012
|
+
// covered at the Ruby layer, where the Lexxy rules render them.)
|
|
716
1013
|
for (what, probe) in [
|
|
717
1014
|
("list nested in a table cell", "<td><ul><li"),
|
|
718
1015
|
("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
1016
|
(
|
|
725
1017
|
"five-level mixed list nesting",
|
|
726
1018
|
"<ol><li value=\"1\"><s><i><strong>Level five</strong></i></s></li></ol>",
|
|
@@ -738,10 +1030,6 @@ mod tests {
|
|
|
738
1030
|
"<strong>a && b < c > d \"q\" '&amp;'</strong>",
|
|
739
1031
|
),
|
|
740
1032
|
("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
1033
|
(
|
|
746
1034
|
"whitespace-only paragraphs",
|
|
747
1035
|
"<p><span>\t</span></p><p><br></p><p><br></p>",
|
|
@@ -823,13 +1111,15 @@ mod tests {
|
|
|
823
1111
|
assert!(html.contains("<li value=\"1\">item</li>"), "{html}");
|
|
824
1112
|
}
|
|
825
1113
|
|
|
826
|
-
///
|
|
827
|
-
///
|
|
828
|
-
/// the
|
|
1114
|
+
/// Lexxy-only nodes (this fixture is a gallery of attachments between
|
|
1115
|
+
/// two paragraphs) are unknown to the core schema and degrade readably —
|
|
1116
|
+
/// the paragraphs survive, the gallery renders nothing rather than
|
|
1117
|
+
/// garbage. Y::Lexxy's rules render it fully; the Ruby fixture tests pin
|
|
1118
|
+
/// that byte for byte.
|
|
829
1119
|
#[test]
|
|
830
|
-
fn
|
|
1120
|
+
fn core_degrades_lexxy_only_nodes_readably() {
|
|
831
1121
|
let bytes = include_bytes!("fixtures/lexxy_gallery.bin");
|
|
832
|
-
let expected = include_str!("fixtures/lexxy_gallery.html");
|
|
1122
|
+
let expected = include_str!("fixtures/lexxy_gallery.core.html");
|
|
833
1123
|
let doc = Doc::new();
|
|
834
1124
|
doc.transact_mut()
|
|
835
1125
|
.apply_update(Update::decode_v1(bytes).unwrap())
|
|
@@ -838,8 +1128,7 @@ mod tests {
|
|
|
838
1128
|
let frag = txn.get_xml_fragment("root").unwrap();
|
|
839
1129
|
let html = render(&txn, &frag).unwrap();
|
|
840
1130
|
assert_eq!(html, expected.trim_end());
|
|
841
|
-
assert!(html.contains("<
|
|
842
|
-
assert_eq!(html.matches("<action-text-attachment").count(), 3);
|
|
1131
|
+
assert!(html.contains("<p>Before gallery</p>"));
|
|
843
1132
|
}
|
|
844
1133
|
|
|
845
1134
|
#[test]
|
|
@@ -982,6 +1271,44 @@ mod tests {
|
|
|
982
1271
|
assert_eq!(html.matches("<a").count(), html.matches("</a>").count());
|
|
983
1272
|
}
|
|
984
1273
|
|
|
1274
|
+
/// Rules reach inline position too: a rule for `link` overrides the
|
|
1275
|
+
/// built-in `<a>`, and a custom inline node (a `Y.XmlText` type the
|
|
1276
|
+
/// core schema doesn't know) renders through its rule instead of being
|
|
1277
|
+
/// dropped.
|
|
1278
|
+
#[test]
|
|
1279
|
+
fn rules_render_inline_nodes_and_override_links() {
|
|
1280
|
+
use yrs::{XmlFragment, XmlTextPrelim};
|
|
1281
|
+
let rules = Rules::parse(
|
|
1282
|
+
r#"{ "nodes": {
|
|
1283
|
+
"link": { "tag": "a", "attrs": [["class", [{"lit": "app-link"}]],
|
|
1284
|
+
["href", [{"ref": "url"}]]] },
|
|
1285
|
+
"keyword": { "tag": "kbd", "attrs": [["data-kind", [{"ref": "kind"}]]] } } }"#,
|
|
1286
|
+
)
|
|
1287
|
+
.unwrap();
|
|
1288
|
+
let doc = Doc::new();
|
|
1289
|
+
let root = doc.get_or_insert_xml_fragment("root");
|
|
1290
|
+
{
|
|
1291
|
+
let mut txn = doc.transact_mut();
|
|
1292
|
+
let p = root.push_back(&mut txn, XmlTextPrelim::new(""));
|
|
1293
|
+
p.insert_attribute(&mut txn, "__type", "paragraph");
|
|
1294
|
+
let link = p.insert_embed(&mut txn, 0, XmlTextPrelim::new("site"));
|
|
1295
|
+
link.insert_attribute(&mut txn, "__type", "link");
|
|
1296
|
+
link.insert_attribute(&mut txn, "__url", "https://x.example");
|
|
1297
|
+
let kw = p.insert_embed(&mut txn, 1, XmlTextPrelim::new("crdt"));
|
|
1298
|
+
kw.insert_attribute(&mut txn, "__type", "keyword");
|
|
1299
|
+
kw.insert_attribute(&mut txn, "__kind", "term");
|
|
1300
|
+
}
|
|
1301
|
+
let txn = doc.transact();
|
|
1302
|
+
let frag = txn.get_xml_fragment("root").unwrap();
|
|
1303
|
+
let segs = render_segments(&txn, &frag, &rules).unwrap();
|
|
1304
|
+
let html = yrs_html_core::flatten(segs).into_html().unwrap();
|
|
1305
|
+
assert_eq!(
|
|
1306
|
+
html,
|
|
1307
|
+
"<p><a class=\"app-link\" href=\"https://x.example\">site</a>\
|
|
1308
|
+
<kbd data-kind=\"term\">crdt</kbd></p>"
|
|
1309
|
+
);
|
|
1310
|
+
}
|
|
1311
|
+
|
|
985
1312
|
#[test]
|
|
986
1313
|
fn escaping_matches_the_browser_serializer() {
|
|
987
1314
|
assert_eq!(escape_text(r#"<a & "b">"#), r#"<a & "b">"#);
|