jq79 0.4.17 → 0.4.18
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 +12 -12
- package/dist/jq79.cjs.map +1 -1
- package/dist/jq79.global.js +12 -12
- package/dist/jq79.global.js.map +1 -1
- package/dist/jq79.js +12 -12
- package/dist/jq79.js.map +1 -1
- package/package.json +1 -1
- package/src/jq79.ts +105 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jq79",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.18",
|
|
4
4
|
"description": "Mini reactive component library: single-file components, Svelte-style setup scripts, fine-grained proxy reactivity. Single-file build, zero dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"reactive",
|
package/src/jq79.ts
CHANGED
|
@@ -182,6 +182,12 @@ const wireTagEvent = (instance: Component79, attr: string, expr: string, scope:
|
|
|
182
182
|
|
|
183
183
|
const kebabToCamel = (name: string) => name.replace(/-(\w)/g, (_, c: string) => c.toUpperCase())
|
|
184
184
|
|
|
185
|
+
// the inverse, used only by the pre-parse name rewrite (see expandNameCase):
|
|
186
|
+
// uppercase ASCII letters only, never digits - `:props.0` is a generated
|
|
187
|
+
// attribute name and splitting on digits would mangle it. Round-trips through
|
|
188
|
+
// kebabToCamel, acronyms included: userID -> user-i-d -> userID
|
|
189
|
+
const camelToKebab = (name: string) => name.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)
|
|
190
|
+
|
|
185
191
|
// the stable boundaries of a rendered chunk. An element is its own handle, but
|
|
186
192
|
// a fragment (a nested component: two anchors with the instance's DOM between
|
|
187
193
|
// them) empties itself into the parent on insertion - after that its identity
|
|
@@ -289,10 +295,11 @@ type SlotMap = Record<string, SlotRenderer>
|
|
|
289
295
|
// nested component is handed
|
|
290
296
|
const SLOTS = Symbol("jq79.slots")
|
|
291
297
|
|
|
292
|
-
// <slot>, <slot.header-bar>: the hole and its name. Names
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
298
|
+
// <slot>, <slot.header-bar>: the hole and its name. Names arrive kebab-case
|
|
299
|
+
// whichever way they were authored (the HTML parser lowercases tag names and
|
|
300
|
+
// attribute modifiers alike, so expandNameCase normalizes camelCase to kebab
|
|
301
|
+
// before parsing) and are camelCase where read - <slot.header-bar> and
|
|
302
|
+
// <slot.headerBar> are :slot.header-bar is $slots.headerBar
|
|
296
303
|
const isSlotTag = (tag: string): boolean => tag === "slot" || tag.startsWith("slot.")
|
|
297
304
|
|
|
298
305
|
const slotName = (suffix: string): string => (suffix ? kebabToCamel(suffix) : "default")
|
|
@@ -515,8 +522,8 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
|
|
|
515
522
|
} else if (attr === ":model" || attr.startsWith(":model.")) {
|
|
516
523
|
// :model[.name]="expr" - two-way: a prop down plus a writeback listener
|
|
517
524
|
// (wired below, once the instance exists). The modifier arrives
|
|
518
|
-
//
|
|
519
|
-
// the bare :model binds the name "default"
|
|
525
|
+
// kebab-case whichever way it was authored (expandNameCase rewrote any
|
|
526
|
+
// camelCase before parsing); the bare :model binds the name "default"
|
|
520
527
|
const name = attr === ":model" ? "default" : kebabToCamel(attr.slice(":model.".length))
|
|
521
528
|
models[name] = value || (attr === ":model" ? "model" : name)
|
|
522
529
|
} else if (attr.startsWith(":")) {
|
|
@@ -673,7 +680,12 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
|
|
|
673
680
|
// explicit re-emit)
|
|
674
681
|
events.forEach(([attr, expr]) => wireTagEvent(instance, attr, expr, scope))
|
|
675
682
|
|
|
676
|
-
|
|
683
|
+
// what this component actually takes, decided by its signature. Applied to
|
|
684
|
+
// every path that writes a prop - the seed here and both sync paths below -
|
|
685
|
+
// or an undeclared name would be filtered on the first render and reappear
|
|
686
|
+
// on the next update
|
|
687
|
+
const declared = declaredPropSet(instance.scripts)
|
|
688
|
+
const seed = pickDeclared(untracked(resolveProps), declared)
|
|
677
689
|
// mounting into a fragment attaches no shadow root of its own: a
|
|
678
690
|
// shadow-rendered child keeps its <style> elements inline, next to the DOM
|
|
679
691
|
// they style, and the parent's shadow root is what scopes both
|
|
@@ -709,7 +721,7 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
|
|
|
709
721
|
if (hasSpread) {
|
|
710
722
|
let written: string[] = []
|
|
711
723
|
syncFx.effect(() => {
|
|
712
|
-
const next = resolveProps()
|
|
724
|
+
const next = pickDeclared(resolveProps(), declared)
|
|
713
725
|
const nextKeys = Object.keys(next)
|
|
714
726
|
written.forEach(key => { if (!(key in next)) (instance.data as Record<string, any>)[key] = undefined })
|
|
715
727
|
nextKeys.forEach(key => { (instance.data as Record<string, any>)[key] = next[key] })
|
|
@@ -717,6 +729,7 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
|
|
|
717
729
|
})
|
|
718
730
|
} else {
|
|
719
731
|
Object.entries(props).forEach(([name, expr]) => {
|
|
732
|
+
if (declared !== null && !declared.has(name)) return
|
|
720
733
|
syncFx.effect(() => { (instance.data as Record<string, any>)[name] = evalExpr(expr, scope) })
|
|
721
734
|
})
|
|
722
735
|
}
|
|
@@ -1277,6 +1290,53 @@ const expandPropsSpread = (src: string): string =>
|
|
|
1277
1290
|
)
|
|
1278
1291
|
.join("")
|
|
1279
1292
|
|
|
1293
|
+
// a `:`-prefixed attribute name in name position, and a </slot.name> closing
|
|
1294
|
+
// tag. Both quote-aware for the same reason ATTR_SPREAD_RE is: a colon inside
|
|
1295
|
+
// a value (@click="a ? b : c", style="color: red") is not an attribute name
|
|
1296
|
+
const ATTR_NAME_RE = /"[^"]*"|'[^']*'|(^|\s)(:[\w.$-]+)/g
|
|
1297
|
+
const CLOSE_SLOT_RE = /<\/slot\.([\w.$-]+)(\s*)>/gi
|
|
1298
|
+
const SLOT_TAG_RE = /^slot\./i
|
|
1299
|
+
|
|
1300
|
+
// camelCase -> kebab-case for every name the HTML parser would lowercase,
|
|
1301
|
+
// BEFORE it gets the chance: `:firstName` would arrive as `:firstname` and
|
|
1302
|
+
// kebabToCamel (which is what reads these names back out) would have nothing
|
|
1303
|
+
// to un-kebab, so the prop, model or slot would silently land under the wrong
|
|
1304
|
+
// key. Rewriting to `:first-name` here means both spellings converge on the
|
|
1305
|
+
// same camelCase name downstream - the author picks, the runtime doesn't care.
|
|
1306
|
+
//
|
|
1307
|
+
// Runs FIRST among the pre-parse passes, which is what keeps it simple: it
|
|
1308
|
+
// never sees the `:props.<n>` that expandPropsSpread generates, and a
|
|
1309
|
+
// <slot.firstName /> is still one occurrence rather than the open+close pair
|
|
1310
|
+
// expandSelfClosingTags turns it into. Same defenses as the passes after it -
|
|
1311
|
+
// <script>/<style> bodies split out, only start-tag interiors scanned, quoted
|
|
1312
|
+
// values consumed whole.
|
|
1313
|
+
//
|
|
1314
|
+
// Two name positions, not one: attribute names (`:model.firstName`) and the
|
|
1315
|
+
// dotted tag names (`<slot.firstName>`), whose closing halves are rewritten
|
|
1316
|
+
// too or the parser sees a mismatched pair. Component tags are deliberately
|
|
1317
|
+
// left alone - findComponentKey already matches them case-insensitively with
|
|
1318
|
+
// dashes stripped, so <UserCard> needs no help and rewriting it would only
|
|
1319
|
+
// obscure what the author wrote
|
|
1320
|
+
const kebabTagName = (tag: string): string =>
|
|
1321
|
+
SLOT_TAG_RE.test(tag) ? `slot.${camelToKebab(tag.slice("slot.".length))}` : tag
|
|
1322
|
+
|
|
1323
|
+
const expandNameCase = (src: string): string =>
|
|
1324
|
+
src
|
|
1325
|
+
.split(RAW_BLOCK_RE)
|
|
1326
|
+
.map((chunk, i) =>
|
|
1327
|
+
i % 2 === 1
|
|
1328
|
+
? chunk
|
|
1329
|
+
: chunk
|
|
1330
|
+
.replace(OPEN_TAG_RE, (_match, tag: string, attrs: string) => {
|
|
1331
|
+
const rewritten = attrs.replace(ATTR_NAME_RE, (whole, space: string | undefined, name: string | undefined) =>
|
|
1332
|
+
name === undefined ? whole : `${space}${camelToKebab(name)}`
|
|
1333
|
+
)
|
|
1334
|
+
return `<${kebabTagName(tag)}${rewritten}>`
|
|
1335
|
+
})
|
|
1336
|
+
.replace(CLOSE_SLOT_RE, (_match, suffix: string, space: string) => `</slot.${camelToKebab(suffix)}${space}>`)
|
|
1337
|
+
)
|
|
1338
|
+
.join("")
|
|
1339
|
+
|
|
1280
1340
|
// <style scoped> support. Every element of the component's own template is
|
|
1281
1341
|
// stamped with data-jq79="<hash>" and the style's selectors are rewritten to
|
|
1282
1342
|
// require that attribute, so its rules can't reach anything the component
|
|
@@ -1367,10 +1427,13 @@ const parseComponentString = (component: string): ComponentParts => {
|
|
|
1367
1427
|
// </style>
|
|
1368
1428
|
|
|
1369
1429
|
// parsed as the content of a <template> so leading <script>/<style> tags
|
|
1370
|
-
// aren't reparented into <head> by the HTML parser.
|
|
1371
|
-
// rewrites run here
|
|
1372
|
-
//
|
|
1373
|
-
|
|
1430
|
+
// aren't reparented into <head> by the HTML parser. All three pre-DOM string
|
|
1431
|
+
// rewrites run here, and the order is load-bearing: camelCase names ->
|
|
1432
|
+
// kebab-case first (before `:props.<n>` exists to be mangled and while a
|
|
1433
|
+
// self-closing tag is still one occurrence), then `...expr` -> :props.<n>
|
|
1434
|
+
// (which reads the raw camelCase before the parser can lowercase names),
|
|
1435
|
+
// then self-closing tags
|
|
1436
|
+
const prepared = expandSelfClosingTags(expandPropsSpread(expandNameCase(component)))
|
|
1374
1437
|
const parsedDOM = new DOMParser().parseFromString(`<template>${prepared}</template>`, "text/html")
|
|
1375
1438
|
const root = parsedDOM.querySelector("template") as HTMLTemplateElement
|
|
1376
1439
|
|
|
@@ -1593,6 +1656,36 @@ const declaredPropNames = (scripts: TagBlock[]): Set<string> => {
|
|
|
1593
1656
|
return names
|
|
1594
1657
|
}
|
|
1595
1658
|
|
|
1659
|
+
// the same names, but null when NO script declared a signature at all - the
|
|
1660
|
+
// distinction declareProps already keeps, and the only one that can decide
|
|
1661
|
+
// whether to filter what a parent passes: `<script :setup>` declares nothing
|
|
1662
|
+
// and stays permissive, `<script :setup="{}">` is a closed signature that
|
|
1663
|
+
// declares zero props and takes none
|
|
1664
|
+
const declaredPropSet = (scripts: TagBlock[]): Set<string> | null => {
|
|
1665
|
+
let names: Set<string> | null = null
|
|
1666
|
+
scripts.forEach(script => {
|
|
1667
|
+
const declarations = parseFactoryProps(script.content) ?? parsePropsPattern(script.attrs[":setup"])
|
|
1668
|
+
if (!declarations) return
|
|
1669
|
+
const into = (names ??= new Set())
|
|
1670
|
+
declarations.forEach(({ name }) => into.add(name))
|
|
1671
|
+
})
|
|
1672
|
+
return names
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
// drops the props a component didn't declare, so an undeclared name is simply
|
|
1676
|
+
// absent from its store rather than quietly present: `{{ label }}` renders
|
|
1677
|
+
// empty and `{{ user.name }}` throws on the member access, both at the usage
|
|
1678
|
+
// site that got the name wrong. A null signature keeps everything - see
|
|
1679
|
+
// declaredPropSet. Silent by design: the main source of extra keys is a
|
|
1680
|
+
// `:props` spread of an object wider than the component (`...sdk`), where
|
|
1681
|
+
// taking only the declared few is the point, not a mistake to report
|
|
1682
|
+
const pickDeclared = (props: Record<string, any>, declared: Set<string> | null): Record<string, any> => {
|
|
1683
|
+
if (declared === null) return props
|
|
1684
|
+
const out: Record<string, any> = {}
|
|
1685
|
+
Object.keys(props).forEach(key => { if (declared.has(key)) out[key] = props[key] })
|
|
1686
|
+
return out
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1596
1689
|
// the sibling components this one resolves by name, or null when there are
|
|
1597
1690
|
// none left to resolve. They go on the store's *prototype* rather than in it:
|
|
1598
1691
|
// the component-key scan walks the chain, so <Row> resolves; they stay out of
|