lime-csr-js 0.1.4

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/LICENCE.md ADDED
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mehmet Sönmez
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ of this software and associated documentation files (the "Software"), to deal
6
+ in the Software without restriction, including without limitation the rights
7
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the Software is
9
+ furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all
12
+ copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,150 @@
1
+ # lime-csr.js
2
+
3
+ An HTML-first client-side rendering engine built with standard browser APIs.
4
+ Templates stay in HTML; the small ESM runtime provides reactive state,
5
+ bindings, structural blocks, and delegated events without expression eval.
6
+
7
+ ## Why?
8
+
9
+ lime-csr.js is for browser-first pages that benefit from declarative HTML but
10
+ do not need a compiler, virtual DOM, router, or framework runtime. Source
11
+ modules run directly in modern browsers during development; a bundled ESM
12
+ file is included for production convenience.
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install lime-csr-js
18
+ ```
19
+
20
+ Package consumers import the public entry point:
21
+
22
+ ```js
23
+ import { createStore, mount } from 'lime-csr-js';
24
+ ```
25
+
26
+ The repository-only source import below is useful when cloning this project;
27
+ it is not the recommended import path from an installed npm package.
28
+
29
+ ```js
30
+ import { createStore, mount } from './src/index.js';
31
+ ```
32
+
33
+ ## Quick Start
34
+
35
+ ```html
36
+ <template id="tpl-counter">
37
+ <button data-on-click="increment">
38
+ Count: <span data-text="count"></span>
39
+ </button>
40
+ </template>
41
+ <main id="app"></main>
42
+
43
+ <script type="module">
44
+ import { createStore, mount } from 'lime-csr-js';
45
+
46
+ const store = createStore({ count: 0 });
47
+ mount('counter', {}, document.getElementById('app'), store, {
48
+ handlers: {
49
+ increment() {
50
+ store.update('count', (count) => count + 1);
51
+ },
52
+ },
53
+ });
54
+ </script>
55
+ ```
56
+
57
+ ## Core Concepts
58
+
59
+ - `createStore(initialState)` exposes `get`, `set`, `update`, `subscribe`, and
60
+ `computed` for path-based reactive state.
61
+ - `${path}` is static interpolation from the context passed to `mount`.
62
+ - `data-text`, `data-model`, `data-show`, and `{x}`/`data-x` read reactively
63
+ from the store.
64
+ - `<if>`, `<for>`, and `<partial>` are structural template elements. Add
65
+ `data-live` to `<if>` or keyed `<for>` blocks when the store should update
66
+ them.
67
+ - `data-on-click`, `data-on-input`, `data-on-change`, `data-on-submit`, and
68
+ `data-on-keydown` use event delegation and named handler functions.
69
+
70
+ See [DOCS.md](DOCS.md) for all syntax, lifecycle semantics, error codes, and
71
+ limitations.
72
+
73
+ ## Browser / CDN Usage
74
+
75
+ Development can import repository source files with a relative module path.
76
+ For production, use the bundled ESM file from the published npm package:
77
+
78
+ ```html
79
+ <script type="module">
80
+ import { createStore, mount } from
81
+ 'https://cdn.jsdelivr.net/npm/lime-csr-js@<published-version>/dist/index.min.js';
82
+ </script>
83
+ ```
84
+
85
+ Replace `<published-version>` with an exact published version (for example,
86
+ the version in the release tag). Do not use `@latest` in production. The
87
+ `dist/index.min.js` path is present in the npm tarball and is browser-native
88
+ ESM; jsDelivr will serve that same file after npm publication.
89
+
90
+ ## API Overview
91
+
92
+ ```js
93
+ const cleanup = mount(templateName, context, target, store, options);
94
+ unmount(target);
95
+ cleanup();
96
+ ```
97
+
98
+ Calling `mount` again for the same target cleans up the previous mount first.
99
+ Both `cleanup()` and `unmount(target)` cancel store subscriptions, model
100
+ listeners, and delegated event listeners.
101
+
102
+ ## Examples
103
+
104
+ Open the HTML files in [examples](examples/) through a local static server.
105
+ They import `../src/index.js` and are intended for repository development.
106
+
107
+ ## Runtime Support
108
+
109
+ Modern browsers with native ES modules, `<template>`, `WeakMap`, `Map`, and
110
+ standard DOM APIs are required. The npm development toolchain requires Node
111
+ 20.19 or newer.
112
+
113
+ ## Security
114
+
115
+ Template paths and event attributes are identifiers, not JavaScript
116
+ expressions: the runtime does not use `eval` or `new Function`. Interpolated
117
+ text is assigned through DOM text APIs, reactive event-handler attributes are
118
+ rejected, and reactive URL attributes permit only `http`, `https`,
119
+ root-relative, or fragment URLs. Templates remain application-authored HTML;
120
+ do not insert untrusted HTML into template markup.
121
+
122
+ ## Known Limitations
123
+
124
+ `<if>`, `<for>`, and `<partial>` should not be placed directly in tables due
125
+ to HTML parser foster parenting. Live condition branches are rebuilt when the
126
+ condition changes, and live lists require unique keys. See the detailed
127
+ limitations in [DOCS.md](DOCS.md#8-known-limitations).
128
+
129
+ ## Technical Documentation
130
+
131
+ [DOCS.md](DOCS.md) is the complete public reference. Documentation and
132
+ implementation are expected to agree; report a mismatch as a bug.
133
+
134
+ ## Development
135
+
136
+ ```bash
137
+ npm ci
138
+ npm run lint
139
+ npm run typecheck
140
+ npm test
141
+ npm run build
142
+ npm pack --dry-run
143
+ ```
144
+
145
+ `npm run build` creates the production ESM bundle. `prepack` runs that build,
146
+ so `npm pack` and `npm publish` cannot package a stale bundle.
147
+
148
+ ## License
149
+
150
+ MIT — see [LICENCE.md](LICENCE.md).
@@ -0,0 +1 @@
1
+ var ie=!0;function Ge(e){ie=!!e}function se(){return ie}function Xe(e,t){if(typeof document>"u")return;let n=document.getElementById("lime-csr-error-overlay-container");n||(n=document.createElement("div"),n.id="lime-csr-error-overlay-container",n.style.cssText="position:fixed;bottom:16px;right:16px;z-index:999999;max-width:350px;display:flex;flex-direction:column;gap:8px;font-family:sans-serif;font-size:13px;",document.body.appendChild(n));let o=document.createElement("div");o.style.cssText="background:#f87171;color:#fff;padding:12px 16px;border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.15);display:flex;align-items:flex-start;justify-content:space-between;gap:12px;animation:lime-fade-in 0.2s ease;border-left:4px solid #b91c1c;";let r=document.createElement("div"),i=document.createElement("strong");i.style.cssText="display:block;margin-bottom:4px;font-weight:bold;",i.textContent=`[lime-csr] ${e}`;let a=document.createElement("span");a.style.cssText="opacity:0.95;line-height:1.4;",a.textContent=t,r.append(i,a),o.appendChild(r);let s=document.createElement("button");if(s.textContent="\xD7",s.style.cssText="background:none;border:none;color:#fff;font-size:18px;cursor:pointer;opacity:0.7;padding:0;line-height:1;font-weight:bold;",s.onmouseover=()=>{s.style.opacity="1"},s.onmouseout=()=>{s.style.opacity="0.7"},s.onclick=()=>{o.remove(),n.childNodes.length===0&&n.remove()},o.appendChild(s),n.appendChild(o),!document.getElementById("lime-csr-overlay-style")){let c=document.createElement("style");c.id="lime-csr-overlay-style",c.textContent="@keyframes lime-fade-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }",document.head.appendChild(c)}}function m(e,t,n){ie&&(n!==void 0?console.warn(`[lime-csr] ${e}: ${t}`,n):console.warn(`[lime-csr] ${e}: ${t}`),Xe(e,t))}var d={unknownOperator(e,t,n){m("UNKNOWN_OPERATOR",`Unknown condition operator: "${e}". Valid operators: ${t.join(", ")}. Use is-truthy, is-eq, is-gt, etc.`,n)},missingOperator(e){m("MISSING_OPERATOR","No condition operator found on <if>. Add at least one operator attribute: is-gt, is-lt, is-eq, is-truthy...",e)},elseAfterContent(e){m("ELSE_AFTER_CONTENT","<else> must be the last direct child of <if>; nodes after it were counted as then. Move the <else>...</else> block to the end.",e)},partialNotFound(e,t,n){let o=t.length?t.join(", "):"(no registered partials)";m("PARTIAL_NOT_FOUND",`Partial not found: "${e}". Registered partials: ${o}. Is <template id="tpl-${e}"> defined?`,n)},partialMissingName(e){m("PARTIAL_MISSING_NAME",'<partial> element is missing the "name" attribute. Use <partial name="..."></partial>.',e)},partialDepthLimit(e,t){m("PARTIAL_DEPTH_LIMIT",`Partial depth limit (${e}) reached (possible infinite loop). A partial may be calling itself directly or indirectly.`,t)},templateNotFound(e){m("TEMPLATE_NOT_FOUND",`Template not found: "tpl-${e}". Is <template id="tpl-${e}"> present on the page?`)},forMissingAttr(e){m("FOR_MISSING_ATTR",'<for> element is missing the "each" and/or "as" attribute. Use <for each="array.path" as="item"></for>.',e)},forNotArray(e,t,n){m("FOR_NOT_ARRAY",`<for each="${e}"> value must be an array; got: ${t}. Is "${e}" an array in context or store?`,n)},bindingMissingPath(e){m("BINDING_MISSING_PATH",'data-text attribute is empty; a store path is required. Use data-text="path.to.value".',e)},bindingMissingDataAttr(e,t,n){m("BINDING_MISSING_DATA_ATTR",`No data-${t} attribute found for {${t}} in "${e}"; binding skipped. Add data-${t}="store.path" or remove the {${t}} placeholder.`,n)},unsafeEventAttr(e,t){m("UNSAFE_EVENT_ATTR",`"${e}" is an event-handler attribute; reactive data cannot bind to it. Use data-on-{event} for events instead (see README).`,t)},liveIfMissingOperator(e){m("LIVE_IF_MISSING_OP","<if data-live>: no valid condition operator found. Add one: is-gt, is-lt, is-eq, is-truthy, etc.",e)},pipelineDepthLimit(e,t){m("PIPELINE_DEPTH_LIMIT",`Render pipeline reached the ${e} iteration limit (possible infinite loop). Stopping. Is a partial calling itself?`,t)},mountTemplateNotFound(e,t,n){let o=t.length?t.join(", "):"(no registered templates)";m("MOUNT_TEMPLATE_NOT_FOUND",`mount(): template "${e}" not found. Registered templates: ${o}. Is <template id="tpl-${e}"> defined?`,n)},missingKey(e){m("FOR_MISSING_KEY",`Reactive <for data-live>: missing "key" attribute. Add a key for efficient DOM updates. (template: ${e??"?"})`)},duplicateKey(e,t){m("FOR_DUPLICATE_KEY",`Reactive <for data-live>: key "${e}" appears on more than one element; keys must be unique. (template: ${t??"?"})`)},modelMissingPath(e){m("MODEL_MISSING_PATH",'data-model attribute is empty; a store path is required. Use data-model="path.to.value".',e)},tableFosterParenting(e){m("TABLE_FOSTER_PARENTING",`<if>/<for>/<partial> cannot be used inside <table> \u2014 the HTML parser moves them outside. Solution: move the condition/loop outside the <table>, or treat the tbody as a partial. (template: ${e??"?"})`)},showMissingPath(e){m("SHOW_MISSING_PATH",'data-show attribute is empty; a store path is required. Use data-show="path.to.value".',e)},unknownEvent(e,t,n){m("UNKNOWN_EVENT",`Unsupported event type: "data-on-${e}". Valid types: ${t.map(o=>`data-on-${o}`).join(", ")}.`,n)},handlerNotFound(e,t,n){let o=t.length?t.join(", "):"(no registered handlers)";m("HANDLER_NOT_FOUND",`Handler not found: "${e}". Registered handlers: ${o}. Is "${e}" defined in the handlers object passed to mount()?`,n)},reservedAttrName(e,t){m("RESERVED_ATTR_NAME",`"${e}" is reserved by lime-csr and cannot be used as a {x}/data-x placeholder. Reserved names: text, model, show, live, ref, diff, and any name starting with "on-". Rename the placeholder.`,t)},indexedModelPath(e,t){m("INDEXED_MODEL_PATH",`data-model="${e}" contains a numeric index (e.g. items.0.name). This is unsafe: if the array is mutated, the path drifts to the wrong item. Use a reactive <for data-live key=...> loop and bind to the loop variable instead.`,t)},computedManualSet(e){m("COMPUTED_MANUAL_SET",`Path "${e}" is managed by store.computed(). Manual store.set() will be overwritten on the next dep change. Use store.computed() or choose a different path.`)},inPlaceMutation(e){m("IN_PLACE_MUTATION",`store.set("${e}", value): value is the SAME reference as the stored object/array. In-place mutation detected \u2014 subscribers will NOT fire. Pass a new reference, e.g. store.set("${e}", [...arr]) or {...obj}.`)},unknownDiffStrategy(e,t){m("UNKNOWN_DIFF_STRATEGY",`<for data-live>: unknown data-diff value "${e}". Valid values: simple, lcs, replace (or omit the attribute). Falling back to "simple". (template: ${t??"?"})`)},blockAfterNotFound(e,t,n){let o=t.length?t.join(", "):"(no registered handlers)";m("BLOCK_AFTER_NOT_FOUND",`data-after handler not found: "${e}". Registered handlers: ${o}. Is "${e}" defined in the handlers object passed to mount()?`,n)},blockBeforeNotFound(e,t,n){let o=t.length?t.join(", "):"(no registered handlers)";m("BLOCK_BEFORE_NOT_FOUND",`data-before handler not found: "${e}". Registered handlers: ${o}. Is "${e}" defined in the handlers object passed to mount()?`,n)}};function _(e,t){let n=String(t).split(".");if(!n.some(o=>ae.has(o)))return n.reduce((o,r)=>{if(!(o==null||!Object.hasOwn(o,r)))return o[r]},e)}var ae=new Set(["__proto__","constructor","prototype"]);function ce(e,t,n){let o=String(t).split("."),r=o.pop();if(ae.has(r)||o.some(s=>ae.has(s)))return{changed:!1};let i=o.reduce((s,c)=>((s[c]==null||typeof s[c]!="object")&&(s[c]={}),s[c]),e),a=i[r];return Object.is(a,n)?{changed:!1}:(i[r]=n,{changed:!0,previousValue:a})}function Ye(e={}){let t=new Map,n=new Set,o=new Set;function r(i,a){let s=String(i).split(".");s.forEach((f,u)=>{let l=s.slice(0,u+1).join("."),b=t.get(l);if(!b)return;let g=_(e,l);b.forEach(w=>w(g,a,i))});let c=i+".";for(let[f,u]of t){if(!f.startsWith(c))continue;let l=_(e,f);u.forEach(b=>b(l,a,i))}}return{get(i=""){return i?_(e,i):e},set(i,a){n.has(i)&&!o.has(i)&&m("COMPUTED_MANUAL_SET",`Path "${i}" is managed by store.computed(). Manual store.set() will be overwritten on next dep change. Use store.computed() or a different path.`);let s=_(e,i);if(a!==null&&typeof a=="object"&&Object.is(s,a))return m("IN_PLACE_MUTATION",`store.set("${i}", value): value is the SAME reference as the stored object/array. In-place mutation detected \u2014 subscriber will NOT fire. Pass a new reference: e.g. store.set("${i}", [...arr]) or store.set("${i}", {...obj}).`),!1;let c=ce(e,i,a);return c.changed&&r(i,c.previousValue),c.changed},update(i,a){return this.set(i,a(this.get(i)))},subscribe(i,a){return t.has(i)||t.set(i,new Set),t.get(i).add(a),()=>{let s=t.get(i);s&&(s.delete(a),s.size===0&&t.delete(i))}},computed(i,a,s){n.add(i);let c=()=>{if(!o.has(i)){o.add(i);try{let u=s(),l=ce(e,i,u);l.changed&&r(i,l.previousValue)}finally{o.delete(i)}}};c();let f=a.map(u=>this.subscribe(u,c));return function(){for(let l of f)l();n.delete(i)}}}}function E(e){if(!e)return!1;if(e.nodeType!==1){let n=e.parentElement;return!!(n?.closest?.("if[data-live]")||n?.closest?.("for[data-live]"))}return e.matches?.("if[data-live]")||e.matches?.("for[data-live]")?!1:!!(e.closest?.("if[data-live]")||e.closest?.("for[data-live]"))}function Q(e){return e?e.nodeType!==1?!!e.parentElement?.closest?.("for:not([data-live])"):e.matches?.("for:not([data-live])")?!1:!!e.closest?.("for:not([data-live])"):!1}function ke(e){let t=e.length;if(t===0)return new Set;let n=[],o=new Array(t).fill(-1);for(let a=0;a<t;a++){let s=e[a],c=0,f=n.length;for(;c<f;){let u=c+f>>1;e[n[u]]<s?c=u+1:f=u}c>0&&(o[a]=n[c-1]),c===n.length?n.push(a):n[c]=a}let r=new Set,i=n[n.length-1];for(;i!==-1;)r.add(i),i=o[i];return r}var le=new Map,ze=new Set(["IF","FOR","ELSE"]),Je="tr, td, th, tbody, thead, tfoot, caption, col, colgroup",W=/\$\{([^}]+)\}/g;function Qe(e,t){let n=_(t,e.trim());return n==null?"":String(n)}function Ie(e,t){return e.replace(W,(n,o)=>Qe(o,t))}function Ze(e,t){for(let n of e.querySelectorAll("table")){let o=n.previousElementSibling;if(!o||!ze.has(o.tagName))continue;if(o.childElementCount===0&&o.textContent.trim()===""&&n.querySelector(Je)){d.tableFosterParenting(t);return}}}function Z(e){if(!le.has(e)){let t=document.getElementById(`tpl-${e}`);if(!t||t.tagName!=="TEMPLATE")return d.templateNotFound(e),null;se()&&Ze(t.content,e),le.set(e,t.content)}return le.get(e).cloneNode(!0)}function U(e,t){let n=document.createTreeWalker(e,NodeFilter.SHOW_TEXT|NodeFilter.SHOW_ELEMENT),o=n.nextNode();for(;o;){if(E(o)||Q(o)){o=n.nextNode();continue}if(o.nodeType===Node.TEXT_NODE)W.test(o.nodeValue)&&(W.lastIndex=0,o.nodeValue=Ie(o.nodeValue,t));else if(o.nodeType===Node.ELEMENT_NODE)for(let r of Array.from(o.attributes))W.test(r.value)&&(W.lastIndex=0,r.value=Ie(r.value,t));o=n.nextNode()}}function fe(e,t={}){let n=Z(e);return n?(U(n,t),n):null}var Pe=50,et=new Set(["__proto__","constructor","prototype"]);function ee(e,t,n=0,o=null){let r=Array.from(e.querySelectorAll("partial"));if(n>=Pe){if(r.length>0){d.partialDepthLimit(Pe,e);for(let i of r)i.remove()}return}for(let i of r){if(!e.contains(i)||E(i)||Q(i))continue;let a=i.getAttribute("name");if(!a){d.partialMissingName(i),i.remove();continue}let s=i.getAttribute("data"),f={...s?_(t,s)??{}:{}};for(let l of Array.from(i.attributes))l.name==="name"||l.name==="data"||l.name.startsWith("data-")||et.has(l.name)||(f[l.name]=_(t,l.value));let u=fe(a,f);if(!u){let l=Array.from(document.querySelectorAll('template[id^="tpl-"]')).map(b=>b.id.slice(4));d.partialNotFound(a,l,i),i.remove();continue}o?o(u,f,n+1):ee(u,f,n+1),i.replaceWith(u)}}function te(e,t,n=null){let o=Array.from(e.querySelectorAll("for"));for(let r of o){if(!e.contains(r)||r.hasAttribute("data-live")||E(r))continue;let i=r.getAttribute("each"),a=r.getAttribute("as"),s=r.getAttribute("index");if(!i||!a){d.forMissingAttr(r),r.remove();continue}let c=_(t,i);if(!Array.isArray(c)){d.forNotArray(i,c===null?"null":typeof c,r),r.remove();continue}if(c.length===0){r.remove();continue}let f=Array.from(r.childNodes).map(l=>l.cloneNode(!0)),u=[];for(let l=0;l<c.length;l++){let b=c[l],g={...t,[a]:b};s&&(g[s]=l);let w=document.createDocumentFragment();for(let P of f)w.appendChild(P.cloneNode(!0));U(w,g),n?n(w,g):te(w,g),u.push(...Array.from(w.childNodes))}r.replaceWith(...u)}}var V={"is-gt":(e,t)=>Number(e)>Number(t),"is-lt":(e,t)=>Number(e)<Number(t),"is-gte":(e,t)=>Number(e)>=Number(t),"is-lte":(e,t)=>Number(e)<=Number(t),"is-eq":(e,t)=>String(e)===String(t),"is-neq":(e,t)=>String(e)!==String(t),"is-truthy":e=>!!e},Me=Object.keys(V);function Ce(e,t){let n=Me.find(a=>e.hasAttribute(a));if(!n){let a=Array.from(e.attributes).find(s=>s.name.startsWith("is-"));return a?d.unknownOperator(a.name,Me,e):d.missingOperator(e),!1}let o=e.getAttribute(n),r=_(t,o),i=e.getAttribute("than")??e.getAttribute("to")??"";return!!V[n](r,i)}function tt(e,t){let n=Ce(e,t),o=Array.from(e.childNodes),r=o.find(s=>s.nodeType===Node.ELEMENT_NODE&&s.tagName==="ELSE")??null;if(r){let s=o.indexOf(r);o.slice(s+1).filter(f=>f.nodeType===Node.ELEMENT_NODE).length>0&&d.elseAfterContent(e)}let i=o.filter(s=>s!==r),a=r?Array.from(r.childNodes):[];e.replaceWith(...n?i:a)}function ue(e,t){let n;for(;(n=Array.from(e.querySelectorAll("if"))).length>0;){let o=n.filter(r=>!r.parentElement?.closest("if")&&!r.hasAttribute("data-live")&&!E(r));if(o.length===0)break;for(let r of o)tt(r,t)}}function Le(e=""){return String(e??"").replace(/[&<>"']/g,t=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"})[t])}function $e(e=""){return Le(e).replace(/`/g,"&#096;")}function de(e=""){let t=String(e??"").trim();return t?/^https?:\/\//i.test(t)||t.startsWith("/")&&!t.startsWith("//")||t.startsWith("#"):!1}function De(e=""){let t=String(e??"").trim();return de(t)?$e(t):""}function nt(e=""){let t=De(e);return t?`url('${t.replace(/'/g,"%27")}')`:""}var ot=new Set(["href","src","action","formaction","data","cite","poster","ping"]),rt=/^on/i,Be=/\{([^}]+)\}/g,it=new Set(["text","model","show","live","ref","diff"]);function st(e){return it.has(e)||e.startsWith("on-")}var at=0;function ct(){return`lcsr-${++at}`}function lt(e,t){let n=[];for(let o of e.querySelectorAll("[data-text]")){if(E(o))continue;let r=o.getAttribute("data-text");if(!r){d.bindingMissingPath(o);continue}o.textContent=t.get(r)??"",n.push(t.subscribe(r,i=>{o.textContent=i??""}))}return n}function ft(e,t){let n=[],o=[...e.nodeType===Node.ELEMENT_NODE&&!E(e)?[e]:[],...Array.from(e.querySelectorAll("*")).filter(r=>!E(r))];for(let r of o){let i=Array.from(r.attributes),a=[];for(let c of i){if(c.name.startsWith("data-")||(Be.lastIndex=0,!Be.test(c.value)))continue;if(rt.test(c.name)){d.unsafeEventAttr(c.name,r);continue}let f=c.value,u={},l=!0;for(let[,b]of f.matchAll(/\{([^}]+)\}/g)){if(b in u)continue;if(st(b)){d.reservedAttrName(b,r),l=!1;break}let g=r.getAttribute(`data-${b}`);if(!g){d.bindingMissingDataAttr(c.name,b,r),l=!1;break}u[b]=g}l&&Object.keys(u).length>0&&a.push({attrName:c.name,template:f,bindings:u})}if(a.length===0)continue;r.dataset.ref||(r.dataset.ref=ct());let s=new Set(a.flatMap(({bindings:c})=>Object.keys(c)));for(let c of s)r.removeAttribute(`data-${c}`);for(let{attrName:c,template:f,bindings:u}of a){let l=()=>{let g=f.replace(/\{([^}]+)\}/g,(w,P)=>String(t.get(u[P])??""));ot.has(c)&&(g=de(g)?g:""),r.setAttribute(c,g)};l();let b=[...new Set(Object.values(u))];for(let g of b)n.push(t.subscribe(g,l))}}return n}function pe(e,t){let n=[...lt(e,t),...ft(e,t)];return function(){for(let r of n)r();n.length=0}}var ne="data-model",ut=/(?:^|\.)\d+(?:\.|$)/;function Fe(e){if(e.tagName==="SELECT")return e.multiple?"select-multiple":"select-single";if(e.tagName==="TEXTAREA")return"text";let t=(e.getAttribute("type")||"text").toLowerCase();return t==="checkbox"?"checkbox":t==="radio"?"radio":t==="number"||t==="range"?"number":"text"}var dt={text:{event:"input",read:e=>e.value,write:(e,t)=>{let n=t==null?"":String(t);e.value!==n&&(e.value=n)}},number:{event:"input",read:e=>{let t=e.value;if(t==="")return"";let n=Number(t);return Number.isNaN(n)?t:n},write:(e,t)=>{let n=t==null?"":String(t);e.value!==n&&(e.value=n)}},checkbox:{event:"change",read:e=>e.checked,write:(e,t)=>{let n=!!t;e.checked!==n&&(e.checked=n)}},"select-single":{event:"change",read:e=>e.value,write:(e,t)=>{let n=t==null?"":String(t);e.value!==n&&(e.value=n)}},"select-multiple":{event:"change",read:e=>Array.from(e.selectedOptions).map(t=>t.value),write:(e,t)=>{let n=Array.isArray(t)?t.map(String):[];for(let o of e.options){let r=n.includes(o.value);o.selected!==r&&(o.selected=r)}}}};function pt(e,t){let n=e.getAttribute(ne),o=dt[Fe(e)];ut.test(n)&&d.indexedModelPath(n,e),o.write(e,t.get(n));let r=()=>{t.set(n,o.read(e))};e.addEventListener(o.event,r);let i=t.subscribe(n,a=>o.write(e,a));return function(){e.removeEventListener(o.event,r),i()}}function mt(e,t,n){let o=a=>{let s=a==null?"":String(a);for(let c of t){let f=c.value===s;c.checked!==f&&(c.checked=f)}};o(n.get(e));let r=t.map(a=>{let s=()=>{a.checked&&n.set(e,a.value)};return a.addEventListener("change",s),{radio:a,onChange:s}}),i=n.subscribe(e,o);return function(){for(let{radio:s,onChange:c}of r)s.removeEventListener("change",c);i()}}function me(e,t){let n=[],o=[...e.nodeType===Node.ELEMENT_NODE&&e.hasAttribute?.(ne)&&!E(e)?[e]:[],...Array.from(e.querySelectorAll(`[${ne}]`)).filter(a=>!E(a))],r=new Map,i=[];for(let a of o){let s=a.getAttribute(ne);if(!s){d.modelMissingPath(a);continue}Fe(a)==="radio"?(r.has(s)||r.set(s,[]),r.get(s).push(a)):i.push(a)}for(let a of i)n.push(pt(a,t));for(let[a,s]of r)n.push(mt(a,s,t));return function(){for(let s of n)s();n.length=0}}var he="data-show";function ge(e,t){let n=[],o=[...e.nodeType===Node.ELEMENT_NODE&&e.hasAttribute?.(he)&&!E(e)?[e]:[],...Array.from(e.querySelectorAll(`[${he}]`)).filter(r=>!E(r))];for(let r of o){let i=r.getAttribute(he);if(!i){d.showMissingPath(r);continue}let a=r.style.display,s=c=>{r.style.display=c?a:"none"};s(t.get(i)),n.push(t.subscribe(i,s))}return function(){for(let i of n)i();n.length=0}}var Ue=new Set(["click","input","change","submit","keydown"]),ht=/^data-on-(.+)$/;function gt(){let e=new Set;for(let t of document.querySelectorAll("template"))for(let n of t.content.querySelectorAll("*"))for(let o of n.attributes){let r=ht.exec(o.name);if(!r)continue;let i=r[1];Ue.has(i)?e.add(i):d.unknownEvent(i,Array.from(Ue),n)}return e}function be(e,t,n){let o=gt(),r=[];for(let i of o){let a=`data-on-${i}`,s=c=>{let f=c.target.closest?.(`[${a}]`);if(!f||!e.contains(f))return;i==="submit"&&c.preventDefault();let u=f.getAttribute(a),l=Object.hasOwn(n,u)?n[u]:void 0;if(!l){d.handlerNotFound(u,Object.keys(n),f);return}l(c,f)};e.addEventListener(i,s),r.push({type:i,onEvent:s})}return function(){for(let{type:a,onEvent:s}of r)e.removeEventListener(a,s);r.length=0}}var ye=Object.keys(V),bt=new Set([...ye,"than","to","data-live","el","data-after","data-before"]);function Ae(e,t,n,o,r){if(!e)return;let i=o&&Object.hasOwn(o,e)?o[e]:void 0;if(typeof i!="function"){let a=o?Object.keys(o):[];r==="after"?d.blockAfterNotFound(e,a,t):d.blockBeforeNotFound(e,a,t);return}i(t,n)}function Ve(e){return e.find(t=>t.nodeType===Node.ELEMENT_NODE)??null}var At=0;function yt(){return`lb${++At}`}function je(e,t){let n=ye.find(a=>e.hasAttribute(a));if(!n)return d.liveIfMissingOperator(e),!1;let o=e.getAttribute(n),r=t.get(o),i=e.getAttribute("than")??e.getAttribute("to")??"";return!!V[n](r,i)}function Nt(e){let t=e.getAttribute("data-live");if(t)return[t];let n=ye.find(r=>e.hasAttribute(r));if(!n)return[];let o=e.getAttribute(n);return o?[o]:[]}function Et(e){let t=Array.from(e.childNodes),n=t.find(i=>i.nodeType===Node.ELEMENT_NODE&&i.tagName==="ELSE")??null,o=t.filter(i=>i!==n),r=n?Array.from(n.childNodes):[];return{thenNodes:o,elseNodes:r}}function He(e){let t=document.createDocumentFragment();for(let n of e)t.appendChild(n.cloneNode(!0));return t}function vt(e,t){for(;e.nextSibling&&e.nextSibling!==t;)e.nextSibling.remove()}function Tt(e,t){e.parentNode.insertBefore(t,e)}function Ne(e,t,n,o,r){let i=[],a=Array.from(e.querySelectorAll("if[data-live]")).filter(s=>!E(s));for(let s of a){let B=function(){let k=je(s,n);if(k===D)return;Ae(H,R,n,r,"before"),I();let F=He(k?w:P);I=o(F,t,n,r);let $;if(g)g.textContent="",g.appendChild(F),$=g;else{let Y=Array.from(F.childNodes);vt(f,u),Tt(u,F),$=Ve(Y)}D=k,R=$,Ae(j,$,n,r,"after")};if(!e.contains(s))continue;let c=yt(),f=document.createComment(`live-if:${c}`),u=document.createComment(`/live-if:${c}`),l=Nt(s),b=s.getAttribute("el")||null,g=b?document.createElement(b):null;if(g)for(let k of Array.from(s.attributes))bt.has(k.name)||g.setAttribute(k.name,k.value);let{thenNodes:w,elseNodes:P}=Et(s),j=s.getAttribute("data-after")||null,H=s.getAttribute("data-before")||null,I=(()=>{}),D=!1,R=null,q=je(s,n);D=q;let N=He(q?w:P);I=o(N,t,n,r);let C;if(g)g.appendChild(N),s.replaceWith(f,g,u),C=g;else{let k=Array.from(N.childNodes);s.replaceWith(f,...k,u),C=Ve(k)}R=C,Ae(j,C,n,r,"after");for(let k of l)i.push(n.subscribe(k,B));i.push(()=>{I()})}return function(){for(let c of i)c();i.length=0}}var St=0;function xt(){return`lf${++St}`}function K(e){return e.find(t=>t.nodeType===Node.ELEMENT_NODE)??null}var _t=new Set(["each","as","key","index","data-live","el","data-diff","data-after","data-before"]),Ot=new Set(["simple","lcs","replace"]);function G(e,t,n,o,r){if(!e)return;let i=o?o[e]:void 0;if(typeof i!="function"){let a=o?Object.keys(o):[];r==="after"?d.blockAfterNotFound(e,a,t):d.blockBeforeNotFound(e,a,t);return}i(t,n)}function Ee(e){let t=document.createDocumentFragment();for(let n of e)t.appendChild(n.cloneNode(!0));return t}function ve(e,t,n,o,r){let i=[],a=Array.from(e.querySelectorAll("for[data-live]")).filter(s=>!E(s));for(let s of a){let B=function(p){R?R.appendChild(p):I.parentNode.insertBefore(p,I)},q=function(p,S){R?R.insertBefore(p,S):I.parentNode.insertBefore(p,S??I)},C=function(p,S,A,y){let v={...t,[f]:S};l&&(v[l]=A);let h=Ee(X),T=o(h,v,n,r),O=Array.from(h.childNodes);N.set(p,{nodes:O,cleanup:T});for(let x of O)y(x);G(w,K(O),n,r,"after")},k=function(p,S){let A=R?null:H;for(let y of p)if(N.has(y)){let v=N.get(y),h=v.nodes[0];if(h&&h.previousSibling===A)A=v.nodes[v.nodes.length-1];else{for(let T of v.nodes)B(T);A=v.nodes[v.nodes.length-1]}}else{let{item:v,idx:h}=S.get(y);C(y,v,h,B);let T=N.get(y);A=T.nodes[T.nodes.length-1]}},xe=function(p,S){let A=new Map(M.map((x,L)=>[x,L])),y=[],v=[];p.forEach((x,L)=>{N.has(x)&&(y.push(A.get(x)),v.push(L))});let h=ke(y),T=new Set;for(let x of h)T.add(v[x]);let O=null;for(let x=p.length-1;x>=0;x--){let L=p[x];if(N.has(L)){let z=N.get(L);if(!T.has(x))for(let re of z.nodes)q(re,O);O=z.nodes[0]}else{let{item:z,idx:re}=S.get(L),we={...t,[f]:z};l&&(we[l]=re);let Re=Ee(X),We=o(Re,we,n,r),J=Array.from(Re.childNodes);N.set(L,{nodes:J,cleanup:We});for(let Ke of J)q(Ke,O);G(w,K(J),n,r,"after"),O=J[0]}}},F=function(p){for(let A of N.values()){G(P,K(A.nodes),n,r,"before"),A.cleanup();for(let y of A.nodes)y.parentNode?.removeChild(y)}N.clear();let S=new Set;for(let A=0;A<p.length;A++){let y=p[A],v={...t,[f]:y};l&&(v[l]=A);let h=_(v,u);if(S.has(h)){d.duplicateKey(String(h??""),c);continue}S.add(h),C(h,y,A,B)}M=Array.from(N.keys())},_e=function(p){if(Array.isArray(p)||(p=[]),g==="replace"){F(p);return}let S=[],A=new Set,y=new Map;for(let h=0;h<p.length;h++){let T=p[h],O={...t,[f]:T};l&&(O[l]=h);let x=_(O,u);if(A.has(x)){d.duplicateKey(String(x??""),c);continue}A.add(x),S.push(x),y.set(x,{item:T,idx:h})}if(M.length<=S.length&&M.every((h,T)=>S[T]===h)){let h=S.slice(M.length);for(let T of h){let{item:O,idx:x}=y.get(T);C(T,O,x,B)}M=S.slice();return}let v=[];for(let h of N.keys())A.has(h)||v.push(h);for(let h of v){let T=N.get(h);G(P,K(T.nodes),n,r,"before"),T.cleanup();for(let O of T.nodes)O.parentNode?.removeChild(O);N.delete(h)}g==="lcs"?xe(S,y):k(S,y),M=S.slice()};if(!e.contains(s))continue;let c=s.getAttribute("each"),f=s.getAttribute("as"),u=s.getAttribute("key"),l=s.getAttribute("index");if(!u){d.missingKey(c??"?"),s.remove();continue}if(!c||!f){s.remove();continue}let b=s.getAttribute("data-diff"),g="simple";b!=null&&b!==""&&(Ot.has(b)?g=b:d.unknownDiffStrategy(b,c));let w=s.getAttribute("data-after")||null,P=s.getAttribute("data-before")||null,j=xt(),H=document.createComment(`live-for:${j}`),I=document.createComment(`/live-for:${j}`),D=s.getAttribute("el")||null,R=D?document.createElement(D):null;if(R)for(let p of Array.from(s.attributes))_t.has(p.name)||R.setAttribute(p.name,p.value);let X=Array.from(s.childNodes).map(p=>p.cloneNode(!0)),N=new Map,M=[],$=Array.isArray(n.get(c))?n.get(c):[],Y=new Set;for(let p=0;p<$.length;p++){let S=$[p],A={...t,[f]:S};l&&(A[l]=p);let y=_(A,u);if(Y.has(y)){d.duplicateKey(String(y??""),c);continue}Y.add(y);let v=Ee(X),h=o(v,A,n,r),T=Array.from(v.childNodes);N.set(y,{nodes:T,cleanup:h})}M=Array.from(N.keys());let Oe=Array.from(N.values()).flatMap(p=>p.nodes);if(R){for(let p of Oe)R.appendChild(p);s.replaceWith(H,R,I)}else s.replaceWith(H,...Oe,I);for(let p of N.values())G(w,K(p.nodes),n,r,"after");i.push(n.subscribe(c,_e)),i.push(()=>{for(let p of N.values())p.cleanup();N.clear()})}return function(){for(let c of i)c();i.length=0}}var qe=100,oe=new WeakMap;function wt(e){let t=n=>Array.from(e.querySelectorAll(n)).some(o=>!E(o));return t("partial")||t("for:not([data-live])")||t("if:not([data-live])")}function Te(e,t,n=0){let o=0;for(;wt(e);){if(++o>qe){d.pipelineDepthLimit(qe,e);break}ee(e,t,n,Te),te(e,t,Te),ue(e,t)}}function Se(e,t,n,o){Te(e,t),U(e,t);let r=n?me(e,n):()=>{},i=n?pe(e,n):()=>{},a=n?ge(e,n):()=>{},s=n?ve(e,t,n,Se,o):()=>{},c=n?Ne(e,t,n,Se,o):()=>{};return function(){r(),i(),a(),s(),c()}}function In(e,t,n,o,r={}){let i=oe.get(n);i&&(i.cleanup(),n.textContent=""),typeof r.beforeRender=="function"&&r.beforeRender(t,o);let a=Z(e);if(!a){let u=Array.from(document.querySelectorAll('template[id^="tpl-"]')).map(l=>l.id.slice(4));return d.mountTemplateNotFound(e,u,n),()=>{}}let s=Se(a,t,o,r.handlers);n.appendChild(a),typeof r.afterRender=="function"&&r.afterRender(n,o);let c=r.handlers?be(n,o,r.handlers):()=>{},f=function(){s(),c()};return oe.set(n,{cleanup:f}),f}function Pn(e){let t=oe.get(e);t&&(t.cleanup(),oe.delete(e)),e.textContent=""}export{V as OPERATORS,Ye as createStore,Le as escapeHtml,Ce as evalCondition,te as expandLoops,ee as expandPartials,_ as getByPath,Z as getTemplate,se as isDevMode,In as mount,ue as processAllIfs,Se as render,fe as renderTemplate,U as resolveStatic,$e as safeAttr,nt as safeStyleUrl,De as safeUrl,ce as setByPath,Ge as setDevMode,pe as setupBindings,be as setupEventBindings,ve as setupLiveFors,Ne as setupLiveIfs,me as setupModelBindings,ge as setupShowBindings,Pn as unmount,m as warn};
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "lime-csr-js",
3
+ "version": "0.1.4",
4
+ "description": "An HTML-first client-side rendering engine built with standard browser APIs.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./dist": "./dist/index.min.js"
10
+ },
11
+ "files": [
12
+ "src/",
13
+ "dist/",
14
+ "DOCS.md",
15
+ "LICENCE.md"
16
+ ],
17
+ "keywords": [
18
+ "html-first",
19
+ "client-side-rendering",
20
+ "reactive",
21
+ "no-build"
22
+ ],
23
+ "license": "MIT",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/mhmtsnmzkanly/lime-csr-js.git"
27
+ },
28
+ "bugs": {
29
+ "url": "https://github.com/mhmtsnmzkanly/lime-csr-js/issues"
30
+ },
31
+ "homepage": "https://github.com/mhmtsnmzkanly/lime-csr-js#readme",
32
+ "sideEffects": false,
33
+ "engines": {
34
+ "node": ">=20.19.0"
35
+ },
36
+ "scripts": {
37
+ "build": "esbuild src/index.js --bundle --minify --format=esm --platform=browser --outfile=dist/index.min.js",
38
+ "prepack": "npm run build",
39
+ "lint": "eslint .",
40
+ "typecheck": "tsc -p tsconfig.json",
41
+ "test": "node --test test/**/*.test.js",
42
+ "test:browser": "node --test test/browser-smoke.test.js",
43
+ "verify": "npm run lint && npm run typecheck && npm test && npm run build",
44
+ "prepublishOnly": "npm run verify"
45
+ },
46
+ "devDependencies": {
47
+ "@eslint/js": "^10.0.1",
48
+ "esbuild": "^0.25.0",
49
+ "eslint": "^10.7.0",
50
+ "jsdom": "^29.1.1",
51
+ "typescript": "^7.0.2"
52
+ }
53
+ }
@@ -0,0 +1,363 @@
1
+ /**
2
+ * @module bindings-blocks
3
+ * Reactive block layer — currently only <if data-live>.
4
+ *
5
+ * SCOPE — THIS MODULE:
6
+ * <if is-gt="count" than="0" data-live>...</if>
7
+ * When the condition's store path changes, the block is RE-EVALUATED;
8
+ * the winning branch (then/else) is torn down and rebuilt.
9
+ *
10
+ * OUT OF SCOPE (next step — bindings-for.js or live-blocks.js):
11
+ * Reactive <for data-live>: same tear-down/rebuild mechanism + key/diff.
12
+ * Without a key the whole list is re-rendered on every change (focus/scroll is lost).
13
+ * Handled separately for this reason.
14
+ *
15
+ * PLACEHOLDER PATTERN — bounded by comment nodes:
16
+ * <if data-live> is removed from the DOM; two comment nodes are put in its place:
17
+ * <!-- live-if:ref --> ← start anchor
18
+ * [active branch content]
19
+ * <!-- /live-if:ref --> ← end anchor
20
+ * When the branch changes, the "between" region is cleared and new content is placed.
21
+ * The comment nodes stay fixed → not dependent on sibling ordering, safe.
22
+ *
23
+ * CLEANUP ORDER (critical):
24
+ * On branch change, FIRST the old branch's cleanup (cancel subscriptions),
25
+ * THEN DOM removal. Reversing this can break cleanup that relies on removed
26
+ * nodes (WeakMap lookups, node-anchored state).
27
+ *
28
+ * IDENTITY WARNING:
29
+ * When the branch changes, that branch is completely torn down and rebuilt —
30
+ * input focus, scroll position, animation state are lost. Acceptable for
31
+ * <if>; <for> hits the same problem per element and needs a key mechanism.
32
+ *
33
+ * NESTED SUPPORT:
34
+ * Since the render(branchFrag, context, store) call also includes
35
+ * setupLiveIfs, inner <if data-live> elements inside a branch get bound
36
+ * with their own cleanups. When the outer branch changes, the outer
37
+ * branch's cleanup also cancels the inner live-if subscriptions.
38
+ *
39
+ * data-live VALUE FOR PATH TRACKING:
40
+ * data-live="" (empty) → the condition operator's path is tracked (is-gt="count" → "count")
41
+ * data-live="x" (set) → the explicit path "x" is tracked (preferred for multiple paths)
42
+ *
43
+ * BLOCK-LEVEL HOOKS — data-after / data-before:
44
+ * <if data-live is-truthy="showChart" el="div" data-after="initChart" data-before="destroyChart">
45
+ * Attribute-based, eval-free, same name-lookup pattern as data-on-* — the
46
+ * attribute value is a KEY looked up in the `handlers` dictionary passed to
47
+ * mount(), never an expression.
48
+ * data-after -- called AFTER a branch's nodes are placed in the DOM
49
+ * (both on the INITIAL render and on every later switch).
50
+ * data-before -- called BEFORE a branch is torn down (synchronously,
51
+ * right before branchCleanup() + DOM removal) -- the
52
+ * element is still attached to the DOM at call time, so
53
+ * e.g. widget.destroy()/observer.disconnect() can still
54
+ * reach real layout/measurements if needed.
55
+ * Handler signature: (rootElement, store). rootElement is the el="tag"
56
+ * container if one is configured, otherwise the branch's first top-level
57
+ * ELEMENT node (template whitespace around it is skipped; use el="..." for
58
+ * a guaranteed stable single root element instead of relying on this).
59
+ * null if the branch has no element nodes at all (e.g. pure text).
60
+ * Same-condition re-evaluation (currentCondition unchanged) never fires
61
+ * either hook, since no branch switch happens at all in that case.
62
+ * Missing handler name -> errors.blockAfterNotFound/blockBeforeNotFound,
63
+ * warn and continue (no crash). data-after/data-before are only evaluated
64
+ * if `handlers` was supplied to render() (see index.js); without it, they
65
+ * silently do nothing beyond the same missing-handler warning.
66
+ * Cleanup is always SYNCHRONOUS -- data-before does not await a Promise;
67
+ * TODO: async before-hooks (e.g. await an exit animation) are a possible
68
+ * future extension, not supported here.
69
+ */
70
+
71
+ import { OPERATORS } from './conditionals.js';
72
+ import { errors } from './errors.js';
73
+ import { inLiveBlock } from './shared.js';
74
+
75
+ const OPERATOR_NAMES = /** @type {string[]} */ (Object.keys(OPERATORS));
76
+
77
+ // 2g/hooks: attributes that belong to the <if> element's own control
78
+ // surface — never copied onto the el="tag" container (everything else, e.g.
79
+ // class/id, is copied so the container can be styled/targeted like a normal element).
80
+ const RESERVED_IF_ATTRS = new Set([
81
+ ...OPERATOR_NAMES, 'than', 'to', 'data-live', 'el', 'data-after', 'data-before',
82
+ ]);
83
+
84
+ /**
85
+ * Looks up `handlerName` in `handlers` and calls it with (rootEl, store).
86
+ * Missing name (or no handlers dictionary at all) -> dev-mode warning, no crash.
87
+ *
88
+ * @param {string|null} handlerName
89
+ * @param {Node|null} rootEl
90
+ * @param {import('./store.js').Store} store
91
+ * @param {Object<string, function(Node, import('./store.js').Store): void>|undefined} handlers
92
+ * @param {'after'|'before'} kind
93
+ */
94
+ function callBlockHook(handlerName, rootEl, store, handlers, kind) {
95
+ if (!handlerName) return;
96
+ const handler = handlers && Object.hasOwn(handlers, handlerName)
97
+ ? handlers[handlerName]
98
+ : undefined;
99
+ if (typeof handler !== 'function') {
100
+ const available = handlers ? Object.keys(handlers) : [];
101
+ if (kind === 'after') errors.blockAfterNotFound(handlerName, available, rootEl);
102
+ else errors.blockBeforeNotFound(handlerName, available, rootEl);
103
+ return;
104
+ }
105
+ handler(rootEl, store);
106
+ }
107
+
108
+ /**
109
+ * Finds the first actual Element among a branch's top-level nodes, for hook
110
+ * rootEl purposes. A branch's nodes[0] is not reliably an Element: template
111
+ * whitespace (a newline/indentation before the branch's own root tag) shows
112
+ * up as a leading Text node. `rootEl.getAttribute`/`querySelector`-style
113
+ * usage in a data-after/data-before handler requires a real Element.
114
+ *
115
+ * @param {Node[]} nodes
116
+ * @returns {Element|null}
117
+ */
118
+ function firstElementNode(nodes) {
119
+ return nodes.find((n) => n.nodeType === Node.ELEMENT_NODE) ?? null;
120
+ }
121
+
122
+ let blockCounter = 0;
123
+ function nextBlockRef() { return `lb${++blockCounter}`; }
124
+
125
+ /**
126
+ * Evaluates the condition for <if data-live> from the store.
127
+ * Difference from evalCondition: uses store.get(path), not context.
128
+ *
129
+ * @param {Element} ifEl
130
+ * @param {import('./store.js').Store} store
131
+ * @returns {boolean}
132
+ */
133
+ function evalLiveCondition(ifEl, store) {
134
+ const opName = OPERATOR_NAMES.find((op) => ifEl.hasAttribute(op));
135
+ if (!opName) {
136
+ errors.liveIfMissingOperator(ifEl);
137
+ return false;
138
+ }
139
+ const path = ifEl.getAttribute(opName);
140
+ const leftValue = store.get(path);
141
+ const rightRaw = ifEl.getAttribute('than') ?? ifEl.getAttribute('to') ?? '';
142
+ return Boolean(OPERATORS[opName](leftValue, rightRaw));
143
+ }
144
+
145
+ /**
146
+ * Returns the store paths to track for an <if>.
147
+ * data-live="path" → explicit path; data-live="" → derived from the operator attribute.
148
+ *
149
+ * LIMIT: only the operator's LEFT side (e.g. is-gt="count" → "count") or an
150
+ * explicit data-live="path" is tracked. The "than"/"to" attribute is NEVER
151
+ * tracked even if it looks like a path — it's always treated as static/literal.
152
+ * If the right side also needs to be reactive, precompute the comparison on
153
+ * the left side and reduce it to a single trackable path.
154
+ *
155
+ * @param {Element} ifEl
156
+ * @returns {string[]}
157
+ */
158
+ function getLivePaths(ifEl) {
159
+ const liveVal = ifEl.getAttribute('data-live');
160
+ if (liveVal) return [liveVal]; // explicit path given
161
+ // empty/unset → derive from the condition operator's value
162
+ const opName = OPERATOR_NAMES.find((op) => ifEl.hasAttribute(op));
163
+ if (!opName) return [];
164
+ const path = ifEl.getAttribute(opName);
165
+ return path ? [path] : [];
166
+ }
167
+
168
+ /**
169
+ * Splits an <if>'s then and else nodes.
170
+ * Nodes are not cloned — references are returned.
171
+ *
172
+ * @param {Element} ifEl
173
+ * @returns {{ thenNodes: Node[], elseNodes: Node[] }}
174
+ */
175
+ function extractBranches(ifEl) {
176
+ const directChildren = Array.from(ifEl.childNodes);
177
+ const elseEl = directChildren.find(
178
+ (ch) => ch.nodeType === Node.ELEMENT_NODE && ch.tagName === 'ELSE',
179
+ ) ?? null;
180
+ const thenNodes = directChildren.filter((ch) => ch !== elseEl);
181
+ const elseNodes = elseEl ? Array.from(elseEl.childNodes) : [];
182
+ return { thenNodes, elseNodes };
183
+ }
184
+
185
+ /**
186
+ * Deep-clones the given nodes into a fresh DocumentFragment.
187
+ *
188
+ * @param {Node[]} nodes
189
+ * @returns {DocumentFragment}
190
+ */
191
+ function cloneToFragment(nodes) {
192
+ const frag = document.createDocumentFragment();
193
+ for (const node of nodes) frag.appendChild(node.cloneNode(true));
194
+ return frag;
195
+ }
196
+
197
+ /**
198
+ * Removes all siblings between the start and end comment anchors.
199
+ *
200
+ * @param {Comment} startAnchor
201
+ * @param {Comment} endAnchor
202
+ */
203
+ function clearBetween(startAnchor, endAnchor) {
204
+ while (startAnchor.nextSibling && startAnchor.nextSibling !== endAnchor) {
205
+ startAnchor.nextSibling.remove();
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Inserts a DocumentFragment BEFORE endAnchor (i.e. after startAnchor).
211
+ *
212
+ * @param {Comment} endAnchor
213
+ * @param {DocumentFragment} frag
214
+ */
215
+ function insertBeforeAnchor(endAnchor, frag) {
216
+ endAnchor.parentNode.insertBefore(frag, endAnchor);
217
+ }
218
+
219
+ /**
220
+ * Sets up every <if data-live> element inside root.
221
+ * For each one: a placeholder comment pair + initial branch render + store subscription are opened.
222
+ * On branch change: old cleanup → new render → DOM update, in that order.
223
+ *
224
+ * renderFn is expected to point to render(); this way:
225
+ * - Structural tags inside the branch (partial/for/if) go through the pipeline.
226
+ * - data-text/attr bindings inside the branch (setupBindings) are set up.
227
+ * - Inner <if data-live> elements inside the branch (setupLiveIfs) also kick in.
228
+ *
229
+ * @param {Element|DocumentFragment} root
230
+ * @param {Object} context
231
+ * @param {import('./store.js').Store} store
232
+ * @param {function(DocumentFragment, Object, import('./store.js').Store, Object=): function(): void} renderFn
233
+ * @param {Object<string, function>=} handlers - For data-after/data-before lookups (see module JSDoc).
234
+ * @returns {function(): void} cleanup — cancels all subscriptions and branch cleanups
235
+ */
236
+ export function setupLiveIfs(root, context, store, renderFn, handlers) {
237
+ const allCleanups = [];
238
+
239
+ const liveIfs = Array.from(root.querySelectorAll('if[data-live]')).filter(
240
+ (el) =>
241
+ !inLiveBlock(el),
242
+ );
243
+
244
+ for (const ifEl of liveIfs) {
245
+ if (!root.contains(ifEl)) continue;
246
+
247
+ const ref = nextBlockRef();
248
+ const startAnchor = document.createComment(`live-if:${ref}`);
249
+ const endAnchor = document.createComment(`/live-if:${ref}`);
250
+ const paths = getLivePaths(ifEl);
251
+
252
+ // 2g: el="tag" -- optional container element wrapping the branch content.
253
+ // If <if data-live el="div"> is used, branch content is placed inside a
254
+ // <div> wrapper instead of bare comment anchors. The container persists
255
+ // across branch changes (only its content is replaced), so focus/scroll
256
+ // on the container itself is preserved.
257
+ const elTag = ifEl.getAttribute('el') || null;
258
+ const container = elTag ? document.createElement(elTag) : null;
259
+
260
+ // 2g: copy any non-reserved attribute (class, id, data-testid, ...) from
261
+ // <if> onto the container, so it can be styled/targeted like a normal element.
262
+ if (container) {
263
+ for (const attr of Array.from(ifEl.attributes)) {
264
+ if (!RESERVED_IF_ATTRS.has(attr.name)) container.setAttribute(attr.name, attr.value);
265
+ }
266
+ }
267
+
268
+ // Extract branch templates
269
+ const { thenNodes, elseNodes } = extractBranches(ifEl);
270
+
271
+ // Hooks: attribute names (null if not set).
272
+ const afterHandlerName = ifEl.getAttribute('data-after') || null;
273
+ const beforeHandlerName = ifEl.getAttribute('data-before') || null;
274
+
275
+ // Active branch cleanup; updated on each branch switch
276
+ let branchCleanup = /** @type {function(): void} */ (() => {});
277
+ let currentCondition = false;
278
+ // The currently active branch's root node, for the NEXT before-hook call
279
+ // (container mode: always `container`; otherwise the branch's own first node).
280
+ let currentBranchRoot = null;
281
+
282
+ /**
283
+ * Evaluates the condition and rebuilds the active branch in the DOM.
284
+ * Order: before-hook (old branch) -> cleanup -> new render -> DOM update -> after-hook (new branch).
285
+ */
286
+ function reEvaluate() {
287
+ const condition = evalLiveCondition(ifEl, store);
288
+ if (condition === currentCondition) return;
289
+
290
+ // 0. data-before hook: fires on the OLD (still in the DOM) branch, before it's torn down.
291
+ callBlockHook(beforeHandlerName, currentBranchRoot, store, handlers, 'before');
292
+
293
+ // 1. Cancel old branch subscriptions FIRST
294
+ branchCleanup();
295
+
296
+ // 2. Clone the new branch and run the full render pipeline
297
+ const nodes = condition ? thenNodes : elseNodes;
298
+ const frag = cloneToFragment(nodes);
299
+ const newCleanup = renderFn(frag, context, store, handlers);
300
+ branchCleanup = newCleanup;
301
+
302
+ // 3. Replace old DOM content with new branch
303
+ let newRootEl;
304
+ if (container) {
305
+ // 2g: container mode -- clear container, insert new content inside it
306
+ container.textContent = '';
307
+ container.appendChild(frag);
308
+ newRootEl = container;
309
+ } else {
310
+ const newNodes = Array.from(frag.childNodes);
311
+ clearBetween(startAnchor, endAnchor);
312
+ insertBeforeAnchor(endAnchor, frag);
313
+ newRootEl = firstElementNode(newNodes);
314
+ }
315
+ currentCondition = condition;
316
+ currentBranchRoot = newRootEl;
317
+
318
+ // 4. data-after hook: fires on the NEW branch, now that it's in the DOM.
319
+ callBlockHook(afterHandlerName, newRootEl, store, handlers, 'after');
320
+ }
321
+
322
+ // Initial render
323
+ const initialCondition = evalLiveCondition(ifEl, store);
324
+ currentCondition = initialCondition;
325
+ const initialNodes = initialCondition ? thenNodes : elseNodes;
326
+ const initialFrag = cloneToFragment(initialNodes);
327
+ const initialCleanup = renderFn(initialFrag, context, store, handlers);
328
+ branchCleanup = initialCleanup;
329
+
330
+ let initialRootEl;
331
+ if (container) {
332
+ // 2g: container mode -- replace <if> with [startAnchor, container, endAnchor]
333
+ // Future branch changes only touch container's children, not the container itself.
334
+ container.appendChild(initialFrag);
335
+ ifEl.replaceWith(startAnchor, container, endAnchor);
336
+ initialRootEl = container;
337
+ } else {
338
+ // Default: replace <if> with comment pair + initial branch content
339
+ const initialNodesArr = Array.from(initialFrag.childNodes);
340
+ ifEl.replaceWith(startAnchor, ...initialNodesArr, endAnchor);
341
+ initialRootEl = firstElementNode(initialNodesArr);
342
+ }
343
+ currentBranchRoot = initialRootEl;
344
+
345
+ // data-after also fires on the initial render -- the branch is placed in
346
+ // the DOM here for the first time too, which is exactly when a
347
+ // third-party widget would need to be initialized.
348
+ callBlockHook(afterHandlerName, initialRootEl, store, handlers, 'after');
349
+
350
+ // Subscribe to condition paths
351
+ for (const path of paths) {
352
+ allCleanups.push(store.subscribe(path, reEvaluate));
353
+ }
354
+
355
+ // Aggregate cleanup for this live-if: subscriptions + current active branch
356
+ allCleanups.push(() => { branchCleanup(); });
357
+ }
358
+
359
+ return function cleanup() {
360
+ for (const unsub of allCleanups) unsub();
361
+ allCleanups.length = 0; // guard against double invocation
362
+ };
363
+ }