assign-gingerly 0.0.54 → 0.0.56

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.
Files changed (112) hide show
  1. package/README.md +143 -2
  2. package/assignFrom.js +37 -18
  3. package/assignFrom.ts +62 -3
  4. package/assignGingerly.js +34 -1
  5. package/assignGingerly.ts +54 -1
  6. package/beVigilant.js +73 -0
  7. package/beVigilant.ts +85 -0
  8. package/enhanceAll.js +106 -0
  9. package/enhanceAll.ts +138 -0
  10. package/handlers/join.js +74 -0
  11. package/handlers/join.ts +80 -0
  12. package/handlers/lazyLoad.js +186 -0
  13. package/handlers/lazyLoad.ts +281 -0
  14. package/handlers/lazyLoadSwitch.js +58 -0
  15. package/handlers/lazyLoadSwitch.ts +64 -0
  16. package/handlers/microDataJoin.js +184 -0
  17. package/handlers/microDataJoin.ts +270 -0
  18. package/inferencer/.gitmodules +3 -0
  19. package/inferencer/.vscode/settings.json +2 -0
  20. package/inferencer/InferencedPropagator.js +230 -0
  21. package/inferencer/InferencedPropagator.ts +269 -0
  22. package/inferencer/LICENSE +21 -0
  23. package/inferencer/README.md +524 -0
  24. package/inferencer/Requirements/SupportForPropagator.md +368 -0
  25. package/inferencer/imports.html +7 -0
  26. package/inferencer/inferencer.js +254 -0
  27. package/inferencer/inferencer.ts +292 -0
  28. package/inferencer/package-lock.json +129 -0
  29. package/inferencer/package.json +60 -0
  30. package/inferencer/playwright-report/data/507ad515125e13390ea07de92f22331c913fa068.md +55 -0
  31. package/inferencer/playwright-report/index.html +90 -0
  32. package/inferencer/playwright.config.ts +54 -0
  33. package/inferencer/test-results/.last-run.json +6 -0
  34. package/inferencer/test-results/inferencer-Inferencer-Enha-535bc-inferencer-tests-in-browser-chromium/error-context.md +55 -0
  35. package/inferencer/tests/inferencedPropagator.html +428 -0
  36. package/inferencer/tests/inferencedPropagator.spec.ts +18 -0
  37. package/inferencer/tests/inferencer.html +355 -0
  38. package/inferencer/tests/inferencer.spec.ts +19 -0
  39. package/inferencer/tsconfig.json +19 -0
  40. package/inferencer/types/.kiro/specs/conversion-template/README.md +128 -0
  41. package/inferencer/types/.kiro/specs/conversion-template/design.md +360 -0
  42. package/inferencer/types/.kiro/specs/conversion-template/requirements.md +191 -0
  43. package/inferencer/types/.kiro/specs/conversion-template/tasks.md +174 -0
  44. package/inferencer/types/.kiro/steering/coding-standards.md +53 -0
  45. package/inferencer/types/.kiro/steering/conversion-guide.md +108 -0
  46. package/inferencer/types/.kiro/steering/declarative-configuration.md +108 -0
  47. package/inferencer/types/.kiro/steering/emc-json-serializability.md +306 -0
  48. package/inferencer/types/EnhancementConversionInstructions.md +1854 -0
  49. package/inferencer/types/LICENSE +21 -0
  50. package/inferencer/types/NewCustomElement.md +388 -0
  51. package/inferencer/types/NewCustomElementFeature.md +683 -0
  52. package/inferencer/types/NewEnhancementInstructions.md +705 -0
  53. package/inferencer/types/README.md +2 -0
  54. package/inferencer/types/agrace/types.d.ts +11 -0
  55. package/inferencer/types/assign-gingerly/types.d.ts +572 -0
  56. package/inferencer/types/be-a-beacon/types.d.ts +17 -0
  57. package/inferencer/types/be-bound/types.d.ts +66 -0
  58. package/inferencer/types/be-buttoned-up/types.d.ts +19 -0
  59. package/inferencer/types/be-calculating/types.d.ts +54 -0
  60. package/inferencer/types/be-clonable/types.d.ts +38 -0
  61. package/inferencer/types/be-committed/types.d.ts +22 -0
  62. package/inferencer/types/be-consoling/types.d.ts +24 -0
  63. package/inferencer/types/be-decked-with/types.d.ts +26 -0
  64. package/inferencer/types/be-delible/types.d.ts +27 -0
  65. package/inferencer/types/be-dispatching/types.d.ts +34 -0
  66. package/inferencer/types/be-evanescent/types.d.ts +20 -0
  67. package/inferencer/types/be-flashy/types.d.ts +21 -0
  68. package/inferencer/types/be-gone/types.d.ts +25 -0
  69. package/inferencer/types/be-observing/types.d.ts +55 -0
  70. package/inferencer/types/be-reflective/types.d.ts +78 -0
  71. package/inferencer/types/be-reformable/types.d.ts +49 -0
  72. package/inferencer/types/be-render-neutral/types.d.ts +32 -0
  73. package/inferencer/types/be-switched/types.d.ts +146 -0
  74. package/inferencer/types/be-typed/types.d.ts +32 -0
  75. package/inferencer/types/be-valued/types.d.ts +22 -0
  76. package/inferencer/types/data-props/types.d.ts +34 -0
  77. package/inferencer/types/do-inc/types.d.ts +56 -0
  78. package/inferencer/types/do-invoke/types.d.ts +38 -0
  79. package/inferencer/types/do-merge/types.d.ts +28 -0
  80. package/inferencer/types/do-toggle/types.d.ts +31 -0
  81. package/inferencer/types/face-up/types.d.ts +100 -0
  82. package/inferencer/types/fetch-for/types.d.ts +36 -0
  83. package/inferencer/types/folder-picker/types.d.ts +21 -0
  84. package/inferencer/types/global.d.ts +29 -0
  85. package/inferencer/types/id-generation/types.d.ts +26 -0
  86. package/inferencer/types/inferencer/types.d.ts +46 -0
  87. package/inferencer/types/mount-observer/types.d.ts +363 -0
  88. package/inferencer/types/nested-regex-groups/types.d.ts +107 -0
  89. package/inferencer/types/pipe-in/types.d.ts +52 -0
  90. package/inferencer/types/roundabout/types.d.ts +268 -0
  91. package/inferencer/types/soak-up/types.d.ts +40 -0
  92. package/inferencer/types/templ-maker/types.d.ts +43 -0
  93. package/inferencer/types/time-ticker/types.d.ts +62 -0
  94. package/inferencer/types/truth-sourcer/types.d.ts +44 -0
  95. package/inferencer/upSearch.js +27 -0
  96. package/inferencer/upSearch.ts +26 -0
  97. package/inferencer/withScopePerimeter.js +27 -0
  98. package/inferencer/withScopePerimeter.ts +33 -0
  99. package/inferredAssignments.js +38 -0
  100. package/inferredAssignments.ts +65 -0
  101. package/isAllowedImportPath.js +42 -0
  102. package/isAllowedImportPath.ts +53 -0
  103. package/markerUtils.js +127 -0
  104. package/markerUtils.ts +162 -0
  105. package/package.json +31 -1
  106. package/paths.js +49 -1
  107. package/paths.ts +80 -1
  108. package/processHandlerCommands.js +19 -19
  109. package/processHandlerCommands.ts +10 -13
  110. package/resolveIdRef.js +144 -125
  111. package/resolveIdRef.ts +30 -0
  112. package/types/assign-gingerly/types.d.ts +87 -0
@@ -0,0 +1,42 @@
1
+ /**
2
+ * isAllowedImportPath.ts — Security utility for validating import paths.
3
+ *
4
+ * Checks that a path is local (relative, absolute, or bare specifier) and
5
+ * not a cross-domain URL. Used to prevent untrusted HTML attributes from
6
+ * triggering imports to arbitrary external domains.
7
+ *
8
+ * @example
9
+ * import { isAllowedImportPath } from 'assign-gingerly/isAllowedImportPath.js';
10
+ *
11
+ * isAllowedImportPath('./local.js'); // true
12
+ * isAllowedImportPath('../parent/file.js'); // true
13
+ * isAllowedImportPath('/absolute/path.js'); // true
14
+ * isAllowedImportPath('bare-specifier/mod.js'); // true
15
+ * isAllowedImportPath('https://evil.com/x.js'); // false
16
+ * isAllowedImportPath('//cdn.example.com/x.js'); // false
17
+ */
18
+ /**
19
+ * Check if an import path is allowed (non-cross-domain).
20
+ *
21
+ * Allowed:
22
+ * - Relative paths: ./foo.js, ../bar.js
23
+ * - Absolute paths: /path/to/file.js
24
+ * - Bare specifiers: package-name/file.js, @scope/package/file.js
25
+ *
26
+ * Blocked:
27
+ * - Protocol URLs: https://..., http://..., data:..., etc.
28
+ * - Protocol-relative: //cdn.example.com/...
29
+ *
30
+ * @param path - The import path to validate
31
+ * @returns true if the path is local/safe, false if cross-domain
32
+ */
33
+ export function isAllowedImportPath(path) {
34
+ if (path.startsWith('./') || path.startsWith('../') || path.startsWith('/')) {
35
+ return true;
36
+ }
37
+ if (path.includes('://') || path.startsWith('//')) {
38
+ return false;
39
+ }
40
+ // Bare specifier (no protocol, no //) — allowed
41
+ return true;
42
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * isAllowedImportPath.ts — Security utility for validating import paths.
3
+ *
4
+ * Checks that a path is local (relative, absolute, or bare specifier) and
5
+ * not a cross-domain URL. Used to prevent untrusted HTML attributes from
6
+ * triggering imports to arbitrary external domains.
7
+ *
8
+ * @example
9
+ * import { isAllowedImportPath } from 'assign-gingerly/isAllowedImportPath.js';
10
+ *
11
+ * isAllowedImportPath('./local.js'); // true
12
+ * isAllowedImportPath('../parent/file.js'); // true
13
+ * isAllowedImportPath('/absolute/path.js'); // true
14
+ * isAllowedImportPath('bare-specifier/mod.js'); // true
15
+ * isAllowedImportPath('https://evil.com/x.js'); // false
16
+ * isAllowedImportPath('//cdn.example.com/x.js'); // false
17
+ */
18
+
19
+ /**
20
+ * Permissions interface for controlling security-sensitive operations.
21
+ * Passed as the last parameter to assignGingerly, assignFrom, and enhanceAll.
22
+ * Only trusted script can set these — they are never parsed from HTML attributes.
23
+ */
24
+ export interface AssignPermissions {
25
+ /** Allow imports from cross-domain URLs (default: false) */
26
+ crossDomainImports?: boolean;
27
+ }
28
+
29
+ /**
30
+ * Check if an import path is allowed (non-cross-domain).
31
+ *
32
+ * Allowed:
33
+ * - Relative paths: ./foo.js, ../bar.js
34
+ * - Absolute paths: /path/to/file.js
35
+ * - Bare specifiers: package-name/file.js, @scope/package/file.js
36
+ *
37
+ * Blocked:
38
+ * - Protocol URLs: https://..., http://..., data:..., etc.
39
+ * - Protocol-relative: //cdn.example.com/...
40
+ *
41
+ * @param path - The import path to validate
42
+ * @returns true if the path is local/safe, false if cross-domain
43
+ */
44
+ export function isAllowedImportPath(path: string): boolean {
45
+ if (path.startsWith('./') || path.startsWith('../') || path.startsWith('/')) {
46
+ return true;
47
+ }
48
+ if (path.includes('://') || path.startsWith('//')) {
49
+ return false;
50
+ }
51
+ // Bare specifier (no protocol, no //) — allowed
52
+ return true;
53
+ }
package/markerUtils.js ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * markerUtils.js — Shared utilities for comment marker management.
3
+ *
4
+ * Used by lazyLoad, microDataJoin, and future template loop handlers.
5
+ */
6
+
7
+ export const MARKER_START_PREFIX = '?start name="';
8
+ export const MARKER_END = '?end';
9
+
10
+ /**
11
+ * Find existing start/end comment markers (TreeWalker approach).
12
+ */
13
+ export function findMarkers(target, name) {
14
+ const startText = `${MARKER_START_PREFIX}${name}"`;
15
+ let startMarker = null;
16
+ let endMarker = null;
17
+
18
+ const walker = document.createTreeWalker(target, NodeFilter.SHOW_COMMENT);
19
+ let node;
20
+ while ((node = walker.nextNode())) {
21
+ if (!startMarker && node.data === startText) {
22
+ startMarker = node;
23
+ } else if (startMarker && !endMarker && node.data === MARKER_END) {
24
+ endMarker = node;
25
+ break;
26
+ }
27
+ }
28
+
29
+ return [startMarker, endMarker];
30
+ }
31
+
32
+ /**
33
+ * Find existing start/end comment markers (XPath approach).
34
+ */
35
+ export function findMarkersXPath(target, name) {
36
+ const startText = `${MARKER_START_PREFIX}${name}"`;
37
+
38
+ const startResult = document.evaluate(
39
+ `.//comment()[. = "${startText}"]`,
40
+ target,
41
+ null,
42
+ XPathResult.FIRST_ORDERED_NODE_TYPE,
43
+ null
44
+ );
45
+ const startMarker = startResult.singleNodeValue;
46
+ if (!startMarker) return [null, null];
47
+
48
+ const endResult = document.evaluate(
49
+ `following-sibling::comment()[. = "${MARKER_END}"][1]`,
50
+ startMarker,
51
+ null,
52
+ XPathResult.FIRST_ORDERED_NODE_TYPE,
53
+ null
54
+ );
55
+ const endMarker = endResult.singleNodeValue;
56
+
57
+ return [startMarker, endMarker];
58
+ }
59
+
60
+ /**
61
+ * Create start/end markers and insert them into the target.
62
+ */
63
+ export function createMarkers(target, name, method = 'appendChild') {
64
+ const startMarker = document.createComment(`${MARKER_START_PREFIX}${name}"`);
65
+ const endMarker = document.createComment(MARKER_END);
66
+
67
+ if (method === 'prepend') {
68
+ target.prepend(endMarker);
69
+ target.prepend(startMarker);
70
+ } else {
71
+ target.appendChild(startMarker);
72
+ target.appendChild(endMarker);
73
+ }
74
+
75
+ return [startMarker, endMarker];
76
+ }
77
+
78
+ /**
79
+ * Get all nodes between start and end markers.
80
+ */
81
+ export function getNodesBetweenMarkers(start, end) {
82
+ const nodes = [];
83
+ let current = start.nextSibling;
84
+ while (current && current !== end) {
85
+ nodes.push(current);
86
+ current = current.nextSibling;
87
+ }
88
+ return nodes;
89
+ }
90
+
91
+ /**
92
+ * Find existing start/end comment markers among siblings of an anchor element.
93
+ * Used for 'after' insertion mode.
94
+ */
95
+ export function findMarkersSibling(anchor, name) {
96
+ const startText = `${MARKER_START_PREFIX}${name}"`;
97
+ let startMarker = null;
98
+ let endMarker = null;
99
+
100
+ let current = anchor.nextSibling;
101
+ while (current) {
102
+ if (current.nodeType === Node.COMMENT_NODE) {
103
+ if (!startMarker && current.data === startText) {
104
+ startMarker = current;
105
+ } else if (startMarker && !endMarker && current.data === MARKER_END) {
106
+ endMarker = current;
107
+ break;
108
+ }
109
+ }
110
+ current = current.nextSibling;
111
+ }
112
+
113
+ return [startMarker, endMarker];
114
+ }
115
+
116
+ /**
117
+ * Create start/end markers as siblings after an anchor element.
118
+ * Used for 'after' insertion mode.
119
+ */
120
+ export function createMarkersSibling(anchor, name) {
121
+ const startMarker = document.createComment(`${MARKER_START_PREFIX}${name}"`);
122
+ const endMarker = document.createComment(MARKER_END);
123
+
124
+ anchor.after(startMarker, endMarker);
125
+
126
+ return [startMarker, endMarker];
127
+ }
package/markerUtils.ts ADDED
@@ -0,0 +1,162 @@
1
+ /**
2
+ * markerUtils.ts — Shared utilities for comment marker management.
3
+ *
4
+ * Used by lazyLoad, microDataJoin, and future template loop handlers.
5
+ * Provides finding, creating, and traversing comment marker pairs.
6
+ *
7
+ * Markers are HTML comment nodes with specific content:
8
+ * - Start: <!--?start name="markerName"-->
9
+ * - End: <!--?end-->
10
+ */
11
+
12
+ export const MARKER_START_PREFIX = '?start name="';
13
+ export const MARKER_END = '?end';
14
+
15
+ /**
16
+ * Find existing start/end comment markers in a target element (TreeWalker approach).
17
+ * Searches the subtree of `target` for matching comment nodes.
18
+ *
19
+ * @param target - The element to search within
20
+ * @param name - The marker name to find
21
+ * @returns [startMarker, endMarker] or [null, null] if not found
22
+ */
23
+ export function findMarkers(target: Element | Node, name: string): [Comment | null, Comment | null] {
24
+ const startText = `${MARKER_START_PREFIX}${name}"`;
25
+ let startMarker: Comment | null = null;
26
+ let endMarker: Comment | null = null;
27
+
28
+ const walker = document.createTreeWalker(target as Node, NodeFilter.SHOW_COMMENT);
29
+ let node: Comment | null;
30
+ while ((node = walker.nextNode() as Comment | null)) {
31
+ if (!startMarker && node.data === startText) {
32
+ startMarker = node;
33
+ } else if (startMarker && !endMarker && node.data === MARKER_END) {
34
+ endMarker = node;
35
+ break;
36
+ }
37
+ }
38
+
39
+ return [startMarker, endMarker];
40
+ }
41
+
42
+ /**
43
+ * Find existing start/end comment markers using XPath (alternative approach).
44
+ * May be faster in large DOMs due to engine-level indexing.
45
+ *
46
+ * @param target - The element to search within
47
+ * @param name - The marker name to find
48
+ * @returns [startMarker, endMarker] or [null, null] if not found
49
+ */
50
+ export function findMarkersXPath(target: Element | Node, name: string): [Comment | null, Comment | null] {
51
+ const startText = `${MARKER_START_PREFIX}${name}"`;
52
+
53
+ const startResult = document.evaluate(
54
+ `.//comment()[. = "${startText}"]`,
55
+ target,
56
+ null,
57
+ XPathResult.FIRST_ORDERED_NODE_TYPE,
58
+ null
59
+ );
60
+ const startMarker = startResult.singleNodeValue as Comment | null;
61
+ if (!startMarker) return [null, null];
62
+
63
+ // Find the next sibling comment that is the end marker
64
+ const endResult = document.evaluate(
65
+ `following-sibling::comment()[. = "${MARKER_END}"][1]`,
66
+ startMarker,
67
+ null,
68
+ XPathResult.FIRST_ORDERED_NODE_TYPE,
69
+ null
70
+ );
71
+ const endMarker = endResult.singleNodeValue as Comment | null;
72
+
73
+ return [startMarker, endMarker];
74
+ }
75
+
76
+ /**
77
+ * Create start/end markers and insert them into the target.
78
+ *
79
+ * @param target - The element to insert markers into
80
+ * @param name - The marker name
81
+ * @param method - 'appendChild' (default) or 'prepend'
82
+ * @returns [startMarker, endMarker]
83
+ */
84
+ export function createMarkers(target: Element, name: string, method: string = 'appendChild'): [Comment, Comment] {
85
+ const startMarker = document.createComment(`${MARKER_START_PREFIX}${name}"`);
86
+ const endMarker = document.createComment(MARKER_END);
87
+
88
+ if (method === 'prepend') {
89
+ target.prepend(endMarker);
90
+ target.prepend(startMarker);
91
+ } else {
92
+ target.appendChild(startMarker);
93
+ target.appendChild(endMarker);
94
+ }
95
+
96
+ return [startMarker, endMarker];
97
+ }
98
+
99
+ /**
100
+ * Get all nodes between start and end markers.
101
+ *
102
+ * @param start - The start comment marker
103
+ * @param end - The end comment marker
104
+ * @returns Array of nodes between the markers (exclusive of markers themselves)
105
+ */
106
+ export function getNodesBetweenMarkers(start: Comment, end: Comment): Node[] {
107
+ const nodes: Node[] = [];
108
+ let current: Node | null = start.nextSibling;
109
+ while (current && current !== end) {
110
+ nodes.push(current);
111
+ current = current.nextSibling;
112
+ }
113
+ return nodes;
114
+ }
115
+
116
+ /**
117
+ * Find existing start/end comment markers among siblings of an anchor element.
118
+ * Used for 'after' insertion mode where markers are siblings, not children.
119
+ *
120
+ * @param anchor - The element after which markers were inserted
121
+ * @param name - The marker name to find
122
+ * @returns [startMarker, endMarker] or [null, null] if not found
123
+ */
124
+ export function findMarkersSibling(anchor: Element | Node, name: string): [Comment | null, Comment | null] {
125
+ const startText = `${MARKER_START_PREFIX}${name}"`;
126
+ let startMarker: Comment | null = null;
127
+ let endMarker: Comment | null = null;
128
+
129
+ let current: Node | null = anchor.nextSibling;
130
+ while (current) {
131
+ if (current.nodeType === Node.COMMENT_NODE) {
132
+ const comment = current as Comment;
133
+ if (!startMarker && comment.data === startText) {
134
+ startMarker = comment;
135
+ } else if (startMarker && !endMarker && comment.data === MARKER_END) {
136
+ endMarker = comment;
137
+ break;
138
+ }
139
+ }
140
+ current = current.nextSibling;
141
+ }
142
+
143
+ return [startMarker, endMarker];
144
+ }
145
+
146
+ /**
147
+ * Create start/end markers as siblings after an anchor element.
148
+ * Used for 'after' insertion mode.
149
+ *
150
+ * @param anchor - The element to insert markers after
151
+ * @param name - The marker name
152
+ * @returns [startMarker, endMarker]
153
+ */
154
+ export function createMarkersSibling(anchor: Element | Node, name: string): [Comment, Comment] {
155
+ const startMarker = document.createComment(`${MARKER_START_PREFIX}${name}"`);
156
+ const endMarker = document.createComment(MARKER_END);
157
+
158
+ // Insert after the anchor: anchor → startMarker → endMarker
159
+ (anchor as ChildNode).after(startMarker, endMarker);
160
+
161
+ return [startMarker, endMarker];
162
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "assign-gingerly",
3
- "version": "0.0.54",
3
+ "version": "0.0.56",
4
4
  "description": "This package provides a utility function for carefully merging one object into another.",
5
5
  "homepage": "https://github.com/bahrus/assign-gingerly#readme",
6
6
  "bugs": {
@@ -17,6 +17,8 @@
17
17
  "files": [
18
18
  "*.js",
19
19
  "*.ts",
20
+ "handlers/**",
21
+ "inferencer/**",
20
22
  "README.md",
21
23
  "LICENSE",
22
24
  "types/assign-gingerly/types.d.ts"
@@ -100,6 +102,34 @@
100
102
  "default": "./paths.js",
101
103
  "types": "./paths.ts"
102
104
  },
105
+ "./enhanceAll.js": {
106
+ "default": "./enhanceAll.js",
107
+ "types": "./enhanceAll.ts"
108
+ },
109
+ "./isAllowedImportPath.js": {
110
+ "default": "./isAllowedImportPath.js",
111
+ "types": "./isAllowedImportPath.ts"
112
+ },
113
+ "./markerUtils.js": {
114
+ "default": "./markerUtils.js",
115
+ "types": "./markerUtils.ts"
116
+ },
117
+ "./inferredAssignments.js": {
118
+ "default": "./inferredAssignments.js",
119
+ "types": "./inferredAssignments.ts"
120
+ },
121
+ "./beVigilant.js": {
122
+ "default": "./beVigilant.js",
123
+ "types": "./beVigilant.ts"
124
+ },
125
+ "./inferencer/inferencer.js": {
126
+ "default": "./inferencer/inferencer.js",
127
+ "types": "./inferencer/inferencer.ts"
128
+ },
129
+ "./inferencer/withScopePerimeter.js": {
130
+ "default": "./inferencer/withScopePerimeter.js",
131
+ "types": "./inferencer/withScopePerimeter.ts"
132
+ },
103
133
  "./assignFrom.js": {
104
134
  "default": "./assignFrom.js",
105
135
  "types": "./assignFrom.ts"
package/paths.js CHANGED
@@ -10,9 +10,41 @@
10
10
  */
11
11
  const PATH_SYMBOL = Symbol('assign-gingerly-path');
12
12
 
13
+ /**
14
+ * Create a proxy for id-ref paths (#[varName]).
15
+ */
16
+ function createIdRefProxy(idRef, options) {
17
+ function handler() {}
18
+ return new Proxy(handler, {
19
+ get(_, prop) {
20
+ if (prop === 'path' || prop === PATH_SYMBOL) {
21
+ return idRef;
22
+ }
23
+ if (typeof prop === 'symbol') return undefined;
24
+ const chained = `${idRef}?.${String(prop)}`;
25
+ return createIdRefProxy(chained, options);
26
+ },
27
+ apply(_, __, args) {
28
+ if (args.length > 0) {
29
+ const arg = args[0];
30
+ let argStr;
31
+ if (arg === true) argStr = 'true';
32
+ else if (arg === false) argStr = 'false';
33
+ else if (arg && typeof arg === 'object' && PATH_SYMBOL in arg) {
34
+ const fullPath = arg[PATH_SYMBOL];
35
+ argStr = fullPath.startsWith('?.') ? fullPath.substring(2) : fullPath;
36
+ }
37
+ else argStr = String(arg);
38
+ const chained = `${idRef}?.${argStr}`;
39
+ return createIdRefProxy(chained, options);
40
+ }
41
+ return createIdRefProxy(idRef, options);
42
+ }
43
+ });
44
+ }
45
+
13
46
  /**
14
47
  * Create a recursive proxy that records property access paths.
15
- * Supports both property access and method call syntax (via apply trap on function target).
16
48
  */
17
49
  function createPathProxy(prefix, options) {
18
50
  const aliasMap = options?.aka;
@@ -27,6 +59,14 @@ function createPathProxy(prefix, options) {
27
59
  if (typeof prop === 'symbol') return undefined;
28
60
 
29
61
  let segment = String(prop);
62
+
63
+ // #-prefix: $['#firstName'] → '#[firstName]' (cached element ref)
64
+ if (segment.startsWith('#')) {
65
+ const varName = segment.substring(1);
66
+ const idRef = `#[${varName}]`;
67
+ return createIdRefProxy(idRef, options);
68
+ }
69
+
30
70
  if (aliasMap) {
31
71
  for (const [alias, target] of Object.entries(aliasMap)) {
32
72
  if (target === segment) { segment = alias; break; }
@@ -111,6 +151,14 @@ export function doAssign(...pairs) {
111
151
  return { assign: Object.assign({}, ...pairs) };
112
152
  }
113
153
 
154
+ /**
155
+ * Compile-time loop expansion: generates one entry per key from a factory function.
156
+ */
157
+ export function forEachKeyIn(keys, factory, options) {
158
+ const $ = paths(options);
159
+ return keys.map(key => factory(key, $));
160
+ }
161
+
114
162
  /**
115
163
  * Tagged template literal that splits a template into an array of parts.
116
164
  * Path proxy objects are auto-detected and converted to path strings.
package/paths.ts CHANGED
@@ -53,6 +53,44 @@ export interface PathsOptions {
53
53
  withMethods?: string[] | Set<string>;
54
54
  }
55
55
 
56
+ /**
57
+ * Create a proxy for id-ref paths (#[varName]).
58
+ * After the initial #[varName], further property access chains with ?. from the resolved element.
59
+ * .path returns the #[varName] prefix (optionally with further ?. path).
60
+ */
61
+ function createIdRefProxy(idRef: string, options?: PathsOptions): any {
62
+ function handler() {}
63
+ return new Proxy(handler, {
64
+ get(_, prop: string | symbol) {
65
+ if (prop === 'path' || prop === PATH_SYMBOL) {
66
+ return idRef;
67
+ }
68
+ if (typeof prop === 'symbol') return undefined;
69
+
70
+ // Chain further path segments after the id ref
71
+ const chained = `${idRef}?.${String(prop)}`;
72
+ return createIdRefProxy(chained, options);
73
+ },
74
+ apply(_, __, args) {
75
+ if (args.length > 0) {
76
+ const arg = args[0];
77
+ let argStr: string;
78
+ if (arg === true) argStr = 'true';
79
+ else if (arg === false) argStr = 'false';
80
+ else if (arg && typeof arg === 'object' && PATH_SYMBOL in arg) {
81
+ const fullPath = arg[PATH_SYMBOL] as string;
82
+ argStr = fullPath.startsWith('?.') ? fullPath.substring(2) : fullPath;
83
+ }
84
+ else argStr = String(arg);
85
+
86
+ const chained = `${idRef}?.${argStr}`;
87
+ return createIdRefProxy(chained, options);
88
+ }
89
+ return createIdRefProxy(idRef, options);
90
+ }
91
+ });
92
+ }
93
+
56
94
  /**
57
95
  * Create a recursive proxy that records property access paths.
58
96
  * Supports both property access and method call syntax (via apply trap on function target).
@@ -74,8 +112,17 @@ function createPathProxy(prefix: string, options?: PathsOptions): any {
74
112
  // Ignore symbol access (Symbol.iterator, Symbol.toPrimitive, etc.)
75
113
  if (typeof prop === 'symbol') return undefined;
76
114
 
77
- // Apply reverse alias: if prop matches an alias value, use the alias key
78
115
  let segment = String(prop);
116
+
117
+ // #-prefix: $['#firstName'] → '#[firstName]' (cached element ref)
118
+ if (segment.startsWith('#')) {
119
+ const varName = segment.substring(1);
120
+ const idRef = `#[${varName}]`;
121
+ // Return a proxy that starts from this id ref (can chain further with ?.)
122
+ return createIdRefProxy(idRef, options);
123
+ }
124
+
125
+ // Apply reverse alias: if prop matches an alias value, use the alias key
79
126
  if (aliasMap) {
80
127
  for (const [alias, target] of Object.entries(aliasMap)) {
81
128
  if (target === segment) { segment = alias; break; }
@@ -222,6 +269,38 @@ export function smoothOver(value: any): any {
222
269
  export function doAssign(...pairs: Record<string, any>[]): { assign: Record<string, any> } {
223
270
  return { assign: Object.assign({}, ...pairs) };
224
271
  }
272
+
273
+ /**
274
+ * Compile-time loop expansion: generates one entry per key from a factory function.
275
+ * Creates a typed proxy internally — the factory receives both the key and the proxy.
276
+ *
277
+ * @param keys - Array of property names to iterate (type-checked against T)
278
+ * @param factory - Function that produces a config entry for each key
279
+ * @param options - Optional PathsOptions (aka, withMethods) for the internal proxy
280
+ * @returns Array of factory results (one per key) — spread into merges array
281
+ *
282
+ * @example
283
+ * import { forEachKeyIn, set, doAssign } from 'assign-gingerly/paths.js';
284
+ *
285
+ * interface Person extends HTMLElement { firstName: string; lastName: string; }
286
+ *
287
+ * const merges = [
288
+ * ...forEachKeyIn<Person>(['firstName', 'lastName'], (key, $) => ({
289
+ * ifKeyIn: [key],
290
+ * assignOptions: { withIds: { [key]: { qry: `[name="${key}"]` } } },
291
+ * ...doAssign(set($['#' + key]).to($[key]))
292
+ * })),
293
+ * ];
294
+ */
295
+ export function forEachKeyIn<T>(
296
+ keys: (keyof T & string)[],
297
+ factory: (key: keyof T & string, proxy: PathProxy<T>) => any,
298
+ options?: PathsOptions
299
+ ): any[] {
300
+ const $ = paths<T>(options);
301
+ return keys.map(key => factory(key, $));
302
+ }
303
+
225
304
  /**
226
305
  * Tagged template literal that splits a template into an array of parts.
227
306
  * Interleaves static string segments with interpolated values.