jq79 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/jq79.ts ADDED
@@ -0,0 +1,964 @@
1
+
2
+ export const $ = (selectorOrEl: string | Element, selector?: string) =>
3
+ typeof selectorOrEl === "string"
4
+ ? document.querySelector(selectorOrEl)
5
+ : selectorOrEl.querySelector(selector || "")
6
+
7
+ export const $$ = (selectorOrEl: string | Element, selector?: string) => Array.from(
8
+ typeof selectorOrEl === "string"
9
+ ? document.querySelectorAll(selectorOrEl)
10
+ : selectorOrEl.querySelectorAll(selector || "")
11
+ )
12
+
13
+ type TemplateNode = {
14
+ tag: string
15
+ attrs: Record<string, string>
16
+ children: (TemplateNode | string)[]
17
+ }
18
+
19
+ type TagBlock = {
20
+ attrs: Record<string, string>
21
+ content: string
22
+ }
23
+
24
+ const elementAttrs = (el: Element): Record<string, string> =>
25
+ Object.fromEntries(Array.from(el.attributes).map(attr => [attr.name, attr.value]))
26
+
27
+ const elementToAST = (el: Element): TemplateNode => ({
28
+ tag: el.tagName.toLowerCase(),
29
+ attrs: elementAttrs(el),
30
+ children: Array.from(el.childNodes).flatMap((node): (TemplateNode | string)[] => {
31
+ if (node.nodeType === Node.TEXT_NODE) {
32
+ const text = node.textContent?.trim() ?? ""
33
+ return text ? [text] : []
34
+ }
35
+ if (node.nodeType === Node.ELEMENT_NODE) {
36
+ return [elementToAST(node as Element)]
37
+ }
38
+ return []
39
+ })
40
+ })
41
+
42
+ // evaluated with `with` (rather than passing scope keys as positional params)
43
+ // so only the identifiers an expression actually references are read from
44
+ // `scope` - which is what makes dependency tracking in createReactiveDeepData
45
+ // precise instead of "read everything up front". `extras` are passed as
46
+ // function parameters (outside the `with`), so scope keys still win but names
47
+ // like $event resolve when the scope doesn't shadow them
48
+ const evalExpr = (expr: string, scope: Record<string, any>, extras?: Record<string, any>): any => {
49
+ try {
50
+ return new Function("$scope", ...Object.keys(extras ?? {}), `with ($scope) { return (${expr}); }`)(
51
+ scope,
52
+ ...Object.values(extras ?? {})
53
+ )
54
+ } catch {
55
+ return undefined
56
+ }
57
+ }
58
+
59
+ const interpolate = (template: string, scope: Record<string, any>): string =>
60
+ template.replace(/{{\s*(.+?)\s*}}/g, (_, expr) => evalExpr(expr, scope) ?? "")
61
+
62
+ type ChangeListener = (value: any, dotKey: string) => void
63
+ type AnyChangeListener = (dotKey: string, value: any) => void
64
+ type ListenerOptions = { immediate?: boolean }
65
+ type Unsubscribe = () => void
66
+
67
+ type ReactiveDeepData<T> = T & {
68
+ $on: (dotKey: string, listener: ChangeListener, options?: ListenerOptions) => Unsubscribe
69
+ $onAny: (listener: AnyChangeListener, options?: ListenerOptions) => Unsubscribe
70
+ // runs `run` immediately, recording every dotKey it reads off this store, then
71
+ // re-runs it whenever a changed dotKey overlaps one of those - see pathsOverlap
72
+ $effect: (run: () => void) => Unsubscribe
73
+ }
74
+
75
+ const getByPath = (obj: Record<string, any>, dotKey: string): any =>
76
+ dotKey.split(".").reduce((acc, key) => (acc == null ? undefined : acc[key]), obj)
77
+
78
+ // only plain objects and arrays get deep-wrapped by the reactive store;
79
+ // class instances (Component79, Date, DOM nodes, ...) pass through untouched
80
+ // so their identity, prototypes and internals stay intact
81
+ const isPlainData = (value: object): boolean => {
82
+ if (Array.isArray(value)) return true
83
+ const proto = Object.getPrototypeOf(value)
84
+ return proto === Object.prototype || proto === null
85
+ }
86
+
87
+ const walkLeaves = (obj: Record<string, any>, path: string, visit: (dotKey: string, value: any) => void) => {
88
+ Object.entries(obj).forEach(([key, value]) => {
89
+ const dotKey = path ? `${path}.${key}` : key
90
+ if (value && typeof value === "object" && isPlainData(value)) walkLeaves(value, dotKey, visit)
91
+ else visit(dotKey, value)
92
+ })
93
+ }
94
+
95
+ // true when `a` and `b` sit on the same ancestor/descendant line, e.g.
96
+ // "user" & "user.address.city" (a change to either affects the other) - false
97
+ // for siblings like "user.name" & "user.age"
98
+ const pathsOverlap = (a: string, b: string): boolean =>
99
+ a === b || a.startsWith(`${b}.`) || b.startsWith(`${a}.`)
100
+
101
+ // active $effect() runs, innermost last - a module-level stack (rather than
102
+ // one per store) so nested effects across stores still nest correctly; reads
103
+ // during makeReactive's `get` trap are attributed to whichever run is on top
104
+ const trackerStack: Set<string>[] = []
105
+
106
+ // runs fn with dependency tracking suspended - reads inside it are attributed
107
+ // to a throwaway set instead of the currently running effect
108
+ const untracked = <T>(fn: () => T): T => {
109
+ trackerStack.push(new Set())
110
+ try {
111
+ return fn()
112
+ } finally {
113
+ trackerStack.pop()
114
+ }
115
+ }
116
+
117
+ type Effect = { deps: Set<string>; run: () => void }
118
+
119
+ export const createReactiveDeepData = <T extends Record<string, any>>(data: T): ReactiveDeepData<T> => {
120
+ const exactListeners = new Map<string, Set<ChangeListener>>()
121
+ const anyListeners = new Set<AnyChangeListener>()
122
+ const effects = new Set<Effect>()
123
+ // every proxy this store has ever handed out, so re-assigning an object
124
+ // that's already reactive (e.g. `data.list = [data.list[1], data.list[0]]`)
125
+ // doesn't wrap it a second time - which would hand out a *new* object
126
+ // identity for the same logical item, breaking reference-equality checks
127
+ // like :each's keyed diffing
128
+ const reactiveProxies = new WeakSet<object>()
129
+
130
+ const notify = (dotKey: string, value: any) => {
131
+ exactListeners.get(dotKey)?.forEach(listener => listener(value, dotKey))
132
+ anyListeners.forEach(listener => listener(dotKey, value))
133
+ effects.forEach(effect => {
134
+ if (Array.from(effect.deps).some(dep => pathsOverlap(dep, dotKey))) effect.run()
135
+ })
136
+ }
137
+
138
+ const makeReactive = (obj: Record<string, any>, path: string): Record<string, any> => {
139
+ if (reactiveProxies.has(obj)) return obj
140
+
141
+ Object.entries(obj).forEach(([key, value]) => {
142
+ if (value && typeof value === "object" && isPlainData(value)) {
143
+ obj[key] = makeReactive(value, path ? `${path}.${key}` : key)
144
+ }
145
+ })
146
+
147
+ const proxy = new Proxy(obj, {
148
+ get(target, key, receiver) {
149
+ if (typeof key === "string") {
150
+ trackerStack[trackerStack.length - 1]?.add(path ? `${path}.${key}` : key)
151
+ }
152
+ return Reflect.get(target, key, receiver)
153
+ },
154
+ set(target, key: string, value, receiver) {
155
+ // an assignment delegated up the prototype chain from a derived scope
156
+ // (Object.create(store) child, or a wrapping proxy): if the key isn't
157
+ // a real property of this store, honor the receiver so the new binding
158
+ // lands on the derived scope - a scope-local variable, not a store
159
+ // mutation, so no notify. If the key IS a store property, fall through
160
+ // and mutate the store itself regardless of receiver, so assignments
161
+ // like @click="count = count + 1" work from any nested scope
162
+ if (receiver !== proxy && !Object.prototype.hasOwnProperty.call(target, key)) {
163
+ return Reflect.set(target, key, value, receiver)
164
+ }
165
+
166
+ const dotKey = path ? `${path}.${key}` : key
167
+ if (value && typeof value === "object" && isPlainData(value)) {
168
+ value = makeReactive(value, dotKey)
169
+ }
170
+ target[key] = value
171
+ notify(dotKey, value)
172
+ return true
173
+ }
174
+ })
175
+
176
+ reactiveProxies.add(proxy)
177
+ return proxy
178
+ }
179
+
180
+ const reactive = makeReactive(data, "") as ReactiveDeepData<T>
181
+
182
+ const $on = (dotKey: string, listener: ChangeListener, { immediate = false }: ListenerOptions = {}): Unsubscribe => {
183
+ if (!exactListeners.has(dotKey)) exactListeners.set(dotKey, new Set())
184
+ exactListeners.get(dotKey)!.add(listener)
185
+ if (immediate) listener(getByPath(reactive, dotKey), dotKey)
186
+ return () => exactListeners.get(dotKey)?.delete(listener)
187
+ }
188
+
189
+ const $onAny = (listener: AnyChangeListener, { immediate = false }: ListenerOptions = {}): Unsubscribe => {
190
+ anyListeners.add(listener)
191
+ if (immediate) walkLeaves(reactive, "", (dotKey, value) => listener(dotKey, value))
192
+ return () => anyListeners.delete(listener)
193
+ }
194
+
195
+ const $effect = (run: () => void): Unsubscribe => {
196
+ const effect: Effect = {
197
+ deps: new Set(),
198
+ run: () => {
199
+ const deps = new Set<string>()
200
+ trackerStack.push(deps)
201
+ try {
202
+ run()
203
+ } finally {
204
+ trackerStack.pop()
205
+ effect.deps = deps
206
+ }
207
+ },
208
+ }
209
+ effects.add(effect)
210
+ effect.run()
211
+ return () => { effects.delete(effect) }
212
+ }
213
+
214
+ Object.defineProperty(reactive, "$on", { value: $on, enumerable: false })
215
+ Object.defineProperty(reactive, "$onAny", { value: $onAny, enumerable: false })
216
+ Object.defineProperty(reactive, "$effect", { value: $effect, enumerable: false })
217
+
218
+ return reactive
219
+ }
220
+
221
+ const CONTROL_ATTRS = new Set([":bind", ":if", ":elseif", ":else", ":each", ":key"])
222
+ const EACH_PATTERN = /^\s*(\w+)\s+in\s+(.+)$/
223
+
224
+ type ConditionalBranch = { expr?: string; node: TemplateNode }
225
+
226
+ // groups the disposers of every $effect created for one rendered subtree
227
+ // (an :if branch, an :each item, ...) so the whole subtree's bindings can be
228
+ // torn down in one call when that subtree is replaced/removed. `scope.$effect`
229
+ // resolves through the prototype chain up to the root store no matter how
230
+ // many nested :each scopes sit in between (see renderEach's itemScope)
231
+ type EffectScope = {
232
+ effect: (run: () => void) => void
233
+ // registers an arbitrary cleanup (e.g. destroying a nested component) to
234
+ // run when this subtree is torn down
235
+ onDispose: (fn: Unsubscribe) => void
236
+ dispose: () => void
237
+ }
238
+
239
+ const createEffectScope = (scope: Record<string, any>): EffectScope => {
240
+ const disposers: Unsubscribe[] = []
241
+ return {
242
+ effect: run => { disposers.push(scope.$effect(run)) },
243
+ onDispose: fn => { disposers.push(fn) },
244
+ dispose: () => { disposers.splice(0).forEach(dispose => dispose()) },
245
+ }
246
+ }
247
+
248
+ // @event attributes: @click="onClick", @submit.prevent="$event => onSubmit($event)",
249
+ // or an inline statement like @click="count = count + 1". The expression is
250
+ // evaluated (with `$event` in scope) on every event; if it yields a function,
251
+ // that function is then invoked with the event - so both a handler reference
252
+ // and an inline arrow/statement work. Modifiers after dots: .prevent .stop
253
+ // .self (runtime guards) and .once .capture (addEventListener options)
254
+ const bindEvent = (el: Element, attr: string, expr: string, scope: Record<string, any>) => {
255
+ const [name, ...modifiers] = attr.slice(1).split(".")
256
+ const mods = new Set(modifiers)
257
+
258
+ el.addEventListener(name, event => {
259
+ if (mods.has("self") && event.target !== el) return
260
+ if (mods.has("prevent")) event.preventDefault()
261
+ if (mods.has("stop")) event.stopPropagation()
262
+
263
+ const handler = evalExpr(expr, scope, { $event: event })
264
+ if (typeof handler === "function") handler.call(el, event)
265
+ }, { once: mods.has("once"), capture: mods.has("capture") })
266
+ }
267
+
268
+ const kebabToCamel = (name: string) => name.replace(/-(\w)/g, (_, c: string) => c.toUpperCase())
269
+
270
+ // finds the scope variable a template tag refers to. HTML parsing lowercases
271
+ // tag names, so <NestedComponent> arrives as "nestedcomponent" and matching is
272
+ // case-insensitive with dashes stripped (<nested-component> works too). Only
273
+ // PascalCase scope keys participate, so ordinary variables named like real
274
+ // elements (title, code, ...) never hijack them
275
+ const findComponentKey = (scope: Record<string, any>, tag: string): string | null => {
276
+ const normalized = tag.replace(/-/g, "").toLowerCase()
277
+ for (let obj: any = scope; obj && obj !== Object.prototype; obj = Object.getPrototypeOf(obj)) {
278
+ for (const key of Object.keys(obj)) {
279
+ if (/^[A-Z]/.test(key) && key.replace(/-/g, "").toLowerCase() === normalized) return key
280
+ }
281
+ }
282
+ return null
283
+ }
284
+
285
+ // <MyComponent :user :title="'str'"></MyComponent> - renders a child
286
+ // component instance at this position. Props: `:name="expr"` evaluates expr
287
+ // in the parent scope (`:name` alone is shorthand for `:name="name"`), plain
288
+ // attributes pass through as literal strings, and kebab-case prop names
289
+ // become camelCase. Props stay live: a parent effect re-evaluates each
290
+ // expression and writes it into the child's store. The component variable is
291
+ // reactive too - while it's undefined (e.g. an `await import(...)` still in
292
+ // flight) nothing renders, and the child appears when it resolves
293
+ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<string, any>, fx: EffectScope): Node => {
294
+ const anchor = document.createComment(key)
295
+ const wrapper = document.createDocumentFragment()
296
+ wrapper.appendChild(anchor)
297
+
298
+ const props: Record<string, string> = {} // prop name -> expression in parent scope
299
+ Object.entries(node.attrs).forEach(([attr, value]) => {
300
+ if (CONTROL_ATTRS.has(attr) || attr.startsWith("@")) return
301
+ if (attr.startsWith(":")) {
302
+ const name = kebabToCamel(attr.slice(1))
303
+ props[name] = value || name
304
+ } else {
305
+ props[kebabToCamel(attr)] = JSON.stringify(value)
306
+ }
307
+ })
308
+
309
+ let current: Component79 | null = null
310
+ let currentDef: Component79 | null = null
311
+ let childFx: EffectScope | null = null
312
+
313
+ fx.effect(() => {
314
+ const value = evalExpr(key, scope)
315
+ const nextDef = value instanceof Component79 ? value : null
316
+ if (nextDef === currentDef) return
317
+
318
+ childFx?.dispose()
319
+ childFx = null
320
+ current?.destroy() // unmounts its marker range, removing the child's DOM
321
+ current = null
322
+ currentDef = nextDef
323
+ if (!nextDef) return
324
+
325
+ // a fresh instance per usage site: the definition's parsed parts are
326
+ // shared, but store/effects/DOM are per instance
327
+ const instance = new Component79({ template: nextDef.template, scripts: nextDef.scripts, styles: nextDef.styles })
328
+ const seed = untracked(() =>
329
+ Object.fromEntries(Object.entries(props).map(([name, expr]) => [name, evalExpr(expr, scope)]))
330
+ )
331
+ const holder = document.createDocumentFragment()
332
+ instance.render(seed).mount(holder)
333
+ anchor.parentNode!.insertBefore(holder, anchor.nextSibling)
334
+
335
+ const syncFx = createEffectScope(scope)
336
+ Object.entries(props).forEach(([name, expr]) => {
337
+ syncFx.effect(() => { (instance.data as Record<string, any>)[name] = evalExpr(expr, scope) })
338
+ })
339
+
340
+ childFx = syncFx
341
+ current = instance
342
+ })
343
+
344
+ fx.onDispose(() => {
345
+ childFx?.dispose()
346
+ current?.destroy()
347
+ })
348
+
349
+ return wrapper
350
+ }
351
+
352
+ // renders a single element node: static attrs, @event listeners, a reactive
353
+ // :bind object, and its (reactive) children. :if/:elseif/:else/:each are
354
+ // handled by renderNodes, which decides *whether*/*how many times* a node is
355
+ // rendered before calling this. Tags matching a PascalCase scope variable
356
+ // render as nested components instead
357
+ const renderNode = (node: TemplateNode, scope: Record<string, any>, fx: EffectScope): Node => {
358
+ const componentKey = findComponentKey(scope, node.tag)
359
+ if (componentKey) return renderNestedComponent(componentKey, node, scope, fx)
360
+
361
+ const el = document.createElement(node.tag)
362
+
363
+ Object.entries(node.attrs).forEach(([key, value]) => {
364
+ if (key.startsWith("@")) bindEvent(el, key, value, scope)
365
+ else if (!CONTROL_ATTRS.has(key)) el.setAttribute(key, value)
366
+ })
367
+
368
+ const bindExpr = node.attrs[":bind"]
369
+ if (bindExpr !== undefined) {
370
+ let boundKeys: string[] = []
371
+
372
+ fx.effect(() => {
373
+ boundKeys.forEach(key => el.removeAttribute(key))
374
+ const bound = evalExpr(bindExpr, scope)
375
+ boundKeys = bound && typeof bound === "object" ? Object.keys(bound) : []
376
+ boundKeys.forEach(key => {
377
+ const value = bound[key]
378
+ if (value != null && value !== false) el.setAttribute(key, String(value))
379
+ })
380
+ })
381
+ }
382
+
383
+ el.appendChild(renderNodes(node.children, scope, fx))
384
+
385
+ return el
386
+ }
387
+
388
+ // a :if/:elseif*/:else? chain sharing one anchor comment so the active branch
389
+ // can be swapped in place without disturbing sibling positions. Only depends
390
+ // on whatever the branch expressions read (e.g. "score"), and skips
391
+ // rebuilding entirely when the active branch hasn't actually changed
392
+ const renderConditional = (branches: ConditionalBranch[], scope: Record<string, any>, fx: EffectScope): Node => {
393
+ const anchor = document.createComment("if")
394
+ const wrapper = document.createDocumentFragment()
395
+ wrapper.appendChild(anchor)
396
+
397
+ let current: Node | null = null
398
+ let activeBranch: ConditionalBranch | null = null
399
+ let branchFx: EffectScope | null = null
400
+
401
+ fx.effect(() => {
402
+ const next = branches.find(branch => branch.expr === undefined || evalExpr(branch.expr, scope)) ?? null
403
+ if (next === activeBranch) return
404
+
405
+ branchFx?.dispose()
406
+ if (current) current.parentNode?.removeChild(current)
407
+ current = null
408
+ activeBranch = next
409
+ if (!next) return
410
+
411
+ branchFx = createEffectScope(scope)
412
+ current = renderNode(next.node, scope, branchFx)
413
+ anchor.parentNode!.insertBefore(current, anchor.nextSibling)
414
+ })
415
+
416
+ return wrapper
417
+ }
418
+
419
+ // defines a loop-local binding directly as `scope`'s own property. Plain
420
+ // assignment (scope[key] = value) would only do this if the key isn't
421
+ // already own on `scope` *or anywhere up its prototype chain* - if it isn't,
422
+ // JS delegates the [[Set]] to whatever's up there, which for us is another
423
+ // reactive proxy's `set` trap: it would wrap `value` as if it were a genuine
424
+ // store mutation and fire a bogus notify() under a name (e.g. "item") shared
425
+ // by every unrelated item in every :each on the page. defineProperty always
426
+ // writes to `scope` itself, never delegating, so this can't happen
427
+ const defineScopeVar = (scope: Record<string, any>, key: string, value: any) => {
428
+ Object.defineProperty(scope, key, { value, writable: true, enumerable: true, configurable: true })
429
+ }
430
+
431
+ type EachEntry = { key: any; item: any; scope: Record<string, any>; node: Node; fx: EffectScope }
432
+
433
+ // :each="item in items", optionally keyed with :key="expr". Only depends on
434
+ // the list expression itself (e.g. "items"), and on each run diffs by key:
435
+ // unchanged items (same key, same item reference) keep their DOM/effects,
436
+ // changed/added ones are (re)rendered, removed ones are disposed. Without
437
+ // :key, position is used as the key, so reordering rebuilds every item after
438
+ // the first change - add :key for anything that gets reordered or filtered.
439
+ // Each item gets its own scope via Object.create(scope), so `item`/`$index`
440
+ // shadow same-named outer bindings without copying the parent scope's keys
441
+ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectScope): Node => {
442
+ const match = node.attrs[":each"].match(EACH_PATTERN)
443
+ if (!match) return document.createComment(`invalid :each expression "${node.attrs[":each"]}"`)
444
+
445
+ const [, itemName, listExpr] = match
446
+ const keyExpr = node.attrs[":key"]
447
+ const { [":each"]: _each, [":key"]: _key, ...itemAttrs } = node.attrs
448
+ const itemNode: TemplateNode = { ...node, attrs: itemAttrs }
449
+
450
+ const anchor = document.createComment("each")
451
+ const wrapper = document.createDocumentFragment()
452
+ wrapper.appendChild(anchor)
453
+
454
+ let entries: EachEntry[] = []
455
+
456
+ fx.effect(() => {
457
+ const list = evalExpr(listExpr, scope)
458
+ const items = Array.isArray(list) ? list : []
459
+ const previous = new Map(entries.map(entry => [entry.key, entry]))
460
+
461
+ const nextEntries = items.map((item, index): EachEntry => {
462
+ const itemScope = Object.create(scope)
463
+ defineScopeVar(itemScope, itemName, item)
464
+ defineScopeVar(itemScope, "$index", index)
465
+ const key = keyExpr !== undefined ? evalExpr(keyExpr, itemScope) : index
466
+ const existing = previous.get(key)
467
+
468
+ if (existing && Object.is(existing.item, item)) {
469
+ defineScopeVar(existing.scope, "$index", index)
470
+ return existing
471
+ }
472
+
473
+ existing?.fx.dispose()
474
+ existing?.node.parentNode?.removeChild(existing.node)
475
+
476
+ const itemFx = createEffectScope(scope)
477
+ return { key, item, scope: itemScope, fx: itemFx, node: renderNode(itemNode, itemScope, itemFx) }
478
+ })
479
+
480
+ const nextKeys = new Set(nextEntries.map(entry => entry.key))
481
+ entries.forEach(entry => {
482
+ if (!nextKeys.has(entry.key)) {
483
+ entry.fx.dispose()
484
+ entry.node.parentNode?.removeChild(entry.node)
485
+ }
486
+ })
487
+
488
+ let prevNode: Node = anchor
489
+ nextEntries.forEach(entry => {
490
+ if (prevNode.nextSibling !== entry.node) anchor.parentNode!.insertBefore(entry.node, prevNode.nextSibling)
491
+ prevNode = entry.node
492
+ })
493
+
494
+ entries = nextEntries
495
+ })
496
+
497
+ return wrapper
498
+ }
499
+
500
+ // renders a list of sibling template nodes (text + elements), grouping
501
+ // consecutive :if/:elseif/:else nodes into a single conditional block
502
+ const renderNodes = (nodes: (TemplateNode | string)[], scope: Record<string, any>, fx: EffectScope): DocumentFragment => {
503
+ const fragment = document.createDocumentFragment()
504
+ let i = 0
505
+
506
+ while (i < nodes.length) {
507
+ const node = nodes[i]
508
+
509
+ if (typeof node === "string") {
510
+ const textNode = document.createTextNode("")
511
+ fx.effect(() => { textNode.textContent = interpolate(node, scope) })
512
+ fragment.appendChild(textNode)
513
+ i++
514
+ continue
515
+ }
516
+
517
+ if (":each" in node.attrs) {
518
+ fragment.appendChild(renderEach(node, scope, fx))
519
+ i++
520
+ continue
521
+ }
522
+
523
+ if (":if" in node.attrs) {
524
+ const branches: ConditionalBranch[] = [{ expr: node.attrs[":if"], node }]
525
+ i++
526
+ while (i < nodes.length && typeof nodes[i] !== "string" && ":elseif" in (nodes[i] as TemplateNode).attrs) {
527
+ const elseifNode = nodes[i] as TemplateNode
528
+ branches.push({ expr: elseifNode.attrs[":elseif"], node: elseifNode })
529
+ i++
530
+ }
531
+ if (i < nodes.length && typeof nodes[i] !== "string" && ":else" in (nodes[i] as TemplateNode).attrs) {
532
+ branches.push({ node: nodes[i] as TemplateNode })
533
+ i++
534
+ }
535
+
536
+ fragment.appendChild(renderConditional(branches, scope, fx))
537
+ continue
538
+ }
539
+
540
+ fragment.appendChild(renderNode(node, scope, fx))
541
+ i++
542
+ }
543
+
544
+ return fragment
545
+ }
546
+
547
+ export const renderComponent = (component: Component79, data: ReactiveDeepData<Record<string, any>>): Node =>
548
+ renderNodes(component.template, data, createEffectScope(data))
549
+
550
+ type ComponentParts = {
551
+ template: TemplateNode[]
552
+ scripts: TagBlock[]
553
+ styles: TagBlock[]
554
+ }
555
+
556
+ const VOID_ELEMENTS = new Set([
557
+ "area", "base", "br", "col", "embed", "hr", "img", "input",
558
+ "link", "meta", "param", "source", "track", "wbr",
559
+ ])
560
+
561
+ // a self-closing tag with its attributes; quoted attribute values are matched
562
+ // as whole chunks so a "/>" inside one doesn't end the tag early
563
+ const SELF_CLOSING_RE = /<([A-Za-z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)\/>/g
564
+ const RAW_BLOCK_RE = /(<script[\s\S]*?<\/script\s*>|<style[\s\S]*?<\/style\s*>)/gi
565
+
566
+ // expands self-closing tags (<MyComponent />, <div />) into explicit
567
+ // open+close pairs BEFORE DOM parsing. The HTML parser ignores the slash and
568
+ // would treat them as unclosed, swallowing the following siblings. Void
569
+ // elements keep their native behavior, and <script>/<style> contents are
570
+ // passed through untouched so code inside them is never rewritten
571
+ const expandSelfClosingTags = (src: string): string =>
572
+ src
573
+ .split(RAW_BLOCK_RE)
574
+ .map((chunk, i) =>
575
+ i % 2 === 1 // odd chunks are the captured script/style blocks
576
+ ? chunk
577
+ : chunk.replace(SELF_CLOSING_RE, (match, tag: string, attrs: string) =>
578
+ VOID_ELEMENTS.has(tag.toLowerCase()) ? match : `<${tag}${attrs}></${tag}>`
579
+ )
580
+ )
581
+ .join("")
582
+
583
+ // converts a string of HTML into an AST representation of the component:
584
+ // - template: the non-script/style top-level elements, as TemplateNodes
585
+ // - scripts/styles: { attrs, content } blocks in source order
586
+ const parseComponentString = (component: string): ComponentParts => {
587
+ // example
588
+ // <script :setup="{ fname, lname }">
589
+ // const fullName = `${fname} ${lname}`
590
+ // </script>
591
+ //
592
+ // <div :bind="{ fullName }"></div>
593
+ // <div class="full-name">
594
+ // {{ fullName }}
595
+ // </div>
596
+ //
597
+ // <style>
598
+ // .full-name {
599
+ // color: red;
600
+ // }
601
+ // </style>
602
+
603
+ // parsed as the content of a <template> so leading <script>/<style> tags
604
+ // aren't reparented into <head> by the HTML parser
605
+ const parsedDOM = new DOMParser().parseFromString(`<template>${expandSelfClosingTags(component)}</template>`, "text/html")
606
+ const root = parsedDOM.querySelector("template") as HTMLTemplateElement
607
+
608
+ const scripts: TagBlock[] = []
609
+ const styles: TagBlock[] = []
610
+ const template: TemplateNode[] = []
611
+
612
+ Array.from(root.content.children).forEach(el => {
613
+ const block = { attrs: elementAttrs(el), content: el.textContent ?? "" }
614
+
615
+ if (el.tagName === "SCRIPT") scripts.push(block)
616
+ else if (el.tagName === "STYLE") styles.push(block)
617
+ else template.push(elementToAST(el))
618
+ })
619
+
620
+ return { template, scripts, styles }
621
+ }
622
+
623
+ // ---------------------------------------------------------------------------
624
+ // :setup script transform
625
+ //
626
+ // setup scripts are written like Svelte components:
627
+ //
628
+ // let firstName = null
629
+ // $: fullName = `${firstName} ${lastName}`
630
+ // fetchUser().then(user => { firstName = user.firstName })
631
+ //
632
+ // and are executed inside `with ($scope)` against the component's reactive
633
+ // store, so plain assignments (even from async callbacks) go through the
634
+ // proxy's set trap and re-render whatever depends on them. To make that work
635
+ // the source is lightly rewritten - no full JS parser, just a scanner that is
636
+ // string/comment-aware and only touches code at brace/paren depth 0:
637
+ // - `let/var/const x = ...` at the top level loses its keyword, becoming a
638
+ // scope assignment (the name is pre-declared on the store so the `with`
639
+ // lookup resolves it)
640
+ // - `$: x = expr` becomes `$__effect(() => { x = expr })`, re-running when a
641
+ // dependency read inside expr changes ($__effect is deliberately NOT a
642
+ // property of the scope, so `with` falls through to the function parameter)
643
+ // ---------------------------------------------------------------------------
644
+
645
+ type SetupTransform = { vars: string[]; code: string }
646
+
647
+ const DECLARATION_RE = /(?:let|var|const)\s+([A-Za-z_$][\w$]*)/y
648
+ const REACTIVE_LABEL_RE = /\$:\s*/y
649
+ const IMPORT_CALL_RE = /import(?=\s*\()/y
650
+ const REACTIVE_ASSIGN_RE = /\$:\s*([A-Za-z_$][\w$]*)\s*=(?!=)/y
651
+
652
+ const skipString = (src: string, start: number): number => {
653
+ const quote = src[start]
654
+ let i = start + 1
655
+ while (i < src.length) {
656
+ if (src[i] === "\\") { i += 2; continue }
657
+ if (src[i] === quote) return i + 1
658
+ i++
659
+ }
660
+ return src.length
661
+ }
662
+
663
+ const skipLineComment = (src: string, start: number): number => {
664
+ const end = src.indexOf("\n", start)
665
+ return end === -1 ? src.length : end
666
+ }
667
+
668
+ const skipBlockComment = (src: string, start: number): number => {
669
+ const end = src.indexOf("*/", start + 2)
670
+ return end === -1 ? src.length : end + 2
671
+ }
672
+
673
+ // end of a statement starting at `start`: the first newline or `;` that isn't
674
+ // inside a string/comment or unbalanced brackets, so multi-line RHS like a
675
+ // wrapped function call or template literal stays in one piece
676
+ const findStatementEnd = (src: string, start: number): number => {
677
+ let depth = 0
678
+ let i = start
679
+ while (i < src.length) {
680
+ const ch = src[i]
681
+ if (ch === "'" || ch === '"' || ch === "`") { i = skipString(src, i); continue }
682
+ if (ch === "/" && src[i + 1] === "/") { i = skipLineComment(src, i); continue }
683
+ if (ch === "/" && src[i + 1] === "*") { i = skipBlockComment(src, i); continue }
684
+ if ("([{".includes(ch)) depth++
685
+ else if (")]}".includes(ch)) depth--
686
+ else if (depth <= 0 && (ch === "\n" || ch === ";")) return i
687
+ i++
688
+ }
689
+ return src.length
690
+ }
691
+
692
+ const transformSetupScript = (src: string): SetupTransform => {
693
+ const vars: string[] = []
694
+ let out = ""
695
+ let i = 0
696
+ let depth = 0
697
+ let atStatementStart = true
698
+
699
+ while (i < src.length) {
700
+ const ch = src[i]
701
+ const next = src[i + 1]
702
+
703
+ if (ch === "'" || ch === '"' || ch === "`") {
704
+ const end = skipString(src, i)
705
+ out += src.slice(i, end)
706
+ i = end
707
+ atStatementStart = false
708
+ continue
709
+ }
710
+ if (ch === "/" && (next === "/" || next === "*")) {
711
+ const end = next === "/" ? skipLineComment(src, i) : skipBlockComment(src, i)
712
+ out += src.slice(i, end)
713
+ i = end
714
+ continue
715
+ }
716
+
717
+ // `import(...)` is a keyword form, so it can't be intercepted through the
718
+ // scope - rewrite the identifier to the injected $__import (which loads
719
+ // .html URLs as components via Component79.fetch and delegates the rest to
720
+ // native import). The `(` is left for the scanner so depth stays balanced
721
+ if (ch === "i" && (i === 0 || !/[\w$.]/.test(src[i - 1]))) {
722
+ IMPORT_CALL_RE.lastIndex = i
723
+ if (IMPORT_CALL_RE.test(src)) {
724
+ out += "$__import"
725
+ i += "import".length
726
+ atStatementStart = false
727
+ continue
728
+ }
729
+ }
730
+
731
+ if (depth === 0 && atStatementStart) {
732
+ DECLARATION_RE.lastIndex = i
733
+ const decl = DECLARATION_RE.exec(src)
734
+ if (decl) {
735
+ vars.push(decl[1])
736
+ out += decl[1]
737
+ i += decl[0].length
738
+ atStatementStart = false
739
+ continue
740
+ }
741
+
742
+ REACTIVE_LABEL_RE.lastIndex = i
743
+ const label = REACTIVE_LABEL_RE.exec(src)
744
+ if (label) {
745
+ REACTIVE_ASSIGN_RE.lastIndex = i
746
+ const assign = REACTIVE_ASSIGN_RE.exec(src)
747
+ if (assign) vars.push(assign[1])
748
+ const start = i + label[0].length
749
+ const end = findStatementEnd(src, start)
750
+ out += `$__effect(() => { ${src.slice(start, end)} });`
751
+ i = end
752
+ continue
753
+ }
754
+ }
755
+
756
+ if ("([{".includes(ch)) depth++
757
+ else if (")]}".includes(ch)) depth = Math.max(0, depth - 1)
758
+
759
+ if (ch === "\n" || ch === ";" || ch === "}") atStatementStart = true
760
+ else if (!/\s/.test(ch)) atStatementStart = false
761
+
762
+ out += ch
763
+ i++
764
+ }
765
+
766
+ return { vars, code: out }
767
+ }
768
+
769
+ // loads .html URLs as components, delegating anything else to native import()
770
+ const importResource = (url: string): Promise<any> =>
771
+ /\.html?([?#]|$)/.test(url) ? Component79.fetch(url) : import(url)
772
+
773
+ // document.head styles are shared by content and refcounted, so N instances
774
+ // of the same component (e.g. one per :each item) inject a single <style> tag
775
+ // that goes away when the last instance is destroyed
776
+ const styleRegistry = new Map<string, { el: HTMLStyleElement; count: number }>()
777
+
778
+ const acquireStyle = (content: string) => {
779
+ let entry = styleRegistry.get(content)
780
+ if (!entry) {
781
+ const el = document.createElement("style")
782
+ el.textContent = content
783
+ document.head.appendChild(el)
784
+ entry = { el, count: 0 }
785
+ styleRegistry.set(content, entry)
786
+ }
787
+ entry.count++
788
+ }
789
+
790
+ const releaseStyle = (content: string) => {
791
+ const entry = styleRegistry.get(content)
792
+ if (entry && --entry.count <= 0) {
793
+ entry.el.remove()
794
+ styleRegistry.delete(content)
795
+ }
796
+ }
797
+
798
+ // scripts run inside `with (scriptScope)`, where scriptScope's `has` trap
799
+ // claims ownership of every name that is neither a real global nor one of the
800
+ // injected helpers. This makes `with` route ALL other reads/writes through the
801
+ // reactive store - even bare assignments to names never declared with
802
+ // let/const, which would otherwise leak onto globalThis - while `console`,
803
+ // `Promise`, `fetch`, etc. still resolve normally. get/set are deliberately
804
+ // not trapped: they default-forward to `scope` (the reactive proxy),
805
+ // preserving tracking and notify.
806
+ // The body is wrapped in an async IIFE so top-level `await` works: everything
807
+ // up to the first await runs synchronously (before the template renders), and
808
+ // later assignments update the DOM reactively when they happen
809
+ const runSetupScript = (code: string, scope: Record<string, any>, effect: (run: () => void) => void) => {
810
+ const scriptScope = new Proxy(scope, {
811
+ has: (target, key) =>
812
+ key !== "$__effect" && key !== "$__import" && (Reflect.has(target, key) || !(key in globalThis)),
813
+ })
814
+ const result: Promise<void> = new Function(
815
+ "$scope", "$__effect", "$__import",
816
+ `return (async () => { with ($scope) { ${code} } })()`
817
+ )(scriptScope, effect, importResource)
818
+ result.catch(error => console.error("jq79: error in :setup script", error))
819
+ }
820
+
821
+ // a parsed single-file component. Typical lifecycle:
822
+ //
823
+ // const jq79 = new Component79(src) // or await Component79.fetch(url)
824
+ // jq79.render({ user }) // build reactive DOM, run scripts, inject styles
825
+ // .mount("#app") // attach (renderShadow mounts into a shadow root)
826
+ // ...
827
+ // jq79.unmount() // detach, keeping state - mount() re-attaches
828
+ // .destroy() // dispose effects and remove styles
829
+ export class Component79 {
830
+ template: TemplateNode[]
831
+ scripts: TagBlock[]
832
+ styles: TagBlock[]
833
+
834
+ data: ReactiveDeepData<Record<string, any>> | null = null
835
+
836
+ private fx: EffectScope | null = null
837
+ // holds the rendered nodes while unmounted; anchors keep this fragment as
838
+ // their parentNode, so effects keep the (detached) DOM up to date and a
839
+ // later mount() shows current state
840
+ private content: DocumentFragment | null = null
841
+ // markers bracketing the component's output so unmount() can collect nodes
842
+ // that :if/:each inserted next to the anchors after mounting
843
+ private startMarker: Comment | null = null
844
+ private endMarker: Comment | null = null
845
+ // shadow rendering keeps per-instance <style> elements; head rendering goes
846
+ // through the shared refcounted styleRegistry instead
847
+ private styleEls: HTMLStyleElement[] = []
848
+ private ownsSharedStyles = false
849
+ private useShadow = false
850
+ private mountRoot: Element | ShadowRoot | DocumentFragment | null = null
851
+
852
+ constructor(src: string | ComponentParts) {
853
+ const { template, scripts, styles } = typeof src === "string" ? parseComponentString(src) : src
854
+ this.template = template
855
+ this.scripts = scripts
856
+ this.styles = styles
857
+ }
858
+
859
+ static async fetch(url: string): Promise<Component79> {
860
+ const response = await fetch(url)
861
+ if (!response.ok) throw new Error(`failed to fetch component from ${url}: ${response.status}`)
862
+ return new Component79(await response.text())
863
+ }
864
+
865
+ render(data: Record<string, any> = {}): this {
866
+ return this.renderWith(data, false)
867
+ }
868
+
869
+ // like render(), but styles are injected into a shadow root attached to the
870
+ // mount target instead of document.head, so they don't leak globally
871
+ renderShadow(data: Record<string, any> = {}): this {
872
+ return this.renderWith(data, true)
873
+ }
874
+
875
+ private renderWith(data: Record<string, any>, shadow: boolean): this {
876
+ this.destroy()
877
+
878
+ const store = createReactiveDeepData({ ...data })
879
+ const fx = createEffectScope(store)
880
+ this.data = store
881
+ this.fx = fx
882
+ this.useShadow = shadow
883
+
884
+ // scripts run before the template renders so `$:` values are initialized
885
+ this.scripts.forEach(script => {
886
+ const { vars, code } = transformSetupScript(script.content)
887
+ // pre-declare script vars on the store so `with` resolves assignments
888
+ // to them (and reads of them) through the reactive proxy
889
+ vars.forEach(name => { if (!(name in store)) (store as any)[name] = undefined })
890
+ runSetupScript(code, store, fx.effect)
891
+ })
892
+
893
+ this.startMarker = document.createComment("jq79")
894
+ this.endMarker = document.createComment("/jq79")
895
+ const content = document.createDocumentFragment()
896
+ content.append(this.startMarker, renderNodes(this.template, store, fx), this.endMarker)
897
+ this.content = content
898
+
899
+ if (shadow) {
900
+ this.styleEls = this.styles.map(style => {
901
+ const el = document.createElement("style")
902
+ el.textContent = style.content
903
+ return el
904
+ })
905
+ } else {
906
+ this.styles.forEach(style => acquireStyle(style.content))
907
+ this.ownsSharedStyles = true
908
+ }
909
+
910
+ return this
911
+ }
912
+
913
+ mount(parent: Element | ShadowRoot | DocumentFragment | string): this {
914
+ const target = typeof parent === "string" ? $(parent) : parent
915
+ if (!target) throw new Error(`mount target not found: ${parent}`)
916
+ if (!this.content) throw new Error("render() must be called before mount()")
917
+ if (this.mountRoot) this.unmount()
918
+
919
+ const root = this.useShadow && target instanceof Element
920
+ ? target.shadowRoot ?? target.attachShadow({ mode: "open" })
921
+ : target
922
+ if (this.useShadow) this.styleEls.forEach(el => root.appendChild(el))
923
+ root.appendChild(this.content)
924
+ this.mountRoot = root
925
+ return this
926
+ }
927
+
928
+ unmount(): this {
929
+ if (!this.mountRoot || !this.content || !this.startMarker || !this.endMarker) return this
930
+
931
+ // move everything between the markers (inclusive) back into the holding
932
+ // fragment - including nodes :if/:each inserted after mounting
933
+ let node: Node | null = this.startMarker
934
+ while (node) {
935
+ const nextNode: Node | null = node.nextSibling
936
+ this.content.appendChild(node)
937
+ if (node === this.endMarker) break
938
+ node = nextNode
939
+ }
940
+
941
+ this.mountRoot = null
942
+ return this
943
+ }
944
+
945
+ destroy(): this {
946
+ this.unmount()
947
+ this.fx?.dispose()
948
+ this.fx = null
949
+ this.styleEls.forEach(el => el.parentNode?.removeChild(el))
950
+ this.styleEls = []
951
+ if (this.ownsSharedStyles) {
952
+ this.styles.forEach(style => releaseStyle(style.content))
953
+ this.ownsSharedStyles = false
954
+ }
955
+ this.content = null
956
+ this.startMarker = null
957
+ this.endMarker = null
958
+ this.data = null
959
+ return this
960
+ }
961
+ }
962
+
963
+ export const parseComponent = (component: string): Component79 => new Component79(component)
964
+