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/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
|
+
|
package/src/template.js
ADDED
|
@@ -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 "&" 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
|
+
}
|