beck_core/
html.rs

1//! `Html` as a value, not a string.
2//!
3//! Ported from Phase 0's `beck-p0-core::html`, which hand-wrote the output the compiler would
4//! generate. In Phase 1 it is the *runtime representation* the compiled `view` builds: the `ui:`
5//! macro lowers to `html_el`/`html_text`/`html_attr`/`html_on`/`html_key`, and those primitives
6//! build these values. The Phase 0 tests came with it unchanged, which is the point — the value
7//! semantics did not move, only who writes the code that produces them.
8//!
9//! §4.2: "UI trees stay symbolic … a component tree that has already become DOM mutation calls
10//! cannot be server-side rendered or pre-rendered at build time." The same value is therefore
11//! rendered three ways: to an SSR string (free first paint), to the wire encoding carried by
12//! patches, and — the point of the exercise — structurally diffed against its predecessor
13//! (`crate::diff`).
14//!
15//! Every node carries a structural hash computed at construction. §5.1: "because views are
16//! signal-derived, the differ knows which subtrees *can't* have changed and skips them". Phase 0
17//! has no signal graph, so it approximates that with an O(1) hash comparison per subtree — the
18//! same asymptotic effect, without the machinery.
19
20use serde_json::{json, Value};
21
22/// Attribute name under which a keyed node's key is materialised on the wire and in SSR output.
23pub const KEY_ATTR: &str = "data-b-k";
24
25/// Elements that carry no children and are written without a closing tag.
26const VOID_TAGS: &[&str] = &[
27    "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track",
28    "wbr",
29];
30
31#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
32pub enum Html {
33    Text {
34        text: String,
35        hash: u64,
36    },
37    Element {
38        tag: String,
39        attrs: Vec<(String, String)>,
40        key: Option<String>,
41        children: Vec<Html>,
42        /// Three accumulators rather than one hash, so that the hash is a function of the node's
43        /// *structure* and not of the order the builder methods happened to be called in. Getting
44        /// this wrong makes two identical subtrees hash differently, and a differ that trusts a
45        /// hash it cannot reproduce is worse than one with no hash at all.
46        tag_key_h: u64,
47        attrs_h: u64,
48        children_h: u64,
49    },
50}
51
52impl Html {
53    pub fn text(s: impl Into<String>) -> Html {
54        let text = s.into();
55        let hash = fnv_str(FNV_OFFSET ^ 0x01, &text);
56        Html::Text { text, hash }
57    }
58
59    /// Start an element. Attributes and children are added with the builder methods below; each
60    /// step costs one multiply per byte added, and nothing is ever rehashed.
61    pub fn el(tag: impl Into<String>) -> Html {
62        let tag = tag.into();
63        let tag_key_h = fnv_str(FNV_OFFSET ^ 0x02, &tag);
64        Html::Element {
65            tag,
66            attrs: Vec::new(),
67            key: None,
68            children: Vec::new(),
69            tag_key_h,
70            attrs_h: FNV_OFFSET,
71            children_h: FNV_OFFSET,
72        }
73    }
74
75    pub fn attr(mut self, name: impl Into<String>, value: impl Into<String>) -> Html {
76        if let Html::Element { attrs, attrs_h, .. } = &mut self {
77            let (name, value) = (name.into(), value.into());
78            *attrs_h = fnv_str(fnv_str(*attrs_h, &name), &value);
79            attrs.push((name, value));
80        }
81        self
82    }
83
84    /// Conditional attribute — the `(if t.done "done" "")` shape from the sketch, without emitting
85    /// an empty attribute the differ would then have to churn on.
86    pub fn attr_if(self, cond: bool, name: impl Into<String>, value: impl Into<String>) -> Html {
87        if cond {
88            self.attr(name, value)
89        } else {
90            self
91        }
92    }
93
94    /// A handler in `view` compiles to a declarative attribute — no user JavaScript exists in Mode
95    /// A (§5.1 "Input capture"), so `script-src` can stay near-empty. `command` is the serialised
96    /// command constructor the thin client posts back up the socket.
97    pub fn on(self, event: &str, command: Value) -> Html {
98        self.attr(format!("data-b-{event}"), command.to_string())
99    }
100
101    pub fn key(mut self, k: impl Into<String>) -> Html {
102        if let Html::Element {
103            tag,
104            key,
105            tag_key_h,
106            ..
107        } = &mut self
108        {
109            let k = k.into();
110            // Recomputed from the tag rather than folded into whatever is there, so that setting a
111            // key is idempotent and order-independent.
112            *tag_key_h = fnv_str(fnv_str(FNV_OFFSET ^ 0x02, tag) ^ 0x9e37_79b9_7f4a_7c15, &k);
113            *key = Some(k);
114        }
115        self
116    }
117
118    pub fn child(mut self, c: Html) -> Html {
119        if let Html::Element {
120            children,
121            children_h,
122            ..
123        } = &mut self
124        {
125            *children_h = fnv_u64(*children_h, c.hash());
126            children.push(c);
127        }
128        self
129    }
130
131    pub fn children(mut self, cs: impl IntoIterator<Item = Html>) -> Html {
132        for c in cs {
133            self = self.child(c);
134        }
135        self
136    }
137
138    /// Rebuild the tree, recomputing every structural hash bottom-up.
139    ///
140    /// Needed after in-place surgery on a node: an edit deep in a tree invalidates the hash of
141    /// every ancestor, and a stale hash is worse than no hash — the differ would skip a subtree
142    /// that did change.
143    pub fn rehash(&self) -> Html {
144        match self {
145            Html::Text { text, .. } => Html::text(text.clone()),
146            Html::Element {
147                tag,
148                attrs,
149                key,
150                children,
151                ..
152            } => {
153                let mut el = Html::el(tag.clone());
154                for (k, v) in attrs {
155                    el = el.attr(k.clone(), v.clone());
156                }
157                if let Some(k) = key {
158                    el = el.key(k.clone());
159                }
160                el.children(children.iter().map(Html::rehash))
161            }
162        }
163    }
164
165    pub fn hash(&self) -> u64 {
166        match self {
167            Html::Text { hash, .. } => *hash,
168            Html::Element {
169                tag_key_h,
170                attrs_h,
171                children_h,
172                ..
173            } => fnv_u64(fnv_u64(*tag_key_h, *attrs_h), *children_h),
174        }
175    }
176
177    pub fn key_of(&self) -> Option<&str> {
178        match self {
179            Html::Element { key, .. } => key.as_deref(),
180            Html::Text { .. } => None,
181        }
182    }
183
184    pub fn child_at(&self, i: usize) -> Option<&Html> {
185        match self {
186            Html::Element { children, .. } => children.get(i),
187            Html::Text { .. } => None,
188        }
189    }
190
191    /// Node count — the denominator of "how much of the tree did the diff actually touch".
192    pub fn node_count(&self) -> usize {
193        match self {
194            Html::Text { .. } => 1,
195            Html::Element { children, .. } => {
196                1 + children.iter().map(Html::node_count).sum::<usize>()
197            }
198        }
199    }
200
201    /// The wire encoding: a text node is a JSON string, an element is `[tag, attrs, children]`.
202    ///
203    /// Positional and terse because it rides in every patch. §4.4 specifies a field-tagged binary
204    /// encoding for Beck↔Beck traffic; the thin client is a browser, and JSON costs it zero bytes
205    /// of decoder — see `crate::patch::Codec` for the measured comparison.
206    pub fn to_wire(&self) -> Value {
207        match self {
208            Html::Text { text, .. } => Value::String(text.clone()),
209            Html::Element {
210                tag,
211                attrs,
212                key,
213                children,
214                ..
215            } => {
216                // Pairs rather than an object, because a JSON object is not ordered and this one
217                // has to be: the client sets attributes in the order it reads them, so an object
218                // — which `serde_json` sorts — makes a rebuilt element carry its attributes in a
219                // different order than the same element the server rendered into the document. It
220                // is invisible until something compares the two, and then it is the difference
221                // between "the DOM is the page" and "the DOM is nearly the page"
222                // (`docs/94` §94.7).
223                let mut pairs: Vec<Value> = attrs.iter().map(|(k, v)| json!([k, v])).collect();
224                if let Some(k) = key {
225                    pairs.push(json!([KEY_ATTR, k]));
226                }
227                json!([
228                    tag,
229                    Value::Array(pairs),
230                    children.iter().map(Html::to_wire).collect::<Vec<_>>()
231                ])
232            }
233        }
234    }
235
236    /// Server-side render. "First paint is free SSR: evaluate pure `view` against the current
237    /// accumulator, ship HTML."
238    ///
239    /// Emitted without any inter-element whitespace, deliberately: patch paths are child indices,
240    /// and a pretty-printer would insert text nodes that the server's tree does not have, so the
241    /// first patch after hydration would address the wrong node.
242    pub fn render(&self) -> String {
243        let mut out = String::with_capacity(1024);
244        self.render_into(&mut out);
245        out
246    }
247
248    pub fn render_into(&self, out: &mut String) {
249        match self {
250            Html::Text { text, .. } => escape_text_into(text, out),
251            Html::Element {
252                tag,
253                attrs,
254                key,
255                children,
256                ..
257            } => {
258                out.push('<');
259                out.push_str(tag);
260                for (k, v) in attrs {
261                    out.push(' ');
262                    out.push_str(k);
263                    out.push_str("=\"");
264                    escape_attr_into(v, out);
265                    out.push('"');
266                }
267                if let Some(k) = key {
268                    out.push(' ');
269                    out.push_str(KEY_ATTR);
270                    out.push_str("=\"");
271                    escape_attr_into(k, out);
272                    out.push('"');
273                }
274                out.push('>');
275                if VOID_TAGS.contains(&tag.as_str()) {
276                    return;
277                }
278                for c in children {
279                    c.render_into(out);
280                }
281                out.push_str("</");
282                out.push_str(tag);
283                out.push('>');
284            }
285        }
286    }
287}
288
289fn escape_text_into(s: &str, out: &mut String) {
290    for c in s.chars() {
291        match c {
292            '&' => out.push_str("&amp;"),
293            '<' => out.push_str("&lt;"),
294            '>' => out.push_str("&gt;"),
295            _ => out.push(c),
296        }
297    }
298}
299
300/// Escape a string for an attribute value the *shell* writes rather than a view.
301///
302/// The runtime builds the surrounding document with `format!` and puts two values from outside the
303/// program into it — the actor's name and its claims — and those are the identity provider's
304/// strings, not the program's. Exported so that they go through the same escaping the view's own
305/// attributes do rather than a second one written beside it.
306pub fn escape_attr(s: &str) -> String {
307    let mut out = String::with_capacity(s.len());
308    escape_attr_into(s, &mut out);
309    out
310}
311
312fn escape_attr_into(s: &str, out: &mut String) {
313    for c in s.chars() {
314        match c {
315            '&' => out.push_str("&amp;"),
316            '<' => out.push_str("&lt;"),
317            '>' => out.push_str("&gt;"),
318            '"' => out.push_str("&quot;"),
319            '\'' => out.push_str("&#39;"),
320            _ => out.push(c),
321        }
322    }
323}
324
325const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
326const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
327
328fn fnv_str(mut h: u64, s: &str) -> u64 {
329    for b in s.as_bytes() {
330        h ^= *b as u64;
331        h = h.wrapping_mul(FNV_PRIME);
332    }
333    h ^= 0xff;
334    h.wrapping_mul(FNV_PRIME)
335}
336
337fn fnv_u64(mut h: u64, v: u64) -> u64 {
338    for b in v.to_le_bytes() {
339        h ^= b as u64;
340        h = h.wrapping_mul(FNV_PRIME);
341    }
342    h
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn structural_hash_distinguishes_structure_not_just_content() {
351        let a = Html::el("li").child(Html::text("x"));
352        let b = Html::el("li").child(Html::text("y"));
353        let c = Html::el("li").child(Html::text("x"));
354        let nested = Html::el("li").child(Html::el("span").child(Html::text("x")));
355        assert_ne!(a.hash(), b.hash());
356        assert_eq!(a.hash(), c.hash());
357        assert_ne!(a.hash(), nested.hash());
358        assert_ne!(
359            Html::el("li").key("1").hash(),
360            Html::el("li").key("2").hash()
361        );
362        assert_ne!(
363            Html::el("li").attr("class", "done").hash(),
364            Html::el("li").attr("class", "").hash()
365        );
366    }
367
368    #[test]
369    fn structural_hash_is_independent_of_builder_call_order() {
370        let a = Html::el("li")
371            .key("k")
372            .attr("class", "done")
373            .child(Html::text("x"));
374        let b = Html::el("li")
375            .attr("class", "done")
376            .child(Html::text("x"))
377            .key("k");
378        assert_eq!(a.hash(), b.hash());
379        assert_eq!(a.rehash().hash(), a.hash());
380    }
381
382    #[test]
383    fn ssr_escapes_and_emits_no_stray_whitespace() {
384        let tree = Html::el("main")
385            .child(Html::el("h1").child(Html::text("a < b & c")))
386            .child(Html::el("input").attr("value", "\"quoted\""));
387        assert_eq!(
388            tree.render(),
389            "<main><h1>a &lt; b &amp; c</h1><input value=\"&quot;quoted&quot;\"></main>"
390        );
391    }
392
393    #[test]
394    fn wire_encoding_is_positional() {
395        let tree = Html::el("li")
396            .key("k1")
397            .attr("class", "done")
398            .child(Html::text("x"));
399        assert_eq!(
400            tree.to_wire(),
401            json!(["li", [["class", "done"], ["data-b-k", "k1"]], ["x"]])
402        );
403    }
404
405    /// Attributes cross in the order the program wrote them, not in the order a map would sort
406    /// them into.
407    ///
408    /// A JSON object is unordered, and `serde_json`'s is a `BTreeMap`, so this used to emit
409    /// `autofocus` before `placeholder` whatever the source said — which meant an element the
410    /// client rebuilt from a patch carried its attributes in a different order than the same
411    /// element the server rendered into the document. Nothing was wrong with the page; it simply
412    /// was not the same page, and only a browser comparing the two could see it
413    /// (`docs/94` §94.12).
414    #[test]
415    fn attributes_cross_in_the_order_they_were_written() {
416        let tree = Html::el("input")
417            .attr("placeholder", "what needs doing?")
418            .attr("autofocus", "on");
419        assert_eq!(
420            tree.to_wire(),
421            json!([
422                "input",
423                [["placeholder", "what needs doing?"], ["autofocus", "on"]],
424                []
425            ])
426        );
427    }
428}