assign-gingerly 0.0.26 → 0.0.28

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,94 @@
1
+ /**
2
+ * Registry for parsers scoped to a synthesizer element (be-hive, htmx-container, etc.)
3
+ * Enables lazy-loading of complex parsers with Promise-based waiting
4
+ */
5
+ export class ScopedParserRegistry {
6
+ parsers = new Map();
7
+ pendingWaits = new Map();
8
+ /**
9
+ * Register a parser with a given name
10
+ * Resolves any pending waiters for this parser
11
+ * @param name - The name to register the parser under
12
+ * @param parser - The parser function
13
+ */
14
+ register(name, parser) {
15
+ if (this.parsers.has(name)) {
16
+ console.warn(`Parser "${name}" already registered in scoped registry, overwriting`);
17
+ }
18
+ this.parsers.set(name, parser);
19
+ // Resolve any pending waiters for this parser
20
+ const waiters = this.pendingWaits.get(name);
21
+ if (waiters) {
22
+ waiters.forEach(({ resolve }) => resolve());
23
+ this.pendingWaits.delete(name);
24
+ }
25
+ }
26
+ /**
27
+ * Get a parser by name
28
+ * @param name - The name of the parser
29
+ * @returns The parser function or undefined if not found
30
+ */
31
+ get(name) {
32
+ return this.parsers.get(name);
33
+ }
34
+ /**
35
+ * Check if a parser is registered
36
+ * @param name - The name to check
37
+ * @returns True if the parser exists
38
+ */
39
+ has(name) {
40
+ return this.parsers.has(name);
41
+ }
42
+ /**
43
+ * Wait for multiple parsers to be registered
44
+ * @param names - Array of parser names to wait for
45
+ * @param timeout - Timeout in milliseconds (default: 60000)
46
+ * @returns Promise that resolves when all parsers are registered
47
+ * @throws Error listing missing parsers if timeout expires
48
+ */
49
+ waitFor(names, timeout = 60000) {
50
+ // Check if all parsers are already registered
51
+ const missing = names.filter(name => !this.has(name));
52
+ if (missing.length === 0) {
53
+ return Promise.resolve();
54
+ }
55
+ // Create promise that resolves when all parsers are registered
56
+ return new Promise((resolve, reject) => {
57
+ let timeoutId;
58
+ // Set timeout that rejects with descriptive error
59
+ timeoutId = setTimeout(() => {
60
+ const stillMissing = names.filter(name => !this.has(name));
61
+ reject(new Error(`Timeout waiting for parsers: ${stillMissing.join(', ')}`));
62
+ }, timeout);
63
+ // Track which parsers we're waiting for
64
+ let remainingCount = missing.length;
65
+ // Create waiter for each missing parser
66
+ missing.forEach(name => {
67
+ const waiter = {
68
+ resolve: () => {
69
+ remainingCount--;
70
+ if (remainingCount === 0) {
71
+ if (timeoutId !== undefined) {
72
+ clearTimeout(timeoutId);
73
+ }
74
+ resolve();
75
+ }
76
+ },
77
+ reject
78
+ };
79
+ // Add waiter to pending list
80
+ if (!this.pendingWaits.has(name)) {
81
+ this.pendingWaits.set(name, []);
82
+ }
83
+ this.pendingWaits.get(name).push(waiter);
84
+ });
85
+ });
86
+ }
87
+ /**
88
+ * Get all registered parser names
89
+ * @returns Array of parser names
90
+ */
91
+ getNames() {
92
+ return Array.from(this.parsers.keys());
93
+ }
94
+ }
@@ -0,0 +1,111 @@
1
+ import { ParserFunction } from './types/assign-gingerly/types';
2
+
3
+ /**
4
+ * Registry for parsers scoped to a synthesizer element (be-hive, htmx-container, etc.)
5
+ * Enables lazy-loading of complex parsers with Promise-based waiting
6
+ */
7
+ export class ScopedParserRegistry {
8
+ private parsers = new Map<string, ParserFunction>();
9
+ private pendingWaits = new Map<string, Array<{
10
+ resolve: () => void;
11
+ reject: (error: Error) => void;
12
+ }>>();
13
+
14
+ /**
15
+ * Register a parser with a given name
16
+ * Resolves any pending waiters for this parser
17
+ * @param name - The name to register the parser under
18
+ * @param parser - The parser function
19
+ */
20
+ register(name: string, parser: ParserFunction): void {
21
+ if (this.parsers.has(name)) {
22
+ console.warn(`Parser "${name}" already registered in scoped registry, overwriting`);
23
+ }
24
+
25
+ this.parsers.set(name, parser);
26
+
27
+ // Resolve any pending waiters for this parser
28
+ const waiters = this.pendingWaits.get(name);
29
+ if (waiters) {
30
+ waiters.forEach(({ resolve }) => resolve());
31
+ this.pendingWaits.delete(name);
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Get a parser by name
37
+ * @param name - The name of the parser
38
+ * @returns The parser function or undefined if not found
39
+ */
40
+ get(name: string): ParserFunction | undefined {
41
+ return this.parsers.get(name);
42
+ }
43
+
44
+ /**
45
+ * Check if a parser is registered
46
+ * @param name - The name to check
47
+ * @returns True if the parser exists
48
+ */
49
+ has(name: string): boolean {
50
+ return this.parsers.has(name);
51
+ }
52
+
53
+ /**
54
+ * Wait for multiple parsers to be registered
55
+ * @param names - Array of parser names to wait for
56
+ * @param timeout - Timeout in milliseconds (default: 60000)
57
+ * @returns Promise that resolves when all parsers are registered
58
+ * @throws Error listing missing parsers if timeout expires
59
+ */
60
+ waitFor(names: string[], timeout: number = 60000): Promise<void> {
61
+ // Check if all parsers are already registered
62
+ const missing = names.filter(name => !this.has(name));
63
+ if (missing.length === 0) {
64
+ return Promise.resolve();
65
+ }
66
+
67
+ // Create promise that resolves when all parsers are registered
68
+ return new Promise((resolve, reject) => {
69
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
70
+
71
+ // Set timeout that rejects with descriptive error
72
+ timeoutId = setTimeout(() => {
73
+ const stillMissing = names.filter(name => !this.has(name));
74
+ reject(new Error(`Timeout waiting for parsers: ${stillMissing.join(', ')}`));
75
+ }, timeout);
76
+
77
+ // Track which parsers we're waiting for
78
+ let remainingCount = missing.length;
79
+
80
+ // Create waiter for each missing parser
81
+ missing.forEach(name => {
82
+ const waiter = {
83
+ resolve: () => {
84
+ remainingCount--;
85
+ if (remainingCount === 0) {
86
+ if (timeoutId !== undefined) {
87
+ clearTimeout(timeoutId);
88
+ }
89
+ resolve();
90
+ }
91
+ },
92
+ reject
93
+ };
94
+
95
+ // Add waiter to pending list
96
+ if (!this.pendingWaits.has(name)) {
97
+ this.pendingWaits.set(name, []);
98
+ }
99
+ this.pendingWaits.get(name)!.push(waiter);
100
+ });
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Get all registered parser names
106
+ * @returns Array of parser names
107
+ */
108
+ getNames(): string[] {
109
+ return Array.from(this.parsers.keys());
110
+ }
111
+ }
@@ -102,7 +102,14 @@ class ElementEnhancementContainer {
102
102
  let attrInitVals = undefined;
103
103
  if (registryItem.withAttrs && element) {
104
104
  try {
105
- attrInitVals = parseWithAttrs(element, registryItem.withAttrs, registryItem.allowUnprefixed || false);
105
+ // Create SpawnContext to pass to parseWithAttrs
106
+ // If mountCtx already has synthesizerElement, use it directly in the SpawnContext
107
+ const spawnContext = {
108
+ config: registryItem,
109
+ mountCtx,
110
+ synthesizerElement: mountCtx?.synthesizerElement
111
+ };
112
+ attrInitVals = parseWithAttrs(element, registryItem.withAttrs, registryItem.allowUnprefixed || false, spawnContext);
106
113
  }
107
114
  catch (e) {
108
115
  console.error('Error parsing attributes:', e);
@@ -178,10 +178,18 @@ class ElementEnhancementContainer {
178
178
  let attrInitVals: any = undefined;
179
179
  if (registryItem.withAttrs && element) {
180
180
  try {
181
+ // Create SpawnContext to pass to parseWithAttrs
182
+ // If mountCtx already has synthesizerElement, use it directly in the SpawnContext
183
+ const spawnContext = {
184
+ config: registryItem,
185
+ mountCtx,
186
+ synthesizerElement: (mountCtx as any)?.synthesizerElement
187
+ };
181
188
  attrInitVals = parseWithAttrs(
182
189
  element,
183
190
  registryItem.withAttrs,
184
- registryItem.allowUnprefixed || false
191
+ registryItem.allowUnprefixed || false,
192
+ spawnContext
185
193
  );
186
194
  } catch (e) {
187
195
  console.error('Error parsing attributes:', e);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "assign-gingerly",
3
- "version": "0.0.26",
3
+ "version": "0.0.28",
4
4
  "description": "This package provides a utility function for carefully merging one object into another.",
5
5
  "homepage": "https://github.com/bahrus/assign-gingerly#readme",
6
6
  "bugs": {
package/parseWithAttrs.js CHANGED
@@ -1,4 +1,4 @@
1
- import { globalParserRegistry } from './parserRegistry.js';
1
+ import { globalParserRegistry, getParserRegistry } from './parserRegistry.js';
2
2
  import { resolveTemplate } from './resolveTemplate.js';
3
3
  // Module-level cache for parsed attribute values
4
4
  // Structure: Map<configKey, Map<attrValue, parsedValue>>
@@ -7,14 +7,15 @@ const parseCache = new Map();
7
7
  * Resolves a parser specification to an actual parser function
8
8
  * Supports:
9
9
  * - Inline functions (direct use)
10
- * - Named parsers from global registry
11
- * - Custom element static methods (element-name.methodName)
10
+ * - Named parsers from scoped registry (if synthesizerElement provided)
11
+ * - Named parsers from global registry (fallback)
12
12
  *
13
13
  * @param parserSpec - Parser function or string reference
14
+ * @param synthesizerElement - Optional synthesizer element for scoped parser lookup
14
15
  * @returns The resolved parser function
15
16
  * @throws Error if parser cannot be resolved
16
17
  */
17
- function resolveParser(parserSpec) {
18
+ function resolveParser(parserSpec, synthesizerElement) {
18
19
  // Undefined - no parser specified
19
20
  if (parserSpec === undefined) {
20
21
  return undefined;
@@ -23,38 +24,28 @@ function resolveParser(parserSpec) {
23
24
  if (typeof parserSpec === 'function') {
24
25
  return parserSpec;
25
26
  }
26
- // Tuple [CustomElementName, StaticMethodName] - resolve custom element static method
27
- if (Array.isArray(parserSpec)) {
28
- const [elementName, methodName] = parserSpec;
29
- if (typeof customElements === 'undefined') {
30
- throw new Error(`Cannot resolve parser [${elementName}, ${methodName}]: customElements is not available`);
31
- }
32
- try {
33
- const ctr = customElements.get(elementName);
34
- if (!ctr) {
35
- throw new Error(`Cannot resolve parser [${elementName}, ${methodName}]: custom element "${elementName}" not found`);
36
- }
37
- if (typeof ctr[methodName] !== 'function') {
38
- throw new Error(`Cannot resolve parser [${elementName}, ${methodName}]: static method "${methodName}" not found on custom element "${elementName}"`);
39
- }
40
- return ctr[methodName];
41
- }
42
- catch (e) {
43
- if (e instanceof Error && e.message.startsWith('Cannot resolve parser')) {
44
- throw e;
27
+ // String reference - resolve from scoped or global registry
28
+ if (typeof parserSpec === 'string') {
29
+ // Check scoped registry first (if synthesizerElement provided)
30
+ if (synthesizerElement) {
31
+ const scopedRegistry = getParserRegistry(synthesizerElement);
32
+ const scopedParser = scopedRegistry.get(parserSpec);
33
+ if (scopedParser) {
34
+ return scopedParser;
45
35
  }
46
- throw new Error(`Cannot resolve parser [${elementName}, ${methodName}]: ${e instanceof Error ? e.message : String(e)}`);
47
36
  }
48
- }
49
- // String reference - resolve from global registry
50
- if (typeof parserSpec === 'string') {
51
- const parser = globalParserRegistry.get(parserSpec);
52
- if (parser) {
53
- return parser;
37
+ // Fallback to global registry
38
+ const globalParser = globalParserRegistry.get(parserSpec);
39
+ if (globalParser) {
40
+ return globalParser;
54
41
  }
55
- // Not found in registry
56
- throw new Error(`Parser "${parserSpec}" not found in globalParserRegistry. ` +
57
- `If you want to reference a custom element static method, use tuple syntax: ["element-name", "methodName"]`);
42
+ // Not found in either registry
43
+ throw new Error(`Parser "${parserSpec}" not found. ` +
44
+ `Checked ${synthesizerElement ? 'scoped registry and ' : ''}global registry.\n` +
45
+ `Ensure the parser is registered via:\n` +
46
+ `- <script type="emc-parser" src="..." parser-name="${parserSpec}">\n` +
47
+ `- registerParser(synthesizerElement, "${parserSpec}", parserFn)\n` +
48
+ `- globalParserRegistry.register("${parserSpec}", parserFn)`);
58
49
  }
59
50
  return undefined;
60
51
  }
@@ -74,9 +65,6 @@ function getCacheKey(config) {
74
65
  else if (typeof config.parser === 'string') {
75
66
  parserStr = `named:${config.parser}`;
76
67
  }
77
- else if (Array.isArray(config.parser)) {
78
- parserStr = `tuple:${config.parser[0]}.${config.parser[1]}`;
79
- }
80
68
  else {
81
69
  parserStr = 'custom';
82
70
  }
@@ -87,12 +75,13 @@ function getCacheKey(config) {
87
75
  * @param attrValue - The attribute value to parse (or null)
88
76
  * @param config - The attribute configuration
89
77
  * @param parser - The parser function to use
78
+ * @param context - The parser context to pass to the parser
90
79
  * @returns The parsed value
91
80
  */
92
- function parseWithCache(attrValue, config, parser) {
81
+ function parseWithCache(attrValue, config, parser, context) {
93
82
  // Skip caching for Boolean (presence check doesn't benefit from caching)
94
83
  if (config.instanceOf === 'Boolean') {
95
- return parser(attrValue);
84
+ return callParser(parser, attrValue, context);
96
85
  }
97
86
  // Get or create cache for this config
98
87
  const cacheKey = getCacheKey(config);
@@ -111,7 +100,7 @@ function parseWithCache(attrValue, config, parser) {
111
100
  return cachedValue;
112
101
  }
113
102
  // Parse the value
114
- const parsedValue = parser(attrValue);
103
+ const parsedValue = callParser(parser, attrValue, context);
115
104
  // Store in cache
116
105
  valueCache.set(valueCacheKey, parsedValue);
117
106
  // Return clone if requested (even on first parse)
@@ -120,6 +109,23 @@ function parseWithCache(attrValue, config, parser) {
120
109
  }
121
110
  return parsedValue;
122
111
  }
112
+ /**
113
+ * Calls a parser function with the appropriate signature
114
+ * Handles both simple (value-only) and advanced (value + context) parser signatures
115
+ * @param parser - The parser function
116
+ * @param attrValue - The attribute value
117
+ * @param context - The parser context
118
+ * @returns The parsed value
119
+ */
120
+ function callParser(parser, attrValue, context) {
121
+ // Check parser arity (number of parameters)
122
+ // If parser accepts 2+ parameters, pass context
123
+ if (parser.length >= 2) {
124
+ return parser(attrValue, context);
125
+ }
126
+ // Otherwise, call with just the value (simple form)
127
+ return parser(attrValue);
128
+ }
123
129
  /**
124
130
  * Checks if a string contains a dash or non-ASCII character
125
131
  */
@@ -217,9 +223,12 @@ function getDefaultParser(instanceOf) {
217
223
  * @param element - The DOM element to read attributes from
218
224
  * @param attrPatterns - The attribute patterns configuration
219
225
  * @param allowUnprefixed - Pattern (string or RegExp) that element tag name must match to allow unprefixed attributes
226
+ * @param spawnContext - Optional spawn context containing enhancement config and synthesizer element
220
227
  * @returns Object with parsed attribute values ready for initVals
221
228
  */
222
- export function parseWithAttrs(element, attrPatterns, allowUnprefixed) {
229
+ export function parseWithAttrs(element, attrPatterns, allowUnprefixed, spawnContext) {
230
+ // Extract synthesizerElement from spawnContext for backward compatibility
231
+ const synthesizerElement = spawnContext?.synthesizerElement;
223
232
  // Validate base attribute if present
224
233
  if ('base' in attrPatterns) {
225
234
  const baseValue = attrPatterns.base;
@@ -276,6 +285,13 @@ export function parseWithAttrs(element, attrPatterns, allowUnprefixed) {
276
285
  // Second pass: read attributes and parse values
277
286
  for (const [key, { attrName, config }] of resolvedAttrs) {
278
287
  const attrValue = getAttributeValue(element, attrName, allowUnprefixed);
288
+ // Create parser context
289
+ const parserContext = {
290
+ attrConfig: config,
291
+ spawnContext,
292
+ element,
293
+ attrName
294
+ };
279
295
  // Handle missing attribute
280
296
  if (attrValue === null) {
281
297
  // Use valIfNull if defined
@@ -297,19 +313,19 @@ export function parseWithAttrs(element, attrPatterns, allowUnprefixed) {
297
313
  }
298
314
  // For Boolean without valIfNull, fall through to parser
299
315
  else {
300
- const parser = resolveParser(config.parser) || getDefaultParser(config.instanceOf);
301
- const parsedValue = parser(attrValue);
316
+ const parser = resolveParser(config.parser, synthesizerElement) || getDefaultParser(config.instanceOf);
317
+ const parsedValue = callParser(parser, attrValue, parserContext);
302
318
  const mapsTo = config.mapsTo ?? (key === 'base' ? '.' : key);
303
319
  result[mapsTo] = parsedValue;
304
320
  }
305
321
  continue;
306
322
  }
307
323
  // Attribute exists - parse normally
308
- const parser = resolveParser(config.parser) || getDefaultParser(config.instanceOf);
324
+ const parser = resolveParser(config.parser, synthesizerElement) || getDefaultParser(config.instanceOf);
309
325
  // Use cache if parseCache is specified
310
326
  const parsedValue = config.parseCache
311
- ? parseWithCache(attrValue, config, parser)
312
- : parser(attrValue);
327
+ ? parseWithCache(attrValue, config, parser, parserContext)
328
+ : callParser(parser, attrValue, parserContext);
313
329
  // Determine target property
314
330
  const mapsTo = config.mapsTo ?? (key === 'base' ? '.' : key);
315
331
  // Add to result
package/parseWithAttrs.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { AttrPatterns, AttrConfig } from './types/assign-gingerly/types';
2
- import { globalParserRegistry } from './parserRegistry.js';
1
+ import { AttrPatterns, AttrConfig, ParserFunction, ParserContext, SpawnContext } from './types/assign-gingerly/types';
2
+ import { globalParserRegistry, getParserRegistry } from './parserRegistry.js';
3
3
  import { resolveTemplate } from './resolveTemplate.js';
4
4
 
5
5
  // Module-level cache for parsed attribute values
@@ -10,16 +10,18 @@ const parseCache = new Map<string, Map<string, any>>();
10
10
  * Resolves a parser specification to an actual parser function
11
11
  * Supports:
12
12
  * - Inline functions (direct use)
13
- * - Named parsers from global registry
14
- * - Custom element static methods (element-name.methodName)
13
+ * - Named parsers from scoped registry (if synthesizerElement provided)
14
+ * - Named parsers from global registry (fallback)
15
15
  *
16
16
  * @param parserSpec - Parser function or string reference
17
+ * @param synthesizerElement - Optional synthesizer element for scoped parser lookup
17
18
  * @returns The resolved parser function
18
19
  * @throws Error if parser cannot be resolved
19
20
  */
20
21
  function resolveParser(
21
- parserSpec: ((v: string | null) => any) | string | [string, string] | undefined
22
- ): ((v: string | null) => any) | undefined {
22
+ parserSpec: ParserFunction | string | undefined,
23
+ synthesizerElement?: Element
24
+ ): ParserFunction | undefined {
23
25
  // Undefined - no parser specified
24
26
  if (parserSpec === undefined) {
25
27
  return undefined;
@@ -30,52 +32,31 @@ function resolveParser(
30
32
  return parserSpec;
31
33
  }
32
34
 
33
- // Tuple [CustomElementName, StaticMethodName] - resolve custom element static method
34
- if (Array.isArray(parserSpec)) {
35
- const [elementName, methodName] = parserSpec;
36
-
37
- if (typeof customElements === 'undefined') {
38
- throw new Error(
39
- `Cannot resolve parser [${elementName}, ${methodName}]: customElements is not available`
40
- );
41
- }
42
-
43
- try {
44
- const ctr = customElements.get(elementName);
45
- if (!ctr) {
46
- throw new Error(
47
- `Cannot resolve parser [${elementName}, ${methodName}]: custom element "${elementName}" not found`
48
- );
49
- }
50
-
51
- if (typeof (ctr as any)[methodName] !== 'function') {
52
- throw new Error(
53
- `Cannot resolve parser [${elementName}, ${methodName}]: static method "${methodName}" not found on custom element "${elementName}"`
54
- );
55
- }
56
-
57
- return (ctr as any)[methodName];
58
- } catch (e) {
59
- if (e instanceof Error && e.message.startsWith('Cannot resolve parser')) {
60
- throw e;
35
+ // String reference - resolve from scoped or global registry
36
+ if (typeof parserSpec === 'string') {
37
+ // Check scoped registry first (if synthesizerElement provided)
38
+ if (synthesizerElement) {
39
+ const scopedRegistry = getParserRegistry(synthesizerElement);
40
+ const scopedParser = scopedRegistry.get(parserSpec);
41
+ if (scopedParser) {
42
+ return scopedParser;
61
43
  }
62
- throw new Error(
63
- `Cannot resolve parser [${elementName}, ${methodName}]: ${e instanceof Error ? e.message : String(e)}`
64
- );
65
44
  }
66
- }
67
-
68
- // String reference - resolve from global registry
69
- if (typeof parserSpec === 'string') {
70
- const parser = globalParserRegistry.get(parserSpec);
71
- if (parser) {
72
- return parser;
45
+
46
+ // Fallback to global registry
47
+ const globalParser = globalParserRegistry.get(parserSpec);
48
+ if (globalParser) {
49
+ return globalParser;
73
50
  }
74
51
 
75
- // Not found in registry
52
+ // Not found in either registry
76
53
  throw new Error(
77
- `Parser "${parserSpec}" not found in globalParserRegistry. ` +
78
- `If you want to reference a custom element static method, use tuple syntax: ["element-name", "methodName"]`
54
+ `Parser "${parserSpec}" not found. ` +
55
+ `Checked ${synthesizerElement ? 'scoped registry and ' : ''}global registry.\n` +
56
+ `Ensure the parser is registered via:\n` +
57
+ `- <script type="emc-parser" src="..." parser-name="${parserSpec}">\n` +
58
+ `- registerParser(synthesizerElement, "${parserSpec}", parserFn)\n` +
59
+ `- globalParserRegistry.register("${parserSpec}", parserFn)`
79
60
  );
80
61
  }
81
62
 
@@ -97,8 +78,6 @@ function getCacheKey(config: AttrConfig<any>): string {
97
78
  parserStr = 'builtin';
98
79
  } else if (typeof config.parser === 'string') {
99
80
  parserStr = `named:${config.parser}`;
100
- } else if (Array.isArray(config.parser)) {
101
- parserStr = `tuple:${config.parser[0]}.${config.parser[1]}`;
102
81
  } else {
103
82
  parserStr = 'custom';
104
83
  }
@@ -111,16 +90,18 @@ function getCacheKey(config: AttrConfig<any>): string {
111
90
  * @param attrValue - The attribute value to parse (or null)
112
91
  * @param config - The attribute configuration
113
92
  * @param parser - The parser function to use
93
+ * @param context - The parser context to pass to the parser
114
94
  * @returns The parsed value
115
95
  */
116
96
  function parseWithCache(
117
97
  attrValue: string | null,
118
98
  config: AttrConfig<any>,
119
- parser: (v: string | null) => any
99
+ parser: ParserFunction,
100
+ context: ParserContext
120
101
  ): any {
121
102
  // Skip caching for Boolean (presence check doesn't benefit from caching)
122
103
  if (config.instanceOf === 'Boolean') {
123
- return parser(attrValue);
104
+ return callParser(parser, attrValue, context);
124
105
  }
125
106
 
126
107
  // Get or create cache for this config
@@ -145,7 +126,7 @@ function parseWithCache(
145
126
  }
146
127
 
147
128
  // Parse the value
148
- const parsedValue = parser(attrValue);
129
+ const parsedValue = callParser(parser, attrValue, context);
149
130
 
150
131
  // Store in cache
151
132
  valueCache.set(valueCacheKey, parsedValue);
@@ -158,6 +139,29 @@ function parseWithCache(
158
139
  return parsedValue;
159
140
  }
160
141
 
142
+ /**
143
+ * Calls a parser function with the appropriate signature
144
+ * Handles both simple (value-only) and advanced (value + context) parser signatures
145
+ * @param parser - The parser function
146
+ * @param attrValue - The attribute value
147
+ * @param context - The parser context
148
+ * @returns The parsed value
149
+ */
150
+ function callParser(
151
+ parser: ParserFunction,
152
+ attrValue: string | null,
153
+ context: ParserContext
154
+ ): any {
155
+ // Check parser arity (number of parameters)
156
+ // If parser accepts 2+ parameters, pass context
157
+ if (parser.length >= 2) {
158
+ return parser(attrValue, context);
159
+ }
160
+
161
+ // Otherwise, call with just the value (simple form)
162
+ return parser(attrValue);
163
+ }
164
+
161
165
  /**
162
166
  * Checks if a string contains a dash or non-ASCII character
163
167
  */
@@ -210,16 +214,16 @@ function getAttributeValue(
210
214
  /**
211
215
  * Gets the default parser for a given instanceOf type
212
216
  */
213
- function getDefaultParser(instanceOf?: string | Function): (v: string | null) => any {
217
+ function getDefaultParser(instanceOf?: string | Function): ParserFunction {
214
218
  if (!instanceOf) {
215
- return (v) => v; // Default to identity
219
+ return (v: string | null) => v; // Default to identity
216
220
  }
217
221
 
218
222
  const typeStr = typeof instanceOf === 'string' ? instanceOf : instanceOf.name;
219
223
 
220
224
  switch (typeStr) {
221
225
  case 'Object':
222
- return (v) => {
226
+ return (v: string | null) => {
223
227
  if (v === null || v === '') return null;
224
228
  try {
225
229
  return JSON.parse(v);
@@ -228,7 +232,7 @@ function getDefaultParser(instanceOf?: string | Function): (v: string | null) =>
228
232
  }
229
233
  };
230
234
  case 'Array':
231
- return (v) => {
235
+ return (v: string | null) => {
232
236
  if (v === null || v === '') return null;
233
237
  try {
234
238
  return JSON.parse(v);
@@ -237,7 +241,7 @@ function getDefaultParser(instanceOf?: string | Function): (v: string | null) =>
237
241
  }
238
242
  };
239
243
  case 'Number':
240
- return (v) => {
244
+ return (v: string | null) => {
241
245
  if (v === null || v === '') return null;
242
246
  const num = Number(v);
243
247
  if (isNaN(num)) {
@@ -246,10 +250,10 @@ function getDefaultParser(instanceOf?: string | Function): (v: string | null) =>
246
250
  return num;
247
251
  };
248
252
  case 'Boolean':
249
- return (v) => v !== null; // Presence check
253
+ return (v: string | null) => v !== null; // Presence check
250
254
  case 'String':
251
255
  default:
252
- return (v) => v; // Identity
256
+ return (v: string | null) => v; // Identity
253
257
  }
254
258
  }
255
259
 
@@ -258,13 +262,18 @@ function getDefaultParser(instanceOf?: string | Function): (v: string | null) =>
258
262
  * @param element - The DOM element to read attributes from
259
263
  * @param attrPatterns - The attribute patterns configuration
260
264
  * @param allowUnprefixed - Pattern (string or RegExp) that element tag name must match to allow unprefixed attributes
265
+ * @param spawnContext - Optional spawn context containing enhancement config and synthesizer element
261
266
  * @returns Object with parsed attribute values ready for initVals
262
267
  */
263
268
  export function parseWithAttrs<T = any>(
264
269
  element: Element,
265
270
  attrPatterns: AttrPatterns<T>,
266
- allowUnprefixed?: string | RegExp
271
+ allowUnprefixed?: string | RegExp,
272
+ spawnContext?: SpawnContext<T>
267
273
  ): Partial<T> {
274
+ // Extract synthesizerElement from spawnContext for backward compatibility
275
+ const synthesizerElement = spawnContext?.synthesizerElement;
276
+
268
277
  // Validate base attribute if present
269
278
  if ('base' in attrPatterns) {
270
279
  const baseValue = attrPatterns.base as string;
@@ -334,6 +343,14 @@ export function parseWithAttrs<T = any>(
334
343
  for (const [key, { attrName, config }] of resolvedAttrs) {
335
344
  const attrValue = getAttributeValue(element, attrName, allowUnprefixed);
336
345
 
346
+ // Create parser context
347
+ const parserContext: ParserContext<T> = {
348
+ attrConfig: config,
349
+ spawnContext,
350
+ element,
351
+ attrName
352
+ };
353
+
337
354
  // Handle missing attribute
338
355
  if (attrValue === null) {
339
356
  // Use valIfNull if defined
@@ -355,8 +372,8 @@ export function parseWithAttrs<T = any>(
355
372
  }
356
373
  // For Boolean without valIfNull, fall through to parser
357
374
  else {
358
- const parser = resolveParser(config.parser) || getDefaultParser(config.instanceOf);
359
- const parsedValue = parser(attrValue);
375
+ const parser = resolveParser(config.parser, synthesizerElement) || getDefaultParser(config.instanceOf);
376
+ const parsedValue = callParser(parser, attrValue, parserContext);
360
377
  const mapsTo = config.mapsTo ?? (key === 'base' ? '.' : key);
361
378
  result[mapsTo as string] = parsedValue;
362
379
  }
@@ -364,12 +381,12 @@ export function parseWithAttrs<T = any>(
364
381
  }
365
382
 
366
383
  // Attribute exists - parse normally
367
- const parser = resolveParser(config.parser) || getDefaultParser(config.instanceOf);
384
+ const parser = resolveParser(config.parser, synthesizerElement) || getDefaultParser(config.instanceOf);
368
385
 
369
386
  // Use cache if parseCache is specified
370
387
  const parsedValue = config.parseCache
371
- ? parseWithCache(attrValue, config, parser)
372
- : parser(attrValue);
388
+ ? parseWithCache(attrValue, config, parser, parserContext)
389
+ : callParser(parser, attrValue, parserContext);
373
390
 
374
391
  // Determine target property
375
392
  const mapsTo = config.mapsTo ?? (key === 'base' ? '.' : key);
package/parserRegistry.js CHANGED
@@ -55,7 +55,7 @@ export const globalParserRegistry = new ParserRegistry();
55
55
  // Register common built-in parsers
56
56
  globalParserRegistry.register('timestamp', (v) => v ? new Date(v).getTime() : null);
57
57
  globalParserRegistry.register('date', (v) => v ? new Date(v) : null);
58
- globalParserRegistry.register('csv', (v) => v ? v.split(',').map(s => s.trim()) : []);
58
+ globalParserRegistry.register('csv', (v) => v ? v.split(',').map((s) => s.trim()) : []);
59
59
  globalParserRegistry.register('int', (v) => v ? parseInt(v, 10) : null);
60
60
  globalParserRegistry.register('float', (v) => v ? parseFloat(v) : null);
61
61
  globalParserRegistry.register('boolean', (v) => v !== null);
@@ -69,3 +69,33 @@ globalParserRegistry.register('json', (v) => {
69
69
  throw new Error(`Failed to parse JSON: "${v}". Error: ${e}`);
70
70
  }
71
71
  });
72
+ import { ScopedParserRegistry } from './ScopedParserRegistry.js';
73
+ /**
74
+ * Symbol for storing scoped parser registry on synthesizer elements
75
+ * Using Symbol.for ensures the same symbol is used across different versions of the package
76
+ */
77
+ const SCOPED_REGISTRY_SYMBOL = Symbol.for('assign-gingerly.scopedParserRegistry');
78
+ /**
79
+ * Get the scoped parser registry for a synthesizer element
80
+ * Creates a new registry if one doesn't exist
81
+ * @param synthesizerElement - The synthesizer element (be-hive, htmx-container, alpine-scope, etc.)
82
+ * @returns The scoped parser registry for this element
83
+ */
84
+ export function getParserRegistry(synthesizerElement) {
85
+ let registry = synthesizerElement[SCOPED_REGISTRY_SYMBOL];
86
+ if (!registry) {
87
+ registry = new ScopedParserRegistry();
88
+ synthesizerElement[SCOPED_REGISTRY_SYMBOL] = registry;
89
+ }
90
+ return registry;
91
+ }
92
+ /**
93
+ * Register a parser in a synthesizer element's scoped registry
94
+ * @param synthesizerElement - The synthesizer element to register the parser with
95
+ * @param name - Parser name
96
+ * @param parser - Parser function
97
+ */
98
+ export function registerParser(synthesizerElement, name, parser) {
99
+ const registry = getParserRegistry(synthesizerElement);
100
+ registry.register(name, parser);
101
+ }
package/parserRegistry.ts CHANGED
@@ -1,16 +1,18 @@
1
+ import { ParserFunction } from './types/assign-gingerly/types';
2
+
1
3
  /**
2
4
  * Registry for named parsers that can be referenced by string name
3
5
  * Enables JSON serialization of configs with custom parsers
4
6
  */
5
7
  export class ParserRegistry {
6
- private parsers = new Map<string, (v: string | null) => any>();
8
+ private parsers = new Map<string, ParserFunction>();
7
9
 
8
10
  /**
9
11
  * Register a parser with a given name
10
12
  * @param name - The name to register the parser under
11
13
  * @param parser - The parser function
12
14
  */
13
- register(name: string, parser: (v: string | null) => any): void {
15
+ register(name: string, parser: ParserFunction): void {
14
16
  if (this.parsers.has(name)) {
15
17
  console.warn(`Parser "${name}" already registered, overwriting`);
16
18
  }
@@ -22,7 +24,7 @@ export class ParserRegistry {
22
24
  * @param name - The name of the parser
23
25
  * @returns The parser function or undefined if not found
24
26
  */
25
- get(name: string): ((v: string | null) => any) | undefined {
27
+ get(name: string): ParserFunction | undefined {
26
28
  return this.parsers.get(name);
27
29
  }
28
30
 
@@ -60,31 +62,31 @@ export class ParserRegistry {
60
62
  export const globalParserRegistry = new ParserRegistry();
61
63
 
62
64
  // Register common built-in parsers
63
- globalParserRegistry.register('timestamp', (v) =>
65
+ globalParserRegistry.register('timestamp', (v: string | null) =>
64
66
  v ? new Date(v).getTime() : null
65
67
  );
66
68
 
67
- globalParserRegistry.register('date', (v) =>
69
+ globalParserRegistry.register('date', (v: string | null) =>
68
70
  v ? new Date(v) : null
69
71
  );
70
72
 
71
- globalParserRegistry.register('csv', (v) =>
72
- v ? v.split(',').map(s => s.trim()) : []
73
+ globalParserRegistry.register('csv', (v: string | null) =>
74
+ v ? v.split(',').map((s: string) => s.trim()) : []
73
75
  );
74
76
 
75
- globalParserRegistry.register('int', (v) =>
77
+ globalParserRegistry.register('int', (v: string | null) =>
76
78
  v ? parseInt(v, 10) : null
77
79
  );
78
80
 
79
- globalParserRegistry.register('float', (v) =>
81
+ globalParserRegistry.register('float', (v: string | null) =>
80
82
  v ? parseFloat(v) : null
81
83
  );
82
84
 
83
- globalParserRegistry.register('boolean', (v) =>
85
+ globalParserRegistry.register('boolean', (v: string | null) =>
84
86
  v !== null
85
87
  );
86
88
 
87
- globalParserRegistry.register('json', (v) => {
89
+ globalParserRegistry.register('json', (v: string | null) => {
88
90
  if (v === null || v === '') return null;
89
91
  try {
90
92
  return JSON.parse(v);
@@ -92,3 +94,43 @@ globalParserRegistry.register('json', (v) => {
92
94
  throw new Error(`Failed to parse JSON: "${v}". Error: ${e}`);
93
95
  }
94
96
  });
97
+
98
+ import { ScopedParserRegistry } from './ScopedParserRegistry.js';
99
+
100
+ /**
101
+ * Symbol for storing scoped parser registry on synthesizer elements
102
+ * Using Symbol.for ensures the same symbol is used across different versions of the package
103
+ */
104
+ const SCOPED_REGISTRY_SYMBOL = Symbol.for('assign-gingerly.scopedParserRegistry');
105
+
106
+ /**
107
+ * Get the scoped parser registry for a synthesizer element
108
+ * Creates a new registry if one doesn't exist
109
+ * @param synthesizerElement - The synthesizer element (be-hive, htmx-container, alpine-scope, etc.)
110
+ * @returns The scoped parser registry for this element
111
+ */
112
+ export function getParserRegistry(synthesizerElement: Element): ScopedParserRegistry {
113
+ let registry = (synthesizerElement as any)[SCOPED_REGISTRY_SYMBOL];
114
+
115
+ if (!registry) {
116
+ registry = new ScopedParserRegistry();
117
+ (synthesizerElement as any)[SCOPED_REGISTRY_SYMBOL] = registry;
118
+ }
119
+
120
+ return registry;
121
+ }
122
+
123
+ /**
124
+ * Register a parser in a synthesizer element's scoped registry
125
+ * @param synthesizerElement - The synthesizer element to register the parser with
126
+ * @param name - Parser name
127
+ * @param parser - Parser function
128
+ */
129
+ export function registerParser(
130
+ synthesizerElement: Element,
131
+ name: string,
132
+ parser: ParserFunction
133
+ ): void {
134
+ const registry = getParserRegistry(synthesizerElement);
135
+ registry.register(name, parser);
136
+ }
@@ -74,6 +74,44 @@ export type pathString = `?.${string}`;
74
74
  export type CustomElementName = string;
75
75
  export type CustomElementConstructorStaticMethodName = string;
76
76
 
77
+ /**
78
+ * Context passed to parser functions
79
+ * Provides access to configuration and spawn context for advanced parsing scenarios
80
+ */
81
+ export interface ParserContext<T = any> {
82
+ /**
83
+ * The attribute configuration that matched this attribute
84
+ * Useful for parsers that need to access additional config properties
85
+ */
86
+ attrConfig: AttrConfig<T>;
87
+
88
+ /**
89
+ * The spawn context containing enhancement config and synthesizer element
90
+ * Useful for parsers that need access to the enhancement or synthesizer context
91
+ */
92
+ spawnContext?: SpawnContext<T>;
93
+
94
+ /**
95
+ * The element being enhanced
96
+ * Useful for parsers that need to read other attributes or element properties
97
+ */
98
+ element: Element;
99
+
100
+ /**
101
+ * The attribute name that was matched (resolved from template)
102
+ * Useful for parsers that handle multiple attributes
103
+ */
104
+ attrName: string;
105
+ }
106
+
107
+ /**
108
+ * Parser function signature
109
+ * Can accept just the attribute value (simple form) or value + context (advanced form)
110
+ */
111
+ export type ParserFunction<T = any> =
112
+ | ((attrValue: string | null) => any)
113
+ | ((attrValue: string | null, context?: ParserContext<T>) => any);
114
+
77
115
  export interface AttrConfig<T = any> {
78
116
  /**
79
117
  * Type of the property value (JSON-serializable string format)
@@ -100,13 +138,19 @@ export interface AttrConfig<T = any> {
100
138
  /**
101
139
  * Parser to transform attribute string value
102
140
  * - Function: Inline parser function (not JSON serializable)
103
- * - String: Named parser reference (JSON serializable) - looks up in global parser registry (e.g., 'timestamp', 'csv')
104
- * - Tuple: [CustomElementName, StaticMethodName] - looks up static method on custom element constructor (e.g., ['my-widget', 'parseSpecial'])
141
+ * - Simple form: (attrValue: string | null) => any
142
+ * - Advanced form: (attrValue: string | null, context: ParserContext) => any
143
+ * - String: Named parser reference (JSON serializable) - looks up in scoped registry (if available) then global parser registry (e.g., 'timestamp', 'csv')
144
+ *
145
+ * Parser functions can optionally accept a second parameter (ParserContext) which provides:
146
+ * - attrConfig: The full AttrConfig object for this attribute
147
+ * - spawnContext: The SpawnContext with enhancement config and synthesizer element
148
+ * - element: The element being enhanced
149
+ * - attrName: The resolved attribute name
105
150
  */
106
151
  parser?:
107
- | ((attrValue: string | null) => any)
108
- | string
109
- | [CustomElementName, CustomElementConstructorStaticMethodName]
152
+ | ParserFunction<T>
153
+ | string
110
154
  ;
111
155
 
112
156
  /**
@@ -156,6 +200,12 @@ export type AttrPatterns<T = any> = {
156
200
  export interface SpawnContext<T = any, TMountContext = any> {
157
201
  config: EnhancementConfig<T>;
158
202
  mountCtx?: TMountContext;
203
+ /**
204
+ * Reference to the synthesizer element (be-hive, htmx-container, alpine-scope, etc.)
205
+ * that contains the EMC script defining this enhancement.
206
+ * Used for scoped parser registry access during attribute parsing.
207
+ */
208
+ synthesizerElement?: Element;
159
209
  }
160
210
 
161
211
  /**