assign-gingerly 0.0.25 → 0.0.27

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/assignGingerly.ts CHANGED
@@ -35,6 +35,31 @@ export interface ItemscopeManagerConfig<T = any> {
35
35
  export interface IAssignGingerlyOptions {
36
36
  registry?: typeof EnhancementRegistry | EnhancementRegistry;
37
37
  bypassChecks?: boolean;
38
+
39
+ /**
40
+ * List of property names that should be treated as methods to call
41
+ * rather than properties to assign.
42
+ *
43
+ * When a path segment matches a name in this array/set:
44
+ * - If the property is a function, call it with appropriate arguments
45
+ * - For the last segment: use RHS value as argument (spread if array)
46
+ * - For middle segments: use next segment as string argument (if next is not also a method)
47
+ * - If consecutive segments are both methods, first is called with no arguments
48
+ * - If the property is not a function, silently skip
49
+ *
50
+ * Example:
51
+ * assignGingerly(element, {
52
+ * '?.classList?.add': 'myClass'
53
+ * }, { withMethods: ['add'] });
54
+ * // Calls: element.classList.add('myClass')
55
+ *
56
+ * Chained methods:
57
+ * assignGingerly(elementRef, {
58
+ * '?.deref?.querySelector?.myElement?.classList?.add': 'active'
59
+ * }, { withMethods: ['deref', 'querySelector', 'add'] });
60
+ * // Calls: elementRef.deref().querySelector('myElement').classList.add('active')
61
+ */
62
+ withMethods?: string[] | Set<string>;
38
63
  }
39
64
 
40
65
  /**
@@ -340,6 +365,62 @@ function isClassInstance(value: any): boolean {
340
365
  return proto !== Object.prototype && proto !== null;
341
366
  }
342
367
 
368
+ /**
369
+ * Helper function to evaluate a nested path with method calls
370
+ * Handles chained method calls where path segments can be methods
371
+ */
372
+ function evaluatePathWithMethods(
373
+ target: any,
374
+ pathParts: string[],
375
+ value: any,
376
+ withMethods: Set<string>
377
+ ): { target: any; lastKey: string; isMethod: boolean } {
378
+ let current = target;
379
+ let i = 0;
380
+
381
+ // Process all segments except the last one
382
+ while (i < pathParts.length - 1) {
383
+ const part = pathParts[i];
384
+ const nextPart = pathParts[i + 1];
385
+
386
+ if (withMethods.has(part)) {
387
+ const method = current[part];
388
+ if (typeof method === 'function') {
389
+ // Check if next part is also a method
390
+ if (withMethods.has(nextPart)) {
391
+ // Both are methods - call first with no args
392
+ current = method.call(current);
393
+ } else {
394
+ // Only current is method - call with next part as string arg
395
+ current = method.call(current, nextPart);
396
+ i++; // Skip next part since we consumed it as argument
397
+ }
398
+ } else {
399
+ // Not a function - just access property (create if needed)
400
+ if (!(part in current) || typeof current[part] !== 'object' || current[part] === null) {
401
+ current[part] = {};
402
+ }
403
+ current = current[part];
404
+ }
405
+ } else {
406
+ // Not a method - normal property access (create if needed)
407
+ if (!(part in current) || typeof current[part] !== 'object' || current[part] === null) {
408
+ current[part] = {};
409
+ }
410
+ current = current[part];
411
+ }
412
+
413
+ i++;
414
+ }
415
+
416
+ const lastKey = pathParts[pathParts.length - 1];
417
+ return {
418
+ target: current,
419
+ lastKey,
420
+ isMethod: withMethods.has(lastKey)
421
+ };
422
+ }
423
+
343
424
  /**
344
425
  * Main assignGingerly function
345
426
  */
@@ -352,6 +433,13 @@ export function assignGingerly(
352
433
  return target;
353
434
  }
354
435
 
436
+ // Convert withMethods array to Set for O(1) lookup
437
+ const withMethodsSet = options?.withMethods
438
+ ? options.withMethods instanceof Set
439
+ ? options.withMethods
440
+ : new Set(options.withMethods)
441
+ : undefined;
442
+
355
443
  const registry = options?.registry instanceof EnhancementRegistry
356
444
  ? options.registry
357
445
  : options?.registry
@@ -524,30 +612,85 @@ export function assignGingerly(
524
612
 
525
613
  if (isNestedPath(key)) {
526
614
  const pathParts = parsePath(key);
527
- const lastKey = pathParts[pathParts.length - 1];
528
- const parent = ensureNestedPath(target, pathParts);
529
-
530
- if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
531
- // Check if property exists and is readonly OR is a class instance
532
- if (lastKey in parent && (isReadonlyProperty(parent, lastKey) || isClassInstance(parent[lastKey]))) {
533
- // Property is readonly or a class instance - check if current value is an object
534
- const currentValue = parent[lastKey];
535
- if (typeof currentValue !== 'object' || currentValue === null) {
536
- throw new Error(`Cannot merge object into ${isReadonlyProperty(parent, lastKey) ? 'readonly ' : ''}primitive property '${String(lastKey)}'`);
615
+
616
+ // Check if we need to handle methods
617
+ if (withMethodsSet) {
618
+ const result = evaluatePathWithMethods(target, pathParts, value, withMethodsSet);
619
+
620
+ if (result.isMethod) {
621
+ // Last segment is a method - call it
622
+ const method = result.target[result.lastKey];
623
+ if (typeof method === 'function') {
624
+ if (Array.isArray(value)) {
625
+ method.apply(result.target, value);
626
+ } else {
627
+ method.call(result.target, value);
628
+ }
537
629
  }
538
- // Recursively apply assignGingerly to the readonly object or class instance
539
- assignGingerly(currentValue, value, options);
540
- } else {
541
- // Property is writable and not a class instance - normal recursive merge
542
- if (!(lastKey in parent) || typeof parent[lastKey] !== 'object') {
543
- parent[lastKey] = {};
630
+ // Silently skip if not a function
631
+ continue;
632
+ }
633
+
634
+ // Not a method - proceed with normal assignment using evaluated target
635
+ const lastKey = result.lastKey;
636
+ const parent = result.target;
637
+
638
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
639
+ // Check if property exists and is readonly OR is a class instance
640
+ if (lastKey in parent && (isReadonlyProperty(parent, lastKey) || isClassInstance(parent[lastKey]))) {
641
+ const currentValue = parent[lastKey];
642
+ if (typeof currentValue !== 'object' || currentValue === null) {
643
+ throw new Error(`Cannot merge object into ${isReadonlyProperty(parent, lastKey) ? 'readonly ' : ''}primitive property '${String(lastKey)}'`);
644
+ }
645
+ assignGingerly(currentValue, value, options);
646
+ } else {
647
+ // Property is writable and not a class instance - replace it
648
+ parent[lastKey] = value;
544
649
  }
545
- assignGingerly(parent[lastKey], value, options);
650
+ } else {
651
+ parent[lastKey] = value;
546
652
  }
547
653
  } else {
548
- parent[lastKey] = value;
654
+ // No withMethods - use original logic
655
+ const lastKey = pathParts[pathParts.length - 1];
656
+ const parent = ensureNestedPath(target, pathParts);
657
+
658
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
659
+ // Check if property exists and is readonly OR is a class instance
660
+ if (lastKey in parent && (isReadonlyProperty(parent, lastKey) || isClassInstance(parent[lastKey]))) {
661
+ // Property is readonly or a class instance - check if current value is an object
662
+ const currentValue = parent[lastKey];
663
+ if (typeof currentValue !== 'object' || currentValue === null) {
664
+ throw new Error(`Cannot merge object into ${isReadonlyProperty(parent, lastKey) ? 'readonly ' : ''}primitive property '${String(lastKey)}'`);
665
+ }
666
+ // Recursively apply assignGingerly to the readonly object or class instance
667
+ assignGingerly(currentValue, value, options);
668
+ } else {
669
+ // Property is writable and not a class instance - replace it
670
+ parent[lastKey] = value;
671
+ }
672
+ } else {
673
+ parent[lastKey] = value;
674
+ }
549
675
  }
550
676
  } else {
677
+ // Non-nested path
678
+
679
+ // Check if this is a method call
680
+ if (withMethodsSet && withMethodsSet.has(key)) {
681
+ const method = target[key];
682
+ if (typeof method === 'function') {
683
+ if (Array.isArray(value)) {
684
+ method.apply(target, value);
685
+ } else {
686
+ method.call(target, value);
687
+ }
688
+ }
689
+ // Silently skip if not a function
690
+ continue;
691
+ }
692
+
693
+ // Normal assignment
551
694
  if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
552
695
  // Check if property exists and is readonly OR is a class instance
553
696
  if (key in target && (isReadonlyProperty(target, key) || isClassInstance(target[key]))) {
@@ -559,7 +702,7 @@ export function assignGingerly(
559
702
  // Recursively apply assignGingerly to the readonly object or class instance
560
703
  assignGingerly(currentValue, value, options);
561
704
  } else {
562
- // Property is writable and not a class instance - simple assignment
705
+ // Property is writable and not a class instance - replace it
563
706
  target[key] = value;
564
707
  }
565
708
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "assign-gingerly",
3
- "version": "0.0.25",
3
+ "version": "0.0.27",
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