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
|
@@ -0,0 +1,1369 @@
|
|
|
1
|
+
//! Native HTML rendering of ProseMirror/Tiptap documents from the yrs collab
|
|
2
|
+
//! structure — no Node process, no headless editor.
|
|
3
|
+
//!
|
|
4
|
+
//! The y-prosemirror binding stores a document in a Y.XmlFragment: block nodes
|
|
5
|
+
//! are Y.XmlElement (the tag is the node type, its attributes are the node
|
|
6
|
+
//! attrs), and text is Y.XmlText whose per-run formatting attributes are the
|
|
7
|
+
//! marks. Node and mark names come from the editor's schema, so this accepts
|
|
8
|
+
//! both spellings in use: Tiptap's camelCase (`bulletList`, `bold`) and the
|
|
9
|
+
//! prosemirror-schema-basic snake_case (`bullet_list`, `strong`).
|
|
10
|
+
//!
|
|
11
|
+
//! This renders **core ProseMirror**: the prosemirror-schema-basic node set
|
|
12
|
+
//! plus the prosemirror-tables family — paragraphs, headings, blockquotes,
|
|
13
|
+
//! code blocks, bullet/ordered lists, images, hard breaks, horizontal rules,
|
|
14
|
+
//! and tables (semantic `<table><tbody>…`, without the `<colgroup>`/
|
|
15
|
+
//! `min-width` styling editor views inject). Tiptap's extension nodes — task
|
|
16
|
+
//! lists, mentions, the details family — live in the Ruby layer as the
|
|
17
|
+
//! `Y::Tiptap` renderer's rule set, built on the same extension API apps use
|
|
18
|
+
//! (`Y::ProseMirror` is the core base class). Output follows
|
|
19
|
+
//! `ueberdosis/tiptap-php`, and with `Y::Tiptap`'s rules it matches Tiptap's
|
|
20
|
+
//! own `getHTML()` byte for byte on the captured fixtures — that guarantee
|
|
21
|
+
//! is held at the Ruby layer; the native tests pin core output as goldens
|
|
22
|
+
//! where a fixture contains Tiptap-only nodes.
|
|
23
|
+
//!
|
|
24
|
+
//! Marks stay native on purpose: mark serialization is text-run machinery
|
|
25
|
+
//! (nesting order, textStyle's CSS, code's exclusivity), which the rule
|
|
26
|
+
//! system can't express. The built-in set covers schema-basic's marks and
|
|
27
|
+
//! Tiptap's — nesting outermost-first: link, a textStyle span, bold, italic,
|
|
28
|
+
//! strike, underline, highlight, then subscript/superscript. `code` excludes
|
|
29
|
+
//! the other formatting marks, so a code run is `<code>` alone — though a
|
|
30
|
+
//! link still wraps it (see `render_run`).
|
|
31
|
+
//!
|
|
32
|
+
//! Custom nodes and marks: apps register rules by node type and mark name
|
|
33
|
+
//! (see `render_rules`). A node rule is consulted before the built-in arms, so
|
|
34
|
+
//! it can extend the schema or override a built-in; a mark rule claims its
|
|
35
|
+
//! mark from the built-in wraps and wraps outside everything, link included.
|
|
36
|
+
//! Declarative rules render here; callback rules emit `Segment::Deferred` for
|
|
37
|
+
//! the caller to fill in after the render.
|
|
38
|
+
|
|
39
|
+
// README examples are living code: compile-checked on every cargo test.
|
|
40
|
+
#[cfg(doctest)]
|
|
41
|
+
#[doc = include_str!("../README.md")]
|
|
42
|
+
mod readme_examples {}
|
|
43
|
+
|
|
44
|
+
// The full rules surface, re-exported: depend on this crate alone;
|
|
45
|
+
// yrs-html-core is an internal implementation crate.
|
|
46
|
+
pub use yrs_html_core::*;
|
|
47
|
+
use yrs::types::text::YChange;
|
|
48
|
+
use yrs::types::Attrs;
|
|
49
|
+
use yrs::{
|
|
50
|
+
Any, GetString, Out, ReadTxn, Text, Xml, XmlElementRef, XmlFragment, XmlFragmentRef, XmlOut,
|
|
51
|
+
XmlTextRef,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// The block tree is walked on a heap stack, so depth can't overflow the
|
|
55
|
+
// native call stack; this caps the work a pathological document can demand.
|
|
56
|
+
const MAX_DEPTH: usize = 1024;
|
|
57
|
+
|
|
58
|
+
/// A unit of block work on the traversal stack. `Open` renders a node (pushing its
|
|
59
|
+
/// block children as more work); `Close` and `CloseOwned` emit a container's
|
|
60
|
+
/// end tag once its children are done (built-in containers close with fixed
|
|
61
|
+
/// strings; rule containers close with their computed tag). `EndDeferred` seals
|
|
62
|
+
/// a callback node: it pops the emitter frame its children rendered into and
|
|
63
|
+
/// emits the deferred segment.
|
|
64
|
+
enum Work {
|
|
65
|
+
Open(XmlElementRef, usize),
|
|
66
|
+
Close(&'static str),
|
|
67
|
+
CloseOwned(String),
|
|
68
|
+
EndDeferred {
|
|
69
|
+
node_type: String,
|
|
70
|
+
attrs_json: String,
|
|
71
|
+
child_types: Vec<String>,
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// Render a ProseMirror/Tiptap-shaped XML root to segments, or `None` when the
|
|
76
|
+
/// root isn't ProseMirror-shaped. ProseMirror blocks are plain Y.XmlElement
|
|
77
|
+
/// tags; a Lexical root (Y.XmlText children carrying a `__type`) is a
|
|
78
|
+
/// different schema and returns `None` instead of a garbled render.
|
|
79
|
+
pub fn render_segments<T: ReadTxn>(
|
|
80
|
+
txn: &T,
|
|
81
|
+
fragment: &XmlFragmentRef,
|
|
82
|
+
rules: &Rules,
|
|
83
|
+
) -> Option<Vec<Segment>> {
|
|
84
|
+
if !is_prosemirror_shaped(txn, fragment) {
|
|
85
|
+
return None;
|
|
86
|
+
}
|
|
87
|
+
let mut em = Emitter::new();
|
|
88
|
+
for node in fragment.children(txn) {
|
|
89
|
+
match node {
|
|
90
|
+
XmlOut::Element(e) => render_block_tree(txn, &e, &mut em, rules),
|
|
91
|
+
// y-prosemirror never writes a bare text run at the root, but a
|
|
92
|
+
// crafted doc can; render it rather than drop it.
|
|
93
|
+
XmlOut::Text(t) => render_text_runs(txn, &t, &mut em, rules),
|
|
94
|
+
// Fragments can't nest as children in yrs, so this arm shouldn't
|
|
95
|
+
// be reachable; it exists because the match must be exhaustive,
|
|
96
|
+
// and escaping the text is the safe degradation.
|
|
97
|
+
XmlOut::Fragment(f) => em.push_str(&escape_text(&f.get_string(txn))),
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
Some(em.into_segments())
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/// Rule-free rendering to a plain string — the simplest way to use this
|
|
104
|
+
/// crate standalone, and the fixture-parity surface the tests pin. With no
|
|
105
|
+
/// callback rules, segments always flatten.
|
|
106
|
+
pub fn render<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> Option<String> {
|
|
107
|
+
render_segments(txn, fragment, &Rules::empty()).map(|segs| {
|
|
108
|
+
yrs_html_core::flatten(segs)
|
|
109
|
+
.into_html()
|
|
110
|
+
.expect("no callback rules registered")
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// The node types the built-in arms cover (both Tiptap and schema-basic
|
|
115
|
+
/// spellings). Everything else needs a rule.
|
|
116
|
+
pub fn is_builtin(ty: &str) -> bool {
|
|
117
|
+
matches!(
|
|
118
|
+
ty,
|
|
119
|
+
"paragraph"
|
|
120
|
+
| "heading"
|
|
121
|
+
| "codeBlock"
|
|
122
|
+
| "code_block"
|
|
123
|
+
| "blockquote"
|
|
124
|
+
| "bulletList"
|
|
125
|
+
| "bullet_list"
|
|
126
|
+
| "orderedList"
|
|
127
|
+
| "ordered_list"
|
|
128
|
+
| "listItem"
|
|
129
|
+
| "list_item"
|
|
130
|
+
| "table"
|
|
131
|
+
| "tableRow"
|
|
132
|
+
| "table_row"
|
|
133
|
+
| "tableHeader"
|
|
134
|
+
| "table_header"
|
|
135
|
+
| "tableCell"
|
|
136
|
+
| "table_cell"
|
|
137
|
+
| "horizontalRule"
|
|
138
|
+
| "horizontal_rule"
|
|
139
|
+
| "image"
|
|
140
|
+
| "hardBreak"
|
|
141
|
+
| "hard_break"
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/// Walk the document and record what each node type actually looks like —
|
|
146
|
+
/// the discovery aid behind `Y::ProseMirror#node_types`. It records facts:
|
|
147
|
+
/// counts, attribute names, child element types, and whether text runs were
|
|
148
|
+
/// seen. The inline-vs-blocks distinction itself is editor-schema knowledge
|
|
149
|
+
/// the storage never serializes, so the rule author reads it off the child
|
|
150
|
+
/// types and text flags.
|
|
151
|
+
pub fn collect_node_types<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> Option<TypeMap> {
|
|
152
|
+
if !is_prosemirror_shaped(txn, fragment) {
|
|
153
|
+
return None;
|
|
154
|
+
}
|
|
155
|
+
let mut map = TypeMap::new();
|
|
156
|
+
for node in fragment.children(txn) {
|
|
157
|
+
if let XmlOut::Element(e) = node {
|
|
158
|
+
observe(txn, &e, &mut map, 0);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
Some(map)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
fn observe<T: ReadTxn>(txn: &T, e: &XmlElementRef, map: &mut TypeMap, depth: usize) {
|
|
165
|
+
let ty = e.tag().to_string();
|
|
166
|
+
let info = map.entry(ty.clone()).or_default();
|
|
167
|
+
info.count += 1;
|
|
168
|
+
for (key, _) in e.attributes(txn) {
|
|
169
|
+
info.attrs.insert(key.to_string());
|
|
170
|
+
}
|
|
171
|
+
if depth >= MAX_DEPTH {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
for child in e.children(txn) {
|
|
175
|
+
match child {
|
|
176
|
+
XmlOut::Text(_) => map.get_mut(&ty).expect("just inserted").text = true,
|
|
177
|
+
XmlOut::Element(el) => {
|
|
178
|
+
map.get_mut(&ty)
|
|
179
|
+
.expect("just inserted")
|
|
180
|
+
.children
|
|
181
|
+
.insert(el.tag().to_string());
|
|
182
|
+
observe(txn, &el, map, depth + 1);
|
|
183
|
+
}
|
|
184
|
+
XmlOut::Fragment(_) => {}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/// A root is ProseMirror-shaped when it's empty or its first child is a block
|
|
190
|
+
/// element with no `__type` (Lexical stamps `__type` on every node; ProseMirror
|
|
191
|
+
/// uses the node type as the element tag).
|
|
192
|
+
fn is_prosemirror_shaped<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> bool {
|
|
193
|
+
match fragment.children(txn).next() {
|
|
194
|
+
Some(XmlOut::Element(e)) => e.get_attribute(txn, "__type").is_none(),
|
|
195
|
+
Some(_) => false, // Lexical stores blocks as XmlText
|
|
196
|
+
None => true, // empty document
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/// Walk one top-level block and everything under it on a heap stack.
|
|
201
|
+
fn render_block_tree<T: ReadTxn>(txn: &T, root: &XmlElementRef, em: &mut Emitter, rules: &Rules) {
|
|
202
|
+
let mut stack: Vec<Work> = vec![Work::Open(root.clone(), 0)];
|
|
203
|
+
while let Some(work) = stack.pop() {
|
|
204
|
+
match work {
|
|
205
|
+
Work::Close(tag) => em.push_str(tag),
|
|
206
|
+
Work::CloseOwned(tag) => em.push_str(&tag),
|
|
207
|
+
Work::EndDeferred {
|
|
208
|
+
node_type,
|
|
209
|
+
attrs_json,
|
|
210
|
+
child_types,
|
|
211
|
+
} => {
|
|
212
|
+
let content = em.end_frame();
|
|
213
|
+
em.emit_deferred(node_type, attrs_json, child_types, content);
|
|
214
|
+
}
|
|
215
|
+
Work::Open(node, depth) => open_block(txn, &node, depth, em, &mut stack, rules),
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/// Render one block. A registered rule wins over the built-in arms (so apps
|
|
221
|
+
/// can extend the schema or override a built-in). Text blocks (paragraph,
|
|
222
|
+
/// heading, code) render in full on the spot; container blocks emit their
|
|
223
|
+
/// opening tag and defer their children (and matching `Close`) to the stack.
|
|
224
|
+
fn open_block<T: ReadTxn>(
|
|
225
|
+
txn: &T,
|
|
226
|
+
e: &XmlElementRef,
|
|
227
|
+
depth: usize,
|
|
228
|
+
em: &mut Emitter,
|
|
229
|
+
stack: &mut Vec<Work>,
|
|
230
|
+
rules: &Rules,
|
|
231
|
+
) {
|
|
232
|
+
if let Some(rule) = rules.nodes.get(e.tag().as_ref()) {
|
|
233
|
+
open_rule_block(txn, e, rule, depth, em, stack, rules);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
match e.tag().as_ref() {
|
|
237
|
+
"paragraph" => {
|
|
238
|
+
em.push_str("<p>");
|
|
239
|
+
render_inline(txn, e, 0, em, rules);
|
|
240
|
+
em.push_str("</p>");
|
|
241
|
+
}
|
|
242
|
+
"heading" => {
|
|
243
|
+
let level = num_attr(txn, e, "level").unwrap_or(1).clamp(1, 6);
|
|
244
|
+
let tag = ['1', '2', '3', '4', '5', '6'][(level - 1) as usize];
|
|
245
|
+
em.push_str("<h");
|
|
246
|
+
em.push(tag);
|
|
247
|
+
em.push('>');
|
|
248
|
+
render_inline(txn, e, 0, em, rules);
|
|
249
|
+
em.push_str("</h");
|
|
250
|
+
em.push(tag);
|
|
251
|
+
em.push('>');
|
|
252
|
+
}
|
|
253
|
+
"codeBlock" | "code_block" => {
|
|
254
|
+
em.push_str("<pre><code");
|
|
255
|
+
if let Some(lang) = str_attr(txn, e, "language").filter(|l| !l.is_empty()) {
|
|
256
|
+
em.push_str(" class=\"language-");
|
|
257
|
+
em.push_str(&escape_attr(&lang));
|
|
258
|
+
em.push('"');
|
|
259
|
+
}
|
|
260
|
+
em.push('>');
|
|
261
|
+
em.push_str(&escape_text(&code_text(txn, e)));
|
|
262
|
+
em.push_str("</code></pre>");
|
|
263
|
+
}
|
|
264
|
+
"blockquote" => open_container(
|
|
265
|
+
txn,
|
|
266
|
+
e,
|
|
267
|
+
depth,
|
|
268
|
+
"<blockquote>",
|
|
269
|
+
"</blockquote>",
|
|
270
|
+
em,
|
|
271
|
+
stack,
|
|
272
|
+
rules,
|
|
273
|
+
),
|
|
274
|
+
"bulletList" | "bullet_list" => {
|
|
275
|
+
open_container(txn, e, depth, "<ul>", "</ul>", em, stack, rules)
|
|
276
|
+
}
|
|
277
|
+
"orderedList" | "ordered_list" => {
|
|
278
|
+
match num_attr(txn, e, "start") {
|
|
279
|
+
Some(start) if start != 1 => {
|
|
280
|
+
em.push_str("<ol start=\"");
|
|
281
|
+
em.push_str(&start.to_string());
|
|
282
|
+
em.push_str("\">");
|
|
283
|
+
}
|
|
284
|
+
_ => em.push_str("<ol>"),
|
|
285
|
+
}
|
|
286
|
+
push_block_children(txn, e, depth, "</ol>", em, stack, rules);
|
|
287
|
+
}
|
|
288
|
+
"listItem" | "list_item" => {
|
|
289
|
+
open_container(txn, e, depth, "<li>", "</li>", em, stack, rules)
|
|
290
|
+
}
|
|
291
|
+
"table" => {
|
|
292
|
+
em.push_str("<table><tbody>");
|
|
293
|
+
push_block_children(txn, e, depth, "</tbody></table>", em, stack, rules);
|
|
294
|
+
}
|
|
295
|
+
"tableRow" | "table_row" => {
|
|
296
|
+
open_container(txn, e, depth, "<tr>", "</tr>", em, stack, rules)
|
|
297
|
+
}
|
|
298
|
+
"tableHeader" | "table_header" => open_cell(txn, e, depth, "th", "</th>", em, stack, rules),
|
|
299
|
+
"tableCell" | "table_cell" => open_cell(txn, e, depth, "td", "</td>", em, stack, rules),
|
|
300
|
+
"horizontalRule" | "horizontal_rule" => em.push_str("<hr>"),
|
|
301
|
+
"image" => render_image(txn, e, em),
|
|
302
|
+
"hardBreak" | "hard_break" => em.push_str("<br>"),
|
|
303
|
+
// Unknown block: keep its content rather than dropping it. If it holds
|
|
304
|
+
// child blocks, render them with no wrapper; otherwise treat it as a
|
|
305
|
+
// text block.
|
|
306
|
+
_ => {
|
|
307
|
+
if has_element_child(txn, e) {
|
|
308
|
+
// Renders its direct text runs, then the children, with no
|
|
309
|
+
// invented wrapper tags.
|
|
310
|
+
push_block_children(txn, e, depth, "", em, stack, rules);
|
|
311
|
+
} else {
|
|
312
|
+
em.begin_frame();
|
|
313
|
+
render_inline(txn, e, 0, em, rules);
|
|
314
|
+
let inline = em.end_frame();
|
|
315
|
+
if !inline.is_empty() {
|
|
316
|
+
em.push_str("<p>");
|
|
317
|
+
em.append(inline);
|
|
318
|
+
em.push_str("</p>");
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/// Render a block through a registered rule. Declarative rules emit the tag,
|
|
326
|
+
/// resolved attributes, and template text here; callback rules capture their
|
|
327
|
+
/// children into a frame and defer the markup to the caller.
|
|
328
|
+
#[allow(clippy::too_many_arguments)]
|
|
329
|
+
fn open_rule_block<T: ReadTxn>(
|
|
330
|
+
txn: &T,
|
|
331
|
+
e: &XmlElementRef,
|
|
332
|
+
rule: &NodeRule,
|
|
333
|
+
depth: usize,
|
|
334
|
+
em: &mut Emitter,
|
|
335
|
+
stack: &mut Vec<Work>,
|
|
336
|
+
rules: &Rules,
|
|
337
|
+
) {
|
|
338
|
+
let ty = e.tag().to_string();
|
|
339
|
+
let (tag, void, attrs, text, content) = match rule {
|
|
340
|
+
NodeRule::Callback { content } => {
|
|
341
|
+
em.begin_frame();
|
|
342
|
+
// Children render into the frame; EndDeferred seals it. Blocks go
|
|
343
|
+
// via the stack (pushed above the marker, so they complete
|
|
344
|
+
// first); inline content renders now.
|
|
345
|
+
stack.push(Work::EndDeferred {
|
|
346
|
+
node_type: ty,
|
|
347
|
+
attrs_json: xml_attrs_json(txn, e),
|
|
348
|
+
child_types: element_child_types(txn, e),
|
|
349
|
+
});
|
|
350
|
+
match content {
|
|
351
|
+
Content::Inline => render_inline(txn, e, 0, em, rules),
|
|
352
|
+
Content::Blocks => {
|
|
353
|
+
render_stray_text(txn, e, em, rules);
|
|
354
|
+
if depth < MAX_DEPTH {
|
|
355
|
+
for child in element_children(txn, e).into_iter().rev() {
|
|
356
|
+
stack.push(Work::Open(child, depth + 1));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
Content::None => {}
|
|
361
|
+
}
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
NodeRule::Declarative {
|
|
365
|
+
tag,
|
|
366
|
+
void,
|
|
367
|
+
attrs,
|
|
368
|
+
text,
|
|
369
|
+
content,
|
|
370
|
+
} => (tag, *void, attrs, text, *content),
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
em.push('<');
|
|
374
|
+
em.push_str(tag);
|
|
375
|
+
for (name, parts) in attrs {
|
|
376
|
+
if let Some(value) = resolve_parts(parts, |r| xml_ref_attr(txn, e, r)) {
|
|
377
|
+
em.push(' ');
|
|
378
|
+
em.push_str(name);
|
|
379
|
+
em.push_str("=\"");
|
|
380
|
+
em.push_str(&escape_attr(&value));
|
|
381
|
+
em.push('\"');
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
em.push('>');
|
|
385
|
+
if void {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if let Some(text) = text {
|
|
389
|
+
if let Some(value) = resolve_parts(text, |r| xml_ref_attr(txn, e, r)) {
|
|
390
|
+
em.push_str(&escape_text(&value));
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
match content {
|
|
394
|
+
Content::Inline => {
|
|
395
|
+
render_inline(txn, e, 0, em, rules);
|
|
396
|
+
em.push_str("</");
|
|
397
|
+
em.push_str(tag);
|
|
398
|
+
em.push('>');
|
|
399
|
+
}
|
|
400
|
+
Content::Blocks => {
|
|
401
|
+
render_stray_text(txn, e, em, rules);
|
|
402
|
+
stack.push(Work::CloseOwned(format!("</{tag}>")));
|
|
403
|
+
if depth < MAX_DEPTH {
|
|
404
|
+
for child in element_children(txn, e).into_iter().rev() {
|
|
405
|
+
stack.push(Work::Open(child, depth + 1));
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
Content::None => {
|
|
410
|
+
em.push_str("</");
|
|
411
|
+
em.push_str(tag);
|
|
412
|
+
em.push('>');
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
#[allow(clippy::too_many_arguments)]
|
|
418
|
+
fn open_container<T: ReadTxn>(
|
|
419
|
+
txn: &T,
|
|
420
|
+
e: &XmlElementRef,
|
|
421
|
+
depth: usize,
|
|
422
|
+
open: &str,
|
|
423
|
+
close: &'static str,
|
|
424
|
+
em: &mut Emitter,
|
|
425
|
+
stack: &mut Vec<Work>,
|
|
426
|
+
rules: &Rules,
|
|
427
|
+
) {
|
|
428
|
+
em.push_str(open);
|
|
429
|
+
push_block_children(txn, e, depth, close, em, stack, rules);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/// A table cell: `<th>`/`<td>` carrying colspan/rowspan (default 1, always
|
|
433
|
+
/// emitted, matching Tiptap).
|
|
434
|
+
#[allow(clippy::too_many_arguments)]
|
|
435
|
+
fn open_cell<T: ReadTxn>(
|
|
436
|
+
txn: &T,
|
|
437
|
+
e: &XmlElementRef,
|
|
438
|
+
depth: usize,
|
|
439
|
+
tag: &str,
|
|
440
|
+
close: &'static str,
|
|
441
|
+
em: &mut Emitter,
|
|
442
|
+
stack: &mut Vec<Work>,
|
|
443
|
+
rules: &Rules,
|
|
444
|
+
) {
|
|
445
|
+
em.push('<');
|
|
446
|
+
em.push_str(tag);
|
|
447
|
+
em.push_str(" colspan=\"");
|
|
448
|
+
em.push_str(&num_attr(txn, e, "colspan").unwrap_or(1).to_string());
|
|
449
|
+
em.push_str("\" rowspan=\"");
|
|
450
|
+
em.push_str(&num_attr(txn, e, "rowspan").unwrap_or(1).to_string());
|
|
451
|
+
em.push_str("\">");
|
|
452
|
+
push_block_children(txn, e, depth, close, em, stack, rules);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/// Defer a node's child *elements* onto the stack, closing tag below them, so
|
|
456
|
+
/// they render in order and the tag closes after. Any direct text runs render
|
|
457
|
+
/// first: schema-valid documents never put bare text in a container, but a
|
|
458
|
+
/// crafted one can, and dropping it would lose content. Past `MAX_DEPTH` the
|
|
459
|
+
/// children are dropped but the tag still closes, keeping the output well
|
|
460
|
+
/// formed.
|
|
461
|
+
#[allow(clippy::too_many_arguments)]
|
|
462
|
+
fn push_block_children<T: ReadTxn>(
|
|
463
|
+
txn: &T,
|
|
464
|
+
e: &XmlElementRef,
|
|
465
|
+
depth: usize,
|
|
466
|
+
close: &'static str,
|
|
467
|
+
em: &mut Emitter,
|
|
468
|
+
stack: &mut Vec<Work>,
|
|
469
|
+
rules: &Rules,
|
|
470
|
+
) {
|
|
471
|
+
render_stray_text(txn, e, em, rules);
|
|
472
|
+
stack.push(Work::Close(close));
|
|
473
|
+
if depth >= MAX_DEPTH {
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
for child in element_children(txn, e).into_iter().rev() {
|
|
477
|
+
stack.push(Work::Open(child, depth + 1));
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/// Direct text runs jammed into a container (schema-valid documents have
|
|
482
|
+
/// none); rendered rather than dropped.
|
|
483
|
+
fn render_stray_text<T: ReadTxn>(txn: &T, e: &XmlElementRef, em: &mut Emitter, rules: &Rules) {
|
|
484
|
+
for node in e.children(txn) {
|
|
485
|
+
if let XmlOut::Text(t) = node {
|
|
486
|
+
render_text_runs(txn, &t, em, rules);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
fn element_children<T: ReadTxn>(txn: &T, e: &XmlElementRef) -> Vec<XmlElementRef> {
|
|
492
|
+
e.children(txn)
|
|
493
|
+
.filter_map(|c| match c {
|
|
494
|
+
XmlOut::Element(el) => Some(el),
|
|
495
|
+
_ => None,
|
|
496
|
+
})
|
|
497
|
+
.collect()
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/// The node type (tag) of every element child, in document order — handed to
|
|
501
|
+
/// callback rules as `node.child_types` for the structural facts `attrs` and
|
|
502
|
+
/// the rendered content can't answer.
|
|
503
|
+
fn element_child_types<T: ReadTxn>(txn: &T, e: &XmlElementRef) -> Vec<String> {
|
|
504
|
+
element_children(txn, e)
|
|
505
|
+
.iter()
|
|
506
|
+
.map(|el| el.tag().to_string())
|
|
507
|
+
.collect()
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/// Render a text block's inline content: text runs (with their marks) and
|
|
511
|
+
/// inline element nodes (hard breaks, inline images). A registered
|
|
512
|
+
/// rule wins over the built-in inline nodes here too. An unknown inline node
|
|
513
|
+
/// keeps its text instead of vanishing; `depth` caps that recursion on a
|
|
514
|
+
/// crafted nest of unknowns.
|
|
515
|
+
fn render_inline<T: ReadTxn>(
|
|
516
|
+
txn: &T,
|
|
517
|
+
e: &XmlElementRef,
|
|
518
|
+
depth: usize,
|
|
519
|
+
em: &mut Emitter,
|
|
520
|
+
rules: &Rules,
|
|
521
|
+
) {
|
|
522
|
+
for node in e.children(txn) {
|
|
523
|
+
match node {
|
|
524
|
+
XmlOut::Text(t) => render_text_runs(txn, &t, em, rules),
|
|
525
|
+
XmlOut::Element(child) => {
|
|
526
|
+
if let Some(rule) = rules.nodes.get(child.tag().as_ref()) {
|
|
527
|
+
render_rule_inline(txn, &child, rule, depth, em, rules);
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
match child.tag().as_ref() {
|
|
531
|
+
"hardBreak" | "hard_break" => em.push_str("<br>"),
|
|
532
|
+
"image" => render_image(txn, &child, em),
|
|
533
|
+
_ => {
|
|
534
|
+
if depth < MAX_DEPTH {
|
|
535
|
+
render_inline(txn, &child, depth + 1, em, rules);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
XmlOut::Fragment(_) => {}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/// A rule node in inline position (inside a text block). Unlike Lexical's
|
|
546
|
+
/// childless decorators, a ProseMirror inline node can hold real content, so
|
|
547
|
+
/// the content slot renders — as inline content, since there are no blocks
|
|
548
|
+
/// inside a text block (a blocks content slot behaves like inline here).
|
|
549
|
+
fn render_rule_inline<T: ReadTxn>(
|
|
550
|
+
txn: &T,
|
|
551
|
+
e: &XmlElementRef,
|
|
552
|
+
rule: &NodeRule,
|
|
553
|
+
depth: usize,
|
|
554
|
+
em: &mut Emitter,
|
|
555
|
+
rules: &Rules,
|
|
556
|
+
) {
|
|
557
|
+
let (tag, void, attrs, text, content) = match rule {
|
|
558
|
+
NodeRule::Callback { content } => {
|
|
559
|
+
em.begin_frame();
|
|
560
|
+
if *content != Content::None && depth < MAX_DEPTH {
|
|
561
|
+
render_inline(txn, e, depth + 1, em, rules);
|
|
562
|
+
}
|
|
563
|
+
let captured = em.end_frame();
|
|
564
|
+
em.emit_deferred(
|
|
565
|
+
e.tag().to_string(),
|
|
566
|
+
xml_attrs_json(txn, e),
|
|
567
|
+
element_child_types(txn, e),
|
|
568
|
+
captured,
|
|
569
|
+
);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
NodeRule::Declarative {
|
|
573
|
+
tag,
|
|
574
|
+
void,
|
|
575
|
+
attrs,
|
|
576
|
+
text,
|
|
577
|
+
content,
|
|
578
|
+
} => (tag, *void, attrs, text, *content),
|
|
579
|
+
};
|
|
580
|
+
em.push('<');
|
|
581
|
+
em.push_str(tag);
|
|
582
|
+
for (name, parts) in attrs {
|
|
583
|
+
if let Some(value) = resolve_parts(parts, |r| xml_ref_attr(txn, e, r)) {
|
|
584
|
+
em.push(' ');
|
|
585
|
+
em.push_str(name);
|
|
586
|
+
em.push_str("=\"");
|
|
587
|
+
em.push_str(&escape_attr(&value));
|
|
588
|
+
em.push('\"');
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
em.push('>');
|
|
592
|
+
if void {
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
if let Some(text) = text {
|
|
596
|
+
if let Some(value) = resolve_parts(text, |r| xml_ref_attr(txn, e, r)) {
|
|
597
|
+
em.push_str(&escape_text(&value));
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
if content != Content::None && depth < MAX_DEPTH {
|
|
601
|
+
render_inline(txn, e, depth + 1, em, rules);
|
|
602
|
+
}
|
|
603
|
+
em.push_str("</");
|
|
604
|
+
em.push_str(tag);
|
|
605
|
+
em.push('>');
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/// Emit each formatted run of a Y.XmlText.
|
|
609
|
+
fn render_text_runs<T: ReadTxn>(txn: &T, t: &XmlTextRef, em: &mut Emitter, rules: &Rules) {
|
|
610
|
+
for d in t.diff(txn, YChange::identity) {
|
|
611
|
+
if let Out::Any(Any::String(s)) = &d.insert {
|
|
612
|
+
em.push_str(&render_run(s, d.attributes.as_deref(), rules));
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/// Wrap one text run in its marks, nesting innermost-first:
|
|
618
|
+
/// subscript/superscript, highlight, underline, strike, italic, bold, a
|
|
619
|
+
/// textStyle span, then link on the outside. `code` renders alone among the
|
|
620
|
+
/// formatting marks (Tiptap's Code mark excludes them all), but a link still
|
|
621
|
+
/// wraps it — Tiptap can't produce code+link, prosemirror-schema-basic can,
|
|
622
|
+
/// and dropping the link would lose the href.
|
|
623
|
+
///
|
|
624
|
+
/// A registered mark rule claims its stored name from the built-in wraps
|
|
625
|
+
/// (overriding it) and wraps outside everything; multiple custom marks nest
|
|
626
|
+
/// alphabetically by name, so output is deterministic regardless of
|
|
627
|
+
/// registration order. Overriding replaces only the markup: a claimed
|
|
628
|
+
/// `code` still excludes the other formatting marks, and a claimed
|
|
629
|
+
/// formatting mark stays excluded from a code run.
|
|
630
|
+
fn render_run(text: &str, marks: Option<&Attrs>, rules: &Rules) -> String {
|
|
631
|
+
let mut html = escape_text(text);
|
|
632
|
+
let Some(marks) = marks else {
|
|
633
|
+
return html;
|
|
634
|
+
};
|
|
635
|
+
if has(marks, &["code"], rules) {
|
|
636
|
+
html = wrap(html, "code");
|
|
637
|
+
} else if !marks.contains_key("code") {
|
|
638
|
+
// (A claimed `code` lands here too — on the run, but skipped by the
|
|
639
|
+
// else-if: the rule replaces the wrap, via wrap_custom_marks below,
|
|
640
|
+
// and code's exclusivity over the other formatting marks holds.)
|
|
641
|
+
if has(marks, &["subscript", "sub"], rules) {
|
|
642
|
+
html = wrap(html, "sub");
|
|
643
|
+
} else if has(marks, &["superscript", "sup"], rules) {
|
|
644
|
+
html = wrap(html, "sup");
|
|
645
|
+
}
|
|
646
|
+
if has(marks, &["highlight"], rules) {
|
|
647
|
+
html = wrap(html, "mark");
|
|
648
|
+
}
|
|
649
|
+
if has(marks, &["underline", "u"], rules) {
|
|
650
|
+
html = wrap(html, "u");
|
|
651
|
+
}
|
|
652
|
+
if has(marks, &["strike", "s"], rules) {
|
|
653
|
+
html = wrap(html, "s");
|
|
654
|
+
}
|
|
655
|
+
if has(marks, &["italic", "em"], rules) {
|
|
656
|
+
html = wrap(html, "em");
|
|
657
|
+
}
|
|
658
|
+
if has(marks, &["bold", "strong"], rules) {
|
|
659
|
+
html = wrap(html, "strong");
|
|
660
|
+
}
|
|
661
|
+
if !rules.marks.contains_key("textStyle") {
|
|
662
|
+
if let Some(Any::Map(style)) = marks.get("textStyle") {
|
|
663
|
+
let css = text_style_css(style);
|
|
664
|
+
if !css.is_empty() {
|
|
665
|
+
html = format!("<span style=\"{}\">{html}</span>", escape_attr(&css));
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if !rules.marks.contains_key("link") {
|
|
671
|
+
if let Some(Any::Map(link)) = marks.get("link") {
|
|
672
|
+
html = wrap_link(html, link);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
if !rules.marks.is_empty() {
|
|
676
|
+
html = wrap_custom_marks(html, marks, rules);
|
|
677
|
+
}
|
|
678
|
+
html
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/// Apply the run's registered custom marks, outermost of everything. A code
|
|
682
|
+
/// run keeps its exclusivity under overrides too: only the `code` and `link`
|
|
683
|
+
/// claims themselves may wrap it, matching what the built-in wraps allow —
|
|
684
|
+
/// otherwise overriding a formatting mark would change which marks render on
|
|
685
|
+
/// a code run, not just their markup.
|
|
686
|
+
fn wrap_custom_marks(html: String, marks: &Attrs, rules: &Rules) -> String {
|
|
687
|
+
let code = marks.contains_key("code");
|
|
688
|
+
let mut names: Vec<&str> = rules
|
|
689
|
+
.marks
|
|
690
|
+
.keys()
|
|
691
|
+
.map(String::as_str)
|
|
692
|
+
.filter(|name| marks.contains_key(*name))
|
|
693
|
+
.filter(|name| !code || matches!(*name, "code" | "link"))
|
|
694
|
+
.collect();
|
|
695
|
+
names.sort_unstable();
|
|
696
|
+
let mut html = html;
|
|
697
|
+
for name in names {
|
|
698
|
+
html = wrap_custom_mark(html, &rules.marks[name], marks.get(name));
|
|
699
|
+
}
|
|
700
|
+
html
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
fn wrap_custom_mark(inner: String, rule: &MarkRule, value: Option<&Any>) -> String {
|
|
704
|
+
let mut out = String::from("<");
|
|
705
|
+
out.push_str(&rule.tag);
|
|
706
|
+
for (attr, parts) in &rule.attrs {
|
|
707
|
+
if let Some(v) = resolve_parts(parts, |r| mark_ref_attr(value, r)) {
|
|
708
|
+
out.push(' ');
|
|
709
|
+
out.push_str(attr);
|
|
710
|
+
out.push_str("=\"");
|
|
711
|
+
out.push_str(&escape_attr(&v));
|
|
712
|
+
out.push('"');
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
out.push('>');
|
|
716
|
+
out.push_str(&inner);
|
|
717
|
+
out.push_str("</");
|
|
718
|
+
out.push_str(&rule.tag);
|
|
719
|
+
out.push('>');
|
|
720
|
+
out
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/// An attribute reference on a custom mark: y-prosemirror stores mark attrs
|
|
724
|
+
/// as a map under the mark's name (bool `true` when the mark has none).
|
|
725
|
+
fn mark_ref_attr(value: Option<&Any>, name: &str) -> Option<String> {
|
|
726
|
+
match value {
|
|
727
|
+
Some(Any::Map(map)) => any_attr_string(map.get(name)?),
|
|
728
|
+
_ => None,
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/// The `style` string for a textStyle mark (Tiptap's Color/FontFamily/etc.
|
|
733
|
+
/// extensions all store their value as a textStyle attribute). Attributes are
|
|
734
|
+
/// camelCase CSS property names; unset ones sit in the map as explicit nulls.
|
|
735
|
+
/// Keys sort alphabetically, which is the order Tiptap serializes (color
|
|
736
|
+
/// before font-family). Hex colors convert to rgb() because that's how they
|
|
737
|
+
/// come back out of the browser's style attribute; other values pass through.
|
|
738
|
+
fn text_style_css(style: &std::collections::HashMap<String, Any>) -> String {
|
|
739
|
+
let mut pairs: Vec<_> = style
|
|
740
|
+
.iter()
|
|
741
|
+
.filter_map(|(k, v)| match v {
|
|
742
|
+
Any::String(s) => Some((k, s.as_ref())),
|
|
743
|
+
_ => None,
|
|
744
|
+
})
|
|
745
|
+
.collect();
|
|
746
|
+
pairs.sort_by(|a, b| a.0.cmp(b.0));
|
|
747
|
+
let mut css = String::new();
|
|
748
|
+
for (key, value) in pairs {
|
|
749
|
+
if !css.is_empty() {
|
|
750
|
+
css.push(' ');
|
|
751
|
+
}
|
|
752
|
+
// camelCase -> kebab-case: fontFamily -> font-family.
|
|
753
|
+
for ch in key.chars() {
|
|
754
|
+
if ch.is_ascii_uppercase() {
|
|
755
|
+
css.push('-');
|
|
756
|
+
css.push(ch.to_ascii_lowercase());
|
|
757
|
+
} else {
|
|
758
|
+
css.push(ch);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
css.push_str(": ");
|
|
762
|
+
css.push_str(&hex_to_rgb(value).unwrap_or_else(|| value.to_string()));
|
|
763
|
+
css.push(';');
|
|
764
|
+
}
|
|
765
|
+
css
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/// `#rgb`/`#rrggbb` -> `rgb(r, g, b)`, matching the browser's style-attribute
|
|
769
|
+
/// serialization. Anything else (named colors, rgb()/hsl(), fonts) is None.
|
|
770
|
+
fn hex_to_rgb(value: &str) -> Option<String> {
|
|
771
|
+
let hex = value.strip_prefix('#')?;
|
|
772
|
+
// len() and the slices below are byte-based; non-ASCII input would panic
|
|
773
|
+
// on a char boundary. Real hex never is, crafted input passes through.
|
|
774
|
+
if !hex.is_ascii() {
|
|
775
|
+
return None;
|
|
776
|
+
}
|
|
777
|
+
let (r, g, b) = match hex.len() {
|
|
778
|
+
3 => {
|
|
779
|
+
let d = |i: usize| u8::from_str_radix(&hex[i..=i].repeat(2), 16);
|
|
780
|
+
(d(0).ok()?, d(1).ok()?, d(2).ok()?)
|
|
781
|
+
}
|
|
782
|
+
6 => {
|
|
783
|
+
let d = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16);
|
|
784
|
+
(d(0).ok()?, d(2).ok()?, d(4).ok()?)
|
|
785
|
+
}
|
|
786
|
+
_ => return None,
|
|
787
|
+
};
|
|
788
|
+
Some(format!("rgb({r}, {g}, {b})"))
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/// `<a>` with Tiptap's attribute order (target, rel, class, href, title),
|
|
792
|
+
/// skipping any that are absent or null.
|
|
793
|
+
fn wrap_link(inner: String, link: &std::collections::HashMap<String, Any>) -> String {
|
|
794
|
+
let mut out = String::from("<a");
|
|
795
|
+
for key in ["target", "rel", "class", "href", "title"] {
|
|
796
|
+
if let Some(Any::String(v)) = link.get(key) {
|
|
797
|
+
out.push(' ');
|
|
798
|
+
out.push_str(key);
|
|
799
|
+
out.push_str("=\"");
|
|
800
|
+
out.push_str(&escape_attr(v));
|
|
801
|
+
out.push('"');
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
out.push('>');
|
|
805
|
+
out.push_str(&inner);
|
|
806
|
+
out.push_str("</a>");
|
|
807
|
+
out
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/// `<img>` with attribute order src, alt, title, skipping absent/null ones.
|
|
811
|
+
fn render_image<T: ReadTxn>(txn: &T, e: &XmlElementRef, em: &mut Emitter) {
|
|
812
|
+
em.push_str("<img");
|
|
813
|
+
for (attr, html) in [("src", "src"), ("alt", "alt"), ("title", "title")] {
|
|
814
|
+
if let Some(v) = str_attr(txn, e, attr) {
|
|
815
|
+
em.push(' ');
|
|
816
|
+
em.push_str(html);
|
|
817
|
+
em.push_str("=\"");
|
|
818
|
+
em.push_str(&escape_attr(&v));
|
|
819
|
+
em.push('"');
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
em.push('>');
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
fn wrap(inner: String, tag: &str) -> String {
|
|
826
|
+
format!("<{tag}>{inner}</{tag}>")
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/// A mark is present and not claimed by a registered rule for that stored
|
|
830
|
+
/// name (a rule overrides the built-in wrap).
|
|
831
|
+
fn has(marks: &Attrs, keys: &[&str], rules: &Rules) -> bool {
|
|
832
|
+
keys.iter()
|
|
833
|
+
.any(|k| marks.contains_key(*k) && !rules.marks.contains_key(*k))
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
fn has_element_child<T: ReadTxn>(txn: &T, e: &XmlElementRef) -> bool {
|
|
837
|
+
e.children(txn).any(|c| matches!(c, XmlOut::Element(_)))
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/// The concatenated text of a code block (no marks — code is plain text).
|
|
841
|
+
fn code_text<T: ReadTxn>(txn: &T, e: &XmlElementRef) -> String {
|
|
842
|
+
let mut s = String::new();
|
|
843
|
+
for node in e.children(txn) {
|
|
844
|
+
if let XmlOut::Text(t) = node {
|
|
845
|
+
for d in t.diff(txn, YChange::identity) {
|
|
846
|
+
if let Out::Any(Any::String(run)) = &d.insert {
|
|
847
|
+
s.push_str(run);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
s
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
fn str_attr<T: ReadTxn>(txn: &T, e: &XmlElementRef, name: &str) -> Option<String> {
|
|
856
|
+
match e.get_attribute(txn, name) {
|
|
857
|
+
Some(Out::Any(Any::String(s))) => Some(s.to_string()),
|
|
858
|
+
_ => None,
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
fn num_attr<T: ReadTxn>(txn: &T, e: &XmlElementRef, name: &str) -> Option<i64> {
|
|
863
|
+
match e.get_attribute(txn, name) {
|
|
864
|
+
Some(Out::Any(Any::Number(n))) => Some(n as i64),
|
|
865
|
+
Some(Out::Any(Any::BigInt(n))) => Some(n),
|
|
866
|
+
_ => None,
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/// Text-content escaping, matching the browser serializer: `&`, `<`, `>`.
|
|
871
|
+
fn escape_text(s: &str) -> String {
|
|
872
|
+
s.replace('&', "&")
|
|
873
|
+
.replace('<', "<")
|
|
874
|
+
.replace('>', ">")
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/// Attribute-value escaping: text escaping plus `"`.
|
|
878
|
+
fn escape_attr(s: &str) -> String {
|
|
879
|
+
escape_text(s).replace('"', """)
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
#[cfg(test)]
|
|
883
|
+
mod tests {
|
|
884
|
+
use super::*;
|
|
885
|
+
use std::collections::HashMap;
|
|
886
|
+
use std::sync::Arc;
|
|
887
|
+
use yrs::updates::decoder::Decode;
|
|
888
|
+
use yrs::{Doc, Transact, Update, XmlElementPrelim, XmlTextPrelim};
|
|
889
|
+
|
|
890
|
+
fn doc_from(bytes: &[u8]) -> Doc {
|
|
891
|
+
let doc = Doc::new();
|
|
892
|
+
doc.transact_mut()
|
|
893
|
+
.apply_update(Update::decode_v1(bytes).unwrap())
|
|
894
|
+
.unwrap();
|
|
895
|
+
doc
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
fn marks(keys: &[&str]) -> Attrs {
|
|
899
|
+
let mut a = Attrs::new();
|
|
900
|
+
for k in keys {
|
|
901
|
+
a.insert((*k).into(), Any::Bool(true));
|
|
902
|
+
}
|
|
903
|
+
a
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
fn run(text: &str, marks: Option<&Attrs>) -> String {
|
|
907
|
+
render_run(text, marks, &Rules::empty())
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/// Core rendering of the captured Tiptap document, pinned as a golden
|
|
911
|
+
/// (`.core.html`). The task list renders through the unknown-container
|
|
912
|
+
/// fallback here (unwrapped children); the external truth — byte parity
|
|
913
|
+
/// with the editor's own `getHTML()` — is held at the Ruby layer, where
|
|
914
|
+
/// `Y::Tiptap` completes the schema. The fixture covers headings, every
|
|
915
|
+
/// mark and combination, links, escaping, blockquote, nested bullet and
|
|
916
|
+
/// ordered lists (with a `start`), a task list, code blocks with and
|
|
917
|
+
/// without a language, a hard break, a horizontal rule, an image, and
|
|
918
|
+
/// the trailing empty paragraph Tiptap keeps.
|
|
919
|
+
#[test]
|
|
920
|
+
fn core_rendering_of_the_tiptap_fixture_is_pinned() {
|
|
921
|
+
let doc = doc_from(include_bytes!("fixtures/prosemirror_tiptap.bin"));
|
|
922
|
+
let txn = doc.transact();
|
|
923
|
+
let frag = txn.get_xml_fragment("default").unwrap();
|
|
924
|
+
assert_eq!(
|
|
925
|
+
render(&txn, &frag).unwrap(),
|
|
926
|
+
include_str!("fixtures/prosemirror_tiptap.core.html")
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/// A table renders as tiptap-php's semantic form — `<table><tbody>` with
|
|
931
|
+
/// colspan/rowspan cells — dropping the `<colgroup>`/`min-width` styling
|
|
932
|
+
/// Tiptap's editor view adds (and which isn't in the CRDT).
|
|
933
|
+
#[test]
|
|
934
|
+
fn renders_a_table_as_semantic_html() {
|
|
935
|
+
let doc = doc_from(include_bytes!("fixtures/prosemirror_table.bin"));
|
|
936
|
+
let txn = doc.transact();
|
|
937
|
+
let frag = txn.get_xml_fragment("default").unwrap();
|
|
938
|
+
assert_eq!(
|
|
939
|
+
render(&txn, &frag).unwrap(),
|
|
940
|
+
include_str!("fixtures/prosemirror_table.html")
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
#[test]
|
|
945
|
+
fn marks_nest_in_tiptaps_serializer_order() {
|
|
946
|
+
assert_eq!(run("x", None), "x");
|
|
947
|
+
assert_eq!(run("x", Some(&marks(&["bold"]))), "<strong>x</strong>");
|
|
948
|
+
assert_eq!(run("x", Some(&marks(&["italic"]))), "<em>x</em>");
|
|
949
|
+
// bold wraps italic.
|
|
950
|
+
assert_eq!(
|
|
951
|
+
run("x", Some(&marks(&["italic", "bold"]))),
|
|
952
|
+
"<strong><em>x</em></strong>"
|
|
953
|
+
);
|
|
954
|
+
// code excludes every other mark.
|
|
955
|
+
assert_eq!(run("x", Some(&marks(&["code", "bold"]))), "<code>x</code>");
|
|
956
|
+
// Full compatible stack, innermost sub to outermost bold.
|
|
957
|
+
assert_eq!(
|
|
958
|
+
run(
|
|
959
|
+
"x",
|
|
960
|
+
Some(&marks(&[
|
|
961
|
+
"bold",
|
|
962
|
+
"italic",
|
|
963
|
+
"strike",
|
|
964
|
+
"underline",
|
|
965
|
+
"highlight",
|
|
966
|
+
"subscript"
|
|
967
|
+
]))
|
|
968
|
+
),
|
|
969
|
+
"<strong><em><s><u><mark><sub>x</sub></mark></u></s></em></strong>"
|
|
970
|
+
);
|
|
971
|
+
// Escaping happens before wrapping.
|
|
972
|
+
assert_eq!(run("<&>", None), "<&>");
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
#[test]
|
|
976
|
+
fn renders_a_link_run_with_tiptaps_attribute_order() {
|
|
977
|
+
let mut link = HashMap::new();
|
|
978
|
+
link.insert(
|
|
979
|
+
"href".to_string(),
|
|
980
|
+
Any::String("https://e.com?a=1&b=2".into()),
|
|
981
|
+
);
|
|
982
|
+
link.insert("target".to_string(), Any::String("_blank".into()));
|
|
983
|
+
link.insert("rel".to_string(), Any::String("noopener".into()));
|
|
984
|
+
let mut a = Attrs::new();
|
|
985
|
+
a.insert("link".into(), Any::Map(Arc::new(link)));
|
|
986
|
+
assert_eq!(
|
|
987
|
+
run("site", Some(&a)),
|
|
988
|
+
"<a target=\"_blank\" rel=\"noopener\" href=\"https://e.com?a=1&b=2\">site</a>"
|
|
989
|
+
);
|
|
990
|
+
|
|
991
|
+
// class and title (Link's remaining attrs) keep Tiptap's serialized
|
|
992
|
+
// order: target, rel, class, href, title. Null-valued attrs skip.
|
|
993
|
+
let mut link = HashMap::new();
|
|
994
|
+
link.insert("href".to_string(), Any::String("https://d.example".into()));
|
|
995
|
+
link.insert("class".to_string(), Any::String("doc-link".into()));
|
|
996
|
+
link.insert("title".to_string(), Any::String("A Doc".into()));
|
|
997
|
+
link.insert("target".to_string(), Any::Null);
|
|
998
|
+
let mut a = Attrs::new();
|
|
999
|
+
a.insert("link".into(), Any::Map(Arc::new(link)));
|
|
1000
|
+
assert_eq!(
|
|
1001
|
+
run("the doc", Some(&a)),
|
|
1002
|
+
"<a class=\"doc-link\" href=\"https://d.example\" title=\"A Doc\">the doc</a>"
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/// Core rendering of the captured mention document, pinned as a golden
|
|
1007
|
+
/// (`.core.html`). Mention is a Tiptap extension node — an atom with no
|
|
1008
|
+
/// children — so core renders it to nothing; parity with the editor's
|
|
1009
|
+
/// `getHTML()` is held at the Ruby layer by `Y::Tiptap`. The fixture
|
|
1010
|
+
/// holds one mention with a label, one with only an id, and a link
|
|
1011
|
+
/// carrying class and title.
|
|
1012
|
+
#[test]
|
|
1013
|
+
fn core_rendering_of_the_mention_fixture_is_pinned() {
|
|
1014
|
+
let doc = doc_from(include_bytes!("fixtures/prosemirror_mention.bin"));
|
|
1015
|
+
let txn = doc.transact();
|
|
1016
|
+
let frag = txn.get_xml_fragment("default").unwrap();
|
|
1017
|
+
assert_eq!(
|
|
1018
|
+
render(&txn, &frag).unwrap(),
|
|
1019
|
+
include_str!("fixtures/prosemirror_mention.core.html")
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/// textStyle (captured from Tiptap's Color/FontFamily extensions): hex
|
|
1024
|
+
/// colors come back out of the browser as rgb(), rgb() strings pass
|
|
1025
|
+
/// through, font-family joins the same span, and the span wraps outside
|
|
1026
|
+
/// bold.
|
|
1027
|
+
#[test]
|
|
1028
|
+
fn renders_the_captured_textstyle_document_byte_for_byte() {
|
|
1029
|
+
let doc = doc_from(include_bytes!("fixtures/prosemirror_textstyle.bin"));
|
|
1030
|
+
let txn = doc.transact();
|
|
1031
|
+
let frag = txn.get_xml_fragment("default").unwrap();
|
|
1032
|
+
assert_eq!(
|
|
1033
|
+
render(&txn, &frag).unwrap(),
|
|
1034
|
+
include_str!("fixtures/prosemirror_textstyle.html")
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
#[test]
|
|
1039
|
+
fn text_style_converts_hex_and_kebab_cases_keys() {
|
|
1040
|
+
let mut style = HashMap::new();
|
|
1041
|
+
style.insert("color".to_string(), Any::String("#ff0000".into()));
|
|
1042
|
+
style.insert(
|
|
1043
|
+
"fontFamily".to_string(),
|
|
1044
|
+
Any::String("Georgia, serif".into()),
|
|
1045
|
+
);
|
|
1046
|
+
style.insert("fontSize".to_string(), Any::Null); // unset: skipped
|
|
1047
|
+
assert_eq!(
|
|
1048
|
+
text_style_css(&style),
|
|
1049
|
+
"color: rgb(255, 0, 0); font-family: Georgia, serif;"
|
|
1050
|
+
);
|
|
1051
|
+
|
|
1052
|
+
assert_eq!(hex_to_rgb("#0f8"), Some("rgb(0, 255, 136)".to_string()));
|
|
1053
|
+
assert_eq!(hex_to_rgb("rebeccapurple"), None);
|
|
1054
|
+
assert_eq!(hex_to_rgb("#12345"), None);
|
|
1055
|
+
// Multibyte input must pass through, not panic on a byte-slice
|
|
1056
|
+
// boundary ("日" is one char, three bytes — it enters the 3 arm).
|
|
1057
|
+
assert_eq!(hex_to_rgb("#日"), None);
|
|
1058
|
+
assert_eq!(hex_to_rgb("#日本"), None);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/// A known container with bare text jammed directly into it (schema-valid
|
|
1062
|
+
/// documents never do this) keeps the text instead of dropping it.
|
|
1063
|
+
#[test]
|
|
1064
|
+
fn a_known_container_keeps_stray_direct_text() {
|
|
1065
|
+
let doc = Doc::new();
|
|
1066
|
+
let frag = doc.get_or_insert_xml_fragment("default");
|
|
1067
|
+
{
|
|
1068
|
+
let mut txn = doc.transact_mut();
|
|
1069
|
+
let bq = frag.push_back(&mut txn, XmlElementPrelim::empty("blockquote"));
|
|
1070
|
+
bq.push_back(&mut txn, XmlTextPrelim::new("stray"));
|
|
1071
|
+
let p = bq.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
|
|
1072
|
+
p.push_back(&mut txn, XmlTextPrelim::new("body"));
|
|
1073
|
+
}
|
|
1074
|
+
let txn = doc.transact();
|
|
1075
|
+
assert_eq!(
|
|
1076
|
+
render(&txn, &frag).unwrap(),
|
|
1077
|
+
"<blockquote>stray<p>body</p></blockquote>"
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/// prosemirror-schema-basic's `code` mark has no excludes, so a code run
|
|
1082
|
+
/// can also carry a link; the link must survive. (Tiptap's Code mark
|
|
1083
|
+
/// excludes everything, so this shape only comes from schema-basic docs.)
|
|
1084
|
+
#[test]
|
|
1085
|
+
fn a_code_run_keeps_its_link() {
|
|
1086
|
+
let mut link = HashMap::new();
|
|
1087
|
+
link.insert("href".to_string(), Any::String("https://e.com".into()));
|
|
1088
|
+
let mut a = Attrs::new();
|
|
1089
|
+
a.insert("code".into(), Any::Map(Arc::new(HashMap::new())));
|
|
1090
|
+
a.insert("link".into(), Any::Map(Arc::new(link)));
|
|
1091
|
+
assert_eq!(
|
|
1092
|
+
run("x", Some(&a)),
|
|
1093
|
+
"<a href=\"https://e.com\"><code>x</code></a>"
|
|
1094
|
+
);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/// An unknown block holding both text and child blocks keeps the text.
|
|
1098
|
+
#[test]
|
|
1099
|
+
fn an_unknown_block_with_mixed_content_keeps_its_text() {
|
|
1100
|
+
let doc = Doc::new();
|
|
1101
|
+
let frag = doc.get_or_insert_xml_fragment("default");
|
|
1102
|
+
{
|
|
1103
|
+
let mut txn = doc.transact_mut();
|
|
1104
|
+
let callout = frag.push_back(&mut txn, XmlElementPrelim::empty("callout"));
|
|
1105
|
+
callout.push_back(&mut txn, XmlTextPrelim::new("intro"));
|
|
1106
|
+
let p = callout.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
|
|
1107
|
+
p.push_back(&mut txn, XmlTextPrelim::new("body"));
|
|
1108
|
+
}
|
|
1109
|
+
let txn = doc.transact();
|
|
1110
|
+
assert_eq!(render(&txn, &frag).unwrap(), "intro<p>body</p>");
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/// An unknown inline node keeps its text instead of vanishing.
|
|
1114
|
+
#[test]
|
|
1115
|
+
fn an_unknown_inline_node_keeps_its_text() {
|
|
1116
|
+
let doc = Doc::new();
|
|
1117
|
+
let frag = doc.get_or_insert_xml_fragment("default");
|
|
1118
|
+
{
|
|
1119
|
+
let mut txn = doc.transact_mut();
|
|
1120
|
+
let p = frag.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
|
|
1121
|
+
p.push_back(&mut txn, XmlTextPrelim::new("see "));
|
|
1122
|
+
let custom = p.push_back(&mut txn, XmlElementPrelim::empty("customInline"));
|
|
1123
|
+
custom.push_back(&mut txn, XmlTextPrelim::new("kept"));
|
|
1124
|
+
p.push_back(&mut txn, XmlTextPrelim::new(" here"));
|
|
1125
|
+
}
|
|
1126
|
+
let txn = doc.transact();
|
|
1127
|
+
assert_eq!(render(&txn, &frag).unwrap(), "<p>see kept here</p>");
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/// The prosemirror-schema-basic spellings (snake_case nodes, `strong`/`em`
|
|
1131
|
+
/// marks) render the same as Tiptap's camelCase.
|
|
1132
|
+
#[test]
|
|
1133
|
+
fn accepts_prosemirror_basic_schema_names() {
|
|
1134
|
+
let doc = Doc::new();
|
|
1135
|
+
let frag = doc.get_or_insert_xml_fragment("default");
|
|
1136
|
+
{
|
|
1137
|
+
let mut txn = doc.transact_mut();
|
|
1138
|
+
let bq = frag.push_back(&mut txn, XmlElementPrelim::empty("blockquote"));
|
|
1139
|
+
let p = bq.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
|
|
1140
|
+
let t = p.push_back(&mut txn, XmlTextPrelim::new("hi bold it"));
|
|
1141
|
+
t.format(&mut txn, 3, 4, marks(&["strong"]));
|
|
1142
|
+
t.format(&mut txn, 8, 2, marks(&["em"]));
|
|
1143
|
+
}
|
|
1144
|
+
let txn = doc.transact();
|
|
1145
|
+
assert_eq!(
|
|
1146
|
+
render(&txn, &frag).unwrap(),
|
|
1147
|
+
"<blockquote><p>hi <strong>bold</strong> <em>it</em></p></blockquote>"
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
#[test]
|
|
1152
|
+
fn a_lexical_shaped_root_is_refused() {
|
|
1153
|
+
// A Lexical doc stores blocks as XmlText carrying `__type`. That's a
|
|
1154
|
+
// different schema; render must return None, not a garbled document.
|
|
1155
|
+
let doc = Doc::new();
|
|
1156
|
+
// Create both roots before opening the read transaction:
|
|
1157
|
+
// get_or_insert_* opens its own write transaction, which would deadlock
|
|
1158
|
+
// against a live read guard.
|
|
1159
|
+
let frag = doc.get_or_insert_xml_fragment("root");
|
|
1160
|
+
let empty = doc.get_or_insert_xml_fragment("empty");
|
|
1161
|
+
{
|
|
1162
|
+
let mut txn = doc.transact_mut();
|
|
1163
|
+
let block = frag.push_back(&mut txn, XmlTextPrelim::new("hello"));
|
|
1164
|
+
block.insert_attribute(&mut txn, "__type", "paragraph");
|
|
1165
|
+
}
|
|
1166
|
+
let txn = doc.transact();
|
|
1167
|
+
assert_eq!(render(&txn, &frag), None);
|
|
1168
|
+
// An empty root is fine.
|
|
1169
|
+
assert_eq!(render(&txn, &empty).as_deref(), Some(""));
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
#[test]
|
|
1173
|
+
fn deeply_nested_blocks_do_not_overflow_the_stack() {
|
|
1174
|
+
// The walk is on the heap, so nesting that would blow a small native
|
|
1175
|
+
// stack under recursion renders fine. Built and rendered on a 512 KiB
|
|
1176
|
+
// thread so it can't pass just by having room to spare.
|
|
1177
|
+
std::thread::Builder::new()
|
|
1178
|
+
.stack_size(512 * 1024)
|
|
1179
|
+
.spawn(|| {
|
|
1180
|
+
let doc = Doc::new();
|
|
1181
|
+
let frag = doc.get_or_insert_xml_fragment("default");
|
|
1182
|
+
{
|
|
1183
|
+
let mut txn = doc.transact_mut();
|
|
1184
|
+
let mut cursor =
|
|
1185
|
+
frag.push_back(&mut txn, XmlElementPrelim::empty("blockquote"));
|
|
1186
|
+
for _ in 0..20_000 {
|
|
1187
|
+
cursor = cursor.push_back(&mut txn, XmlElementPrelim::empty("blockquote"));
|
|
1188
|
+
}
|
|
1189
|
+
let p = cursor.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
|
|
1190
|
+
p.push_back(&mut txn, XmlTextPrelim::new("deep"));
|
|
1191
|
+
}
|
|
1192
|
+
let txn = doc.transact();
|
|
1193
|
+
let html = render(&txn, &frag).expect("prosemirror-shaped");
|
|
1194
|
+
assert_eq!(
|
|
1195
|
+
html.matches("<blockquote>").count(),
|
|
1196
|
+
html.matches("</blockquote>").count()
|
|
1197
|
+
);
|
|
1198
|
+
assert!(html.contains("<p>deep</p>") || html.contains("<blockquote>"));
|
|
1199
|
+
})
|
|
1200
|
+
.unwrap()
|
|
1201
|
+
.join()
|
|
1202
|
+
.unwrap();
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
#[test]
|
|
1206
|
+
fn escaping_matches_the_browser_serializer() {
|
|
1207
|
+
assert_eq!(escape_text(r#"<a & "b">"#), r#"<a & "b">"#);
|
|
1208
|
+
assert_eq!(
|
|
1209
|
+
escape_attr(r#"<a & "b">"#),
|
|
1210
|
+
r#"<a & "b">"#
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/// A declarative node rule renders natively: tag, attribute refs resolved
|
|
1215
|
+
/// from the node's attrs, and a blocks content slot. Rules can also
|
|
1216
|
+
/// override a built-in (paragraph here).
|
|
1217
|
+
#[test]
|
|
1218
|
+
fn a_declarative_rule_renders_and_can_override_a_builtin() {
|
|
1219
|
+
let rules = Rules::parse(
|
|
1220
|
+
r#"{ "nodes": {
|
|
1221
|
+
"callout": { "tag": "aside",
|
|
1222
|
+
"attrs": [["class", [{"lit": "callout callout--"}, {"ref": "kind"}]]],
|
|
1223
|
+
"content": "blocks" },
|
|
1224
|
+
"paragraph": { "tag": "div", "attrs": [["class", [{"lit": "para"}]]] } } }"#,
|
|
1225
|
+
)
|
|
1226
|
+
.unwrap();
|
|
1227
|
+
let doc = Doc::new();
|
|
1228
|
+
let frag = doc.get_or_insert_xml_fragment("default");
|
|
1229
|
+
{
|
|
1230
|
+
let mut txn = doc.transact_mut();
|
|
1231
|
+
let callout = frag.push_back(&mut txn, XmlElementPrelim::empty("callout"));
|
|
1232
|
+
callout.insert_attribute(&mut txn, "kind", "warning");
|
|
1233
|
+
let p = callout.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
|
|
1234
|
+
p.push_back(&mut txn, XmlTextPrelim::new("careful"));
|
|
1235
|
+
}
|
|
1236
|
+
let txn = doc.transact();
|
|
1237
|
+
let segs = render_segments(&txn, &frag, &rules).unwrap();
|
|
1238
|
+
assert_eq!(
|
|
1239
|
+
yrs_html_core::flatten(segs).into_html().unwrap(),
|
|
1240
|
+
"<aside class=\"callout callout--warning\"><div class=\"para\">careful</div></aside>"
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/// A callback rule defers: the node comes back as a Deferred segment with
|
|
1245
|
+
/// its attrs as JSON and its children already rendered.
|
|
1246
|
+
#[test]
|
|
1247
|
+
fn a_callback_rule_emits_a_deferred_segment_with_rendered_content() {
|
|
1248
|
+
let rules = Rules::parse(
|
|
1249
|
+
r#"{ "nodes": { "videoEmbed": { "callback": true, "content": "blocks" } } }"#,
|
|
1250
|
+
)
|
|
1251
|
+
.unwrap();
|
|
1252
|
+
let doc = Doc::new();
|
|
1253
|
+
let frag = doc.get_or_insert_xml_fragment("default");
|
|
1254
|
+
{
|
|
1255
|
+
let mut txn = doc.transact_mut();
|
|
1256
|
+
let intro = frag.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
|
|
1257
|
+
intro.push_back(&mut txn, XmlTextPrelim::new("watch:"));
|
|
1258
|
+
let video = frag.push_back(&mut txn, XmlElementPrelim::empty("videoEmbed"));
|
|
1259
|
+
video.insert_attribute(&mut txn, "src", "https://v.example/1");
|
|
1260
|
+
let caption = video.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
|
|
1261
|
+
caption.push_back(&mut txn, XmlTextPrelim::new("the caption"));
|
|
1262
|
+
}
|
|
1263
|
+
let txn = doc.transact();
|
|
1264
|
+
let segs = render_segments(&txn, &frag, &rules).unwrap();
|
|
1265
|
+
assert_eq!(segs.len(), 2);
|
|
1266
|
+
assert!(matches!(&segs[0], Segment::Html(s) if s == "<p>watch:</p>"));
|
|
1267
|
+
let Segment::Deferred {
|
|
1268
|
+
node_type,
|
|
1269
|
+
attrs_json,
|
|
1270
|
+
child_types,
|
|
1271
|
+
content,
|
|
1272
|
+
} = &segs[1]
|
|
1273
|
+
else {
|
|
1274
|
+
panic!("expected a deferred segment");
|
|
1275
|
+
};
|
|
1276
|
+
assert_eq!(node_type, "videoEmbed");
|
|
1277
|
+
assert_eq!(attrs_json, r#"{"src":"https://v.example/1"}"#);
|
|
1278
|
+
assert_eq!(child_types, &["paragraph"]);
|
|
1279
|
+
assert!(matches!(&content[0], Segment::Html(s) if s == "<p>the caption</p>"));
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/// Discovery reports facts per type, and an unknown type annotates as
|
|
1283
|
+
/// unhandled (null) — the signal a rule author filters for.
|
|
1284
|
+
#[test]
|
|
1285
|
+
fn node_type_discovery_reports_unknown_types_as_unhandled() {
|
|
1286
|
+
let doc = Doc::new();
|
|
1287
|
+
let frag = doc.get_or_insert_xml_fragment("default");
|
|
1288
|
+
{
|
|
1289
|
+
let mut txn = doc.transact_mut();
|
|
1290
|
+
let callout = frag.push_back(&mut txn, XmlElementPrelim::empty("callout"));
|
|
1291
|
+
callout.insert_attribute(&mut txn, "kind", "warning");
|
|
1292
|
+
let p = callout.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
|
|
1293
|
+
p.push_back(&mut txn, XmlTextPrelim::new("body"));
|
|
1294
|
+
}
|
|
1295
|
+
let txn = doc.transact();
|
|
1296
|
+
let map = collect_node_types(&txn, &frag).unwrap();
|
|
1297
|
+
let json = yrs_html_core::type_map_json(&map, |ty| {
|
|
1298
|
+
if is_builtin(ty) {
|
|
1299
|
+
Some("builtin")
|
|
1300
|
+
} else {
|
|
1301
|
+
None
|
|
1302
|
+
}
|
|
1303
|
+
});
|
|
1304
|
+
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
|
|
1305
|
+
assert_eq!(v["callout"]["handled"], serde_json::Value::Null);
|
|
1306
|
+
assert_eq!(v["callout"]["attrs"][0], "kind");
|
|
1307
|
+
assert_eq!(v["callout"]["children"][0], "paragraph");
|
|
1308
|
+
assert_eq!(v["paragraph"]["handled"], "builtin");
|
|
1309
|
+
assert_eq!(v["paragraph"]["text"], true);
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/// A custom mark claims its stored name from the built-ins and wraps
|
|
1313
|
+
/// outside everything, attribute refs resolving against the mark's map.
|
|
1314
|
+
#[test]
|
|
1315
|
+
fn a_custom_mark_overrides_the_builtin_and_wraps_outermost() {
|
|
1316
|
+
let rules = Rules::parse(
|
|
1317
|
+
r#"{ "marks": {
|
|
1318
|
+
"comment": { "tag": "span", "attrs": [["data-comment-id", [{"ref": "id"}]]] },
|
|
1319
|
+
"bold": { "tag": "b" } } }"#,
|
|
1320
|
+
)
|
|
1321
|
+
.unwrap();
|
|
1322
|
+
let mut comment = HashMap::new();
|
|
1323
|
+
comment.insert("id".to_string(), Any::String("c1".into()));
|
|
1324
|
+
let mut a = Attrs::new();
|
|
1325
|
+
a.insert("bold".into(), Any::Bool(true));
|
|
1326
|
+
a.insert("italic".into(), Any::Bool(true));
|
|
1327
|
+
a.insert("comment".into(), Any::Map(Arc::new(comment)));
|
|
1328
|
+
assert_eq!(
|
|
1329
|
+
render_run("x", Some(&a), &rules),
|
|
1330
|
+
"<span data-comment-id=\"c1\"><b><em>x</em></b></span>"
|
|
1331
|
+
);
|
|
1332
|
+
|
|
1333
|
+
// Overriding code replaces only its tag: the other
|
|
1334
|
+
// formatting marks stay excluded.
|
|
1335
|
+
let kbd = Rules::parse(r#"{ "marks": { "code": { "tag": "kbd" } } }"#).unwrap();
|
|
1336
|
+
let mut a = Attrs::new();
|
|
1337
|
+
a.insert("code".into(), Any::Bool(true));
|
|
1338
|
+
a.insert("bold".into(), Any::Bool(true));
|
|
1339
|
+
assert_eq!(render_run("x", Some(&a), &kbd), "<kbd>x</kbd>");
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
/// The exclusivity holds from the other direction too: a claimed
|
|
1343
|
+
/// formatting mark must not sneak onto a code run through the custom
|
|
1344
|
+
/// wrap when the built-in wrap would have excluded it.
|
|
1345
|
+
#[test]
|
|
1346
|
+
fn a_claimed_formatting_mark_stays_excluded_on_a_code_run() {
|
|
1347
|
+
let rules = Rules::parse(r#"{ "marks": { "bold": { "tag": "b" } } }"#).unwrap();
|
|
1348
|
+
let mut a = Attrs::new();
|
|
1349
|
+
a.insert("code".into(), Any::Bool(true));
|
|
1350
|
+
a.insert("bold".into(), Any::Bool(true));
|
|
1351
|
+
assert_eq!(render_run("x", Some(&a), &rules), "<code>x</code>");
|
|
1352
|
+
|
|
1353
|
+
// The link claim is the exception, matching the built-in behavior
|
|
1354
|
+
// (schema-basic can produce code+link; the href must survive).
|
|
1355
|
+
let link_rule =
|
|
1356
|
+
Rules::parse(r#"{ "marks": { "link": { "tag": "a", "attrs": [["href", [{"ref": "href"}]]] }, "bold": { "tag": "b" } } }"#)
|
|
1357
|
+
.unwrap();
|
|
1358
|
+
let mut link = HashMap::new();
|
|
1359
|
+
link.insert("href".to_string(), Any::String("https://e.com".into()));
|
|
1360
|
+
let mut a = Attrs::new();
|
|
1361
|
+
a.insert("code".into(), Any::Bool(true));
|
|
1362
|
+
a.insert("bold".into(), Any::Bool(true));
|
|
1363
|
+
a.insert("link".into(), Any::Map(Arc::new(link)));
|
|
1364
|
+
assert_eq!(
|
|
1365
|
+
render_run("x", Some(&a), &link_rule),
|
|
1366
|
+
"<a href=\"https://e.com\"><code>x</code></a>"
|
|
1367
|
+
);
|
|
1368
|
+
}
|
|
1369
|
+
}
|