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/DOCS.md +1456 -0
- package/LICENCE.md +20 -0
- package/README.md +150 -0
- package/dist/index.min.js +1 -0
- package/package.json +53 -0
- package/src/bindings-blocks.js +363 -0
- package/src/bindings-events.js +176 -0
- package/src/bindings-loops.js +568 -0
- package/src/bindings-model.js +262 -0
- package/src/bindings-show.js +97 -0
- package/src/bindings.js +240 -0
- package/src/conditionals.js +153 -0
- package/src/errors.js +414 -0
- package/src/index.js +312 -0
- package/src/loops.js +117 -0
- package/src/partials.js +158 -0
- package/src/shared.js +103 -0
- package/src/store.js +265 -0
- package/src/template.js +263 -0
- package/src/utils.js +84 -0
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=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[t])}function $e(e=""){return Le(e).replace(/`/g,"`")}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
|
+
}
|