assign-gingerly 0.0.51 → 0.0.53

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
@@ -3366,6 +3366,65 @@ assignFrom(target, {
3366
3366
 
3367
3367
  For full documentation, see [docs/assignFrom.md](docs/assignFrom.md).
3368
3368
 
3369
+ ## Protocol Resolution in `resolveValues` and `assignFrom`
3370
+
3371
+ `resolveValues` (and by extension `assignFrom`) supports resolving values from external sources via protocol-prefixed strings. This enables declarative references to `globalThis`, `localStorage`, `sessionStorage`, or custom stores.
3372
+
3373
+ ```JavaScript
3374
+ import { resolveValues } from 'assign-gingerly/resolveValues.js';
3375
+
3376
+ const result = await resolveValues({
3377
+ baseURL: 'globalThis://myAppConfig?.apiBaseUrl',
3378
+ authToken: 'localStorage://auth?.token',
3379
+ label: '?.title' // normal path resolution still works
3380
+ }, source, {
3381
+ protocols: {
3382
+ globalThis: (key) => globalThis[key],
3383
+ localStorage: (key) => JSON.parse(localStorage.getItem(key) || 'null')
3384
+ }
3385
+ });
3386
+ ```
3387
+
3388
+ **How it works:**
3389
+
3390
+ 1. If a value contains `://` and the part before it matches a key in `protocols`, it's treated as a protocol reference.
3391
+ 2. The protocol handler is called with the key portion (between `://` and the first `?.`, or end of string).
3392
+ 3. If a `?.` path follows the key, it's resolved against the handler's result using `resolveValue`.
3393
+ 4. If the protocol isn't found in the map, the value passes through unchanged (no error).
3394
+
3395
+ **Path after protocol key:**
3396
+
3397
+ ```JavaScript
3398
+ // 'globalThis://myConfig?.database?.host'
3399
+ // 1. Protocol: 'globalThis'
3400
+ // 2. Key: 'myConfig'
3401
+ // 3. Handler returns: globalThis['myConfig'] → { database: { host: 'localhost' } }
3402
+ // 4. Remaining path: '?.database?.host' → resolves to 'localhost'
3403
+ ```
3404
+
3405
+ **With `assignFrom` and the `"..."` spread key:**
3406
+
3407
+ `assignFrom` supports a special `"..."` key that spreads the resolved value into the parent object:
3408
+
3409
+ ```JavaScript
3410
+ import { assignFrom } from 'assign-gingerly/assignFrom.js';
3411
+
3412
+ await assignFrom(myForm, {
3413
+ "...": "globalThis://qmywdO1vr0SwyuIe4fvzxQ",
3414
+ path: "api/v2/:operation/:expression",
3415
+ headers: {
3416
+ "...": "globalThis://rPpwNLcYsUOjFcg+N8lmOA"
3417
+ }
3418
+ }, {
3419
+ from: source,
3420
+ protocols: { globalThis: (key) => globalThis[key] }
3421
+ });
3422
+ ```
3423
+
3424
+ The `"..."` key causes the resolved object to be merged (spread) into the result before passing to `assignGingerly`, rather than being assigned to a property named `"..."`.
3425
+
3426
+ **Note:** Both `resolveValues` and `assignFrom` are async (return Promises) to support async protocol handlers (e.g., IndexedDB, fetch). For patterns without protocols, the async overhead is negligible.
3427
+
3369
3428
  ## Custom Assignment with `static assignTo` Protocol
3370
3429
 
3371
3430
  Classes can opt into custom assignment behavior by defining a `static assignTo` method. When `assignGingerly` encounters a property whose current value is an instance of such a class, it delegates the assignment to `assignTo` instead of performing the default merge/replace logic.
@@ -4845,6 +4904,43 @@ It resolves async `fallbackSpawn` implementations from the base class, creates a
4845
4904
 
4846
4905
  For full documentation, see [docs/defineWithFeatures.md](docs/defineWithFeatures.md).
4847
4906
 
4907
+ ### Resolving async spawns with `resolveAndAssignFeatures`
4908
+
4909
+ When defining a custom element via a traditional JS module (rather than declaratively via cede scripts), `resolveAndAssignFeatures` handles the boilerplate of resolving async `fallbackSpawn` implementations before calling `assignFeatures`:
4910
+
4911
+ ```JavaScript
4912
+ import { resolveAndAssignFeatures } from 'assign-gingerly/resolveAndAssignFeatures.js';
4913
+
4914
+ export async function wireFeatures(ElementClass, cfg) {
4915
+ const { roundabout } = cfg.features;
4916
+ const { customData, withAttrs } = roundabout;
4917
+
4918
+ await resolveAndAssignFeatures(ElementClass, {
4919
+ timeTicker: { spawn: TimeTicker }, // explicit spawn — used as-is
4920
+ faceUp: { // no spawn — resolved from fallbackSpawn
4921
+ callbackForwarding: ['connectedCallback', 'disconnectedCallback']
4922
+ },
4923
+ roundabout: { // no spawn — resolved from fallbackSpawn
4924
+ customData,
4925
+ withAttrs,
4926
+ callbackForwarding: ['connectedCallback']
4927
+ }
4928
+ });
4929
+ }
4930
+ ```
4931
+
4932
+ **What it does:**
4933
+
4934
+ For each feature in the config that doesn't have an explicit `spawn`, it resolves the async `fallbackSpawn` from the class's `static supportedFeatures`, sets it as the spawn, then calls `assignFeatures`. Features with an explicit `spawn` are left untouched.
4935
+
4936
+ **When to use it:**
4937
+
4938
+ - Defining custom elements via JS modules (the traditional `import` + `define` pattern)
4939
+ - When the base class uses async `fallbackSpawn` for lazy loading but you want synchronous feature access after registration
4940
+ - As a reusable `wireFeatures` function that multiple element definitions can share
4941
+
4942
+ See [time-ticker/wireFeatures.js](https://github.com/bahrus/time-ticker/blob/baseline/wireFeatures.js) for a real-world example.
4943
+
4848
4944
  <details>
4849
4945
  <summary>Catalog of Published Custom Element Features</summary>
4850
4946
 
package/assignFrom.js CHANGED
@@ -21,10 +21,36 @@
21
21
  */
22
22
  import { resolveValues } from './resolveValues.js';
23
23
  import assignGingerly from './assignGingerly.js';
24
- export function assignFrom(target, pattern, options) {
25
- const resolved = resolveValues(pattern, options.from, {
24
+ export async function assignFrom(target, pattern, options) {
25
+ const resolved = await resolveValues(pattern, options.from, {
26
26
  withMethods: options.withMethods,
27
- aka: options.aka
27
+ aka: options.aka,
28
+ protocols: options.protocols
28
29
  });
30
+ // Recursively handle "..." spread keys at all nesting levels
31
+ handleSpreads(resolved);
29
32
  return assignGingerly(target, resolved, options);
30
33
  }
34
+ /**
35
+ * Recursively walk an object and handle "..." spread keys.
36
+ * When a "..." key is found, its value (which should be an object after protocol resolution)
37
+ * is spread into the parent, replacing the "..." entry.
38
+ */
39
+ function handleSpreads(obj) {
40
+ for (const [key, value] of Object.entries(obj)) {
41
+ if (key !== '...' && typeof value === 'object' && value !== null && !Array.isArray(value)) {
42
+ const proto = Object.getPrototypeOf(value);
43
+ if (proto === Object.prototype || proto === null) {
44
+ obj[key] = handleSpreads(value);
45
+ }
46
+ }
47
+ }
48
+ if ('...' in obj) {
49
+ const spreadValue = obj['...'];
50
+ delete obj['...'];
51
+ if (spreadValue && typeof spreadValue === 'object') {
52
+ Object.assign(obj, spreadValue);
53
+ }
54
+ }
55
+ return obj;
56
+ }
package/assignFrom.ts CHANGED
@@ -19,22 +19,51 @@
19
19
  * }, { from: source });
20
20
  * // target is now { color: 'red', text: 'Hello' }
21
21
  */
22
- import { resolveValues } from './resolveValues.js';
22
+ import { resolveValues, ResolveValuesOptions } from './resolveValues.js';
23
23
  import assignGingerly, { IAssignGingerlyOptions } from './assignGingerly.js';
24
24
 
25
- export interface AssignFromOptions extends IAssignGingerlyOptions {
25
+ export interface AssignFromOptions extends IAssignGingerlyOptions, ResolveValuesOptions {
26
26
  /** Source object to resolve RHS path strings against */
27
27
  from: any;
28
28
  }
29
29
 
30
- export function assignFrom(
30
+ export async function assignFrom(
31
31
  target: any,
32
32
  pattern: Record<string, any>,
33
33
  options: AssignFromOptions
34
- ): any {
35
- const resolved = resolveValues(pattern, options.from, {
34
+ ): Promise<any> {
35
+ const resolved = await resolveValues(pattern, options.from, {
36
36
  withMethods: options.withMethods,
37
- aka: options.aka
37
+ aka: options.aka,
38
+ protocols: options.protocols
38
39
  });
40
+
41
+ // Recursively handle "..." spread keys at all nesting levels
42
+ handleSpreads(resolved);
43
+
39
44
  return assignGingerly(target, resolved, options);
40
45
  }
46
+
47
+ /**
48
+ * Recursively walk an object and handle "..." spread keys.
49
+ * When a "..." key is found, its value (which should be an object after protocol resolution)
50
+ * is spread into the parent, replacing the "..." entry.
51
+ */
52
+ function handleSpreads(obj: Record<string, any>): Record<string, any> {
53
+ for (const [key, value] of Object.entries(obj)) {
54
+ if (key !== '...' && typeof value === 'object' && value !== null && !Array.isArray(value)) {
55
+ const proto = Object.getPrototypeOf(value);
56
+ if (proto === Object.prototype || proto === null) {
57
+ obj[key] = handleSpreads(value);
58
+ }
59
+ }
60
+ }
61
+ if ('...' in obj) {
62
+ const spreadValue = obj['...'];
63
+ delete obj['...'];
64
+ if (spreadValue && typeof spreadValue === 'object') {
65
+ Object.assign(obj, spreadValue);
66
+ }
67
+ }
68
+ return obj;
69
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "assign-gingerly",
3
- "version": "0.0.51",
3
+ "version": "0.0.53",
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": {
@@ -97,7 +97,7 @@
97
97
  "devDependencies": {
98
98
  "@playwright/test": "1.60.0",
99
99
  "spa-ssi": "0.0.27",
100
- "@types/node": "25.8.0",
100
+ "@types/node": "25.9.3",
101
101
  "typescript": "6.0.3"
102
102
  }
103
103
  }
package/resolveValues.js CHANGED
@@ -25,6 +25,41 @@ function parseCachedPath(path) {
25
25
  }
26
26
  return parts;
27
27
  }
28
+ /**
29
+ * Resolves a protocol-prefixed value (e.g., 'globalThis://key?.path').
30
+ *
31
+ * 1. Extracts the protocol name (before '://')
32
+ * 2. If the protocol isn't in the protocols map, returns the value unchanged (false positive)
33
+ * 3. Extracts the key (between '://' and first '?.' or end of string)
34
+ * 4. Calls the protocol handler with the key
35
+ * 5. If there's a remaining '?.' path, resolves it against the handler's result
36
+ */
37
+ async function resolveProtocolValue(value, protocols, options) {
38
+ // Extract protocol name (before ://)
39
+ const protoEnd = value.indexOf('://');
40
+ const protocol = value.substring(0, protoEnd);
41
+ // Resolve via protocol handler
42
+ const handler = protocols[protocol];
43
+ if (!handler)
44
+ return value; // false flag — coincidentally looks like a protocol
45
+ const rest = value.substring(protoEnd + 3);
46
+ // Split at first ?. to separate key from path
47
+ const pathStart = rest.indexOf('?.');
48
+ const key = pathStart === -1 ? rest : rest.substring(0, pathStart);
49
+ const path = pathStart === -1 ? null : rest.substring(pathStart);
50
+ const resolved = await handler(key);
51
+ // If there's a remaining path, resolve it against the result
52
+ if (path) {
53
+ return resolveValue(path, resolved, options);
54
+ }
55
+ return resolved;
56
+ }
57
+ /**
58
+ * Checks if a string value looks like a protocol reference.
59
+ */
60
+ function hasProtocol(value) {
61
+ return value.includes('://');
62
+ }
28
63
  /**
29
64
  * Navigate a path against a source object, optionally calling methods.
30
65
  * Returns the resolved value at the end of the path.
@@ -96,7 +131,7 @@ function navigatePath(source, parts, withMethods) {
96
131
  * aka: { 'q': 'querySelector' }
97
132
  * });
98
133
  */
99
- export function resolveValues(pattern, source, options) {
134
+ export async function resolveValues(pattern, source, options) {
100
135
  // Build alias map
101
136
  const aliasMap = new Map();
102
137
  if (options?.aka) {
@@ -110,6 +145,7 @@ export function resolveValues(pattern, source, options) {
110
145
  ? options.withMethods
111
146
  : new Set(options.withMethods)
112
147
  : undefined;
148
+ const protocols = options?.protocols;
113
149
  const result = {};
114
150
  for (const [key, value] of Object.entries(pattern)) {
115
151
  if (typeof value === 'string' && value.startsWith('?.')) {
@@ -120,6 +156,20 @@ export function resolveValues(pattern, source, options) {
120
156
  // Navigate with method support
121
157
  result[key] = parts.length === 0 ? source : navigatePath(source, parts, withMethods);
122
158
  }
159
+ else if (typeof value === 'string' && protocols && hasProtocol(value)) {
160
+ // Protocol-prefixed value — resolve asynchronously
161
+ result[key] = await resolveProtocolValue(value, protocols, options);
162
+ }
163
+ else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
164
+ // Recursively resolve nested plain objects (e.g., headers: { "...": "globalThis://key" })
165
+ // Only recurse into plain objects — skip DOM elements, class instances, etc.
166
+ const proto = Object.getPrototypeOf(value);
167
+ if (proto === Object.prototype || proto === null) {
168
+ result[key] = await resolveValues(value, source, options);
169
+ } else {
170
+ result[key] = value;
171
+ }
172
+ }
123
173
  else {
124
174
  result[key] = value;
125
175
  }
package/resolveValues.ts CHANGED
@@ -13,6 +13,21 @@ export interface ResolveValuesOptions {
13
13
  * Substituted before path resolution, matching complete tokens between `?.` delimiters.
14
14
  */
15
15
  aka?: Record<string, string>;
16
+
17
+ /**
18
+ * Protocol handlers for resolving protocol-prefixed values (e.g., 'globalThis://key').
19
+ * Each handler receives the key portion and returns the resolved value (sync or async).
20
+ *
21
+ * If a value contains '://' but the protocol isn't in this map, the value passes through unchanged.
22
+ * If a '?.' appears after the protocol key, the remaining path is resolved against the handler's result.
23
+ *
24
+ * @example
25
+ * protocols: {
26
+ * globalThis: (key) => globalThis[key],
27
+ * localStorage: (key) => JSON.parse(localStorage.getItem(key) || 'null')
28
+ * }
29
+ */
30
+ protocols?: Record<string, (key: string) => any | Promise<any>>;
16
31
  }
17
32
 
18
33
  /**
@@ -44,6 +59,51 @@ function parseCachedPath(path: string): string[] {
44
59
  return parts;
45
60
  }
46
61
 
62
+ /**
63
+ * Resolves a protocol-prefixed value (e.g., 'globalThis://key?.path').
64
+ *
65
+ * 1. Extracts the protocol name (before '://')
66
+ * 2. If the protocol isn't in the protocols map, returns the value unchanged (false positive)
67
+ * 3. Extracts the key (between '://' and first '?.' or end of string)
68
+ * 4. Calls the protocol handler with the key
69
+ * 5. If there's a remaining '?.' path, resolves it against the handler's result
70
+ */
71
+ async function resolveProtocolValue(
72
+ value: string,
73
+ protocols: Record<string, (key: string) => any | Promise<any>>,
74
+ options?: ResolveValuesOptions
75
+ ): Promise<any> {
76
+ // Extract protocol name (before ://)
77
+ const protoEnd = value.indexOf('://');
78
+ const protocol = value.substring(0, protoEnd);
79
+
80
+ // Resolve via protocol handler
81
+ const handler = protocols[protocol];
82
+ if (!handler) return value; // false flag — coincidentally looks like a protocol
83
+
84
+ const rest = value.substring(protoEnd + 3);
85
+
86
+ // Split at first ?. to separate key from path
87
+ const pathStart = rest.indexOf('?.');
88
+ const key = pathStart === -1 ? rest : rest.substring(0, pathStart);
89
+ const path = pathStart === -1 ? null : rest.substring(pathStart);
90
+
91
+ const resolved = await handler(key);
92
+
93
+ // If there's a remaining path, resolve it against the result
94
+ if (path) {
95
+ return resolveValue(path, resolved, options);
96
+ }
97
+ return resolved;
98
+ }
99
+
100
+ /**
101
+ * Checks if a string value looks like a protocol reference.
102
+ */
103
+ function hasProtocol(value: string): boolean {
104
+ return value.includes('://');
105
+ }
106
+
47
107
  /**
48
108
  * Navigate a path against a source object, optionally calling methods.
49
109
  * Returns the resolved value at the end of the path.
@@ -120,11 +180,11 @@ function navigatePath(
120
180
  * aka: { 'q': 'querySelector' }
121
181
  * });
122
182
  */
123
- export function resolveValues(
183
+ export async function resolveValues(
124
184
  pattern: Record<string, any>,
125
185
  source: any,
126
186
  options?: ResolveValuesOptions
127
- ): Record<string, any> {
187
+ ): Promise<Record<string, any>> {
128
188
  // Build alias map
129
189
  const aliasMap = new Map<string, string>();
130
190
  if (options?.aka) {
@@ -139,6 +199,8 @@ export function resolveValues(
139
199
  ? options.withMethods
140
200
  : new Set(options.withMethods)
141
201
  : undefined;
202
+
203
+ const protocols = options?.protocols;
142
204
 
143
205
  const result: Record<string, any> = {};
144
206
  for (const [key, value] of Object.entries(pattern)) {
@@ -151,6 +213,18 @@ export function resolveValues(
151
213
 
152
214
  // Navigate with method support
153
215
  result[key] = parts.length === 0 ? source : navigatePath(source, parts, withMethods);
216
+ } else if (typeof value === 'string' && protocols && hasProtocol(value)) {
217
+ // Protocol-prefixed value — resolve asynchronously
218
+ result[key] = await resolveProtocolValue(value, protocols, options);
219
+ } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
220
+ // Recursively resolve nested plain objects (e.g., headers: { "...": "globalThis://key" })
221
+ // Only recurse into plain objects — skip DOM elements, class instances, etc.
222
+ const proto = Object.getPrototypeOf(value);
223
+ if (proto === Object.prototype || proto === null) {
224
+ result[key] = await resolveValues(value, source, options);
225
+ } else {
226
+ result[key] = value;
227
+ }
154
228
  } else {
155
229
  result[key] = value;
156
230
  }
@@ -184,6 +184,13 @@ export interface AttrConfig<T = unknown, TParserConfig = unknown> {
184
184
  * Should make sure it is added to static observedAttribrutes
185
185
  */
186
186
  sourceOfTruth?: boolean;
187
+
188
+ /**
189
+ * Options to pass to the parser function (e.g., splitStatements behavior).
190
+ * For named parsers like 'parse-pattern-statements', this is forwarded
191
+ * as the options argument to the underlying parse function.
192
+ */
193
+ parserOptions?: any;
187
194
  }
188
195
 
189
196
  export type AttrPatterns<T = any> = {