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/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
- return path
210
- .split('?.')
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
- return path
335
- .split('?.')
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
+ }