assign-gingerly 0.0.31 → 0.0.33

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/assignFrom.js ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Resolve RHS path strings against a source object, then assign the
3
+ * resolved values into a target using assignGingerly.
4
+ *
5
+ * Combines resolveValues + assignGingerly into a single call.
6
+ * Inherits all assignGingerly options (withMethods, aka, signal, etc.).
7
+ *
8
+ * @param target - Object to merge resolved values into
9
+ * @param pattern - Object whose RHS values may contain `?.` path strings
10
+ * @param options - Options including `from` (source object) and any assignGingerly options
11
+ * @returns The target object after merging
12
+ *
13
+ * @example
14
+ * const source = { theme: { color: 'red' }, label: 'Hello' };
15
+ * const target = { color: 'blue', text: '' };
16
+ * assignFrom(target, {
17
+ * color: '?.theme?.color',
18
+ * text: '?.label'
19
+ * }, { from: source });
20
+ * // target is now { color: 'red', text: 'Hello' }
21
+ */
22
+ import { resolveValues } from './resolveValues.js';
23
+ import assignGingerly from './assignGingerly.js';
24
+ export function assignFrom(target, pattern, options) {
25
+ const resolved = resolveValues(pattern, options.from);
26
+ return assignGingerly(target, resolved, options);
27
+ }
package/assignFrom.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Resolve RHS path strings against a source object, then assign the
3
+ * resolved values into a target using assignGingerly.
4
+ *
5
+ * Combines resolveValues + assignGingerly into a single call.
6
+ * Inherits all assignGingerly options (withMethods, aka, signal, etc.).
7
+ *
8
+ * @param target - Object to merge resolved values into
9
+ * @param pattern - Object whose RHS values may contain `?.` path strings
10
+ * @param options - Options including `from` (source object) and any assignGingerly options
11
+ * @returns The target object after merging
12
+ *
13
+ * @example
14
+ * const source = { theme: { color: 'red' }, label: 'Hello' };
15
+ * const target = { color: 'blue', text: '' };
16
+ * assignFrom(target, {
17
+ * color: '?.theme?.color',
18
+ * text: '?.label'
19
+ * }, { from: source });
20
+ * // target is now { color: 'red', text: 'Hello' }
21
+ */
22
+ import { resolveValues } from './resolveValues.js';
23
+ import assignGingerly, { IAssignGingerlyOptions } from './assignGingerly.js';
24
+
25
+ export interface AssignFromOptions extends IAssignGingerlyOptions {
26
+ /** Source object to resolve RHS path strings against */
27
+ from: any;
28
+ }
29
+
30
+ export function assignFrom(
31
+ target: any,
32
+ pattern: Record<string, any>,
33
+ options: AssignFromOptions
34
+ ): any {
35
+ const resolved = resolveValues(pattern, options.from);
36
+ return assignGingerly(target, resolved, options);
37
+ }
package/assignGingerly.js CHANGED
@@ -228,12 +228,17 @@ function ensureNestedPath(obj, pathParts) {
228
228
  return current;
229
229
  }
230
230
  /**
231
- * Helper function to check if a property is readonly
232
- * A property is readonly if:
231
+ * Helper function to check if a property should be merged into rather than replaced.
232
+ * A property is non-replaceable if:
233
233
  * - It's a data property with writable: false, OR
234
234
  * - It's an accessor property with a getter but no setter
235
+ *
236
+ * Properties with both a getter and setter (e.g., element.style) are treated as
237
+ * replaceable — the setter runs with whatever value is provided (garbage in, garbage out).
238
+ *
239
+ * Exported for use by eachTime.ts
235
240
  */
236
- function isReadonlyProperty(obj, propName) {
241
+ export function isReadonlyProperty(obj, propName) {
237
242
  let descriptor = Object.getOwnPropertyDescriptor(obj, propName);
238
243
  if (!descriptor) {
239
244
  // Check prototype chain
@@ -251,8 +256,8 @@ function isReadonlyProperty(obj, propName) {
251
256
  if ('value' in descriptor) {
252
257
  return descriptor.writable === false;
253
258
  }
254
- // If it's an accessor property, check if it has only a getter (no setter)
255
- if ('get' in descriptor) {
259
+ // If it's an accessor property with a getter but no setter, it's readonly
260
+ if ('get' in descriptor && descriptor.get !== undefined) {
256
261
  return descriptor.set === undefined;
257
262
  }
258
263
  return false;
@@ -260,8 +265,10 @@ function isReadonlyProperty(obj, propName) {
260
265
  /**
261
266
  * Helper function to check if a value is a class instance (not a plain object)
262
267
  * Returns true for instances of classes, false for plain objects, arrays, and primitives
268
+ *
269
+ * Exported for use by eachTime.ts
263
270
  */
264
- function isClassInstance(value) {
271
+ export function isClassInstance(value) {
265
272
  if (!value || typeof value !== 'object')
266
273
  return false;
267
274
  if (Array.isArray(value))
@@ -273,8 +280,10 @@ function isClassInstance(value) {
273
280
  /**
274
281
  * Helper function to evaluate a nested path with method calls
275
282
  * Handles chained method calls where path segments can be methods
283
+ *
284
+ * Exported for use by eachTime.ts
276
285
  */
277
- function evaluatePathWithMethods(target, pathParts, value, withMethods) {
286
+ export function evaluatePathWithMethods(target, pathParts, value, withMethods) {
278
287
  let current = target;
279
288
  let i = 0;
280
289
  // Process all segments except the last one
@@ -319,6 +328,159 @@ function evaluatePathWithMethods(target, pathParts, value, withMethods) {
319
328
  isMethod: withMethods.has(lastKey)
320
329
  };
321
330
  }
331
+ /**
332
+ * Check if a value is iterable (can be used with for...of or has forEach)
333
+ */
334
+ function isIterable(value) {
335
+ if (value == null)
336
+ return false;
337
+ // Check for Symbol.iterator
338
+ if (typeof value[Symbol.iterator] === 'function')
339
+ return true;
340
+ // Check if it's an Array
341
+ if (Array.isArray(value))
342
+ return true;
343
+ // Check if it's array-like (has length and numeric indices)
344
+ // This covers NodeList, HTMLCollection, etc.
345
+ if (typeof value.length === 'number' && value.length >= 0) {
346
+ return true;
347
+ }
348
+ return false;
349
+ }
350
+ /**
351
+ * Check if a segment is the forEach symbol (@each) or aliased to it
352
+ */
353
+ function isForEachSymbol(segment, aliasMap) {
354
+ // Direct match
355
+ if (segment === '@each')
356
+ return true;
357
+ // Check if this segment is aliased to '@each'
358
+ const aliasTarget = aliasMap.get(segment);
359
+ return aliasTarget === '@each';
360
+ }
361
+ /**
362
+ * Check if a segment is the reactive forEach symbol (@eachTime) or aliased to it
363
+ */
364
+ function isReactiveForEachSymbol(segment, aliasMap) {
365
+ // Direct match
366
+ if (segment === '@eachTime')
367
+ return true;
368
+ // Check if this segment is aliased to '@eachTime'
369
+ const aliasTarget = aliasMap.get(segment);
370
+ return aliasTarget === '@eachTime';
371
+ }
372
+ /**
373
+ * Apply a path to each item in an iterable
374
+ */
375
+ function applyToEach(iterable, remainingPath, value, withMethods, aliasMap, options) {
376
+ // Convert to array for iteration
377
+ const items = Array.isArray(iterable) ? iterable : Array.from(iterable);
378
+ // Apply the remaining path to each item
379
+ for (const item of items) {
380
+ if (remainingPath.length === 0) {
381
+ // No remaining path, can't do anything
382
+ continue;
383
+ }
384
+ // Check if there's another @each in the remaining path
385
+ const forEachIndex = remainingPath.findIndex(part => isForEachSymbol(part, aliasMap));
386
+ if (forEachIndex !== -1) {
387
+ // There's a nested @each
388
+ // Evaluate path up to the @each
389
+ const pathToForEach = remainingPath.slice(0, forEachIndex);
390
+ const pathAfterForEach = remainingPath.slice(forEachIndex + 1);
391
+ // Navigate to the nested iterable
392
+ let current = item;
393
+ for (const part of pathToForEach) {
394
+ if (withMethods.has(part)) {
395
+ const method = current[part];
396
+ if (typeof method === 'function') {
397
+ // For methods in the middle, we need to check the next part
398
+ const nextIndex = pathToForEach.indexOf(part) + 1;
399
+ const nextPart = pathToForEach[nextIndex];
400
+ if (nextPart && withMethods.has(nextPart)) {
401
+ current = method.call(current);
402
+ }
403
+ else if (nextPart) {
404
+ current = method.call(current, nextPart);
405
+ // Skip next part
406
+ pathToForEach.splice(nextIndex, 1);
407
+ }
408
+ else {
409
+ current = method.call(current);
410
+ }
411
+ }
412
+ else {
413
+ current = current[part];
414
+ }
415
+ }
416
+ else {
417
+ current = current[part];
418
+ }
419
+ }
420
+ // Recursively apply to the nested iterable
421
+ if (isIterable(current)) {
422
+ applyToEach(current, pathAfterForEach, value, withMethods, aliasMap, options);
423
+ }
424
+ }
425
+ else {
426
+ // No nested @each, evaluate the remaining path normally
427
+ const result = evaluatePathWithMethods(item, remainingPath, value, withMethods);
428
+ if (result.isMethod) {
429
+ // Last segment is a method - call it
430
+ const method = result.target[result.lastKey];
431
+ if (typeof method === 'function') {
432
+ if (Array.isArray(value)) {
433
+ method.apply(result.target, value);
434
+ }
435
+ else {
436
+ method.call(result.target, value);
437
+ }
438
+ }
439
+ }
440
+ else {
441
+ // Normal assignment
442
+ const lastKey = result.lastKey;
443
+ const parent = result.target;
444
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
445
+ if (lastKey in parent && isReadonlyProperty(parent, lastKey)) {
446
+ const currentValue = parent[lastKey];
447
+ if (typeof currentValue !== 'object' || currentValue === null) {
448
+ throw new Error(`Cannot merge object into readonly primitive property '${String(lastKey)}'`);
449
+ }
450
+ assignGingerly(currentValue, value, options);
451
+ }
452
+ else {
453
+ parent[lastKey] = value;
454
+ }
455
+ }
456
+ else {
457
+ parent[lastKey] = value;
458
+ }
459
+ }
460
+ }
461
+ }
462
+ }
463
+ /**
464
+ * Apply alias substitutions to a key string.
465
+ * Replaces complete tokens between `?.` delimiters with their aliased values.
466
+ *
467
+ * @param key - The key string (e.g., '?.$?.my-element?.c?.+')
468
+ * @param aliasMap - Map of alias -> target name
469
+ * @returns The key with aliases substituted (e.g., '?.querySelector?.my-element?.classList?.add')
470
+ */
471
+ function applyAliases(key, aliasMap) {
472
+ if (aliasMap.size === 0)
473
+ return key;
474
+ // Split by ?. to get tokens
475
+ const parts = key.split('?.');
476
+ // Apply aliases to each part
477
+ const substituted = parts.map(part => {
478
+ // Check if this exact part is an alias
479
+ return aliasMap.get(part) ?? part;
480
+ });
481
+ // Rejoin with ?.
482
+ return substituted.join('?.');
483
+ }
322
484
  /**
323
485
  * Main assignGingerly function
324
486
  */
@@ -332,12 +494,23 @@ export function assignGingerly(target, source, options) {
332
494
  ? options.withMethods
333
495
  : new Set(options.withMethods)
334
496
  : undefined;
497
+ // Convert aka object to Map for O(1) lookup and validate aliases
498
+ const aliasMap = new Map();
499
+ if (options?.aka) {
500
+ for (const [alias, target] of Object.entries(options.aka)) {
501
+ // Validate: disallow space and backtick in aliases
502
+ if (alias.includes(' ') || alias.includes('`')) {
503
+ throw new Error(`Invalid alias '${alias}': aliases cannot contain space or backtick characters`);
504
+ }
505
+ aliasMap.set(alias, target);
506
+ }
507
+ }
335
508
  const registry = options?.registry instanceof EnhancementRegistry
336
509
  ? options.registry
337
510
  : options?.registry
338
511
  ? new options.registry()
339
512
  : undefined;
340
- // Convert Symbol.for string keys to actual symbols
513
+ // Convert Symbol.for string keys to actual symbols and apply aliases
341
514
  const processedSource = {};
342
515
  for (const key of Object.keys(source)) {
343
516
  if (isSymbolForKey(key)) {
@@ -351,7 +524,9 @@ export function assignGingerly(target, source, options) {
351
524
  }
352
525
  }
353
526
  else {
354
- processedSource[key] = source[key];
527
+ // Apply aliases to string keys
528
+ const substitutedKey = applyAliases(key, aliasMap);
529
+ processedSource[substitutedKey] = source[key];
355
530
  }
356
531
  }
357
532
  // Copy over actual symbol keys
@@ -492,6 +667,50 @@ export function assignGingerly(target, source, options) {
492
667
  }
493
668
  if (isNestedPath(key)) {
494
669
  const pathParts = parsePath(key);
670
+ // Check if path contains @each or @eachTime (forEach)
671
+ const forEachIndex = pathParts.findIndex(part => isForEachSymbol(part, aliasMap) || isReactiveForEachSymbol(part, aliasMap));
672
+ if (forEachIndex !== -1) {
673
+ // Check if it's reactive (@eachTime)
674
+ const isReactive = isReactiveForEachSymbol(pathParts[forEachIndex], aliasMap);
675
+ if (isReactive) {
676
+ // Reactive forEach - dynamic load and fire-and-forget
677
+ (async () => {
678
+ try {
679
+ const { handleEachTime } = await import('./eachTime.js');
680
+ await handleEachTime(target, pathParts, forEachIndex, value, withMethodsSet, aliasMap, options);
681
+ }
682
+ catch (error) {
683
+ console.error('Error in @eachTime:', error);
684
+ }
685
+ })();
686
+ continue;
687
+ }
688
+ // Static forEach (@each) - existing logic
689
+ const pathToForEach = pathParts.slice(0, forEachIndex);
690
+ const pathAfterForEach = pathParts.slice(forEachIndex + 1);
691
+ // Navigate to the iterable
692
+ let current = target;
693
+ if (pathToForEach.length > 0) {
694
+ if (withMethodsSet) {
695
+ const result = evaluatePathWithMethods(target, pathToForEach, value, withMethodsSet);
696
+ // The result.target is the current position after evaluating the path
697
+ // This is already the iterable we want
698
+ current = result.target;
699
+ }
700
+ else {
701
+ for (const part of pathToForEach) {
702
+ current = current[part];
703
+ }
704
+ }
705
+ }
706
+ // Apply to each item in the iterable
707
+ if (isIterable(current)) {
708
+ applyToEach(current, pathAfterForEach, value, withMethodsSet || new Set(), aliasMap, options);
709
+ }
710
+ // If not iterable, let JavaScript throw error naturally when trying to iterate
711
+ continue;
712
+ }
713
+ // No @each in path - handle normally
495
714
  // Check if we need to handle methods
496
715
  if (withMethodsSet) {
497
716
  const result = evaluatePathWithMethods(target, pathParts, value, withMethodsSet);
@@ -513,16 +732,16 @@ export function assignGingerly(target, source, options) {
513
732
  const lastKey = result.lastKey;
514
733
  const parent = result.target;
515
734
  if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
516
- // Check if property exists and is readonly OR is a class instance
517
- if (lastKey in parent && (isReadonlyProperty(parent, lastKey) || isClassInstance(parent[lastKey]))) {
735
+ // Check if property exists and is readonly
736
+ if (lastKey in parent && isReadonlyProperty(parent, lastKey)) {
518
737
  const currentValue = parent[lastKey];
519
738
  if (typeof currentValue !== 'object' || currentValue === null) {
520
- throw new Error(`Cannot merge object into ${isReadonlyProperty(parent, lastKey) ? 'readonly ' : ''}primitive property '${String(lastKey)}'`);
739
+ throw new Error(`Cannot merge object into readonly primitive property '${String(lastKey)}'`);
521
740
  }
522
741
  assignGingerly(currentValue, value, options);
523
742
  }
524
743
  else {
525
- // Property is writable and not a class instance - replace it
744
+ // Property is writable - replace it
526
745
  parent[lastKey] = value;
527
746
  }
528
747
  }
@@ -535,18 +754,18 @@ export function assignGingerly(target, source, options) {
535
754
  const lastKey = pathParts[pathParts.length - 1];
536
755
  const parent = ensureNestedPath(target, pathParts);
537
756
  if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
538
- // Check if property exists and is readonly OR is a class instance
539
- if (lastKey in parent && (isReadonlyProperty(parent, lastKey) || isClassInstance(parent[lastKey]))) {
540
- // Property is readonly or a class instance - check if current value is an object
757
+ // Check if property exists and is readonly
758
+ if (lastKey in parent && isReadonlyProperty(parent, lastKey)) {
759
+ // Property is readonly - check if current value is an object
541
760
  const currentValue = parent[lastKey];
542
761
  if (typeof currentValue !== 'object' || currentValue === null) {
543
- throw new Error(`Cannot merge object into ${isReadonlyProperty(parent, lastKey) ? 'readonly ' : ''}primitive property '${String(lastKey)}'`);
762
+ throw new Error(`Cannot merge object into readonly primitive property '${String(lastKey)}'`);
544
763
  }
545
- // Recursively apply assignGingerly to the readonly object or class instance
764
+ // Recursively apply assignGingerly to the readonly object
546
765
  assignGingerly(currentValue, value, options);
547
766
  }
548
767
  else {
549
- // Property is writable and not a class instance - replace it
768
+ // Property is writable - replace it
550
769
  parent[lastKey] = value;
551
770
  }
552
771
  }
@@ -573,18 +792,18 @@ export function assignGingerly(target, source, options) {
573
792
  }
574
793
  // Normal assignment
575
794
  if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
576
- // Check if property exists and is readonly OR is a class instance
577
- if (key in target && (isReadonlyProperty(target, key) || isClassInstance(target[key]))) {
578
- // Property is readonly or a class instance - check if current value is an object
795
+ // Check if property exists and is readonly
796
+ if (key in target && isReadonlyProperty(target, key)) {
797
+ // Property is readonly - check if current value is an object
579
798
  const currentValue = target[key];
580
799
  if (typeof currentValue !== 'object' || currentValue === null) {
581
- throw new Error(`Cannot merge object into ${isReadonlyProperty(target, key) ? 'readonly ' : ''}primitive property '${String(key)}'`);
800
+ throw new Error(`Cannot merge object into readonly primitive property '${String(key)}'`);
582
801
  }
583
- // Recursively apply assignGingerly to the readonly object or class instance
802
+ // Recursively apply assignGingerly to the readonly object
584
803
  assignGingerly(currentValue, value, options);
585
804
  }
586
805
  else {
587
- // Property is writable and not a class instance - replace it
806
+ // Property is writable - replace it
588
807
  target[key] = value;
589
808
  }
590
809
  }