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/README.md +43 -271
- package/assets/Component79.svg +8 -0
- package/dist/dom.d.ts +3 -0
- package/dist/jq79.cjs +9 -5
- package/dist/jq79.cjs.map +1 -1
- package/dist/jq79.d.ts +8 -17
- package/dist/jq79.global.js +9 -5
- package/dist/jq79.global.js.map +1 -1
- package/dist/jq79.js +9 -5
- package/dist/jq79.js.map +1 -1
- package/dist/reactive.d.ts +20 -0
- package/dist/transform.d.ts +7 -0
- package/dist/vite.cjs +116 -0
- package/dist/vite.cjs.map +1 -0
- package/dist/vite.d.ts +7 -0
- package/dist/vite.js +92 -0
- package/dist/vite.js.map +1 -0
- package/package.json +24 -4
- package/src/dom.ts +33 -0
- package/src/jq79.ts +95 -367
- package/src/reactive.ts +187 -0
- package/src/transform.ts +282 -0
- package/src/vite.ts +154 -0
package/src/reactive.ts
ADDED
|
@@ -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
|
+
}
|
package/src/transform.ts
ADDED
|
@@ -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
|