jq79 0.4.12 → 0.4.14

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/src/jq79.ts CHANGED
@@ -1,7 +1,7 @@
1
1
 
2
2
  import { $, $$, $create, sanitizeHTML, allowedHosts } from "./dom"
3
3
  import type { AllowUrl } from "./dom"
4
- import { $reactive, untracked, createEffectScope } from "./reactive"
4
+ import { $reactive, untracked, createEffectScope, ALSO_WAKEN_BY } from "./reactive"
5
5
  import type { ReactiveDeepData, EffectScope } from "./reactive"
6
6
  import { transformSetupScript, transformFactoryScript, parsePropsPattern, parseFactoryProps, type PropDecl } from "./transform"
7
7
 
@@ -31,10 +31,16 @@ const elementAttrs = (el: Element): Record<string, string> =>
31
31
  // to decide what it's worth (nothing in a block or flex container, one space
32
32
  // between inline elements). Trimming it here, as this used to, silently glued
33
33
  // siblings together and ate the spaces in `hola <b>mundo</b> adios`
34
+ //
35
+ // A <template>'s children are read from its .content fragment: that is where
36
+ // the HTML parser puts them, and its childNodes are empty. Without the descent
37
+ // they are not in the AST at all - which is where slot content is written
38
+ // (<template :slot.name>), and why a nested <template> used to render as an
39
+ // empty element whatever was inside it
34
40
  const elementToAST = (el: Element): TemplateNode => ({
35
41
  tag: el.tagName.toLowerCase(),
36
42
  attrs: elementAttrs(el),
37
- children: Array.from(el.childNodes).flatMap((node): (TemplateNode | string)[] => {
43
+ children: Array.from((el instanceof HTMLTemplateElement ? el.content : el).childNodes).flatMap((node): (TemplateNode | string)[] => {
38
44
  if (node.nodeType === Node.TEXT_NODE) {
39
45
  const text = node.textContent ?? ""
40
46
  return text ? [text] : []
@@ -103,7 +109,8 @@ const CONTROL_ATTRS = new Set([":attrs", ":class", ":value", ":checked", ":selec
103
109
  // single-flag shorthand) and `:props.<n>` (one spread among several) are
104
110
  // open-ended, so they're matched by prefix - they can't be enumerated into the set
105
111
  const isControlAttr = (attr: string): boolean =>
106
- CONTROL_ATTRS.has(attr) || attr.startsWith(":class.") || attr.startsWith(":props.")
112
+ CONTROL_ATTRS.has(attr) || attr.startsWith(":class.") || attr.startsWith(":props.") ||
113
+ attr === ":slot" || attr.startsWith(":slot.")
107
114
  // `item in items`, `item, i in items`, `(value, key) in props` - the second
108
115
  // binding is the array index or the object key, parens optional (Vue-style).
109
116
  // The list expression can span lines, so it matches [\s\S] rather than `.`
@@ -216,6 +223,236 @@ const findComponentKey = (scope: Record<string, any>, tag: string): string | nul
216
223
  return null
217
224
  }
218
225
 
226
+ // how deep a component may nest inside itself before the runtime calls it a
227
+ // cycle. Deeper than any real tree, shallower than the JS stack: a truncated
228
+ // render with an error on the console beats a stack overflow with none
229
+ const MAX_NESTING_DEPTH = 200
230
+ let nestingDepth = 0
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // slots - content projection
234
+ //
235
+ // A component tag's children are content the child renders where it wrote a
236
+ // <slot>. The dot marks the named variant on both sides, like :model.<name>
237
+ // and :class.<name> already do:
238
+ //
239
+ // <!-- Card.html --> <!-- the parent -->
240
+ // <section> <Card>
241
+ // <header> <template :slot.header><h2>{{ t }}</h2></template>
242
+ // <slot.header>?</slot.header>
243
+ // </header> <p>{{ body }}</p>
244
+ // <slot /> </Card>
245
+ // </section>
246
+ //
247
+ // Three rules decide everything below:
248
+ //
249
+ // 1. Content belongs to the parent - its AST, its scope, its effects, its
250
+ // scoped styles. The child decides *where* it goes and *whether* it goes,
251
+ // never what the names in it mean.
252
+ // 2. Slot props are declared, not injected: `:slot="{ item }"` on the usage
253
+ // site, for the same reason :each writes `item in rows`. Every bare name in
254
+ // the parent's file is introduced by the parent, so a `<slot :item>` the
255
+ // child adds later can't silently capture one.
256
+ // 3. What isn't projected isn't rendered. No <slot>, or one behind a false
257
+ // :if, and the content's effects never exist.
258
+ //
259
+ // The content travels as a thunk, not as DOM: an instance is replaced (a
260
+ // definition swap, a hot reload) and one <slot> may render many times, so a
261
+ // pre-rendered fragment would leak effects and could only be inserted once
262
+ // ---------------------------------------------------------------------------
263
+
264
+ // renders one slot's content at the position the child put the <slot>: it is
265
+ // handed the slot's props (lazy, so each read re-evaluates in the child's
266
+ // scope), that position's scope and effect scope, and the style mode the
267
+ // child renders under
268
+ type SlotRenderer = (
269
+ props: Record<string, () => any>,
270
+ slotScope: Record<string, any>,
271
+ fx: EffectScope,
272
+ shadow: boolean
273
+ ) => Node
274
+
275
+ type SlotMap = Record<string, SlotRenderer>
276
+
277
+ // the content an instance was handed, by slot name. Symbol-keyed and
278
+ // non-enumerable on the store's data, like UNFILLED_PROPS: it rides the scope
279
+ // chain (so a <slot> inside an :each or a :with finds it) and never shows up
280
+ // as data - not in Object.keys, not in a snapshot spread, not in the props a
281
+ // nested component is handed
282
+ const SLOTS = Symbol("jq79.slots")
283
+
284
+ // <slot>, <slot.header-bar>: the hole and its name. Names are kebab-case where
285
+ // written (the HTML parser lowercases tag names and attribute modifiers alike)
286
+ // and camelCase where read - <slot.header-bar> is :slot.header-bar is
287
+ // $slots.headerBar
288
+ const isSlotTag = (tag: string): boolean => tag === "slot" || tag.startsWith("slot.")
289
+
290
+ const slotName = (suffix: string): string => (suffix ? kebabToCamel(suffix) : "default")
291
+
292
+ // the content of one slot, as written at the usage site
293
+ type SlotContent = { nodes: (TemplateNode | string)[]; binder?: string }
294
+
295
+ // the :slot attribute of a <template>, if it carries one
296
+ const slotAttrOf = (node: TemplateNode): string | undefined =>
297
+ Object.keys(node.attrs).find(attr => attr === ":slot" || attr.startsWith(":slot."))
298
+
299
+ const slotAttrName = (name: string) => (name === "default" ? ":slot" : `:slot.${name}`)
300
+
301
+ // whitespace-only text between two <template :slot> blocks is the indentation
302
+ // between them and nothing else - the same call renderNodes makes between the
303
+ // branches of an :if chain. It is what decides whether a tag has default
304
+ // content at all, which is what $slots.default answers
305
+ const isMeaningful = (node: TemplateNode | string): boolean => typeof node !== "string" || node.trim() !== ""
306
+
307
+ // a component tag's children, partitioned by slot name: a direct
308
+ // <template :slot.<name>> child fills that name, everything else is the
309
+ // default slot's content. The attribute's value is the pattern the content
310
+ // binds the slot's props to - on the tag itself for the default, since the
311
+ // default content has no <template> of its own to carry it
312
+ const partitionSlots = (node: TemplateNode): Record<string, SlotContent> => {
313
+ const contents: Record<string, SlotContent> = {}
314
+ const loose: (TemplateNode | string)[] = []
315
+
316
+ node.children.forEach(child => {
317
+ const attr = typeof child === "object" && child.tag === "template" ? slotAttrOf(child) : undefined
318
+ if (typeof child === "string" || attr === undefined) {
319
+ loose.push(child)
320
+ return
321
+ }
322
+ const name = slotName(attr.slice(":slot.".length))
323
+ // first wins, like two <template name="X"> in one file: a duplicate is a
324
+ // typo, and the fix is to delete one - not to guess which
325
+ if (name in contents) {
326
+ console.warn(`jq79: two <template ${slotAttrName(name)}> in <${node.tag}>; the second was ignored`)
327
+ return
328
+ }
329
+ contents[name] = { nodes: child.children, binder: child.attrs[attr] || undefined }
330
+ })
331
+
332
+ const hasLoose = loose.some(isMeaningful)
333
+ if (hasLoose && "default" in contents) {
334
+ console.warn(
335
+ `jq79: <${node.tag}> has both a <template :slot> and content outside it - ` +
336
+ "the <template> is the default slot's content, and the rest was ignored"
337
+ )
338
+ } else if (hasLoose) {
339
+ contents.default = { nodes: loose, binder: node.attrs[":slot"] || undefined }
340
+ }
341
+ return contents
342
+ }
343
+
344
+ // `:slot="{ item, index: i, total = 0 }"` - the names the content binds the
345
+ // slot's props to. The bindings are accessors, not values: each read
346
+ // re-evaluates the child's expression, so an effect that reads `item` tracks
347
+ // exactly what that expression touches, on every run (createWithScope's design)
348
+ const bindSlotProps = (scope: Record<string, any>, binder: string | undefined, props: Record<string, () => any>) => {
349
+ parsePropsPattern(binder)?.forEach(({ name, as, default: fallback }) => {
350
+ const local = as ?? name
351
+ Object.defineProperty(scope, local, {
352
+ enumerable: true,
353
+ configurable: true,
354
+ get: () => {
355
+ const value = props[name]?.()
356
+ return value === undefined && fallback !== undefined ? evalExpr(fallback, scope) : value
357
+ },
358
+ // a slot prop is the child's value: it arrives on every read and there
359
+ // is nowhere for a write to go. Silence would be worse - `with` swallows
360
+ // an assignment to a getter without a word
361
+ set: () => console.warn(`jq79: "${local}" is a slot prop - it comes from the component, so assigning to it does nothing`),
362
+ })
363
+ })
364
+ }
365
+
366
+ // a <template :slot> only fills a slot as a direct child of a component tag,
367
+ // where the usage site takes it out of the children before they are ever
368
+ // rendered (see partitionSlots). Anywhere else the position is a mistake, and
369
+ // rendering the content in place - in the wrong scope, into a <template>
370
+ // nobody clones - would be a strange way to say so. A comment rather than
371
+ // nothing: an :if branch needs a node to hold on to (see boundsOf)
372
+ const misplacedSlotContent = (node: TemplateNode): Node => {
373
+ const attr = slotAttrOf(node)
374
+ console.warn(`jq79: <template ${attr}> fills a slot only as a direct child of a component tag; here it rendered nothing`)
375
+ return document.createComment(`misplaced ${attr}`)
376
+ }
377
+
378
+ // what a usage site hands its instance: every slot it filled, as the thunk
379
+ // that renders it. Built once per site, and in one call - a component tag is
380
+ // on the stack while its whole subtree renders below it (a component that
381
+ // renders itself does this 200 deep), so the intermediates stay in here rather
382
+ // than in the frame that waits
383
+ const buildSlots = (node: TemplateNode, scope: Record<string, any>): SlotMap | null => {
384
+ const contents = Object.entries(partitionSlots(node))
385
+ if (!contents.length) return null
386
+ const slots: SlotMap = {}
387
+ contents.forEach(([name, content]) => { slots[name] = makeSlotRenderer(content, scope) })
388
+ return slots
389
+ }
390
+
391
+ // the thunk one slot's content becomes: the usage site closes over its AST and
392
+ // its scope, the child calls it wherever (and however many times) it renders
393
+ // the matching <slot>
394
+ const makeSlotRenderer = (content: SlotContent, parentScope: Record<string, any>): SlotRenderer =>
395
+ (props, slotScope, fx, shadow) => {
396
+ // the parent's scope, plus the names the content declared for the slot's
397
+ // props (rule 1: what the content says is decided where it was written)
398
+ const scope: Record<string, any> = Object.create(parentScope)
399
+ bindSlotProps(scope, content.binder, props)
400
+ // this content reads the parent's store (its own names) and the child's
401
+ // (through the slot props), so every effect created anywhere inside it is
402
+ // registered with both - see ALSO_WAKEN_BY. Appended rather than assigned:
403
+ // content forwarded through a <slot> inside slot content is still woken by
404
+ // the store it came from
405
+ const inherited: Record<string, any>[] = (scope as any)[ALSO_WAKEN_BY] ?? []
406
+ Object.defineProperty(scope, ALSO_WAKEN_BY, { value: [...inherited, slotScope] })
407
+
408
+ const contentFx = createEffectScope(scope)
409
+ // rule 3: the <slot> is the content's lifetime. When the child's subtree at
410
+ // this position goes - an :if turning false, the instance being replaced,
411
+ // the whole child being destroyed - the content's effects go with it
412
+ fx.onDispose(() => contentFx.dispose())
413
+ return renderNodes(content.nodes, scope, contentFx, shadow)
414
+ }
415
+
416
+ // <slot />, <slot.name>fallback</slot.name>: where the parent's content goes.
417
+ // Unfilled, the slot renders its own children instead - in this component's
418
+ // scope, since that content is this component's. Every attribute that isn't a
419
+ // directive is a slot prop: `:item="item"` evaluates here and reaches the
420
+ // content under the name it declared, a plain attribute passes a literal
421
+ // string, and there are no reserved names (the slot's own name is in the tag).
422
+ // Bracketed by anchors like a nested component, so the chunk has stable bounds
423
+ // even when it renders nothing (see boundsOf)
424
+ const renderSlot = (node: TemplateNode, scope: Record<string, any>, fx: EffectScope, shadow: boolean): Node => {
425
+ const name = slotName(node.tag.slice("slot.".length))
426
+ const wrapper = document.createDocumentFragment()
427
+ const anchor = document.createComment(node.tag)
428
+ const endAnchor = document.createComment(`/${node.tag}`)
429
+ wrapper.append(anchor, endAnchor)
430
+
431
+ const render = (scope as any)[SLOTS]?.[name] as SlotRenderer | undefined
432
+ if (!render) {
433
+ wrapper.insertBefore(renderNodes(node.children, scope, fx, shadow), endAnchor)
434
+ return wrapper
435
+ }
436
+
437
+ const props: Record<string, () => any> = {}
438
+ Object.entries(node.attrs).forEach(([attr, value]) => {
439
+ // the scope stamp is the component's, not a prop; @events have no element
440
+ // to bind here; and a directive means what it means everywhere else -
441
+ // :if/:each/:with decide whether and how often this slot renders, so they
442
+ // are the renderer's, not the content's
443
+ if (attr === SCOPE_ATTR || isControlAttr(attr) || attr.startsWith("@")) return
444
+ if (attr.startsWith(":")) {
445
+ const expr = value || attr.slice(1)
446
+ props[kebabToCamel(attr.slice(1))] = () => evalExpr(expr, scope)
447
+ } else {
448
+ props[kebabToCamel(attr)] = () => value
449
+ }
450
+ })
451
+
452
+ wrapper.insertBefore(render(props, scope, fx, shadow), endAnchor)
453
+ return wrapper
454
+ }
455
+
219
456
  // <MyComponent :user :title="'str'"></MyComponent> - renders a child
220
457
  // component instance at this position. Props: `:name="expr"` evaluates expr
221
458
  // in the parent scope (`:name` alone is shorthand for `:name="name"`), plain
@@ -239,6 +476,11 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
239
476
  const wrapper = document.createDocumentFragment()
240
477
  wrapper.append(anchor, endAnchor)
241
478
 
479
+ // the tag's children, as content for the child's <slot>s. Built once per
480
+ // usage site (the AST doesn't change) and closed over the parent's scope
481
+ // here, so every instance this site ever renders is handed the same thunks
482
+ const slots = buildSlots(node, scope)
483
+
242
484
  const props: Record<string, string> = {} // prop name -> expression in parent scope
243
485
  const models: Record<string, string> = {} // model name -> assignable expression in parent scope
244
486
  const events: Array<[string, string]> = [] // @attr (modifiers included) -> handler expression
@@ -329,9 +571,34 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
329
571
  let currentDef: Component79 | null = null
330
572
  let childFx: EffectScope | null = null
331
573
 
574
+ // a usage site that resolves to no component renders nothing, which is
575
+ // deliberate - `undefined` while an `await import(...)` is in flight has to
576
+ // wait quietly, and the child appears when it lands. Two cases can never
577
+ // resolve, though, and both are wiring mistakes worth naming: a value that
578
+ // isn't a component (and so will never become one by waiting), and a name
579
+ // the component declared as a prop that the parent passed nothing for. Once
580
+ // each, per usage site: an effect re-runs
581
+ const reported = new Set<string>()
582
+ const reportUnresolved = (value: any) => {
583
+ if (value === undefined || value === null) {
584
+ const unfilled: Set<string> | undefined = (scope as any)[UNFILLED_PROPS]
585
+ if (!unfilled?.has(key) || reported.has("unfilled")) return
586
+ reported.add("unfilled")
587
+ console.error(
588
+ `jq79: <${node.tag}> is declared as a prop and the parent passed nothing - nothing renders here. ` +
589
+ `Pass it (:${key}="…"), or drop it from the signature to use the one declared in this file.`
590
+ )
591
+ return
592
+ }
593
+ if (reported.has("type")) return
594
+ reported.add("type")
595
+ console.error(`jq79: <${node.tag}> is ${typeof value}, not a component - nothing renders here`)
596
+ }
597
+
332
598
  fx.effect(() => {
333
599
  const value = evalExpr(key, scope)
334
600
  const nextDef = value instanceof Component79 ? value : null
601
+ if (!nextDef) reportUnresolved(value)
335
602
  if (nextDef === currentDef) return
336
603
 
337
604
  childFx?.dispose()
@@ -349,7 +616,15 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
349
616
  styles: nextDef.styles,
350
617
  modules: nextDef.modules,
351
618
  filename: nextDef.filename,
619
+ // its file's other components, and which of them it is: without the
620
+ // first a child rendered here loses the siblings its definition could
621
+ // see, and without the second hot reload can't tell it what it is
622
+ siblings: nextDef.siblings,
623
+ name: nextDef.name,
352
624
  })
625
+ // the content this site wrote inside the tag, before the first render: a
626
+ // <slot> is resolved while rendering, so the map has to be there by then
627
+ if (slots) instance.slots = slots
353
628
  // the writeback half of :model - one event, one contract. The name is
354
629
  // normalized like the attribute was (kebab->camel; absent means default),
355
630
  // and everything off-contract warns and does nothing: an event protocol's
@@ -395,7 +670,24 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
395
670
  // shadow-rendered child keeps its <style> elements inline, next to the DOM
396
671
  // they style, and the parent's shadow root is what scopes both
397
672
  const holder = document.createDocumentFragment()
398
- ;(shadow ? instance.renderShadow(seed) : instance.render(seed)).mount(holder)
673
+ // rendering a child happens on this same stack, so a component that
674
+ // renders itself recurses as deep as its data does - and a cycle in that
675
+ // data would recurse until the JS stack gave out, ~900 identical frames
676
+ // naming nothing. Cut and named instead, exactly like the effect runner
677
+ // cuts an effect that wakes itself
678
+ if (nestingDepth >= MAX_NESTING_DEPTH) {
679
+ console.error(
680
+ `jq79: <${node.tag}> is ${MAX_NESTING_DEPTH} levels deep inside itself; giving up here. ` +
681
+ "A component that renders itself stops when its data stops - is there a cycle in it?"
682
+ )
683
+ return
684
+ }
685
+ nestingDepth++
686
+ try {
687
+ ;(shadow ? instance.renderShadow(seed) : instance.render(seed)).mount(holder)
688
+ } finally {
689
+ nestingDepth--
690
+ }
399
691
  endAnchor.parentNode!.insertBefore(holder, endAnchor)
400
692
 
401
693
  const syncFx = createEffectScope(scope)
@@ -515,6 +807,12 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
515
807
  const withExpr = node.attrs[":with"]
516
808
  const scope = withExpr !== undefined ? createWithScope(withExpr, outerScope) : outerScope
517
809
 
810
+ // before the component-key scan, so <slot> is <slot> even in a file that
811
+ // happens to have a component named Slot in scope: the tag is the library's
812
+ // now, and a name that resolved it away would be a very quiet surprise
813
+ if (isSlotTag(node.tag)) return renderSlot(node, scope, fx, shadow)
814
+ if (node.tag === "template" && slotAttrOf(node) !== undefined) return misplacedSlotContent(node)
815
+
518
816
  const componentKey = findComponentKey(scope, node.tag)
519
817
  if (componentKey) return renderNestedComponent(componentKey, node, scope, fx, shadow)
520
818
 
@@ -618,6 +916,13 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
618
916
  const options = allowedExpr !== undefined ? { allowUrl: normalizeAllowUrl(evalExpr(allowedExpr, scope)) } : undefined
619
917
  el.innerHTML = sanitizeHTML(String(evalExpr(htmlExpr, scope) ?? ""), options)
620
918
  })
919
+ } else if (el instanceof HTMLTemplateElement) {
920
+ // a plain nested <template> stays what HTML says it is: an inert element
921
+ // whose children live in .content, which is where whoever clones it looks
922
+ // for them. They render (bindings and all) and go there - appended as
923
+ // childNodes they would be in the DOM but in no document fragment, seen by
924
+ // nothing and rendered by nobody
925
+ el.content.appendChild(renderNodes(node.children, scope, fx, shadow))
621
926
  } else {
622
927
  el.appendChild(renderNodes(node.children, scope, fx, shadow))
623
928
  }
@@ -889,6 +1194,15 @@ type ComponentParts = {
889
1194
  // where this component came from (a URL for fetch(), a path for the vite
890
1195
  // plugin). Names the setup scripts in devtools - see scriptSourceUrl
891
1196
  filename?: string
1197
+ // the components the file's <template name="..."> blocks declared, by name.
1198
+ // Every component parsed out of one file holds this same map - itself
1199
+ // included - which is what makes a sibling usable without an import, and
1200
+ // what lets a <template name="TreeNode"> render a <TreeNode>
1201
+ siblings?: Record<string, Component79>
1202
+ // which of the file's components this is: a template's name, or undefined
1203
+ // for the file's own. The file is the hot-reload unit, so a reparse hands
1204
+ // each live instance the parts belonging to the component it is
1205
+ name?: string
892
1206
  }
893
1207
 
894
1208
  const VOID_ELEMENTS = new Set([
@@ -897,8 +1211,10 @@ const VOID_ELEMENTS = new Set([
897
1211
  ])
898
1212
 
899
1213
  // a self-closing tag with its attributes; quoted attribute values are matched
900
- // as whole chunks so a "/>" inside one doesn't end the tag early
901
- const SELF_CLOSING_RE = /<([A-Za-z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)\/>/g
1214
+ // as whole chunks so a "/>" inside one doesn't end the tag early. The tag name
1215
+ // admits a dot for the named forms of a tag - <slot.header /> - which is a
1216
+ // legal HTML tag name (the tokenizer reads to the first space, "/" or ">")
1217
+ const SELF_CLOSING_RE = /<([A-Za-z][\w.-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)\/>/g
902
1218
  const RAW_BLOCK_RE = /(<script[\s\S]*?<\/script\s*>|<style[\s\S]*?<\/style\s*>)/gi
903
1219
 
904
1220
  // expands self-closing tags (<MyComponent />, <div />) into explicit
@@ -921,7 +1237,7 @@ const expandSelfClosingTags = (src: string): string =>
921
1237
  // a start tag with its attributes, quote-aware so a ">" inside a value doesn't
922
1238
  // end it early; and a single spread attribute in name position (preceded by
923
1239
  // start-or-whitespace), its expression an identifier or member path
924
- const OPEN_TAG_RE = /<([A-Za-z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g
1240
+ const OPEN_TAG_RE = /<([A-Za-z][\w.-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g
925
1241
  const ATTR_SPREAD_RE = /"[^"]*"|'[^']*'|(^|\s)\.\.\.([A-Za-z_$][\w$.]*)/g
926
1242
 
927
1243
  // `...expr` as an attribute is sugar for :props="expr" (spread an object's
@@ -1015,9 +1331,16 @@ const scopeCss = (css: string, scope: string): string => {
1015
1331
  return Array.from(sheet.cssRules).map(rule => rule.cssText).join("\n")
1016
1332
  }
1017
1333
 
1334
+ // a component name has to be PascalCase to be usable: findComponentKey only
1335
+ // ever considers capitalized scope keys, so a lowercase name would declare a
1336
+ // component no tag could reference. It is also what keeps the named exports
1337
+ // from colliding with a definition's own fields, which are all lowercase
1338
+ const COMPONENT_NAME_RE = /^[A-Z][A-Za-z0-9]*$/
1339
+
1018
1340
  // converts a string of HTML into an AST representation of the component:
1019
1341
  // - template: the non-script/style top-level elements, as TemplateNodes
1020
1342
  // - scripts/styles: { attrs, content } blocks in source order
1343
+ // - siblings: the components its top-level <template name="..."> declared
1021
1344
  const parseComponentString = (component: string): ComponentParts => {
1022
1345
  // example
1023
1346
  // <script :setup="{ fname, lname }">
@@ -1043,11 +1366,64 @@ const parseComponentString = (component: string): ComponentParts => {
1043
1366
  const parsedDOM = new DOMParser().parseFromString(`<template>${prepared}</template>`, "text/html")
1044
1367
  const root = parsedDOM.querySelector("template") as HTMLTemplateElement
1045
1368
 
1369
+ // a top-level <template> declares another component of this file; everything
1370
+ // else is this one's own
1371
+ const own: Element[] = []
1372
+ const declarations: HTMLTemplateElement[] = []
1373
+ Array.from(root.content.children).forEach(el => {
1374
+ if (el.tagName === "TEMPLATE") declarations.push(el as HTMLTemplateElement)
1375
+ else own.push(el)
1376
+ })
1377
+
1378
+ // the file's own component hashes the whole file: every component in it
1379
+ // re-renders on any edit anyway (the file is the hot-reload unit), so a
1380
+ // stamp that changes when a sibling is edited costs nothing, and scopeHash
1381
+ // gets to keep hashing the source it was handed
1382
+ const parts = componentPartsFrom(own, component)
1383
+
1384
+ // one map, shared by reference: it is filled below, after each definition
1385
+ // has already been handed it, so every component of the file sees all the
1386
+ // others *and itself* - which is what makes a recursive component possible
1387
+ const siblings: Record<string, Component79> = {}
1388
+ declarations.forEach(el => {
1389
+ const name = el.getAttribute("name")
1390
+ // ignored rather than fatal, like every other malformed thing here: a bad
1391
+ // save mid-typing must not take the page down, least of all under HMR
1392
+ if (name === null) {
1393
+ console.warn("jq79: a top-level <template> without a name declares nothing and was ignored")
1394
+ return
1395
+ }
1396
+ if (!COMPONENT_NAME_RE.test(name)) {
1397
+ console.warn(
1398
+ `jq79: <template name="${name}"> was ignored - a component name has to be PascalCase, ` +
1399
+ "or no tag could ever reference it (only capitalized names resolve as components)"
1400
+ )
1401
+ return
1402
+ }
1403
+ if (name in siblings) {
1404
+ console.warn(`jq79: two <template name="${name}"> in one file; the second was ignored`)
1405
+ return
1406
+ }
1407
+ // its own source is its own scope: a named template is a shadow root
1408
+ // inside a shadow root, so the file's scoped rules stop at its boundary
1409
+ // and its own stop there too
1410
+ siblings[name] = new Component79({ ...componentPartsFrom(Array.from(el.content.children), el.innerHTML), siblings, name })
1411
+ })
1412
+ if (Object.keys(siblings).length) parts.siblings = siblings
1413
+
1414
+ return parts
1415
+ }
1416
+
1417
+ // the script/style/markup split of one component's top-level elements, with
1418
+ // <style scoped> resolved against the source those elements came from - the
1419
+ // whole file for its own component, a <template>'s contents for a named one,
1420
+ // so the two get different stamps and neither can style the other
1421
+ const componentPartsFrom = (elements: Element[], hashSource: string): ComponentParts => {
1046
1422
  const scripts: TagBlock[] = []
1047
1423
  const styles: TagBlock[] = []
1048
1424
  const template: TemplateNode[] = []
1049
1425
 
1050
- Array.from(root.content.children).forEach(el => {
1426
+ elements.forEach(el => {
1051
1427
  const block: TagBlock = { attrs: elementAttrs(el), content: el.textContent ?? "" }
1052
1428
 
1053
1429
  if (el.tagName === "SCRIPT") scripts.push(block)
@@ -1074,7 +1450,7 @@ const parseComponentString = (component: string): ComponentParts => {
1074
1450
  // in something that isn't CSS yet would only garble what devtools shows
1075
1451
  const isScoped = (style: TagBlock) => "scoped" in style.attrs && !("lang" in style.attrs)
1076
1452
  if (styles.some(isScoped)) {
1077
- const scope = scopeHash(component)
1453
+ const scope = scopeHash(hashSource)
1078
1454
  stampScope(template, scope)
1079
1455
  styles.forEach(style => {
1080
1456
  if (isScoped(style)) style.scoped = scopeCss(style.content, scope)
@@ -1193,6 +1569,50 @@ const declareProps = (store: Record<string, any>, props: PropDecl[] | null) => {
1193
1569
  })
1194
1570
  }
1195
1571
 
1572
+ // every prop name a component's scripts declare, across both script modes.
1573
+ // Read before the store exists, because what a component declares decides
1574
+ // which of its file's sibling components it can still see: declaring a name
1575
+ // says it comes from the parent, so the file's own definition of that name is
1576
+ // deliberately not in this component's scope
1577
+ const declaredPropNames = (scripts: TagBlock[]): Set<string> => {
1578
+ const names = new Set<string>()
1579
+ scripts.forEach(script => {
1580
+ const declarations = parseFactoryProps(script.content) ?? parsePropsPattern(script.attrs[":setup"])
1581
+ declarations?.forEach(({ name }) => names.add(name))
1582
+ })
1583
+ return names
1584
+ }
1585
+
1586
+ // the sibling components this one resolves by name, or null when there are
1587
+ // none left to resolve. They go on the store's *prototype* rather than in it:
1588
+ // the component-key scan walks the chain, so <Row> resolves; they stay out of
1589
+ // the data, so Object.keys, snapshots and spreads never see them; and an own
1590
+ // key shadows a prototype one, so a prop the parent did pass wins for free
1591
+ const siblingsInScope = (
1592
+ siblings: Record<string, Component79> | undefined,
1593
+ declared: Set<string>
1594
+ ): Record<string, Component79> | null => {
1595
+ if (!siblings) return null
1596
+ // null-prototype, for the same reason storeApi is: `key in scope` must not
1597
+ // start answering true for toString, constructor and the rest
1598
+ const inScope: Record<string, Component79> = Object.create(null)
1599
+ let any = false
1600
+ Object.entries(siblings).forEach(([name, component]) => {
1601
+ if (declared.has(name)) return
1602
+ inScope[name] = component
1603
+ any = true
1604
+ })
1605
+ return any ? inScope : null
1606
+ }
1607
+
1608
+ // names a component declared as props and the parent passed nothing for. Such
1609
+ // a name can never become a component later - there is no binding on the tag
1610
+ // to update it - so a <Tag> reading one is a wiring mistake that can be named
1611
+ // on sight, unlike the `undefined` of an import still in flight. Symbol-keyed
1612
+ // and non-enumerable: it rides the scope chain (so an :each item scope finds
1613
+ // it too) without ever showing up as data
1614
+ const UNFILLED_PROPS = Symbol("jq79.unfilledProps")
1615
+
1196
1616
  // default-import interop for factory scripts: real modules expose .default,
1197
1617
  // while importing an .html component resolves to the Component79 itself
1198
1618
  const interopDefault = (mod: any) => (mod && mod.default !== undefined ? mod.default : mod)
@@ -1297,6 +1717,13 @@ export const hotUpdate = (filename: string, src: string): number => {
1297
1717
  // parsed once and shared by every instance - which is already what a
1298
1718
  // definition and the clones :component makes from it do
1299
1719
  const parts = parseComponentString(src)
1720
+ // the file is the hot-reload unit, so one reparse serves every component it
1721
+ // declares: an instance is handed the parts of the component it *is*, by
1722
+ // name. A name that is no longer in the file (a <template> renamed or
1723
+ // deleted) has no parts to be given, and only a reload can fix the page
1724
+ let orphaned = false
1725
+ const partsFor = (instance: Component79): ComponentParts | null =>
1726
+ instance.name === undefined ? parts : parts.siblings?.[instance.name] ?? null
1300
1727
 
1301
1728
  let rerendered = 0
1302
1729
  for (const [name, refs] of hotRegistry) {
@@ -1307,11 +1734,16 @@ export const hotUpdate = (filename: string, src: string): number => {
1307
1734
  refs.delete(ref) // collected since the last update
1308
1735
  continue
1309
1736
  }
1310
- if (instance.hotReplace(parts)) rerendered++
1737
+ const next = partsFor(instance)
1738
+ if (!next) {
1739
+ orphaned = true
1740
+ continue
1741
+ }
1742
+ if (instance.hotReplace(next)) rerendered++
1311
1743
  }
1312
1744
  if (!refs.size) hotRegistry.delete(name)
1313
1745
  }
1314
- return rerendered
1746
+ return orphaned ? 0 : rerendered
1315
1747
  }
1316
1748
 
1317
1749
  // starts tracking instances, so hotUpdate can find them. jq79/dev's client
@@ -1324,6 +1756,14 @@ export const enableHotReload = (): void => {
1324
1756
 
1325
1757
  type EmitListener = (event: CustomEvent, payload: any) => void
1326
1758
 
1759
+ const fetchComponent = async (url: string): Promise<Component79> => {
1760
+ const response = await fetch(url)
1761
+ if (!response.ok) throw new Error(`failed to fetch component from ${url}: ${response.status}`)
1762
+ // the URL names the component's scripts in devtools, and is where the
1763
+ // browser will look for the source when a breakpoint lands in one
1764
+ return new Component79(await response.text(), { filename: url })
1765
+ }
1766
+
1327
1767
  // a parsed single-file component. Typical lifecycle:
1328
1768
  //
1329
1769
  // const jq79 = new Component79(src) // or await Component79.fetch(url)
@@ -1341,6 +1781,20 @@ export class Component79 {
1341
1781
  modules?: Record<string, any>
1342
1782
  // the component's origin, used to name its scripts in devtools
1343
1783
  filename?: string
1784
+ // the other components declared in the same file, by name (see
1785
+ // ComponentParts.siblings). They are also this definition's own properties,
1786
+ // so `const { Row } = await Component79.fetch(url)` reaches them
1787
+ siblings?: Record<string, Component79>
1788
+ // this component's name inside its file, for the components a <template>
1789
+ // declared; the file's own component has none - it is the default, and a
1790
+ // default is named by whoever imports it
1791
+ name?: string
1792
+ // the content the usage site handed this instance, by slot name (see the
1793
+ // slots section). Not part of a definition - it belongs to the tag that
1794
+ // wrote it - so renderNestedComponent sets it on the instance it creates,
1795
+ // and every render reads it from here: a hot reload re-renders from a data
1796
+ // snapshot, which a symbol on the store would not survive
1797
+ slots?: SlotMap
1344
1798
 
1345
1799
  data: ReactiveDeepData<Record<string, any>> | null = null
1346
1800
 
@@ -1372,9 +1826,29 @@ export class Component79 {
1372
1826
  this.styles = parts.styles
1373
1827
  this.modules = options.modules ?? (typeof src === "string" ? undefined : src.modules)
1374
1828
  this.filename = options.filename ?? (typeof src === "string" ? undefined : src.filename)
1829
+ this.siblings = parts.siblings
1830
+ this.name = parts.name
1831
+ this.adoptSiblings()
1375
1832
  hotRegister(this) // a no-op unless the page enabled hot reload
1376
1833
  }
1377
1834
 
1835
+ // the parser builds a file's sibling definitions before anyone has told it
1836
+ // where the file came from, so whoever holds the parse hands its origin down
1837
+ // - and keeps doing it after a hot reload, which parses the file afresh.
1838
+ // Without it a reloaded child would have no filename, and an instance with
1839
+ // no filename is not tracked: the next edit would never reach it
1840
+ private adoptSiblings() {
1841
+ if (!this.siblings) return
1842
+ Object.entries(this.siblings).forEach(([name, sibling]) => {
1843
+ sibling.filename ??= this.filename
1844
+ sibling.modules ??= this.modules
1845
+ // the file's own component also *is* the file: its named components hang
1846
+ // off it as properties, which is what `const { Row } = …` reads (and
1847
+ // what the bundler re-exports by name)
1848
+ if (!this.name) (this as any)[name] = sibling
1849
+ })
1850
+ }
1851
+
1378
1852
  // swaps this component's parsed parts for `src`'s and, if it is on the page,
1379
1853
  // re-renders it where it stands - seeded with a snapshot of its data, so
1380
1854
  // props and store values survive (the setup script runs again, so whatever it
@@ -1410,6 +1884,11 @@ export class Component79 {
1410
1884
  this.template = parts.template
1411
1885
  this.scripts = parts.scripts
1412
1886
  this.styles = parts.styles
1887
+ // the file's other components as they are now: the next render resolves
1888
+ // <Row> against these, so a parent picks up an edited child even when the
1889
+ // child's own instances are patched separately
1890
+ this.siblings = parts.siblings
1891
+ this.adoptSiblings()
1413
1892
  if (!rendered) return false // a definition: its clones re-render themselves
1414
1893
 
1415
1894
  this.renderWith(data, shadow)
@@ -1424,12 +1903,13 @@ export class Component79 {
1424
1903
  return true
1425
1904
  }
1426
1905
 
1427
- static async fetch(url: string): Promise<Component79> {
1428
- const response = await fetch(url)
1429
- if (!response.ok) throw new Error(`failed to fetch component from ${url}: ${response.status}`)
1430
- // the URL names the component's scripts in devtools, and is where the
1431
- // browser will look for the source when a breakpoint lands in one
1432
- return new Component79(await response.text(), { filename: url })
1906
+ static fetch(url: string): Promise<Component79>
1907
+ static fetch(urls: string[]): Promise<Component79[]>
1908
+ // an array of URLs fetches them all at once and resolves to the components in
1909
+ // the same order, so one await destructures them - and, like Promise.all, the
1910
+ // first failure rejects the whole thing
1911
+ static fetch(urls: string | string[]): Promise<Component79 | Component79[]> {
1912
+ return Array.isArray(urls) ? Promise.all(urls.map(fetchComponent)) : fetchComponent(urls)
1433
1913
  }
1434
1914
 
1435
1915
  // subscribes to this instance's $emit events, on top of the DOM CustomEvent
@@ -1460,7 +1940,23 @@ export class Component79 {
1460
1940
  private renderWith(data: Record<string, any>, shadow: boolean): this {
1461
1941
  this.destroy()
1462
1942
 
1463
- const store = $reactive({ ...data })
1943
+ // what this component can see of its file's other components, and which of
1944
+ // its declared props arrived empty - both decided by the signature, before
1945
+ // the store exists (see siblingsInScope / UNFILLED_PROPS)
1946
+ const declared = declaredPropNames(this.scripts)
1947
+ const siblingScope = siblingsInScope(this.siblings, declared)
1948
+ const raw: Record<string, any> = siblingScope
1949
+ ? Object.assign(Object.create(siblingScope), data)
1950
+ : { ...data }
1951
+ const unfilled = new Set([...declared].filter(name => !(name in data)))
1952
+ if (unfilled.size) Object.defineProperty(raw, UNFILLED_PROPS, { value: unfilled })
1953
+ // the slot content, for the <slot>s the template renders, and the static
1954
+ // map of which names were filled, for the component to ask about
1955
+ // (`<footer :if="$slots.footer">`). Filled at the usage site, so it can
1956
+ // only change when the tag itself re-renders - which builds a new instance
1957
+ if (this.slots) Object.defineProperty(raw, SLOTS, { value: this.slots })
1958
+
1959
+ const store = $reactive(raw)
1464
1960
  const fx = createEffectScope(store)
1465
1961
  this.data = store
1466
1962
  this.fx = fx
@@ -1528,6 +2024,20 @@ export class Component79 {
1528
2024
  const $import = (url: string): Promise<any> =>
1529
2025
  modules && url in modules ? Promise.resolve(modules[url]) : importResource(url)
1530
2026
 
2027
+ // the names a component answers on top of its store: $emit, so an inline
2028
+ // handler can emit without routing through a setup function
2029
+ // (@input="$emit('update', $event.target.value)"), and $slots, the static
2030
+ // map of the names the usage site filled, so a wrapper can be dropped when
2031
+ // nothing filled it (<footer :if="$slots.footer">). Both reach the
2032
+ // template (through templateScope, below) and both script modes (as
2033
+ // instance helpers), and a same-named store key shadows either.
2034
+ // Null-prototype, for the same reason storeApi is: `key in injected` must
2035
+ // not start answering true for toString, constructor and the rest
2036
+ const injected: Record<string, any> = Object.assign(Object.create(null), {
2037
+ $emit,
2038
+ $slots: Object.fromEntries(Object.keys(this.slots ?? {}).map(name => [name, true])),
2039
+ })
2040
+
1531
2041
  // scripts run before the template renders so `$:` values are initialized;
1532
2042
  // a `:mounted` script defers entirely until mount() instead. A top-level
1533
2043
  // `export default` switches the script to factory mode (plain lexical JS)
@@ -1536,7 +2046,13 @@ export class Component79 {
1536
2046
  const defer = (code: string) => `await $mounted();${code}`
1537
2047
 
1538
2048
  this.scripts.forEach((script, index) => {
1539
- const instanceHelpers = { $emit, $mounted, $self, $$self }
2049
+ // the file's other components are passed as parameters of the compiled
2050
+ // script, not just left on the store's prototype: a factory script runs
2051
+ // as plain lexical JS with no `with`, so a bare `Row` in one would
2052
+ // resolve to nothing at all. In setup mode this composes with `with` -
2053
+ // scriptScope's `has` declines any name that is a helper, so the
2054
+ // parameter is what the name resolves to
2055
+ const instanceHelpers = { $mounted, $self, $$self, ...injected, ...siblingScope }
1540
2056
  const at: ScriptLocation = { filename: this.filename, index }
1541
2057
  const factoryCode = transformFactoryScript(script.content)
1542
2058
  if (factoryCode !== null) {
@@ -1555,16 +2071,16 @@ export class Component79 {
1555
2071
  })
1556
2072
 
1557
2073
  const content = document.createDocumentFragment()
1558
- // the template renders under a scope that also answers $emit (unless the
1559
- // store shadows the name), so an inline handler can emit without routing
1560
- // through a setup function: @input="$emit('update', $event.target.value)".
1561
- // has/get only - never an own key - so Object.keys, snapshot spreads and
1562
- // the component-key scan don't see it, and every read still forwards
1563
- // through the reactive store, keeping dependency tracking intact
2074
+ // the injected names, served by has/get only - never as own keys - so
2075
+ // Object.keys, snapshot spreads and the component-key scan don't see them,
2076
+ // and every read still forwards through the reactive store, keeping
2077
+ // dependency tracking intact
1564
2078
  const templateScope = new Proxy(store as Record<string, any>, {
1565
- has: (target, key) => key === "$emit" || Reflect.has(target, key),
2079
+ has: (target, key) => (typeof key === "string" && key in injected) || Reflect.has(target, key),
1566
2080
  get: (target, key, receiver) =>
1567
- key === "$emit" && !Reflect.has(target, key) ? $emit : Reflect.get(target, key, receiver),
2081
+ typeof key === "string" && key in injected && !Reflect.has(target, key)
2082
+ ? injected[key]
2083
+ : Reflect.get(target, key, receiver),
1568
2084
  })
1569
2085
  content.append(this.startMarker, renderNodes(this.template, templateScope, fx, shadow), this.endMarker)
1570
2086
  this.content = content