gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit7565a8637565a863tracker: LOG timemre7565a863/plugins/web/view.hl

29.0 KB

  1. // The View walker — realm-neutral: the same code renders HTML on the server and
  2. // (compiled to JS) creates or claims DOM in the browser. It walks a TREE, the
  3. // syntax of a View member reflected into plain hybrids by the server (see
  4. // server.hl `tree()`), against an INSTANCE whose members supply the values.
  5. //
  6. // Tree node shapes:
  7. // { k = 'el' tag key path = [ keys… ] site attrs = [ { name text } | { name member } ] children = [ … ] }
  8. // `site` is `file:line:col` of the literal — the id its class is minted under
  9. // `tag` is what the DOM gets (a custom key kebab-cased), `key` the source key
  10. // { k = 'text' text }
  11. // { k = 'member' name } an ordered entry naming a member of the class — a SITE
  12. // { k = 'field' name path } `p.title`: a path off a member or a `for` row variable
  13. // { k = 'for' row list body } `for (row of list) { … }`, body rendered per entry
  14. // { k = 'if' cond then other } `if (cond) { … } else { … }`
  15. // { k = 'on' event } a DOM event the owning literal handles (its inline `on`)
  16. // { k = 'component' cls key path bindings ons } `Child { count = count on done(v) { … } }`:
  17. // a composed component — its own mount renders here
  18. // { k = 'other' tag } a construct this round does not render yet (if / for / on)
  19. //
  20. // `ctx` rides through every call: `ctx.slot` is the HTML a shell's `slot` member
  21. // stands for (null when rendering a page alone), `ctx.kids` the child mounts of
  22. // the instance being rendered, keyed by the component node's path joined with
  23. // '/'. A component node renders ITS mount: its own instance, its own kids.
  24. // THE HTML ELEMENTS. An entry whose key is one of these is that element; any other
  25. // key is a CUSTOM element and is written kebab-case (`postsList` → `<posts-list>`),
  26. // which is also what a Style rule of that name selects. This list is what §4 calls
  27. // "the compiler's own View element list": one list, so a tag rule in a styles file
  28. // and a tag in a View can never disagree.
  29. static tags = [
  30. 'a' 'abbr' 'address' 'article' 'aside' 'audio' 'b' 'bdi' 'bdo' 'blockquote' 'body' 'br' 'button'
  31. 'canvas' 'caption' 'cite' 'code' 'col' 'colgroup' 'data' 'datalist' 'dd' 'del' 'details' 'dfn'
  32. 'dialog' 'div' 'dl' 'dt' 'em' 'embed' 'fieldset' 'figcaption' 'figure' 'footer' 'form'
  33. 'h1' 'h2' 'h3' 'h4' 'h5' 'h6' 'head' 'header' 'hgroup' 'hr' 'html' 'i' 'iframe' 'img' 'input'
  34. 'ins' 'kbd' 'label' 'legend' 'li' 'link' 'main' 'map' 'mark' 'menu' 'meta' 'meter' 'nav'
  35. 'noscript' 'object' 'ol' 'optgroup' 'option' 'output' 'p' 'picture' 'pre' 'progress' 'q' 'rp'
  36. 'rt' 'ruby' 's' 'samp' 'script' 'search' 'section' 'select' 'slot' 'small' 'source' 'span'
  37. 'strong' 'style' 'sub' 'summary' 'sup' 'table' 'tbody' 'td' 'template' 'textarea' 'tfoot'
  38. 'th' 'thead' 'time' 'title' 'tr' 'track' 'u' 'ul' 'var' 'video' 'wbr' 'svg' 'path' 'circle'
  39. 'rect' 'line' 'polyline' 'polygon' 'g' 'text' 'use' 'defs' 'symbol' 'ellipse' 'stop' 'tspan'
  40. 'feBlend' 'feColorMatrix' 'feComposite' 'feFlood' 'feGaussianBlur' 'feMerge' 'feMergeNode'
  41. 'feOffset' 'feTurbulence' 'feDropShadow' 'feMorphology' 'feImage' 'feTile'
  42. 'feComponentTransfer' 'feFuncR' 'feFuncG' 'feFuncB' 'feFuncA' 'feConvolveMatrix'
  43. 'feDiffuseLighting' 'feSpecularLighting' 'feDisplacementMap' 'feDistantLight' 'fePointLight'
  44. 'feSpotLight' 'linearGradient' 'radialGradient' 'clipPath' 'textPath' 'foreignObject'
  45. 'animateMotion' 'animateTransform'
  46. ]
  47. // a form control with a `value` the app binds (a member or a field path), and no
  48. // autocomplete of the author's own
  49. boundValue(&node) {
  50. if (node.tag != 'input' && node.tag != 'textarea' && node.tag != 'select') { return false }
  51. let bound = false
  52. for (a of node.attrs) {
  53. if (a.name == 'autocomplete') { return false }
  54. if (a.name == 'value' && (a.member != null || a.ref != null)) { bound = true }
  55. }
  56. return bound
  57. }
  58. // the value an option reports to the form: its `value`, or null when it has none
  59. // and its text stands for it
  60. optionValue(&node, &page, &rows) {
  61. for (a of node.attrs) {
  62. if (a.name == 'value') {
  63. if (a.member != null) { return '' + value({ k = 'member'; name = a.member; }, page, rows) }
  64. if (a.ref != null) { return '' + value(a.ref, page, rows) }
  65. return '' + a.text
  66. }
  67. }
  68. return null
  69. }
  70. // ---- THE ELEMENTS THE BROWSER'S PARSER INSERTS ----------------------------------
  71. // HTML has elements whose START TAG IS OPTIONAL: a `tr` written straight inside a
  72. // `table` is parsed into a `tbody` the parser creates, a `td` outside a `tr` gets both,
  73. // and a `col` gets a `colgroup`. The served tree and the PARSED tree have to be the same
  74. // tree — the claim walk pairs them element for element — so the tree carries what the
  75. // parser would insert and the author goes on writing `table { tr { td { … } } }`.
  76. // (Measured 2026-09-14 on demo-nested: 38 rows, 38 "claim: no element for tr under
  77. // TABLE", one per row; the fix in the app was to write the `tbody` by hand.)
  78. //
  79. // One row per (parent, child) pair the standard's optional-tag rules name. The walk
  80. // applies them REPEATEDLY, so a `td` under a `table` gains a `tbody` and then a `tr`
  81. // inside it — what the parser does, and not a table special case: a pair added to this
  82. // list is a pair the language then renders the way the browser will read it.
  83. static impliedElements = [
  84. { under = 'table' child = 'tr' wrap = 'tbody' }
  85. { under = 'table' child = 'td' wrap = 'tbody' }
  86. { under = 'table' child = 'th' wrap = 'tbody' }
  87. { under = 'table' child = 'col' wrap = 'colgroup' }
  88. { under = 'tbody' child = 'td' wrap = 'tr' }
  89. { under = 'tbody' child = 'th' wrap = 'tr' }
  90. { under = 'thead' child = 'td' wrap = 'tr' }
  91. { under = 'thead' child = 'th' wrap = 'tr' }
  92. { under = 'tfoot' child = 'td' wrap = 'tr' }
  93. { under = 'tfoot' child = 'th' wrap = 'tr' }
  94. ]
  95. // the container the parser would open for this child under this parent, or null
  96. impliedWrap(under, child) {
  97. if (child == null) { return null }
  98. for (r of impliedElements) { if (r.under == under && r.child == child) { return r.wrap } }
  99. return null
  100. }
  101. // THE TAG A NODE PUTS IN THE DOCUMENT: an element's own, and for a `for` or an `if` the
  102. // tag of the first element it renders — a region of `tr`s under a `table` needs the
  103. // `tbody` a bare `tr` needs, and it is the same rule that says so.
  104. firstTag(&n) {
  105. if (n == null) { return null }
  106. if (n.k == 'el') { return n.tag }
  107. if (n.k == 'for') { return firstTagIn(n.body) }
  108. if (n.k == 'if') {
  109. let t = firstTagIn(n.then)
  110. return t == null ? firstTagIn(n.other) : t
  111. }
  112. return null
  113. }
  114. firstTagIn(&list) {
  115. if (list == null) { return null }
  116. for (n of list) {
  117. let t = firstTag(n)
  118. if (t != null) { return t }
  119. }
  120. return null
  121. }
  122. // an element's children with those containers written out. A RUN of children that need
  123. // the same one SHARES it, because the parser opens one `tbody` and goes on putting rows
  124. // in it until something else closes it.
  125. implied(under, children, path, site) {
  126. let out = []
  127. let run = []
  128. let runWrap = null
  129. for (c of children) {
  130. let w = impliedWrap(under, firstTag(c))
  131. if (w != runWrap && run.length > 0) {
  132. out.push(impliedNode(runWrap, run, path, site))
  133. run = []
  134. }
  135. runWrap = w
  136. if (w == null) { out.push(c) } else { run.push(c) }
  137. }
  138. if (run.length > 0) { out.push(impliedNode(runWrap, run, path, site)) }
  139. return out
  140. }
  141. // the node for a container nobody wrote: no attributes, no handlers, and the rule
  142. // applied again inside it (`table { td }` is `table > tbody > tr > td`)
  143. impliedNode(tag, children, path, site) {
  144. let here = path.slice(0)
  145. here.push(tag)
  146. return { k = 'el'; tag = tag; key = tag; path = here; attrs = []; children = implied(tag, children, here, site + '>' + tag); site = site + '>' + tag; }
  147. }
  148. static voidTags = [ 'area' 'base' 'br' 'col' 'embed' 'hr' 'img' 'input' 'link' 'meta' 'source' 'track' 'wbr' ]
  149. // THE VERBATIM ELEMENTS: whitespace between their tags is CONTENT, not layout. A
  150. // textarea's text IS its value, and a `pre`, a `script` and a `style` show or run
  151. // exactly what stands there — so the readable form never indents inside them
  152. // (the creator, 2026-09-13: "textarea seems to show the indentation as value").
  153. static verbatimTags = [ 'textarea' 'pre' 'script' 'style' ]
  154. isTag(name) {
  155. return tags.includes(name)
  156. }
  157. // the tag the DOM gets: an element of the list keeps its spelling (SVG has camelCase
  158. // elements: `linearGradient`, `clipPath`, `foreignObject`), any other key is custom, kebab-case
  159. domTag(name) {
  160. return tags.includes(name) ? name : kebab(name)
  161. }
  162. // camelCase → kebab-case (the archived WebView.kebab, verbatim): a separator only
  163. // BETWEEN a lowercase letter or digit and an uppercase one; at position 0, after
  164. // another uppercase or after a sigil an uppercase simply lowercases, so `ChatForm`
  165. // is `chat-form` and `#Svg` is `#svg`. The tag written on a custom element and the
  166. // selector a Style rule emits both come through here, so they cannot disagree.
  167. static KEBAB_JOINERS = "-_#@"
  168. kebab(name) {
  169. let out = ''
  170. let i = 0
  171. let prev = ''
  172. while (i < name.length) {
  173. let c = name.charAt(i)
  174. let lower = c.toLowerCase()
  175. let between = prev != '' && prev == prev.toLowerCase() && KEBAB_JOINERS.indexOf(prev) < 0
  176. if (c != lower && between) { out = out + '-' + lower } else { out = out + lower }
  177. prev = c
  178. i = i + 1
  179. }
  180. return out
  181. }
  182. // THE TAG-PREFIX STRIP (archived WebView.className, verbatim): rule `headerMain`
  183. // referenced under `header` is class `main`; under any other tag it keeps its name.
  184. className(rule, tag) {
  185. if (rule.length <= tag.length) { return rule }
  186. if (!rule.startsWith(tag)) { return rule }
  187. let rest = rule.slice(tag.length)
  188. let head = rest.charAt(0)
  189. if (head == head.toLowerCase()) { return rule }
  190. return head.toLowerCase() + rest.slice(1)
  191. }
  192. // THE PER-FILE CLASS of a `#local` rule (archived WebView.localClass, verbatim):
  193. // four characters out of the file's project-relative key, so it is stable across
  194. // checkouts and a golden can pin it. Every local rule of one file shares it; the
  195. // element's tag in front of it keeps them apart in the sheet (`svg.ma31`).
  196. static LOCAL_KEY_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/._-"
  197. static LOCAL_LETTERS = "abcdefghijklmnopqrstuvwxyz"
  198. static LOCAL_ALPHA = "abcdefghijklmnopqrstuvwxyz0123456789"
  199. localClass(fileKey) {
  200. let h = 0
  201. let i = 0
  202. while (i < fileKey.length) {
  203. h = (h * 31 + LOCAL_KEY_CHARS.indexOf(fileKey.charAt(i)) + 1) % 1000003
  204. i = i + 1
  205. }
  206. let out = LOCAL_LETTERS.charAt(h % 26)
  207. let rest = (h - (h % 26)) / 26
  208. let j = 0
  209. while (j < 3) {
  210. out = out + LOCAL_ALPHA.charAt(rest % 36)
  211. rest = (rest - (rest % 36)) / 36
  212. j = j + 1
  213. }
  214. return out
  215. }
  216. // the language has no escapes: a newline and a tab are written as themselves
  217. static newline = "
  218. "
  219. static tab = " "
  220. // a component node's key in a mount's `kids`: its path, '/'-joined (a list has no
  221. // join in the language; the walk is written out)
  222. kidKey(&path) {
  223. let out = ''
  224. for (p of path) { out = out + '/' + p }
  225. return out
  226. }
  227. // THE KEY OF ONE ROW'S CHILD: the component node's path plus the index of every
  228. // enclosing `for` iteration, which `ctx.rowKey` carries down the walk. Outside
  229. // every `for` the rowKey is empty and this is the plain path key.
  230. kidKeyIn(&path, &ctx) {
  231. return kidKey(path) + (ctx.rowKey != null ? ctx.rowKey : '')
  232. }
  233. // THE KEY OF ONE ROW — "lists are keyed by the record's id" (CONCEPT §2), and by
  234. // the row's index where the row is not a record or carries no id (a list of strings
  235. // is one: demo-blog's paragraphs). ONE rule, called by everything that has to tell
  236. // one row from the next: the server's HTML walk below, the mount key of a row's
  237. // children (WebFramework `mountKids`), and the browser's keyed region (client.hl).
  238. // Two rows with the SAME id are two rows to the language and one key to this rule;
  239. // the browser's region gives the second one a key of its own, the server does not.
  240. rowKey(&entry, i) {
  241. if (entry != null) {
  242. let t = hlTypeName(entry)
  243. if ((t == 'Hybrid' || t == 'Instance') && entry.id != null) { return '' + entry.id }
  244. }
  245. return '' + i
  246. }
  247. // the walking context of one `for` iteration: the same slot and kids, one row deeper
  248. rowCtx(&ctx, &entry, i) {
  249. let out = ctx + {}
  250. out.rowKey = (ctx.rowKey != null ? ctx.rowKey : '') + '#' + rowKey(entry, i)
  251. return out
  252. }
  253. // A LITERAL'S VALUE, TYPED — COMPONENTS.md §12, "typed, not stringified". `hlSyntax`
  254. // hands a literal back as its source text and its KIND, and a reference binding carries
  255. // the value rather than the text: `Level2 { n = 1 }` gives the child a Number, so `n * 97`
  256. // is arithmetic and not "operator '*' does not apply to String and Number" (measured
  257. // 2026-09-14, demo-nested wall 3). An attribute and a text child keep the text, because
  258. // HTML is text; only what crosses into a member of another class is typed.
  259. literalValue(kind, text) {
  260. if (kind == 'number') { return toNumber(text) }
  261. if (kind == 'boolean') { return text == 'true' }
  262. if (kind == 'null_literal') { return null }
  263. return text
  264. }
  265. // THE VALUE A REFERENCE BINDING CARRIES when it is a literal or a folded static — the
  266. // one place the two are read, so the server's mount and the browser's do it alike. A
  267. // static is folded to its VALUE and carried as one ('folded'), never printed and parsed
  268. // back; a literal carries its source text and its kind, which is all the syntax has.
  269. refValue(b) {
  270. if (b.kind == 'folded') { return b.value }
  271. if (b.kind != null) { return literalValue(b.kind, b.text) }
  272. return b.text
  273. }
  274. // HTML BOOLEAN ATTRIBUTES MEAN PRESENCE, NOT VALUE (mission 084's rule, ticket #33): a
  275. // parser reads `disabled="false"` as DISABLED, so a bound one is written ` disabled=""`
  276. // when its value is on and left out when it is off, and the browser sets or removes it
  277. // and writes the property. The list is mission 084's, the one the archived SSR, graph and
  278. // client agreed on.
  279. static boolAttrs = ['checked' 'disabled' 'selected' 'readonly' 'required' 'multiple' 'open' 'hidden']
  280. isBoolAttr(name) {
  281. return boolAttrs.includes(name)
  282. }
  283. // is a boolean attribute ON for this value? false, null, '' and 0 leave it off — what the
  284. // browser's own `!!v` says, so the server's markup and the client's write agree
  285. boolOn(val) {
  286. if (val == null || val == false || val == '') { return false }
  287. if (hlTypeName(val) == 'Number' && val == 0) { return false }
  288. return true
  289. }
  290. escape(s) {
  291. if (s == null) { return '' }
  292. // EVERY occurrence, not the first: `replace` stops after one, so a value with two
  293. // quotes in it ("a" "b") left the second one raw in the attribute it was written into
  294. return ('' + s).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;')
  295. }
  296. // THE VALUE a read names: a `for` row variable in scope wins over a member of the
  297. // same name (nesting is scope); a field path walks into it.
  298. value(&node, &page, &rows) {
  299. if (node.k == 'text') { return refValue(node) }
  300. if (node.k == 'expr') { return evalExpr(node.x, page, rows) }
  301. let v = null
  302. if (rows[node.name] != null) { v = rows[node.name] } else { v = page[node.name] }
  303. if (node.k == 'field') {
  304. for (f of node.path) {
  305. if (v == null) { return null }
  306. v = v[f]
  307. }
  308. }
  309. return v
  310. }
  311. // A VIEW EXPRESSION (`p { "x" + n }`, `a - b`, `n > 3 ? 'big' : 'small'`) is evaluated by
  312. // the LANGUAGE'S OWN OPERATORS: this walk only picks the operator the author wrote and
  313. // applies it, so what `+` gives null and a string, what `==` says of two types, what `&&`
  314. // returns — all of it is the runtime's answer, on the server (the interpreter or a
  315. // binary) and in the browser (the client target), and hl:web has no rule of its own.
  316. // The leaves are reads (`value`) and literals. `&&`, `||` and `?:` evaluate what they
  317. // pick only, as the language does.
  318. evalExpr(&x, &page, &rows) {
  319. if (x.k == 'bin') {
  320. let l = evalExpr(x.l, page, rows)
  321. let op = x.op
  322. if (op == 'logical_and') { return l && evalExpr(x.r, page, rows) }
  323. if (op == 'logical_or') { return l || evalExpr(x.r, page, rows) }
  324. let r = evalExpr(x.r, page, rows)
  325. if (op == 'add') { return l + r }
  326. if (op == 'subtract') { return l - r }
  327. if (op == 'multiply') { return l * r }
  328. if (op == 'divide') { return l / r }
  329. if (op == 'modulo') { return l % r }
  330. if (op == 'equal') { return l == r }
  331. if (op == 'not_equal') { return l != r }
  332. if (op == 'less_than') { return l < r }
  333. if (op == 'greater_than') { return l > r }
  334. if (op == 'less_equal') { return l <= r }
  335. if (op == 'greater_equal') { return l >= r }
  336. if (op == 'bit_and') { return l & r }
  337. if (op == 'bit_or') { return l | r }
  338. if (op == 'bit_xor') { return l ^ r }
  339. if (op == 'shift_left') { return l << r }
  340. return l >> r
  341. }
  342. if (x.k == 'un') {
  343. let o = evalExpr(x.x, page, rows)
  344. if (x.op == 'logical_not') { return !o }
  345. if (x.op == 'negate') { return -o }
  346. return ~o
  347. }
  348. if (x.k == 'cond') { return evalExpr(x.c, page, rows) ? evalExpr(x.a, page, rows) : evalExpr(x.b, page, rows) }
  349. return value(x, page, rows)
  350. }
  351. // THE BIG VALUES OF THE WALK ARE TAKEN BY REFERENCE, and this is the whole of why the
  352. // server's render was slow (mission 312 wall 2, measured 2026-09-15). A METHOD CALL
  353. // DEEP-COPIES ITS ARGUMENTS — a lambda call does not, which is why this went unseen —
  354. // so every one of these calls copied the page INSTANCE and the `ctx` holding every
  355. // child mount of the page, once per element and once per read. The cost of rendering
  356. // one element was therefore proportional to everything the page was holding: a 43-row
  357. // table beside 120 components took 9.6 s, the same table alone 0.44 s, and a page whose
  358. // instance merely HELD 30 000 objects nobody renders took 34 s for 344 static elements.
  359. // Nothing in this walk writes through `page`, `rows` or `ctx` — `kidCtx`, `inner` and
  360. // `rowCtx`'s result are locals it copies on purpose — so a reference is the same walk,
  361. // and the `&` at a call site is optional, so every caller (client.hl included) is
  362. // unchanged. Measured after: 0.57 s, 1.27 s, 0.12 s for the three cases above.
  363. //
  364. // HTML of one node. `page` is the instance; `rows` the `for` variables in scope;
  365. // `ctx` carries the slot's HTML and the child mounts. `indent` is the current line's
  366. // indentation (a string of tabs) for the readable form, or null for the compact
  367. // one-line form (the production setting). Whitespace is only ever placed BETWEEN
  368. // elements: an element that carries text or a member value stays on one line, so
  369. // its text content is exactly what the author wrote and hydration finds it so.
  370. html(&node, &page, &rows, &ctx, indent) {
  371. if (node.k == 'text') { return escape(node.text) }
  372. if (node.k == 'on') { return '' } // the browser binds it
  373. if (node.k == 'member' || node.k == 'field' || node.k == 'expr') {
  374. if (node.k == 'member' && node.name == 'slot' && ctx.slot != null) { return ctx.slot }
  375. return escape(value(node, page, rows))
  376. }
  377. if (node.k == 'for') {
  378. let out = ''
  379. let list = value(node.list, page, rows)
  380. if (list == null) { return '' }
  381. let ri = 0
  382. for (entry of list) {
  383. let inner = rows + {}
  384. inner[node.row] = entry
  385. out = out + render(node.body, page, inner, rowCtx(ctx, entry, ri), indent)
  386. ri = ri + 1
  387. }
  388. return out
  389. }
  390. if (node.k == 'if') {
  391. if (value(node.cond, page, rows)) { return render(node.then, page, rows, ctx, indent) }
  392. return render(node.other, page, rows, ctx, indent)
  393. }
  394. if (node.k == 'component') {
  395. let kid = ctx.kids[kidKeyIn(node.path, ctx)]
  396. if (kid == null) { return '<!-- ' + node.cls + ' not mounted -->' }
  397. // THE FILL IS RENDERED HERE, IN THE HOST'S FRAME — the host's instance, the
  398. // host's `for` rows, the host's own child mounts — and handed to the child as
  399. // its `slot`, exactly as the shell is handed the page it wraps. The child
  400. // never evaluates it; it only places it.
  401. return render(withRootClasses(kid.view, node.classes), kid.page, {}, { slot = fillHtml(node, page, rows, ctx); kids = kid.kids; }, indent)
  402. }
  403. if (node.k == 'el') {
  404. let attrs = ''
  405. // HTML GIVES A TEXTAREA NO `value` ATTRIBUTE: what stands between its tags is
  406. // its value, so a bound one is written there — the server-rendered control
  407. // already shows what the member holds, before any script runs.
  408. let valueText = null
  409. // AND A SELECT NO `value` ATTRIBUTE EITHER: what it shows is the option marked
  410. // `selected`, so a bound value is carried down to the options (`childCtx.sel`)
  411. // and marks the matching one. A `value` written on the select itself means
  412. // nothing to the browser, which then shows the FIRST option — and hydration's
  413. // two-way read then wrote that first option back into the member (measured
  414. // 2026-09-13 on demo-blog: a published post's editor said "Draft", and saving
  415. // unpublished it).
  416. let selValue = null
  417. for (a of node.attrs) {
  418. if (node.tag == 'textarea' && a.name == 'value') {
  419. // not an attribute: it is written below, as the element's content
  420. if (a.member != null) { valueText = escape(value({ k = 'member'; name = a.member; }, page, rows)) }
  421. else if (a.ref != null) { valueText = escape(value(a.ref, page, rows)) }
  422. else { valueText = escape(a.text) }
  423. } else if (node.tag == 'select' && a.name == 'value') {
  424. // not an attribute: it selects an option below
  425. if (a.member != null) { selValue = '' + value({ k = 'member'; name = a.member; }, page, rows) }
  426. else if (a.ref != null) { selValue = '' + value(a.ref, page, rows) }
  427. else { selValue = '' + a.text }
  428. } else if (isBoolAttr(a.name) && (a.member != null || a.ref != null)) {
  429. if (boolOn(value(a.member != null ? { k = 'member'; name = a.member; } : a.ref, page, rows))) { attrs = attrs + ' ' + a.name + '=""' }
  430. } else if (a.member != null) {
  431. attrs = attrs + ' ' + a.name + '="' + escape(value({ k = 'member'; name = a.member; }, page, rows)) + extraOf(a) + '"'
  432. } else if (a.ref != null) {
  433. attrs = attrs + ' ' + a.name + '="' + escape(value(a.ref, page, rows)) + extraOf(a) + '"'
  434. } else {
  435. attrs = attrs + ' ' + a.name + '="' + escape(a.text) + '"'
  436. }
  437. }
  438. // A CONTROL WHOSE VALUE THE APP BINDS IS THE APP'S: the browser must neither
  439. // autofill it nor restore a remembered value over it on reload (Chrome restores
  440. // unnamed text inputs by type and document order, so a remembered value lands
  441. // on whichever input now stands at that position — measured by the creator
  442. // 2026-09-13: "Gamma" in the add field after Ctrl+R)
  443. if (boundValue(node)) { attrs = attrs + ' autocomplete="off"' }
  444. // THE CONTEXT IS NEVER COPIED INTO A LOCAL HERE. `let kidCtx = ctx` costs a copy
  445. // of everything the frame holds — every child mount of this page — and it stood
  446. // on the path of EVERY element: with the 43-row matrix in a page of 121
  447. // components it was 5.1 s against 0.6 s for the same two halves apart (measured
  448. // 2026-09-15, mission 312 wall 2). A select is the only node that changes the
  449. // context for its children, so it is the only branch that builds one.
  450. let children = ''
  451. if (node.tag == 'select') {
  452. // the select's value walks down to its options, however deep the `for` or the
  453. // `if` that builds them stands
  454. let kidCtx = ctx + {}
  455. kidCtx.sel = selValue
  456. children = kidsHtml(node, page, rows, kidCtx, indent)
  457. } else {
  458. children = kidsHtml(node, page, rows, ctx, indent)
  459. }
  460. // the textarea's value stands where its content stands
  461. if (valueText != null) { children = valueText }
  462. // THE OPTION THE SELECT SHOWS: the one whose value is the member's. An option
  463. // with no `value` reports its text, as the browser reads it.
  464. if (node.tag == 'option' && ctx.sel != null) {
  465. let ov = optionValue(node, page, rows)
  466. if (ov == null) { ov = children }
  467. if (ov == ctx.sel) { attrs = attrs + ' selected' }
  468. }
  469. // a void element has no end tag (the HTML standard's list; the claim walks
  470. // elements, so the browser's parse of either form agrees with the tree)
  471. if (voidTags.includes(node.tag)) { return '<' + node.tag + attrs + '>' }
  472. return '<' + node.tag + attrs + '>' + children + '</' + node.tag + '>'
  473. }
  474. return '<!-- ' + node.tag + ' not rendered yet -->'
  475. }
  476. // AN ELEMENT'S CHILDREN, in the readable form or the compact one — split out of `html`
  477. // so that the one node which changes the context for its children (a select, which hands
  478. // its value down to its options) is the only one that builds a context at all.
  479. kidsHtml(&node, &page, &rows, &ctx, indent) {
  480. if (indent != null && blocky(node, ctx)) {
  481. return block(node.children, page, rows, ctx, indent + tab) + newline + indent
  482. }
  483. let out = ''
  484. for (c of node.children) { out = out + html(c, page, rows, ctx, null) }
  485. return out
  486. }
  487. // The HTML a reference's fill stands for, or null when the reference fills nothing
  488. // (a bare `Card`, or `Card { }`) — then the child's `slot` renders as the member it
  489. // is, which is nothing.
  490. //
  491. // COMPACT, whatever form the page is rendered in: this frame is the HOST's, and the
  492. // indentation of the line the child places `slot` on is decided inside the child,
  493. // one frame further down. Whitespace between elements is not content and the claim
  494. // walk steps ELEMENTS, so a fill that is written on one line is claimed the same.
  495. fillHtml(&node, &page, &rows, &ctx) {
  496. if (node.fill == null || node.fill.length == 0) { return null }
  497. return render(node.fill, page, rows, ctx, null)
  498. }
  499. // AN ELEMENT'S CHILDREN MINUS THE HANDLERS A REFERENCE OVERWROTE. COMPONENTS.md:757
  500. // — "a same-named event binding on the reference overwrites the child root's own": the
  501. // reference's `on click` REPLACES the child root's `on click`, it does not stand beside
  502. // it, so the child's own entry for that event is never bound on that element.
  503. withoutOns(&nodes, &ons) {
  504. let out = []
  505. for (n of nodes) {
  506. let overwritten = false
  507. for (o of ons) { if (n.k == 'on' && o.event == n.event) { overwritten = true } }
  508. if (!overwritten) { out.push(n) }
  509. }
  510. return out
  511. }
  512. // THE CLASSES A REFERENCE PUTS ON THE CHILD'S ROOT, appended to a class the child
  513. // BINDS (`button { class = tone }`): the bound value is only known at render, so the
  514. // extra classes ride beside it and every writer of that attribute appends them —
  515. // here, and in the browser's create and repaint. Before this the merge handled only a
  516. // LITERAL class, and a child root that bound its own came out with TWO class
  517. // attributes, of which a browser keeps the first — so the host's `#Child` class
  518. // silently did nothing (measured 2026-09-13 on press.hl's `button { class = tone }`).
  519. extraOf(a) {
  520. if (a.extra == null) { return '' }
  521. return ' ' + a.extra
  522. }
  523. // A composed child's ROOT elements carry the classes the host's `#Child` local rule
  524. // put on the reference: the child's tree is shared, so the roots are copied with
  525. // the class merged in, and the child's own tree is untouched.
  526. withRootClasses(&nodes, &classes) {
  527. if (classes == null || classes.length == 0) { return nodes }
  528. let out = []
  529. for (n of nodes) {
  530. if (n.k == 'el') {
  531. let attrs = []
  532. let merged = false
  533. for (a of n.attrs) {
  534. if (a.name == 'class' && a.text != null) { attrs.push({ name = 'class'; text = a.text + ' ' + joinClasses(classes); }) merged = true }
  535. else if (a.name == 'class') { attrs.push({ name = 'class'; member = a.member; ref = a.ref; extra = joinClasses(classes); }) merged = true }
  536. else { attrs.push(a) }
  537. }
  538. if (!merged) { attrs.push({ name = 'class'; text = joinClasses(classes); }) }
  539. out.push({ k = 'el'; tag = n.tag; key = n.key; path = n.path; attrs = attrs; children = n.children; site = n.site; })
  540. } else { out.push(n) }
  541. }
  542. return out
  543. }
  544. joinClasses(classes) {
  545. let out = ''
  546. for (c of classes) { out = out == '' ? c : out + ' ' + c }
  547. return out
  548. }
  549. // Does this element hold only elements (and control flow around them)? Then its
  550. // children may each take a line. A text or member child makes it inline. The
  551. // shell's `slot` counts as an element: the child page stands there as a block.
  552. blocky(&node, &ctx) {
  553. if (verbatimTags.includes(node.tag)) { return false }
  554. if (node.children.length == 0) { return false }
  555. for (c of node.children) {
  556. if (c.k == 'text' || c.k == 'field' || c.k == 'expr') { return false }
  557. if (c.k == 'member' && !(c.name == 'slot' && ctx.slot != null)) { return false }
  558. }
  559. return true
  560. }
  561. // Elements one per line at `indent`; `for` and `if` unfold into lines at the same
  562. // level. Anything else that appears here renders inline at the end of the run.
  563. block(&nodes, &page, &rows, &ctx, indent) {
  564. let out = ''
  565. for (n of nodes) {
  566. if (n.k == 'el') {
  567. out = out + newline + indent + html(n, page, rows, ctx, indent)
  568. } else if (n.k == 'component') {
  569. out = out + html(n, page, rows, ctx, indent)
  570. } else if (n.k == 'for') {
  571. let list = value(n.list, page, rows)
  572. if (list != null) {
  573. let ri = 0
  574. for (entry of list) {
  575. let inner = rows + {}
  576. inner[n.row] = entry
  577. out = out + block(n.body, page, inner, rowCtx(ctx, entry, ri), indent)
  578. ri = ri + 1
  579. }
  580. }
  581. } else if (n.k == 'if') {
  582. if (value(n.cond, page, rows)) { out = out + block(n.then, page, rows, ctx, indent) }
  583. else { out = out + block(n.other, page, rows, ctx, indent) }
  584. } else if (n.k == 'member' && n.name == 'slot' && ctx.slot != null) {
  585. out = out + newline + indent + ctx.slot
  586. } else if (n.k != 'on') {
  587. out = out + html(n, page, rows, ctx, indent)
  588. }
  589. }
  590. return out
  591. }
  592. // HTML of a whole View tree (a list of nodes), compact when `indent` is null
  593. render(&nodes, &page, &rows, &ctx, indent) {
  594. if (indent != null) { return block(nodes, page, rows, ctx, indent) }
  595. let out = ''
  596. for (n of nodes) { out = out + html(n, page, rows, ctx, null) }
  597. return out
  598. }

Branches

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