assign-gingerly 0.0.52 → 0.0.54

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
@@ -1,4 +1,4 @@
1
- # assign-gingerly and assign-tentatively
1
+ # assign-gingerly, assign-tentatively, and assign-from
2
2
 
3
3
  [![Playwright Tests](https://github.com/bahrus/assign-gingerly/actions/workflows/CI.yml/badge.svg?branch=baseline)](https://github.com/bahrus/assign-gingerly/actions/workflows/CI.yml)
4
4
  [![NPM version](https://badge.fury.io/js/assign-gingerly.png)](http://badge.fury.io/js/assign-gingerly)
@@ -9,7 +9,7 @@
9
9
 
10
10
  ## Introduction
11
11
 
12
- This package starts out innocently enough -- it provides two utility functions for carefully merging one object into another. This is a primitive sorely lacking in the web, and this package is a polyfill for what we (me with a lot of help from AI) would like to see built into the platform. We make no apologies about adding these features directly to the underlying API's, as it is part of a proposal which is sitting there gathering dust, with no apparent alternatives under consideration. In particular the reference:
12
+ This package starts out innocently enough -- it provides three utility functions for carefully merging one object into another. This is a primitive sorely lacking in the web, and this package is a polyfill for what we (me with a lot of help from AI) would like to see built into the platform. We make no apologies about adding these features directly to the underlying API's, as it is part of a proposal which is sitting there gathering dust, with no apparent alternatives under consideration. In particular the reference:
13
13
 
14
14
  ```JavaScript
15
15
  import 'assign-gingerly/object-extension.js';
@@ -17,7 +17,7 @@ import 'assign-gingerly/object-extension.js';
17
17
 
18
18
  has the "side effect" of enhancing the platform API in a way that this proposal can only hope the platform chooses to adopt in the future (or some variation).
19
19
 
20
- One can achieve the same functionality with a little more work, and "playing nicer" with the platform by importing assign-gingerly.js and/or assign-tentatively.js, which has no such side effects.
20
+ One can achieve the same functionality with a little more work, and "playing nicer" with the platform by importing assign-gingerly.js, assign-tentatively.js, and/or assignFrom.js, which has no such side effects.
21
21
 
22
22
  ## Object Extension Pattern
23
23
 
@@ -39,7 +39,7 @@ So in our view this package helps fill the void left by not supporting the "is"
39
39
 
40
40
  Anyway, let's start out detailing the more innocent features of this package / polyfill.
41
41
 
42
- The two utility functions are:
42
+ The three utility functions are:
43
43
 
44
44
  ## assignGingerly
45
45
 
@@ -56,6 +56,22 @@ The second fundamental utility function is:
56
56
 
57
57
  assignTentatively provides a far more limited subset of functionality compared to assignGingerly. The tradeoff is that assignTentatively can do something important assignGingerly cannot do -- be "reversed". This can be quite useful for some scenarios. Think of how css "turns on" visual effects while conditions are met, then reverts to how things were before the conditions were met when the conditions are no longer met, as if nothing happened. Another example is allowing user edits to be rolled back as they repeatedly hit "ctrl+z".
58
58
 
59
+ The third fundamental utility function is:
60
+
61
+ ## assignFrom
62
+
63
+ assignFrom builds on assignGingerly by adding a resolution step: RHS values that are `?.`-prefixed path strings are resolved against a source object before assignment. This enables a declarative, data-driven pattern where a view model (or any source) feeds values into a target through path expressions — replacing imperative property lookups with a single configuration object.
64
+
65
+ assignFrom adds support for:
66
+
67
+ 1. Resolving RHS path strings against a source object (`from`).
68
+ 2. Protocol resolution (`globalThis://`, `localStorage://`, custom protocols).
69
+ 3. Handler plugins via the ` =>` operator for custom logic (template instantiation, DOM manipulation, etc.).
70
+ 4. Looped substitution with `where_x_in` / `where_y_in` / `where_z_in` for expanding template patterns into multiple concrete assignments.
71
+ 5. Spread merging via the `"..."` key.
72
+
73
+ All of assignGingerly's features (nested paths, `withMethods`, `aka`, `@each`, `@eachTime`, registry, etc.) are inherited.
74
+
59
75
  ## Example 1 - assignGingerly as a "superset" of Object.assign:
60
76
 
61
77
  ```TypeScript
@@ -3425,6 +3441,691 @@ The `"..."` key causes the resolved object to be merged (spread) into the result
3425
3441
 
3426
3442
  **Note:** Both `resolveValues` and `assignFrom` are async (return Promises) to support async protocol handlers (e.g., IndexedDB, fetch). For patterns without protocols, the async overhead is negligible.
3427
3443
 
3444
+ ## assignFrom Handlers (` =>` operator)
3445
+
3446
+ `assignFrom` supports a plugin system via the ` =>` operator. When a LHS key ends with ` =>`, instead of normal assignment, a handler class is invoked to perform custom logic (DOM manipulation, template instantiation, etc.).
3447
+
3448
+ ### Defining and using a handler
3449
+
3450
+ Handlers are provided via the `handlers` option — scoped to each `assignFrom` call:
3451
+
3452
+ ```JavaScript
3453
+ import { assignFrom } from 'assign-gingerly/assignFrom.js';
3454
+
3455
+ class MyListHandler {
3456
+ constructor(config) {
3457
+ this.config = config;
3458
+ }
3459
+
3460
+ async assign(lhsTarget, resolvedParams, options) {
3461
+ const { list, template } = resolvedParams;
3462
+ // Custom logic: e.g., clone template for each item in list
3463
+ }
3464
+ }
3465
+
3466
+ await assignFrom(myElement, {
3467
+ '?.querySelector?.tbody =>': {
3468
+ do: 'my-list',
3469
+ resolve: {
3470
+ list: '?.rankings',
3471
+ template: 'globalThis://myTemplate'
3472
+ }
3473
+ }
3474
+ }, {
3475
+ withMethods: ['querySelector'],
3476
+ from: viewModel,
3477
+ protocols: { globalThis: k => globalThis[k] },
3478
+ handlers: {
3479
+ 'my-list': MyListHandler, // class constructor
3480
+ }
3481
+ });
3482
+ ```
3483
+
3484
+ Handlers can also be specified as import paths (dynamically loaded on demand):
3485
+
3486
+ ```JavaScript
3487
+ await assignFrom(myElement, {
3488
+ '?.querySelector?.tbody =>': {
3489
+ do: 'my-list',
3490
+ resolve: { list: '?.rankings', template: 'globalThis://myTemplate' }
3491
+ }
3492
+ }, {
3493
+ from: viewModel,
3494
+ withMethods: ['querySelector'],
3495
+ protocols: { globalThis: k => globalThis[k] },
3496
+ handlers: {
3497
+ 'my-list': './handlers/my-list.js', // relative path
3498
+ 'vendor-chart': 'chart-package/handler.js', // bare specifier (import map)
3499
+ }
3500
+ });
3501
+ ```
3502
+
3503
+ 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.
3504
+
3505
+ Built-in handlers (`builtIns.lazyLoad`, `builtIns.join`, etc.) auto-load without needing to be listed in `handlers`.
3506
+
3507
+ **How it works:**
3508
+
3509
+ 1. Keys ending with ` =>` are separated from normal keys.
3510
+ 2. Normal keys are processed via `resolveValues` + `assignGingerly` as usual.
3511
+ 3. For handler keys: the LHS path is evaluated (with `withMethods` support) to get the target.
3512
+ 4. The `resolve` map is processed through `resolveValues` — paths (`?.`), protocols (`globalThis://`), and literals are all resolved.
3513
+ 5. The handler class (looked up via `do` in `options.handlers`, then built-in auto-load) is instantiated with the full config, then `assign(target, resolvedParams, options)` is called.
3514
+
3515
+ **The `resolve` map supports:**
3516
+ - `?.` paths — resolved against `options.from`
3517
+ - Protocol strings — resolved via `options.protocols`
3518
+ - Plain literals — passed through unchanged
3519
+
3520
+ ### Built-in handler: `builtIns.lazyLoad`
3521
+
3522
+ Conditionally loads (clones) a template into a target element. Uses comment markers to track inserted content and supports show/hide/remove modes. Built-in handlers are auto-loaded on demand — no explicit import is needed.
3523
+
3524
+ ```JavaScript
3525
+ await assignFrom(document.body, {
3526
+ '?.querySelector?..mainView =>': {
3527
+ do: 'builtIns.lazyLoad',
3528
+ resolve: {
3529
+ if: '?.isVisible',
3530
+ instantiate: 'globalThis://myTemplate',
3531
+ }
3532
+ }
3533
+ }, { withMethods: ['querySelector'], from: myVM, protocols: { globalThis: k => globalThis[k] } });
3534
+ ```
3535
+
3536
+ **Resolve parameters:**
3537
+
3538
+ | Parameter | Type | Description |
3539
+ |-----------|------|-------------|
3540
+ | `if` | boolean | Show content when truthy, hide/remove when falsy |
3541
+ | `instantiate` | HTMLTemplateElement | The template to clone (typically resolved via globalThis protocol) |
3542
+ | `method` | string | `'appendChild'` (default) or `'prepend'` — where to place markers |
3543
+ | `forget` | boolean | If true, removes nodes entirely when `if` is false (default: hides with `hidden` attribute) |
3544
+
3545
+ **Behavior:**
3546
+
3547
+ - **First load (`if` = true, no existing content):** Clones the template, inserts content between `<!--?start name="X"-->` / `<!--?end-->` comment markers.
3548
+ - **Show (content exists but hidden):** Removes `hidden` attribute from elements between markers.
3549
+ - **Hide (`if` = false, `forget` = false):** Adds `hidden` attribute to elements between markers.
3550
+ - **Remove (`if` = false, `forget` = true):** Removes nodes between markers entirely. Markers persist for re-insertion if `if` becomes true again.
3551
+
3552
+ This is useful for conditional rendering, routing, and lazy-loading views.
3553
+
3554
+ ### View Transitions
3555
+
3556
+ Both `builtIns.lazyLoad` and `builtIns.lazyLoadSwitch` support animated transitions via the [View Transition API](https://developer.mozilla.org/docs/Web/API/View_Transition_API). Enable with `transitional: true` in the resolve map:
3557
+
3558
+ ```JavaScript
3559
+ await assignFrom(container, {
3560
+ '?.querySelector?..outlet =>': {
3561
+ do: 'builtIns.lazyLoad',
3562
+ resolve: {
3563
+ if: '?.isVisible',
3564
+ instantiate: 'globalThis://myTemplate',
3565
+ transitional: true,
3566
+ }
3567
+ }
3568
+ }, { withMethods: ['querySelector'], from: vm, protocols: { globalThis: k => globalThis[k] } });
3569
+ ```
3570
+
3571
+ **How it works:**
3572
+
3573
+ 1. When `transitional: true`, DOM mutations (show/hide/clone) are wrapped in `document.startViewTransition()`.
3574
+ 2. Instead of the `hidden` attribute, a CSS class (`.ag-hide` by default) is toggled for visibility.
3575
+ 3. The actual animation is entirely CSS-driven — you control timing, easing, and effects via your own styles.
3576
+ 4. If `document.startViewTransition` is unavailable (older browsers), the handler falls back to direct DOM mutation with no animation.
3577
+
3578
+ **CSS customization:**
3579
+
3580
+ The handler injects a minimal default style (`.ag-hide { display: none }`) once per rootNode. Override it with your own for animated transitions:
3581
+
3582
+ ```css
3583
+ /* Fade + slide transition */
3584
+ .ag-hide {
3585
+ opacity: 0;
3586
+ transform: translateY(-10px);
3587
+ transition: opacity 0.5s ease, transform 0.5s ease;
3588
+ }
3589
+
3590
+ /* View transition keyframes (for cross-fade between old/new states) */
3591
+ ::view-transition-old(root) {
3592
+ animation: fade-out 0.5s ease;
3593
+ }
3594
+ ::view-transition-new(root) {
3595
+ animation: fade-in 0.5s ease;
3596
+ }
3597
+ ```
3598
+
3599
+ **Custom hide class:**
3600
+
3601
+ Use the `hideClass` resolve parameter to use a different CSS class name:
3602
+
3603
+ ```JavaScript
3604
+ resolve: {
3605
+ if: '?.isVisible',
3606
+ instantiate: 'globalThis://myTemplate',
3607
+ transitional: true,
3608
+ hideClass: 'my-custom-hide', // default: 'ag-hide'
3609
+ }
3610
+ ```
3611
+
3612
+ **Concurrency handling:**
3613
+
3614
+ - Rapid toggling (e.g., clicking a button repeatedly) is handled gracefully.
3615
+ - An in-flight transition is cancelled (`skipTransition()`) before starting a new one.
3616
+ - Duplicate show/hide requests while one is pending are ignored (re-entry protection).
3617
+
3618
+ **Routing example with transitions:**
3619
+
3620
+ ```JavaScript
3621
+ await assignFrom(container, {
3622
+ '?.querySelector?..routerOutlet =>': [
3623
+ { do: 'builtIns.lazyLoadSwitch', resolve: { lhs: '?.route', rhs: 'home', instantiate: 'globalThis://homeView', transitional: true } },
3624
+ { do: 'builtIns.lazyLoadSwitch', resolve: { lhs: '?.route', rhs: 'settings', instantiate: 'globalThis://settingsView', transitional: true } },
3625
+ { do: 'builtIns.lazyLoadSwitch', resolve: { lhs: '?.route', rhs: 'profile', instantiate: 'globalThis://profileView', transitional: true } },
3626
+ ]
3627
+ }, { withMethods: ['querySelector'], from: router, protocols: { globalThis: k => globalThis[k] } });
3628
+ ```
3629
+
3630
+ **Shared utility for external consumers (`be-switched`, etc.):**
3631
+
3632
+ The transition coordination logic is exported as a reusable utility:
3633
+
3634
+ ```JavaScript
3635
+ import { withTransition, ensureHideStyle } from 'assign-gingerly/transitionHelper.js';
3636
+
3637
+ // Inject the hide class style once per rootNode
3638
+ ensureHideStyle(myShadowRoot);
3639
+
3640
+ // Wrap any DOM mutation with view transition coordination
3641
+ withTransition(markerNode, 'show', true, () => {
3642
+ element.classList.remove('ag-hide');
3643
+ });
3644
+
3645
+ withTransition(markerNode, 'hide', true, () => {
3646
+ element.classList.add('ag-hide');
3647
+ });
3648
+ ```
3649
+
3650
+ See the visual demo at `demos/view-transition-demo.html`.
3651
+
3652
+ ### Multiple Handlers
3653
+
3654
+ When the RHS of a ` =>` key is an array, each element is treated as a separate handler config and they are executed sequentially (awaiting each before proceeding to the next). All handlers share the same LHS target.
3655
+
3656
+ ```JavaScript
3657
+ await assignFrom(document.body, {
3658
+ '?.querySelector?..mainView =>': [
3659
+ {
3660
+ do: 'builtIns.lazyLoad',
3661
+ resolve: {
3662
+ if: '?.isVisible',
3663
+ instantiate: 'globalThis://viewTemplate',
3664
+ }
3665
+ },
3666
+ {
3667
+ do: 'applyTheme',
3668
+ resolve: {
3669
+ theme: '?.currentTheme'
3670
+ }
3671
+ }
3672
+ ]
3673
+ }, { withMethods: ['querySelector'], from: myVM, protocols: { globalThis: k => globalThis[k] } });
3674
+ ```
3675
+
3676
+ **Behavior:**
3677
+
3678
+ - Empty array — silent no-op.
3679
+ - Single-element array — identical to passing the object directly.
3680
+ - Nested arrays — throws an error (not supported).
3681
+ - Mixed `do` values — fully supported, each handler is looked up independently.
3682
+ - Error handling — fail-fast. If a handler throws, remaining handlers are skipped.
3683
+
3684
+ ## Looped Substitution (`where_x_in`, `where_y_in`, `where_z_in`)
3685
+
3686
+ `assignFrom` supports expanding template patterns into multiple concrete assignments via variable substitution. Placeholders `${x}`, `${y}`, and `${z}` in pattern keys and string values are replaced with each value from the corresponding option array.
3687
+
3688
+ ```JavaScript
3689
+ const vm = {
3690
+ firstName: 'Monkey',
3691
+ lastName: 'Luffy'
3692
+ };
3693
+
3694
+ await assignFrom(myForm, {
3695
+ '?.[name="${x}"]': '?.${x}'
3696
+ }, {
3697
+ from: vm,
3698
+ withMethods: ['querySelector'],
3699
+ where_x_in: ['firstName', 'lastName']
3700
+ });
3701
+
3702
+ // Expands to the equivalent of:
3703
+ // '?.[name="firstName"]': '?.firstName' → querySelector('[name="firstName"]').value = 'Monkey'
3704
+ // '?.[name="lastName"]': '?.lastName' → querySelector('[name="lastName"]').value = 'Luffy'
3705
+ ```
3706
+
3707
+ **How it works:**
3708
+
3709
+ 1. Before any other processing, `assignFrom` checks for `where_x_in`, `where_y_in`, and `where_z_in` in options.
3710
+ 2. For each pattern entry whose key or value contains the placeholder (e.g., `${x}`), the entry is expanded — one copy per value in the array.
3711
+ 3. Substitution applies to both LHS keys and all RHS string values (including nested objects like handler `resolve` maps).
3712
+ 4. Multiple variables produce a **cartesian product**: x is expanded first, then y, then z. Result count = x.length × y.length × z.length.
3713
+ 5. Non-string RHS values pass through untouched.
3714
+
3715
+ **Cartesian expansion with multiple variables:**
3716
+
3717
+ ```JavaScript
3718
+ await assignFrom(grid, {
3719
+ '?.querySelector?.[data-row="${x}"][data-col="${y}"]?.textContent': '${x}-${y}'
3720
+ }, {
3721
+ from: {},
3722
+ withMethods: ['querySelector'],
3723
+ where_x_in: ['1', '2'],
3724
+ where_y_in: ['A', 'B']
3725
+ });
3726
+
3727
+ // Produces 4 entries (2 × 2):
3728
+ // '?.[data-row="1"][data-col="A"]' → '1-A'
3729
+ // '?.[data-row="1"][data-col="B"]' → '1-B'
3730
+ // '?.[data-row="2"][data-col="A"]' → '2-A'
3731
+ // '?.[data-row="2"][data-col="B"]' → '2-B'
3732
+ ```
3733
+
3734
+ **With handler ( =>) keys:**
3735
+
3736
+ Substitution applies inside handler `resolve` maps too:
3737
+
3738
+ ```JavaScript
3739
+ await assignFrom(container, {
3740
+ '?.querySelector?..${x}View =>': {
3741
+ do: 'builtIns.lazyLoad',
3742
+ resolve: {
3743
+ if: '?.${x}Visible',
3744
+ instantiate: 'globalThis://${x}Template'
3745
+ }
3746
+ }
3747
+ }, {
3748
+ from: vm,
3749
+ withMethods: ['querySelector'],
3750
+ protocols: { globalThis: k => globalThis[k] },
3751
+ where_x_in: ['home', 'settings', 'profile']
3752
+ });
3753
+ ```
3754
+
3755
+ **Edge cases:**
3756
+
3757
+ - Empty array (`where_x_in: []`) — template entries produce nothing (silent no-op).
3758
+ - Missing option — if a pattern contains `${x}` but `where_x_in` is not provided, the literal `${x}` remains in the string.
3759
+ - Entries without placeholders — passed through unchanged.
3760
+
3761
+ ### Built-in handler: `builtIns.join`
3762
+
3763
+ 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.
3764
+
3765
+ ```JavaScript
3766
+ const vm = {
3767
+ lastName: 'Targaryen',
3768
+ firstName: 'Helaena'
3769
+ };
3770
+
3771
+ await assignFrom(oElement, {
3772
+ '?.textContent =>': {
3773
+ do: 'builtIns.join',
3774
+ resolve: {
3775
+ value: ['?.lastName', ', ', '?.firstName']
3776
+ }
3777
+ }
3778
+ }, { from: vm });
3779
+
3780
+ // oElement.textContent = 'Targaryen, Helaena'
3781
+ ```
3782
+
3783
+ **How it works:**
3784
+
3785
+ 1. The `resolve.value` array is resolved by `resolveValues` — `?.` path strings are replaced with actual values from `options.from`.
3786
+ 2. Top-level `null`/`undefined` values are filtered out.
3787
+ 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.
3788
+ 4. Remaining elements are joined with the separator (default: `''`, empty string).
3789
+ 5. The joined string is returned and assigned to the LHS path.
3790
+
3791
+ **Optional segments with nested arrays:**
3792
+
3793
+ ```JavaScript
3794
+ const vm = {
3795
+ lastName: 'Targaryen',
3796
+ middleName: undefined, // not present
3797
+ firstName: 'Helaena'
3798
+ };
3799
+
3800
+ await assignFrom(oElement, {
3801
+ '?.textContent =>': {
3802
+ do: 'builtIns.join',
3803
+ resolve: {
3804
+ value: ['?.lastName', [', ', '?.middleName'], ', ', '?.firstName']
3805
+ }
3806
+ }
3807
+ }, { from: vm });
3808
+
3809
+ // middleName is undefined → sub-array [', ', undefined] is dropped entirely
3810
+ // oElement.textContent = 'Targaryen, Helaena'
3811
+
3812
+ // If middleName were 'D':
3813
+ // oElement.textContent = 'Targaryen, D, Helaena'
3814
+ ```
3815
+
3816
+ **Custom separator:**
3817
+
3818
+ ```JavaScript
3819
+ await assignFrom(oElement, {
3820
+ '?.textContent =>': {
3821
+ do: 'builtIns.join',
3822
+ separator: ' | ',
3823
+ resolve: {
3824
+ value: ['?.firstName', '?.lastName']
3825
+ }
3826
+ }
3827
+ }, { from: vm });
3828
+
3829
+ // oElement.textContent = 'Helaena | Targaryen'
3830
+ ```
3831
+
3832
+ **Return-value protocol:**
3833
+
3834
+ 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.
3835
+
3836
+ ### Built-in handler: `builtIns.microDataJoin`
3837
+
3838
+ Renders a template array as semantic microdata-annotated DOM. Each dynamic value gets an appropriate HTML element based on its JavaScript type, with `itemprop` set to the property name.
3839
+
3840
+ ```JavaScript
3841
+ const vm = {
3842
+ lastName: 'Targaryen',
3843
+ firstName: 'Helaena',
3844
+ birthDT: new Date('July 1, 109'),
3845
+ age: 21,
3846
+ isHappy: false
3847
+ };
3848
+
3849
+ await assignFrom(oSection, {
3850
+ '?.querySelector?.div =>': {
3851
+ do: 'builtIns.microDataJoin',
3852
+ resolve: {
3853
+ template: [
3854
+ { prop: 'firstName', val: '?.firstName' },
3855
+ ' ',
3856
+ { prop: 'lastName', val: '?.lastName' },
3857
+ ' who was born on ',
3858
+ { prop: 'birthDT', val: '?.birthDT' },
3859
+ ' is ',
3860
+ { prop: 'age', val: '?.age' },
3861
+ ' years old'
3862
+ ]
3863
+ }
3864
+ }
3865
+ }, { from: vm, withMethods: ['querySelector'] });
3866
+ ```
3867
+
3868
+ Produces:
3869
+
3870
+ ```html
3871
+ <div itemscope>
3872
+ <!--?start name="microDataJoin"-->
3873
+ <span itemprop="firstName">Helaena</span>
3874
+
3875
+ <span itemprop="lastName">Targaryen</span>
3876
+ who was born on
3877
+ <time itemprop="birthDT" datetime="0109-07-01T...">7/1/0109</time>
3878
+ is
3879
+ <data itemprop="age" value="21">21</data>
3880
+ years old
3881
+ <!--?end-->
3882
+ </div>
3883
+ ```
3884
+
3885
+ **Type → element mapping:**
3886
+
3887
+ | JS Type | HTML Element | Attributes | textContent |
3888
+ |---------|-------------|------------|-------------|
3889
+ | `string` | `<span>` | `itemprop` | the value |
3890
+ | `number` | `<data>` | `itemprop`, `value` (raw) | locale-formatted |
3891
+ | `boolean` | `<data>` | `itemprop`, `value` (true/false) | empty |
3892
+ | `Date` | `<time>` | `itemprop`, `datetime` (ISO) | locale-formatted |
3893
+
3894
+ **Optional segments (nested arrays):**
3895
+
3896
+ 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:
3897
+
3898
+ ```JavaScript
3899
+ resolve: {
3900
+ template: [
3901
+ { prop: 'firstName', val: '?.firstName' },
3902
+ [' ', { prop: 'middleName', val: '?.middleName' }], // dropped if middleName is undefined
3903
+ ' ',
3904
+ { prop: 'lastName', val: '?.lastName' }
3905
+ ]
3906
+ }
3907
+ ```
3908
+
3909
+ **Idempotent updates:**
3910
+
3911
+ Uses comment markers (`<!--?start name="microDataJoin"-->` / `<!--?end-->`) to track rendered content. On first call, creates all elements. On subsequent calls, updates existing elements in place (no re-creation) — `textContent`, `value`, and `datetime` attributes are updated without touching the DOM structure.
3912
+
3913
+ **Authoring with `md` tag:**
3914
+
3915
+ The `md` tagged template literal produces the `{prop, val}` structure from proxy objects — full autocomplete and type safety:
3916
+
3917
+ ```TypeScript
3918
+ import { paths, md } from 'assign-gingerly/paths.js';
3919
+
3920
+ interface Person { firstName: string; lastName: string; birthDT: Date; age: number; }
3921
+ const $ = paths<Person>();
3922
+
3923
+ const template = md`${$.firstName} ${$.lastName} who was born on ${$.birthDT} is ${$.age} years old`;
3924
+ // Produces: [{ prop: 'firstName', val: '?.firstName' }, ' ', { prop: 'lastName', val: '?.lastName' }, ...]
3925
+ ```
3926
+
3927
+ For custom property names or per-segment formatting, pass an explicit object:
3928
+
3929
+ ```TypeScript
3930
+ const template = md`${$.firstName} ${{ prop: 'birthDate', val: $.birthDT, format: 'long' }}`;
3931
+ ```
3932
+
3933
+ ## Typed Path Authoring with `paths`, `sp`, and `md`
3934
+
3935
+ 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`.
3936
+
3937
+ ```TypeScript
3938
+ import { paths, sp } from 'assign-gingerly/paths.js';
3939
+
3940
+ interface Person {
3941
+ firstName?: string;
3942
+ middleName?: string;
3943
+ lastName: string;
3944
+ address: { city: string; zip: string };
3945
+ }
3946
+
3947
+ const $ = paths<Person>();
3948
+
3949
+ // sp produces: ['?.lastName', ', ', '?.firstName']
3950
+ // with full autocomplete on $.lastName, $.firstName, etc.
3951
+ export default {
3952
+ '?.textContent =>': {
3953
+ do: 'builtIns.join',
3954
+ resolve: {
3955
+ value: sp`${$.lastName}, ${$.firstName}`
3956
+ }
3957
+ }
3958
+ };
3959
+ ```
3960
+
3961
+ **How `paths` works:**
3962
+
3963
+ - `paths<T>()` creates a deeply-proxied object typed as `T`
3964
+ - Every property access returns a deeper proxy (e.g., `$.address.city`)
3965
+ - `.path` extracts the `?.`-prefixed string: `$.address.city.path` → `'?.address?.city'`
3966
+ - Inside `sp` template literals, `.path` is not needed — proxy objects are auto-detected
3967
+
3968
+ **How `sp` works:**
3969
+
3970
+ - `sp` (split into parts) is a tagged template literal
3971
+ - It interleaves static string segments with interpolated values
3972
+ - Path proxy objects are automatically converted to `?.`-prefixed strings
3973
+ - Arrays passed as interpolations are preserved as nested arrays (for optional segments)
3974
+
3975
+ **Optional segments in `sp`:**
3976
+
3977
+ ```TypeScript
3978
+ const $ = paths<Person>();
3979
+
3980
+ // Nested array for all-or-nothing segment:
3981
+ const value = sp`${$.lastName}${[', ', $.middleName]}, ${$.firstName}`;
3982
+ // ['?.lastName', [', ', '?.middleName'], ', ', '?.firstName']
3983
+ ```
3984
+
3985
+ **Using `.path` outside of `sp`:**
3986
+
3987
+ When you need the path string in a non-`sp` context (object keys, plain arrays, other expressions):
3988
+
3989
+ ```TypeScript
3990
+ const $ = paths<Person>();
3991
+
3992
+ $.lastName.path // '?.lastName'
3993
+ $.address.city.path // '?.address?.city'
3994
+
3995
+ // As an object key:
3996
+ const pattern = {
3997
+ [$.textContent.path]: '?.firstName' // '?.textContent': '?.firstName'
3998
+ };
3999
+ ```
4000
+
4001
+ **Benefits:**
4002
+
4003
+ - Full IDE autocomplete on property names
4004
+ - Compile-time errors for typos (e.g., `$.lasName` → TS error)
4005
+ - Template literal syntax reads like a natural string template
4006
+ - Output is a plain JSON-serializable array
4007
+ - No runtime overhead beyond initial proxy creation
4008
+
4009
+ **`md` — microdata template tag:**
4010
+
4011
+ The `md` tag produces `{prop, val}` objects for `builtIns.microDataJoin`:
4012
+
4013
+ ```TypeScript
4014
+ import { paths, md } from 'assign-gingerly/paths.js';
4015
+
4016
+ interface Person { firstName: string; lastName: string; birthDT: Date; age: number; }
4017
+ const $ = paths<Person>();
4018
+
4019
+ const template = md`${$.firstName} ${$.lastName} born ${$.birthDT}, age ${$.age}`;
4020
+ // [{ prop: 'firstName', val: '?.firstName' }, ' ', { prop: 'lastName', val: '?.lastName' }, ' born ', { prop: 'birthDT', val: '?.birthDT' }, ', age ', { prop: 'age', val: '?.age' }]
4021
+ ```
4022
+
4023
+ For custom property names or per-segment config, pass an explicit object:
4024
+
4025
+ ```TypeScript
4026
+ md`${$.firstName} ${{ prop: 'birthDate', val: $.birthDT, format: 'long' }}`
4027
+ // [{ prop: 'firstName', val: '?.firstName' }, ' ', { prop: 'birthDate', val: '?.birthDT', format: 'long' }]
4028
+ ```
4029
+
4030
+ **Difference between `sp` and `md`:**
4031
+
4032
+ | Tag | Output for proxy interpolation | Use with |
4033
+ |-----|-------------------------------|----------|
4034
+ | `sp` | `'?.firstName'` (path string) | `builtIns.join` |
4035
+ | `md` | `{ prop: 'firstName', val: '?.firstName' }` | `builtIns.microDataJoin` |
4036
+
4037
+ Both auto-detect path proxies (no `.path` needed inside template literals) and preserve nested arrays for optional segments.
4038
+
4039
+ ## Cached Element Resolution with `#[x]` and `withIds`
4040
+
4041
+ `assignFrom` supports cached element references via the `#[x]` syntax in LHS keys. This provides near-zero-cost repeated access to DOM elements (~10ns via WeakRef) instead of expensive `querySelector` calls (~3,000-17,000ns for class selectors at scale).
4042
+
4043
+ ```TypeScript
4044
+ import { assignFrom } from 'assign-gingerly/assignFrom.js';
4045
+
4046
+ await assignFrom(document.body, {
4047
+ '#[main]?.textContent': '?.greeting',
4048
+ '#[main] =>': {
4049
+ do: 'builtIns.lazyLoad',
4050
+ resolve: { if: '?.showContent', instantiate: 'globalThis://myTemplate' }
4051
+ }
4052
+ }, {
4053
+ from: viewModel,
4054
+ withIds: {
4055
+ main: { qry: '.mainView' } // find by class, auto-assign ID, cache
4056
+ }
4057
+ });
4058
+ ```
4059
+
4060
+ **How it works:**
4061
+
4062
+ 1. On first encounter of `#[main]`, the element is found via `querySelector('.mainView')` against the target.
4063
+ 2. If the element doesn't have an `id` attribute, one is auto-generated (`_ag0`, `_ag1`, etc.).
4064
+ 3. A `WeakRef` to the element is cached in a module-level `WeakMap` keyed by rootNode.
4065
+ 4. On subsequent calls, the cached `WeakRef.deref()` returns the element in ~10ns.
4066
+ 5. If the WeakRef is collected (element was GC'd), falls back to `getElementById` (~10-100ns).
4067
+
4068
+ **Two forms of `withIds` configuration:**
4069
+
4070
+ ```TypeScript
4071
+ withIds: {
4072
+ x: { qry: '.mainView' }, // Object form: querySelector on target, auto-assign ID
4073
+ y: 'existingId', // String form: element already has an ID, just cache it
4074
+ }
4075
+ ```
4076
+
4077
+ **Chaining with `?.` paths:**
4078
+
4079
+ `#[x]` anchors the start of the path. Further `?.` segments chain from the resolved element:
4080
+
4081
+ ```TypeScript
4082
+ await assignFrom(document.body, {
4083
+ '#[form]?.querySelector?..username?.value': '?.username',
4084
+ '#[form]?.querySelector?..email?.value': '?.email',
4085
+ '#[header]?.style?.color': '?.themeColor',
4086
+ }, {
4087
+ from: viewModel,
4088
+ withMethods: ['querySelector'],
4089
+ withIds: {
4090
+ form: { qry: '.registration-form' },
4091
+ header: 'page-header'
4092
+ }
4093
+ });
4094
+ ```
4095
+
4096
+ **With handlers (` =>`):**
4097
+
4098
+ ```TypeScript
4099
+ await assignFrom(container, {
4100
+ '#[outlet] =>': {
4101
+ do: 'builtIns.lazyLoadSwitch',
4102
+ resolve: { lhs: '?.route', rhs: 'home', instantiate: 'globalThis://homeView' }
4103
+ }
4104
+ }, {
4105
+ from: router,
4106
+ withIds: { outlet: { qry: '.router-outlet' } },
4107
+ protocols: { globalThis: k => globalThis[k] }
4108
+ });
4109
+ ```
4110
+
4111
+ **Performance context (from benchmarks at 5000 elements):**
4112
+
4113
+ | Method | Chrome | Firefox | Safari |
4114
+ |--------|--------|---------|--------|
4115
+ | `querySelector('.class')` | 9,660ns | 6,810ns | 17,770ns |
4116
+ | `getElementById(id)` | 100ns | 20ns | 10ns |
4117
+ | `Map<id, WeakRef>.deref()` | 12ns | 40ns | 10ns |
4118
+
4119
+ The `#[x]` + `withIds` pattern gives you the Map+WeakRef speed tier automatically.
4120
+
4121
+ **Key behaviors:**
4122
+
4123
+ - **Lazy resolution** — elements are resolved on first encounter, not eagerly at the start.
4124
+ - **Auto-ID generation** — IDs are short and predictable (`_ag0`, `_ag1`, ...), unique within the rootNode.
4125
+ - **GC-safe** — WeakRef cache doesn't prevent element garbage collection.
4126
+ - **Scoped to rootNode** — works correctly inside Shadow DOM (IDs are scoped per shadow root).
4127
+ - **`assignFrom` only** — this feature is not available in `assignGingerly` or `assignTentatively`.
4128
+
3428
4129
  ## Custom Assignment with `static assignTo` Protocol
3429
4130
 
3430
4131
  Classes can opt into custom assignment behavior by defining a `static assignTo` method. When `assignGingerly` encounters a property whose current value is an instance of such a class, it delegates the assignment to `assignTo` instead of performing the default merge/replace logic.