assign-gingerly 0.0.84 → 0.0.85

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
@@ -1641,7 +1641,7 @@ interface EnhancementConfig<T, TObj = Element> {
1641
1641
  }
1642
1642
  ```
1643
1643
 
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.
1644
+ 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
1645
 
1646
1646
  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
1647
 
@@ -2443,1155 +2443,22 @@ assignGingerly(element, { [enhSymbol]: 'test' }, { registry });
2443
2443
 
2444
2444
  </details>
2445
2445
 
2446
- ## Parsing Attributes with `parseWithAttrs`
2446
+ ## Parsing HTML Attributes with `withAttrs`
2447
2447
 
2448
- The `parseWithAttrs` function provides a declarative way to read and parse HTML attributes and pass the parsed values into the spawned enhancement constructor.
2448
+ 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
2449
 
2450
- ### Automatic Integration with Enhancement Spawning
2450
+ See [docs/withAttrs.md](docs/withAttrs.md) for the full reference, including:
2451
2451
 
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>
2452
+ - `parseWithAttrs` usage and automatic integration with `enh.get()` / `assignGingerly()`
2453
+ - The `enh-` prefix and `allowUnprefixed` rules
2454
+ - `AttrPatterns` / `AttrConfig` options (`mapsTo`, `instanceOf`, `parser`, `valIfNull`, `parseCache`, `sourceOfTruth`)
2455
+ - `buildCSSQuery` for generating CSS selectors from `withAttrs`
2456
+ - Using `withAttrs` with custom element features
3332
2457
 
3333
2458
  ## Building CSS Queries with `buildCSSQuery`
3334
2459
 
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
3380
-
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], ...'
2460
+ 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).
3421
2461
 
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
2462
 
3596
2463
  ## Resolving and Assigning with `assignFrom`
3597
2464
 
@@ -5763,43 +4630,9 @@ Because the real class is resolved before the getter is installed, the first acc
5763
4630
 
5764
4631
  ### Attribute parsing with `withAttrs`
5765
4632
 
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`)
4633
+ 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
4634
 
5802
- Attributes are always unprefixed for features (no `enh-` prefix). The same `parseWithAttrs` function used by enhancements is reused here.
4635
+ See [docs/withAttrs.md](docs/withAttrs.md#attribute-patterns-for-custom-element-features) for the full guide and examples.
5803
4636
 
5804
4637
  ### Shared context with `getSharedContext`
5805
4638