jq79 0.4.11 → 0.4.12
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 +106 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jq79",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.12",
|
|
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
|
@@ -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 `.`
|
|
@@ -241,10 +242,24 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
|
|
|
241
242
|
const props: Record<string, string> = {} // prop name -> expression in parent scope
|
|
242
243
|
const models: Record<string, string> = {} // model name -> assignable expression in parent scope
|
|
243
244
|
const events: Array<[string, string]> = [] // @attr (modifiers included) -> handler expression
|
|
245
|
+
// named props AND spreads in source order - what a :props merge folds over so
|
|
246
|
+
// precedence follows the JS object-spread rule (later wins). `name` absent
|
|
247
|
+
// marks a spread: the whole object's properties, not one binding
|
|
248
|
+
const sources: Array<{ name?: string; expr: string }> = []
|
|
249
|
+
let hasSpread = false
|
|
244
250
|
Object.entries(node.attrs).forEach(([attr, value]) => {
|
|
245
251
|
// the parent's scope stamp is stamped on every template element, this tag
|
|
246
252
|
// included - it's not a prop, and the child renders under its own scope
|
|
247
|
-
if (attr === SCOPE_ATTR
|
|
253
|
+
if (attr === SCOPE_ATTR) return
|
|
254
|
+
if (attr === ":props" || attr.startsWith(":props.")) {
|
|
255
|
+
// :props="obj" spreads obj's own properties as props; :props.<n> is one
|
|
256
|
+
// spread among several (the `...obj` sugar rewrites to it - see
|
|
257
|
+
// expandPropsSpread), the suffix only keeping the attribute names distinct
|
|
258
|
+
hasSpread = true
|
|
259
|
+
sources.push({ expr: value })
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
if (isControlAttr(attr)) return
|
|
248
263
|
if (attr.startsWith("@")) {
|
|
249
264
|
events.push([attr, value])
|
|
250
265
|
} else if (attr === ":model" || attr.startsWith(":model.")) {
|
|
@@ -257,8 +272,12 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
|
|
|
257
272
|
} else if (attr.startsWith(":")) {
|
|
258
273
|
const name = kebabToCamel(attr.slice(1))
|
|
259
274
|
props[name] = value || name
|
|
275
|
+
sources.push({ name, expr: value || name })
|
|
260
276
|
} else {
|
|
261
|
-
|
|
277
|
+
const name = kebabToCamel(attr)
|
|
278
|
+
const expr = JSON.stringify(value)
|
|
279
|
+
props[name] = expr
|
|
280
|
+
sources.push({ name, expr })
|
|
262
281
|
}
|
|
263
282
|
})
|
|
264
283
|
|
|
@@ -287,6 +306,25 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
|
|
|
287
306
|
}
|
|
288
307
|
})
|
|
289
308
|
|
|
309
|
+
// the full prop set the child gets, resolved in source order: each named prop
|
|
310
|
+
// sets one key, each spread merges an object's own properties, later sources
|
|
311
|
+
// overwriting earlier (the JS object-spread rule). :model bindings apply last,
|
|
312
|
+
// so they win - the same precedence the collision warning above promises. A
|
|
313
|
+
// spread expression that isn't an object contributes nothing (fail closed,
|
|
314
|
+
// like :with), so an `await`-pending object spreads once it resolves
|
|
315
|
+
const resolveProps = (): Record<string, any> => {
|
|
316
|
+
const out: Record<string, any> = {}
|
|
317
|
+
sources.forEach(({ name, expr }) => {
|
|
318
|
+
if (name !== undefined) out[name] = evalExpr(expr, scope)
|
|
319
|
+
else {
|
|
320
|
+
const obj = evalExpr(expr, scope)
|
|
321
|
+
if (obj !== null && typeof obj === "object") Object.assign(out, obj)
|
|
322
|
+
}
|
|
323
|
+
})
|
|
324
|
+
Object.entries(models).forEach(([name, expr]) => { out[modelProp(name)] = evalExpr(expr, scope) })
|
|
325
|
+
return out
|
|
326
|
+
}
|
|
327
|
+
|
|
290
328
|
let current: Component79 | null = null
|
|
291
329
|
let currentDef: Component79 | null = null
|
|
292
330
|
let childFx: EffectScope | null = null
|
|
@@ -352,9 +390,7 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
|
|
|
352
390
|
// explicit re-emit)
|
|
353
391
|
events.forEach(([attr, expr]) => wireTagEvent(instance, attr, expr, scope))
|
|
354
392
|
|
|
355
|
-
const seed = untracked(
|
|
356
|
-
Object.fromEntries(Object.entries(props).map(([name, expr]) => [name, evalExpr(expr, scope)]))
|
|
357
|
-
)
|
|
393
|
+
const seed = untracked(resolveProps)
|
|
358
394
|
// mounting into a fragment attaches no shadow root of its own: a
|
|
359
395
|
// shadow-rendered child keeps its <style> elements inline, next to the DOM
|
|
360
396
|
// they style, and the parent's shadow root is what scopes both
|
|
@@ -363,9 +399,27 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
|
|
|
363
399
|
endAnchor.parentNode!.insertBefore(holder, endAnchor)
|
|
364
400
|
|
|
365
401
|
const syncFx = createEffectScope(scope)
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
402
|
+
// without a spread the prop set is fixed and known: one effect per prop, so
|
|
403
|
+
// a change to one prop re-syncs only that prop. A spread's key set is
|
|
404
|
+
// dynamic and its precedence is positional, so it can't be resolved a key at
|
|
405
|
+
// a time across independent effects (whichever re-ran last would win) - one
|
|
406
|
+
// effect re-merges everything in order and writes the diff, clearing keys a
|
|
407
|
+
// spread has dropped since last run. Named props are always in the merge, so
|
|
408
|
+
// they're never cleared; the extra cost is confined to spread-using tags
|
|
409
|
+
if (hasSpread) {
|
|
410
|
+
let written: string[] = []
|
|
411
|
+
syncFx.effect(() => {
|
|
412
|
+
const next = resolveProps()
|
|
413
|
+
const nextKeys = Object.keys(next)
|
|
414
|
+
written.forEach(key => { if (!(key in next)) (instance.data as Record<string, any>)[key] = undefined })
|
|
415
|
+
nextKeys.forEach(key => { (instance.data as Record<string, any>)[key] = next[key] })
|
|
416
|
+
written = nextKeys
|
|
417
|
+
})
|
|
418
|
+
} else {
|
|
419
|
+
Object.entries(props).forEach(([name, expr]) => {
|
|
420
|
+
syncFx.effect(() => { (instance.data as Record<string, any>)[name] = evalExpr(expr, scope) })
|
|
421
|
+
})
|
|
422
|
+
}
|
|
369
423
|
|
|
370
424
|
childFx = syncFx
|
|
371
425
|
current = instance
|
|
@@ -864,6 +918,41 @@ const expandSelfClosingTags = (src: string): string =>
|
|
|
864
918
|
)
|
|
865
919
|
.join("")
|
|
866
920
|
|
|
921
|
+
// a start tag with its attributes, quote-aware so a ">" inside a value doesn't
|
|
922
|
+
// end it early; and a single spread attribute in name position (preceded by
|
|
923
|
+
// start-or-whitespace), its expression an identifier or member path
|
|
924
|
+
const OPEN_TAG_RE = /<([A-Za-z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g
|
|
925
|
+
const ATTR_SPREAD_RE = /"[^"]*"|'[^']*'|(^|\s)\.\.\.([A-Za-z_$][\w$.]*)/g
|
|
926
|
+
|
|
927
|
+
// `...expr` as an attribute is sugar for :props="expr" (spread an object's
|
|
928
|
+
// properties as props - see renderNestedComponent). Rewritten BEFORE DOM
|
|
929
|
+
// parsing, into a value-based :props.<n>, because the HTML parser lowercases
|
|
930
|
+
// attribute *names*: with the expression in the name, `...userData` would arrive
|
|
931
|
+
// as `...userdata` and resolve to nothing. Moving it into a value - which the
|
|
932
|
+
// parser leaves untouched - keeps camelCase intact. Same pre-parse string move
|
|
933
|
+
// as expandSelfClosingTags, with the same defenses against rewriting code that
|
|
934
|
+
// only looks like a spread: <script>/<style> bodies are split out (a JS `...rest`
|
|
935
|
+
// there is not an attribute), only a start tag's interior is scanned (text
|
|
936
|
+
// between tags is safe), and quoted values are consumed whole so a genuine JS
|
|
937
|
+
// spread in a value (@click="f(...args)", :x="{ ...a }") is skipped. The <n>
|
|
938
|
+
// suffix (per tag) only keeps several spreads' attribute names distinct. A call
|
|
939
|
+
// (`...getProps()`) stops at the paren and is left alone - use :props="expr()"
|
|
940
|
+
const expandPropsSpread = (src: string): string =>
|
|
941
|
+
src
|
|
942
|
+
.split(RAW_BLOCK_RE)
|
|
943
|
+
.map((chunk, i) =>
|
|
944
|
+
i % 2 === 1
|
|
945
|
+
? chunk
|
|
946
|
+
: chunk.replace(OPEN_TAG_RE, (_match, tag: string, attrs: string) => {
|
|
947
|
+
let n = 0
|
|
948
|
+
const rewritten = attrs.replace(ATTR_SPREAD_RE, (whole, space: string | undefined, expr: string | undefined) =>
|
|
949
|
+
expr === undefined ? whole : `${space}:props.${n++}="${expr}"`
|
|
950
|
+
)
|
|
951
|
+
return `<${tag}${rewritten}>`
|
|
952
|
+
})
|
|
953
|
+
)
|
|
954
|
+
.join("")
|
|
955
|
+
|
|
867
956
|
// <style scoped> support. Every element of the component's own template is
|
|
868
957
|
// stamped with data-jq79="<hash>" and the style's selectors are rewritten to
|
|
869
958
|
// require that attribute, so its rules can't reach anything the component
|
|
@@ -947,8 +1036,11 @@ const parseComponentString = (component: string): ComponentParts => {
|
|
|
947
1036
|
// </style>
|
|
948
1037
|
|
|
949
1038
|
// parsed as the content of a <template> so leading <script>/<style> tags
|
|
950
|
-
// aren't reparented into <head> by the HTML parser
|
|
951
|
-
|
|
1039
|
+
// aren't reparented into <head> by the HTML parser. Both pre-DOM string
|
|
1040
|
+
// rewrites run here: `...expr` -> :props.<n>="expr" first (it reads the raw
|
|
1041
|
+
// camelCase before the parser can lowercase names), then self-closing tags
|
|
1042
|
+
const prepared = expandSelfClosingTags(expandPropsSpread(component))
|
|
1043
|
+
const parsedDOM = new DOMParser().parseFromString(`<template>${prepared}</template>`, "text/html")
|
|
952
1044
|
const root = parsedDOM.querySelector("template") as HTMLTemplateElement
|
|
953
1045
|
|
|
954
1046
|
const scripts: TagBlock[] = []
|