assign-gingerly 0.0.52 → 0.0.54

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.
@@ -0,0 +1,188 @@
1
+ /**
2
+ * processHandlerCommands - Handles ` =>` operator keys in assignFrom.
3
+ *
4
+ * Dynamically imported only when ` =>` keys are detected in the pattern.
5
+ */
6
+ import { resolveValues } from './resolveValues.js';
7
+ import { evaluatePathWithMethods } from './assignGingerly.js';
8
+ /**
9
+ * Map of built-in handler names to their module paths.
10
+ * These are auto-loaded on demand — no explicit import required.
11
+ */
12
+ const BUILT_IN_MAP = {
13
+ 'builtIns.lazyLoad': './handlers/lazyLoad.js',
14
+ 'builtIns.lazyLoadSwitch': './handlers/lazyLoadSwitch.js',
15
+ 'builtIns.join': './handlers/join.js',
16
+ 'builtIns.microDataJoin': './handlers/microDataJoin.js',
17
+ };
18
+ /**
19
+ * Check if an import path is allowed (non-cross-domain).
20
+ */
21
+ function isAllowedImportPath(path) {
22
+ return path.startsWith('./') || path.startsWith('../') || path.startsWith('/')
23
+ || (!path.includes('://') && !path.startsWith('//'));
24
+ }
25
+ /**
26
+ * Find a handler class in a dynamically imported module.
27
+ * Checks default export first, then searches for the first class with `assign` on prototype.
28
+ */
29
+ function findHandlerInModule(module) {
30
+ if (module.default && typeof module.default === 'function'
31
+ && module.default.prototype && 'assign' in module.default.prototype) {
32
+ return module.default;
33
+ }
34
+ for (const key of Object.keys(module)) {
35
+ const exported = module[key];
36
+ if (typeof exported === 'function' && exported.prototype && 'assign' in exported.prototype) {
37
+ return exported;
38
+ }
39
+ }
40
+ return undefined;
41
+ }
42
+ /**
43
+ * Dynamically load a built-in handler by name.
44
+ */
45
+ async function loadBuiltIn(name) {
46
+ const path = BUILT_IN_MAP[name];
47
+ if (!path)
48
+ return undefined;
49
+ const module = await import(path);
50
+ return findHandlerInModule(module);
51
+ }
52
+ /**
53
+ * Resolve a handler from options.handlers (class constructor or import path).
54
+ */
55
+ async function resolveFromHandlers(name, handlers) {
56
+ if (!handlers || !(name in handlers))
57
+ return undefined;
58
+ const entry = handlers[name];
59
+ if (typeof entry === 'function') {
60
+ return entry;
61
+ }
62
+ if (typeof entry === 'string') {
63
+ if (!isAllowedImportPath(entry)) {
64
+ throw new Error(
65
+ `assignFrom: handler "${name}" has an invalid import path "${entry}". ` +
66
+ `Only relative, absolute, or bare specifier paths are allowed (no cross-domain URLs).`
67
+ );
68
+ }
69
+ const module = await import(entry);
70
+ const HandlerClass = findHandlerInModule(module);
71
+ if (!HandlerClass) {
72
+ throw new Error(
73
+ `assignFrom: handler "${name}" — module "${entry}" does not export a valid handler class.`
74
+ );
75
+ }
76
+ return HandlerClass;
77
+ }
78
+ return undefined;
79
+ }
80
+ /**
81
+ * Process all handler command keys (ending with ' =>') in a pattern.
82
+ *
83
+ * @param target - The target object being assigned to
84
+ * @param handlerKeys - Array of keys ending with ' =>'
85
+ * @param pattern - The original pattern object
86
+ * @param options - The assignFrom options
87
+ */
88
+ export async function processHandlerCommands(target, handlerKeys, pattern, options) {
89
+ for (const key of handlerKeys) {
90
+ const lhsPath = key.substring(0, key.length - 3); // Remove ' =>'
91
+ const rhs = pattern[key];
92
+ // Normalize RHS to an array of handler configs
93
+ const configs = Array.isArray(rhs) ? rhs : [rhs];
94
+ // Validate — no nested arrays
95
+ for (const config of configs) {
96
+ if (Array.isArray(config)) {
97
+ throw new Error(`assignFrom: handler command "${key}" does not support nested arrays`);
98
+ }
99
+ if (!config || typeof config !== 'object' || !config.do) {
100
+ throw new Error(`assignFrom: handler command "${key}" requires a config object with a "do" field`);
101
+ }
102
+ }
103
+ // Empty array — skip silently
104
+ if (configs.length === 0)
105
+ continue;
106
+ // Resolve the LHS path, preserving parent + key for return-value assignment.
107
+ let lhsTarget;
108
+ let lhsParent = undefined;
109
+ let lhsKey = undefined;
110
+ if (lhsPath.startsWith('?.')) {
111
+ const pathParts = lhsPath.split('?.').filter(p => p.length > 0);
112
+ const withMethodsSet = options.withMethods
113
+ ? options.withMethods instanceof Set
114
+ ? options.withMethods
115
+ : new Set(options.withMethods)
116
+ : undefined;
117
+ if (withMethodsSet && pathParts.length > 0) {
118
+ const result = evaluatePathWithMethods(target, pathParts, undefined, withMethodsSet);
119
+ lhsParent = result.target;
120
+ lhsKey = result.lastKey;
121
+ lhsTarget = result.target[result.lastKey];
122
+ if (result.isMethod && typeof result.target[result.lastKey] === 'function') {
123
+ lhsTarget = result.target[result.lastKey].call(result.target);
124
+ lhsParent = undefined;
125
+ lhsKey = undefined;
126
+ }
127
+ }
128
+ else {
129
+ if (pathParts.length === 0) {
130
+ lhsTarget = target;
131
+ }
132
+ else if (pathParts.length === 1) {
133
+ lhsParent = target;
134
+ lhsKey = pathParts[0];
135
+ lhsTarget = target[pathParts[0]];
136
+ }
137
+ else {
138
+ let current = target;
139
+ for (let i = 0; i < pathParts.length - 1; i++) {
140
+ if (current == null)
141
+ break;
142
+ current = current[pathParts[i]];
143
+ }
144
+ lhsParent = current;
145
+ lhsKey = pathParts[pathParts.length - 1];
146
+ lhsTarget = current != null ? current[lhsKey] : undefined;
147
+ }
148
+ }
149
+ }
150
+ else if (lhsPath) {
151
+ lhsParent = target;
152
+ lhsKey = lhsPath;
153
+ lhsTarget = target[lhsPath];
154
+ }
155
+ else {
156
+ lhsTarget = target;
157
+ }
158
+ // Execute handlers sequentially, sharing the same lhsTarget
159
+ for (const config of configs) {
160
+ // 1. Check options.handlers (local, per-call)
161
+ let HandlerClass = await resolveFromHandlers(config.do, options.handlers);
162
+ // 2. Fallback to built-in auto-load
163
+ if (!HandlerClass && config.do.startsWith('builtIns.')) {
164
+ HandlerClass = await loadBuiltIn(config.do);
165
+ }
166
+ if (!HandlerClass) {
167
+ throw new Error(`assignFrom: unknown handler "${config.do}". Provide it in options.handlers.`);
168
+ }
169
+ // Resolve 'resolve' map if present — uses full resolveValues (paths, protocols, literals)
170
+ let resolvedParams = {};
171
+ if (config.resolve) {
172
+ resolvedParams = await resolveValues(config.resolve, options.from, {
173
+ withMethods: options.withMethods,
174
+ aka: options.aka,
175
+ protocols: options.protocols
176
+ });
177
+ }
178
+ // Instantiate and invoke the handler
179
+ const handler = new HandlerClass(config);
180
+ const result = await handler.assign(lhsTarget, resolvedParams, options);
181
+ // Return-value protocol: if handler returns a non-undefined value,
182
+ // assign it back to the LHS path
183
+ if (result !== undefined && lhsParent != null && lhsKey != null) {
184
+ lhsParent[lhsKey] = result;
185
+ }
186
+ }
187
+ }
188
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * processHandlerCommands - Handles ` =>` operator keys in assignFrom.
3
+ *
4
+ * Dynamically imported only when ` =>` keys are detected in the pattern.
5
+ */
6
+
7
+ import { resolveValues } from './resolveValues.js';
8
+ import { evaluatePathWithMethods } from './assignGingerly.js';
9
+ import type { AssignFromOptions, AssignFromHandlerConstructor } from './assignFrom.js';
10
+
11
+ /**
12
+ * Map of built-in handler names to their module paths.
13
+ * These are auto-loaded on demand — no explicit import required.
14
+ */
15
+ const BUILT_IN_MAP: Record<string, string> = {
16
+ 'builtIns.lazyLoad': './handlers/lazyLoad.js',
17
+ 'builtIns.lazyLoadSwitch': './handlers/lazyLoadSwitch.js',
18
+ 'builtIns.join': './handlers/join.js',
19
+ 'builtIns.microDataJoin': './handlers/microDataJoin.js',
20
+ };
21
+
22
+ /**
23
+ * Check if an import path is allowed (non-cross-domain).
24
+ */
25
+ function isAllowedImportPath(path: string): boolean {
26
+ return path.startsWith('./') || path.startsWith('../') || path.startsWith('/')
27
+ || (!path.includes('://') && !path.startsWith('//'));
28
+ }
29
+
30
+ /**
31
+ * Find a handler class in a dynamically imported module.
32
+ * Checks default export first, then searches for the first class with `assign` on prototype.
33
+ */
34
+ function findHandlerInModule(module: any): AssignFromHandlerConstructor | undefined {
35
+ // Check default export first
36
+ if (module.default && typeof module.default === 'function'
37
+ && module.default.prototype && 'assign' in module.default.prototype) {
38
+ return module.default;
39
+ }
40
+ // Search other exports
41
+ for (const key of Object.keys(module)) {
42
+ const exported = module[key];
43
+ if (typeof exported === 'function' && exported.prototype && 'assign' in exported.prototype) {
44
+ return exported as AssignFromHandlerConstructor;
45
+ }
46
+ }
47
+ return undefined;
48
+ }
49
+
50
+ /**
51
+ * Dynamically load a built-in handler by name.
52
+ * Returns the handler constructor, or undefined if the name isn't a recognized built-in.
53
+ */
54
+ async function loadBuiltIn(name: string): Promise<AssignFromHandlerConstructor | undefined> {
55
+ const path = BUILT_IN_MAP[name];
56
+ if (!path) return undefined;
57
+ const module = await import(path);
58
+ return findHandlerInModule(module);
59
+ }
60
+
61
+ /**
62
+ * Resolve a handler from options.handlers (class constructor or import path).
63
+ */
64
+ async function resolveFromHandlers(
65
+ name: string,
66
+ handlers: Record<string, AssignFromHandlerConstructor | string> | undefined
67
+ ): Promise<AssignFromHandlerConstructor | undefined> {
68
+ if (!handlers || !(name in handlers)) return undefined;
69
+
70
+ const entry = handlers[name];
71
+
72
+ // Class constructor — use directly
73
+ if (typeof entry === 'function') {
74
+ return entry as AssignFromHandlerConstructor;
75
+ }
76
+
77
+ // Import path string — validate and dynamically import
78
+ if (typeof entry === 'string') {
79
+ if (!isAllowedImportPath(entry)) {
80
+ throw new Error(
81
+ `assignFrom: handler "${name}" has an invalid import path "${entry}". ` +
82
+ `Only relative, absolute, or bare specifier paths are allowed (no cross-domain URLs).`
83
+ );
84
+ }
85
+ const module = await import(entry);
86
+ const HandlerClass = findHandlerInModule(module);
87
+ if (!HandlerClass) {
88
+ throw new Error(
89
+ `assignFrom: handler "${name}" — module "${entry}" does not export a valid handler class.`
90
+ );
91
+ }
92
+ return HandlerClass;
93
+ }
94
+
95
+ return undefined;
96
+ }
97
+
98
+ /**
99
+ * Process all handler command keys (ending with ' =>') in a pattern.
100
+ *
101
+ * @param target - The target object being assigned to
102
+ * @param handlerKeys - Array of keys ending with ' =>'
103
+ * @param pattern - The original pattern object
104
+ * @param options - The assignFrom options
105
+ * @param handlerRegistry - The registry of handler classes
106
+ */
107
+ export async function processHandlerCommands(
108
+ target: any,
109
+ handlerKeys: string[],
110
+ pattern: Record<string, any>,
111
+ options: AssignFromOptions
112
+ ): Promise<void> {
113
+ for (const key of handlerKeys) {
114
+ const lhsPath = key.substring(0, key.length - 3); // Remove ' =>'
115
+ const rhs = pattern[key];
116
+
117
+ // Normalize RHS to an array of handler configs
118
+ const configs = Array.isArray(rhs) ? rhs : [rhs];
119
+
120
+ // Validate — no nested arrays
121
+ for (const config of configs) {
122
+ if (Array.isArray(config)) {
123
+ throw new Error(`assignFrom: handler command "${key}" does not support nested arrays`);
124
+ }
125
+ if (!config || typeof config !== 'object' || !config.do) {
126
+ throw new Error(`assignFrom: handler command "${key}" requires a config object with a "do" field`);
127
+ }
128
+ }
129
+
130
+ // Empty array — skip silently
131
+ if (configs.length === 0) continue;
132
+
133
+ // Resolve the LHS path, preserving parent + key for return-value assignment.
134
+ // lhsParent[lhsKey] === lhsTarget (the current value at the path)
135
+ let lhsTarget: any;
136
+ let lhsParent: any = undefined;
137
+ let lhsKey: string | undefined = undefined;
138
+
139
+ if (lhsPath.startsWith('?.')) {
140
+ const pathParts = lhsPath.split('?.').filter(p => p.length > 0);
141
+ const withMethodsSet = options.withMethods
142
+ ? options.withMethods instanceof Set
143
+ ? options.withMethods
144
+ : new Set(options.withMethods)
145
+ : undefined;
146
+
147
+ if (withMethodsSet && pathParts.length > 0) {
148
+ const result = evaluatePathWithMethods(target, pathParts, undefined, withMethodsSet);
149
+ lhsParent = result.target;
150
+ lhsKey = result.lastKey;
151
+ lhsTarget = result.target[result.lastKey];
152
+ // If last key is a method, call it to get the target
153
+ if (result.isMethod && typeof result.target[result.lastKey] === 'function') {
154
+ lhsTarget = result.target[result.lastKey].call(result.target);
155
+ lhsParent = undefined; // Can't assign back to a method call result
156
+ lhsKey = undefined;
157
+ }
158
+ } else {
159
+ // Simple path navigation — walk to parent, keep last key
160
+ if (pathParts.length === 0) {
161
+ lhsTarget = target;
162
+ } else if (pathParts.length === 1) {
163
+ lhsParent = target;
164
+ lhsKey = pathParts[0];
165
+ lhsTarget = target[pathParts[0]];
166
+ } else {
167
+ let current = target;
168
+ for (let i = 0; i < pathParts.length - 1; i++) {
169
+ if (current == null) break;
170
+ current = current[pathParts[i]];
171
+ }
172
+ lhsParent = current;
173
+ lhsKey = pathParts[pathParts.length - 1];
174
+ lhsTarget = current != null ? current[lhsKey] : undefined;
175
+ }
176
+ }
177
+ } else if (lhsPath) {
178
+ lhsParent = target;
179
+ lhsKey = lhsPath;
180
+ lhsTarget = target[lhsPath];
181
+ } else {
182
+ lhsTarget = target;
183
+ }
184
+
185
+ // Execute handlers sequentially, sharing the same lhsTarget
186
+ for (const config of configs) {
187
+ // 1. Check options.handlers (local, per-call)
188
+ let HandlerClass = await resolveFromHandlers(config.do, options.handlers);
189
+
190
+ // 2. Fallback to built-in auto-load
191
+ if (!HandlerClass && config.do.startsWith('builtIns.')) {
192
+ HandlerClass = await loadBuiltIn(config.do);
193
+ }
194
+
195
+ if (!HandlerClass) {
196
+ throw new Error(`assignFrom: unknown handler "${config.do}". Provide it in options.handlers.`);
197
+ }
198
+
199
+ // Resolve 'resolve' map if present — uses full resolveValues (paths, protocols, literals)
200
+ let resolvedParams: Record<string, any> = {};
201
+ if (config.resolve) {
202
+ resolvedParams = await resolveValues(config.resolve, options.from, {
203
+ withMethods: options.withMethods,
204
+ aka: options.aka,
205
+ protocols: options.protocols
206
+ });
207
+ }
208
+
209
+ // Instantiate and invoke the handler
210
+ const handler = new HandlerClass(config);
211
+ const result = await handler.assign(lhsTarget, resolvedParams, options);
212
+
213
+ // Return-value protocol: if handler returns a non-undefined value,
214
+ // assign it back to the LHS path
215
+ if (result !== undefined && lhsParent != null && lhsKey != null) {
216
+ lhsParent[lhsKey] = result;
217
+ }
218
+ }
219
+ }
220
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * resolveIdRef.js — Cached element resolution via #[x] syntax.
3
+ *
4
+ * Dynamically imported by assignFrom when `withIds` is provided or `#[x]` patterns are detected.
5
+ * Provides lazy, WeakRef-cached element lookups keyed by variable name.
6
+ *
7
+ * First access: runs the query against the target, auto-assigns an ID if needed, caches via WeakRef.
8
+ * Subsequent access: WeakRef.deref() (~10ns) or getElementById fallback (~20-100ns).
9
+ */
10
+
11
+ /**
12
+ * Module-level cache: rootNode → Map<varName, { id, WeakRef }>
13
+ * WeakMap ensures cleanup when rootNode is GC'd.
14
+ */
15
+ const idCacheMap = new WeakMap();
16
+
17
+ /**
18
+ * Counter per rootNode for generating unique IDs.
19
+ */
20
+ const idCounterMap = new WeakMap();
21
+
22
+ /**
23
+ * Generate a unique ID within a rootNode.
24
+ * Format: _ag0, _ag1, _ag2, ...
25
+ */
26
+ function generateUniqueId(rootNode) {
27
+ let counter = idCounterMap.get(rootNode) ?? 0;
28
+ let id;
29
+ // Ensure uniqueness (skip if ID already exists in the document)
30
+ do {
31
+ id = `_ag${counter}`;
32
+ counter++;
33
+ } while (rootNode.getElementById?.(id));
34
+ idCounterMap.set(rootNode, counter);
35
+ return id;
36
+ }
37
+
38
+ /**
39
+ * Resolve a single #[varName] reference lazily.
40
+ *
41
+ * @param varName - The variable name (e.g., 'x' from '#[x]')
42
+ * @param target - The target element to query against
43
+ * @param withIds - The withIds configuration map
44
+ * @returns The resolved element, or undefined if not found
45
+ */
46
+ export function resolveIdVariable(varName, target, withIds) {
47
+ const config = withIds[varName];
48
+ if (config === undefined) return undefined;
49
+
50
+ const rootNode = target.getRootNode?.() ?? target;
51
+
52
+ // Get or create cache for this rootNode
53
+ let cache = idCacheMap.get(rootNode);
54
+ if (!cache) {
55
+ cache = new Map();
56
+ idCacheMap.set(rootNode, cache);
57
+ }
58
+
59
+ // Check cache first
60
+ const cached = cache.get(varName);
61
+ if (cached) {
62
+ const el = cached.ref.deref();
63
+ if (el) return el;
64
+
65
+ // WeakRef was collected — try getElementById fallback
66
+ const el2 = rootNode.getElementById?.(cached.id);
67
+ if (el2) {
68
+ cache.set(varName, { id: cached.id, ref: new WeakRef(el2) });
69
+ return el2;
70
+ }
71
+ // Element no longer exists — fall through to re-query
72
+ }
73
+
74
+ // First time or cache miss — resolve the element
75
+ let el = null;
76
+
77
+ if (typeof config === 'string') {
78
+ // String form: existing ID — use getElementById directly
79
+ el = rootNode.getElementById?.(config) ?? null;
80
+ } else {
81
+ // Object form: { qry } — run querySelector against target
82
+ el = target.querySelector?.(config.qry) ?? null;
83
+ }
84
+
85
+ if (!el) return undefined;
86
+
87
+ // Ensure the element has an ID
88
+ let id = el.id;
89
+ if (!id) {
90
+ id = generateUniqueId(rootNode);
91
+ el.id = id;
92
+ }
93
+
94
+ // Cache it
95
+ cache.set(varName, { id, ref: new WeakRef(el) });
96
+ return el;
97
+ }
98
+
99
+ /**
100
+ * Check if a key starts with #[...] syntax.
101
+ */
102
+ export function hasIdRef(key) {
103
+ return key.startsWith('#[');
104
+ }
105
+
106
+ /**
107
+ * Extract the variable name and remaining path from a #[x]... key.
108
+ * Returns { varName, remainingPath } or null if not valid.
109
+ */
110
+ export function parseIdRef(key) {
111
+ const closeIdx = key.indexOf(']');
112
+ if (closeIdx === -1) return null;
113
+
114
+ const varName = key.substring(2, closeIdx); // skip '#['
115
+ if (!varName) return null;
116
+
117
+ let remainingPath = key.substring(closeIdx + 1);
118
+
119
+ // Remove handler suffix if present (caller handles it separately)
120
+ if (remainingPath.endsWith(' =>')) {
121
+ remainingPath = remainingPath.substring(0, remainingPath.length - 3);
122
+ }
123
+
124
+ return { varName, remainingPath };
125
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * resolveIdRef.ts — Cached element resolution via #[x] syntax.
3
+ *
4
+ * Dynamically imported by assignFrom when `withIds` is provided or `#[x]` patterns are detected.
5
+ * Provides lazy, WeakRef-cached element lookups keyed by variable name.
6
+ *
7
+ * First access: runs the query against the target, auto-assigns an ID if needed, caches via WeakRef.
8
+ * Subsequent access: WeakRef.deref() (~10ns) or getElementById fallback (~20-100ns).
9
+ */
10
+
11
+ /**
12
+ * Configuration for a withIds entry.
13
+ */
14
+ export type WithIdConfig = string | { qry: string };
15
+
16
+ /**
17
+ * Module-level cache: rootNode → Map<varName, { id, WeakRef }>
18
+ * WeakMap ensures cleanup when rootNode is GC'd.
19
+ */
20
+ const idCacheMap = new WeakMap<object, Map<string, { id: string; ref: WeakRef<Element> }>>();
21
+
22
+ /**
23
+ * Counter per rootNode for generating unique IDs.
24
+ */
25
+ const idCounterMap = new WeakMap<object, number>();
26
+
27
+ /**
28
+ * Generate a unique ID within a rootNode.
29
+ * Format: _ag0, _ag1, _ag2, ...
30
+ */
31
+ function generateUniqueId(rootNode: any): string {
32
+ let counter = idCounterMap.get(rootNode) ?? 0;
33
+ let id: string;
34
+ // Ensure uniqueness (skip if ID already exists in the document)
35
+ do {
36
+ id = `_ag${counter}`;
37
+ counter++;
38
+ } while (rootNode.getElementById?.(id));
39
+ idCounterMap.set(rootNode, counter);
40
+ return id;
41
+ }
42
+
43
+ /**
44
+ * Resolve a single #[varName] reference lazily.
45
+ *
46
+ * @param varName - The variable name (e.g., 'x' from '#[x]')
47
+ * @param target - The target element to query against
48
+ * @param withIds - The withIds configuration map
49
+ * @returns The resolved element, or undefined if not found
50
+ */
51
+ export function resolveIdVariable(
52
+ varName: string,
53
+ target: any,
54
+ withIds: Record<string, WithIdConfig>
55
+ ): Element | undefined {
56
+ const config = withIds[varName];
57
+ if (config === undefined) return undefined;
58
+
59
+ const rootNode = target.getRootNode?.() ?? target;
60
+
61
+ // Get or create cache for this rootNode
62
+ let cache = idCacheMap.get(rootNode);
63
+ if (!cache) {
64
+ cache = new Map();
65
+ idCacheMap.set(rootNode, cache);
66
+ }
67
+
68
+ // Check cache first
69
+ const cached = cache.get(varName);
70
+ if (cached) {
71
+ const el = cached.ref.deref();
72
+ if (el) return el;
73
+
74
+ // WeakRef was collected — try getElementById fallback
75
+ const el2 = rootNode.getElementById?.(cached.id);
76
+ if (el2) {
77
+ cache.set(varName, { id: cached.id, ref: new WeakRef(el2) });
78
+ return el2;
79
+ }
80
+ // Element no longer exists — fall through to re-query
81
+ }
82
+
83
+ // First time or cache miss — resolve the element
84
+ let el: Element | null = null;
85
+
86
+ if (typeof config === 'string') {
87
+ // String form: existing ID — use getElementById directly
88
+ el = rootNode.getElementById?.(config) ?? null;
89
+ } else {
90
+ // Object form: { qry } — run querySelector against target
91
+ el = target.querySelector?.(config.qry) ?? null;
92
+ }
93
+
94
+ if (!el) return undefined;
95
+
96
+ // Ensure the element has an ID
97
+ let id = el.id;
98
+ if (!id) {
99
+ id = generateUniqueId(rootNode);
100
+ el.id = id;
101
+ }
102
+
103
+ // Cache it
104
+ cache.set(varName, { id, ref: new WeakRef(el) });
105
+ return el;
106
+ }
107
+
108
+ /**
109
+ * Check if a key starts with #[...] syntax.
110
+ */
111
+ export function hasIdRef(key: string): boolean {
112
+ return key.startsWith('#[');
113
+ }
114
+
115
+ /**
116
+ * Extract the variable name and remaining path from a #[x]... key.
117
+ * Returns [varName, remainingPath] or null if not a valid #[x] reference.
118
+ *
119
+ * Examples:
120
+ * '#[x]' → ['x', '']
121
+ * '#[x] =>' → ['x', ''] (handler suffix handled separately)
122
+ * '#[x]?.querySelector?..child' → ['x', '?.querySelector?..child']
123
+ * '#[x]?.textContent =>' → ['x', '?.textContent'] (handler suffix handled separately)
124
+ */
125
+ export function parseIdRef(key: string): { varName: string; remainingPath: string } | null {
126
+ const closeIdx = key.indexOf(']');
127
+ if (closeIdx === -1) return null;
128
+
129
+ const varName = key.substring(2, closeIdx); // skip '#['
130
+ if (!varName) return null;
131
+
132
+ let remainingPath = key.substring(closeIdx + 1);
133
+
134
+ // Remove handler suffix if present (caller handles it separately)
135
+ if (remainingPath.endsWith(' =>')) {
136
+ remainingPath = remainingPath.substring(0, remainingPath.length - 3);
137
+ }
138
+
139
+ return { varName, remainingPath };
140
+ }