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.
@@ -0,0 +1,153 @@
1
+ /**
2
+ * @module conditionals
3
+ * @description Statically resolves <if>/<else> condition blocks in the DOM.
4
+ *
5
+ * <else> MODEL — a wrapper:
6
+ * - <else></else> is a WRAPPER. <else/> or an empty self-close is FORBIDDEN;
7
+ * the HTML5 parser doesn't treat it as a void element and swallows the
8
+ * following content.
9
+ * - Everything among <if>'s DIRECT children EXCEPT the <else> ELEMENT is the "then" group.
10
+ * - <else>'s INSIDE (childNodes) is the "else" group.
11
+ *
12
+ * <if is-gt="x" than="0">
13
+ * <span>then content</span> ← direct child other than <else> = then
14
+ * <else>
15
+ * <span>else content</span> ← inside of <else> = else
16
+ * </else>
17
+ * </if>
18
+ *
19
+ * Other rules:
20
+ * - <if>/<else> do NOT remain in the final DOM; only the winning content is placed.
21
+ * - Processing is outer-to-inner; inner <if>s wait their turn until the outer one resolves.
22
+ * - Reactivity (data-live) is NOT in this module — handled in bindings.js.
23
+ * - Special parsing contexts like <table> TODO: may need a separate mechanism.
24
+ */
25
+
26
+ import { getByPath } from './store.js';
27
+ import { errors } from './errors.js';
28
+ import { inLiveBlock } from './shared.js';
29
+
30
+ /**
31
+ * Supported condition operators.
32
+ * A new operator = one line in this table; no other change needed.
33
+ *
34
+ * Left side: the operator attribute's value is a path — resolved via getByPath.
35
+ * Right side: raw string from the "than" or "to" attribute (except is-truthy).
36
+ *
37
+ * @type {Record<string, function(*, *=): boolean>}
38
+ */
39
+ export const OPERATORS = {
40
+ 'is-gt': (left, right) => Number(left) > Number(right),
41
+ 'is-lt': (left, right) => Number(left) < Number(right),
42
+ 'is-gte': (left, right) => Number(left) >= Number(right),
43
+ 'is-lte': (left, right) => Number(left) <= Number(right),
44
+ 'is-eq': (left, right) => String(left) === String(right),
45
+ 'is-neq': (left, right) => String(left) !== String(right),
46
+ 'is-truthy': (left) => Boolean(left),
47
+ };
48
+
49
+ const OPERATOR_NAMES = Object.keys(OPERATORS);
50
+
51
+ /**
52
+ * Evaluates an <if> element's condition against the context.
53
+ *
54
+ * @param {Element} ifEl - The <if> element
55
+ * @param {Object} context - Plain object for path resolution
56
+ * @returns {boolean}
57
+ */
58
+ export function evalCondition(ifEl, context) {
59
+ const opName = OPERATOR_NAMES.find((op) => ifEl.hasAttribute(op));
60
+
61
+ if (!opName) {
62
+ const unknownOp = Array.from(ifEl.attributes).find((a) => a.name.startsWith('is-'));
63
+ if (unknownOp) {
64
+ errors.unknownOperator(unknownOp.name, OPERATOR_NAMES, ifEl);
65
+ } else {
66
+ errors.missingOperator(ifEl);
67
+ }
68
+ return false;
69
+ }
70
+
71
+ const path = ifEl.getAttribute(opName);
72
+ const leftValue = getByPath(context, path);
73
+ const rightRaw = ifEl.getAttribute('than') ?? ifEl.getAttribute('to') ?? '';
74
+
75
+ return Boolean(OPERATORS[opName](leftValue, rightRaw));
76
+ }
77
+
78
+ /**
79
+ * Evaluates a single <if> element; leaves the winning content in its place in the DOM.
80
+ *
81
+ * Then group : <if>'s DIRECT children — EXCEPT the <else> element.
82
+ * Else group : <else>'s childNodes.
83
+ *
84
+ * Tolerance: warns the user if another element follows <else>, but still
85
+ * works (anything after it is counted as then, regardless of position).
86
+ *
87
+ * @param {Element} ifEl - The <if> element
88
+ * @param {Object} context
89
+ * @returns {void}
90
+ */
91
+ export function processIf(ifEl, context) {
92
+ const condition = evalCondition(ifEl, context);
93
+ // replaceWith on a live NodeList is unsafe; copy first
94
+ const directChildren = Array.from(ifEl.childNodes);
95
+
96
+ // Find only the DIRECT child <else>; don't descend into grandchildren — parentage guarantee
97
+ const elseEl = directChildren.find(
98
+ (ch) => ch.nodeType === Node.ELEMENT_NODE && ch.tagName === 'ELSE',
99
+ ) ?? null;
100
+
101
+ // Tolerance: warn if an element node follows <else>, but still proceed
102
+ if (elseEl) {
103
+ const idxElse = directChildren.indexOf(elseEl);
104
+ const afterElse = directChildren
105
+ .slice(idxElse + 1)
106
+ .filter((ch) => ch.nodeType === Node.ELEMENT_NODE);
107
+ if (afterElse.length > 0) {
108
+ errors.elseAfterContent(ifEl);
109
+ }
110
+ }
111
+
112
+ // Then: all direct children other than the <else> element (position-independent)
113
+ const thenNodes = directChildren.filter((ch) => ch !== elseEl);
114
+ // Else: <else>'s inside; empty if there's no <else>
115
+ const elseNodes = elseEl ? Array.from(elseEl.childNodes) : [];
116
+
117
+ // Remove <if>, put the winner in its place (empty spread → element is deleted)
118
+ ifEl.replaceWith(...(condition ? thenNodes : elseNodes));
119
+ }
120
+
121
+ /**
122
+ * Processes every <if> element under root, outer-to-inner.
123
+ *
124
+ * Outer-to-inner: elements whose parentElement is not inside an <if> count as
125
+ * "outermost." The DOM changes after each pass; the loop continues until no
126
+ * <if> remains.
127
+ *
128
+ * @param {Element|DocumentFragment} root
129
+ * @param {Object} context
130
+ * @returns {void}
131
+ */
132
+ export function processAllIfs(root, context) {
133
+ let candidates;
134
+
135
+ while ((candidates = Array.from(root.querySelectorAll('if'))).length > 0) {
136
+ // Outermost <if>s: no ancestor is an <if>, does not carry data-live, AND
137
+ // is not inside a not-yet-expanded <if data-live>/<for data-live> block.
138
+ // Ones carrying data-live are left to bindings-blocks.js (reactive tear-down/rebuild);
139
+ // ordinary <if>s INSIDE a live block are not processed early here, since
140
+ // they'll only get the correct (branch/item) context via renderFn's (render()) call.
141
+ const outermost = candidates.filter(
142
+ (el) =>
143
+ !el.parentElement?.closest('if') &&
144
+ !el.hasAttribute('data-live') &&
145
+ !inLiveBlock(el),
146
+ );
147
+ if (outermost.length === 0) break; // deadlock guard (also exits if only live-ifs remain)
148
+
149
+ for (const ifEl of outermost) {
150
+ processIf(ifEl, context);
151
+ }
152
+ }
153
+ }
package/src/errors.js ADDED
@@ -0,0 +1,414 @@
1
+ /**
2
+ * @module errors
3
+ * Dev-mode warning layer.
4
+ *
5
+ * Rules:
6
+ * - When enabled: console.warn('[lime-csr] CODE: message', context?). Never throw — page must keep running.
7
+ * - When disabled: complete silence. End users must not see console noise.
8
+ * - This module imports NO other module — no circular dependency risk.
9
+ *
10
+ * Default: ON. Enabled by default for developer convenience; disable in production with setDevMode(false).
11
+ */
12
+
13
+ /** @type {boolean} */
14
+ let devMode = true;
15
+
16
+ /**
17
+ * Enables or disables dev mode.
18
+ * @param {boolean} enabled
19
+ */
20
+ export function setDevMode(enabled) {
21
+ devMode = Boolean(enabled);
22
+ }
23
+
24
+ /** @returns {boolean} */
25
+ export function isDevMode() {
26
+ return devMode;
27
+ }
28
+
29
+ /**
30
+ * Shows a red error overlay in the bottom right corner of the page (dev mode only).
31
+ *
32
+ * @param {string} code
33
+ * @param {string} message
34
+ */
35
+ function showOverlay(code, message) {
36
+ if (typeof document === 'undefined') return;
37
+ let container = document.getElementById('lime-csr-error-overlay-container');
38
+ if (!container) {
39
+ container = document.createElement('div');
40
+ container.id = 'lime-csr-error-overlay-container';
41
+ container.style.cssText = 'position:fixed;bottom:16px;right:16px;z-index:999999;max-width:350px;display:flex;flex-direction:column;gap:8px;font-family:sans-serif;font-size:13px;';
42
+ document.body.appendChild(container);
43
+ }
44
+
45
+ const el = document.createElement('div');
46
+ el.style.cssText = 'background:#f87171;color:#fff;padding:12px 16px;border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.15);display:flex;align-items:flex-start;justify-content:space-between;gap:12px;animation:lime-fade-in 0.2s ease;border-left:4px solid #b91c1c;';
47
+
48
+ const content = document.createElement('div');
49
+ const title = document.createElement('strong');
50
+ title.style.cssText = 'display:block;margin-bottom:4px;font-weight:bold;';
51
+ title.textContent = `[lime-csr] ${code}`;
52
+ const detail = document.createElement('span');
53
+ detail.style.cssText = 'opacity:0.95;line-height:1.4;';
54
+ detail.textContent = message;
55
+ content.append(title, detail);
56
+ el.appendChild(content);
57
+
58
+ const closeBtn = document.createElement('button');
59
+ closeBtn.textContent = '\u00d7';
60
+ closeBtn.style.cssText = 'background:none;border:none;color:#fff;font-size:18px;cursor:pointer;opacity:0.7;padding:0;line-height:1;font-weight:bold;';
61
+ closeBtn.onmouseover = () => { closeBtn.style.opacity = '1'; };
62
+ closeBtn.onmouseout = () => { closeBtn.style.opacity = '0.7'; };
63
+ closeBtn.onclick = () => {
64
+ el.remove();
65
+ if (container.childNodes.length === 0) {
66
+ container.remove();
67
+ }
68
+ };
69
+ el.appendChild(closeBtn);
70
+ container.appendChild(el);
71
+
72
+ if (!document.getElementById('lime-csr-overlay-style')) {
73
+ const style = document.createElement('style');
74
+ style.id = 'lime-csr-overlay-style';
75
+ style.textContent = '@keyframes lime-fade-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }';
76
+ document.head.appendChild(style);
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Primary warning function. Does nothing if dev mode is off.
82
+ *
83
+ * @param {string} code - Error code (e.g. "PARTIAL_NOT_FOUND")
84
+ * @param {string} message - Actionable description — "what's wrong, how to fix it"
85
+ * @param {*} [context] - Additional context (element, path, name, etc.) — appended to console output
86
+ */
87
+ export function warn(code, message, context) {
88
+ if (!devMode) return;
89
+ if (context !== undefined) {
90
+ console.warn(`[lime-csr] ${code}: ${message}`, context);
91
+ } else {
92
+ console.warn(`[lime-csr] ${code}: ${message}`);
93
+ }
94
+ showOverlay(code, message);
95
+ }
96
+
97
+ /**
98
+ * Known error scenario wrappers.
99
+ * Each one only calls warn() — no logic here, logic is in warn.
100
+ *
101
+ * @namespace
102
+ */
103
+ export const errors = {
104
+ /** Unknown is-* operator on an <if>. */
105
+ unknownOperator(op, validOps, context) {
106
+ warn(
107
+ 'UNKNOWN_OPERATOR',
108
+ `Unknown condition operator: "${op}". Valid operators: ${validOps.join(', ')}. ` +
109
+ `Use is-truthy, is-eq, is-gt, etc.`,
110
+ context,
111
+ );
112
+ },
113
+
114
+ /** No is-* operator found on an <if>. */
115
+ missingOperator(context) {
116
+ warn(
117
+ 'MISSING_OPERATOR',
118
+ `No condition operator found on <if>. ` +
119
+ `Add at least one operator attribute: is-gt, is-lt, is-eq, is-truthy...`,
120
+ context,
121
+ );
122
+ },
123
+
124
+ /** <else> must be the last direct child of <if>. */
125
+ elseAfterContent(context) {
126
+ warn(
127
+ 'ELSE_AFTER_CONTENT',
128
+ `<else> must be the last direct child of <if>; nodes after it were counted as then. ` +
129
+ `Move the <else>...</else> block to the end.`,
130
+ context,
131
+ );
132
+ },
133
+
134
+ /** Partial requested via <partial name="..."> was not found. */
135
+ partialNotFound(name, available, context) {
136
+ const list = available.length ? available.join(', ') : '(no registered partials)';
137
+ warn(
138
+ 'PARTIAL_NOT_FOUND',
139
+ `Partial not found: "${name}". Registered partials: ${list}. ` +
140
+ `Is <template id="tpl-${name}"> defined?`,
141
+ context,
142
+ );
143
+ },
144
+
145
+ /** <partial> element is missing the "name" attribute. */
146
+ partialMissingName(context) {
147
+ warn(
148
+ 'PARTIAL_MISSING_NAME',
149
+ `<partial> element is missing the "name" attribute. Use <partial name="..."></partial>.`,
150
+ context,
151
+ );
152
+ },
153
+
154
+ /** Recursive partial expansion reached maximum depth. */
155
+ partialDepthLimit(depth, context) {
156
+ warn(
157
+ 'PARTIAL_DEPTH_LIMIT',
158
+ `Partial depth limit (${depth}) reached (possible infinite loop). ` +
159
+ `A partial may be calling itself directly or indirectly.`,
160
+ context,
161
+ );
162
+ },
163
+
164
+ /** Template requested via getTemplate / mount was not found in DOM. */
165
+ templateNotFound(name) {
166
+ warn(
167
+ 'TEMPLATE_NOT_FOUND',
168
+ `Template not found: "tpl-${name}". ` +
169
+ `Is <template id="tpl-${name}"> present on the page?`,
170
+ );
171
+ },
172
+
173
+ /** <for> element is missing the "each" or "as" attribute. */
174
+ forMissingAttr(context) {
175
+ warn(
176
+ 'FOR_MISSING_ATTR',
177
+ `<for> element is missing the "each" and/or "as" attribute. ` +
178
+ `Use <for each="array.path" as="item"></for>.`,
179
+ context,
180
+ );
181
+ },
182
+
183
+ /** <for each="..."> value is not an array. */
184
+ forNotArray(path, type, context) {
185
+ warn(
186
+ 'FOR_NOT_ARRAY',
187
+ `<for each="${path}"> value must be an array; got: ${type}. ` +
188
+ `Is "${path}" an array in context or store?`,
189
+ context,
190
+ );
191
+ },
192
+
193
+ /** data-text attribute is present but its value is empty. */
194
+ bindingMissingPath(context) {
195
+ warn(
196
+ 'BINDING_MISSING_PATH',
197
+ `data-text attribute is empty; a store path is required. ` +
198
+ `Use data-text="path.to.value".`,
199
+ context,
200
+ );
201
+ },
202
+
203
+ /** No matching data-x attribute found for the {x} placeholder. */
204
+ bindingMissingDataAttr(attrName, key, context) {
205
+ warn(
206
+ 'BINDING_MISSING_DATA_ATTR',
207
+ `No data-${key} attribute found for {${key}} in "${attrName}"; binding skipped. ` +
208
+ `Add data-${key}="store.path" or remove the {${key}} placeholder.`,
209
+ context,
210
+ );
211
+ },
212
+
213
+ /** A reactive {x}/data-x binding targets an event-handler attribute like onclick/onerror. */
214
+ unsafeEventAttr(attrName, context) {
215
+ warn(
216
+ 'UNSAFE_EVENT_ATTR',
217
+ `"${attrName}" is an event-handler attribute; reactive data cannot bind to it. ` +
218
+ `Use data-on-{event} for events instead (see README).`,
219
+ context,
220
+ );
221
+ },
222
+
223
+ /** No valid condition operator found on <if data-live>. */
224
+ liveIfMissingOperator(context) {
225
+ warn(
226
+ 'LIVE_IF_MISSING_OP',
227
+ `<if data-live>: no valid condition operator found. ` +
228
+ `Add one: is-gt, is-lt, is-eq, is-truthy, etc.`,
229
+ context,
230
+ );
231
+ },
232
+
233
+ /** Render pipeline reached its maximum iteration limit. */
234
+ pipelineDepthLimit(maxIter, context) {
235
+ warn(
236
+ 'PIPELINE_DEPTH_LIMIT',
237
+ `Render pipeline reached the ${maxIter} iteration limit (possible infinite loop). Stopping. ` +
238
+ `Is a partial calling itself?`,
239
+ context,
240
+ );
241
+ },
242
+
243
+ /** Template requested via mount() was not found. */
244
+ mountTemplateNotFound(name, available, context) {
245
+ const list = available.length ? available.join(', ') : '(no registered templates)';
246
+ warn(
247
+ 'MOUNT_TEMPLATE_NOT_FOUND',
248
+ `mount(): template "${name}" not found. Registered templates: ${list}. ` +
249
+ `Is <template id="tpl-${name}"> defined?`,
250
+ context,
251
+ );
252
+ },
253
+
254
+ // ── Reactive <for data-live> ────────────────────────────────────────────────
255
+
256
+ /** Reactive <for data-live> is missing the "key" attribute. */
257
+ missingKey(templateName) {
258
+ warn(
259
+ 'FOR_MISSING_KEY',
260
+ `Reactive <for data-live>: missing "key" attribute. ` +
261
+ `Add a key for efficient DOM updates. (template: ${templateName ?? '?'})`,
262
+ );
263
+ },
264
+
265
+ /** Same key used on more than one element in a reactive <for data-live>. */
266
+ duplicateKey(keyVal, templateName) {
267
+ warn(
268
+ 'FOR_DUPLICATE_KEY',
269
+ `Reactive <for data-live>: key "${keyVal}" appears on more than one element; ` +
270
+ `keys must be unique. (template: ${templateName ?? '?'})`,
271
+ );
272
+ },
273
+
274
+ /** data-model attribute present but empty. */
275
+ modelMissingPath(context) {
276
+ warn(
277
+ 'MODEL_MISSING_PATH',
278
+ `data-model attribute is empty; a store path is required. ` +
279
+ `Use data-model="path.to.value".`,
280
+ context,
281
+ );
282
+ },
283
+
284
+ /** Custom element written inside <table> — HTML parser will foster-parent it outside. */
285
+ tableFosterParenting(templateName) {
286
+ warn(
287
+ 'TABLE_FOSTER_PARENTING',
288
+ `<if>/<for>/<partial> cannot be used inside <table> — the HTML parser moves them outside. ` +
289
+ `Solution: move the condition/loop outside the <table>, or treat the tbody as a partial. ` +
290
+ `(template: ${templateName ?? '?'})`,
291
+ );
292
+ },
293
+
294
+ /** data-show attribute present but empty. */
295
+ showMissingPath(context) {
296
+ warn(
297
+ 'SHOW_MISSING_PATH',
298
+ `data-show attribute is empty; a store path is required. ` +
299
+ `Use data-show="path.to.value".`,
300
+ context,
301
+ );
302
+ },
303
+
304
+ /** An unsupported event type was used with data-on-{event}. */
305
+ unknownEvent(eventName, validEvents, context) {
306
+ warn(
307
+ 'UNKNOWN_EVENT',
308
+ `Unsupported event type: "data-on-${eventName}". Valid types: ` +
309
+ `${validEvents.map((e) => `data-on-${e}`).join(', ')}.`,
310
+ context,
311
+ );
312
+ },
313
+
314
+ /** data-on-{event} points to a handler name not in the handlers dictionary. */
315
+ handlerNotFound(name, available, context) {
316
+ const list = available.length ? available.join(', ') : '(no registered handlers)';
317
+ warn(
318
+ 'HANDLER_NOT_FOUND',
319
+ `Handler not found: "${name}". Registered handlers: ${list}. ` +
320
+ `Is "${name}" defined in the handlers object passed to mount()?`,
321
+ context,
322
+ );
323
+ },
324
+
325
+ // ── Phase 2 additions ───────────────────────────────────────────────────────
326
+
327
+ /**
328
+ * A reserved name (text, model, show, live, ref, diff, or on-* prefix) was used
329
+ * as a placeholder name in {x}/data-x bindings.
330
+ */
331
+ reservedAttrName(name, context) {
332
+ warn(
333
+ 'RESERVED_ATTR_NAME',
334
+ `"${name}" is reserved by lime-csr and cannot be used as a {x}/data-x placeholder. ` +
335
+ `Reserved names: text, model, show, live, ref, diff, and any name starting with "on-". ` +
336
+ `Rename the placeholder.`,
337
+ context,
338
+ );
339
+ },
340
+
341
+ /**
342
+ * data-model path contains a numeric index segment (e.g. "items.0.name").
343
+ * Path drift: after array mutation the index points to the wrong item.
344
+ */
345
+ indexedModelPath(path, context) {
346
+ warn(
347
+ 'INDEXED_MODEL_PATH',
348
+ `data-model="${path}" contains a numeric index (e.g. items.0.name). ` +
349
+ `This is unsafe: if the array is mutated, the path drifts to the wrong item. ` +
350
+ `Use a reactive <for data-live key=...> loop and bind to the loop variable instead.`,
351
+ context,
352
+ );
353
+ },
354
+
355
+ /**
356
+ * store.set() was called on a path managed by store.computed().
357
+ * The manual value will be overwritten on the next dep change.
358
+ */
359
+ computedManualSet(path) {
360
+ warn(
361
+ 'COMPUTED_MANUAL_SET',
362
+ `Path "${path}" is managed by store.computed(). ` +
363
+ `Manual store.set() will be overwritten on the next dep change. ` +
364
+ `Use store.computed() or choose a different path.`,
365
+ );
366
+ },
367
+
368
+ /**
369
+ * store.set() received the same object/array reference already stored.
370
+ * In-place mutation: Object.is() returns true, so no subscriber fires.
371
+ */
372
+ inPlaceMutation(path) {
373
+ warn(
374
+ 'IN_PLACE_MUTATION',
375
+ `store.set("${path}", value): value is the SAME reference as the stored object/array. ` +
376
+ `In-place mutation detected — subscribers will NOT fire. ` +
377
+ `Pass a new reference, e.g. store.set("${path}", [...arr]) or {...obj}.`,
378
+ );
379
+ },
380
+
381
+ // ── Phase 3 additions ───────────────────────────────────────────────────────
382
+
383
+ /** <for data-live data-diff="..."> used a value outside simple/lcs/replace. */
384
+ unknownDiffStrategy(value, templateName) {
385
+ warn(
386
+ 'UNKNOWN_DIFF_STRATEGY',
387
+ `<for data-live>: unknown data-diff value "${value}". Valid values: ` +
388
+ `simple, lcs, replace (or omit the attribute). Falling back to "simple". ` +
389
+ `(template: ${templateName ?? '?'})`,
390
+ );
391
+ },
392
+
393
+ /** data-after on <if>/<for> points to a handler name not in the handlers dictionary. */
394
+ blockAfterNotFound(name, available, context) {
395
+ const list = available.length ? available.join(', ') : '(no registered handlers)';
396
+ warn(
397
+ 'BLOCK_AFTER_NOT_FOUND',
398
+ `data-after handler not found: "${name}". Registered handlers: ${list}. ` +
399
+ `Is "${name}" defined in the handlers object passed to mount()?`,
400
+ context,
401
+ );
402
+ },
403
+
404
+ /** data-before on <if>/<for> points to a handler name not in the handlers dictionary. */
405
+ blockBeforeNotFound(name, available, context) {
406
+ const list = available.length ? available.join(', ') : '(no registered handlers)';
407
+ warn(
408
+ 'BLOCK_BEFORE_NOT_FOUND',
409
+ `data-before handler not found: "${name}". Registered handlers: ${list}. ` +
410
+ `Is "${name}" defined in the handlers object passed to mount()?`,
411
+ context,
412
+ );
413
+ },
414
+ };