assign-gingerly 0.0.84 → 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/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
 
@@ -1641,7 +1642,7 @@ interface EnhancementConfig<T, TObj = Element> {
1641
1642
  }
1642
1643
  ```
1643
1644
 
1644
- The `withAttrs` property enables automatic attribute parsing when the enhancement is spawned. See the [Parsing Attributes with parseWithAttrs](#parsing-attributes-with-parsewithattrs) section for details.
1645
+ The `withAttrs` property enables automatic attribute parsing when the enhancement is spawned. See [docs/withAttrs.md](docs/withAttrs.md) for the full attribute-patterns reference.
1645
1646
 
1646
1647
  It also tips off extending polyfills / libraries, in particular mount-observer, to be on the lookout for the attributes specified by withAttrs. But *assign-gingerly, by itself, performs **no** DOM observing to automatically spawn the class instance*. It expects consumers of the polyfill to programmatically attach such behavior/enhancements, and/or rely on alternative, higher level packages to be vigilant for enhancement opportunities.
1647
1648
 
@@ -2443,1155 +2444,22 @@ assignGingerly(element, { [enhSymbol]: 'test' }, { registry });
2443
2444
 
2444
2445
  </details>
2445
2446
 
2446
- ## Parsing Attributes with `parseWithAttrs`
2447
+ ## Parsing HTML Attributes with `withAttrs`
2447
2448
 
2448
- The `parseWithAttrs` function provides a declarative way to read and parse HTML attributes and pass the parsed values into the spawned enhancement constructor.
2449
+ Enhancements and custom element features can declare attribute patterns that are automatically parsed from the host element into `initVals` when the enhancement/feature is spawned. The `withAttrs` configuration supports typed parsing, template variables, default values, custom parsers, and the `sourceOfTruth` marker consumed by higher-level features such as [truth-sourcer](https://github.com/bahrus/truth-sourcer).
2449
2450
 
2450
- ### Automatic Integration with Enhancement Spawning
2451
+ See [docs/withAttrs.md](docs/withAttrs.md) for the full reference, including:
2451
2452
 
2452
- **Important**: When using the `enh.get()`, `enh.set`, or `assignGingerly()` methods with registry items, you typically **do not need to call `parseWithAttrs()` manually**. The attribute parsing happens automatically during enhancement spawning when you include a `withAttrs` property in your registry item configuration.
2453
-
2454
- ```html
2455
- <my-element my-enhancement-count="42" my-enhancement-theme="dark"></my-element>
2456
- ```
2457
-
2458
- ```TypeScript
2459
- import 'assign-gingerly/object-extension.js';
2460
-
2461
- class MyEnhancement {
2462
- elementRef;
2463
- ctx;
2464
- count = 0;
2465
- theme = 'light';
2466
-
2467
- constructor(oElement, ctx, initVals) {
2468
- this.element = new WeakRef(oElement);
2469
- this.ctx = ctx;
2470
- // initVals automatically contains parsed attributes!
2471
- if (initVals) {
2472
- Object.assign(this, initVals);
2473
- }
2474
- }
2475
- }
2476
-
2477
- const element = document.querySelector('my-element');
2478
- const enhancementConfig = {
2479
- spawn: MyEnhancement,
2480
- enhKey: 'myEnh',
2481
- withAttrs: {
2482
- base: 'my-enhancement',
2483
- count: '${base}-count',
2484
- _count: { instanceOf: 'Number' },
2485
- theme: '${base}-theme'
2486
-
2487
- }
2488
- };
2489
-
2490
-
2491
- // Spawn the enhancement - attributes are automatically parsed!
2492
- const instance = element.enh.get(enhancementConfig);
2493
- console.log(instance.count); // 42 (parsed from attribute)
2494
- console.log(instance.theme); // 'dark' (parsed from attribute)
2495
- ```
2496
-
2497
- <details>
2498
- <summary>Example without enhKey</summary>
2499
-
2500
- ```TypeScript
2501
- // withAttrs works even without enhKey
2502
- class SimpleEnhancement {
2503
- element;
2504
- ctx;
2505
- value = null;
2506
-
2507
- constructor(oElement, ctx, initVals) {
2508
- this.element = oElement;
2509
- this.ctx = ctx;
2510
- if (initVals) {
2511
- Object.assign(this, initVals);
2512
- }
2513
- }
2514
- }
2515
-
2516
- const element = document.createElement('div');
2517
- element.setAttribute('data-value', 'test123');
2518
-
2519
- const config = {
2520
- spawn: SimpleEnhancement,
2521
- // No enhKey - attributes still parsed!
2522
- withAttrs: {
2523
- base: 'data-',
2524
- value: '${base}value'
2525
- }
2526
- };
2527
-
2528
- const instance = element.enh.get(config);
2529
- console.log(instance.value); // 'test123' (parsed from attribute)
2530
- ```
2531
-
2532
- </details>
2533
-
2534
- <details>
2535
- <summary>How it works</summary>
2536
-
2537
- 1. When an enhancement is spawned via `enh.get()`, `enh.set`, or `assignGingerly()`
2538
- 2. If the registry item has a `withAttrs` property defined
2539
- 3. `parseWithAttrs(element, registryItem.withAttrs)` is automatically called
2540
- 4. The parsed attributes are passed to the enhancement constructor as `initVals`
2541
- 5. If the registry item also has an `enhKey`, the parsed attributes are merged with any existing values from `element.enh[enhKey]` (existing values take precedence)
2542
-
2543
- </details>
2544
-
2545
- > [!NOTE]
2546
- > `withAttrs` works with or without `enhKey`. When there's no `enhKey`, the parsed attributes are passed directly to the constructor. When there is an `enhKey`, they're merged with any pre-existing values on the enh container.
2547
-
2548
- ### The `enh-` Prefix for Attribute Isolation
2549
-
2550
- The `parseWithAttrs` function supports an `enh-` prefix for attributes to provide better isolation and avoid conflicts, especially for custom elements and SVG elements.
2551
-
2552
- **Behavior by Element Type:**
2553
-
2554
- - **Built-in HTML elements** (div, span, etc.): The `enh-` prefix acts as an **alias**. The function tries `enh-` prefixed attributes first, then falls back to unprefixed attributes.
2555
- ```html
2556
- <!-- Both work for built-in elements -->
2557
- <div data-count="42"></div>
2558
- <div enh-data-count="42"></div>
2559
-
2560
- <!-- enh- prefix takes precedence -->
2561
- <div data-count="10" enh-data-count="42"></div> <!-- Uses 42 -->
2562
- ```
2563
-
2564
- - **Custom elements and SVG elements**: The `enh-` prefix is **strictly enforced** by default. Only `enh-` prefixed attributes are read.
2565
- ```html
2566
- <!-- Only enh- prefixed attributes work -->
2567
- <my-element data-count="42"></my-element> <!-- Ignored -->
2568
- <my-element enh-data-count="42"></my-element> <!-- Works -->
2569
-
2570
- <svg enh-data-theme="dark"></svg> <!-- Works -->
2571
- <svg data-theme="dark"></svg> <!-- Ignored -->
2572
- ```
2573
-
2574
- **Overriding with `allowUnprefixed`:**
2575
-
2576
- For custom elements and SVG, you can opt-in to reading unprefixed attributes by specifying a pattern (string or RegExp) that the element's tag name must match:
2577
-
2578
- ```TypeScript
2579
- // Allow unprefixed for elements matching pattern
2580
- registry.push({
2581
- spawn: MyEnhancement,
2582
- enhKey: 'myEnh',
2583
- allowUnprefixed: '^my-', // Only for elements starting with "my-"
2584
- withAttrs: {
2585
- base: 'data-',
2586
- count: '${base}count',
2587
- _count: { instanceOf: 'Number' }
2588
- }
2589
- });
2590
-
2591
- // Or use RegExp for more complex patterns
2592
- registry.push({
2593
- spawn: MyEnhancement,
2594
- enhKey: 'myEnh',
2595
- allowUnprefixed: /^(my-|app-)/, // For "my-*" or "app-*" elements
2596
- withAttrs: {
2597
- base: 'data-',
2598
- count: '${base}count',
2599
- _count: { instanceOf: 'Number' }
2600
- }
2601
- });
2602
- ```
2603
-
2604
- <details>
2605
- <summary>Why use `enh-` prefix?</summary>
2606
-
2607
- 1. **Avoid conflicts**: Custom elements may use unprefixed attributes for their own purposes
2608
- 2. **Clear intent**: Makes it obvious which attributes are for enhancements
2609
- 3. **Future-proof**: Protects against future attribute additions to custom elements
2610
- 4. **Consistency**: Provides a standard convention across all enhanced elements
2611
- 5. **Selective override**: Pattern-based `allowUnprefixed` lets you opt-in specific element families while maintaining strict isolation for others
2612
-
2613
- </details>
2614
-
2615
- <details>
2616
- <summary>Manual Usage</summary>
2617
-
2618
- While automatic parsing is the recommended approach, you can also call `parseWithAttrs()` manually when needed.
2619
-
2620
- When calling `parseWithAttrs()` manually, pass the pattern as the third (optional) parameter:
2621
-
2622
- ```TypeScript
2623
- // Allow unprefixed only for elements matching pattern
2624
- const result = parseWithAttrs(element, attrPatterns, '^my-');
2625
-
2626
- // Or with RegExp
2627
- const result = parseWithAttrs(element, attrPatterns, /^(my-|app-)/);
2628
- ```
2629
-
2630
- **Pattern Matching:**
2631
- - The pattern is tested against the element's **lowercase tag name**
2632
- - String patterns are automatically converted to RegExp
2633
- - If the tag name matches, unprefixed attributes are allowed (but `enh-` still takes precedence)
2634
- - If the tag name doesn't match, only `enh-` prefixed attributes are read
2635
-
2636
- **Example:**
2637
- ```html
2638
- <my-widget data-count="42"></my-widget>
2639
- <other-widget data-count="42"></other-widget>
2640
- ```
2641
-
2642
- ```TypeScript
2643
- // Pattern: '^my-' (only matches "my-widget")
2644
- const result1 = parseWithAttrs(
2645
- document.querySelector('my-widget'),
2646
- { base: 'data-', count: '${base}count', _count: { instanceOf: 'Number' } },
2647
- '^my-'
2648
- );
2649
- // result1.count = 42 (unprefixed allowed because tag matches)
2650
-
2651
- const result2 = parseWithAttrs(
2652
- document.querySelector('other-widget'),
2653
- { base: 'data-', count: '${base}count', _count: { instanceOf: 'Number' } },
2654
- '^my-'
2655
- );
2656
- // result2.count = undefined (unprefixed ignored because tag doesn't match)
2657
- ```
2658
-
2659
- ### Basic Usage
2660
-
2661
- ```TypeScript
2662
- import { parseWithAttrs } from 'assign-gingerly/parseWithAttrs';
2663
-
2664
- const element = document.querySelector('#myElement');
2665
- const config = parseWithAttrs(element, {
2666
- base: 'data-',
2667
- count: '${base}count',
2668
- _count: {
2669
- instanceOf: 'Number',
2670
- mapsTo: 'itemCount'
2671
- }
2672
- });
2673
- ```
2674
-
2675
- ### Error Handling
2676
-
2677
- The function throws descriptive errors for common issues:
2678
-
2679
- ```TypeScript
2680
- // Circular reference
2681
- parseWithAttrs(element, {
2682
- a: '${b}',
2683
- b: '${a}' // Error: Circular reference detected
2684
- });
2685
-
2686
- // Undefined variable
2687
- parseWithAttrs(element, {
2688
- name: '${missing}' // Error: Undefined template variable: missing
2689
- });
2690
-
2691
- // Invalid JSON
2692
- // HTML: <div data-obj='{invalid}'></div>
2693
- parseWithAttrs(element, {
2694
- base: 'data-',
2695
- obj: '${base}obj',
2696
- _obj: { instanceOf: 'Object' }
2697
- // Error: Failed to parse JSON: "{invalid}"
2698
- });
2699
-
2700
- // Invalid number
2701
- // HTML: <div data-count="abc"></div>
2702
- parseWithAttrs(element, {
2703
- base: 'data-',
2704
- count: '${base}count',
2705
- _count: { instanceOf: 'Number' }
2706
- // Error: Failed to parse number: "abc"
2707
- });
2708
- ```
2709
-
2710
- </details>
2711
-
2712
- **Base Attribute Validation:**
2713
-
2714
- The `base` attribute must contain either a dash (`-`) or a non-ASCII character to prevent conflicts with native attributes:
2715
-
2716
- ```TypeScript
2717
- // Valid base attributes
2718
- const enhConfig1 = { base: 'data-config' }; // Has dash
2719
- const enhConfig2 = { base: '??-theme' }); // Has non-ASCII (and dash)
2720
-
2721
- // Invalid - throws error
2722
- const enhConig3 = { base: 'config' }; // No dash or non-ASCII
2723
- ```
2724
-
2725
-
2726
- <details>
2727
- <summary>AttrPatterns Configuration</summary>
2728
-
2729
- The `parseWithAttrs` function accepts an `AttrPatterns` object that defines:
2730
-
2731
- 1. **Attribute name templates**: String values with `${variable}` placeholders
2732
- 2. **Configuration objects**: Properties prefixed with `_` that specify parsing behavior
2733
-
2734
- ```TypeScript
2735
- interface AttrPatterns<T> {
2736
- base?: string; // Base attribute name prefix
2737
- _base?: AttrConfig<T>; // Configuration for base attribute
2738
- [key: string]: string | AttrConfig<T>; // Other attributes and configs
2739
- }
2740
-
2741
- interface AttrConfig<T> {
2742
- mapsTo?: keyof T | '.'; // Target property name (or '.' to spread)
2743
- instanceOf?: string | Function; // Type for default parser
2744
- parser?:
2745
- | ((v: string | null) => any) // Inline parser function
2746
- | string // Named parser from globalParserRegistry
2747
- | [string, string]; // [CustomElementName, StaticMethodName]
2748
- }
2749
- ```
2750
-
2751
- ### Template Variables
2752
-
2753
- Attribute names support template variables using `${varName}` syntax:
2754
-
2755
- ```TypeScript
2756
- // HTML: <div data-user-name="Alice" data-user-age="30"></div>
2757
-
2758
- const result = parseWithAttrs(element, {
2759
- base: 'data-',
2760
- user: '${base}user',
2761
- name: '${user}-name',
2762
- age: '${user}-age'
2763
- });
2764
- // Result: { name: 'Alice', age: '30' }
2765
- ```
2766
-
2767
- **Deep Nesting:**
2768
-
2769
- Template variables can reference other template variables to any depth, creating hierarchical attribute naming patterns:
2770
-
2771
- ```TypeScript
2772
- // HTML: <div data-app-user-profile-name="Alice" data-app-user-profile-email="alice@example.com"></div>
2773
-
2774
- const result = parseWithAttrs(element, {
2775
- base: 'data-',
2776
- app: '${base}app',
2777
- user: '${app}-user',
2778
- profile: '${user}-profile',
2779
- name: '${profile}-name',
2780
- email: '${profile}-email'
2781
- });
2782
- // Result: { name: 'Alice', email: 'alice@example.com' }
2783
-
2784
- // The resolution chain: base ? app ? user ? profile ? name/email
2785
- // Resolves to: data-app-user-profile-name and data-app-user-profile-email
2786
- ```
2787
-
2788
- **Benefits of hierarchical variables:**
2789
- - Build complex attribute names from simple parts
2790
- - Maintain consistency across related attributes
2791
- - Easy to refactor by changing a single variable
2792
- - Self-documenting attribute structure
2793
-
2794
- Template variables are resolved recursively and cached for performance. Circular references are detected and throw an error.
2795
-
2796
- ### Type Parsing with instanceOf
2797
-
2798
- The `instanceOf` property determines how attribute values are parsed:
2799
-
2800
- ```TypeScript
2801
- // HTML: <div data-count="42" data-active data-tags='["a","b"]'></div>
2802
-
2803
- const result = parseWithAttrs(element, {
2804
- base: 'data-',
2805
- count: '${base}count',
2806
- _count: { instanceOf: 'Number' },
2807
-
2808
- active: '${base}active',
2809
- _active: { instanceOf: 'Boolean' }, // Presence check
2810
-
2811
- tags: '${base}-tags',
2812
- _tags: { instanceOf: 'Array' }
2813
- });
2814
- // Result: { count: 42, active: true, tags: ['a', 'b'] }
2815
- ```
2816
-
2817
- **Built-in type parsers:**
2818
- - `String`: Identity (default)
2819
- - `Number`: Parses numeric values, throws on invalid numbers
2820
- - `Boolean`: Presence check (attribute exists = true)
2821
- - `Object`: Parses JSON objects
2822
- - `Array`: Parses JSON arrays
2823
-
2824
- ### Custom Parsers
2825
-
2826
- Provide a custom `parser` function for specialized parsing:
2827
-
2828
- ```TypeScript
2829
- // HTML: <div data-timestamp="2024-01-15T10:30:00Z"></div>
2830
-
2831
- const result = parseWithAttrs(element, {
2832
- base: 'data-',
2833
- timestamp: '${base}timestamp',
2834
- _timestamp: {
2835
- mapsTo: 'createdAt',
2836
- parser: (v) => v ? new Date(v).getTime() : null
2837
- }
2838
- });
2839
- // Result: { createdAt: 1705315800000 }
2840
- ```
2841
-
2842
- ### Named Parsers for Reusability and JSON Serialization
2843
-
2844
- Instead of inline functions, you can reference parsers by name, making configs JSON serializable and parsers reusable:
2845
-
2846
- ```TypeScript
2847
- import { globalParserRegistry, parseWithAttrs } from 'assign-gingerly';
2848
-
2849
- // Register parsers once (typically in app initialization)
2850
- globalParserRegistry.register('timestamp', (v) =>
2851
- v ? new Date(v).getTime() : null
2852
- );
2853
-
2854
- globalParserRegistry.register('csv', (v) =>
2855
- v ? v.split(',').map(s => s.trim()) : []
2856
- );
2857
-
2858
- // Use by name - config is now JSON serializable!
2859
- const config = {
2860
- base: 'data-',
2861
- created: '${base}created',
2862
- _created: {
2863
- parser: 'timestamp' // String reference instead of function
2864
- },
2865
- tags: '${base}tags',
2866
- _tags: {
2867
- parser: 'csv'
2868
- }
2869
- };
2870
-
2871
- // Can serialize to JSON
2872
- const json = JSON.stringify(config);
2873
-
2874
- // Use the config
2875
- const result = parseWithAttrs(element, config);
2876
- ```
2877
-
2878
- **Built-in Named Parsers:**
2879
-
2880
- [TODO]: Check if this is all needed
2881
-
2882
- The following parsers are pre-registered in `globalParserRegistry`:
2883
-
2884
- - `'timestamp'` - Parses ISO date string to Unix timestamp (milliseconds)
2885
- - `'date'` - Parses string to Date object
2886
- - `'csv'` - Splits comma-separated values into trimmed array
2887
- - `'int'` - Parses integer with `parseInt(v, 10)`
2888
- - `'float'` - Parses float with `parseFloat(v)`
2889
- - `'boolean'` - Presence check (same as `instanceOf: 'Boolean'`)
2890
- - `'json'` - Parses JSON (same as `instanceOf: 'Object'` or `'Array'`)
2891
-
2892
- **Custom Element Static Method Parsers:**
2893
-
2894
- You can reference static methods on custom elements using tuple syntax `[elementName, methodName]`:
2895
-
2896
- ```TypeScript
2897
- class MyWidget extends HTMLElement {
2898
- static parseSpecialFormat(v) {
2899
- return v ? v.toUpperCase() : null;
2900
- }
2901
-
2902
- static parseWithPrefix(v) {
2903
- return v ? `PREFIX:${v}` : null;
2904
- }
2905
- }
2906
- customElements.define('my-widget', MyWidget);
2907
-
2908
- // Reference custom element parsers using tuple syntax
2909
- const config = {
2910
- base: 'data-',
2911
- value: '${base}value',
2912
- _value: {
2913
- parser: ['my-widget', 'parseSpecialFormat'] // [element-name, methodName]
2914
- },
2915
- title: '${base}title',
2916
- _title: {
2917
- parser: ['my-widget', 'parseWithPrefix']
2918
- }
2919
- };
2920
-
2921
- const result = parseWithAttrs(element, config);
2922
- ```
2923
-
2924
- **Parser Resolution:**
2925
-
2926
- When a parser is specified, it can be:
2927
-
2928
- 1. **Inline function** - `parser: (v) => v.toUpperCase()` - Used directly
2929
- 2. **String reference** - `parser: 'timestamp'` - Looks up in `globalParserRegistry`
2930
- 3. **Tuple reference** - `parser: ['my-widget', 'parseMethod']` - Looks up static method on custom element constructor
2931
-
2932
- **Error Handling:**
2933
-
2934
- The tuple syntax provides clear error messages:
2935
-
2936
- ```TypeScript
2937
- // Element not found
2938
- parser: ['non-existent', 'method']
2939
- // Error: Cannot resolve parser [non-existent, method]: custom element "non-existent" not found
2940
-
2941
- // Method not found
2942
- parser: ['my-widget', 'nonExistent']
2943
- // Error: Cannot resolve parser [my-widget, nonExistent]: static method "nonExistent" not found on custom element "my-widget"
2944
-
2945
- // String not found in registry
2946
- parser: 'unknown'
2947
- // Error: Parser "unknown" not found in globalParserRegistry. If you want to reference a custom element static method, use tuple syntax: ["element-name", "methodName"]
2948
- ```
2949
-
2950
- **Example: Organizing Parsers**
2951
-
2952
- ```TypeScript
2953
- // parsers.js - Centralized parser definitions
2954
- export function registerCommonParsers(registry) {
2955
- registry.register('uppercase', (v) => v ? v.toUpperCase() : null);
2956
- registry.register('lowercase', (v) => v ? v.toLowerCase() : null);
2957
- registry.register('trim', (v) => v ? v.trim() : null);
2958
- registry.register('phone', (v) => v ? v.replace(/\D/g, '') : null);
2959
- }
2960
-
2961
- // app.js - Register at startup
2962
- import { globalParserRegistry } from 'assign-gingerly';
2963
- import { registerCommonParsers } from './parsers.js';
2964
-
2965
- registerCommonParsers(globalParserRegistry);
2966
-
2967
- // Now all configs can use these parsers by name
2968
- ```
2969
-
2970
- **Benefits of Named Parsers:**
2971
-
2972
- - ? **JSON serializable** - Configs can be stored/transmitted as JSON
2973
- - ? **Reusable** - Define once, use everywhere
2974
- - ? **Maintainable** - Update parser logic in one place
2975
- - ? **Testable** - Test parsers independently
2976
- - ? **Discoverable** - `globalParserRegistry.getNames()` lists all available parsers
2977
- - ? **Backward compatible** - Inline functions still work
2978
-
2979
- **Mixing Inline and Named Parsers:**
2980
-
2981
- ```TypeScript
2982
- const config = {
2983
- base: 'data-',
2984
- created: '${base}created',
2985
- _created: {
2986
- parser: 'timestamp' // Named parser
2987
- },
2988
- special: '${base}special',
2989
- _special: {
2990
- parser: (v) => v ? v.split('').reverse().join('') : null // Inline
2991
- }
2992
- };
2993
- ```
2994
-
2995
- ### Property Mapping with mapsTo
2996
-
2997
- The `mapsTo` property controls where parsed values are placed:
2998
-
2999
- ```TypeScript
3000
- // HTML: <div data-count="5"></div>
3001
-
3002
- const result = parseWithAttrs(element, {
3003
- base: 'data-',
3004
- count: '${base}count',
3005
- _count: {
3006
- instanceOf: 'Number',
3007
- mapsTo: 'itemCount' // Maps to different property name
3008
- }
3009
- });
3010
- // Result: { itemCount: 5 }
3011
- ```
3012
-
3013
- **Special value `'.'`**: Spreads the parsed object into the root:
3014
-
3015
- ```TypeScript
3016
- // HTML: <div data-config='{"theme":"dark","lang":"en"}'></div>
3017
-
3018
- const result = parseWithAttrs(element, {
3019
- base: 'data-config',
3020
- _base: {
3021
- instanceOf: 'Object',
3022
- mapsTo: '.' // Spread into root
3023
- }
3024
- });
3025
- // Result: { theme: 'dark', lang: 'en' }
3026
- ```
3027
-
3028
- ### Default Values with valIfNull
3029
-
3030
- The `valIfNull` property allows us to specify default values when attributes are missing:
3031
-
3032
- ```TypeScript
3033
- // HTML: <div></div> (no attributes)
3034
-
3035
- const result = parseWithAttrs(element, {
3036
- base: 'data-',
3037
- theme: '${base}theme',
3038
- _theme: {
3039
- instanceOf: 'String',
3040
- valIfNull: 'light' // Default when attribute is missing
3041
- },
3042
- count: '${base}count',
3043
- _count: {
3044
- instanceOf: 'Number',
3045
- valIfNull: 0 // Default to 0
3046
- }
3047
- });
3048
- // Result: { theme: 'light', count: 0 }
3049
- ```
3050
-
3051
- **How it works:**
3052
- - **Attribute missing**: If the attribute doesn't exist and `valIfNull` is defined, the default value is used **without calling the parser**
3053
- - **Attribute present**: If the attribute exists (even if empty string), the parser is called normally and `valIfNull` is ignored
3054
- - **No valIfNull**: If `valIfNull` is undefined and the attribute is missing, the property is not added to the result (current behavior)
3055
-
3056
- **Important notes:**
3057
- 1. **Parser is bypassed**: When `valIfNull` is used, the parser is NOT called - the default value is used as-is
3058
- 2. **Empty string vs missing**: `valIfNull` only applies when the attribute is completely absent. If the attribute exists but is empty (`data-count=""`), the parser IS called
3059
- 3. **Any value allowed**: `valIfNull` can be any JavaScript value: string, number, boolean, object, array, null, etc.
3060
- 4. **Falsy values work**: Even falsy values like `0`, `false`, `''`, or `null` are valid defaults
3061
-
3062
- **Examples with different types:**
3063
-
3064
- ```TypeScript
3065
- // Object default
3066
- const result1 = parseWithAttrs(element, {
3067
- base: 'config-',
3068
- settings: '${base}settings',
3069
- _settings: {
3070
- instanceOf: 'Object',
3071
- valIfNull: { enabled: false, mode: 'auto' }
3072
- }
3073
- });
3074
- // Result: { settings: { enabled: false, mode: 'auto' } }
3075
-
3076
- // Boolean default
3077
- const result2 = parseWithAttrs(element, {
3078
- base: 'feature-',
3079
- enabled: '${base}enabled',
3080
- _enabled: {
3081
- instanceOf: 'Boolean',
3082
- valIfNull: false
3083
- }
3084
- });
3085
- // Result: { enabled: false }
3086
-
3087
- // Array default
3088
- const result3 = parseWithAttrs(element, {
3089
- base: 'data-',
3090
- items: '${base}items',
3091
- _items: {
3092
- instanceOf: 'Array',
3093
- valIfNull: []
3094
- }
3095
- });
3096
- // Result: { items: [] }
3097
-
3098
- // null as default
3099
- const result4 = parseWithAttrs(element, {
3100
- base: 'data-',
3101
- value: '${base}value',
3102
- _value: {
3103
- instanceOf: 'String',
3104
- valIfNull: null
3105
- }
3106
- });
3107
- // Result: { value: null }
3108
- ```
3109
-
3110
- **Comparison: Empty string vs missing attribute:**
3111
-
3112
- ```html
3113
- <!-- Attribute is missing -->
3114
- <div></div>
3115
-
3116
- <!-- Attribute exists but is empty -->
3117
- <div data-count=""></div>
3118
- ```
3119
-
3120
- ```TypeScript
3121
- const config = {
3122
- base: 'data-',
3123
- count: '${base}count',
3124
- _count: {
3125
- instanceOf: 'Number',
3126
- valIfNull: 99
3127
- }
3128
- };
3129
-
3130
- // Missing attribute - uses valIfNull
3131
- const result1 = parseWithAttrs(document.querySelector('div:nth-child(1)'), config);
3132
- // Result: { count: 99 }
3133
-
3134
- // Empty string - calls parser (returns null for empty Number)
3135
- const result2 = parseWithAttrs(document.querySelector('div:nth-child(2)'), config);
3136
- // Result: { count: null }
3137
- ```
3138
-
3139
- ### Performance Optimization with parseCache
3140
-
3141
- The `parseCache` property enables caching of parsed attribute values to improve performance when the same attribute values appear repeatedly throughout the document:
3142
-
3143
- ```TypeScript
3144
- // HTML: Multiple elements with same attribute values
3145
- // <div data-config='{"theme":"dark","size":"large"}'></div>
3146
- // <div data-config='{"theme":"dark","size":"large"}'></div>
3147
- // <div data-config='{"theme":"dark","size":"large"}'></div>
3148
-
3149
- const config = {
3150
- base: 'data-',
3151
- config: '${base}config',
3152
- _config: {
3153
- instanceOf: 'Object',
3154
- parseCache: 'shared' // Cache and reuse parsed objects
3155
- }
3156
- };
3157
-
3158
- // First parse - parses and caches
3159
- const result1 = parseWithAttrs(element1, config);
3160
-
3161
- // Subsequent parses - returns cached value (no parsing)
3162
- const result2 = parseWithAttrs(element2, config);
3163
- const result3 = parseWithAttrs(element3, config);
3164
- ```
3165
-
3166
- **Cache Strategies:**
3167
-
3168
- 1. **`'shared'`**: Returns the same object reference from cache
3169
- - **Fastest**: No cloning overhead
3170
- - **Risk**: Enhancements that mutate the object will affect all instances
3171
- - **Best for**: Immutable data or when you trust enhancements not to mutate
3172
-
3173
- 2. **`'cloned'`**: Returns a structural clone of the cached object
3174
- - **Safer**: Each instance gets its own copy
3175
- - **Slower**: Uses `structuredClone()` which has overhead
3176
- - **Best for**: Mutable data or when enhancements might modify values
3177
-
3178
- **Examples:**
3179
-
3180
- ```TypeScript
3181
- // Shared cache - fast but requires discipline
3182
- const sharedConfig = {
3183
- base: 'data-',
3184
- settings: '${base}settings',
3185
- _settings: {
3186
- instanceOf: 'Object',
3187
- parseCache: 'shared' // All instances share same object
3188
- }
3189
- };
3190
-
3191
- // Cloned cache - safer for mutable data
3192
- const clonedConfig = {
3193
- base: 'data-',
3194
- state: '${base}state',
3195
- _state: {
3196
- instanceOf: 'Object',
3197
- parseCache: 'cloned' // Each instance gets a copy
3198
- }
3199
- };
3200
-
3201
- // Custom parser with caching
3202
- let parseCount = 0;
3203
- const customConfig = {
3204
- base: 'data-',
3205
- timestamp: '${base}timestamp',
3206
- _timestamp: {
3207
- parser: (v) => {
3208
- parseCount++; // Track parse calls
3209
- return v ? new Date(v).getTime() : null;
3210
- },
3211
- parseCache: 'shared' // Parser only called once per unique value
3212
- }
3213
- };
3214
- ```
3215
-
3216
- **Important Notes:**
3217
-
3218
- 1. **Parser purity**: Parsers should be pure functions (no side effects) when using caching
3219
- 2. **Boolean types**: Caching is skipped for Boolean types (presence check doesn't benefit)
3220
- 3. **Cache scope**: Cache is module-level and persists across all `parseWithAttrs()` calls
3221
- 4. **Cache key**: Values are cached per `(instanceOf, parserType, attributeValue)` tuple
3222
- 5. **Memory**: Cache grows with unique attribute values encountered (no automatic cleanup)
3223
- 6. **Browser support**: `'cloned'` strategy requires `structuredClone()` (modern browsers)
3224
-
3225
- **Performance Considerations:**
3226
-
3227
- - **Shared cache**: Best for simple objects, arrays, or when parsing is expensive
3228
- - **Cloned cache**: Overhead may negate benefits for simple values (strings, numbers)
3229
- - **No cache**: Better for unique values or when parsing is trivial
3230
- - **Custom parsers**: Caching is most beneficial when parser does expensive operations (Date parsing, complex transformations)
3231
-
3232
- **Example: Shared cache mutation risk**
3233
-
3234
- ```TypeScript
3235
- const config = {
3236
- base: 'data-',
3237
- items: '${base}items',
3238
- _items: {
3239
- instanceOf: 'Array',
3240
- parseCache: 'shared'
3241
- }
3242
- };
3243
-
3244
- // HTML: <div data-items='[1,2,3]'></div>
3245
-
3246
- const result1 = parseWithAttrs(element1, config);
3247
- result1.items.push(4); // Mutation!
3248
-
3249
- const result2 = parseWithAttrs(element2, config);
3250
- console.log(result2.items); // [1,2,3,4] - mutation is visible!
3251
- ```
3252
-
3253
- **Example: Cloned cache safety**
3254
-
3255
- ```TypeScript
3256
- const config = {
3257
- base: 'data-',
3258
- items: '${base}items',
3259
- _items: {
3260
- instanceOf: 'Array',
3261
- parseCache: 'cloned' // Safe from mutations
3262
- }
3263
- };
3264
-
3265
- const result1 = parseWithAttrs(element1, config);
3266
- result1.items.push(4); // Mutation
3267
-
3268
- const result2 = parseWithAttrs(element2, config);
3269
- console.log(result2.items); // [1,2,3] - original value preserved
3270
- ```
3271
-
3272
- ### Base Attribute
3273
-
3274
- The special `base` property handles a single attribute that spreads into the result:
3275
-
3276
- ```TypeScript
3277
- // HTML: <div data-greetings='{"hello":"world","goodbye":"Mars"}'></div>
3278
-
3279
- const result = parseWithAttrs(element, {
3280
- base: 'data-greetings'
3281
- // Default: spreads into root with Object parser
3282
- });
3283
- // Result: { hello: 'world', goodbye: 'Mars' }
3284
-
3285
- // With custom mapsTo:
3286
- const result2 = parseWithAttrs(element, {
3287
- base: 'data-greetings',
3288
- _base: {
3289
- mapsTo: 'greetings',
3290
- instanceOf: 'Object'
3291
- }
3292
- });
3293
- // Result: { greetings: { hello: 'world', goodbye: 'Mars' } }
3294
- ```
3295
-
3296
- ### Best Practices
3297
-
3298
- 1. **Use base for common prefixes**: Reduces repetition in attribute names
3299
- 2. **Leverage template variables**: Build complex attribute names from simple parts
3300
- 3. **Specify instanceOf**: Ensures proper type conversion
3301
- 4. **Use mapsTo for clarity**: Map attribute names to meaningful property names
3302
- 5. **Combine with assignGingerly**: Use nested paths (`?.`) for deep property assignment
3303
- 6. **Handle missing attributes**: Non-existent attributes are skipped (except Boolean types)
3304
-
3305
- ### Nested Paths with assignGingerly
3306
-
3307
- Combine `parseWithAttrs` with `assignGingerly` for nested property assignment:
3308
-
3309
- ```TypeScript
3310
- // HTML: <div data-height="100px" data--is-happy></div>
3311
-
3312
- const element = document.createElement('div');
3313
- const attrs = parseWithAttrs(element, {
3314
- base: 'data-',
3315
- height: '${base}height',
3316
- _height: {
3317
- mapsTo: '?.style?.height'
3318
- },
3319
- isHappy: '${base}-is-happy',
3320
- _isHappy: {
3321
- instanceOf: 'Boolean',
3322
- mapsTo: '?.moods?.personIsHappy'
3323
- }
3324
- });
3325
-
3326
- assignGingerly(element, attrs);
3327
- // element.style.height === '100px'
3328
- // element.moods.personIsHappy === true
3329
- ```
3330
-
3331
- </details>
2453
+ - `parseWithAttrs` usage and automatic integration with `enh.get()` / `assignGingerly()`
2454
+ - The `enh-` prefix and `allowUnprefixed` rules
2455
+ - `AttrPatterns` / `AttrConfig` options (`mapsTo`, `instanceOf`, `parser`, `valIfNull`, `parseCache`, `sourceOfTruth`)
2456
+ - `buildCSSQuery` for generating CSS selectors from `withAttrs`
2457
+ - Using `withAttrs` with custom element features
3332
2458
 
3333
2459
  ## Building CSS Queries with `buildCSSQuery`
3334
2460
 
3335
- The `buildCSSQuery` function generates CSS selector strings that match elements with attributes defined in an enhancement configuration's `withAttrs`. This is particularly useful for libraries like mount-observer that need to find elements that should be enhanced.
3336
-
3337
- ### Basic Usage
3338
-
3339
- ```TypeScript
3340
- import { buildCSSQuery } from 'assign-gingerly';
3341
-
3342
- const config = {
3343
- spawn: MyEnhancement,
3344
- withAttrs: {
3345
- base: 'my-component',
3346
- theme: '${base}-theme'
3347
- }
3348
- };
3349
-
3350
- const query = buildCSSQuery(config, 'div, span');
3351
- console.log(query);
3352
- // 'div[my-component], span[my-component], div[enh-my-component], span[enh-my-component],
3353
- // div[my-component-theme], span[my-component-theme], div[enh-my-component-theme], span[enh-my-component-theme]'
3354
-
3355
- // Use with querySelector
3356
- const elements = document.querySelectorAll(query);
3357
- ```
3358
-
3359
- **Without selectors (matches any element):**
3360
-
3361
- ```TypeScript
3362
- // Omit the selectors parameter
3363
- const query = buildCSSQuery(config);
3364
- // or explicitly pass empty string
3365
- const query = buildCSSQuery(config, '');
3366
-
3367
- console.log(query);
3368
- // '[my-component], [enh-my-component], [my-component-theme], [enh-my-component-theme]'
3369
-
3370
- // Matches any element with these attributes
3371
- const elements = document.querySelectorAll(query);
3372
- ```
3373
-
3374
- ### How It Works
3375
-
3376
- `buildCSSQuery` creates a cross-product of:
3377
- 1. **Selectors**: The CSS selectors you provide (e.g., `'div, span'`)
3378
- 2. **Attributes**: All attribute names from `withAttrs` (resolving template variables)
3379
- 3. **Prefixes**: Both unprefixed and `enh-` prefixed versions
2461
+ The `buildCSSQuery` helper generates CSS selector strings from a `withAttrs` configuration. It is covered in [docs/withAttrs.md](docs/withAttrs.md#building-css-queries-with-buildcssquery).
3380
2462
 
3381
- This ensures you find all elements that might be enhanced, regardless of whether they use the `enh-` prefix or not.
3382
-
3383
- ### Template Variable Resolution
3384
-
3385
- Template variables in `withAttrs` are automatically resolved:
3386
-
3387
- ```TypeScript
3388
- const config = {
3389
- spawn: BeABeacon,
3390
- withAttrs: {
3391
- base: 'be-a-beacon',
3392
- theme: '${base}-theme',
3393
- size: '${base}-size'
3394
- }
3395
- };
3396
-
3397
- buildCSSQuery(config, 'template, script');
3398
- // Returns selectors for: be-a-beacon, be-a-beacon-theme, be-a-beacon-size
3399
- // Each with both prefixed and unprefixed versions
3400
- ```
3401
-
3402
- ### Complex Selectors
3403
-
3404
- The function supports any valid CSS selector:
3405
-
3406
- ```TypeScript
3407
- const config = {
3408
- spawn: MyEnhancement,
3409
- withAttrs: {
3410
- base: 'data-enhanced'
3411
- }
3412
- };
3413
-
3414
- // Classes and IDs
3415
- buildCSSQuery(config, 'div.highlight, span#special');
3416
- // 'div.highlight[data-enhanced], span#special[data-enhanced], ...'
3417
-
3418
- // Combinators
3419
- buildCSSQuery(config, 'div > span, ul li');
3420
- // 'div > span[data-enhanced], ul li[data-enhanced], ...'
3421
-
3422
- // Pseudo-classes
3423
- buildCSSQuery(config, 'div:hover, span:first-child');
3424
- // 'div:hover[data-enhanced], span:first-child[data-enhanced], ...'
3425
-
3426
- // Attribute selectors
3427
- buildCSSQuery(config, 'div[existing-attr]');
3428
- // 'div[existing-attr][data-enhanced], ...'
3429
- ```
3430
-
3431
- ### Underscore-Prefixed Keys Excluded
3432
-
3433
- Configuration keys starting with `_` are excluded from the query:
3434
-
3435
- ```TypeScript
3436
- const config = {
3437
- spawn: MyEnhancement,
3438
- withAttrs: {
3439
- base: 'my-attr',
3440
- _base: {
3441
- mapsTo: 'something' // Config only, not an attribute
3442
- },
3443
- theme: '${base}-theme',
3444
- _theme: {
3445
- instanceOf: 'String' // Config only
3446
- }
3447
- }
3448
- };
3449
-
3450
- buildCSSQuery(config, 'div');
3451
- // Only includes: my-attr and my-attr-theme
3452
- // Does NOT include: _base or _theme
3453
- ```
3454
-
3455
- ### Edge Cases
3456
-
3457
- **Omitting or empty selectors return attribute-only selectors:**
3458
- ```TypeScript
3459
- const config = {
3460
- spawn: MyClass,
3461
- withAttrs: {
3462
- base: 'my-attr',
3463
- theme: '${base}-theme'
3464
- }
3465
- };
3466
-
3467
- buildCSSQuery(config); // Omit selectors parameter
3468
- // or
3469
- buildCSSQuery(config, ''); // Empty string
3470
- // Both return: '[my-attr], [enh-my-attr], [my-attr-theme], [enh-my-attr-theme]'
3471
- // Matches any element with these attributes
3472
- ```
3473
-
3474
- **Empty withAttrs returns empty string:**
3475
- ```TypeScript
3476
- buildCSSQuery({ spawn: MyClass }, 'div'); // '' (no withAttrs)
3477
- buildCSSQuery({ spawn: MyClass, withAttrs: {} }, 'div'); // '' (empty withAttrs)
3478
- ```
3479
-
3480
- **Deduplication:**
3481
- ```TypeScript
3482
- buildCSSQuery(config, 'div, div, div');
3483
- // Duplicates are removed automatically
3484
- ```
3485
-
3486
- **Whitespace handling:**
3487
- ```TypeScript
3488
- buildCSSQuery(config, ' div , span , p ');
3489
- // Whitespace is trimmed automatically
3490
- ```
3491
-
3492
- ### Use Cases
3493
-
3494
- 1. **Mount Observer Integration**: Find elements that need enhancement
3495
- ```TypeScript
3496
- // Match any element with the attributes
3497
- const matching = buildCSSQuery(enhancementConfig);
3498
- const observer = new MountObserver({
3499
- matching,
3500
- do: (mountedElement) => {
3501
- enhance(mountedElement);
3502
- }
3503
- });
3504
- ```
3505
-
3506
- See [Mount-Observer](https://github.com/bahrus/mount-observer).
3507
-
3508
- 2. **Specific Element Types**: Enhance only certain element types
3509
- ```TypeScript
3510
- const query = buildCSSQuery(config, 'template, script');
3511
- document.querySelectorAll(query).forEach(el => {
3512
- const instance = el.enh.get(config);
3513
- });
3514
- ```
3515
-
3516
- 3. **Conditional Enhancement**: Find elements in specific contexts
3517
- ```TypeScript
3518
- const query = buildCSSQuery(config, '.container > div');
3519
- const elements = document.querySelectorAll(query);
3520
- ```
3521
-
3522
- ### API Reference
3523
-
3524
- ```TypeScript
3525
- function buildCSSQuery(
3526
- config: EnhancementConfig,
3527
- selectors?: string
3528
- ): string
3529
- ```
3530
-
3531
- **Parameters:**
3532
- - `config`: Enhancement configuration with `withAttrs` property
3533
- - `selectors` (optional): Comma-separated CSS selectors (e.g., `'div, span'`)
3534
- - If omitted or empty string, returns attribute selectors without element prefix
3535
- - This matches any element with the specified attributes
3536
-
3537
- **Returns:**
3538
- - CSS query string with cross-product of selectors and attributes
3539
- - If selectors is omitted or empty: returns attribute-only selectors (e.g., `'[attr], [enh-attr]'`)
3540
- - If withAttrs is missing or empty: returns empty string
3541
-
3542
- **Throws:**
3543
- - Error if template variables have circular references
3544
- - Error if template variables reference undefined keys
3545
-
3546
- ### Performance Notes
3547
-
3548
- - The function is synchronous and fast
3549
- - Resulting queries can be long with many attributes, but CSS engines handle this efficiently
3550
- - Queries are deduplicated automatically
3551
- - Consider caching the result if calling repeatedly with the same config
3552
-
3553
- <!--
3554
-
3555
- ### Complete Example
3556
-
3557
- ```TypeScript
3558
- // HTML: <user-card
3559
- // data-config='{"theme":"dark"}'
3560
- // data-config-name="Alice"
3561
- // data-config-age="30"
3562
- // data-config-active
3563
- // ></user-card>
3564
-
3565
- const element = document.querySelector('user-card');
3566
- const result = parseWithAttrs(element, {
3567
- base: 'data-config',
3568
- _base: {
3569
- mapsTo: 'settings',
3570
- instanceOf: 'Object'
3571
- },
3572
- name: '${base}-name',
3573
- age: '${base}-age',
3574
- _age: {
3575
- instanceOf: 'Number',
3576
- mapsTo: 'userAge'
3577
- },
3578
- active: '${base}-active',
3579
- _active: {
3580
- instanceOf: 'Boolean',
3581
- mapsTo: 'isActive'
3582
- }
3583
- });
3584
-
3585
- console.log(result);
3586
- // {
3587
- // settings: { theme: 'dark' },
3588
- // name: 'Alice',
3589
- // userAge: 30,
3590
- // isActive: true
3591
- // }
3592
- ```
3593
-
3594
- -->
3595
2463
 
3596
2464
  ## Resolving and Assigning with `assignFrom`
3597
2465
 
@@ -3824,16 +2692,18 @@ await assignFromAsync(myElement, {
3824
2692
 
3825
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.
3826
2694
 
3827
- 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.
3828
2698
 
3829
2699
  **Handler aliases:** The `handlers` option also accepts built-in names as values, allowing you to define short aliases (including emoji) for concise configs:
3830
2700
 
3831
2701
  ```JavaScript
3832
2702
  import { builtInEmoji } from 'assign-gingerly/builtInEmoji.js';
3833
- // { '📦': 'builtIns.lazyLoad', '🎚️': 'builtIns.lazyLoadSwitch', '🔗': 'builtIns.join', '🏷️': 'builtIns.microDataJoin', '📋': 'builtIns.manageTemplateList' }
2703
+ // { '📦': 'builtIns.lazyLoad', '🎚️': 'builtIns.lazyLoadSwitch', '🏷️': 'builtIns.microDataJoin', '📋': 'builtIns.manageTemplateList' }
3834
2704
 
3835
2705
  assignFrom(target, {
3836
- '?.textContent =>': { do: '🔗', get: { value: ['?.first', ' ', '?.last'] } }
2706
+ '?.el =>': { do: '🏷️', get: { template: [...] } }
3837
2707
  }, { from: vm, handlers: builtInEmoji });
3838
2708
 
3839
2709
  // Or define your own:
@@ -4093,9 +2963,15 @@ await assignFromAsync(document.body, {
4093
2963
  - Mixed `do` values — fully supported, each handler is looked up independently.
4094
2964
  - Error handling — fail-fast. If a handler throws, remaining handlers are skipped.
4095
2965
 
4096
- ### 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 (` =&`)
4097
2971
 
4098
- 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.
4099
2975
 
4100
2976
  ```JavaScript
4101
2977
  const vm = {
@@ -4104,11 +2980,8 @@ const vm = {
4104
2980
  };
4105
2981
 
4106
2982
  assignFrom(oElement, {
4107
- '?.textContent =>': {
4108
- do: 'builtIns.join',
4109
- get: {
4110
- value: ['?.lastName', ', ', '?.firstName']
4111
- }
2983
+ '?.textContent =&': {
2984
+ join: ['?.lastName', ', ', '?.firstName']
4112
2985
  }
4113
2986
  }, { from: vm });
4114
2987
 
@@ -4117,11 +2990,11 @@ assignFrom(oElement, {
4117
2990
 
4118
2991
  **How it works:**
4119
2992
 
4120
- 1. The `get.value` array is resolved by `getValues` — `?.` path strings are replaced with actual values from `options.from`.
4121
- 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.
4122
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.
4123
- 4. Remaining elements are joined with the separator (default: `''`, empty string).
4124
- 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.
4125
2998
 
4126
2999
  **Optional segments with nested arrays:**
4127
3000
 
@@ -4133,11 +3006,8 @@ const vm = {
4133
3006
  };
4134
3007
 
4135
3008
  assignFrom(oElement, {
4136
- '?.textContent =>': {
4137
- do: 'builtIns.join',
4138
- get: {
4139
- value: ['?.lastName', [', ', '?.middleName'], ', ', '?.firstName']
4140
- }
3009
+ '?.textContent =&': {
3010
+ join: ['?.lastName', [', ', '?.middleName'], ', ', '?.firstName']
4141
3011
  }
4142
3012
  }, { from: vm });
4143
3013
 
@@ -4152,21 +3022,16 @@ assignFrom(oElement, {
4152
3022
 
4153
3023
  ```JavaScript
4154
3024
  assignFrom(oElement, {
4155
- '?.textContent =>': {
4156
- do: 'builtIns.join',
4157
- get: {
4158
- value: ['?.firstName', '?.lastName'],
4159
- separator: ' | '
4160
- }
3025
+ '?.textContent =&': {
3026
+ join: ['?.firstName', '?.lastName'],
3027
+ separator: ' | '
4161
3028
  }
4162
3029
  }, { from: vm });
4163
3030
 
4164
3031
  // oElement.textContent = 'Helaena | Targaryen'
4165
3032
  ```
4166
3033
 
4167
- **Return-value protocol:**
4168
-
4169
- 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.
4170
3035
 
4171
3036
  ### Built-in handler: `builtIns.microDataJoin`
4172
3037
 
@@ -4228,7 +3093,7 @@ Produces:
4228
3093
 
4229
3094
  **Optional segments (nested arrays):**
4230
3095
 
4231
- 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:
4232
3097
 
4233
3098
  ```JavaScript
4234
3099
  get: {
@@ -4372,7 +3237,7 @@ assignFrom(element, {
4372
3237
 
4373
3238
  ## Typed Path Authoring with `paths`, `sp`, and `md`
4374
3239
 
4375
- 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`.
4376
3241
 
4377
3242
  ```TypeScript
4378
3243
  import { paths, sp } from 'assign-gingerly/DX/paths.js';
@@ -4389,11 +3254,8 @@ const $ = paths<Person>();
4389
3254
  // sp produces: ['?.lastName', ', ', '?.firstName']
4390
3255
  // with full autocomplete on $.lastName, $.firstName, etc.
4391
3256
  export default {
4392
- '?.textContent =>': {
4393
- do: 'builtIns.join',
4394
- get: {
4395
- value: sp`${$.lastName}, ${$.firstName}`
4396
- }
3257
+ '?.textContent =&': {
3258
+ join: sp`${$.lastName}, ${$.firstName}`
4397
3259
  }
4398
3260
  };
4399
3261
  ```
@@ -4401,9 +3263,9 @@ export default {
4401
3263
  **How `paths` works:**
4402
3264
 
4403
3265
  - `paths<T>()` creates a deeply-proxied object typed as `T`
4404
- - Every property access returns a deeper proxy (e.g., `$.address.city`)
3266
+ - Every property access returns a deeper proxy (e.g., `$.address.city`)
4405
3267
  - Capitalized reserved tokens like `.Each` and `.EqNot` normalize into command syntax
4406
- - `.Path` extracts the `?.`-prefixed string: `$.address.city.Path` → `'?.address?.city'`
3268
+ - `.Path` extracts the `?.`-prefixed string: `$.address.city.Path` → `'?.address?.city'`
4407
3269
  - Inside `sp` template literals, `.Path` is not needed — proxy objects are auto-detected
4408
3270
 
4409
3271
  **How `sp` works:**
@@ -4472,7 +3334,7 @@ md`${$.firstName} ${{ prop: 'birthDate', val: $.birthDT, format: 'long' }}`
4472
3334
 
4473
3335
  | Tag | Output for proxy interpolation | Use with |
4474
3336
  |-----|-------------------------------|----------|
4475
- | `sp` | `'?.firstName'` (path string) | `builtIns.join` |
3337
+ | `sp` | `'?.firstName'` (path string) | the `join` sync op |
4476
3338
  | `md` | `{ prop: 'firstName', val: '?.firstName' }` | `builtIns.microDataJoin` |
4477
3339
 
4478
3340
  Both auto-detect path proxies (no `.Path` needed inside template literals) and preserve nested arrays for optional segments.
@@ -5763,43 +4625,9 @@ Because the real class is resolved before the getter is installed, the first acc
5763
4625
 
5764
4626
  ### Attribute parsing with `withAttrs`
5765
4627
 
5766
- Features can declare attribute patterns to parse element attributes into `initVals`:
5767
-
5768
- ```JavaScript
5769
- customElements.assignFeatures(ClubMember, {
5770
- photoTaker: {
5771
- spawn: PhotoTakerImpl,
5772
- withAttrs: {
5773
- base: 'photo',
5774
- resolution: '${base}-resolution',
5775
- format: '${base}-format'
5776
- }
5777
- }
5778
- });
5779
- ```
5780
-
5781
- ```HTML
5782
- <club-member photo-resolution="4k" photo-format="png"></club-member>
5783
- ```
5784
-
5785
- This parses into `initVals = { resolution: '4k', format: 'png' }`. By default, non-underscore keys are assumed to be strings with `mapsTo` equal to the key name. The `_key` form is only needed to override defaults (e.g., parse as Number, map to a different property name, use a custom parser):
5786
-
5787
- ```JavaScript
5788
- withAttrs: {
5789
- base: 'photo',
5790
- resolution: '${base}-resolution',
5791
- // Override: parse as Number instead of String
5792
- _resolution: { instanceOf: 'Number', mapsTo: 'resolutionPx' },
5793
- format: '${base}-format'
5794
- // No _format needed — defaults to String, mapsTo: 'format'
5795
- }
5796
- ```
5797
-
5798
- **Merge priority (lowest to highest):**
5799
- 1. Attribute-parsed values (`withAttrs`)
5800
- 2. Programmatic `initVals` (from `captureFeatureInitVals`)
4628
+ Features can declare attribute patterns that are parsed from the host element into `initVals`. Unlike enhancements, feature attributes are always read unprefixed. Programmatic `initVals` take precedence over parsed values.
5801
4629
 
5802
- Attributes are always unprefixed for features (no `enh-` prefix). The same `parseWithAttrs` function used by enhancements is reused here.
4630
+ See [docs/withAttrs.md](docs/withAttrs.md#attribute-patterns-for-custom-element-features) for the full guide and examples.
5803
4631
 
5804
4632
  ### Shared context with `getSharedContext`
5805
4633