jq79 0.6.2 → 0.6.3
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 +4 -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 +1 -1
- package/src/jq79.ts +476 -34
package/package.json
CHANGED
package/src/jq79.ts
CHANGED
|
@@ -502,6 +502,18 @@ const scanComponentKey = (scope: Record<string, any>, tag: string): string | nul
|
|
|
502
502
|
let tagMemo: Map<string, string | null> | null = null
|
|
503
503
|
let memoBase: object | null = null
|
|
504
504
|
|
|
505
|
+
// Scope objects known to declare no PascalCase key of their own, so the walk
|
|
506
|
+
// below can skip them without calling Object.keys - which allocates an array
|
|
507
|
+
// and scans it, per element, per row. An :each item scope holds `item`,
|
|
508
|
+
// `$index` and maybe the `, at` name, and whether any of those can be a
|
|
509
|
+
// component name is decided by the template, once (see EachPlan).
|
|
510
|
+
//
|
|
511
|
+
// Only scopes jq79 creates and never adds a key to go in here. A store must
|
|
512
|
+
// never: a setup script's `const Row = await $import(...)` arrives as a new key
|
|
513
|
+
// after the template has already rendered, which is the whole reason the tag
|
|
514
|
+
// memo lives for exactly one pass
|
|
515
|
+
const plainScopes = new WeakSet<object>()
|
|
516
|
+
|
|
505
517
|
// opened and closed by hand rather than by a wrapper taking a callback: a
|
|
506
518
|
// component that renders itself through :each stacks one renderEach per level,
|
|
507
519
|
// and a callback would add a frame to each of them. The cyclic-component test
|
|
@@ -532,6 +544,7 @@ const findComponentKey = (scope: Record<string, any>, tag: string): string | nul
|
|
|
532
544
|
tagMemo.set(tag, key)
|
|
533
545
|
return key
|
|
534
546
|
}
|
|
547
|
+
if (plainScopes.has(obj)) continue
|
|
535
548
|
for (const key of Object.keys(obj)) {
|
|
536
549
|
if (/^[A-Z]/.test(key) && key.replace(/-/g, "").toLowerCase() === normalized) return key
|
|
537
550
|
}
|
|
@@ -1193,6 +1206,202 @@ const applyAttr = (el: Element, name: string, value: any) => {
|
|
|
1193
1206
|
// normally. :if/:elseif/:else/:each are handled by renderNodes, which decides
|
|
1194
1207
|
// *whether*/*how many times* a node is rendered before calling this. Tags
|
|
1195
1208
|
// matching a PascalCase scope variable render as nested components instead
|
|
1209
|
+
|
|
1210
|
+
// ---------------------------------------------------------------------------
|
|
1211
|
+
// Cloning a fixed shape instead of deriving it per instance.
|
|
1212
|
+
//
|
|
1213
|
+
// renderNode asks the same questions of the same AST node for every instance of
|
|
1214
|
+
// it: is this a slot, a component, an unknown tag; which of these attributes is
|
|
1215
|
+
// a directive; split this text on `{{`. For a :each of 1,000 rows that is ~25
|
|
1216
|
+
// questions per element per row whose answers were fixed by the source text.
|
|
1217
|
+
// Where a subtree's *shape* is fixed - the elements, their static attributes and
|
|
1218
|
+
// their nesting never vary, only the values bound into them - the shape is built
|
|
1219
|
+
// once per definition into a detached skeleton, and each instance is one
|
|
1220
|
+
// cloneNode plus a walk to each binding point.
|
|
1221
|
+
//
|
|
1222
|
+
// Worth -20 to -49% of create1k depending on how much fixed structure a row
|
|
1223
|
+
// has, and nothing at all on a row that has none. Measured, with the method and
|
|
1224
|
+
// the caveats, in TODOS/2026-08-24.clone-skeletons-measured.md.
|
|
1225
|
+
//
|
|
1226
|
+
// Two rules keep this from becoming the bug it could be:
|
|
1227
|
+
//
|
|
1228
|
+
// 1. **The holes are an allowlist, never a denylist.** `plannableAttr` names
|
|
1229
|
+
// the four things a skeleton knows how to fill; every other attribute makes
|
|
1230
|
+
// the subtree unplannable. So a directive added to renderNode later is
|
|
1231
|
+
// *slower* until somebody teaches it here - never silently mis-rendered,
|
|
1232
|
+
// which is the failure a second render path invites.
|
|
1233
|
+
// 2. **The interpreted path stays the fallback for everything else**, including
|
|
1234
|
+
// every tag that could still turn into a component. The upgrade watch and
|
|
1235
|
+
// the unresolved-component throw are not reimplemented here; they are never
|
|
1236
|
+
// reached from here.
|
|
1237
|
+
//
|
|
1238
|
+
// tests/skeleton.test.ts renders a corpus both ways and diffs the DOM, which is
|
|
1239
|
+
// what makes rule 1 enforceable rather than a promise.
|
|
1240
|
+
// ---------------------------------------------------------------------------
|
|
1241
|
+
|
|
1242
|
+
// Flipping this must never change what renders, only how - which is what
|
|
1243
|
+
// tests/skeleton.test.ts exists to keep true. It is on, and switchable through
|
|
1244
|
+
// `Component79.debug({ cloneSkeletons: false })`, because a second render path
|
|
1245
|
+
// is the kind of change that wants an off switch a user can reach without a
|
|
1246
|
+
// rebuild: a page that renders wrong is a bug report either way, but one whose
|
|
1247
|
+
// reporter can say "it goes away with cloning off" is a bug report that names
|
|
1248
|
+
// the file
|
|
1249
|
+
const debugFlags: DebugFlags = { cloneSkeletons: true }
|
|
1250
|
+
|
|
1251
|
+
// What `Component79.debug()` can switch. One flag today; the shape is an object
|
|
1252
|
+
// so the next one does not change the call
|
|
1253
|
+
export type DebugFlags = {
|
|
1254
|
+
// build a fixed-shape subtree by cloning a skeleton made once per definition,
|
|
1255
|
+
// instead of walking the AST for every instance of it. Off means every
|
|
1256
|
+
// element goes through renderNode, exactly as before this existed
|
|
1257
|
+
cloneSkeletons: boolean
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// The four things a hole can be, in the order renderNode registers them.
|
|
1261
|
+
// A `:` attribute with a dot in it is rejected wholesale except `:class.`:
|
|
1262
|
+
// `:model.`, `:props.`, `:slot.` and `:html.allowed` all live in that shape, and
|
|
1263
|
+
// so would the next directive family somebody invents
|
|
1264
|
+
const plannableAttr = (name: string): boolean => {
|
|
1265
|
+
if (name.startsWith("@")) return true
|
|
1266
|
+
if (name === ":class") return true
|
|
1267
|
+
if (name.startsWith(":class.")) return true
|
|
1268
|
+
if (!name.startsWith(":")) return name !== COMPONENT_TAG_ATTR // a static attribute
|
|
1269
|
+
return !isControlAttr(name) && !name.includes(".")
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
const plannableNode = (node: TemplateNode): boolean => {
|
|
1273
|
+
if (node.component || node.tag.includes("-")) return false
|
|
1274
|
+
if (isSlotTag(node.tag) || node.tag === "template") return false
|
|
1275
|
+
// an unknown tag may still become a component, and <svg> is one of them:
|
|
1276
|
+
// createElement builds SVG names in the HTML namespace (see renderNode)
|
|
1277
|
+
if (document.createElement(node.tag) instanceof HTMLUnknownElement) return false
|
|
1278
|
+
for (const key in node.attrs) if (!plannableAttr(key)) return false
|
|
1279
|
+
return node.children.every(child => typeof child === "string" || plannableNode(child))
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// A hole, and the path from the skeleton root to the node it fills: child
|
|
1283
|
+
// indices rather than a query, resolved by walking childNodes. The AST keeps
|
|
1284
|
+
// whitespace text nodes on purpose, and the skeleton keeps them too, so the
|
|
1285
|
+
// indices line up on both sides by construction
|
|
1286
|
+
type SkeletonOp =
|
|
1287
|
+
| { kind: "text"; path: number[]; parts: TextPart[] }
|
|
1288
|
+
| { kind: "event"; path: number[]; attr: string; expr: string }
|
|
1289
|
+
| { kind: "attr"; path: number[]; name: string; expr: string }
|
|
1290
|
+
| { kind: "class"; path: number[]; classExpr?: string; toggles: [string, string][] | null; staticClasses: Set<string> }
|
|
1291
|
+
|
|
1292
|
+
type SkeletonPlan = { skeleton: Element; ops: SkeletonOp[]; tags: string[] }
|
|
1293
|
+
|
|
1294
|
+
// mirrors renderNode's own order: the attribute walk (events and attribute
|
|
1295
|
+
// bindings as they appear), then :class, then the children. Effects run in
|
|
1296
|
+
// registration order, so this is not cosmetic
|
|
1297
|
+
const buildSkeleton = (node: TemplateNode, path: number[], ops: SkeletonOp[], tags: Set<string>): Element => {
|
|
1298
|
+
tags.add(node.tag)
|
|
1299
|
+
const el = document.createElement(node.tag)
|
|
1300
|
+
|
|
1301
|
+
let classExpr: string | undefined
|
|
1302
|
+
let toggles: [string, string][] | null = null
|
|
1303
|
+
for (const key in node.attrs) {
|
|
1304
|
+
const value = node.attrs[key]
|
|
1305
|
+
if (key.startsWith("@")) ops.push({ kind: "event", path, attr: key, expr: value })
|
|
1306
|
+
else if (key === ":class") classExpr = value
|
|
1307
|
+
else if (key.startsWith(":class.")) (toggles ??= []).push([key.slice(":class.".length), value])
|
|
1308
|
+
else if (key.startsWith(":")) {
|
|
1309
|
+
const name = key.slice(1)
|
|
1310
|
+
ops.push({ kind: "attr", path, name, expr: value || kebabToCamel(name) })
|
|
1311
|
+
} else el.setAttribute(key, value)
|
|
1312
|
+
}
|
|
1313
|
+
if (classExpr !== undefined || toggles) {
|
|
1314
|
+
ops.push({ kind: "class", path, classExpr, toggles, staticClasses: new Set(classNames(node.attrs.class ?? "")) })
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
node.children.forEach((child, index) => {
|
|
1318
|
+
if (typeof child === "string") {
|
|
1319
|
+
// an interpolated text node is a hole; the skeleton holds the empty node
|
|
1320
|
+
// it will be written into, so the child indices match either way
|
|
1321
|
+
if (child.includes("{{")) {
|
|
1322
|
+
ops.push({ kind: "text", path: [...path, index], parts: splitText(child) })
|
|
1323
|
+
el.appendChild(document.createTextNode(""))
|
|
1324
|
+
} else el.appendChild(document.createTextNode(child))
|
|
1325
|
+
return
|
|
1326
|
+
}
|
|
1327
|
+
el.appendChild(buildSkeleton(child, [...path, index], ops, tags))
|
|
1328
|
+
})
|
|
1329
|
+
|
|
1330
|
+
return el
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// how many elements a subtree is worth cloning for. Below this the fixed cost
|
|
1334
|
+
// of the plan - the lookup, the tag check, the path walks - is the whole
|
|
1335
|
+
// saving: planning fragments of one or two elements measured as a wash at best
|
|
1336
|
+
// and a regression on a row whose only fragments are that small
|
|
1337
|
+
const MIN_SKELETON_ELEMENTS = 3
|
|
1338
|
+
|
|
1339
|
+
const countElements = (node: TemplateNode): number =>
|
|
1340
|
+
1 + node.children.reduce((total, child) => total + (typeof child === "string" ? 0 : countElements(child)), 0)
|
|
1341
|
+
|
|
1342
|
+
const skeletonPlans = new WeakMap<TemplateNode, SkeletonPlan | null>()
|
|
1343
|
+
|
|
1344
|
+
const planOf = (node: TemplateNode): SkeletonPlan | null => {
|
|
1345
|
+
const cached = skeletonPlans.get(node)
|
|
1346
|
+
if (cached !== undefined) return cached
|
|
1347
|
+
|
|
1348
|
+
let plan: SkeletonPlan | null = null
|
|
1349
|
+
if (plannableNode(node) && countElements(node) >= MIN_SKELETON_ELEMENTS) {
|
|
1350
|
+
const ops: SkeletonOp[] = []
|
|
1351
|
+
const tags = new Set<string>()
|
|
1352
|
+
const skeleton = buildSkeleton(node, [], ops, tags)
|
|
1353
|
+
plan = { skeleton, ops, tags: Array.from(tags) }
|
|
1354
|
+
}
|
|
1355
|
+
skeletonPlans.set(node, plan)
|
|
1356
|
+
return plan
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
const atPath = (root: Node, path: number[]): Node => {
|
|
1360
|
+
let at = root
|
|
1361
|
+
for (let i = 0; i < path.length; i++) at = at.childNodes[path[i]]
|
|
1362
|
+
return at
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
const renderFromSkeleton = (plan: SkeletonPlan, scope: Record<string, any>, fx: EffectScope): Node => {
|
|
1366
|
+
const root = plan.skeleton.cloneNode(true) as Element
|
|
1367
|
+
|
|
1368
|
+
for (const op of plan.ops) {
|
|
1369
|
+
const target = op.path.length === 0 ? root : atPath(root, op.path)
|
|
1370
|
+
|
|
1371
|
+
if (op.kind === "text") {
|
|
1372
|
+
const textNode = target as Text
|
|
1373
|
+
const parts = op.parts
|
|
1374
|
+
fx.effect(() => {
|
|
1375
|
+
const text = renderText(parts, scope)
|
|
1376
|
+
if (textNode.textContent !== text) textNode.textContent = text
|
|
1377
|
+
})
|
|
1378
|
+
} else if (op.kind === "event") {
|
|
1379
|
+
bindEvent(target as Element, op.attr, op.expr, scope)
|
|
1380
|
+
} else if (op.kind === "attr") {
|
|
1381
|
+
const el = target as Element
|
|
1382
|
+
const { name, expr } = op
|
|
1383
|
+
fx.effect(() => applyAttr(el, name, evalExpr(expr, scope)))
|
|
1384
|
+
} else {
|
|
1385
|
+
const el = target as Element
|
|
1386
|
+
const { classExpr, toggles, staticClasses } = op
|
|
1387
|
+
let bound: string[] = []
|
|
1388
|
+
fx.effect(() => {
|
|
1389
|
+
const next = classExpr !== undefined ? classNames(evalExpr(classExpr, scope)) : []
|
|
1390
|
+
toggles?.forEach(([name, expr]) => {
|
|
1391
|
+
if (evalExpr(expr, scope)) next.push(...classNames(name))
|
|
1392
|
+
})
|
|
1393
|
+
bound.forEach(name => {
|
|
1394
|
+
if (!next.includes(name) && !staticClasses.has(name)) el.classList.remove(name)
|
|
1395
|
+
})
|
|
1396
|
+
el.classList.add(...next)
|
|
1397
|
+
bound = next
|
|
1398
|
+
})
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
return root
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1196
1405
|
const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: EffectScope, shadow: boolean): Node => {
|
|
1197
1406
|
// :with applies to the element's own bindings (@events, :attrs) and its
|
|
1198
1407
|
// whole subtree. On a :each element the item scope is already in place, so
|
|
@@ -1209,6 +1418,18 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
|
|
|
1209
1418
|
const componentKey = findComponentKey(scope, node.tag)
|
|
1210
1419
|
if (componentKey) return renderNestedComponent(componentKey, node, scope, fx, shadow)
|
|
1211
1420
|
|
|
1421
|
+
// A planned subtree is cloned - unless a scope key captures one of its tags.
|
|
1422
|
+
// findComponentKey strips dashes and lowercases, and every PascalCase scope
|
|
1423
|
+
// key participates, so a variable named `Td` makes every <td> under it a
|
|
1424
|
+
// component and `Map`, `Data`, `Table`, `Form` and `Label` are all HTML tags
|
|
1425
|
+
// somebody might name a component after. "It is a known HTML tag" is not on
|
|
1426
|
+
// its own an answer; this is. It costs what the interpreted path already
|
|
1427
|
+
// pays - one findComponentKey per distinct tag, memoized per render pass
|
|
1428
|
+
if (debugFlags.cloneSkeletons) {
|
|
1429
|
+
const plan = planOf(node)
|
|
1430
|
+
if (plan && !plan.tags.some(tag => findComponentKey(scope, tag))) return renderFromSkeleton(plan, scope, fx)
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1212
1433
|
const el = document.createElement(node.tag)
|
|
1213
1434
|
|
|
1214
1435
|
// <UserCrad /> - written as a component (node.component), resolving to no
|
|
@@ -1395,12 +1616,18 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
|
|
|
1395
1616
|
if ((el as HTMLInputElement).value !== value) (el as HTMLInputElement).value = value
|
|
1396
1617
|
})
|
|
1397
1618
|
}
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1619
|
+
// written out rather than looped over a literal array: the loop allocated the
|
|
1620
|
+
// array *and* its closure for every element rendered - 8,000 of each per
|
|
1621
|
+
// create1k, almost all of them to find nothing. Same reason the attribute
|
|
1622
|
+
// walk above is a `for...in` (TODOS/2026-08-23.where-the-create-time-goes.md)
|
|
1623
|
+
const checkedExpr = node.attrs[":checked"]
|
|
1624
|
+
if (checkedExpr !== undefined) {
|
|
1625
|
+
fx.effect(() => { (el as HTMLInputElement).checked = !!evalExpr(checkedExpr, scope) })
|
|
1626
|
+
}
|
|
1627
|
+
const selectedExpr = node.attrs[":selected"]
|
|
1628
|
+
if (selectedExpr !== undefined) {
|
|
1629
|
+
fx.effect(() => { (el as HTMLOptionElement).selected = !!evalExpr(selectedExpr, scope) })
|
|
1630
|
+
}
|
|
1404
1631
|
|
|
1405
1632
|
return el
|
|
1406
1633
|
}
|
|
@@ -1556,10 +1783,37 @@ const identifierIn = (text: string, name: string): boolean => {
|
|
|
1556
1783
|
return false
|
|
1557
1784
|
}
|
|
1558
1785
|
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1786
|
+
// Everything renderEach reads off the template and nothing else: the parsed
|
|
1787
|
+
// clause, how the key is read, the item node, and whether anything in the
|
|
1788
|
+
// subtree names a position. All of it is fixed by the source, and none of it
|
|
1789
|
+
// was cached - renderEach runs once per render of its parent, which for a
|
|
1790
|
+
// :each nested inside another is once per row of the outer one. `mentionsAny`
|
|
1791
|
+
// walks the whole item subtree, so that was a subtree walk per row per pass
|
|
1792
|
+
type EachPlan = {
|
|
1793
|
+
itemName: string
|
|
1794
|
+
atName: string | undefined
|
|
1795
|
+
listExpr: string
|
|
1796
|
+
keyExpr: string | undefined
|
|
1797
|
+
keyIsItem: boolean
|
|
1798
|
+
keyProp: string | undefined
|
|
1799
|
+
itemNode: TemplateNode
|
|
1800
|
+
readsPosition: boolean
|
|
1801
|
+
// can either loop name be mistaken for a component? Decided from the
|
|
1802
|
+
// template, so the item scope can be marked plain without being scanned
|
|
1803
|
+
namesComponent: boolean
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
const eachPlans = new WeakMap<TemplateNode, EachPlan | null>()
|
|
1807
|
+
|
|
1808
|
+
const eachPlanOf = (node: TemplateNode): EachPlan | null => {
|
|
1809
|
+
const cached = eachPlans.get(node)
|
|
1810
|
+
if (cached !== undefined) return cached
|
|
1562
1811
|
|
|
1812
|
+
const match = node.attrs[":each"].match(EACH_PATTERN)
|
|
1813
|
+
if (!match) {
|
|
1814
|
+
eachPlans.set(node, null)
|
|
1815
|
+
return null
|
|
1816
|
+
}
|
|
1563
1817
|
const [, itemName, atName, listExpr] = match
|
|
1564
1818
|
const keyExpr = node.attrs[":key"]
|
|
1565
1819
|
|
|
@@ -1592,6 +1846,28 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
|
|
|
1592
1846
|
const positionalNames = ["$index", ...(atName ? [atName] : [])]
|
|
1593
1847
|
const readsPosition = mentionsAny(itemNode, positionalNames)
|
|
1594
1848
|
|
|
1849
|
+
const namesComponent = /^[A-Z]/.test(itemName) || (atName !== undefined && /^[A-Z]/.test(atName))
|
|
1850
|
+
const plan: EachPlan = { itemName, atName, listExpr, keyExpr, keyIsItem, keyProp, itemNode, readsPosition, namesComponent }
|
|
1851
|
+
eachPlans.set(node, plan)
|
|
1852
|
+
return plan
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectScope, shadow: boolean): Node => {
|
|
1856
|
+
const plan = eachPlanOf(node)
|
|
1857
|
+
if (!plan) return document.createComment(`invalid :each expression "${node.attrs[":each"]}"`)
|
|
1858
|
+
|
|
1859
|
+
const { itemName, atName, listExpr, keyExpr, keyIsItem, keyProp, itemNode, readsPosition, namesComponent } = plan
|
|
1860
|
+
|
|
1861
|
+
// `:key="row.id"`, or the loop variable itself, is what a key almost always
|
|
1862
|
+
// is - and reading one needs neither a scope to resolve names against nor a
|
|
1863
|
+
// compiled expression, because the item is already in hand. A pass evaluates
|
|
1864
|
+
// one key per row, so a 1,000-row list paid 1,000 `with`-scoped calls through
|
|
1865
|
+
// the store proxy to discover that nothing had changed: most of the 37% of a
|
|
1866
|
+
// pass that goes on evaluating expressions
|
|
1867
|
+
// (TODOS/2026-08-23.where-the-list-operations-go.md). Anything else - a call,
|
|
1868
|
+
// an index, a deeper path, a name from the outer scope - still goes through
|
|
1869
|
+
// evalExpr, and so does a non-object item, which keeps every diagnostic a
|
|
1870
|
+
// property read of a null row would have raised
|
|
1595
1871
|
const anchor = document.createComment("each")
|
|
1596
1872
|
const wrapper = document.createDocumentFragment()
|
|
1597
1873
|
wrapper.appendChild(anchor)
|
|
@@ -1707,6 +1983,8 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
|
|
|
1707
1983
|
defineScopeVar(itemScope, itemName, item)
|
|
1708
1984
|
if (atName) defineScopeVar(itemScope, atName, at)
|
|
1709
1985
|
defineScopeVar(itemScope, "$index", index)
|
|
1986
|
+
// one WeakSet write per row against one Object.keys per element in it
|
|
1987
|
+
if (!namesComponent) plainScopes.add(itemScope)
|
|
1710
1988
|
const itemFx = createEffectScope(scope)
|
|
1711
1989
|
// bounds captured before the positioning pass inserts the entry: a
|
|
1712
1990
|
// component entry is a fragment, which empties on insertion (see boundsOf)
|
|
@@ -1753,6 +2031,168 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
|
|
|
1753
2031
|
return wrapper
|
|
1754
2032
|
}
|
|
1755
2033
|
|
|
2034
|
+
// the first sibling from `from` that is not indentation. The branches of a
|
|
2035
|
+
// chain are written on their own lines, so whitespace-only text sits between
|
|
2036
|
+
// them in the AST and must not break the chain up. One copy of the rule, used
|
|
2037
|
+
// by the renderer that groups a chain and by the validator that checks its
|
|
2038
|
+
// grammar - the two can never disagree about what "adjacent" means
|
|
2039
|
+
const nextSiblingAt = (nodes: (TemplateNode | string)[], from: number): number => {
|
|
2040
|
+
let at = from
|
|
2041
|
+
while (at < nodes.length && typeof nodes[at] === "string" && !(nodes[at] as string).trim()) at++
|
|
2042
|
+
return at
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
// The grammar of a conditional chain: `:if`, then any number of `:elseif`,
|
|
2046
|
+
// then an optional `:else`, on adjacent sibling elements. Both ways of getting
|
|
2047
|
+
// it wrong render *something*, which is why they need saying out loud:
|
|
2048
|
+
//
|
|
2049
|
+
// - two of the three on one element: the first in precedence order applies and
|
|
2050
|
+
// the rest are control attrs, so they are silently dropped
|
|
2051
|
+
// - a branch no chain claimed - no `:if` before it, or one separated from it by
|
|
2052
|
+
// an element (a `:each` row, a component tag, any sibling that isn't
|
|
2053
|
+
// whitespace): it falls through to renderNode, where `:elseif`/`:else` are
|
|
2054
|
+
// control attrs skipped by the attribute walk, and the element renders
|
|
2055
|
+
// **unconditionally**. That one had no diagnostic at all
|
|
2056
|
+
//
|
|
2057
|
+
// Reported once per definition, from the parse-time walk below
|
|
2058
|
+
const warnChainAttrs = (node: TemplateNode) => {
|
|
2059
|
+
const hasIf = ":if" in node.attrs
|
|
2060
|
+
const hasElseif = ":elseif" in node.attrs
|
|
2061
|
+
const hasElse = ":else" in node.attrs
|
|
2062
|
+
if ((hasIf ? 1 : 0) + (hasElseif ? 1 : 0) + (hasElse ? 1 : 0) < 2) return
|
|
2063
|
+
// allocated only on the way to a warning, never on the path that finds none
|
|
2064
|
+
const present = [hasIf ? ":if" : null, hasElseif ? ":elseif" : null, hasElse ? ":else" : null].filter(Boolean)
|
|
2065
|
+
console.warn(
|
|
2066
|
+
`jq79: ${present.join(" and ")} on the same <${node.tag}> - only ${present[0]} applies; ` +
|
|
2067
|
+
"the branches of a chain are sibling elements, one directive each"
|
|
2068
|
+
)
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
// `afterClosedChain`: the branch chain immediately before this node ended with
|
|
2072
|
+
// an `:else`, so this is a *second* one rather than a stray - which is the
|
|
2073
|
+
// difference between a useful message and a puzzling one ("continues no :if"
|
|
2074
|
+
// reads as nonsense when there is an :if two lines up)
|
|
2075
|
+
const warnOrphanBranch = (node: TemplateNode, afterClosedChain: boolean) => {
|
|
2076
|
+
const attr = ":elseif" in node.attrs ? ":elseif" : ":else"
|
|
2077
|
+
console.warn(
|
|
2078
|
+
afterClosedChain
|
|
2079
|
+
? `jq79: a second ${attr} on <${node.tag}> - the chain before it already ended with :else, ` +
|
|
2080
|
+
"which closes it. One :if, any number of :elseif, at most one :else"
|
|
2081
|
+
: `jq79: ${attr} on <${node.tag}> continues no :if - it renders unconditionally. ` +
|
|
2082
|
+
"A chain is :if, then :elseif, then :else, on adjacent siblings: anything but whitespace between them breaks it"
|
|
2083
|
+
)
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
// Checks one node list's chains, and every list below it, against that grammar.
|
|
2087
|
+
// Run once per definition from componentPartsFrom, not per render: a template
|
|
2088
|
+
// says what it says before any data exists, so a stray :else is reported when
|
|
2089
|
+
// the component is defined - once, whatever the list it sits in later renders
|
|
2090
|
+
// a thousand rows of, and even if it sits in a branch that never becomes
|
|
2091
|
+
// active. Rendering is left alone entirely; nothing below costs an instance
|
|
2092
|
+
// anything.
|
|
2093
|
+
//
|
|
2094
|
+
// The dispatch mirrors renderNodes' loop, because that is what decides which
|
|
2095
|
+
// node ends up a branch of what: a :each node is claimed before the chain
|
|
2096
|
+
// grouping ever sees it, which is exactly why it breaks a chain
|
|
2097
|
+
const validateChains = (nodes: (TemplateNode | string)[]) => {
|
|
2098
|
+
nodes.forEach(node => {
|
|
2099
|
+
if (typeof node !== "string") validateChains(node.children)
|
|
2100
|
+
})
|
|
2101
|
+
|
|
2102
|
+
// the chain that ended immediately before this point closed itself with an
|
|
2103
|
+
// :else, so a further branch here is a second one rather than a stray
|
|
2104
|
+
let afterClosedChain = false
|
|
2105
|
+
|
|
2106
|
+
for (let i = 0; i < nodes.length; ) {
|
|
2107
|
+
const node = nodes[i]
|
|
2108
|
+
|
|
2109
|
+
if (typeof node === "string") {
|
|
2110
|
+
if (node.trim()) afterClosedChain = false
|
|
2111
|
+
i++
|
|
2112
|
+
continue
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
// renderEach speaks for a :each element carrying a branch attribute of its
|
|
2116
|
+
// own, and says something more useful than the grammar would
|
|
2117
|
+
if (":each" in node.attrs) {
|
|
2118
|
+
afterClosedChain = false
|
|
2119
|
+
i++
|
|
2120
|
+
continue
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
warnChainAttrs(node)
|
|
2124
|
+
|
|
2125
|
+
if (":if" in node.attrs) {
|
|
2126
|
+
i++
|
|
2127
|
+
// the same walk renderNodes does, so the nodes claimed here are the ones
|
|
2128
|
+
// it will claim: any number of :elseif, then at most one :else
|
|
2129
|
+
const claim = (attr: string): TemplateNode | undefined => {
|
|
2130
|
+
const next = nextSiblingAt(nodes, i)
|
|
2131
|
+
const candidate = nodes[next]
|
|
2132
|
+
if (typeof candidate === "object" && attr in candidate.attrs) {
|
|
2133
|
+
i = next + 1
|
|
2134
|
+
return candidate
|
|
2135
|
+
}
|
|
2136
|
+
return undefined
|
|
2137
|
+
}
|
|
2138
|
+
// a claimed branch never reaches the check above - `:elseif :else` on one
|
|
2139
|
+
// element is claimed as an :elseif and its :else dropped, in silence
|
|
2140
|
+
for (let elseif = claim(":elseif"); elseif; elseif = claim(":elseif")) warnChainAttrs(elseif)
|
|
2141
|
+
const elseNode = claim(":else")
|
|
2142
|
+
if (elseNode) warnChainAttrs(elseNode)
|
|
2143
|
+
// an :else closes the chain. A chain that ended without one cannot be
|
|
2144
|
+
// followed by a stray at all - claim() would have taken it
|
|
2145
|
+
afterClosedChain = elseNode !== undefined
|
|
2146
|
+
continue
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
// no chain claimed this node, so a branch attribute on it is an orphan and
|
|
2150
|
+
// the element renders unconditionally
|
|
2151
|
+
if (":elseif" in node.attrs || ":else" in node.attrs) warnOrphanBranch(node, afterClosedChain)
|
|
2152
|
+
afterClosedChain = false
|
|
2153
|
+
i++
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
// The chain a `:if` node heads, and the index the sibling walk resumes at.
|
|
2158
|
+
// Both are fixed by the template - the node list is the same array on every
|
|
2159
|
+
// render - and renderNodes runs per instance, so a chain inside a :each row was
|
|
2160
|
+
// re-grouped, and its two arrays re-allocated, once per row per pass.
|
|
2161
|
+
// renderConditional only reads the branches, so one array serves every instance
|
|
2162
|
+
type Chain = { branches: ConditionalBranch[]; next: number }
|
|
2163
|
+
|
|
2164
|
+
const chains = new WeakMap<TemplateNode, Chain>()
|
|
2165
|
+
|
|
2166
|
+
const chainOf = (nodes: (TemplateNode | string)[], node: TemplateNode, from: number): Chain => {
|
|
2167
|
+
const cached = chains.get(node)
|
|
2168
|
+
if (cached) return cached
|
|
2169
|
+
|
|
2170
|
+
const branches: ConditionalBranch[] = [{ expr: node.attrs[":if"], node }]
|
|
2171
|
+
let at = from + 1
|
|
2172
|
+
// the whitespace between the branches is indentation and nothing else, so it
|
|
2173
|
+
// is skipped rather than rendered (nextSiblingAt): only one branch is ever in
|
|
2174
|
+
// the DOM, so there is nothing for it to be a space *between*
|
|
2175
|
+
const claim = (attr: string): TemplateNode | undefined => {
|
|
2176
|
+
const next = nextSiblingAt(nodes, at)
|
|
2177
|
+
const candidate = nodes[next]
|
|
2178
|
+
if (typeof candidate === "object" && attr in candidate.attrs) {
|
|
2179
|
+
at = next + 1
|
|
2180
|
+
return candidate
|
|
2181
|
+
}
|
|
2182
|
+
return undefined
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
for (let elseif = claim(":elseif"); elseif; elseif = claim(":elseif")) {
|
|
2186
|
+
branches.push({ expr: elseif.attrs[":elseif"], node: elseif })
|
|
2187
|
+
}
|
|
2188
|
+
const elseNode = claim(":else")
|
|
2189
|
+
if (elseNode) branches.push({ node: elseNode })
|
|
2190
|
+
|
|
2191
|
+
const chain: Chain = { branches, next: at }
|
|
2192
|
+
chains.set(node, chain)
|
|
2193
|
+
return chain
|
|
2194
|
+
}
|
|
2195
|
+
|
|
1756
2196
|
// renders a list of sibling template nodes (text + elements), grouping
|
|
1757
2197
|
// consecutive :if/:elseif/:else nodes into a single conditional block
|
|
1758
2198
|
// `into` renders straight into an element that is not in the document yet -
|
|
@@ -1802,31 +2242,9 @@ const renderNodes = <T extends ParentNode>(
|
|
|
1802
2242
|
}
|
|
1803
2243
|
|
|
1804
2244
|
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))
|
|
2245
|
+
const chain = chainOf(nodes, node, i)
|
|
2246
|
+
fragment.appendChild(renderConditional(chain.branches, scope, fx, shadow))
|
|
2247
|
+
i = chain.next
|
|
1830
2248
|
continue
|
|
1831
2249
|
}
|
|
1832
2250
|
|
|
@@ -2194,6 +2612,10 @@ const componentPartsFrom = (elements: Element[], hashSource: string): ComponentP
|
|
|
2194
2612
|
})
|
|
2195
2613
|
}
|
|
2196
2614
|
|
|
2615
|
+
// the template says what it says before any data exists, so its conditional
|
|
2616
|
+
// chains are checked here, once per definition
|
|
2617
|
+
validateChains(template)
|
|
2618
|
+
|
|
2197
2619
|
return { template, scripts, styles }
|
|
2198
2620
|
}
|
|
2199
2621
|
|
|
@@ -2884,6 +3306,26 @@ export class Component79 {
|
|
|
2884
3306
|
//
|
|
2885
3307
|
// Component79.fetch("./app.html").mount("main")
|
|
2886
3308
|
// const app = await Component79.fetch("./app.html")
|
|
3309
|
+
// Reads the debug flags, and sets the ones it is given:
|
|
3310
|
+
//
|
|
3311
|
+
// Component79.debug() // what is on right now
|
|
3312
|
+
// Component79.debug({ cloneSkeletons: false }) // turn one off
|
|
3313
|
+
//
|
|
3314
|
+
// Returns the flags as they stand after the call, so a caller can put them
|
|
3315
|
+
// back. Global to the module, not per component: these switch how the
|
|
3316
|
+
// renderer works, and a page rendering two ways at once is the one state
|
|
3317
|
+
// nobody could debug
|
|
3318
|
+
static debug(options?: Partial<DebugFlags>): DebugFlags {
|
|
3319
|
+
if (options) {
|
|
3320
|
+
for (const key in options) {
|
|
3321
|
+
const value = options[key as keyof DebugFlags]
|
|
3322
|
+
if (typeof value === "boolean") debugFlags[key as keyof DebugFlags] = value
|
|
3323
|
+
else console.warn(`jq79: Component79.debug ignored "${key}" - the flags are booleans, and the ones it knows are: ${Object.keys(debugFlags).join(", ")}`)
|
|
3324
|
+
}
|
|
3325
|
+
}
|
|
3326
|
+
return { ...debugFlags }
|
|
3327
|
+
}
|
|
3328
|
+
|
|
2887
3329
|
static fetch(url: string): PendingComponent79 {
|
|
2888
3330
|
if (Array.isArray(url)) throw new TypeError("Component79.fetch takes one URL; use fetchAll for an array")
|
|
2889
3331
|
return new PendingComponent79(fetchComponent(url))
|