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/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() // unmounts its marker range, removing the child's DOM
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, scope: Record<string, any>, fx: EffectScope): Node => {
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 SETUP_HELPERS)),
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(SETUP_HELPERS),
885
+ "$scope", "$__effect", "$__import", ...Object.keys(helpers),
842
886
  `return (async () => { with ($scope) { ${code} } })()`
843
- )(scriptScope, effect, importResource, ...Object.values(SETUP_HELPERS))
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.render({ user }) // build reactive DOM, run scripts, inject styles
851
- // .mount("#app") // attach (renderShadow mounts into a shadow root)
852
- // ...
853
- // jq79.unmount() // detach, keeping state - mount() re-attaches
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 unmounted; anchors keep this fragment as
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 unmount() can collect nodes
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
- // scripts run before the template renders so `$:` values are initialized
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
- runSetupScript(code, store, fx.effect)
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
- mount(parent: Element | ShadowRoot | DocumentFragment | string): this {
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) throw new Error("render() must be called before mount()")
943
- if (this.mountRoot) this.unmount()
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
- unmount(): this {
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.unmount()
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
  }