jq79 0.1.5 → 0.2.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 +84 -8
- package/dist/jq79.cjs +5 -4
- package/dist/jq79.cjs.map +1 -1
- package/dist/jq79.d.ts +9 -2
- package/dist/jq79.global.js +5 -4
- package/dist/jq79.global.js.map +1 -1
- package/dist/jq79.js +5 -4
- package/dist/jq79.js.map +1 -1
- package/package.json +1 -1
- package/src/jq79.ts +160 -23
package/src/jq79.ts
CHANGED
|
@@ -238,7 +238,7 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
|
|
|
238
238
|
return reactive
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
-
const CONTROL_ATTRS = new Set([":bind", ":if", ":elseif", ":else", ":each", ":key"])
|
|
241
|
+
const CONTROL_ATTRS = new Set([":bind", ":if", ":elseif", ":else", ":each", ":key", ":with"])
|
|
242
242
|
const EACH_PATTERN = /^\s*(\w+)\s+in\s+(.+)$/
|
|
243
243
|
|
|
244
244
|
type ConditionalBranch = { expr?: string; node: TemplateNode }
|
|
@@ -337,7 +337,7 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
|
|
|
337
337
|
|
|
338
338
|
childFx?.dispose()
|
|
339
339
|
childFx = null
|
|
340
|
-
current?.destroy() //
|
|
340
|
+
current?.destroy() // detaches its marker range, removing the child's DOM
|
|
341
341
|
current = null
|
|
342
342
|
currentDef = nextDef
|
|
343
343
|
if (!nextDef) return
|
|
@@ -369,12 +369,53 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
|
|
|
369
369
|
return wrapper
|
|
370
370
|
}
|
|
371
371
|
|
|
372
|
+
// :with="expr" narrows the scope for an element and its subtree: names
|
|
373
|
+
// resolve against the expression's value first, then fall back to the outer
|
|
374
|
+
// scope. The value is re-evaluated lazily on every name lookup (never
|
|
375
|
+
// snapshotted), so an effect reading through this proxy tracks both the
|
|
376
|
+
// expression's own dependencies and the property it reads - replacing the
|
|
377
|
+
// object or mutating one of its properties re-renders exactly the dependents,
|
|
378
|
+
// without rebuilding the subtree. Assignments to names the object owns write
|
|
379
|
+
// through to it (reactively, if it came from a store); everything else
|
|
380
|
+
// behaves as if the :with weren't there
|
|
381
|
+
const createWithScope = (expr: string, scope: Record<string, any>): Record<string, any> => {
|
|
382
|
+
const source = (): Record<string, any> | null => {
|
|
383
|
+
const value = evalExpr(expr, scope)
|
|
384
|
+
return value !== null && typeof value === "object" ? value : null
|
|
385
|
+
}
|
|
386
|
+
return new Proxy(scope, {
|
|
387
|
+
has(target, key) {
|
|
388
|
+
const obj = source()
|
|
389
|
+
return (obj !== null && Reflect.has(obj, key)) || Reflect.has(target, key)
|
|
390
|
+
},
|
|
391
|
+
get(target, key) {
|
|
392
|
+
const obj = source()
|
|
393
|
+
if (obj !== null && Reflect.has(obj, key)) return obj[key as string]
|
|
394
|
+
return Reflect.get(target, key)
|
|
395
|
+
},
|
|
396
|
+
set(target, key, value) {
|
|
397
|
+
const obj = source()
|
|
398
|
+
if (obj !== null && Reflect.has(obj, key)) {
|
|
399
|
+
obj[key as string] = value
|
|
400
|
+
return true
|
|
401
|
+
}
|
|
402
|
+
return Reflect.set(target, key, value)
|
|
403
|
+
},
|
|
404
|
+
})
|
|
405
|
+
}
|
|
406
|
+
|
|
372
407
|
// renders a single element node: static attrs, @event listeners, a reactive
|
|
373
408
|
// :bind object, and its (reactive) children. :if/:elseif/:else/:each are
|
|
374
409
|
// handled by renderNodes, which decides *whether*/*how many times* a node is
|
|
375
410
|
// rendered before calling this. Tags matching a PascalCase scope variable
|
|
376
411
|
// render as nested components instead
|
|
377
|
-
const renderNode = (node: TemplateNode,
|
|
412
|
+
const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: EffectScope): Node => {
|
|
413
|
+
// :with applies to the element's own bindings (@events, :bind) and its
|
|
414
|
+
// whole subtree. On a :each element the item scope is already in place, so
|
|
415
|
+
// :with="item" works
|
|
416
|
+
const withExpr = node.attrs[":with"]
|
|
417
|
+
const scope = withExpr !== undefined ? createWithScope(withExpr, outerScope) : outerScope
|
|
418
|
+
|
|
378
419
|
const componentKey = findComponentKey(scope, node.tag)
|
|
379
420
|
if (componentKey) return renderNestedComponent(componentKey, node, scope, fx)
|
|
380
421
|
|
|
@@ -831,26 +872,31 @@ const SETUP_HELPERS: Record<string, any> = { $, $$, $create, $reactive }
|
|
|
831
872
|
// The body is wrapped in an async IIFE so top-level `await` works: everything
|
|
832
873
|
// up to the first await runs synchronously (before the template renders), and
|
|
833
874
|
// later assignments update the DOM reactively when they happen
|
|
834
|
-
const runSetupScript = (code: string, scope: Record<string, any>, effect: (run: () => void) => void) => {
|
|
875
|
+
const runSetupScript = (code: string, scope: Record<string, any>, effect: (run: () => void) => void, instanceHelpers: Record<string, any> = {}) => {
|
|
876
|
+
// instanceHelpers are per-component-instance additions (e.g. $emit, which
|
|
877
|
+
// is bound to this instance's DOM position)
|
|
878
|
+
const helpers = { ...SETUP_HELPERS, ...instanceHelpers }
|
|
835
879
|
const scriptScope = new Proxy(scope, {
|
|
836
880
|
has: (target, key) =>
|
|
837
881
|
key !== "$__effect" && key !== "$__import" &&
|
|
838
|
-
(Reflect.has(target, key) || !(key in globalThis) && !(key in
|
|
882
|
+
(Reflect.has(target, key) || !(key in globalThis) && !(key in helpers)),
|
|
839
883
|
})
|
|
840
884
|
const result: Promise<void> = new Function(
|
|
841
|
-
"$scope", "$__effect", "$__import", ...Object.keys(
|
|
885
|
+
"$scope", "$__effect", "$__import", ...Object.keys(helpers),
|
|
842
886
|
`return (async () => { with ($scope) { ${code} } })()`
|
|
843
|
-
)(scriptScope, effect, importResource, ...Object.values(
|
|
887
|
+
)(scriptScope, effect, importResource, ...Object.values(helpers))
|
|
844
888
|
result.catch(error => console.error("jq79: error in :setup script", error))
|
|
845
889
|
}
|
|
846
890
|
|
|
891
|
+
type EmitListener = (event: CustomEvent, payload: any) => void
|
|
892
|
+
|
|
847
893
|
// a parsed single-file component. Typical lifecycle:
|
|
848
894
|
//
|
|
849
895
|
// const jq79 = new Component79(src) // or await Component79.fetch(url)
|
|
850
|
-
// jq79.
|
|
851
|
-
//
|
|
852
|
-
// ...
|
|
853
|
-
// jq79.
|
|
896
|
+
// jq79.on("submit", (e, payload) => {}) // hear this instance's $emit events
|
|
897
|
+
// jq79.mount("#app", { user }) // render (reactive DOM, scripts, styles) + attach
|
|
898
|
+
// ... // (mountShadow mounts into a shadow root)
|
|
899
|
+
// jq79.detach() // detach, keeping state - mount() re-attaches
|
|
854
900
|
// .destroy() // dispose effects and remove styles
|
|
855
901
|
export class Component79 {
|
|
856
902
|
template: TemplateNode[]
|
|
@@ -860,11 +906,11 @@ export class Component79 {
|
|
|
860
906
|
data: ReactiveDeepData<Record<string, any>> | null = null
|
|
861
907
|
|
|
862
908
|
private fx: EffectScope | null = null
|
|
863
|
-
// holds the rendered nodes while
|
|
909
|
+
// holds the rendered nodes while detached; anchors keep this fragment as
|
|
864
910
|
// their parentNode, so effects keep the (detached) DOM up to date and a
|
|
865
911
|
// later mount() shows current state
|
|
866
912
|
private content: DocumentFragment | null = null
|
|
867
|
-
// markers bracketing the component's output so
|
|
913
|
+
// markers bracketing the component's output so detach() can collect nodes
|
|
868
914
|
// that :if/:each inserted next to the anchors after mounting
|
|
869
915
|
private startMarker: Comment | null = null
|
|
870
916
|
private endMarker: Comment | null = null
|
|
@@ -874,6 +920,11 @@ export class Component79 {
|
|
|
874
920
|
private ownsSharedStyles = false
|
|
875
921
|
private useShadow = false
|
|
876
922
|
private mountRoot: Element | ShadowRoot | DocumentFragment | null = null
|
|
923
|
+
// settles the $mounted() promise handed to this render generation's scripts
|
|
924
|
+
private resolveMounted: (() => void) | null = null
|
|
925
|
+
// instance-level listeners for $emit events, registered with on(). Kept
|
|
926
|
+
// outside the render generation so they survive re-render and destroy()
|
|
927
|
+
private emitListeners = new Map<string, Set<EmitListener>>()
|
|
877
928
|
|
|
878
929
|
constructor(src: string | ComponentParts) {
|
|
879
930
|
const { template, scripts, styles } = typeof src === "string" ? parseComponentString(src) : src
|
|
@@ -888,6 +939,21 @@ export class Component79 {
|
|
|
888
939
|
return new Component79(await response.text())
|
|
889
940
|
}
|
|
890
941
|
|
|
942
|
+
// subscribes to this instance's $emit events, on top of the DOM CustomEvent
|
|
943
|
+
// dispatch - so it hears emits even while the component is detached (where
|
|
944
|
+
// the event has no ancestors to bubble to). Chainable; can be called before
|
|
945
|
+
// render()
|
|
946
|
+
on(eventName: string, listener: EmitListener): this {
|
|
947
|
+
if (!this.emitListeners.has(eventName)) this.emitListeners.set(eventName, new Set())
|
|
948
|
+
this.emitListeners.get(eventName)!.add(listener)
|
|
949
|
+
return this
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
off(eventName: string, listener: EmitListener): this {
|
|
953
|
+
this.emitListeners.get(eventName)?.delete(listener)
|
|
954
|
+
return this
|
|
955
|
+
}
|
|
956
|
+
|
|
891
957
|
render(data: Record<string, any> = {}): this {
|
|
892
958
|
return this.renderWith(data, false)
|
|
893
959
|
}
|
|
@@ -907,17 +973,65 @@ export class Component79 {
|
|
|
907
973
|
this.fx = fx
|
|
908
974
|
this.useShadow = shadow
|
|
909
975
|
|
|
910
|
-
|
|
976
|
+
this.startMarker = document.createComment("jq79")
|
|
977
|
+
this.endMarker = document.createComment("/jq79")
|
|
978
|
+
|
|
979
|
+
// $emit dispatches a bubbling CustomEvent from this instance's start
|
|
980
|
+
// marker, so once mounted it travels up the real DOM and parents can
|
|
981
|
+
// listen on any ancestor (or with @event-name on a wrapping element).
|
|
982
|
+
// Captures the marker rather than `this` so a later re-render's scripts
|
|
983
|
+
// can't dispatch from the wrong generation - the same guard keeps stale
|
|
984
|
+
// generations from reaching the instance's on() listeners
|
|
985
|
+
const marker = this.startMarker
|
|
986
|
+
const $emit = (eventName: string, payload?: any): boolean => {
|
|
987
|
+
const event = new CustomEvent(eventName, { detail: payload, bubbles: true, composed: true })
|
|
988
|
+
const result = marker.dispatchEvent(event)
|
|
989
|
+
if (marker === this.startMarker) {
|
|
990
|
+
this.emitListeners.get(eventName)?.forEach(listener => listener(event, payload))
|
|
991
|
+
}
|
|
992
|
+
return result
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// `await $mounted()` suspends a setup script until mount() attaches the
|
|
996
|
+
// component, so code below it can querySelector its own DOM. Resumption
|
|
997
|
+
// is a microtask, so in the usual synchronous render().mount() flow the
|
|
998
|
+
// whole tree (nested components included) is in the document before the
|
|
999
|
+
// script continues. If this instance is never mounted, the promise stays
|
|
1000
|
+
// pending and the script's tail never runs
|
|
1001
|
+
let resolveMounted!: () => void
|
|
1002
|
+
const mounted = new Promise<void>(resolve => { resolveMounted = resolve })
|
|
1003
|
+
this.resolveMounted = resolveMounted
|
|
1004
|
+
const $mounted = () => mounted
|
|
1005
|
+
|
|
1006
|
+
// $self / $$self mirror $ / $$ but only search this instance's own
|
|
1007
|
+
// output: the sibling nodes between its markers. They work detached too
|
|
1008
|
+
// (the holding fragment keeps markers and rendered nodes as siblings),
|
|
1009
|
+
// though the template renders after the scripts run, so they only find
|
|
1010
|
+
// something from post-await code or callbacks
|
|
1011
|
+
const endMarker = this.endMarker
|
|
1012
|
+
const $$self = (selector: string): Element[] => {
|
|
1013
|
+
const found: Element[] = []
|
|
1014
|
+
for (let node: Node | null = marker.nextSibling; node && node !== endMarker; node = node.nextSibling) {
|
|
1015
|
+
if (node instanceof Element) {
|
|
1016
|
+
if (node.matches(selector)) found.push(node)
|
|
1017
|
+
found.push(...Array.from(node.querySelectorAll(selector)))
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
return found
|
|
1021
|
+
}
|
|
1022
|
+
const $self = (selector: string): Element | null => $$self(selector)[0] ?? null
|
|
1023
|
+
|
|
1024
|
+
// scripts run before the template renders so `$:` values are initialized;
|
|
1025
|
+
// a `:mounted` script defers entirely until mount() instead
|
|
911
1026
|
this.scripts.forEach(script => {
|
|
912
1027
|
const { vars, code } = transformSetupScript(script.content)
|
|
913
1028
|
// pre-declare script vars on the store so `with` resolves assignments
|
|
914
1029
|
// to them (and reads of them) through the reactive proxy
|
|
915
1030
|
vars.forEach(name => { if (!(name in store)) (store as any)[name] = undefined })
|
|
916
|
-
|
|
1031
|
+
const body = ":mounted" in script.attrs ? `await $mounted();\n${code}` : code
|
|
1032
|
+
runSetupScript(body, store, fx.effect, { $emit, $mounted, $self, $$self })
|
|
917
1033
|
})
|
|
918
1034
|
|
|
919
|
-
this.startMarker = document.createComment("jq79")
|
|
920
|
-
this.endMarker = document.createComment("/jq79")
|
|
921
1035
|
const content = document.createDocumentFragment()
|
|
922
1036
|
content.append(this.startMarker, renderNodes(this.template, store, fx), this.endMarker)
|
|
923
1037
|
this.content = content
|
|
@@ -936,22 +1050,44 @@ export class Component79 {
|
|
|
936
1050
|
return this
|
|
937
1051
|
}
|
|
938
1052
|
|
|
939
|
-
|
|
1053
|
+
// renders (when needed) and attaches in one call: the component is rendered
|
|
1054
|
+
// on the first mount, and re-rendered fresh whenever `data` is passed.
|
|
1055
|
+
// mount(el) on an already-rendered component just re-attaches, keeping its
|
|
1056
|
+
// state - the detach()/mount() round trip. Rendering here keeps whichever
|
|
1057
|
+
// style mode was last used (document.head unless renderShadow/mountShadow
|
|
1058
|
+
// chose a shadow root)
|
|
1059
|
+
mount(parent: Element | ShadowRoot | DocumentFragment | string, data?: Record<string, any>): this {
|
|
1060
|
+
const target = typeof parent === "string" ? $(parent) : parent
|
|
1061
|
+
if (!target) throw new Error(`mount target not found: ${parent}`)
|
|
1062
|
+
if (!this.content || data !== undefined) this.renderWith(data ?? {}, this.useShadow)
|
|
1063
|
+
return this.attach(target)
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// like mount(), but renders with styles scoped to a shadow root on the
|
|
1067
|
+
// target instead of document.head
|
|
1068
|
+
mountShadow(parent: Element | ShadowRoot | DocumentFragment | string, data?: Record<string, any>): this {
|
|
940
1069
|
const target = typeof parent === "string" ? $(parent) : parent
|
|
941
1070
|
if (!target) throw new Error(`mount target not found: ${parent}`)
|
|
942
|
-
if (!this.content
|
|
943
|
-
|
|
1071
|
+
if (!this.content || data !== undefined || !this.useShadow) this.renderWith(data ?? {}, true)
|
|
1072
|
+
return this.attach(target)
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
private attach(target: Element | ShadowRoot | DocumentFragment): this {
|
|
1076
|
+
if (this.mountRoot) this.detach()
|
|
944
1077
|
|
|
945
1078
|
const root = this.useShadow && target instanceof Element
|
|
946
1079
|
? target.shadowRoot ?? target.attachShadow({ mode: "open" })
|
|
947
1080
|
: target
|
|
948
1081
|
if (this.useShadow) this.styleEls.forEach(el => root.appendChild(el))
|
|
949
|
-
root.appendChild(this.content)
|
|
1082
|
+
root.appendChild(this.content!)
|
|
950
1083
|
this.mountRoot = root
|
|
1084
|
+
this.resolveMounted?.()
|
|
951
1085
|
return this
|
|
952
1086
|
}
|
|
953
1087
|
|
|
954
|
-
|
|
1088
|
+
// detaches from the DOM while keeping all state; a later mount() re-attaches
|
|
1089
|
+
// with any updates that happened while detached already applied
|
|
1090
|
+
detach(): this {
|
|
955
1091
|
if (!this.mountRoot || !this.content || !this.startMarker || !this.endMarker) return this
|
|
956
1092
|
|
|
957
1093
|
// move everything between the markers (inclusive) back into the holding
|
|
@@ -969,7 +1105,7 @@ export class Component79 {
|
|
|
969
1105
|
}
|
|
970
1106
|
|
|
971
1107
|
destroy(): this {
|
|
972
|
-
this.
|
|
1108
|
+
this.detach()
|
|
973
1109
|
this.fx?.dispose()
|
|
974
1110
|
this.fx = null
|
|
975
1111
|
this.styleEls.forEach(el => el.parentNode?.removeChild(el))
|
|
@@ -982,6 +1118,7 @@ export class Component79 {
|
|
|
982
1118
|
this.startMarker = null
|
|
983
1119
|
this.endMarker = null
|
|
984
1120
|
this.data = null
|
|
1121
|
+
this.resolveMounted = null
|
|
985
1122
|
return this
|
|
986
1123
|
}
|
|
987
1124
|
}
|