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/README.md +109 -0
- package/ScopedParserRegistry.js +94 -0
- package/ScopedParserRegistry.ts +111 -0
- package/assignGingerly.js +127 -18
- package/assignGingerly.ts +162 -19
- package/package.json +1 -1
- package/parseWithAttrs.js +61 -45
- package/parseWithAttrs.ts +82 -65
- package/parserRegistry.js +31 -1
- package/parserRegistry.ts +53 -11
- package/types/assign-gingerly/types.d.ts +56 -5
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
if (
|
|
533
|
-
//
|
|
534
|
-
const
|
|
535
|
-
if (typeof
|
|
536
|
-
|
|
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
|
-
//
|
|
539
|
-
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
-
|
|
650
|
+
} else {
|
|
651
|
+
parent[lastKey] = value;
|
|
546
652
|
}
|
|
547
653
|
} else {
|
|
548
|
-
|
|
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 -
|
|
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
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
|
|
11
|
-
* -
|
|
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
|
-
//
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
if (
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
57
|
-
`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|