assign-gingerly 0.0.31 → 0.0.32
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 +371 -17
- package/assignFrom.js +27 -0
- package/assignFrom.ts +37 -0
- package/assignGingerly.js +221 -5
- package/assignGingerly.ts +287 -5
- package/eachTime.js +110 -0
- package/eachTime.ts +137 -0
- package/index.js +2 -0
- package/index.ts +2 -0
- package/object-extension.js +65 -12
- package/object-extension.ts +74 -15
- package/package.json +9 -1
- package/resolveValues.js +44 -0
- package/resolveValues.ts +45 -0
- package/types/assign-gingerly/types.d.ts +11 -2
package/assignGingerly.js
CHANGED
|
@@ -232,8 +232,10 @@ function ensureNestedPath(obj, pathParts) {
|
|
|
232
232
|
* A property is readonly if:
|
|
233
233
|
* - It's a data property with writable: false, OR
|
|
234
234
|
* - It's an accessor property with a getter but no setter
|
|
235
|
+
*
|
|
236
|
+
* Exported for use by eachTime.ts
|
|
235
237
|
*/
|
|
236
|
-
function isReadonlyProperty(obj, propName) {
|
|
238
|
+
export function isReadonlyProperty(obj, propName) {
|
|
237
239
|
let descriptor = Object.getOwnPropertyDescriptor(obj, propName);
|
|
238
240
|
if (!descriptor) {
|
|
239
241
|
// Check prototype chain
|
|
@@ -260,8 +262,10 @@ function isReadonlyProperty(obj, propName) {
|
|
|
260
262
|
/**
|
|
261
263
|
* Helper function to check if a value is a class instance (not a plain object)
|
|
262
264
|
* Returns true for instances of classes, false for plain objects, arrays, and primitives
|
|
265
|
+
*
|
|
266
|
+
* Exported for use by eachTime.ts
|
|
263
267
|
*/
|
|
264
|
-
function isClassInstance(value) {
|
|
268
|
+
export function isClassInstance(value) {
|
|
265
269
|
if (!value || typeof value !== 'object')
|
|
266
270
|
return false;
|
|
267
271
|
if (Array.isArray(value))
|
|
@@ -273,8 +277,10 @@ function isClassInstance(value) {
|
|
|
273
277
|
/**
|
|
274
278
|
* Helper function to evaluate a nested path with method calls
|
|
275
279
|
* Handles chained method calls where path segments can be methods
|
|
280
|
+
*
|
|
281
|
+
* Exported for use by eachTime.ts
|
|
276
282
|
*/
|
|
277
|
-
function evaluatePathWithMethods(target, pathParts, value, withMethods) {
|
|
283
|
+
export function evaluatePathWithMethods(target, pathParts, value, withMethods) {
|
|
278
284
|
let current = target;
|
|
279
285
|
let i = 0;
|
|
280
286
|
// Process all segments except the last one
|
|
@@ -319,6 +325,159 @@ function evaluatePathWithMethods(target, pathParts, value, withMethods) {
|
|
|
319
325
|
isMethod: withMethods.has(lastKey)
|
|
320
326
|
};
|
|
321
327
|
}
|
|
328
|
+
/**
|
|
329
|
+
* Check if a value is iterable (can be used with for...of or has forEach)
|
|
330
|
+
*/
|
|
331
|
+
function isIterable(value) {
|
|
332
|
+
if (value == null)
|
|
333
|
+
return false;
|
|
334
|
+
// Check for Symbol.iterator
|
|
335
|
+
if (typeof value[Symbol.iterator] === 'function')
|
|
336
|
+
return true;
|
|
337
|
+
// Check if it's an Array
|
|
338
|
+
if (Array.isArray(value))
|
|
339
|
+
return true;
|
|
340
|
+
// Check if it's array-like (has length and numeric indices)
|
|
341
|
+
// This covers NodeList, HTMLCollection, etc.
|
|
342
|
+
if (typeof value.length === 'number' && value.length >= 0) {
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Check if a segment is the forEach symbol (@each) or aliased to it
|
|
349
|
+
*/
|
|
350
|
+
function isForEachSymbol(segment, aliasMap) {
|
|
351
|
+
// Direct match
|
|
352
|
+
if (segment === '@each')
|
|
353
|
+
return true;
|
|
354
|
+
// Check if this segment is aliased to '@each'
|
|
355
|
+
const aliasTarget = aliasMap.get(segment);
|
|
356
|
+
return aliasTarget === '@each';
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Check if a segment is the reactive forEach symbol (@eachTime) or aliased to it
|
|
360
|
+
*/
|
|
361
|
+
function isReactiveForEachSymbol(segment, aliasMap) {
|
|
362
|
+
// Direct match
|
|
363
|
+
if (segment === '@eachTime')
|
|
364
|
+
return true;
|
|
365
|
+
// Check if this segment is aliased to '@eachTime'
|
|
366
|
+
const aliasTarget = aliasMap.get(segment);
|
|
367
|
+
return aliasTarget === '@eachTime';
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Apply a path to each item in an iterable
|
|
371
|
+
*/
|
|
372
|
+
function applyToEach(iterable, remainingPath, value, withMethods, aliasMap, options) {
|
|
373
|
+
// Convert to array for iteration
|
|
374
|
+
const items = Array.isArray(iterable) ? iterable : Array.from(iterable);
|
|
375
|
+
// Apply the remaining path to each item
|
|
376
|
+
for (const item of items) {
|
|
377
|
+
if (remainingPath.length === 0) {
|
|
378
|
+
// No remaining path, can't do anything
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
// Check if there's another @each in the remaining path
|
|
382
|
+
const forEachIndex = remainingPath.findIndex(part => isForEachSymbol(part, aliasMap));
|
|
383
|
+
if (forEachIndex !== -1) {
|
|
384
|
+
// There's a nested @each
|
|
385
|
+
// Evaluate path up to the @each
|
|
386
|
+
const pathToForEach = remainingPath.slice(0, forEachIndex);
|
|
387
|
+
const pathAfterForEach = remainingPath.slice(forEachIndex + 1);
|
|
388
|
+
// Navigate to the nested iterable
|
|
389
|
+
let current = item;
|
|
390
|
+
for (const part of pathToForEach) {
|
|
391
|
+
if (withMethods.has(part)) {
|
|
392
|
+
const method = current[part];
|
|
393
|
+
if (typeof method === 'function') {
|
|
394
|
+
// For methods in the middle, we need to check the next part
|
|
395
|
+
const nextIndex = pathToForEach.indexOf(part) + 1;
|
|
396
|
+
const nextPart = pathToForEach[nextIndex];
|
|
397
|
+
if (nextPart && withMethods.has(nextPart)) {
|
|
398
|
+
current = method.call(current);
|
|
399
|
+
}
|
|
400
|
+
else if (nextPart) {
|
|
401
|
+
current = method.call(current, nextPart);
|
|
402
|
+
// Skip next part
|
|
403
|
+
pathToForEach.splice(nextIndex, 1);
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
current = method.call(current);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
current = current[part];
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
current = current[part];
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
// Recursively apply to the nested iterable
|
|
418
|
+
if (isIterable(current)) {
|
|
419
|
+
applyToEach(current, pathAfterForEach, value, withMethods, aliasMap, options);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
// No nested @each, evaluate the remaining path normally
|
|
424
|
+
const result = evaluatePathWithMethods(item, remainingPath, value, withMethods);
|
|
425
|
+
if (result.isMethod) {
|
|
426
|
+
// Last segment is a method - call it
|
|
427
|
+
const method = result.target[result.lastKey];
|
|
428
|
+
if (typeof method === 'function') {
|
|
429
|
+
if (Array.isArray(value)) {
|
|
430
|
+
method.apply(result.target, value);
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
method.call(result.target, value);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
// Normal assignment
|
|
439
|
+
const lastKey = result.lastKey;
|
|
440
|
+
const parent = result.target;
|
|
441
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
442
|
+
if (lastKey in parent && (isReadonlyProperty(parent, lastKey) || isClassInstance(parent[lastKey]))) {
|
|
443
|
+
const currentValue = parent[lastKey];
|
|
444
|
+
if (typeof currentValue !== 'object' || currentValue === null) {
|
|
445
|
+
throw new Error(`Cannot merge object into ${isReadonlyProperty(parent, lastKey) ? 'readonly ' : ''}primitive property '${String(lastKey)}'`);
|
|
446
|
+
}
|
|
447
|
+
assignGingerly(currentValue, value, options);
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
parent[lastKey] = value;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
else {
|
|
454
|
+
parent[lastKey] = value;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Apply alias substitutions to a key string.
|
|
462
|
+
* Replaces complete tokens between `?.` delimiters with their aliased values.
|
|
463
|
+
*
|
|
464
|
+
* @param key - The key string (e.g., '?.$?.my-element?.c?.+')
|
|
465
|
+
* @param aliasMap - Map of alias -> target name
|
|
466
|
+
* @returns The key with aliases substituted (e.g., '?.querySelector?.my-element?.classList?.add')
|
|
467
|
+
*/
|
|
468
|
+
function applyAliases(key, aliasMap) {
|
|
469
|
+
if (aliasMap.size === 0)
|
|
470
|
+
return key;
|
|
471
|
+
// Split by ?. to get tokens
|
|
472
|
+
const parts = key.split('?.');
|
|
473
|
+
// Apply aliases to each part
|
|
474
|
+
const substituted = parts.map(part => {
|
|
475
|
+
// Check if this exact part is an alias
|
|
476
|
+
return aliasMap.get(part) ?? part;
|
|
477
|
+
});
|
|
478
|
+
// Rejoin with ?.
|
|
479
|
+
return substituted.join('?.');
|
|
480
|
+
}
|
|
322
481
|
/**
|
|
323
482
|
* Main assignGingerly function
|
|
324
483
|
*/
|
|
@@ -332,12 +491,23 @@ export function assignGingerly(target, source, options) {
|
|
|
332
491
|
? options.withMethods
|
|
333
492
|
: new Set(options.withMethods)
|
|
334
493
|
: undefined;
|
|
494
|
+
// Convert aka object to Map for O(1) lookup and validate aliases
|
|
495
|
+
const aliasMap = new Map();
|
|
496
|
+
if (options?.aka) {
|
|
497
|
+
for (const [alias, target] of Object.entries(options.aka)) {
|
|
498
|
+
// Validate: disallow space and backtick in aliases
|
|
499
|
+
if (alias.includes(' ') || alias.includes('`')) {
|
|
500
|
+
throw new Error(`Invalid alias '${alias}': aliases cannot contain space or backtick characters`);
|
|
501
|
+
}
|
|
502
|
+
aliasMap.set(alias, target);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
335
505
|
const registry = options?.registry instanceof EnhancementRegistry
|
|
336
506
|
? options.registry
|
|
337
507
|
: options?.registry
|
|
338
508
|
? new options.registry()
|
|
339
509
|
: undefined;
|
|
340
|
-
// Convert Symbol.for string keys to actual symbols
|
|
510
|
+
// Convert Symbol.for string keys to actual symbols and apply aliases
|
|
341
511
|
const processedSource = {};
|
|
342
512
|
for (const key of Object.keys(source)) {
|
|
343
513
|
if (isSymbolForKey(key)) {
|
|
@@ -351,7 +521,9 @@ export function assignGingerly(target, source, options) {
|
|
|
351
521
|
}
|
|
352
522
|
}
|
|
353
523
|
else {
|
|
354
|
-
|
|
524
|
+
// Apply aliases to string keys
|
|
525
|
+
const substitutedKey = applyAliases(key, aliasMap);
|
|
526
|
+
processedSource[substitutedKey] = source[key];
|
|
355
527
|
}
|
|
356
528
|
}
|
|
357
529
|
// Copy over actual symbol keys
|
|
@@ -492,6 +664,50 @@ export function assignGingerly(target, source, options) {
|
|
|
492
664
|
}
|
|
493
665
|
if (isNestedPath(key)) {
|
|
494
666
|
const pathParts = parsePath(key);
|
|
667
|
+
// Check if path contains @each or @eachTime (forEach)
|
|
668
|
+
const forEachIndex = pathParts.findIndex(part => isForEachSymbol(part, aliasMap) || isReactiveForEachSymbol(part, aliasMap));
|
|
669
|
+
if (forEachIndex !== -1) {
|
|
670
|
+
// Check if it's reactive (@eachTime)
|
|
671
|
+
const isReactive = isReactiveForEachSymbol(pathParts[forEachIndex], aliasMap);
|
|
672
|
+
if (isReactive) {
|
|
673
|
+
// Reactive forEach - dynamic load and fire-and-forget
|
|
674
|
+
(async () => {
|
|
675
|
+
try {
|
|
676
|
+
const { handleEachTime } = await import('./eachTime.js');
|
|
677
|
+
await handleEachTime(target, pathParts, forEachIndex, value, withMethodsSet, aliasMap, options);
|
|
678
|
+
}
|
|
679
|
+
catch (error) {
|
|
680
|
+
console.error('Error in @eachTime:', error);
|
|
681
|
+
}
|
|
682
|
+
})();
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
// Static forEach (@each) - existing logic
|
|
686
|
+
const pathToForEach = pathParts.slice(0, forEachIndex);
|
|
687
|
+
const pathAfterForEach = pathParts.slice(forEachIndex + 1);
|
|
688
|
+
// Navigate to the iterable
|
|
689
|
+
let current = target;
|
|
690
|
+
if (pathToForEach.length > 0) {
|
|
691
|
+
if (withMethodsSet) {
|
|
692
|
+
const result = evaluatePathWithMethods(target, pathToForEach, value, withMethodsSet);
|
|
693
|
+
// The result.target is the current position after evaluating the path
|
|
694
|
+
// This is already the iterable we want
|
|
695
|
+
current = result.target;
|
|
696
|
+
}
|
|
697
|
+
else {
|
|
698
|
+
for (const part of pathToForEach) {
|
|
699
|
+
current = current[part];
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
// Apply to each item in the iterable
|
|
704
|
+
if (isIterable(current)) {
|
|
705
|
+
applyToEach(current, pathAfterForEach, value, withMethodsSet || new Set(), aliasMap, options);
|
|
706
|
+
}
|
|
707
|
+
// If not iterable, let JavaScript throw error naturally when trying to iterate
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
// No @each in path - handle normally
|
|
495
711
|
// Check if we need to handle methods
|
|
496
712
|
if (withMethodsSet) {
|
|
497
713
|
const result = evaluatePathWithMethods(target, pathParts, value, withMethodsSet);
|
package/assignGingerly.ts
CHANGED
|
@@ -60,6 +60,43 @@ export interface IAssignGingerlyOptions {
|
|
|
60
60
|
* // Calls: elementRef.deref().querySelector('myElement').classList.add('active')
|
|
61
61
|
*/
|
|
62
62
|
withMethods?: string[] | Set<string>;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Alias mappings for property and method names.
|
|
66
|
+
* Allows shorter, customizable shortcuts in path expressions.
|
|
67
|
+
*
|
|
68
|
+
* Aliases are substituted before path evaluation, matching complete tokens
|
|
69
|
+
* between `?.` delimiters (not substrings).
|
|
70
|
+
*
|
|
71
|
+
* Reserved characters (cannot be used in aliases): space, backtick (`)
|
|
72
|
+
*
|
|
73
|
+
* Example:
|
|
74
|
+
* assignGingerly(element, {
|
|
75
|
+
* '?.$?.my-element?.c?.+': 'highlighted'
|
|
76
|
+
* }, {
|
|
77
|
+
* withMethods: ['querySelector', 'add'],
|
|
78
|
+
* aka: { '$': 'querySelector', 'c': 'classList', '+': 'add' }
|
|
79
|
+
* });
|
|
80
|
+
* // Equivalent to: element.querySelector('my-element').classList.add('highlighted')
|
|
81
|
+
*/
|
|
82
|
+
aka?: Record<string, string>;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* AbortSignal for cleaning up reactive subscriptions (@eachTime)
|
|
86
|
+
* Required when using @eachTime symbol for reactive iteration
|
|
87
|
+
* When the signal is aborted, all event listeners are automatically removed
|
|
88
|
+
*
|
|
89
|
+
* Example:
|
|
90
|
+
* const controller = new AbortController();
|
|
91
|
+
* assignGingerly(div, {
|
|
92
|
+
* '?.mountObserver?.@eachTime?.classList?.add': 'highlighted'
|
|
93
|
+
* }, {
|
|
94
|
+
* withMethods: ['add'],
|
|
95
|
+
* signal: controller.signal
|
|
96
|
+
* });
|
|
97
|
+
* // Later: controller.abort(); // Cleanup all listeners
|
|
98
|
+
*/
|
|
99
|
+
signal?: AbortSignal;
|
|
63
100
|
}
|
|
64
101
|
|
|
65
102
|
/**
|
|
@@ -323,8 +360,10 @@ function ensureNestedPath(obj: any, pathParts: string[]): any {
|
|
|
323
360
|
* A property is readonly if:
|
|
324
361
|
* - It's a data property with writable: false, OR
|
|
325
362
|
* - It's an accessor property with a getter but no setter
|
|
363
|
+
*
|
|
364
|
+
* Exported for use by eachTime.ts
|
|
326
365
|
*/
|
|
327
|
-
function isReadonlyProperty(obj: any, propName: string | symbol): boolean {
|
|
366
|
+
export function isReadonlyProperty(obj: any, propName: string | symbol): boolean {
|
|
328
367
|
let descriptor = Object.getOwnPropertyDescriptor(obj, propName);
|
|
329
368
|
|
|
330
369
|
if (!descriptor) {
|
|
@@ -355,8 +394,10 @@ function isReadonlyProperty(obj: any, propName: string | symbol): boolean {
|
|
|
355
394
|
/**
|
|
356
395
|
* Helper function to check if a value is a class instance (not a plain object)
|
|
357
396
|
* Returns true for instances of classes, false for plain objects, arrays, and primitives
|
|
397
|
+
*
|
|
398
|
+
* Exported for use by eachTime.ts
|
|
358
399
|
*/
|
|
359
|
-
function isClassInstance(value: any): boolean {
|
|
400
|
+
export function isClassInstance(value: any): boolean {
|
|
360
401
|
if (!value || typeof value !== 'object') return false;
|
|
361
402
|
if (Array.isArray(value)) return false;
|
|
362
403
|
|
|
@@ -368,8 +409,10 @@ function isClassInstance(value: any): boolean {
|
|
|
368
409
|
/**
|
|
369
410
|
* Helper function to evaluate a nested path with method calls
|
|
370
411
|
* Handles chained method calls where path segments can be methods
|
|
412
|
+
*
|
|
413
|
+
* Exported for use by eachTime.ts
|
|
371
414
|
*/
|
|
372
|
-
function evaluatePathWithMethods(
|
|
415
|
+
export function evaluatePathWithMethods(
|
|
373
416
|
target: any,
|
|
374
417
|
pathParts: string[],
|
|
375
418
|
value: any,
|
|
@@ -421,6 +464,172 @@ function evaluatePathWithMethods(
|
|
|
421
464
|
};
|
|
422
465
|
}
|
|
423
466
|
|
|
467
|
+
/**
|
|
468
|
+
* Check if a value is iterable (can be used with for...of or has forEach)
|
|
469
|
+
*/
|
|
470
|
+
function isIterable(value: any): boolean {
|
|
471
|
+
if (value == null) return false;
|
|
472
|
+
|
|
473
|
+
// Check for Symbol.iterator
|
|
474
|
+
if (typeof value[Symbol.iterator] === 'function') return true;
|
|
475
|
+
|
|
476
|
+
// Check if it's an Array
|
|
477
|
+
if (Array.isArray(value)) return true;
|
|
478
|
+
|
|
479
|
+
// Check if it's array-like (has length and numeric indices)
|
|
480
|
+
// This covers NodeList, HTMLCollection, etc.
|
|
481
|
+
if (typeof value.length === 'number' && value.length >= 0) {
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Check if a segment is the forEach symbol (@each) or aliased to it
|
|
490
|
+
*/
|
|
491
|
+
function isForEachSymbol(segment: string, aliasMap: Map<string, string>): boolean {
|
|
492
|
+
// Direct match
|
|
493
|
+
if (segment === '@each') return true;
|
|
494
|
+
|
|
495
|
+
// Check if this segment is aliased to '@each'
|
|
496
|
+
const aliasTarget = aliasMap.get(segment);
|
|
497
|
+
return aliasTarget === '@each';
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Check if a segment is the reactive forEach symbol (@eachTime) or aliased to it
|
|
502
|
+
*/
|
|
503
|
+
function isReactiveForEachSymbol(segment: string, aliasMap: Map<string, string>): boolean {
|
|
504
|
+
// Direct match
|
|
505
|
+
if (segment === '@eachTime') return true;
|
|
506
|
+
|
|
507
|
+
// Check if this segment is aliased to '@eachTime'
|
|
508
|
+
const aliasTarget = aliasMap.get(segment);
|
|
509
|
+
return aliasTarget === '@eachTime';
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Apply a path to each item in an iterable
|
|
514
|
+
*/
|
|
515
|
+
function applyToEach(
|
|
516
|
+
iterable: any,
|
|
517
|
+
remainingPath: string[],
|
|
518
|
+
value: any,
|
|
519
|
+
withMethods: Set<string>,
|
|
520
|
+
aliasMap: Map<string, string>,
|
|
521
|
+
options?: IAssignGingerlyOptions
|
|
522
|
+
): void {
|
|
523
|
+
// Convert to array for iteration
|
|
524
|
+
const items = Array.isArray(iterable) ? iterable : Array.from(iterable);
|
|
525
|
+
|
|
526
|
+
// Apply the remaining path to each item
|
|
527
|
+
for (const item of items) {
|
|
528
|
+
if (remainingPath.length === 0) {
|
|
529
|
+
// No remaining path, can't do anything
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Check if there's another @each in the remaining path
|
|
534
|
+
const forEachIndex = remainingPath.findIndex(part => isForEachSymbol(part, aliasMap));
|
|
535
|
+
|
|
536
|
+
if (forEachIndex !== -1) {
|
|
537
|
+
// There's a nested @each
|
|
538
|
+
// Evaluate path up to the @each
|
|
539
|
+
const pathToForEach = remainingPath.slice(0, forEachIndex);
|
|
540
|
+
const pathAfterForEach = remainingPath.slice(forEachIndex + 1);
|
|
541
|
+
|
|
542
|
+
// Navigate to the nested iterable
|
|
543
|
+
let current = item;
|
|
544
|
+
for (const part of pathToForEach) {
|
|
545
|
+
if (withMethods.has(part)) {
|
|
546
|
+
const method = current[part];
|
|
547
|
+
if (typeof method === 'function') {
|
|
548
|
+
// For methods in the middle, we need to check the next part
|
|
549
|
+
const nextIndex = pathToForEach.indexOf(part) + 1;
|
|
550
|
+
const nextPart = pathToForEach[nextIndex];
|
|
551
|
+
if (nextPart && withMethods.has(nextPart)) {
|
|
552
|
+
current = method.call(current);
|
|
553
|
+
} else if (nextPart) {
|
|
554
|
+
current = method.call(current, nextPart);
|
|
555
|
+
// Skip next part
|
|
556
|
+
pathToForEach.splice(nextIndex, 1);
|
|
557
|
+
} else {
|
|
558
|
+
current = method.call(current);
|
|
559
|
+
}
|
|
560
|
+
} else {
|
|
561
|
+
current = current[part];
|
|
562
|
+
}
|
|
563
|
+
} else {
|
|
564
|
+
current = current[part];
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Recursively apply to the nested iterable
|
|
569
|
+
if (isIterable(current)) {
|
|
570
|
+
applyToEach(current, pathAfterForEach, value, withMethods, aliasMap, options);
|
|
571
|
+
}
|
|
572
|
+
} else {
|
|
573
|
+
// No nested @each, evaluate the remaining path normally
|
|
574
|
+
const result = evaluatePathWithMethods(item, remainingPath, value, withMethods);
|
|
575
|
+
|
|
576
|
+
if (result.isMethod) {
|
|
577
|
+
// Last segment is a method - call it
|
|
578
|
+
const method = result.target[result.lastKey];
|
|
579
|
+
if (typeof method === 'function') {
|
|
580
|
+
if (Array.isArray(value)) {
|
|
581
|
+
method.apply(result.target, value);
|
|
582
|
+
} else {
|
|
583
|
+
method.call(result.target, value);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
} else {
|
|
587
|
+
// Normal assignment
|
|
588
|
+
const lastKey = result.lastKey;
|
|
589
|
+
const parent = result.target;
|
|
590
|
+
|
|
591
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
592
|
+
if (lastKey in parent && (isReadonlyProperty(parent, lastKey) || isClassInstance(parent[lastKey]))) {
|
|
593
|
+
const currentValue = parent[lastKey];
|
|
594
|
+
if (typeof currentValue !== 'object' || currentValue === null) {
|
|
595
|
+
throw new Error(`Cannot merge object into ${isReadonlyProperty(parent, lastKey) ? 'readonly ' : ''}primitive property '${String(lastKey)}'`);
|
|
596
|
+
}
|
|
597
|
+
assignGingerly(currentValue, value, options);
|
|
598
|
+
} else {
|
|
599
|
+
parent[lastKey] = value;
|
|
600
|
+
}
|
|
601
|
+
} else {
|
|
602
|
+
parent[lastKey] = value;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Apply alias substitutions to a key string.
|
|
611
|
+
* Replaces complete tokens between `?.` delimiters with their aliased values.
|
|
612
|
+
*
|
|
613
|
+
* @param key - The key string (e.g., '?.$?.my-element?.c?.+')
|
|
614
|
+
* @param aliasMap - Map of alias -> target name
|
|
615
|
+
* @returns The key with aliases substituted (e.g., '?.querySelector?.my-element?.classList?.add')
|
|
616
|
+
*/
|
|
617
|
+
function applyAliases(key: string, aliasMap: Map<string, string>): string {
|
|
618
|
+
if (aliasMap.size === 0) return key;
|
|
619
|
+
|
|
620
|
+
// Split by ?. to get tokens
|
|
621
|
+
const parts = key.split('?.');
|
|
622
|
+
|
|
623
|
+
// Apply aliases to each part
|
|
624
|
+
const substituted = parts.map(part => {
|
|
625
|
+
// Check if this exact part is an alias
|
|
626
|
+
return aliasMap.get(part) ?? part;
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
// Rejoin with ?.
|
|
630
|
+
return substituted.join('?.');
|
|
631
|
+
}
|
|
632
|
+
|
|
424
633
|
/**
|
|
425
634
|
* Main assignGingerly function
|
|
426
635
|
*/
|
|
@@ -440,13 +649,25 @@ export function assignGingerly(
|
|
|
440
649
|
: new Set(options.withMethods)
|
|
441
650
|
: undefined;
|
|
442
651
|
|
|
652
|
+
// Convert aka object to Map for O(1) lookup and validate aliases
|
|
653
|
+
const aliasMap = new Map<string, string>();
|
|
654
|
+
if (options?.aka) {
|
|
655
|
+
for (const [alias, target] of Object.entries(options.aka)) {
|
|
656
|
+
// Validate: disallow space and backtick in aliases
|
|
657
|
+
if (alias.includes(' ') || alias.includes('`')) {
|
|
658
|
+
throw new Error(`Invalid alias '${alias}': aliases cannot contain space or backtick characters`);
|
|
659
|
+
}
|
|
660
|
+
aliasMap.set(alias, target);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
443
664
|
const registry = options?.registry instanceof EnhancementRegistry
|
|
444
665
|
? options.registry
|
|
445
666
|
: options?.registry
|
|
446
667
|
? new options.registry()
|
|
447
668
|
: undefined;
|
|
448
669
|
|
|
449
|
-
// Convert Symbol.for string keys to actual symbols
|
|
670
|
+
// Convert Symbol.for string keys to actual symbols and apply aliases
|
|
450
671
|
const processedSource: Record<string | symbol, any> = {};
|
|
451
672
|
for (const key of Object.keys(source)) {
|
|
452
673
|
if (isSymbolForKey(key)) {
|
|
@@ -458,7 +679,9 @@ export function assignGingerly(
|
|
|
458
679
|
processedSource[key] = source[key];
|
|
459
680
|
}
|
|
460
681
|
} else {
|
|
461
|
-
|
|
682
|
+
// Apply aliases to string keys
|
|
683
|
+
const substitutedKey = applyAliases(key, aliasMap);
|
|
684
|
+
processedSource[substitutedKey] = source[key];
|
|
462
685
|
}
|
|
463
686
|
}
|
|
464
687
|
// Copy over actual symbol keys
|
|
@@ -613,6 +836,65 @@ export function assignGingerly(
|
|
|
613
836
|
if (isNestedPath(key)) {
|
|
614
837
|
const pathParts = parsePath(key);
|
|
615
838
|
|
|
839
|
+
// Check if path contains @each or @eachTime (forEach)
|
|
840
|
+
const forEachIndex = pathParts.findIndex(part =>
|
|
841
|
+
isForEachSymbol(part, aliasMap) || isReactiveForEachSymbol(part, aliasMap)
|
|
842
|
+
);
|
|
843
|
+
|
|
844
|
+
if (forEachIndex !== -1) {
|
|
845
|
+
// Check if it's reactive (@eachTime)
|
|
846
|
+
const isReactive = isReactiveForEachSymbol(pathParts[forEachIndex], aliasMap);
|
|
847
|
+
|
|
848
|
+
if (isReactive) {
|
|
849
|
+
// Reactive forEach - dynamic load and fire-and-forget
|
|
850
|
+
(async () => {
|
|
851
|
+
try {
|
|
852
|
+
const { handleEachTime } = await import('./eachTime.js');
|
|
853
|
+
await handleEachTime(
|
|
854
|
+
target,
|
|
855
|
+
pathParts,
|
|
856
|
+
forEachIndex,
|
|
857
|
+
value,
|
|
858
|
+
withMethodsSet,
|
|
859
|
+
aliasMap,
|
|
860
|
+
options
|
|
861
|
+
);
|
|
862
|
+
} catch (error) {
|
|
863
|
+
console.error('Error in @eachTime:', error);
|
|
864
|
+
}
|
|
865
|
+
})();
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// Static forEach (@each) - existing logic
|
|
870
|
+
const pathToForEach = pathParts.slice(0, forEachIndex);
|
|
871
|
+
const pathAfterForEach = pathParts.slice(forEachIndex + 1);
|
|
872
|
+
|
|
873
|
+
// Navigate to the iterable
|
|
874
|
+
let current = target;
|
|
875
|
+
if (pathToForEach.length > 0) {
|
|
876
|
+
if (withMethodsSet) {
|
|
877
|
+
const result = evaluatePathWithMethods(target, pathToForEach, value, withMethodsSet);
|
|
878
|
+
// The result.target is the current position after evaluating the path
|
|
879
|
+
// This is already the iterable we want
|
|
880
|
+
current = result.target;
|
|
881
|
+
} else {
|
|
882
|
+
for (const part of pathToForEach) {
|
|
883
|
+
current = current[part];
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Apply to each item in the iterable
|
|
889
|
+
if (isIterable(current)) {
|
|
890
|
+
applyToEach(current, pathAfterForEach, value, withMethodsSet || new Set(), aliasMap, options);
|
|
891
|
+
}
|
|
892
|
+
// If not iterable, let JavaScript throw error naturally when trying to iterate
|
|
893
|
+
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// No @each in path - handle normally
|
|
616
898
|
// Check if we need to handle methods
|
|
617
899
|
if (withMethodsSet) {
|
|
618
900
|
const result = evaluatePathWithMethods(target, pathParts, value, withMethodsSet);
|