assign-gingerly 0.0.38 → 0.0.39
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 +897 -11
- package/assignFeatures.js +457 -0
- package/assignFeatures.ts +683 -0
- package/assignGingerly.js +96 -3
- package/assignGingerly.ts +122 -3
- package/evaluatePathWithAsyncMethods.js +90 -0
- package/evaluatePathWithAsyncMethods.ts +102 -0
- package/index.js +3 -1
- package/index.ts +3 -1
- package/installForwarding.js +61 -0
- package/installForwarding.ts +71 -0
- package/package.json +14 -2
- package/resolveValues.js +62 -2
- package/resolveValues.ts +71 -2
- package/types/assign-gingerly/types.d.ts +139 -0
package/assignGingerly.js
CHANGED
|
@@ -203,12 +203,21 @@ function parseDeleteCommand(key) {
|
|
|
203
203
|
* Helper function to parse a path string with ?. notation
|
|
204
204
|
* Always splits on '?.' delimiter, preserving dots that are part of values
|
|
205
205
|
* (e.g., CSS class selectors like '.username')
|
|
206
|
+
/**
|
|
207
|
+
* Path cache for parsed path strings in assignGingerly.
|
|
208
|
+
* Avoids re-splitting the same path on repeated calls.
|
|
209
|
+
*/
|
|
210
|
+
const agPathCache = new Map();
|
|
211
|
+
/**
|
|
206
212
|
* Paths must use ?. notation — plain dot notation is not supported.
|
|
207
213
|
*/
|
|
208
214
|
function parsePath(path) {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
.filter(part => part.length > 0);
|
|
215
|
+
let parts = agPathCache.get(path);
|
|
216
|
+
if (!parts) {
|
|
217
|
+
parts = path.split('?.').filter(part => part.length > 0);
|
|
218
|
+
agPathCache.set(path, parts);
|
|
219
|
+
}
|
|
220
|
+
return parts;
|
|
212
221
|
}
|
|
213
222
|
/**
|
|
214
223
|
* Helper function to check if a path starts with ?. notation
|
|
@@ -264,6 +273,26 @@ export function isReadonlyProperty(obj, propName) {
|
|
|
264
273
|
}
|
|
265
274
|
return false;
|
|
266
275
|
}
|
|
276
|
+
/**
|
|
277
|
+
* Checks if a class defines a static `assignTo` method and calls it.
|
|
278
|
+
* This is the "bring your own assigner" protocol — classes can opt into
|
|
279
|
+
* custom assignment behavior by defining `static assignTo`.
|
|
280
|
+
*
|
|
281
|
+
* Only triggers for classes that explicitly define `assignTo` on themselves
|
|
282
|
+
* (not inherited from Object or other base classes).
|
|
283
|
+
*
|
|
284
|
+
* @returns true if assignTo was found and called, false otherwise
|
|
285
|
+
*/
|
|
286
|
+
function tryAssignTo(currentValue, value, parent, key) {
|
|
287
|
+
if (currentValue != null && typeof currentValue === 'object') {
|
|
288
|
+
const { constructor } = currentValue;
|
|
289
|
+
if (constructor && Object.hasOwn(constructor, 'assignTo') && typeof constructor.assignTo === 'function') {
|
|
290
|
+
constructor.assignTo(currentValue, value, parent, key);
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
267
296
|
/**
|
|
268
297
|
* Helper function to check if a value is a class instance (not a plain object)
|
|
269
298
|
* Returns true for instances of classes, false for plain objects, arrays, and primitives
|
|
@@ -496,6 +525,12 @@ export function assignGingerly(target, source, options) {
|
|
|
496
525
|
? options.withMethods
|
|
497
526
|
: new Set(options.withMethods)
|
|
498
527
|
: undefined;
|
|
528
|
+
// Convert withAsyncMethods array to Set for O(1) lookup
|
|
529
|
+
const withAsyncMethodsSet = options?.withAsyncMethods
|
|
530
|
+
? options.withAsyncMethods instanceof Set
|
|
531
|
+
? options.withAsyncMethods
|
|
532
|
+
: new Set(options.withAsyncMethods)
|
|
533
|
+
: undefined;
|
|
499
534
|
// Convert aka object to Map for O(1) lookup and validate aliases
|
|
500
535
|
const aliasMap = new Map();
|
|
501
536
|
if (options?.aka) {
|
|
@@ -743,6 +778,52 @@ export function assignGingerly(target, source, options) {
|
|
|
743
778
|
continue;
|
|
744
779
|
}
|
|
745
780
|
// No @each in path - handle normally
|
|
781
|
+
// Check if we need to handle async methods (fire-and-forget)
|
|
782
|
+
if (withAsyncMethodsSet && pathParts.some(p => withAsyncMethodsSet.has(p))) {
|
|
783
|
+
// Fire-and-forget: dynamically import the async evaluator and run the chain
|
|
784
|
+
const capturedTarget = target;
|
|
785
|
+
const capturedPathParts = pathParts;
|
|
786
|
+
const capturedValue = value;
|
|
787
|
+
const capturedWithMethodsSet = withMethodsSet || new Set();
|
|
788
|
+
const capturedOptions = options;
|
|
789
|
+
(async () => {
|
|
790
|
+
const { evaluatePathWithAsyncMethods } = await import('./evaluatePathWithAsyncMethods.js');
|
|
791
|
+
const result = await evaluatePathWithAsyncMethods(capturedTarget, capturedPathParts, capturedValue, capturedWithMethodsSet, withAsyncMethodsSet);
|
|
792
|
+
if (result.isMethod || result.isAsyncMethod) {
|
|
793
|
+
// Last segment is a method — call it
|
|
794
|
+
const method = result.target[result.lastKey];
|
|
795
|
+
if (typeof method === 'function') {
|
|
796
|
+
const returnVal = Array.isArray(capturedValue)
|
|
797
|
+
? method.apply(result.target, capturedValue)
|
|
798
|
+
: method.call(result.target, capturedValue);
|
|
799
|
+
// If it's an async method, await it (for side effects)
|
|
800
|
+
if (result.isAsyncMethod)
|
|
801
|
+
await returnVal;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
else {
|
|
805
|
+
// Not a method — assign the value
|
|
806
|
+
const lastKey = result.lastKey;
|
|
807
|
+
const parent = result.target;
|
|
808
|
+
if (typeof capturedValue === 'object' && capturedValue !== null && !Array.isArray(capturedValue)) {
|
|
809
|
+
if (lastKey in parent && isReadonlyProperty(parent, lastKey)) {
|
|
810
|
+
const currentValue = parent[lastKey];
|
|
811
|
+
if (typeof currentValue !== 'object' || currentValue === null) {
|
|
812
|
+
throw new Error(`Cannot merge object into readonly primitive property '${String(lastKey)}'`);
|
|
813
|
+
}
|
|
814
|
+
assignGingerly(currentValue, capturedValue, capturedOptions);
|
|
815
|
+
}
|
|
816
|
+
else {
|
|
817
|
+
parent[lastKey] = capturedValue;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
else {
|
|
821
|
+
parent[lastKey] = capturedValue;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
})();
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
746
827
|
// Check if we need to handle methods
|
|
747
828
|
if (withMethodsSet) {
|
|
748
829
|
const result = evaluatePathWithMethods(target, pathParts, value, withMethodsSet);
|
|
@@ -763,6 +844,10 @@ export function assignGingerly(target, source, options) {
|
|
|
763
844
|
// Not a method - proceed with normal assignment using evaluated target
|
|
764
845
|
const lastKey = result.lastKey;
|
|
765
846
|
const parent = result.target;
|
|
847
|
+
// Check for static assignTo protocol
|
|
848
|
+
if (lastKey in parent && tryAssignTo(parent[lastKey], value, parent, lastKey)) {
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
766
851
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
767
852
|
// Check if property exists and is readonly
|
|
768
853
|
if (lastKey in parent && isReadonlyProperty(parent, lastKey)) {
|
|
@@ -785,6 +870,10 @@ export function assignGingerly(target, source, options) {
|
|
|
785
870
|
// No withMethods - use original logic
|
|
786
871
|
const lastKey = pathParts[pathParts.length - 1];
|
|
787
872
|
const parent = ensureNestedPath(target, pathParts);
|
|
873
|
+
// Check for static assignTo protocol
|
|
874
|
+
if (lastKey in parent && tryAssignTo(parent[lastKey], value, parent, lastKey)) {
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
788
877
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
789
878
|
// Check if property exists and is readonly
|
|
790
879
|
if (lastKey in parent && isReadonlyProperty(parent, lastKey)) {
|
|
@@ -823,6 +912,10 @@ export function assignGingerly(target, source, options) {
|
|
|
823
912
|
continue;
|
|
824
913
|
}
|
|
825
914
|
// Normal assignment
|
|
915
|
+
// Check for static assignTo protocol
|
|
916
|
+
if (key in target && tryAssignTo(target[key], value, target, key)) {
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
826
919
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
827
920
|
// Check if property exists and is readonly
|
|
828
921
|
if (key in target && isReadonlyProperty(target, key)) {
|
package/assignGingerly.ts
CHANGED
|
@@ -97,6 +97,25 @@ export interface IAssignGingerlyOptions {
|
|
|
97
97
|
* // Later: controller.abort(); // Cleanup all listeners
|
|
98
98
|
*/
|
|
99
99
|
signal?: AbortSignal;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* List of property names that should be treated as async methods.
|
|
103
|
+
* Works together with withMethods — async methods are awaited before
|
|
104
|
+
* continuing the chain.
|
|
105
|
+
*
|
|
106
|
+
* The path evaluation for keys containing async methods is fire-and-forget:
|
|
107
|
+
* assignGingerly remains synchronous and returns immediately. The async
|
|
108
|
+
* chain completes in the background.
|
|
109
|
+
*
|
|
110
|
+
* NOTE: Interaction with @each and @eachTime is not yet implemented.
|
|
111
|
+
*
|
|
112
|
+
* Example:
|
|
113
|
+
* assignGingerly(el, {
|
|
114
|
+
* '?.whenFeatureReady?.photoTaker?.someProp': 'hello'
|
|
115
|
+
* }, { withAsyncMethods: ['whenFeatureReady'] });
|
|
116
|
+
* // Calls: (await el.whenFeatureReady('photoTaker')).someProp = 'hello'
|
|
117
|
+
*/
|
|
118
|
+
withAsyncMethods?: string[] | Set<string>;
|
|
100
119
|
}
|
|
101
120
|
|
|
102
121
|
/**
|
|
@@ -328,12 +347,22 @@ function parseDeleteCommand(key: string): string | null {
|
|
|
328
347
|
* Helper function to parse a path string with ?. notation
|
|
329
348
|
* Always splits on '?.' delimiter, preserving dots that are part of values
|
|
330
349
|
* (e.g., CSS class selectors like '.username')
|
|
350
|
+
/**
|
|
351
|
+
* Path cache for parsed path strings in assignGingerly.
|
|
352
|
+
* Avoids re-splitting the same path on repeated calls.
|
|
353
|
+
*/
|
|
354
|
+
const agPathCache = new Map<string, string[]>();
|
|
355
|
+
|
|
356
|
+
/**
|
|
331
357
|
* Paths must use ?. notation — plain dot notation is not supported.
|
|
332
358
|
*/
|
|
333
359
|
function parsePath(path: string): string[] {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
.filter(part => part.length > 0);
|
|
360
|
+
let parts = agPathCache.get(path);
|
|
361
|
+
if (!parts) {
|
|
362
|
+
parts = path.split('?.').filter(part => part.length > 0);
|
|
363
|
+
agPathCache.set(path, parts);
|
|
364
|
+
}
|
|
365
|
+
return parts;
|
|
337
366
|
}
|
|
338
367
|
|
|
339
368
|
/**
|
|
@@ -396,6 +425,27 @@ export function isReadonlyProperty(obj: any, propName: string | symbol): boolean
|
|
|
396
425
|
return false;
|
|
397
426
|
}
|
|
398
427
|
|
|
428
|
+
/**
|
|
429
|
+
* Checks if a class defines a static `assignTo` method and calls it.
|
|
430
|
+
* This is the "bring your own assigner" protocol — classes can opt into
|
|
431
|
+
* custom assignment behavior by defining `static assignTo`.
|
|
432
|
+
*
|
|
433
|
+
* Only triggers for classes that explicitly define `assignTo` on themselves
|
|
434
|
+
* (not inherited from Object or other base classes).
|
|
435
|
+
*
|
|
436
|
+
* @returns true if assignTo was found and called, false otherwise
|
|
437
|
+
*/
|
|
438
|
+
function tryAssignTo(currentValue: any, value: any, parent: any, key: string | symbol): boolean {
|
|
439
|
+
if (currentValue != null && typeof currentValue === 'object') {
|
|
440
|
+
const { constructor } = currentValue;
|
|
441
|
+
if (constructor && Object.hasOwn(constructor, 'assignTo') && typeof constructor.assignTo === 'function') {
|
|
442
|
+
constructor.assignTo(currentValue, value, parent, key);
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
|
|
399
449
|
/**
|
|
400
450
|
* Helper function to check if a value is a class instance (not a plain object)
|
|
401
451
|
* Returns true for instances of classes, false for plain objects, arrays, and primitives
|
|
@@ -654,6 +704,13 @@ export function assignGingerly(
|
|
|
654
704
|
: new Set(options.withMethods)
|
|
655
705
|
: undefined;
|
|
656
706
|
|
|
707
|
+
// Convert withAsyncMethods array to Set for O(1) lookup
|
|
708
|
+
const withAsyncMethodsSet = options?.withAsyncMethods
|
|
709
|
+
? options.withAsyncMethods instanceof Set
|
|
710
|
+
? options.withAsyncMethods
|
|
711
|
+
: new Set(options.withAsyncMethods)
|
|
712
|
+
: undefined;
|
|
713
|
+
|
|
657
714
|
// Convert aka object to Map for O(1) lookup and validate aliases
|
|
658
715
|
const aliasMap = new Map<string, string>();
|
|
659
716
|
if (options?.aka) {
|
|
@@ -919,6 +976,53 @@ export function assignGingerly(
|
|
|
919
976
|
}
|
|
920
977
|
|
|
921
978
|
// No @each in path - handle normally
|
|
979
|
+
// Check if we need to handle async methods (fire-and-forget)
|
|
980
|
+
if (withAsyncMethodsSet && pathParts.some(p => withAsyncMethodsSet.has(p))) {
|
|
981
|
+
// Fire-and-forget: dynamically import the async evaluator and run the chain
|
|
982
|
+
const capturedTarget = target;
|
|
983
|
+
const capturedPathParts = pathParts;
|
|
984
|
+
const capturedValue = value;
|
|
985
|
+
const capturedWithMethodsSet = withMethodsSet || new Set<string>();
|
|
986
|
+
const capturedOptions = options;
|
|
987
|
+
(async () => {
|
|
988
|
+
const { evaluatePathWithAsyncMethods } = await import('./evaluatePathWithAsyncMethods.js');
|
|
989
|
+
const result = await evaluatePathWithAsyncMethods(
|
|
990
|
+
capturedTarget, capturedPathParts, capturedValue,
|
|
991
|
+
capturedWithMethodsSet, withAsyncMethodsSet
|
|
992
|
+
);
|
|
993
|
+
|
|
994
|
+
if (result.isMethod || result.isAsyncMethod) {
|
|
995
|
+
// Last segment is a method — call it
|
|
996
|
+
const method = result.target[result.lastKey];
|
|
997
|
+
if (typeof method === 'function') {
|
|
998
|
+
const returnVal = Array.isArray(capturedValue)
|
|
999
|
+
? method.apply(result.target, capturedValue)
|
|
1000
|
+
: method.call(result.target, capturedValue);
|
|
1001
|
+
// If it's an async method, await it (for side effects)
|
|
1002
|
+
if (result.isAsyncMethod) await returnVal;
|
|
1003
|
+
}
|
|
1004
|
+
} else {
|
|
1005
|
+
// Not a method — assign the value
|
|
1006
|
+
const lastKey = result.lastKey;
|
|
1007
|
+
const parent = result.target;
|
|
1008
|
+
if (typeof capturedValue === 'object' && capturedValue !== null && !Array.isArray(capturedValue)) {
|
|
1009
|
+
if (lastKey in parent && isReadonlyProperty(parent, lastKey)) {
|
|
1010
|
+
const currentValue = parent[lastKey];
|
|
1011
|
+
if (typeof currentValue !== 'object' || currentValue === null) {
|
|
1012
|
+
throw new Error(`Cannot merge object into readonly primitive property '${String(lastKey)}'`);
|
|
1013
|
+
}
|
|
1014
|
+
assignGingerly(currentValue, capturedValue, capturedOptions);
|
|
1015
|
+
} else {
|
|
1016
|
+
parent[lastKey] = capturedValue;
|
|
1017
|
+
}
|
|
1018
|
+
} else {
|
|
1019
|
+
parent[lastKey] = capturedValue;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
})();
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
922
1026
|
// Check if we need to handle methods
|
|
923
1027
|
if (withMethodsSet) {
|
|
924
1028
|
const result = evaluatePathWithMethods(target, pathParts, value, withMethodsSet);
|
|
@@ -941,6 +1045,11 @@ export function assignGingerly(
|
|
|
941
1045
|
const lastKey = result.lastKey;
|
|
942
1046
|
const parent = result.target;
|
|
943
1047
|
|
|
1048
|
+
// Check for static assignTo protocol
|
|
1049
|
+
if (lastKey in parent && tryAssignTo(parent[lastKey], value, parent, lastKey)) {
|
|
1050
|
+
continue;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
944
1053
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
945
1054
|
// Check if property exists and is readonly
|
|
946
1055
|
if (lastKey in parent && isReadonlyProperty(parent, lastKey)) {
|
|
@@ -961,6 +1070,11 @@ export function assignGingerly(
|
|
|
961
1070
|
const lastKey = pathParts[pathParts.length - 1];
|
|
962
1071
|
const parent = ensureNestedPath(target, pathParts);
|
|
963
1072
|
|
|
1073
|
+
// Check for static assignTo protocol
|
|
1074
|
+
if (lastKey in parent && tryAssignTo(parent[lastKey], value, parent, lastKey)) {
|
|
1075
|
+
continue;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
964
1078
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
965
1079
|
// Check if property exists and is readonly
|
|
966
1080
|
if (lastKey in parent && isReadonlyProperty(parent, lastKey)) {
|
|
@@ -997,6 +1111,11 @@ export function assignGingerly(
|
|
|
997
1111
|
}
|
|
998
1112
|
|
|
999
1113
|
// Normal assignment
|
|
1114
|
+
// Check for static assignTo protocol
|
|
1115
|
+
if (key in target && tryAssignTo(target[key], value, target, key)) {
|
|
1116
|
+
continue;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1000
1119
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
1001
1120
|
// Check if property exists and is readonly
|
|
1002
1121
|
if (key in target && isReadonlyProperty(target, key)) {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* evaluatePathWithAsyncMethods - Async variant of evaluatePathWithMethods.
|
|
3
|
+
*
|
|
4
|
+
* Walks a path of property accesses and method calls, awaiting any methods
|
|
5
|
+
* that are in the withAsyncMethods set before continuing the chain.
|
|
6
|
+
*
|
|
7
|
+
* This module is loaded dynamically (only when withAsyncMethods is used)
|
|
8
|
+
* to avoid adding async overhead to the synchronous path.
|
|
9
|
+
*
|
|
10
|
+
* NOTE: Interaction with @each and @eachTime is not yet implemented.
|
|
11
|
+
* Deferred until a compelling use case presents itself.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Evaluates a path with support for both sync and async method calls.
|
|
15
|
+
* Awaits the return value of any method in the withAsyncMethods set.
|
|
16
|
+
*
|
|
17
|
+
* @param target - The root object to start path evaluation from
|
|
18
|
+
* @param pathParts - Array of path segments (split from '?.' notation)
|
|
19
|
+
* @param value - The value to assign or pass as argument at the end
|
|
20
|
+
* @param withMethods - Set of method names that are called synchronously
|
|
21
|
+
* @param withAsyncMethods - Set of method names that are awaited
|
|
22
|
+
* @returns Promise resolving to the evaluation result
|
|
23
|
+
*/
|
|
24
|
+
export async function evaluatePathWithAsyncMethods(target, pathParts, value, withMethods, withAsyncMethods) {
|
|
25
|
+
let current = target;
|
|
26
|
+
let i = 0;
|
|
27
|
+
// Process all segments except the last one
|
|
28
|
+
while (i < pathParts.length - 1) {
|
|
29
|
+
const part = pathParts[i];
|
|
30
|
+
const nextPart = pathParts[i + 1];
|
|
31
|
+
if (withAsyncMethods.has(part)) {
|
|
32
|
+
// Async method — call and await
|
|
33
|
+
const method = current[part];
|
|
34
|
+
if (typeof method === 'function') {
|
|
35
|
+
if (withAsyncMethods.has(nextPart) || withMethods.has(nextPart)) {
|
|
36
|
+
// Next is also a method — call current with no args
|
|
37
|
+
current = await method.call(current);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
// Call with next part as string arg, then await
|
|
41
|
+
current = await method.call(current, nextPart);
|
|
42
|
+
i++; // Skip next part since we consumed it as argument
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
// Not a function — just access property
|
|
47
|
+
if (current[part] === undefined || current[part] === null) {
|
|
48
|
+
current[part] = {};
|
|
49
|
+
}
|
|
50
|
+
current = current[part];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
else if (withMethods.has(part)) {
|
|
54
|
+
// Sync method — same logic as evaluatePathWithMethods
|
|
55
|
+
const method = current[part];
|
|
56
|
+
if (typeof method === 'function') {
|
|
57
|
+
if (withMethods.has(nextPart) || withAsyncMethods.has(nextPart)) {
|
|
58
|
+
// Next is also a method — call current with no args
|
|
59
|
+
current = method.call(current);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
// Call with next part as string arg
|
|
63
|
+
current = method.call(current, nextPart);
|
|
64
|
+
i++; // Skip next part since we consumed it as argument
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
if (!(part in current) || typeof current[part] !== 'object' || current[part] === null) {
|
|
69
|
+
current[part] = {};
|
|
70
|
+
}
|
|
71
|
+
current = current[part];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
// Not a method — normal property access
|
|
76
|
+
if (!(part in current) || typeof current[part] !== 'object' || current[part] === null) {
|
|
77
|
+
current[part] = {};
|
|
78
|
+
}
|
|
79
|
+
current = current[part];
|
|
80
|
+
}
|
|
81
|
+
i++;
|
|
82
|
+
}
|
|
83
|
+
const lastKey = pathParts[pathParts.length - 1];
|
|
84
|
+
return {
|
|
85
|
+
target: current,
|
|
86
|
+
lastKey,
|
|
87
|
+
isMethod: withMethods.has(lastKey),
|
|
88
|
+
isAsyncMethod: withAsyncMethods.has(lastKey)
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* evaluatePathWithAsyncMethods - Async variant of evaluatePathWithMethods.
|
|
3
|
+
*
|
|
4
|
+
* Walks a path of property accesses and method calls, awaiting any methods
|
|
5
|
+
* that are in the withAsyncMethods set before continuing the chain.
|
|
6
|
+
*
|
|
7
|
+
* This module is loaded dynamically (only when withAsyncMethods is used)
|
|
8
|
+
* to avoid adding async overhead to the synchronous path.
|
|
9
|
+
*
|
|
10
|
+
* NOTE: Interaction with @each and @eachTime is not yet implemented.
|
|
11
|
+
* Deferred until a compelling use case presents itself.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface AsyncPathResult {
|
|
15
|
+
target: any;
|
|
16
|
+
lastKey: string;
|
|
17
|
+
isMethod: boolean;
|
|
18
|
+
isAsyncMethod: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Evaluates a path with support for both sync and async method calls.
|
|
23
|
+
* Awaits the return value of any method in the withAsyncMethods set.
|
|
24
|
+
*
|
|
25
|
+
* @param target - The root object to start path evaluation from
|
|
26
|
+
* @param pathParts - Array of path segments (split from '?.' notation)
|
|
27
|
+
* @param value - The value to assign or pass as argument at the end
|
|
28
|
+
* @param withMethods - Set of method names that are called synchronously
|
|
29
|
+
* @param withAsyncMethods - Set of method names that are awaited
|
|
30
|
+
* @returns Promise resolving to the evaluation result
|
|
31
|
+
*/
|
|
32
|
+
export async function evaluatePathWithAsyncMethods(
|
|
33
|
+
target: any,
|
|
34
|
+
pathParts: string[],
|
|
35
|
+
value: any,
|
|
36
|
+
withMethods: Set<string>,
|
|
37
|
+
withAsyncMethods: Set<string>
|
|
38
|
+
): Promise<AsyncPathResult> {
|
|
39
|
+
let current = target;
|
|
40
|
+
let i = 0;
|
|
41
|
+
|
|
42
|
+
// Process all segments except the last one
|
|
43
|
+
while (i < pathParts.length - 1) {
|
|
44
|
+
const part = pathParts[i];
|
|
45
|
+
const nextPart = pathParts[i + 1];
|
|
46
|
+
|
|
47
|
+
if (withAsyncMethods.has(part)) {
|
|
48
|
+
// Async method — call and await
|
|
49
|
+
const method = current[part];
|
|
50
|
+
if (typeof method === 'function') {
|
|
51
|
+
if (withAsyncMethods.has(nextPart) || withMethods.has(nextPart)) {
|
|
52
|
+
// Next is also a method — call current with no args
|
|
53
|
+
current = await method.call(current);
|
|
54
|
+
} else {
|
|
55
|
+
// Call with next part as string arg, then await
|
|
56
|
+
current = await method.call(current, nextPart);
|
|
57
|
+
i++; // Skip next part since we consumed it as argument
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
// Not a function — just access property
|
|
61
|
+
if (current[part] === undefined || current[part] === null) {
|
|
62
|
+
current[part] = {};
|
|
63
|
+
}
|
|
64
|
+
current = current[part];
|
|
65
|
+
}
|
|
66
|
+
} else if (withMethods.has(part)) {
|
|
67
|
+
// Sync method — same logic as evaluatePathWithMethods
|
|
68
|
+
const method = current[part];
|
|
69
|
+
if (typeof method === 'function') {
|
|
70
|
+
if (withMethods.has(nextPart) || withAsyncMethods.has(nextPart)) {
|
|
71
|
+
// Next is also a method — call current with no args
|
|
72
|
+
current = method.call(current);
|
|
73
|
+
} else {
|
|
74
|
+
// Call with next part as string arg
|
|
75
|
+
current = method.call(current, nextPart);
|
|
76
|
+
i++; // Skip next part since we consumed it as argument
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
if (!(part in current) || typeof current[part] !== 'object' || current[part] === null) {
|
|
80
|
+
current[part] = {};
|
|
81
|
+
}
|
|
82
|
+
current = current[part];
|
|
83
|
+
}
|
|
84
|
+
} else {
|
|
85
|
+
// Not a method — normal property access
|
|
86
|
+
if (!(part in current) || typeof current[part] !== 'object' || current[part] === null) {
|
|
87
|
+
current[part] = {};
|
|
88
|
+
}
|
|
89
|
+
current = current[part];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
i++;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const lastKey = pathParts[pathParts.length - 1];
|
|
96
|
+
return {
|
|
97
|
+
target: current,
|
|
98
|
+
lastKey,
|
|
99
|
+
isMethod: withMethods.has(lastKey),
|
|
100
|
+
isAsyncMethod: withAsyncMethods.has(lastKey)
|
|
101
|
+
};
|
|
102
|
+
}
|
package/index.js
CHANGED
|
@@ -7,6 +7,8 @@ export { parseWithAttrs } from './parseWithAttrs.js';
|
|
|
7
7
|
export { buildCSSQuery } from './buildCSSQuery.js';
|
|
8
8
|
export { resolveTemplate } from './resolveTemplate.js';
|
|
9
9
|
export { getHost } from './getHost.js';
|
|
10
|
-
export { resolveValues } from './resolveValues.js';
|
|
10
|
+
export { resolveValues, resolveValue } from './resolveValues.js';
|
|
11
11
|
export { assignFrom } from './assignFrom.js';
|
|
12
|
+
export { assignFeatures, FeaturesRegistry, captureFeatureInitVals, PropertyBag } from './assignFeatures.js';
|
|
13
|
+
export { installForwarding } from './installForwarding.js';
|
|
12
14
|
import './object-extension.js';
|
package/index.ts
CHANGED
|
@@ -7,6 +7,8 @@ export {parseWithAttrs} from './parseWithAttrs.js';
|
|
|
7
7
|
export {buildCSSQuery} from './buildCSSQuery.js';
|
|
8
8
|
export {resolveTemplate} from './resolveTemplate.js';
|
|
9
9
|
export {getHost} from './getHost.js';
|
|
10
|
-
export {resolveValues} from './resolveValues.js';
|
|
10
|
+
export {resolveValues, resolveValue} from './resolveValues.js';
|
|
11
11
|
export {assignFrom} from './assignFrom.js';
|
|
12
|
+
export {assignFeatures, FeaturesRegistry, captureFeatureInitVals, PropertyBag} from './assignFeatures.js';
|
|
13
|
+
export {installForwarding} from './installForwarding.js';
|
|
12
14
|
import './object-extension.js';
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* installForwarding - Installs getter/setter property forwarding on a class prototype.
|
|
3
|
+
*
|
|
4
|
+
* Reads `static propLinks` from the constructor and installs getter/setter pairs
|
|
5
|
+
* that delegate to nested paths on the instance.
|
|
6
|
+
*
|
|
7
|
+
* - Getter uses `resolveValue` for full path resolution (with caching, method support, aliases).
|
|
8
|
+
* - Setter uses `assignGingerly` for path-based assignment (creates intermediates as needed).
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* import { installForwarding } from 'assign-gingerly/installForwarding.js';
|
|
12
|
+
*
|
|
13
|
+
* class ClubMember extends HTMLElement {
|
|
14
|
+
* static propLinks = {
|
|
15
|
+
* 'command': '?.behaviors?.commandBehavior?.command',
|
|
16
|
+
* 'commandForElement': '?.behaviors?.commandBehavior?.commandForElement'
|
|
17
|
+
* };
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* installForwarding(ClubMember);
|
|
21
|
+
* // Now el.command delegates to el.behaviors.commandBehavior.command
|
|
22
|
+
*/
|
|
23
|
+
import { resolveValue } from './resolveValues.js';
|
|
24
|
+
import assignGingerly from './assignGingerly.js';
|
|
25
|
+
/**
|
|
26
|
+
* Installs property forwarding on a class prototype based on `static propLinks`.
|
|
27
|
+
*
|
|
28
|
+
* Each entry in `propLinks` maps a top-level property name to a `?.`-delimited
|
|
29
|
+
* path string. A getter/setter pair is installed on the prototype that:
|
|
30
|
+
* - Getter: resolves the path against `this` using `resolveValue`
|
|
31
|
+
* - Setter: assigns the value at the path using `assignGingerly`
|
|
32
|
+
*
|
|
33
|
+
* @param ctr - The constructor whose prototype will receive forwarded properties
|
|
34
|
+
* @param options - Optional assignGingerly/resolveValues options (withMethods, aka, etc.)
|
|
35
|
+
* @throws If a forwarded property name already exists on the prototype
|
|
36
|
+
*/
|
|
37
|
+
export function installForwarding(ctr, options) {
|
|
38
|
+
const propLinks = ctr.propLinks;
|
|
39
|
+
if (!propLinks)
|
|
40
|
+
return;
|
|
41
|
+
for (const [propName, path] of Object.entries(propLinks)) {
|
|
42
|
+
// Validate: don't overwrite existing properties
|
|
43
|
+
if (Object.getOwnPropertyDescriptor(ctr.prototype, propName)) {
|
|
44
|
+
throw new Error(`installForwarding: "${propName}" already exists on ${ctr.name || 'constructor'}.prototype`);
|
|
45
|
+
}
|
|
46
|
+
// Validate: path must start with ?.
|
|
47
|
+
if (!path.startsWith('?.')) {
|
|
48
|
+
throw new Error(`installForwarding: path for "${propName}" must start with "?." — got "${path}"`);
|
|
49
|
+
}
|
|
50
|
+
Object.defineProperty(ctr.prototype, propName, {
|
|
51
|
+
get() {
|
|
52
|
+
return resolveValue(path, this, options);
|
|
53
|
+
},
|
|
54
|
+
set(value) {
|
|
55
|
+
assignGingerly(this, { [path]: value }, options);
|
|
56
|
+
},
|
|
57
|
+
enumerable: true,
|
|
58
|
+
configurable: true
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* installForwarding - Installs getter/setter property forwarding on a class prototype.
|
|
3
|
+
*
|
|
4
|
+
* Reads `static propLinks` from the constructor and installs getter/setter pairs
|
|
5
|
+
* that delegate to nested paths on the instance.
|
|
6
|
+
*
|
|
7
|
+
* - Getter uses `resolveValue` for full path resolution (with caching, method support, aliases).
|
|
8
|
+
* - Setter uses `assignGingerly` for path-based assignment (creates intermediates as needed).
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* import { installForwarding } from 'assign-gingerly/installForwarding.js';
|
|
12
|
+
*
|
|
13
|
+
* class ClubMember extends HTMLElement {
|
|
14
|
+
* static propLinks = {
|
|
15
|
+
* 'command': '?.behaviors?.commandBehavior?.command',
|
|
16
|
+
* 'commandForElement': '?.behaviors?.commandBehavior?.commandForElement'
|
|
17
|
+
* };
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* installForwarding(ClubMember);
|
|
21
|
+
* // Now el.command delegates to el.behaviors.commandBehavior.command
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { resolveValue, ResolveValuesOptions } from './resolveValues.js';
|
|
25
|
+
import assignGingerly, { IAssignGingerlyOptions } from './assignGingerly.js';
|
|
26
|
+
|
|
27
|
+
export interface InstallForwardingOptions extends ResolveValuesOptions, IAssignGingerlyOptions {}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Installs property forwarding on a class prototype based on `static propLinks`.
|
|
31
|
+
*
|
|
32
|
+
* Each entry in `propLinks` maps a top-level property name to a `?.`-delimited
|
|
33
|
+
* path string. A getter/setter pair is installed on the prototype that:
|
|
34
|
+
* - Getter: resolves the path against `this` using `resolveValue`
|
|
35
|
+
* - Setter: assigns the value at the path using `assignGingerly`
|
|
36
|
+
*
|
|
37
|
+
* @param ctr - The constructor whose prototype will receive forwarded properties
|
|
38
|
+
* @param options - Optional assignGingerly/resolveValues options (withMethods, aka, etc.)
|
|
39
|
+
* @throws If a forwarded property name already exists on the prototype
|
|
40
|
+
*/
|
|
41
|
+
export function installForwarding(ctr: Function, options?: InstallForwardingOptions): void {
|
|
42
|
+
const propLinks: Record<string, string> | undefined = (ctr as any).propLinks;
|
|
43
|
+
if (!propLinks) return;
|
|
44
|
+
|
|
45
|
+
for (const [propName, path] of Object.entries(propLinks)) {
|
|
46
|
+
// Validate: don't overwrite existing properties
|
|
47
|
+
if (Object.getOwnPropertyDescriptor(ctr.prototype, propName)) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`installForwarding: "${propName}" already exists on ${(ctr as any).name || 'constructor'}.prototype`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Validate: path must start with ?.
|
|
54
|
+
if (!path.startsWith('?.')) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`installForwarding: path for "${propName}" must start with "?." — got "${path}"`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
Object.defineProperty(ctr.prototype, propName, {
|
|
61
|
+
get(this: any) {
|
|
62
|
+
return resolveValue(path, this, options);
|
|
63
|
+
},
|
|
64
|
+
set(this: any, value: any) {
|
|
65
|
+
assignGingerly(this, { [path]: value }, options);
|
|
66
|
+
},
|
|
67
|
+
enumerable: true,
|
|
68
|
+
configurable: true
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|