@ruledwdl/dom 0.1.0 → 0.1.1

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
@@ -136,9 +136,17 @@ hero.registry.addRule({
136
136
 
137
137
  ### Instance Methods
138
138
  - `dom.remount()`: Rebuild the entire DOM from state snapshot.
139
- - `dom.getLiveMap()`: Returns a snapshot copy of `Map<semanticId, HTMLElement>`.
139
+ - `dom.getLiveMap()`: Returns a snapshot copy of `Map<semanticId, HTMLElement>` (first element per semantic ID).
140
+ - `dom.getLiveNodes(semanticId)`: Returns an array of all live elements matching `semanticId` (e.g. repeated loop rows).
141
+ - `dom.getNode(semanticId, index = 0)`: Returns the live element at the specified index for `semanticId`.
140
142
  - `dom.destroy()`: Removes event listeners, empties container, and cleans up style tags.
141
143
 
144
+ ### Repeated Loops & Surgical Data Reconciliation
145
+ `@ruledwdl/dom` natively reconciles repeated WDL loop layers (e.g. `li.feature*features`) without remounting or wiping `innerHTML`:
146
+ - Materializes all items on initial mount with `data-wdl-index="0"`, `data-wdl-index="1"`, etc.
147
+ - Resolves per-item attribute templates (e.g. `${text}`, `${_index}`).
148
+ - On `component.data.set('features', nextItems)`, surgically updates retained rows, appends added rows, and removes deleted rows.
149
+
142
150
  ---
143
151
 
144
152
  ## License
@@ -1,2 +1,2 @@
1
- var f=new Set(["text","class","html"]);function p(l){if(typeof l!="string"){if(l&&typeof l=="object")return{tag:String(l.tag||"div").toLowerCase(),semanticId:String(l.semanticId||l.id||"").replace(/^\./,"")};throw new Error("[wdl-dom] invalid layer expression")}let e=l.trim(),i=e.match(/^([a-zA-Z][a-zA-Z0-9_-]*)(?:\.([a-zA-Z0-9_-]+))?$/);return i?{tag:i[1].toLowerCase(),semanticId:(i[2]||"").replace(/^\./,"")}:{tag:e.toLowerCase()||"div",semanticId:""}}function g(l){if(Array.isArray(l))return structuredClone(l);if(typeof l!="string"||!l.trim())return[];let e={tag:"__root__",semanticId:"",children:[]},i=[e],s=0,a=()=>i[i.length-1];for(;s<l.length;){let t=l[s];if(/\s/.test(t)){s++;continue}if(t===">"){let r=a().children[a().children.length-1];r&&i.push(r),s++;continue}if(t==="+"){s++;continue}if(t==="<"){s++,i.length>1&&i.pop();continue}let n="";for(;s<l.length&&/[a-zA-Z0-9_-]/.test(l[s]);)n+=l[s++];let o="";if(l[s]===".")for(s++;s<l.length&&/[a-zA-Z0-9_-]/.test(l[s]);)o+=l[s++];a().children.push({tag:n.toLowerCase()||"div",semanticId:o,children:[]})}return e.children}function h(l){return l==null?"":String(l).replace(/^\./,"")}var m=class{constructor(e={}){let{container:i,component:s,onDataBind:a=null,styleTarget:t=typeof document<"u"?document.head:null,debug:n=!1}=e;if(!i)throw new Error("[wdl-dom] container is required");if(!s)throw new Error("[wdl-dom] component (ComponentState) is required");if(this.container=typeof i=="string"?document.querySelector(i):i,!this.container)throw new Error("[wdl-dom] container not found");this.component=s,this.onDataBind=a,this.styleTarget=t,this.debug=n,this.liveMap=new Map,this._unsubs=[],this._styleEl=null,this._mount(),this._bindStateEvents()}remount(){this._mount()}getLiveMap(){return new Map(this.liveMap)}destroy(){this._unsubs.forEach(e=>{try{e()}catch{}}),this._unsubs=[],this.container.replaceChildren(),this.liveMap.clear(),this._styleEl&&this._styleEl.parentNode&&this._styleEl.parentNode.removeChild(this._styleEl),this._styleEl=null}_mount(){this.container.replaceChildren(),this.liveMap.clear();let e;try{e=typeof this.component.layers?.tree=="function"?this.component.layers.tree():g(this.component.layers?.list?.()??this.component.getSnapshot?.()?.layers)}catch(s){this._log("warn","tree() failed, falling back to simple parse",s);let a=this.component.getSnapshot?.()?.layers??"";e=g(a)}Array.isArray(e)||(e=[]);for(let s of e){let a=this._createElementFromNode(s);this.container.appendChild(a)}let i=this.component.attr?.list?.()??this.component.getSnapshot?.()?.attr??{};for(let[s,a]of Object.entries(i))this._applyAttrs(h(s),a);this._applyRootVariant(),this._syncRegistryStyles(),this._log("mount",`mounted ${this.liveMap.size} nodes`)}_createElementFromNode(e){let i=(e.tag||"div").toLowerCase(),s=h(e.semanticId||e.id||""),a=document.createElement(i);if(s&&(a.classList.add(s),a.setAttribute("wdl-comp",s),this.liveMap.set(s,a)),Array.isArray(e.children))for(let t of e.children)a.appendChild(this._createElementFromNode(t));return a}_bindStateEvents(){let e=this.component;if(typeof e.on!="function"){this._log("warn","component has no .on() \u2014 events will not be applied");return}let i=a=>this._onStateEvent(a),s=["layers:change","attr:change","data:change","variant:change","registry:change"];for(let a of s){let t=e.on(a,i);typeof t=="function"?this._unsubs.push(t):this._unsubs.push(()=>e.off?.(a,i))}}_onStateEvent(e){if(!(!e||!e.type))switch(this._log("event",e.type,e.action,e.targetId,e.payload),e.type){case"layers:change":this._handleLayers(e);break;case"attr:change":this._handleAttr(e);break;case"data:change":this._handleData(e);break;case"variant:change":this._handleVariant(e);break;case"registry:change":this._handleRegistry(e);break;default:this._log("warn","unknown event type",e.type)}}_handleLayers(e){let{action:i,targetId:s,payload:a}=e,t=h(s);switch(i){case"set":{this._mount();break}case"append":{let n=this.liveMap.get(t);if(!n){this._log("warn",`append: parent "${t}" not in liveMap \u2014 remounting`),this._mount();return}let o=p(a),r=this._createElementFromNode({tag:o.tag,semanticId:o.semanticId,children:[]});n.appendChild(r),this._log("op",`append <${o.tag}.${o.semanticId}> \u2192 #${t}`);break}case"prepend":{let n=this.liveMap.get(t);if(!n){this._log("warn",`prepend: parent "${t}" not in liveMap \u2014 remounting`),this._mount();return}let o=p(a),r=this._createElementFromNode({tag:o.tag,semanticId:o.semanticId,children:[]});n.insertBefore(r,n.firstChild),this._log("op",`prepend <${o.tag}.${o.semanticId}> \u2192 #${t}`);break}case"before":case"after":{let n=this.liveMap.get(t);if(!n||!n.parentNode){this._log("warn",`${i}: target "${t}" missing \u2014 remounting`),this._mount();return}let o=p(a),r=this._createElementFromNode({tag:o.tag,semanticId:o.semanticId,children:[]});i==="before"?n.parentNode.insertBefore(r,n):n.parentNode.insertBefore(r,n.nextSibling),this._log("op",`${i} <${o.tag}.${o.semanticId}> relative to #${t}`);break}case"wrap":{let n=this.liveMap.get(t);if(!n||!n.parentNode){this._log("warn",`wrap: target "${t}" missing \u2014 remounting`),this._mount();return}let o=p(a),r=this._createElementFromNode({tag:o.tag,semanticId:o.semanticId,children:[]});n.parentNode.insertBefore(r,n),r.appendChild(n),this._log("op",`wrap #${t} with <${o.tag}.${o.semanticId}>`);break}case"unwrap":{let n=this.liveMap.get(t);if(!n||!n.parentNode){this._log("warn",`unwrap: target "${t}" missing \u2014 remounting`),this._mount();return}let o=n.parentNode;for(;n.firstChild;)o.insertBefore(n.firstChild,n);n.remove(),this.liveMap.delete(t),this._log("op",`unwrap #${t}`);break}case"move":{let n=this.liveMap.get(t),{targetSemanticId:o,position:r}=a||{},c=h(o),d=this.liveMap.get(c);if(!n||!d){this._log("warn",`move: source "${t}" or target "${c}" missing \u2014 remounting`),this._mount();return}r==="before"?d.parentNode?.insertBefore(n,d):r==="after"?d.parentNode?.insertBefore(n,d.nextSibling):d.appendChild(n),this._log("op",`move #${t} -> ${r} #${c}`);break}case"remove":{let n=this.liveMap.get(t);if(!n){this._log("warn",`remove: #${t} not in liveMap`);return}for(let[o,r]of[...this.liveMap])o!==t&&n.contains(r)&&this.liveMap.delete(o);n.remove(),this.liveMap.delete(t),this._log("op",`remove #${t}`);break}case"update":{let n=this.liveMap.get(t);if(!n){this._log("warn",`update: #${t} missing \u2014 remounting`),this._mount();return}let o=a||{};if(o.tag&&o.tag.toLowerCase()!==n.tagName.toLowerCase()){let r=document.createElement(String(o.tag).toLowerCase());for(let c of n.attributes)r.setAttribute(c.name,c.value);for(;n.firstChild;)r.appendChild(n.firstChild);if(n.parentNode?.replaceChild(r,n),this.liveMap.set(t,r),o.semanticId&&h(o.semanticId)!==t){let c=h(o.semanticId);r.classList.remove(t),r.classList.add(c),r.setAttribute("wdl-comp",c),this.liveMap.delete(t),this.liveMap.set(c,r)}}else if(o.semanticId&&h(o.semanticId)!==t){let r=h(o.semanticId);n.classList.remove(t),n.classList.add(r),n.setAttribute("wdl-comp",r),this.liveMap.delete(t),this.liveMap.set(r,n)}this._log("op",`update #${t}`,o);break}default:this._log("warn",`layers: unknown action "${i}" \u2014 remounting`),this._mount()}}_handleAttr(e){let{action:i,targetId:s,payload:a}=e,t=h(s);if(i==="set"||i==="update"){this._applyAttrs(t,a||{}),this._log("op",`attr.${i} #${t}`,a);return}if(i==="remove"){let n=this.liveMap.get(t);if(!n)return;let o=a;o==null||o===""?(n.textContent="",n.className=t,this._log("op",`attr.remove entire #${t}`)):o==="text"?(n.textContent="",this._log("op",`attr.remove text from #${t}`)):o==="class"?(n.className=t,this._log("op",`attr.remove class from #${t}`)):o==="html"?(n.innerHTML="",this._log("op",`attr.remove html from #${t}`)):(n.removeAttribute(o),this._log("op",`attr.remove ${o} from #${t}`))}}_applyAttrs(e,i){if(!i||typeof i!="object")return;let s=this.liveMap.get(e);if(!s){this._log("warn",`attr: #${e} not in liveMap`);return}if(i.text!==void 0&&(s.textContent=String(i.text)),i.html!==void 0&&(s.innerHTML=String(i.html)),i.class!==void 0){let a=String(i.class).trim();s.className=a?`${e} ${a}`:e}for(let[a,t]of Object.entries(i))if(!f.has(a)){if(a==="style"&&t&&typeof t=="object"){Object.assign(s.style,t);continue}t==null?s.removeAttribute(a):s.setAttribute(a,String(t))}}_handleData(e){let{action:i,targetId:s,payload:a}=e,t=s||"";if(typeof this.onDataBind=="function")try{this.onDataBind(this.container,t,i==="remove"?void 0:a)}catch(n){this._log("warn","onDataBind error",n)}this._log("op",`data.${i} ${t}`,i==="remove"?void 0:a)}_handleVariant(e){let{targetId:i,payload:s}=e,a=h(i)||this._rootSemanticId(),t=this.liveMap.get(a);if(!t){let n=this._rootSemanticId(),o=n?this.liveMap.get(n):null;o&&(s?o.dataset.variant=String(s):delete o.dataset.variant,this._log("op",`variant \u2192 ${s||"(none)"} on #${n}`));return}s?t.dataset.variant=String(s):delete t.dataset.variant,this._log("op",`variant \u2192 ${s||"(none)"} on #${a}`)}_applyRootVariant(){let e=this.component.attr?.list?.()??this.component.getSnapshot?.()?.attr??{},i=this._rootSemanticId();if(!i)return;let s="."+i,a=e[s]?.["data-variant"]??e["."]?.["data-variant"]??null;a&&this.liveMap.has(i)&&(this.liveMap.get(i).dataset.variant=String(a))}_rootSemanticId(){for(let[i,s]of this.liveMap)if(s.parentNode===this.container)return i;let e=this.liveMap.keys().next();return e.done?"":e.value}_handleRegistry(e){this._syncRegistryStyles(),this._log("op",`registry.${e.action}`)}_syncRegistryStyles(){if(!this.styleTarget)return;let e=this.component.registry?.get?.()??this.component.getSnapshot?.()?.registry??null,i=this.component.id||this.component.getSnapshot?.()?.id||"comp";if(this._styleEl||(this._styleEl=document.createElement("style"),this._styleEl.setAttribute("data-wdl-dom",i),this.styleTarget.appendChild(this._styleEl)),!e){this._styleEl.textContent="";return}let s=Array.isArray(e.rules)?e.rules:[],a=[];if(e.vars&&typeof e.vars=="object"){let t=Object.entries(e.vars).map(([n,o])=>`--${n}: ${o};`).join(" ");t&&a.push(`:root, [wdl-comp] { ${t} }`)}for(let t of s){if(!t||!t.selector)continue;let n=typeof t.css=="string"?t.css:t.css&&typeof t.css=="object"?Object.entries(t.css).map(([c,d])=>`${u(c)}: ${d};`).join(" "):"";if(!n)continue;let o=t.selector.startsWith("&")?t.selector.replace("&",`[wdl-comp="${i}"]`):t.selector,r=t.media?`@media ${t.media} { ${o} { ${n} } }`:`${o} { ${n} }`;a.push(r)}this._styleEl.textContent=a.join(`
2
- `)}_log(e,...i){if(!this.debug&&e!=="warn")return;let s=`[wdl-dom:${e}]`;e==="warn"?console.warn(s,...i):console.log(s,...i)}};function u(l){return String(l).replace(/[A-Z]/g,e=>"-"+e.toLowerCase())}function _(l){return new m(l)}var y=_;export{m as WdlDom,_ as createWdlDom,y as default};
1
+ var N=new Set(["text","class","html","alpine","htmx","attr-ref"]);function g(l,e){return!e||l==null?l??"":String(e).split(".").reduce((i,t)=>i!=null?i[t]:"",l)??""}function S(l,e){return typeof l!="string"?l:l.replace(/\$\{([\w.]+)\}/g,(i,t)=>{let s=g(e,t);return s!=null?String(s):""}).replace(/\{\{([\w.]+)\}\}/g,(i,t)=>{let s=g(e,t);return s!=null?String(s):""})}function f(l,e){return l==null?l:typeof l=="function"?"":typeof l=="string"?S(l,e):Array.isArray(l)?l.map(i=>f(i,e)):typeof l=="object"?Object.fromEntries(Object.entries(l).map(([i,t])=>[i,f(t,e)])):l}function m(l){if(typeof l!="string"){if(l&&typeof l=="object")return{tag:String(l.tag||"div").toLowerCase(),semanticId:String(l.semanticId||l.id||"").replace(/^\./,""),repeator:l.repeator||l.loopKey||null};throw new Error("[wdl-dom] invalid layer expression")}let i=l.trim(),t=null,s=i.indexOf("*");s!==-1&&(t=i.slice(s+1),i=i.slice(0,s));let n=i.match(/^([a-zA-Z][a-zA-Z0-9_-]*)(?:\.([a-zA-Z0-9_-]+))?$/);return n?{tag:n[1].toLowerCase(),semanticId:(n[2]||"").replace(/^\./,""),repeator:t}:{tag:i.toLowerCase()||"div",semanticId:"",repeator:t}}function w(l){if(Array.isArray(l))return structuredClone(l);if(typeof l!="string"||!l.trim())return[];let e={tag:"__root__",semanticId:"",children:[]},i=[e],t=0,s=()=>i[i.length-1];for(;t<l.length;){let n=l[t];if(/\s/.test(n)){t++;continue}if(n===">"){let r=s().children[s().children.length-1];r&&i.push(r),t++;continue}if(n==="+"){t++;continue}if(n==="<"){if(t++,t<l.length&&l[t]==="*"){t++;let r="";for(;t<l.length&&/\d/.test(l[t]);)r+=l[t++];let h=r?parseInt(r,10):1;for(let d=0;d<h;d++)i.length>1&&i.pop()}else if(t<l.length&&l[t]==="@"){t++;let r="";for(;t<l.length&&/\d/.test(l[t]);)r+=l[t++];let d=(r?parseInt(r,10):0)+1;for(;i.length>d&&i.length>1;)i.pop()}else{let r=1;for(;t<l.length&&l[t]==="<";)r++,t++;for(let h=0;h<r;h++)i.length>1&&i.pop()}continue}let c="";for(;t<l.length&&/[a-zA-Z0-9_-]/.test(l[t]);)c+=l[t++];let o="";if(l[t]===".")for(t++;t<l.length&&/[a-zA-Z0-9_-]/.test(l[t]);)o+=l[t++];let a=null;if(l[t]==="*"){t++;let r="";for(;t<l.length&&/[a-zA-Z0-9_.]/.test(l[t]);)r+=l[t++];r&&(a=r)}s().children.push({tag:c.toLowerCase()||"div",semanticId:o,repeator:a,children:[]})}return e.children}function p(l){return l==null?"":String(l).replace(/^\./,"")}var y=class{constructor(e={}){let{container:i,component:t,onDataBind:s=null,styleTarget:n=typeof document<"u"?document.head:null,debug:c=!1}=e;if(!i)throw new Error("[wdl-dom] container is required");if(!t)throw new Error("[wdl-dom] component (ComponentState) is required");if(this.container=typeof i=="string"?document.querySelector(i):i,!this.container)throw new Error("[wdl-dom] container not found");this.component=t,this.onDataBind=s,this.styleTarget=n,this.debug=c,this.liveMap=new Map,this.liveNodes=new Map,this._loopBindings=[],this._unsubs=[],this._styleEl=null,this._mount(),this._bindStateEvents()}remount(){this._mount()}getLiveMap(){return new Map(this.liveMap)}getLiveNodes(e){let i=p(e);return[...this.liveNodes.get(i)||[]]}getNode(e,i=0){return this.getLiveNodes(e)[i]||null}destroy(){this._unsubs.forEach(e=>{try{e()}catch{}}),this._unsubs=[],this.container.replaceChildren(),this.liveMap.clear(),this.liveNodes.clear(),this._loopBindings=[],this._styleEl&&this._styleEl.parentNode&&this._styleEl.parentNode.removeChild(this._styleEl),this._styleEl=null}_registerNode(e,i){if(!e)return;let t=p(e);this.liveNodes.has(t)||this.liveNodes.set(t,[]);let s=this.liveNodes.get(t);s.includes(i)||s.push(i),this.liveMap.set(t,s[0])}_unregisterNode(e,i){if(!e)return;let t=p(e);if(this.liveNodes.has(t)){let s=this.liveNodes.get(t),n=s.indexOf(i);n!==-1&&s.splice(n,1),s.length===0?(this.liveNodes.delete(t),this.liveMap.delete(t)):this.liveMap.set(t,s[0])}else this.liveMap.delete(t)}_unregisterElementHierarchy(e){if(!e)return;let i=e.getAttribute?.("wdl-comp");if(i&&this._unregisterNode(i,e),Array.isArray(e.children))for(let t of e.children)this._unregisterElementHierarchy(t)}_getComponentData(){return this.component.data?.get?.()??this.component.getSnapshot?.()?.data??{}}_getComponentAttrMap(){return this.component.attr?.list?.()??this.component.getSnapshot?.()?.attr??{}}_resolveLoopItems(e,i){if(!e)return[];if(/^\d+$/.test(String(e))){let s=parseInt(e,10);return Array.from({length:Math.max(0,s)},(n,c)=>({_index:c}))}let t=g(i,e);return Array.isArray(t)?t:typeof t=="number"?Array.from({length:Math.max(0,t)},(s,n)=>({_index:n})):[]}_createItemScope(e,i,t){let s=t||this._getComponentData();return typeof e=="object"&&e!==null?{...s,...e,item:e,_index:i}:{...s,value:e,item:e,_index:i}}_mount(){this.container.replaceChildren(),this.liveMap.clear(),this.liveNodes.clear(),this._loopBindings=[];let e;try{e=typeof this.component.layers?.tree=="function"?this.component.layers.tree():w(this.component.layers?.list?.()??this.component.getSnapshot?.()?.layers)}catch(t){this._log("warn","tree() failed, falling back to simple parse",t);let s=this.component.getSnapshot?.()?.layers??"";e=w(s)}Array.isArray(e)||(e=[]);let i=this._getComponentData();for(let t of e)this._mountLayerNode(this.container,t,i);this._applyRootVariant(),this._syncRegistryStyles(),this._log("mount",`mounted ${this.liveMap.size} semantic keys, ${this._loopBindings.length} loops`)}_mountLayerNode(e,i,t){if(i.repeator){let n=this._resolveLoopItems(i.repeator,t),c=[];for(let a=0;a<n.length;a++){let r=this._createItemScope(n[a],a,t),h=this._createSingleElement(i,r,a);e.appendChild(h),c.push(h)}let o=null;typeof document.createComment=="function"&&(o=document.createComment(`wdl-loop:${i.repeator}`),e.appendChild(o)),this._loopBindings.push({parentEl:e,node:i,repeator:i.repeator,elements:c,anchor:o});return}let s=this._createSingleElement(i,t);e.appendChild(s)}_createSingleElement(e,i,t){let s=(e.tag||"div").toLowerCase(),n=p(e.semanticId||e.id||""),c=document.createElement(s);n&&(c.classList.add(n),c.setAttribute("wdl-comp",n),this._registerNode(n,c));let o=t!==void 0?t:i?._index;if(o!==void 0){let a=String(o);c.setAttribute("data-wdl-index",a),c.dataset&&(c.dataset.wdlIndex=a)}if(this._applyNodeAttrs(c,e,i),Array.isArray(e.children))for(let a of e.children)this._mountLayerNode(c,a,i);return c}_applyNodeAttrs(e,i,t){let s=this._getComponentAttrMap(),n=p(i.semanticId||i.id||""),c=(i.tag||"div").toLowerCase(),o=s[c]||{},a=n?s["."+n]||{}:{},r=n?s[n]||{}:{},h={...o,...a,...r},d=f(h,t);this._applyPropsToElement(e,n,d)}_applyPropsToElement(e,i,t){if(!(!t||typeof t!="object")){if(t.text!==void 0&&(e.textContent=String(t.text)),t.html!==void 0&&(e.innerHTML=String(t.html)),t.class!==void 0){let s=String(t.class).trim();e.className=i?s?`${i} ${s}`:i:s}for(let[s,n]of Object.entries(t))if(!N.has(s)){if(s==="style"&&n&&typeof n=="object"){Object.assign(e.style,n);continue}n==null?e.removeAttribute(s):e.setAttribute(s,String(n))}}}_bindStateEvents(){let e=this.component;if(typeof e.on!="function"){this._log("warn","component has no .on() \u2014 events will not be applied");return}let i=s=>this._onStateEvent(s),t=["layers:change","attr:change","data:change","variant:change","registry:change"];for(let s of t){let n=e.on(s,i);typeof n=="function"?this._unsubs.push(n):this._unsubs.push(()=>e.off?.(s,i))}}_onStateEvent(e){if(!(!e||!e.type))switch(this._log("event",e.type,e.action,e.targetId,e.payload),e.type){case"layers:change":this._handleLayers(e);break;case"attr:change":this._handleAttr(e);break;case"data:change":this._handleData(e);break;case"variant:change":this._handleVariant(e);break;case"registry:change":this._handleRegistry(e);break;default:this._log("warn","unknown event type",e.type)}}_handleLayers(e){let{action:i,targetId:t,payload:s}=e,n=p(t),c=this._getComponentData();switch(i){case"set":{this._mount();break}case"append":{let o=this.getNode(n);if(!o){this._log("warn",`append: parent "${n}" not in liveMap \u2014 remounting`),this._mount();return}let a=m(s);this._mountLayerNode(o,a,c),this._log("op",`append <${a.tag}.${a.semanticId}> \u2192 #${n}`);break}case"prepend":{let o=this.getNode(n);if(!o){this._log("warn",`prepend: parent "${n}" not in liveMap \u2014 remounting`),this._mount();return}let a=m(s),r=this._createSingleElement(a,c);o.insertBefore(r,o.firstChild),this._log("op",`prepend <${a.tag}.${a.semanticId}> \u2192 #${n}`);break}case"before":case"after":{let o=this.getNode(n);if(!o||!o.parentNode){this._log("warn",`${i}: target "${n}" missing \u2014 remounting`),this._mount();return}let a=m(s),r=this._createSingleElement(a,c);i==="before"?o.parentNode.insertBefore(r,o):o.parentNode.insertBefore(r,o.nextSibling),this._log("op",`${i} <${a.tag}.${a.semanticId}> relative to #${n}`);break}case"wrap":{let o=this.getNode(n);if(!o||!o.parentNode){this._log("warn",`wrap: target "${n}" missing \u2014 remounting`),this._mount();return}let a=m(s),r=this._createSingleElement(a,c);o.parentNode.insertBefore(r,o),r.appendChild(o),this._log("op",`wrap #${n} with <${a.tag}.${a.semanticId}>`);break}case"unwrap":{let o=this.getNode(n);if(!o||!o.parentNode){this._log("warn",`unwrap: target "${n}" missing \u2014 remounting`),this._mount();return}let a=o.parentNode;for(;o.firstChild;)a.insertBefore(o.firstChild,o);this._unregisterElementHierarchy(o),o.remove(),this._log("op",`unwrap #${n}`);break}case"move":{let o=this.getNode(n),{targetSemanticId:a,position:r}=s||{},h=p(a),d=this.getNode(h);if(!o||!d){this._log("warn",`move: source "${n}" or target "${h}" missing \u2014 remounting`),this._mount();return}r==="before"?d.parentNode?.insertBefore(o,d):r==="after"?d.parentNode?.insertBefore(o,d.nextSibling):d.appendChild(o),this._log("op",`move #${n} -> ${r} #${h}`);break}case"remove":{let o=this.getLiveNodes(n);if(o.length===0){this._log("warn",`remove: #${n} not in liveMap`);return}for(let a of o)this._unregisterElementHierarchy(a),a.remove();this._loopBindings=this._loopBindings.filter(a=>a.node.semanticId!==n),this._log("op",`remove #${n}`);break}case"update":{let o=this.getNode(n);if(!o){this._log("warn",`update: #${n} missing \u2014 remounting`),this._mount();return}let a=s||{};if(a.tag&&a.tag.toLowerCase()!==o.tagName.toLowerCase()){let r=document.createElement(String(a.tag).toLowerCase());for(let d of o.attributes)r.setAttribute(d.name,d.value);for(;o.firstChild;)r.appendChild(o.firstChild);o.parentNode?.replaceChild(r,o),this._unregisterNode(n,o);let h=a.semanticId?p(a.semanticId):n;a.semanticId&&h!==n&&(r.classList.remove(n),r.classList.add(h),r.setAttribute("wdl-comp",h)),this._registerNode(h,r)}else if(a.semanticId&&p(a.semanticId)!==n){let r=p(a.semanticId);o.classList.remove(n),o.classList.add(r),o.setAttribute("wdl-comp",r),this._unregisterNode(n,o),this._registerNode(r,o)}this._log("op",`update #${n}`,a);break}default:this._log("warn",`layers: unknown action "${i}" \u2014 remounting`),this._mount()}}_handleAttr(e){let{action:i,targetId:t,payload:s}=e,n=p(t);if(i==="set"||i==="update"){let c=this.getLiveNodes(n);if(c.length===0){this._log("warn",`attr: #${n} not in liveMap`);return}let o=this._getComponentData();for(let a of c){let r=a.getAttribute("data-wdl-index"),h=o;if(r!=null){let u=parseInt(r,10),v=this._findLoopForElement(a);if(v){let _=g(o,v.repeator);Array.isArray(_)&&_[u]!==void 0?h=this._createItemScope(_[u],u,o):h={...o,_index:u}}else h={...o,_index:u}}let d=f(s||{},h);this._applyPropsToElement(a,n,d)}this._log("op",`attr.${i} #${n}`,s);return}if(i==="remove"){let c=this.getLiveNodes(n);if(c.length===0)return;let o=s;for(let a of c)o==null||o===""?(a.textContent="",a.className=n):o==="text"?a.textContent="":o==="class"?a.className=n:o==="html"?a.innerHTML="":a.removeAttribute(o);this._log("op",`attr.remove ${o||"all"} from #${n}`)}}_applyAttrs(e,i){let t=this.getLiveNodes(e);if(t.length===0){this._log("warn",`attr: #${e} not in liveMap`);return}let s=this._getComponentData();for(let n of t){let c=n.getAttribute("data-wdl-index"),o=s;if(c!=null){let r=parseInt(c,10),h=this._findLoopForElement(n);if(h){let d=g(s,h.repeator);Array.isArray(d)&&d[r]!==void 0?o=this._createItemScope(d[r],r,s):o={...s,_index:r}}}let a=f(i||{},o);this._applyPropsToElement(n,e,a)}}_findLoopForElement(e){return this._loopBindings.find(i=>i.elements.includes(e))||null}_handleData(e){let{action:i,targetId:t,payload:s}=e,n=t||"",c=this._getComponentData();if(this._reconcileLoops(n,c),this._reconcileStaticDataBindings(n,c),typeof this.onDataBind=="function")try{this.onDataBind(this.container,n,i==="remove"?void 0:s)}catch(o){this._log("warn","onDataBind error",o)}this._log("op",`data.${i} ${n}`,i==="remove"?void 0:s)}_reconcileLoops(e,i){for(let t of this._loopBindings){if(!(!e||t.repeator===e||e.startsWith(t.repeator+".")||t.repeator.startsWith(e+".")))continue;let n=this._resolveLoopItems(t.repeator,i),c=n.length,o=t.elements.length,a=Math.min(o,c);for(let r=0;r<a;r++){let h=t.elements[r],d=this._createItemScope(n[r],r,i);h.setAttribute("data-wdl-index",String(r)),h.dataset&&(h.dataset.wdlIndex=String(r)),this._updateElementSubtree(h,t.node,d,r)}if(c>o)for(let r=o;r<c;r++){let h=this._createItemScope(n[r],r,i),d=this._createSingleElement(t.node,h,r);if(t.anchor&&t.anchor.parentNode===t.parentEl)t.parentEl.insertBefore(d,t.anchor);else if(t.elements.length>0){let u=t.elements[t.elements.length-1];t.parentEl.insertBefore(d,u.nextSibling)}else t.parentEl.appendChild(d);t.elements.push(d)}if(c<o)for(let r=o-1;r>=c;r--){let h=t.elements[r];this._unregisterElementHierarchy(h),h.remove(),t.elements.pop()}}}_updateElementSubtree(e,i,t,s){if(this._applyNodeAttrs(e,i,t),Array.isArray(i.children)&&Array.isArray(e.children)){let n=0;for(let c of i.children)if(c.repeator){let o=this._loopBindings.find(a=>a.parentEl===e&&a.node===c);o&&this._reconcileLoops(o.repeator,t)}else e.children[n]&&(this._updateElementSubtree(e.children[n],c,t,s),n++)}}_reconcileStaticDataBindings(e,i){let t=this._getComponentAttrMap();for(let[s,n]of Object.entries(t)){let c=p(s),o=JSON.stringify(n);if(!o.includes("${")&&!o.includes("{{")||e&&!o.includes(e))continue;let a=this.getLiveNodes(c);for(let r of a){if(r.hasAttribute("data-wdl-index")||this._findLoopForElement(r))continue;let h=f(n,i);this._applyPropsToElement(r,c,h)}}}_handleVariant(e){let{targetId:i,payload:t}=e,s=p(i)||this._rootSemanticId(),n=this.getNode(s);if(!n){let c=this._rootSemanticId(),o=c?this.getNode(c):null;o&&(t?o.dataset.variant=String(t):delete o.dataset.variant,this._log("op",`variant \u2192 ${t||"(none)"} on #${c}`));return}t?n.dataset.variant=String(t):delete n.dataset.variant,this._log("op",`variant \u2192 ${t||"(none)"} on #${s}`)}_applyRootVariant(){let e=this._getComponentAttrMap(),i=this._rootSemanticId();if(!i)return;let t="."+i,s=e[t]?.["data-variant"]??e["."]?.["data-variant"]??null;if(s&&this.liveMap.has(i)){let n=this.getNode(i);n&&n.dataset&&(n.dataset.variant=String(s))}}_rootSemanticId(){for(let[i,t]of this.liveMap)if(t.parentNode===this.container)return i;let e=this.liveMap.keys().next();return e.done?"":e.value}_handleRegistry(e){this._syncRegistryStyles(),this._log("op",`registry.${e.action}`)}_syncRegistryStyles(){if(!this.styleTarget)return;let e=this.component.registry?.get?.()??this.component.getSnapshot?.()?.registry??null,i=this.component.id||this.component.getSnapshot?.()?.id||"comp";if(this._styleEl||(this._styleEl=document.createElement("style"),this._styleEl.setAttribute("data-wdl-dom",i),this.styleTarget.appendChild(this._styleEl)),!e){this._styleEl.textContent="";return}let t=Array.isArray(e.rules)?e.rules:[],s=[];if(e.vars&&typeof e.vars=="object"){let n=Object.entries(e.vars).map(([c,o])=>`--${c}: ${o};`).join(" ");n&&s.push(`:root, [wdl-comp] { ${n} }`)}for(let n of t){if(!n||!n.selector)continue;let c=typeof n.css=="string"?n.css:n.css&&typeof n.css=="object"?Object.entries(n.css).map(([r,h])=>`${$(r)}: ${h};`).join(" "):"";if(!c)continue;let o=n.selector.startsWith("&")?n.selector.replace("&",`[wdl-comp="${i}"]`):n.selector,a=n.media?`@media ${n.media} { ${o} { ${c} } }`:`${o} { ${c} }`;s.push(a)}this._styleEl.textContent=s.join(`
2
+ `)}_log(e,...i){if(!this.debug&&e!=="warn")return;let t=`[wdl-dom:${e}]`;e==="warn"?console.warn(t,...i):console.log(t,...i)}};function $(l){return String(l).replace(/[A-Z]/g,e=>"-"+e.toLowerCase())}function E(l){return new y(l)}var A=E;export{y as WdlDom,E as createWdlDom,A as default,p as normalizeId,m as parseLayerToken,w as parseLayersSimple,f as resolveAll,g as resolvePath,S as resolveStr};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ruledwdl/dom",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Native DOM runtime for @ruledwdl/state with surgical element updates driven by state events.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/index.js CHANGED
@@ -6,9 +6,9 @@
6
6
  * No innerHTML on updates.
7
7
  *
8
8
  * State events handled (from @ruledwdl/state):
9
- * layers:change actions: set | append | before | after | wrap | remove | update
9
+ * layers:change actions: set | append | prepend | before | after | wrap | unwrap | move | remove | update
10
10
  * attr:change actions: set | update | remove
11
- * data:change actions: set | remove (optional data-* / text binding)
11
+ * data:change actions: set | update | remove (surgical loop child reconciliation & item-scoped binding)
12
12
  * variant:change actions: set
13
13
  * registry:change actions: set | update | addRule | removeRule
14
14
  *
@@ -23,59 +23,109 @@
23
23
  * }
24
24
  *
25
25
  * Usage:
26
- * import { createWdlDom } from './wdl-dom.js';
26
+ * import { createWdlDom } from '@ruledwdl/dom';
27
27
  * // or: import { ComponentManager } from '@ruledwdl/state';
28
28
  *
29
- * const hero = manager.create('hero', { layers: '...', attr: {...} });
29
+ * const comp = manager.create('pricing-card', {
30
+ * layers: 'ul.features > li.feature*features',
31
+ * attr: { '.feature': { text: '${text}' } },
32
+ * data: { features: [{ text: 'First' }, { text: 'Second' }] }
33
+ * });
30
34
  * const dom = createWdlDom({
31
35
  * container: document.getElementById('app'),
32
- * component: hero, // ComponentState instance
33
- * // optional:
34
- * // onDataBind: (el, path, value) => { ... },
35
- * // styleTarget: document.head,
36
+ * component: comp
36
37
  * });
37
38
  *
38
- * // later: hero.attr.set('title', { text: 'Hi' }); // → surgical update
39
+ * // later: comp.data.set('features', [{ text: 'Updated' }]); // → surgical row reconciliation
39
40
  * // destroy: dom.destroy();
40
41
  */
41
42
 
42
43
  // ---------------------------------------------------------------------------
43
- // Helpers
44
+ // Helpers & Data Resolvers
44
45
  // ---------------------------------------------------------------------------
45
46
 
46
- const ATTR_SKIP = new Set(['text', 'class', 'html']);
47
+ const ATTR_SKIP = new Set(['text', 'class', 'html', 'alpine', 'htmx', 'attr-ref']);
48
+
49
+ /**
50
+ * Resolve dot-notated path on an object (e.g. "user.name" or "text")
51
+ */
52
+ export function resolvePath(obj, path) {
53
+ if (!path || obj == null) return obj ?? '';
54
+ return String(path).split('.').reduce((a, k) => (a != null ? a[k] : ''), obj) ?? '';
55
+ }
56
+
57
+ /**
58
+ * Resolve template strings with ${path} or {{path}} expressions
59
+ */
60
+ export function resolveStr(str, data) {
61
+ if (typeof str !== 'string') return str;
62
+ return str
63
+ .replace(/\$\{([\w.]+)\}/g, (_, p) => {
64
+ const val = resolvePath(data, p);
65
+ return val != null ? String(val) : '';
66
+ })
67
+ .replace(/\{\{([\w.]+)\}\}/g, (_, p) => {
68
+ const val = resolvePath(data, p);
69
+ return val != null ? String(val) : '';
70
+ });
71
+ }
72
+
73
+ /**
74
+ * Deeply resolve string templates within objects/arrays
75
+ */
76
+ export function resolveAll(obj, data) {
77
+ if (obj == null) return obj;
78
+ if (typeof obj === 'function') return '';
79
+ if (typeof obj === 'string') return resolveStr(obj, data);
80
+ if (Array.isArray(obj)) return obj.map((v) => resolveAll(v, data));
81
+ if (typeof obj === 'object') {
82
+ return Object.fromEntries(
83
+ Object.entries(obj).map(([k, v]) => [k, resolveAll(v, data)])
84
+ );
85
+ }
86
+ return obj;
87
+ }
47
88
 
48
89
  /**
49
- * Parse a single layer token: "button.cta" | "div" | "h1.title"
50
- * @returns {{ tag: string, semanticId: string }}
90
+ * Parse a single layer token: "button.cta" | "li.feature*features" | "div.card*3"
91
+ * @returns {{ tag: string, semanticId: string, repeator: string | null }}
51
92
  */
52
- function parseLayerToken(expr) {
93
+ export function parseLayerToken(expr) {
53
94
  if (typeof expr !== 'string') {
54
95
  if (expr && typeof expr === 'object') {
55
96
  return {
56
97
  tag: String(expr.tag || 'div').toLowerCase(),
57
98
  semanticId: String(expr.semanticId || expr.id || '').replace(/^\./, ''),
99
+ repeator: expr.repeator || expr.loopKey || null,
58
100
  };
59
101
  }
60
102
  throw new Error('[wdl-dom] invalid layer expression');
61
103
  }
62
104
  const trimmed = expr.trim();
63
- const m = trimmed.match(/^([a-zA-Z][a-zA-Z0-9_-]*)(?:\.([a-zA-Z0-9_-]+))?$/);
105
+ let str = trimmed;
106
+ let repeator = null;
107
+ const multIdx = str.indexOf('*');
108
+ if (multIdx !== -1) {
109
+ repeator = str.slice(multIdx + 1);
110
+ str = str.slice(0, multIdx);
111
+ }
112
+ const m = str.match(/^([a-zA-Z][a-zA-Z0-9_-]*)(?:\.([a-zA-Z0-9_-]+))?$/);
64
113
  if (!m) {
65
114
  // fallback: treat whole string as tag
66
- return { tag: trimmed.toLowerCase() || 'div', semanticId: '' };
115
+ return { tag: str.toLowerCase() || 'div', semanticId: '', repeator };
67
116
  }
68
117
  return {
69
118
  tag: m[1].toLowerCase(),
70
119
  semanticId: (m[2] || '').replace(/^\./, ''),
120
+ repeator,
71
121
  };
72
122
  }
73
123
 
74
124
  /**
75
- * Very small layers string → tree (supports > and + only).
125
+ * Simple layers string → tree parser (supports >, +, <, <*N, <@N, and *repeator).
76
126
  * For full WDL grammar prefer tree from component.layers.tree().
77
127
  */
78
- function parseLayersSimple(str) {
128
+ export function parseLayersSimple(str) {
79
129
  if (Array.isArray(str)) return structuredClone(str);
80
130
  if (typeof str !== 'string' || !str.trim()) return [];
81
131
 
@@ -87,20 +137,57 @@ function parseLayersSimple(str) {
87
137
 
88
138
  while (i < str.length) {
89
139
  const ch = str[i];
90
- if (/\s/.test(ch)) { i++; continue; }
140
+ if (/\s/.test(ch)) {
141
+ i++;
142
+ continue;
143
+ }
91
144
  if (ch === '>') {
92
145
  const last = top().children[top().children.length - 1];
93
146
  if (last) stack.push(last);
94
147
  i++;
95
148
  continue;
96
149
  }
97
- if (ch === '+') { i++; continue; }
150
+ if (ch === '+') {
151
+ i++;
152
+ continue;
153
+ }
98
154
  if (ch === '<') {
99
155
  i++;
100
- if (stack.length > 1) stack.pop();
156
+ if (i < str.length && str[i] === '*') {
157
+ i++;
158
+ let numStr = '';
159
+ while (i < str.length && /\d/.test(str[i])) {
160
+ numStr += str[i++];
161
+ }
162
+ const count = numStr ? parseInt(numStr, 10) : 1;
163
+ for (let k = 0; k < count; k++) {
164
+ if (stack.length > 1) stack.pop();
165
+ }
166
+ } else if (i < str.length && str[i] === '@') {
167
+ i++;
168
+ let numStr = '';
169
+ while (i < str.length && /\d/.test(str[i])) {
170
+ numStr += str[i++];
171
+ }
172
+ const targetDepth = numStr ? parseInt(numStr, 10) : 0;
173
+ const targetStackLen = targetDepth + 1;
174
+ while (stack.length > targetStackLen && stack.length > 1) {
175
+ stack.pop();
176
+ }
177
+ } else {
178
+ let count = 1;
179
+ while (i < str.length && str[i] === '<') {
180
+ count++;
181
+ i++;
182
+ }
183
+ for (let k = 0; k < count; k++) {
184
+ if (stack.length > 1) stack.pop();
185
+ }
186
+ }
101
187
  continue;
102
188
  }
103
- // element token
189
+
190
+ // Element token
104
191
  let tag = '';
105
192
  while (i < str.length && /[a-zA-Z0-9_-]/.test(str[i])) tag += str[i++];
106
193
  let semanticId = '';
@@ -108,12 +195,24 @@ function parseLayersSimple(str) {
108
195
  i++;
109
196
  while (i < str.length && /[a-zA-Z0-9_-]/.test(str[i])) semanticId += str[i++];
110
197
  }
111
- top().children.push({ tag: tag.toLowerCase() || 'div', semanticId, children: [] });
198
+ let repeator = null;
199
+ if (str[i] === '*') {
200
+ i++;
201
+ let rep = '';
202
+ while (i < str.length && /[a-zA-Z0-9_.]/.test(str[i])) rep += str[i++];
203
+ if (rep) repeator = rep;
204
+ }
205
+ top().children.push({
206
+ tag: tag.toLowerCase() || 'div',
207
+ semanticId,
208
+ repeator,
209
+ children: [],
210
+ });
112
211
  }
113
212
  return root.children;
114
213
  }
115
214
 
116
- function normalizeId(id) {
215
+ export function normalizeId(id) {
117
216
  if (id == null) return '';
118
217
  return String(id).replace(/^\./, '');
119
218
  }
@@ -126,7 +225,7 @@ export class WdlDom {
126
225
  /**
127
226
  * @param {object} options
128
227
  * @param {HTMLElement|string} options.container
129
- * @param {object} options.component ComponentState instance (has .on, .layers, .attr, .getSnapshot)
228
+ * @param {object} options.component ComponentState instance (has .on, .layers, .attr, .data, .getSnapshot)
130
229
  * @param {(el: HTMLElement, path: string, value: any) => void} [options.onDataBind]
131
230
  * @param {HTMLElement|Document} [options.styleTarget=document.head]
132
231
  * @param {boolean} [options.debug=false]
@@ -154,9 +253,18 @@ export class WdlDom {
154
253
  this.styleTarget = styleTarget;
155
254
  this.debug = debug;
156
255
 
157
- /** @type {Map<string, HTMLElement>} semanticId → element */
256
+ /** @type {Map<string, HTMLElement>} semanticId → first live element (backward compatible) */
158
257
  this.liveMap = new Map();
159
258
 
259
+ /** @type {Map<string, HTMLElement[]>} semanticId → all live elements */
260
+ this.liveNodes = new Map();
261
+
262
+ /**
263
+ * Active loop bindings for surgical reconciliation
264
+ * @type {Array<{ parentEl: HTMLElement, node: any, repeator: string, elements: HTMLElement[], anchor: any }>}
265
+ */
266
+ this._loopBindings = [];
267
+
160
268
  /** @type {Array<() => void>} */
161
269
  this._unsubs = [];
162
270
 
@@ -176,25 +284,131 @@ export class WdlDom {
176
284
  this._mount();
177
285
  }
178
286
 
179
- /** Current live map (read-only view) */
287
+ /** Current live map of first elements (read-only view) */
180
288
  getLiveMap() {
181
289
  return new Map(this.liveMap);
182
290
  }
183
291
 
292
+ /** Get all live elements matching a semantic ID */
293
+ getLiveNodes(semanticId) {
294
+ const id = normalizeId(semanticId);
295
+ return [...(this.liveNodes.get(id) || [])];
296
+ }
297
+
298
+ /** Get single element by semantic ID and optional index */
299
+ getNode(semanticId, index = 0) {
300
+ const list = this.getLiveNodes(semanticId);
301
+ return list[index] || null;
302
+ }
303
+
184
304
  /** Clean up listeners and DOM */
185
305
  destroy() {
186
306
  this._unsubs.forEach((u) => {
187
- try { u(); } catch (_) {}
307
+ try {
308
+ u();
309
+ } catch (_) {}
188
310
  });
189
311
  this._unsubs = [];
190
312
  this.container.replaceChildren();
191
313
  this.liveMap.clear();
314
+ this.liveNodes.clear();
315
+ this._loopBindings = [];
192
316
  if (this._styleEl && this._styleEl.parentNode) {
193
317
  this._styleEl.parentNode.removeChild(this._styleEl);
194
318
  }
195
319
  this._styleEl = null;
196
320
  }
197
321
 
322
+ // -----------------------------------------------------------------------
323
+ // Node Registry Management
324
+ // -----------------------------------------------------------------------
325
+
326
+ _registerNode(id, el) {
327
+ if (!id) return;
328
+ const norm = normalizeId(id);
329
+ if (!this.liveNodes.has(norm)) {
330
+ this.liveNodes.set(norm, []);
331
+ }
332
+ const list = this.liveNodes.get(norm);
333
+ if (!list.includes(el)) {
334
+ list.push(el);
335
+ }
336
+ this.liveMap.set(norm, list[0]);
337
+ }
338
+
339
+ _unregisterNode(id, el) {
340
+ if (!id) return;
341
+ const norm = normalizeId(id);
342
+ if (this.liveNodes.has(norm)) {
343
+ const list = this.liveNodes.get(norm);
344
+ const idx = list.indexOf(el);
345
+ if (idx !== -1) list.splice(idx, 1);
346
+ if (list.length === 0) {
347
+ this.liveNodes.delete(norm);
348
+ this.liveMap.delete(norm);
349
+ } else {
350
+ this.liveMap.set(norm, list[0]);
351
+ }
352
+ } else {
353
+ this.liveMap.delete(norm);
354
+ }
355
+ }
356
+
357
+ _unregisterElementHierarchy(el) {
358
+ if (!el) return;
359
+ const compId = el.getAttribute?.('wdl-comp');
360
+ if (compId) {
361
+ this._unregisterNode(compId, el);
362
+ }
363
+ if (Array.isArray(el.children)) {
364
+ for (const child of el.children) {
365
+ this._unregisterElementHierarchy(child);
366
+ }
367
+ }
368
+ }
369
+
370
+ // -----------------------------------------------------------------------
371
+ // Data and Scope Helpers
372
+ // -----------------------------------------------------------------------
373
+
374
+ _getComponentData() {
375
+ return (
376
+ this.component.data?.get?.() ??
377
+ this.component.getSnapshot?.()?.data ??
378
+ {}
379
+ );
380
+ }
381
+
382
+ _getComponentAttrMap() {
383
+ return (
384
+ this.component.attr?.list?.() ??
385
+ this.component.getSnapshot?.()?.attr ??
386
+ {}
387
+ );
388
+ }
389
+
390
+ _resolveLoopItems(repeator, dataScope) {
391
+ if (!repeator) return [];
392
+ if (/^\d+$/.test(String(repeator))) {
393
+ const count = parseInt(repeator, 10);
394
+ return Array.from({ length: Math.max(0, count) }, (_, idx) => ({ _index: idx }));
395
+ }
396
+ const resolved = resolvePath(dataScope, repeator);
397
+ if (Array.isArray(resolved)) return resolved;
398
+ if (typeof resolved === 'number') {
399
+ return Array.from({ length: Math.max(0, resolved) }, (_, idx) => ({ _index: idx }));
400
+ }
401
+ return [];
402
+ }
403
+
404
+ _createItemScope(item, index, baseData) {
405
+ const data = baseData || this._getComponentData();
406
+ if (typeof item === 'object' && item !== null) {
407
+ return { ...data, ...item, item, _index: index };
408
+ }
409
+ return { ...data, value: item, item, _index: index };
410
+ }
411
+
198
412
  // -----------------------------------------------------------------------
199
413
  // Initial mount
200
414
  // -----------------------------------------------------------------------
@@ -202,12 +416,18 @@ export class WdlDom {
202
416
  _mount() {
203
417
  this.container.replaceChildren();
204
418
  this.liveMap.clear();
419
+ this.liveNodes.clear();
420
+ this._loopBindings = [];
205
421
 
206
422
  let tree;
207
423
  try {
208
- tree = typeof this.component.layers?.tree === 'function'
209
- ? this.component.layers.tree()
210
- : parseLayersSimple(this.component.layers?.list?.() ?? this.component.getSnapshot?.()?.layers);
424
+ tree =
425
+ typeof this.component.layers?.tree === 'function'
426
+ ? this.component.layers.tree()
427
+ : parseLayersSimple(
428
+ this.component.layers?.list?.() ??
429
+ this.component.getSnapshot?.()?.layers
430
+ );
211
431
  } catch (e) {
212
432
  this._log('warn', 'tree() failed, falling back to simple parse', e);
213
433
  const layers = this.component.getSnapshot?.()?.layers ?? '';
@@ -216,18 +436,10 @@ export class WdlDom {
216
436
 
217
437
  if (!Array.isArray(tree)) tree = [];
218
438
 
219
- for (const node of tree) {
220
- const el = this._createElementFromNode(node);
221
- this.container.appendChild(el);
222
- }
439
+ const compData = this._getComponentData();
223
440
 
224
- // Apply all current attributes
225
- const attrMap =
226
- this.component.attr?.list?.() ??
227
- this.component.getSnapshot?.()?.attr ??
228
- {};
229
- for (const [sel, props] of Object.entries(attrMap)) {
230
- this._applyAttrs(normalizeId(sel), props);
441
+ for (const node of tree) {
442
+ this._mountLayerNode(this.container, node, compData);
231
443
  }
232
444
 
233
445
  // Apply variant if present on root
@@ -236,13 +448,50 @@ export class WdlDom {
236
448
  // Registry styles
237
449
  this._syncRegistryStyles();
238
450
 
239
- this._log('mount', `mounted ${this.liveMap.size} nodes`);
451
+ this._log('mount', `mounted ${this.liveMap.size} semantic keys, ${this._loopBindings.length} loops`);
240
452
  }
241
453
 
242
454
  /**
243
- * @param {{ tag: string, semanticId?: string, id?: string, children?: any[] }} node
455
+ * Mount a layer node into a parent container/element.
456
+ * Handles loop expansion vs single element mounting.
244
457
  */
245
- _createElementFromNode(node) {
458
+ _mountLayerNode(parentEl, node, dataScope) {
459
+ if (node.repeator) {
460
+ const items = this._resolveLoopItems(node.repeator, dataScope);
461
+ const elements = [];
462
+
463
+ for (let i = 0; i < items.length; i++) {
464
+ const itemScope = this._createItemScope(items[i], i, dataScope);
465
+ const el = this._createSingleElement(node, itemScope, i);
466
+ parentEl.appendChild(el);
467
+ elements.push(el);
468
+ }
469
+
470
+ // Create comment anchor to keep track of loop boundary
471
+ let anchor = null;
472
+ if (typeof document.createComment === 'function') {
473
+ anchor = document.createComment(`wdl-loop:${node.repeator}`);
474
+ parentEl.appendChild(anchor);
475
+ }
476
+
477
+ this._loopBindings.push({
478
+ parentEl,
479
+ node,
480
+ repeator: node.repeator,
481
+ elements,
482
+ anchor,
483
+ });
484
+ return;
485
+ }
486
+
487
+ const el = this._createSingleElement(node, dataScope);
488
+ parentEl.appendChild(el);
489
+ }
490
+
491
+ /**
492
+ * Create a single DOM element for a layer node with item scope and attributes.
493
+ */
494
+ _createSingleElement(node, dataScope, index) {
246
495
  const tag = (node.tag || 'div').toLowerCase();
247
496
  const id = normalizeId(node.semanticId || node.id || '');
248
497
  const el = document.createElement(tag);
@@ -250,17 +499,77 @@ export class WdlDom {
250
499
  if (id) {
251
500
  el.classList.add(id);
252
501
  el.setAttribute('wdl-comp', id);
253
- this.liveMap.set(id, el);
502
+ this._registerNode(id, el);
503
+ }
504
+
505
+ const effectiveIndex = index !== undefined ? index : dataScope?._index;
506
+ if (effectiveIndex !== undefined) {
507
+ const idxStr = String(effectiveIndex);
508
+ el.setAttribute('data-wdl-index', idxStr);
509
+ if (el.dataset) el.dataset.wdlIndex = idxStr;
254
510
  }
255
511
 
512
+ this._applyNodeAttrs(el, node, dataScope);
513
+
256
514
  if (Array.isArray(node.children)) {
257
515
  for (const child of node.children) {
258
- el.appendChild(this._createElementFromNode(child));
516
+ this._mountLayerNode(el, child, dataScope);
259
517
  }
260
518
  }
519
+
261
520
  return el;
262
521
  }
263
522
 
523
+ /**
524
+ * Apply matching attributes to an element using resolved dataScope.
525
+ */
526
+ _applyNodeAttrs(el, node, dataScope) {
527
+ const attrMap = this._getComponentAttrMap();
528
+ const id = normalizeId(node.semanticId || node.id || '');
529
+ const tag = (node.tag || 'div').toLowerCase();
530
+
531
+ // Priority: tag fallback -> semanticId (.id or id)
532
+ const tagAttrs = attrMap[tag] || {};
533
+ const idDotAttrs = id ? attrMap['.' + id] || {} : {};
534
+ const idAttrs = id ? attrMap[id] || {} : {};
535
+
536
+ const merged = { ...tagAttrs, ...idDotAttrs, ...idAttrs };
537
+ const resolved = resolveAll(merged, dataScope);
538
+
539
+ this._applyPropsToElement(el, id, resolved);
540
+ }
541
+
542
+ /**
543
+ * Directly apply a resolved properties dictionary onto an element.
544
+ */
545
+ _applyPropsToElement(el, id, props) {
546
+ if (!props || typeof props !== 'object') return;
547
+
548
+ if (props.text !== undefined) {
549
+ el.textContent = String(props.text);
550
+ }
551
+ if (props.html !== undefined) {
552
+ el.innerHTML = String(props.html);
553
+ }
554
+ if (props.class !== undefined) {
555
+ const extra = String(props.class).trim();
556
+ el.className = id ? (extra ? `${id} ${extra}` : id) : extra;
557
+ }
558
+
559
+ for (const [key, value] of Object.entries(props)) {
560
+ if (ATTR_SKIP.has(key)) continue;
561
+ if (key === 'style' && value && typeof value === 'object') {
562
+ Object.assign(el.style, value);
563
+ continue;
564
+ }
565
+ if (value == null) {
566
+ el.removeAttribute(key);
567
+ } else {
568
+ el.setAttribute(key, String(value));
569
+ }
570
+ }
571
+ }
572
+
264
573
  // -----------------------------------------------------------------------
265
574
  // State event binding
266
575
  // -----------------------------------------------------------------------
@@ -274,7 +583,6 @@ export class WdlDom {
274
583
 
275
584
  const handler = (event) => this._onStateEvent(event);
276
585
 
277
- // All events documented by @ruledwdl/state
278
586
  const types = [
279
587
  'layers:change',
280
588
  'attr:change',
@@ -285,7 +593,6 @@ export class WdlDom {
285
593
 
286
594
  for (const type of types) {
287
595
  const off = c.on(type, handler);
288
- // state.on may return unsubscribe fn or nothing
289
596
  if (typeof off === 'function') this._unsubs.push(off);
290
597
  else this._unsubs.push(() => c.off?.(type, handler));
291
598
  }
@@ -321,68 +628,54 @@ export class WdlDom {
321
628
 
322
629
  // -----------------------------------------------------------------------
323
630
  // layers:change
324
- // actions: set | append | before | after | wrap | remove | update
631
+ // actions: set | append | prepend | before | after | wrap | unwrap | move | remove | update
325
632
  // -----------------------------------------------------------------------
326
633
 
327
634
  _handleLayers(event) {
328
635
  const { action, targetId, payload } = event;
329
636
  const id = normalizeId(targetId);
637
+ const compData = this._getComponentData();
330
638
 
331
639
  switch (action) {
332
640
  case 'set': {
333
- // Full layers replacement → remount
334
641
  this._mount();
335
642
  break;
336
643
  }
337
644
  case 'append': {
338
- // payload = layer expression (string or node-like)
339
- const parentEl = this.liveMap.get(id);
645
+ const parentEl = this.getNode(id);
340
646
  if (!parentEl) {
341
647
  this._log('warn', `append: parent "${id}" not in liveMap — remounting`);
342
648
  this._mount();
343
649
  return;
344
650
  }
345
651
  const token = parseLayerToken(payload);
346
- const el = this._createElementFromNode({
347
- tag: token.tag,
348
- semanticId: token.semanticId,
349
- children: [],
350
- });
351
- parentEl.appendChild(el);
652
+ this._mountLayerNode(parentEl, token, compData);
352
653
  this._log('op', `append <${token.tag}.${token.semanticId}> → #${id}`);
353
654
  break;
354
655
  }
355
656
  case 'prepend': {
356
- const parentEl = this.liveMap.get(id);
657
+ const parentEl = this.getNode(id);
357
658
  if (!parentEl) {
358
659
  this._log('warn', `prepend: parent "${id}" not in liveMap — remounting`);
359
660
  this._mount();
360
661
  return;
361
662
  }
362
663
  const token = parseLayerToken(payload);
363
- const el = this._createElementFromNode({
364
- tag: token.tag,
365
- semanticId: token.semanticId,
366
- children: [],
367
- });
664
+ const el = this._createSingleElement(token, compData);
368
665
  parentEl.insertBefore(el, parentEl.firstChild);
369
666
  this._log('op', `prepend <${token.tag}.${token.semanticId}> → #${id}`);
370
667
  break;
371
668
  }
372
669
  case 'before':
373
670
  case 'after': {
374
- const targetEl = this.liveMap.get(id);
671
+ const targetEl = this.getNode(id);
375
672
  if (!targetEl || !targetEl.parentNode) {
376
673
  this._log('warn', `${action}: target "${id}" missing — remounting`);
377
674
  this._mount();
378
675
  return;
379
676
  }
380
677
  const token = parseLayerToken(payload);
381
- const el = this._createElementFromNode({
382
- tag: token.tag,
383
- semanticId: token.semanticId,
384
- children: [],
385
- });
678
+ const el = this._createSingleElement(token, compData);
386
679
  if (action === 'before') {
387
680
  targetEl.parentNode.insertBefore(el, targetEl);
388
681
  } else {
@@ -392,26 +685,21 @@ export class WdlDom {
392
685
  break;
393
686
  }
394
687
  case 'wrap': {
395
- // payload = wrapper layer expression
396
- const targetEl = this.liveMap.get(id);
688
+ const targetEl = this.getNode(id);
397
689
  if (!targetEl || !targetEl.parentNode) {
398
690
  this._log('warn', `wrap: target "${id}" missing — remounting`);
399
691
  this._mount();
400
692
  return;
401
693
  }
402
694
  const token = parseLayerToken(payload);
403
- const wrapper = this._createElementFromNode({
404
- tag: token.tag,
405
- semanticId: token.semanticId,
406
- children: [],
407
- });
695
+ const wrapper = this._createSingleElement(token, compData);
408
696
  targetEl.parentNode.insertBefore(wrapper, targetEl);
409
697
  wrapper.appendChild(targetEl);
410
698
  this._log('op', `wrap #${id} with <${token.tag}.${token.semanticId}>`);
411
699
  break;
412
700
  }
413
701
  case 'unwrap': {
414
- const wrapperEl = this.liveMap.get(id);
702
+ const wrapperEl = this.getNode(id);
415
703
  if (!wrapperEl || !wrapperEl.parentNode) {
416
704
  this._log('warn', `unwrap: target "${id}" missing — remounting`);
417
705
  this._mount();
@@ -421,16 +709,16 @@ export class WdlDom {
421
709
  while (wrapperEl.firstChild) {
422
710
  parent.insertBefore(wrapperEl.firstChild, wrapperEl);
423
711
  }
712
+ this._unregisterElementHierarchy(wrapperEl);
424
713
  wrapperEl.remove();
425
- this.liveMap.delete(id);
426
714
  this._log('op', `unwrap #${id}`);
427
715
  break;
428
716
  }
429
717
  case 'move': {
430
- const sourceEl = this.liveMap.get(id);
718
+ const sourceEl = this.getNode(id);
431
719
  const { targetSemanticId, position } = payload || {};
432
720
  const targetId = normalizeId(targetSemanticId);
433
- const targetEl = this.liveMap.get(targetId);
721
+ const targetEl = this.getNode(targetId);
434
722
 
435
723
  if (!sourceEl || !targetEl) {
436
724
  this._log('warn', `move: source "${id}" or target "${targetId}" missing — remounting`);
@@ -449,23 +737,22 @@ export class WdlDom {
449
737
  break;
450
738
  }
451
739
  case 'remove': {
452
- const el = this.liveMap.get(id);
453
- if (!el) {
740
+ const nodes = this.getLiveNodes(id);
741
+ if (nodes.length === 0) {
454
742
  this._log('warn', `remove: #${id} not in liveMap`);
455
743
  return;
456
744
  }
457
- // Remove descendants from liveMap first
458
- for (const [sid, node] of [...this.liveMap]) {
459
- if (sid !== id && el.contains(node)) this.liveMap.delete(sid);
745
+ for (const el of nodes) {
746
+ this._unregisterElementHierarchy(el);
747
+ el.remove();
460
748
  }
461
- el.remove();
462
- this.liveMap.delete(id);
749
+ // Also cleanup loop bindings associated with this semantic ID
750
+ this._loopBindings = this._loopBindings.filter((l) => l.node.semanticId !== id);
463
751
  this._log('op', `remove #${id}`);
464
752
  break;
465
753
  }
466
754
  case 'update': {
467
- // payload = { tag?, semanticId? }
468
- const el = this.liveMap.get(id);
755
+ const el = this.getNode(id);
469
756
  if (!el) {
470
757
  this._log('warn', `update: #${id} missing — remounting`);
471
758
  this._mount();
@@ -473,28 +760,25 @@ export class WdlDom {
473
760
  }
474
761
  const patch = payload || {};
475
762
  if (patch.tag && patch.tag.toLowerCase() !== el.tagName.toLowerCase()) {
476
- // Tag change requires recreate
477
763
  const next = document.createElement(String(patch.tag).toLowerCase());
478
- // copy attributes & children
479
764
  for (const attr of el.attributes) next.setAttribute(attr.name, attr.value);
480
765
  while (el.firstChild) next.appendChild(el.firstChild);
481
766
  el.parentNode?.replaceChild(next, el);
482
- this.liveMap.set(id, next);
483
- if (patch.semanticId && normalizeId(patch.semanticId) !== id) {
484
- const newId = normalizeId(patch.semanticId);
767
+ this._unregisterNode(id, el);
768
+ const finalId = patch.semanticId ? normalizeId(patch.semanticId) : id;
769
+ if (patch.semanticId && finalId !== id) {
485
770
  next.classList.remove(id);
486
- next.classList.add(newId);
487
- next.setAttribute('wdl-comp', newId);
488
- this.liveMap.delete(id);
489
- this.liveMap.set(newId, next);
771
+ next.classList.add(finalId);
772
+ next.setAttribute('wdl-comp', finalId);
490
773
  }
774
+ this._registerNode(finalId, next);
491
775
  } else if (patch.semanticId && normalizeId(patch.semanticId) !== id) {
492
776
  const newId = normalizeId(patch.semanticId);
493
777
  el.classList.remove(id);
494
778
  el.classList.add(newId);
495
779
  el.setAttribute('wdl-comp', newId);
496
- this.liveMap.delete(id);
497
- this.liveMap.set(newId, el);
780
+ this._unregisterNode(id, el);
781
+ this._registerNode(newId, el);
498
782
  }
499
783
  this._log('op', `update #${id}`, patch);
500
784
  break;
@@ -515,89 +799,114 @@ export class WdlDom {
515
799
  const id = normalizeId(targetId);
516
800
 
517
801
  if (action === 'set' || action === 'update') {
518
- // payload = full attrs object (set) or patch (update)
519
- this._applyAttrs(id, payload || {});
802
+ const nodes = this.getLiveNodes(id);
803
+ if (nodes.length === 0) {
804
+ this._log('warn', `attr: #${id} not in liveMap`);
805
+ return;
806
+ }
807
+ const compData = this._getComponentData();
808
+ for (const el of nodes) {
809
+ const idx = el.getAttribute('data-wdl-index');
810
+ let scope = compData;
811
+ if (idx !== null && idx !== undefined) {
812
+ const indexNum = parseInt(idx, 10);
813
+ const loop = this._findLoopForElement(el);
814
+ if (loop) {
815
+ const items = resolvePath(compData, loop.repeator);
816
+ if (Array.isArray(items) && items[indexNum] !== undefined) {
817
+ scope = this._createItemScope(items[indexNum], indexNum, compData);
818
+ } else {
819
+ scope = { ...compData, _index: indexNum };
820
+ }
821
+ } else {
822
+ scope = { ...compData, _index: indexNum };
823
+ }
824
+ }
825
+ const resolved = resolveAll(payload || {}, scope);
826
+ this._applyPropsToElement(el, id, resolved);
827
+ }
520
828
  this._log('op', `attr.${action} #${id}`, payload);
521
829
  return;
522
830
  }
523
831
 
524
832
  if (action === 'remove') {
525
- const el = this.liveMap.get(id);
526
- if (!el) return;
527
- const attrKey = payload; // may be undefined → remove whole entry
528
- if (attrKey == null || attrKey === '') {
529
- el.textContent = '';
530
- // keep semantic class
531
- el.className = id;
532
- this._log('op', `attr.remove entire #${id}`);
533
- } else if (attrKey === 'text') {
534
- el.textContent = '';
535
- this._log('op', `attr.remove text from #${id}`);
536
- } else if (attrKey === 'class') {
537
- el.className = id;
538
- this._log('op', `attr.remove class from #${id}`);
539
- } else if (attrKey === 'html') {
540
- el.innerHTML = '';
541
- this._log('op', `attr.remove html from #${id}`);
542
- } else {
543
- el.removeAttribute(attrKey);
544
- this._log('op', `attr.remove ${attrKey} from #${id}`);
833
+ const nodes = this.getLiveNodes(id);
834
+ if (nodes.length === 0) return;
835
+ const attrKey = payload;
836
+ for (const el of nodes) {
837
+ if (attrKey == null || attrKey === '') {
838
+ el.textContent = '';
839
+ el.className = id;
840
+ } else if (attrKey === 'text') {
841
+ el.textContent = '';
842
+ } else if (attrKey === 'class') {
843
+ el.className = id;
844
+ } else if (attrKey === 'html') {
845
+ el.innerHTML = '';
846
+ } else {
847
+ el.removeAttribute(attrKey);
848
+ }
545
849
  }
850
+ this._log('op', `attr.remove ${attrKey || 'all'} from #${id}`);
546
851
  }
547
852
  }
548
853
 
549
- /**
550
- * Apply attribute object onto live element.
551
- * Supports: text, class, html, data-*, style object, arbitrary attrs.
552
- */
553
854
  _applyAttrs(id, props) {
554
- if (!props || typeof props !== 'object') return;
555
- const el = this.liveMap.get(id);
556
- if (!el) {
855
+ const nodes = this.getLiveNodes(id);
856
+ if (nodes.length === 0) {
557
857
  this._log('warn', `attr: #${id} not in liveMap`);
558
858
  return;
559
859
  }
560
-
561
- if (props.text !== undefined) {
562
- el.textContent = String(props.text);
563
- }
564
- if (props.html !== undefined) {
565
- el.innerHTML = String(props.html);
566
- }
567
- if (props.class !== undefined) {
568
- const extra = String(props.class).trim();
569
- el.className = extra ? `${id} ${extra}` : id;
570
- }
571
-
572
- for (const [key, value] of Object.entries(props)) {
573
- if (ATTR_SKIP.has(key)) continue;
574
- if (key === 'style' && value && typeof value === 'object') {
575
- Object.assign(el.style, value);
576
- continue;
577
- }
578
- if (value == null) {
579
- el.removeAttribute(key);
580
- } else {
581
- el.setAttribute(key, String(value));
860
+ const compData = this._getComponentData();
861
+ for (const el of nodes) {
862
+ const idx = el.getAttribute('data-wdl-index');
863
+ let scope = compData;
864
+ if (idx !== null && idx !== undefined) {
865
+ const indexNum = parseInt(idx, 10);
866
+ const loop = this._findLoopForElement(el);
867
+ if (loop) {
868
+ const items = resolvePath(compData, loop.repeator);
869
+ if (Array.isArray(items) && items[indexNum] !== undefined) {
870
+ scope = this._createItemScope(items[indexNum], indexNum, compData);
871
+ } else {
872
+ scope = { ...compData, _index: indexNum };
873
+ }
874
+ }
582
875
  }
876
+ const resolved = resolveAll(props || {}, scope);
877
+ this._applyPropsToElement(el, id, resolved);
583
878
  }
584
879
  }
585
880
 
881
+ _findLoopForElement(el) {
882
+ return this._loopBindings.find((l) => l.elements.includes(el)) || null;
883
+ }
884
+
586
885
  // -----------------------------------------------------------------------
587
886
  // data:change
588
- // actions: set | remove
589
- // Optional: onDataBind callback for custom binding
887
+ // actions: set | update | remove
888
+ // Surgically reconciles repeated loop items & per-item template bindings
590
889
  // -----------------------------------------------------------------------
591
890
 
592
891
  _handleData(event) {
593
892
  const { action, targetId, payload } = event;
594
- // targetId is the path (e.g. "user.name"), payload is the value
595
893
  const path = targetId || '';
894
+ const compData = this._getComponentData();
895
+
896
+ // 1. Surgically reconcile all matching loop bindings
897
+ this._reconcileLoops(path, compData);
898
+
899
+ // 2. Reconcile non-loop elements bound to this path or full data
900
+ this._reconcileStaticDataBindings(path, compData);
901
+
902
+ // 3. Optional onDataBind callback hook for external consumers
596
903
  if (typeof this.onDataBind === 'function') {
597
- // Let consumer decide how data maps to DOM
598
- // We pass the root container + path + value
599
904
  try {
600
- this.onDataBind(this.container, path, action === 'remove' ? undefined : payload);
905
+ this.onDataBind(
906
+ this.container,
907
+ path,
908
+ action === 'remove' ? undefined : payload
909
+ );
601
910
  } catch (e) {
602
911
  this._log('warn', 'onDataBind error', e);
603
912
  }
@@ -605,6 +914,113 @@ export class WdlDom {
605
914
  this._log('op', `data.${action} ${path}`, action === 'remove' ? undefined : payload);
606
915
  }
607
916
 
917
+ /**
918
+ * Reconcile loop children surgically on data change.
919
+ */
920
+ _reconcileLoops(path, compData) {
921
+ for (const loop of this._loopBindings) {
922
+ const matches =
923
+ !path ||
924
+ loop.repeator === path ||
925
+ path.startsWith(loop.repeator + '.') ||
926
+ loop.repeator.startsWith(path + '.');
927
+
928
+ if (!matches) continue;
929
+
930
+ const items = this._resolveLoopItems(loop.repeator, compData);
931
+ const nextCount = items.length;
932
+ const prevCount = loop.elements.length;
933
+ const minCount = Math.min(prevCount, nextCount);
934
+
935
+ // 1. Update retained rows in place
936
+ for (let i = 0; i < minCount; i++) {
937
+ const el = loop.elements[i];
938
+ const itemScope = this._createItemScope(items[i], i, compData);
939
+ el.setAttribute('data-wdl-index', String(i));
940
+ if (el.dataset) el.dataset.wdlIndex = String(i);
941
+ this._updateElementSubtree(el, loop.node, itemScope, i);
942
+ }
943
+
944
+ // 2. Insert newly added rows
945
+ if (nextCount > prevCount) {
946
+ for (let i = prevCount; i < nextCount; i++) {
947
+ const itemScope = this._createItemScope(items[i], i, compData);
948
+ const newEl = this._createSingleElement(loop.node, itemScope, i);
949
+ if (loop.anchor && loop.anchor.parentNode === loop.parentEl) {
950
+ loop.parentEl.insertBefore(newEl, loop.anchor);
951
+ } else if (loop.elements.length > 0) {
952
+ const lastEl = loop.elements[loop.elements.length - 1];
953
+ loop.parentEl.insertBefore(newEl, lastEl.nextSibling);
954
+ } else {
955
+ loop.parentEl.appendChild(newEl);
956
+ }
957
+ loop.elements.push(newEl);
958
+ }
959
+ }
960
+
961
+ // 3. Remove deleted rows
962
+ if (nextCount < prevCount) {
963
+ for (let i = prevCount - 1; i >= nextCount; i--) {
964
+ const delEl = loop.elements[i];
965
+ this._unregisterElementHierarchy(delEl);
966
+ delEl.remove();
967
+ loop.elements.pop();
968
+ }
969
+ }
970
+ }
971
+ }
972
+
973
+ /**
974
+ * Update an element and its non-loop children with new itemScope.
975
+ */
976
+ _updateElementSubtree(el, node, itemScope, index) {
977
+ this._applyNodeAttrs(el, node, itemScope);
978
+
979
+ if (Array.isArray(node.children) && Array.isArray(el.children)) {
980
+ let childElIdx = 0;
981
+ for (const childNode of node.children) {
982
+ if (childNode.repeator) {
983
+ // Nested loop: find its binding and reconcile
984
+ const childLoop = this._loopBindings.find(
985
+ (l) => l.parentEl === el && l.node === childNode
986
+ );
987
+ if (childLoop) {
988
+ this._reconcileLoops(childLoop.repeator, itemScope);
989
+ }
990
+ } else if (el.children[childElIdx]) {
991
+ this._updateElementSubtree(
992
+ el.children[childElIdx],
993
+ childNode,
994
+ itemScope,
995
+ index
996
+ );
997
+ childElIdx++;
998
+ }
999
+ }
1000
+ }
1001
+ }
1002
+
1003
+ /**
1004
+ * Update non-loop live elements that reference template data bindings.
1005
+ */
1006
+ _reconcileStaticDataBindings(path, compData) {
1007
+ const attrMap = this._getComponentAttrMap();
1008
+ for (const [key, props] of Object.entries(attrMap)) {
1009
+ const id = normalizeId(key);
1010
+ const str = JSON.stringify(props);
1011
+ if (!str.includes('${') && !str.includes('{{')) continue;
1012
+ if (path && !str.includes(path)) continue;
1013
+
1014
+ const nodes = this.getLiveNodes(id);
1015
+ for (const el of nodes) {
1016
+ // Skip elements governed by loops as they are reconciled separately
1017
+ if (el.hasAttribute('data-wdl-index') || this._findLoopForElement(el)) continue;
1018
+ const resolved = resolveAll(props, compData);
1019
+ this._applyPropsToElement(el, id, resolved);
1020
+ }
1021
+ }
1022
+ }
1023
+
608
1024
  // -----------------------------------------------------------------------
609
1025
  // variant:change
610
1026
  // action: set targetId = semanticId (or component id) payload = variantName
@@ -613,11 +1029,10 @@ export class WdlDom {
613
1029
  _handleVariant(event) {
614
1030
  const { targetId, payload } = event;
615
1031
  const id = normalizeId(targetId) || this._rootSemanticId();
616
- const el = this.liveMap.get(id);
1032
+ const el = this.getNode(id);
617
1033
  if (!el) {
618
- // try root
619
1034
  const rootId = this._rootSemanticId();
620
- const rootEl = rootId ? this.liveMap.get(rootId) : null;
1035
+ const rootEl = rootId ? this.getNode(rootId) : null;
621
1036
  if (rootEl) {
622
1037
  if (payload) rootEl.dataset.variant = String(payload);
623
1038
  else delete rootEl.dataset.variant;
@@ -631,11 +1046,7 @@ export class WdlDom {
631
1046
  }
632
1047
 
633
1048
  _applyRootVariant() {
634
- // Read current variant from attr if present
635
- const attr =
636
- this.component.attr?.list?.() ??
637
- this.component.getSnapshot?.()?.attr ??
638
- {};
1049
+ const attr = this._getComponentAttrMap();
639
1050
  const rootId = this._rootSemanticId();
640
1051
  if (!rootId) return;
641
1052
  const rootKey = '.' + rootId;
@@ -644,12 +1055,12 @@ export class WdlDom {
644
1055
  attr['.']?.['data-variant'] ??
645
1056
  null;
646
1057
  if (variant && this.liveMap.has(rootId)) {
647
- this.liveMap.get(rootId).dataset.variant = String(variant);
1058
+ const el = this.getNode(rootId);
1059
+ if (el && el.dataset) el.dataset.variant = String(variant);
648
1060
  }
649
1061
  }
650
1062
 
651
1063
  _rootSemanticId() {
652
- // First entry in liveMap that is a direct child of container, or first key
653
1064
  for (const [id, el] of this.liveMap) {
654
1065
  if (el.parentNode === this.container) return id;
655
1066
  }
@@ -751,3 +1162,4 @@ export function createWdlDom(options) {
751
1162
  }
752
1163
 
753
1164
  export default createWdlDom;
1165
+