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 std::sync::Arc;
21
22use serde_json::{json, Value};
23
24/// Attribute name under which a keyed node's key is materialised on the wire and in SSR output.
25pub const KEY_ATTR: &str = "data-b-k";
26
27/// Elements that carry no children and are written without a closing tag.
28const VOID_TAGS: &[&str] = &[
29    "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track",
30    "wbr",
31];
32
33#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
34pub enum Html {
35    Text {
36        text: String,
37        hash: u64,
38    },
39    Element {
40        tag: String,
41        attrs: Vec<(String, String)>,
42        key: Option<String>,
43        /// Shared handles rather than owned subtrees, because a page is **reassembled** on every
44        /// event and a child that did not change must cost a refcount rather than a copy.
45        ///
46        /// With `Vec<Html>` here, `child` deep-copied the whole subtree it was given, at every
47        /// level — so one event rebuilding a page of `n` nodes copied every one of them, and the
48        /// cost compounded with nesting depth because each enclosing element copied what its
49        /// children had just copied (`docs/23` §23.8, which described this as "n handles").
50        children: Vec<Arc<Html>>,
51        /// Three accumulators rather than one hash, so that the hash is a function of the node's
52        /// *structure* and not of the order the builder methods happened to be called in. Getting
53        /// this wrong makes two identical subtrees hash differently, and a differ that trusts a
54        /// hash it cannot reproduce is worse than one with no hash at all.
55        tag_key_h: u64,
56        attrs_h: u64,
57        children_h: u64,
58    },
59}
60
61impl Html {
62    pub fn text(s: impl Into<String>) -> Html {
63        let text = s.into();
64        let hash = fnv_str(FNV_OFFSET ^ 0x01, &text);
65        Html::Text { text, hash }
66    }
67
68    /// Start an element. Attributes and children are added with the builder methods below; each
69    /// step costs one multiply per byte added, and nothing is ever rehashed.
70    pub fn el(tag: impl Into<String>) -> Html {
71        let tag = tag.into();
72        let tag_key_h = fnv_str(FNV_OFFSET ^ 0x02, &tag);
73        Html::Element {
74            tag,
75            attrs: Vec::new(),
76            key: None,
77            children: Vec::new(),
78            tag_key_h,
79            attrs_h: FNV_OFFSET,
80            children_h: FNV_OFFSET,
81        }
82    }
83
84    pub fn attr(mut self, name: impl Into<String>, value: impl Into<String>) -> Html {
85        if let Html::Element { attrs, attrs_h, .. } = &mut self {
86            let (name, value) = (name.into(), value.into());
87            *attrs_h = fnv_str(fnv_str(*attrs_h, &name), &value);
88            attrs.push((name, value));
89        }
90        self
91    }
92
93    /// Conditional attribute — the `(if t.done "done" "")` shape from the sketch, without emitting
94    /// an empty attribute the differ would then have to churn on.
95    pub fn attr_if(self, cond: bool, name: impl Into<String>, value: impl Into<String>) -> Html {
96        if cond {
97            self.attr(name, value)
98        } else {
99            self
100        }
101    }
102
103    /// A handler in `view` compiles to a declarative attribute — no user JavaScript exists in Mode
104    /// A (§5.1 "Input capture"), so `script-src` can stay near-empty. `command` is the serialised
105    /// command constructor the thin client posts back up the socket.
106    pub fn on(self, event: &str, command: Value) -> Html {
107        self.attr(format!("data-b-{event}"), command.to_string())
108    }
109
110    pub fn key(mut self, k: impl Into<String>) -> Html {
111        if let Html::Element {
112            tag,
113            key,
114            tag_key_h,
115            ..
116        } = &mut self
117        {
118            let k = k.into();
119            // Recomputed from the tag rather than folded into whatever is there, so that setting a
120            // key is idempotent and order-independent.
121            *tag_key_h = fnv_str(fnv_str(FNV_OFFSET ^ 0x02, tag) ^ 0x9e37_79b9_7f4a_7c15, &k);
122            *key = Some(k);
123        }
124        self
125    }
126
127    pub fn child(self, c: Html) -> Html {
128        self.child_shared(Arc::new(c))
129    }
130
131    /// Add a child that is **already** behind an [`Arc`], which is what every page assembled from
132    /// a view has: the engine holds each child as a `Value::Html` and hands the same allocation to
133    /// this element. Cloning the handle is what makes reassembling a page cost its *changed*
134    /// nodes rather than all of them.
135    ///
136    /// The hash is the child's own, already computed when it was built, so sharing costs nothing
137    /// at the differ either — an unchanged subtree still compares equal by hash without being
138    /// walked.
139    pub fn child_shared(mut self, c: Arc<Html>) -> Html {
140        if let Html::Element {
141            children,
142            children_h,
143            ..
144        } = &mut self
145        {
146            *children_h = fnv_u64(*children_h, c.hash());
147            children.push(c);
148        }
149        self
150    }
151
152    pub fn children(mut self, cs: impl IntoIterator<Item = Html>) -> Html {
153        for c in cs {
154            self = self.child(c);
155        }
156        self
157    }
158
159    /// [`Html::children`] for children that are already shared — the counterpart of
160    /// [`Html::child_shared`], used where an element is rebuilt from the children it already had.
161    pub fn children_shared(mut self, cs: impl IntoIterator<Item = Arc<Html>>) -> Html {
162        for c in cs {
163            self = self.child_shared(c);
164        }
165        self
166    }
167
168    /// Rebuild the tree, recomputing every structural hash bottom-up.
169    ///
170    /// Needed after in-place surgery on a node: an edit deep in a tree invalidates the hash of
171    /// every ancestor, and a stale hash is worse than no hash — the differ would skip a subtree
172    /// that did change.
173    pub fn rehash(&self) -> Html {
174        match self {
175            Html::Text { text, .. } => Html::text(text.clone()),
176            Html::Element {
177                tag,
178                attrs,
179                key,
180                children,
181                ..
182            } => {
183                let mut el = Html::el(tag.clone());
184                for (k, v) in attrs {
185                    el = el.attr(k.clone(), v.clone());
186                }
187                if let Some(k) = key {
188                    el = el.key(k.clone());
189                }
190                el.children(children.iter().map(|c| c.rehash()))
191            }
192        }
193    }
194
195    pub fn hash(&self) -> u64 {
196        match self {
197            Html::Text { hash, .. } => *hash,
198            Html::Element {
199                tag_key_h,
200                attrs_h,
201                children_h,
202                ..
203            } => fnv_u64(fnv_u64(*tag_key_h, *attrs_h), *children_h),
204        }
205    }
206
207    pub fn key_of(&self) -> Option<&str> {
208        match self {
209            Html::Element { key, .. } => key.as_deref(),
210            Html::Text { .. } => None,
211        }
212    }
213
214    /// The wire encoding: a text node is a JSON string, an element is `[tag, attrs, children]`.
215    ///
216    /// Positional and terse because it rides in every patch. §4.4 specifies a field-tagged binary
217    /// encoding for Beck↔Beck traffic; the thin client is a browser, and JSON costs it zero bytes
218    /// of decoder — see `crate::patch::Codec` for the measured comparison.
219    pub fn to_wire(&self) -> Value {
220        match self {
221            Html::Text { text, .. } => Value::String(text.clone()),
222            Html::Element {
223                tag,
224                attrs,
225                key,
226                children,
227                ..
228            } => {
229                // Pairs rather than an object, because a JSON object is not ordered and this one
230                // has to be: the client sets attributes in the order it reads them, so an object
231                // — which `serde_json` sorts — makes a rebuilt element carry its attributes in a
232                // different order than the same element the server rendered into the document. It
233                // is invisible until something compares the two, and then it is the difference
234                // between "the DOM is the page" and "the DOM is nearly the page"
235                // (`docs/94` §94.13).
236                let mut pairs: Vec<Value> = attrs.iter().map(|(k, v)| json!([k, v])).collect();
237                if let Some(k) = key {
238                    pairs.push(json!([KEY_ATTR, k]));
239                }
240                json!([
241                    tag,
242                    Value::Array(pairs),
243                    children.iter().map(|c| c.to_wire()).collect::<Vec<_>>()
244                ])
245            }
246        }
247    }
248
249    /// Server-side render. "First paint is free SSR: evaluate pure `view` against the current
250    /// accumulator, ship HTML."
251    ///
252    /// Emitted without any inter-element whitespace, deliberately: patch paths are child indices,
253    /// and a pretty-printer would insert text nodes that the server's tree does not have, so the
254    /// first patch after hydration would address the wrong node.
255    pub fn render(&self) -> String {
256        let mut out = String::with_capacity(1024);
257        self.render_into(&mut out);
258        out
259    }
260
261    pub fn render_into(&self, out: &mut String) {
262        match self {
263            Html::Text { text, .. } => escape_text_into(text, out),
264            Html::Element {
265                tag,
266                attrs,
267                key,
268                children,
269                ..
270            } => {
271                out.push('<');
272                out.push_str(tag);
273                for (k, v) in attrs {
274                    out.push(' ');
275                    out.push_str(k);
276                    out.push_str("=\"");
277                    escape_attr_into(v, out);
278                    out.push('"');
279                }
280                if let Some(k) = key {
281                    out.push(' ');
282                    out.push_str(KEY_ATTR);
283                    out.push_str("=\"");
284                    escape_attr_into(k, out);
285                    out.push('"');
286                }
287                out.push('>');
288                if VOID_TAGS.contains(&tag.as_str()) {
289                    return;
290                }
291                for c in children {
292                    c.render_into(out);
293                }
294                out.push_str("</");
295                out.push_str(tag);
296                out.push('>');
297            }
298        }
299    }
300}
301
302/// The node `html_el(tag, attrs, children)` builds, out of the three values it is given.
303///
304/// **One function, two callers.** The evaluator reaches it from `Prim::HtmlEl`; the native heap's
305/// decoder reaches it because a compiled `view` answers with the *arguments* rather than with a
306/// tree — a `Str`, a list of attributes and a list of children — and the tree is built here on the
307/// way out. What lives in this function is not the assembly but the three rules that go with it:
308/// an attribute with an empty value is **dropped** rather than emitted, a handler becomes
309/// `data-b-<event>` carrying the command as JSON, and a key sets the node's key rather than an
310/// attribute. Each is a decision the differ downstream depends on, and a second spelling of any of
311/// them would be a compiled page that differs from an interpreted one in a way no type can catch.
312pub fn element(
313    tag: &crate::core::Value,
314    attrs: &[crate::core::Value],
315    children: &[crate::core::Value],
316) -> Result<Html, String> {
317    use crate::core::{AttrValue, Value as Val};
318
319    let mut el = Html::el(tag.display());
320    for a in attrs {
321        match a {
322            Val::Attr(at) => match &**at {
323                // An empty attribute value is dropped rather than emitted, so the differ has
324                // nothing to churn on — Phase 0's `attr_if`.
325                AttrValue::Plain(k, v) => {
326                    if !v.is_empty() {
327                        el = el.attr(k.to_string(), v.to_string());
328                    }
329                }
330                AttrValue::On(ev, cmd) => el = el.on(ev, cmd.to_json()),
331                AttrValue::Key(k) => el = el.key(k.to_string()),
332            },
333            other => return Err(format!("not an attribute: {}", other.display())),
334        }
335    }
336    for ch in children {
337        match ch {
338            // The `Arc` and not the tree behind it: `ch` already owns this child, and every other
339            // element that holds it holds the same allocation.
340            Val::Html(h) => el = el.child_shared(h.clone()),
341            other => return Err(format!("not an Html child: {}", other.display())),
342        }
343    }
344    Ok(el)
345}
346
347/// The node `html_text(v)` builds: a tree that is *already* a tree is spliced, and anything else is
348/// its rendering as text.
349///
350/// The same two callers as [`element`], and the same reason: `ui:` lowers every non-element child
351/// through `html_text`, so "or Html" deciding differently in a compiled view than in an interpreted
352/// one would make a view composed out of functions render its parts as escaped markup in one
353/// backend and as markup in the other (`docs/94` §94.6).
354pub fn text_of(v: &crate::core::Value) -> Html {
355    match v {
356        crate::core::Value::Html(h) => (**h).clone(),
357        other => Html::text(other.display()),
358    }
359}
360
361fn escape_text_into(s: &str, out: &mut String) {
362    for c in s.chars() {
363        match c {
364            '&' => out.push_str("&amp;"),
365            '<' => out.push_str("&lt;"),
366            '>' => out.push_str("&gt;"),
367            _ => out.push(c),
368        }
369    }
370}
371
372/// Escape a string for an attribute value the *shell* writes rather than a view.
373///
374/// The runtime builds the surrounding document with `format!` and puts two values from outside the
375/// program into it — the actor's name and its claims — and those are the identity provider's
376/// strings, not the program's. Exported so that they go through the same escaping the view's own
377/// attributes do rather than a second one written beside it.
378pub fn escape_attr(s: &str) -> String {
379    let mut out = String::with_capacity(s.len());
380    escape_attr_into(s, &mut out);
381    out
382}
383
384fn escape_attr_into(s: &str, out: &mut String) {
385    for c in s.chars() {
386        match c {
387            '&' => out.push_str("&amp;"),
388            '<' => out.push_str("&lt;"),
389            '>' => out.push_str("&gt;"),
390            '"' => out.push_str("&quot;"),
391            '\'' => out.push_str("&#39;"),
392            _ => out.push(c),
393        }
394    }
395}
396
397const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
398const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
399
400fn fnv_str(mut h: u64, s: &str) -> u64 {
401    for b in s.as_bytes() {
402        h ^= *b as u64;
403        h = h.wrapping_mul(FNV_PRIME);
404    }
405    h ^= 0xff;
406    h.wrapping_mul(FNV_PRIME)
407}
408
409fn fnv_u64(mut h: u64, v: u64) -> u64 {
410    for b in v.to_le_bytes() {
411        h ^= b as u64;
412        h = h.wrapping_mul(FNV_PRIME);
413    }
414    h
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[test]
422    fn structural_hash_distinguishes_structure_not_just_content() {
423        let a = Html::el("li").child(Html::text("x"));
424        let b = Html::el("li").child(Html::text("y"));
425        let c = Html::el("li").child(Html::text("x"));
426        let nested = Html::el("li").child(Html::el("span").child(Html::text("x")));
427        assert_ne!(a.hash(), b.hash());
428        assert_eq!(a.hash(), c.hash());
429        assert_ne!(a.hash(), nested.hash());
430        assert_ne!(
431            Html::el("li").key("1").hash(),
432            Html::el("li").key("2").hash()
433        );
434        assert_ne!(
435            Html::el("li").attr("class", "done").hash(),
436            Html::el("li").attr("class", "").hash()
437        );
438    }
439
440    #[test]
441    fn structural_hash_is_independent_of_builder_call_order() {
442        let a = Html::el("li")
443            .key("k")
444            .attr("class", "done")
445            .child(Html::text("x"));
446        let b = Html::el("li")
447            .attr("class", "done")
448            .child(Html::text("x"))
449            .key("k");
450        assert_eq!(a.hash(), b.hash());
451        assert_eq!(a.rehash().hash(), a.hash());
452    }
453
454    #[test]
455    fn ssr_escapes_and_emits_no_stray_whitespace() {
456        let tree = Html::el("main")
457            .child(Html::el("h1").child(Html::text("a < b & c")))
458            .child(Html::el("input").attr("value", "\"quoted\""));
459        assert_eq!(
460            tree.render(),
461            "<main><h1>a &lt; b &amp; c</h1><input value=\"&quot;quoted&quot;\"></main>"
462        );
463    }
464
465    #[test]
466    fn wire_encoding_is_positional() {
467        let tree = Html::el("li")
468            .key("k1")
469            .attr("class", "done")
470            .child(Html::text("x"));
471        assert_eq!(
472            tree.to_wire(),
473            json!(["li", [["class", "done"], ["data-b-k", "k1"]], ["x"]])
474        );
475    }
476
477    /// Attributes cross in the order the program wrote them, not in the order a map would sort
478    /// them into.
479    ///
480    /// A JSON object is unordered, and `serde_json`'s is a `BTreeMap`, so this used to emit
481    /// `autofocus` before `placeholder` whatever the source said — which meant an element the
482    /// client rebuilt from a patch carried its attributes in a different order than the same
483    /// element the server rendered into the document. Nothing was wrong with the page; it simply
484    /// was not the same page, and only a browser comparing the two could see it
485    /// (`docs/94` §94.13).
486    #[test]
487    fn attributes_cross_in_the_order_they_were_written() {
488        let tree = Html::el("input")
489            .attr("placeholder", "what needs doing?")
490            .attr("autofocus", "on");
491        assert_eq!(
492            tree.to_wire(),
493            json!([
494                "input",
495                [["placeholder", "what needs doing?"], ["autofocus", "on"]],
496                []
497            ])
498        );
499    }
500}