jq79 0.1.6 → 0.3.0

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,35 +1,11 @@
1
1
 
2
- export const $ = (selectorOrEl: string | Element, selector?: string) =>
3
- typeof selectorOrEl === "string"
4
- ? document.querySelector(selectorOrEl)
5
- : selectorOrEl.querySelector(selector || "")
6
-
7
- export const $$ = (selectorOrEl: string | Element, selector?: string) => Array.from(
8
- typeof selectorOrEl === "string"
9
- ? document.querySelectorAll(selectorOrEl)
10
- : selectorOrEl.querySelectorAll(selector || "")
11
- )
12
-
13
- // $create(tag, attrs): attrs are set as attributes, except className, which
14
- // may be a string or an array of class names.
15
- export const $create = (tag: string, attrs: Record<string, any> = {}): HTMLElement => {
16
- const el = document.createElement(tag);
17
- for (const [name, value] of Object.entries(attrs)) {
18
- if (name === 'className') {
19
- el.className = Array.isArray(value) ? value.join(' ') : value;
20
- } else if (name === 'textContent') {
21
- el.textContent = value;
22
- } else if (name === 'children') {
23
- for (const child of value) {
24
- el.appendChild(child);
25
- }
26
- } else {
27
- el.setAttribute(name, value);
28
- }
29
- }
30
- return el;
31
- };
2
+ import { $, $$, $create } from "./dom"
3
+ import { $reactive, untracked, createEffectScope } from "./reactive"
4
+ import type { ReactiveDeepData, EffectScope } from "./reactive"
5
+ import { transformSetupScript, transformFactoryScript } from "./transform"
32
6
 
7
+ export { $, $$, $create } from "./dom"
8
+ export { $reactive } from "./reactive"
33
9
  type TemplateNode = {
34
10
  tag: string
35
11
  attrs: Record<string, string>
@@ -79,191 +55,12 @@ const evalExpr = (expr: string, scope: Record<string, any>, extras?: Record<stri
79
55
  const interpolate = (template: string, scope: Record<string, any>): string =>
80
56
  template.replace(/{{\s*(.+?)\s*}}/g, (_, expr) => evalExpr(expr, scope) ?? "")
81
57
 
82
- type ChangeListener = (value: any, dotKey: string) => void
83
- type AnyChangeListener = (dotKey: string, value: any) => void
84
- type ListenerOptions = { immediate?: boolean }
85
- type Unsubscribe = () => void
86
-
87
- type ReactiveDeepData<T> = T & {
88
- $on: (dotKey: string, listener: ChangeListener, options?: ListenerOptions) => Unsubscribe
89
- $onAny: (listener: AnyChangeListener, options?: ListenerOptions) => Unsubscribe
90
- // runs `run` immediately, recording every dotKey it reads off this store, then
91
- // re-runs it whenever a changed dotKey overlaps one of those - see pathsOverlap
92
- $effect: (run: () => void) => Unsubscribe
93
- }
94
-
95
- const getByPath = (obj: Record<string, any>, dotKey: string): any =>
96
- dotKey.split(".").reduce((acc, key) => (acc == null ? undefined : acc[key]), obj)
97
-
98
- // only plain objects and arrays get deep-wrapped by the reactive store;
99
- // class instances (Component79, Date, DOM nodes, ...) pass through untouched
100
- // so their identity, prototypes and internals stay intact
101
- const isPlainData = (value: object): boolean => {
102
- if (Array.isArray(value)) return true
103
- const proto = Object.getPrototypeOf(value)
104
- return proto === Object.prototype || proto === null
105
- }
106
-
107
- const walkLeaves = (obj: Record<string, any>, path: string, visit: (dotKey: string, value: any) => void) => {
108
- Object.entries(obj).forEach(([key, value]) => {
109
- const dotKey = path ? `${path}.${key}` : key
110
- if (value && typeof value === "object" && isPlainData(value)) walkLeaves(value, dotKey, visit)
111
- else visit(dotKey, value)
112
- })
113
- }
114
-
115
- // true when `a` and `b` sit on the same ancestor/descendant line, e.g.
116
- // "user" & "user.address.city" (a change to either affects the other) - false
117
- // for siblings like "user.name" & "user.age"
118
- const pathsOverlap = (a: string, b: string): boolean =>
119
- a === b || a.startsWith(`${b}.`) || b.startsWith(`${a}.`)
120
-
121
- // active $effect() runs, innermost last - a module-level stack (rather than
122
- // one per store) so nested effects across stores still nest correctly; reads
123
- // during makeReactive's `get` trap are attributed to whichever run is on top
124
- const trackerStack: Set<string>[] = []
125
-
126
- // runs fn with dependency tracking suspended - reads inside it are attributed
127
- // to a throwaway set instead of the currently running effect
128
- const untracked = <T>(fn: () => T): T => {
129
- trackerStack.push(new Set())
130
- try {
131
- return fn()
132
- } finally {
133
- trackerStack.pop()
134
- }
135
- }
136
-
137
- type Effect = { deps: Set<string>; run: () => void }
138
-
139
- export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepData<T> => {
140
- const exactListeners = new Map<string, Set<ChangeListener>>()
141
- const anyListeners = new Set<AnyChangeListener>()
142
- const effects = new Set<Effect>()
143
- // every proxy this store has ever handed out, so re-assigning an object
144
- // that's already reactive (e.g. `data.list = [data.list[1], data.list[0]]`)
145
- // doesn't wrap it a second time - which would hand out a *new* object
146
- // identity for the same logical item, breaking reference-equality checks
147
- // like :each's keyed diffing
148
- const reactiveProxies = new WeakSet<object>()
149
-
150
- const notify = (dotKey: string, value: any) => {
151
- exactListeners.get(dotKey)?.forEach(listener => listener(value, dotKey))
152
- anyListeners.forEach(listener => listener(dotKey, value))
153
- effects.forEach(effect => {
154
- if (Array.from(effect.deps).some(dep => pathsOverlap(dep, dotKey))) effect.run()
155
- })
156
- }
157
-
158
- const makeReactive = (obj: Record<string, any>, path: string): Record<string, any> => {
159
- if (reactiveProxies.has(obj)) return obj
160
-
161
- Object.entries(obj).forEach(([key, value]) => {
162
- if (value && typeof value === "object" && isPlainData(value)) {
163
- obj[key] = makeReactive(value, path ? `${path}.${key}` : key)
164
- }
165
- })
166
-
167
- const proxy = new Proxy(obj, {
168
- get(target, key, receiver) {
169
- if (typeof key === "string") {
170
- trackerStack[trackerStack.length - 1]?.add(path ? `${path}.${key}` : key)
171
- }
172
- return Reflect.get(target, key, receiver)
173
- },
174
- set(target, key: string, value, receiver) {
175
- // an assignment delegated up the prototype chain from a derived scope
176
- // (Object.create(store) child, or a wrapping proxy): if the key isn't
177
- // a real property of this store, honor the receiver so the new binding
178
- // lands on the derived scope - a scope-local variable, not a store
179
- // mutation, so no notify. If the key IS a store property, fall through
180
- // and mutate the store itself regardless of receiver, so assignments
181
- // like @click="count = count + 1" work from any nested scope
182
- if (receiver !== proxy && !Object.prototype.hasOwnProperty.call(target, key)) {
183
- return Reflect.set(target, key, value, receiver)
184
- }
185
-
186
- const dotKey = path ? `${path}.${key}` : key
187
- if (value && typeof value === "object" && isPlainData(value)) {
188
- value = makeReactive(value, dotKey)
189
- }
190
- target[key] = value
191
- notify(dotKey, value)
192
- return true
193
- }
194
- })
195
-
196
- reactiveProxies.add(proxy)
197
- return proxy
198
- }
199
-
200
- const reactive = makeReactive(data, "") as ReactiveDeepData<T>
201
-
202
- const $on = (dotKey: string, listener: ChangeListener, { immediate = false }: ListenerOptions = {}): Unsubscribe => {
203
- if (!exactListeners.has(dotKey)) exactListeners.set(dotKey, new Set())
204
- exactListeners.get(dotKey)!.add(listener)
205
- if (immediate) listener(getByPath(reactive, dotKey), dotKey)
206
- return () => exactListeners.get(dotKey)?.delete(listener)
207
- }
208
-
209
- const $onAny = (listener: AnyChangeListener, { immediate = false }: ListenerOptions = {}): Unsubscribe => {
210
- anyListeners.add(listener)
211
- if (immediate) walkLeaves(reactive, "", (dotKey, value) => listener(dotKey, value))
212
- return () => anyListeners.delete(listener)
213
- }
214
-
215
- const $effect = (run: () => void): Unsubscribe => {
216
- const effect: Effect = {
217
- deps: new Set(),
218
- run: () => {
219
- const deps = new Set<string>()
220
- trackerStack.push(deps)
221
- try {
222
- run()
223
- } finally {
224
- trackerStack.pop()
225
- effect.deps = deps
226
- }
227
- },
228
- }
229
- effects.add(effect)
230
- effect.run()
231
- return () => { effects.delete(effect) }
232
- }
233
-
234
- Object.defineProperty(reactive, "$on", { value: $on, enumerable: false })
235
- Object.defineProperty(reactive, "$onAny", { value: $onAny, enumerable: false })
236
- Object.defineProperty(reactive, "$effect", { value: $effect, enumerable: false })
237
-
238
- return reactive
239
- }
240
58
 
241
59
  const CONTROL_ATTRS = new Set([":bind", ":if", ":elseif", ":else", ":each", ":key", ":with"])
242
60
  const EACH_PATTERN = /^\s*(\w+)\s+in\s+(.+)$/
243
61
 
244
62
  type ConditionalBranch = { expr?: string; node: TemplateNode }
245
63
 
246
- // groups the disposers of every $effect created for one rendered subtree
247
- // (an :if branch, an :each item, ...) so the whole subtree's bindings can be
248
- // torn down in one call when that subtree is replaced/removed. `scope.$effect`
249
- // resolves through the prototype chain up to the root store no matter how
250
- // many nested :each scopes sit in between (see renderEach's itemScope)
251
- type EffectScope = {
252
- effect: (run: () => void) => void
253
- // registers an arbitrary cleanup (e.g. destroying a nested component) to
254
- // run when this subtree is torn down
255
- onDispose: (fn: Unsubscribe) => void
256
- dispose: () => void
257
- }
258
-
259
- const createEffectScope = (scope: Record<string, any>): EffectScope => {
260
- const disposers: Unsubscribe[] = []
261
- return {
262
- effect: run => { disposers.push(scope.$effect(run)) },
263
- onDispose: fn => { disposers.push(fn) },
264
- dispose: () => { disposers.splice(0).forEach(dispose => dispose()) },
265
- }
266
- }
267
64
 
268
65
  // @event attributes: @click="onClick", @submit.prevent="$event => onSubmit($event)",
269
66
  // or an inline statement like @click="count = count + 1". The expression is
@@ -337,14 +134,14 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
337
134
 
338
135
  childFx?.dispose()
339
136
  childFx = null
340
- current?.destroy() // unmounts its marker range, removing the child's DOM
137
+ current?.destroy() // detaches its marker range, removing the child's DOM
341
138
  current = null
342
139
  currentDef = nextDef
343
140
  if (!nextDef) return
344
141
 
345
- // a fresh instance per usage site: the definition's parsed parts are
346
- // shared, but store/effects/DOM are per instance
347
- const instance = new Component79({ template: nextDef.template, scripts: nextDef.scripts, styles: nextDef.styles })
142
+ // a fresh instance per usage site: the definition's parsed parts (and
143
+ // pre-resolved modules) are shared, but store/effects/DOM are per instance
144
+ const instance = new Component79({ template: nextDef.template, scripts: nextDef.scripts, styles: nextDef.styles, modules: nextDef.modules })
348
145
  const seed = untracked(() =>
349
146
  Object.fromEntries(Object.entries(props).map(([name, expr]) => [name, evalExpr(expr, scope)]))
350
147
  )
@@ -421,6 +218,22 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
421
218
 
422
219
  const el = document.createElement(node.tag)
423
220
 
221
+ // a tag that isn't standard HTML but has no matching scope key *yet* may be
222
+ // a component that arrives later (e.g. an async factory script exposing an
223
+ // imported component after `await`). Watch for the key: the effect tracks
224
+ // no deps, so it only re-runs on the store's new-key sweep, and swaps the
225
+ // placeholder element for the component exactly once
226
+ if (el instanceof HTMLUnknownElement || node.tag.includes("-")) {
227
+ let upgraded = false
228
+ fx.effect(() => {
229
+ if (upgraded) return
230
+ const key = findComponentKey(scope, node.tag)
231
+ if (!key) return
232
+ upgraded = true
233
+ el.replaceWith(renderNestedComponent(key, node, scope, fx))
234
+ })
235
+ }
236
+
424
237
  Object.entries(node.attrs).forEach(([key, value]) => {
425
238
  if (key.startsWith("@")) bindEvent(el, key, value, scope)
426
239
  else if (!CONTROL_ATTRS.has(key)) el.setAttribute(key, value)
@@ -612,6 +425,10 @@ type ComponentParts = {
612
425
  template: TemplateNode[]
613
426
  scripts: TagBlock[]
614
427
  styles: TagBlock[]
428
+ // pre-resolved modules for `import(...)` calls in setup scripts, keyed by
429
+ // the literal specifier. Bundlers (the jq79/vite plugin) fill this so
430
+ // imports resolve from the bundle instead of being fetched at runtime
431
+ modules?: Record<string, any>
615
432
  }
616
433
 
617
434
  const VOID_ELEMENTS = new Set([
@@ -681,152 +498,6 @@ const parseComponentString = (component: string): ComponentParts => {
681
498
  return { template, scripts, styles }
682
499
  }
683
500
 
684
- // ---------------------------------------------------------------------------
685
- // :setup script transform
686
- //
687
- // setup scripts are written like Svelte components:
688
- //
689
- // let firstName = null
690
- // $: fullName = `${firstName} ${lastName}`
691
- // fetchUser().then(user => { firstName = user.firstName })
692
- //
693
- // and are executed inside `with ($scope)` against the component's reactive
694
- // store, so plain assignments (even from async callbacks) go through the
695
- // proxy's set trap and re-render whatever depends on them. To make that work
696
- // the source is lightly rewritten - no full JS parser, just a scanner that is
697
- // string/comment-aware and only touches code at brace/paren depth 0:
698
- // - `let/var/const x = ...` at the top level loses its keyword, becoming a
699
- // scope assignment (the name is pre-declared on the store so the `with`
700
- // lookup resolves it)
701
- // - `$: x = expr` becomes `$__effect(() => { x = expr })`, re-running when a
702
- // dependency read inside expr changes ($__effect is deliberately NOT a
703
- // property of the scope, so `with` falls through to the function parameter)
704
- // ---------------------------------------------------------------------------
705
-
706
- type SetupTransform = { vars: string[]; code: string }
707
-
708
- const DECLARATION_RE = /(?:let|var|const)\s+([A-Za-z_$][\w$]*)/y
709
- const REACTIVE_LABEL_RE = /\$:\s*/y
710
- const IMPORT_CALL_RE = /import(?=\s*\()/y
711
- const REACTIVE_ASSIGN_RE = /\$:\s*([A-Za-z_$][\w$]*)\s*=(?!=)/y
712
-
713
- const skipString = (src: string, start: number): number => {
714
- const quote = src[start]
715
- let i = start + 1
716
- while (i < src.length) {
717
- if (src[i] === "\\") { i += 2; continue }
718
- if (src[i] === quote) return i + 1
719
- i++
720
- }
721
- return src.length
722
- }
723
-
724
- const skipLineComment = (src: string, start: number): number => {
725
- const end = src.indexOf("\n", start)
726
- return end === -1 ? src.length : end
727
- }
728
-
729
- const skipBlockComment = (src: string, start: number): number => {
730
- const end = src.indexOf("*/", start + 2)
731
- return end === -1 ? src.length : end + 2
732
- }
733
-
734
- // end of a statement starting at `start`: the first newline or `;` that isn't
735
- // inside a string/comment or unbalanced brackets, so multi-line RHS like a
736
- // wrapped function call or template literal stays in one piece
737
- const findStatementEnd = (src: string, start: number): number => {
738
- let depth = 0
739
- let i = start
740
- while (i < src.length) {
741
- const ch = src[i]
742
- if (ch === "'" || ch === '"' || ch === "`") { i = skipString(src, i); continue }
743
- if (ch === "/" && src[i + 1] === "/") { i = skipLineComment(src, i); continue }
744
- if (ch === "/" && src[i + 1] === "*") { i = skipBlockComment(src, i); continue }
745
- if ("([{".includes(ch)) depth++
746
- else if (")]}".includes(ch)) depth--
747
- else if (depth <= 0 && (ch === "\n" || ch === ";")) return i
748
- i++
749
- }
750
- return src.length
751
- }
752
-
753
- const transformSetupScript = (src: string): SetupTransform => {
754
- const vars: string[] = []
755
- let out = ""
756
- let i = 0
757
- let depth = 0
758
- let atStatementStart = true
759
-
760
- while (i < src.length) {
761
- const ch = src[i]
762
- const next = src[i + 1]
763
-
764
- if (ch === "'" || ch === '"' || ch === "`") {
765
- const end = skipString(src, i)
766
- out += src.slice(i, end)
767
- i = end
768
- atStatementStart = false
769
- continue
770
- }
771
- if (ch === "/" && (next === "/" || next === "*")) {
772
- const end = next === "/" ? skipLineComment(src, i) : skipBlockComment(src, i)
773
- out += src.slice(i, end)
774
- i = end
775
- continue
776
- }
777
-
778
- // `import(...)` is a keyword form, so it can't be intercepted through the
779
- // scope - rewrite the identifier to the injected $__import (which loads
780
- // .html URLs as components via Component79.fetch and delegates the rest to
781
- // native import). The `(` is left for the scanner so depth stays balanced
782
- if (ch === "i" && (i === 0 || !/[\w$.]/.test(src[i - 1]))) {
783
- IMPORT_CALL_RE.lastIndex = i
784
- if (IMPORT_CALL_RE.test(src)) {
785
- out += "$__import"
786
- i += "import".length
787
- atStatementStart = false
788
- continue
789
- }
790
- }
791
-
792
- if (depth === 0 && atStatementStart) {
793
- DECLARATION_RE.lastIndex = i
794
- const decl = DECLARATION_RE.exec(src)
795
- if (decl) {
796
- vars.push(decl[1])
797
- out += decl[1]
798
- i += decl[0].length
799
- atStatementStart = false
800
- continue
801
- }
802
-
803
- REACTIVE_LABEL_RE.lastIndex = i
804
- const label = REACTIVE_LABEL_RE.exec(src)
805
- if (label) {
806
- REACTIVE_ASSIGN_RE.lastIndex = i
807
- const assign = REACTIVE_ASSIGN_RE.exec(src)
808
- if (assign) vars.push(assign[1])
809
- const start = i + label[0].length
810
- const end = findStatementEnd(src, start)
811
- out += `$__effect(() => { ${src.slice(start, end)} });`
812
- i = end
813
- continue
814
- }
815
- }
816
-
817
- if ("([{".includes(ch)) depth++
818
- else if (")]}".includes(ch)) depth = Math.max(0, depth - 1)
819
-
820
- if (ch === "\n" || ch === ";" || ch === "}") atStatementStart = true
821
- else if (!/\s/.test(ch)) atStatementStart = false
822
-
823
- out += ch
824
- i++
825
- }
826
-
827
- return { vars, code: out }
828
- }
829
-
830
501
  // loads .html URLs as components, delegating anything else to native import()
831
502
  const importResource = (url: string): Promise<any> =>
832
503
  /\.html?([?#]|$)/.test(url) ? Component79.fetch(url) : import(url)
@@ -872,7 +543,7 @@ const SETUP_HELPERS: Record<string, any> = { $, $$, $create, $reactive }
872
543
  // The body is wrapped in an async IIFE so top-level `await` works: everything
873
544
  // up to the first await runs synchronously (before the template renders), and
874
545
  // later assignments update the DOM reactively when they happen
875
- const runSetupScript = (code: string, scope: Record<string, any>, effect: (run: () => void) => void, instanceHelpers: Record<string, any> = {}) => {
546
+ const runSetupScript = (code: string, scope: Record<string, any>, effect: (run: () => void) => void, instanceHelpers: Record<string, any> = {}, importer: (url: string) => Promise<any> = importResource) => {
876
547
  // instanceHelpers are per-component-instance additions (e.g. $emit, which
877
548
  // is bound to this instance's DOM position)
878
549
  const helpers = { ...SETUP_HELPERS, ...instanceHelpers }
@@ -884,31 +555,74 @@ const runSetupScript = (code: string, scope: Record<string, any>, effect: (run:
884
555
  const result: Promise<void> = new Function(
885
556
  "$scope", "$__effect", "$__import", ...Object.keys(helpers),
886
557
  `return (async () => { with ($scope) { ${code} } })()`
887
- )(scriptScope, effect, importResource, ...Object.values(helpers))
558
+ )(scriptScope, effect, importer, ...Object.values(helpers))
888
559
  result.catch(error => console.error("jq79: error in :setup script", error))
889
560
  }
890
561
 
562
+ // default-import interop for factory scripts: real modules expose .default,
563
+ // while importing an .html component resolves to the Component79 itself
564
+ const interopDefault = (mod: any) => (mod && mod.default !== undefined ? mod.default : mod)
565
+
566
+ // runs a factory script: the (rewritten) module body executes in plain
567
+ // lexical strict-mode scope - no `with`, no implicit reactivity - with the
568
+ // library helpers as parameters, then the default export is called with the
569
+ // instance context and a returned object is merged into the store. A fully
570
+ // synchronous body invokes the factory before the first render, matching
571
+ // setup-script timing; bodies with top-level await (static imports included)
572
+ // resolve later and the template updates reactively
573
+ const runFactoryScript = (code: string, scope: Record<string, any>, effect: (run: () => void) => void, instanceHelpers: Record<string, any> = {}, importer: (url: string) => Promise<any> = importResource) => {
574
+ const helpers = { ...SETUP_HELPERS, ...instanceHelpers }
575
+ const $__exports: { default?: (ctx: Record<string, any>) => any; done?: boolean } = {}
576
+ const result: Promise<void> = new Function(
577
+ "$__exports", "$__default", "$__import", ...Object.keys(helpers),
578
+ `return (async () => { "use strict";\n${code}\n;$__exports.done = true })()`
579
+ )($__exports, interopDefault, importer, ...Object.values(helpers))
580
+
581
+ const logError = (error: any) => console.error("jq79: error in factory script", error)
582
+ let invoked = false
583
+ const invoke = () => {
584
+ if (invoked) return
585
+ invoked = true
586
+ const factory = $__exports.default
587
+ if (typeof factory !== "function") return
588
+ const merge = (bindings: any) => {
589
+ if (bindings && typeof bindings === "object") Object.assign(scope, bindings)
590
+ }
591
+ const returned = factory({ $data: scope, $effect: effect, ...instanceHelpers })
592
+ if (returned instanceof Promise) returned.then(merge).catch(logError)
593
+ else merge(returned)
594
+ }
595
+
596
+ result.then(invoke, logError)
597
+ if ($__exports.done) invoke() // fully-sync body: factory runs before first render
598
+ }
599
+
600
+ type EmitListener = (event: CustomEvent, payload: any) => void
601
+
891
602
  // a parsed single-file component. Typical lifecycle:
892
603
  //
893
604
  // const jq79 = new Component79(src) // or await Component79.fetch(url)
894
- // jq79.render({ user }) // build reactive DOM, run scripts, inject styles
895
- // .mount("#app") // attach (renderShadow mounts into a shadow root)
896
- // ...
897
- // jq79.unmount() // detach, keeping state - mount() re-attaches
605
+ // jq79.on("submit", (e, payload) => {}) // hear this instance's $emit events
606
+ // jq79.mount("#app", { user }) // render (reactive DOM, scripts, styles) + attach
607
+ // ... // (mountShadow mounts into a shadow root)
608
+ // jq79.detach() // detach, keeping state - mount() re-attaches
898
609
  // .destroy() // dispose effects and remove styles
899
610
  export class Component79 {
900
611
  template: TemplateNode[]
901
612
  scripts: TagBlock[]
902
613
  styles: TagBlock[]
614
+ // pre-resolved modules for setup-script `import(...)` calls (see
615
+ // ComponentParts.modules); checked before falling back to fetch/import
616
+ modules?: Record<string, any>
903
617
 
904
618
  data: ReactiveDeepData<Record<string, any>> | null = null
905
619
 
906
620
  private fx: EffectScope | null = null
907
- // holds the rendered nodes while unmounted; anchors keep this fragment as
621
+ // holds the rendered nodes while detached; anchors keep this fragment as
908
622
  // their parentNode, so effects keep the (detached) DOM up to date and a
909
623
  // later mount() shows current state
910
624
  private content: DocumentFragment | null = null
911
- // markers bracketing the component's output so unmount() can collect nodes
625
+ // markers bracketing the component's output so detach() can collect nodes
912
626
  // that :if/:each inserted next to the anchors after mounting
913
627
  private startMarker: Comment | null = null
914
628
  private endMarker: Comment | null = null
@@ -920,12 +634,16 @@ export class Component79 {
920
634
  private mountRoot: Element | ShadowRoot | DocumentFragment | null = null
921
635
  // settles the $mounted() promise handed to this render generation's scripts
922
636
  private resolveMounted: (() => void) | null = null
923
-
924
- constructor(src: string | ComponentParts) {
925
- const { template, scripts, styles } = typeof src === "string" ? parseComponentString(src) : src
926
- this.template = template
927
- this.scripts = scripts
928
- this.styles = styles
637
+ // instance-level listeners for $emit events, registered with on(). Kept
638
+ // outside the render generation so they survive re-render and destroy()
639
+ private emitListeners = new Map<string, Set<EmitListener>>()
640
+
641
+ constructor(src: string | ComponentParts, options: { modules?: Record<string, any> } = {}) {
642
+ const parts = typeof src === "string" ? parseComponentString(src) : src
643
+ this.template = parts.template
644
+ this.scripts = parts.scripts
645
+ this.styles = parts.styles
646
+ this.modules = options.modules ?? (typeof src === "string" ? undefined : src.modules)
929
647
  }
930
648
 
931
649
  static async fetch(url: string): Promise<Component79> {
@@ -934,6 +652,21 @@ export class Component79 {
934
652
  return new Component79(await response.text())
935
653
  }
936
654
 
655
+ // subscribes to this instance's $emit events, on top of the DOM CustomEvent
656
+ // dispatch - so it hears emits even while the component is detached (where
657
+ // the event has no ancestors to bubble to). Chainable; can be called before
658
+ // render()
659
+ on(eventName: string, listener: EmitListener): this {
660
+ if (!this.emitListeners.has(eventName)) this.emitListeners.set(eventName, new Set())
661
+ this.emitListeners.get(eventName)!.add(listener)
662
+ return this
663
+ }
664
+
665
+ off(eventName: string, listener: EmitListener): this {
666
+ this.emitListeners.get(eventName)?.delete(listener)
667
+ return this
668
+ }
669
+
937
670
  render(data: Record<string, any> = {}): this {
938
671
  return this.renderWith(data, false)
939
672
  }
@@ -960,10 +693,17 @@ export class Component79 {
960
693
  // marker, so once mounted it travels up the real DOM and parents can
961
694
  // listen on any ancestor (or with @event-name on a wrapping element).
962
695
  // Captures the marker rather than `this` so a later re-render's scripts
963
- // can't dispatch from the wrong generation
696
+ // can't dispatch from the wrong generation - the same guard keeps stale
697
+ // generations from reaching the instance's on() listeners
964
698
  const marker = this.startMarker
965
- const $emit = (eventName: string, payload?: any): boolean =>
966
- marker.dispatchEvent(new CustomEvent(eventName, { detail: payload, bubbles: true, composed: true }))
699
+ const $emit = (eventName: string, payload?: any): boolean => {
700
+ const event = new CustomEvent(eventName, { detail: payload, bubbles: true, composed: true })
701
+ const result = marker.dispatchEvent(event)
702
+ if (marker === this.startMarker) {
703
+ this.emitListeners.get(eventName)?.forEach(listener => listener(event, payload))
704
+ }
705
+ return result
706
+ }
967
707
 
968
708
  // `await $mounted()` suspends a setup script until mount() attaches the
969
709
  // component, so code below it can querySelector its own DOM. Resumption
@@ -994,15 +734,30 @@ export class Component79 {
994
734
  }
995
735
  const $self = (selector: string): Element | null => $$self(selector)[0] ?? null
996
736
 
737
+ // import() calls whose specifier was pre-resolved by a bundler (the
738
+ // modules map) get the bundled module; everything else falls back to the
739
+ // runtime importResource (fetch for .html, native import otherwise)
740
+ const modules = this.modules
741
+ const $import = (url: string): Promise<any> =>
742
+ modules && url in modules ? Promise.resolve(modules[url]) : importResource(url)
743
+
997
744
  // scripts run before the template renders so `$:` values are initialized;
998
- // a `:mounted` script defers entirely until mount() instead
745
+ // a `:mounted` script defers entirely until mount() instead. A top-level
746
+ // `export default` switches the script to factory mode (plain lexical JS)
999
747
  this.scripts.forEach(script => {
748
+ const instanceHelpers = { $emit, $mounted, $self, $$self }
749
+ const factoryCode = transformFactoryScript(script.content)
750
+ if (factoryCode !== null) {
751
+ const body = ":mounted" in script.attrs ? `await $mounted();\n${factoryCode}` : factoryCode
752
+ runFactoryScript(body, store, fx.effect, instanceHelpers, $import)
753
+ return
754
+ }
1000
755
  const { vars, code } = transformSetupScript(script.content)
1001
756
  // pre-declare script vars on the store so `with` resolves assignments
1002
757
  // to them (and reads of them) through the reactive proxy
1003
758
  vars.forEach(name => { if (!(name in store)) (store as any)[name] = undefined })
1004
759
  const body = ":mounted" in script.attrs ? `await $mounted();\n${code}` : code
1005
- runSetupScript(body, store, fx.effect, { $emit, $mounted, $self, $$self })
760
+ runSetupScript(body, store, fx.effect, instanceHelpers, $import)
1006
761
  })
1007
762
 
1008
763
  const content = document.createDocumentFragment()
@@ -1023,23 +778,44 @@ export class Component79 {
1023
778
  return this
1024
779
  }
1025
780
 
1026
- mount(parent: Element | ShadowRoot | DocumentFragment | string): this {
781
+ // renders (when needed) and attaches in one call: the component is rendered
782
+ // on the first mount, and re-rendered fresh whenever `data` is passed.
783
+ // mount(el) on an already-rendered component just re-attaches, keeping its
784
+ // state - the detach()/mount() round trip. Rendering here keeps whichever
785
+ // style mode was last used (document.head unless renderShadow/mountShadow
786
+ // chose a shadow root)
787
+ mount(parent: Element | ShadowRoot | DocumentFragment | string, data?: Record<string, any>): this {
1027
788
  const target = typeof parent === "string" ? $(parent) : parent
1028
789
  if (!target) throw new Error(`mount target not found: ${parent}`)
1029
- if (!this.content) throw new Error("render() must be called before mount()")
1030
- if (this.mountRoot) this.unmount()
790
+ if (!this.content || data !== undefined) this.renderWith(data ?? {}, this.useShadow)
791
+ return this.attach(target)
792
+ }
793
+
794
+ // like mount(), but renders with styles scoped to a shadow root on the
795
+ // target instead of document.head
796
+ mountShadow(parent: Element | ShadowRoot | DocumentFragment | string, data?: Record<string, any>): this {
797
+ const target = typeof parent === "string" ? $(parent) : parent
798
+ if (!target) throw new Error(`mount target not found: ${parent}`)
799
+ if (!this.content || data !== undefined || !this.useShadow) this.renderWith(data ?? {}, true)
800
+ return this.attach(target)
801
+ }
802
+
803
+ private attach(target: Element | ShadowRoot | DocumentFragment): this {
804
+ if (this.mountRoot) this.detach()
1031
805
 
1032
806
  const root = this.useShadow && target instanceof Element
1033
807
  ? target.shadowRoot ?? target.attachShadow({ mode: "open" })
1034
808
  : target
1035
809
  if (this.useShadow) this.styleEls.forEach(el => root.appendChild(el))
1036
- root.appendChild(this.content)
810
+ root.appendChild(this.content!)
1037
811
  this.mountRoot = root
1038
812
  this.resolveMounted?.()
1039
813
  return this
1040
814
  }
1041
815
 
1042
- unmount(): this {
816
+ // detaches from the DOM while keeping all state; a later mount() re-attaches
817
+ // with any updates that happened while detached already applied
818
+ detach(): this {
1043
819
  if (!this.mountRoot || !this.content || !this.startMarker || !this.endMarker) return this
1044
820
 
1045
821
  // move everything between the markers (inclusive) back into the holding
@@ -1057,7 +833,7 @@ export class Component79 {
1057
833
  }
1058
834
 
1059
835
  destroy(): this {
1060
- this.unmount()
836
+ this.detach()
1061
837
  this.fx?.dispose()
1062
838
  this.fx = null
1063
839
  this.styleEls.forEach(el => el.parentNode?.removeChild(el))