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/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
+ }
@@ -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
+ }