assign-gingerly 0.0.66 → 0.0.68

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.
@@ -0,0 +1,49 @@
1
+ /**
2
+ * IterableMixin
3
+ * -------------
4
+ * Adds a private `TItem[]` list to the *instance* (storage has to live
5
+ * somewhere), but exposes all the behavior — getItems/setItems/assignTo
6
+ * — as STATIC methods on the resulting constructor, matching the
7
+ * original `static getItems(instance)` style. No instance methods are
8
+ * added.
9
+ *
10
+ * Because private fields are only reachable from code written inside
11
+ * the class body, the static methods have to be declared right next
12
+ * to the `#myList` field — that's what makes `instance.#myList` legal
13
+ * from a `static` method.
14
+ *
15
+ * Works on HTMLElement subclasses (dispatches an event if the instance
16
+ * supports it) and on plain classes (skips the dispatch silently).
17
+ */
18
+ export function IterableMixin() {
19
+ return function (Base) {
20
+ class WithIterableStatics extends Base {
21
+ #myList = [];
22
+ static getItems(instance) {
23
+ return instance.#myList;
24
+ }
25
+ static setItems(instance, items) {
26
+ instance.#myList = items;
27
+ WithIterableStatics.#notifyChange(instance);
28
+ }
29
+ static assignTo(instance, rhs) {
30
+ if (Array.isArray(rhs)) {
31
+ WithIterableStatics.setItems(instance, rhs);
32
+ }
33
+ else if (typeof rhs === 'object' && rhs !== null) {
34
+ Object.assign(instance, rhs);
35
+ }
36
+ }
37
+ static #notifyChange(instance) {
38
+ // Only dispatch if this instance actually supports it
39
+ // (i.e. it's an HTMLElement / EventTarget). Plain classes
40
+ // just skip this silently.
41
+ const maybeTarget = instance;
42
+ if (typeof maybeTarget.dispatchEvent === 'function') {
43
+ maybeTarget.dispatchEvent(new Event('items-changed'));
44
+ }
45
+ }
46
+ }
47
+ return WithIterableStatics;
48
+ };
49
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * A constructor type — anything `new`-able. Required so the mixin
3
+ * can be applied on top of an arbitrary base class.
4
+ */
5
+ type Constructor<T = {}> = new (...args: any[]) => T;
6
+
7
+ /**
8
+ * IterableMixin
9
+ * -------------
10
+ * Adds a private `TItem[]` list to the *instance* (storage has to live
11
+ * somewhere), but exposes all the behavior — getItems/setItems/assignTo
12
+ * — as STATIC methods on the resulting constructor, matching the
13
+ * original `static getItems(instance)` style. No instance methods are
14
+ * added.
15
+ *
16
+ * Because private fields are only reachable from code written inside
17
+ * the class body, the static methods have to be declared right next
18
+ * to the `#myList` field — that's what makes `instance.#myList` legal
19
+ * from a `static` method.
20
+ *
21
+ * Works on HTMLElement subclasses (dispatches an event if the instance
22
+ * supports it) and on plain classes (skips the dispatch silently).
23
+ */
24
+ export function IterableMixin<TItem>() {
25
+ return function <TBase extends Constructor>(Base: TBase) {
26
+ class WithIterableStatics extends Base {
27
+ #myList: TItem[] = [];
28
+
29
+ static getItems(instance: WithIterableStatics): TItem[] {
30
+ return instance.#myList;
31
+ }
32
+
33
+ static setItems(instance: WithIterableStatics, items: TItem[]): void {
34
+ instance.#myList = items;
35
+ WithIterableStatics.#notifyChange(instance);
36
+ }
37
+
38
+ static assignTo(
39
+ instance: WithIterableStatics,
40
+ rhs: TItem[] | Record<string, unknown>
41
+ ): void {
42
+ if (Array.isArray(rhs)) {
43
+ WithIterableStatics.setItems(instance, rhs);
44
+ } else if (typeof rhs === 'object' && rhs !== null) {
45
+ Object.assign(instance, rhs);
46
+ }
47
+ }
48
+
49
+ static #notifyChange(instance: WithIterableStatics): void {
50
+ // Only dispatch if this instance actually supports it
51
+ // (i.e. it's an HTMLElement / EventTarget). Plain classes
52
+ // just skip this silently.
53
+ const maybeTarget = instance as unknown as { dispatchEvent?: (e: Event) => boolean };
54
+ if (typeof maybeTarget.dispatchEvent === 'function') {
55
+ maybeTarget.dispatchEvent(new Event('items-changed'));
56
+ }
57
+ }
58
+ }
59
+
60
+ return WithIterableStatics;
61
+ };
62
+ }
@@ -33,7 +33,8 @@ export const akaMethods = {
33
33
  '🔍': 'querySelector',
34
34
  '🧺': 'querySelectorAll',
35
35
  '+': 'add',
36
- '🧬': 'cloneNode'
36
+ '🧬': 'cloneNode',
37
+ '🔤': 'textContent',
37
38
  };
38
39
  export const aka = {
39
40
  '©️': 'content?.cloneNode?.true',
@@ -35,7 +35,8 @@ export const akaMethods: Record<string, string> = {
35
35
  '🔍': 'querySelector',
36
36
  '🧺': 'querySelectorAll',
37
37
  '+': 'add',
38
- '🧬': 'cloneNode'
38
+ '🧬': 'cloneNode',
39
+ '🔤': 'textContent',
39
40
  };
40
41
 
41
42
  export const aka: Record<string, string> = {
@@ -20,8 +20,8 @@
20
20
  * installForwarding(ClubMember);
21
21
  * // Now el.command delegates to el.behaviors.commandBehavior.command
22
22
  */
23
- import { resolveValue } from './resolveValues.js';
24
- import assignGingerly from './assignGingerly.js';
23
+ import { resolveValue } from '../resolveValues.js';
24
+ import assignGingerly from '../assignGingerly.js';
25
25
  /**
26
26
  * Installs property forwarding on a class prototype based on `static propLinks`.
27
27
  *
@@ -21,8 +21,8 @@
21
21
  * // Now el.command delegates to el.behaviors.commandBehavior.command
22
22
  */
23
23
 
24
- import { resolveValue, ResolveValuesOptions } from './resolveValues.js';
25
- import assignGingerly, { IAssignGingerlyOptions } from './assignGingerly.js';
24
+ import { resolveValue, ResolveValuesOptions } from '../resolveValues.js';
25
+ import assignGingerly, { IAssignGingerlyOptions } from '../assignGingerly.js';
26
26
 
27
27
  export interface InstallForwardingOptions extends ResolveValuesOptions, IAssignGingerlyOptions {}
28
28
 
package/DX/paths.js ADDED
@@ -0,0 +1,381 @@
1
+ /**
2
+ * paths.ts — Typed path proxy and template tag for assignFrom authoring.
3
+ *
4
+ * Provides compile-time autocomplete and type safety for `?.`-prefixed path strings.
5
+ *
6
+ * @example
7
+ * import { paths, sp } from 'assign-gingerly/DX/paths.js';
8
+ *
9
+ * interface Person {
10
+ * firstName?: string;
11
+ * middleName?: string;
12
+ * lastName: string;
13
+ * address: { city: string; zip: string };
14
+ * }
15
+ *
16
+ * const $ = paths<Person>();
17
+ *
18
+ * // Use sp (split into parts) to create arrays for builtIns.join:
19
+ * const value = sp`${$.lastName}, ${$.firstName}`;
20
+ * // ['?.lastName', ', ', '?.firstName']
21
+ *
22
+ * // Use .path for raw string contexts (object keys, plain arrays):
23
+ * const key = $.textContent.path; // '?.textContent'
24
+ */
25
+ /**
26
+ * Symbol used internally to detect path proxy objects.
27
+ * The sp tag function uses this to auto-extract path strings from proxies.
28
+ */
29
+ const PATH_SYMBOL = Symbol('assign-gingerly-path');
30
+ /**
31
+ * Create a proxy for id-ref paths (#[varName]).
32
+ * After the initial #[varName], further property access chains with ?. from the resolved element.
33
+ * .path returns the #[varName] prefix (optionally with further ?. path).
34
+ */
35
+ function createIdRefProxy(idRef, options) {
36
+ function handler() { }
37
+ return new Proxy(handler, {
38
+ get(_, prop) {
39
+ if (prop === 'path' || prop === PATH_SYMBOL) {
40
+ return idRef;
41
+ }
42
+ if (typeof prop === 'symbol')
43
+ return undefined;
44
+ // Chain further path segments after the id ref
45
+ const chained = `${idRef}?.${String(prop)}`;
46
+ return createIdRefProxy(chained, options);
47
+ },
48
+ apply(_, __, args) {
49
+ if (args.length > 0) {
50
+ const arg = args[0];
51
+ let argStr;
52
+ if (arg === true)
53
+ argStr = 'true';
54
+ else if (arg === false)
55
+ argStr = 'false';
56
+ else if (arg && typeof arg === 'object' && PATH_SYMBOL in arg) {
57
+ const fullPath = arg[PATH_SYMBOL];
58
+ argStr = fullPath.startsWith('?.') ? fullPath.substring(2) : fullPath;
59
+ }
60
+ else
61
+ argStr = String(arg);
62
+ const chained = `${idRef}?.${argStr}`;
63
+ return createIdRefProxy(chained, options);
64
+ }
65
+ return createIdRefProxy(idRef, options);
66
+ }
67
+ });
68
+ }
69
+ /**
70
+ * Create a recursive proxy that records property access paths.
71
+ * Supports both property access and method call syntax (via apply trap on function target).
72
+ *
73
+ * When `aka` is provided, property names that match an alias *value* are output
74
+ * using the alias *key* instead (reverse alias).
75
+ */
76
+ function createPathProxy(prefix, options) {
77
+ const aliasMap = options?.aka;
78
+ // Use a function as the target to enable the apply trap
79
+ function handler() { }
80
+ return new Proxy(handler, {
81
+ get(_, prop) {
82
+ if (prop === 'path' || prop === PATH_SYMBOL) {
83
+ return prefix.length > 0 ? `?.${prefix}` : '?.';
84
+ }
85
+ // Ignore symbol access (Symbol.iterator, Symbol.toPrimitive, etc.)
86
+ if (typeof prop === 'symbol')
87
+ return undefined;
88
+ let segment = String(prop);
89
+ // #-prefix: $['#firstName'] → '#[firstName]' (cached element ref)
90
+ if (segment.startsWith('#')) {
91
+ const varName = segment.substring(1);
92
+ const idRef = `#[${varName}]`;
93
+ // Return a proxy that starts from this id ref (can chain further with ?.)
94
+ return createIdRefProxy(idRef, options);
95
+ }
96
+ // Apply reverse alias: if prop matches an alias value, use the alias key
97
+ if (aliasMap) {
98
+ for (const [alias, target] of Object.entries(aliasMap)) {
99
+ if (target === segment) {
100
+ segment = alias;
101
+ break;
102
+ }
103
+ }
104
+ }
105
+ const newPath = prefix ? `${prefix}?.${segment}` : segment;
106
+ return createPathProxy(newPath, options);
107
+ },
108
+ apply(_, __, args) {
109
+ // Method call syntax: $.querySelector('.username') → extends path with the argument
110
+ if (args.length > 0) {
111
+ const arg = args[0];
112
+ let argStr;
113
+ if (arg === true)
114
+ argStr = 'true';
115
+ else if (arg === false)
116
+ argStr = 'false';
117
+ else if (arg && typeof arg === 'object' && PATH_SYMBOL in arg) {
118
+ // Proxy arg — extract path without '?.' prefix
119
+ const fullPath = arg[PATH_SYMBOL];
120
+ argStr = fullPath.startsWith('?.') ? fullPath.substring(2) : fullPath;
121
+ }
122
+ else
123
+ argStr = String(arg);
124
+ const newPath = prefix ? `${prefix}?.${argStr}` : argStr;
125
+ return createPathProxy(newPath, options);
126
+ }
127
+ // No args — method called with no arguments, return self
128
+ return createPathProxy(prefix, options);
129
+ }
130
+ });
131
+ }
132
+ /**
133
+ * Create a typed path proxy for a given interface/type.
134
+ * Property accesses on the returned proxy produce `?.`-prefixed path strings.
135
+ * Method calls append their argument to the path.
136
+ *
137
+ * @param options - Optional aka aliases and withMethods for path generation
138
+ *
139
+ * @example
140
+ * const $ = paths<Person>();
141
+ * $.lastName.path // '?.lastName'
142
+ * $.address.city.path // '?.address?.city'
143
+ *
144
+ * // With aka (reverse alias applied):
145
+ * const $ = paths<MyEl>({ aka: { q: 'querySelector' } });
146
+ * $.querySelector('.user').textContent.path // '?.q?..user?.textContent'
147
+ *
148
+ * // Inside sp template literals, .path is not needed:
149
+ * sp`${$.lastName}, ${$.firstName}` // ['?.lastName', ', ', '?.firstName']
150
+ */
151
+ export function paths(options) {
152
+ return createPathProxy('', options);
153
+ }
154
+ /**
155
+ * Create an assignment pair: { [lhs.path]: rhs.path }.
156
+ * Used to express "set this target to this source value" in a spreadable form.
157
+ *
158
+ * @example
159
+ * set($.clone.querySelector('.username').textContent).to($.username)
160
+ * // { '?.clone?.q?..username?.textContent': '?.username' }
161
+ *
162
+ * // Spread into an assign object:
163
+ * assign: {
164
+ * ...set($.textContent).to($.name),
165
+ * ...set($.className).to($.theme),
166
+ * count: 1
167
+ * }
168
+ */
169
+ export function set(lhs) {
170
+ const lhsStr = lhs && typeof lhs === 'object' && PATH_SYMBOL in lhs
171
+ ? lhs[PATH_SYMBOL]
172
+ : String(lhs);
173
+ return {
174
+ to(rhs) {
175
+ const rhsStr = rhs && typeof rhs === 'object' && PATH_SYMBOL in rhs
176
+ ? rhs[PATH_SYMBOL]
177
+ : rhs;
178
+ return { [lhsStr]: rhsStr };
179
+ }
180
+ };
181
+ }
182
+ /**
183
+ * Recursively walk a value and convert any path proxy objects to their
184
+ * `?.`-prefixed string representation.
185
+ *
186
+ * Use this to wrap entire config objects or arrays that contain proxy values,
187
+ * extracting all path strings in one pass.
188
+ *
189
+ * @example
190
+ * const $ = paths<MyVM>({ aka: { q: 'querySelector' } });
191
+ *
192
+ * const config = smoothOver({
193
+ * assign: {
194
+ * incrementButton: $.clone.querySelector('.increment'),
195
+ * decrementButton: $.clone.querySelector('.decrement'),
196
+ * }
197
+ * });
198
+ * // { assign: { incrementButton: '?.clone?.q?..increment', ... } }
199
+ */
200
+ export function smoothOver(value) {
201
+ if (value && typeof value === 'object' && PATH_SYMBOL in value) {
202
+ return value[PATH_SYMBOL];
203
+ }
204
+ if (Array.isArray(value)) {
205
+ return value.map(smoothOver);
206
+ }
207
+ if (value && typeof value === 'object') {
208
+ const proto = Object.getPrototypeOf(value);
209
+ if (proto === Object.prototype || proto === null) {
210
+ const result = {};
211
+ for (const [k, v] of Object.entries(value)) {
212
+ result[k] = smoothOver(v);
213
+ }
214
+ return result;
215
+ }
216
+ }
217
+ return value;
218
+ }
219
+ /**
220
+ * Merge multiple set(...).to(...) pairs (and/or plain objects) into an `{ assign: {...} }` object.
221
+ * Spread the result into a merge config to avoid repeated `...` per entry.
222
+ *
223
+ * @example
224
+ * {
225
+ * ifKeyIn: ['statusClassName', 'statusMessageText'],
226
+ * ifAllOf: ['clone'],
227
+ * ...doAssign(
228
+ * set($.clone.querySelector('.status').className).to($.statusClassName),
229
+ * set($.clone.querySelector('.status-text').textContent).to($.statusMessageText),
230
+ * )
231
+ * }
232
+ * // Equivalent to: { ifKeyIn: [...], ifAllOf: [...], assign: { '?.clone?.q?..status?.className': '?.statusClassName', ... } }
233
+ *
234
+ * // Mix with literal values:
235
+ * ...doAssign(
236
+ * set($.clone.querySelector('.count-value').textContent).to($.count),
237
+ * { renderCount: 1 },
238
+ * )
239
+ */
240
+ export function doAssign(...pairs) {
241
+ return { assign: Object.assign({}, ...pairs) };
242
+ }
243
+ /**
244
+ * Compile-time loop expansion: generates one entry per key from a factory function.
245
+ * Creates a typed proxy internally — the factory receives both the key and the proxy.
246
+ *
247
+ * @param keys - Array of property names to iterate (type-checked against T)
248
+ * @param factory - Function that produces a config entry for each key
249
+ * @param options - Optional PathsOptions (aka, withMethods) for the internal proxy
250
+ * @returns Array of factory results (one per key) — spread into merges array
251
+ *
252
+ * @example
253
+ * import { forEachKeyIn, set, doAssign } from 'assign-gingerly/paths.js';
254
+ *
255
+ * interface Person extends HTMLElement { firstName: string; lastName: string; }
256
+ *
257
+ * const merges = [
258
+ * ...forEachKeyIn<Person>(['firstName', 'lastName'], (key, $) => ({
259
+ * ifKeyIn: [key],
260
+ * assignOptions: { pin: { [key]: { qry: `[name="${key}"]` } } },
261
+ * ...doAssign(set($['#' + key]).to($[key]))
262
+ * })),
263
+ * ];
264
+ */
265
+ export function forEachKeyIn(keys, factory, options) {
266
+ const $ = paths(options);
267
+ return keys.map(key => factory(key, $));
268
+ }
269
+ /**
270
+ * Tagged template literal that splits a template into an array of parts.
271
+ * Interleaves static string segments with interpolated values.
272
+ *
273
+ * Path proxy objects are auto-detected and converted to their `?.`-prefixed
274
+ * string representation — no `.path` call needed inside sp template literals.
275
+ *
276
+ * Arrays passed as interpolations are preserved as nested arrays (for
277
+ * all-or-nothing optional segments in builtIns.join).
278
+ *
279
+ * @example
280
+ * const $ = paths<Person>();
281
+ *
282
+ * // Basic usage:
283
+ * sp`${$.lastName}, ${$.firstName}`
284
+ * // ['?.lastName', ', ', '?.firstName']
285
+ *
286
+ * // With optional segment (nested array, all-or-nothing in join):
287
+ * sp`${$.lastName}${[', ', $.middleName]}, ${$.firstName}`
288
+ * // ['?.lastName', [', ', '?.middleName'], ', ', '?.firstName']
289
+ */
290
+ export function sp(strings, ...values) {
291
+ const result = [];
292
+ for (let i = 0; i < strings.length; i++) {
293
+ if (strings[i])
294
+ result.push(strings[i]);
295
+ if (i < values.length) {
296
+ const v = values[i];
297
+ if (v && typeof v === 'object' && PATH_SYMBOL in v) {
298
+ // Auto-extract path from proxy object
299
+ result.push(v[PATH_SYMBOL]);
300
+ }
301
+ else if (Array.isArray(v)) {
302
+ // Nested array — recursively extract paths from proxy elements
303
+ result.push(v.map(el => el && typeof el === 'object' && PATH_SYMBOL in el ? el[PATH_SYMBOL] : el));
304
+ }
305
+ else {
306
+ result.push(v);
307
+ }
308
+ }
309
+ }
310
+ return result;
311
+ }
312
+ /**
313
+ * Extract the last segment from a `?.`-prefixed path string.
314
+ * e.g., '?.address?.city' → 'city', '?.firstName' → 'firstName'
315
+ */
316
+ function extractPropName(pathStr) {
317
+ const parts = pathStr.split('?.');
318
+ return parts[parts.length - 1];
319
+ }
320
+ /**
321
+ * Tagged template literal that produces an array of {prop, val} objects + literal strings.
322
+ * Designed for `builtIns.microDataJoin` — provides both the property name (for itemprop)
323
+ * and the path string (for resolution).
324
+ *
325
+ * Path proxy objects are auto-detected and converted to `{prop, val}` objects.
326
+ * Plain objects passed as interpolations are preserved as-is (allows developer overrides
327
+ * with custom prop, val, format, etc.).
328
+ * Arrays are preserved as nested arrays (for optional segments).
329
+ *
330
+ * @example
331
+ * const $ = paths<Person>();
332
+ *
333
+ * // Basic usage:
334
+ * md`${$.firstName} ${$.lastName}`
335
+ * // [{ prop: 'firstName', val: '?.firstName' }, ' ', { prop: 'lastName', val: '?.lastName' }]
336
+ *
337
+ * // With developer override (custom prop name, format):
338
+ * md`${$.firstName} ${{ prop: 'birthDate', val: $.birthDT, format: 'long' }}`
339
+ * // [{ prop: 'firstName', val: '?.firstName' }, ' ', { prop: 'birthDate', val: '?.birthDT', format: 'long' }]
340
+ *
341
+ * // With optional segment:
342
+ * md`${$.firstName}${[' ', $.middleName]} ${$.lastName}`
343
+ * // [{ prop: 'firstName', val: '?.firstName' }, [' ', { prop: 'middleName', val: '?.middleName' }], ' ', { prop: 'lastName', val: '?.lastName' }]
344
+ */
345
+ export function md(strings, ...values) {
346
+ const result = [];
347
+ for (let i = 0; i < strings.length; i++) {
348
+ if (strings[i])
349
+ result.push(strings[i]);
350
+ if (i < values.length) {
351
+ const v = values[i];
352
+ if (v && typeof v === 'object' && PATH_SYMBOL in v) {
353
+ // Proxy object → {prop, val}
354
+ const pathStr = v[PATH_SYMBOL];
355
+ result.push({ prop: extractPropName(pathStr), val: pathStr });
356
+ }
357
+ else if (Array.isArray(v)) {
358
+ // Nested array — recursively convert proxy elements to {prop, val}
359
+ result.push(v.map(el => {
360
+ if (el && typeof el === 'object' && PATH_SYMBOL in el) {
361
+ const pathStr = el[PATH_SYMBOL];
362
+ return { prop: extractPropName(pathStr), val: pathStr };
363
+ }
364
+ return el;
365
+ }));
366
+ }
367
+ else if (v && typeof v === 'object' && 'prop' in v) {
368
+ // Developer override object — extract val from proxy if present
369
+ const processed = { ...v };
370
+ if (processed.val && typeof processed.val === 'object' && PATH_SYMBOL in processed.val) {
371
+ processed.val = processed.val[PATH_SYMBOL];
372
+ }
373
+ result.push(processed);
374
+ }
375
+ else {
376
+ result.push(v);
377
+ }
378
+ }
379
+ }
380
+ return result;
381
+ }
@@ -4,7 +4,7 @@
4
4
  * Provides compile-time autocomplete and type safety for `?.`-prefixed path strings.
5
5
  *
6
6
  * @example
7
- * import { paths, sp } from 'assign-gingerly/paths.js';
7
+ * import { paths, sp } from 'assign-gingerly/DX/paths.js';
8
8
  *
9
9
  * interface Person {
10
10
  * firstName?: string;
package/README.md CHANGED
@@ -4220,7 +4220,7 @@ Uses comment markers (`<!--?start name="microDataJoin"-->` / `<!--?end-->`) to t
4220
4220
  The `md` tagged template literal produces the `{prop, val}` structure from proxy objects — full autocomplete and type safety:
4221
4221
 
4222
4222
  ```TypeScript
4223
- import { paths, md } from 'assign-gingerly/paths.js';
4223
+ import { paths, md } from 'assign-gingerly/DX/paths.js';
4224
4224
 
4225
4225
  interface Person { firstName: string; lastName: string; birthDT: Date; age: number; }
4226
4226
  const $ = paths<Person>();
@@ -4345,7 +4345,7 @@ assignFrom(element, {
4345
4345
  For JSON generated config files generated from TypeScript/`.mts`/`mjs` files during a build or server-side rendering, the `paths` utility provides compile-time autocomplete and type safety for `?.`-prefixed path strings. The `sp` tagged template literal ("split into parts") produces arrays suitable for `builtIns.join`. The `md` tagged template literal produces `{prop, val}` objects suitable for `builtIns.microDataJoin`.
4346
4346
 
4347
4347
  ```TypeScript
4348
- import { paths, sp } from 'assign-gingerly/paths.js';
4348
+ import { paths, sp } from 'assign-gingerly/DX/paths.js';
4349
4349
 
4350
4350
  interface Person {
4351
4351
  firstName?: string;
@@ -4421,7 +4421,7 @@ const pattern = {
4421
4421
  The `md` tag produces `{prop, val}` objects for `builtIns.microDataJoin`:
4422
4422
 
4423
4423
  ```TypeScript
4424
- import { paths, md } from 'assign-gingerly/paths.js';
4424
+ import { paths, md } from 'assign-gingerly/DX/paths.js';
4425
4425
 
4426
4426
  interface Person { firstName: string; lastName: string; birthDT: Date; age: number; }
4427
4427
  const $ = paths<Person>();
@@ -4790,6 +4790,18 @@ console.log(app.todos.title); // 'My Todos'
4790
4790
  console.log([...app.todos]); // ['Buy milk', 'Walk dog'] (list unchanged)
4791
4791
  ```
4792
4792
 
4793
+ This pattern seems useful and reusable enough that this package provides a DX utility mixin to add this functionality to any class definition:
4794
+
4795
+ ```JS
4796
+ import {IterableMixin} from 'assign-gingerly/DX/IterableMixin.js';
4797
+
4798
+ class PlainList extends IterableMixin<number>()(class {}) {}
4799
+
4800
+ class MyIterableCustomElement extends IterableMixin<string>()(HTMLElement) {}
4801
+
4802
+ class Combined extends OtherMixin()(IterableMixin<string>()(HTMLElement)) {}
4803
+ ```
4804
+
4793
4805
  ### Use case: Validation on assignment
4794
4806
 
4795
4807
  ```JavaScript
@@ -4834,7 +4846,7 @@ Only classes that explicitly define their own `assignTo` are affected. The check
4834
4846
  `installForwarding` installs getter/setter pairs on a class prototype that delegate to nested paths on the instance. This is useful for exposing deeply nested properties at the top level of an object — particularly for custom elements that delegate behavior to compositional feature classes.
4835
4847
 
4836
4848
  ```JavaScript
4837
- import { installForwarding } from 'assign-gingerly/installForwarding.js';
4849
+ import { installForwarding } from 'assign-gingerly/DX/installForwarding.js';
4838
4850
  ```
4839
4851
 
4840
4852
  ### Basic usage
@@ -5932,7 +5944,7 @@ assignGingerly(el, {
5932
5944
 
5933
5945
  ```JavaScript
5934
5946
  import { PropertyBag, assignFeatures } from 'assign-gingerly/assignFeatures.js';
5935
- import { installForwarding } from 'assign-gingerly/installForwarding.js';
5947
+ import { installForwarding } from 'assign-gingerly/DX/installForwarding.js';
5936
5948
 
5937
5949
  // 1. Define a feature container by subclassing PropertyBag
5938
5950
  class ClubMemberBehaviors extends PropertyBag {
package/index.js CHANGED
@@ -11,7 +11,7 @@ export { resolveValues, resolveValue } from './resolveValues.js';
11
11
  export { assignFromAsync } from './assignFromAsync.js';
12
12
  export { assignFrom } from './assignFrom.js';
13
13
  export { assignFeatures, FeaturesRegistry, captureFeatureInitVals, PropertyBag, suggestFeatureInfo, getFeatureInfoSuggestions } from './assignFeatures.js';
14
- export { installForwarding } from './installForwarding.js';
14
+ export { installForwarding } from './DX/installForwarding.js';
15
15
  export { defineWithFeatures } from './defineWithFeatures.js';
16
16
  export { resolveAndAssignFeatures } from './resolveAndAssignFeatures.js';
17
17
  export { nudge } from './handlers/nudge.js';
package/index.ts CHANGED
@@ -11,7 +11,7 @@ export {resolveValues, resolveValue} from './resolveValues.js';
11
11
  export {assignFromAsync} from './assignFromAsync.js';
12
12
  export {assignFrom} from './assignFrom.js';
13
13
  export {assignFeatures, FeaturesRegistry, captureFeatureInitVals, PropertyBag, suggestFeatureInfo, getFeatureInfoSuggestions} from './assignFeatures.js';
14
- export {installForwarding} from './installForwarding.js';
14
+ export {installForwarding} from './DX/installForwarding.js';
15
15
  export {defineWithFeatures} from './defineWithFeatures.js';
16
16
  export {resolveAndAssignFeatures} from './resolveAndAssignFeatures.js';
17
17
  export {nudge} from './handlers/nudge.js';
@@ -0,0 +1,10 @@
1
+ # Inferencer Submodule Isolation
2
+
3
+ The `inferencer/` folder is a git submodule that must remain self-contained and independently publishable.
4
+
5
+ ## Rules
6
+
7
+ - **No imports from outside the folder.** Code in `inferencer/` must NOT import from any file in the parent project (no `../assignGingerly.js`, no `../paths.js`, etc.).
8
+ - **No references to assign-gingerly internals.** The inferencer module must work as a standalone package with zero dependencies on assign-gingerly runtime code.
9
+ - **Types only exception:** Type-only imports from `../types/` are acceptable IF they are also published as part of the inferencer package's own type declarations. Prefer duplicating small type definitions over creating a dependency.
10
+ - **Test exceptions:** Test files within `inferencer/` MAY reference the parent project for integration testing, but runtime source files must not.
@@ -1383,6 +1383,27 @@ const selectorMatch = prop.match(/^\[(.+?)\](?:\?\.(.+))?$/);
1383
1383
  3. **Wrong parser in HTML** - HTML references `parse-pattern-statements` but emc.mjs uses `parse-grouped-capture-statements`
1384
1384
  4. **Period vs chained accessor** - Using `.` instead of `?.` for selector properties
1385
1385
  5. **Forgetting to rebuild** - After changing emc.mjs or emoji.mjs, always run `npm run build`
1386
+ 6. **Hydrate fires before all attributes are read** - Attribute props are assigned one at a time during initialization; use the `initialized` flag pattern (see below) when an action must wait for all of them
1387
+
1388
+ ### Blocking an Action Until All Attributes Are Read
1389
+
1390
+ When converting an enhancement whose `hydrate` (or other action) depends on multiple attribute-derived props, it's often predictable that nothing should happen until all relevant attributes have been read. Gating on the props alone doesn't work:
1391
+
1392
+ - `ifKeyIn` alone means **at least one** of the listed props is defined, so the action can fire after the first attribute is read with the rest still `undefined`.
1393
+ - With `ifKeyIn` and `ifAllOf` combined, roundabout only runs the action when the *changed* property is in `ifKeyIn` — a prop listed only in `ifAllOf` never triggers it.
1394
+
1395
+ The proven fix (from three-peat): add `initialized?: boolean` to `AllProps`, set `self.initialized = true` in `init` immediately after `await roundabout(...)`, and gate the action on it in both lists:
1396
+
1397
+ ```javascript
1398
+ actions: {
1399
+ hydrate: {
1400
+ ifKeyIn: ['src', 'listProp', 'initialized'],
1401
+ ifAllOf: ['enhancedElement', 'initialized']
1402
+ }
1403
+ }
1404
+ ```
1405
+
1406
+ `initialized` flips only after `roundabout()` returns (all attribute reads complete), and including it in `ifKeyIn` makes its change the trigger. Keep the attribute props in `ifKeyIn` too, so later attribute changes still re-trigger the action. See `NewEnhancementInstructions.md` ("Blocking an Action Until All Attributes Are Read") for the full step-by-step recipe.
1386
1407
 
1387
1408
  ### Debugging Tips
1388
1409
 
@@ -681,6 +681,52 @@ import { findAdjacentElement } from 'be-hive/findAdjacentElement.js';
681
681
  import { findAdjacentElement } from 'trans-render/lib/findAdjacentElement.js';
682
682
  ```
683
683
 
684
+ ### Blocking an Action Until All Attributes Are Read
685
+
686
+ **Use this pattern only when it's predictable that an action (typically `hydrate`) must not run until all relevant attributes have been read.** Attribute-derived props are assigned one at a time during `roundabout()`'s initial pass, so gating on the props themselves isn't enough:
687
+
688
+ - `ifKeyIn` alone means **at least one** of the listed props is defined — the action fires as soon as the first attribute is read, while later ones are still `undefined`.
689
+ - When `ifKeyIn` is combined with `ifAllOf`, the action only executes when the property that *changed* is in `ifKeyIn` (`shouldExecute = conditionsMet && changedIsInKeyIn` in roundabout). A prop listed only in `ifAllOf` can never trigger the action.
690
+
691
+ The proven solution (from three-peat) is an `initialized` flag set after `roundabout()` returns:
692
+
693
+ **1. Add `initialized` to `AllProps` in `types/<name>/types.d.ts`:**
694
+
695
+ ```typescript
696
+ export interface AllProps extends EndUserProps{
697
+ enhancedElement: Element & ElementEnhancementGateway;
698
+ resolved?: boolean;
699
+ initialized?: boolean;
700
+ }
701
+ ```
702
+
703
+ **2. Set it at the end of `init`, after the `await roundabout(...)`:**
704
+
705
+ ```javascript
706
+ async init(self, enhancedElement, ctx, initVals){
707
+ // ...build raOptions...
708
+ await (await import('roundabout-lib/roundabout.js')).roundabout(raOptions);
709
+ self.initialized = true; // all attribute reads are done at this point
710
+ }
711
+ ```
712
+
713
+ **3. Gate the action on `initialized` in `emc.mjs` — in *both* lists:**
714
+
715
+ ```javascript
716
+ actions: {
717
+ hydrate: {
718
+ ifKeyIn: ['src', 'listProp', 'initialized'],
719
+ ifAllOf: ['enhancedElement', 'initialized']
720
+ }
721
+ }
722
+ ```
723
+
724
+ - `initialized` is the last prop to change, and it only flips after every attribute has been read, so it is the reliable trigger.
725
+ - It must be in `ifKeyIn`, not just `ifAllOf` — otherwise its change event doesn't satisfy `changedIsInKeyIn` and the action never fires (the other props were already assigned during the initial pass and never change again).
726
+ - Keeping the attribute props (`src`, `listProp`) in `ifKeyIn` preserves re-hydration when those attributes change later at runtime.
727
+
728
+ Reference implementation: three-peat (`emc.mjs`, `three-peat.js`, `types/three-peat/types.d.ts`).
729
+
684
730
  ### Debugging Tips
685
731
 
686
732
  1. **Check the generated JSON** — Run `node emc.mjs` and verify all sections (especially `customData`) are present
@@ -702,4 +748,4 @@ Don't try to implement all features at once.
702
748
 
703
749
  ---
704
750
 
705
- *Last updated: June 2026*
751
+ *Last updated: July 2026*
@@ -745,11 +745,11 @@ export interface LazyLoadResolvedParams {
745
745
  * Used for SSR placeholder content (e.g., "Loading..." text) that disappears once real content loads. */
746
746
  placeholder?: string;
747
747
  /** Assignment config applied to cloned content before insertion.
748
- * Same shape as manageTemplateList's fromEachItem: { assignToFragment, withOptions } or { configs: [...] } */
748
+ * Same shape as manageTemplateList's fromEachItem: { toClone, withOptions } or { configs: [...] } */
749
749
  assign?: {
750
- assignToFragment?: Record<string, any>;
750
+ toClone?: Record<string, any>;
751
751
  withOptions?: Record<string, any>;
752
- configs?: Array<{ assignToFragment?: Record<string, any>; withOptions?: Record<string, any> }>;
752
+ configs?: Array<{ toClone?: Record<string, any>; withOptions?: Record<string, any> }>;
753
753
  };
754
754
  }
755
755
 
@@ -795,7 +795,7 @@ export interface LazyLoadSwitchResolvedParams extends Omit<LazyLoadResolvedParam
795
795
  }
796
796
 
797
797
  export interface FromEachItemConfig {
798
- assignToFragment?: Record<string, any>;
798
+ toClone?: Record<string, any>;
799
799
  withOptions?: AssignFromOptions;
800
800
  resolve?: {
801
801
  key?: string;
@@ -10,9 +10,49 @@ export interface EndUserProps{
10
10
 
11
11
  /**
12
12
  * Specify id of peer element to pull list from.
13
+ * If not provided, the host is found by searching upwards
14
+ * for an itemscope-managed element, falling back to the
15
+ * shadow root host.
13
16
  */
14
17
  src?: string;
15
- each: FromEachItemConfig,
16
- target: string,
17
- updateOn: string,
18
- }
18
+
19
+ /**
20
+ * Specifies how each item's values are distributed into the
21
+ * cloned document fragment. Parsed from JSON.
22
+ * If not provided, each item's properties are inferred into
23
+ * the clone's [itemprop] descendants.
24
+ */
25
+ each?: FromEachItemConfig,
26
+
27
+ /**
28
+ * id of an element (within the same root node) into which the
29
+ * repeating cloned fragments should be placed.
30
+ * If not provided, the fragments are appended to the children
31
+ * of the adorned element (or, if the adorned element is a
32
+ * template, to the template's parent element).
33
+ */
34
+ target?: string,
35
+
36
+ /**
37
+ * Name of the event the host dispatches when the list has changed.
38
+ * If not provided, but listProp is, the host is assumed to have
39
+ * a propagator (one is created if it doesn't exist), which is
40
+ * listened to for an event named after listProp.
41
+ */
42
+ updateOn?: string,
43
+ }
44
+
45
+ export interface AllProps extends EndUserProps{
46
+ enhancedElement: Element & ElementEnhancementGateway;
47
+ resolved?: boolean;
48
+ initialized?: boolean;
49
+ }
50
+
51
+ export type AP = AllProps;
52
+ export type PAP = Partial<AP>;
53
+ export type ProPAP = Promise<PAP>;
54
+
55
+ export interface Actions {
56
+ init(self: AP, enhancedElement: Element & ElementEnhancementGateway, ctx: SpawnContext, initVals: PAP): Promise<void>;
57
+ hydrate(self: AP): ProPAP;
58
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "assign-gingerly",
3
- "version": "0.0.66",
3
+ "version": "0.0.68",
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,7 @@
17
17
  "files": [
18
18
  "*.js",
19
19
  "*.ts",
20
+ "DX/**",
20
21
  "handlers/**",
21
22
  "inferencer/**",
22
23
  "README.md",
@@ -106,9 +107,13 @@
106
107
  "default": "./handlers/manageTemplateList.js",
107
108
  "types": "./handlers/manageTemplateList.ts"
108
109
  },
109
- "./paths.js": {
110
- "default": "./paths.js",
111
- "types": "./paths.ts"
110
+ "./DX/paths.js": {
111
+ "default": "./DX/paths.js",
112
+ "types": "./DX/paths.ts"
113
+ },
114
+ "./DX/IterableMixin.js": {
115
+ "default": "./DX/IterableMixin.js",
116
+ "types": "./DX/IterableMixin.ts"
112
117
  },
113
118
  "./enhanceAll.js": {
114
119
  "default": "./enhanceAll.js",
@@ -158,9 +163,9 @@
158
163
  "default": "./assignFromAsync-extension.js",
159
164
  "types": "./assignFromAsync-extension.ts"
160
165
  },
161
- "./emojis.js": {
162
- "default": "./emojis.js",
163
- "types": "./emojis.ts"
166
+ "./DX/emojis.js": {
167
+ "default": "./DX/emojis.js",
168
+ "types": "./DX/emojis.ts"
164
169
  },
165
170
  "./assignFeatures.js": {
166
171
  "default": "./assignFeatures.js",
package/resolveIdRef.js CHANGED
@@ -78,7 +78,7 @@ export function resolveIdVariable(varName, target, pin) {
78
78
  // Fire-and-forget: log correction suggestion
79
79
  const capturedConfig = config;
80
80
  const capturedVarName = varName;
81
- import('./pinCorrector.js').then(module => {
81
+ import('./DX/pinCorrector.js').then(module => {
82
82
  module.logConfigCorrection(target, capturedVarName, capturedConfig);
83
83
  }).catch(() => { });
84
84
  }
package/resolveIdRef.ts CHANGED
@@ -91,7 +91,7 @@ export function resolveIdVariable(
91
91
  // Fire-and-forget: log correction suggestion
92
92
  const capturedConfig = config;
93
93
  const capturedVarName = varName;
94
- import('./pinCorrector.js').then(module => {
94
+ import('./DX/pinCorrector.js').then(module => {
95
95
  module.logConfigCorrection(target, capturedVarName, capturedConfig);
96
96
  }).catch(() => {});
97
97
  }
package/paths.js DELETED
@@ -1,231 +0,0 @@
1
- /**
2
- * paths.js — Typed path proxy and template tags for assignFrom authoring.
3
- *
4
- * Provides compile-time autocomplete and type safety for `?.`-prefixed path strings.
5
- * Supports method call syntax, alias reversal, and batch proxy extraction.
6
- */
7
-
8
- /**
9
- * Symbol used internally to detect path proxy objects.
10
- */
11
- const PATH_SYMBOL = Symbol('assign-gingerly-path');
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
-
46
- /**
47
- * Create a recursive proxy that records property access paths.
48
- */
49
- function createPathProxy(prefix, options) {
50
- const aliasMap = options?.aka;
51
-
52
- function handler() {}
53
-
54
- return new Proxy(handler, {
55
- get(_, prop) {
56
- if (prop === 'path' || prop === PATH_SYMBOL) {
57
- return prefix.length > 0 ? `?.${prefix}` : '?.';
58
- }
59
- if (typeof prop === 'symbol') return undefined;
60
-
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
-
70
- if (aliasMap) {
71
- for (const [alias, target] of Object.entries(aliasMap)) {
72
- if (target === segment) { segment = alias; break; }
73
- }
74
- }
75
-
76
- const newPath = prefix ? `${prefix}?.${segment}` : segment;
77
- return createPathProxy(newPath, options);
78
- },
79
- apply(_, __, args) {
80
- if (args.length > 0) {
81
- const arg = args[0];
82
- let argStr;
83
- if (arg === true) argStr = 'true';
84
- else if (arg === false) argStr = 'false';
85
- else if (arg && typeof arg === 'object' && PATH_SYMBOL in arg) {
86
- const fullPath = arg[PATH_SYMBOL];
87
- argStr = fullPath.startsWith('?.') ? fullPath.substring(2) : fullPath;
88
- }
89
- else argStr = String(arg);
90
-
91
- const newPath = prefix ? `${prefix}?.${argStr}` : argStr;
92
- return createPathProxy(newPath, options);
93
- }
94
- return createPathProxy(prefix, options);
95
- }
96
- });
97
- }
98
-
99
- /**
100
- * Create a typed path proxy.
101
- */
102
- export function paths(options) {
103
- return createPathProxy('', options);
104
- }
105
-
106
- /**
107
- * Create an assignment pair: { [lhs.path]: rhs.path }.
108
- */
109
- export function set(lhs) {
110
- const lhsStr = lhs && typeof lhs === 'object' && PATH_SYMBOL in lhs
111
- ? lhs[PATH_SYMBOL]
112
- : String(lhs);
113
- return {
114
- to(rhs) {
115
- const rhsStr = rhs && typeof rhs === 'object' && PATH_SYMBOL in rhs
116
- ? rhs[PATH_SYMBOL]
117
- : rhs;
118
- return { [lhsStr]: rhsStr };
119
- }
120
- };
121
- }
122
-
123
- /**
124
- * Recursively walk a value and convert any path proxy objects to path strings.
125
- */
126
- export function smoothOver(value) {
127
- if (value && typeof value === 'object' && PATH_SYMBOL in value) {
128
- return value[PATH_SYMBOL];
129
- }
130
- if (Array.isArray(value)) {
131
- return value.map(smoothOver);
132
- }
133
- if (value && typeof value === 'object') {
134
- const proto = Object.getPrototypeOf(value);
135
- if (proto === Object.prototype || proto === null) {
136
- const result = {};
137
- for (const [k, v] of Object.entries(value)) {
138
- result[k] = smoothOver(v);
139
- }
140
- return result;
141
- }
142
- }
143
- return value;
144
- }
145
-
146
- /**
147
- * Merge multiple set(...).to(...) pairs into an { assign: {...} } object.
148
- * Spread the result into a merge config.
149
- */
150
- export function doAssign(...pairs) {
151
- return { assign: Object.assign({}, ...pairs) };
152
- }
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
-
162
- /**
163
- * Tagged template literal that splits a template into an array of parts.
164
- * Path proxy objects are auto-detected and converted to path strings.
165
- */
166
- export function sp(strings, ...values) {
167
- const result = [];
168
- for (let i = 0; i < strings.length; i++) {
169
- if (strings[i]) result.push(strings[i]);
170
- if (i < values.length) {
171
- const v = values[i];
172
- if (v && typeof v === 'object' && PATH_SYMBOL in v) {
173
- result.push(v[PATH_SYMBOL]);
174
- }
175
- else if (Array.isArray(v)) {
176
- result.push(v.map(el =>
177
- el && typeof el === 'object' && PATH_SYMBOL in el ? el[PATH_SYMBOL] : el
178
- ));
179
- }
180
- else {
181
- result.push(v);
182
- }
183
- }
184
- }
185
- return result;
186
- }
187
-
188
- /**
189
- * Extract the last segment from a `?.`-prefixed path string.
190
- */
191
- function extractPropName(pathStr) {
192
- const parts = pathStr.split('?.');
193
- return parts[parts.length - 1];
194
- }
195
-
196
- /**
197
- * Tagged template literal that produces {prop, val} objects for microDataJoin.
198
- */
199
- export function md(strings, ...values) {
200
- const result = [];
201
- for (let i = 0; i < strings.length; i++) {
202
- if (strings[i]) result.push(strings[i]);
203
- if (i < values.length) {
204
- const v = values[i];
205
- if (v && typeof v === 'object' && PATH_SYMBOL in v) {
206
- const pathStr = v[PATH_SYMBOL];
207
- result.push({ prop: extractPropName(pathStr), val: pathStr });
208
- }
209
- else if (Array.isArray(v)) {
210
- result.push(v.map(el => {
211
- if (el && typeof el === 'object' && PATH_SYMBOL in el) {
212
- const pathStr = el[PATH_SYMBOL];
213
- return { prop: extractPropName(pathStr), val: pathStr };
214
- }
215
- return el;
216
- }));
217
- }
218
- else if (v && typeof v === 'object' && 'prop' in v) {
219
- const processed = { ...v };
220
- if (processed.val && typeof processed.val === 'object' && PATH_SYMBOL in processed.val) {
221
- processed.val = processed.val[PATH_SYMBOL];
222
- }
223
- result.push(processed);
224
- }
225
- else {
226
- result.push(v);
227
- }
228
- }
229
- }
230
- return result;
231
- }
File without changes
File without changes