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.
@@ -0,0 +1,187 @@
1
+ // the reactive store ($reactive): proxy-based deep reactivity with
2
+ // dot-path dependency tracking, plus the effect-scope helper the renderer
3
+ // uses to tear down a subtree's bindings in one call
4
+
5
+ type ChangeListener = (value: any, dotKey: string) => void
6
+ type AnyChangeListener = (dotKey: string, value: any) => void
7
+ type ListenerOptions = { immediate?: boolean }
8
+ type Unsubscribe = () => void
9
+
10
+ export type ReactiveDeepData<T> = T & {
11
+ $on: (dotKey: string, listener: ChangeListener, options?: ListenerOptions) => Unsubscribe
12
+ $onAny: (listener: AnyChangeListener, options?: ListenerOptions) => Unsubscribe
13
+ // runs `run` immediately, recording every dotKey it reads off this store, then
14
+ // re-runs it whenever a changed dotKey overlaps one of those - see pathsOverlap
15
+ $effect: (run: () => void) => Unsubscribe
16
+ }
17
+
18
+ const getByPath = (obj: Record<string, any>, dotKey: string): any =>
19
+ dotKey.split(".").reduce((acc, key) => (acc == null ? undefined : acc[key]), obj)
20
+
21
+ // only plain objects and arrays get deep-wrapped by the reactive store;
22
+ // class instances (Component79, Date, DOM nodes, ...) pass through untouched
23
+ // so their identity, prototypes and internals stay intact
24
+ const isPlainData = (value: object): boolean => {
25
+ if (Array.isArray(value)) return true
26
+ const proto = Object.getPrototypeOf(value)
27
+ return proto === Object.prototype || proto === null
28
+ }
29
+
30
+ const walkLeaves = (obj: Record<string, any>, path: string, visit: (dotKey: string, value: any) => void) => {
31
+ Object.entries(obj).forEach(([key, value]) => {
32
+ const dotKey = path ? `${path}.${key}` : key
33
+ if (value && typeof value === "object" && isPlainData(value)) walkLeaves(value, dotKey, visit)
34
+ else visit(dotKey, value)
35
+ })
36
+ }
37
+
38
+ // true when `a` and `b` sit on the same ancestor/descendant line, e.g.
39
+ // "user" & "user.address.city" (a change to either affects the other) - false
40
+ // for siblings like "user.name" & "user.age"
41
+ const pathsOverlap = (a: string, b: string): boolean =>
42
+ a === b || a.startsWith(`${b}.`) || b.startsWith(`${a}.`)
43
+
44
+ // active $effect() runs, innermost last - a module-level stack (rather than
45
+ // one per store) so nested effects across stores still nest correctly; reads
46
+ // during makeReactive's `get` trap are attributed to whichever run is on top
47
+ const trackerStack: Set<string>[] = []
48
+
49
+ // runs fn with dependency tracking suspended - reads inside it are attributed
50
+ // to a throwaway set instead of the currently running effect
51
+ export const untracked = <T>(fn: () => T): T => {
52
+ trackerStack.push(new Set())
53
+ try {
54
+ return fn()
55
+ } finally {
56
+ trackerStack.pop()
57
+ }
58
+ }
59
+
60
+ type Effect = { deps: Set<string>; run: () => void }
61
+
62
+ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepData<T> => {
63
+ const exactListeners = new Map<string, Set<ChangeListener>>()
64
+ const anyListeners = new Set<AnyChangeListener>()
65
+ const effects = new Set<Effect>()
66
+ // every proxy this store has ever handed out, so re-assigning an object
67
+ // that's already reactive (e.g. `data.list = [data.list[1], data.list[0]]`)
68
+ // doesn't wrap it a second time - which would hand out a *new* object
69
+ // identity for the same logical item, breaking reference-equality checks
70
+ // like :each's keyed diffing
71
+ const reactiveProxies = new WeakSet<object>()
72
+
73
+ const notify = (dotKey: string, value: any, isNewKey = false) => {
74
+ exactListeners.get(dotKey)?.forEach(listener => listener(value, dotKey))
75
+ anyListeners.forEach(listener => listener(dotKey, value))
76
+ effects.forEach(effect => {
77
+ // a newly-created key re-runs every effect: an effect that read the
78
+ // name while it didn't exist couldn't track it (`with` skipped the
79
+ // store entirely), so dep matching would never wake it up
80
+ if (isNewKey || Array.from(effect.deps).some(dep => pathsOverlap(dep, dotKey))) effect.run()
81
+ })
82
+ }
83
+
84
+ const makeReactive = (obj: Record<string, any>, path: string): Record<string, any> => {
85
+ if (reactiveProxies.has(obj)) return obj
86
+
87
+ Object.entries(obj).forEach(([key, value]) => {
88
+ if (value && typeof value === "object" && isPlainData(value)) {
89
+ obj[key] = makeReactive(value, path ? `${path}.${key}` : key)
90
+ }
91
+ })
92
+
93
+ const proxy = new Proxy(obj, {
94
+ get(target, key, receiver) {
95
+ if (typeof key === "string") {
96
+ trackerStack[trackerStack.length - 1]?.add(path ? `${path}.${key}` : key)
97
+ }
98
+ return Reflect.get(target, key, receiver)
99
+ },
100
+ set(target, key: string, value, receiver) {
101
+ // an assignment delegated up the prototype chain from a derived scope
102
+ // (Object.create(store) child, or a wrapping proxy): if the key isn't
103
+ // a real property of this store, honor the receiver so the new binding
104
+ // lands on the derived scope - a scope-local variable, not a store
105
+ // mutation, so no notify. If the key IS a store property, fall through
106
+ // and mutate the store itself regardless of receiver, so assignments
107
+ // like @click="count = count + 1" work from any nested scope
108
+ if (receiver !== proxy && !Object.prototype.hasOwnProperty.call(target, key)) {
109
+ return Reflect.set(target, key, value, receiver)
110
+ }
111
+
112
+ const dotKey = path ? `${path}.${key}` : key
113
+ if (value && typeof value === "object" && isPlainData(value)) {
114
+ value = makeReactive(value, dotKey)
115
+ }
116
+ const isNewKey = !Object.prototype.hasOwnProperty.call(target, key)
117
+ target[key] = value
118
+ notify(dotKey, value, isNewKey)
119
+ return true
120
+ }
121
+ })
122
+
123
+ reactiveProxies.add(proxy)
124
+ return proxy
125
+ }
126
+
127
+ const reactive = makeReactive(data, "") as ReactiveDeepData<T>
128
+
129
+ const $on = (dotKey: string, listener: ChangeListener, { immediate = false }: ListenerOptions = {}): Unsubscribe => {
130
+ if (!exactListeners.has(dotKey)) exactListeners.set(dotKey, new Set())
131
+ exactListeners.get(dotKey)!.add(listener)
132
+ if (immediate) listener(getByPath(reactive, dotKey), dotKey)
133
+ return () => exactListeners.get(dotKey)?.delete(listener)
134
+ }
135
+
136
+ const $onAny = (listener: AnyChangeListener, { immediate = false }: ListenerOptions = {}): Unsubscribe => {
137
+ anyListeners.add(listener)
138
+ if (immediate) walkLeaves(reactive, "", (dotKey, value) => listener(dotKey, value))
139
+ return () => anyListeners.delete(listener)
140
+ }
141
+
142
+ const $effect = (run: () => void): Unsubscribe => {
143
+ const effect: Effect = {
144
+ deps: new Set(),
145
+ run: () => {
146
+ const deps = new Set<string>()
147
+ trackerStack.push(deps)
148
+ try {
149
+ run()
150
+ } finally {
151
+ trackerStack.pop()
152
+ effect.deps = deps
153
+ }
154
+ },
155
+ }
156
+ effects.add(effect)
157
+ effect.run()
158
+ return () => { effects.delete(effect) }
159
+ }
160
+
161
+ Object.defineProperty(reactive, "$on", { value: $on, enumerable: false })
162
+ Object.defineProperty(reactive, "$onAny", { value: $onAny, enumerable: false })
163
+ Object.defineProperty(reactive, "$effect", { value: $effect, enumerable: false })
164
+
165
+ return reactive
166
+ }
167
+ // groups the disposers of every $effect created for one rendered subtree
168
+ // (an :if branch, an :each item, ...) so the whole subtree's bindings can be
169
+ // torn down in one call when that subtree is replaced/removed. `scope.$effect`
170
+ // resolves through the prototype chain up to the root store no matter how
171
+ // many nested :each scopes sit in between (see renderEach's itemScope)
172
+ export type EffectScope = {
173
+ effect: (run: () => void) => void
174
+ // registers an arbitrary cleanup (e.g. destroying a nested component) to
175
+ // run when this subtree is torn down
176
+ onDispose: (fn: Unsubscribe) => void
177
+ dispose: () => void
178
+ }
179
+
180
+ export const createEffectScope = (scope: Record<string, any>): EffectScope => {
181
+ const disposers: Unsubscribe[] = []
182
+ return {
183
+ effect: run => { disposers.push(scope.$effect(run)) },
184
+ onDispose: fn => { disposers.push(fn) },
185
+ dispose: () => { disposers.splice(0).forEach(dispose => dispose()) },
186
+ }
187
+ }
@@ -0,0 +1,282 @@
1
+ // ---------------------------------------------------------------------------
2
+ // :setup script transform
3
+ //
4
+ // setup scripts are written like Svelte components:
5
+ //
6
+ // let firstName = null
7
+ // $: fullName = `${firstName} ${lastName}`
8
+ // fetchUser().then(user => { firstName = user.firstName })
9
+ //
10
+ // and are executed inside `with ($scope)` against the component's reactive
11
+ // store, so plain assignments (even from async callbacks) go through the
12
+ // proxy's set trap and re-render whatever depends on them. To make that work
13
+ // the source is lightly rewritten - no full JS parser, just a scanner that is
14
+ // string/comment-aware and only touches code at brace/paren depth 0:
15
+ // - `let/var/const x = ...` at the top level loses its keyword, becoming a
16
+ // scope assignment (the name is pre-declared on the store so the `with`
17
+ // lookup resolves it)
18
+ // - `$: x = expr` becomes `$__effect(() => { x = expr })`, re-running when a
19
+ // dependency read inside expr changes ($__effect is deliberately NOT a
20
+ // property of the scope, so `with` falls through to the function parameter)
21
+ // ---------------------------------------------------------------------------
22
+
23
+ type SetupTransform = { vars: string[]; code: string }
24
+
25
+ const DECLARATION_RE = /(?:let|var|const)\s+([A-Za-z_$][\w$]*)/y
26
+ const REACTIVE_LABEL_RE = /\$:\s*/y
27
+ const IMPORT_CALL_RE = /import(?=\s*\()/y
28
+ const REACTIVE_ASSIGN_RE = /\$:\s*([A-Za-z_$][\w$]*)\s*=(?!=)/y
29
+
30
+ const skipString = (src: string, start: number): number => {
31
+ const quote = src[start]
32
+ let i = start + 1
33
+ while (i < src.length) {
34
+ if (src[i] === "\\") { i += 2; continue }
35
+ if (src[i] === quote) return i + 1
36
+ i++
37
+ }
38
+ return src.length
39
+ }
40
+
41
+ const skipLineComment = (src: string, start: number): number => {
42
+ const end = src.indexOf("\n", start)
43
+ return end === -1 ? src.length : end
44
+ }
45
+
46
+ const skipBlockComment = (src: string, start: number): number => {
47
+ const end = src.indexOf("*/", start + 2)
48
+ return end === -1 ? src.length : end + 2
49
+ }
50
+
51
+ // end of a statement starting at `start`: the first newline or `;` that isn't
52
+ // inside a string/comment or unbalanced brackets, so multi-line RHS like a
53
+ // wrapped function call or template literal stays in one piece
54
+ const findStatementEnd = (src: string, start: number): number => {
55
+ let depth = 0
56
+ let i = start
57
+ while (i < src.length) {
58
+ const ch = src[i]
59
+ if (ch === "'" || ch === '"' || ch === "`") { i = skipString(src, i); continue }
60
+ if (ch === "/" && src[i + 1] === "/") { i = skipLineComment(src, i); continue }
61
+ if (ch === "/" && src[i + 1] === "*") { i = skipBlockComment(src, i); continue }
62
+ if ("([{".includes(ch)) depth++
63
+ else if (")]}".includes(ch)) depth--
64
+ else if (depth <= 0 && (ch === "\n" || ch === ";")) return i
65
+ i++
66
+ }
67
+ return src.length
68
+ }
69
+
70
+ export const transformSetupScript = (src: string): SetupTransform => {
71
+ const vars: string[] = []
72
+ let out = ""
73
+ let i = 0
74
+ let depth = 0
75
+ let atStatementStart = true
76
+
77
+ while (i < src.length) {
78
+ const ch = src[i]
79
+ const next = src[i + 1]
80
+
81
+ if (ch === "'" || ch === '"' || ch === "`") {
82
+ const end = skipString(src, i)
83
+ out += src.slice(i, end)
84
+ i = end
85
+ atStatementStart = false
86
+ continue
87
+ }
88
+ if (ch === "/" && (next === "/" || next === "*")) {
89
+ const end = next === "/" ? skipLineComment(src, i) : skipBlockComment(src, i)
90
+ out += src.slice(i, end)
91
+ i = end
92
+ continue
93
+ }
94
+
95
+ // `import(...)` is a keyword form, so it can't be intercepted through the
96
+ // scope - rewrite the identifier to the injected $__import (which loads
97
+ // .html URLs as components via Component79.fetch and delegates the rest to
98
+ // native import). The `(` is left for the scanner so depth stays balanced
99
+ if (ch === "i" && (i === 0 || !/[\w$.]/.test(src[i - 1]))) {
100
+ IMPORT_CALL_RE.lastIndex = i
101
+ if (IMPORT_CALL_RE.test(src)) {
102
+ out += "$__import"
103
+ i += "import".length
104
+ atStatementStart = false
105
+ continue
106
+ }
107
+ }
108
+
109
+ if (depth === 0 && atStatementStart) {
110
+ DECLARATION_RE.lastIndex = i
111
+ const decl = DECLARATION_RE.exec(src)
112
+ if (decl) {
113
+ vars.push(decl[1])
114
+ out += decl[1]
115
+ i += decl[0].length
116
+ atStatementStart = false
117
+ continue
118
+ }
119
+
120
+ REACTIVE_LABEL_RE.lastIndex = i
121
+ const label = REACTIVE_LABEL_RE.exec(src)
122
+ if (label) {
123
+ REACTIVE_ASSIGN_RE.lastIndex = i
124
+ const assign = REACTIVE_ASSIGN_RE.exec(src)
125
+ if (assign) vars.push(assign[1])
126
+ const start = i + label[0].length
127
+ const end = findStatementEnd(src, start)
128
+ out += `$__effect(() => { ${src.slice(start, end)} });`
129
+ i = end
130
+ continue
131
+ }
132
+ }
133
+
134
+ if ("([{".includes(ch)) depth++
135
+ else if (")]}".includes(ch)) depth = Math.max(0, depth - 1)
136
+
137
+ if (ch === "\n" || ch === ";" || ch === "}") atStatementStart = true
138
+ else if (!/\s/.test(ch)) atStatementStart = false
139
+
140
+ out += ch
141
+ i++
142
+ }
143
+
144
+ return { vars, code: out }
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // factory scripts - a <script> whose top level has `export default` runs as a
149
+ // plain lexical module instead of a `with`-scoped setup script: no implicit
150
+ // reactivity, no `$:` labels - standard JS that editors and type-checkers
151
+ // understand. The default export is called with the instance context
152
+ // ({ $data, $effect, $emit, $mounted, $self, $$self }) and a returned object
153
+ // is merged into the reactive store for the template to use.
154
+ // Detection is backwards-safe: `export default` is a SyntaxError inside a
155
+ // setup script, so no previously-working component can change behavior.
156
+ // The same scanner rewrites the module-only syntax into a Function body:
157
+ // - `export default X` -> `$__exports.default = X`
158
+ // - `import d from "m"` -> `const d = $__default(await $__import("m"))`
159
+ // (and the other static clause forms), so imports resolve through the same
160
+ // $__import as setup scripts: bundler map first, then fetch/native import
161
+ // ---------------------------------------------------------------------------
162
+
163
+ const EXPORT_DEFAULT_RE = /export\s+default(?![\w$])/y
164
+ // clause (default/namespace/named, no quotes or parens) + specifier; the
165
+ // no-clause alternative requires the specifier right away, so dynamic
166
+ // `import(...)` and `import.meta` never match
167
+ const STATIC_IMPORT_RE = /import\s*(?:([\w$\s,{}*]+?)\s*from\s*)?(["'])([^"'\n]+)\2/y
168
+
169
+ // splits an import clause on top-level commas: `d, { a, b as c }` keeps the
170
+ // braced group together
171
+ const splitImportClause = (clause: string): string[] => {
172
+ const parts: string[] = []
173
+ let depth = 0
174
+ let start = 0
175
+ for (let i = 0; i <= clause.length; i++) {
176
+ const ch = clause[i]
177
+ if (ch === "{") depth++
178
+ else if (ch === "}") depth--
179
+ else if (i === clause.length || (ch === "," && depth === 0)) {
180
+ const part = clause.slice(start, i).trim()
181
+ if (part) parts.push(part)
182
+ start = i + 1
183
+ }
184
+ }
185
+ return parts
186
+ }
187
+
188
+ // one static import statement -> const bindings from the awaited module
189
+ const staticImportToAwait = (clause: string | undefined, spec: string, n: number): string => {
190
+ const source = `await $__import(${JSON.stringify(spec)})`
191
+ if (clause === undefined) return source // side-effect import
192
+ const parts = splitImportClause(clause)
193
+ const bindings: string[] = []
194
+ let ref = source
195
+ if (parts.length > 1) {
196
+ const tmp = `$__mod${n}`
197
+ bindings.push(`${tmp} = ${source}`)
198
+ ref = tmp
199
+ }
200
+ for (const part of parts) {
201
+ if (part.startsWith("{")) bindings.push(`${part.replace(/\s+as\s+/g, ": ")} = ${ref}`)
202
+ else if (part.startsWith("*")) bindings.push(`${part.replace(/^\*\s*as\s+/, "")} = ${ref}`)
203
+ else bindings.push(`${part} = $__default(${ref})`)
204
+ }
205
+ return `const ${bindings.join(", ")}`
206
+ }
207
+
208
+ // rewrites a factory script into a Function body, or returns null when the
209
+ // script has no top-level `export default` (i.e. it's a regular setup script)
210
+ export const transformFactoryScript = (src: string): string | null => {
211
+ let out = ""
212
+ let i = 0
213
+ let depth = 0
214
+ let atStatementStart = true
215
+ let isFactory = false
216
+ let modCount = 0
217
+
218
+ while (i < src.length) {
219
+ const ch = src[i]
220
+ const next = src[i + 1]
221
+ const atWordBoundary = i === 0 || !/[\w$.]/.test(src[i - 1])
222
+
223
+ if (ch === "'" || ch === '"' || ch === "`") {
224
+ const end = skipString(src, i)
225
+ out += src.slice(i, end)
226
+ i = end
227
+ atStatementStart = false
228
+ continue
229
+ }
230
+ if (ch === "/" && (next === "/" || next === "*")) {
231
+ const end = next === "/" ? skipLineComment(src, i) : skipBlockComment(src, i)
232
+ out += src.slice(i, end)
233
+ i = end
234
+ continue
235
+ }
236
+
237
+ if (ch === "i" && atWordBoundary) {
238
+ // dynamic import() -> $__import, same rewrite as setup scripts
239
+ IMPORT_CALL_RE.lastIndex = i
240
+ if (IMPORT_CALL_RE.test(src)) {
241
+ out += "$__import"
242
+ i += "import".length
243
+ atStatementStart = false
244
+ continue
245
+ }
246
+ if (depth === 0 && atStatementStart) {
247
+ STATIC_IMPORT_RE.lastIndex = i
248
+ const staticImport = STATIC_IMPORT_RE.exec(src)
249
+ if (staticImport) {
250
+ out += staticImportToAwait(staticImport[1], staticImport[3], modCount++)
251
+ i += staticImport[0].length
252
+ atStatementStart = false
253
+ continue
254
+ }
255
+ }
256
+ }
257
+
258
+ if (ch === "e" && atWordBoundary && depth === 0 && atStatementStart) {
259
+ EXPORT_DEFAULT_RE.lastIndex = i
260
+ const exportDefault = EXPORT_DEFAULT_RE.exec(src)
261
+ if (exportDefault) {
262
+ isFactory = true
263
+ out += "$__exports.default ="
264
+ i += exportDefault[0].length
265
+ atStatementStart = false
266
+ continue
267
+ }
268
+ }
269
+
270
+ if ("([{".includes(ch)) depth++
271
+ else if (")]}".includes(ch)) depth = Math.max(0, depth - 1)
272
+
273
+ if (ch === "\n" || ch === ";" || ch === "}") atStatementStart = true
274
+ else if (!/\s/.test(ch)) atStatementStart = false
275
+
276
+ out += ch
277
+ i++
278
+ }
279
+
280
+ return isFactory ? out : null
281
+ }
282
+
package/src/vite.ts ADDED
@@ -0,0 +1,154 @@
1
+ import { readFile } from "node:fs/promises"
2
+ import type { Plugin } from "vite"
3
+
4
+ // Vite plugin: import .html single-file components as modules.
5
+ //
6
+ // import { jq79 } from "jq79/vite" // vite.config
7
+ // import UserCard from "./UserCard.html" // app code
8
+ //
9
+ // The imported value is a Component79 built from the file's source - the same
10
+ // thing `await Component79.fetch(url)` resolves to, but bundled at build time
11
+ // instead of fetched at runtime. The plugin is a pure loader: the component
12
+ // source is inlined verbatim (no transforms), so a file keeps working
13
+ // unchanged if it's ever served from public/ and loaded with fetch instead.
14
+ //
15
+ // Only .html files imported from other modules are claimed; entry points
16
+ // (index.html) have no importer and imports carrying an explicit query
17
+ // (?raw, ?url) keep their built-in Vite meaning.
18
+
19
+ export interface Jq79PluginOptions {
20
+ // which import specifiers are treated as components (default: any .html)
21
+ include?: RegExp
22
+ // resolved absolute paths to skip even when `include` matches
23
+ exclude?: RegExp
24
+ }
25
+
26
+ // claimed modules get this suffix so their id no longer ends in ".html" and
27
+ // Vite's own html handling (entries, asset pipeline) leaves them alone
28
+ const COMPONENT_QUERY = "?jq79"
29
+
30
+ const SCRIPT_BLOCK_RE = /<script\b[^>]*>([\s\S]*?)<\/script\s*>/gi
31
+ // import("...") with a literal specifier; the lookbehind skips $__import and
32
+ // property accesses like foo.import(...)
33
+ const IMPORT_LITERAL_RE = /(?<![\w$.])import\s*\(\s*(["'])([^"'\n]+?)\1\s*\)/g
34
+ // static import statements (factory scripts): optional clause + literal
35
+ // specifier. The clause can't contain parens/quotes, so dynamic import()
36
+ // and import.meta never match
37
+ const STATIC_IMPORT_LITERAL_RE = /(?<![\w$.])import\s*(?:[\w$\s,{}*]+?\s*from\s*)?(["'])([^"'\n]+)\1/g
38
+
39
+ const isHtmlUrl = (spec: string) => /\.html?([?#]|$)/.test(spec)
40
+ const isRelative = (spec: string) => spec.startsWith("./") || spec.startsWith("../")
41
+ const isExternalUrl = (spec: string) => /^[a-z][a-z0-9+.-]*:/i.test(spec) || spec.startsWith("/")
42
+
43
+ // literal import specifiers in the component's script blocks - dynamic
44
+ // `import("...")` calls and static factory-script imports - that should
45
+ // resolve from the bundle instead of at runtime. Absolute paths and full
46
+ // URLs are left alone (they point at served files, e.g. public/), and so
47
+ // are .html specifiers the plugin wouldn't claim as components
48
+ const hoistableImports = (source: string, include: RegExp): string[] => {
49
+ const specifiers = new Set<string>()
50
+ for (const [, script] of source.matchAll(SCRIPT_BLOCK_RE)) {
51
+ const specs = [
52
+ ...[...script.matchAll(IMPORT_LITERAL_RE)].map(match => match[2]),
53
+ ...[...script.matchAll(STATIC_IMPORT_LITERAL_RE)].map(match => match[2]),
54
+ ]
55
+ for (const spec of specs) {
56
+ if (isExternalUrl(spec)) continue
57
+ if (isHtmlUrl(spec) && !include.test(spec)) continue // html left to runtime fetch
58
+ specifiers.add(spec) // a claimed component, a source file or an npm package
59
+ }
60
+ }
61
+ return [...specifiers]
62
+ }
63
+
64
+ // the emitted module. Literal import("...") specifiers found in the
65
+ // component's scripts become real module imports, handed to Component79 as a
66
+ // resolution map: at runtime $__import checks the map before falling back to
67
+ // fetch, so bundled components ship with their imports and nothing changes
68
+ // for unbundled ones. Claimed components import as their default (a
69
+ // Component79, matching what runtime fetch resolves to); everything else as
70
+ // a namespace (matching native import()).
71
+ //
72
+ // In dev, `hot.data` carries the exported instance across updates: importers
73
+ // hold a reference to the *first* module evaluation's instance, so later
74
+ // evaluations patch that same instance in place (the parsed parts are public
75
+ // fields) instead of exporting a new one nobody sees. A live instance is
76
+ // re-rendered where it stands, seeded with a snapshot of its current store;
77
+ // an instance only used as a definition (nested component clones can't be
78
+ // reached from here) falls back to a full reload. `mountRoot` is internal to
79
+ // Component79, but plugin and runtime ship in lockstep from the same package.
80
+ const componentModule = (source: string, include: RegExp): string => {
81
+ const hoisted = hoistableImports(source, include)
82
+ const imports = hoisted
83
+ .map((spec, i) =>
84
+ include.test(spec)
85
+ ? `import __jq79_${i} from ${JSON.stringify(spec)}`
86
+ : `import * as __jq79_${i} from ${JSON.stringify(spec)}`
87
+ )
88
+ .join("\n")
89
+ const modulesMap = `{ ${hoisted.map((spec, i) => `${JSON.stringify(spec)}: __jq79_${i}`).join(", ")} }`
90
+
91
+ return `
92
+ import { Component79 } from "jq79"
93
+ ${imports}
94
+
95
+ const src = ${JSON.stringify(source)}
96
+ const modules = ${modulesMap}
97
+
98
+ let component
99
+
100
+ if (import.meta.hot && import.meta.hot.data.component) {
101
+ const prior = import.meta.hot.data.component
102
+ const next = new Component79(src)
103
+ prior.template = next.template
104
+ prior.scripts = next.scripts
105
+ prior.styles = next.styles
106
+ prior.modules = modules
107
+ const root = prior.mountRoot
108
+ if (root) {
109
+ prior.mount(root, { ...prior.data })
110
+ } else if (!prior.data) {
111
+ import.meta.hot.invalidate()
112
+ }
113
+ component = prior
114
+ } else {
115
+ component = new Component79(src, { modules })
116
+ }
117
+
118
+ if (import.meta.hot) {
119
+ import.meta.hot.data.component = component
120
+ import.meta.hot.accept()
121
+ }
122
+
123
+ export default component
124
+ `
125
+ }
126
+
127
+ export function jq79(options: Jq79PluginOptions = {}): Plugin {
128
+ const include = options.include ?? /\.html$/
129
+ const { exclude } = options
130
+
131
+ return {
132
+ name: "jq79",
133
+ enforce: "pre",
134
+
135
+ async resolveId(source, importer) {
136
+ if (!importer) return null // entry points are never components
137
+ if (source.includes("?")) return null // ?raw, ?url, ... keep their meaning
138
+ if (!include.test(source)) return null
139
+
140
+ const resolved = await this.resolve(source, importer, { skipSelf: true })
141
+ if (!resolved || resolved.external) return null
142
+ if (exclude?.test(resolved.id)) return null
143
+ return resolved.id + COMPONENT_QUERY
144
+ },
145
+
146
+ async load(id) {
147
+ if (!id.endsWith(COMPONENT_QUERY)) return null
148
+ const file = id.slice(0, -COMPONENT_QUERY.length)
149
+ return { code: componentModule(await readFile(file, "utf8"), include), map: null }
150
+ },
151
+ }
152
+ }
153
+
154
+ export default jq79