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/README.md +44 -259
- 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 +16 -19
- 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 +160 -384
- package/src/reactive.ts +187 -0
- package/src/transform.ts +282 -0
- package/src/vite.ts +154 -0
package/src/jq79.ts
CHANGED
|
@@ -1,35 +1,11 @@
|
|
|
1
1
|
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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() //
|
|
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
|
|
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,
|
|
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.
|
|
895
|
-
//
|
|
896
|
-
// ...
|
|
897
|
-
// jq79.
|
|
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
|
|
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
|
|
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
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
1030
|
-
|
|
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
|
-
|
|
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.
|
|
836
|
+
this.detach()
|
|
1061
837
|
this.fx?.dispose()
|
|
1062
838
|
this.fx = null
|
|
1063
839
|
this.styleEls.forEach(el => el.parentNode?.removeChild(el))
|