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.
data/ext/yrby/src/read.rs CHANGED
@@ -31,11 +31,15 @@ pub fn xml_blocks_text<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> String
31
31
  XmlOut::Text(t) => walk_lexical_block(txn, &t, &mut out),
32
32
  XmlOut::Element(e) => {
33
33
  // ProseMirror blocks have a tag but no `__type`; get_string recurses
34
- // them (tags kept, caller strips). A Lexical decorator (horizontal
35
- // rule, image) is an XmlElement *with* a `__type` and no extractable
36
- // text -- skip it rather than emit its `<UNDEFINED …>` serialization.
34
+ // them (tags kept, caller strips). A Lexical decorator is an
35
+ // XmlElement *with* a `__type`: attachments carry readable text
36
+ // (a mention's plain text, an upload's caption); the rest
37
+ // (horizontal rule) have none — skip rather than emit their
38
+ // `<UNDEFINED …>` serialization.
37
39
  if e.get_attribute(txn, "__type").is_none() {
38
40
  out.push(e.get_string(txn));
41
+ } else if let Some(text) = lexical_decorator_text(txn, &e) {
42
+ out.push(text);
39
43
  }
40
44
  }
41
45
  XmlOut::Fragment(f) => out.push(f.get_string(txn)),
@@ -62,6 +66,26 @@ fn lexical_type<T: ReadTxn>(txn: &T, t: &XmlTextRef) -> String {
62
66
  }
63
67
  }
64
68
 
69
+ /// The readable text of a Lexical decorator element, if it has any: a mention
70
+ /// or embed attachment contributes its plain text; an upload attachment its
71
+ /// caption, alt text, or filename. Dividers and unknown decorators yield None.
72
+ fn lexical_decorator_text<T: ReadTxn>(txn: &T, e: &yrs::XmlElementRef) -> Option<String> {
73
+ let attr = |name: &str| match e.get_attribute(txn, name) {
74
+ Some(Out::Any(Any::String(s))) if !s.is_empty() => Some(s.to_string()),
75
+ _ => None,
76
+ };
77
+ match e.get_attribute(txn, "__type") {
78
+ Some(Out::Any(Any::String(ty))) => match ty.as_ref() {
79
+ "custom_action_text_attachment" => attr("plainText"),
80
+ "action_text_attachment" => attr("caption")
81
+ .or_else(|| attr("altText"))
82
+ .or_else(|| attr("fileName")),
83
+ _ => None,
84
+ },
85
+ _ => None,
86
+ }
87
+ }
88
+
65
89
  /// The `__type` of an embedded Lexical `Y.Map`. Two kinds appear inside a
66
90
  /// block: text-node metadata (`"text"`) and node maps like the LineBreakNode
67
91
  /// (`"linebreak"`). Structure confirmed from live-editor bytes (see the
@@ -120,7 +144,13 @@ fn walk_lexical_block<T: ReadTxn>(txn: &T, t: &XmlTextRef, out: &mut Vec<String>
120
144
  }
121
145
  }
122
146
  }
123
- _ => {} // decorator embeds we don't read for text
147
+ // An inline decorator (a mention attachment) joins the line.
148
+ Out::YXmlElement(e) => {
149
+ if let Some(text) = lexical_decorator_text(txn, &e) {
150
+ line.push_str(&text);
151
+ }
152
+ }
153
+ _ => {} // other embeds carry no text
124
154
  }
125
155
  }
126
156
  if !line.is_empty() {
@@ -332,6 +362,43 @@ mod tests {
332
362
  assert_eq!(xml_blocks_text(&txn, &frag), "foo\nbarbaz");
333
363
  }
334
364
 
365
+ #[test]
366
+ fn lexxy_full_schema_doc_extracts_attachment_text_too() {
367
+ // The full-schema capture (see lexical_html.rs): attachments must now
368
+ // contribute readable text — a mention's plain text inline, an
369
+ // upload's caption as its own line — while the divider stays silent.
370
+ use yrs::updates::decoder::Decode;
371
+ use yrs::{Transact, Update};
372
+ let bytes = include_bytes!("fixtures/lexxy_full.bin");
373
+ let doc = Doc::new();
374
+ doc.transact_mut()
375
+ .apply_update(Update::decode_v1(bytes).unwrap())
376
+ .unwrap();
377
+ let txn = doc.transact();
378
+ let frag = txn.get_xml_fragment("root").unwrap();
379
+ let text = xml_blocks_text(&txn, &frag);
380
+
381
+ assert!(
382
+ text.contains("Mention: @Alice done."),
383
+ "inline mention joins its line:\n{text}"
384
+ );
385
+ assert!(
386
+ text.contains("The team, 2026"),
387
+ "upload caption becomes a line"
388
+ );
389
+ assert!(
390
+ !text.contains("UNDEFINED"),
391
+ "no decorator serialization leaks"
392
+ );
393
+ assert!(
394
+ !text.contains('\u{2504}'),
395
+ "the divider glyph is not emitted"
396
+ );
397
+ for expected in ["Heading H6", "Done item", "def hello", "after-empty"] {
398
+ assert!(text.contains(expected), "missing {expected:?}");
399
+ }
400
+ }
401
+
335
402
  #[test]
336
403
  fn lexical_complex_doc_extracts_all_nested_text() {
337
404
  // A real Lexxy/Lexical doc with every block type: headings, formatted
@@ -0,0 +1,529 @@
1
+ //! Custom render rules and segmented output — the extensibility core shared
2
+ //! by both HTML renderers.
3
+ //!
4
+ //! Callers register per-node rules. Two tiers:
5
+ //!
6
+ //! - **Declarative rules** (tag, attributes, text, content slot) compile to
7
+ //! [`NodeRule`]/[`MarkRule`] here and render natively, inside the document
8
+ //! transaction, at full speed. This covers the tiptap-php `renderHTML`
9
+ //! shape: markup as data.
10
+ //! - **Callback rules** defer to the caller. Rendering never runs app code
11
+ //! while the document is locked — the renderer emits [`Segment::Deferred`]
12
+ //! entries carrying the node's type, attributes (as JSON), and its
13
+ //! already-rendered children, and the caller fills them in after the
14
+ //! render returns. (In the Ruby gem, that caller is the app's block, run
15
+ //! once the transaction has closed and the GVL is held again.)
16
+ //!
17
+ //! Rules arrive as one JSON document (see `parse`), so the same format
18
+ //! serves any binding or caller.
19
+
20
+ use std::collections::{BTreeMap, BTreeSet, HashMap};
21
+ use yrs::{Any, Out, ReadTxn, Xml};
22
+
23
+ /// One piece of renderer output. `Html` is finished markup; `Deferred` is a
24
+ /// callback node whose markup the caller supplies after the render, carrying
25
+ /// everything needed to produce it. Content nests, so callback nodes inside
26
+ /// callback nodes resolve depth-first. `child_types` lists the node's
27
+ /// element/block children by type, in document order — structural facts a
28
+ /// callback can't recover from `attrs` or the rendered content (an image
29
+ /// count, whether a list item holds a nested list).
30
+ #[derive(Debug)]
31
+ pub enum Segment {
32
+ Html(String),
33
+ Deferred {
34
+ node_type: String,
35
+ attrs_json: String,
36
+ child_types: Vec<String>,
37
+ content: Vec<Segment>,
38
+ },
39
+ }
40
+
41
+ /// Builds segmented output. Renderers append markup through this instead of a
42
+ /// bare `String`; frames capture sub-output (a deferred node's children, or a
43
+ /// "did this render anything?" probe) without string sentinels.
44
+ pub struct Emitter {
45
+ frames: Vec<Vec<Segment>>,
46
+ }
47
+
48
+ impl Emitter {
49
+ pub fn new() -> Self {
50
+ Emitter {
51
+ frames: vec![Vec::new()],
52
+ }
53
+ }
54
+
55
+ pub fn push_str(&mut self, s: &str) {
56
+ if s.is_empty() {
57
+ return;
58
+ }
59
+ let frame = self.frames.last_mut().expect("emitter frame");
60
+ if let Some(Segment::Html(last)) = frame.last_mut() {
61
+ last.push_str(s);
62
+ } else {
63
+ frame.push(Segment::Html(s.to_string()));
64
+ }
65
+ }
66
+
67
+ pub fn push(&mut self, c: char) {
68
+ let mut buf = [0u8; 4];
69
+ self.push_str(c.encode_utf8(&mut buf));
70
+ }
71
+
72
+ /// Start capturing output into a sub-frame.
73
+ pub fn begin_frame(&mut self) {
74
+ self.frames.push(Vec::new());
75
+ }
76
+
77
+ /// Finish the current sub-frame and return what it captured.
78
+ pub fn end_frame(&mut self) -> Vec<Segment> {
79
+ debug_assert!(self.frames.len() > 1, "unbalanced emitter frame");
80
+ self.frames.pop().unwrap_or_default()
81
+ }
82
+
83
+ /// Append previously captured segments to the current frame.
84
+ pub fn append(&mut self, segments: Vec<Segment>) {
85
+ for seg in segments {
86
+ match seg {
87
+ Segment::Html(s) => self.push_str(&s),
88
+ deferred => self
89
+ .frames
90
+ .last_mut()
91
+ .expect("emitter frame")
92
+ .push(deferred),
93
+ }
94
+ }
95
+ }
96
+
97
+ pub fn emit_deferred(
98
+ &mut self,
99
+ node_type: String,
100
+ attrs_json: String,
101
+ child_types: Vec<String>,
102
+ content: Vec<Segment>,
103
+ ) {
104
+ self.frames
105
+ .last_mut()
106
+ .expect("emitter frame")
107
+ .push(Segment::Deferred {
108
+ node_type,
109
+ attrs_json,
110
+ child_types,
111
+ content,
112
+ });
113
+ }
114
+
115
+ pub fn into_segments(mut self) -> Vec<Segment> {
116
+ debug_assert_eq!(self.frames.len(), 1, "unbalanced emitter frame");
117
+ self.frames.pop().unwrap_or_default()
118
+ }
119
+ }
120
+
121
+ /// What flattening produced. Both variants are normal outcomes — `Deferred`
122
+ /// means callback nodes are present and need splicing — so this is an enum
123
+ /// rather than a `Result`.
124
+ pub enum Flattened {
125
+ Html(String),
126
+ Deferred(Vec<Segment>),
127
+ }
128
+
129
+ impl Flattened {
130
+ /// The finished markup, or `None` when callback nodes still need
131
+ /// splicing. Handy where the caller knows no callback rules exist.
132
+ /// (Only test code needs it today, but it's part of the shape the
133
+ /// extracted crates will expose.)
134
+ #[cfg_attr(not(test), allow(dead_code))]
135
+ pub fn into_html(self) -> Option<String> {
136
+ match self {
137
+ Flattened::Html(html) => Some(html),
138
+ Flattened::Deferred(_) => None,
139
+ }
140
+ }
141
+ }
142
+
143
+ /// Join the segments when every one is finished markup, so the common
144
+ /// no-callback path stays a single string and the splicing layer can be
145
+ /// skipped; hand the segments back untouched when callback nodes are present.
146
+ pub fn flatten(segments: Vec<Segment>) -> Flattened {
147
+ if segments
148
+ .iter()
149
+ .any(|s| matches!(s, Segment::Deferred { .. }))
150
+ {
151
+ return Flattened::Deferred(segments);
152
+ }
153
+ // The merge invariant makes the common case exactly one Html segment;
154
+ // move it out rather than copying the whole document.
155
+ let mut out = String::new();
156
+ for seg in segments {
157
+ if let Segment::Html(s) = seg {
158
+ if out.is_empty() {
159
+ out = s;
160
+ } else {
161
+ out.push_str(&s);
162
+ }
163
+ }
164
+ }
165
+ Flattened::Html(out)
166
+ }
167
+
168
+ /// A piece of an attribute value or text template: a literal, or a reference
169
+ /// to one of the node's stored attributes.
170
+ pub enum AttrPart {
171
+ Lit(String),
172
+ Ref(String),
173
+ }
174
+
175
+ /// Resolve a lit/ref template against a node's attributes. `None` (attribute
176
+ /// or text skipped) when the resolved value is empty — matching how the
177
+ /// built-in renderers omit absent attributes.
178
+ pub fn resolve_parts<F: Fn(&str) -> Option<String>>(
179
+ parts: &[AttrPart],
180
+ lookup: F,
181
+ ) -> Option<String> {
182
+ let mut out = String::new();
183
+ for part in parts {
184
+ match part {
185
+ AttrPart::Lit(s) => out.push_str(s),
186
+ AttrPart::Ref(name) => {
187
+ if let Some(v) = lookup(name) {
188
+ out.push_str(&v);
189
+ }
190
+ }
191
+ }
192
+ }
193
+ if out.is_empty() {
194
+ None
195
+ } else {
196
+ Some(out)
197
+ }
198
+ }
199
+
200
+ /// An attribute reference on a node: rules say `:kind`; Lexical stores its own
201
+ /// props as `__kind` — try the raw name first, then prefixed. (ProseMirror
202
+ /// stores attrs bare, so the fallback never fires there.)
203
+ pub fn xml_ref_attr<T: ReadTxn, N: Xml>(txn: &T, node: &N, name: &str) -> Option<String> {
204
+ let value = |out: Option<Out>| match out {
205
+ Some(Out::Any(any)) => any_attr_string(&any),
206
+ _ => None,
207
+ };
208
+ value(node.get_attribute(txn, name))
209
+ .or_else(|| value(node.get_attribute(txn, &format!("__{name}"))))
210
+ }
211
+
212
+ /// A stored attribute as a string: strings pass through; numbers print
213
+ /// JS-style; bools as true/false. Anything else is None.
214
+ pub fn any_attr_string(any: &Any) -> Option<String> {
215
+ match any {
216
+ Any::String(s) => Some(s.to_string()),
217
+ Any::Number(n) => Some(if n.fract() == 0.0 {
218
+ format!("{}", *n as i64)
219
+ } else {
220
+ format!("{n}")
221
+ }),
222
+ Any::BigInt(n) => Some(format!("{n}")),
223
+ Any::Bool(b) => Some(if *b { "true" } else { "false" }.to_string()),
224
+ _ => None,
225
+ }
226
+ }
227
+
228
+ /// A node's stored attributes as a JSON object, for callback rules. Keys as
229
+ /// stored (`__type` and friends keep their prefix); values via yrs's own JSON
230
+ /// encoding.
231
+ pub fn xml_attrs_json<T: ReadTxn, N: Xml>(txn: &T, node: &N) -> String {
232
+ let mut out = String::from("{");
233
+ let mut first = true;
234
+ for (key, value) in node.attributes(txn) {
235
+ let Out::Any(any) = value else { continue };
236
+ if !first {
237
+ out.push(',');
238
+ }
239
+ first = false;
240
+ out.push_str(&serde_json::to_string(key).unwrap_or_else(|_| "\"\"".into()));
241
+ out.push(':');
242
+ let mut v = String::new();
243
+ any.to_json(&mut v);
244
+ out.push_str(&v);
245
+ }
246
+ out.push('}');
247
+ out
248
+ }
249
+
250
+ /// What goes inside a custom node's element.
251
+ #[derive(Clone, Copy, PartialEq)]
252
+ pub enum Content {
253
+ Blocks,
254
+ Inline,
255
+ None,
256
+ }
257
+
258
+ /// One node rule: markup as data, or a deferral to the caller.
259
+ pub enum NodeRule {
260
+ /// Render natively: the element, its attribute/text templates, and what
261
+ /// goes inside it.
262
+ Declarative {
263
+ tag: String,
264
+ void: bool,
265
+ attrs: Vec<(String, Vec<AttrPart>)>,
266
+ text: Option<Vec<AttrPart>>,
267
+ content: Content,
268
+ },
269
+ /// Emit a [`Segment::Deferred`] for the caller to fill in; `content` is
270
+ /// what renders into its children.
271
+ Callback { content: Content },
272
+ }
273
+
274
+ /// A custom mark (ProseMirror only): a wrapping tag with attributes read from
275
+ /// the mark's own value map.
276
+ pub struct MarkRule {
277
+ pub tag: String,
278
+ pub attrs: Vec<(String, Vec<AttrPart>)>,
279
+ }
280
+
281
+ pub struct Rules {
282
+ pub nodes: HashMap<String, NodeRule>,
283
+ pub marks: HashMap<String, MarkRule>,
284
+ }
285
+
286
+ /// What a document walk observed about one node type — the facts behind
287
+ /// `Y::Lexical#node_types` / `Y::ProseMirror#node_types`, the discovery aid
288
+ /// for writing rules against a real document.
289
+ #[derive(Default)]
290
+ pub struct TypeInfo {
291
+ pub count: usize,
292
+ pub attrs: BTreeSet<String>,
293
+ pub children: BTreeSet<String>,
294
+ pub text: bool,
295
+ }
296
+
297
+ /// Per-type observations, ordered for stable output.
298
+ pub type TypeMap = BTreeMap<String, TypeInfo>;
299
+
300
+ /// Serialize the observations, annotating each type with what already
301
+ /// handles it (`"rule"`, `"builtin"`, or null — the ones a rule author needs
302
+ /// to cover).
303
+ pub fn type_map_json(map: &TypeMap, handled: impl Fn(&str) -> Option<&'static str>) -> String {
304
+ let mut root = serde_json::Map::new();
305
+ for (ty, info) in map {
306
+ let mut entry = serde_json::Map::new();
307
+ entry.insert("count".into(), info.count.into());
308
+ entry.insert(
309
+ "attrs".into(),
310
+ info.attrs.iter().cloned().collect::<Vec<_>>().into(),
311
+ );
312
+ entry.insert(
313
+ "children".into(),
314
+ info.children.iter().cloned().collect::<Vec<_>>().into(),
315
+ );
316
+ entry.insert("text".into(), info.text.into());
317
+ entry.insert(
318
+ "handled".into(),
319
+ match handled(ty) {
320
+ Some(by) => by.into(),
321
+ None => serde_json::Value::Null,
322
+ },
323
+ );
324
+ root.insert(ty.clone(), entry.into());
325
+ }
326
+ serde_json::Value::Object(root).to_string()
327
+ }
328
+
329
+ impl Rules {
330
+ pub fn empty() -> Self {
331
+ Rules {
332
+ nodes: HashMap::new(),
333
+ marks: HashMap::new(),
334
+ }
335
+ }
336
+
337
+ /// Parse the rules JSON (however the caller compiled it). Absent keys
338
+ /// take their defaults (`void`/`callback` false, `content` inline), so a
339
+ /// typical document looks like:
340
+ ///
341
+ /// ```json
342
+ /// { "nodes": { "callout": { "tag": "aside",
343
+ /// "attrs": [["class", [{"lit": "callout"}]],
344
+ /// ["data-kind", [{"ref": "kind"}]]],
345
+ /// "content": "blocks" },
346
+ /// "video": { "callback": true } },
347
+ /// "marks": { "comment": { "tag": "span",
348
+ /// "attrs": [["data-id", [{"ref": "id"}]]] } } }
349
+ /// ```
350
+ pub fn parse(json: &str) -> Result<Rules, String> {
351
+ let root: serde_json::Value =
352
+ serde_json::from_str(json).map_err(|e| format!("invalid rules JSON: {e}"))?;
353
+ let mut rules = Rules::empty();
354
+
355
+ if let Some(nodes) = root.get("nodes").and_then(|v| v.as_object()) {
356
+ for (name, spec) in nodes {
357
+ rules
358
+ .nodes
359
+ .insert(name.clone(), parse_node_rule(name, spec)?);
360
+ }
361
+ }
362
+ if let Some(marks) = root.get("marks").and_then(|v| v.as_object()) {
363
+ for (name, spec) in marks {
364
+ rules
365
+ .marks
366
+ .insert(name.clone(), parse_mark_rule(name, spec)?);
367
+ }
368
+ }
369
+ Ok(rules)
370
+ }
371
+ }
372
+
373
+ fn parse_node_rule(name: &str, spec: &serde_json::Value) -> Result<NodeRule, String> {
374
+ let content = match spec.get("content").and_then(|v| v.as_str()) {
375
+ Some("blocks") => Content::Blocks,
376
+ Some("inline") | None => Content::Inline,
377
+ Some("none") => Content::None,
378
+ Some(other) => {
379
+ return Err(format!(
380
+ "rule for {name:?}: unknown content kind {other:?} (blocks|inline|none)"
381
+ ))
382
+ }
383
+ };
384
+ if spec
385
+ .get("callback")
386
+ .and_then(|v| v.as_bool())
387
+ .unwrap_or(false)
388
+ {
389
+ return Ok(NodeRule::Callback { content });
390
+ }
391
+ let Some(tag) = spec.get("tag").and_then(|v| v.as_str()) else {
392
+ return Err(format!("rule for {name:?} needs a tag (or a callback)"));
393
+ };
394
+ Ok(NodeRule::Declarative {
395
+ tag: tag.to_string(),
396
+ void: spec.get("void").and_then(|v| v.as_bool()).unwrap_or(false),
397
+ attrs: parse_attrs(name, spec.get("attrs"))?,
398
+ text: match spec.get("text") {
399
+ Some(serde_json::Value::Array(parts)) => Some(parse_parts(name, parts)?),
400
+ Some(serde_json::Value::Null) | None => None,
401
+ Some(_) => return Err(format!("rule for {name:?}: text must be a template array")),
402
+ },
403
+ content,
404
+ })
405
+ }
406
+
407
+ fn parse_mark_rule(name: &str, spec: &serde_json::Value) -> Result<MarkRule, String> {
408
+ let Some(tag) = spec.get("tag").and_then(|v| v.as_str()) else {
409
+ return Err(format!("mark rule for {name:?} needs a tag"));
410
+ };
411
+ Ok(MarkRule {
412
+ tag: tag.to_string(),
413
+ attrs: parse_attrs(name, spec.get("attrs"))?,
414
+ })
415
+ }
416
+
417
+ fn parse_attrs(
418
+ name: &str,
419
+ attrs: Option<&serde_json::Value>,
420
+ ) -> Result<Vec<(String, Vec<AttrPart>)>, String> {
421
+ let mut out = Vec::new();
422
+ let entries = match attrs {
423
+ None | Some(serde_json::Value::Null) => return Ok(out),
424
+ Some(serde_json::Value::Array(entries)) => entries,
425
+ Some(_) => {
426
+ return Err(format!(
427
+ "rule for {name:?}: attrs must be an array of [name, template] pairs"
428
+ ))
429
+ }
430
+ };
431
+ for entry in entries {
432
+ let (Some(attr_name), Some(serde_json::Value::Array(parts))) =
433
+ (entry.get(0).and_then(|v| v.as_str()), entry.get(1))
434
+ else {
435
+ return Err(format!("rule for {name:?}: malformed attrs entry"));
436
+ };
437
+ out.push((attr_name.to_string(), parse_parts(name, parts)?));
438
+ }
439
+ Ok(out)
440
+ }
441
+
442
+ fn parse_parts(name: &str, parts: &[serde_json::Value]) -> Result<Vec<AttrPart>, String> {
443
+ parts
444
+ .iter()
445
+ .map(|part| {
446
+ if let Some(lit) = part.get("lit").and_then(|v| v.as_str()) {
447
+ Ok(AttrPart::Lit(lit.to_string()))
448
+ } else if let Some(r) = part.get("ref").and_then(|v| v.as_str()) {
449
+ Ok(AttrPart::Ref(r.to_string()))
450
+ } else {
451
+ Err(format!(
452
+ "rule for {name:?}: template part must be lit or ref"
453
+ ))
454
+ }
455
+ })
456
+ .collect()
457
+ }
458
+
459
+ #[cfg(test)]
460
+ mod tests {
461
+ use super::*;
462
+
463
+ #[test]
464
+ fn parses_the_compiled_rule_shape() {
465
+ let rules = Rules::parse(
466
+ r#"{ "nodes": { "callout": { "tag": "aside",
467
+ "attrs": [["class", [{"lit": "callout"}]],
468
+ ["data-kind", [{"ref": "kind"}]]],
469
+ "content": "blocks" },
470
+ "video": { "callback": true } },
471
+ "marks": { "comment": { "tag": "span",
472
+ "attrs": [["data-id", [{"ref": "id"}]]] } } }"#,
473
+ )
474
+ .unwrap();
475
+ assert_eq!(rules.nodes.len(), 2);
476
+ let NodeRule::Declarative {
477
+ tag,
478
+ attrs,
479
+ content,
480
+ ..
481
+ } = &rules.nodes["callout"]
482
+ else {
483
+ panic!("callout should be declarative");
484
+ };
485
+ assert_eq!(tag, "aside");
486
+ assert!(matches!(content, Content::Blocks));
487
+ assert_eq!(attrs.len(), 2);
488
+ assert!(matches!(rules.nodes["video"], NodeRule::Callback { .. }));
489
+ assert_eq!(rules.marks["comment"].tag, "span");
490
+ }
491
+
492
+ #[test]
493
+ fn rejects_malformed_rules_loudly() {
494
+ assert!(Rules::parse("not json").is_err());
495
+ assert!(Rules::parse(r#"{ "nodes": { "x": {} } }"#).is_err()); // no tag, no callback
496
+ assert!(Rules::parse(r#"{ "nodes": { "x": { "tag": "a", "content": "wat" } } }"#).is_err());
497
+ assert!(Rules::parse(r#"{ "marks": { "x": {} } }"#).is_err());
498
+ // attrs present but not the array-of-pairs form must fail loudly,
499
+ // not silently drop the attributes.
500
+ assert!(
501
+ Rules::parse(r#"{ "nodes": { "x": { "tag": "a", "attrs": {"class": "y"} } } }"#)
502
+ .is_err()
503
+ );
504
+ }
505
+
506
+ #[test]
507
+ fn emitter_frames_capture_and_merge() {
508
+ let mut em = Emitter::new();
509
+ em.push_str("<p>");
510
+ em.begin_frame();
511
+ em.push_str("inner");
512
+ let captured = em.end_frame();
513
+ em.emit_deferred("video".into(), "{}".into(), Vec::new(), captured);
514
+ em.push_str("</p>");
515
+ let segs = em.into_segments();
516
+ assert_eq!(segs.len(), 3);
517
+ assert!(matches!(&segs[0], Segment::Html(s) if s == "<p>"));
518
+ assert!(matches!(&segs[1], Segment::Deferred { node_type, .. } if node_type == "video"));
519
+ assert!(matches!(&segs[2], Segment::Html(s) if s == "</p>"));
520
+
521
+ // Adjacent Html merges; flatten() hands deferred segments back.
522
+ let mut em = Emitter::new();
523
+ em.push_str("a");
524
+ em.push_str("b");
525
+ let segs = em.into_segments();
526
+ assert_eq!(segs.len(), 1);
527
+ assert_eq!(flatten(segs).into_html().unwrap(), "ab");
528
+ }
529
+ }