assign-gingerly 0.0.30 → 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/assignFrom.js ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Resolve RHS path strings against a source object, then assign the
3
+ * resolved values into a target using assignGingerly.
4
+ *
5
+ * Combines resolveValues + assignGingerly into a single call.
6
+ * Inherits all assignGingerly options (withMethods, aka, signal, etc.).
7
+ *
8
+ * @param target - Object to merge resolved values into
9
+ * @param pattern - Object whose RHS values may contain `?.` path strings
10
+ * @param options - Options including `from` (source object) and any assignGingerly options
11
+ * @returns The target object after merging
12
+ *
13
+ * @example
14
+ * const source = { theme: { color: 'red' }, label: 'Hello' };
15
+ * const target = { color: 'blue', text: '' };
16
+ * assignFrom(target, {
17
+ * color: '?.theme?.color',
18
+ * text: '?.label'
19
+ * }, { from: source });
20
+ * // target is now { color: 'red', text: 'Hello' }
21
+ */
22
+ import { resolveValues } from './resolveValues.js';
23
+ import assignGingerly from './assignGingerly.js';
24
+ export function assignFrom(target, pattern, options) {
25
+ const resolved = resolveValues(pattern, options.from);
26
+ return assignGingerly(target, resolved, options);
27
+ }
package/assignFrom.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Resolve RHS path strings against a source object, then assign the
3
+ * resolved values into a target using assignGingerly.
4
+ *
5
+ * Combines resolveValues + assignGingerly into a single call.
6
+ * Inherits all assignGingerly options (withMethods, aka, signal, etc.).
7
+ *
8
+ * @param target - Object to merge resolved values into
9
+ * @param pattern - Object whose RHS values may contain `?.` path strings
10
+ * @param options - Options including `from` (source object) and any assignGingerly options
11
+ * @returns The target object after merging
12
+ *
13
+ * @example
14
+ * const source = { theme: { color: 'red' }, label: 'Hello' };
15
+ * const target = { color: 'blue', text: '' };
16
+ * assignFrom(target, {
17
+ * color: '?.theme?.color',
18
+ * text: '?.label'
19
+ * }, { from: source });
20
+ * // target is now { color: 'red', text: 'Hello' }
21
+ */
22
+ import { resolveValues } from './resolveValues.js';
23
+ import assignGingerly, { IAssignGingerlyOptions } from './assignGingerly.js';
24
+
25
+ export interface AssignFromOptions extends IAssignGingerlyOptions {
26
+ /** Source object to resolve RHS path strings against */
27
+ from: any;
28
+ }
29
+
30
+ export function assignFrom(
31
+ target: any,
32
+ pattern: Record<string, any>,
33
+ options: AssignFromOptions
34
+ ): any {
35
+ const resolved = resolveValues(pattern, options.from);
36
+ return assignGingerly(target, resolved, options);
37
+ }
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
- processedSource[key] = source[key];
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
- processedSource[key] = source[key];
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);