gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit98226b4198226b41antcolony#40: mission references point to the moved missionsmre98226b41/plugins/web/view.hl

27.2 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. let v = null
  301. if (rows[node.name] != null) { v = rows[node.name] } else { v = page[node.name] }
  302. if (node.k == 'field') {
  303. for (f of node.path) {
  304. if (v == null) { return null }
  305. v = v[f]
  306. }
  307. }
  308. return v
  309. }
  310. // THE BIG VALUES OF THE WALK ARE TAKEN BY REFERENCE, and this is the whole of why the
  311. // server's render was slow (mission 312 wall 2, measured 2026-09-15). A METHOD CALL
  312. // DEEP-COPIES ITS ARGUMENTS — a lambda call does not, which is why this went unseen —
  313. // so every one of these calls copied the page INSTANCE and the `ctx` holding every
  314. // child mount of the page, once per element and once per read. The cost of rendering
  315. // one element was therefore proportional to everything the page was holding: a 43-row
  316. // table beside 120 components took 9.6 s, the same table alone 0.44 s, and a page whose
  317. // instance merely HELD 30 000 objects nobody renders took 34 s for 344 static elements.
  318. // Nothing in this walk writes through `page`, `rows` or `ctx` — `kidCtx`, `inner` and
  319. // `rowCtx`'s result are locals it copies on purpose — so a reference is the same walk,
  320. // and the `&` at a call site is optional, so every caller (client.hl included) is
  321. // unchanged. Measured after: 0.57 s, 1.27 s, 0.12 s for the three cases above.
  322. //
  323. // HTML of one node. `page` is the instance; `rows` the `for` variables in scope;
  324. // `ctx` carries the slot's HTML and the child mounts. `indent` is the current line's
  325. // indentation (a string of tabs) for the readable form, or null for the compact
  326. // one-line form (the production setting). Whitespace is only ever placed BETWEEN
  327. // elements: an element that carries text or a member value stays on one line, so
  328. // its text content is exactly what the author wrote and hydration finds it so.
  329. html(&node, &page, &rows, &ctx, indent) {
  330. if (node.k == 'text') { return escape(node.text) }
  331. if (node.k == 'on') { return '' } // the browser binds it
  332. if (node.k == 'member' || node.k == 'field') {
  333. if (node.k == 'member' && node.name == 'slot' && ctx.slot != null) { return ctx.slot }
  334. return escape(value(node, page, rows))
  335. }
  336. if (node.k == 'for') {
  337. let out = ''
  338. let list = value(node.list, page, rows)
  339. if (list == null) { return '' }
  340. let ri = 0
  341. for (entry of list) {
  342. let inner = rows + {}
  343. inner[node.row] = entry
  344. out = out + render(node.body, page, inner, rowCtx(ctx, entry, ri), indent)
  345. ri = ri + 1
  346. }
  347. return out
  348. }
  349. if (node.k == 'if') {
  350. if (value(node.cond, page, rows)) { return render(node.then, page, rows, ctx, indent) }
  351. return render(node.other, page, rows, ctx, indent)
  352. }
  353. if (node.k == 'component') {
  354. let kid = ctx.kids[kidKeyIn(node.path, ctx)]
  355. if (kid == null) { return '<!-- ' + node.cls + ' not mounted -->' }
  356. // THE FILL IS RENDERED HERE, IN THE HOST'S FRAME — the host's instance, the
  357. // host's `for` rows, the host's own child mounts — and handed to the child as
  358. // its `slot`, exactly as the shell is handed the page it wraps. The child
  359. // never evaluates it; it only places it.
  360. return render(withRootClasses(kid.view, node.classes), kid.page, {}, { slot = fillHtml(node, page, rows, ctx); kids = kid.kids; }, indent)
  361. }
  362. if (node.k == 'el') {
  363. let attrs = ''
  364. // HTML GIVES A TEXTAREA NO `value` ATTRIBUTE: what stands between its tags is
  365. // its value, so a bound one is written there — the server-rendered control
  366. // already shows what the member holds, before any script runs.
  367. let valueText = null
  368. // AND A SELECT NO `value` ATTRIBUTE EITHER: what it shows is the option marked
  369. // `selected`, so a bound value is carried down to the options (`childCtx.sel`)
  370. // and marks the matching one. A `value` written on the select itself means
  371. // nothing to the browser, which then shows the FIRST option — and hydration's
  372. // two-way read then wrote that first option back into the member (measured
  373. // 2026-09-13 on demo-blog: a published post's editor said "Draft", and saving
  374. // unpublished it).
  375. let selValue = null
  376. for (a of node.attrs) {
  377. if (node.tag == 'textarea' && a.name == 'value') {
  378. // not an attribute: it is written below, as the element's content
  379. if (a.member != null) { valueText = escape(value({ k = 'member'; name = a.member; }, page, rows)) }
  380. else if (a.ref != null) { valueText = escape(value(a.ref, page, rows)) }
  381. else { valueText = escape(a.text) }
  382. } else if (node.tag == 'select' && a.name == 'value') {
  383. // not an attribute: it selects an option below
  384. if (a.member != null) { selValue = '' + value({ k = 'member'; name = a.member; }, page, rows) }
  385. else if (a.ref != null) { selValue = '' + value(a.ref, page, rows) }
  386. else { selValue = '' + a.text }
  387. } else if (isBoolAttr(a.name) && (a.member != null || a.ref != null)) {
  388. if (boolOn(value(a.member != null ? { k = 'member'; name = a.member; } : a.ref, page, rows))) { attrs = attrs + ' ' + a.name + '=""' }
  389. } else if (a.member != null) {
  390. attrs = attrs + ' ' + a.name + '="' + escape(value({ k = 'member'; name = a.member; }, page, rows)) + extraOf(a) + '"'
  391. } else if (a.ref != null) {
  392. attrs = attrs + ' ' + a.name + '="' + escape(value(a.ref, page, rows)) + extraOf(a) + '"'
  393. } else {
  394. attrs = attrs + ' ' + a.name + '="' + escape(a.text) + '"'
  395. }
  396. }
  397. // A CONTROL WHOSE VALUE THE APP BINDS IS THE APP'S: the browser must neither
  398. // autofill it nor restore a remembered value over it on reload (Chrome restores
  399. // unnamed text inputs by type and document order, so a remembered value lands
  400. // on whichever input now stands at that position — measured by the creator
  401. // 2026-09-13: "Gamma" in the add field after Ctrl+R)
  402. if (boundValue(node)) { attrs = attrs + ' autocomplete="off"' }
  403. // THE CONTEXT IS NEVER COPIED INTO A LOCAL HERE. `let kidCtx = ctx` costs a copy
  404. // of everything the frame holds — every child mount of this page — and it stood
  405. // on the path of EVERY element: with the 43-row matrix in a page of 121
  406. // components it was 5.1 s against 0.6 s for the same two halves apart (measured
  407. // 2026-09-15, mission 312 wall 2). A select is the only node that changes the
  408. // context for its children, so it is the only branch that builds one.
  409. let children = ''
  410. if (node.tag == 'select') {
  411. // the select's value walks down to its options, however deep the `for` or the
  412. // `if` that builds them stands
  413. let kidCtx = ctx + {}
  414. kidCtx.sel = selValue
  415. children = kidsHtml(node, page, rows, kidCtx, indent)
  416. } else {
  417. children = kidsHtml(node, page, rows, ctx, indent)
  418. }
  419. // the textarea's value stands where its content stands
  420. if (valueText != null) { children = valueText }
  421. // THE OPTION THE SELECT SHOWS: the one whose value is the member's. An option
  422. // with no `value` reports its text, as the browser reads it.
  423. if (node.tag == 'option' && ctx.sel != null) {
  424. let ov = optionValue(node, page, rows)
  425. if (ov == null) { ov = children }
  426. if (ov == ctx.sel) { attrs = attrs + ' selected' }
  427. }
  428. // a void element has no end tag (the HTML standard's list; the claim walks
  429. // elements, so the browser's parse of either form agrees with the tree)
  430. if (voidTags.includes(node.tag)) { return '<' + node.tag + attrs + '>' }
  431. return '<' + node.tag + attrs + '>' + children + '</' + node.tag + '>'
  432. }
  433. return '<!-- ' + node.tag + ' not rendered yet -->'
  434. }
  435. // AN ELEMENT'S CHILDREN, in the readable form or the compact one — split out of `html`
  436. // so that the one node which changes the context for its children (a select, which hands
  437. // its value down to its options) is the only one that builds a context at all.
  438. kidsHtml(&node, &page, &rows, &ctx, indent) {
  439. if (indent != null && blocky(node, ctx)) {
  440. return block(node.children, page, rows, ctx, indent + tab) + newline + indent
  441. }
  442. let out = ''
  443. for (c of node.children) { out = out + html(c, page, rows, ctx, null) }
  444. return out
  445. }
  446. // The HTML a reference's fill stands for, or null when the reference fills nothing
  447. // (a bare `Card`, or `Card { }`) — then the child's `slot` renders as the member it
  448. // is, which is nothing.
  449. //
  450. // COMPACT, whatever form the page is rendered in: this frame is the HOST's, and the
  451. // indentation of the line the child places `slot` on is decided inside the child,
  452. // one frame further down. Whitespace between elements is not content and the claim
  453. // walk steps ELEMENTS, so a fill that is written on one line is claimed the same.
  454. fillHtml(&node, &page, &rows, &ctx) {
  455. if (node.fill == null || node.fill.length == 0) { return null }
  456. return render(node.fill, page, rows, ctx, null)
  457. }
  458. // AN ELEMENT'S CHILDREN MINUS THE HANDLERS A REFERENCE OVERWROTE. COMPONENTS.md:757
  459. // — "a same-named event binding on the reference overwrites the child root's own": the
  460. // reference's `on click` REPLACES the child root's `on click`, it does not stand beside
  461. // it, so the child's own entry for that event is never bound on that element.
  462. withoutOns(&nodes, &ons) {
  463. let out = []
  464. for (n of nodes) {
  465. let overwritten = false
  466. for (o of ons) { if (n.k == 'on' && o.event == n.event) { overwritten = true } }
  467. if (!overwritten) { out.push(n) }
  468. }
  469. return out
  470. }
  471. // THE CLASSES A REFERENCE PUTS ON THE CHILD'S ROOT, appended to a class the child
  472. // BINDS (`button { class = tone }`): the bound value is only known at render, so the
  473. // extra classes ride beside it and every writer of that attribute appends them —
  474. // here, and in the browser's create and repaint. Before this the merge handled only a
  475. // LITERAL class, and a child root that bound its own came out with TWO class
  476. // attributes, of which a browser keeps the first — so the host's `#Child` class
  477. // silently did nothing (measured 2026-09-13 on press.hl's `button { class = tone }`).
  478. extraOf(a) {
  479. if (a.extra == null) { return '' }
  480. return ' ' + a.extra
  481. }
  482. // A composed child's ROOT elements carry the classes the host's `#Child` local rule
  483. // put on the reference: the child's tree is shared, so the roots are copied with
  484. // the class merged in, and the child's own tree is untouched.
  485. withRootClasses(&nodes, &classes) {
  486. if (classes == null || classes.length == 0) { return nodes }
  487. let out = []
  488. for (n of nodes) {
  489. if (n.k == 'el') {
  490. let attrs = []
  491. let merged = false
  492. for (a of n.attrs) {
  493. if (a.name == 'class' && a.text != null) { attrs.push({ name = 'class'; text = a.text + ' ' + joinClasses(classes); }) merged = true }
  494. else if (a.name == 'class') { attrs.push({ name = 'class'; member = a.member; ref = a.ref; extra = joinClasses(classes); }) merged = true }
  495. else { attrs.push(a) }
  496. }
  497. if (!merged) { attrs.push({ name = 'class'; text = joinClasses(classes); }) }
  498. out.push({ k = 'el'; tag = n.tag; key = n.key; path = n.path; attrs = attrs; children = n.children; site = n.site; })
  499. } else { out.push(n) }
  500. }
  501. return out
  502. }
  503. joinClasses(classes) {
  504. let out = ''
  505. for (c of classes) { out = out == '' ? c : out + ' ' + c }
  506. return out
  507. }
  508. // Does this element hold only elements (and control flow around them)? Then its
  509. // children may each take a line. A text or member child makes it inline. The
  510. // shell's `slot` counts as an element: the child page stands there as a block.
  511. blocky(&node, &ctx) {
  512. if (verbatimTags.includes(node.tag)) { return false }
  513. if (node.children.length == 0) { return false }
  514. for (c of node.children) {
  515. if (c.k == 'text' || c.k == 'field') { return false }
  516. if (c.k == 'member' && !(c.name == 'slot' && ctx.slot != null)) { return false }
  517. }
  518. return true
  519. }
  520. // Elements one per line at `indent`; `for` and `if` unfold into lines at the same
  521. // level. Anything else that appears here renders inline at the end of the run.
  522. block(&nodes, &page, &rows, &ctx, indent) {
  523. let out = ''
  524. for (n of nodes) {
  525. if (n.k == 'el') {
  526. out = out + newline + indent + html(n, page, rows, ctx, indent)
  527. } else if (n.k == 'component') {
  528. out = out + html(n, page, rows, ctx, indent)
  529. } else if (n.k == 'for') {
  530. let list = value(n.list, page, rows)
  531. if (list != null) {
  532. let ri = 0
  533. for (entry of list) {
  534. let inner = rows + {}
  535. inner[n.row] = entry
  536. out = out + block(n.body, page, inner, rowCtx(ctx, entry, ri), indent)
  537. ri = ri + 1
  538. }
  539. }
  540. } else if (n.k == 'if') {
  541. if (value(n.cond, page, rows)) { out = out + block(n.then, page, rows, ctx, indent) }
  542. else { out = out + block(n.other, page, rows, ctx, indent) }
  543. } else if (n.k == 'member' && n.name == 'slot' && ctx.slot != null) {
  544. out = out + newline + indent + ctx.slot
  545. } else if (n.k != 'on') {
  546. out = out + html(n, page, rows, ctx, indent)
  547. }
  548. }
  549. return out
  550. }
  551. // HTML of a whole View tree (a list of nodes), compact when `indent` is null
  552. render(&nodes, &page, &rows, &ctx, indent) {
  553. if (indent != null) { return block(nodes, page, rows, ctx, indent) }
  554. let out = ''
  555. for (n of nodes) { out = out + html(n, page, rows, ctx, null) }
  556. return out
  557. }

Branches

Latest commits

  • 98226b41antcolony#40: mission references point to the moved missionsmre
  • ff805b9aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • 51a7bcdfident: Hybriel master 73267707 (#122); /code uses the new page() signature; pending address passed as parameter; once-checksmre
  • 836f644fident#24: installable app (manifest, service worker, data-free offline /start), own iconmre
  • 8bebbbf2deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • cc063ea2deploy.sh: never send .git or .gitignore to Byrodinmre
  • 81b15b7bState of 2026-09-27, before the move to gitoriamre