yrby 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- //! Output follows `ueberdosis/tiptap-php` (the maintained PHP renderer for
12
- //! ProseMirror JSON) and matches Tiptap's own `getHTML()` byte for byte on the
13
- //! captured fixtures including mentions with one deliberate exception: a
14
- //! table renders as the semantic `<table><tbody>…`, without the
15
- //! `<colgroup>`/`min-width` styling Tiptap's editor view injects (tiptap-php
16
- //! drops it too). The details family follows tiptap-php's renderHTML, since
17
- //! that Tiptap extension is Pro-only.
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 nest in a fixed order (outermost first): link, bold, italic, strike,
20
- //! underline, highlight, then subscript/superscript. `code` excludes every
21
- //! other mark, so a code run is just `<code>`.
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 costs memory, not native
31
- // stack frames; this caps the work a pathological document can demand.
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 pending block on the traversal stack. `Open` renders a node (pushing its
35
- /// block children as more work); `Close` emits a container's end tag once its
36
- /// children are done. Container end tags are all fixed strings.
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 HTML, or `None` when the root
43
- /// isn't ProseMirror-shaped. ProseMirror blocks are plain Y.XmlElement tags; a
44
- /// Lexical root (Y.XmlText children carrying a `__type`) is a different schema
45
- /// and returns `None` instead of a garbled render.
46
- pub fn render<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> Option<String> {
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 out = String::new();
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 out),
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 out),
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) => out.push_str(&escape_text(&f.get_string(txn))),
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, out: &mut String) {
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) => out.push_str(tag),
83
- Work::Open(node, depth) => open_block(txn, &node, depth, out, &mut stack),
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. Text blocks (paragraph, heading, code) render in full on
89
- /// the spot; container blocks emit their opening tag and defer their children
90
- /// (and matching `Close`) to the stack.
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
- out: &mut String,
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
- out.push_str("<p>");
101
- out.push_str(&render_inline(txn, e, 0));
102
- out.push_str("</p>");
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
- out.push_str("<h");
108
- out.push(tag);
109
- out.push('>');
110
- out.push_str(&render_inline(txn, e, 0));
111
- out.push_str("</h");
112
- out.push(tag);
113
- out.push('>');
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
- out.push_str("<pre><code");
250
+ em.push_str("<pre><code");
117
251
  if let Some(lang) = str_attr(txn, e, "language").filter(|l| !l.is_empty()) {
118
- out.push_str(" class=\"language-");
119
- out.push_str(&escape_attr(&lang));
120
- out.push('"');
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
- push_block_children(txn, e, depth, "</ol>", out, stack);
256
+ em.push('>');
257
+ em.push_str(&escape_text(&code_text(txn, e)));
258
+ em.push_str("</code></pre>");
138
259
  }
139
- "listItem" | "list_item" => open_container(txn, e, depth, "<li>", "</li>", out, stack),
140
- "taskList" | "task_list" => open_container(
260
+ "blockquote" => open_container(
141
261
  txn,
142
262
  e,
143
263
  depth,
144
- "<ul data-type=\"taskList\">",
145
- "</ul>",
146
- out,
264
+ "<blockquote>",
265
+ "</blockquote>",
266
+ em,
147
267
  stack,
268
+ rules,
148
269
  ),
149
- "taskItem" | "task_item" => {
150
- let checked = bool_attr(txn, e, "checked");
151
- out.push_str("<li data-checked=\"");
152
- out.push_str(if checked { "true" } else { "false" });
153
- out.push_str("\" data-type=\"taskItem\"><label><input type=\"checkbox\"");
154
- if checked {
155
- out.push_str(" checked=\"checked\"");
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
- out.push_str("><span></span></label><div>");
158
- push_block_children(txn, e, depth, "</div></li>", out, stack);
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
- out.push_str("<table><tbody>");
162
- push_block_children(txn, e, depth, "</tbody></table>", out, stack);
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
- "detailsSummary" | "details_summary" => {
178
- out.push_str("<summary>");
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
- "detailsContent" | "details_content" => open_container(
183
- txn,
184
- e,
185
- depth,
186
- "<div data-type=\"detailsContent\">",
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, "", out, stack);
306
+ push_block_children(txn, e, depth, "", em, stack, rules);
202
307
  } else {
203
- let inline = render_inline(txn, e, 0);
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
- out.push_str("<p>");
206
- out.push_str(&inline);
207
- out.push_str("</p>");
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
- out: &mut String,
420
+ em: &mut Emitter,
221
421
  stack: &mut Vec<Work>,
422
+ rules: &Rules,
222
423
  ) {
223
- out.push_str(open);
224
- push_block_children(txn, e, depth, close, out, stack);
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
- out: &mut String,
437
+ em: &mut Emitter,
236
438
  stack: &mut Vec<Work>,
439
+ rules: &Rules,
237
440
  ) {
238
- out.push('<');
239
- out.push_str(tag);
240
- out.push_str(" colspan=\"");
241
- out.push_str(&num_attr(txn, e, "colspan").unwrap_or(1).to_string());
242
- out.push_str("\" rowspan=\"");
243
- out.push_str(&num_attr(txn, e, "rowspan").unwrap_or(1).to_string());
244
- out.push_str("\">");
245
- push_block_children(txn, e, depth, close, out, stack);
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
- /// to `out` first: schema-valid documents never put bare text in a container,
251
- /// but a crafted one can, and dropping it would lose content. Past `MAX_DEPTH`
252
- /// the children are dropped but the tag still closes, keeping the output well
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
- out: &mut String,
463
+ em: &mut Emitter,
260
464
  stack: &mut Vec<Work>,
465
+ rules: &Rules,
261
466
  ) {
262
- for node in e.children(txn) {
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
- let children: Vec<_> = e
272
- .children(txn)
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
- for child in children.into_iter().rev() {
279
- stack.push(Work::Open(child, depth + 1));
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, mentions, inline images). An unknown
285
- /// inline node keeps its text instead of vanishing; `depth` caps that
286
- /// recursion on a crafted nest of unknowns.
287
- fn render_inline<T: ReadTxn>(txn: &T, e: &XmlElementRef, depth: usize) -> String {
288
- let mut out = String::new();
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, &mut out),
292
- XmlOut::Element(child) => match child.tag().as_ref() {
293
- "hardBreak" | "hard_break" => out.push_str("<br>"),
294
- "image" => render_image(txn, &child, &mut out),
295
- "mention" => render_mention(txn, &child, &mut out),
296
- _ => {
297
- if depth < MAX_DEPTH {
298
- out.push_str(&render_inline(txn, &child, depth + 1));
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 mention, as Tiptap's Mention extension serializes it (no app-configured
309
- /// HTMLAttributes): `data-type`, `data-id`, `data-label` when present, the
310
- /// suggestion char, and `@label` (falling back to `@id`) as the text.
311
- fn render_mention<T: ReadTxn>(txn: &T, e: &XmlElementRef, out: &mut String) {
312
- let id = str_attr(txn, e, "id");
313
- let label = str_attr(txn, e, "label");
314
- let char = str_attr(txn, e, "mentionSuggestionChar").unwrap_or_else(|| "@".to_string());
315
- out.push_str("<span data-type=\"mention\"");
316
- if let Some(id) = &id {
317
- out.push_str(" data-id=\"");
318
- out.push_str(&escape_attr(id));
319
- out.push('"');
320
- }
321
- if let Some(label) = &label {
322
- out.push_str(" data-label=\"");
323
- out.push_str(&escape_attr(label));
324
- out.push('"');
325
- }
326
- out.push_str(" data-mention-suggestion-char=\"");
327
- out.push_str(&escape_attr(&char));
328
- out.push_str("\">");
329
- out.push_str(&escape_text(&char));
330
- out.push_str(&escape_text(&label.or(id).unwrap_or_default()));
331
- out.push_str("</span>");
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, out: &mut String) {
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
- out.push_str(&render_run(s, d.attributes.as_deref()));
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
- fn render_run(text: &str, marks: Option<&Attrs>) -> String {
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
- if has(marks, &["subscript", "sub"]) {
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 let Some(Any::Map(style)) = marks.get("textStyle") {
378
- let css = text_style_css(style);
379
- if !css.is_empty() {
380
- html = format!("<span style=\"{}\">{html}</span>", escape_attr(&css));
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 let Some(Any::Map(link)) = marks.get("link") {
385
- html = wrap_link(html, link);
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, out: &mut String) {
470
- out.push_str("<img");
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
- out.push(' ');
474
- out.push_str(html);
475
- out.push_str("=\"");
476
- out.push_str(&escape_attr(&v));
477
- out.push('"');
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
- out.push('>');
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
- fn has(marks: &Attrs, keys: &[&str]) -> bool {
488
- keys.iter().any(|k| marks.contains_key(*k))
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('&', "&amp;")
@@ -562,20 +899,27 @@ mod tests {
562
899
  a
563
900
  }
564
901
 
565
- /// The core proof: a document captured from a real Tiptap editor renders to
566
- /// exactly the editor's own `getHTML()`. The fixture covers headings,
567
- /// every mark and combination, links, escaping, blockquote, nested bullet
568
- /// and ordered lists (with a `start`), a task list, code blocks with and
569
- /// without a language, a hard break, a horizontal rule, an image, and the
570
- /// trailing empty paragraph Tiptap keeps.
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 renders_the_captured_tiptap_document_byte_for_byte() {
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!(render_run("x", None), "x");
599
- assert_eq!(
600
- render_run("x", Some(&marks(&["bold"]))),
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
- render_run("x", Some(&marks(&["italic", "bold"]))),
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
- render_run(
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!(render_run("<&>", None), "&lt;&amp;&gt;");
968
+ assert_eq!(run("<&>", None), "&lt;&amp;&gt;");
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
- render_run("site", Some(&a)),
983
+ run("site", Some(&a)),
646
984
  "<a target=\"_blank\" rel=\"noopener\" href=\"https://e.com?a=1&amp;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
- render_run("the doc", Some(&a)),
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
- /// Mentions (captured from Tiptap's Mention extension): the fixture holds
665
- /// one mention with a label, one with only an id, and a link carrying
666
- /// class and title.
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 renders_the_captured_mention_document_byte_for_byte() {
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
- render_run("x", Some(&a)),
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#"&lt;a &amp; &quot;b&quot;&gt;"#
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
  }