jq79 0.4.16 → 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/README.md +1 -1
- package/dist/jq79.cjs +12 -12
- package/dist/jq79.cjs.map +1 -1
- package/dist/jq79.d.ts +1 -0
- 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 +117 -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
|
@@ -7,6 +7,14 @@ import { transformSetupScript, transformFactoryScript, parsePropsPattern, parseF
|
|
|
7
7
|
|
|
8
8
|
export { $, $$, $create } from "./dom"
|
|
9
9
|
export { $reactive } from "./reactive"
|
|
10
|
+
|
|
11
|
+
// the package version, substituted at build time (tsup/vitest `define`, read
|
|
12
|
+
// from package.json - releases bump it there and nowhere else). The typeof
|
|
13
|
+
// guard is what keeps the raw source runnable: tests and any bundler that
|
|
14
|
+
// doesn't define it see a bare identifier, not a ReferenceError
|
|
15
|
+
declare const __JQ79_VERSION__: string
|
|
16
|
+
const VERSION = typeof __JQ79_VERSION__ === "string" ? __JQ79_VERSION__ : "0.0.0-dev"
|
|
17
|
+
|
|
10
18
|
type TemplateNode = {
|
|
11
19
|
tag: string
|
|
12
20
|
attrs: Record<string, string>
|
|
@@ -174,6 +182,12 @@ const wireTagEvent = (instance: Component79, attr: string, expr: string, scope:
|
|
|
174
182
|
|
|
175
183
|
const kebabToCamel = (name: string) => name.replace(/-(\w)/g, (_, c: string) => c.toUpperCase())
|
|
176
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
|
+
|
|
177
191
|
// the stable boundaries of a rendered chunk. An element is its own handle, but
|
|
178
192
|
// a fragment (a nested component: two anchors with the instance's DOM between
|
|
179
193
|
// them) empties itself into the parent on insertion - after that its identity
|
|
@@ -281,10 +295,11 @@ type SlotMap = Record<string, SlotRenderer>
|
|
|
281
295
|
// nested component is handed
|
|
282
296
|
const SLOTS = Symbol("jq79.slots")
|
|
283
297
|
|
|
284
|
-
// <slot>, <slot.header-bar>: the hole and its name. Names
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
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
|
|
288
303
|
const isSlotTag = (tag: string): boolean => tag === "slot" || tag.startsWith("slot.")
|
|
289
304
|
|
|
290
305
|
const slotName = (suffix: string): string => (suffix ? kebabToCamel(suffix) : "default")
|
|
@@ -507,8 +522,8 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
|
|
|
507
522
|
} else if (attr === ":model" || attr.startsWith(":model.")) {
|
|
508
523
|
// :model[.name]="expr" - two-way: a prop down plus a writeback listener
|
|
509
524
|
// (wired below, once the instance exists). The modifier arrives
|
|
510
|
-
//
|
|
511
|
-
// 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"
|
|
512
527
|
const name = attr === ":model" ? "default" : kebabToCamel(attr.slice(":model.".length))
|
|
513
528
|
models[name] = value || (attr === ":model" ? "model" : name)
|
|
514
529
|
} else if (attr.startsWith(":")) {
|
|
@@ -665,7 +680,12 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
|
|
|
665
680
|
// explicit re-emit)
|
|
666
681
|
events.forEach(([attr, expr]) => wireTagEvent(instance, attr, expr, scope))
|
|
667
682
|
|
|
668
|
-
|
|
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)
|
|
669
689
|
// mounting into a fragment attaches no shadow root of its own: a
|
|
670
690
|
// shadow-rendered child keeps its <style> elements inline, next to the DOM
|
|
671
691
|
// they style, and the parent's shadow root is what scopes both
|
|
@@ -701,7 +721,7 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
|
|
|
701
721
|
if (hasSpread) {
|
|
702
722
|
let written: string[] = []
|
|
703
723
|
syncFx.effect(() => {
|
|
704
|
-
const next = resolveProps()
|
|
724
|
+
const next = pickDeclared(resolveProps(), declared)
|
|
705
725
|
const nextKeys = Object.keys(next)
|
|
706
726
|
written.forEach(key => { if (!(key in next)) (instance.data as Record<string, any>)[key] = undefined })
|
|
707
727
|
nextKeys.forEach(key => { (instance.data as Record<string, any>)[key] = next[key] })
|
|
@@ -709,6 +729,7 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
|
|
|
709
729
|
})
|
|
710
730
|
} else {
|
|
711
731
|
Object.entries(props).forEach(([name, expr]) => {
|
|
732
|
+
if (declared !== null && !declared.has(name)) return
|
|
712
733
|
syncFx.effect(() => { (instance.data as Record<string, any>)[name] = evalExpr(expr, scope) })
|
|
713
734
|
})
|
|
714
735
|
}
|
|
@@ -1269,6 +1290,53 @@ const expandPropsSpread = (src: string): string =>
|
|
|
1269
1290
|
)
|
|
1270
1291
|
.join("")
|
|
1271
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
|
+
|
|
1272
1340
|
// <style scoped> support. Every element of the component's own template is
|
|
1273
1341
|
// stamped with data-jq79="<hash>" and the style's selectors are rewritten to
|
|
1274
1342
|
// require that attribute, so its rules can't reach anything the component
|
|
@@ -1359,10 +1427,13 @@ const parseComponentString = (component: string): ComponentParts => {
|
|
|
1359
1427
|
// </style>
|
|
1360
1428
|
|
|
1361
1429
|
// parsed as the content of a <template> so leading <script>/<style> tags
|
|
1362
|
-
// aren't reparented into <head> by the HTML parser.
|
|
1363
|
-
// rewrites run here
|
|
1364
|
-
//
|
|
1365
|
-
|
|
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)))
|
|
1366
1437
|
const parsedDOM = new DOMParser().parseFromString(`<template>${prepared}</template>`, "text/html")
|
|
1367
1438
|
const root = parsedDOM.querySelector("template") as HTMLTemplateElement
|
|
1368
1439
|
|
|
@@ -1585,6 +1656,36 @@ const declaredPropNames = (scripts: TagBlock[]): Set<string> => {
|
|
|
1585
1656
|
return names
|
|
1586
1657
|
}
|
|
1587
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
|
+
|
|
1588
1689
|
// the sibling components this one resolves by name, or null when there are
|
|
1589
1690
|
// none left to resolve. They go on the store's *prototype* rather than in it:
|
|
1590
1691
|
// the component-key scan walks the chain, so <Row> resolves; they stay out of
|
|
@@ -1775,6 +1876,10 @@ const fetchComponent = async (url: string): Promise<Component79> => {
|
|
|
1775
1876
|
// jq79.detach() // detach, keeping state - mount() re-attaches
|
|
1776
1877
|
// .destroy() // dispose effects and remove styles
|
|
1777
1878
|
export class Component79 {
|
|
1879
|
+
// the version of jq79 this class came from, so a page can tell which build it
|
|
1880
|
+
// loaded (a CDN <script> pins nothing on its own)
|
|
1881
|
+
static readonly version: string = VERSION
|
|
1882
|
+
|
|
1778
1883
|
template: TemplateNode[]
|
|
1779
1884
|
scripts: TagBlock[]
|
|
1780
1885
|
styles: TagBlock[]
|