tracker
All repositories: gitoria
51.0 KB
// plugins/web/compile.hl — THE FRAMEWORK COMPILES EACH COMPONENT.//// Mission 313 (the creator, 15 Sep 14:30: "clientside interpreter + table against// component code per component, no tables needed"). The old hl:web ships ONE generic client// that walks a tree the seed carries: it pairs elements with nodes, asks a table which// members each element shows, asks `view.value` what each bound spot holds, and does it// again on every paint. hl:web ships the ANSWERS instead — this file writes each// component's own code, once, where the old hl:web would have walked.//// WHAT IS EMITTED, per component, beside the class `hlJs` compiled://// v(c,m,o,host,i,rows,rk,fill,cr,opt) the component's View, unrolled: one// statement per element, per handler, per// bound spot. `cr` false CLAIMS the server's// DOM (take host.children[i], step on), `cr`// true CREATES it — the two walks the old hl:web// spells out separately, which differ only in// where an element comes from.// b["<for site>"] one `for` body — the row factory the keyed// region builds a row with.// t/e["<if site>"] an `if`'s two branches.// f["<reference site>"] the fragment this component writes into a// child's `slot` — built in THIS frame, which// is why it is this component's code.// p["<element site>"] the paint statements: `p` writes what one// member feeds (the old hl:web's paint(&m,s,name)),// `r` rewrites a row's element where the DOM// does not already hold it (rowPaint).//// The generated code calls a FIXED runtime (client.hl) for everything that is not this// component: the mount records, the keyed region diff, the binding edges, the// derivations, the socket, navigation. It never looks anything up in a table — the// names, the sites and the write sets are written into the statements.//// It is TEXT the framework produces, served from memory beside the module (WebFramework// `module`), never a JavaScript file under plugins/.import { newline, tab } from './view.hl'import HlwView from './view.hl'// ---- the emitted half --------------------------------------------------------------module(gen, key, nodes, fnTable) {fns = [] // the sub-walks: row bodies, if branches, fillsdone = {}rowVars = []tbl = tableOf(gen, key) // what the language says about this file, read onceregions = regionIndex(tbl) // a `for`'s list reads, an `if`'s condition reads// A SHELL'S VIEW IS A `body` ELEMENT and the document already has one: the walk runs// against document.body itself, so the root list is that element's children and the// element only takes the marks a region rebuild on it would need.let roots = nodeslet bodySite = nullif (nodes.length == 1 && nodes[0].k == 'el' && nodes[0].tag == 'body') {roots = nodes[0].childrenbodySite = nodes[0].site}let root = walkTop(key, roots)if (bodySite != null) {root = root.replace('let r=rows;', 'let r=rows;await c.markHost(m,host,' + str(bodySite) + ');')}let src = ';(function(){var g=(typeof globalThis!=="undefined"?globalThis:window);'src = src + 'var C=(g.__hlC=g.__hlC||{});'// the element a claim takes or a create makes — the one shape both walks share; a create// inside an svg (or an svg itself) is made in the SVG namespace, as the parser makes it// THE PRELUDE, one per module: the element a claim takes or a create makes, and the// two reads. A read written out in full — `(r["title"]!=null?r["title"]:inst["title"])`// — is 45 bytes and a component's View holds dozens of them; named, it is 16. The// rule is the same rule (a `for` row variable in scope wins over a member of the// same name, and a field path walks into it), spelled once.// A claim also moves the host's CURSOR — the last node the walk took there — which is// where a text leaf and a region's mark are looked for next (client.hl `leafNode`,// `markAt`).src = src + 'function A(cr,host,i,tag){if(cr){if(tag==="svg"||(host.namespaceURI==="http://www.w3.org/2000/svg"&&host.localName.toLowerCase()!=="foreignobject")){return document.createElementNS("http://www.w3.org/2000/svg",tag);}return document.createElement(tag);}var e=host.children[i];if(e!=null){host.__hlCur=e;}return e;}'src = src + 'function S(v){return v==null?"":""+v;}'src = src + 'async function X(t,r,q){return await g.__hlX(t,q,r);}'src = src + 'function RV(r,q,n){return r[n]!=null?r[n]:q[n];}'src = src + 'function RF(r,q,n,p){var v=RV(r,q,n);for(var k=0;k<p.length;k++){if(v==null)return null;v=v[p[k]];}return v;}'src = src + 'var B={},T={},E={},F={},P={};'// ONE FUNCTION PER LIST, however many sites reach it: a nested walk is written once// and named — emitting it per site made the reference app's home page 215 KB of the// same code over and over.let wi = 0for (f of fns) {wi = wi + 1let name = 'w' + wisrc = src + 'var ' + name + '=' + f.src + ';'for (site of f.sites) { src = src + f.slot + '[' + str(site) + ']=' + name + ';' }}let paints = []collectPaints(&paints, roots)for (e of paints) { src = src + 'P[' + str(e.site) + ']={p:' + e.paint + ',r:' + e.row + '};' }src = src + 'C[' + str(key) + ']={v:' + root + ',b:B,t:T,e:E,f:F,p:P'// THE ANSWERS A LOOKUP STILL NEEDS, and only those. A local event's write set stands// at its own listener and a region's names at its own region — written into the// call. These three are asked for by NAME at run time and cannot be: an inbound// frame names its event, a routine handed down names the member that holds it, and// a write set names the derivations that follow it.src = src + ',ev:' + eventMap() + ',mw:' + memberMap() + ',dv:' + derivList() + ',fn:' + fnMap(fnTable) + '};'return src + '})();' + newline}tbl = nullregions = {}// WHAT A FUNCTION VALUE WRITES, by the site it was made at (tickets #98, #99): the// module announces a function's return with its site, and the browser repaints this// set — the members it writes, closed over the methods it calls and the events it// emits, the same closure a handler's set gets. A function that writes nothing has// no entry.fnMap(fns) {let bySite = {}if (fns != null) {for (f of fns) {let names = closeWrites(tbl, f.writes, f.reads)if (names.length > 0) { bySite[f.site] = names }}}return mapOf(bySite)}// what a DECLARED handler writes, by event: one entry per event name, unionedeventMap() {let byEvent = {}for (h of tbl.handlers) {if (!h.nested) {let have = byEvent[h.event] == null ? [] : byEvent[h.event]for (w of closeWrites(tbl, h.writes, h.reads)) { if (!have.includes(w)) { have.push(w) } }byEvent[h.event] = have}}return mapOf(byEvent)}// A MEMBER HOLDING A ROUTINE: what calling it writes is what its initializer writesmemberMap() {let byName = {}for (m of tbl.members) {if (m.nodes != null) { byName[m.name] = memberNames(m.nodes.writes) }}return mapOf(byName)}// EVERY INITIALIZER, IN DECLARATION ORDER, with the members it reads — the whole of// what a derivation needs; the class carries the computation itself as `__derive__`.// `srv` (written only when true, same rule as `isStatic` on a read — every byte here// ships to the browser) marks a SERVER REFERENCE SITE (COMPONENTS.md §9.1/§9.3): an// initializer that calls an imported class (`import Store from './store.hl'`, then// `Store.chatsOf(…)` — `hlDenotes`'s own `kind: 'class'`, not a second resolution).// It is evaluated per request, on the SERVER, and its value ships into the page as// state; `client.hl`'s `moved()` must never call it locally — routger's `conversations// = … ? store.chatsOf(session.user.id) : []` reached `hl:time`, which ships no client// half, the moment a session sync (0b17f65b) made `session` move in the browser and// `moved()` re-ran every derivation that reads it. A fresh value for a server// reference site ships in `sync` instead (WebFramework.hl `serverSyncNames`).derivList() {let src = '['let first = truefor (d of tbl.derivations) {if (!first) { src = src + ',' }first = falsesrc = src + '{name:' + str(d.name) + ',site:' + str(d.site) + ',reads:' + list(memberNames(d.reads))if (d.serverSite) { src = src + ',srv:true' }src = src + '}'}return src + ']'}// does this read list (RAW — hlDenotes's own kind per name, before `memberNames`// narrows it to members) reach an imported class or a braced static of one — the two// forms COMPONENTS.md §9.1/§9.3 calls "a call on an imported class" (`import Store// from './store.hl'`, `Store.noteRows()`, OR the form the maintained demos actually// use, `import { postRow } from './store.hl'`, `postRow(id)`)? See `derivList` above.serverSite(reads) {if (reads == null) { return false }for (r of reads) { if (r.kind == 'class' || r.kind == 'import') { return true } }return false}mapOf(byName) {let src = '{'let first = truefor (k of byName.keys()) {if (!first) { src = src + ',' }first = falsesrc = src + str(k) + ':' + list(byName[k])}return src + '}'}// the write set and the read set of the handler at this site, closed over the methods// it calls and the handlers it emits to — computed here, written into the listenerhandlerSets(site) {for (h of tbl.handlers) {if (h.nested && h.site == site) {return list(closeWrites(tbl, h.writes, h.reads)) + ',' + list(closeReads(tbl, h.reads))}}return '[],[]'}regionNamesAt(site) {let names = regions[site]return list(names == null ? [] : names)}fns = []// ---- the walk, unrolled ------------------------------------------------------------// `top` marks the View's own root list: an element there is a COMPOSITION ROOT — a// reference above may have overwritten some of its events and put classes on it, which// is what `opt` carries (the old hl:web's rootOverwrites and withRootClasses).walk(key, nodes) {return plain(key, nodes)}// a list's walkplain(key, nodes) {let body = ''for (n of nodes) { body = body + node(key, n, false, false) }return 'async function(c,m,o,host,i,rows,rk,fill,cr,opt){let inst=m.instance;let r=rows;' + body + 'return i;}'}// the View's own root list, whose elements are composition rootswalkTop(key, nodes) {let body = ''for (n of nodes) { body = body + node(key, n, true, false) }return 'async function(c,m,o,host,i,rows,rk,fill,cr,opt){let inst=m.instance;let r=rows;' + body + 'return i;}'}// a sub-walk, registered under its own site so the runtime can reach it by name// A SUB-WALK IS WRITTEN ONCE, however often the compile reaches its list.sub(key, nodes, slot, site) {if (done[slot + ' ' + site] != null) { return null }done[slot + ' ' + site] = truefns.push({ slot = slot; sites = [site]; src = walk(key, nodes); })return null}done = {}node(key, n, top, filterOns) {if (n.k == 'el') { return elNode(key, n, top) }if (n.k == 'text') { return 'if(cr){host.appendChild(document.createTextNode(' + str(n.text) + '));}else{await c.leafAt(host,false,i,null,' + str(n.text) + ');}' }if (n.k == 'on') {// A REFERENCE ABOVE MAY HAVE OVERWRITTEN THIS EVENT on the composition root:// the root's own handler for it is dropped and the reference's stands instead// (the old hl:web's withoutOns, which applies to the root element's direct children).let guard = filterOns ? 'if(!(opt&&opt.over&&opt.over.indexOf(' + str(n.event) + ')>=0)){' : '{'return guard + 'await c.onAt(m,host,' + str(n.event) + ',' + str(n.site) + ',rows,' + handlerSets(n.site) + ');}'}if (n.k == 'member' && n.name == 'slot') {// the SHELL's slot is where the route component hangs; a child's slot is the// fragment its host wrote, and the host's frame builds itreturn 'if(m.isShell){i=await c.slotShell(m,host,i,cr);}else if(fill!=null){i=await c.slotFill(m,o,host,i,fill,cr);}'}// A TEXT LEAF STANDING IN A LIST — an `if` branch, a `for` body — beside elements or// alone: its own text node, and a site of the member it reads (or of the row it// stands in), painted by rewriting that node.if (n.k == 'member' || n.k == 'field' || n.k == 'expr') {let names = readNames(n)return 'await c.textAt(m,host,cr,async function(inst,r){return ' + textRead(n) + ';},' + list(names) + ',rows,rk);'}if (n.k == 'for') {// THE ROW VARIABLE IS IN SCOPE INSIDE THE BODY, and a read of it is the ROW's,// not a member of the instance — the language resolves it that way and the site// sets must say the same, or a member of the same name would paint the row.rowVars.push(n.row)sub(key, n.body, 'B', n.site)rowVars.pop()return 'i=await c.forAt(m,o,host,i,' + str(n.site) + ',' + str(n.row) + ',function(inst,r){return ' + rawRead(n.list) + ';},rows,rk,fill,cr,' + regionNamesAt(n.site) + ');'}if (n.k == 'if') {sub(key, n.then, 'T', n.site)sub(key, n.other, 'E', n.site)return 'i=await c.ifAt(m,o,host,i,' + str(n.site) + ',function(inst,r){return ' + rawRead(n.cond) + ';},rows,rk,fill,cr,' + regionNamesAt(n.site) + ');'}if (n.k == 'component') {if (n.fill != null && n.fill.length > 0) { sub(key, n.fill, 'F', n.site) }return 'i=await c.kidAt(m,host,i,' + kidSpec(n) + ',rows,rk,fill,cr,opt);'}return ''}// ---- an element --------------------------------------------------------------------elNode(key, n, top) {let src = '{let el=A(cr,host,i,' + str(n.tag) + ');if(!cr){i=i+1;}'src = src + 'if(el==null){await c.acqFail(' + str(n.tag) + ',host);}else{'// the attributes, on the walk that BUILDS the element (a claim takes the server's)src = src + 'if(cr){' + createAttrs(n) + (top ? 'await c.rootClasses(el,opt);' : '') + 'host.appendChild(el);}'src = src + 'el.__hlRows=rows;el.__hlSite=' + str(n.site) + ';el.__hlKey=' + str(key) + ';'// the element's own named entries, for the literal a handler on it firessrc = src + 'el.__hlLit=function(inst,r){return ' + litNamed(n) + ';};'let names = siteNames(n)let valueMember = 'null'for (a of n.attrs) { if (a.member != null && a.name == 'value') { valueMember = str(a.member) } }let sited = ''if (names.length > 0 || valueMember != 'null') {sited = 'await c.siteAt(m,el,' + list(names) + ',' + str(n.site) + ',' + valueMember + ',' + (n.tag == 'select' ? 'true' : 'false') + ',rows);'}let kids = ''// AN ELEMENT THAT HOLDS ONLY TEXT is painted whole (`textContent`, see the paints):// built, it gets that text as one node; claimed, the server's is taken as it standslet whole = textOf(n)if (whole != null) {kids = 'if(cr){host.appendChild(document.createTextNode(' + whole + '));}'for (c of n.children) { if (c.k == 'on') { kids = kids + node(key, c, false, top) } }}else if (mixed(n)) { kids = mixedKids(key, n, top) }else { for (c of n.children) { kids = kids + node(key, c, false, top) } }let inner = '{let host=el;let i=0;' + kids + '}'// A SELECT IS SITED AFTER ITS OPTIONS: the member decides which option stands, and// the read-back that `siteAt` does reports nothing before they exist.if (n.tag == 'select') { src = src + inner + (selectValue(n) == 'null' ? '' : 'if(cr){await c.applySelectAt(m,el,' + selectValue(n) + ');}') + sited }else { src = src + sited + inner }return src + '}}'}// A TEXT LEAF BESIDE AN ELEMENT OR A REGION (ticket #86 follow-up): `td { d.n if (…) {…} }`// cannot be painted through `textContent`, which would wipe the branch. Each RUN of// adjacent leaves is one text node, held on the element (`__hlLeaves`, by the run's// index) where the walk builds or claims it, and the paints rewrite that node alone.mixed(n) {if (textOf(n) != null) { return false }for (c of n.children) { if (isReadNode(c)) { return true } }return false}isLeaf(c) { return c.k == 'text' || isReadNode(c) }// a node that shows a value read at render time: a member, a field path, or an expressionisReadNode(c) { return (c.k == 'member' || c.k == 'field' || c.k == 'expr') && c.name != 'slot' }// the members whose move repaints a read (`Style` and a `for` row variable are not members// of the instance), and the guard a paint writes for themreadNames(c) {let out = []for (nm of (c.k == 'expr' ? c.names : [c.name])) {if (nm != 'Style' && !rowVars.includes(nm) && !out.includes(nm)) { out.push(nm) }}return out}guardOf(c) {let g = ''for (nm of readNames(c)) { g = g + (g == '' ? '' : '||') + 'n===' + str(nm) }return g}// the runs of adjacent leaves, in order; an `on` renders nothing and splits nonerunsOf(n) {let out = []let run = []for (c of n.children) {if (isLeaf(c)) { run.push(c) }else if (c.k != 'on') {if (run.length > 0) { out.push(run) }run = []}}if (run.length > 0) { out.push(run) }return out}runText(run) {let src = '""'for (c of run) { src = src + '+(' + (c.k == 'text' ? str(c.text) : textRead(c)) + ')' }return src}runFeeds(run) {for (c of run) { if (c.k != 'text') { return true } }return false}mixedKids(key, n, top) {let kids = ''let run = []let k = 0for (c of n.children) {if (isLeaf(c)) { run.push(c) }else {if (c.k != 'on' && run.length > 0) { kids = kids + leafRun(run, k) k = k + 1 run = [] }kids = kids + node(key, c, false, top)}}if (run.length > 0) { kids = kids + leafRun(run, k) }return kids}// EVERY CALL INTO THE CLIENT IS AWAITED: its methods are async in the browser, and one// not awaited runs its DOM work after the walk has gone on — a built leaf then lands// after the elements that follow it (ticket #104).leafRun(run, k) {if (!runFeeds(run)) { return 'if(cr){host.appendChild(document.createTextNode(' + runText(run) + '));}else{await c.leafAt(host,false,i,null,' + runText(run) + ');}' }return 'await c.leafAt(host,cr,i,' + k + ',' + runText(run) + ');'}// the attributes an element is BUILT with — a bound one written in, a literal one as it// stands. `value` is a property once the page is live and an attribute as the control's// default; a textarea has no value attribute at all and a select's value is an option.createAttrs(n) {let src = ''for (a of n.attrs) {if (isBool(a)) {src = src + '{' + boolWrite(a, rawRead(a.member != null ? { k = 'member'; name = a.member; } : a.ref)) + '}'} else {let rd = a.member != null ? readOf({ k = 'member'; name = a.member; }) : (a.ref != null ? readOf(a.ref) : str(a.text))src = src + '{let v=' + rd + ';'if (a.extra != null) { src = src + 'v=v+" "+' + str(a.extra) + ';' }if (n.tag == 'select' && a.name == 'value') { src = src + '}' }else if (a.name == 'value') {src = src + 'if(el.value!=null){el.value=v;' + (n.tag == 'textarea' ? '' : 'el.setAttribute("value",v);') + '}else{el.setAttribute("value",v);}}'} else { src = src + 'el.setAttribute(' + str(a.name) + ',v);}' }}}if (boundValue(n)) { src = src + 'el.setAttribute("autocomplete","off");' }return src}// a BOUND boolean attribute — a literal one was decided at build (WebFramework `element`)isBool(a) {return (a.member != null || a.ref != null) && v.isBoolAttr(a.name)}// A BOUND BOOLEAN ATTRIBUTE, WRITTEN (ticket #33): set when the value is on, REMOVED when// it is off — the markup's rule (view.boolAttrs) — and the DOM property with it, because// once the page is live `checked` is the property and the attribute only its default.boolWrite(a, raw) {let prop = a.name == 'readonly' ? 'readOnly' : a.namereturn 'let on=!!' + raw + ';if(on){el.setAttribute(' + str(a.name) + ',"");}else if(el.removeAttribute){el.removeAttribute(' + str(a.name) + ');}if(' + str(prop) + ' in el&&el[' + str(prop) + ']!==on){el[' + str(prop) + ']=on;}'}// view.hl's `boundValue`, to the letter: a FORM CONTROL whose `value` reads a member or// a field, and not one the author gave an `autocomplete` of its ownboundValue(n) {if (n.tag != 'input' && n.tag != 'textarea' && n.tag != 'select') { return false }let bound = falsefor (a of n.attrs) {if (a.name == 'autocomplete') { return false }if (a.name == 'value' && (a.member != null || a.ref != null)) { bound = true }}return bound}selectValue(n) {for (a of n.attrs) {if (a.name == 'value') {if (a.member != null) { return readOf({ k = 'member'; name = a.member; }) }if (a.ref != null) { return readOf(a.ref) }return str(a.text)}}return 'null'}// the element's own entries as the literal sees them — its attributes by name, then the// `for` row variables in scope (the row rides on the value)litNamed(n) {let src = '{'let first = truefor (a of n.attrs) {if (!first) { src = src + ',' }first = falselet v = a.member != null ? rawRead({ k = 'member'; name = a.member; }) : (a.ref != null ? rawRead(a.ref) : str(a.text))src = src + str(a.name) + ':' + v}return src + '}'}// the reference's own description, written at its use site: what it binds, what it// overwrites, the classes the host's `#Child` rule puts on itkidSpec(n) {let src = '{key:' + str(n.key) + ',kid:' + str(kidKeyOf(n)) + ',site:' + str(n.site) + ',fill:' + ((n.fill != null && n.fill.length > 0) ? 'true' : 'false')src = src + ',bindings:['let first = truefor (b of n.bindings) {if (!first) { src = src + ',' }first = falsesrc = src + '{name:' + str(b.name) + ',member:' + (b.member == null ? 'null' : str(b.member))src = src + ',ref:' + (b.ref == null ? 'null' : 'function(inst,r){return ' + rawRead(b.ref) + ';}')src = src + ',refName:' + (b.ref == null ? 'null' : str(b.ref.name))src = src + ',text:' + (b.text == null ? 'null' : 'true') + ',value:' + (b.text == null ? 'null' : refLiteral(b)) + '}'}src = src + '],ons:['first = truefor (o of n.ons) {if (!first) { src = src + ',' }first = falsesrc = src + '{event:' + str(o.event) + ',site:' + str(o.site) + ',sets:[' + handlerSets(o.site) + ']}'}src = src + '],classes:' + list(n.classes == null ? [] : n.classes) + '}'return src}// view.hl's kidKey: the reference's path, joined — the key the server mounted it underkidKeyOf(n) {let out = ''for (p of n.path) { out = out + '/' + p }return out}// the value a literal binding carries — view.refValue, folded at compile timerefLiteral(b) {if (b.kind == 'folded') { return jsValue(b.value) }if (b.kind == 'number') { return '' + b.text }if (b.kind == 'boolean') { return b.text == 'true' ? 'true' : 'false' }if (b.kind == 'null_literal') { return 'null' }return str(b.text)}jsValue(v) {if (v == null) { return 'null' }if (hlTypeName(v) == 'String') { return str(v) }if (hlTypeName(v) == 'Boolean') { return v ? 'true' : 'false' }if (hlTypeName(v) == 'Number') { return '' + v }return JSON.stringify(v)}// ---- the site set: what this element SHOWS ------------------------------------------// The old hl:web asked the module's table which members the element at this site reads. The// answer is a fact about the View, so it is written in: the members its bound// attributes and its text leaves name. `Style` is folded into a class name at build and// the shell's `slot` is swapped by navigation — neither is ever painted, and neither is// a member read that reaches here (a `for` row variable resolves to its row).siteNames(n) {let out = []for (a of n.attrs) {if (a.member != null && a.member != 'Style' && !rowVars.includes(a.member) && !out.includes(a.member)) { out.push(a.member) }// A FIELD OF A MEMBER (`value = f.title`, ticket #93) is a read of the member:// a write to `f.title`, or a new `f`, moves `f`, and this element repaintsif (a.ref != null && a.ref.name != 'Style' && !rowVars.includes(a.ref.name) && !out.includes(a.ref.name)) { out.push(a.ref.name) }}for (c of n.children) {if (isReadNode(c)) {for (nm of readNames(c)) { if (!out.includes(nm)) { out.push(nm) } }}}return out}rowVars = []// ---- the paints ---------------------------------------------------------------------collectPaints(&out, nodes) {for (n of nodes) {if (n.k == 'el') {let paint = paintFn(n)let row = rowFn(n)if (paint != null || row != 'null') {out.push({ site = n.site; paint = paint == null ? 'null' : paint; row = row; })}collectPaints(&out, n.children)} else if (n.k == 'for') { collectPaints(&out, n.body) }else if (n.k == 'if') { collectPaints(&out, n.then) collectPaints(&out, n.other) }else if (n.k == 'component') { if (n.fill != null) { collectPaints(&out, n.fill) } }}return null}// `paint(&m, s, name)`, compiled: the bound attributes under the member each reads, and// the text where the member that moved stands among the leaves.paintFn(n) {let body = ''for (a of n.attrs) {if (a.member != null) {body = body + 'if(n===' + str(a.member) + '){' + attrWrite(n, a, readOf({ k = 'member'; name = a.member; })) + '}'} else if (a.ref != null) {// a field of a member paints when the MEMBER moves (ticket #93)body = body + 'if(n===' + str(a.ref.name) + '){' + attrWrite(n, a, readOf(a.ref)) + '}'}}let text = textOf(n)if (text != null) {let guard = ''for (c of n.children) {if (isReadNode(c)) {let g = guardOf(c)if (g != '') {if (guard != '') { guard = guard + '||' }guard = guard + g}}}if (guard != '') { body = body + 'if(' + guard + '){el.textContent=' + text + ';}' }} else if (mixed(n)) {let k = 0for (run of runsOf(n)) {let guard = ''for (c of run) {if (c.k != 'text') {let g = guardOf(c)if (g != '') {if (guard != '') { guard = guard + '||' }guard = guard + g}}}if (guard != '') { body = body + 'if((' + guard + ')&&el.__hlLeaves&&el.__hlLeaves[' + k + ']){el.__hlLeaves[' + k + '].data=' + runText(run) + ';}' }k = k + 1}}if (body == '') { return null }return 'async function(el,inst,r,n){' + body + '}'}// `rowPaint(&m, el)`, compiled: every bound spot, written only where the DOM does not// already hold it — which is why a push into 2000 rows costs the 1999 nothing.rowFn(n) {let body = ''for (a of n.attrs) {if (isBool(a)) {body = body + '{' + boolWrite(a, rawRead(a.member != null ? { k = 'member'; name = a.member; } : a.ref)) + '}'} else if (a.member != null || a.ref != null) {let one = 'let v=' + readOf(a.member != null ? { k = 'member'; name = a.member; } : a.ref) + ';'if (a.extra != null) { one = one + 'v=v+" "+' + str(a.extra) + ';' }if (n.tag == 'select' && a.name == 'value') { one = one + 'if(el.value!==v){el.value=v;}' }else {one = one + 'if(el.getAttribute(' + str(a.name) + ')!==v){el.setAttribute(' + str(a.name) + ',v);}'if (a.name == 'value') { one = one + 'if(el.value!=null&&el.value!==v){el.value=v;}' }}body = body + '{' + one + '}'}}let text = textOf(n)let feeds = falsefor (c of n.children) { if (isReadNode(c)) { feeds = true } }if (text != null && feeds) { body = body + '{let t=' + text + ';if(el.textContent!==t){el.textContent=t;}}' }if (text == null && feeds) {let k = 0for (run of runsOf(n)) {if (runFeeds(run)) { body = body + '{let x=el.__hlLeaves&&el.__hlLeaves[' + k + '];let t=' + runText(run) + ';if(x&&x.data!==t){x.data=t;}}' }k = k + 1}}if (body == '') { return 'null' }return 'async function(el,inst,r){' + body + '}'}attrWrite(n, a, v) {if (isBool(a)) { return boolWrite(a, rawRead(a.member != null ? { k = 'member'; name = a.member; } : a.ref)) }let src = 'let v=' + v + ';'if (a.extra != null) { src = src + 'v=v+" "+' + str(a.extra) + ';' }if (n.tag == 'select' && a.name == 'value') { return src + 'el.value=v;' }src = src + 'el.setAttribute(' + str(a.name) + ',v);'if (a.name == 'value') { src = src + 'if(el.value!=null){el.value=v;}' }return src}textOf(n) {let parts = []let content = 0let feeds = falsefor (c of n.children) {if (c.k != 'on') {content = content + 1if (c.k == 'text') { parts.push(str(c.text)) }else if (isReadNode(c)) { parts.push(textRead(c)) feeds = true }}}if (!feeds) { return null }if (parts.length != content) { return null }let src = '""'for (p of parts) { src = src + '+(' + p + ')' }return src}// ---- a read, written in ---------------------------------------------------------------// `view.value`'s own rule as an expression: a `for` row variable in scope wins over a// member of the same name, and a field path walks into it. `readOf` answers the STRING// the DOM takes; `rawRead` the value itself (a binding, a list, a condition).readOf(node) { return '(""+' + rawRead(node) + ')' }// A READ SHOWN AS TEXT: null is no text, as the server writes it (view.hl `escape`)textRead(node) { return 'S(' + rawRead(node) + ')' }rawRead(node) {if (node == null) { return 'null' }if (node.k == 'text') { return node.kind == 'folded' && hlTypeName(node.value) == 'Hybrid' ? JSON.stringify(node.value) : str(node.text) }if (node.k == 'expr') { return '(await X(' + JSON.stringify(node.x) + ',r,inst))' }if (node.k != 'field') { return 'RV(r,inst,' + str(node.name) + ')' }return 'RF(r,inst,' + str(node.name) + ',' + list(node.path) + ')'}list(names) {let src = '['let first = truefor (n of names) {if (!first) { src = src + ',' }first = falsesrc = src + str(n)}return src + ']'}// a JavaScript string literal — JSON's escaping is a subset of JavaScript's, so a// source text's quotes, newlines and backslashes survive into the statement unchangedstr(s) {if (s == null) { return '""' }return JSON.stringify('' + s)}// ---- WHAT THE LANGUAGE SAYS ABOUT THIS FILE, read HERE and written into the code ----// The old hl:web shipped the language's answers to the browser as data: `hlJs` writes each file's// members, handlers and methods — every node of every initializer, with the names it// reads — into the module, and the client reduces that table on every boot. The answers// are facts about the source, so this file asks for them once, at compile time, and// writes the RESULT into the statements: a handler's write set stands at the listener// that needs it, a region's names at the region, the derivations as a list of what to// re-run. The module carries no table at all (WebFramework `module` drops the one the// emitter writes).//// The reflection is the framework's own analysis generation — the same `gen` the View// trees come from. Nothing starts a second one: a handle is only valid inside the// generation that made it.tableOf(gen, key) {let members = []let derivations = []let handlers = []let methods = []for (m of hlMembers(gen, key)) {let entry = { name = m.name; nodes = null; }if (m.hasValue) {let n = syntaxTree(gen, key, m.node)entry.nodes = nderivations.push({ name = m.name; site = n.site; reads = n.reads; serverSite = serverSite(n.reads); })nestedHandlers(&handlers, n)}members.push(entry)}for (h of hlEvents(gen, key).handles) {let n = syntaxTree(gen, key, h.node)handlers.push({ event = h.event; site = n.site; reads = n.reads; writes = n.writes; nested = false; })}for (mm of hlMethods(gen, key)) {if (!mm.inlineHandler) {let n = syntaxTree(gen, key, mm.node)methods.push({ name = mm.name; reads = n.reads; writes = n.writes; })}}// A SERVER REFERENCE SITE IS ONE THROUGH A LOCAL HELPER TOO (#121): an initializer// that calls a method or static of this class which calls the import still reaches// the server only, so it is never re-derived in the browser either.let post = []for (d of derivations) {post.push({ name = d.name; site = d.site; reads = d.reads; serverSite = d.serverSite || reachesServerSite(members, methods, d.reads); })}return { members = members; derivations = post; handlers = handlers; methods = methods; }}// does a read list reach an imported class THROUGH this class's own methods and statics,// transitively — the walk of `closeReads`, over the helpers a derivation callsreachesServerSite(members, methods, reads) {let queue = []if (reads != null) {for (r of reads) {if (r.kind == 'method' || (r.kind == 'member' && r.isStatic == true)) { queue.push(r.kind + ' ' + r.name) }}}let seen = {}while (queue.length > 0) {let at = queue.shift()if (seen[at] == null) {seen[at] = truelet sp = at.indexOf(' ')let kind = at.slice(0, sp)let name = at.slice(sp + 1)let hop = []if (kind == 'method') { for (mm of methods) { if (mm.name == name) { hop = mm.reads } } }else { for (mb of members) { if (mb.name == name && mb.nodes != null) { hop = nodeReads(mb.nodes) } } }if (serverSite(hop)) { return true }for (r of hop) {if (r.kind == 'method' || (r.kind == 'member' && r.isStatic == true)) { queue.push(r.kind + ' ' + r.name) }}}}return false}// A NESTED `on` IS A DOM HANDLER, filed under its own site — the id the View node// carries. Its body is not the literal's syntax, so the node answers for it and the// walk stops there.nestedHandlers(&into, n) {if (n == null) { return null }if (n.tag == 'on_statement') {into.push({ event = n.event; site = n.site; reads = n.reads; writes = n.writes; nested = true; })return null}for (c of [n.value, n.list, n.body, n.condition, n.consequent, n.alternate, n.object, n.property, n.callee, n.left, n.right, n.operand]) {if (c != null) { nestedHandlers(&into, c) }}for (l of [n.entries, n.args, n.elements]) {if (l != null) { for (c of l) { nestedHandlers(&into, c) } }}return null}// the site with its file's base name: everything after the last '/'. The path part// is not "up to the first ':'" — a package key has a colon of its own// ('hl:markdown/Markdown.hl:24:3'), and that reading left a package component's// sites unshortened, so its regions found no reads and never repainted (#111)shortSite(site) {if (site == null) { return null }let cut = site.lastIndexOf('/')if (cut < 0) { return site }return site.slice(cut + 1)}// the syntax of one node, recursively — hl:core/AST's `tree`, read off the framework's// own generation instead of starting one of its ownsyntaxTree(gen, key, node) {let n = hlSyntax(gen, key, node)if (n == null) { return null }// A SITE IS NAMED BY THE FILE'S BASE NAME. The reflection answers with the project// key ('components/sort_list.hl:77:26'); the View tree, the emitter's own// `__derive__` branches and every site this framework writes use the base name// ('sort_list.hl:77:26'). One spelling, or a lookup by site finds nothing.let out = { tag = n.tag; site = shortSite(n.site); reads = n.reads; writes = n.writes; }if (n.tag == 'object_expression') {let kids = []for (h of n.entries) { kids.push(syntaxTree(gen, key, h)) }out.entries = kids} else if (n.tag == 'object_property') {out.key = n.keyout.value = syntaxTree(gen, key, n.value)} else if (n.tag == 'view_for') {out.list = syntaxTree(gen, key, n.list)out.body = syntaxTree(gen, key, n.body)} else if (n.tag == 'view_if') {out.condition = syntaxTree(gen, key, n.condition)out.consequent = syntaxTree(gen, key, n.consequent)out.alternate = syntaxTree(gen, key, n.alternate)} else if (n.tag == 'on_statement') {out.event = n.event} else if (n.tag == 'member_expression') {out.object = syntaxTree(gen, key, n.object)out.property = syntaxTree(gen, key, n.property)} else if (n.tag == 'call_expression') {out.callee = syntaxTree(gen, key, n.callee)let args = []for (h of n.args) { args.push(syntaxTree(gen, key, h)) }out.args = args} else if (n.tag == 'binary_expression') {out.left = syntaxTree(gen, key, n.left)out.right = syntaxTree(gen, key, n.right)} else if (n.tag == 'unary_expression') {out.operand = syntaxTree(gen, key, n.operand)} else if (n.tag == 'conditional_expression') {out.condition = syntaxTree(gen, key, n.condition)out.consequent = syntaxTree(gen, key, n.consequent)out.alternate = syntaxTree(gen, key, n.alternate)} else if (n.tag == 'array_expression') {let els = []for (h of n.elements) { els.push(syntaxTree(gen, key, h)) }out.elements = els}return out}// ---- the reductions the browser used to make on every boot --------------------------// A `for`'s names are what its list reads, an `if`'s what its condition reads — filed// under the region's own site.regionIndex(t) {let idx = {}for (m of t.members) { if (m.nodes != null) { indexRegions(&idx, m.nodes) } }return idx}indexRegions(&idx, n) {if (n == null) { return null }if (n.tag == 'view_for') {idx[n.site] = memberNames(nodeReads(n.list))indexRegions(&idx, n.body)return null}if (n.tag == 'view_if') {idx[n.site] = memberNames(nodeReads(n.condition))indexRegions(&idx, n.consequent)indexRegions(&idx, n.alternate)return null}if (n.tag == 'on_statement') { return null }for (c of [n.value, n.list, n.body, n.condition, n.consequent, n.alternate, n.object, n.property, n.callee, n.left, n.right, n.operand]) {if (c != null) { indexRegions(&idx, c) }}for (l of [n.entries, n.args, n.elements]) {if (l != null) { for (c of l) { indexRegions(&idx, c) } }}return null}// the member reads of a read list — the language resolved every name, so this is a// filter and not a second resolutionmemberNames(reads) {let out = []if (reads == null) { return out }for (r of reads) {if (r.kind == 'member' && r.isStatic != true && !out.includes(r.name)) { out.push(r.name) }}return out}nodeReads(n) {if (n == null) { return [] }if (n.reads != null) { return n.reads }let out = []for (c of [n.value, n.list, n.body, n.condition, n.consequent, n.alternate, n.object, n.property, n.callee, n.left, n.right, n.operand]) {if (c != null) { for (r of nodeReads(c)) { out.push(r) } }}for (l of [n.entries, n.args, n.elements]) {if (l != null) { for (c of l) { for (r of nodeReads(c)) { out.push(r) } } }}return out}// A ROUTINE'S WRITES, PLUS THE WRITES OF EVERYTHING IT REACHES — the method it calls// and the handler it emits to, transitively. Both are READS the language resolved, so// this is a walk of its answer and never a guess.closeWrites(t, writes, reads) {let out = memberNames(writes)let queue = []reached(&queue, reads)let seen = {}while (queue.length > 0) {let at = queue.shift()if (seen[at] == null) {seen[at] = truelet sp = at.indexOf(' ')let kind = at.slice(0, sp)let name = at.slice(sp + 1)for (mm of t.methods) {if (kind == 'method' && mm.name == name) {for (w of memberNames(mm.writes)) { if (!out.includes(w)) { out.push(w) } }reached(&queue, mm.reads)}}for (h of t.handlers) {if (kind == 'handler' && !h.nested && h.event == name) {for (w of memberNames(h.writes)) { if (!out.includes(w)) { out.push(w) } }reached(&queue, h.reads)}}for (mb of t.members) {if (kind == 'member' && mb.name == name && mb.nodes != null) {for (w of memberNames(mb.nodes.writes)) { if (!out.includes(w)) { out.push(w) } }reached(&queue, nodeReads(mb.nodes))}}}}return out}// the mirror: a handler's reads plus the reads of everything it reaches — the// candidates for a routine a host handed down, whose writes only the host's table namescloseReads(t, reads) {let out = memberNames(reads)let queue = []reached(&queue, reads)let seen = {}while (queue.length > 0) {let at = queue.shift()if (seen[at] == null) {seen[at] = truelet sp = at.indexOf(' ')let kind = at.slice(0, sp)let name = at.slice(sp + 1)for (mm of t.methods) {if (kind == 'method' && mm.name == name) {for (r of memberNames(mm.reads)) { if (!out.includes(r)) { out.push(r) } }reached(&queue, mm.reads)}}for (h of t.handlers) {if (kind == 'handler' && !h.nested && h.event == name) {for (r of memberNames(h.reads)) { if (!out.includes(r)) { out.push(r) } }reached(&queue, h.reads)}}for (mb of t.members) {if (kind == 'member' && mb.name == name && mb.nodes != null) {for (r of memberNames(nodeReads(mb.nodes))) { if (!out.includes(r)) { out.push(r) } }reached(&queue, nodeReads(mb.nodes))}}}}return out}reached(&queue, reads) {if (reads == null) { return null }for (r of reads) {if (r.kind == 'method' || r.kind == 'handler' || (r.kind == 'member' && r.isStatic != true)) { queue.push(r.kind + ' ' + r.name) }}return null}// ---- THE SERVER RENDER, COMPILED --------------------------------------------------// The old hl:web rendered a page by WALKING the View tree per request: for every node it asks// what kind it is, for every element it builds the attribute string, and it decides the// indentation of every line again, on every request, for every one of the 121 mounts a// page may hold. Every one of those decisions is a fact about the View.//// So a component's render is compiled ONCE into a list of STEPS — constant text, and a// read with its own closure — and a request is a concatenation. The structure (tags,// static attributes, the readable form's line breaks, which element is a block) is// decided at compile time; only the values are asked for per request.//// KEYED BY THE INDENT IT IS RENDERED AT, because that is the only thing about a// component's HTML that its caller decides: the shell renders at one tab, the page at// the depth the slot stands, a fill compactly (null). An app has a handful of those.serverSteps(key, nodes, indent, classes, slotted) {let ck = key + '|' + (indent == null ? '~' : indent) + '|' + joinList(classes) + '|' + (slotted ? '1' : '0')if (steps[ck] == null) {steps[ck] = blockSteps(classes == null || classes.length == 0 ? nodes : v.withRootClasses(nodes, classes), indent, slotted)}return steps[ck]}// A `slot` THE HOST FILLED COUNTS AS CONTENT, and an empty one does not — the one// thing about a component's shape its caller decides, so it is part of the key.staticVoid = [ 'area' 'base' 'br' 'col' 'embed' 'hr' 'img' 'input' 'link' 'meta' 'source' 'track' 'wbr' ]staticVerbatim = [ 'textarea' 'pre' 'script' 'style' ]blocky(n, slotted) {if (staticVerbatim.includes(n.tag)) { return false }if (n.children.length == 0) { return false }for (c of n.children) {if (c.k == 'text' || c.k == 'field' || c.k == 'expr') { return false }if (c.k == 'member' && !(c.name == 'slot' && slotted)) { return false }}return true}steps = {}v = new HlwViewjoinList(l) {if (l == null) { return '' }let out = ''for (c of l) { out = out + ',' + c }return out}// `render(nodes, …, indent)`: the readable form puts each element on its own line at// `indent`, the compact one writes them end to end.blockSteps(nodes, indent, slotted) {let out = []for (n of nodes) {if (indent != null) {if (n.k == 'el') { push(&out, { k = 's'; v = newline + indent; }) nodeSteps(&out, n, indent, slotted) }else if (n.k == 'component') { nodeSteps(&out, n, indent, slotted) }else if (n.k == 'for') { forSteps(&out, n, indent, slotted) }else if (n.k == 'if') { ifSteps(&out, n, indent, slotted) }else if (n.k == 'member' && n.name == 'slot') { push(&out, { k = 'slotline'; v = newline + indent; }) }else if (n.k != 'on') { nodeSteps(&out, n, indent, slotted) }} else {nodeSteps(&out, n, null, slotted)}}return out}forSteps(&out, n, indent, slotted) {out.push({ k = 'for'; row = n.row; read = readFn(n.list); body = blockSteps(n.body, indent, slotted); })return null}ifSteps(&out, n, indent, slotted) {out.push({ k = 'if'; read = readFn(n.cond); then = blockSteps(n.then, indent, slotted); other = blockSteps(n.other, indent, slotted); })return null}// one node's steps — `html(node, …)`, decided oncenodeSteps(&out, n, indent, slotted) {if (n.k == 'text') { push(&out, { k = 's'; v = v.escape(n.text); }) return null }if (n.k == 'on') { return null }if (n.k == 'member' && n.name == 'slot') { out.push({ k = 'slot'; }) return null }if (n.k == 'member' || n.k == 'field' || n.k == 'expr') { out.push({ k = 'r'; read = readFn(n); }) return null }if (n.k == 'for') { forSteps(&out, n, indent, slotted) return null }if (n.k == 'if') { ifSteps(&out, n, indent, slotted) return null }if (n.k == 'component') {out.push({ k = 'kid'; path = n.path; cls = n.cls; key = n.key; classes = n.classes; indent = indent;fill = (n.fill == null || n.fill.length == 0) ? null : blockSteps(n.fill, null, slotted); })return null}if (n.k == 'el') { elSteps(&out, n, indent, slotted) return null }push(&out, { k = 's'; v = '<!-- ' + n.tag + ' not rendered yet -->'; })return null}elSteps(&out, n, indent, slotted) {let attrs = []let valueRead = null // a textarea's value is its contentlet selRead = null // a select's value selects an optionfor (a of n.attrs) {if (n.tag == 'textarea' && a.name == 'value') { valueRead = attrRead(a) }else if (n.tag == 'select' && a.name == 'value') { selRead = attrRead(a) }// a bound BOOLEAN attribute is its presence (ticket #33): ` checked=""` or nothingelse if ((a.member != null || a.ref != null) && v.isBoolAttr(a.name)) { attrs.push({ k = 'b'; name = a.name; read = attrRead(a); }) }else if (a.member != null || a.ref != null) {push(&attrs, { k = 's'; v = ' ' + a.name + '="'; })attrs.push({ k = 'r'; read = attrRead(a); })push(&attrs, { k = 's'; v = v.extraOf(a) + '"'; })} else {push(&attrs, { k = 's'; v = ' ' + a.name + '="' + v.escape(a.text) + '"'; })}}if (v.boundValue(n)) { push(&attrs, { k = 's'; v = ' autocomplete="off"'; }) }// the children, in the form this element takes: a block puts them one per linelet kids = []if (indent != null && blocky(n, slotted)) {kids = blockSteps(n.children, indent + tab, slotted)push(&kids, { k = 's'; v = newline + indent; })} else {kids = blockSteps(n.children, null, slotted)}if (valueRead != null) { kids = [{ k = 'r'; read = valueRead; }] }// AN OPTION IS THE ONE ELEMENT WHOSE OWN TAG DEPENDS ON THE VALUE ABOVE IT: it// carries `selected` when its value is the select's. Everything else is settled.if (n.tag == 'option') {out.push({ k = 'opt'; attrs = attrs; body = kids; ov = optionRead(n); })return null}push(&out, { k = 's'; v = '<' + n.tag; })for (a of attrs) { push(&out, a) }if (staticVoid.includes(n.tag)) { push(&out, { k = 's'; v = '>'; }) return null }push(&out, { k = 's'; v = '>'; })// A SELECT HANDS ITS VALUE TO ITS OPTIONS, however deep they stand: its children// run under it, and it is the only element that changes anything for them.if (n.tag == 'select') { out.push({ k = 'sel'; read = selRead; body = kids; }) }else { for (kk of kids) { push(&out, kk) } }push(&out, { k = 's'; v = '</' + n.tag + '>'; })return null}// CONSTANT TEXT MERGES. Two literal steps in a row are one string decided at compile// time — which is what makes a request a concatenation of a handful of pieces instead// of one per node.push(&out, step) {if (step.k == 's' && out.length > 0 && out[out.length - 1].k == 's') {let last = out[out.length - 1]last.v = last.v + step.vout[out.length - 1] = lastreturn null}out.push(step)return null}// ---- the reads, each with its own closure ------------------------------------------// `view.value`'s rule, split once: a `for` row variable in scope wins over a member of// the same name, and a field path walks into it. The closure holds the name — there is// no node to look at per request.readFn(node) {if (node == null) { return (inst, rows) => { return null } }if (node.k == 'text') { let t = v.refValue(node) return (inst, rows) => { return t } }if (node.k == 'expr') { let x = node.x return (inst, rows) => { return v.evalExpr(x, inst, rows) } }let name = node.nameif (node.k != 'field') {return (inst, rows) => { return rows[name] != null ? rows[name] : inst[name] }}let path = node.pathreturn (inst, rows) => {let val = rows[name] != null ? rows[name] : inst[name]for (f of path) {if (val == null) { return null }val = val[f]}return val}}attrRead(a) {if (a.member != null) { return readFn({ k = 'member'; name = a.member; }) }if (a.ref != null) { return readFn(a.ref) }let t = a.textreturn (inst, rows) => { return t }}// an option's own value: its `value` attribute, or null when it has none (the browser// then reports its text, and the run compares against what it rendered)optionRead(n) {for (a of n.attrs) {if (a.name == 'value') { return attrRead(a) }}return null}// ---- the run: a concatenation ------------------------------------------------------// EVERY REGION IS WRITTEN BETWEEN TWO MARKS (tickets #103, #104): an `if` around the// branch that stands, a `for` around its rows and each row around its own nodes. The// client claims them as they are and builds the same ones, and a region's nodes are// exactly what stands between its two marks — nothing it holds has to be remembered// node by node, and text beside a region can never run into the region's text.markOpen = '<!--[-->'markClose = '<!--]-->'// THE PIECES ARE JOINED ONCE, AT THE END (ticket #127). `out = out + piece` copied// the whole page so far for every piece: a 3.5 MB page of 2000 rows allocated 2.3 GB// in growing copies, 7 GB per render all told — 12 s a load once the collector ran// by bytes (#126), where the joined pieces take 2.5 s.runSteps(list, &inst, &rows, &ctx) {let out = []for (s of list) {if (s.k == 's') { out.push(s.v) }else if (s.k == 'r') { let f = s.read out.push(v.escape(f(&inst, &rows))) }else if (s.k == 'b') { let f = s.read if (v.boolOn(f(&inst, &rows))) { out.push(' ' + s.name + '=""') } }else if (s.k == 'slot') { if (ctx.slot != null) { out.push(ctx.slot) } }else if (s.k == 'slotline') { if (ctx.slot != null) { out.push(s.v + ctx.slot) } }else if (s.k == 'for') {let f = s.readlet list2 = f(&inst, &rows)out.push(markOpen)if (list2 != null) {let ri = 0for (entry of list2) {let inner = rows + {}inner[s.row] = entrylet rc = v.rowCtx(&ctx, &entry, ri)out.push(markOpen + runSteps(s.body, &inst, &inner, &rc) + markClose)ri = ri + 1}}out.push(markClose)}else if (s.k == 'if') {let f = s.readif (f(&inst, &rows)) { out.push(markOpen + runSteps(s.then, &inst, &rows, &ctx) + markClose) }else { out.push(markOpen + runSteps(s.other, &inst, &rows, &ctx) + markClose) }}else if (s.k == 'opt') {let head = runSteps(s.attrs, &inst, &rows, &ctx)let body = runSteps(s.body, &inst, &rows, &ctx)let mark = ''if (ctx.sel != null) {let ov = bodyif (s.ov != null) { let f = s.ov ov = '' + f(&inst, &rows) }if (ov == ctx.sel) { mark = ' selected' }}out.push('<option' + head + mark + '>' + body + '</option>')}else if (s.k == 'sel') {let f = s.readlet inner = ctx + {}inner.sel = f == null ? null : '' + f(&inst, &rows)out.push(runSteps(s.body, &inst, &rows, &inner))}else if (s.k == 'kid') {let kid = ctx.kids[v.kidKeyIn(s.path, ctx)]if (kid == null) { out.push('<!-- ' + s.cls + ' not mounted -->') }else {let fillText = s.fill == null ? null : runSteps(s.fill, &inst, &rows, &ctx)let kidCtx = { slot = fillText; kids = kid.kids; }let kidInst = kid.pagelet empty = {}out.push(runSteps(serverSteps(s.key, kid.view, s.indent, s.classes, fillText != null), &kidInst, &empty, &kidCtx))}}}return out.join('')}
Branches
- mainmain branch
Latest commits
- 84e1b3e1tracker: reports 025 + 026mre
- dc40d859tracker#31 (mission 026): duplicate titles merged — the 68 type+tmdbId pairs held by 157 records were the old tracker's (all migrated); merge.hl repair job (own clock, before the TMDB jobs) keeps one keeper per title (follows/watches > old short id > oldest), moves follows, watches, seasons, cast, credits, timelines, tombstones the rest (mergedInto, never deleted), slugs + short ids 301 to the keeper; stray seasons merged into their listed twin (Reacher S3 watches) or linked when watched; search import re-checks before its put; deploy.sh waits up to 90 s for 200; real copy 68 -> 0 dup ids, az5b2 follows/watches equal; gates 342/0, 32/0, 52/0mre
- 2667da05tracker#30 (mission 025): /my/ pages from slim cached title cards, episode rows and watch sets (after the jobs /my/series 1.8 s -> 0.06 s, /my/unwatched 4.1 -> 0.18 s); timeline page shows its name once; franchise widget under the poster/title; movies with TV leftovers (First Contact) go through the details repair; tools/count-tmdb-ids.hl; gates 327/0, 52/0, 32/0mre
- 3909810dantcolony#40: mission references in README/STATUS/docs point to the moved missionsmre
- e63b1d28antcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
- 1cda451dtracker: Hybriel master 190aa11d (#127 both shapes, GC correctness fc838894) — conductor adopts despite /my/series 2.4x after jobs (memory 8.0 → 1.7 GB boot); see reports/071mre
- c1fa2f2etracker (mission 071): Hybriel master 190aa11d measured on the real copy vs the live binary 8590df63 — NOT adopted (after the first-start jobs /my/series 2.4x slower, /series 1.6x, RSS swings 7.4-12.2 GB; fresh it is flat at 1.7-2.2 GB and /my/unwatched faster), vendor stays 8efba065, candidate kept in .scratch/w071/vendor-190aa11d; tests/kinds.mjs: collection seed off (its TMDB request broke check 1 in 1 of 4 runs); tests/realdata-071.sh + realdata-071-bench.mjs + tools/realdata-071-table.py; README + STATUS (numbers, how to repeat); gates 325/0, 32/0, 50/0mre
- 8081350atracker docs (mission 070): README (summary, Config HL_GC_BYTES — kept at Hybriel's default, the 256 MiB setting is taken out of docker-compose.yml again: the jobs grew to 12+ GB with it too, see STATUS), Test (three gates), Deploy (first start ~50 min: kinds then seed, restart once after collections done, memory numbers), Vendored Hybriel 8efba065 + #48 audit, Files; STATUS mission 070 entry (merges, migrated counts, lambda audit, gates, RSS old vs new, how to repeat, open points); docs/kinds.md + docs/franchises.md job order; tests/realdata-070-*.sh, tools/count-migrated.hl, tools/ref-params.py, tools/lambda-audit.pymre
- 1ad19c8ctracker: re-vendor Hybriel master 8efba065 (#126 GC by bytes, #48 lambda parameters copy) (mission 070): bin/hybriel sha256 50361e95…, plugins core crypto data fetch fs http http1 mpackdb proc smtp time web; lambda audit: 13 lambdas change a passed record/list (11 through a local alias), no caller relies on it — unchanged; 319 read-only lambda parameters get & (no copy per call: /my/schedule 2.35 → 0.46 s, /my/unwatched 13.7 → 5.7 s on the real copy); /my/unwatched one merge sort instead of n² inserts; docker-compose HL_GC_BYTES=268435456; deploy.sh runs kinds.mjs + franchises.mjs too (default ports 8700–8710); tests/realdata-070.mjs; gates browser 325/0, kinds 32/0, franchises 50/0mre
- 46b21389tracker (mission 070, conductor): the sync never destroys migrated data — the one-time summary step MOVES a copied summary to migratedSummary (marker summaries-moved.txt) instead of clearing it; a migrated record's first title/genres/homepage/tagline TMDB replaces → migratedTitle/migratedGenres/migratedHomepage/migratedTagline, a migrated season's/episode's title/summary → migratedTitle/migratedSummary (set once); tests/peek-shows.hl prints them; gate 325/0mre
- 749019b0Merge t19 (tracker#19 franchises + timelines) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/browser.mjs/faketmdb.mjs, both sides kept; franchise/timeline pages get #20's typed heading (Franchise | …, Timeline | …), their title links via titlePath (/movies|/series|/shows); the collection seed waits for repair, kinds and credits too; franchises.mjs URLs + 2 new checks; gates browser 323/0, kinds 32/0, franchises 50/0mre
- daf49feaMerge t20 (tracker#20 typed headings + #21 series/shows split) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/search.hl/components/search.hl/browser.mjs/faketmdb.mjs, both sides kept; clock order backfill → repair → kinds → credits; withDetailsFields stores tmdbType + kind; gates: browser.mjs URLs → /series|/movies, typed h1 selectors; kinds.mjs repair/credits off, fixture name = seed name; browser 323/0, kinds 32/0mre
- 20e09d89Merge t18 (tracker#18 delta sync) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/browser.mjs, both sides kept; pageShowOf summary = summaryOfmre
- f83571c3tracker#18 (mission 067): daily sync by change lists — TMDB /tv|movie/changes (since the stored day, paged) + TVmaze /updates/shows → only our changed titles (followed: full step, unfollowed: light step — changed seasons, no TVmaze), full walk on first run / gap > 14 days / failed list; show record refreshed (title, tmdbSummary, tagline, status, genres …; renamed titles re-indexed); summary = the creator's own text (page: summary > tmdbSummary > tvmazeSummary), one-time clear of copied summaries (9,647 on the live copy); gate 261, tests/realdata-018*.mjs, README + STATUSmre
- 1ed5457etracker#20 + #21 (mission 068): typed headings "<Type> | <name>" in type colours; TV titles split into Series (/series) and Shows (/shows) by TMDB type + Reality/Talk/News genres — kind stored by sync/import/adult backfill + new kind backfill (resumes), /movies/<slug>, /shows/<slug> of a series/movie → 301, /my/series + /my/shows, home 5 tiles + 3 rows, search/filmography labels; gates kinds 32 + browser 266, tests/realdata-068.mjs, tools/count-kinds.hl, docs/kinds.md, README + STATUSmre
- f2b00674Merge t26 (tracker#26 + #28) into main (mission 062): short ids for every new person (castPersonId, guest route), guest stars stored on the title and created as people only when opened (/person/tmdb/<id>?show=<id> → 302), lean watch/follow clicks (showRow a small object, cast/crew from the slug, watches cached per user, face rows only after a season toggle); gate 311, tests/realdata-062.mjs, README + STATUSmre
- 0553b51ftracker#19 (mission 066): franchises and timelines — tables, /franchises, /franchises/<slug>, /timelines/<slug> (Timeline | Release sort, series by last episode), the Prequel | Timeline | Sequel widget with the franchise above, the creator's editor, TMDB collection seed in the app (resumes, paced); gate tests/franchises.mjs 48/0 + browser.mjs 266/0, tests/realdata-066.mjs, docs/franchises.md, README + STATUSmre
- fa1f9dfatracker#29 (mission 063): unwatched check muted grey outline + check (accent only on hover), watched stays solid — no code regression, the accent outline read as ticked; gate checks real checks visibly (computed style + screenshot pixel) on /my/unwatched, show, movie, /my/movies; gate 266, tests/realdata-063.mjs, README + STATUSmre
- 10bb3f93tracker#28 (mission 061): full cast (all seasons, main cast by episodes, guest stars) + crew (created by, directed by, written by, screenplay, story, music) — stored by the details completion, the daily sync, the search import (one details request) and a background credits job (resumes, RSS limit); show page collapsed after 20 with client-side Show all; showBySlug via a slug map; gate 249, tests/realdata-028.mjs, README + STATUSmre
- d8b12d67tracker#27 (mission 060): short ids for movies, series and persons — old 702 kept (data/old-short-ids.json), new random [a-z0-9]{5} unique across both, claimed at creation, background backfill (resumes), shown under poster/photo, /<shortId> → 301; gate 259, tests/realdata-060*, README + STATUSmre