gitoriaLog in with ident

calendar

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit6722b72d6722b72ddeploy.sh: never send .git or .gitignore to Byrodinmre6722b72d/plugins/web/view.hl

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

Branches

Latest commits

  • 6722b72ddeploy.sh: never send .git or .gitignore to Byrodinmre
  • be099807State of 2026-09-27, before the move to gitoriamre