assign-gingerly 0.0.90 → 0.0.91

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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "assign-gingerly",
3
- "version": "0.0.90",
3
+ "version": "0.0.91",
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": {
@@ -188,6 +188,80 @@ function navigatePath(source, parts, withMethods, permissionProcessor) {
188
188
  function hasProtocol(value) {
189
189
  return value.includes('://');
190
190
  }
191
+ /**
192
+ * Detect a leading run of `!` characters used as a boolean coercion / negation
193
+ * marker (`!` = negate, `!!` = coerce to boolean, `!!!` = negate, …). Returns the
194
+ * count and the remaining string, or null when the value does not start with `!`.
195
+ *
196
+ * The marker is only *honored* when the remainder is itself a resolvable
197
+ * reference (see `looksLikeReference`); otherwise the original string is a plain
198
+ * literal — e.g. a CSS `!important` passes through untouched.
199
+ */
200
+ function parseNegationMarker(value) {
201
+ let count = 0;
202
+ while (count < value.length && value.charCodeAt(count) === 33 /* '!' */)
203
+ count++;
204
+ if (count === 0)
205
+ return null;
206
+ return { count, rest: value.slice(count) };
207
+ }
208
+ /**
209
+ * Whether a string should be resolved as a value reference rather than kept as a
210
+ * literal: a `?.` path, a `$0` root reference, or a recognized protocol.
211
+ */
212
+ function looksLikeReference(value, protocols) {
213
+ return value.startsWith('?.')
214
+ || value.startsWith('$0')
215
+ || (!!protocols && hasProtocol(value));
216
+ }
217
+ /**
218
+ * Resolve a single reference string (`?.` path, `$0` root ref, or protocol) to
219
+ * its value. Callers must confirm `looksLikeReference(value)` first.
220
+ */
221
+ function resolveReferenceString(value, source, aliasMap, withMethods, protocols, options, substitutionMap) {
222
+ const permissionProcessor = options?.permissionProcessor;
223
+ if (value.startsWith('?.')) {
224
+ const substituted = applySubstitutions(value, substitutionMap);
225
+ const aliased = applyAliases(substituted, aliasMap);
226
+ const parts = parseCachedPath(aliased);
227
+ return parts.length === 0 ? source : navigatePath(source, parts, withMethods, permissionProcessor);
228
+ }
229
+ if (value.startsWith('$0')) {
230
+ const rootRef = resolveRootReference(value, source, options?.root);
231
+ if (rootRef === null)
232
+ return source;
233
+ const substitutedPath = applySubstitutions(rootRef.path, substitutionMap);
234
+ const aliased = applyAliases(substitutedPath, aliasMap);
235
+ const normalizedPath = aliased.startsWith('?.') ? aliased : (aliased ? `?.${aliased}` : '?.');
236
+ const parts = parseCachedPath(normalizedPath);
237
+ return parts.length === 0 ? rootRef.source : navigatePath(rootRef.source, parts, withMethods, permissionProcessor);
238
+ }
239
+ // protocol — looksLikeReference already ensured protocols is defined
240
+ return getProtocolValue(value, protocols, options);
241
+ }
242
+ /**
243
+ * Resolve a string RHS value: a reference (`?.` / `$0` / protocol), a
244
+ * `!`-prefixed coercion/negation of a reference, or a plain literal.
245
+ */
246
+ function resolveStringValue(value, source, aliasMap, withMethods, protocols, options, substitutionMap) {
247
+ // A leading `!` run can only be a coercion/negation marker or a literal —
248
+ // no `?.` path, `$0` ref, or protocol name starts with `!`. Check it first so
249
+ // `hasProtocol` inside `looksLikeReference` can't misread `!!proto://x`.
250
+ if (value.charCodeAt(0) === 33 /* '!' */) {
251
+ const neg = parseNegationMarker(value);
252
+ if (neg !== null && looksLikeReference(neg.rest, protocols)) {
253
+ let resolved = resolveReferenceString(neg.rest, source, aliasMap, withMethods, protocols, options, substitutionMap);
254
+ for (let i = 0; i < neg.count; i++)
255
+ resolved = !resolved;
256
+ return resolved;
257
+ }
258
+ return value;
259
+ }
260
+ if (looksLikeReference(value, protocols)) {
261
+ return resolveReferenceString(value, source, aliasMap, withMethods, protocols, options, substitutionMap);
262
+ }
263
+ return value;
264
+ }
191
265
  /**
192
266
  * Resolve a protocol-prefixed value synchronously.
193
267
  */
@@ -212,30 +286,10 @@ function getProtocolValue(value, protocols, options) {
212
286
  * Recurses into nested arrays and plain objects.
213
287
  */
214
288
  function getArray(arr, source, aliasMap, withMethods, protocols, options, substitutionMap) {
215
- const permissionProcessor = options?.permissionProcessor;
216
289
  const result = [];
217
290
  for (const item of arr) {
218
- if (typeof item === 'string' && item.startsWith('?.')) {
219
- const substituted = applySubstitutions(item, substitutionMap);
220
- const aliased = applyAliases(substituted, aliasMap);
221
- const parts = parseCachedPath(aliased);
222
- result.push(parts.length === 0 ? source : navigatePath(source, parts, withMethods, permissionProcessor));
223
- }
224
- else if (typeof item === 'string' && item.startsWith('$0')) {
225
- const rootRef = resolveRootReference(item, source, options?.root);
226
- if (rootRef === null) {
227
- result.push(source);
228
- }
229
- else {
230
- const substitutedPath = applySubstitutions(rootRef.path, substitutionMap);
231
- const aliased = applyAliases(substitutedPath, aliasMap);
232
- const normalizedPath = aliased.startsWith('?.') ? aliased : (aliased ? `?.${aliased}` : '?.');
233
- const parts = parseCachedPath(normalizedPath);
234
- result.push(parts.length === 0 ? rootRef.source : navigatePath(rootRef.source, parts, withMethods, permissionProcessor));
235
- }
236
- }
237
- else if (typeof item === 'string' && protocols && hasProtocol(item)) {
238
- result.push(getProtocolValue(item, protocols, options));
291
+ if (typeof item === 'string') {
292
+ result.push(resolveStringValue(item, source, aliasMap, withMethods, protocols, options, substitutionMap));
239
293
  }
240
294
  else if (Array.isArray(item)) {
241
295
  result.push(getArray(item, source, aliasMap, withMethods, protocols, options, substitutionMap));
@@ -271,30 +325,10 @@ export function getValues(pattern, source, options) {
271
325
  const { aliasMap, withMethods } = normalizeAliasOptions(options);
272
326
  const substitutionMap = resolveSubstitutions(options?.substitutions, source, options);
273
327
  const protocols = options?.protocols;
274
- const permissionProcessor = options?.permissionProcessor;
275
328
  const result = {};
276
329
  for (const [key, value] of Object.entries(pattern)) {
277
- if (typeof value === 'string' && value.startsWith('?.')) {
278
- const substituted = applySubstitutions(value, substitutionMap);
279
- const aliased = applyAliases(substituted, aliasMap);
280
- const parts = parseCachedPath(aliased);
281
- result[key] = parts.length === 0 ? source : navigatePath(source, parts, withMethods, permissionProcessor);
282
- }
283
- else if (typeof value === 'string' && value.startsWith('$0')) {
284
- const rootRef = resolveRootReference(value, source, options?.root);
285
- if (rootRef === null) {
286
- result[key] = source;
287
- }
288
- else {
289
- const substitutedPath = applySubstitutions(rootRef.path, substitutionMap);
290
- const aliased = applyAliases(substitutedPath, aliasMap);
291
- const normalizedPath = aliased.startsWith('?.') ? aliased : (aliased ? `?.${aliased}` : '?.');
292
- const parts = parseCachedPath(normalizedPath);
293
- result[key] = parts.length === 0 ? rootRef.source : navigatePath(rootRef.source, parts, withMethods, permissionProcessor);
294
- }
295
- }
296
- else if (typeof value === 'string' && protocols && hasProtocol(value)) {
297
- result[key] = getProtocolValue(value, protocols, options);
330
+ if (typeof value === 'string') {
331
+ result[key] = resolveStringValue(value, source, aliasMap, withMethods, protocols, options, substitutionMap);
298
332
  }
299
333
  else if (Array.isArray(value)) {
300
334
  result[key] = getArray(value, source, aliasMap, withMethods, protocols, options, substitutionMap);
@@ -323,6 +357,15 @@ export function getValues(pattern, source, options) {
323
357
  * @returns The resolved value, or undefined if any segment is nullish
324
358
  */
325
359
  export function getValue(path, source, options) {
360
+ // Leading `!` run: coerce/negate the resolved reference (`!` negate, `!!` boolean, …).
361
+ // Only honored in front of a `?.` path or `$0` root reference — otherwise literal.
362
+ const neg = parseNegationMarker(path);
363
+ if (neg !== null && (neg.rest.startsWith('?.') || neg.rest.startsWith('$0'))) {
364
+ let resolved = getValue(neg.rest, source, options);
365
+ for (let i = 0; i < neg.count; i++)
366
+ resolved = !resolved;
367
+ return resolved;
368
+ }
326
369
  const rootRef = resolveRootReference(path, source, options?.root);
327
370
  if (rootRef) {
328
371
  path = rootRef.path;
@@ -227,6 +227,99 @@ function hasProtocol(value: string): boolean {
227
227
  return value.includes('://');
228
228
  }
229
229
 
230
+ /**
231
+ * Detect a leading run of `!` characters used as a boolean coercion / negation
232
+ * marker (`!` = negate, `!!` = coerce to boolean, `!!!` = negate, …). Returns the
233
+ * count and the remaining string, or null when the value does not start with `!`.
234
+ *
235
+ * The marker is only *honored* when the remainder is itself a resolvable
236
+ * reference (see `looksLikeReference`); otherwise the original string is a plain
237
+ * literal — e.g. a CSS `!important` passes through untouched.
238
+ */
239
+ function parseNegationMarker(value: string): { count: number; rest: string } | null {
240
+ let count = 0;
241
+ while (count < value.length && value.charCodeAt(count) === 33 /* '!' */) count++;
242
+ if (count === 0) return null;
243
+ return { count, rest: value.slice(count) };
244
+ }
245
+
246
+ /**
247
+ * Whether a string should be resolved as a value reference rather than kept as a
248
+ * literal: a `?.` path, a `$0` root reference, or a recognized protocol.
249
+ */
250
+ function looksLikeReference(
251
+ value: string,
252
+ protocols: Record<string, (key: string) => any> | undefined
253
+ ): boolean {
254
+ return value.startsWith('?.')
255
+ || value.startsWith('$0')
256
+ || (!!protocols && hasProtocol(value));
257
+ }
258
+
259
+ /**
260
+ * Resolve a single reference string (`?.` path, `$0` root ref, or protocol) to
261
+ * its value. Callers must confirm `looksLikeReference(value)` first.
262
+ */
263
+ function resolveReferenceString(
264
+ value: string,
265
+ source: any,
266
+ aliasMap: Map<string, string>,
267
+ withMethods: Set<string> | undefined,
268
+ protocols: Record<string, (key: string) => any> | undefined,
269
+ options: GetValuesOptions | undefined,
270
+ substitutionMap: Map<string, string> | undefined
271
+ ): any {
272
+ const permissionProcessor = options?.permissionProcessor;
273
+ if (value.startsWith('?.')) {
274
+ const substituted = applySubstitutions(value, substitutionMap);
275
+ const aliased = applyAliases(substituted, aliasMap);
276
+ const parts = parseCachedPath(aliased);
277
+ return parts.length === 0 ? source : navigatePath(source, parts, withMethods, permissionProcessor);
278
+ }
279
+ if (value.startsWith('$0')) {
280
+ const rootRef = resolveRootReference(value, source, options?.root);
281
+ if (rootRef === null) return source;
282
+ const substitutedPath = applySubstitutions(rootRef.path, substitutionMap);
283
+ const aliased = applyAliases(substitutedPath, aliasMap);
284
+ const normalizedPath = aliased.startsWith('?.') ? aliased : (aliased ? `?.${aliased}` : '?.');
285
+ const parts = parseCachedPath(normalizedPath);
286
+ return parts.length === 0 ? rootRef.source : navigatePath(rootRef.source, parts, withMethods, permissionProcessor);
287
+ }
288
+ // protocol — looksLikeReference already ensured protocols is defined
289
+ return getProtocolValue(value, protocols!, options);
290
+ }
291
+
292
+ /**
293
+ * Resolve a string RHS value: a reference (`?.` / `$0` / protocol), a
294
+ * `!`-prefixed coercion/negation of a reference, or a plain literal.
295
+ */
296
+ function resolveStringValue(
297
+ value: string,
298
+ source: any,
299
+ aliasMap: Map<string, string>,
300
+ withMethods: Set<string> | undefined,
301
+ protocols: Record<string, (key: string) => any> | undefined,
302
+ options: GetValuesOptions | undefined,
303
+ substitutionMap: Map<string, string> | undefined
304
+ ): any {
305
+ // A leading `!` run can only be a coercion/negation marker or a literal —
306
+ // no `?.` path, `$0` ref, or protocol name starts with `!`. Check it first so
307
+ // `hasProtocol` inside `looksLikeReference` can't misread `!!proto://x`.
308
+ if (value.charCodeAt(0) === 33 /* '!' */) {
309
+ const neg = parseNegationMarker(value);
310
+ if (neg !== null && looksLikeReference(neg.rest, protocols)) {
311
+ let resolved = resolveReferenceString(neg.rest, source, aliasMap, withMethods, protocols, options, substitutionMap);
312
+ for (let i = 0; i < neg.count; i++) resolved = !resolved;
313
+ return resolved;
314
+ }
315
+ return value;
316
+ }
317
+ if (looksLikeReference(value, protocols)) {
318
+ return resolveReferenceString(value, source, aliasMap, withMethods, protocols, options, substitutionMap);
319
+ }
320
+ return value;
321
+ }
322
+
230
323
  /**
231
324
  * Resolve a protocol-prefixed value synchronously.
232
325
  */
@@ -268,27 +361,10 @@ function getArray(
268
361
  options?: GetValuesOptions,
269
362
  substitutionMap?: Map<string, string>
270
363
  ): any[] {
271
- const permissionProcessor = options?.permissionProcessor;
272
364
  const result: any[] = [];
273
365
  for (const item of arr) {
274
- if (typeof item === 'string' && item.startsWith('?.')) {
275
- const substituted = applySubstitutions(item, substitutionMap);
276
- const aliased = applyAliases(substituted, aliasMap);
277
- const parts = parseCachedPath(aliased);
278
- result.push(parts.length === 0 ? source : navigatePath(source, parts, withMethods, permissionProcessor));
279
- } else if (typeof item === 'string' && item.startsWith('$0')) {
280
- const rootRef = resolveRootReference(item, source, options?.root);
281
- if (rootRef === null) {
282
- result.push(source);
283
- } else {
284
- const substitutedPath = applySubstitutions(rootRef.path, substitutionMap);
285
- const aliased = applyAliases(substitutedPath, aliasMap);
286
- const normalizedPath = aliased.startsWith('?.') ? aliased : (aliased ? `?.${aliased}` : '?.');
287
- const parts = parseCachedPath(normalizedPath);
288
- result.push(parts.length === 0 ? rootRef.source : navigatePath(rootRef.source, parts, withMethods, permissionProcessor));
289
- }
290
- } else if (typeof item === 'string' && protocols && hasProtocol(item)) {
291
- result.push(getProtocolValue(item, protocols, options));
366
+ if (typeof item === 'string') {
367
+ result.push(resolveStringValue(item, source, aliasMap, withMethods, protocols, options, substitutionMap));
292
368
  } else if (Array.isArray(item)) {
293
369
  result.push(getArray(item, source, aliasMap, withMethods, protocols, options, substitutionMap));
294
370
  } else if (item && typeof item === 'object') {
@@ -326,28 +402,11 @@ export function getValues(
326
402
  const substitutionMap = resolveSubstitutions(options?.substitutions, source, options);
327
403
 
328
404
  const protocols = options?.protocols;
329
- const permissionProcessor = options?.permissionProcessor;
330
405
 
331
406
  const result: Record<string, any> = {};
332
407
  for (const [key, value] of Object.entries(pattern)) {
333
- if (typeof value === 'string' && value.startsWith('?.')) {
334
- const substituted = applySubstitutions(value, substitutionMap);
335
- const aliased = applyAliases(substituted, aliasMap);
336
- const parts = parseCachedPath(aliased);
337
- result[key] = parts.length === 0 ? source : navigatePath(source, parts, withMethods, permissionProcessor);
338
- } else if (typeof value === 'string' && value.startsWith('$0')) {
339
- const rootRef = resolveRootReference(value, source, options?.root);
340
- if (rootRef === null) {
341
- result[key] = source;
342
- } else {
343
- const substitutedPath = applySubstitutions(rootRef.path, substitutionMap);
344
- const aliased = applyAliases(substitutedPath, aliasMap);
345
- const normalizedPath = aliased.startsWith('?.') ? aliased : (aliased ? `?.${aliased}` : '?.');
346
- const parts = parseCachedPath(normalizedPath);
347
- result[key] = parts.length === 0 ? rootRef.source : navigatePath(rootRef.source, parts, withMethods, permissionProcessor);
348
- }
349
- } else if (typeof value === 'string' && protocols && hasProtocol(value)) {
350
- result[key] = getProtocolValue(value, protocols, options);
408
+ if (typeof value === 'string') {
409
+ result[key] = resolveStringValue(value, source, aliasMap, withMethods, protocols, options, substitutionMap);
351
410
  } else if (Array.isArray(value)) {
352
411
  result[key] = getArray(value, source, aliasMap, withMethods, protocols, options, substitutionMap);
353
412
  } else if (typeof value === 'object' && value !== null) {
@@ -377,6 +436,15 @@ export function getValue(
377
436
  source: any,
378
437
  options?: GetValuesOptions
379
438
  ): any {
439
+ // Leading `!` run: coerce/negate the resolved reference (`!` negate, `!!` boolean, …).
440
+ // Only honored in front of a `?.` path or `$0` root reference — otherwise literal.
441
+ const neg = parseNegationMarker(path);
442
+ if (neg !== null && (neg.rest.startsWith('?.') || neg.rest.startsWith('$0'))) {
443
+ let resolved = getValue(neg.rest, source, options);
444
+ for (let i = 0; i < neg.count; i++) resolved = !resolved;
445
+ return resolved;
446
+ }
447
+
380
448
  const rootRef = resolveRootReference(path, source, options?.root);
381
449
  if (rootRef) {
382
450
  path = rootRef.path;