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/src/shared.js ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * @module shared
3
+ * @description Pure utility helper functions for lime-csr.js.
4
+ * This module is a leaf dependency and must not import any other modules in the codebase.
5
+ */
6
+
7
+ /**
8
+ * Checks if a given node is inside a not-yet-expanded reactive block
9
+ * (<if data-live> or <for data-live>).
10
+ * If the node itself is the root of the live block, it returns false
11
+ * (so that its own attributes can still be processed/bound).
12
+ *
13
+ * Used by: index.js, template.js, partials.js, loops.js, conditionals.js,
14
+ * bindings.js, bindings-model.js, bindings-show.js.
15
+ *
16
+ * @param {Node} node
17
+ * @returns {boolean}
18
+ */
19
+ export function inLiveBlock(node) {
20
+ if (!node) return false;
21
+ if (node.nodeType !== 1) { // Node.ELEMENT_NODE is 1
22
+ const parent = node.parentElement;
23
+ return !!(parent?.closest?.('if[data-live]') || parent?.closest?.('for[data-live]'));
24
+ }
25
+ const isLiveRoot = node.matches?.('if[data-live]') || node.matches?.('for[data-live]');
26
+ if (isLiveRoot) return false;
27
+ return !!(node.closest?.('if[data-live]') || node.closest?.('for[data-live]'));
28
+ }
29
+
30
+ /**
31
+ * Checks if a given node is inside the content of a not-yet-expanded ordinary
32
+ * (non-data-live) <for> block.
33
+ * If the node itself is the root <for> element, it returns false (so its own
34
+ * attributes can still be resolved).
35
+ *
36
+ * Used by: template.js, partials.js, loops.js.
37
+ *
38
+ * @param {Node} node
39
+ * @returns {boolean}
40
+ */
41
+ export function inUnexpandedFor(node) {
42
+ if (!node) return false;
43
+ if (node.nodeType !== 1) {
44
+ const parent = node.parentElement;
45
+ return !!parent?.closest?.('for:not([data-live])');
46
+ }
47
+ const isForRoot = node.matches?.('for:not([data-live])');
48
+ if (isForRoot) return false;
49
+ return !!node.closest?.('for:not([data-live])');
50
+ }
51
+
52
+ /**
53
+ * Longest Increasing Subsequence, via patience sorting — O(n log n).
54
+ *
55
+ * Given a sequence of numbers, returns the SET of indices (into `seq`) that
56
+ * form one valid longest increasing subsequence. Used by the "lcs" diff
57
+ * strategy: `seq` is the OLD position of each surviving item, listed in NEW
58
+ * order — the LIS is the maximal set of survivors whose relative order is
59
+ * unchanged, and can therefore stay physically untouched in the DOM.
60
+ *
61
+ * Used by: bindings-loops.js.
62
+ *
63
+ * @param {number[]} seq
64
+ * @returns {Set<number>} indices into `seq`
65
+ */
66
+ export function longestIncreasingSubsequenceIndices(seq) {
67
+ const n = seq.length;
68
+ if (n === 0) return new Set();
69
+
70
+ // tails[k] = index (into seq) of the smallest possible tail value for an
71
+ // increasing subsequence of length k+1 found so far.
72
+ const tails = [];
73
+ // predecessors[i] = index (into seq) of the previous element in the
74
+ // increasing subsequence that ends at i, or -1 if i starts one.
75
+ const predecessors = new Array(n).fill(-1);
76
+
77
+ for (let i = 0; i < n; i++) {
78
+ const val = seq[i];
79
+
80
+ // Binary search: first position in `tails` whose seq-value is >= val.
81
+ let lo = 0;
82
+ let hi = tails.length;
83
+ while (lo < hi) {
84
+ const mid = (lo + hi) >> 1;
85
+ if (seq[tails[mid]] < val) lo = mid + 1;
86
+ else hi = mid;
87
+ }
88
+
89
+ if (lo > 0) predecessors[i] = tails[lo - 1];
90
+ if (lo === tails.length) tails.push(i);
91
+ else tails[lo] = i;
92
+ }
93
+
94
+ // Reconstruct the subsequence by walking predecessors backward from the
95
+ // last element of the longest tail found.
96
+ const result = new Set();
97
+ let k = tails[tails.length - 1];
98
+ while (k !== -1) {
99
+ result.add(k);
100
+ k = predecessors[k];
101
+ }
102
+ return result;
103
+ }
package/src/store.js ADDED
@@ -0,0 +1,265 @@
1
+ /**
2
+ * @module store
3
+ * @description Path-based reactive state management.
4
+ *
5
+ * Core rules:
6
+ * - State access via dot-path: "user.profile.name"
7
+ * - When a path changes, all ancestor segments are also notified.
8
+ * E.g. changing "a.b.c" triggers subscribers for "a", "a.b", "a.b.c".
9
+ * - Downward notification: changing "a.b" also notifies descendants like "a.b.c"
10
+ * (subscribers whose path starts with the changed path). This means
11
+ * store.set("user", {name:"new"}) correctly updates data-text="user.name" bindings.
12
+ * - Object.is change check: setting the same value again does not trigger subscribers.
13
+ * - subscribe() returns a cancel function; when the last subscriber for a path is
14
+ * removed, that path's Map entry is deleted — leak-free cleanup.
15
+ * - __proto__/constructor/prototype are rejected as path segments (prototype
16
+ * pollution guard); set/update silently do nothing for such paths.
17
+ * - store.computed(path, deps, fn): registers a derived value that auto-updates
18
+ * when any dep changes. Returns a dispose function.
19
+ */
20
+ import { warn } from './errors.js';
21
+
22
+ /**
23
+ * @typedef {Object} Store
24
+ * @property {function(string=): *} get
25
+ * Returns the value at path; returns the entire state if no path is given.
26
+ * @property {function(string, *): boolean} set
27
+ * Writes value to path, notifies subscribers; returns whether a change occurred.
28
+ * @property {function(string, function(*): *): boolean} update
29
+ * Passes the current value to the updater function, sets the result.
30
+ * @property {function(string, function(*, *, string): void): function(): void} subscribe
31
+ * Subscribes to path; the returned function cancels the subscription.
32
+ */
33
+
34
+ /**
35
+ * Reads a value from an object via a dotted path.
36
+ *
37
+ * @param {Object} source - Source object
38
+ * @param {string} path - Dotted path, e.g. "user.profile.name"
39
+ * @returns {*} The found value; `undefined` if any segment is missing.
40
+ */
41
+ export function getByPath(source, path) {
42
+ const keys = String(path).split('.');
43
+ if (keys.some((key) => UNSAFE_PATH_SEGMENTS.has(key))) return undefined;
44
+
45
+ return keys.reduce((value, key) => {
46
+ if (value == null || !Object.hasOwn(value, key)) return undefined;
47
+ return value[key];
48
+ }, source);
49
+ }
50
+
51
+ // __proto__/constructor/prototype are never accepted as a path segment.
52
+ // Otherwise "obj[key]" would point to an existing (typeof "object") prototype
53
+ // chain, and the next assignment would pollute Object.prototype globally.
54
+ const UNSAFE_PATH_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]);
55
+
56
+ /**
57
+ * Writes a value to an object via a dotted path; mutates the source object.
58
+ * Intermediate objects are created if missing.
59
+ *
60
+ * Security: if any path segment is `__proto__`, `constructor`, or `prototype`,
61
+ * the write is silently rejected (prototype pollution guard).
62
+ *
63
+ * @param {Object} source - Target object
64
+ * @param {string} path - Dotted path
65
+ * @param {*} newValue - Value to write
66
+ * @returns {{ changed: boolean, previousValue: * }}
67
+ * `changed`: whether a real change occurred (Object.is comparison).
68
+ * `previousValue`: the previous value (`undefined` if no change).
69
+ */
70
+ export function setByPath(source, path, newValue) {
71
+ const keys = String(path).split(".");
72
+ const lastKey = keys.pop();
73
+
74
+ if (UNSAFE_PATH_SEGMENTS.has(lastKey) || keys.some((key) => UNSAFE_PATH_SEGMENTS.has(key))) {
75
+ return { changed: false };
76
+ }
77
+
78
+ // Walk all but the last segment; create intermediate objects if missing
79
+ const target = keys.reduce((obj, key) => {
80
+ if (obj[key] == null || typeof obj[key] !== "object") obj[key] = {};
81
+ return obj[key];
82
+ }, source);
83
+
84
+ const previousValue = target[lastKey];
85
+ if (Object.is(previousValue, newValue)) return { changed: false };
86
+
87
+ target[lastKey] = newValue;
88
+ return { changed: true, previousValue };
89
+ }
90
+
91
+ /**
92
+ * Creates a path-based reactive store.
93
+ *
94
+ * @param {Object} [initialState={}] - Initial state object (held by reference, not copied).
95
+ * @returns {Store}
96
+ */
97
+ export function createStore(initialState = {}) {
98
+ // Map holding subscriber functions per path
99
+ const subscribers = new Map();
100
+
101
+ // Set of computed paths — direct store.set() on these warns in dev-mode
102
+ const computedPaths = new Set();
103
+
104
+ // Guard flag to swallow a computed's own re-trigger (loop prevention)
105
+ const computedUpdating = new Set();
106
+
107
+ /**
108
+ * Notifies subscribers for the changed path, all ancestor segments (upward),
109
+ * and all descendant paths (downward — keys that start with `path + "."``).
110
+ *
111
+ * Upward: "a.b.c" changed → notify "a", "a.b", "a.b.c".
112
+ * Downward: "user" changed → also notify "user.name", "user.profile.age", etc.
113
+ * This ensures store.set("user", {...}) updates data-text="user.name" bindings.
114
+ *
115
+ * @param {string} path - The changed full path
116
+ * @param {*} previousValue - Value before the change
117
+ */
118
+ function notify(path, previousValue) {
119
+ const segments = String(path).split(".");
120
+
121
+ // Upward: "a.b.c" → notify "a", "a.b", "a.b.c"
122
+ segments.forEach((_, index) => {
123
+ const currentPath = segments.slice(0, index + 1).join(".");
124
+ const bucket = subscribers.get(currentPath);
125
+ if (!bucket) return;
126
+ const currentValue = getByPath(initialState, currentPath);
127
+ bucket.forEach((callback) => callback(currentValue, previousValue, path));
128
+ });
129
+
130
+ // Downward: notify all subscribers whose path starts with `path + "."`
131
+ const prefix = path + ".";
132
+ for (const [subPath, bucket] of subscribers) {
133
+ if (!subPath.startsWith(prefix)) continue;
134
+ const currentValue = getByPath(initialState, subPath);
135
+ bucket.forEach((callback) => callback(currentValue, previousValue, path));
136
+ }
137
+ }
138
+
139
+ return {
140
+ /**
141
+ * Returns the value at path.
142
+ *
143
+ * @param {string} [path=""] - If empty, returns the entire state object.
144
+ * @returns {*}
145
+ */
146
+ get(path = "") {
147
+ return path ? getByPath(initialState, path) : initialState;
148
+ },
149
+
150
+ /**
151
+ * Writes value to path; notifies subscribers if the value changed.
152
+ * Dev-mode warnings:
153
+ * - Computed path: warns that computed paths should not be set directly.
154
+ * - In-place mutation: if value is an object/array and the reference is
155
+ * identical to the stored value, warns about same-reference mutation.
156
+ *
157
+ * @param {string} path
158
+ * @param {*} value
159
+ * @returns {boolean} `true` if a change occurred.
160
+ */
161
+ set(path, value) {
162
+ // Dev-mode: warn about direct writes to computed paths
163
+ if (computedPaths.has(path) && !computedUpdating.has(path)) {
164
+ warn('COMPUTED_MANUAL_SET',
165
+ `Path "${path}" is managed by store.computed(). Manual store.set() will be ` +
166
+ `overwritten on next dep change. Use store.computed() or a different path.`);
167
+ }
168
+
169
+ // Dev-mode: warn about in-place mutation (same object/array reference)
170
+ const existing = getByPath(initialState, path);
171
+ if (value !== null && typeof value === 'object' && Object.is(existing, value)) {
172
+ warn('IN_PLACE_MUTATION',
173
+ `store.set("${path}", value): value is the SAME reference as the stored object/array. ` +
174
+ `In-place mutation detected — subscriber will NOT fire. Pass a new reference: ` +
175
+ `e.g. store.set("${path}", [...arr]) or store.set("${path}", {...obj}).`);
176
+ return false;
177
+ }
178
+
179
+ const result = setByPath(initialState, path, value);
180
+ if (result.changed) notify(path, result.previousValue);
181
+ return result.changed;
182
+ },
183
+
184
+ /**
185
+ * Passes the current value to `updater`, sets the return value.
186
+ *
187
+ * @param {string} path
188
+ * @param {function(*): *} updater
189
+ * @returns {boolean}
190
+ */
191
+ update(path, updater) {
192
+ return this.set(path, updater(this.get(path)));
193
+ },
194
+
195
+ /**
196
+ * Subscribes to path. The returned function cancels the subscription.
197
+ * When the last subscriber for a path is removed, its Map entry is deleted (no leak).
198
+ *
199
+ * @param {string} path
200
+ * @param {function(currentValue: *, previousValue: *, changedPath: string): void} callback
201
+ * @returns {function(): void} Cleanup — removes the subscription.
202
+ */
203
+ subscribe(path, callback) {
204
+ if (!subscribers.has(path)) subscribers.set(path, new Set());
205
+ subscribers.get(path).add(callback);
206
+ return () => {
207
+ const bucket = subscribers.get(path);
208
+ if (!bucket) return;
209
+ bucket.delete(callback);
210
+ if (bucket.size === 0) subscribers.delete(path);
211
+ };
212
+ },
213
+
214
+ /**
215
+ * Registers a computed (derived) value at `path`.
216
+ * Immediately computes and sets the initial value, then re-computes
217
+ * whenever any of the `deps` paths change.
218
+ *
219
+ * Chaining: a computed path can itself be a dep of another computed.
220
+ * Loop prevention: if a dep change triggers the same computed recursively,
221
+ * the re-entry is swallowed.
222
+ *
223
+ * Dev-mode: calling store.set(path) on a computed path warns the developer.
224
+ *
225
+ * @param {string} path - Destination path in the store (ordinary path).
226
+ * @param {string[]} deps - Array of store paths to watch.
227
+ * @param {function(): *} fn - Pure function; return value is written to path.
228
+ * @returns {function(): void} dispose — cancels all dep subscriptions.
229
+ *
230
+ * @example
231
+ * const dispose = store.computed('fullName', ['firstName', 'lastName'],
232
+ * () => store.get('firstName') + ' ' + store.get('lastName'));
233
+ * // later:
234
+ * dispose();
235
+ */
236
+ computed(path, deps, fn) {
237
+ computedPaths.add(path);
238
+
239
+ const recompute = () => {
240
+ if (computedUpdating.has(path)) return; // loop guard
241
+ computedUpdating.add(path);
242
+ try {
243
+ const newVal = fn();
244
+ // Bypass the computed-path warning by going through setByPath directly
245
+ const result = setByPath(initialState, path, newVal);
246
+ if (result.changed) notify(path, result.previousValue);
247
+ } finally {
248
+ computedUpdating.delete(path);
249
+ }
250
+ };
251
+
252
+ // Initial computation
253
+ recompute();
254
+
255
+ // Subscribe to each dep
256
+ const unsubs = deps.map((dep) => this.subscribe(dep, recompute));
257
+
258
+ return function dispose() {
259
+ for (const unsub of unsubs) unsub();
260
+ computedPaths.delete(path);
261
+ };
262
+ },
263
+ };
264
+ }
265
+
@@ -0,0 +1,263 @@
1
+ /**
2
+ * @module template
3
+ * @description <template> reading, fragment cache, and static ${path} interpolation.
4
+ *
5
+ * Core rules:
6
+ * - Source: a standard <template id="tpl-{name}"> element (DOM, not a string).
7
+ * - Output: DocumentFragment — not a string innerHTML.
8
+ * - ${path}: path resolution only. NO expression evaluation (new Function).
9
+ * - Resolved values are written RAW to node.nodeValue / attr.value.
10
+ * NOT escaped: neither of these two DOM properties parses HTML (no entity
11
+ * decoding), so escapeHtml/safeAttr is unnecessary here — and if used,
12
+ * characters like "&" would show up literally as "&amp;" on screen
13
+ * (double-encoding). Security already comes from the DOM APIs themselves;
14
+ * see the same rationale in bindings.js (the raw-value rule for setAttribute).
15
+ * - The cache holds the original; every render takes its own cloneNode(true).
16
+ * - <if>, <for>, <partial>, and reactive data-* are NOT processed in this module.
17
+ * - resolveStatic does not touch content inside a not-yet-expanded <if
18
+ * data-live>/<for data-live> OR an ordinary (non-data-live) <for> —
19
+ * preventing early/wrong-context resolution in nested loops (see
20
+ * inLiveBlock, inUnexpandedFor).
21
+ * - getTemplate detects (dev-mode, one-time) special tags that were written
22
+ * inside a <table> but got moved out by the HTML parser's foster-parenting
23
+ * when the template was first read, and warns about it — it does NOT fix
24
+ * the behavior, only warns actionably (see detectTableFosterParenting).
25
+ */
26
+
27
+ import { getByPath } from "./store.js";
28
+ import { errors, isDevMode } from "./errors.js";
29
+ import { inLiveBlock, inUnexpandedFor } from "./shared.js";
30
+
31
+ /** @type {Map<string, DocumentFragment>} Holds original fragments; used before cloning. */
32
+ const templateCache = new Map();
33
+
34
+ // Special tags written inside a <table> but moved out by the HTML parser's
35
+ // "foster parenting" algorithm (see detectTableFosterParenting).
36
+ // NOTE: <partial> is DELIBERATELY left out — <partial> is ALWAYS childless BY
37
+ // DESIGN (it's just a reference), so the "is it empty?" signal carries no
38
+ // discriminating power for it (see the function's JSDoc).
39
+ const SPECIAL_TAGS = new Set(['IF', 'FOR', 'ELSE']);
40
+ const TABLE_CHILD_SELECTOR = 'tr, td, th, tbody, thead, tfoot, caption, col, colgroup';
41
+
42
+ // Regex that matches ${...} placeholders — must contain only a path, not an expression
43
+ const PLACEHOLDER = /\$\{([^}]+)\}/g;
44
+
45
+ /**
46
+ * Resolves a dotted path from the context object. Returns an empty string if not found.
47
+ *
48
+ * Returns a raw string (NO escaping): the result is only ever written to
49
+ * node.nodeValue / attr.value, neither of which parses HTML, so escaping
50
+ * would be both unnecessary and wrong (double-encoding). Security comes from
51
+ * the DOM APIs themselves.
52
+ *
53
+ * @param {string} path - Dotted path, e.g. "user.name"
54
+ * @param {Object} context - Plain object to read values from
55
+ * @returns {string}
56
+ */
57
+ function resolvePath(path, context) {
58
+ const value = getByPath(context, path.trim());
59
+ if (value == null) return "";
60
+ return String(value);
61
+ }
62
+
63
+ /**
64
+ * Resolves every ${path} placeholder in a string from the context.
65
+ * new Function is NEVER used; only path resolution via getByPath.
66
+ *
67
+ * @param {string} str - Raw string that may contain ${...}
68
+ * @param {Object} context - Object to read values from
69
+ * @returns {string}
70
+ */
71
+ function resolveString(str, context) {
72
+ return str.replace(PLACEHOLDER, (_match, path) => resolvePath(path, context));
73
+ }
74
+
75
+ /**
76
+ * Is the node INSIDE the content of a not-yet-expanded <if data-live> or
77
+ * <for data-live> block? (Consistent with the same pattern in bindings.js.)
78
+ *
79
+ * IMPORTANT DISTINCTION: if an element IS ITSELF a live-root
80
+ * (if[data-live]/for[data-live]) — even if one of ITS ANCESTORS is ALSO a
81
+ * live-root — its own attributes like is-.../than/each/as/data-live may
82
+ * carry a dynamic path via ${...} (e.g. `is-truthy="${likedPath}"`, or
83
+ * `each="${repliesPath}"` on a nested <for data-live>) and MUST be resolved
84
+ * this pass: all live-roots within the same partial/render call share the
85
+ * SAME (correct) context. Only descendants that are NOT themselves a
86
+ * live-root (text/element CONTENT) are deferred.
87
+ *
88
+ * @param {Node} node
89
+ * @returns {boolean}
90
+ */
91
+
92
+
93
+ /**
94
+ * Is the node INSIDE the content of a not-yet-expanded ordinary
95
+ * (non-data-live) <for> block? (Same pattern as inUnexpandedFor in partials.js.)
96
+ *
97
+ * WHY THIS IS NEEDED: loops.js calls resolveStatic(frag, itemContext) for the
98
+ * outer item BEFORE expanding an inner (not-yet-expanded) <for>. A bare
99
+ * interpolation like ${p.label} inside the inner <for>'s body, if resolved
100
+ * this pass against the outer context (since p isn't bound yet), would
101
+ * become an empty/wrong string — AND since the placeholder would be gone,
102
+ * the inner loop's own (correct-context) pass would have nothing left to
103
+ * resolve. So such a node is SKIPPED here; the inner <for>'s own
104
+ * resolveStatic call (via loops.js's recursion in the same pass) resolves it
105
+ * with the correct itemContext.
106
+ *
107
+ * SAME DISTINCTION (consistent with inLiveBlock): if an element IS ITSELF a
108
+ * not-yet-expanded <for>, its own attributes like each/as/index (e.g.
109
+ * `each="${x}"`) can still be resolved this pass — only descendants INSIDE
110
+ * the <for> (text/element content) are deferred.
111
+ *
112
+ * @param {Node} node
113
+ * @returns {boolean}
114
+ */
115
+
116
+
117
+ /**
118
+ * Are the <table>s in the fragment victims of a special tag that got moved
119
+ * out by the HTML parser's "foster parenting" algorithm?
120
+ *
121
+ * WHY THIS SIGNAL — REAL PARSER BEHAVIOR (verified in headless Chromium):
122
+ * When you write `<table><if is-truthy="x"><tr><td>${row}</td></tr></if></table>`,
123
+ * the WHATWG HTML "in table" insertion mode's "anything else" rule moves
124
+ * <if> to right BEFORE the table DURING PARSING (foster parenting; see
125
+ * https://html.spec.whatwg.org/#parsing-main-intable). BUT the <tr>/<td>
126
+ * that arrive while <if> is still on the open-elements stack are
127
+ * TABLE-VALID tags themselves, so they are NOT foster-parented — they go
128
+ * straight into <table><tbody>, not INSIDE <if>. Result: <if> is left as a
129
+ * COMPLETELY EMPTY shell before the table; <tr>/<td> end up inside <table>
130
+ * but rendered UNCONDITIONALLY (as if <if> never wrapped them at all). So
131
+ * LOOKING FOR a table-child INSIDE a fostered special tag is wrong (it will
132
+ * never be found) — the actual signature is the tag being left EMPTY.
133
+ *
134
+ * REDUCING FALSE-POSITIVE RISK: checking only "is there an empty <if>/<for>
135
+ * right before a table" alone isn't strong enough either (rarely, a
136
+ * deliberately empty block could happen to sit right next to an unrelated
137
+ * table). So TWO signals are required together: (1) the special tag is
138
+ * COMPLETELY empty (no element or text children) AND (2) the adjacent
139
+ * <table> ACTUALLY contains row content (tr/td/tbody/...). A block that is
140
+ * NOT empty — e.g. plain <if>...</if><table>...</table> unrelated to the
141
+ * table (satisfies condition 2 but not condition 1) — never produces a false positive.
142
+ *
143
+ * OUT OF SCOPE (deliberate limits):
144
+ * - <partial>: not included in SPECIAL_TAGS — <partial> is ALWAYS childless
145
+ * by design, so the "is it empty?" signal carries no discriminating power
146
+ * for it (looks the same whether fostered or not). Since there's no
147
+ * reliable discriminating signal, it's not checked at all, to avoid
148
+ * raising false-positive risk.
149
+ * - Not detected if the special tag is NOT the <table>'s immediate previous
150
+ * sibling (e.g. nested inside a <div>) — a false negative, acceptable per
151
+ * KISS (this is only a dev-mode warning anyway, it doesn't fix the behavior).
152
+ *
153
+ * @param {DocumentFragment|Element} fragment
154
+ * @param {string} templateName
155
+ * @returns {void}
156
+ */
157
+ function detectTableFosterParenting(fragment, templateName) {
158
+ for (const table of fragment.querySelectorAll('table')) {
159
+ const prev = table.previousElementSibling;
160
+ if (!prev || !SPECIAL_TAGS.has(prev.tagName)) continue;
161
+ const isEmptyShell = prev.childElementCount === 0 && prev.textContent.trim() === '';
162
+ if (isEmptyShell && table.querySelector(TABLE_CHILD_SELECTOR)) {
163
+ errors.tableFosterParenting(templateName);
164
+ return; // one warning per template is enough
165
+ }
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Finds the <template id="tpl-{name}"> element and caches the original fragment.
171
+ * Every call returns an independent copy via cloneNode(true) from the cache.
172
+ *
173
+ * The foster-parenting check (detectTableFosterParenting) runs only ONCE,
174
+ * when the template is FIRST read (entering the cache) — NOT on every
175
+ * render, for performance. Doesn't run at all if dev_mode is off.
176
+ *
177
+ * @param {string} name - Template name; looked up in the DOM as `id="tpl-{name}"`.
178
+ * @returns {DocumentFragment|null} Cloned fragment; null if the template isn't found.
179
+ */
180
+ export function getTemplate(name) {
181
+ if (!templateCache.has(name)) {
182
+ const el = document.getElementById(`tpl-${name}`);
183
+ if (!el || el.tagName !== "TEMPLATE") {
184
+ errors.templateNotFound(name);
185
+ return null;
186
+ }
187
+ if (isDevMode()) detectTableFosterParenting(el.content, name);
188
+ // Store the original content; cloning happens on every render
189
+ templateCache.set(name, el.content);
190
+ }
191
+ return templateCache.get(name).cloneNode(true);
192
+ }
193
+
194
+ /**
195
+ * Updates every text node and element attribute inside a DocumentFragment via
196
+ * ${path} resolution. Static (one-time) interpolation only — reactive
197
+ * updates are bindings.js's responsibility.
198
+ *
199
+ * Does NOT touch nodes inside a not-yet-expanded <if data-live>/<for
200
+ * data-live> — that content is only resolved via setupLiveIfs/setupLiveFors's
201
+ * renderFn call, with the correct branch/item context (see inLiveBlock). For
202
+ * the same reason, also does NOT touch nodes INSIDE a not-yet-expanded
203
+ * ordinary (non-data-live) <for> — in nested static <for>s, the inner loop's
204
+ * own variable isn't bound yet, so early resolution would be wrong/empty
205
+ * (see inUnexpandedFor). That content is resolved with the correct
206
+ * itemContext by loops.js's own recursive resolveStatic call in the same pass.
207
+ *
208
+ * @param {DocumentFragment|Element} root - Root node to traverse
209
+ * @param {Object} context - Object used for path resolution
210
+ * @returns {void}
211
+ */
212
+ export function resolveStatic(root, context) {
213
+ // TreeWalker: traverses both text nodes (SHOW_TEXT) and elements (SHOW_ELEMENT)
214
+ const walker = document.createTreeWalker(
215
+ root,
216
+ NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT,
217
+ );
218
+
219
+ let node = walker.nextNode();
220
+ while (node) {
221
+ if (inLiveBlock(node) || inUnexpandedFor(node)) {
222
+ node = walker.nextNode();
223
+ continue;
224
+ }
225
+ if (node.nodeType === Node.TEXT_NODE) {
226
+ // ${} in the middle of a text node: "Hello ${user.name}, welcome" is a single node.
227
+ // A one-time replacement is sufficient here; splitting for reactivity, if
228
+ // needed, is done by bindings.js.
229
+ if (PLACEHOLDER.test(node.nodeValue)) {
230
+ PLACEHOLDER.lastIndex = 0; // reset the stateful regex
231
+ node.nodeValue = resolveString(node.nodeValue, context);
232
+ }
233
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
234
+ // ${} in element attributes
235
+ for (const attr of Array.from(node.attributes)) {
236
+ if (PLACEHOLDER.test(attr.value)) {
237
+ PLACEHOLDER.lastIndex = 0;
238
+ attr.value = resolveString(attr.value, context);
239
+ }
240
+ }
241
+ }
242
+ node = walker.nextNode();
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Reads the template, clones it, resolves ${path} placeholders with the
248
+ * context, and returns a ready DocumentFragment.
249
+ *
250
+ * Why it returns a fragment (not a string): reactive handles (bindings.js)
251
+ * will later bind directly to DOM nodes; that binding couldn't be
252
+ * established if a string were returned.
253
+ *
254
+ * @param {string} name - Template name
255
+ * @param {Object} [context={}] - Value object for ${path} resolution
256
+ * @returns {DocumentFragment|null} Ready fragment; null if the template isn't found.
257
+ */
258
+ export function renderTemplate(name, context = {}) {
259
+ const fragment = getTemplate(name);
260
+ if (!fragment) return null;
261
+ resolveStatic(fragment, context);
262
+ return fragment;
263
+ }