assign-gingerly 0.0.31 → 0.0.33
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 +416 -140
- package/assignFrom.js +27 -0
- package/assignFrom.ts +37 -0
- package/assignGingerly.js +244 -25
- package/assignGingerly.ts +310 -25
- package/eachTime.js +110 -0
- package/eachTime.ts +136 -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.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
|
/**
|
|
@@ -319,12 +356,17 @@ function ensureNestedPath(obj: any, pathParts: string[]): any {
|
|
|
319
356
|
}
|
|
320
357
|
|
|
321
358
|
/**
|
|
322
|
-
* Helper function to check if a property
|
|
323
|
-
* A property is
|
|
359
|
+
* Helper function to check if a property should be merged into rather than replaced.
|
|
360
|
+
* A property is non-replaceable 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
|
+
* Properties with both a getter and setter (e.g., element.style) are treated as
|
|
365
|
+
* replaceable — the setter runs with whatever value is provided (garbage in, garbage out).
|
|
366
|
+
*
|
|
367
|
+
* Exported for use by eachTime.ts
|
|
326
368
|
*/
|
|
327
|
-
function isReadonlyProperty(obj: any, propName: string | symbol): boolean {
|
|
369
|
+
export function isReadonlyProperty(obj: any, propName: string | symbol): boolean {
|
|
328
370
|
let descriptor = Object.getOwnPropertyDescriptor(obj, propName);
|
|
329
371
|
|
|
330
372
|
if (!descriptor) {
|
|
@@ -344,8 +386,8 @@ function isReadonlyProperty(obj: any, propName: string | symbol): boolean {
|
|
|
344
386
|
return descriptor.writable === false;
|
|
345
387
|
}
|
|
346
388
|
|
|
347
|
-
// If it's an accessor property
|
|
348
|
-
if ('get' in descriptor) {
|
|
389
|
+
// If it's an accessor property with a getter but no setter, it's readonly
|
|
390
|
+
if ('get' in descriptor && descriptor.get !== undefined) {
|
|
349
391
|
return descriptor.set === undefined;
|
|
350
392
|
}
|
|
351
393
|
|
|
@@ -355,8 +397,10 @@ function isReadonlyProperty(obj: any, propName: string | symbol): boolean {
|
|
|
355
397
|
/**
|
|
356
398
|
* Helper function to check if a value is a class instance (not a plain object)
|
|
357
399
|
* Returns true for instances of classes, false for plain objects, arrays, and primitives
|
|
400
|
+
*
|
|
401
|
+
* Exported for use by eachTime.ts
|
|
358
402
|
*/
|
|
359
|
-
function isClassInstance(value: any): boolean {
|
|
403
|
+
export function isClassInstance(value: any): boolean {
|
|
360
404
|
if (!value || typeof value !== 'object') return false;
|
|
361
405
|
if (Array.isArray(value)) return false;
|
|
362
406
|
|
|
@@ -368,8 +412,10 @@ function isClassInstance(value: any): boolean {
|
|
|
368
412
|
/**
|
|
369
413
|
* Helper function to evaluate a nested path with method calls
|
|
370
414
|
* Handles chained method calls where path segments can be methods
|
|
415
|
+
*
|
|
416
|
+
* Exported for use by eachTime.ts
|
|
371
417
|
*/
|
|
372
|
-
function evaluatePathWithMethods(
|
|
418
|
+
export function evaluatePathWithMethods(
|
|
373
419
|
target: any,
|
|
374
420
|
pathParts: string[],
|
|
375
421
|
value: any,
|
|
@@ -421,6 +467,172 @@ function evaluatePathWithMethods(
|
|
|
421
467
|
};
|
|
422
468
|
}
|
|
423
469
|
|
|
470
|
+
/**
|
|
471
|
+
* Check if a value is iterable (can be used with for...of or has forEach)
|
|
472
|
+
*/
|
|
473
|
+
function isIterable(value: any): boolean {
|
|
474
|
+
if (value == null) return false;
|
|
475
|
+
|
|
476
|
+
// Check for Symbol.iterator
|
|
477
|
+
if (typeof value[Symbol.iterator] === 'function') return true;
|
|
478
|
+
|
|
479
|
+
// Check if it's an Array
|
|
480
|
+
if (Array.isArray(value)) return true;
|
|
481
|
+
|
|
482
|
+
// Check if it's array-like (has length and numeric indices)
|
|
483
|
+
// This covers NodeList, HTMLCollection, etc.
|
|
484
|
+
if (typeof value.length === 'number' && value.length >= 0) {
|
|
485
|
+
return true;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Check if a segment is the forEach symbol (@each) or aliased to it
|
|
493
|
+
*/
|
|
494
|
+
function isForEachSymbol(segment: string, aliasMap: Map<string, string>): boolean {
|
|
495
|
+
// Direct match
|
|
496
|
+
if (segment === '@each') return true;
|
|
497
|
+
|
|
498
|
+
// Check if this segment is aliased to '@each'
|
|
499
|
+
const aliasTarget = aliasMap.get(segment);
|
|
500
|
+
return aliasTarget === '@each';
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Check if a segment is the reactive forEach symbol (@eachTime) or aliased to it
|
|
505
|
+
*/
|
|
506
|
+
function isReactiveForEachSymbol(segment: string, aliasMap: Map<string, string>): boolean {
|
|
507
|
+
// Direct match
|
|
508
|
+
if (segment === '@eachTime') return true;
|
|
509
|
+
|
|
510
|
+
// Check if this segment is aliased to '@eachTime'
|
|
511
|
+
const aliasTarget = aliasMap.get(segment);
|
|
512
|
+
return aliasTarget === '@eachTime';
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Apply a path to each item in an iterable
|
|
517
|
+
*/
|
|
518
|
+
function applyToEach(
|
|
519
|
+
iterable: any,
|
|
520
|
+
remainingPath: string[],
|
|
521
|
+
value: any,
|
|
522
|
+
withMethods: Set<string>,
|
|
523
|
+
aliasMap: Map<string, string>,
|
|
524
|
+
options?: IAssignGingerlyOptions
|
|
525
|
+
): void {
|
|
526
|
+
// Convert to array for iteration
|
|
527
|
+
const items = Array.isArray(iterable) ? iterable : Array.from(iterable);
|
|
528
|
+
|
|
529
|
+
// Apply the remaining path to each item
|
|
530
|
+
for (const item of items) {
|
|
531
|
+
if (remainingPath.length === 0) {
|
|
532
|
+
// No remaining path, can't do anything
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Check if there's another @each in the remaining path
|
|
537
|
+
const forEachIndex = remainingPath.findIndex(part => isForEachSymbol(part, aliasMap));
|
|
538
|
+
|
|
539
|
+
if (forEachIndex !== -1) {
|
|
540
|
+
// There's a nested @each
|
|
541
|
+
// Evaluate path up to the @each
|
|
542
|
+
const pathToForEach = remainingPath.slice(0, forEachIndex);
|
|
543
|
+
const pathAfterForEach = remainingPath.slice(forEachIndex + 1);
|
|
544
|
+
|
|
545
|
+
// Navigate to the nested iterable
|
|
546
|
+
let current = item;
|
|
547
|
+
for (const part of pathToForEach) {
|
|
548
|
+
if (withMethods.has(part)) {
|
|
549
|
+
const method = current[part];
|
|
550
|
+
if (typeof method === 'function') {
|
|
551
|
+
// For methods in the middle, we need to check the next part
|
|
552
|
+
const nextIndex = pathToForEach.indexOf(part) + 1;
|
|
553
|
+
const nextPart = pathToForEach[nextIndex];
|
|
554
|
+
if (nextPart && withMethods.has(nextPart)) {
|
|
555
|
+
current = method.call(current);
|
|
556
|
+
} else if (nextPart) {
|
|
557
|
+
current = method.call(current, nextPart);
|
|
558
|
+
// Skip next part
|
|
559
|
+
pathToForEach.splice(nextIndex, 1);
|
|
560
|
+
} else {
|
|
561
|
+
current = method.call(current);
|
|
562
|
+
}
|
|
563
|
+
} else {
|
|
564
|
+
current = current[part];
|
|
565
|
+
}
|
|
566
|
+
} else {
|
|
567
|
+
current = current[part];
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Recursively apply to the nested iterable
|
|
572
|
+
if (isIterable(current)) {
|
|
573
|
+
applyToEach(current, pathAfterForEach, value, withMethods, aliasMap, options);
|
|
574
|
+
}
|
|
575
|
+
} else {
|
|
576
|
+
// No nested @each, evaluate the remaining path normally
|
|
577
|
+
const result = evaluatePathWithMethods(item, remainingPath, value, withMethods);
|
|
578
|
+
|
|
579
|
+
if (result.isMethod) {
|
|
580
|
+
// Last segment is a method - call it
|
|
581
|
+
const method = result.target[result.lastKey];
|
|
582
|
+
if (typeof method === 'function') {
|
|
583
|
+
if (Array.isArray(value)) {
|
|
584
|
+
method.apply(result.target, value);
|
|
585
|
+
} else {
|
|
586
|
+
method.call(result.target, value);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
} else {
|
|
590
|
+
// Normal assignment
|
|
591
|
+
const lastKey = result.lastKey;
|
|
592
|
+
const parent = result.target;
|
|
593
|
+
|
|
594
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
595
|
+
if (lastKey in parent && isReadonlyProperty(parent, lastKey)) {
|
|
596
|
+
const currentValue = parent[lastKey];
|
|
597
|
+
if (typeof currentValue !== 'object' || currentValue === null) {
|
|
598
|
+
throw new Error(`Cannot merge object into readonly primitive property '${String(lastKey)}'`);
|
|
599
|
+
}
|
|
600
|
+
assignGingerly(currentValue, value, options);
|
|
601
|
+
} else {
|
|
602
|
+
parent[lastKey] = value;
|
|
603
|
+
}
|
|
604
|
+
} else {
|
|
605
|
+
parent[lastKey] = value;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Apply alias substitutions to a key string.
|
|
614
|
+
* Replaces complete tokens between `?.` delimiters with their aliased values.
|
|
615
|
+
*
|
|
616
|
+
* @param key - The key string (e.g., '?.$?.my-element?.c?.+')
|
|
617
|
+
* @param aliasMap - Map of alias -> target name
|
|
618
|
+
* @returns The key with aliases substituted (e.g., '?.querySelector?.my-element?.classList?.add')
|
|
619
|
+
*/
|
|
620
|
+
function applyAliases(key: string, aliasMap: Map<string, string>): string {
|
|
621
|
+
if (aliasMap.size === 0) return key;
|
|
622
|
+
|
|
623
|
+
// Split by ?. to get tokens
|
|
624
|
+
const parts = key.split('?.');
|
|
625
|
+
|
|
626
|
+
// Apply aliases to each part
|
|
627
|
+
const substituted = parts.map(part => {
|
|
628
|
+
// Check if this exact part is an alias
|
|
629
|
+
return aliasMap.get(part) ?? part;
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
// Rejoin with ?.
|
|
633
|
+
return substituted.join('?.');
|
|
634
|
+
}
|
|
635
|
+
|
|
424
636
|
/**
|
|
425
637
|
* Main assignGingerly function
|
|
426
638
|
*/
|
|
@@ -440,13 +652,25 @@ export function assignGingerly(
|
|
|
440
652
|
: new Set(options.withMethods)
|
|
441
653
|
: undefined;
|
|
442
654
|
|
|
655
|
+
// Convert aka object to Map for O(1) lookup and validate aliases
|
|
656
|
+
const aliasMap = new Map<string, string>();
|
|
657
|
+
if (options?.aka) {
|
|
658
|
+
for (const [alias, target] of Object.entries(options.aka)) {
|
|
659
|
+
// Validate: disallow space and backtick in aliases
|
|
660
|
+
if (alias.includes(' ') || alias.includes('`')) {
|
|
661
|
+
throw new Error(`Invalid alias '${alias}': aliases cannot contain space or backtick characters`);
|
|
662
|
+
}
|
|
663
|
+
aliasMap.set(alias, target);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
443
667
|
const registry = options?.registry instanceof EnhancementRegistry
|
|
444
668
|
? options.registry
|
|
445
669
|
: options?.registry
|
|
446
670
|
? new options.registry()
|
|
447
671
|
: undefined;
|
|
448
672
|
|
|
449
|
-
// Convert Symbol.for string keys to actual symbols
|
|
673
|
+
// Convert Symbol.for string keys to actual symbols and apply aliases
|
|
450
674
|
const processedSource: Record<string | symbol, any> = {};
|
|
451
675
|
for (const key of Object.keys(source)) {
|
|
452
676
|
if (isSymbolForKey(key)) {
|
|
@@ -458,7 +682,9 @@ export function assignGingerly(
|
|
|
458
682
|
processedSource[key] = source[key];
|
|
459
683
|
}
|
|
460
684
|
} else {
|
|
461
|
-
|
|
685
|
+
// Apply aliases to string keys
|
|
686
|
+
const substitutedKey = applyAliases(key, aliasMap);
|
|
687
|
+
processedSource[substitutedKey] = source[key];
|
|
462
688
|
}
|
|
463
689
|
}
|
|
464
690
|
// Copy over actual symbol keys
|
|
@@ -613,6 +839,65 @@ export function assignGingerly(
|
|
|
613
839
|
if (isNestedPath(key)) {
|
|
614
840
|
const pathParts = parsePath(key);
|
|
615
841
|
|
|
842
|
+
// Check if path contains @each or @eachTime (forEach)
|
|
843
|
+
const forEachIndex = pathParts.findIndex(part =>
|
|
844
|
+
isForEachSymbol(part, aliasMap) || isReactiveForEachSymbol(part, aliasMap)
|
|
845
|
+
);
|
|
846
|
+
|
|
847
|
+
if (forEachIndex !== -1) {
|
|
848
|
+
// Check if it's reactive (@eachTime)
|
|
849
|
+
const isReactive = isReactiveForEachSymbol(pathParts[forEachIndex], aliasMap);
|
|
850
|
+
|
|
851
|
+
if (isReactive) {
|
|
852
|
+
// Reactive forEach - dynamic load and fire-and-forget
|
|
853
|
+
(async () => {
|
|
854
|
+
try {
|
|
855
|
+
const { handleEachTime } = await import('./eachTime.js');
|
|
856
|
+
await handleEachTime(
|
|
857
|
+
target,
|
|
858
|
+
pathParts,
|
|
859
|
+
forEachIndex,
|
|
860
|
+
value,
|
|
861
|
+
withMethodsSet,
|
|
862
|
+
aliasMap,
|
|
863
|
+
options
|
|
864
|
+
);
|
|
865
|
+
} catch (error) {
|
|
866
|
+
console.error('Error in @eachTime:', error);
|
|
867
|
+
}
|
|
868
|
+
})();
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// Static forEach (@each) - existing logic
|
|
873
|
+
const pathToForEach = pathParts.slice(0, forEachIndex);
|
|
874
|
+
const pathAfterForEach = pathParts.slice(forEachIndex + 1);
|
|
875
|
+
|
|
876
|
+
// Navigate to the iterable
|
|
877
|
+
let current = target;
|
|
878
|
+
if (pathToForEach.length > 0) {
|
|
879
|
+
if (withMethodsSet) {
|
|
880
|
+
const result = evaluatePathWithMethods(target, pathToForEach, value, withMethodsSet);
|
|
881
|
+
// The result.target is the current position after evaluating the path
|
|
882
|
+
// This is already the iterable we want
|
|
883
|
+
current = result.target;
|
|
884
|
+
} else {
|
|
885
|
+
for (const part of pathToForEach) {
|
|
886
|
+
current = current[part];
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Apply to each item in the iterable
|
|
892
|
+
if (isIterable(current)) {
|
|
893
|
+
applyToEach(current, pathAfterForEach, value, withMethodsSet || new Set(), aliasMap, options);
|
|
894
|
+
}
|
|
895
|
+
// If not iterable, let JavaScript throw error naturally when trying to iterate
|
|
896
|
+
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// No @each in path - handle normally
|
|
616
901
|
// Check if we need to handle methods
|
|
617
902
|
if (withMethodsSet) {
|
|
618
903
|
const result = evaluatePathWithMethods(target, pathParts, value, withMethodsSet);
|
|
@@ -636,15 +921,15 @@ export function assignGingerly(
|
|
|
636
921
|
const parent = result.target;
|
|
637
922
|
|
|
638
923
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
639
|
-
// Check if property exists and is readonly
|
|
640
|
-
if (lastKey in parent &&
|
|
924
|
+
// Check if property exists and is readonly
|
|
925
|
+
if (lastKey in parent && isReadonlyProperty(parent, lastKey)) {
|
|
641
926
|
const currentValue = parent[lastKey];
|
|
642
927
|
if (typeof currentValue !== 'object' || currentValue === null) {
|
|
643
|
-
throw new Error(`Cannot merge object into
|
|
928
|
+
throw new Error(`Cannot merge object into readonly primitive property '${String(lastKey)}'`);
|
|
644
929
|
}
|
|
645
930
|
assignGingerly(currentValue, value, options);
|
|
646
931
|
} else {
|
|
647
|
-
// Property is writable
|
|
932
|
+
// Property is writable - replace it
|
|
648
933
|
parent[lastKey] = value;
|
|
649
934
|
}
|
|
650
935
|
} else {
|
|
@@ -656,17 +941,17 @@ export function assignGingerly(
|
|
|
656
941
|
const parent = ensureNestedPath(target, pathParts);
|
|
657
942
|
|
|
658
943
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
659
|
-
// Check if property exists and is readonly
|
|
660
|
-
if (lastKey in parent &&
|
|
661
|
-
// Property is readonly
|
|
944
|
+
// Check if property exists and is readonly
|
|
945
|
+
if (lastKey in parent && isReadonlyProperty(parent, lastKey)) {
|
|
946
|
+
// Property is readonly - check if current value is an object
|
|
662
947
|
const currentValue = parent[lastKey];
|
|
663
948
|
if (typeof currentValue !== 'object' || currentValue === null) {
|
|
664
|
-
throw new Error(`Cannot merge object into
|
|
949
|
+
throw new Error(`Cannot merge object into readonly primitive property '${String(lastKey)}'`);
|
|
665
950
|
}
|
|
666
|
-
// Recursively apply assignGingerly to the readonly object
|
|
951
|
+
// Recursively apply assignGingerly to the readonly object
|
|
667
952
|
assignGingerly(currentValue, value, options);
|
|
668
953
|
} else {
|
|
669
|
-
// Property is writable
|
|
954
|
+
// Property is writable - replace it
|
|
670
955
|
parent[lastKey] = value;
|
|
671
956
|
}
|
|
672
957
|
} else {
|
|
@@ -692,17 +977,17 @@ export function assignGingerly(
|
|
|
692
977
|
|
|
693
978
|
// Normal assignment
|
|
694
979
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
695
|
-
// Check if property exists and is readonly
|
|
696
|
-
if (key in target &&
|
|
697
|
-
// Property is readonly
|
|
980
|
+
// Check if property exists and is readonly
|
|
981
|
+
if (key in target && isReadonlyProperty(target, key)) {
|
|
982
|
+
// Property is readonly - check if current value is an object
|
|
698
983
|
const currentValue = target[key];
|
|
699
984
|
if (typeof currentValue !== 'object' || currentValue === null) {
|
|
700
|
-
throw new Error(`Cannot merge object into
|
|
985
|
+
throw new Error(`Cannot merge object into readonly primitive property '${String(key)}'`);
|
|
701
986
|
}
|
|
702
|
-
// Recursively apply assignGingerly to the readonly object
|
|
987
|
+
// Recursively apply assignGingerly to the readonly object
|
|
703
988
|
assignGingerly(currentValue, value, options);
|
|
704
989
|
} else {
|
|
705
|
-
// Property is writable
|
|
990
|
+
// Property is writable - replace it
|
|
706
991
|
target[key] = value;
|
|
707
992
|
}
|
|
708
993
|
} else {
|
package/eachTime.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* eachTime.ts - Reactive forEach implementation for @eachTime symbol
|
|
3
|
+
* This module is dynamically loaded only when @eachTime is encountered
|
|
4
|
+
* Provides event-driven iteration over elements as they mount
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Check if a value is an EventTarget
|
|
8
|
+
*/
|
|
9
|
+
function isEventTarget(value) {
|
|
10
|
+
return value != null && typeof value.addEventListener === 'function';
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Handle reactive forEach (@eachTime) by setting up event listeners
|
|
14
|
+
*
|
|
15
|
+
* @param target - The root object to start navigation from
|
|
16
|
+
* @param pathParts - Complete path parts array including @eachTime
|
|
17
|
+
* @param forEachIndex - Index of @eachTime in pathParts
|
|
18
|
+
* @param value - Value to assign to each mounted element
|
|
19
|
+
* @param withMethods - Set of method names to call instead of assign
|
|
20
|
+
* @param aliasMap - Map of aliases for token substitution
|
|
21
|
+
* @param options - Options including required AbortSignal
|
|
22
|
+
*/
|
|
23
|
+
export async function handleEachTime(target, pathParts, forEachIndex, value, withMethods, aliasMap, options) {
|
|
24
|
+
// Validate signal - required for cleanup
|
|
25
|
+
if (!options?.signal) {
|
|
26
|
+
throw new Error('@eachTime requires an AbortSignal in options.signal for cleanup');
|
|
27
|
+
}
|
|
28
|
+
// Split path into before and after @eachTime
|
|
29
|
+
const pathToEventSource = pathParts.slice(0, forEachIndex);
|
|
30
|
+
const pathAfterForEach = pathParts.slice(forEachIndex + 1);
|
|
31
|
+
// Navigate to the event source
|
|
32
|
+
let eventSource = target;
|
|
33
|
+
if (pathToEventSource.length > 0) {
|
|
34
|
+
// Import evaluatePathWithMethods for navigation
|
|
35
|
+
const { evaluatePathWithMethods } = await import('./assignGingerly.js');
|
|
36
|
+
if (withMethods && withMethods.size > 0) {
|
|
37
|
+
const result = evaluatePathWithMethods(target, pathToEventSource, value, withMethods);
|
|
38
|
+
eventSource = result.target;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
// Simple property navigation
|
|
42
|
+
for (const part of pathToEventSource) {
|
|
43
|
+
eventSource = eventSource[part];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Validate event source is an EventTarget
|
|
48
|
+
if (!isEventTarget(eventSource)) {
|
|
49
|
+
throw new Error('@eachTime requires an EventTarget (e.g., IMountObserver)');
|
|
50
|
+
}
|
|
51
|
+
// Setup event listener for 'mount' events
|
|
52
|
+
const handler = (event) => {
|
|
53
|
+
// Extract mounted element from IMountEvent
|
|
54
|
+
const mountedElement = event.mountedElement;
|
|
55
|
+
if (!mountedElement)
|
|
56
|
+
return;
|
|
57
|
+
// Apply remaining path to mounted element (async to avoid blocking)
|
|
58
|
+
(async () => {
|
|
59
|
+
try {
|
|
60
|
+
// Import needed functions from assignGingerly
|
|
61
|
+
const { evaluatePathWithMethods, assignGingerly, isReadonlyProperty } = await import('./assignGingerly.js');
|
|
62
|
+
if (pathAfterForEach.length > 0) {
|
|
63
|
+
const result = evaluatePathWithMethods(mountedElement, pathAfterForEach, value, withMethods || new Set());
|
|
64
|
+
if (result.isMethod) {
|
|
65
|
+
// Last segment is a method - call it
|
|
66
|
+
const method = result.target[result.lastKey];
|
|
67
|
+
if (typeof method === 'function') {
|
|
68
|
+
if (Array.isArray(value)) {
|
|
69
|
+
method.apply(result.target, value);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
method.call(result.target, value);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
// Normal assignment
|
|
78
|
+
const lastKey = result.lastKey;
|
|
79
|
+
const parent = result.target;
|
|
80
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
81
|
+
// Check if property exists and is readonly
|
|
82
|
+
if (lastKey in parent && isReadonlyProperty(parent, lastKey)) {
|
|
83
|
+
const currentValue = parent[lastKey];
|
|
84
|
+
if (typeof currentValue !== 'object' || currentValue === null) {
|
|
85
|
+
throw new Error(`Cannot merge object into readonly primitive property '${String(lastKey)}'`);
|
|
86
|
+
}
|
|
87
|
+
// Recursively apply assignGingerly
|
|
88
|
+
assignGingerly(currentValue, value, options);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
// Property is writable - replace it
|
|
92
|
+
parent[lastKey] = value;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
// Primitive value - direct assignment
|
|
97
|
+
parent[lastKey] = value;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
console.error('Error applying @eachTime to mounted element:', error);
|
|
104
|
+
}
|
|
105
|
+
})();
|
|
106
|
+
};
|
|
107
|
+
// Register listener with AbortSignal for automatic cleanup
|
|
108
|
+
// Hardcode 'mount' event for IMountObserver
|
|
109
|
+
eventSource.addEventListener('mount', handler, { signal: options.signal });
|
|
110
|
+
}
|
package/eachTime.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* eachTime.ts - Reactive forEach implementation for @eachTime symbol
|
|
3
|
+
* This module is dynamically loaded only when @eachTime is encountered
|
|
4
|
+
* Provides event-driven iteration over elements as they mount
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { IAssignGingerlyOptions } from './assignGingerly.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Check if a value is an EventTarget
|
|
11
|
+
*/
|
|
12
|
+
function isEventTarget(value: any): boolean {
|
|
13
|
+
return value != null && typeof value.addEventListener === 'function';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Handle reactive forEach (@eachTime) by setting up event listeners
|
|
18
|
+
*
|
|
19
|
+
* @param target - The root object to start navigation from
|
|
20
|
+
* @param pathParts - Complete path parts array including @eachTime
|
|
21
|
+
* @param forEachIndex - Index of @eachTime in pathParts
|
|
22
|
+
* @param value - Value to assign to each mounted element
|
|
23
|
+
* @param withMethods - Set of method names to call instead of assign
|
|
24
|
+
* @param aliasMap - Map of aliases for token substitution
|
|
25
|
+
* @param options - Options including required AbortSignal
|
|
26
|
+
*/
|
|
27
|
+
export async function handleEachTime(
|
|
28
|
+
target: any,
|
|
29
|
+
pathParts: string[],
|
|
30
|
+
forEachIndex: number,
|
|
31
|
+
value: any,
|
|
32
|
+
withMethods: Set<string> | undefined,
|
|
33
|
+
aliasMap: Map<string, string>,
|
|
34
|
+
options?: IAssignGingerlyOptions
|
|
35
|
+
): Promise<void> {
|
|
36
|
+
// Validate signal - required for cleanup
|
|
37
|
+
if (!options?.signal) {
|
|
38
|
+
throw new Error('@eachTime requires an AbortSignal in options.signal for cleanup');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Split path into before and after @eachTime
|
|
42
|
+
const pathToEventSource = pathParts.slice(0, forEachIndex);
|
|
43
|
+
const pathAfterForEach = pathParts.slice(forEachIndex + 1);
|
|
44
|
+
|
|
45
|
+
// Navigate to the event source
|
|
46
|
+
let eventSource = target;
|
|
47
|
+
if (pathToEventSource.length > 0) {
|
|
48
|
+
// Import evaluatePathWithMethods for navigation
|
|
49
|
+
const { evaluatePathWithMethods } = await import('./assignGingerly.js');
|
|
50
|
+
|
|
51
|
+
if (withMethods && withMethods.size > 0) {
|
|
52
|
+
const result = evaluatePathWithMethods(target, pathToEventSource, value, withMethods);
|
|
53
|
+
eventSource = result.target;
|
|
54
|
+
} else {
|
|
55
|
+
// Simple property navigation
|
|
56
|
+
for (const part of pathToEventSource) {
|
|
57
|
+
eventSource = eventSource[part];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Validate event source is an EventTarget
|
|
63
|
+
if (!isEventTarget(eventSource)) {
|
|
64
|
+
throw new Error('@eachTime requires an EventTarget (e.g., IMountObserver)');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Setup event listener for 'mount' events
|
|
68
|
+
const handler = (event: Event) => {
|
|
69
|
+
// Extract mounted element from IMountEvent
|
|
70
|
+
const mountedElement = (event as any).mountedElement;
|
|
71
|
+
if (!mountedElement) return;
|
|
72
|
+
|
|
73
|
+
// Apply remaining path to mounted element (async to avoid blocking)
|
|
74
|
+
(async () => {
|
|
75
|
+
try {
|
|
76
|
+
// Import needed functions from assignGingerly
|
|
77
|
+
const {
|
|
78
|
+
evaluatePathWithMethods,
|
|
79
|
+
assignGingerly,
|
|
80
|
+
isReadonlyProperty
|
|
81
|
+
} = await import('./assignGingerly.js');
|
|
82
|
+
|
|
83
|
+
if (pathAfterForEach.length > 0) {
|
|
84
|
+
const result = evaluatePathWithMethods(
|
|
85
|
+
mountedElement,
|
|
86
|
+
pathAfterForEach,
|
|
87
|
+
value,
|
|
88
|
+
withMethods || new Set()
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
if (result.isMethod) {
|
|
92
|
+
// Last segment is a method - call it
|
|
93
|
+
const method = result.target[result.lastKey];
|
|
94
|
+
if (typeof method === 'function') {
|
|
95
|
+
if (Array.isArray(value)) {
|
|
96
|
+
method.apply(result.target, value);
|
|
97
|
+
} else {
|
|
98
|
+
method.call(result.target, value);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
// Normal assignment
|
|
103
|
+
const lastKey = result.lastKey;
|
|
104
|
+
const parent = result.target;
|
|
105
|
+
|
|
106
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
107
|
+
// Check if property exists and is readonly
|
|
108
|
+
if (lastKey in parent && isReadonlyProperty(parent, lastKey)) {
|
|
109
|
+
const currentValue = parent[lastKey];
|
|
110
|
+
if (typeof currentValue !== 'object' || currentValue === null) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Cannot merge object into readonly primitive property '${String(lastKey)}'`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
// Recursively apply assignGingerly
|
|
116
|
+
assignGingerly(currentValue, value, options);
|
|
117
|
+
} else {
|
|
118
|
+
// Property is writable - replace it
|
|
119
|
+
parent[lastKey] = value;
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
// Primitive value - direct assignment
|
|
123
|
+
parent[lastKey] = value;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
} catch (error) {
|
|
128
|
+
console.error('Error applying @eachTime to mounted element:', error);
|
|
129
|
+
}
|
|
130
|
+
})();
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// Register listener with AbortSignal for automatic cleanup
|
|
134
|
+
// Hardcode 'mount' event for IMountObserver
|
|
135
|
+
eventSource.addEventListener('mount', handler, { signal: options.signal });
|
|
136
|
+
}
|