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("&"),
293 '<' => out.push_str("<"),
294 '>' => out.push_str(">"),
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("&"),
316 '<' => out.push_str("<"),
317 '>' => out.push_str(">"),
318 '"' => out.push_str("""),
319 '\'' => out.push_str("'"),
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 < b & c</h1><input value=\""quoted"\"></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}