assign-gingerly 0.0.90 → 0.0.92

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -102,6 +102,8 @@ assignFrom adds support for:
102
102
  5. Looped substitution with `where_x_in` / `where_y_in` / `where_z_in` for expanding template patterns into multiple concrete assignments.
103
103
  6. Dynamic substitutions via the `substitutions` option for injecting runtime string values into path segments. See [docs/substitutions.md](docs/substitutions.md).
104
104
  7. Spread merging via the `"..."` key.
105
+ 8. Boolean coercion / negation of a resolved value via a leading `!` marker: `'!!?.isOpen'` resolves `?.isOpen` then coerces to a real boolean, `'!?.isOpen'` negates it (`!!!` negates, and so on). Only honored in front of a `?.` path, `$0` reference, or protocol — a plain literal like `'!important'` is left untouched. Nullish coerces to `false`, which makes `!!` the safe way to feed an optional source value into a boolean sink (`hidden`, `disabled`, `classList.toggle`'s force argument, …).
106
+ 9. Multiple invocations of one `withMethods` method via the ` =*` operator — see [docs/multi-invoke.md](docs/multi-invoke.md).
105
107
 
106
108
  Example:
107
109
 
@@ -853,6 +855,7 @@ While we are in the business of passing values of object A into object B, we mig
853
855
  | ` ?=` | Ternary | Conditional assignment (assignFrom only) — [details](docs/ternary-assignment.md) | `'?.text ?=': ['?.cond', 'yes', 'no']` |
854
856
  | ` =>` | Handler | Invoke a handler plugin (assignFrom only) | `'?.el =>': { do: 'builtIns.lazyLoad', ... }` |
855
857
  | ` =&` | Sync op | Compute a value synchronously and assign it back (assignFrom only) — no dynamic import, no await, ever | `'?.text =&': { join: ['?.first', ' ', '?.last'] }` |
858
+ | ` =*` | Multi-invoke | Call a `withMethods` method once per argument-list (assignFrom only) — [details](docs/multi-invoke.md) | `` '?.classList?.toggle =*': [['a', '!!?.x'], ['b', '!!?.y']] `` |
856
859
 
857
860
  All operators use a space before the suffix to distinguish them from property names. They compose with `?.` nested paths and `withMethods`.
858
861
 
@@ -957,6 +960,45 @@ The `=!` command syntax is `<path> =!` where the path uses the `?.` nested notat
957
960
 
958
961
  For existing values, the toggle is performed using JavaScript's logical NOT operator (`!value`), regardless of what type it is.
959
962
 
963
+ ## Example 5a - Conditional class lists with the `!!` marker and `=*` command
964
+
965
+ The imperative pattern
966
+
967
+ ```JS
968
+ if (vm.isOpen) el.classList.add('isOpenCls');
969
+ else el.classList.remove('isOpenCls');
970
+ ```
971
+
972
+ is just `el.classList.toggle('isOpenCls', vm.isOpen)`. In `assignFrom` that is a `toggle`
973
+ call in `withMethods` with a resolved force argument — and the `!!` marker coerces the
974
+ resolved value to a real boolean so a missing source value *removes* the class instead of
975
+ flipping it:
976
+
977
+ ```TypeScript
978
+ assignFrom(el, {
979
+ '?.classList?.toggle': ['isOpenCls', '!!?.isOpen']
980
+ }, { from: vm, withMethods: ['toggle'] });
981
+ ```
982
+
983
+ For several classes at once, the `=*` operator runs the method once per argument-list:
984
+
985
+ ```TypeScript
986
+ const vm = { isOpen: true, isLoading: false, hasError: true };
987
+
988
+ assignFrom(el, {
989
+ '?.classList?.toggle =*': [
990
+ ['isOpenCls', '!!?.isOpen'],
991
+ ['loadingCls', '!!?.isLoading'],
992
+ ['errorCls', '!!?.hasError'],
993
+ ]
994
+ }, { from: vm, withMethods: ['toggle'] });
995
+
996
+ // toggle('isOpenCls', true); toggle('loadingCls', false); toggle('errorCls', true)
997
+ ```
998
+
999
+ See [docs/multi-invoke.md](docs/multi-invoke.md) for the full `=*` semantics, including its
1000
+ interaction with `where_x_in`.
1001
+
960
1002
  ## Example 6 - Deleting properties with -= command
961
1003
 
962
1004
  The `-=` command allows us to delete properties from objects:
package/assignFrom.js CHANGED
@@ -57,6 +57,21 @@ export function parseSyncOpCommand(key) {
57
57
  return null;
58
58
  return key.substring(0, key.length - 3); // Remove ' =&' suffix
59
59
  }
60
+ /**
61
+ * Check if a key ends with the multi-invoke operator ' =*'.
62
+ */
63
+ export function isMultiInvokeCommand(key) {
64
+ return key.endsWith(' =*');
65
+ }
66
+ /**
67
+ * Parse a =* multi-invoke command and extract the base LHS path — the path a
68
+ * `withMethods` method sits at, to be called once per argument-list on the RHS.
69
+ */
70
+ export function parseMultiInvokeCommand(key) {
71
+ if (!isMultiInvokeCommand(key))
72
+ return null;
73
+ return key.substring(0, key.length - 3); // Remove ' =*' suffix
74
+ }
60
75
  /**
61
76
  * Throws if a resolved sync-op value (or anything nested inside it) is a thenable.
62
77
  *
@@ -338,12 +353,19 @@ export function expandSubstitutions(pattern, options) {
338
353
  return mergeHandlerDuplicates(entries);
339
354
  }
340
355
  /**
341
- * Convert entries to an object, merging duplicate handler (` =>`) keys into arrays.
356
+ * Convert entries to an object. Duplicate keys which `where_x_in` expansion can
357
+ * produce — are collapsed with last-wins, except:
358
+ * - ` =>` handler keys merge into the Multiple Handlers array form.
359
+ * - ` =*` multi-invoke keys concatenate their argument-list arrays, so an
360
+ * expanded pattern still invokes the method once per expansion.
342
361
  */
343
362
  export function mergeHandlerDuplicates(entries) {
344
363
  const result = {};
345
364
  for (const [key, value] of entries) {
346
- if (key.endsWith(' =>') && key in result) {
365
+ if (key in result && key.endsWith(' =*') && Array.isArray(result[key]) && Array.isArray(value)) {
366
+ result[key] = result[key].concat(value);
367
+ }
368
+ else if (key.endsWith(' =>') && key in result) {
347
369
  const existing = result[key];
348
370
  if (Array.isArray(existing)) {
349
371
  existing.push(value);
@@ -389,6 +411,7 @@ export function categorizeKeys(expandedPattern) {
389
411
  const idRefHandlerKeys = [];
390
412
  const ternaryKeys = [];
391
413
  const syncOpKeys = [];
414
+ const multiInvokeKeys = [];
392
415
  for (const key of Object.keys(expandedPattern)) {
393
416
  if (isHandlerCommand(key)) {
394
417
  if (key.startsWith('#[')) {
@@ -404,6 +427,9 @@ export function categorizeKeys(expandedPattern) {
404
427
  else if (isSyncOpCommand(key)) {
405
428
  syncOpKeys.push(key);
406
429
  }
430
+ else if (isMultiInvokeCommand(key)) {
431
+ multiInvokeKeys.push(key);
432
+ }
407
433
  else if (key.startsWith('#[')) {
408
434
  idRefNormalKeys.push(key);
409
435
  }
@@ -411,7 +437,7 @@ export function categorizeKeys(expandedPattern) {
411
437
  normalPattern[key] = expandedPattern[key];
412
438
  }
413
439
  }
414
- return { handlerKeys, normalPattern, idRefNormalKeys, idRefHandlerKeys, ternaryKeys, syncOpKeys };
440
+ return { handlerKeys, normalPattern, idRefNormalKeys, idRefHandlerKeys, ternaryKeys, syncOpKeys, multiInvokeKeys };
415
441
  }
416
442
  /**
417
443
  * Merge pin and at into a single lookup map for resolveIdVariable.
@@ -469,7 +495,7 @@ export function assignFrom(target, pattern, options, permissionProcessor) {
469
495
  // Expand looped substitution variables
470
496
  const expandedPattern = expandSubstitutions(pattern, options);
471
497
  // Categorize keys
472
- const { handlerKeys, normalPattern, idRefNormalKeys, idRefHandlerKeys, ternaryKeys, syncOpKeys } = categorizeKeys(expandedPattern);
498
+ const { handlerKeys, normalPattern, idRefNormalKeys, idRefHandlerKeys, ternaryKeys, syncOpKeys, multiInvokeKeys } = categorizeKeys(expandedPattern);
473
499
  const resolveOptions = { ...options, root: target, permissionProcessor };
474
500
  // Process ?= ternary keys (sync)
475
501
  if (ternaryKeys.length > 0) {
@@ -517,6 +543,26 @@ export function assignFrom(target, pattern, options, permissionProcessor) {
517
543
  }
518
544
  }
519
545
  }
546
+ // Process =* multi-invoke keys (sync): call a withMethods method sitting at the
547
+ // base path once per argument-list on the RHS. Each argument-list is resolved
548
+ // against `from` (so `?.` paths and `!!` markers work), then delegated to
549
+ // assignGingerly as a single array-valued key — which spreads it as call args.
550
+ if (multiInvokeKeys.length > 0) {
551
+ for (const key of multiInvokeKeys) {
552
+ const basePath = parseMultiInvokeCommand(key);
553
+ if (basePath === null)
554
+ continue;
555
+ const callList = expandedPattern[key];
556
+ if (!Array.isArray(callList)) {
557
+ throw new Error(`assignFrom: multi-invoke command "${key}" requires an array of argument-lists`);
558
+ }
559
+ for (const rawArgs of callList) {
560
+ const argList = Array.isArray(rawArgs) ? rawArgs : [rawArgs];
561
+ const resolved = getValues({ __args: argList }, options.from, resolveOptions).__args;
562
+ assignGingerly(target, { [basePath]: resolved }, options, permissionProcessor);
563
+ }
564
+ }
565
+ }
520
566
  // Process normal keys via getValues (sync) + assignGingerly
521
567
  if (Object.keys(normalPattern).length > 0) {
522
568
  // Resolve #[x] references on RHS values before getValues
package/assignFrom.ts CHANGED
@@ -67,6 +67,22 @@ export function parseSyncOpCommand(key: string): string | null {
67
67
  return key.substring(0, key.length - 3); // Remove ' =&' suffix
68
68
  }
69
69
 
70
+ /**
71
+ * Check if a key ends with the multi-invoke operator ' =*'.
72
+ */
73
+ export function isMultiInvokeCommand(key: string): boolean {
74
+ return key.endsWith(' =*');
75
+ }
76
+
77
+ /**
78
+ * Parse a =* multi-invoke command and extract the base LHS path — the path a
79
+ * `withMethods` method sits at, to be called once per argument-list on the RHS.
80
+ */
81
+ export function parseMultiInvokeCommand(key: string): string | null {
82
+ if (!isMultiInvokeCommand(key)) return null;
83
+ return key.substring(0, key.length - 3); // Remove ' =*' suffix
84
+ }
85
+
70
86
  /**
71
87
  * Throws if a resolved sync-op value (or anything nested inside it) is a thenable.
72
88
  *
@@ -349,12 +365,18 @@ export function expandSubstitutions(
349
365
  }
350
366
 
351
367
  /**
352
- * Convert entries to an object, merging duplicate handler (` =>`) keys into arrays.
368
+ * Convert entries to an object. Duplicate keys which `where_x_in` expansion can
369
+ * produce — are collapsed with last-wins, except:
370
+ * - ` =>` handler keys merge into the Multiple Handlers array form.
371
+ * - ` =*` multi-invoke keys concatenate their argument-list arrays, so an
372
+ * expanded pattern still invokes the method once per expansion.
353
373
  */
354
374
  export function mergeHandlerDuplicates(entries: [string, any][]): Record<string, any> {
355
375
  const result: Record<string, any> = {};
356
376
  for (const [key, value] of entries) {
357
- if (key.endsWith(' =>') && key in result) {
377
+ if (key in result && key.endsWith(' =*') && Array.isArray(result[key]) && Array.isArray(value)) {
378
+ result[key] = (result[key] as any[]).concat(value);
379
+ } else if (key.endsWith(' =>') && key in result) {
358
380
  const existing = result[key];
359
381
  if (Array.isArray(existing)) {
360
382
  existing.push(value);
@@ -400,6 +422,7 @@ export function categorizeKeys(expandedPattern: Record<string, any>) {
400
422
  const idRefHandlerKeys: string[] = [];
401
423
  const ternaryKeys: string[] = [];
402
424
  const syncOpKeys: string[] = [];
425
+ const multiInvokeKeys: string[] = [];
403
426
 
404
427
  for (const key of Object.keys(expandedPattern)) {
405
428
  if (isHandlerCommand(key)) {
@@ -412,6 +435,8 @@ export function categorizeKeys(expandedPattern: Record<string, any>) {
412
435
  ternaryKeys.push(key);
413
436
  } else if (isSyncOpCommand(key)) {
414
437
  syncOpKeys.push(key);
438
+ } else if (isMultiInvokeCommand(key)) {
439
+ multiInvokeKeys.push(key);
415
440
  } else if (key.startsWith('#[')) {
416
441
  idRefNormalKeys.push(key);
417
442
  } else {
@@ -419,7 +444,7 @@ export function categorizeKeys(expandedPattern: Record<string, any>) {
419
444
  }
420
445
  }
421
446
 
422
- return { handlerKeys, normalPattern, idRefNormalKeys, idRefHandlerKeys, ternaryKeys, syncOpKeys };
447
+ return { handlerKeys, normalPattern, idRefNormalKeys, idRefHandlerKeys, ternaryKeys, syncOpKeys, multiInvokeKeys };
423
448
  }
424
449
 
425
450
  /**
@@ -495,7 +520,7 @@ export function assignFrom(
495
520
  const expandedPattern = expandSubstitutions(pattern, options);
496
521
 
497
522
  // Categorize keys
498
- const { handlerKeys, normalPattern, idRefNormalKeys, idRefHandlerKeys, ternaryKeys, syncOpKeys } = categorizeKeys(expandedPattern);
523
+ const { handlerKeys, normalPattern, idRefNormalKeys, idRefHandlerKeys, ternaryKeys, syncOpKeys, multiInvokeKeys } = categorizeKeys(expandedPattern);
499
524
 
500
525
  const resolveOptions = { ...options, root: target, permissionProcessor } as any;
501
526
 
@@ -547,6 +572,26 @@ export function assignFrom(
547
572
  }
548
573
  }
549
574
 
575
+ // Process =* multi-invoke keys (sync): call a withMethods method sitting at the
576
+ // base path once per argument-list on the RHS. Each argument-list is resolved
577
+ // against `from` (so `?.` paths and `!!` markers work), then delegated to
578
+ // assignGingerly as a single array-valued key — which spreads it as call args.
579
+ if (multiInvokeKeys.length > 0) {
580
+ for (const key of multiInvokeKeys) {
581
+ const basePath = parseMultiInvokeCommand(key);
582
+ if (basePath === null) continue;
583
+ const callList = expandedPattern[key];
584
+ if (!Array.isArray(callList)) {
585
+ throw new Error(`assignFrom: multi-invoke command "${key}" requires an array of argument-lists`);
586
+ }
587
+ for (const rawArgs of callList) {
588
+ const argList = Array.isArray(rawArgs) ? rawArgs : [rawArgs];
589
+ const resolved = getValues({ __args: argList }, options.from, resolveOptions).__args;
590
+ assignGingerly(target, { [basePath]: resolved }, options, permissionProcessor);
591
+ }
592
+ }
593
+ }
594
+
550
595
  // Process normal keys via getValues (sync) + assignGingerly
551
596
  if (Object.keys(normalPattern).length > 0) {
552
597
  // Resolve #[x] references on RHS values before getValues
@@ -21,14 +21,14 @@
21
21
  */
22
22
  import { resolveValues } from './resolve/resolveValues.js';
23
23
  import assignGingerly from './assignGingerly.js';
24
- import { expandSubstitutions, categorizeKeys, handleSpreads } from './assignFrom.js';
24
+ import { expandSubstitutions, categorizeKeys, handleSpreads, parseMultiInvokeCommand } from './assignFrom.js';
25
25
  // Module cache for processHandlerCommands — avoids await on dynamic import after first call
26
26
  let _processHandlerCommands;
27
27
  export async function assignFromAsync(target, pattern, options, permissionProcessor) {
28
28
  // First: expand looped substitution variables (${x}, ${y}, ${z})
29
29
  const expandedPattern = expandSubstitutions(pattern, options);
30
30
  // Categorize keys
31
- const { handlerKeys, normalPattern, idRefNormalKeys, idRefHandlerKeys } = categorizeKeys(expandedPattern);
31
+ const { handlerKeys, normalPattern, idRefNormalKeys, idRefHandlerKeys, multiInvokeKeys } = categorizeKeys(expandedPattern);
32
32
  // Process normal keys via resolveValues + assignGingerly
33
33
  if (Object.keys(normalPattern).length > 0) {
34
34
  const resolved = await resolveValues(normalPattern, options.from, {
@@ -44,6 +44,28 @@ export async function assignFromAsync(target, pattern, options, permissionProces
44
44
  handleSpreads(resolved);
45
45
  assignGingerly(target, resolved, options, permissionProcessor);
46
46
  }
47
+ // Process =* multi-invoke keys: call a withMethods method sitting at the base
48
+ // path once per argument-list on the RHS (see assignFrom for the sync form).
49
+ if (multiInvokeKeys && multiInvokeKeys.length > 0) {
50
+ const { withMethods, aka, akaMethods, substitutions, protocols, from } = options;
51
+ for (const key of multiInvokeKeys) {
52
+ const basePath = parseMultiInvokeCommand(key);
53
+ if (basePath === null)
54
+ continue;
55
+ const callList = expandedPattern[key];
56
+ if (!Array.isArray(callList)) {
57
+ throw new Error(`assignFrom: multi-invoke command "${key}" requires an array of argument-lists`);
58
+ }
59
+ for (const rawArgs of callList) {
60
+ const argList = Array.isArray(rawArgs) ? rawArgs : [rawArgs];
61
+ const resolved = await resolveValues({ __args: argList }, from, {
62
+ withMethods, aka, akaMethods, substitutions, protocols,
63
+ root: target, permissionProcessor
64
+ });
65
+ assignGingerly(target, { [basePath]: resolved.__args }, options, permissionProcessor);
66
+ }
67
+ }
68
+ }
47
69
  // Process #[x] normal keys — resolve element, then apply remaining path + value
48
70
  if (idRefNormalKeys.length > 0 && (options.pin || options.at)) {
49
71
  const ids = { ...options.pin, ...options.at };
@@ -24,7 +24,7 @@ import {IAssignGingerlyOptions} from './types/assign-gingerly/types.js';
24
24
  import assignGingerly from './assignGingerly.js';
25
25
  import type { PermissionProcessor, AssignFromHandler, AssignFromHandlerConstructor } from './types/assign-gingerly/types.js';
26
26
  import {
27
- expandSubstitutions, categorizeKeys, handleSpreads, isHandlerCommand
27
+ expandSubstitutions, categorizeKeys, handleSpreads, isHandlerCommand, parseMultiInvokeCommand
28
28
  } from './assignFrom.js';
29
29
 
30
30
  export interface AssignFromOptions extends IAssignGingerlyOptions {
@@ -61,7 +61,7 @@ export async function assignFromAsync(
61
61
  const expandedPattern = expandSubstitutions(pattern, options);
62
62
 
63
63
  // Categorize keys
64
- const { handlerKeys, normalPattern, idRefNormalKeys, idRefHandlerKeys } = categorizeKeys(expandedPattern);
64
+ const { handlerKeys, normalPattern, idRefNormalKeys, idRefHandlerKeys, multiInvokeKeys } = categorizeKeys(expandedPattern);
65
65
 
66
66
  // Process normal keys via resolveValues + assignGingerly
67
67
  if (Object.keys(normalPattern).length > 0) {
@@ -81,6 +81,28 @@ export async function assignFromAsync(
81
81
  assignGingerly(target, resolved, options, permissionProcessor);
82
82
  }
83
83
 
84
+ // Process =* multi-invoke keys: call a withMethods method sitting at the base
85
+ // path once per argument-list on the RHS (see assignFrom for the sync form).
86
+ if (multiInvokeKeys && multiInvokeKeys.length > 0) {
87
+ const { withMethods, aka, akaMethods, substitutions, protocols, from } = options;
88
+ for (const key of multiInvokeKeys) {
89
+ const basePath = parseMultiInvokeCommand(key);
90
+ if (basePath === null) continue;
91
+ const callList = expandedPattern[key];
92
+ if (!Array.isArray(callList)) {
93
+ throw new Error(`assignFrom: multi-invoke command "${key}" requires an array of argument-lists`);
94
+ }
95
+ for (const rawArgs of callList) {
96
+ const argList = Array.isArray(rawArgs) ? rawArgs : [rawArgs];
97
+ const resolved = await resolveValues({ __args: argList }, from, {
98
+ withMethods, aka, akaMethods, substitutions, protocols,
99
+ root: target, permissionProcessor
100
+ });
101
+ assignGingerly(target, { [basePath]: resolved.__args }, options, permissionProcessor);
102
+ }
103
+ }
104
+ }
105
+
84
106
  // Process #[x] normal keys — resolve element, then apply remaining path + value
85
107
  if (idRefNormalKeys.length > 0 && (options.pin || options.at)) {
86
108
  const ids = { ...options.pin, ...options.at };
@@ -50,12 +50,21 @@ export class Infer {
50
50
  }
51
51
  #value;
52
52
  get value() {
53
- return this.#value;
53
+ const element = this.#weakRef.deref();
54
+ if (element === undefined)
55
+ return this.#value;
56
+ const propName = typeof this.#propName === 'string'
57
+ ? this.#propName
58
+ : inferValueProperty(element);
59
+ return coerceElementValue(element, propName);
54
60
  }
55
61
  set value(nv) {
56
62
  this.#value = nv;
57
63
  const { enhancedElement } = this;
58
- enhancedElement[inferValueProperty(enhancedElement)] = nv;
64
+ const propName = typeof this.#propName === 'string'
65
+ ? this.#propName
66
+ : inferValueProperty(enhancedElement);
67
+ enhancedElement[propName] = serializeForProperty(propName, nv);
59
68
  }
60
69
  #display;
61
70
  get display() {
@@ -166,6 +175,75 @@ export function inferValueProperty(element) {
166
175
  }
167
176
  }
168
177
  }
178
+ /**
179
+ * Read the inferred value property off an element and coerce it to a natural
180
+ * JavaScript type, mirroring the legacy be-value-added parsing rules:
181
+ * - `<time>` (dateTime) -> Date (or undefined when empty)
182
+ * - `<input type=number|range>` (valueAsNumber) -> number (undefined when NaN)
183
+ * - `<input type=checkbox|radio>` (checked) -> boolean
184
+ * - schema.org `itemtype` hints (Number/Integer/Float/Boolean/Date/DateTime) are honored
185
+ * - `textContent` is returned verbatim
186
+ * - everything else is JSON-parsed when possible (so `<data value="123">` -> 123,
187
+ * `<data value="true">` -> true), falling back to the raw string
188
+ */
189
+ export function coerceElementValue(element, propName = inferValueProperty(element)) {
190
+ const raw = element[propName];
191
+ switch (propName) {
192
+ case 'valueAsNumber':
193
+ return Number.isNaN(raw) ? undefined : raw;
194
+ case 'valueAsDate':
195
+ return raw ?? undefined;
196
+ case 'checked':
197
+ case 'selectedIndex':
198
+ return raw;
199
+ case 'dateTime': {
200
+ const s = raw == null ? '' : String(raw);
201
+ return s === '' ? undefined : new Date(s);
202
+ }
203
+ }
204
+ if (raw == null)
205
+ return undefined;
206
+ if (typeof raw !== 'string')
207
+ return raw;
208
+ switch (element.getAttribute('itemtype')) {
209
+ case 'https://schema.org/Number': return Number(raw);
210
+ case 'https://schema.org/Integer': return parseInt(raw, 10);
211
+ case 'https://schema.org/Float': return parseFloat(raw);
212
+ case 'https://schema.org/Boolean': return raw === 'true' || raw === 'True';
213
+ case 'https://schema.org/Date':
214
+ case 'https://schema.org/DateTime': return new Date(raw);
215
+ }
216
+ if (propName === 'textContent')
217
+ return raw;
218
+ if (raw === '')
219
+ return undefined;
220
+ try {
221
+ return JSON.parse(raw);
222
+ }
223
+ catch {
224
+ return raw;
225
+ }
226
+ }
227
+ /**
228
+ * Serialize a JS value for assignment to a (usually string-typed) DOM value
229
+ * property, mirroring legacy be-value-added write-back:
230
+ * - DOM-typed properties (`checked`, `valueAsNumber`, `valueAsDate`) take the raw value
231
+ * - a `Date` is written as an ISO string (so `<time>.dateTime` round-trips)
232
+ * - plain objects / arrays are JSON-stringified
233
+ */
234
+ export function serializeForProperty(propName, nv) {
235
+ switch (propName) {
236
+ case 'checked':
237
+ case 'valueAsNumber':
238
+ case 'valueAsDate':
239
+ return nv;
240
+ }
241
+ if (nv instanceof Date)
242
+ return nv.toISOString();
243
+ if (nv !== null && typeof nv === 'object')
244
+ return JSON.stringify(nv);
245
+ return nv;
246
+ }
169
247
  /**
170
248
  * Infer the most appropriate display property for an element
171
249
  * @param element - The element to infer the property for
@@ -65,13 +65,21 @@ export class Infer<TValue = any, TDisplay = any> {
65
65
  #value: TValue | undefined;
66
66
 
67
67
  get value(): TValue | undefined {
68
- return this.#value;
68
+ const element = this.#weakRef.deref();
69
+ if (element === undefined) return this.#value;
70
+ const propName = typeof this.#propName === 'string'
71
+ ? this.#propName
72
+ : inferValueProperty(element);
73
+ return coerceElementValue(element, propName) as TValue | undefined;
69
74
  }
70
-
75
+
71
76
  set value(nv: TValue){
72
77
  this.#value = nv;
73
78
  const {enhancedElement} = this;
74
- (enhancedElement as any)[inferValueProperty(enhancedElement)] = nv;
79
+ const propName = typeof this.#propName === 'string'
80
+ ? this.#propName
81
+ : inferValueProperty(enhancedElement);
82
+ (enhancedElement as any)[propName] = serializeForProperty(propName, nv);
75
83
  }
76
84
 
77
85
  #display: TDisplay | undefined;
@@ -200,6 +208,75 @@ export function inferValueProperty(element: Element): string {
200
208
  }
201
209
  }
202
210
 
211
+ /**
212
+ * Read the inferred value property off an element and coerce it to a natural
213
+ * JavaScript type, mirroring the legacy be-value-added parsing rules:
214
+ * - `<time>` (dateTime) -> Date (or undefined when empty)
215
+ * - `<input type=number|range>` (valueAsNumber) -> number (undefined when NaN)
216
+ * - `<input type=checkbox|radio>` (checked) -> boolean
217
+ * - schema.org `itemtype` hints (Number/Integer/Float/Boolean/Date/DateTime) are honored
218
+ * - `textContent` is returned verbatim
219
+ * - everything else is JSON-parsed when possible (so `<data value="123">` -> 123,
220
+ * `<data value="true">` -> true), falling back to the raw string
221
+ */
222
+ export function coerceElementValue(element: Element, propName: string = inferValueProperty(element)): any {
223
+ const raw = (element as any)[propName];
224
+
225
+ switch (propName) {
226
+ case 'valueAsNumber':
227
+ return Number.isNaN(raw) ? undefined : raw;
228
+ case 'valueAsDate':
229
+ return raw ?? undefined;
230
+ case 'checked':
231
+ case 'selectedIndex':
232
+ return raw;
233
+ case 'dateTime': {
234
+ const s = raw == null ? '' : String(raw);
235
+ return s === '' ? undefined : new Date(s);
236
+ }
237
+ }
238
+
239
+ if (raw == null) return undefined;
240
+ if (typeof raw !== 'string') return raw;
241
+
242
+ switch (element.getAttribute('itemtype')) {
243
+ case 'https://schema.org/Number': return Number(raw);
244
+ case 'https://schema.org/Integer': return parseInt(raw, 10);
245
+ case 'https://schema.org/Float': return parseFloat(raw);
246
+ case 'https://schema.org/Boolean': return raw === 'true' || raw === 'True';
247
+ case 'https://schema.org/Date':
248
+ case 'https://schema.org/DateTime': return new Date(raw);
249
+ }
250
+
251
+ if (propName === 'textContent') return raw;
252
+ if (raw === '') return undefined;
253
+
254
+ try {
255
+ return JSON.parse(raw);
256
+ } catch {
257
+ return raw;
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Serialize a JS value for assignment to a (usually string-typed) DOM value
263
+ * property, mirroring legacy be-value-added write-back:
264
+ * - DOM-typed properties (`checked`, `valueAsNumber`, `valueAsDate`) take the raw value
265
+ * - a `Date` is written as an ISO string (so `<time>.dateTime` round-trips)
266
+ * - plain objects / arrays are JSON-stringified
267
+ */
268
+ export function serializeForProperty(propName: string, nv: any): any {
269
+ switch (propName) {
270
+ case 'checked':
271
+ case 'valueAsNumber':
272
+ case 'valueAsDate':
273
+ return nv;
274
+ }
275
+ if (nv instanceof Date) return nv.toISOString();
276
+ if (nv !== null && typeof nv === 'object') return JSON.stringify(nv);
277
+ return nv;
278
+ }
279
+
203
280
  /**
204
281
  * Infer the most appropriate display property for an element
205
282
  * @param element - The element to infer the property for
@@ -139,6 +139,18 @@ Plus infrastructure:
139
139
  - `attachInternals()` called in the constructor
140
140
  - Async `fallbackSpawn` for lazy-loading all feature implementations
141
141
 
142
+ ### Injecting a custom or package-local feature
143
+
144
+ `assignFeatures` accepts a `spawn` per feature key — a class, an async loader, or
145
+ an **import-path string**. A string `spawn` overrides the catalog `fallbackSpawn`
146
+ and is dynamically `import()`ed through the page's import map, so `el-maker.json`
147
+ stays pure JSON. Use it to add a feature that isn't in the catalog, or to swap in
148
+ a package-local **subclass** of a catalog feature when the generic one needs
149
+ element-specific logic. See
150
+ [NewHTMLFirstCustomElement.md → "give a shared feature element-specific logic"](./NewHTMLFirstCustomElement.md#how-do-i-give-a-shared-feature-element-specific-logic-penciling-in)
151
+ and the [css-charts](https://github.com/bahrus/css-charts) conversion for a
152
+ worked example ("penciling in" `CSSChartsH2OTable extends H2OTable`).
153
+
142
154
 
143
155
 
144
156
 
@@ -71,10 +71,14 @@ A custom element feature is a class that:
71
71
 
72
72
  ## Step 3: Create Type Definitions
73
73
 
74
+ **All types for the feature live in `types/[project-name]/types.d.ts` — nothing else.** Do not scatter `@typedef {Object} ...` blocks through the `.js` file. That includes the `customData` / injection-config shape, the `detail` payload of any event the feature dispatches, and every internal helper type. The `.js` file only ever *imports* these via `@import`; it never defines them. A reader (or the package that later adopts the feature) should be able to learn the whole type surface from the one `.d.ts`.
75
+
74
76
  Create `types/[project-name]/types.d.ts` with the feature structure:
75
77
 
76
78
  ```typescript
77
- import { SpawnContext } from "../assign-gingerly/types";
79
+ import { SpawnContext, FeatureSpawnContext } from "../assign-gingerly/types";
80
+
81
+ export { FeatureSpawnContext };
78
82
 
79
83
  /**
80
84
  * Configuration/properties that the feature exposes
@@ -96,9 +100,26 @@ export type AP = AllProps;
96
100
  export type PAP = Partial<AP>;
97
101
 
98
102
  /**
99
- * Context passed to the feature constructor
103
+ * The `customData` passed through the injection config — parsed by the
104
+ * constructor into initial state. Keep every configurable knob here.
105
+ */
106
+ export interface CustomData {
107
+ myProp?: string;
108
+ eventType?: string;
109
+ }
110
+
111
+ /**
112
+ * `detail` payload for any CustomEvent the feature dispatches on the host.
100
113
  */
101
- export interface FeatureSpawnContext extends SpawnContext {
114
+ export interface MyFeatureResolvedDetail {
115
+ // ...
116
+ }
117
+ ```
118
+
119
+ If a project's `types/assign-gingerly/types.d.ts` does not yet export `FeatureSpawnContext`, define it locally in this file instead (as `truth-sourcer` and `face-up` do):
120
+
121
+ ```typescript
122
+ export interface FeatureSpawnContext {
102
123
  key: string;
103
124
  optIn: any;
104
125
  injection: any;
@@ -110,7 +131,9 @@ export interface FeatureSpawnContext extends SpawnContext {
110
131
  **Key points:**
111
132
  - `FeatureProps` — the public API of the feature
112
133
  - `AllProps` — includes internal state like a WeakRef to the host element
134
+ - `CustomData` — the injection-config shape; the constructor reads `ctx.injection.customData` and narrows it to this type
113
135
  - The feature class does NOT need to extend any base class
136
+ - The `.js` file imports all of the above with `/** @import {...} from './types/[project-name]/types' */` — it defines no types of its own
114
137
 
115
138
  ## Step 4: Create the Feature Class
116
139
 
@@ -150,7 +173,7 @@ export { MyFeature };
150
173
  - Constructor signature: `(hostElement, ctx, initVals)`
151
174
  - Store host as a `WeakRef` to avoid preventing garbage collection
152
175
  - Apply `initVals` via `Object.assign` in the constructor
153
- - Use `@ts-check` with JSDoc type imports from the `types/` folder
176
+ - Use `@ts-check` with JSDoc type imports from the `types/` folder — all type definitions live in `types/[project-name]/types.d.ts`, never as inline `@typedef` blocks in the `.js`
154
177
  - No compiled TypeScript — ship raw `.js` files
155
178
 
156
179
  ## Step 5: Create imports.html
@@ -679,6 +702,7 @@ customElements.define('my-element', MyElement);
679
702
 
680
703
  - **Call `assignFeatures` before `customElements.define()`** — getters must be on the prototype before instances exist
681
704
  - **Use `@ts-check`** — catches type errors early in `.js` files
705
+ - **Keep all types in `types/[project-name]/types.d.ts`** — including `customData` and event `detail` shapes; the `.js` only `@import`s them, it never declares `@typedef`s
682
706
  - **Store host as WeakRef** — prevents memory leaks
683
707
  - **Keep features focused** — one responsibility per feature class
684
708
  - **Use `validateShape`** — catches injection errors early in development