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("&"),
365 '<' => out.push_str("<"),
366 '>' => out.push_str(">"),
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("&"),
388 '<' => out.push_str("<"),
389 '>' => out.push_str(">"),
390 '"' => out.push_str("""),
391 '\'' => out.push_str("'"),
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 < b & c</h1><input value=\""quoted"\"></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}