jq79 0.6.2 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/jq79.cjs +13 -13
- package/dist/jq79.cjs.map +1 -1
- package/dist/jq79.d.ts +5 -0
- package/dist/jq79.global.js +13 -13
- package/dist/jq79.global.js.map +1 -1
- package/dist/jq79.js +13 -13
- package/dist/jq79.js.map +1 -1
- package/package.json +2 -1
- package/src/jq79.ts +637 -36
package/src/jq79.ts
CHANGED
|
@@ -25,6 +25,13 @@ type TemplateNode = {
|
|
|
25
25
|
// parse (see stampComponentTag) and lifted off attrs here, where it stops
|
|
26
26
|
// looking like an attribute to every loop downstream
|
|
27
27
|
component?: string
|
|
28
|
+
// the element's namespace, present only when it is NOT HTML - an <svg>
|
|
29
|
+
// subtree, or MathML. Read straight off the parsed tree, because the HTML
|
|
30
|
+
// parser has already run the foreign-content algorithm over it and knows
|
|
31
|
+
// things a tag name cannot say: whether this <title> is SVG's or HTML's, and
|
|
32
|
+
// where a <foreignObject> hands the namespace back. Absent is the common
|
|
33
|
+
// case and means HTML, so an ordinary node is exactly the shape it was
|
|
34
|
+
ns?: string
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
type TagBlock = {
|
|
@@ -51,17 +58,25 @@ const elementAttrs = (el: Element): Record<string, string> =>
|
|
|
51
58
|
// they are not in the AST at all - which is where slot content is written
|
|
52
59
|
// (<template :slot.name>), and why a nested <template> used to render as an
|
|
53
60
|
// empty element whatever was inside it
|
|
61
|
+
const HTML_NS = "http://www.w3.org/1999/xhtml"
|
|
62
|
+
|
|
54
63
|
const elementToAST = (el: Element): TemplateNode => {
|
|
55
64
|
const attrs = elementAttrs(el)
|
|
65
|
+
// `tagName` is uppercase for HTML and as-authored for everything else, so the
|
|
66
|
+
// lowercasing that normalizes <DIV> would destroy <clipPath>, <linearGradient>
|
|
67
|
+
// and <feGaussianBlur>, whose names are case-sensitive
|
|
68
|
+
const ns = el.namespaceURI
|
|
56
69
|
// the pre-parse stamp becomes a field and leaves attrs entirely: it is not a
|
|
57
70
|
// prop, not a directive and not an attribute, and every loop that walks attrs
|
|
58
71
|
// would otherwise need to know its name
|
|
59
72
|
const component = attrs[COMPONENT_TAG_ATTR]
|
|
60
73
|
delete attrs[COMPONENT_TAG_ATTR]
|
|
74
|
+
const foreign = ns !== null && ns !== HTML_NS
|
|
61
75
|
return {
|
|
62
|
-
tag: el.tagName.toLowerCase(),
|
|
76
|
+
tag: foreign ? el.tagName : el.tagName.toLowerCase(),
|
|
63
77
|
attrs,
|
|
64
78
|
...(component === undefined ? {} : { component }),
|
|
79
|
+
...(foreign ? { ns } : {}),
|
|
65
80
|
children: Array.from((el instanceof HTMLTemplateElement ? el.content : el).childNodes).flatMap((node): (TemplateNode | string)[] => {
|
|
66
81
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
67
82
|
const text = node.textContent ?? ""
|
|
@@ -502,6 +517,18 @@ const scanComponentKey = (scope: Record<string, any>, tag: string): string | nul
|
|
|
502
517
|
let tagMemo: Map<string, string | null> | null = null
|
|
503
518
|
let memoBase: object | null = null
|
|
504
519
|
|
|
520
|
+
// Scope objects known to declare no PascalCase key of their own, so the walk
|
|
521
|
+
// below can skip them without calling Object.keys - which allocates an array
|
|
522
|
+
// and scans it, per element, per row. An :each item scope holds `item`,
|
|
523
|
+
// `$index` and maybe the `, at` name, and whether any of those can be a
|
|
524
|
+
// component name is decided by the template, once (see EachPlan).
|
|
525
|
+
//
|
|
526
|
+
// Only scopes jq79 creates and never adds a key to go in here. A store must
|
|
527
|
+
// never: a setup script's `const Row = await $import(...)` arrives as a new key
|
|
528
|
+
// after the template has already rendered, which is the whole reason the tag
|
|
529
|
+
// memo lives for exactly one pass
|
|
530
|
+
const plainScopes = new WeakSet<object>()
|
|
531
|
+
|
|
505
532
|
// opened and closed by hand rather than by a wrapper taking a callback: a
|
|
506
533
|
// component that renders itself through :each stacks one renderEach per level,
|
|
507
534
|
// and a callback would add a frame to each of them. The cyclic-component test
|
|
@@ -532,6 +559,7 @@ const findComponentKey = (scope: Record<string, any>, tag: string): string | nul
|
|
|
532
559
|
tagMemo.set(tag, key)
|
|
533
560
|
return key
|
|
534
561
|
}
|
|
562
|
+
if (plainScopes.has(obj)) continue
|
|
535
563
|
for (const key of Object.keys(obj)) {
|
|
536
564
|
if (/^[A-Z]/.test(key) && key.replace(/-/g, "").toLowerCase() === normalized) return key
|
|
537
565
|
}
|
|
@@ -1181,6 +1209,14 @@ const BOOLEAN_ATTRS = new Set([
|
|
|
1181
1209
|
// browser doesn't have - and `readonly`/`novalidate`/`ismap` reflect under
|
|
1182
1210
|
// camelCase property names no kebab->camel pass can produce, failing toward
|
|
1183
1211
|
// `readonly="false"`, which is read-only
|
|
1212
|
+
// the one place an element is built, so the interpreted path and the cloner
|
|
1213
|
+
// cannot disagree about what a tag means. `ns` is set only for a foreign
|
|
1214
|
+
// element (see TemplateNode) - and creating one in its own namespace is what
|
|
1215
|
+
// makes `viewBox` keep its case, because setAttribute only lowercases a
|
|
1216
|
+
// qualified name on an HTML element
|
|
1217
|
+
const createFor = (node: TemplateNode): Element =>
|
|
1218
|
+
node.ns === undefined ? document.createElement(node.tag) : document.createElementNS(node.ns, node.tag)
|
|
1219
|
+
|
|
1184
1220
|
const applyAttr = (el: Element, name: string, value: any) => {
|
|
1185
1221
|
const boolean = BOOLEAN_ATTRS.has(name)
|
|
1186
1222
|
if (boolean ? !value : value == null) el.removeAttribute(name)
|
|
@@ -1193,6 +1229,338 @@ const applyAttr = (el: Element, name: string, value: any) => {
|
|
|
1193
1229
|
// normally. :if/:elseif/:else/:each are handled by renderNodes, which decides
|
|
1194
1230
|
// *whether*/*how many times* a node is rendered before calling this. Tags
|
|
1195
1231
|
// matching a PascalCase scope variable render as nested components instead
|
|
1232
|
+
|
|
1233
|
+
// ---------------------------------------------------------------------------
|
|
1234
|
+
// Cloning a fixed shape instead of deriving it per instance.
|
|
1235
|
+
//
|
|
1236
|
+
// renderNode asks the same questions of the same AST node for every instance of
|
|
1237
|
+
// it: is this a slot, a component, an unknown tag; which of these attributes is
|
|
1238
|
+
// a directive; split this text on `{{`. For a :each of 1,000 rows that is ~25
|
|
1239
|
+
// questions per element per row whose answers were fixed by the source text.
|
|
1240
|
+
// Where a subtree's *shape* is fixed - the elements, their static attributes and
|
|
1241
|
+
// their nesting never vary, only the values bound into them - the shape is built
|
|
1242
|
+
// once per definition into a detached skeleton, and each instance is one
|
|
1243
|
+
// cloneNode plus a walk to each binding point.
|
|
1244
|
+
//
|
|
1245
|
+
// Worth -20 to -49% of create1k depending on how much fixed structure a row
|
|
1246
|
+
// has, and nothing at all on a row that has none. Measured, with the method and
|
|
1247
|
+
// the caveats, in TODOS/2026-08-24.clone-skeletons-measured.md.
|
|
1248
|
+
//
|
|
1249
|
+
// Two rules keep this from becoming the bug it could be:
|
|
1250
|
+
//
|
|
1251
|
+
// 1. **The holes are an allowlist, never a denylist.** `plannableAttr` names
|
|
1252
|
+
// what a skeleton knows how to fill; every other attribute makes the subtree
|
|
1253
|
+
// unplannable. So a directive added to renderNode later is *slower* until
|
|
1254
|
+
// somebody teaches it here - never silently mis-rendered, which is the
|
|
1255
|
+
// failure a second render path invites.
|
|
1256
|
+
//
|
|
1257
|
+
// The allowlist is where the one divergence found so far came from, and it
|
|
1258
|
+
// came from *widening* rather than from renderNode growing: `:model` is the
|
|
1259
|
+
// single directive renderNode treats specially that CONTROL_ATTRS does not
|
|
1260
|
+
// name, so it slipped through the generic `:<name>` clause. Adding to this
|
|
1261
|
+
// list is the dangerous edit in this file - see
|
|
1262
|
+
// TODOS/2026-08-24.more-holes-in-the-cloner.md.
|
|
1263
|
+
// 2. **The interpreted path stays the fallback for everything else**, including
|
|
1264
|
+
// every tag that could still turn into a component. The upgrade watch and
|
|
1265
|
+
// the unresolved-component throw are not reimplemented here; they are never
|
|
1266
|
+
// reached from here.
|
|
1267
|
+
//
|
|
1268
|
+
// tests/skeleton.test.ts renders a corpus both ways and diffs the DOM *and the
|
|
1269
|
+
// order the bindings register in*, which is what makes rule 1 enforceable
|
|
1270
|
+
// rather than a promise. The order axis is not decoration: :value on a <select>
|
|
1271
|
+
// has to run after its <option>s are bound, and no DOM diff can see that.
|
|
1272
|
+
// ---------------------------------------------------------------------------
|
|
1273
|
+
|
|
1274
|
+
// Flipping this must never change what renders, only how - which is what
|
|
1275
|
+
// tests/skeleton.test.ts exists to keep true. It is on, and switchable through
|
|
1276
|
+
// `Component79.debug({ cloneSkeletons: false })`, because a second render path
|
|
1277
|
+
// is the kind of change that wants an off switch a user can reach without a
|
|
1278
|
+
// rebuild: a page that renders wrong is a bug report either way, but one whose
|
|
1279
|
+
// reporter can say "it goes away with cloning off" is a bug report that names
|
|
1280
|
+
// the file
|
|
1281
|
+
const debugFlags: DebugFlags = { cloneSkeletons: true }
|
|
1282
|
+
|
|
1283
|
+
// What `Component79.debug()` can switch. One flag today; the shape is an object
|
|
1284
|
+
// so the next one does not change the call
|
|
1285
|
+
export type DebugFlags = {
|
|
1286
|
+
// build a fixed-shape subtree by cloning a skeleton made once per definition,
|
|
1287
|
+
// instead of walking the AST for every instance of it. Off means every
|
|
1288
|
+
// element goes through renderNode, exactly as before this existed
|
|
1289
|
+
cloneSkeletons: boolean
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
// the control attributes a skeleton knows how to fill. The rest of
|
|
1293
|
+
// CONTROL_ATTRS stays rejected on purpose: :if/:elseif/:else/:each/:key change
|
|
1294
|
+
// the shape rather than filling a hole, :with changes the scope its subtree
|
|
1295
|
+
// evaluates in, :props belongs to a component tag, and :html carries a
|
|
1296
|
+
// sanitizer plus a second attribute (:html.allowed) whose "without :html"
|
|
1297
|
+
// warning fires once per render interpreted and would fire once per definition
|
|
1298
|
+
// here - see TODOS/2026-08-24.more-holes-in-the-cloner.md
|
|
1299
|
+
const PLANNABLE_CONTROL_ATTRS = new Set([":text", ":attrs", ":value", ":checked", ":selected"])
|
|
1300
|
+
|
|
1301
|
+
// What a hole can be, in the order renderNode registers them.
|
|
1302
|
+
// A `:` attribute with a dot in it is rejected wholesale except `:class.`:
|
|
1303
|
+
// `:model.`, `:props.`, `:slot.` and `:html.allowed` all live in that shape, and
|
|
1304
|
+
// so would the next directive family somebody invents
|
|
1305
|
+
const plannableAttr = (name: string): boolean => {
|
|
1306
|
+
if (name.startsWith("@")) return true
|
|
1307
|
+
if (name === ":class") return true
|
|
1308
|
+
if (name.startsWith(":class.")) return true
|
|
1309
|
+
if (PLANNABLE_CONTROL_ATTRS.has(name)) return true
|
|
1310
|
+
if (!name.startsWith(":")) return name !== COMPONENT_TAG_ATTR // a static attribute
|
|
1311
|
+
// `:model` is the one directive renderNode treats specially that CONTROL_ATTRS
|
|
1312
|
+
// does not name, so the clause below would let it through as a generic
|
|
1313
|
+
// `:<name>` binding and the skeleton would write `model="..."` where the
|
|
1314
|
+
// interpreted path warns and writes nothing (`:model` binds component tags
|
|
1315
|
+
// only). `:model.<name>` is caught by the dot; the bare form needs saying
|
|
1316
|
+
if (name === ":model") return false
|
|
1317
|
+
return !isControlAttr(name) && !name.includes(".")
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
const plannableNode = (node: TemplateNode): boolean => {
|
|
1321
|
+
if (node.component || node.tag.includes("-")) return false
|
|
1322
|
+
if (isSlotTag(node.tag) || node.tag === "template") return false
|
|
1323
|
+
// an unknown tag may still become a component, so it stays interpreted - the
|
|
1324
|
+
// upgrade watch lives there and a clone cannot carry it. A *foreign* element
|
|
1325
|
+
// skips the test rather than failing it: <circle> is an HTMLUnknownElement
|
|
1326
|
+
// when built with createElement, which is exactly the mistake this used to
|
|
1327
|
+
// make, and nothing in an <svg> subtree can ever become a component (no SVG
|
|
1328
|
+
// tag is uppercase-initial, so none is ever stamped as one)
|
|
1329
|
+
if (node.ns === undefined && document.createElement(node.tag) instanceof HTMLUnknownElement) return false
|
|
1330
|
+
for (const key in node.attrs) if (!plannableAttr(key)) return false
|
|
1331
|
+
// an element with :text has no children on either path (see buildSkeleton), so
|
|
1332
|
+
// what the source wrote inside it cannot make the subtree unplannable - a
|
|
1333
|
+
// component tag under a :text is markup nobody renders, not markup the clone
|
|
1334
|
+
// path would get wrong
|
|
1335
|
+
if (node.attrs[":text"] !== undefined) return true
|
|
1336
|
+
return node.children.every(child => typeof child === "string" || plannableNode(child))
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
// A hole, and the path from the skeleton root to the node it fills: child
|
|
1340
|
+
// indices rather than a query, resolved by walking childNodes. The AST keeps
|
|
1341
|
+
// whitespace text nodes on purpose, and the skeleton keeps them too, so the
|
|
1342
|
+
// indices line up on both sides by construction
|
|
1343
|
+
type SkeletonOp =
|
|
1344
|
+
| { kind: "text"; path: number[]; parts: TextPart[] }
|
|
1345
|
+
| { kind: "event"; path: number[]; attr: string; expr: string }
|
|
1346
|
+
| { kind: "attr"; path: number[]; name: string; expr: string }
|
|
1347
|
+
| { kind: "attrs"; path: number[]; expr: string }
|
|
1348
|
+
| { kind: "class"; path: number[]; classExpr?: string; toggles: [string, string][] | null; staticClasses: Set<string> }
|
|
1349
|
+
| { kind: "textContent"; path: number[]; expr: string }
|
|
1350
|
+
| { kind: "value"; path: number[]; expr: string }
|
|
1351
|
+
| { kind: "checked"; path: number[]; expr: string }
|
|
1352
|
+
| { kind: "selected"; path: number[]; expr: string }
|
|
1353
|
+
|
|
1354
|
+
type SkeletonPlan = { skeleton: Element; ops: SkeletonOp[]; tags: string[] }
|
|
1355
|
+
|
|
1356
|
+
// mirrors renderNode's own order: the attribute walk (events and attribute
|
|
1357
|
+
// bindings as they appear), then :class, then the children. Effects run in
|
|
1358
|
+
// registration order, so this is not cosmetic
|
|
1359
|
+
const buildSkeleton = (node: TemplateNode, path: number[], ops: SkeletonOp[], tags: Set<string>): Element => {
|
|
1360
|
+
tags.add(node.tag)
|
|
1361
|
+
const el = createFor(node)
|
|
1362
|
+
|
|
1363
|
+
let classExpr: string | undefined
|
|
1364
|
+
let toggles: [string, string][] | null = null
|
|
1365
|
+
for (const key in node.attrs) {
|
|
1366
|
+
const value = node.attrs[key]
|
|
1367
|
+
if (key.startsWith("@")) ops.push({ kind: "event", path, attr: key, expr: value })
|
|
1368
|
+
else if (key === ":class") classExpr = value
|
|
1369
|
+
else if (key.startsWith(":class.")) (toggles ??= []).push([key.slice(":class.".length), value])
|
|
1370
|
+
// a directive of its own, bound below - the same skip renderNode's walk
|
|
1371
|
+
// makes, and for the same reason: without it :text would be written out as
|
|
1372
|
+
// an attribute named `text`. :class/:class. are control attrs too and are
|
|
1373
|
+
// already caught above
|
|
1374
|
+
else if (isControlAttr(key)) { /* handled after the walk */ }
|
|
1375
|
+
else if (key.startsWith(":")) {
|
|
1376
|
+
const name = key.slice(1)
|
|
1377
|
+
ops.push({ kind: "attr", path, name, expr: value || kebabToCamel(name) })
|
|
1378
|
+
} else el.setAttribute(key, value)
|
|
1379
|
+
}
|
|
1380
|
+
const attrsExpr = node.attrs[":attrs"]
|
|
1381
|
+
if (attrsExpr !== undefined) ops.push({ kind: "attrs", path, expr: attrsExpr })
|
|
1382
|
+
|
|
1383
|
+
if (classExpr !== undefined || toggles) {
|
|
1384
|
+
ops.push({ kind: "class", path, classExpr, toggles, staticClasses: new Set(classNames(node.attrs.class ?? "")) })
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
// :text replaces the element's content, and renderNode never renders the
|
|
1388
|
+
// children of an element carrying one. So the skeleton gives it none either:
|
|
1389
|
+
// a :text node is a leaf on both paths, whatever the source wrote inside it
|
|
1390
|
+
const textExpr = node.attrs[":text"]
|
|
1391
|
+
if (textExpr !== undefined) ops.push({ kind: "textContent", path, expr: textExpr })
|
|
1392
|
+
else node.children.forEach((child, index) => {
|
|
1393
|
+
if (typeof child === "string") {
|
|
1394
|
+
// an interpolated text node is a hole; the skeleton holds the empty node
|
|
1395
|
+
// it will be written into, so the child indices match either way
|
|
1396
|
+
if (child.includes("{{")) {
|
|
1397
|
+
ops.push({ kind: "text", path: [...path, index], parts: splitText(child) })
|
|
1398
|
+
el.appendChild(document.createTextNode(""))
|
|
1399
|
+
} else el.appendChild(document.createTextNode(child))
|
|
1400
|
+
return
|
|
1401
|
+
}
|
|
1402
|
+
el.appendChild(buildSkeleton(child, [...path, index], ops, tags))
|
|
1403
|
+
})
|
|
1404
|
+
|
|
1405
|
+
// after the children, because renderNode registers them there and for its
|
|
1406
|
+
// reason: :value on a <select> can only pick an <option> that already exists.
|
|
1407
|
+
// `ops` is flat and in registration order, and this is the recursive call's
|
|
1408
|
+
// tail, so a parent's form-state ops land after every op of every descendant -
|
|
1409
|
+
// which is exactly what renderNode's own recursion does
|
|
1410
|
+
const valueExpr = node.attrs[":value"]
|
|
1411
|
+
if (valueExpr !== undefined) ops.push({ kind: "value", path, expr: valueExpr })
|
|
1412
|
+
const checkedExpr = node.attrs[":checked"]
|
|
1413
|
+
if (checkedExpr !== undefined) ops.push({ kind: "checked", path, expr: checkedExpr })
|
|
1414
|
+
const selectedExpr = node.attrs[":selected"]
|
|
1415
|
+
if (selectedExpr !== undefined) ops.push({ kind: "selected", path, expr: selectedExpr })
|
|
1416
|
+
|
|
1417
|
+
return el
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// how many elements a subtree is worth cloning for. Below this the fixed cost
|
|
1421
|
+
// of the plan - the lookup, the tag check, the path walks - is the whole
|
|
1422
|
+
// saving: planning fragments of one or two elements measured as a wash at best
|
|
1423
|
+
// and a regression on a row whose only fragments are that small
|
|
1424
|
+
const MIN_SKELETON_ELEMENTS = 3
|
|
1425
|
+
|
|
1426
|
+
// children under a :text are not built by either path, so they are not elements
|
|
1427
|
+
// this threshold should be counting - a <p :text="v"> with two <span>s written
|
|
1428
|
+
// inside it is one element's worth of cloning, not three
|
|
1429
|
+
const countElements = (node: TemplateNode): number =>
|
|
1430
|
+
node.attrs[":text"] !== undefined
|
|
1431
|
+
? 1
|
|
1432
|
+
: 1 + node.children.reduce((total, child) => total + (typeof child === "string" ? 0 : countElements(child)), 0)
|
|
1433
|
+
|
|
1434
|
+
const skeletonPlans = new WeakMap<TemplateNode, SkeletonPlan | null>()
|
|
1435
|
+
|
|
1436
|
+
// A definition rendered ONCE pays for a plan it never reuses: +23% at
|
|
1437
|
+
// MIN_SKELETON_ELEMENTS, +11.5% at six elements, measured in
|
|
1438
|
+
// TODOS/2026-08-24.one-shot-render-measured.md. So the plan is built on the
|
|
1439
|
+
// SECOND render, not the first - a one-shot definition never builds one at all,
|
|
1440
|
+
// and a :each of 1,000 rows interprets row 1 and clones the other 999.
|
|
1441
|
+
//
|
|
1442
|
+
// The element count could not answer this. It is a proxy for "will this be
|
|
1443
|
+
// rendered again", and raising it to protect the one-shot case would take a
|
|
1444
|
+
// 9-element list row - which amortizes beautifully - off the clone path. This
|
|
1445
|
+
// keys on the thing itself.
|
|
1446
|
+
//
|
|
1447
|
+
// renderEach calls renderNode per row and renderNode calls planOf, so the list
|
|
1448
|
+
// case needs no special handling; it falls out.
|
|
1449
|
+
//
|
|
1450
|
+
// The third state is a WeakSet rather than a sentinel in the map, so the map's
|
|
1451
|
+
// type keeps saying what it means: absent is "never seen", null is "examined,
|
|
1452
|
+
// not plannable"
|
|
1453
|
+
const seenOnce = new WeakSet<TemplateNode>()
|
|
1454
|
+
|
|
1455
|
+
const planOf = (node: TemplateNode): SkeletonPlan | null => {
|
|
1456
|
+
const cached = skeletonPlans.get(node)
|
|
1457
|
+
if (cached !== undefined) return cached
|
|
1458
|
+
|
|
1459
|
+
// first sighting: interpret it, and decide nothing. Examining it here is the
|
|
1460
|
+
// cost the one-shot case was paying
|
|
1461
|
+
if (!seenOnce.has(node)) {
|
|
1462
|
+
seenOnce.add(node)
|
|
1463
|
+
return null
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
let plan: SkeletonPlan | null = null
|
|
1467
|
+
if (plannableNode(node) && countElements(node) >= MIN_SKELETON_ELEMENTS) {
|
|
1468
|
+
const ops: SkeletonOp[] = []
|
|
1469
|
+
const tags = new Set<string>()
|
|
1470
|
+
const skeleton = buildSkeleton(node, [], ops, tags)
|
|
1471
|
+
plan = { skeleton, ops, tags: Array.from(tags) }
|
|
1472
|
+
}
|
|
1473
|
+
skeletonPlans.set(node, plan)
|
|
1474
|
+
return plan
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
const atPath = (root: Node, path: number[]): Node => {
|
|
1478
|
+
let at = root
|
|
1479
|
+
for (let i = 0; i < path.length; i++) at = at.childNodes[path[i]]
|
|
1480
|
+
return at
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
const renderFromSkeleton = (plan: SkeletonPlan, scope: Record<string, any>, fx: EffectScope): Node => {
|
|
1484
|
+
const root = plan.skeleton.cloneNode(true) as Element
|
|
1485
|
+
|
|
1486
|
+
// ordered by how often a row actually carries the kind, not by when it was
|
|
1487
|
+
// added: this chain runs once per op per instance, so the four holes a
|
|
1488
|
+
// benchmark row is made of are matched before the five a form is
|
|
1489
|
+
for (const op of plan.ops) {
|
|
1490
|
+
const target = op.path.length === 0 ? root : atPath(root, op.path)
|
|
1491
|
+
|
|
1492
|
+
if (op.kind === "text") {
|
|
1493
|
+
const textNode = target as Text
|
|
1494
|
+
const parts = op.parts
|
|
1495
|
+
fx.effect(() => {
|
|
1496
|
+
const text = renderText(parts, scope)
|
|
1497
|
+
if (textNode.textContent !== text) textNode.textContent = text
|
|
1498
|
+
})
|
|
1499
|
+
} else if (op.kind === "event") {
|
|
1500
|
+
bindEvent(target as Element, op.attr, op.expr, scope)
|
|
1501
|
+
} else if (op.kind === "attr") {
|
|
1502
|
+
const el = target as Element
|
|
1503
|
+
const { name, expr } = op
|
|
1504
|
+
fx.effect(() => applyAttr(el, name, evalExpr(expr, scope)))
|
|
1505
|
+
} else if (op.kind === "class") {
|
|
1506
|
+
const el = target as Element
|
|
1507
|
+
const { classExpr, toggles, staticClasses } = op
|
|
1508
|
+
let bound: string[] = []
|
|
1509
|
+
fx.effect(() => {
|
|
1510
|
+
const next = classExpr !== undefined ? classNames(evalExpr(classExpr, scope)) : []
|
|
1511
|
+
toggles?.forEach(([name, expr]) => {
|
|
1512
|
+
if (evalExpr(expr, scope)) next.push(...classNames(name))
|
|
1513
|
+
})
|
|
1514
|
+
bound.forEach(name => {
|
|
1515
|
+
if (!next.includes(name) && !staticClasses.has(name)) el.classList.remove(name)
|
|
1516
|
+
})
|
|
1517
|
+
el.classList.add(...next)
|
|
1518
|
+
bound = next
|
|
1519
|
+
})
|
|
1520
|
+
} else if (op.kind === "attrs") {
|
|
1521
|
+
const el = target as Element
|
|
1522
|
+
const { expr } = op
|
|
1523
|
+
let boundKeys: string[] = []
|
|
1524
|
+
fx.effect(() => {
|
|
1525
|
+
boundKeys.forEach(key => el.removeAttribute(key))
|
|
1526
|
+
const bound = evalExpr(expr, scope)
|
|
1527
|
+
boundKeys = bound && typeof bound === "object" ? Object.keys(bound) : []
|
|
1528
|
+
boundKeys.forEach(key => applyAttr(el, key, bound[key]))
|
|
1529
|
+
})
|
|
1530
|
+
} else if (op.kind === "textContent") {
|
|
1531
|
+
const el = target as Element
|
|
1532
|
+
const { expr } = op
|
|
1533
|
+
fx.effect(() => { el.textContent = String(evalExpr(expr, scope) ?? "") })
|
|
1534
|
+
} else if (op.kind === "value") {
|
|
1535
|
+
// the property, not the attribute, and skipping a write that would not
|
|
1536
|
+
// change it - renderNode's reasons apply here unchanged
|
|
1537
|
+
const el = target as HTMLInputElement
|
|
1538
|
+
const { expr } = op
|
|
1539
|
+
fx.effect(() => {
|
|
1540
|
+
const value = String(evalExpr(expr, scope) ?? "")
|
|
1541
|
+
if (el.value !== value) el.value = value
|
|
1542
|
+
})
|
|
1543
|
+
} else if (op.kind === "checked") {
|
|
1544
|
+
const el = target as HTMLInputElement
|
|
1545
|
+
const { expr } = op
|
|
1546
|
+
fx.effect(() => { el.checked = !!evalExpr(expr, scope) })
|
|
1547
|
+
} else if (op.kind === "selected") {
|
|
1548
|
+
const el = target as HTMLOptionElement
|
|
1549
|
+
const { expr } = op
|
|
1550
|
+
fx.effect(() => { el.selected = !!evalExpr(expr, scope) })
|
|
1551
|
+
} else {
|
|
1552
|
+
// every kind is named above, so this is unreachable - and the assignment
|
|
1553
|
+
// is what makes the compiler say so. A kind added to SkeletonOp and
|
|
1554
|
+
// forgotten here is the exact failure this whole file is arranged to
|
|
1555
|
+
// prevent, and it is cheaper to catch it in tsc than in the corpus
|
|
1556
|
+
const unhandled: never = op
|
|
1557
|
+
void unhandled
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
return root
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1196
1564
|
const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: EffectScope, shadow: boolean): Node => {
|
|
1197
1565
|
// :with applies to the element's own bindings (@events, :attrs) and its
|
|
1198
1566
|
// whole subtree. On a :each element the item scope is already in place, so
|
|
@@ -1209,7 +1577,19 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
|
|
|
1209
1577
|
const componentKey = findComponentKey(scope, node.tag)
|
|
1210
1578
|
if (componentKey) return renderNestedComponent(componentKey, node, scope, fx, shadow)
|
|
1211
1579
|
|
|
1212
|
-
|
|
1580
|
+
// A planned subtree is cloned - unless a scope key captures one of its tags.
|
|
1581
|
+
// findComponentKey strips dashes and lowercases, and every PascalCase scope
|
|
1582
|
+
// key participates, so a variable named `Td` makes every <td> under it a
|
|
1583
|
+
// component and `Map`, `Data`, `Table`, `Form` and `Label` are all HTML tags
|
|
1584
|
+
// somebody might name a component after. "It is a known HTML tag" is not on
|
|
1585
|
+
// its own an answer; this is. It costs what the interpreted path already
|
|
1586
|
+
// pays - one findComponentKey per distinct tag, memoized per render pass
|
|
1587
|
+
if (debugFlags.cloneSkeletons) {
|
|
1588
|
+
const plan = planOf(node)
|
|
1589
|
+
if (plan && !plan.tags.some(tag => findComponentKey(scope, tag))) return renderFromSkeleton(plan, scope, fx)
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
const el = createFor(node)
|
|
1213
1593
|
|
|
1214
1594
|
// <UserCrad /> - written as a component (node.component), resolving to no
|
|
1215
1595
|
// component, and not an element either. Nothing else on the page can supply
|
|
@@ -1395,12 +1775,18 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
|
|
|
1395
1775
|
if ((el as HTMLInputElement).value !== value) (el as HTMLInputElement).value = value
|
|
1396
1776
|
})
|
|
1397
1777
|
}
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1778
|
+
// written out rather than looped over a literal array: the loop allocated the
|
|
1779
|
+
// array *and* its closure for every element rendered - 8,000 of each per
|
|
1780
|
+
// create1k, almost all of them to find nothing. Same reason the attribute
|
|
1781
|
+
// walk above is a `for...in` (TODOS/2026-08-23.where-the-create-time-goes.md)
|
|
1782
|
+
const checkedExpr = node.attrs[":checked"]
|
|
1783
|
+
if (checkedExpr !== undefined) {
|
|
1784
|
+
fx.effect(() => { (el as HTMLInputElement).checked = !!evalExpr(checkedExpr, scope) })
|
|
1785
|
+
}
|
|
1786
|
+
const selectedExpr = node.attrs[":selected"]
|
|
1787
|
+
if (selectedExpr !== undefined) {
|
|
1788
|
+
fx.effect(() => { (el as HTMLOptionElement).selected = !!evalExpr(selectedExpr, scope) })
|
|
1789
|
+
}
|
|
1404
1790
|
|
|
1405
1791
|
return el
|
|
1406
1792
|
}
|
|
@@ -1556,10 +1942,37 @@ const identifierIn = (text: string, name: string): boolean => {
|
|
|
1556
1942
|
return false
|
|
1557
1943
|
}
|
|
1558
1944
|
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1945
|
+
// Everything renderEach reads off the template and nothing else: the parsed
|
|
1946
|
+
// clause, how the key is read, the item node, and whether anything in the
|
|
1947
|
+
// subtree names a position. All of it is fixed by the source, and none of it
|
|
1948
|
+
// was cached - renderEach runs once per render of its parent, which for a
|
|
1949
|
+
// :each nested inside another is once per row of the outer one. `mentionsAny`
|
|
1950
|
+
// walks the whole item subtree, so that was a subtree walk per row per pass
|
|
1951
|
+
type EachPlan = {
|
|
1952
|
+
itemName: string
|
|
1953
|
+
atName: string | undefined
|
|
1954
|
+
listExpr: string
|
|
1955
|
+
keyExpr: string | undefined
|
|
1956
|
+
keyIsItem: boolean
|
|
1957
|
+
keyProp: string | undefined
|
|
1958
|
+
itemNode: TemplateNode
|
|
1959
|
+
readsPosition: boolean
|
|
1960
|
+
// can either loop name be mistaken for a component? Decided from the
|
|
1961
|
+
// template, so the item scope can be marked plain without being scanned
|
|
1962
|
+
namesComponent: boolean
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
const eachPlans = new WeakMap<TemplateNode, EachPlan | null>()
|
|
1966
|
+
|
|
1967
|
+
const eachPlanOf = (node: TemplateNode): EachPlan | null => {
|
|
1968
|
+
const cached = eachPlans.get(node)
|
|
1969
|
+
if (cached !== undefined) return cached
|
|
1562
1970
|
|
|
1971
|
+
const match = node.attrs[":each"].match(EACH_PATTERN)
|
|
1972
|
+
if (!match) {
|
|
1973
|
+
eachPlans.set(node, null)
|
|
1974
|
+
return null
|
|
1975
|
+
}
|
|
1563
1976
|
const [, itemName, atName, listExpr] = match
|
|
1564
1977
|
const keyExpr = node.attrs[":key"]
|
|
1565
1978
|
|
|
@@ -1592,6 +2005,28 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
|
|
|
1592
2005
|
const positionalNames = ["$index", ...(atName ? [atName] : [])]
|
|
1593
2006
|
const readsPosition = mentionsAny(itemNode, positionalNames)
|
|
1594
2007
|
|
|
2008
|
+
const namesComponent = /^[A-Z]/.test(itemName) || (atName !== undefined && /^[A-Z]/.test(atName))
|
|
2009
|
+
const plan: EachPlan = { itemName, atName, listExpr, keyExpr, keyIsItem, keyProp, itemNode, readsPosition, namesComponent }
|
|
2010
|
+
eachPlans.set(node, plan)
|
|
2011
|
+
return plan
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectScope, shadow: boolean): Node => {
|
|
2015
|
+
const plan = eachPlanOf(node)
|
|
2016
|
+
if (!plan) return document.createComment(`invalid :each expression "${node.attrs[":each"]}"`)
|
|
2017
|
+
|
|
2018
|
+
const { itemName, atName, listExpr, keyExpr, keyIsItem, keyProp, itemNode, readsPosition, namesComponent } = plan
|
|
2019
|
+
|
|
2020
|
+
// `:key="row.id"`, or the loop variable itself, is what a key almost always
|
|
2021
|
+
// is - and reading one needs neither a scope to resolve names against nor a
|
|
2022
|
+
// compiled expression, because the item is already in hand. A pass evaluates
|
|
2023
|
+
// one key per row, so a 1,000-row list paid 1,000 `with`-scoped calls through
|
|
2024
|
+
// the store proxy to discover that nothing had changed: most of the 37% of a
|
|
2025
|
+
// pass that goes on evaluating expressions
|
|
2026
|
+
// (TODOS/2026-08-23.where-the-list-operations-go.md). Anything else - a call,
|
|
2027
|
+
// an index, a deeper path, a name from the outer scope - still goes through
|
|
2028
|
+
// evalExpr, and so does a non-object item, which keeps every diagnostic a
|
|
2029
|
+
// property read of a null row would have raised
|
|
1595
2030
|
const anchor = document.createComment("each")
|
|
1596
2031
|
const wrapper = document.createDocumentFragment()
|
|
1597
2032
|
wrapper.appendChild(anchor)
|
|
@@ -1707,6 +2142,8 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
|
|
|
1707
2142
|
defineScopeVar(itemScope, itemName, item)
|
|
1708
2143
|
if (atName) defineScopeVar(itemScope, atName, at)
|
|
1709
2144
|
defineScopeVar(itemScope, "$index", index)
|
|
2145
|
+
// one WeakSet write per row against one Object.keys per element in it
|
|
2146
|
+
if (!namesComponent) plainScopes.add(itemScope)
|
|
1710
2147
|
const itemFx = createEffectScope(scope)
|
|
1711
2148
|
// bounds captured before the positioning pass inserts the entry: a
|
|
1712
2149
|
// component entry is a fragment, which empties on insertion (see boundsOf)
|
|
@@ -1753,6 +2190,168 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
|
|
|
1753
2190
|
return wrapper
|
|
1754
2191
|
}
|
|
1755
2192
|
|
|
2193
|
+
// the first sibling from `from` that is not indentation. The branches of a
|
|
2194
|
+
// chain are written on their own lines, so whitespace-only text sits between
|
|
2195
|
+
// them in the AST and must not break the chain up. One copy of the rule, used
|
|
2196
|
+
// by the renderer that groups a chain and by the validator that checks its
|
|
2197
|
+
// grammar - the two can never disagree about what "adjacent" means
|
|
2198
|
+
const nextSiblingAt = (nodes: (TemplateNode | string)[], from: number): number => {
|
|
2199
|
+
let at = from
|
|
2200
|
+
while (at < nodes.length && typeof nodes[at] === "string" && !(nodes[at] as string).trim()) at++
|
|
2201
|
+
return at
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
// The grammar of a conditional chain: `:if`, then any number of `:elseif`,
|
|
2205
|
+
// then an optional `:else`, on adjacent sibling elements. Both ways of getting
|
|
2206
|
+
// it wrong render *something*, which is why they need saying out loud:
|
|
2207
|
+
//
|
|
2208
|
+
// - two of the three on one element: the first in precedence order applies and
|
|
2209
|
+
// the rest are control attrs, so they are silently dropped
|
|
2210
|
+
// - a branch no chain claimed - no `:if` before it, or one separated from it by
|
|
2211
|
+
// an element (a `:each` row, a component tag, any sibling that isn't
|
|
2212
|
+
// whitespace): it falls through to renderNode, where `:elseif`/`:else` are
|
|
2213
|
+
// control attrs skipped by the attribute walk, and the element renders
|
|
2214
|
+
// **unconditionally**. That one had no diagnostic at all
|
|
2215
|
+
//
|
|
2216
|
+
// Reported once per definition, from the parse-time walk below
|
|
2217
|
+
const warnChainAttrs = (node: TemplateNode) => {
|
|
2218
|
+
const hasIf = ":if" in node.attrs
|
|
2219
|
+
const hasElseif = ":elseif" in node.attrs
|
|
2220
|
+
const hasElse = ":else" in node.attrs
|
|
2221
|
+
if ((hasIf ? 1 : 0) + (hasElseif ? 1 : 0) + (hasElse ? 1 : 0) < 2) return
|
|
2222
|
+
// allocated only on the way to a warning, never on the path that finds none
|
|
2223
|
+
const present = [hasIf ? ":if" : null, hasElseif ? ":elseif" : null, hasElse ? ":else" : null].filter(Boolean)
|
|
2224
|
+
console.warn(
|
|
2225
|
+
`jq79: ${present.join(" and ")} on the same <${node.tag}> - only ${present[0]} applies; ` +
|
|
2226
|
+
"the branches of a chain are sibling elements, one directive each"
|
|
2227
|
+
)
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
// `afterClosedChain`: the branch chain immediately before this node ended with
|
|
2231
|
+
// an `:else`, so this is a *second* one rather than a stray - which is the
|
|
2232
|
+
// difference between a useful message and a puzzling one ("continues no :if"
|
|
2233
|
+
// reads as nonsense when there is an :if two lines up)
|
|
2234
|
+
const warnOrphanBranch = (node: TemplateNode, afterClosedChain: boolean) => {
|
|
2235
|
+
const attr = ":elseif" in node.attrs ? ":elseif" : ":else"
|
|
2236
|
+
console.warn(
|
|
2237
|
+
afterClosedChain
|
|
2238
|
+
? `jq79: a second ${attr} on <${node.tag}> - the chain before it already ended with :else, ` +
|
|
2239
|
+
"which closes it. One :if, any number of :elseif, at most one :else"
|
|
2240
|
+
: `jq79: ${attr} on <${node.tag}> continues no :if - it renders unconditionally. ` +
|
|
2241
|
+
"A chain is :if, then :elseif, then :else, on adjacent siblings: anything but whitespace between them breaks it"
|
|
2242
|
+
)
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
// Checks one node list's chains, and every list below it, against that grammar.
|
|
2246
|
+
// Run once per definition from componentPartsFrom, not per render: a template
|
|
2247
|
+
// says what it says before any data exists, so a stray :else is reported when
|
|
2248
|
+
// the component is defined - once, whatever the list it sits in later renders
|
|
2249
|
+
// a thousand rows of, and even if it sits in a branch that never becomes
|
|
2250
|
+
// active. Rendering is left alone entirely; nothing below costs an instance
|
|
2251
|
+
// anything.
|
|
2252
|
+
//
|
|
2253
|
+
// The dispatch mirrors renderNodes' loop, because that is what decides which
|
|
2254
|
+
// node ends up a branch of what: a :each node is claimed before the chain
|
|
2255
|
+
// grouping ever sees it, which is exactly why it breaks a chain
|
|
2256
|
+
const validateChains = (nodes: (TemplateNode | string)[]) => {
|
|
2257
|
+
nodes.forEach(node => {
|
|
2258
|
+
if (typeof node !== "string") validateChains(node.children)
|
|
2259
|
+
})
|
|
2260
|
+
|
|
2261
|
+
// the chain that ended immediately before this point closed itself with an
|
|
2262
|
+
// :else, so a further branch here is a second one rather than a stray
|
|
2263
|
+
let afterClosedChain = false
|
|
2264
|
+
|
|
2265
|
+
for (let i = 0; i < nodes.length; ) {
|
|
2266
|
+
const node = nodes[i]
|
|
2267
|
+
|
|
2268
|
+
if (typeof node === "string") {
|
|
2269
|
+
if (node.trim()) afterClosedChain = false
|
|
2270
|
+
i++
|
|
2271
|
+
continue
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
// renderEach speaks for a :each element carrying a branch attribute of its
|
|
2275
|
+
// own, and says something more useful than the grammar would
|
|
2276
|
+
if (":each" in node.attrs) {
|
|
2277
|
+
afterClosedChain = false
|
|
2278
|
+
i++
|
|
2279
|
+
continue
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
warnChainAttrs(node)
|
|
2283
|
+
|
|
2284
|
+
if (":if" in node.attrs) {
|
|
2285
|
+
i++
|
|
2286
|
+
// the same walk renderNodes does, so the nodes claimed here are the ones
|
|
2287
|
+
// it will claim: any number of :elseif, then at most one :else
|
|
2288
|
+
const claim = (attr: string): TemplateNode | undefined => {
|
|
2289
|
+
const next = nextSiblingAt(nodes, i)
|
|
2290
|
+
const candidate = nodes[next]
|
|
2291
|
+
if (typeof candidate === "object" && attr in candidate.attrs) {
|
|
2292
|
+
i = next + 1
|
|
2293
|
+
return candidate
|
|
2294
|
+
}
|
|
2295
|
+
return undefined
|
|
2296
|
+
}
|
|
2297
|
+
// a claimed branch never reaches the check above - `:elseif :else` on one
|
|
2298
|
+
// element is claimed as an :elseif and its :else dropped, in silence
|
|
2299
|
+
for (let elseif = claim(":elseif"); elseif; elseif = claim(":elseif")) warnChainAttrs(elseif)
|
|
2300
|
+
const elseNode = claim(":else")
|
|
2301
|
+
if (elseNode) warnChainAttrs(elseNode)
|
|
2302
|
+
// an :else closes the chain. A chain that ended without one cannot be
|
|
2303
|
+
// followed by a stray at all - claim() would have taken it
|
|
2304
|
+
afterClosedChain = elseNode !== undefined
|
|
2305
|
+
continue
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
// no chain claimed this node, so a branch attribute on it is an orphan and
|
|
2309
|
+
// the element renders unconditionally
|
|
2310
|
+
if (":elseif" in node.attrs || ":else" in node.attrs) warnOrphanBranch(node, afterClosedChain)
|
|
2311
|
+
afterClosedChain = false
|
|
2312
|
+
i++
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
// The chain a `:if` node heads, and the index the sibling walk resumes at.
|
|
2317
|
+
// Both are fixed by the template - the node list is the same array on every
|
|
2318
|
+
// render - and renderNodes runs per instance, so a chain inside a :each row was
|
|
2319
|
+
// re-grouped, and its two arrays re-allocated, once per row per pass.
|
|
2320
|
+
// renderConditional only reads the branches, so one array serves every instance
|
|
2321
|
+
type Chain = { branches: ConditionalBranch[]; next: number }
|
|
2322
|
+
|
|
2323
|
+
const chains = new WeakMap<TemplateNode, Chain>()
|
|
2324
|
+
|
|
2325
|
+
const chainOf = (nodes: (TemplateNode | string)[], node: TemplateNode, from: number): Chain => {
|
|
2326
|
+
const cached = chains.get(node)
|
|
2327
|
+
if (cached) return cached
|
|
2328
|
+
|
|
2329
|
+
const branches: ConditionalBranch[] = [{ expr: node.attrs[":if"], node }]
|
|
2330
|
+
let at = from + 1
|
|
2331
|
+
// the whitespace between the branches is indentation and nothing else, so it
|
|
2332
|
+
// is skipped rather than rendered (nextSiblingAt): only one branch is ever in
|
|
2333
|
+
// the DOM, so there is nothing for it to be a space *between*
|
|
2334
|
+
const claim = (attr: string): TemplateNode | undefined => {
|
|
2335
|
+
const next = nextSiblingAt(nodes, at)
|
|
2336
|
+
const candidate = nodes[next]
|
|
2337
|
+
if (typeof candidate === "object" && attr in candidate.attrs) {
|
|
2338
|
+
at = next + 1
|
|
2339
|
+
return candidate
|
|
2340
|
+
}
|
|
2341
|
+
return undefined
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
for (let elseif = claim(":elseif"); elseif; elseif = claim(":elseif")) {
|
|
2345
|
+
branches.push({ expr: elseif.attrs[":elseif"], node: elseif })
|
|
2346
|
+
}
|
|
2347
|
+
const elseNode = claim(":else")
|
|
2348
|
+
if (elseNode) branches.push({ node: elseNode })
|
|
2349
|
+
|
|
2350
|
+
const chain: Chain = { branches, next: at }
|
|
2351
|
+
chains.set(node, chain)
|
|
2352
|
+
return chain
|
|
2353
|
+
}
|
|
2354
|
+
|
|
1756
2355
|
// renders a list of sibling template nodes (text + elements), grouping
|
|
1757
2356
|
// consecutive :if/:elseif/:else nodes into a single conditional block
|
|
1758
2357
|
// `into` renders straight into an element that is not in the document yet -
|
|
@@ -1802,31 +2401,9 @@ const renderNodes = <T extends ParentNode>(
|
|
|
1802
2401
|
}
|
|
1803
2402
|
|
|
1804
2403
|
if (":if" in node.attrs) {
|
|
1805
|
-
const
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
// the branches of a chain are siblings in the AST, but the template writes
|
|
1809
|
-
// them on their own lines - so the whitespace between them is indentation
|
|
1810
|
-
// and nothing else, and it's dropped rather than rendered: only one branch
|
|
1811
|
-
// is ever in the DOM, so there is nothing for it to be a space *between*
|
|
1812
|
-
const nextBranch = (attr: string): TemplateNode | undefined => {
|
|
1813
|
-
let next = i
|
|
1814
|
-
while (next < nodes.length && typeof nodes[next] === "string" && !(nodes[next] as string).trim()) next++
|
|
1815
|
-
const candidate = nodes[next]
|
|
1816
|
-
if (typeof candidate === "object" && attr in candidate.attrs) {
|
|
1817
|
-
i = next + 1
|
|
1818
|
-
return candidate
|
|
1819
|
-
}
|
|
1820
|
-
return undefined
|
|
1821
|
-
}
|
|
1822
|
-
|
|
1823
|
-
for (let elseif = nextBranch(":elseif"); elseif; elseif = nextBranch(":elseif")) {
|
|
1824
|
-
branches.push({ expr: elseif.attrs[":elseif"], node: elseif })
|
|
1825
|
-
}
|
|
1826
|
-
const elseNode = nextBranch(":else")
|
|
1827
|
-
if (elseNode) branches.push({ node: elseNode })
|
|
1828
|
-
|
|
1829
|
-
fragment.appendChild(renderConditional(branches, scope, fx, shadow))
|
|
2404
|
+
const chain = chainOf(nodes, node, i)
|
|
2405
|
+
fragment.appendChild(renderConditional(chain.branches, scope, fx, shadow))
|
|
2406
|
+
i = chain.next
|
|
1830
2407
|
continue
|
|
1831
2408
|
}
|
|
1832
2409
|
|
|
@@ -2194,6 +2771,10 @@ const componentPartsFrom = (elements: Element[], hashSource: string): ComponentP
|
|
|
2194
2771
|
})
|
|
2195
2772
|
}
|
|
2196
2773
|
|
|
2774
|
+
// the template says what it says before any data exists, so its conditional
|
|
2775
|
+
// chains are checked here, once per definition
|
|
2776
|
+
validateChains(template)
|
|
2777
|
+
|
|
2197
2778
|
return { template, scripts, styles }
|
|
2198
2779
|
}
|
|
2199
2780
|
|
|
@@ -2884,6 +3465,26 @@ export class Component79 {
|
|
|
2884
3465
|
//
|
|
2885
3466
|
// Component79.fetch("./app.html").mount("main")
|
|
2886
3467
|
// const app = await Component79.fetch("./app.html")
|
|
3468
|
+
// Reads the debug flags, and sets the ones it is given:
|
|
3469
|
+
//
|
|
3470
|
+
// Component79.debug() // what is on right now
|
|
3471
|
+
// Component79.debug({ cloneSkeletons: false }) // turn one off
|
|
3472
|
+
//
|
|
3473
|
+
// Returns the flags as they stand after the call, so a caller can put them
|
|
3474
|
+
// back. Global to the module, not per component: these switch how the
|
|
3475
|
+
// renderer works, and a page rendering two ways at once is the one state
|
|
3476
|
+
// nobody could debug
|
|
3477
|
+
static debug(options?: Partial<DebugFlags>): DebugFlags {
|
|
3478
|
+
if (options) {
|
|
3479
|
+
for (const key in options) {
|
|
3480
|
+
const value = options[key as keyof DebugFlags]
|
|
3481
|
+
if (typeof value === "boolean") debugFlags[key as keyof DebugFlags] = value
|
|
3482
|
+
else console.warn(`jq79: Component79.debug ignored "${key}" - the flags are booleans, and the ones it knows are: ${Object.keys(debugFlags).join(", ")}`)
|
|
3483
|
+
}
|
|
3484
|
+
}
|
|
3485
|
+
return { ...debugFlags }
|
|
3486
|
+
}
|
|
3487
|
+
|
|
2887
3488
|
static fetch(url: string): PendingComponent79 {
|
|
2888
3489
|
if (Array.isArray(url)) throw new TypeError("Component79.fetch takes one URL; use fetchAll for an array")
|
|
2889
3490
|
return new PendingComponent79(fetchComponent(url))
|