jq79 0.2.0 → 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
@@ -342,9 +139,9 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
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,10 +555,48 @@ 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
+
891
600
  type EmitListener = (event: CustomEvent, payload: any) => void
892
601
 
893
602
  // a parsed single-file component. Typical lifecycle:
@@ -902,6 +611,9 @@ export class Component79 {
902
611
  template: TemplateNode[]
903
612
  scripts: TagBlock[]
904
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>
905
617
 
906
618
  data: ReactiveDeepData<Record<string, any>> | null = null
907
619
 
@@ -926,11 +638,12 @@ export class Component79 {
926
638
  // outside the render generation so they survive re-render and destroy()
927
639
  private emitListeners = new Map<string, Set<EmitListener>>()
928
640
 
929
- constructor(src: string | ComponentParts) {
930
- const { template, scripts, styles } = typeof src === "string" ? parseComponentString(src) : src
931
- this.template = template
932
- this.scripts = scripts
933
- this.styles = styles
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)
934
647
  }
935
648
 
936
649
  static async fetch(url: string): Promise<Component79> {
@@ -1021,15 +734,30 @@ export class Component79 {
1021
734
  }
1022
735
  const $self = (selector: string): Element | null => $$self(selector)[0] ?? null
1023
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
+
1024
744
  // scripts run before the template renders so `$:` values are initialized;
1025
- // 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)
1026
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
+ }
1027
755
  const { vars, code } = transformSetupScript(script.content)
1028
756
  // pre-declare script vars on the store so `with` resolves assignments
1029
757
  // to them (and reads of them) through the reactive proxy
1030
758
  vars.forEach(name => { if (!(name in store)) (store as any)[name] = undefined })
1031
759
  const body = ":mounted" in script.attrs ? `await $mounted();\n${code}` : code
1032
- runSetupScript(body, store, fx.effect, { $emit, $mounted, $self, $$self })
760
+ runSetupScript(body, store, fx.effect, instanceHelpers, $import)
1033
761
  })
1034
762
 
1035
763
  const content = document.createDocumentFragment()