jq79 0.4.11 → 0.4.13
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/README.md +1 -1
- package/dev/vite.ts +47 -0
- package/dist/jq79.cjs +13 -13
- package/dist/jq79.cjs.map +1 -1
- package/dist/jq79.d.ts +6 -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/dist/vite.cjs +39 -0
- package/dist/vite.cjs.map +1 -1
- package/dist/vite.js +39 -0
- package/dist/vite.js.map +1 -1
- package/package.json +1 -1
- package/src/jq79.ts +356 -27
package/src/jq79.ts
CHANGED
|
@@ -96,13 +96,14 @@ const interpolate = (template: string, scope: Record<string, any>): string =>
|
|
|
96
96
|
template.replace(/{{\s*([\s\S]+?)\s*}}/g, (_, expr) => evalExpr(expr, scope) ?? "")
|
|
97
97
|
|
|
98
98
|
|
|
99
|
-
const CONTROL_ATTRS = new Set([":attrs", ":class", ":value", ":checked", ":selected", ":if", ":elseif", ":else", ":each", ":key", ":with", ":text", ":html", ":html.allowed"])
|
|
99
|
+
const CONTROL_ATTRS = new Set([":attrs", ":class", ":value", ":checked", ":selected", ":if", ":elseif", ":else", ":each", ":key", ":with", ":text", ":html", ":html.allowed", ":props"])
|
|
100
100
|
|
|
101
101
|
// a control attribute is one the static-attr loop and nested-component prop
|
|
102
102
|
// collection must skip. The set holds the fixed names; `:class.<name>` (the
|
|
103
|
-
// single-flag shorthand)
|
|
104
|
-
// enumerated into the set
|
|
105
|
-
const isControlAttr = (attr: string): boolean =>
|
|
103
|
+
// single-flag shorthand) and `:props.<n>` (one spread among several) are
|
|
104
|
+
// open-ended, so they're matched by prefix - they can't be enumerated into the set
|
|
105
|
+
const isControlAttr = (attr: string): boolean =>
|
|
106
|
+
CONTROL_ATTRS.has(attr) || attr.startsWith(":class.") || attr.startsWith(":props.")
|
|
106
107
|
// `item in items`, `item, i in items`, `(value, key) in props` - the second
|
|
107
108
|
// binding is the array index or the object key, parens optional (Vue-style).
|
|
108
109
|
// The list expression can span lines, so it matches [\s\S] rather than `.`
|
|
@@ -215,6 +216,12 @@ const findComponentKey = (scope: Record<string, any>, tag: string): string | nul
|
|
|
215
216
|
return null
|
|
216
217
|
}
|
|
217
218
|
|
|
219
|
+
// how deep a component may nest inside itself before the runtime calls it a
|
|
220
|
+
// cycle. Deeper than any real tree, shallower than the JS stack: a truncated
|
|
221
|
+
// render with an error on the console beats a stack overflow with none
|
|
222
|
+
const MAX_NESTING_DEPTH = 200
|
|
223
|
+
let nestingDepth = 0
|
|
224
|
+
|
|
218
225
|
// <MyComponent :user :title="'str'"></MyComponent> - renders a child
|
|
219
226
|
// component instance at this position. Props: `:name="expr"` evaluates expr
|
|
220
227
|
// in the parent scope (`:name` alone is shorthand for `:name="name"`), plain
|
|
@@ -241,10 +248,24 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
|
|
|
241
248
|
const props: Record<string, string> = {} // prop name -> expression in parent scope
|
|
242
249
|
const models: Record<string, string> = {} // model name -> assignable expression in parent scope
|
|
243
250
|
const events: Array<[string, string]> = [] // @attr (modifiers included) -> handler expression
|
|
251
|
+
// named props AND spreads in source order - what a :props merge folds over so
|
|
252
|
+
// precedence follows the JS object-spread rule (later wins). `name` absent
|
|
253
|
+
// marks a spread: the whole object's properties, not one binding
|
|
254
|
+
const sources: Array<{ name?: string; expr: string }> = []
|
|
255
|
+
let hasSpread = false
|
|
244
256
|
Object.entries(node.attrs).forEach(([attr, value]) => {
|
|
245
257
|
// the parent's scope stamp is stamped on every template element, this tag
|
|
246
258
|
// included - it's not a prop, and the child renders under its own scope
|
|
247
|
-
if (attr === SCOPE_ATTR
|
|
259
|
+
if (attr === SCOPE_ATTR) return
|
|
260
|
+
if (attr === ":props" || attr.startsWith(":props.")) {
|
|
261
|
+
// :props="obj" spreads obj's own properties as props; :props.<n> is one
|
|
262
|
+
// spread among several (the `...obj` sugar rewrites to it - see
|
|
263
|
+
// expandPropsSpread), the suffix only keeping the attribute names distinct
|
|
264
|
+
hasSpread = true
|
|
265
|
+
sources.push({ expr: value })
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
if (isControlAttr(attr)) return
|
|
248
269
|
if (attr.startsWith("@")) {
|
|
249
270
|
events.push([attr, value])
|
|
250
271
|
} else if (attr === ":model" || attr.startsWith(":model.")) {
|
|
@@ -257,8 +278,12 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
|
|
|
257
278
|
} else if (attr.startsWith(":")) {
|
|
258
279
|
const name = kebabToCamel(attr.slice(1))
|
|
259
280
|
props[name] = value || name
|
|
281
|
+
sources.push({ name, expr: value || name })
|
|
260
282
|
} else {
|
|
261
|
-
|
|
283
|
+
const name = kebabToCamel(attr)
|
|
284
|
+
const expr = JSON.stringify(value)
|
|
285
|
+
props[name] = expr
|
|
286
|
+
sources.push({ name, expr })
|
|
262
287
|
}
|
|
263
288
|
})
|
|
264
289
|
|
|
@@ -287,13 +312,57 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
|
|
|
287
312
|
}
|
|
288
313
|
})
|
|
289
314
|
|
|
315
|
+
// the full prop set the child gets, resolved in source order: each named prop
|
|
316
|
+
// sets one key, each spread merges an object's own properties, later sources
|
|
317
|
+
// overwriting earlier (the JS object-spread rule). :model bindings apply last,
|
|
318
|
+
// so they win - the same precedence the collision warning above promises. A
|
|
319
|
+
// spread expression that isn't an object contributes nothing (fail closed,
|
|
320
|
+
// like :with), so an `await`-pending object spreads once it resolves
|
|
321
|
+
const resolveProps = (): Record<string, any> => {
|
|
322
|
+
const out: Record<string, any> = {}
|
|
323
|
+
sources.forEach(({ name, expr }) => {
|
|
324
|
+
if (name !== undefined) out[name] = evalExpr(expr, scope)
|
|
325
|
+
else {
|
|
326
|
+
const obj = evalExpr(expr, scope)
|
|
327
|
+
if (obj !== null && typeof obj === "object") Object.assign(out, obj)
|
|
328
|
+
}
|
|
329
|
+
})
|
|
330
|
+
Object.entries(models).forEach(([name, expr]) => { out[modelProp(name)] = evalExpr(expr, scope) })
|
|
331
|
+
return out
|
|
332
|
+
}
|
|
333
|
+
|
|
290
334
|
let current: Component79 | null = null
|
|
291
335
|
let currentDef: Component79 | null = null
|
|
292
336
|
let childFx: EffectScope | null = null
|
|
293
337
|
|
|
338
|
+
// a usage site that resolves to no component renders nothing, which is
|
|
339
|
+
// deliberate - `undefined` while an `await import(...)` is in flight has to
|
|
340
|
+
// wait quietly, and the child appears when it lands. Two cases can never
|
|
341
|
+
// resolve, though, and both are wiring mistakes worth naming: a value that
|
|
342
|
+
// isn't a component (and so will never become one by waiting), and a name
|
|
343
|
+
// the component declared as a prop that the parent passed nothing for. Once
|
|
344
|
+
// each, per usage site: an effect re-runs
|
|
345
|
+
const reported = new Set<string>()
|
|
346
|
+
const reportUnresolved = (value: any) => {
|
|
347
|
+
if (value === undefined || value === null) {
|
|
348
|
+
const unfilled: Set<string> | undefined = (scope as any)[UNFILLED_PROPS]
|
|
349
|
+
if (!unfilled?.has(key) || reported.has("unfilled")) return
|
|
350
|
+
reported.add("unfilled")
|
|
351
|
+
console.error(
|
|
352
|
+
`jq79: <${node.tag}> is declared as a prop and the parent passed nothing - nothing renders here. ` +
|
|
353
|
+
`Pass it (:${key}="…"), or drop it from the signature to use the one declared in this file.`
|
|
354
|
+
)
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
if (reported.has("type")) return
|
|
358
|
+
reported.add("type")
|
|
359
|
+
console.error(`jq79: <${node.tag}> is ${typeof value}, not a component - nothing renders here`)
|
|
360
|
+
}
|
|
361
|
+
|
|
294
362
|
fx.effect(() => {
|
|
295
363
|
const value = evalExpr(key, scope)
|
|
296
364
|
const nextDef = value instanceof Component79 ? value : null
|
|
365
|
+
if (!nextDef) reportUnresolved(value)
|
|
297
366
|
if (nextDef === currentDef) return
|
|
298
367
|
|
|
299
368
|
childFx?.dispose()
|
|
@@ -311,6 +380,11 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
|
|
|
311
380
|
styles: nextDef.styles,
|
|
312
381
|
modules: nextDef.modules,
|
|
313
382
|
filename: nextDef.filename,
|
|
383
|
+
// its file's other components, and which of them it is: without the
|
|
384
|
+
// first a child rendered here loses the siblings its definition could
|
|
385
|
+
// see, and without the second hot reload can't tell it what it is
|
|
386
|
+
siblings: nextDef.siblings,
|
|
387
|
+
name: nextDef.name,
|
|
314
388
|
})
|
|
315
389
|
// the writeback half of :model - one event, one contract. The name is
|
|
316
390
|
// normalized like the attribute was (kebab->camel; absent means default),
|
|
@@ -352,20 +426,53 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
|
|
|
352
426
|
// explicit re-emit)
|
|
353
427
|
events.forEach(([attr, expr]) => wireTagEvent(instance, attr, expr, scope))
|
|
354
428
|
|
|
355
|
-
const seed = untracked(
|
|
356
|
-
Object.fromEntries(Object.entries(props).map(([name, expr]) => [name, evalExpr(expr, scope)]))
|
|
357
|
-
)
|
|
429
|
+
const seed = untracked(resolveProps)
|
|
358
430
|
// mounting into a fragment attaches no shadow root of its own: a
|
|
359
431
|
// shadow-rendered child keeps its <style> elements inline, next to the DOM
|
|
360
432
|
// they style, and the parent's shadow root is what scopes both
|
|
361
433
|
const holder = document.createDocumentFragment()
|
|
362
|
-
|
|
434
|
+
// rendering a child happens on this same stack, so a component that
|
|
435
|
+
// renders itself recurses as deep as its data does - and a cycle in that
|
|
436
|
+
// data would recurse until the JS stack gave out, ~900 identical frames
|
|
437
|
+
// naming nothing. Cut and named instead, exactly like the effect runner
|
|
438
|
+
// cuts an effect that wakes itself
|
|
439
|
+
if (nestingDepth >= MAX_NESTING_DEPTH) {
|
|
440
|
+
console.error(
|
|
441
|
+
`jq79: <${node.tag}> is ${MAX_NESTING_DEPTH} levels deep inside itself; giving up here. ` +
|
|
442
|
+
"A component that renders itself stops when its data stops - is there a cycle in it?"
|
|
443
|
+
)
|
|
444
|
+
return
|
|
445
|
+
}
|
|
446
|
+
nestingDepth++
|
|
447
|
+
try {
|
|
448
|
+
;(shadow ? instance.renderShadow(seed) : instance.render(seed)).mount(holder)
|
|
449
|
+
} finally {
|
|
450
|
+
nestingDepth--
|
|
451
|
+
}
|
|
363
452
|
endAnchor.parentNode!.insertBefore(holder, endAnchor)
|
|
364
453
|
|
|
365
454
|
const syncFx = createEffectScope(scope)
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
455
|
+
// without a spread the prop set is fixed and known: one effect per prop, so
|
|
456
|
+
// a change to one prop re-syncs only that prop. A spread's key set is
|
|
457
|
+
// dynamic and its precedence is positional, so it can't be resolved a key at
|
|
458
|
+
// a time across independent effects (whichever re-ran last would win) - one
|
|
459
|
+
// effect re-merges everything in order and writes the diff, clearing keys a
|
|
460
|
+
// spread has dropped since last run. Named props are always in the merge, so
|
|
461
|
+
// they're never cleared; the extra cost is confined to spread-using tags
|
|
462
|
+
if (hasSpread) {
|
|
463
|
+
let written: string[] = []
|
|
464
|
+
syncFx.effect(() => {
|
|
465
|
+
const next = resolveProps()
|
|
466
|
+
const nextKeys = Object.keys(next)
|
|
467
|
+
written.forEach(key => { if (!(key in next)) (instance.data as Record<string, any>)[key] = undefined })
|
|
468
|
+
nextKeys.forEach(key => { (instance.data as Record<string, any>)[key] = next[key] })
|
|
469
|
+
written = nextKeys
|
|
470
|
+
})
|
|
471
|
+
} else {
|
|
472
|
+
Object.entries(props).forEach(([name, expr]) => {
|
|
473
|
+
syncFx.effect(() => { (instance.data as Record<string, any>)[name] = evalExpr(expr, scope) })
|
|
474
|
+
})
|
|
475
|
+
}
|
|
369
476
|
|
|
370
477
|
childFx = syncFx
|
|
371
478
|
current = instance
|
|
@@ -835,6 +942,15 @@ type ComponentParts = {
|
|
|
835
942
|
// where this component came from (a URL for fetch(), a path for the vite
|
|
836
943
|
// plugin). Names the setup scripts in devtools - see scriptSourceUrl
|
|
837
944
|
filename?: string
|
|
945
|
+
// the components the file's <template name="..."> blocks declared, by name.
|
|
946
|
+
// Every component parsed out of one file holds this same map - itself
|
|
947
|
+
// included - which is what makes a sibling usable without an import, and
|
|
948
|
+
// what lets a <template name="TreeNode"> render a <TreeNode>
|
|
949
|
+
siblings?: Record<string, Component79>
|
|
950
|
+
// which of the file's components this is: a template's name, or undefined
|
|
951
|
+
// for the file's own. The file is the hot-reload unit, so a reparse hands
|
|
952
|
+
// each live instance the parts belonging to the component it is
|
|
953
|
+
name?: string
|
|
838
954
|
}
|
|
839
955
|
|
|
840
956
|
const VOID_ELEMENTS = new Set([
|
|
@@ -864,6 +980,41 @@ const expandSelfClosingTags = (src: string): string =>
|
|
|
864
980
|
)
|
|
865
981
|
.join("")
|
|
866
982
|
|
|
983
|
+
// a start tag with its attributes, quote-aware so a ">" inside a value doesn't
|
|
984
|
+
// end it early; and a single spread attribute in name position (preceded by
|
|
985
|
+
// start-or-whitespace), its expression an identifier or member path
|
|
986
|
+
const OPEN_TAG_RE = /<([A-Za-z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g
|
|
987
|
+
const ATTR_SPREAD_RE = /"[^"]*"|'[^']*'|(^|\s)\.\.\.([A-Za-z_$][\w$.]*)/g
|
|
988
|
+
|
|
989
|
+
// `...expr` as an attribute is sugar for :props="expr" (spread an object's
|
|
990
|
+
// properties as props - see renderNestedComponent). Rewritten BEFORE DOM
|
|
991
|
+
// parsing, into a value-based :props.<n>, because the HTML parser lowercases
|
|
992
|
+
// attribute *names*: with the expression in the name, `...userData` would arrive
|
|
993
|
+
// as `...userdata` and resolve to nothing. Moving it into a value - which the
|
|
994
|
+
// parser leaves untouched - keeps camelCase intact. Same pre-parse string move
|
|
995
|
+
// as expandSelfClosingTags, with the same defenses against rewriting code that
|
|
996
|
+
// only looks like a spread: <script>/<style> bodies are split out (a JS `...rest`
|
|
997
|
+
// there is not an attribute), only a start tag's interior is scanned (text
|
|
998
|
+
// between tags is safe), and quoted values are consumed whole so a genuine JS
|
|
999
|
+
// spread in a value (@click="f(...args)", :x="{ ...a }") is skipped. The <n>
|
|
1000
|
+
// suffix (per tag) only keeps several spreads' attribute names distinct. A call
|
|
1001
|
+
// (`...getProps()`) stops at the paren and is left alone - use :props="expr()"
|
|
1002
|
+
const expandPropsSpread = (src: string): string =>
|
|
1003
|
+
src
|
|
1004
|
+
.split(RAW_BLOCK_RE)
|
|
1005
|
+
.map((chunk, i) =>
|
|
1006
|
+
i % 2 === 1
|
|
1007
|
+
? chunk
|
|
1008
|
+
: chunk.replace(OPEN_TAG_RE, (_match, tag: string, attrs: string) => {
|
|
1009
|
+
let n = 0
|
|
1010
|
+
const rewritten = attrs.replace(ATTR_SPREAD_RE, (whole, space: string | undefined, expr: string | undefined) =>
|
|
1011
|
+
expr === undefined ? whole : `${space}:props.${n++}="${expr}"`
|
|
1012
|
+
)
|
|
1013
|
+
return `<${tag}${rewritten}>`
|
|
1014
|
+
})
|
|
1015
|
+
)
|
|
1016
|
+
.join("")
|
|
1017
|
+
|
|
867
1018
|
// <style scoped> support. Every element of the component's own template is
|
|
868
1019
|
// stamped with data-jq79="<hash>" and the style's selectors are rewritten to
|
|
869
1020
|
// require that attribute, so its rules can't reach anything the component
|
|
@@ -926,9 +1077,16 @@ const scopeCss = (css: string, scope: string): string => {
|
|
|
926
1077
|
return Array.from(sheet.cssRules).map(rule => rule.cssText).join("\n")
|
|
927
1078
|
}
|
|
928
1079
|
|
|
1080
|
+
// a component name has to be PascalCase to be usable: findComponentKey only
|
|
1081
|
+
// ever considers capitalized scope keys, so a lowercase name would declare a
|
|
1082
|
+
// component no tag could reference. It is also what keeps the named exports
|
|
1083
|
+
// from colliding with a definition's own fields, which are all lowercase
|
|
1084
|
+
const COMPONENT_NAME_RE = /^[A-Z][A-Za-z0-9]*$/
|
|
1085
|
+
|
|
929
1086
|
// converts a string of HTML into an AST representation of the component:
|
|
930
1087
|
// - template: the non-script/style top-level elements, as TemplateNodes
|
|
931
1088
|
// - scripts/styles: { attrs, content } blocks in source order
|
|
1089
|
+
// - siblings: the components its top-level <template name="..."> declared
|
|
932
1090
|
const parseComponentString = (component: string): ComponentParts => {
|
|
933
1091
|
// example
|
|
934
1092
|
// <script :setup="{ fname, lname }">
|
|
@@ -947,15 +1105,71 @@ const parseComponentString = (component: string): ComponentParts => {
|
|
|
947
1105
|
// </style>
|
|
948
1106
|
|
|
949
1107
|
// parsed as the content of a <template> so leading <script>/<style> tags
|
|
950
|
-
// aren't reparented into <head> by the HTML parser
|
|
951
|
-
|
|
1108
|
+
// aren't reparented into <head> by the HTML parser. Both pre-DOM string
|
|
1109
|
+
// rewrites run here: `...expr` -> :props.<n>="expr" first (it reads the raw
|
|
1110
|
+
// camelCase before the parser can lowercase names), then self-closing tags
|
|
1111
|
+
const prepared = expandSelfClosingTags(expandPropsSpread(component))
|
|
1112
|
+
const parsedDOM = new DOMParser().parseFromString(`<template>${prepared}</template>`, "text/html")
|
|
952
1113
|
const root = parsedDOM.querySelector("template") as HTMLTemplateElement
|
|
953
1114
|
|
|
1115
|
+
// a top-level <template> declares another component of this file; everything
|
|
1116
|
+
// else is this one's own
|
|
1117
|
+
const own: Element[] = []
|
|
1118
|
+
const declarations: HTMLTemplateElement[] = []
|
|
1119
|
+
Array.from(root.content.children).forEach(el => {
|
|
1120
|
+
if (el.tagName === "TEMPLATE") declarations.push(el as HTMLTemplateElement)
|
|
1121
|
+
else own.push(el)
|
|
1122
|
+
})
|
|
1123
|
+
|
|
1124
|
+
// the file's own component hashes the whole file: every component in it
|
|
1125
|
+
// re-renders on any edit anyway (the file is the hot-reload unit), so a
|
|
1126
|
+
// stamp that changes when a sibling is edited costs nothing, and scopeHash
|
|
1127
|
+
// gets to keep hashing the source it was handed
|
|
1128
|
+
const parts = componentPartsFrom(own, component)
|
|
1129
|
+
|
|
1130
|
+
// one map, shared by reference: it is filled below, after each definition
|
|
1131
|
+
// has already been handed it, so every component of the file sees all the
|
|
1132
|
+
// others *and itself* - which is what makes a recursive component possible
|
|
1133
|
+
const siblings: Record<string, Component79> = {}
|
|
1134
|
+
declarations.forEach(el => {
|
|
1135
|
+
const name = el.getAttribute("name")
|
|
1136
|
+
// ignored rather than fatal, like every other malformed thing here: a bad
|
|
1137
|
+
// save mid-typing must not take the page down, least of all under HMR
|
|
1138
|
+
if (name === null) {
|
|
1139
|
+
console.warn("jq79: a top-level <template> without a name declares nothing and was ignored")
|
|
1140
|
+
return
|
|
1141
|
+
}
|
|
1142
|
+
if (!COMPONENT_NAME_RE.test(name)) {
|
|
1143
|
+
console.warn(
|
|
1144
|
+
`jq79: <template name="${name}"> was ignored - a component name has to be PascalCase, ` +
|
|
1145
|
+
"or no tag could ever reference it (only capitalized names resolve as components)"
|
|
1146
|
+
)
|
|
1147
|
+
return
|
|
1148
|
+
}
|
|
1149
|
+
if (name in siblings) {
|
|
1150
|
+
console.warn(`jq79: two <template name="${name}"> in one file; the second was ignored`)
|
|
1151
|
+
return
|
|
1152
|
+
}
|
|
1153
|
+
// its own source is its own scope: a named template is a shadow root
|
|
1154
|
+
// inside a shadow root, so the file's scoped rules stop at its boundary
|
|
1155
|
+
// and its own stop there too
|
|
1156
|
+
siblings[name] = new Component79({ ...componentPartsFrom(Array.from(el.content.children), el.innerHTML), siblings, name })
|
|
1157
|
+
})
|
|
1158
|
+
if (Object.keys(siblings).length) parts.siblings = siblings
|
|
1159
|
+
|
|
1160
|
+
return parts
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// the script/style/markup split of one component's top-level elements, with
|
|
1164
|
+
// <style scoped> resolved against the source those elements came from - the
|
|
1165
|
+
// whole file for its own component, a <template>'s contents for a named one,
|
|
1166
|
+
// so the two get different stamps and neither can style the other
|
|
1167
|
+
const componentPartsFrom = (elements: Element[], hashSource: string): ComponentParts => {
|
|
954
1168
|
const scripts: TagBlock[] = []
|
|
955
1169
|
const styles: TagBlock[] = []
|
|
956
1170
|
const template: TemplateNode[] = []
|
|
957
1171
|
|
|
958
|
-
|
|
1172
|
+
elements.forEach(el => {
|
|
959
1173
|
const block: TagBlock = { attrs: elementAttrs(el), content: el.textContent ?? "" }
|
|
960
1174
|
|
|
961
1175
|
if (el.tagName === "SCRIPT") scripts.push(block)
|
|
@@ -982,7 +1196,7 @@ const parseComponentString = (component: string): ComponentParts => {
|
|
|
982
1196
|
// in something that isn't CSS yet would only garble what devtools shows
|
|
983
1197
|
const isScoped = (style: TagBlock) => "scoped" in style.attrs && !("lang" in style.attrs)
|
|
984
1198
|
if (styles.some(isScoped)) {
|
|
985
|
-
const scope = scopeHash(
|
|
1199
|
+
const scope = scopeHash(hashSource)
|
|
986
1200
|
stampScope(template, scope)
|
|
987
1201
|
styles.forEach(style => {
|
|
988
1202
|
if (isScoped(style)) style.scoped = scopeCss(style.content, scope)
|
|
@@ -1101,6 +1315,50 @@ const declareProps = (store: Record<string, any>, props: PropDecl[] | null) => {
|
|
|
1101
1315
|
})
|
|
1102
1316
|
}
|
|
1103
1317
|
|
|
1318
|
+
// every prop name a component's scripts declare, across both script modes.
|
|
1319
|
+
// Read before the store exists, because what a component declares decides
|
|
1320
|
+
// which of its file's sibling components it can still see: declaring a name
|
|
1321
|
+
// says it comes from the parent, so the file's own definition of that name is
|
|
1322
|
+
// deliberately not in this component's scope
|
|
1323
|
+
const declaredPropNames = (scripts: TagBlock[]): Set<string> => {
|
|
1324
|
+
const names = new Set<string>()
|
|
1325
|
+
scripts.forEach(script => {
|
|
1326
|
+
const declarations = parseFactoryProps(script.content) ?? parsePropsPattern(script.attrs[":setup"])
|
|
1327
|
+
declarations?.forEach(({ name }) => names.add(name))
|
|
1328
|
+
})
|
|
1329
|
+
return names
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
// the sibling components this one resolves by name, or null when there are
|
|
1333
|
+
// none left to resolve. They go on the store's *prototype* rather than in it:
|
|
1334
|
+
// the component-key scan walks the chain, so <Row> resolves; they stay out of
|
|
1335
|
+
// the data, so Object.keys, snapshots and spreads never see them; and an own
|
|
1336
|
+
// key shadows a prototype one, so a prop the parent did pass wins for free
|
|
1337
|
+
const siblingsInScope = (
|
|
1338
|
+
siblings: Record<string, Component79> | undefined,
|
|
1339
|
+
declared: Set<string>
|
|
1340
|
+
): Record<string, Component79> | null => {
|
|
1341
|
+
if (!siblings) return null
|
|
1342
|
+
// null-prototype, for the same reason storeApi is: `key in scope` must not
|
|
1343
|
+
// start answering true for toString, constructor and the rest
|
|
1344
|
+
const inScope: Record<string, Component79> = Object.create(null)
|
|
1345
|
+
let any = false
|
|
1346
|
+
Object.entries(siblings).forEach(([name, component]) => {
|
|
1347
|
+
if (declared.has(name)) return
|
|
1348
|
+
inScope[name] = component
|
|
1349
|
+
any = true
|
|
1350
|
+
})
|
|
1351
|
+
return any ? inScope : null
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// names a component declared as props and the parent passed nothing for. Such
|
|
1355
|
+
// a name can never become a component later - there is no binding on the tag
|
|
1356
|
+
// to update it - so a <Tag> reading one is a wiring mistake that can be named
|
|
1357
|
+
// on sight, unlike the `undefined` of an import still in flight. Symbol-keyed
|
|
1358
|
+
// and non-enumerable: it rides the scope chain (so an :each item scope finds
|
|
1359
|
+
// it too) without ever showing up as data
|
|
1360
|
+
const UNFILLED_PROPS = Symbol("jq79.unfilledProps")
|
|
1361
|
+
|
|
1104
1362
|
// default-import interop for factory scripts: real modules expose .default,
|
|
1105
1363
|
// while importing an .html component resolves to the Component79 itself
|
|
1106
1364
|
const interopDefault = (mod: any) => (mod && mod.default !== undefined ? mod.default : mod)
|
|
@@ -1205,6 +1463,13 @@ export const hotUpdate = (filename: string, src: string): number => {
|
|
|
1205
1463
|
// parsed once and shared by every instance - which is already what a
|
|
1206
1464
|
// definition and the clones :component makes from it do
|
|
1207
1465
|
const parts = parseComponentString(src)
|
|
1466
|
+
// the file is the hot-reload unit, so one reparse serves every component it
|
|
1467
|
+
// declares: an instance is handed the parts of the component it *is*, by
|
|
1468
|
+
// name. A name that is no longer in the file (a <template> renamed or
|
|
1469
|
+
// deleted) has no parts to be given, and only a reload can fix the page
|
|
1470
|
+
let orphaned = false
|
|
1471
|
+
const partsFor = (instance: Component79): ComponentParts | null =>
|
|
1472
|
+
instance.name === undefined ? parts : parts.siblings?.[instance.name] ?? null
|
|
1208
1473
|
|
|
1209
1474
|
let rerendered = 0
|
|
1210
1475
|
for (const [name, refs] of hotRegistry) {
|
|
@@ -1215,11 +1480,16 @@ export const hotUpdate = (filename: string, src: string): number => {
|
|
|
1215
1480
|
refs.delete(ref) // collected since the last update
|
|
1216
1481
|
continue
|
|
1217
1482
|
}
|
|
1218
|
-
|
|
1483
|
+
const next = partsFor(instance)
|
|
1484
|
+
if (!next) {
|
|
1485
|
+
orphaned = true
|
|
1486
|
+
continue
|
|
1487
|
+
}
|
|
1488
|
+
if (instance.hotReplace(next)) rerendered++
|
|
1219
1489
|
}
|
|
1220
1490
|
if (!refs.size) hotRegistry.delete(name)
|
|
1221
1491
|
}
|
|
1222
|
-
return rerendered
|
|
1492
|
+
return orphaned ? 0 : rerendered
|
|
1223
1493
|
}
|
|
1224
1494
|
|
|
1225
1495
|
// starts tracking instances, so hotUpdate can find them. jq79/dev's client
|
|
@@ -1232,6 +1502,14 @@ export const enableHotReload = (): void => {
|
|
|
1232
1502
|
|
|
1233
1503
|
type EmitListener = (event: CustomEvent, payload: any) => void
|
|
1234
1504
|
|
|
1505
|
+
const fetchComponent = async (url: string): Promise<Component79> => {
|
|
1506
|
+
const response = await fetch(url)
|
|
1507
|
+
if (!response.ok) throw new Error(`failed to fetch component from ${url}: ${response.status}`)
|
|
1508
|
+
// the URL names the component's scripts in devtools, and is where the
|
|
1509
|
+
// browser will look for the source when a breakpoint lands in one
|
|
1510
|
+
return new Component79(await response.text(), { filename: url })
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1235
1513
|
// a parsed single-file component. Typical lifecycle:
|
|
1236
1514
|
//
|
|
1237
1515
|
// const jq79 = new Component79(src) // or await Component79.fetch(url)
|
|
@@ -1249,6 +1527,14 @@ export class Component79 {
|
|
|
1249
1527
|
modules?: Record<string, any>
|
|
1250
1528
|
// the component's origin, used to name its scripts in devtools
|
|
1251
1529
|
filename?: string
|
|
1530
|
+
// the other components declared in the same file, by name (see
|
|
1531
|
+
// ComponentParts.siblings). They are also this definition's own properties,
|
|
1532
|
+
// so `const { Row } = await Component79.fetch(url)` reaches them
|
|
1533
|
+
siblings?: Record<string, Component79>
|
|
1534
|
+
// this component's name inside its file, for the components a <template>
|
|
1535
|
+
// declared; the file's own component has none - it is the default, and a
|
|
1536
|
+
// default is named by whoever imports it
|
|
1537
|
+
name?: string
|
|
1252
1538
|
|
|
1253
1539
|
data: ReactiveDeepData<Record<string, any>> | null = null
|
|
1254
1540
|
|
|
@@ -1280,9 +1566,29 @@ export class Component79 {
|
|
|
1280
1566
|
this.styles = parts.styles
|
|
1281
1567
|
this.modules = options.modules ?? (typeof src === "string" ? undefined : src.modules)
|
|
1282
1568
|
this.filename = options.filename ?? (typeof src === "string" ? undefined : src.filename)
|
|
1569
|
+
this.siblings = parts.siblings
|
|
1570
|
+
this.name = parts.name
|
|
1571
|
+
this.adoptSiblings()
|
|
1283
1572
|
hotRegister(this) // a no-op unless the page enabled hot reload
|
|
1284
1573
|
}
|
|
1285
1574
|
|
|
1575
|
+
// the parser builds a file's sibling definitions before anyone has told it
|
|
1576
|
+
// where the file came from, so whoever holds the parse hands its origin down
|
|
1577
|
+
// - and keeps doing it after a hot reload, which parses the file afresh.
|
|
1578
|
+
// Without it a reloaded child would have no filename, and an instance with
|
|
1579
|
+
// no filename is not tracked: the next edit would never reach it
|
|
1580
|
+
private adoptSiblings() {
|
|
1581
|
+
if (!this.siblings) return
|
|
1582
|
+
Object.entries(this.siblings).forEach(([name, sibling]) => {
|
|
1583
|
+
sibling.filename ??= this.filename
|
|
1584
|
+
sibling.modules ??= this.modules
|
|
1585
|
+
// the file's own component also *is* the file: its named components hang
|
|
1586
|
+
// off it as properties, which is what `const { Row } = …` reads (and
|
|
1587
|
+
// what the bundler re-exports by name)
|
|
1588
|
+
if (!this.name) (this as any)[name] = sibling
|
|
1589
|
+
})
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1286
1592
|
// swaps this component's parsed parts for `src`'s and, if it is on the page,
|
|
1287
1593
|
// re-renders it where it stands - seeded with a snapshot of its data, so
|
|
1288
1594
|
// props and store values survive (the setup script runs again, so whatever it
|
|
@@ -1318,6 +1624,11 @@ export class Component79 {
|
|
|
1318
1624
|
this.template = parts.template
|
|
1319
1625
|
this.scripts = parts.scripts
|
|
1320
1626
|
this.styles = parts.styles
|
|
1627
|
+
// the file's other components as they are now: the next render resolves
|
|
1628
|
+
// <Row> against these, so a parent picks up an edited child even when the
|
|
1629
|
+
// child's own instances are patched separately
|
|
1630
|
+
this.siblings = parts.siblings
|
|
1631
|
+
this.adoptSiblings()
|
|
1321
1632
|
if (!rendered) return false // a definition: its clones re-render themselves
|
|
1322
1633
|
|
|
1323
1634
|
this.renderWith(data, shadow)
|
|
@@ -1332,12 +1643,13 @@ export class Component79 {
|
|
|
1332
1643
|
return true
|
|
1333
1644
|
}
|
|
1334
1645
|
|
|
1335
|
-
static
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1646
|
+
static fetch(url: string): Promise<Component79>
|
|
1647
|
+
static fetch(urls: string[]): Promise<Component79[]>
|
|
1648
|
+
// an array of URLs fetches them all at once and resolves to the components in
|
|
1649
|
+
// the same order, so one await destructures them - and, like Promise.all, the
|
|
1650
|
+
// first failure rejects the whole thing
|
|
1651
|
+
static fetch(urls: string | string[]): Promise<Component79 | Component79[]> {
|
|
1652
|
+
return Array.isArray(urls) ? Promise.all(urls.map(fetchComponent)) : fetchComponent(urls)
|
|
1341
1653
|
}
|
|
1342
1654
|
|
|
1343
1655
|
// subscribes to this instance's $emit events, on top of the DOM CustomEvent
|
|
@@ -1368,7 +1680,18 @@ export class Component79 {
|
|
|
1368
1680
|
private renderWith(data: Record<string, any>, shadow: boolean): this {
|
|
1369
1681
|
this.destroy()
|
|
1370
1682
|
|
|
1371
|
-
|
|
1683
|
+
// what this component can see of its file's other components, and which of
|
|
1684
|
+
// its declared props arrived empty - both decided by the signature, before
|
|
1685
|
+
// the store exists (see siblingsInScope / UNFILLED_PROPS)
|
|
1686
|
+
const declared = declaredPropNames(this.scripts)
|
|
1687
|
+
const siblingScope = siblingsInScope(this.siblings, declared)
|
|
1688
|
+
const raw: Record<string, any> = siblingScope
|
|
1689
|
+
? Object.assign(Object.create(siblingScope), data)
|
|
1690
|
+
: { ...data }
|
|
1691
|
+
const unfilled = new Set([...declared].filter(name => !(name in data)))
|
|
1692
|
+
if (unfilled.size) Object.defineProperty(raw, UNFILLED_PROPS, { value: unfilled })
|
|
1693
|
+
|
|
1694
|
+
const store = $reactive(raw)
|
|
1372
1695
|
const fx = createEffectScope(store)
|
|
1373
1696
|
this.data = store
|
|
1374
1697
|
this.fx = fx
|
|
@@ -1444,7 +1767,13 @@ export class Component79 {
|
|
|
1444
1767
|
const defer = (code: string) => `await $mounted();${code}`
|
|
1445
1768
|
|
|
1446
1769
|
this.scripts.forEach((script, index) => {
|
|
1447
|
-
|
|
1770
|
+
// the file's other components are passed as parameters of the compiled
|
|
1771
|
+
// script, not just left on the store's prototype: a factory script runs
|
|
1772
|
+
// as plain lexical JS with no `with`, so a bare `Row` in one would
|
|
1773
|
+
// resolve to nothing at all. In setup mode this composes with `with` -
|
|
1774
|
+
// scriptScope's `has` declines any name that is a helper, so the
|
|
1775
|
+
// parameter is what the name resolves to
|
|
1776
|
+
const instanceHelpers = { $emit, $mounted, $self, $$self, ...siblingScope }
|
|
1448
1777
|
const at: ScriptLocation = { filename: this.filename, index }
|
|
1449
1778
|
const factoryCode = transformFactoryScript(script.content)
|
|
1450
1779
|
if (factoryCode !== null) {
|