assign-gingerly 0.0.85 → 0.0.86

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/DX/emojis.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * import { builtInEmoji } from 'assign-gingerly/builtInEmoji.js';
8
8
  *
9
9
  * assignFrom(target, {
10
- * '?.el =>': { do: '🔗', get: { value: ['?.first', ' ', '?.last'] } }
10
+ * '?.el =>': { do: '🏷️', get: { template: [...] } }
11
11
  * }, { from: vm, handlers: builtInEmoji });
12
12
  */
13
13
  /**
@@ -17,14 +17,16 @@
17
17
  * |-------|---------|
18
18
  * | 📦 | builtIns.lazyLoad |
19
19
  * | 🎚️ | builtIns.lazyLoadSwitch |
20
- * | 🔗 | builtIns.join |
21
20
  * | 🏷️ | builtIns.microDataJoin |
22
21
  * | 📋 | builtIns.manageTemplateList |
22
+ *
23
+ * `join` is no longer a `do:` handler — it moved to the synchronous ` =&` operator
24
+ * (see syncOps/join.ts) and isn't looked up through options.handlers, so it has no
25
+ * emoji alias here.
23
26
  */
24
27
  export const builtInEmoji = {
25
28
  '📦': 'builtIns.lazyLoad',
26
29
  '🎚️': 'builtIns.lazyLoadSwitch',
27
- '🔗': 'builtIns.join',
28
30
  '🏷️': 'builtIns.microDataJoin',
29
31
  '📋': 'builtIns.manageTemplateList',
30
32
  '📊': 'builtIns.rangeSelector',
package/DX/emojis.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * import { builtInEmoji } from 'assign-gingerly/builtInEmoji.js';
8
8
  *
9
9
  * assignFrom(target, {
10
- * '?.el =>': { do: '🔗', get: { value: ['?.first', ' ', '?.last'] } }
10
+ * '?.el =>': { do: '🏷️', get: { template: [...] } }
11
11
  * }, { from: vm, handlers: builtInEmoji });
12
12
  */
13
13
 
@@ -18,14 +18,16 @@
18
18
  * |-------|---------|
19
19
  * | 📦 | builtIns.lazyLoad |
20
20
  * | 🎚️ | builtIns.lazyLoadSwitch |
21
- * | 🔗 | builtIns.join |
22
21
  * | 🏷️ | builtIns.microDataJoin |
23
22
  * | 📋 | builtIns.manageTemplateList |
23
+ *
24
+ * `join` is no longer a `do:` handler — it moved to the synchronous ` =&` operator
25
+ * (see syncOps/join.ts) and isn't looked up through options.handlers, so it has no
26
+ * emoji alias here.
24
27
  */
25
28
  export const builtInEmoji= {
26
29
  '📦': 'builtIns.lazyLoad',
27
30
  '🎚️': 'builtIns.lazyLoadSwitch',
28
- '🔗': 'builtIns.join',
29
31
  '🏷️': 'builtIns.microDataJoin',
30
32
  '📋': 'builtIns.manageTemplateList',
31
33
  '📊': 'builtIns.rangeSelector',
package/DX/paths.js CHANGED
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * const $ = paths<Person>();
17
17
  *
18
- * // Use sp (split into parts) to create arrays for builtIns.join:
18
+ * // Use sp (split into parts) to create arrays for the ' =&' join op:
19
19
  * const value = sp`${$.lastName}, ${$.firstName}`;
20
20
  * // ['?.lastName', ', ', '?.firstName']
21
21
  *
@@ -321,7 +321,7 @@ export function forEachKeyIn(keys, factory, options) {
321
321
  * string representation — no `.Path` call needed inside sp template literals.
322
322
  *
323
323
  * Arrays passed as interpolations are preserved as nested arrays (for
324
- * all-or-nothing optional segments in builtIns.join).
324
+ * all-or-nothing optional segments in the ' =&' join op).
325
325
  *
326
326
  * @example
327
327
  * const $ = paths<Person>();
package/DX/paths.ts CHANGED
@@ -15,12 +15,12 @@
15
15
  *
16
16
  * const $ = paths<Person>();
17
17
  *
18
- * // Use sp (split into parts) to create arrays for builtIns.join:
18
+ * // Use sp (split into parts) to create arrays for the ' =&' join op:
19
19
  * const value = sp`${$.lastName}, ${$.firstName}`;
20
20
  * // ['?.lastName', ', ', '?.firstName']
21
21
  *
22
- * // Use .Path for raw string contexts (object keys, plain arrays):
23
- * const key = $.textContent.Path; // '?.textContent'
22
+ * // Use .Path for raw string contexts (object keys, plain arrays):
23
+ * const key = $.textContent.Path; // '?.textContent'
24
24
  */
25
25
 
26
26
  /**
@@ -102,25 +102,25 @@ export interface PathsOptions {
102
102
  /**
103
103
  * Create a proxy for id-ref paths (#[varName]).
104
104
  * After the initial #[varName], further property access chains with ?. from the resolved element.
105
- * .Path returns the #[varName] prefix (optionally with further ?. path).
105
+ * .Path returns the #[varName] prefix (optionally with further ?. path).
106
106
  */
107
107
  function createIdRefProxy(idRef: string, options?: PathsOptions): any {
108
108
  function handler() {}
109
109
  Object.defineProperty(handler, PATH_SYMBOL, { value: idRef });
110
110
  return new Proxy(handler, {
111
111
  get(_, prop: string | symbol) {
112
- if (prop === 'Path' || prop === PATH_SYMBOL) {
113
- return idRef;
114
- }
112
+ if (prop === 'Path' || prop === PATH_SYMBOL) {
113
+ return idRef;
114
+ }
115
115
  if (typeof prop === 'symbol') return undefined;
116
116
 
117
117
  const reservedToken = getReservedToken(prop);
118
118
  if (reservedToken === 'Each') {
119
119
  return createIdRefProxy(appendPathSegment(idRef, '@each'), options);
120
120
  }
121
- if (reservedToken && reservedToken !== 'Path') {
122
- return createIdRefProxy(appendCommandSuffix(idRef, reservedToken), options);
123
- }
121
+ if (reservedToken && reservedToken !== 'Path') {
122
+ return createIdRefProxy(appendCommandSuffix(idRef, reservedToken), options);
123
+ }
124
124
 
125
125
  // Chain further path segments after the id ref
126
126
  const chained = appendPathSegment(idRef, String(prop));
@@ -162,16 +162,16 @@ function createPathProxy(prefix: string, options?: PathsOptions): any {
162
162
 
163
163
  return new Proxy(handler, {
164
164
  get(_, prop: string | symbol) {
165
- if (prop === 'Path' || prop === PATH_SYMBOL) {
166
- return serializePath(prefix);
167
- }
165
+ if (prop === 'Path' || prop === PATH_SYMBOL) {
166
+ return serializePath(prefix);
167
+ }
168
168
  // Ignore symbol access (Symbol.iterator, Symbol.toPrimitive, etc.)
169
169
  if (typeof prop === 'symbol') return undefined;
170
170
 
171
171
  const reservedToken = getReservedToken(String(prop));
172
- if (reservedToken === 'Path') {
173
- return serializePath(prefix);
174
- }
172
+ if (reservedToken === 'Path') {
173
+ return serializePath(prefix);
174
+ }
175
175
  if (reservedToken === 'Each') {
176
176
  return createPathProxy(appendPathSegment(prefix, '@each'), options);
177
177
  }
@@ -231,14 +231,14 @@ function createPathProxy(prefix: string, options?: PathsOptions): any {
231
231
  *
232
232
  * @example
233
233
  * const $ = paths<Person>();
234
- * $.lastName.Path // '?.lastName'
235
- * $.address.city.Path // '?.address?.city'
234
+ * $.lastName.Path // '?.lastName'
235
+ * $.address.city.Path // '?.address?.city'
236
236
  *
237
237
  * // With aka (reverse alias applied):
238
238
  * const $ = paths<MyEl>({ aka: { q: 'querySelector' } });
239
- * $.querySelector('.user').textContent.Path // '?.q?..user?.textContent'
239
+ * $.querySelector('.user').textContent.Path // '?.q?..user?.textContent'
240
240
  *
241
- * // Inside sp template literals, .Path is not needed:
241
+ * // Inside sp template literals, .Path is not needed:
242
242
  * sp`${$.lastName}, ${$.firstName}` // ['?.lastName', ', ', '?.firstName']
243
243
  */
244
244
  export function paths<T>(options?: PathsOptions): PathProxy<T> {
@@ -246,7 +246,7 @@ export function paths<T>(options?: PathsOptions): PathProxy<T> {
246
246
  }
247
247
 
248
248
  /**
249
- * Create an assignment pair: { [lhs.Path]: rhs.Path }.
249
+ * Create an assignment pair: { [lhs.Path]: rhs.Path }.
250
250
  * Used to express "set this target to this source value" in a spreadable form.
251
251
  *
252
252
  * @example
@@ -373,10 +373,10 @@ export function forEachKeyIn<T>(
373
373
  * Interleaves static string segments with interpolated values.
374
374
  *
375
375
  * Path proxy objects are auto-detected and converted to their `?.`-prefixed
376
- * string representation — no `.Path` call needed inside sp template literals.
376
+ * string representation — no `.Path` call needed inside sp template literals.
377
377
  *
378
378
  * Arrays passed as interpolations are preserved as nested arrays (for
379
- * all-or-nothing optional segments in builtIns.join).
379
+ * all-or-nothing optional segments in the ' =&' join op).
380
380
  *
381
381
  * @example
382
382
  * const $ = paths<Person>();
package/README.md CHANGED
@@ -851,7 +851,8 @@ While we are in the business of passing values of object A into object B, we mig
851
851
  | ` -=` | Delete | Remove properties from an object | `'?.data -=': 'key'` |
852
852
  | ` Y=` | Merge | Recursively `assignGingerly` into a sub-object | `'style Y=': { width: '100px' }` |
853
853
  | ` ?=` | Ternary | Conditional assignment (assignFrom only) — [details](docs/ternary-assignment.md) | `'?.text ?=': ['?.cond', 'yes', 'no']` |
854
- | ` =>` | Handler | Invoke a handler plugin (assignFrom only) | `'?.el =>': { do: 'builtIns.join', ... }` |
854
+ | ` =>` | Handler | Invoke a handler plugin (assignFrom only) | `'?.el =>': { do: 'builtIns.lazyLoad', ... }` |
855
+ | ` =&` | Sync op | Compute a value synchronously and assign it back (assignFrom only) — no dynamic import, no await, ever | `'?.text =&': { join: ['?.first', ' ', '?.last'] }` |
855
856
 
856
857
  All operators use a space before the suffix to distinguish them from property names. They compose with `?.` nested paths and `withMethods`.
857
858
 
@@ -2691,16 +2692,18 @@ await assignFromAsync(myElement, {
2691
2692
 
2692
2693
  Import paths must be local (relative, absolute, or bare specifiers — no cross-domain URLs). The module's default export is checked first; otherwise the first exported class with an `assign` method is used.
2693
2694
 
2694
- Built-in handlers (`builtIns.lazyLoad`, `builtIns.join`, etc.) auto-load without needing to be listed in `handlers`.
2695
+ Built-in handlers (`builtIns.lazyLoad`, `builtIns.microDataJoin`, etc.) auto-load without needing to be listed in `handlers`.
2696
+
2697
+ > `join` isn't in this list — it's a synchronous [` =&` op](#sync-op-join) now, not an async `do:` handler, so it's never dynamically imported and never awaited.
2695
2698
 
2696
2699
  **Handler aliases:** The `handlers` option also accepts built-in names as values, allowing you to define short aliases (including emoji) for concise configs:
2697
2700
 
2698
2701
  ```JavaScript
2699
2702
  import { builtInEmoji } from 'assign-gingerly/builtInEmoji.js';
2700
- // { '📦': 'builtIns.lazyLoad', '🎚️': 'builtIns.lazyLoadSwitch', '🔗': 'builtIns.join', '🏷️': 'builtIns.microDataJoin', '📋': 'builtIns.manageTemplateList' }
2703
+ // { '📦': 'builtIns.lazyLoad', '🎚️': 'builtIns.lazyLoadSwitch', '🏷️': 'builtIns.microDataJoin', '📋': 'builtIns.manageTemplateList' }
2701
2704
 
2702
2705
  assignFrom(target, {
2703
- '?.textContent =>': { do: '🔗', get: { value: ['?.first', ' ', '?.last'] } }
2706
+ '?.el =>': { do: '🏷️', get: { template: [...] } }
2704
2707
  }, { from: vm, handlers: builtInEmoji });
2705
2708
 
2706
2709
  // Or define your own:
@@ -2960,9 +2963,15 @@ await assignFromAsync(document.body, {
2960
2963
  - Mixed `do` values — fully supported, each handler is looked up independently.
2961
2964
  - Error handling — fail-fast. If a handler throws, remaining handlers are skipped.
2962
2965
 
2963
- ### Built-in handler: `builtIns.join`
2966
+ **Return-value protocol:**
2967
+
2968
+ When a handler's `assign()` method returns a non-`undefined` value, `processHandlerCommands` assigns it back to the LHS path. Handlers like `builtIns.lazyLoad` return `undefined` (void) and operate by side effects instead.
2969
+
2970
+ ### Sync op: join (` =&`)
2964
2971
 
2965
- Joins a resolved array into a single string. Supports nested sub-arrays with "all-or-nothing" semantics for optional segments. Uses the **return-value protocol** the handler returns the joined string, which `processHandlerCommands` assigns back to the LHS path.
2972
+ ` =&` is a separate operator from ` =>`, for computed values that have nothing to await no dynamic import, no handler class, no microtask hop, ever. The RHS names exactly one op from a small built-in registry (`syncOps/registry.ts`); sibling keys are that op's own config. `join` is the first (and so far only) op in the registry.
2973
+
2974
+ `join` resolves an array of `?.` paths and literals, then joins it into a single string. It supports nested sub-arrays with "all-or-nothing" semantics for optional segments.
2966
2975
 
2967
2976
  ```JavaScript
2968
2977
  const vm = {
@@ -2971,11 +2980,8 @@ const vm = {
2971
2980
  };
2972
2981
 
2973
2982
  assignFrom(oElement, {
2974
- '?.textContent =>': {
2975
- do: 'builtIns.join',
2976
- get: {
2977
- value: ['?.lastName', ', ', '?.firstName']
2978
- }
2983
+ '?.textContent =&': {
2984
+ join: ['?.lastName', ', ', '?.firstName']
2979
2985
  }
2980
2986
  }, { from: vm });
2981
2987
 
@@ -2984,11 +2990,11 @@ assignFrom(oElement, {
2984
2990
 
2985
2991
  **How it works:**
2986
2992
 
2987
- 1. The `get.value` array is resolved by `getValues` — `?.` path strings are replaced with actual values from `options.from`.
2988
- 2. Top-level `null`/`undefined` values are filtered out.
2993
+ 1. The whole config object (`{ join: [...], separator: '...' }`) is resolved in one pass by `getValues` — `?.` path strings anywhere inside it, including nested inside the `join` array, are replaced with actual values from `options.from`.
2994
+ 2. Top-level `null`/`undefined` values in the resolved array are filtered out.
2989
2995
  3. Nested sub-arrays use **all-or-nothing** semantics: if any element in a sub-array resolves to `null`/`undefined`, the entire sub-array is dropped.
2990
- 4. Remaining elements are joined with the separator (default: `''`, empty string).
2991
- 5. The joined string is returned and assigned to the LHS path.
2996
+ 4. Remaining elements are joined with `separator` (default: `''`, empty string).
2997
+ 5. The joined string is assigned straight to the LHS path — every sync op always produces a value to assign; there's no opt-in return-value protocol to it like ` =>` has.
2992
2998
 
2993
2999
  **Optional segments with nested arrays:**
2994
3000
 
@@ -3000,11 +3006,8 @@ const vm = {
3000
3006
  };
3001
3007
 
3002
3008
  assignFrom(oElement, {
3003
- '?.textContent =>': {
3004
- do: 'builtIns.join',
3005
- get: {
3006
- value: ['?.lastName', [', ', '?.middleName'], ', ', '?.firstName']
3007
- }
3009
+ '?.textContent =&': {
3010
+ join: ['?.lastName', [', ', '?.middleName'], ', ', '?.firstName']
3008
3011
  }
3009
3012
  }, { from: vm });
3010
3013
 
@@ -3019,21 +3022,16 @@ assignFrom(oElement, {
3019
3022
 
3020
3023
  ```JavaScript
3021
3024
  assignFrom(oElement, {
3022
- '?.textContent =>': {
3023
- do: 'builtIns.join',
3024
- get: {
3025
- value: ['?.firstName', '?.lastName'],
3026
- separator: ' | '
3027
- }
3025
+ '?.textContent =&': {
3026
+ join: ['?.firstName', '?.lastName'],
3027
+ separator: ' | '
3028
3028
  }
3029
3029
  }, { from: vm });
3030
3030
 
3031
3031
  // oElement.textContent = 'Helaena | Targaryen'
3032
3032
  ```
3033
3033
 
3034
- **Return-value protocol:**
3035
-
3036
- When a handler's `assign()` method returns a non-`undefined` value, `processHandlerCommands` assigns it back to the LHS path. This is how `builtIns.join` sets `textContent` — the handler computes the string and returns it. Existing handlers like `builtIns.lazyLoad` return `undefined` (void) and operate by side effects, so they're unaffected.
3034
+ **On `options.protocols`:** `getValues` runs synchronously and calls protocol handlers without awaiting them. `options.protocols` is typed to allow an async handler (`(key: string) => any | Promise<any>`), because ` =>`'s `resolve:` path can use one — but ` =&` has no await to catch a Promise coming back from one. If a resolved value under ` =&` turns out to be a thenable, assignFrom throws immediately rather than silently assigning `"[object Promise]"`. Use ` =>` with `resolve:` for handler configs that need an async protocol.
3037
3035
 
3038
3036
  ### Built-in handler: `builtIns.microDataJoin`
3039
3037
 
@@ -3095,7 +3093,7 @@ Produces:
3095
3093
 
3096
3094
  **Optional segments (nested arrays):**
3097
3095
 
3098
- Same all-or-nothing semantics as `builtIns.join` — if any `val` in a nested sub-array is null/undefined, the entire sub-array is dropped:
3096
+ Same all-or-nothing semantics as the [`join` sync op](#sync-op-join) — if any `val` in a nested sub-array is null/undefined, the entire sub-array is dropped:
3099
3097
 
3100
3098
  ```JavaScript
3101
3099
  get: {
@@ -3239,7 +3237,7 @@ assignFrom(element, {
3239
3237
 
3240
3238
  ## Typed Path Authoring with `paths`, `sp`, and `md`
3241
3239
 
3242
- 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`.
3240
+ 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 the [`join` sync op](#sync-op-join). The `md` tagged template literal produces `{prop, val}` objects suitable for `builtIns.microDataJoin`.
3243
3241
 
3244
3242
  ```TypeScript
3245
3243
  import { paths, sp } from 'assign-gingerly/DX/paths.js';
@@ -3256,11 +3254,8 @@ const $ = paths<Person>();
3256
3254
  // sp produces: ['?.lastName', ', ', '?.firstName']
3257
3255
  // with full autocomplete on $.lastName, $.firstName, etc.
3258
3256
  export default {
3259
- '?.textContent =>': {
3260
- do: 'builtIns.join',
3261
- get: {
3262
- value: sp`${$.lastName}, ${$.firstName}`
3263
- }
3257
+ '?.textContent =&': {
3258
+ join: sp`${$.lastName}, ${$.firstName}`
3264
3259
  }
3265
3260
  };
3266
3261
  ```
@@ -3268,9 +3263,9 @@ export default {
3268
3263
  **How `paths` works:**
3269
3264
 
3270
3265
  - `paths<T>()` creates a deeply-proxied object typed as `T`
3271
- - Every property access returns a deeper proxy (e.g., `$.address.city`)
3266
+ - Every property access returns a deeper proxy (e.g., `$.address.city`)
3272
3267
  - Capitalized reserved tokens like `.Each` and `.EqNot` normalize into command syntax
3273
- - `.Path` extracts the `?.`-prefixed string: `$.address.city.Path` → `'?.address?.city'`
3268
+ - `.Path` extracts the `?.`-prefixed string: `$.address.city.Path` → `'?.address?.city'`
3274
3269
  - Inside `sp` template literals, `.Path` is not needed — proxy objects are auto-detected
3275
3270
 
3276
3271
  **How `sp` works:**
@@ -3339,7 +3334,7 @@ md`${$.firstName} ${{ prop: 'birthDate', val: $.birthDT, format: 'long' }}`
3339
3334
 
3340
3335
  | Tag | Output for proxy interpolation | Use with |
3341
3336
  |-----|-------------------------------|----------|
3342
- | `sp` | `'?.firstName'` (path string) | `builtIns.join` |
3337
+ | `sp` | `'?.firstName'` (path string) | the `join` sync op |
3343
3338
  | `md` | `{ prop: 'firstName', val: '?.firstName' }` | `builtIns.microDataJoin` |
3344
3339
 
3345
3340
  Both auto-detect path proxies (no `.Path` needed inside template literals) and preserve nested arrays for optional segments.
package/assignFrom.js CHANGED
@@ -7,11 +7,14 @@
7
7
  * For async protocol handlers or awaitable handler execution, use assignFromAsync.
8
8
  *
9
9
  * Handler commands (` =>`) are fire-and-forget (kicked off asynchronously, not awaited).
10
+ * Sync-op commands (` =&`) are always fully synchronous — see SYNC_OPS.
10
11
  */
11
12
  import { getValues, getValue } from './resolve/getValues.js';
12
13
  import assignGingerly from './assignGingerly.js';
13
14
  import { resolveIdVariable, parseIdRef } from './resolve/resolveIdRef.js';
14
15
  import { processInferredAssignments } from './inferredAssignments.js';
16
+ import { resolveLhsPath } from './utils/resolveLhsPath.js';
17
+ import { SYNC_OPS } from './syncOps/registry.js';
15
18
  /**
16
19
  * Supported substitution variables and their option keys.
17
20
  */
@@ -40,6 +43,48 @@ export function parseTernaryCommand(key) {
40
43
  return null;
41
44
  return key.substring(0, key.length - 3); // Remove ' ?=' suffix
42
45
  }
46
+ /**
47
+ * Check if a key ends with the sync-op operator ' =&'.
48
+ */
49
+ export function isSyncOpCommand(key) {
50
+ return key.endsWith(' =&');
51
+ }
52
+ /**
53
+ * Parse a =& sync-op command and extract the LHS path.
54
+ */
55
+ export function parseSyncOpCommand(key) {
56
+ if (!isSyncOpCommand(key))
57
+ return null;
58
+ return key.substring(0, key.length - 3); // Remove ' =&' suffix
59
+ }
60
+ /**
61
+ * Throws if a resolved sync-op value (or anything nested inside it) is a thenable.
62
+ *
63
+ * getValues is synchronous by contract, but `options.protocols` handlers are
64
+ * typed to allow returning a Promise. A sync op has no await to catch that —
65
+ * left unchecked it would silently stringify as "[object Promise]" — so this
66
+ * turns it into a clear error instead.
67
+ */
68
+ function assertNoThenable(value, opName, key) {
69
+ if (value == null)
70
+ return;
71
+ if (typeof value.then === 'function') {
72
+ throw new Error(`assignFrom: sync op '${opName}' (key "${key}") received an async value — ` +
73
+ `a protocol in options.protocols returned a Promise, which ' =&' cannot await. ` +
74
+ `Use ' =>' with 'resolve:' instead for async values.`);
75
+ }
76
+ if (Array.isArray(value)) {
77
+ for (const item of value)
78
+ assertNoThenable(item, opName, key);
79
+ }
80
+ else if (typeof value === 'object') {
81
+ const proto = Object.getPrototypeOf(value);
82
+ if (proto === Object.prototype || proto === null) {
83
+ for (const item of Object.values(value))
84
+ assertNoThenable(item, opName, key);
85
+ }
86
+ }
87
+ }
43
88
  /**
44
89
  * Resolve a single value — if it's a `?.` path string, resolve against source.
45
90
  * If it's a protocol string, resolve via protocol. Otherwise pass through as literal.
@@ -343,6 +388,7 @@ export function categorizeKeys(expandedPattern) {
343
388
  const idRefNormalKeys = [];
344
389
  const idRefHandlerKeys = [];
345
390
  const ternaryKeys = [];
391
+ const syncOpKeys = [];
346
392
  for (const key of Object.keys(expandedPattern)) {
347
393
  if (isHandlerCommand(key)) {
348
394
  if (key.startsWith('#[')) {
@@ -355,6 +401,9 @@ export function categorizeKeys(expandedPattern) {
355
401
  else if (isTernaryCommand(key)) {
356
402
  ternaryKeys.push(key);
357
403
  }
404
+ else if (isSyncOpCommand(key)) {
405
+ syncOpKeys.push(key);
406
+ }
358
407
  else if (key.startsWith('#[')) {
359
408
  idRefNormalKeys.push(key);
360
409
  }
@@ -362,7 +411,7 @@ export function categorizeKeys(expandedPattern) {
362
411
  normalPattern[key] = expandedPattern[key];
363
412
  }
364
413
  }
365
- return { handlerKeys, normalPattern, idRefNormalKeys, idRefHandlerKeys, ternaryKeys };
414
+ return { handlerKeys, normalPattern, idRefNormalKeys, idRefHandlerKeys, ternaryKeys, syncOpKeys };
366
415
  }
367
416
  /**
368
417
  * Merge pin and at into a single lookup map for resolveIdVariable.
@@ -420,7 +469,7 @@ export function assignFrom(target, pattern, options, permissionProcessor) {
420
469
  // Expand looped substitution variables
421
470
  const expandedPattern = expandSubstitutions(pattern, options);
422
471
  // Categorize keys
423
- const { handlerKeys, normalPattern, idRefNormalKeys, idRefHandlerKeys, ternaryKeys } = categorizeKeys(expandedPattern);
472
+ const { handlerKeys, normalPattern, idRefNormalKeys, idRefHandlerKeys, ternaryKeys, syncOpKeys } = categorizeKeys(expandedPattern);
424
473
  const resolveOptions = { ...options, root: target, permissionProcessor };
425
474
  // Process ?= ternary keys (sync)
426
475
  if (ternaryKeys.length > 0) {
@@ -441,6 +490,33 @@ export function assignFrom(target, pattern, options, permissionProcessor) {
441
490
  assignGingerly(target, ternaryResolved, options, permissionProcessor);
442
491
  }
443
492
  }
493
+ // Process =& sync-op keys (sync — always, no dynamic import, no await, ever)
494
+ if (syncOpKeys.length > 0) {
495
+ for (const key of syncOpKeys) {
496
+ const lhsPath = parseSyncOpCommand(key);
497
+ if (lhsPath === null)
498
+ continue;
499
+ const config = expandedPattern[key];
500
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
501
+ throw new Error(`assignFrom: sync-op command "${key}" requires a config object naming exactly one op`);
502
+ }
503
+ const opName = Object.keys(config).find(k => k in SYNC_OPS);
504
+ if (!opName) {
505
+ throw new Error(`assignFrom: sync-op command "${key}" does not name a known op (${Object.keys(SYNC_OPS).join(', ')})`);
506
+ }
507
+ const resolvedConfig = getValues(config, options.from, resolveOptions);
508
+ assertNoThenable(resolvedConfig, opName, key);
509
+ const { [opName]: args, ...extra } = resolvedConfig;
510
+ const result = SYNC_OPS[opName](args, extra);
511
+ if (result === undefined)
512
+ continue;
513
+ const { lhsParent, lhsKey } = resolveLhsPath(target, lhsPath, options);
514
+ if (lhsParent != null && lhsKey != null
515
+ && !permissionProcessor?.redirectRestrictedProp(lhsParent, lhsKey, result)) {
516
+ lhsParent[lhsKey] = result;
517
+ }
518
+ }
519
+ }
444
520
  // Process normal keys via getValues (sync) + assignGingerly
445
521
  if (Object.keys(normalPattern).length > 0) {
446
522
  // Resolve #[x] references on RHS values before getValues
package/assignFrom.ts CHANGED
@@ -7,12 +7,15 @@
7
7
  * For async protocol handlers or awaitable handler execution, use assignFromAsync.
8
8
  *
9
9
  * Handler commands (` =>`) are fire-and-forget (kicked off asynchronously, not awaited).
10
+ * Sync-op commands (` =&`) are always fully synchronous — see SYNC_OPS.
10
11
  */
11
12
 
12
13
  import { getValues, getValue } from './resolve/getValues.js';
13
14
  import assignGingerly from './assignGingerly.js';
14
15
  import { resolveIdVariable, parseIdRef } from './resolve/resolveIdRef.js';
15
16
  import { processInferredAssignments } from './inferredAssignments.js';
17
+ import { resolveLhsPath } from './utils/resolveLhsPath.js';
18
+ import { SYNC_OPS } from './syncOps/registry.js';
16
19
  import type { PermissionProcessor, AssignFromOptions, AssignFromHandler, AssignFromHandlerConstructor } from './types/assign-gingerly/types.js';
17
20
 
18
21
  // Re-export types for consumers
@@ -49,6 +52,48 @@ export function parseTernaryCommand(key: string): string | null {
49
52
  return key.substring(0, key.length - 3); // Remove ' ?=' suffix
50
53
  }
51
54
 
55
+ /**
56
+ * Check if a key ends with the sync-op operator ' =&'.
57
+ */
58
+ export function isSyncOpCommand(key: string): boolean {
59
+ return key.endsWith(' =&');
60
+ }
61
+
62
+ /**
63
+ * Parse a =& sync-op command and extract the LHS path.
64
+ */
65
+ export function parseSyncOpCommand(key: string): string | null {
66
+ if (!isSyncOpCommand(key)) return null;
67
+ return key.substring(0, key.length - 3); // Remove ' =&' suffix
68
+ }
69
+
70
+ /**
71
+ * Throws if a resolved sync-op value (or anything nested inside it) is a thenable.
72
+ *
73
+ * getValues is synchronous by contract, but `options.protocols` handlers are
74
+ * typed to allow returning a Promise. A sync op has no await to catch that —
75
+ * left unchecked it would silently stringify as "[object Promise]" — so this
76
+ * turns it into a clear error instead.
77
+ */
78
+ function assertNoThenable(value: any, opName: string, key: string): void {
79
+ if (value == null) return;
80
+ if (typeof value.then === 'function') {
81
+ throw new Error(
82
+ `assignFrom: sync op '${opName}' (key "${key}") received an async value — ` +
83
+ `a protocol in options.protocols returned a Promise, which ' =&' cannot await. ` +
84
+ `Use ' =>' with 'resolve:' instead for async values.`
85
+ );
86
+ }
87
+ if (Array.isArray(value)) {
88
+ for (const item of value) assertNoThenable(item, opName, key);
89
+ } else if (typeof value === 'object') {
90
+ const proto = Object.getPrototypeOf(value);
91
+ if (proto === Object.prototype || proto === null) {
92
+ for (const item of Object.values(value)) assertNoThenable(item, opName, key);
93
+ }
94
+ }
95
+ }
96
+
52
97
  /**
53
98
  * Resolve a single value — if it's a `?.` path string, resolve against source.
54
99
  * If it's a protocol string, resolve via protocol. Otherwise pass through as literal.
@@ -354,6 +399,7 @@ export function categorizeKeys(expandedPattern: Record<string, any>) {
354
399
  const idRefNormalKeys: string[] = [];
355
400
  const idRefHandlerKeys: string[] = [];
356
401
  const ternaryKeys: string[] = [];
402
+ const syncOpKeys: string[] = [];
357
403
 
358
404
  for (const key of Object.keys(expandedPattern)) {
359
405
  if (isHandlerCommand(key)) {
@@ -364,6 +410,8 @@ export function categorizeKeys(expandedPattern: Record<string, any>) {
364
410
  }
365
411
  } else if (isTernaryCommand(key)) {
366
412
  ternaryKeys.push(key);
413
+ } else if (isSyncOpCommand(key)) {
414
+ syncOpKeys.push(key);
367
415
  } else if (key.startsWith('#[')) {
368
416
  idRefNormalKeys.push(key);
369
417
  } else {
@@ -371,7 +419,7 @@ export function categorizeKeys(expandedPattern: Record<string, any>) {
371
419
  }
372
420
  }
373
421
 
374
- return { handlerKeys, normalPattern, idRefNormalKeys, idRefHandlerKeys, ternaryKeys };
422
+ return { handlerKeys, normalPattern, idRefNormalKeys, idRefHandlerKeys, ternaryKeys, syncOpKeys };
375
423
  }
376
424
 
377
425
  /**
@@ -447,7 +495,7 @@ export function assignFrom(
447
495
  const expandedPattern = expandSubstitutions(pattern, options);
448
496
 
449
497
  // Categorize keys
450
- const { handlerKeys, normalPattern, idRefNormalKeys, idRefHandlerKeys, ternaryKeys } = categorizeKeys(expandedPattern);
498
+ const { handlerKeys, normalPattern, idRefNormalKeys, idRefHandlerKeys, ternaryKeys, syncOpKeys } = categorizeKeys(expandedPattern);
451
499
 
452
500
  const resolveOptions = { ...options, root: target, permissionProcessor } as any;
453
501
 
@@ -469,6 +517,36 @@ export function assignFrom(
469
517
  }
470
518
  }
471
519
 
520
+ // Process =& sync-op keys (sync — always, no dynamic import, no await, ever)
521
+ if (syncOpKeys.length > 0) {
522
+ for (const key of syncOpKeys) {
523
+ const lhsPath = parseSyncOpCommand(key);
524
+ if (lhsPath === null) continue;
525
+ const config = expandedPattern[key];
526
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
527
+ throw new Error(`assignFrom: sync-op command "${key}" requires a config object naming exactly one op`);
528
+ }
529
+
530
+ const opName = Object.keys(config).find(k => k in SYNC_OPS);
531
+ if (!opName) {
532
+ throw new Error(`assignFrom: sync-op command "${key}" does not name a known op (${Object.keys(SYNC_OPS).join(', ')})`);
533
+ }
534
+
535
+ const resolvedConfig = getValues(config, options.from, resolveOptions);
536
+ assertNoThenable(resolvedConfig, opName, key);
537
+
538
+ const { [opName]: args, ...extra } = resolvedConfig;
539
+ const result = SYNC_OPS[opName](args, extra);
540
+ if (result === undefined) continue;
541
+
542
+ const { lhsParent, lhsKey } = resolveLhsPath(target, lhsPath, options);
543
+ if (lhsParent != null && lhsKey != null
544
+ && !permissionProcessor?.redirectRestrictedProp(lhsParent, lhsKey, result)) {
545
+ lhsParent[lhsKey] = result;
546
+ }
547
+ }
548
+ }
549
+
472
550
  // Process normal keys via getValues (sync) + assignGingerly
473
551
  if (Object.keys(normalPattern).length > 0) {
474
552
  // Resolve #[x] references on RHS values before getValues
@@ -224,7 +224,7 @@ Create `.vscode/settings.json`:
224
224
  }
225
225
  ```
226
226
 
227
- ## Step 8: Set Up .kiro Directory
227
+ ## Step 8: Set Up .kiro Directory Only if kiro is implementing.
228
228
 
229
229
  Create `.kiro/steering/project-context.md` to reference the shared types documentation:
230
230
 
@@ -350,6 +350,8 @@ customElements.assignFeatures(MyElement, {
350
350
 
351
351
  The parsed attributes (`{ myProp: 'hello', count: 42 }`) are passed as `initVals` to the constructor.
352
352
 
353
+ See [withAttrs](https://github.com/bahrus/assign-gingerly/blob/baseline/docs/withAttrs.md) for an in-depth discussion of all the various configuration options.
354
+
353
355
  ### Async Spawn (Lazy Loading)
354
356
 
355
357
  Feature implementations can be loaded asynchronously:
@@ -451,6 +451,8 @@ const raConfig = {
451
451
  };
452
452
  ```
453
453
 
454
+ [Please fully digest all the attribute parsing tha assign-gingerly provides before configuring the attributes.](https://github.com/bahrus/assign-gingerly/blob/baseline/docs/withAttrs.md)
455
+
454
456
 
455
457
  ## Step 8
456
458