tracker
All repositories: gitoria
29.0 KB
// The View walker — realm-neutral: the same code renders HTML on the server and// (compiled to JS) creates or claims DOM in the browser. It walks a TREE, the// syntax of a View member reflected into plain hybrids by the server (see// server.hl `tree()`), against an INSTANCE whose members supply the values.//// Tree node shapes:// { k = 'el' tag key path = [ keys… ] site attrs = [ { name text } | { name member } ] children = [ … ] }// `site` is `file:line:col` of the literal — the id its class is minted under// `tag` is what the DOM gets (a custom key kebab-cased), `key` the source key// { k = 'text' text }// { k = 'member' name } an ordered entry naming a member of the class — a SITE// { k = 'field' name path } `p.title`: a path off a member or a `for` row variable// { k = 'for' row list body } `for (row of list) { … }`, body rendered per entry// { k = 'if' cond then other } `if (cond) { … } else { … }`// { k = 'on' event } a DOM event the owning literal handles (its inline `on`)// { k = 'component' cls key path bindings ons } `Child { count = count on done(v) { … } }`:// a composed component — its own mount renders here// { k = 'other' tag } a construct this round does not render yet (if / for / on)//// `ctx` rides through every call: `ctx.slot` is the HTML a shell's `slot` member// stands for (null when rendering a page alone), `ctx.kids` the child mounts of// the instance being rendered, keyed by the component node's path joined with// '/'. A component node renders ITS mount: its own instance, its own kids.// THE HTML ELEMENTS. An entry whose key is one of these is that element; any other// key is a CUSTOM element and is written kebab-case (`postsList` → `<posts-list>`),// which is also what a Style rule of that name selects. This list is what §4 calls// "the compiler's own View element list": one list, so a tag rule in a styles file// and a tag in a View can never disagree.static tags = ['a' 'abbr' 'address' 'article' 'aside' 'audio' 'b' 'bdi' 'bdo' 'blockquote' 'body' 'br' 'button''canvas' 'caption' 'cite' 'code' 'col' 'colgroup' 'data' 'datalist' 'dd' 'del' 'details' 'dfn''dialog' 'div' 'dl' 'dt' 'em' 'embed' 'fieldset' 'figcaption' 'figure' 'footer' 'form''h1' 'h2' 'h3' 'h4' 'h5' 'h6' 'head' 'header' 'hgroup' 'hr' 'html' 'i' 'iframe' 'img' 'input''ins' 'kbd' 'label' 'legend' 'li' 'link' 'main' 'map' 'mark' 'menu' 'meta' 'meter' 'nav''noscript' 'object' 'ol' 'optgroup' 'option' 'output' 'p' 'picture' 'pre' 'progress' 'q' 'rp''rt' 'ruby' 's' 'samp' 'script' 'search' 'section' 'select' 'slot' 'small' 'source' 'span''strong' 'style' 'sub' 'summary' 'sup' 'table' 'tbody' 'td' 'template' 'textarea' 'tfoot''th' 'thead' 'time' 'title' 'tr' 'track' 'u' 'ul' 'var' 'video' 'wbr' 'svg' 'path' 'circle''rect' 'line' 'polyline' 'polygon' 'g' 'text' 'use' 'defs' 'symbol' 'ellipse' 'stop' 'tspan''feBlend' 'feColorMatrix' 'feComposite' 'feFlood' 'feGaussianBlur' 'feMerge' 'feMergeNode''feOffset' 'feTurbulence' 'feDropShadow' 'feMorphology' 'feImage' 'feTile''feComponentTransfer' 'feFuncR' 'feFuncG' 'feFuncB' 'feFuncA' 'feConvolveMatrix''feDiffuseLighting' 'feSpecularLighting' 'feDisplacementMap' 'feDistantLight' 'fePointLight''feSpotLight' 'linearGradient' 'radialGradient' 'clipPath' 'textPath' 'foreignObject''animateMotion' 'animateTransform']// a form control with a `value` the app binds (a member or a field path), and no// autocomplete of the author's ownboundValue(&node) {if (node.tag != 'input' && node.tag != 'textarea' && node.tag != 'select') { return false }let bound = falsefor (a of node.attrs) {if (a.name == 'autocomplete') { return false }if (a.name == 'value' && (a.member != null || a.ref != null)) { bound = true }}return bound}// the value an option reports to the form: its `value`, or null when it has none// and its text stands for itoptionValue(&node, &page, &rows) {for (a of node.attrs) {if (a.name == 'value') {if (a.member != null) { return '' + value({ k = 'member'; name = a.member; }, page, rows) }if (a.ref != null) { return '' + value(a.ref, page, rows) }return '' + a.text}}return null}// ---- THE ELEMENTS THE BROWSER'S PARSER INSERTS ----------------------------------// HTML has elements whose START TAG IS OPTIONAL: a `tr` written straight inside a// `table` is parsed into a `tbody` the parser creates, a `td` outside a `tr` gets both,// and a `col` gets a `colgroup`. The served tree and the PARSED tree have to be the same// tree — the claim walk pairs them element for element — so the tree carries what the// parser would insert and the author goes on writing `table { tr { td { … } } }`.// (Measured 2026-09-14 on demo-nested: 38 rows, 38 "claim: no element for tr under// TABLE", one per row; the fix in the app was to write the `tbody` by hand.)//// One row per (parent, child) pair the standard's optional-tag rules name. The walk// applies them REPEATEDLY, so a `td` under a `table` gains a `tbody` and then a `tr`// inside it — what the parser does, and not a table special case: a pair added to this// list is a pair the language then renders the way the browser will read it.static impliedElements = [{ under = 'table' child = 'tr' wrap = 'tbody' }{ under = 'table' child = 'td' wrap = 'tbody' }{ under = 'table' child = 'th' wrap = 'tbody' }{ under = 'table' child = 'col' wrap = 'colgroup' }{ under = 'tbody' child = 'td' wrap = 'tr' }{ under = 'tbody' child = 'th' wrap = 'tr' }{ under = 'thead' child = 'td' wrap = 'tr' }{ under = 'thead' child = 'th' wrap = 'tr' }{ under = 'tfoot' child = 'td' wrap = 'tr' }{ under = 'tfoot' child = 'th' wrap = 'tr' }]// the container the parser would open for this child under this parent, or nullimpliedWrap(under, child) {if (child == null) { return null }for (r of impliedElements) { if (r.under == under && r.child == child) { return r.wrap } }return null}// THE TAG A NODE PUTS IN THE DOCUMENT: an element's own, and for a `for` or an `if` the// tag of the first element it renders — a region of `tr`s under a `table` needs the// `tbody` a bare `tr` needs, and it is the same rule that says so.firstTag(&n) {if (n == null) { return null }if (n.k == 'el') { return n.tag }if (n.k == 'for') { return firstTagIn(n.body) }if (n.k == 'if') {let t = firstTagIn(n.then)return t == null ? firstTagIn(n.other) : t}return null}firstTagIn(&list) {if (list == null) { return null }for (n of list) {let t = firstTag(n)if (t != null) { return t }}return null}// an element's children with those containers written out. A RUN of children that need// the same one SHARES it, because the parser opens one `tbody` and goes on putting rows// in it until something else closes it.implied(under, children, path, site) {let out = []let run = []let runWrap = nullfor (c of children) {let w = impliedWrap(under, firstTag(c))if (w != runWrap && run.length > 0) {out.push(impliedNode(runWrap, run, path, site))run = []}runWrap = wif (w == null) { out.push(c) } else { run.push(c) }}if (run.length > 0) { out.push(impliedNode(runWrap, run, path, site)) }return out}// the node for a container nobody wrote: no attributes, no handlers, and the rule// applied again inside it (`table { td }` is `table > tbody > tr > td`)impliedNode(tag, children, path, site) {let here = path.slice(0)here.push(tag)return { k = 'el'; tag = tag; key = tag; path = here; attrs = []; children = implied(tag, children, here, site + '>' + tag); site = site + '>' + tag; }}static voidTags = [ 'area' 'base' 'br' 'col' 'embed' 'hr' 'img' 'input' 'link' 'meta' 'source' 'track' 'wbr' ]// THE VERBATIM ELEMENTS: whitespace between their tags is CONTENT, not layout. A// textarea's text IS its value, and a `pre`, a `script` and a `style` show or run// exactly what stands there — so the readable form never indents inside them// (the creator, 2026-09-13: "textarea seems to show the indentation as value").static verbatimTags = [ 'textarea' 'pre' 'script' 'style' ]isTag(name) {return tags.includes(name)}// the tag the DOM gets: an element of the list keeps its spelling (SVG has camelCase// elements: `linearGradient`, `clipPath`, `foreignObject`), any other key is custom, kebab-casedomTag(name) {return tags.includes(name) ? name : kebab(name)}// camelCase → kebab-case (the archived WebView.kebab, verbatim): a separator only// BETWEEN a lowercase letter or digit and an uppercase one; at position 0, after// another uppercase or after a sigil an uppercase simply lowercases, so `ChatForm`// is `chat-form` and `#Svg` is `#svg`. The tag written on a custom element and the// selector a Style rule emits both come through here, so they cannot disagree.static KEBAB_JOINERS = "-_#@"kebab(name) {let out = ''let i = 0let prev = ''while (i < name.length) {let c = name.charAt(i)let lower = c.toLowerCase()let between = prev != '' && prev == prev.toLowerCase() && KEBAB_JOINERS.indexOf(prev) < 0if (c != lower && between) { out = out + '-' + lower } else { out = out + lower }prev = ci = i + 1}return out}// THE TAG-PREFIX STRIP (archived WebView.className, verbatim): rule `headerMain`// referenced under `header` is class `main`; under any other tag it keeps its name.className(rule, tag) {if (rule.length <= tag.length) { return rule }if (!rule.startsWith(tag)) { return rule }let rest = rule.slice(tag.length)let head = rest.charAt(0)if (head == head.toLowerCase()) { return rule }return head.toLowerCase() + rest.slice(1)}// THE PER-FILE CLASS of a `#local` rule (archived WebView.localClass, verbatim):// four characters out of the file's project-relative key, so it is stable across// checkouts and a golden can pin it. Every local rule of one file shares it; the// element's tag in front of it keeps them apart in the sheet (`svg.ma31`).static LOCAL_KEY_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/._-"static LOCAL_LETTERS = "abcdefghijklmnopqrstuvwxyz"static LOCAL_ALPHA = "abcdefghijklmnopqrstuvwxyz0123456789"localClass(fileKey) {let h = 0let i = 0while (i < fileKey.length) {h = (h * 31 + LOCAL_KEY_CHARS.indexOf(fileKey.charAt(i)) + 1) % 1000003i = i + 1}let out = LOCAL_LETTERS.charAt(h % 26)let rest = (h - (h % 26)) / 26let j = 0while (j < 3) {out = out + LOCAL_ALPHA.charAt(rest % 36)rest = (rest - (rest % 36)) / 36j = j + 1}return out}// the language has no escapes: a newline and a tab are written as themselvesstatic newline = ""static tab = " "// a component node's key in a mount's `kids`: its path, '/'-joined (a list has no// join in the language; the walk is written out)kidKey(&path) {let out = ''for (p of path) { out = out + '/' + p }return out}// THE KEY OF ONE ROW'S CHILD: the component node's path plus the index of every// enclosing `for` iteration, which `ctx.rowKey` carries down the walk. Outside// every `for` the rowKey is empty and this is the plain path key.kidKeyIn(&path, &ctx) {return kidKey(path) + (ctx.rowKey != null ? ctx.rowKey : '')}// THE KEY OF ONE ROW — "lists are keyed by the record's id" (CONCEPT §2), and by// the row's index where the row is not a record or carries no id (a list of strings// is one: demo-blog's paragraphs). ONE rule, called by everything that has to tell// one row from the next: the server's HTML walk below, the mount key of a row's// children (WebFramework `mountKids`), and the browser's keyed region (client.hl).// Two rows with the SAME id are two rows to the language and one key to this rule;// the browser's region gives the second one a key of its own, the server does not.rowKey(&entry, i) {if (entry != null) {let t = hlTypeName(entry)if ((t == 'Hybrid' || t == 'Instance') && entry.id != null) { return '' + entry.id }}return '' + i}// the walking context of one `for` iteration: the same slot and kids, one row deeperrowCtx(&ctx, &entry, i) {let out = ctx + {}out.rowKey = (ctx.rowKey != null ? ctx.rowKey : '') + '#' + rowKey(entry, i)return out}// A LITERAL'S VALUE, TYPED — COMPONENTS.md §12, "typed, not stringified". `hlSyntax`// hands a literal back as its source text and its KIND, and a reference binding carries// the value rather than the text: `Level2 { n = 1 }` gives the child a Number, so `n * 97`// is arithmetic and not "operator '*' does not apply to String and Number" (measured// 2026-09-14, demo-nested wall 3). An attribute and a text child keep the text, because// HTML is text; only what crosses into a member of another class is typed.literalValue(kind, text) {if (kind == 'number') { return toNumber(text) }if (kind == 'boolean') { return text == 'true' }if (kind == 'null_literal') { return null }return text}// THE VALUE A REFERENCE BINDING CARRIES when it is a literal or a folded static — the// one place the two are read, so the server's mount and the browser's do it alike. A// static is folded to its VALUE and carried as one ('folded'), never printed and parsed// back; a literal carries its source text and its kind, which is all the syntax has.refValue(b) {if (b.kind == 'folded') { return b.value }if (b.kind != null) { return literalValue(b.kind, b.text) }return b.text}// HTML BOOLEAN ATTRIBUTES MEAN PRESENCE, NOT VALUE (mission 084's rule, ticket #33): a// parser reads `disabled="false"` as DISABLED, so a bound one is written ` disabled=""`// when its value is on and left out when it is off, and the browser sets or removes it// and writes the property. The list is mission 084's, the one the archived SSR, graph and// client agreed on.static boolAttrs = ['checked' 'disabled' 'selected' 'readonly' 'required' 'multiple' 'open' 'hidden']isBoolAttr(name) {return boolAttrs.includes(name)}// is a boolean attribute ON for this value? false, null, '' and 0 leave it off — what the// browser's own `!!v` says, so the server's markup and the client's write agreeboolOn(val) {if (val == null || val == false || val == '') { return false }if (hlTypeName(val) == 'Number' && val == 0) { return false }return true}escape(s) {if (s == null) { return '' }// EVERY occurrence, not the first: `replace` stops after one, so a value with two// quotes in it ("a" "b") left the second one raw in the attribute it was written intoreturn ('' + s).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')}// THE VALUE a read names: a `for` row variable in scope wins over a member of the// same name (nesting is scope); a field path walks into it.value(&node, &page, &rows) {if (node.k == 'text') { return refValue(node) }if (node.k == 'expr') { return evalExpr(node.x, page, rows) }let v = nullif (rows[node.name] != null) { v = rows[node.name] } else { v = page[node.name] }if (node.k == 'field') {for (f of node.path) {if (v == null) { return null }v = v[f]}}return v}// A VIEW EXPRESSION (`p { "x" + n }`, `a - b`, `n > 3 ? 'big' : 'small'`) is evaluated by// the LANGUAGE'S OWN OPERATORS: this walk only picks the operator the author wrote and// applies it, so what `+` gives null and a string, what `==` says of two types, what `&&`// returns — all of it is the runtime's answer, on the server (the interpreter or a// binary) and in the browser (the client target), and hl:web has no rule of its own.// The leaves are reads (`value`) and literals. `&&`, `||` and `?:` evaluate what they// pick only, as the language does.evalExpr(&x, &page, &rows) {if (x.k == 'bin') {let l = evalExpr(x.l, page, rows)let op = x.opif (op == 'logical_and') { return l && evalExpr(x.r, page, rows) }if (op == 'logical_or') { return l || evalExpr(x.r, page, rows) }let r = evalExpr(x.r, page, rows)if (op == 'add') { return l + r }if (op == 'subtract') { return l - r }if (op == 'multiply') { return l * r }if (op == 'divide') { return l / r }if (op == 'modulo') { return l % r }if (op == 'equal') { return l == r }if (op == 'not_equal') { return l != r }if (op == 'less_than') { return l < r }if (op == 'greater_than') { return l > r }if (op == 'less_equal') { return l <= r }if (op == 'greater_equal') { return l >= r }if (op == 'bit_and') { return l & r }if (op == 'bit_or') { return l | r }if (op == 'bit_xor') { return l ^ r }if (op == 'shift_left') { return l << r }return l >> r}if (x.k == 'un') {let o = evalExpr(x.x, page, rows)if (x.op == 'logical_not') { return !o }if (x.op == 'negate') { return -o }return ~o}if (x.k == 'cond') { return evalExpr(x.c, page, rows) ? evalExpr(x.a, page, rows) : evalExpr(x.b, page, rows) }return value(x, page, rows)}// THE BIG VALUES OF THE WALK ARE TAKEN BY REFERENCE, and this is the whole of why the// server's render was slow (mission 312 wall 2, measured 2026-09-15). A METHOD CALL// DEEP-COPIES ITS ARGUMENTS — a lambda call does not, which is why this went unseen —// so every one of these calls copied the page INSTANCE and the `ctx` holding every// child mount of the page, once per element and once per read. The cost of rendering// one element was therefore proportional to everything the page was holding: a 43-row// table beside 120 components took 9.6 s, the same table alone 0.44 s, and a page whose// instance merely HELD 30 000 objects nobody renders took 34 s for 344 static elements.// Nothing in this walk writes through `page`, `rows` or `ctx` — `kidCtx`, `inner` and// `rowCtx`'s result are locals it copies on purpose — so a reference is the same walk,// and the `&` at a call site is optional, so every caller (client.hl included) is// unchanged. Measured after: 0.57 s, 1.27 s, 0.12 s for the three cases above.//// HTML of one node. `page` is the instance; `rows` the `for` variables in scope;// `ctx` carries the slot's HTML and the child mounts. `indent` is the current line's// indentation (a string of tabs) for the readable form, or null for the compact// one-line form (the production setting). Whitespace is only ever placed BETWEEN// elements: an element that carries text or a member value stays on one line, so// its text content is exactly what the author wrote and hydration finds it so.html(&node, &page, &rows, &ctx, indent) {if (node.k == 'text') { return escape(node.text) }if (node.k == 'on') { return '' } // the browser binds itif (node.k == 'member' || node.k == 'field' || node.k == 'expr') {if (node.k == 'member' && node.name == 'slot' && ctx.slot != null) { return ctx.slot }return escape(value(node, page, rows))}if (node.k == 'for') {let out = ''let list = value(node.list, page, rows)if (list == null) { return '' }let ri = 0for (entry of list) {let inner = rows + {}inner[node.row] = entryout = out + render(node.body, page, inner, rowCtx(ctx, entry, ri), indent)ri = ri + 1}return out}if (node.k == 'if') {if (value(node.cond, page, rows)) { return render(node.then, page, rows, ctx, indent) }return render(node.other, page, rows, ctx, indent)}if (node.k == 'component') {let kid = ctx.kids[kidKeyIn(node.path, ctx)]if (kid == null) { return '<!-- ' + node.cls + ' not mounted -->' }// THE FILL IS RENDERED HERE, IN THE HOST'S FRAME — the host's instance, the// host's `for` rows, the host's own child mounts — and handed to the child as// its `slot`, exactly as the shell is handed the page it wraps. The child// never evaluates it; it only places it.return render(withRootClasses(kid.view, node.classes), kid.page, {}, { slot = fillHtml(node, page, rows, ctx); kids = kid.kids; }, indent)}if (node.k == 'el') {let attrs = ''// HTML GIVES A TEXTAREA NO `value` ATTRIBUTE: what stands between its tags is// its value, so a bound one is written there — the server-rendered control// already shows what the member holds, before any script runs.let valueText = null// AND A SELECT NO `value` ATTRIBUTE EITHER: what it shows is the option marked// `selected`, so a bound value is carried down to the options (`childCtx.sel`)// and marks the matching one. A `value` written on the select itself means// nothing to the browser, which then shows the FIRST option — and hydration's// two-way read then wrote that first option back into the member (measured// 2026-09-13 on demo-blog: a published post's editor said "Draft", and saving// unpublished it).let selValue = nullfor (a of node.attrs) {if (node.tag == 'textarea' && a.name == 'value') {// not an attribute: it is written below, as the element's contentif (a.member != null) { valueText = escape(value({ k = 'member'; name = a.member; }, page, rows)) }else if (a.ref != null) { valueText = escape(value(a.ref, page, rows)) }else { valueText = escape(a.text) }} else if (node.tag == 'select' && a.name == 'value') {// not an attribute: it selects an option belowif (a.member != null) { selValue = '' + value({ k = 'member'; name = a.member; }, page, rows) }else if (a.ref != null) { selValue = '' + value(a.ref, page, rows) }else { selValue = '' + a.text }} else if (isBoolAttr(a.name) && (a.member != null || a.ref != null)) {if (boolOn(value(a.member != null ? { k = 'member'; name = a.member; } : a.ref, page, rows))) { attrs = attrs + ' ' + a.name + '=""' }} else if (a.member != null) {attrs = attrs + ' ' + a.name + '="' + escape(value({ k = 'member'; name = a.member; }, page, rows)) + extraOf(a) + '"'} else if (a.ref != null) {attrs = attrs + ' ' + a.name + '="' + escape(value(a.ref, page, rows)) + extraOf(a) + '"'} else {attrs = attrs + ' ' + a.name + '="' + escape(a.text) + '"'}}// A CONTROL WHOSE VALUE THE APP BINDS IS THE APP'S: the browser must neither// autofill it nor restore a remembered value over it on reload (Chrome restores// unnamed text inputs by type and document order, so a remembered value lands// on whichever input now stands at that position — measured by the creator// 2026-09-13: "Gamma" in the add field after Ctrl+R)if (boundValue(node)) { attrs = attrs + ' autocomplete="off"' }// THE CONTEXT IS NEVER COPIED INTO A LOCAL HERE. `let kidCtx = ctx` costs a copy// of everything the frame holds — every child mount of this page — and it stood// on the path of EVERY element: with the 43-row matrix in a page of 121// components it was 5.1 s against 0.6 s for the same two halves apart (measured// 2026-09-15, mission 312 wall 2). A select is the only node that changes the// context for its children, so it is the only branch that builds one.let children = ''if (node.tag == 'select') {// the select's value walks down to its options, however deep the `for` or the// `if` that builds them standslet kidCtx = ctx + {}kidCtx.sel = selValuechildren = kidsHtml(node, page, rows, kidCtx, indent)} else {children = kidsHtml(node, page, rows, ctx, indent)}// the textarea's value stands where its content standsif (valueText != null) { children = valueText }// THE OPTION THE SELECT SHOWS: the one whose value is the member's. An option// with no `value` reports its text, as the browser reads it.if (node.tag == 'option' && ctx.sel != null) {let ov = optionValue(node, page, rows)if (ov == null) { ov = children }if (ov == ctx.sel) { attrs = attrs + ' selected' }}// a void element has no end tag (the HTML standard's list; the claim walks// elements, so the browser's parse of either form agrees with the tree)if (voidTags.includes(node.tag)) { return '<' + node.tag + attrs + '>' }return '<' + node.tag + attrs + '>' + children + '</' + node.tag + '>'}return '<!-- ' + node.tag + ' not rendered yet -->'}// AN ELEMENT'S CHILDREN, in the readable form or the compact one — split out of `html`// so that the one node which changes the context for its children (a select, which hands// its value down to its options) is the only one that builds a context at all.kidsHtml(&node, &page, &rows, &ctx, indent) {if (indent != null && blocky(node, ctx)) {return block(node.children, page, rows, ctx, indent + tab) + newline + indent}let out = ''for (c of node.children) { out = out + html(c, page, rows, ctx, null) }return out}// The HTML a reference's fill stands for, or null when the reference fills nothing// (a bare `Card`, or `Card { }`) — then the child's `slot` renders as the member it// is, which is nothing.//// COMPACT, whatever form the page is rendered in: this frame is the HOST's, and the// indentation of the line the child places `slot` on is decided inside the child,// one frame further down. Whitespace between elements is not content and the claim// walk steps ELEMENTS, so a fill that is written on one line is claimed the same.fillHtml(&node, &page, &rows, &ctx) {if (node.fill == null || node.fill.length == 0) { return null }return render(node.fill, page, rows, ctx, null)}// AN ELEMENT'S CHILDREN MINUS THE HANDLERS A REFERENCE OVERWROTE. COMPONENTS.md:757// — "a same-named event binding on the reference overwrites the child root's own": the// reference's `on click` REPLACES the child root's `on click`, it does not stand beside// it, so the child's own entry for that event is never bound on that element.withoutOns(&nodes, &ons) {let out = []for (n of nodes) {let overwritten = falsefor (o of ons) { if (n.k == 'on' && o.event == n.event) { overwritten = true } }if (!overwritten) { out.push(n) }}return out}// THE CLASSES A REFERENCE PUTS ON THE CHILD'S ROOT, appended to a class the child// BINDS (`button { class = tone }`): the bound value is only known at render, so the// extra classes ride beside it and every writer of that attribute appends them —// here, and in the browser's create and repaint. Before this the merge handled only a// LITERAL class, and a child root that bound its own came out with TWO class// attributes, of which a browser keeps the first — so the host's `#Child` class// silently did nothing (measured 2026-09-13 on press.hl's `button { class = tone }`).extraOf(a) {if (a.extra == null) { return '' }return ' ' + a.extra}// A composed child's ROOT elements carry the classes the host's `#Child` local rule// put on the reference: the child's tree is shared, so the roots are copied with// the class merged in, and the child's own tree is untouched.withRootClasses(&nodes, &classes) {if (classes == null || classes.length == 0) { return nodes }let out = []for (n of nodes) {if (n.k == 'el') {let attrs = []let merged = falsefor (a of n.attrs) {if (a.name == 'class' && a.text != null) { attrs.push({ name = 'class'; text = a.text + ' ' + joinClasses(classes); }) merged = true }else if (a.name == 'class') { attrs.push({ name = 'class'; member = a.member; ref = a.ref; extra = joinClasses(classes); }) merged = true }else { attrs.push(a) }}if (!merged) { attrs.push({ name = 'class'; text = joinClasses(classes); }) }out.push({ k = 'el'; tag = n.tag; key = n.key; path = n.path; attrs = attrs; children = n.children; site = n.site; })} else { out.push(n) }}return out}joinClasses(classes) {let out = ''for (c of classes) { out = out == '' ? c : out + ' ' + c }return out}// Does this element hold only elements (and control flow around them)? Then its// children may each take a line. A text or member child makes it inline. The// shell's `slot` counts as an element: the child page stands there as a block.blocky(&node, &ctx) {if (verbatimTags.includes(node.tag)) { return false }if (node.children.length == 0) { return false }for (c of node.children) {if (c.k == 'text' || c.k == 'field' || c.k == 'expr') { return false }if (c.k == 'member' && !(c.name == 'slot' && ctx.slot != null)) { return false }}return true}// Elements one per line at `indent`; `for` and `if` unfold into lines at the same// level. Anything else that appears here renders inline at the end of the run.block(&nodes, &page, &rows, &ctx, indent) {let out = ''for (n of nodes) {if (n.k == 'el') {out = out + newline + indent + html(n, page, rows, ctx, indent)} else if (n.k == 'component') {out = out + html(n, page, rows, ctx, indent)} else if (n.k == 'for') {let list = value(n.list, page, rows)if (list != null) {let ri = 0for (entry of list) {let inner = rows + {}inner[n.row] = entryout = out + block(n.body, page, inner, rowCtx(ctx, entry, ri), indent)ri = ri + 1}}} else if (n.k == 'if') {if (value(n.cond, page, rows)) { out = out + block(n.then, page, rows, ctx, indent) }else { out = out + block(n.other, page, rows, ctx, indent) }} else if (n.k == 'member' && n.name == 'slot' && ctx.slot != null) {out = out + newline + indent + ctx.slot} else if (n.k != 'on') {out = out + html(n, page, rows, ctx, indent)}}return out}// HTML of a whole View tree (a list of nodes), compact when `indent` is nullrender(&nodes, &page, &rows, &ctx, indent) {if (indent != null) { return block(nodes, page, rows, ctx, indent) }let out = ''for (n of nodes) { out = out + html(n, page, rows, ctx, null) }return out}
Branches
- mainmain branch
Latest commits
- 7565a863tracker: LOG timemre
- b10f00c8tracker#39: double episodes — migrated episodes whose TMDB id TMDB replaced are adopted by their number in the sync (old id -> migratedTmdbId); merge.hl step 3 merges each season's doubles at start (keeper: most watches > synced > first; watches moved/parked; tombstones into mergedEpisodes, nothing deleted); tools/count-duplicate-episodes.hl; gate fixture + paths-m039; live copy 850 -> 0 in 64 s; gates 373/0, 32/0, 52/0mre
- 8751adb8tracker: report 032mre
- 9bce1f65tracker mission 032: STATUS gate files + the hour-boundary flakemre
- 718bfb89tracker#37 (mission 032): /people = everyone, last updated first (updatedAt stamped by the person fill; view built at boot, touched people first at once), photo + name tiles (person colour) with the /movies pagination, /people/<letter> removed; photo = our file, tmdbProfile, a cast/crew entry's profile (in-memory map at boot), else the new 'no photo' placeholder; new cast/crew/created_by people keep tmdbProfile; search people rows with the photo; /settings = the heading only; util.hl sortDesc starts from sorted runs (same result, 105k: 1.6 s -> 0.15 s); gates 369/0, 32/0, 52/0, check-theme 0; README/STATUS/LOGmre
- 96ba683adeploy.sh: a gate without a 'passed,' line (check-theme) no longer ends the scriptmre
- eb3b9205tracker: report 031mre
- 9b5d2e89tracker mission 031: README (What it does, Test: four gates + the #32 checks, Files: theme/, new pages), STATUS (real copy, A/B load, how to repeat, open points), LOGmre
- 39950e4ctracker#32 (mission 031): the WorldAPI theme (theme/ vendored verbatim from layouts.worldapi.org 85b5654; styles.hl inherits it: accent green-dark, type colours 1-6; own base/header rules, row lines, genre-pill and inverted-button frames removed, the season foldable keeps its line; check-theme 21 -> 0, 4th deploy gate; main actions class primary) and the #32 header (theme AppHeader/MainMenu/UserMenu/Sidebar/ContentFirst: desktop brand, search, Series|Shows|Movies|Genres|People, user icon with Unwatched..Settings, Logout; signed out the ident selector, phone the iD icon dropdown; phone menu in the sidebar overlay; marked entry by :has); /find -> /search/<q>, /genres, /people(/<letter>), /settings; main { ContentFirst { slot } } works around the hl:web one-line slot bug; gates 365/0, 32/0, 52/0, check-theme 0mre
- a386dc92tracker: reports 029 + 030mre
- 71e0fd7dtracker missions 029 + 030: README (What it does, Files, gate count), STATUS (real-copy numbers, how to repeat, open points), LOGmre
- d36ea6eatracker#34 + #35 (mission 030): Follow directly under the poster, as wide as the poster (show.hl, styles.hl); the status pill next to a series' title — TVmaze's status (new tvmazeStatus, stored by the sync's TVmaze merge) else TMDB's, TVmaze Ended + TMDB Canceled = Canceled, inverted (filled, dark text, no border), green running / yellow pending / red canceled / muted ended (shows.hl statusOf); the daily delta asks TVmaze's status of an unfollowed series TVmaze's change list names (dailysync.hl syncRunStep, sync.hl syncTvmazeStatus); the status backfill after the details repair (backfill.hl, jobs.hl statusTick; resumable, 550 ms per TVmaze request); gates 354/0, 32/0, 52/0mre
- 7d7d4487tracker#33 (mission 029): reduced titles — every title TMDB's details never went through this app (no detailsAt, no tmdbSync) is incomplete (shows.hl isIncomplete; the old tracker's migrated rows passed #26's test: 5,697 non-adult on the live copy, 691 series without seasons); the repair job does the visibly reduced first (shows.hl missingParts), the page completes one on open; a title TMDB has no poster for (The Remaining) shows the placeholder; tools/count-incomplete.hl; gate fixtures stand for synced titles (tmdbSync), tests/seed-reduced.hl + #33 checks; gates 347/0, 32/0, 52/0mre
- 661c2592tracker: report 028mre
- 27c916fatracker mission 028: README ("Code order", the new file map), STATUS (counts before/after, tests, how to repeat, open), LOGmre
- d924f398tracker mission 028: comments name the new files (sync.hl, dailysync.hl, backfill.hl, credits.hl, jobs.hl, images.hl …); tools/ref-params.py + tools/lambda-audit.py also scan lib/ (they globbed the root only), lambda-audit counts a plain `x = p` alias like `let x = p`mre
- 2e89b968tracker mission 028 (code order) 5/5 let: `let` only where a variable is reassigned — 667 never-reassigned lets became plain declarations (project.hl, lib/, components/, tools/, tests/); kept: 264 in loop bodies (a plain declaration there is 'Cannot reassign' on the 2nd pass), 234 reassigned, 27 whose name is also a member/outer/free name (a plain write would rebind it); tools/let-audit.py decides and fixes (README 'Code order'); tests/realdata-m028.{sh,mjs} = the page-output diff on a real copy; gates 342/0, 32/0, 52/0, real-copy pages identicalmre
- 54796ff2tracker mission 028 (code order) 4/5 thin faces + last copies: the show page's check/follow faces call lib/watches.hl toggleWatched / toggleSeasonWatched (seasonAllWatched moved there) and lib/follows.hl toggleFollowed; both logins (header selector face, /login/callback) share lib/users.hl userOfCode; todayStr/listOf copies in components and the export readers copied into tools/migrate.hl + tools/old-short-ids.hl now once (lib/util.hl, lib/export.hl); gates 342/0, 32/0, 52/0; old-short-ids output byte-identical, migrate output identicalmre
- 06b078e3tracker mission 028 (code order) 3/5 project.hl is the map: config, routes, wiring and a feature → file index (914 → 258 lines); the background jobs (daily sync run, backfills, details repair, credits job, merge, short ids, collection seed) moved unchanged into lib/jobs.hl (a class: their state is reassigned every step, a static cannot be; one instance made after the server), the login callback into lib/users.hl, poster/photo serving into lib/images.hl, the /shows/<slug> rule into lib/shows.hl showsMovedPath; route handlers are thin wrappers; gates 342/0, 32/0, 52/0, real-copy pages identicalmre
- 94716fd2tracker mission 028 (code order) 2/5 util + topics: lib/util.hl holds envOr, storageDir, postersDir, profilesDir, newId, hexDigits, todayStr, dateOr, textOr, hasId, listOr, firstOf, sortDesc once (were copied into up to 5 files); tmdbsync.hl split into tmdb.hl (TMDB/TVmaze requests), sync.hl (one title's sync), sync-helpers.hl, backfill.hl; details.hl split into details.hl, credits.hl, credits-helpers.hl (isIncomplete to shows.hl); search-helpers.hl (words, query, ranking, slugs); collections.hl (the TMDB collection seed, out of franchises.hl); deltasync.hl renamed dailysync.hl; no behaviour change: gates 342/0, 32/0, 52/0, real-copy pages identicalmre