yrby 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +62 -1
- data/README.md +222 -16
- data/ext/yrby/src/lexical_html.rs +611 -288
- data/ext/yrby/src/lib.rs +206 -76
- data/ext/yrby/src/prosemirror_html.rs +736 -267
- data/ext/yrby/src/render_rules.rs +529 -0
- data/lib/y/lexxy.rb +100 -0
- data/lib/y/rendering.rb +282 -0
- data/lib/y/tiptap.rb +63 -0
- data/lib/y/version.rb +1 -1
- data/lib/y.rb +4 -0
- metadata +5 -1
|
@@ -8,18 +8,38 @@
|
|
|
8
8
|
//! both spellings in use: Tiptap's camelCase (`bulletList`, `bold`) and the
|
|
9
9
|
//! prosemirror-schema-basic snake_case (`bullet_list`, `strong`).
|
|
10
10
|
//!
|
|
11
|
-
//!
|
|
12
|
-
//!
|
|
13
|
-
//!
|
|
14
|
-
//!
|
|
15
|
-
//!
|
|
16
|
-
//!
|
|
17
|
-
//!
|
|
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.
|
|
18
23
|
//!
|
|
19
|
-
//! Marks
|
|
20
|
-
//!
|
|
21
|
-
//!
|
|
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.
|
|
22
38
|
|
|
39
|
+
use crate::render_rules::{
|
|
40
|
+
any_attr_string, resolve_parts, xml_attrs_json, xml_ref_attr, Content, Emitter, MarkRule,
|
|
41
|
+
NodeRule, Rules, Segment, TypeMap,
|
|
42
|
+
};
|
|
23
43
|
use yrs::types::text::YChange;
|
|
24
44
|
use yrs::types::Attrs;
|
|
25
45
|
use yrs::{
|
|
@@ -27,40 +47,139 @@ use yrs::{
|
|
|
27
47
|
XmlTextRef,
|
|
28
48
|
};
|
|
29
49
|
|
|
30
|
-
// The block tree is walked on a heap stack, so depth
|
|
31
|
-
// stack
|
|
50
|
+
// The block tree is walked on a heap stack, so depth can't overflow the
|
|
51
|
+
// native call stack; this caps the work a pathological document can demand.
|
|
32
52
|
const MAX_DEPTH: usize = 1024;
|
|
33
53
|
|
|
34
|
-
/// A
|
|
35
|
-
/// block children as more work); `Close`
|
|
36
|
-
/// children are done
|
|
54
|
+
/// A unit of block work on the traversal stack. `Open` renders a node (pushing its
|
|
55
|
+
/// block children as more work); `Close` and `CloseOwned` emit a container's
|
|
56
|
+
/// end tag once its children are done (built-in containers close with fixed
|
|
57
|
+
/// strings; rule containers close with their computed tag). `EndDeferred` seals
|
|
58
|
+
/// a callback node: it pops the emitter frame its children rendered into and
|
|
59
|
+
/// emits the deferred segment.
|
|
37
60
|
enum Work {
|
|
38
61
|
Open(XmlElementRef, usize),
|
|
39
62
|
Close(&'static str),
|
|
63
|
+
CloseOwned(String),
|
|
64
|
+
EndDeferred {
|
|
65
|
+
node_type: String,
|
|
66
|
+
attrs_json: String,
|
|
67
|
+
child_types: Vec<String>,
|
|
68
|
+
},
|
|
40
69
|
}
|
|
41
70
|
|
|
42
|
-
/// Render a ProseMirror/Tiptap-shaped XML root to
|
|
43
|
-
/// isn't ProseMirror-shaped. ProseMirror blocks are plain Y.XmlElement
|
|
44
|
-
/// Lexical root (Y.XmlText children carrying a `__type`) is a
|
|
45
|
-
/// and returns `None` instead of a garbled render.
|
|
46
|
-
pub fn
|
|
71
|
+
/// Render a ProseMirror/Tiptap-shaped XML root to segments, or `None` when the
|
|
72
|
+
/// root isn't ProseMirror-shaped. ProseMirror blocks are plain Y.XmlElement
|
|
73
|
+
/// tags; a Lexical root (Y.XmlText children carrying a `__type`) is a
|
|
74
|
+
/// different schema and returns `None` instead of a garbled render.
|
|
75
|
+
pub fn render_segments<T: ReadTxn>(
|
|
76
|
+
txn: &T,
|
|
77
|
+
fragment: &XmlFragmentRef,
|
|
78
|
+
rules: &Rules,
|
|
79
|
+
) -> Option<Vec<Segment>> {
|
|
47
80
|
if !is_prosemirror_shaped(txn, fragment) {
|
|
48
81
|
return None;
|
|
49
82
|
}
|
|
50
|
-
let mut
|
|
83
|
+
let mut em = Emitter::new();
|
|
51
84
|
for node in fragment.children(txn) {
|
|
52
85
|
match node {
|
|
53
|
-
XmlOut::Element(e) => render_block_tree(txn, &e, &mut
|
|
86
|
+
XmlOut::Element(e) => render_block_tree(txn, &e, &mut em, rules),
|
|
54
87
|
// y-prosemirror never writes a bare text run at the root, but a
|
|
55
88
|
// crafted doc can; render it rather than drop it.
|
|
56
|
-
XmlOut::Text(t) => render_text_runs(txn, &t, &mut
|
|
89
|
+
XmlOut::Text(t) => render_text_runs(txn, &t, &mut em, rules),
|
|
57
90
|
// Fragments can't nest as children in yrs, so this arm shouldn't
|
|
58
91
|
// be reachable; it exists because the match must be exhaustive,
|
|
59
92
|
// and escaping the text is the safe degradation.
|
|
60
|
-
XmlOut::Fragment(f) =>
|
|
93
|
+
XmlOut::Fragment(f) => em.push_str(&escape_text(&f.get_string(txn))),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
Some(em.into_segments())
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// Rule-free rendering to a plain string — the fixture-parity surface most
|
|
100
|
+
/// tests pin. With no callback rules, segments always flatten.
|
|
101
|
+
#[cfg(test)]
|
|
102
|
+
pub fn render<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> Option<String> {
|
|
103
|
+
render_segments(txn, fragment, &Rules::empty()).map(|segs| {
|
|
104
|
+
crate::render_rules::flatten(segs)
|
|
105
|
+
.into_html()
|
|
106
|
+
.expect("no callback rules registered")
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/// The node types the built-in arms cover (both Tiptap and schema-basic
|
|
111
|
+
/// spellings). Everything else needs a rule.
|
|
112
|
+
pub fn is_builtin(ty: &str) -> bool {
|
|
113
|
+
matches!(
|
|
114
|
+
ty,
|
|
115
|
+
"paragraph"
|
|
116
|
+
| "heading"
|
|
117
|
+
| "codeBlock"
|
|
118
|
+
| "code_block"
|
|
119
|
+
| "blockquote"
|
|
120
|
+
| "bulletList"
|
|
121
|
+
| "bullet_list"
|
|
122
|
+
| "orderedList"
|
|
123
|
+
| "ordered_list"
|
|
124
|
+
| "listItem"
|
|
125
|
+
| "list_item"
|
|
126
|
+
| "table"
|
|
127
|
+
| "tableRow"
|
|
128
|
+
| "table_row"
|
|
129
|
+
| "tableHeader"
|
|
130
|
+
| "table_header"
|
|
131
|
+
| "tableCell"
|
|
132
|
+
| "table_cell"
|
|
133
|
+
| "horizontalRule"
|
|
134
|
+
| "horizontal_rule"
|
|
135
|
+
| "image"
|
|
136
|
+
| "hardBreak"
|
|
137
|
+
| "hard_break"
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/// Walk the document and record what each node type actually looks like —
|
|
142
|
+
/// the discovery aid behind `Y::ProseMirror#node_types`. It records facts:
|
|
143
|
+
/// counts, attribute names, child element types, and whether text runs were
|
|
144
|
+
/// seen. The inline-vs-blocks distinction itself is editor-schema knowledge
|
|
145
|
+
/// the storage never serializes, so the rule author reads it off the child
|
|
146
|
+
/// types and text flags.
|
|
147
|
+
pub fn collect_node_types<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> Option<TypeMap> {
|
|
148
|
+
if !is_prosemirror_shaped(txn, fragment) {
|
|
149
|
+
return None;
|
|
150
|
+
}
|
|
151
|
+
let mut map = TypeMap::new();
|
|
152
|
+
for node in fragment.children(txn) {
|
|
153
|
+
if let XmlOut::Element(e) = node {
|
|
154
|
+
observe(txn, &e, &mut map, 0);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
Some(map)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
fn observe<T: ReadTxn>(txn: &T, e: &XmlElementRef, map: &mut TypeMap, depth: usize) {
|
|
161
|
+
let ty = e.tag().to_string();
|
|
162
|
+
let info = map.entry(ty.clone()).or_default();
|
|
163
|
+
info.count += 1;
|
|
164
|
+
for (key, _) in e.attributes(txn) {
|
|
165
|
+
info.attrs.insert(key.to_string());
|
|
166
|
+
}
|
|
167
|
+
if depth >= MAX_DEPTH {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
for child in e.children(txn) {
|
|
171
|
+
match child {
|
|
172
|
+
XmlOut::Text(_) => map.get_mut(&ty).expect("just inserted").text = true,
|
|
173
|
+
XmlOut::Element(el) => {
|
|
174
|
+
map.get_mut(&ty)
|
|
175
|
+
.expect("just inserted")
|
|
176
|
+
.children
|
|
177
|
+
.insert(el.tag().to_string());
|
|
178
|
+
observe(txn, &el, map, depth + 1);
|
|
179
|
+
}
|
|
180
|
+
XmlOut::Fragment(_) => {}
|
|
61
181
|
}
|
|
62
182
|
}
|
|
63
|
-
Some(out)
|
|
64
183
|
}
|
|
65
184
|
|
|
66
185
|
/// A root is ProseMirror-shaped when it's empty or its first child is a block
|
|
@@ -75,122 +194,108 @@ fn is_prosemirror_shaped<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> bool
|
|
|
75
194
|
}
|
|
76
195
|
|
|
77
196
|
/// Walk one top-level block and everything under it on a heap stack.
|
|
78
|
-
fn render_block_tree<T: ReadTxn>(txn: &T, root: &XmlElementRef,
|
|
197
|
+
fn render_block_tree<T: ReadTxn>(txn: &T, root: &XmlElementRef, em: &mut Emitter, rules: &Rules) {
|
|
79
198
|
let mut stack: Vec<Work> = vec![Work::Open(root.clone(), 0)];
|
|
80
199
|
while let Some(work) = stack.pop() {
|
|
81
200
|
match work {
|
|
82
|
-
Work::Close(tag) =>
|
|
83
|
-
Work::
|
|
201
|
+
Work::Close(tag) => em.push_str(tag),
|
|
202
|
+
Work::CloseOwned(tag) => em.push_str(&tag),
|
|
203
|
+
Work::EndDeferred {
|
|
204
|
+
node_type,
|
|
205
|
+
attrs_json,
|
|
206
|
+
child_types,
|
|
207
|
+
} => {
|
|
208
|
+
let content = em.end_frame();
|
|
209
|
+
em.emit_deferred(node_type, attrs_json, child_types, content);
|
|
210
|
+
}
|
|
211
|
+
Work::Open(node, depth) => open_block(txn, &node, depth, em, &mut stack, rules),
|
|
84
212
|
}
|
|
85
213
|
}
|
|
86
214
|
}
|
|
87
215
|
|
|
88
|
-
/// Render one block.
|
|
89
|
-
///
|
|
90
|
-
///
|
|
216
|
+
/// Render one block. A registered rule wins over the built-in arms (so apps
|
|
217
|
+
/// can extend the schema or override a built-in). Text blocks (paragraph,
|
|
218
|
+
/// heading, code) render in full on the spot; container blocks emit their
|
|
219
|
+
/// opening tag and defer their children (and matching `Close`) to the stack.
|
|
91
220
|
fn open_block<T: ReadTxn>(
|
|
92
221
|
txn: &T,
|
|
93
222
|
e: &XmlElementRef,
|
|
94
223
|
depth: usize,
|
|
95
|
-
|
|
224
|
+
em: &mut Emitter,
|
|
96
225
|
stack: &mut Vec<Work>,
|
|
226
|
+
rules: &Rules,
|
|
97
227
|
) {
|
|
228
|
+
if let Some(rule) = rules.nodes.get(e.tag().as_ref()) {
|
|
229
|
+
open_rule_block(txn, e, rule, depth, em, stack, rules);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
98
232
|
match e.tag().as_ref() {
|
|
99
233
|
"paragraph" => {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
234
|
+
em.push_str("<p>");
|
|
235
|
+
render_inline(txn, e, 0, em, rules);
|
|
236
|
+
em.push_str("</p>");
|
|
103
237
|
}
|
|
104
238
|
"heading" => {
|
|
105
239
|
let level = num_attr(txn, e, "level").unwrap_or(1).clamp(1, 6);
|
|
106
240
|
let tag = ['1', '2', '3', '4', '5', '6'][(level - 1) as usize];
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
241
|
+
em.push_str("<h");
|
|
242
|
+
em.push(tag);
|
|
243
|
+
em.push('>');
|
|
244
|
+
render_inline(txn, e, 0, em, rules);
|
|
245
|
+
em.push_str("</h");
|
|
246
|
+
em.push(tag);
|
|
247
|
+
em.push('>');
|
|
114
248
|
}
|
|
115
249
|
"codeBlock" | "code_block" => {
|
|
116
|
-
|
|
250
|
+
em.push_str("<pre><code");
|
|
117
251
|
if let Some(lang) = str_attr(txn, e, "language").filter(|l| !l.is_empty()) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
out.push('>');
|
|
123
|
-
out.push_str(&escape_text(&code_text(txn, e)));
|
|
124
|
-
out.push_str("</code></pre>");
|
|
125
|
-
}
|
|
126
|
-
"blockquote" => open_container(txn, e, depth, "<blockquote>", "</blockquote>", out, stack),
|
|
127
|
-
"bulletList" | "bullet_list" => open_container(txn, e, depth, "<ul>", "</ul>", out, stack),
|
|
128
|
-
"orderedList" | "ordered_list" => {
|
|
129
|
-
match num_attr(txn, e, "start") {
|
|
130
|
-
Some(start) if start != 1 => {
|
|
131
|
-
out.push_str("<ol start=\"");
|
|
132
|
-
out.push_str(&start.to_string());
|
|
133
|
-
out.push_str("\">");
|
|
134
|
-
}
|
|
135
|
-
_ => out.push_str("<ol>"),
|
|
252
|
+
em.push_str(" class=\"language-");
|
|
253
|
+
em.push_str(&escape_attr(&lang));
|
|
254
|
+
em.push('"');
|
|
136
255
|
}
|
|
137
|
-
|
|
256
|
+
em.push('>');
|
|
257
|
+
em.push_str(&escape_text(&code_text(txn, e)));
|
|
258
|
+
em.push_str("</code></pre>");
|
|
138
259
|
}
|
|
139
|
-
"
|
|
140
|
-
"taskList" | "task_list" => open_container(
|
|
260
|
+
"blockquote" => open_container(
|
|
141
261
|
txn,
|
|
142
262
|
e,
|
|
143
263
|
depth,
|
|
144
|
-
"<
|
|
145
|
-
"</
|
|
146
|
-
|
|
264
|
+
"<blockquote>",
|
|
265
|
+
"</blockquote>",
|
|
266
|
+
em,
|
|
147
267
|
stack,
|
|
268
|
+
rules,
|
|
148
269
|
),
|
|
149
|
-
"
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
270
|
+
"bulletList" | "bullet_list" => {
|
|
271
|
+
open_container(txn, e, depth, "<ul>", "</ul>", em, stack, rules)
|
|
272
|
+
}
|
|
273
|
+
"orderedList" | "ordered_list" => {
|
|
274
|
+
match num_attr(txn, e, "start") {
|
|
275
|
+
Some(start) if start != 1 => {
|
|
276
|
+
em.push_str("<ol start=\"");
|
|
277
|
+
em.push_str(&start.to_string());
|
|
278
|
+
em.push_str("\">");
|
|
279
|
+
}
|
|
280
|
+
_ => em.push_str("<ol>"),
|
|
156
281
|
}
|
|
157
|
-
|
|
158
|
-
|
|
282
|
+
push_block_children(txn, e, depth, "</ol>", em, stack, rules);
|
|
283
|
+
}
|
|
284
|
+
"listItem" | "list_item" => {
|
|
285
|
+
open_container(txn, e, depth, "<li>", "</li>", em, stack, rules)
|
|
159
286
|
}
|
|
160
287
|
"table" => {
|
|
161
|
-
|
|
162
|
-
push_block_children(txn, e, depth, "</tbody></table>",
|
|
163
|
-
}
|
|
164
|
-
"tableRow" | "table_row" => open_container(txn, e, depth, "<tr>", "</tr>", out, stack),
|
|
165
|
-
"tableHeader" | "table_header" => open_cell(txn, e, depth, "th", "</th>", out, stack),
|
|
166
|
-
"tableCell" | "table_cell" => open_cell(txn, e, depth, "td", "</td>", out, stack),
|
|
167
|
-
// The details family follows tiptap-php (the extension is Tiptap Pro,
|
|
168
|
-
// so there's no free getHTML() to capture against).
|
|
169
|
-
"details" => {
|
|
170
|
-
let open = if bool_attr(txn, e, "open") {
|
|
171
|
-
"<details open=\"open\">"
|
|
172
|
-
} else {
|
|
173
|
-
"<details>"
|
|
174
|
-
};
|
|
175
|
-
open_container(txn, e, depth, open, "</details>", out, stack);
|
|
288
|
+
em.push_str("<table><tbody>");
|
|
289
|
+
push_block_children(txn, e, depth, "</tbody></table>", em, stack, rules);
|
|
176
290
|
}
|
|
177
|
-
"
|
|
178
|
-
|
|
179
|
-
out.push_str(&render_inline(txn, e, 0));
|
|
180
|
-
out.push_str("</summary>");
|
|
291
|
+
"tableRow" | "table_row" => {
|
|
292
|
+
open_container(txn, e, depth, "<tr>", "</tr>", em, stack, rules)
|
|
181
293
|
}
|
|
182
|
-
"
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
"</div>",
|
|
188
|
-
out,
|
|
189
|
-
stack,
|
|
190
|
-
),
|
|
191
|
-
"horizontalRule" | "horizontal_rule" => out.push_str("<hr>"),
|
|
192
|
-
"image" => render_image(txn, e, out),
|
|
193
|
-
"hardBreak" | "hard_break" => out.push_str("<br>"),
|
|
294
|
+
"tableHeader" | "table_header" => open_cell(txn, e, depth, "th", "</th>", em, stack, rules),
|
|
295
|
+
"tableCell" | "table_cell" => open_cell(txn, e, depth, "td", "</td>", em, stack, rules),
|
|
296
|
+
"horizontalRule" | "horizontal_rule" => em.push_str("<hr>"),
|
|
297
|
+
"image" => render_image(txn, e, em),
|
|
298
|
+
"hardBreak" | "hard_break" => em.push_str("<br>"),
|
|
194
299
|
// Unknown block: keep its content rather than dropping it. If it holds
|
|
195
300
|
// child blocks, render them with no wrapper; otherwise treat it as a
|
|
196
301
|
// text block.
|
|
@@ -198,144 +303,309 @@ fn open_block<T: ReadTxn>(
|
|
|
198
303
|
if has_element_child(txn, e) {
|
|
199
304
|
// Renders its direct text runs, then the children, with no
|
|
200
305
|
// invented wrapper tags.
|
|
201
|
-
push_block_children(txn, e, depth, "",
|
|
306
|
+
push_block_children(txn, e, depth, "", em, stack, rules);
|
|
202
307
|
} else {
|
|
203
|
-
|
|
308
|
+
em.begin_frame();
|
|
309
|
+
render_inline(txn, e, 0, em, rules);
|
|
310
|
+
let inline = em.end_frame();
|
|
204
311
|
if !inline.is_empty() {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
312
|
+
em.push_str("<p>");
|
|
313
|
+
em.append(inline);
|
|
314
|
+
em.push_str("</p>");
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/// Render a block through a registered rule. Declarative rules emit the tag,
|
|
322
|
+
/// resolved attributes, and template text here; callback rules capture their
|
|
323
|
+
/// children into a frame and defer the markup to the caller.
|
|
324
|
+
#[allow(clippy::too_many_arguments)]
|
|
325
|
+
fn open_rule_block<T: ReadTxn>(
|
|
326
|
+
txn: &T,
|
|
327
|
+
e: &XmlElementRef,
|
|
328
|
+
rule: &NodeRule,
|
|
329
|
+
depth: usize,
|
|
330
|
+
em: &mut Emitter,
|
|
331
|
+
stack: &mut Vec<Work>,
|
|
332
|
+
rules: &Rules,
|
|
333
|
+
) {
|
|
334
|
+
let ty = e.tag().to_string();
|
|
335
|
+
let (tag, void, attrs, text, content) = match rule {
|
|
336
|
+
NodeRule::Callback { content } => {
|
|
337
|
+
em.begin_frame();
|
|
338
|
+
// Children render into the frame; EndDeferred seals it. Blocks go
|
|
339
|
+
// via the stack (pushed above the marker, so they complete
|
|
340
|
+
// first); inline content renders now.
|
|
341
|
+
stack.push(Work::EndDeferred {
|
|
342
|
+
node_type: ty,
|
|
343
|
+
attrs_json: xml_attrs_json(txn, e),
|
|
344
|
+
child_types: element_child_types(txn, e),
|
|
345
|
+
});
|
|
346
|
+
match content {
|
|
347
|
+
Content::Inline => render_inline(txn, e, 0, em, rules),
|
|
348
|
+
Content::Blocks => {
|
|
349
|
+
render_stray_text(txn, e, em, rules);
|
|
350
|
+
if depth < MAX_DEPTH {
|
|
351
|
+
for child in element_children(txn, e).into_iter().rev() {
|
|
352
|
+
stack.push(Work::Open(child, depth + 1));
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
Content::None => {}
|
|
357
|
+
}
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
NodeRule::Declarative {
|
|
361
|
+
tag,
|
|
362
|
+
void,
|
|
363
|
+
attrs,
|
|
364
|
+
text,
|
|
365
|
+
content,
|
|
366
|
+
} => (tag, *void, attrs, text, *content),
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
em.push('<');
|
|
370
|
+
em.push_str(tag);
|
|
371
|
+
for (name, parts) in attrs {
|
|
372
|
+
if let Some(value) = resolve_parts(parts, |r| xml_ref_attr(txn, e, r)) {
|
|
373
|
+
em.push(' ');
|
|
374
|
+
em.push_str(name);
|
|
375
|
+
em.push_str("=\"");
|
|
376
|
+
em.push_str(&escape_attr(&value));
|
|
377
|
+
em.push('\"');
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
em.push('>');
|
|
381
|
+
if void {
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if let Some(text) = text {
|
|
385
|
+
if let Some(value) = resolve_parts(text, |r| xml_ref_attr(txn, e, r)) {
|
|
386
|
+
em.push_str(&escape_text(&value));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
match content {
|
|
390
|
+
Content::Inline => {
|
|
391
|
+
render_inline(txn, e, 0, em, rules);
|
|
392
|
+
em.push_str("</");
|
|
393
|
+
em.push_str(tag);
|
|
394
|
+
em.push('>');
|
|
395
|
+
}
|
|
396
|
+
Content::Blocks => {
|
|
397
|
+
render_stray_text(txn, e, em, rules);
|
|
398
|
+
stack.push(Work::CloseOwned(format!("</{tag}>")));
|
|
399
|
+
if depth < MAX_DEPTH {
|
|
400
|
+
for child in element_children(txn, e).into_iter().rev() {
|
|
401
|
+
stack.push(Work::Open(child, depth + 1));
|
|
208
402
|
}
|
|
209
403
|
}
|
|
210
404
|
}
|
|
405
|
+
Content::None => {
|
|
406
|
+
em.push_str("</");
|
|
407
|
+
em.push_str(tag);
|
|
408
|
+
em.push('>');
|
|
409
|
+
}
|
|
211
410
|
}
|
|
212
411
|
}
|
|
213
412
|
|
|
413
|
+
#[allow(clippy::too_many_arguments)]
|
|
214
414
|
fn open_container<T: ReadTxn>(
|
|
215
415
|
txn: &T,
|
|
216
416
|
e: &XmlElementRef,
|
|
217
417
|
depth: usize,
|
|
218
418
|
open: &str,
|
|
219
419
|
close: &'static str,
|
|
220
|
-
|
|
420
|
+
em: &mut Emitter,
|
|
221
421
|
stack: &mut Vec<Work>,
|
|
422
|
+
rules: &Rules,
|
|
222
423
|
) {
|
|
223
|
-
|
|
224
|
-
push_block_children(txn, e, depth, close,
|
|
424
|
+
em.push_str(open);
|
|
425
|
+
push_block_children(txn, e, depth, close, em, stack, rules);
|
|
225
426
|
}
|
|
226
427
|
|
|
227
428
|
/// A table cell: `<th>`/`<td>` carrying colspan/rowspan (default 1, always
|
|
228
429
|
/// emitted, matching Tiptap).
|
|
430
|
+
#[allow(clippy::too_many_arguments)]
|
|
229
431
|
fn open_cell<T: ReadTxn>(
|
|
230
432
|
txn: &T,
|
|
231
433
|
e: &XmlElementRef,
|
|
232
434
|
depth: usize,
|
|
233
435
|
tag: &str,
|
|
234
436
|
close: &'static str,
|
|
235
|
-
|
|
437
|
+
em: &mut Emitter,
|
|
236
438
|
stack: &mut Vec<Work>,
|
|
439
|
+
rules: &Rules,
|
|
237
440
|
) {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
push_block_children(txn, e, depth, close,
|
|
441
|
+
em.push('<');
|
|
442
|
+
em.push_str(tag);
|
|
443
|
+
em.push_str(" colspan=\"");
|
|
444
|
+
em.push_str(&num_attr(txn, e, "colspan").unwrap_or(1).to_string());
|
|
445
|
+
em.push_str("\" rowspan=\"");
|
|
446
|
+
em.push_str(&num_attr(txn, e, "rowspan").unwrap_or(1).to_string());
|
|
447
|
+
em.push_str("\">");
|
|
448
|
+
push_block_children(txn, e, depth, close, em, stack, rules);
|
|
246
449
|
}
|
|
247
450
|
|
|
248
451
|
/// Defer a node's child *elements* onto the stack, closing tag below them, so
|
|
249
452
|
/// they render in order and the tag closes after. Any direct text runs render
|
|
250
|
-
///
|
|
251
|
-
///
|
|
252
|
-
///
|
|
453
|
+
/// first: schema-valid documents never put bare text in a container, but a
|
|
454
|
+
/// crafted one can, and dropping it would lose content. Past `MAX_DEPTH` the
|
|
455
|
+
/// children are dropped but the tag still closes, keeping the output well
|
|
253
456
|
/// formed.
|
|
457
|
+
#[allow(clippy::too_many_arguments)]
|
|
254
458
|
fn push_block_children<T: ReadTxn>(
|
|
255
459
|
txn: &T,
|
|
256
460
|
e: &XmlElementRef,
|
|
257
461
|
depth: usize,
|
|
258
462
|
close: &'static str,
|
|
259
|
-
|
|
463
|
+
em: &mut Emitter,
|
|
260
464
|
stack: &mut Vec<Work>,
|
|
465
|
+
rules: &Rules,
|
|
261
466
|
) {
|
|
262
|
-
|
|
263
|
-
if let XmlOut::Text(t) = node {
|
|
264
|
-
render_text_runs(txn, &t, out);
|
|
265
|
-
}
|
|
266
|
-
}
|
|
467
|
+
render_stray_text(txn, e, em, rules);
|
|
267
468
|
stack.push(Work::Close(close));
|
|
268
469
|
if depth >= MAX_DEPTH {
|
|
269
470
|
return;
|
|
270
471
|
}
|
|
271
|
-
|
|
272
|
-
.
|
|
472
|
+
for child in element_children(txn, e).into_iter().rev() {
|
|
473
|
+
stack.push(Work::Open(child, depth + 1));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/// Direct text runs jammed into a container (schema-valid documents have
|
|
478
|
+
/// none); rendered rather than dropped.
|
|
479
|
+
fn render_stray_text<T: ReadTxn>(txn: &T, e: &XmlElementRef, em: &mut Emitter, rules: &Rules) {
|
|
480
|
+
for node in e.children(txn) {
|
|
481
|
+
if let XmlOut::Text(t) = node {
|
|
482
|
+
render_text_runs(txn, &t, em, rules);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
fn element_children<T: ReadTxn>(txn: &T, e: &XmlElementRef) -> Vec<XmlElementRef> {
|
|
488
|
+
e.children(txn)
|
|
273
489
|
.filter_map(|c| match c {
|
|
274
490
|
XmlOut::Element(el) => Some(el),
|
|
275
491
|
_ => None,
|
|
276
492
|
})
|
|
277
|
-
.collect()
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
493
|
+
.collect()
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/// The node type (tag) of every element child, in document order — handed to
|
|
497
|
+
/// callback rules as `node.child_types` for the structural facts `attrs` and
|
|
498
|
+
/// the rendered content can't answer.
|
|
499
|
+
fn element_child_types<T: ReadTxn>(txn: &T, e: &XmlElementRef) -> Vec<String> {
|
|
500
|
+
element_children(txn, e)
|
|
501
|
+
.iter()
|
|
502
|
+
.map(|el| el.tag().to_string())
|
|
503
|
+
.collect()
|
|
281
504
|
}
|
|
282
505
|
|
|
283
506
|
/// Render a text block's inline content: text runs (with their marks) and
|
|
284
|
-
/// inline element nodes (hard breaks,
|
|
285
|
-
///
|
|
286
|
-
///
|
|
287
|
-
|
|
288
|
-
|
|
507
|
+
/// inline element nodes (hard breaks, inline images). A registered
|
|
508
|
+
/// rule wins over the built-in inline nodes here too. An unknown inline node
|
|
509
|
+
/// keeps its text instead of vanishing; `depth` caps that recursion on a
|
|
510
|
+
/// crafted nest of unknowns.
|
|
511
|
+
fn render_inline<T: ReadTxn>(
|
|
512
|
+
txn: &T,
|
|
513
|
+
e: &XmlElementRef,
|
|
514
|
+
depth: usize,
|
|
515
|
+
em: &mut Emitter,
|
|
516
|
+
rules: &Rules,
|
|
517
|
+
) {
|
|
289
518
|
for node in e.children(txn) {
|
|
290
519
|
match node {
|
|
291
|
-
XmlOut::Text(t) => render_text_runs(txn, &t,
|
|
292
|
-
XmlOut::Element(child) =>
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
520
|
+
XmlOut::Text(t) => render_text_runs(txn, &t, em, rules),
|
|
521
|
+
XmlOut::Element(child) => {
|
|
522
|
+
if let Some(rule) = rules.nodes.get(child.tag().as_ref()) {
|
|
523
|
+
render_rule_inline(txn, &child, rule, depth, em, rules);
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
match child.tag().as_ref() {
|
|
527
|
+
"hardBreak" | "hard_break" => em.push_str("<br>"),
|
|
528
|
+
"image" => render_image(txn, &child, em),
|
|
529
|
+
_ => {
|
|
530
|
+
if depth < MAX_DEPTH {
|
|
531
|
+
render_inline(txn, &child, depth + 1, em, rules);
|
|
532
|
+
}
|
|
299
533
|
}
|
|
300
534
|
}
|
|
301
|
-
}
|
|
535
|
+
}
|
|
302
536
|
XmlOut::Fragment(_) => {}
|
|
303
537
|
}
|
|
304
538
|
}
|
|
305
|
-
out
|
|
306
539
|
}
|
|
307
540
|
|
|
308
|
-
/// A
|
|
309
|
-
///
|
|
310
|
-
///
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
541
|
+
/// A rule node in inline position (inside a text block). Unlike Lexical's
|
|
542
|
+
/// childless decorators, a ProseMirror inline node can hold real content, so
|
|
543
|
+
/// the content slot renders — as inline content, since there are no blocks
|
|
544
|
+
/// inside a text block (a blocks content slot behaves like inline here).
|
|
545
|
+
fn render_rule_inline<T: ReadTxn>(
|
|
546
|
+
txn: &T,
|
|
547
|
+
e: &XmlElementRef,
|
|
548
|
+
rule: &NodeRule,
|
|
549
|
+
depth: usize,
|
|
550
|
+
em: &mut Emitter,
|
|
551
|
+
rules: &Rules,
|
|
552
|
+
) {
|
|
553
|
+
let (tag, void, attrs, text, content) = match rule {
|
|
554
|
+
NodeRule::Callback { content } => {
|
|
555
|
+
em.begin_frame();
|
|
556
|
+
if *content != Content::None && depth < MAX_DEPTH {
|
|
557
|
+
render_inline(txn, e, depth + 1, em, rules);
|
|
558
|
+
}
|
|
559
|
+
let captured = em.end_frame();
|
|
560
|
+
em.emit_deferred(
|
|
561
|
+
e.tag().to_string(),
|
|
562
|
+
xml_attrs_json(txn, e),
|
|
563
|
+
element_child_types(txn, e),
|
|
564
|
+
captured,
|
|
565
|
+
);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
NodeRule::Declarative {
|
|
569
|
+
tag,
|
|
570
|
+
void,
|
|
571
|
+
attrs,
|
|
572
|
+
text,
|
|
573
|
+
content,
|
|
574
|
+
} => (tag, *void, attrs, text, *content),
|
|
575
|
+
};
|
|
576
|
+
em.push('<');
|
|
577
|
+
em.push_str(tag);
|
|
578
|
+
for (name, parts) in attrs {
|
|
579
|
+
if let Some(value) = resolve_parts(parts, |r| xml_ref_attr(txn, e, r)) {
|
|
580
|
+
em.push(' ');
|
|
581
|
+
em.push_str(name);
|
|
582
|
+
em.push_str("=\"");
|
|
583
|
+
em.push_str(&escape_attr(&value));
|
|
584
|
+
em.push('\"');
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
em.push('>');
|
|
588
|
+
if void {
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
if let Some(text) = text {
|
|
592
|
+
if let Some(value) = resolve_parts(text, |r| xml_ref_attr(txn, e, r)) {
|
|
593
|
+
em.push_str(&escape_text(&value));
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if content != Content::None && depth < MAX_DEPTH {
|
|
597
|
+
render_inline(txn, e, depth + 1, em, rules);
|
|
598
|
+
}
|
|
599
|
+
em.push_str("</");
|
|
600
|
+
em.push_str(tag);
|
|
601
|
+
em.push('>');
|
|
332
602
|
}
|
|
333
603
|
|
|
334
604
|
/// Emit each formatted run of a Y.XmlText.
|
|
335
|
-
fn render_text_runs<T: ReadTxn>(txn: &T, t: &XmlTextRef,
|
|
605
|
+
fn render_text_runs<T: ReadTxn>(txn: &T, t: &XmlTextRef, em: &mut Emitter, rules: &Rules) {
|
|
336
606
|
for d in t.diff(txn, YChange::identity) {
|
|
337
607
|
if let Out::Any(Any::String(s)) = &d.insert {
|
|
338
|
-
|
|
608
|
+
em.push_str(&render_run(s, d.attributes.as_deref(), rules));
|
|
339
609
|
}
|
|
340
610
|
}
|
|
341
611
|
}
|
|
@@ -346,47 +616,115 @@ fn render_text_runs<T: ReadTxn>(txn: &T, t: &XmlTextRef, out: &mut String) {
|
|
|
346
616
|
/// formatting marks (Tiptap's Code mark excludes them all), but a link still
|
|
347
617
|
/// wraps it — Tiptap can't produce code+link, prosemirror-schema-basic can,
|
|
348
618
|
/// and dropping the link would lose the href.
|
|
349
|
-
|
|
619
|
+
///
|
|
620
|
+
/// A registered mark rule claims its stored name from the built-in wraps
|
|
621
|
+
/// (overriding it) and wraps outside everything; multiple custom marks nest
|
|
622
|
+
/// alphabetically by name, so output is deterministic regardless of
|
|
623
|
+
/// registration order. Overriding replaces only the markup: a claimed
|
|
624
|
+
/// `code` still excludes the other formatting marks, and a claimed
|
|
625
|
+
/// formatting mark stays excluded from a code run.
|
|
626
|
+
fn render_run(text: &str, marks: Option<&Attrs>, rules: &Rules) -> String {
|
|
350
627
|
let mut html = escape_text(text);
|
|
351
628
|
let Some(marks) = marks else {
|
|
352
629
|
return html;
|
|
353
630
|
};
|
|
354
|
-
if has(marks, &["code"]) {
|
|
631
|
+
if has(marks, &["code"], rules) {
|
|
355
632
|
html = wrap(html, "code");
|
|
356
|
-
} else {
|
|
357
|
-
|
|
633
|
+
} else if !marks.contains_key("code") {
|
|
634
|
+
// (A claimed `code` lands here too — on the run, but skipped by the
|
|
635
|
+
// else-if: the rule replaces the wrap, via wrap_custom_marks below,
|
|
636
|
+
// and code's exclusivity over the other formatting marks holds.)
|
|
637
|
+
if has(marks, &["subscript", "sub"], rules) {
|
|
358
638
|
html = wrap(html, "sub");
|
|
359
|
-
} else if has(marks, &["superscript", "sup"]) {
|
|
639
|
+
} else if has(marks, &["superscript", "sup"], rules) {
|
|
360
640
|
html = wrap(html, "sup");
|
|
361
641
|
}
|
|
362
|
-
if has(marks, &["highlight"]) {
|
|
642
|
+
if has(marks, &["highlight"], rules) {
|
|
363
643
|
html = wrap(html, "mark");
|
|
364
644
|
}
|
|
365
|
-
if has(marks, &["underline", "u"]) {
|
|
645
|
+
if has(marks, &["underline", "u"], rules) {
|
|
366
646
|
html = wrap(html, "u");
|
|
367
647
|
}
|
|
368
|
-
if has(marks, &["strike", "s"]) {
|
|
648
|
+
if has(marks, &["strike", "s"], rules) {
|
|
369
649
|
html = wrap(html, "s");
|
|
370
650
|
}
|
|
371
|
-
if has(marks, &["italic", "em"]) {
|
|
651
|
+
if has(marks, &["italic", "em"], rules) {
|
|
372
652
|
html = wrap(html, "em");
|
|
373
653
|
}
|
|
374
|
-
if has(marks, &["bold", "strong"]) {
|
|
654
|
+
if has(marks, &["bold", "strong"], rules) {
|
|
375
655
|
html = wrap(html, "strong");
|
|
376
656
|
}
|
|
377
|
-
if
|
|
378
|
-
let
|
|
379
|
-
|
|
380
|
-
|
|
657
|
+
if !rules.marks.contains_key("textStyle") {
|
|
658
|
+
if let Some(Any::Map(style)) = marks.get("textStyle") {
|
|
659
|
+
let css = text_style_css(style);
|
|
660
|
+
if !css.is_empty() {
|
|
661
|
+
html = format!("<span style=\"{}\">{html}</span>", escape_attr(&css));
|
|
662
|
+
}
|
|
381
663
|
}
|
|
382
664
|
}
|
|
383
665
|
}
|
|
384
|
-
if
|
|
385
|
-
|
|
666
|
+
if !rules.marks.contains_key("link") {
|
|
667
|
+
if let Some(Any::Map(link)) = marks.get("link") {
|
|
668
|
+
html = wrap_link(html, link);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if !rules.marks.is_empty() {
|
|
672
|
+
html = wrap_custom_marks(html, marks, rules);
|
|
386
673
|
}
|
|
387
674
|
html
|
|
388
675
|
}
|
|
389
676
|
|
|
677
|
+
/// Apply the run's registered custom marks, outermost of everything. A code
|
|
678
|
+
/// run keeps its exclusivity under overrides too: only the `code` and `link`
|
|
679
|
+
/// claims themselves may wrap it, matching what the built-in wraps allow —
|
|
680
|
+
/// otherwise overriding a formatting mark would change which marks render on
|
|
681
|
+
/// a code run, not just their markup.
|
|
682
|
+
fn wrap_custom_marks(html: String, marks: &Attrs, rules: &Rules) -> String {
|
|
683
|
+
let code = marks.contains_key("code");
|
|
684
|
+
let mut names: Vec<&str> = rules
|
|
685
|
+
.marks
|
|
686
|
+
.keys()
|
|
687
|
+
.map(String::as_str)
|
|
688
|
+
.filter(|name| marks.contains_key(*name))
|
|
689
|
+
.filter(|name| !code || matches!(*name, "code" | "link"))
|
|
690
|
+
.collect();
|
|
691
|
+
names.sort_unstable();
|
|
692
|
+
let mut html = html;
|
|
693
|
+
for name in names {
|
|
694
|
+
html = wrap_custom_mark(html, &rules.marks[name], marks.get(name));
|
|
695
|
+
}
|
|
696
|
+
html
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
fn wrap_custom_mark(inner: String, rule: &MarkRule, value: Option<&Any>) -> String {
|
|
700
|
+
let mut out = String::from("<");
|
|
701
|
+
out.push_str(&rule.tag);
|
|
702
|
+
for (attr, parts) in &rule.attrs {
|
|
703
|
+
if let Some(v) = resolve_parts(parts, |r| mark_ref_attr(value, r)) {
|
|
704
|
+
out.push(' ');
|
|
705
|
+
out.push_str(attr);
|
|
706
|
+
out.push_str("=\"");
|
|
707
|
+
out.push_str(&escape_attr(&v));
|
|
708
|
+
out.push('"');
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
out.push('>');
|
|
712
|
+
out.push_str(&inner);
|
|
713
|
+
out.push_str("</");
|
|
714
|
+
out.push_str(&rule.tag);
|
|
715
|
+
out.push('>');
|
|
716
|
+
out
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/// An attribute reference on a custom mark: y-prosemirror stores mark attrs
|
|
720
|
+
/// as a map under the mark's name (bool `true` when the mark has none).
|
|
721
|
+
fn mark_ref_attr(value: Option<&Any>, name: &str) -> Option<String> {
|
|
722
|
+
match value {
|
|
723
|
+
Some(Any::Map(map)) => any_attr_string(map.get(name)?),
|
|
724
|
+
_ => None,
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
390
728
|
/// The `style` string for a textStyle mark (Tiptap's Color/FontFamily/etc.
|
|
391
729
|
/// extensions all store their value as a textStyle attribute). Attributes are
|
|
392
730
|
/// camelCase CSS property names; unset ones sit in the map as explicit nulls.
|
|
@@ -466,26 +804,29 @@ fn wrap_link(inner: String, link: &std::collections::HashMap<String, Any>) -> St
|
|
|
466
804
|
}
|
|
467
805
|
|
|
468
806
|
/// `<img>` with attribute order src, alt, title, skipping absent/null ones.
|
|
469
|
-
fn render_image<T: ReadTxn>(txn: &T, e: &XmlElementRef,
|
|
470
|
-
|
|
807
|
+
fn render_image<T: ReadTxn>(txn: &T, e: &XmlElementRef, em: &mut Emitter) {
|
|
808
|
+
em.push_str("<img");
|
|
471
809
|
for (attr, html) in [("src", "src"), ("alt", "alt"), ("title", "title")] {
|
|
472
810
|
if let Some(v) = str_attr(txn, e, attr) {
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
811
|
+
em.push(' ');
|
|
812
|
+
em.push_str(html);
|
|
813
|
+
em.push_str("=\"");
|
|
814
|
+
em.push_str(&escape_attr(&v));
|
|
815
|
+
em.push('"');
|
|
478
816
|
}
|
|
479
817
|
}
|
|
480
|
-
|
|
818
|
+
em.push('>');
|
|
481
819
|
}
|
|
482
820
|
|
|
483
821
|
fn wrap(inner: String, tag: &str) -> String {
|
|
484
822
|
format!("<{tag}>{inner}</{tag}>")
|
|
485
823
|
}
|
|
486
824
|
|
|
487
|
-
|
|
488
|
-
|
|
825
|
+
/// A mark is present and not claimed by a registered rule for that stored
|
|
826
|
+
/// name (a rule overrides the built-in wrap).
|
|
827
|
+
fn has(marks: &Attrs, keys: &[&str], rules: &Rules) -> bool {
|
|
828
|
+
keys.iter()
|
|
829
|
+
.any(|k| marks.contains_key(*k) && !rules.marks.contains_key(*k))
|
|
489
830
|
}
|
|
490
831
|
|
|
491
832
|
fn has_element_child<T: ReadTxn>(txn: &T, e: &XmlElementRef) -> bool {
|
|
@@ -522,10 +863,6 @@ fn num_attr<T: ReadTxn>(txn: &T, e: &XmlElementRef, name: &str) -> Option<i64> {
|
|
|
522
863
|
}
|
|
523
864
|
}
|
|
524
865
|
|
|
525
|
-
fn bool_attr<T: ReadTxn>(txn: &T, e: &XmlElementRef, name: &str) -> bool {
|
|
526
|
-
matches!(e.get_attribute(txn, name), Some(Out::Any(Any::Bool(true))))
|
|
527
|
-
}
|
|
528
|
-
|
|
529
866
|
/// Text-content escaping, matching the browser serializer: `&`, `<`, `>`.
|
|
530
867
|
fn escape_text(s: &str) -> String {
|
|
531
868
|
s.replace('&', "&")
|
|
@@ -562,20 +899,27 @@ mod tests {
|
|
|
562
899
|
a
|
|
563
900
|
}
|
|
564
901
|
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
///
|
|
570
|
-
///
|
|
902
|
+
fn run(text: &str, marks: Option<&Attrs>) -> String {
|
|
903
|
+
render_run(text, marks, &Rules::empty())
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/// Core rendering of the captured Tiptap document, pinned as a golden
|
|
907
|
+
/// (`.core.html`). The task list renders through the unknown-container
|
|
908
|
+
/// fallback here (unwrapped children); the external truth — byte parity
|
|
909
|
+
/// with the editor's own `getHTML()` — is held at the Ruby layer, where
|
|
910
|
+
/// `Y::Tiptap` completes the schema. The fixture covers headings, every
|
|
911
|
+
/// mark and combination, links, escaping, blockquote, nested bullet and
|
|
912
|
+
/// ordered lists (with a `start`), a task list, code blocks with and
|
|
913
|
+
/// without a language, a hard break, a horizontal rule, an image, and
|
|
914
|
+
/// the trailing empty paragraph Tiptap keeps.
|
|
571
915
|
#[test]
|
|
572
|
-
fn
|
|
916
|
+
fn core_rendering_of_the_tiptap_fixture_is_pinned() {
|
|
573
917
|
let doc = doc_from(include_bytes!("fixtures/prosemirror_tiptap.bin"));
|
|
574
918
|
let txn = doc.transact();
|
|
575
919
|
let frag = txn.get_xml_fragment("default").unwrap();
|
|
576
920
|
assert_eq!(
|
|
577
921
|
render(&txn, &frag).unwrap(),
|
|
578
|
-
include_str!("fixtures/prosemirror_tiptap.html")
|
|
922
|
+
include_str!("fixtures/prosemirror_tiptap.core.html")
|
|
579
923
|
);
|
|
580
924
|
}
|
|
581
925
|
|
|
@@ -595,25 +939,19 @@ mod tests {
|
|
|
595
939
|
|
|
596
940
|
#[test]
|
|
597
941
|
fn marks_nest_in_tiptaps_serializer_order() {
|
|
598
|
-
assert_eq!(
|
|
599
|
-
assert_eq!(
|
|
600
|
-
|
|
601
|
-
"<strong>x</strong>"
|
|
602
|
-
);
|
|
603
|
-
assert_eq!(render_run("x", Some(&marks(&["italic"]))), "<em>x</em>");
|
|
942
|
+
assert_eq!(run("x", None), "x");
|
|
943
|
+
assert_eq!(run("x", Some(&marks(&["bold"]))), "<strong>x</strong>");
|
|
944
|
+
assert_eq!(run("x", Some(&marks(&["italic"]))), "<em>x</em>");
|
|
604
945
|
// bold wraps italic.
|
|
605
946
|
assert_eq!(
|
|
606
|
-
|
|
947
|
+
run("x", Some(&marks(&["italic", "bold"]))),
|
|
607
948
|
"<strong><em>x</em></strong>"
|
|
608
949
|
);
|
|
609
950
|
// code excludes every other mark.
|
|
610
|
-
assert_eq!(
|
|
611
|
-
render_run("x", Some(&marks(&["code", "bold"]))),
|
|
612
|
-
"<code>x</code>"
|
|
613
|
-
);
|
|
951
|
+
assert_eq!(run("x", Some(&marks(&["code", "bold"]))), "<code>x</code>");
|
|
614
952
|
// Full compatible stack, innermost sub to outermost bold.
|
|
615
953
|
assert_eq!(
|
|
616
|
-
|
|
954
|
+
run(
|
|
617
955
|
"x",
|
|
618
956
|
Some(&marks(&[
|
|
619
957
|
"bold",
|
|
@@ -627,7 +965,7 @@ mod tests {
|
|
|
627
965
|
"<strong><em><s><u><mark><sub>x</sub></mark></u></s></em></strong>"
|
|
628
966
|
);
|
|
629
967
|
// Escaping happens before wrapping.
|
|
630
|
-
assert_eq!(
|
|
968
|
+
assert_eq!(run("<&>", None), "<&>");
|
|
631
969
|
}
|
|
632
970
|
|
|
633
971
|
#[test]
|
|
@@ -642,7 +980,7 @@ mod tests {
|
|
|
642
980
|
let mut a = Attrs::new();
|
|
643
981
|
a.insert("link".into(), Any::Map(Arc::new(link)));
|
|
644
982
|
assert_eq!(
|
|
645
|
-
|
|
983
|
+
run("site", Some(&a)),
|
|
646
984
|
"<a target=\"_blank\" rel=\"noopener\" href=\"https://e.com?a=1&b=2\">site</a>"
|
|
647
985
|
);
|
|
648
986
|
|
|
@@ -656,22 +994,25 @@ mod tests {
|
|
|
656
994
|
let mut a = Attrs::new();
|
|
657
995
|
a.insert("link".into(), Any::Map(Arc::new(link)));
|
|
658
996
|
assert_eq!(
|
|
659
|
-
|
|
997
|
+
run("the doc", Some(&a)),
|
|
660
998
|
"<a class=\"doc-link\" href=\"https://d.example\" title=\"A Doc\">the doc</a>"
|
|
661
999
|
);
|
|
662
1000
|
}
|
|
663
1001
|
|
|
664
|
-
///
|
|
665
|
-
///
|
|
666
|
-
///
|
|
1002
|
+
/// Core rendering of the captured mention document, pinned as a golden
|
|
1003
|
+
/// (`.core.html`). Mention is a Tiptap extension node — an atom with no
|
|
1004
|
+
/// children — so core renders it to nothing; parity with the editor's
|
|
1005
|
+
/// `getHTML()` is held at the Ruby layer by `Y::Tiptap`. The fixture
|
|
1006
|
+
/// holds one mention with a label, one with only an id, and a link
|
|
1007
|
+
/// carrying class and title.
|
|
667
1008
|
#[test]
|
|
668
|
-
fn
|
|
1009
|
+
fn core_rendering_of_the_mention_fixture_is_pinned() {
|
|
669
1010
|
let doc = doc_from(include_bytes!("fixtures/prosemirror_mention.bin"));
|
|
670
1011
|
let txn = doc.transact();
|
|
671
1012
|
let frag = txn.get_xml_fragment("default").unwrap();
|
|
672
1013
|
assert_eq!(
|
|
673
1014
|
render(&txn, &frag).unwrap(),
|
|
674
|
-
include_str!("fixtures/prosemirror_mention.html")
|
|
1015
|
+
include_str!("fixtures/prosemirror_mention.core.html")
|
|
675
1016
|
);
|
|
676
1017
|
}
|
|
677
1018
|
|
|
@@ -733,34 +1074,6 @@ mod tests {
|
|
|
733
1074
|
);
|
|
734
1075
|
}
|
|
735
1076
|
|
|
736
|
-
/// The details family follows tiptap-php's renderHTML (the Tiptap
|
|
737
|
-
/// extension is Pro-only, so tiptap-php is the reference).
|
|
738
|
-
#[test]
|
|
739
|
-
fn renders_the_details_family_per_tiptap_php() {
|
|
740
|
-
let doc = Doc::new();
|
|
741
|
-
let frag = doc.get_or_insert_xml_fragment("default");
|
|
742
|
-
{
|
|
743
|
-
let mut txn = doc.transact_mut();
|
|
744
|
-
let details = frag.push_back(&mut txn, XmlElementPrelim::empty("details"));
|
|
745
|
-
details.insert_attribute(&mut txn, "open", true);
|
|
746
|
-
let summary = details.push_back(&mut txn, XmlElementPrelim::empty("detailsSummary"));
|
|
747
|
-
summary.push_back(&mut txn, XmlTextPrelim::new("More info"));
|
|
748
|
-
let content = details.push_back(&mut txn, XmlElementPrelim::empty("detailsContent"));
|
|
749
|
-
let p = content.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
|
|
750
|
-
p.push_back(&mut txn, XmlTextPrelim::new("The body."));
|
|
751
|
-
|
|
752
|
-
let closed = frag.push_back(&mut txn, XmlElementPrelim::empty("details"));
|
|
753
|
-
closed.push_back(&mut txn, XmlElementPrelim::empty("detailsSummary"));
|
|
754
|
-
}
|
|
755
|
-
let txn = doc.transact();
|
|
756
|
-
assert_eq!(
|
|
757
|
-
render(&txn, &frag).unwrap(),
|
|
758
|
-
"<details open=\"open\"><summary>More info</summary>\
|
|
759
|
-
<div data-type=\"detailsContent\"><p>The body.</p></div></details>\
|
|
760
|
-
<details><summary></summary></details>"
|
|
761
|
-
);
|
|
762
|
-
}
|
|
763
|
-
|
|
764
1077
|
/// prosemirror-schema-basic's `code` mark has no excludes, so a code run
|
|
765
1078
|
/// can also carry a link; the link must survive. (Tiptap's Code mark
|
|
766
1079
|
/// excludes everything, so this shape only comes from schema-basic docs.)
|
|
@@ -772,7 +1085,7 @@ mod tests {
|
|
|
772
1085
|
a.insert("code".into(), Any::Map(Arc::new(HashMap::new())));
|
|
773
1086
|
a.insert("link".into(), Any::Map(Arc::new(link)));
|
|
774
1087
|
assert_eq!(
|
|
775
|
-
|
|
1088
|
+
run("x", Some(&a)),
|
|
776
1089
|
"<a href=\"https://e.com\"><code>x</code></a>"
|
|
777
1090
|
);
|
|
778
1091
|
}
|
|
@@ -893,4 +1206,160 @@ mod tests {
|
|
|
893
1206
|
r#"<a & "b">"#
|
|
894
1207
|
);
|
|
895
1208
|
}
|
|
1209
|
+
|
|
1210
|
+
/// A declarative node rule renders natively: tag, attribute refs resolved
|
|
1211
|
+
/// from the node's attrs, and a blocks content slot. Rules can also
|
|
1212
|
+
/// override a built-in (paragraph here).
|
|
1213
|
+
#[test]
|
|
1214
|
+
fn a_declarative_rule_renders_and_can_override_a_builtin() {
|
|
1215
|
+
let rules = Rules::parse(
|
|
1216
|
+
r#"{ "nodes": {
|
|
1217
|
+
"callout": { "tag": "aside",
|
|
1218
|
+
"attrs": [["class", [{"lit": "callout callout--"}, {"ref": "kind"}]]],
|
|
1219
|
+
"content": "blocks" },
|
|
1220
|
+
"paragraph": { "tag": "div", "attrs": [["class", [{"lit": "para"}]]] } } }"#,
|
|
1221
|
+
)
|
|
1222
|
+
.unwrap();
|
|
1223
|
+
let doc = Doc::new();
|
|
1224
|
+
let frag = doc.get_or_insert_xml_fragment("default");
|
|
1225
|
+
{
|
|
1226
|
+
let mut txn = doc.transact_mut();
|
|
1227
|
+
let callout = frag.push_back(&mut txn, XmlElementPrelim::empty("callout"));
|
|
1228
|
+
callout.insert_attribute(&mut txn, "kind", "warning");
|
|
1229
|
+
let p = callout.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
|
|
1230
|
+
p.push_back(&mut txn, XmlTextPrelim::new("careful"));
|
|
1231
|
+
}
|
|
1232
|
+
let txn = doc.transact();
|
|
1233
|
+
let segs = render_segments(&txn, &frag, &rules).unwrap();
|
|
1234
|
+
assert_eq!(
|
|
1235
|
+
crate::render_rules::flatten(segs).into_html().unwrap(),
|
|
1236
|
+
"<aside class=\"callout callout--warning\"><div class=\"para\">careful</div></aside>"
|
|
1237
|
+
);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/// A callback rule defers: the node comes back as a Deferred segment with
|
|
1241
|
+
/// its attrs as JSON and its children already rendered.
|
|
1242
|
+
#[test]
|
|
1243
|
+
fn a_callback_rule_emits_a_deferred_segment_with_rendered_content() {
|
|
1244
|
+
let rules = Rules::parse(
|
|
1245
|
+
r#"{ "nodes": { "videoEmbed": { "callback": true, "content": "blocks" } } }"#,
|
|
1246
|
+
)
|
|
1247
|
+
.unwrap();
|
|
1248
|
+
let doc = Doc::new();
|
|
1249
|
+
let frag = doc.get_or_insert_xml_fragment("default");
|
|
1250
|
+
{
|
|
1251
|
+
let mut txn = doc.transact_mut();
|
|
1252
|
+
let intro = frag.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
|
|
1253
|
+
intro.push_back(&mut txn, XmlTextPrelim::new("watch:"));
|
|
1254
|
+
let video = frag.push_back(&mut txn, XmlElementPrelim::empty("videoEmbed"));
|
|
1255
|
+
video.insert_attribute(&mut txn, "src", "https://v.example/1");
|
|
1256
|
+
let caption = video.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
|
|
1257
|
+
caption.push_back(&mut txn, XmlTextPrelim::new("the caption"));
|
|
1258
|
+
}
|
|
1259
|
+
let txn = doc.transact();
|
|
1260
|
+
let segs = render_segments(&txn, &frag, &rules).unwrap();
|
|
1261
|
+
assert_eq!(segs.len(), 2);
|
|
1262
|
+
assert!(matches!(&segs[0], Segment::Html(s) if s == "<p>watch:</p>"));
|
|
1263
|
+
let Segment::Deferred {
|
|
1264
|
+
node_type,
|
|
1265
|
+
attrs_json,
|
|
1266
|
+
child_types,
|
|
1267
|
+
content,
|
|
1268
|
+
} = &segs[1]
|
|
1269
|
+
else {
|
|
1270
|
+
panic!("expected a deferred segment");
|
|
1271
|
+
};
|
|
1272
|
+
assert_eq!(node_type, "videoEmbed");
|
|
1273
|
+
assert_eq!(attrs_json, r#"{"src":"https://v.example/1"}"#);
|
|
1274
|
+
assert_eq!(child_types, &["paragraph"]);
|
|
1275
|
+
assert!(matches!(&content[0], Segment::Html(s) if s == "<p>the caption</p>"));
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/// Discovery reports facts per type, and an unknown type annotates as
|
|
1279
|
+
/// unhandled (null) — the signal a rule author filters for.
|
|
1280
|
+
#[test]
|
|
1281
|
+
fn node_type_discovery_reports_unknown_types_as_unhandled() {
|
|
1282
|
+
let doc = Doc::new();
|
|
1283
|
+
let frag = doc.get_or_insert_xml_fragment("default");
|
|
1284
|
+
{
|
|
1285
|
+
let mut txn = doc.transact_mut();
|
|
1286
|
+
let callout = frag.push_back(&mut txn, XmlElementPrelim::empty("callout"));
|
|
1287
|
+
callout.insert_attribute(&mut txn, "kind", "warning");
|
|
1288
|
+
let p = callout.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
|
|
1289
|
+
p.push_back(&mut txn, XmlTextPrelim::new("body"));
|
|
1290
|
+
}
|
|
1291
|
+
let txn = doc.transact();
|
|
1292
|
+
let map = collect_node_types(&txn, &frag).unwrap();
|
|
1293
|
+
let json = crate::render_rules::type_map_json(&map, |ty| {
|
|
1294
|
+
if is_builtin(ty) {
|
|
1295
|
+
Some("builtin")
|
|
1296
|
+
} else {
|
|
1297
|
+
None
|
|
1298
|
+
}
|
|
1299
|
+
});
|
|
1300
|
+
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
|
|
1301
|
+
assert_eq!(v["callout"]["handled"], serde_json::Value::Null);
|
|
1302
|
+
assert_eq!(v["callout"]["attrs"][0], "kind");
|
|
1303
|
+
assert_eq!(v["callout"]["children"][0], "paragraph");
|
|
1304
|
+
assert_eq!(v["paragraph"]["handled"], "builtin");
|
|
1305
|
+
assert_eq!(v["paragraph"]["text"], true);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/// A custom mark claims its stored name from the built-ins and wraps
|
|
1309
|
+
/// outside everything, attribute refs resolving against the mark's map.
|
|
1310
|
+
#[test]
|
|
1311
|
+
fn a_custom_mark_overrides_the_builtin_and_wraps_outermost() {
|
|
1312
|
+
let rules = Rules::parse(
|
|
1313
|
+
r#"{ "marks": {
|
|
1314
|
+
"comment": { "tag": "span", "attrs": [["data-comment-id", [{"ref": "id"}]]] },
|
|
1315
|
+
"bold": { "tag": "b" } } }"#,
|
|
1316
|
+
)
|
|
1317
|
+
.unwrap();
|
|
1318
|
+
let mut comment = HashMap::new();
|
|
1319
|
+
comment.insert("id".to_string(), Any::String("c1".into()));
|
|
1320
|
+
let mut a = Attrs::new();
|
|
1321
|
+
a.insert("bold".into(), Any::Bool(true));
|
|
1322
|
+
a.insert("italic".into(), Any::Bool(true));
|
|
1323
|
+
a.insert("comment".into(), Any::Map(Arc::new(comment)));
|
|
1324
|
+
assert_eq!(
|
|
1325
|
+
render_run("x", Some(&a), &rules),
|
|
1326
|
+
"<span data-comment-id=\"c1\"><b><em>x</em></b></span>"
|
|
1327
|
+
);
|
|
1328
|
+
|
|
1329
|
+
// Overriding code replaces only its tag: the other
|
|
1330
|
+
// formatting marks stay excluded.
|
|
1331
|
+
let kbd = Rules::parse(r#"{ "marks": { "code": { "tag": "kbd" } } }"#).unwrap();
|
|
1332
|
+
let mut a = Attrs::new();
|
|
1333
|
+
a.insert("code".into(), Any::Bool(true));
|
|
1334
|
+
a.insert("bold".into(), Any::Bool(true));
|
|
1335
|
+
assert_eq!(render_run("x", Some(&a), &kbd), "<kbd>x</kbd>");
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/// The exclusivity holds from the other direction too: a claimed
|
|
1339
|
+
/// formatting mark must not sneak onto a code run through the custom
|
|
1340
|
+
/// wrap when the built-in wrap would have excluded it.
|
|
1341
|
+
#[test]
|
|
1342
|
+
fn a_claimed_formatting_mark_stays_excluded_on_a_code_run() {
|
|
1343
|
+
let rules = Rules::parse(r#"{ "marks": { "bold": { "tag": "b" } } }"#).unwrap();
|
|
1344
|
+
let mut a = Attrs::new();
|
|
1345
|
+
a.insert("code".into(), Any::Bool(true));
|
|
1346
|
+
a.insert("bold".into(), Any::Bool(true));
|
|
1347
|
+
assert_eq!(render_run("x", Some(&a), &rules), "<code>x</code>");
|
|
1348
|
+
|
|
1349
|
+
// The link claim is the exception, matching the built-in behavior
|
|
1350
|
+
// (schema-basic can produce code+link; the href must survive).
|
|
1351
|
+
let link_rule =
|
|
1352
|
+
Rules::parse(r#"{ "marks": { "link": { "tag": "a", "attrs": [["href", [{"ref": "href"}]]] }, "bold": { "tag": "b" } } }"#)
|
|
1353
|
+
.unwrap();
|
|
1354
|
+
let mut link = HashMap::new();
|
|
1355
|
+
link.insert("href".to_string(), Any::String("https://e.com".into()));
|
|
1356
|
+
let mut a = Attrs::new();
|
|
1357
|
+
a.insert("code".into(), Any::Bool(true));
|
|
1358
|
+
a.insert("bold".into(), Any::Bool(true));
|
|
1359
|
+
a.insert("link".into(), Any::Map(Arc::new(link)));
|
|
1360
|
+
assert_eq!(
|
|
1361
|
+
render_run("x", Some(&a), &link_rule),
|
|
1362
|
+
"<a href=\"https://e.com\"><code>x</code></a>"
|
|
1363
|
+
);
|
|
1364
|
+
}
|
|
896
1365
|
}
|