jq79 0.5.8 → 0.5.9
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 +1 -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 +150 -17
package/package.json
CHANGED
package/src/jq79.ts
CHANGED
|
@@ -19,6 +19,12 @@ type TemplateNode = {
|
|
|
19
19
|
tag: string
|
|
20
20
|
attrs: Record<string, string>
|
|
21
21
|
children: (TemplateNode | string)[]
|
|
22
|
+
// the tag as the author capitalized it, present only when they wrote it
|
|
23
|
+
// uppercase-initial - i.e. when they meant a component. `tag` cannot answer
|
|
24
|
+
// this: the HTML parser lowercases it, so the claim is captured before the
|
|
25
|
+
// parse (see stampComponentTag) and lifted off attrs here, where it stops
|
|
26
|
+
// looking like an attribute to every loop downstream
|
|
27
|
+
component?: string
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
type TagBlock = {
|
|
@@ -45,20 +51,29 @@ const elementAttrs = (el: Element): Record<string, string> =>
|
|
|
45
51
|
// they are not in the AST at all - which is where slot content is written
|
|
46
52
|
// (<template :slot.name>), and why a nested <template> used to render as an
|
|
47
53
|
// empty element whatever was inside it
|
|
48
|
-
const elementToAST = (el: Element): TemplateNode =>
|
|
49
|
-
|
|
50
|
-
attrs:
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
54
|
+
const elementToAST = (el: Element): TemplateNode => {
|
|
55
|
+
const attrs = elementAttrs(el)
|
|
56
|
+
// the pre-parse stamp becomes a field and leaves attrs entirely: it is not a
|
|
57
|
+
// prop, not a directive and not an attribute, and every loop that walks attrs
|
|
58
|
+
// would otherwise need to know its name
|
|
59
|
+
const component = attrs[COMPONENT_TAG_ATTR]
|
|
60
|
+
delete attrs[COMPONENT_TAG_ATTR]
|
|
61
|
+
return {
|
|
62
|
+
tag: el.tagName.toLowerCase(),
|
|
63
|
+
attrs,
|
|
64
|
+
...(component === undefined ? {} : { component }),
|
|
65
|
+
children: Array.from((el instanceof HTMLTemplateElement ? el.content : el).childNodes).flatMap((node): (TemplateNode | string)[] => {
|
|
66
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
67
|
+
const text = node.textContent ?? ""
|
|
68
|
+
return text ? [text] : []
|
|
69
|
+
}
|
|
70
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
71
|
+
return [elementToAST(node as Element)]
|
|
72
|
+
}
|
|
73
|
+
return []
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
}
|
|
62
77
|
|
|
63
78
|
// evaluated with `with` (rather than passing scope keys as positional params)
|
|
64
79
|
// so only the identifiers an expression actually references are read from
|
|
@@ -383,6 +398,32 @@ const findComponentKey = (scope: Record<string, any>, tag: string): string | nul
|
|
|
383
398
|
return null
|
|
384
399
|
}
|
|
385
400
|
|
|
401
|
+
// every name a tag *could* have resolved to, walking the same chain
|
|
402
|
+
// findComponentKey does. Deduped and sorted, because the chain can hold one
|
|
403
|
+
// name twice (a prop shadowing a sibling) and the order it comes out in is the
|
|
404
|
+
// prototype's, which means nothing to a reader scanning for their typo
|
|
405
|
+
const componentsInScope = (scope: Record<string, any>): string[] => {
|
|
406
|
+
const names = new Set<string>()
|
|
407
|
+
for (let obj: any = scope; obj && obj !== Object.prototype; obj = Object.getPrototypeOf(obj)) {
|
|
408
|
+
for (const key of Object.keys(obj)) if (/^[A-Z]/.test(key)) names.add(key)
|
|
409
|
+
}
|
|
410
|
+
return [...names].sort()
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// a tag whose name resolves to no component, once nothing can still supply one.
|
|
414
|
+
// The error names what *is* in scope: the mistake is nearly always a typo or a
|
|
415
|
+
// missing import, and both are one glance from the list. "(none)" is its own
|
|
416
|
+
// answer - it says the component has no components at all, which points at the
|
|
417
|
+
// import rather than at the spelling
|
|
418
|
+
const unresolvedComponent = (tag: string, scope: Record<string, any>): Error => {
|
|
419
|
+
const names = componentsInScope(scope)
|
|
420
|
+
return new Error(
|
|
421
|
+
`jq79: <${tag}> is not defined - no component of that name is in scope, and nothing renders here. ` +
|
|
422
|
+
`Import it in a :setup script, declare it as a prop, or add a <template name="${tag}"> to this file. ` +
|
|
423
|
+
`In scope: ${names.length ? names.join(", ") : "(none)"}.`
|
|
424
|
+
)
|
|
425
|
+
}
|
|
426
|
+
|
|
386
427
|
// how deep a component may nest inside itself before the runtime calls it a
|
|
387
428
|
// cycle. Deeper than any real tree, shallower than the JS stack: a truncated
|
|
388
429
|
// render with an error on the console beats a stack overflow with none
|
|
@@ -1025,6 +1066,32 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
|
|
|
1025
1066
|
|
|
1026
1067
|
const el = document.createElement(node.tag)
|
|
1027
1068
|
|
|
1069
|
+
// <UserCrad /> - written as a component (node.component), resolving to no
|
|
1070
|
+
// component, and not an element either. Nothing else on the page can supply
|
|
1071
|
+
// the name once every script has settled, so this renders no markup, no
|
|
1072
|
+
// styles, no children and no script, forever, and says so by throwing rather
|
|
1073
|
+
// than leaving a hole where a region of the page was meant to be.
|
|
1074
|
+
//
|
|
1075
|
+
// All three conditions carry weight. Without the capitalization <lable> and
|
|
1076
|
+
// <svg> would be fatal (createElement builds SVG names in the HTML namespace,
|
|
1077
|
+
// so an <svg> is an HTMLUnknownElement too); without the element check <DIV>
|
|
1078
|
+
// would be, though it renders a perfectly good div; and without the pending
|
|
1079
|
+
// count a factory that awaits $mounted() before returning its components
|
|
1080
|
+
// could never render one, which is exactly what the watcher below is for.
|
|
1081
|
+
//
|
|
1082
|
+
// An *absent* count is a fourth case, and it is not zero: renderComponent()
|
|
1083
|
+
// renders a template against a store somebody else owns and assembles, so
|
|
1084
|
+
// nothing there has finished and nothing says a key can't still be written
|
|
1085
|
+
// in. The claim being tested is a component's claim about its own scripts,
|
|
1086
|
+
// and where none was made the tag waits for the upgrade, as it always has.
|
|
1087
|
+
//
|
|
1088
|
+
// Written without a local for the count because renderNode is on the stack
|
|
1089
|
+
// for the whole of the subtree below it, so a slot here is a slot per level
|
|
1090
|
+
// of a component nested inside itself - see renderWith
|
|
1091
|
+
if (node.component && el instanceof HTMLUnknownElement && ((scope as any)[PENDING_SCRIPTS] as PendingScripts | undefined)?.count === 0) {
|
|
1092
|
+
throw unresolvedComponent(node.component, scope)
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1028
1095
|
// a tag that isn't standard HTML but has no matching scope key *yet* may be
|
|
1029
1096
|
// a component that arrives later (e.g. an async factory script exposing an
|
|
1030
1097
|
// imported component after `await`). Watch for the key: the effect tracks
|
|
@@ -1527,6 +1594,36 @@ const SLOT_TAG_RE = /^slot\./i
|
|
|
1527
1594
|
const kebabTagName = (tag: string): string =>
|
|
1528
1595
|
SLOT_TAG_RE.test(tag) ? `slot.${camelToKebab(tag.slice("slot.".length))}` : tag
|
|
1529
1596
|
|
|
1597
|
+
// the same pass records what it declined to rewrite. An uppercase-initial tag
|
|
1598
|
+
// is a claim about a component: HTML's own elements are matched
|
|
1599
|
+
// case-insensitively but nobody writes <DIV> by accident, and a custom element
|
|
1600
|
+
// may not be spelled that way at all. So <UserCard> is a name the author
|
|
1601
|
+
// expected to resolve - which is what lets renderNode throw when it doesn't
|
|
1602
|
+
// (see unresolvedComponent).
|
|
1603
|
+
//
|
|
1604
|
+
// Carried in a *value* rather than left in the tag name, because the value is
|
|
1605
|
+
// the one place the HTML parser preserves case - the same move expandPropsSpread
|
|
1606
|
+
// makes for `...userData`, and for the same reason. elementToAST lifts it
|
|
1607
|
+
// straight off attrs into a field, so no attribute loop downstream ever sees
|
|
1608
|
+
// it - and since that lift is unconditional, the name has to be one no author
|
|
1609
|
+
// would write: a plain `:component` would eat the prop of that name off
|
|
1610
|
+
// <Card :component="Widget" />
|
|
1611
|
+
const COMPONENT_TAG_ATTR = ":jq79-component"
|
|
1612
|
+
const COMPONENT_TAG_RE = /^[A-Z]/
|
|
1613
|
+
|
|
1614
|
+
// appends the stamp inside the tag, *before* a self-closing slash: this pass
|
|
1615
|
+
// runs first and expandSelfClosingTags still has to recognize the `/>` that
|
|
1616
|
+
// OPEN_TAG_RE swept into the attributes. A slash inside a quoted value can't be
|
|
1617
|
+
// mistaken for it - only a trailing one is matched
|
|
1618
|
+
const TRAILING_SLASH_RE = /\/\s*$/
|
|
1619
|
+
|
|
1620
|
+
const stampComponentTag = (tag: string, attrs: string): string => {
|
|
1621
|
+
if (!COMPONENT_TAG_RE.test(tag)) return attrs
|
|
1622
|
+
const stamp = ` ${COMPONENT_TAG_ATTR}="${tag}"`
|
|
1623
|
+
const slash = TRAILING_SLASH_RE.exec(attrs)
|
|
1624
|
+
return slash ? `${attrs.slice(0, slash.index)}${stamp}${slash[0]}` : `${attrs}${stamp}`
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1530
1627
|
const expandNameCase = (src: string): string =>
|
|
1531
1628
|
src
|
|
1532
1629
|
.split(RAW_BLOCK_RE)
|
|
@@ -1538,7 +1635,7 @@ const expandNameCase = (src: string): string =>
|
|
|
1538
1635
|
const rewritten = attrs.replace(ATTR_NAME_RE, (whole, space: string | undefined, name: string | undefined) =>
|
|
1539
1636
|
name === undefined ? whole : `${space}${camelToKebab(name)}`
|
|
1540
1637
|
)
|
|
1541
|
-
return `<${kebabTagName(tag)}${rewritten}>`
|
|
1638
|
+
return `<${kebabTagName(tag)}${stampComponentTag(tag, rewritten)}>`
|
|
1542
1639
|
})
|
|
1543
1640
|
.replace(CLOSE_SLOT_RE, (_match, suffix: string, space: string) => `</slot.${camelToKebab(suffix)}${space}>`)
|
|
1544
1641
|
)
|
|
@@ -2049,6 +2146,21 @@ const siblingsInScope = (
|
|
|
2049
2146
|
// it too) without ever showing up as data
|
|
2050
2147
|
const UNFILLED_PROPS = Symbol("jq79.unfilledProps")
|
|
2051
2148
|
|
|
2149
|
+
// how many of this render generation's scripts have yet to settle, as a live
|
|
2150
|
+
// box rather than a snapshot. Rides the scope chain like UNFILLED_PROPS, and
|
|
2151
|
+
// for one reader: a <Tag> naming no component in scope is only a mistake once
|
|
2152
|
+
// nothing is left that could still supply the name.
|
|
2153
|
+
//
|
|
2154
|
+
// The count is not the render gate. A script that called $mounted() released
|
|
2155
|
+
// the template and is still running - that is the whole point of the call - so
|
|
2156
|
+
// at paint time this can be non-zero, and a name arriving from a factory that
|
|
2157
|
+
// awaited $mounted() is exactly the case the count keeps quiet. Read live, so
|
|
2158
|
+
// an :if that opens after everything settled is judged against the scripts as
|
|
2159
|
+
// they are then, not as they were at the first paint
|
|
2160
|
+
const PENDING_SCRIPTS = Symbol("jq79.pendingScripts")
|
|
2161
|
+
|
|
2162
|
+
type PendingScripts = { count: number }
|
|
2163
|
+
|
|
2052
2164
|
// default-import interop for factory scripts: real modules expose .default,
|
|
2053
2165
|
// while importing an .html component resolves to the Component79 itself
|
|
2054
2166
|
const interopDefault = (mod: any) => (mod && mod.default !== undefined ? mod.default : mod)
|
|
@@ -2466,6 +2578,14 @@ export class Component79 {
|
|
|
2466
2578
|
// (`<footer :if="$slots.footer">`). Filled at the usage site, so it can
|
|
2467
2579
|
// only change when the tag itself re-renders - which builds a new instance
|
|
2468
2580
|
if (this.slots) Object.defineProperty(raw, SLOTS, { value: this.slots })
|
|
2581
|
+
// in place before the store wraps it, because the scripts that increment it
|
|
2582
|
+
// run against the store and the template reads it back through the same
|
|
2583
|
+
// scope chain. Read back out of `raw` where it is needed rather than kept
|
|
2584
|
+
// in a local: this frame is on the stack for the whole of the subtree it
|
|
2585
|
+
// renders, so a component nested inside itself pays for it once per level -
|
|
2586
|
+
// and the depth guard at MAX_NESTING_DEPTH only beats a RangeError while
|
|
2587
|
+
// this function stays small (see the note in docs/development.md)
|
|
2588
|
+
Object.defineProperty(raw, PENDING_SCRIPTS, { value: { count: 0 } as PendingScripts })
|
|
2469
2589
|
|
|
2470
2590
|
const store = $reactive(raw)
|
|
2471
2591
|
const fx = createEffectScope(store)
|
|
@@ -2654,8 +2774,21 @@ export class Component79 {
|
|
|
2654
2774
|
// a script that threw has nothing left to contribute, so its rejection
|
|
2655
2775
|
// releases the gate exactly as completion does - the error is already
|
|
2656
2776
|
// reported by the runner, and holding the template hostage to it would
|
|
2657
|
-
// turn one broken script into a blank component
|
|
2658
|
-
|
|
2777
|
+
// turn one broken script into a blank component. It also stops counting
|
|
2778
|
+
// as a source of names, for that same reason.
|
|
2779
|
+
//
|
|
2780
|
+
// A script that finished on this stack is counted at zero rather than
|
|
2781
|
+
// incremented and decremented a microtask later: its names are on the
|
|
2782
|
+
// store already, and the promise it settles through does not resolve
|
|
2783
|
+
// until after the synchronous paint - which is every paint, for the
|
|
2784
|
+
// components that have no await in them at all
|
|
2785
|
+
if (run.sync) run.settled.then(release, release)
|
|
2786
|
+
else {
|
|
2787
|
+
const pending: PendingScripts = (raw as any)[PENDING_SCRIPTS]
|
|
2788
|
+
pending.count++
|
|
2789
|
+
const settle = () => { pending.count--; release() }
|
|
2790
|
+
run.settled.then(settle, settle)
|
|
2791
|
+
}
|
|
2659
2792
|
if (!run.sync && !open) allSync = false
|
|
2660
2793
|
})
|
|
2661
2794
|
|