Skip to main content

yjs_html_core/
lib.rs

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