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/index.js
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module index
|
|
3
|
+
* lime-csr.js -- main entry point and orchestration layer.
|
|
4
|
+
*
|
|
5
|
+
* Modules do NOT import each other; ordering is done only here.
|
|
6
|
+
*
|
|
7
|
+
* RENDER PIPELINE ORDER (critical):
|
|
8
|
+
* 1. beforeRender(context, store) -- lifecycle hook (optional, from mount options)
|
|
9
|
+
* 2. expandPartials -- replace <partial>s with their contents (recursive).
|
|
10
|
+
* 3. expandLoops -- expand <for>s based on array elements.
|
|
11
|
+
* 4. processAllIfs -- select <if>/<else> branches.
|
|
12
|
+
* (1-3 may be nested, so loop until no special tags remain)
|
|
13
|
+
* 5. resolveStatic -- resolve remaining ${path} placeholders (top-level static).
|
|
14
|
+
* 6. setupModelBindings -- data-model two-way form binding. RUNS FIRST (2h).
|
|
15
|
+
* 7. setupBindings -- data-text + {x}/data-x reactive bindings.
|
|
16
|
+
* 8. setupShowBindings -- data-show reactive visibility (display toggle).
|
|
17
|
+
* (6-8 order matters for 2h: data-model runs before data-on-* handlers)
|
|
18
|
+
* 9. afterRender(rootEl, store) -- lifecycle hook (optional, from mount options)
|
|
19
|
+
*
|
|
20
|
+
* WHY resolveStatic LAST:
|
|
21
|
+
* If called first, ${item.x} inside a <for> would be resolved in the wrong
|
|
22
|
+
* (top-level) context -> "". expandLoops already calls resolveStatic itself;
|
|
23
|
+
* only the top-level ${path}s (e.g. ${pageTitle}) need resolution after
|
|
24
|
+
* structural expansion.
|
|
25
|
+
*
|
|
26
|
+
* WHY setupBindings LAST:
|
|
27
|
+
* Binding before structure is fixed (partial/for/if resolved) would open
|
|
28
|
+
* subscriptions on nodes that will be deleted -> memory leak.
|
|
29
|
+
*
|
|
30
|
+
* pipeline() CALLBACK:
|
|
31
|
+
* expandLoops can receive pipeline() per item; this lets <if> and <partial>
|
|
32
|
+
* inside the loop resolve correctly in the item context (itemContext).
|
|
33
|
+
*
|
|
34
|
+
* data-live BOUNDARY (hasSpecialTags + inLiveBlock):
|
|
35
|
+
* A not-yet-expanded <if data-live>/<for data-live> block's INNER
|
|
36
|
+
* ordinary <partial>/<for>/<if> are not touched in this pass -- they would
|
|
37
|
+
* be rendered with the wrong (outer) context. That content is only processed
|
|
38
|
+
* by setupLiveIfs/setupLiveFors's renderFn (render()) with the correct
|
|
39
|
+
* branch/element context.
|
|
40
|
+
*
|
|
41
|
+
* STATIC <for> + <partial> BOUNDARY (RESOLVED):
|
|
42
|
+
* Previously: a <partial> inside a static (non-data-live) <for> that
|
|
43
|
+
* referenced a loop variable (data="item.x") would resolve with the wrong
|
|
44
|
+
* (outer) context. Now partials.js's inUnexpandedFor() defers such <partial>s;
|
|
45
|
+
* expandLoops's SAME-PASS pipeline() call handles them with the correct context.
|
|
46
|
+
* Pipeline ORDER DID NOT CHANGE -- only which <partial>s expandPartials touches.
|
|
47
|
+
*
|
|
48
|
+
* EVENT DELEGATION (mount()'s OPTIONAL 5th argument -- NOT in render() PIPELINE):
|
|
49
|
+
* data-on-{event} (bindings-events.js) is NOT part of the render() pipeline;
|
|
50
|
+
* it sets up a single delegation listener on mount()'s `target` only.
|
|
51
|
+
* `options.handlers` not provided -> event delegation NOT set up (zero cost,
|
|
52
|
+
* backward compatible).
|
|
53
|
+
*
|
|
54
|
+
* LIFECYCLE HOOKS (2e):
|
|
55
|
+
* mount() options.beforeRender(context, store): called BEFORE pipeline.
|
|
56
|
+
* mount() options.afterRender(rootEl, store): called AFTER content appended to target.
|
|
57
|
+
* Both are optional and backward-compatible (omitting them changes nothing).
|
|
58
|
+
*
|
|
59
|
+
* BLOCK-LEVEL HOOKS (data-after/data-before) NEED `handlers` INSIDE render():
|
|
60
|
+
* Unlike data-on-* (bindings-events.js), which is delegation-based and lives
|
|
61
|
+
* entirely outside the render() pipeline, data-after/data-before on
|
|
62
|
+
* <if data-live>/<for data-live> (bindings-blocks.js/bindings-loops.js) are
|
|
63
|
+
* evaluated DURING setupLiveIfs/setupLiveFors -- when a branch/item is
|
|
64
|
+
* actually placed in or removed from the DOM. That happens on every
|
|
65
|
+
* reconcile, not just at mount() time, so `handlers` cannot be threaded in
|
|
66
|
+
* as a one-time delegation listener the way data-on-* is; it must be
|
|
67
|
+
* available to render() itself. render() therefore takes `handlers` as an
|
|
68
|
+
* explicit 4th parameter and passes it straight through to setupLiveFors/
|
|
69
|
+
* setupLiveIfs as a 5th argument, which those modules also forward on their
|
|
70
|
+
* own recursive renderFn(...) calls -- so nested live-blocks inside a
|
|
71
|
+
* branch/item see the same handlers dictionary. mount() passes
|
|
72
|
+
* options.handlers into the initial render() call. Omitting handlers is
|
|
73
|
+
* backward-compatible: data-after/data-before are simply skipped with a
|
|
74
|
+
* dev-mode warning if referenced without a handlers dictionary.
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
import { getTemplate, resolveStatic } from './template.js';
|
|
78
|
+
import { expandPartials } from './partials.js';
|
|
79
|
+
import { expandLoops } from './loops.js';
|
|
80
|
+
import { processAllIfs } from './conditionals.js';
|
|
81
|
+
import { setupBindings } from './bindings.js';
|
|
82
|
+
import { setupModelBindings } from './bindings-model.js';
|
|
83
|
+
import { setupShowBindings } from './bindings-show.js';
|
|
84
|
+
import { setupEventBindings } from './bindings-events.js';
|
|
85
|
+
import { setupLiveIfs } from './bindings-blocks.js';
|
|
86
|
+
import { setupLiveFors } from './bindings-loops.js';
|
|
87
|
+
import { errors } from './errors.js';
|
|
88
|
+
import { inLiveBlock } from './shared.js';
|
|
89
|
+
|
|
90
|
+
// ── Re-exports for external consumers ────────────────────────────────────────
|
|
91
|
+
export { createStore, getByPath, setByPath } from './store.js';
|
|
92
|
+
export { getTemplate, resolveStatic, renderTemplate } from './template.js';
|
|
93
|
+
export { escapeHtml, safeAttr, safeUrl, safeStyleUrl } from './utils.js';
|
|
94
|
+
export { evalCondition, processAllIfs, OPERATORS } from './conditionals.js';
|
|
95
|
+
export { expandLoops } from './loops.js';
|
|
96
|
+
export { expandPartials } from './partials.js';
|
|
97
|
+
export { setupBindings } from './bindings.js';
|
|
98
|
+
export { setupModelBindings } from './bindings-model.js';
|
|
99
|
+
export { setupShowBindings } from './bindings-show.js';
|
|
100
|
+
export { setupEventBindings } from './bindings-events.js';
|
|
101
|
+
export { setDevMode, isDevMode, warn } from './errors.js';
|
|
102
|
+
export { setupLiveIfs } from './bindings-blocks.js';
|
|
103
|
+
export { setupLiveFors } from './bindings-loops.js';
|
|
104
|
+
|
|
105
|
+
// ── Infinite loop protection ─────────────────────────────────────────────────
|
|
106
|
+
const MAX_PIPELINE_ITERATIONS = 100;
|
|
107
|
+
|
|
108
|
+
// ── Last cleanup record per target ───────────────────────────────────────────
|
|
109
|
+
// WeakMap: automatically cleaned up when the target element is GC'd.
|
|
110
|
+
const mountedTargets = new WeakMap();
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Is there a structural tag under root that the pipeline can STILL process?
|
|
116
|
+
*
|
|
117
|
+
* <if>/<for> with data-live are left to setupLiveIfs/setupLiveFors; the
|
|
118
|
+
* pipeline does NOT touch them. Likewise, ordinary <partial>/<for>/<if>
|
|
119
|
+
* INSIDE a not-yet-expanded live block also don't count this pass — they too
|
|
120
|
+
* are only processed once the correct branch/item context is supplied by
|
|
121
|
+
* renderFn (render()). Without this check, runPipeline would spin uselessly
|
|
122
|
+
* up to the MAX_PIPELINE_ITERATIONS limit because of live-block content that
|
|
123
|
+
* never advances.
|
|
124
|
+
*
|
|
125
|
+
* @param {Element|DocumentFragment} root
|
|
126
|
+
* @returns {boolean}
|
|
127
|
+
*/
|
|
128
|
+
function hasSpecialTags(root) {
|
|
129
|
+
const pending = (selector) =>
|
|
130
|
+
Array.from(root.querySelectorAll(selector)).some((el) => !inLiveBlock(el));
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
pending('partial') ||
|
|
134
|
+
pending('for:not([data-live])') ||
|
|
135
|
+
pending('if:not([data-live])')
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Runs the partial → for → if loop on the given fragment.
|
|
141
|
+
* Also passed as a callback to expandLoops AND expandPartials; this way
|
|
142
|
+
* inner partial/for/if resolve correctly with the loop item's / partial's
|
|
143
|
+
* (isolated/inherited) context.
|
|
144
|
+
*
|
|
145
|
+
* @param {DocumentFragment|Element} frag
|
|
146
|
+
* @param {Object} ctx
|
|
147
|
+
* @param {number} [depth=0] - Partial recursion depth (internal; passed to expandPartials)
|
|
148
|
+
*/
|
|
149
|
+
function runPipeline(frag, ctx, depth = 0) {
|
|
150
|
+
let iterations = 0;
|
|
151
|
+
while (hasSpecialTags(frag)) {
|
|
152
|
+
if (++iterations > MAX_PIPELINE_ITERATIONS) {
|
|
153
|
+
errors.pipelineDepthLimit(MAX_PIPELINE_ITERATIONS, frag);
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
// Passing the pipeline callback: so inner if/for/partial also resolve
|
|
157
|
+
// correctly within the partial's own isolated context (see partials.js).
|
|
158
|
+
expandPartials(frag, ctx, depth, runPipeline);
|
|
159
|
+
// Passing the pipeline callback: so inner if/partial also resolve within the loop item's context
|
|
160
|
+
expandLoops(frag, ctx, runPipeline);
|
|
161
|
+
processAllIfs(frag, ctx);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Applies the full render pipeline to a fragment and returns a cleanup function.
|
|
167
|
+
*
|
|
168
|
+
* Pure function: not tied to global state, takes store and context as parameters.
|
|
169
|
+
* A reactive block (<if data-live>, reactive <for>) will call this function
|
|
170
|
+
* again for a subtree in the future: when the condition/list changes, the old
|
|
171
|
+
* subtree is cleaned up, the new fragment is processed with render() and placed.
|
|
172
|
+
*
|
|
173
|
+
* @param {DocumentFragment} fragment - Cloned fragment from getTemplate()
|
|
174
|
+
* @param {Object} context - For static ${path} resolution (stateless)
|
|
175
|
+
* @param {import('./store.js').Store|null} store - For reactive bindings
|
|
176
|
+
* @param {Object<string, function(Event, Element): void>} [handlers] - For
|
|
177
|
+
* data-on-*'s handler lookup (bindings-events.js, via mount() only) AND
|
|
178
|
+
* for block-level data-after/data-before hooks (bindings-blocks.js/
|
|
179
|
+
* bindings-loops.js, evaluated here). Optional; omitting it just means
|
|
180
|
+
* data-after/data-before are skipped with a dev-mode warning if referenced.
|
|
181
|
+
* @returns {function(): void} cleanup — cancels the subscriptions from setupBindings
|
|
182
|
+
*/
|
|
183
|
+
export function render(fragment, context, store, handlers) {
|
|
184
|
+
// 1-3. Structural expansion: loop until no partial/for/non-live-if remains.
|
|
185
|
+
// <if>s with data-live are PRESERVED at this stage — left to setupLiveIfs.
|
|
186
|
+
runPipeline(fragment, context);
|
|
187
|
+
|
|
188
|
+
// 4. Resolve the remaining static ${path} placeholders.
|
|
189
|
+
resolveStatic(fragment, context);
|
|
190
|
+
|
|
191
|
+
// 5b. Two-way form binding (data-model). RUNS FIRST (2h): when data-model
|
|
192
|
+
// and data-on-input are on the same element, model's listener is
|
|
193
|
+
// registered before data-on-* handlers, so the store is up to date
|
|
194
|
+
// by the time the application handler runs.
|
|
195
|
+
// Same live-block skipping rule (bindings-model.js filter).
|
|
196
|
+
const modelCleanup = store ? setupModelBindings(fragment, store) : () => {};
|
|
197
|
+
|
|
198
|
+
// 5c. Reactive text/attr bindings (data-text + {x}/data-x). Runs after
|
|
199
|
+
// model bindings per 2h ordering. Live-block content bound by
|
|
200
|
+
// setupLiveFors/setupLiveIfs below -- no double-subscribe.
|
|
201
|
+
const bindingsCleanup = store ? setupBindings(fragment, store) : () => {};
|
|
202
|
+
|
|
203
|
+
// 5d. Reactive visibility (data-show). RUNS BEFORE DOM APPEND so the
|
|
204
|
+
// initial state has no FOUC (see bindings-show.js).
|
|
205
|
+
// Same live-block skipping rule; order vs. 5b/5c does not matter.
|
|
206
|
+
const showCleanup = store ? setupShowBindings(fragment, store) : () => {};
|
|
207
|
+
|
|
208
|
+
// 6. Reactive <for data-live>: key-based diff + identity preservation.
|
|
209
|
+
// ORDER CRITICAL: setupLiveFors runs first. <if data-live> inside a
|
|
210
|
+
// <for data-live> is handled by the recursive render() call inside
|
|
211
|
+
// renderFn; by the time setupLiveIfs runs, those are already anchors.
|
|
212
|
+
// `handlers` is passed through for data-after/data-before lookups; the
|
|
213
|
+
// module's own recursive renderFn(...) calls also forward it, since
|
|
214
|
+
// render() itself accepts (and needs) a 4th `handlers` argument.
|
|
215
|
+
const liveForCleanup = store
|
|
216
|
+
? setupLiveFors(fragment, context, store, render, handlers)
|
|
217
|
+
: () => {};
|
|
218
|
+
|
|
219
|
+
// 7. Reactive <if data-live>: tear-down/rebuild + branch cleanup.
|
|
220
|
+
// render() itself is passed as renderFn; re-called on branch change.
|
|
221
|
+
// `handlers` is threaded through the same way as setupLiveFors above.
|
|
222
|
+
const liveCleanup = store
|
|
223
|
+
? setupLiveIfs(fragment, context, store, render, handlers)
|
|
224
|
+
: () => {};
|
|
225
|
+
|
|
226
|
+
return function cleanup() {
|
|
227
|
+
modelCleanup();
|
|
228
|
+
bindingsCleanup();
|
|
229
|
+
showCleanup();
|
|
230
|
+
liveForCleanup();
|
|
231
|
+
liveCleanup();
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Fetches a template, renders it, and mounts it to a DOM target.
|
|
237
|
+
* If called again on the same target: old cleanup runs, content cleared,
|
|
238
|
+
* new content mounted (page/component transition).
|
|
239
|
+
*
|
|
240
|
+
* @param {string} templateName
|
|
241
|
+
* @param {Object} context
|
|
242
|
+
* @param {Element} target
|
|
243
|
+
* @param {import('./store.js').Store|null} store
|
|
244
|
+
* @param {{
|
|
245
|
+
* handlers?: Object<string, function(Event, Element): void>,
|
|
246
|
+
* beforeRender?: function(Object, import('./store.js').Store): void,
|
|
247
|
+
* afterRender?: function(Element, import('./store.js').Store): void
|
|
248
|
+
* }} [options={}]
|
|
249
|
+
* handlers: event delegation (bindings-events.js). Omit for zero cost.
|
|
250
|
+
* beforeRender(context, store): called BEFORE the render pipeline.
|
|
251
|
+
* afterRender(rootEl, store): called AFTER content is appended to target.
|
|
252
|
+
* Both lifecycle hooks are optional and backward-compatible.
|
|
253
|
+
* @returns {function(): void} cleanup
|
|
254
|
+
*/
|
|
255
|
+
export function mount(templateName, context, target, store, options = {}) {
|
|
256
|
+
// If already mounted on this target: cancel old subscriptions and clear content
|
|
257
|
+
const previous = mountedTargets.get(target);
|
|
258
|
+
if (previous) {
|
|
259
|
+
previous.cleanup();
|
|
260
|
+
// textContent = '' removes all child nodes (faster than innerHTML, no XSS risk)
|
|
261
|
+
target.textContent = '';
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 2e: beforeRender lifecycle hook
|
|
265
|
+
if (typeof options.beforeRender === 'function') {
|
|
266
|
+
options.beforeRender(context, store);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const fragment = getTemplate(templateName);
|
|
270
|
+
if (!fragment) {
|
|
271
|
+
const available = Array.from(document.querySelectorAll('template[id^="tpl-"]'))
|
|
272
|
+
.map((t) => t.id.slice(4));
|
|
273
|
+
errors.mountTemplateNotFound(templateName, available, target);
|
|
274
|
+
return () => {};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const renderCleanup = render(fragment, context, store, options.handlers);
|
|
278
|
+
target.appendChild(fragment);
|
|
279
|
+
|
|
280
|
+
// 2e: afterRender lifecycle hook (called after content is in the DOM)
|
|
281
|
+
if (typeof options.afterRender === 'function') {
|
|
282
|
+
options.afterRender(target, store);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Event delegation: root is target (stable mount-lifetime root -- reactive
|
|
286
|
+
// inner changes don't affect it, see bindings-events.js).
|
|
287
|
+
const eventsCleanup = options.handlers
|
|
288
|
+
? setupEventBindings(target, store, options.handlers)
|
|
289
|
+
: () => {};
|
|
290
|
+
|
|
291
|
+
const cleanup = function cleanup() {
|
|
292
|
+
renderCleanup();
|
|
293
|
+
eventsCleanup();
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
mountedTargets.set(target, { cleanup });
|
|
297
|
+
return cleanup;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Cancels the reactive bindings on target and clears its content.
|
|
302
|
+
*
|
|
303
|
+
* @param {Element} target
|
|
304
|
+
*/
|
|
305
|
+
export function unmount(target) {
|
|
306
|
+
const entry = mountedTargets.get(target);
|
|
307
|
+
if (entry) {
|
|
308
|
+
entry.cleanup();
|
|
309
|
+
mountedTargets.delete(target);
|
|
310
|
+
}
|
|
311
|
+
target.textContent = '';
|
|
312
|
+
}
|
package/src/loops.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module loops
|
|
3
|
+
* @description Statically expands <for each="..." as="..."> lists in the DOM.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* <for each="comments" as="comment">
|
|
7
|
+
* <p>${comment.body}</p>
|
|
8
|
+
* </for>
|
|
9
|
+
*
|
|
10
|
+
* SCOPE DIFFERENCE — contrast with partial isolation:
|
|
11
|
+
* - partial: isolated context — only the "data" object is visible, parent is hidden.
|
|
12
|
+
* - for: INHERITED context — parent context is preserved, "as" (and index) are added on top.
|
|
13
|
+
* Accessing outer variables (e.g. ${post.title}) inside <for> is natural.
|
|
14
|
+
* In a nested <for>, the inner "as" naturally shadows the outer "as" via spread.
|
|
15
|
+
*
|
|
16
|
+
* Other rules:
|
|
17
|
+
* - Empty array → <for> is removed, nothing is printed (not an error).
|
|
18
|
+
* - "each" is not an array → warning + <for> is removed.
|
|
19
|
+
* - <for> leaves no trace in the final DOM; only the expanded content remains.
|
|
20
|
+
* - Nested <for> is processed recursively; inner <partial>/<if> are ordered by index.js.
|
|
21
|
+
* - Reactive <for> (updates on list change) is NOT in this module — it's in bindings.js.
|
|
22
|
+
* - Does NOT import conditionals.js or partials.js; orchestration lives in index.js.
|
|
23
|
+
* - HTML5: <for/> is not treated as void; use <for ...></for>.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { getByPath } from './store.js';
|
|
27
|
+
import { resolveStatic } from './template.js';
|
|
28
|
+
import { errors } from './errors.js';
|
|
29
|
+
import { inLiveBlock } from './shared.js';
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Expands every <for> element under root.
|
|
35
|
+
* For each array item, <for>'s content is cloned and resolved with the inherited context.
|
|
36
|
+
*
|
|
37
|
+
* @param {Element|DocumentFragment} root - Root to traverse
|
|
38
|
+
* @param {Object} context - Context for path resolution
|
|
39
|
+
* @returns {void}
|
|
40
|
+
*/
|
|
41
|
+
export function expandLoops(root, context, pipeline = null) {
|
|
42
|
+
// Copy up front to avoid the live-NodeList problem
|
|
43
|
+
const fors = Array.from(root.querySelectorAll('for'));
|
|
44
|
+
|
|
45
|
+
for (const forEl of fors) {
|
|
46
|
+
// May already have left the DOM via replaceWith if it was inside an already-processed <for>
|
|
47
|
+
if (!root.contains(forEl)) continue;
|
|
48
|
+
|
|
49
|
+
// data-live → reactive list; left to bindings-loops.js, don't touch here.
|
|
50
|
+
if (forEl.hasAttribute('data-live')) continue;
|
|
51
|
+
|
|
52
|
+
// Also don't touch it if it's inside a not-yet-expanded live-if/live-for block —
|
|
53
|
+
// the correct (branch/item) context only arrives via renderFn's (render()) call.
|
|
54
|
+
if (inLiveBlock(forEl)) continue;
|
|
55
|
+
|
|
56
|
+
const each = forEl.getAttribute('each');
|
|
57
|
+
const as = forEl.getAttribute('as');
|
|
58
|
+
const indexAttr = forEl.getAttribute('index'); // optional: 0, 1, 2...
|
|
59
|
+
|
|
60
|
+
if (!each || !as) {
|
|
61
|
+
errors.forMissingAttr(forEl);
|
|
62
|
+
forEl.remove();
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const list = getByPath(context, each);
|
|
67
|
+
|
|
68
|
+
if (!Array.isArray(list)) {
|
|
69
|
+
errors.forNotArray(each, list === null ? 'null' : typeof list, forEl);
|
|
70
|
+
forEl.remove();
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Empty array: remove <for>, nothing is printed — this is not an error
|
|
75
|
+
if (list.length === 0) {
|
|
76
|
+
forEl.remove();
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Store <for>'s content as template nodes; don't mutate the original
|
|
81
|
+
const templateNodes = Array.from(forEl.childNodes).map((n) => n.cloneNode(true));
|
|
82
|
+
|
|
83
|
+
const allNodes = [];
|
|
84
|
+
|
|
85
|
+
for (let i = 0; i < list.length; i++) {
|
|
86
|
+
const item = list[i];
|
|
87
|
+
|
|
88
|
+
// Inherited context: UNLIKE partial, the parent context is preserved.
|
|
89
|
+
// "as" (and "index" if present) is added on top; in a nested for, the
|
|
90
|
+
// inner "as" naturally shadows the outer "as" via spread.
|
|
91
|
+
const itemContext = { ...context, [as]: item };
|
|
92
|
+
if (indexAttr) itemContext[indexAttr] = i;
|
|
93
|
+
|
|
94
|
+
// Clone the content nodes independently for each item
|
|
95
|
+
const frag = document.createDocumentFragment();
|
|
96
|
+
for (const node of templateNodes) frag.appendChild(node.cloneNode(true));
|
|
97
|
+
|
|
98
|
+
// Static ${path} resolution: text nodes + attributes
|
|
99
|
+
resolveStatic(frag, itemContext);
|
|
100
|
+
|
|
101
|
+
// Process inner structural tags.
|
|
102
|
+
// If pipeline is given (comes from index.js): resolve partial+for+if all in the CORRECT context.
|
|
103
|
+
// If not (direct call / legacy behavior): only recurse into inner <for>s.
|
|
104
|
+
if (pipeline) {
|
|
105
|
+
pipeline(frag, itemContext);
|
|
106
|
+
} else {
|
|
107
|
+
expandLoops(frag, itemContext);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Move nodes into allNodes (removed from frag, order preserved)
|
|
111
|
+
allNodes.push(...Array.from(frag.childNodes));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Replace <for> with all the rendered content; leaves no trace
|
|
115
|
+
forEl.replaceWith(...allNodes);
|
|
116
|
+
}
|
|
117
|
+
}
|
package/src/partials.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module partials
|
|
3
|
+
* @description Expands <partial name="..."> elements from templates.
|
|
4
|
+
*
|
|
5
|
+
* Data passing — isolation model:
|
|
6
|
+
* <partial name="avatar" data="user.profile"></partial>
|
|
7
|
+
* - data="user.profile" → resolved via getByPath in the parent context.
|
|
8
|
+
* - The resolved object becomes the partial's NEW context; the parent context is INVISIBLE.
|
|
9
|
+
* - If data is absent: renders with an empty object {}.
|
|
10
|
+
* - Inside the partial, ${name} → the "name" field of the new (resolved) context.
|
|
11
|
+
*
|
|
12
|
+
* Extra props — passing multiple fields:
|
|
13
|
+
* <partial name="like-button" data="post" action="likeAction" count="likeCount"></partial>
|
|
14
|
+
* - Every attribute OTHER THAN "name"/"data" is a PROP: its value (a raw path
|
|
15
|
+
* string, NOT WRAPPED in ${...} — same rule as data) is resolved via
|
|
16
|
+
* getByPath in the parent context and added ON TOP of data (if present)
|
|
17
|
+
* to the partial context (prop wins on collision).
|
|
18
|
+
* - The attribute name becomes the context key VERBATIM. Since HTML attribute
|
|
19
|
+
* names are normalized to lowercase, multi-word prop names must be written
|
|
20
|
+
* in kebab-case (e.g. state-class="x" → context["state-class"], read
|
|
21
|
+
* inside the partial as ${state-class} — a single-segment literal key, not a path).
|
|
22
|
+
* - If data is not given at all: the context consists only of props (instead of {}).
|
|
23
|
+
*
|
|
24
|
+
* Recursive expansion:
|
|
25
|
+
* - If a new <partial> appears inside the expanded fragment, it is processed
|
|
26
|
+
* recursively with the same context. If MAX_DEPTH (50) is exceeded, a
|
|
27
|
+
* warning is issued and expansion stops.
|
|
28
|
+
*
|
|
29
|
+
* THE pipeline() CALLBACK (same pattern as loops.js):
|
|
30
|
+
* - A partial's OWN template may contain <if>/<for> (e.g. a "verified"
|
|
31
|
+
* badge). These must be resolved with the partial's isolated context
|
|
32
|
+
* (partialContext) — not the caller's context. expandPartials alone only
|
|
33
|
+
* recurses into NESTED <partial>s; it does not process <if>/<for>.
|
|
34
|
+
* index.js passes its own pipeline() function (expandPartials→expandLoops→
|
|
35
|
+
* processAllIfs) as a callback; this way the partial content goes through
|
|
36
|
+
* the full pipeline with the correct (isolated) context. Without a
|
|
37
|
+
* pipeline (direct call), only nested <partial>s are recursed — legacy behavior.
|
|
38
|
+
*
|
|
39
|
+
* Important:
|
|
40
|
+
* - This module does NOT import conditionals.js; orchestration happens in index.js.
|
|
41
|
+
* - Reactivity (data-live) is NOT in this module — handled in
|
|
42
|
+
* bindings-blocks.js/bindings-loops.js. But if a <partial> is INSIDE a
|
|
43
|
+
* not-yet-expanded <if data-live>/<for data-live> block, it is SKIPPED
|
|
44
|
+
* here too: otherwise it would be rendered early with the wrong (outer)
|
|
45
|
+
* context — the correct (branch/item) context is only known by
|
|
46
|
+
* setupLiveIfs/setupLiveFors's renderFn call.
|
|
47
|
+
* - For the same reason, if a <partial> is INSIDE a not-yet-expanded
|
|
48
|
+
* ordinary (non-data-live) <for>, it is also SKIPPED: since expandPartials
|
|
49
|
+
* runs BEFORE expandLoops in the same pass, the "as" variable does not yet
|
|
50
|
+
* exist in context. The correct (item) context is supplied by
|
|
51
|
+
* expandLoops's pipeline() call later in the same pass (see inUnexpandedFor).
|
|
52
|
+
* - <partial> leaves no trace in the final DOM; only the expanded content remains.
|
|
53
|
+
* - HTML5: <partial/> is not treated as void; use <partial ...></partial>.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
import { getByPath } from './store.js';
|
|
57
|
+
import { renderTemplate } from './template.js';
|
|
58
|
+
import { errors } from './errors.js';
|
|
59
|
+
import { inLiveBlock, inUnexpandedFor } from './shared.js';
|
|
60
|
+
|
|
61
|
+
/** Maximum recursion depth against infinite loops. */
|
|
62
|
+
const MAX_DEPTH = 50;
|
|
63
|
+
const UNSAFE_PROP_NAMES = new Set(['__proto__', 'constructor', 'prototype']);
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Expands every <partial> element under root.
|
|
69
|
+
* Recurses up to MAX_DEPTH if a new <partial> appears inside the expanded fragment.
|
|
70
|
+
*
|
|
71
|
+
* @param {Element|DocumentFragment} root - Root to traverse
|
|
72
|
+
* @param {Object} context - Parent context (for resolving the data path)
|
|
73
|
+
* @param {number} [depth=0] - Recursion depth (internal)
|
|
74
|
+
* @param {function(DocumentFragment, Object, number): void} [pipeline=null]
|
|
75
|
+
* If given (comes from index.js): the partial content is processed with
|
|
76
|
+
* this callback (the full if/for/partial pipeline) in partialContext.
|
|
77
|
+
* If not: only nested <partial>s are recursed (legacy/standalone call behavior).
|
|
78
|
+
* @returns {void}
|
|
79
|
+
*/
|
|
80
|
+
export function expandPartials(root, context, depth = 0, pipeline = null) {
|
|
81
|
+
// Grab all <partial>s at once; don't traverse a live list
|
|
82
|
+
const partials = Array.from(root.querySelectorAll('partial'));
|
|
83
|
+
|
|
84
|
+
if (depth >= MAX_DEPTH) {
|
|
85
|
+
// Warning and returning is NOT enough: if unprocessed <partial>s remain in
|
|
86
|
+
// the DOM, the caller's (index.js) hasSpecialTags/while loop (which uses
|
|
87
|
+
// the pipeline callback) would think "there's still work" and retry —
|
|
88
|
+
// hitting the depth limit again on every attempt. This causes an
|
|
89
|
+
// infinite/exponentially growing amount of work. So the remaining
|
|
90
|
+
// <partial>s are removed (degrade gracefully).
|
|
91
|
+
if (partials.length > 0) {
|
|
92
|
+
errors.partialDepthLimit(MAX_DEPTH, root);
|
|
93
|
+
for (const el of partials) el.remove();
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const partialEl of partials) {
|
|
99
|
+
// May already have left the DOM via replaceWith if it was inside an already-processed partial
|
|
100
|
+
if (!root.contains(partialEl)) continue;
|
|
101
|
+
|
|
102
|
+
// Don't touch it if it's inside a live-if/live-for — the correct context
|
|
103
|
+
// will already be supplied per branch/item by renderFn (render()).
|
|
104
|
+
if (inLiveBlock(partialEl)) continue;
|
|
105
|
+
|
|
106
|
+
// Also don't touch it if it's inside a not-yet-expanded ordinary <for> —
|
|
107
|
+
// the "as" variable isn't in context yet; the correct context will be
|
|
108
|
+
// supplied by expandLoops's pipeline() call in the same pass (see inUnexpandedFor).
|
|
109
|
+
if (inUnexpandedFor(partialEl)) continue;
|
|
110
|
+
|
|
111
|
+
const name = partialEl.getAttribute('name');
|
|
112
|
+
if (!name) {
|
|
113
|
+
errors.partialMissingName(partialEl);
|
|
114
|
+
partialEl.remove();
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// data attribute -> resolve in parent context -> base of partial's isolated context
|
|
119
|
+
const dataPath = partialEl.getAttribute('data');
|
|
120
|
+
const baseContext = dataPath ? (getByPath(context, dataPath) ?? {}) : {};
|
|
121
|
+
|
|
122
|
+
// Extra props: attributes OTHER THAN "name", "data", and any "data-*" are props.
|
|
123
|
+
// 2f rule: data-* attributes are reserved for the lime-csr engine (data-model,
|
|
124
|
+
// data-text, etc.) and CANNOT be prop names. Non-data-* attrs override the base
|
|
125
|
+
// context key of the same name (prop wins over data default on collision).
|
|
126
|
+
const partialContext = { ...baseContext };
|
|
127
|
+
for (const attr of Array.from(partialEl.attributes)) {
|
|
128
|
+
if (attr.name === 'name' || attr.name === 'data') continue;
|
|
129
|
+
if (attr.name.startsWith('data-')) continue; // 2f: reserved for engine
|
|
130
|
+
if (UNSAFE_PROP_NAMES.has(attr.name)) continue;
|
|
131
|
+
partialContext[attr.name] = getByPath(context, attr.value);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// renderTemplate: finds the template, clones it, resolves ${path}, returns a fragment
|
|
135
|
+
const fragment = renderTemplate(name, partialContext);
|
|
136
|
+
|
|
137
|
+
if (!fragment) {
|
|
138
|
+
const available = Array.from(document.querySelectorAll('template[id^="tpl-"]'))
|
|
139
|
+
.map((t) => t.id.slice(4));
|
|
140
|
+
errors.partialNotFound(name, available, partialEl);
|
|
141
|
+
partialEl.remove();
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Process the expanded fragment with the partial's own (isolated) context.
|
|
146
|
+
// If pipeline is given: the full if/for/partial pipeline (resolves
|
|
147
|
+
// <if>/<for> too, with the correct context). Otherwise only nested
|
|
148
|
+
// <partial>s are recursed.
|
|
149
|
+
if (pipeline) {
|
|
150
|
+
pipeline(fragment, partialContext, depth + 1);
|
|
151
|
+
} else {
|
|
152
|
+
expandPartials(fragment, partialContext, depth + 1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Replace the <partial> element with the processed fragment — leaves no trace
|
|
156
|
+
partialEl.replaceWith(fragment);
|
|
157
|
+
}
|
|
158
|
+
}
|