laghu 1.0.0 → 1.1.0
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/CHANGELOG.md +66 -0
- package/README.md +211 -29
- package/SECURITY.md +45 -0
- package/dist/laghu.d.ts +32 -8
- package/dist/laghu.min.js +1 -1
- package/dist/router.min.js +1 -0
- package/package.json +5 -3
- package/src/laghu.js +577 -87
- package/src/router.js +123 -0
package/src/laghu.js
CHANGED
|
@@ -23,8 +23,10 @@
|
|
|
23
23
|
*/
|
|
24
24
|
(function () {
|
|
25
25
|
'use strict';
|
|
26
|
-
var VERSION = '1.
|
|
27
|
-
|
|
26
|
+
var VERSION = '1.1.0';
|
|
27
|
+
// Opt-in dev noise. Production stays silent unless handlers listen
|
|
28
|
+
// for laghu:error / laghu:request-error, which always dispatch.
|
|
29
|
+
var DEV = typeof window !== 'undefined' && /** @type {any} */ (window).__LAGHU_DEV__ === true;
|
|
28
30
|
|
|
29
31
|
/** @type {Map<string, any>} */
|
|
30
32
|
var stores = new Map();
|
|
@@ -32,20 +34,78 @@
|
|
|
32
34
|
var directives = new Map();
|
|
33
35
|
/** @type {WeakMap<object, Map<string, Set<Function>>>} */
|
|
34
36
|
var targetMap = new WeakMap();
|
|
37
|
+
/** Proxies created by reactive, so re-wrapping returns the same proxy
|
|
38
|
+
* instead of stacking proxy-on-proxy traps. */
|
|
39
|
+
/** @type {WeakMap<object, any>} */
|
|
40
|
+
var proxyCache = new WeakMap();
|
|
41
|
+
/** @type {WeakSet<object>} */
|
|
42
|
+
var proxySet = new WeakSet();
|
|
35
43
|
/** @type {Function | null} */
|
|
36
44
|
var activeEffect = null;
|
|
37
45
|
/** @type {Set<Function>} */
|
|
38
46
|
var jobQueue = new Set();
|
|
39
47
|
var flushing = false;
|
|
48
|
+
/** Attributes whose values can navigate or load code: scriptable
|
|
49
|
+
* URLs are refused here (baseline; DOMPurify for untrusted input). */
|
|
50
|
+
/** @type {Record<string, number>} */
|
|
51
|
+
var URL_ATTRS = { href: 1, src: 1, action: 1, formaction: 1, cite: 1, poster: 1, data: 1, srcset: 1, srcdoc: 1 };
|
|
52
|
+
/** Path segments that must never be read or written: prototype
|
|
53
|
+
* pollution sinks and callable gadgets (constructor, toString).
|
|
54
|
+
* @param {string} seg */
|
|
55
|
+
function badSeg(seg) {
|
|
56
|
+
return seg === '__proto__' || seg === 'constructor' || seg === 'prototype';
|
|
57
|
+
}
|
|
58
|
+
/** True for URL values that execute code (checked case-insensitively,
|
|
59
|
+
* whitespace-insensitively, so casing tricks do not slip through).
|
|
60
|
+
* @param {any} v */
|
|
61
|
+
function unsafeUrl(v) {
|
|
62
|
+
var s = String(v).replace(/[\s\x00-\x1f]+/g, '').toLowerCase();
|
|
63
|
+
return s.indexOf('javascript:') === 0 || s.indexOf('data:text/html') === 0 || s.indexOf('vbscript:') === 0;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Baseline HTML cleaner used when no sanitizer is installed. Strips
|
|
67
|
+
* scripts, frames, event-handler attributes, and scriptable URLs.
|
|
68
|
+
* This is a floor, not a policy: pair with DOMPurify through
|
|
69
|
+
* Laghu.sanitize for untrusted content.
|
|
70
|
+
* @param {any} html
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
function defaultSanitize(html) {
|
|
74
|
+
var s = String(html);
|
|
75
|
+
s = s.replace(/<script[\s\S]*?(<\/script\s*>|$)/gi, '');
|
|
76
|
+
s = s.replace(/<\/?(iframe|object|embed|link|meta|base)[^>]*>/gi, '');
|
|
77
|
+
s = s.replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '');
|
|
78
|
+
s = s.replace(/(href|src|xlink:href|action|formaction|poster|data)\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/gi, function (m, a, q, d1, d2, d3) {
|
|
79
|
+
var v = d1 !== undefined ? d1 : (d2 !== undefined ? d2 : d3);
|
|
80
|
+
if (unsafeUrl(v)) return a + '="#"';
|
|
81
|
+
return m;
|
|
82
|
+
});
|
|
83
|
+
return s;
|
|
84
|
+
}
|
|
40
85
|
/** @type {(html: string) => string} */
|
|
41
|
-
var sanitize =
|
|
86
|
+
var sanitize = defaultSanitize;
|
|
42
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Report a runtime failure. Always dispatches laghu:error on document
|
|
90
|
+
* (so production has signal without console noise); logs only in dev.
|
|
91
|
+
* @param {any} err
|
|
92
|
+
*/
|
|
93
|
+
function reportError(err) {
|
|
94
|
+
try {
|
|
95
|
+
if (typeof document !== 'undefined' && document.dispatchEvent) {
|
|
96
|
+
document.dispatchEvent(new CustomEvent('laghu:error', {
|
|
97
|
+
detail: { error: String((err && err.message) || err), version: VERSION }
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
} catch (e) {}
|
|
101
|
+
if (DEV && typeof console !== 'undefined') console.error('[laghu]', err);
|
|
102
|
+
}
|
|
43
103
|
function flush() {
|
|
44
104
|
flushing = false;
|
|
45
105
|
var jobs = Array.from(jobQueue);
|
|
46
106
|
jobQueue.clear();
|
|
47
107
|
for (var i = 0; i < jobs.length; i++) {
|
|
48
|
-
try { jobs[i](); } catch (e) {
|
|
108
|
+
try { jobs[i](); } catch (e) { reportError(e); }
|
|
49
109
|
}
|
|
50
110
|
}
|
|
51
111
|
/** @param {Function} fn */
|
|
@@ -53,13 +113,18 @@
|
|
|
53
113
|
jobQueue.add(fn);
|
|
54
114
|
if (!flushing) { flushing = true; Promise.resolve().then(flush); }
|
|
55
115
|
}
|
|
116
|
+
/** Nesting stack so inner effects never clobber outer dependencies. @type {Array<Function>} */
|
|
117
|
+
var effectStack = [];
|
|
118
|
+
/** @type {WeakMap<Function, Array<Set<Function>>>} */
|
|
119
|
+
var runDeps = new WeakMap();
|
|
56
120
|
/**
|
|
57
121
|
* Run a function now and re-run it whenever reactive state it read
|
|
58
122
|
* changes. Each DOM binding creates one effect, so updates touch only
|
|
59
123
|
* the exact node that depends on the changed value.
|
|
60
124
|
*
|
|
61
125
|
* Effects are batched in a microtask: many synchronous writes cause
|
|
62
|
-
* one re-run.
|
|
126
|
+
* one re-run. A throwing run reports laghu:error and keeps the page
|
|
127
|
+
* alive; subscriptions refresh per run so untaken branches release.
|
|
63
128
|
*
|
|
64
129
|
* @example
|
|
65
130
|
* var state = Laghu.reactive({ count: 0 });
|
|
@@ -74,13 +139,30 @@
|
|
|
74
139
|
*/
|
|
75
140
|
function effect(fn) {
|
|
76
141
|
var stopped = false;
|
|
142
|
+
/** @type {Array<Set<Function>>} */
|
|
143
|
+
var deps = [];
|
|
77
144
|
var run = function () {
|
|
78
145
|
if (stopped) return;
|
|
146
|
+
// Drop last run's subscriptions first: conditional branches
|
|
147
|
+
// (a ? b : c) must not keep firing on the untaken path.
|
|
148
|
+
for (var i = 0; i < deps.length; i++) deps[i].delete(run);
|
|
149
|
+
deps.length = 0;
|
|
150
|
+
effectStack.push(run);
|
|
79
151
|
activeEffect = run;
|
|
80
|
-
|
|
152
|
+
runDeps.set(run, deps);
|
|
153
|
+
try { fn(); }
|
|
154
|
+
catch (e) { reportError(e); }
|
|
155
|
+
finally {
|
|
156
|
+
effectStack.pop();
|
|
157
|
+
activeEffect = effectStack.length ? effectStack[effectStack.length - 1] : null;
|
|
158
|
+
}
|
|
81
159
|
};
|
|
82
160
|
run();
|
|
83
|
-
return function () {
|
|
161
|
+
return function () {
|
|
162
|
+
stopped = true;
|
|
163
|
+
for (var i = 0; i < deps.length; i++) deps[i].delete(run);
|
|
164
|
+
deps.length = 0;
|
|
165
|
+
};
|
|
84
166
|
}
|
|
85
167
|
/** @param {object} t @param {string} k */
|
|
86
168
|
function track(t, k) {
|
|
@@ -90,6 +172,8 @@
|
|
|
90
172
|
var s = m.get(k);
|
|
91
173
|
if (!s) { s = new Set(); m.set(k, s); }
|
|
92
174
|
s.add(activeEffect);
|
|
175
|
+
var arr = runDeps.get(activeEffect);
|
|
176
|
+
if (arr) arr.push(s);
|
|
93
177
|
}
|
|
94
178
|
/** @param {object} t @param {string} k */
|
|
95
179
|
function trigger(t, k) {
|
|
@@ -101,6 +185,34 @@
|
|
|
101
185
|
}
|
|
102
186
|
/** @param {any} v @returns {boolean} */
|
|
103
187
|
function isObj(v) { return v !== null && typeof v === 'object'; }
|
|
188
|
+
/**
|
|
189
|
+
* Make a child scope that inherits a parent while owning the given
|
|
190
|
+
* variables. Uses defineProperty because plain assignment on an
|
|
191
|
+
* object whose prototype is a reactive Proxy would route into the
|
|
192
|
+
* proxy set trap and write the parent instead of the child.
|
|
193
|
+
*
|
|
194
|
+
* @example
|
|
195
|
+
* var row = childScope(state, { c: item, i: 0 });
|
|
196
|
+
* row.c; // item, owned by row, parent untouched
|
|
197
|
+
*
|
|
198
|
+
* @param {any} scope Parent scope on the prototype chain.
|
|
199
|
+
* @param {Record<string, any>} vars Variables owned by the child.
|
|
200
|
+
* @returns {any} Child scope.
|
|
201
|
+
*/
|
|
202
|
+
function childScope(scope, vars) {
|
|
203
|
+
var o = Object.create(scope);
|
|
204
|
+
for (var k in vars) {
|
|
205
|
+
if (Object.prototype.hasOwnProperty.call(vars, k)) {
|
|
206
|
+
Object.defineProperty(o, k, {
|
|
207
|
+
value: vars[k],
|
|
208
|
+
writable: true,
|
|
209
|
+
enumerable: true,
|
|
210
|
+
configurable: true
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return o;
|
|
215
|
+
}
|
|
104
216
|
/**
|
|
105
217
|
* Read a dotted path from a scope, walking the prototype chain for the
|
|
106
218
|
* first segment (so child scopes inherit parent values). Returns
|
|
@@ -121,20 +233,28 @@
|
|
|
121
233
|
if (!path) return undefined;
|
|
122
234
|
var parts = String(path).split('.');
|
|
123
235
|
var head = parts[0].trim();
|
|
236
|
+
if (badSeg(head)) return undefined;
|
|
124
237
|
if (head === '$store') {
|
|
125
238
|
var sn = parts[1];
|
|
126
239
|
var st = stores.get(sn);
|
|
127
240
|
if (!st) return undefined;
|
|
128
241
|
var cur2 = st;
|
|
129
242
|
for (var k = 2; k < parts.length; k++) {
|
|
243
|
+
if (badSeg(parts[k])) return undefined;
|
|
130
244
|
if (!isObj(cur2)) return undefined;
|
|
131
245
|
cur2 = cur2[parts[k]];
|
|
132
246
|
}
|
|
133
247
|
return cur2;
|
|
134
248
|
}
|
|
135
|
-
if (!(head in Object(scope)))
|
|
249
|
+
if (!(head in Object(scope))) {
|
|
250
|
+
// Track the miss so late-added keys (framework $error, user keys
|
|
251
|
+
// declared after first render) still wake this reader up.
|
|
252
|
+
if (typeof head === 'string' && isObj(scope)) track(scope, head);
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
136
255
|
var cur = scope[head];
|
|
137
256
|
for (var i = 1; i < parts.length; i++) {
|
|
257
|
+
if (badSeg(parts[i])) return undefined;
|
|
138
258
|
if (!isObj(cur) && typeof cur !== 'function') return undefined;
|
|
139
259
|
cur = cur[parts[i]];
|
|
140
260
|
}
|
|
@@ -143,6 +263,9 @@
|
|
|
143
263
|
/**
|
|
144
264
|
* Write a dotted path into a scope, creating intermediate objects.
|
|
145
265
|
* Supports $store paths the same way getPath reads them.
|
|
266
|
+
* Writes follow the prototype chain: when the head key already
|
|
267
|
+
* exists on an ancestor scope, the ancestor updates instead of the
|
|
268
|
+
* child shadowing it. Fresh keys land directly on the scope.
|
|
146
269
|
*
|
|
147
270
|
* @example
|
|
148
271
|
* var s = {};
|
|
@@ -154,6 +277,12 @@
|
|
|
154
277
|
*/
|
|
155
278
|
function setPath(scope, path, val) {
|
|
156
279
|
var parts = String(path).split('.');
|
|
280
|
+
for (var g = 0; g < parts.length; g++) {
|
|
281
|
+
if (badSeg(parts[g].trim())) {
|
|
282
|
+
if (DEV && typeof console !== 'undefined') console.warn('[laghu] blocked unsafe path "' + path + '"');
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
157
286
|
if (parts[0].trim() === '$store') {
|
|
158
287
|
var st = stores.get(parts[1]);
|
|
159
288
|
if (!st) return;
|
|
@@ -165,7 +294,16 @@
|
|
|
165
294
|
c2[parts[parts.length - 1]] = val;
|
|
166
295
|
return;
|
|
167
296
|
}
|
|
168
|
-
|
|
297
|
+
// Write through the scope chain: when the head key already lives on
|
|
298
|
+
// an ancestor (child row scopes, $event wrappers), update the owner
|
|
299
|
+
// instead of shadowing it on the child. Fresh keys land on scope.
|
|
300
|
+
var base = scope;
|
|
301
|
+
var node = scope;
|
|
302
|
+
while (node && node !== Object.prototype) {
|
|
303
|
+
if (Object.prototype.hasOwnProperty.call(node, parts[0])) { base = node; break; }
|
|
304
|
+
node = Object.getPrototypeOf(node);
|
|
305
|
+
}
|
|
306
|
+
var cur = base;
|
|
169
307
|
for (var i = 0; i < parts.length - 1; i++) {
|
|
170
308
|
var key = parts[i];
|
|
171
309
|
if (!isObj(cur[key])) cur[key] = {};
|
|
@@ -183,12 +321,18 @@
|
|
|
183
321
|
* state.user.name = 'Grace'; // triggers effects that read user or user.name
|
|
184
322
|
* state.tags.push('x'); // triggers effects that read tags or length
|
|
185
323
|
*
|
|
324
|
+
* Idempotent: passing an already reactive object returns it as is
|
|
325
|
+
* instead of stacking proxy on proxy.
|
|
326
|
+
*
|
|
186
327
|
* @param {object} obj Plain object or array to make reactive.
|
|
187
328
|
* @returns {any} Reactive proxy around the same data.
|
|
188
329
|
*/
|
|
189
330
|
function reactive(obj) {
|
|
190
331
|
if (!isObj(obj)) return obj;
|
|
191
|
-
|
|
332
|
+
if (proxySet.has(obj)) return obj;
|
|
333
|
+
var hit = proxyCache.get(obj);
|
|
334
|
+
if (hit) return hit;
|
|
335
|
+
var proxy = new Proxy(obj, {
|
|
192
336
|
get: function (t, p, r) {
|
|
193
337
|
if (typeof p === 'string') track(t, p);
|
|
194
338
|
var v = Reflect.get(t, p, r);
|
|
@@ -197,15 +341,28 @@
|
|
|
197
341
|
set: function (/** @type {any} */ t, p, v) {
|
|
198
342
|
var old = t[/** @type {string} */ (p)];
|
|
199
343
|
var ok = Reflect.set(t, p, v);
|
|
200
|
-
if (old !== v && typeof p === 'string')
|
|
344
|
+
if (old !== v && typeof p === 'string') {
|
|
345
|
+
trigger(t, p);
|
|
346
|
+
// Miss-reads subscribe on the proxy itself (the raw target is
|
|
347
|
+
// unreachable from outside), so wake those subscribers too.
|
|
348
|
+
var px = proxyCache.get(t);
|
|
349
|
+
if (px) trigger(px, p);
|
|
350
|
+
}
|
|
201
351
|
return ok;
|
|
202
352
|
},
|
|
203
353
|
deleteProperty: function (t, p) {
|
|
204
354
|
var ok = Reflect.deleteProperty(t, p);
|
|
205
|
-
if (typeof p === 'string')
|
|
355
|
+
if (typeof p === 'string') {
|
|
356
|
+
trigger(t, p);
|
|
357
|
+
var px = proxyCache.get(t);
|
|
358
|
+
if (px) trigger(px, p);
|
|
359
|
+
}
|
|
206
360
|
return ok;
|
|
207
361
|
}
|
|
208
362
|
});
|
|
363
|
+
proxyCache.set(obj, proxy);
|
|
364
|
+
proxySet.add(proxy);
|
|
365
|
+
return proxy;
|
|
209
366
|
}
|
|
210
367
|
/** @param {string} s @param {string} op @returns {string[]} */
|
|
211
368
|
function splitTop(s, op) {
|
|
@@ -214,27 +371,77 @@
|
|
|
214
371
|
var c = s[i];
|
|
215
372
|
if (q) { cur += c; if (c === q && s[i - 1] !== '\\') q = null; continue; }
|
|
216
373
|
if (c === '"' || c === "'") { q = c; cur += c; continue; }
|
|
217
|
-
if (c === '(') depth++;
|
|
218
|
-
if (c === ')') depth--;
|
|
374
|
+
if (c === '(' || c === '[') depth++;
|
|
375
|
+
if (c === ')' || c === ']') depth--;
|
|
219
376
|
if (depth === 0 && s.slice(i, i + op.length) === op) { out.push(cur); cur = ''; i += op.length - 1; continue; }
|
|
220
377
|
cur += c;
|
|
221
378
|
}
|
|
222
379
|
out.push(cur);
|
|
223
380
|
return out;
|
|
224
381
|
}
|
|
382
|
+
/**
|
|
383
|
+
* Find the last top-level operator char outside quotes and parens.
|
|
384
|
+
* Position 0 never counts, so leading signs stay unary.
|
|
385
|
+
* @param {string} s @param {string} chars
|
|
386
|
+
* @returns {number} Index, or -1 when absent.
|
|
387
|
+
*/
|
|
388
|
+
function lastTopOp(s, chars) {
|
|
389
|
+
var depth = 0, q = null;
|
|
390
|
+
for (var i = s.length - 1; i > 0; i--) {
|
|
391
|
+
var c = s[i];
|
|
392
|
+
if (q) { if (c === q && s[i - 1] !== '\\') q = null; continue; }
|
|
393
|
+
if (c === '"' || c === "'") { q = c; continue; }
|
|
394
|
+
if (c === ')') depth++;
|
|
395
|
+
if (c === '(') depth--;
|
|
396
|
+
if (depth === 0 && chars.indexOf(c) >= 0) return i;
|
|
397
|
+
}
|
|
398
|
+
return -1;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Split a ternary at its top-level ? and :. Returns null when the
|
|
402
|
+
* string holds no top-level question mark.
|
|
403
|
+
* @param {string} s
|
|
404
|
+
* @returns {Array<string>|null} [cond, yes, no] or null.
|
|
405
|
+
*/
|
|
406
|
+
function splitTernary(s) {
|
|
407
|
+
var depth = 0, q = null, qi = -1;
|
|
408
|
+
var i;
|
|
409
|
+
for (i = 0; i < s.length; i++) {
|
|
410
|
+
var c = s[i];
|
|
411
|
+
if (q) { if (c === q && s[i - 1] !== '\\') q = null; continue; }
|
|
412
|
+
if (c === '"' || c === "'") { q = c; continue; }
|
|
413
|
+
if (c === '(' || c === '[') depth++;
|
|
414
|
+
if (c === ')' || c === ']') depth--;
|
|
415
|
+
if (c === '?' && depth === 0) { qi = i; break; }
|
|
416
|
+
}
|
|
417
|
+
if (qi < 0) return null;
|
|
418
|
+
depth = 0; q = null;
|
|
419
|
+
for (i = qi + 1; i < s.length; i++) {
|
|
420
|
+
var d = s[i];
|
|
421
|
+
if (q) { if (d === q && s[i - 1] !== '\\') q = null; continue; }
|
|
422
|
+
if (d === '"' || d === "'") { q = d; continue; }
|
|
423
|
+
if (d === '(' || d === '[') depth++;
|
|
424
|
+
if (d === ')' || d === ']') depth--;
|
|
425
|
+
if (d === ':' && depth === 0) return [s.slice(0, qi), s.slice(qi + 1, i), s.slice(i + 1)];
|
|
426
|
+
}
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
225
429
|
/**
|
|
226
430
|
* Evaluate a small expression against a scope without new Function,
|
|
227
431
|
* so it runs under strict Content Security Policy.
|
|
228
432
|
*
|
|
229
|
-
* Supported: dotted paths,
|
|
230
|
-
* not (!),
|
|
231
|
-
* and/or (&&, ||),
|
|
433
|
+
* Supported: dotted paths, bracket indexes (list[0], map["key"]),
|
|
434
|
+
* string/number/true/false/null literals, not (!), arithmetic
|
|
435
|
+
* (+ - * / %), comparisons, and/or (&&, ||), ternary (a ? b : c),
|
|
436
|
+
* and calls with evaluated arguments such as "total(qty)".
|
|
232
437
|
* Anything else returns undefined. Unknown method names warn in dev.
|
|
233
438
|
*
|
|
234
439
|
* @example
|
|
235
440
|
* Laghu.eval({ count: 6 }, 'count > 5'); // true
|
|
236
441
|
* Laghu.eval({ user: { name: 'Ada' } }, 'user.name'); // 'Ada'
|
|
237
442
|
* Laghu.eval({ open: false }, '!open'); // true
|
|
443
|
+
* Laghu.eval({ price: 3, qty: 4 }, 'price * qty'); // 12
|
|
444
|
+
* Laghu.eval({ t: 1 }, 't > 0 ? "up" : "down"'); // 'up'
|
|
238
445
|
*
|
|
239
446
|
* @param {any} scope Scope to resolve paths and methods against.
|
|
240
447
|
* @param {string} expr Expression text from a data-* attribute.
|
|
@@ -243,6 +450,8 @@
|
|
|
243
450
|
function evalExpr(scope, expr) {
|
|
244
451
|
var s = String(expr).trim();
|
|
245
452
|
if (!s) return undefined;
|
|
453
|
+
var ter0 = splitTernary(s);
|
|
454
|
+
if (ter0) return evalExpr(scope, ter0[0]) ? evalExpr(scope, ter0[1]) : evalExpr(scope, ter0[2]);
|
|
246
455
|
var or = splitTop(s, '||');
|
|
247
456
|
if (or.length > 1) {
|
|
248
457
|
var r;
|
|
@@ -267,19 +476,83 @@
|
|
|
267
476
|
if (op === '===') return L === R;
|
|
268
477
|
return L !== R;
|
|
269
478
|
}
|
|
479
|
+
var ai = lastTopOp(s, '+-');
|
|
480
|
+
if (ai > 0 && s.slice(0, ai).trim() && s.slice(ai + 1).trim()) {
|
|
481
|
+
var lop = s[ai];
|
|
482
|
+
/** @type {any} */ var la = evalExpr(scope, s.slice(0, ai));
|
|
483
|
+
/** @type {any} */ var ra = evalExpr(scope, s.slice(ai + 1));
|
|
484
|
+
return lop === '+' ? la + ra : la - ra;
|
|
485
|
+
}
|
|
486
|
+
var mi = lastTopOp(s, '*/%');
|
|
487
|
+
if (mi > 0 && s.slice(0, mi).trim() && s.slice(mi + 1).trim()) {
|
|
488
|
+
var mop = s[mi];
|
|
489
|
+
/** @type {any} */ var lm = evalExpr(scope, s.slice(0, mi));
|
|
490
|
+
/** @type {any} */ var rm = evalExpr(scope, s.slice(mi + 1));
|
|
491
|
+
if (mop === '*') return lm * rm;
|
|
492
|
+
if (mop === '/') return lm / rm;
|
|
493
|
+
return lm % rm;
|
|
494
|
+
}
|
|
270
495
|
if (s[0] === '!') return !evalExpr(scope, s.slice(1));
|
|
496
|
+
if (s[0] === '-') return -evalExpr(scope, s.slice(1));
|
|
497
|
+
// parenthesized group: (a + b) * c. Only when the first paren
|
|
498
|
+
// matches the final char, so calls fall through below.
|
|
499
|
+
if (s[0] === '(') {
|
|
500
|
+
var gd = 0, gq = null, gm = -1, gi;
|
|
501
|
+
for (gi = 0; gi < s.length; gi++) {
|
|
502
|
+
var gc = s[gi];
|
|
503
|
+
if (gq) { if (gc === gq && s[gi - 1] !== '\\') gq = null; continue; }
|
|
504
|
+
if (gc === '"' || gc === "'") { gq = gc; continue; }
|
|
505
|
+
if (gc === '(') gd++;
|
|
506
|
+
if (gc === ')') { gd--; if (gd === 0) { gm = gi; break; } }
|
|
507
|
+
}
|
|
508
|
+
if (gm === s.length - 1) return evalExpr(scope, s.slice(1, -1));
|
|
509
|
+
}
|
|
271
510
|
if ((s[0] === '"' && s[s.length - 1] === '"') || (s[0] === "'" && s[s.length - 1] === "'")) return s.slice(1, -1);
|
|
272
511
|
if (/^-?\d+(\.\d+)?$/.test(s)) return Number(s);
|
|
273
512
|
if (s === 'true') return true;
|
|
274
513
|
if (s === 'false') return false;
|
|
275
514
|
if (s === 'null') return null;
|
|
276
515
|
if (s === 'undefined') return undefined;
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
516
|
+
// calls with evaluated arguments: name(a, b). The name resolves
|
|
517
|
+
// through the blocked-segment guard, so constructor-style gadgets
|
|
518
|
+
// stay unreachable here too.
|
|
519
|
+
if (s.charAt(s.length - 1) === ')') {
|
|
520
|
+
var op2 = s.indexOf('(');
|
|
521
|
+
if (op2 > 0) {
|
|
522
|
+
var depth = 0, qq = null, match = -1, k;
|
|
523
|
+
for (k = op2; k < s.length; k++) {
|
|
524
|
+
var ch = s[k];
|
|
525
|
+
if (qq) { if (ch === qq && s[k - 1] !== '\\') qq = null; continue; }
|
|
526
|
+
if (ch === '"' || ch === "'") { qq = ch; continue; }
|
|
527
|
+
if (ch === '(') depth++;
|
|
528
|
+
if (ch === ')') { depth--; if (depth === 0) { match = k; break; } }
|
|
529
|
+
}
|
|
530
|
+
if (match === s.length - 1) {
|
|
531
|
+
var cname = s.slice(0, op2);
|
|
532
|
+
if (/^[\w.$]+$/.test(cname)) {
|
|
533
|
+
var fn = getPath(scope, cname);
|
|
534
|
+
if (typeof fn === 'function') {
|
|
535
|
+
var raw = s.slice(op2 + 1, match).trim();
|
|
536
|
+
var args = raw ? splitTop(raw, ',').map(function (a) { return evalExpr(scope, a); }) : [];
|
|
537
|
+
return fn.apply(scope, args);
|
|
538
|
+
}
|
|
539
|
+
if (DEV && typeof console !== 'undefined') console.warn('[laghu] unknown method "' + s + '"');
|
|
540
|
+
return undefined;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
// bracket index: list[0], map["key"]. Falls back to a plain path.
|
|
546
|
+
var br = s.match(/^(.*)\[(\d+|"(?:[^"]*)"|'(?:[^']*)')\]$/);
|
|
547
|
+
if (br && br[1]) {
|
|
548
|
+
var base = evalExpr(scope, br[1]);
|
|
549
|
+
if (base === null || base === undefined) return undefined;
|
|
550
|
+
/** @type {any} */
|
|
551
|
+
var key = br[2];
|
|
552
|
+
if (key[0] === '"' || key[0] === "'") key = key.slice(1, -1);
|
|
553
|
+
else key = Number(key);
|
|
554
|
+
if (typeof key === 'string' && badSeg(key)) return undefined;
|
|
555
|
+
return base[key];
|
|
283
556
|
}
|
|
284
557
|
return getPath(scope, s);
|
|
285
558
|
}
|
|
@@ -312,13 +585,26 @@
|
|
|
312
585
|
var name = s.replace(/\(\)$/, '');
|
|
313
586
|
var fn = actions[name] || getPath(scope, name);
|
|
314
587
|
if (typeof fn === 'function') { fn.call(scope); return; }
|
|
588
|
+
if (/^[A-Za-z_$][\w.$-]*$/.test(s)) {
|
|
589
|
+
// Plain name (kebab-case allowed): an action or state function
|
|
590
|
+
// reference. Never an expression, so hyphens stay literal here
|
|
591
|
+
// even though they mean subtraction inside evalExpr strings.
|
|
592
|
+
unknownAction(s);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
315
595
|
var v = evalExpr(scope, s);
|
|
316
596
|
if (typeof v === 'function') v.call(scope);
|
|
317
|
-
else if (v === undefined)
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
597
|
+
else if (v === undefined) unknownAction(s);
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Loud unknown-action path: dev warning plus a document event every
|
|
601
|
+
* time, so typos fail visibly in any environment.
|
|
602
|
+
* @param {string} s
|
|
603
|
+
*/
|
|
604
|
+
function unknownAction(s) {
|
|
605
|
+
if (DEV && typeof console !== 'undefined') console.warn('[laghu] unknown action "' + s + '"');
|
|
606
|
+
if (typeof CustomEvent !== 'undefined' && typeof document !== 'undefined' && document.dispatchEvent) {
|
|
607
|
+
document.dispatchEvent(new CustomEvent('laghu:unknown-action', { detail: { name: s } }));
|
|
322
608
|
}
|
|
323
609
|
}
|
|
324
610
|
/** @param {Element} el @param {Function} fn */
|
|
@@ -327,16 +613,35 @@
|
|
|
327
613
|
if (!list) { list = []; /** @type {any} */ (el)._bcu = list; }
|
|
328
614
|
list.push(fn);
|
|
329
615
|
}
|
|
616
|
+
/**
|
|
617
|
+
* One binding per element per kind. hydrate rescans whole subtrees
|
|
618
|
+
* (template inserts, swaps), so without this guard already wired nodes
|
|
619
|
+
* would collect duplicate effects and listeners. Returns true when
|
|
620
|
+
* this kind was already wired on this element.
|
|
621
|
+
*
|
|
622
|
+
* @param {Element} el Element carrying the attribute.
|
|
623
|
+
* @param {string} key Binding kind, for example "click" or "bind".
|
|
624
|
+
* @returns {boolean} True when already wired, meaning skip.
|
|
625
|
+
*/
|
|
626
|
+
function claimed(el, key) {
|
|
627
|
+
var host = /** @type {any} */ (el);
|
|
628
|
+
var map = host._bH || (host._bH = {});
|
|
629
|
+
if (map[key]) return true;
|
|
630
|
+
map[key] = 1;
|
|
631
|
+
return false;
|
|
632
|
+
}
|
|
330
633
|
/** @param {Element} root */
|
|
331
634
|
function destroyTree(root) {
|
|
332
635
|
if (/** @type {any} */ (root)._bcu) {
|
|
333
636
|
/** @type {any} */ (root)._bcu.forEach(function (/** @type {Function} */ fn) { try { fn(); } catch (e) {} });
|
|
334
637
|
/** @type {any} */ (root)._bcu = [];
|
|
335
638
|
}
|
|
639
|
+
/** @type {any} */ (root)._bH = undefined;
|
|
336
640
|
var kids = root.querySelectorAll ? root.querySelectorAll('*') : [];
|
|
337
641
|
for (var i = 0; i < kids.length; i++) {
|
|
338
642
|
var c = /** @type {any} */ (kids[i]);
|
|
339
643
|
if (c._bcu) { c._bcu.forEach(function (/** @type {Function} */ fn) { try { fn(); } catch (e) {} }); c._bcu = []; }
|
|
644
|
+
c._bH = undefined;
|
|
340
645
|
if (c._bReq) { try { c._bReq.abort(); } catch (e) {} c._bReq = null; }
|
|
341
646
|
}
|
|
342
647
|
var self = /** @type {any} */ (root);
|
|
@@ -539,7 +844,8 @@
|
|
|
539
844
|
* data-html (innerHTML through the sanitize hook), data-bind:attr,
|
|
540
845
|
* data-class:name, data-style:prop, data-model (two-way form binding),
|
|
541
846
|
* data-show and data-if (hidden toggle on elements, real attach and
|
|
542
|
-
* detach on template tags), template data-for lists with
|
|
847
|
+
* detach on template tags), template data-for lists with item-relative
|
|
848
|
+
* data-key (rows keyed by item property, stable across reorder),
|
|
543
849
|
* data-click, data-on:event with .prevent .stop .once, data-init,
|
|
544
850
|
* custom directives, and data-get/post/put/patch/delete fetch blocks.
|
|
545
851
|
*
|
|
@@ -561,17 +867,25 @@
|
|
|
561
867
|
function hydrate(root, state, actions) {
|
|
562
868
|
actions = actions || {};
|
|
563
869
|
var scope = state;
|
|
870
|
+
// Seed the shared fetch-error slot before any binding reads it, so
|
|
871
|
+
// data-bind="$error.status" subscribes on first render instead of
|
|
872
|
+
// staying blind. setPath lands it on the owning scope.
|
|
873
|
+
try {
|
|
874
|
+
if (isObj(scope) && !('$error' in Object(scope))) setPath(scope, '$error', null);
|
|
875
|
+
} catch (e) {}
|
|
564
876
|
if (root instanceof Element && /** @type {any} */ (root).hasAttribute && /** @type {any} */ (root).hasAttribute('data-scope') && !/** @type {any} */ (root)._bScope) {
|
|
565
877
|
/** @type {any} */ (root)._bScope = scope;
|
|
566
878
|
}
|
|
567
879
|
root.querySelectorAll('[data-bind],[data-text]').forEach(function (node) {
|
|
568
880
|
var el = /** @type {HTMLElement} */ (node);
|
|
881
|
+
if (claimed(el, 'bind')) return;
|
|
569
882
|
var path = el.getAttribute('data-bind') || el.getAttribute('data-text') || '';
|
|
570
883
|
var stop = effect(function () { el.textContent = String(evalExpr(scope, path) ?? ''); });
|
|
571
884
|
onCleanup(el, stop);
|
|
572
885
|
});
|
|
573
886
|
root.querySelectorAll('[data-html]').forEach(function (node) {
|
|
574
887
|
var el = /** @type {HTMLElement} */ (node);
|
|
888
|
+
if (claimed(el, 'html')) return;
|
|
575
889
|
var path = el.getAttribute('data-html') || '';
|
|
576
890
|
var stop = effect(function () { el.innerHTML = sanitize(String(evalExpr(scope, path) ?? '')); });
|
|
577
891
|
onCleanup(el, stop);
|
|
@@ -581,20 +895,27 @@
|
|
|
581
895
|
Array.prototype.forEach.call(el.attributes || [], function (/** @type {Attr} */ a) {
|
|
582
896
|
var an = a.name;
|
|
583
897
|
if (an.indexOf('data-bind:') === 0) {
|
|
898
|
+
if (claimed(el, 'a:' + an)) return;
|
|
584
899
|
var attr = an.slice('data-bind:'.length);
|
|
585
900
|
var expr = a.value;
|
|
586
901
|
var stop = effect(function () {
|
|
587
902
|
var v = evalExpr(scope, expr);
|
|
588
903
|
if (v === false || v === null || v === undefined) el.removeAttribute(attr);
|
|
904
|
+
else if (URL_ATTRS[attr.toLowerCase()] && unsafeUrl(v)) {
|
|
905
|
+
el.removeAttribute(attr);
|
|
906
|
+
if (DEV && typeof console !== 'undefined') console.warn('[laghu] blocked scriptable URL for "' + attr + '"');
|
|
907
|
+
}
|
|
589
908
|
else el.setAttribute(attr, String(v));
|
|
590
909
|
});
|
|
591
910
|
onCleanup(el, stop);
|
|
592
911
|
} else if (an.indexOf('data-class:') === 0) {
|
|
912
|
+
if (claimed(el, 'a:' + an)) return;
|
|
593
913
|
var cls = an.slice('data-class:'.length);
|
|
594
914
|
var e2 = a.value;
|
|
595
915
|
var s2 = effect(function () { el.classList.toggle(cls, !!evalExpr(scope, e2)); });
|
|
596
916
|
onCleanup(el, s2);
|
|
597
917
|
} else if (an.indexOf('data-style:') === 0) {
|
|
918
|
+
if (claimed(el, 'a:' + an)) return;
|
|
598
919
|
var prop = an.slice('data-style:'.length);
|
|
599
920
|
var e3 = a.value;
|
|
600
921
|
var s3 = effect(function () {
|
|
@@ -608,6 +929,7 @@
|
|
|
608
929
|
// two-way model: text/number/checkbox/radio/select/textarea
|
|
609
930
|
root.querySelectorAll('[data-model]').forEach(function (node) {
|
|
610
931
|
var el = /** @type {any} */ (node);
|
|
932
|
+
if (claimed(el, 'model')) return;
|
|
611
933
|
var path = el.getAttribute('data-model') || '';
|
|
612
934
|
var isCheck = el.type === 'checkbox';
|
|
613
935
|
var isRadio = el.type === 'radio';
|
|
@@ -657,6 +979,7 @@
|
|
|
657
979
|
});
|
|
658
980
|
root.querySelectorAll('[data-show]').forEach(function (node) {
|
|
659
981
|
var el = /** @type {HTMLElement} */ (node);
|
|
982
|
+
if (claimed(el, 'show')) return;
|
|
660
983
|
var expr = el.getAttribute('data-show') || '';
|
|
661
984
|
var stop = effect(function () { el.hidden = !evalExpr(scope, expr); });
|
|
662
985
|
onCleanup(el, stop);
|
|
@@ -664,6 +987,7 @@
|
|
|
664
987
|
Array.prototype.forEach.call(root.querySelectorAll('[data-if]'), function (node) {
|
|
665
988
|
var el = /** @type {HTMLElement} */ (node);
|
|
666
989
|
if (el.tagName === 'TEMPLATE') return;
|
|
990
|
+
if (claimed(el, 'if')) return;
|
|
667
991
|
var expr = el.getAttribute('data-if') || '';
|
|
668
992
|
var stop = effect(function () { el.hidden = !evalExpr(scope, expr); });
|
|
669
993
|
onCleanup(el, stop);
|
|
@@ -671,6 +995,7 @@
|
|
|
671
995
|
Array.prototype.forEach.call(root.querySelectorAll('template[data-if]'), function (node) {
|
|
672
996
|
var tpl = /** @type {HTMLTemplateElement} */ (node);
|
|
673
997
|
var expr = tpl.getAttribute('data-if') || '';
|
|
998
|
+
if (claimed(tpl, 'tif')) return;
|
|
674
999
|
var anchor = document.createComment('b-if');
|
|
675
1000
|
if (tpl.parentNode) tpl.parentNode.insertBefore(anchor, tpl);
|
|
676
1001
|
/** @type {any} */ var frag = null;
|
|
@@ -697,6 +1022,7 @@
|
|
|
697
1022
|
Array.prototype.forEach.call(root.querySelectorAll('template[data-for]'), function (node) {
|
|
698
1023
|
var tpl = /** @type {HTMLTemplateElement} */ (node);
|
|
699
1024
|
var spec = tpl.getAttribute('data-for') || '';
|
|
1025
|
+
if (claimed(tpl, 'tfor')) return;
|
|
700
1026
|
var mm = spec.match(/^\s*(\w+)(?:\s*,\s*(\w+))?\s+in\s+(.+)\s*$/);
|
|
701
1027
|
if (!mm) { if (DEV && typeof console !== 'undefined') console.warn('[laghu] bad data-for "' + spec + '"'); return; }
|
|
702
1028
|
var itemVar = mm[1]; var idxVar = mm[2]; var listExpr = mm[3];
|
|
@@ -714,41 +1040,77 @@
|
|
|
714
1040
|
var order = [];
|
|
715
1041
|
for (var i = 0; i < list.length; i++) {
|
|
716
1042
|
var item = list[i];
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
1043
|
+
// data-key names a property of the item itself ("id"), with a
|
|
1044
|
+
// scope lookup fallback, then the index. Item-relative keys
|
|
1045
|
+
// keep rows stable across reorder; index keys only append.
|
|
1046
|
+
/** @type {any} */
|
|
1047
|
+
var key = i;
|
|
1048
|
+
if (keyExpr) {
|
|
1049
|
+
var ki = evalExpr(item, keyExpr);
|
|
1050
|
+
if (ki === undefined || ki === null) {
|
|
1051
|
+
/** @type {Record<string, any>} */
|
|
1052
|
+
var tmpVars = {};
|
|
1053
|
+
tmpVars[itemVar] = item;
|
|
1054
|
+
ki = evalExpr(childScope(scope, tmpVars), keyExpr);
|
|
1055
|
+
}
|
|
1056
|
+
if (ki !== undefined && ki !== null) key = ki;
|
|
1057
|
+
}
|
|
721
1058
|
seen.add(key);
|
|
722
1059
|
order.push(key);
|
|
723
1060
|
var row = rows.get(key);
|
|
724
1061
|
if (!row) {
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
1062
|
+
/** @type {Record<string, any>} */
|
|
1063
|
+
var rsVars = {};
|
|
1064
|
+
rsVars[itemVar] = item;
|
|
1065
|
+
if (idxVar) rsVars[idxVar] = i;
|
|
1066
|
+
var rs = childScope(scope, rsVars);
|
|
728
1067
|
var f = /** @type {DocumentFragment} */ (tpl.content.cloneNode(true));
|
|
729
1068
|
var holder = document.createElement('span');
|
|
730
1069
|
holder.style.display = 'contents';
|
|
731
|
-
|
|
1070
|
+
holder.setAttribute('data-bkey', String(key));
|
|
732
1071
|
holder.appendChild(f);
|
|
733
1072
|
parent.insertBefore(holder, anchor);
|
|
734
1073
|
hydrate(holder, rs, actions);
|
|
735
1074
|
rows.set(key, { nodes: [holder], scope: rs });
|
|
736
1075
|
} else {
|
|
1076
|
+
// Same key, possibly new identity (replaced array) or new
|
|
1077
|
+
// index (reorder). Same refs update nothing: fine-grained
|
|
1078
|
+
// bindings already show the mutation. New identity rebinds
|
|
1079
|
+
// the row so effects read the new objects; reorder alone
|
|
1080
|
+
// only moves nodes and keeps focus.
|
|
1081
|
+
var prevItem = row.scope[itemVar];
|
|
1082
|
+
var prevIdx = idxVar ? row.scope[idxVar] : -1;
|
|
737
1083
|
row.scope[itemVar] = item;
|
|
738
1084
|
if (idxVar) row.scope[idxVar] = i;
|
|
1085
|
+
if (prevItem !== item || (idxVar && prevIdx !== i)) {
|
|
1086
|
+
var h0 = row.nodes[0];
|
|
1087
|
+
if (h0 instanceof Element) { destroyTree(h0); hydrate(h0, row.scope, actions); }
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
// Move holders only when DOM order differs from desired order.
|
|
1092
|
+
// Spurious moves report as remove+add to MutationObserver, which
|
|
1093
|
+
// would strip the moved rows' bindings, so never move blindly.
|
|
1094
|
+
var seq = [];
|
|
1095
|
+
var chn = parent.childNodes;
|
|
1096
|
+
for (var q = 0; q < chn.length; q++) {
|
|
1097
|
+
var hn = chn[q];
|
|
1098
|
+
if (hn.nodeType === 1 && /** @type {Element} */ (hn).hasAttribute('data-bkey')) {
|
|
1099
|
+
seq.push(/** @type {Element} */ (hn).getAttribute('data-bkey'));
|
|
739
1100
|
}
|
|
740
1101
|
}
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
var
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
1102
|
+
var sameOrder = seq.length === order.length;
|
|
1103
|
+
if (sameOrder) {
|
|
1104
|
+
for (var q2 = 0; q2 < order.length; q2++) {
|
|
1105
|
+
if (seq[q2] !== String(order[q2])) { sameOrder = false; break; }
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
if (!sameOrder) {
|
|
1109
|
+
for (var k = 0; k < order.length; k++) {
|
|
1110
|
+
var r2 = rows.get(order[k]);
|
|
1111
|
+
if (r2) parent.insertBefore(/** @type {HTMLElement} */ (r2.nodes[0]), anchor);
|
|
749
1112
|
}
|
|
750
1113
|
}
|
|
751
|
-
// fix order: the loop inserts each holder before the anchor in sequence
|
|
752
1114
|
rows.forEach(function (row2, kk) {
|
|
753
1115
|
if (!seen.has(kk)) {
|
|
754
1116
|
row2.nodes.forEach(function (nn) {
|
|
@@ -765,6 +1127,7 @@
|
|
|
765
1127
|
// actions: data-click + data-on:event(.prevent.stop.once)
|
|
766
1128
|
root.querySelectorAll('[data-click]').forEach(function (node) {
|
|
767
1129
|
var el = /** @type {HTMLElement} */ (node);
|
|
1130
|
+
if (claimed(el, 'click')) return;
|
|
768
1131
|
var code = el.getAttribute('data-click') || '';
|
|
769
1132
|
var spec = el.getAttribute('data-trigger') || 'click';
|
|
770
1133
|
bindTrigger(el, spec, function () { execAction(scope, actions || {}, code); });
|
|
@@ -773,6 +1136,7 @@
|
|
|
773
1136
|
var el = /** @type {HTMLElement} */ (node);
|
|
774
1137
|
Array.prototype.forEach.call(el.attributes || [], function (/** @type {Attr} */ a) {
|
|
775
1138
|
if (a.name.indexOf('data-on:') === 0) {
|
|
1139
|
+
if (claimed(el, 'on:' + a.name)) return;
|
|
776
1140
|
var raw = a.name.slice('data-on:'.length);
|
|
777
1141
|
var parts = raw.split('.');
|
|
778
1142
|
var ev = parts[0];
|
|
@@ -782,7 +1146,7 @@
|
|
|
782
1146
|
bindTrigger(el, spec, function (/** @type {Event} */ e) {
|
|
783
1147
|
if (mods.indexOf('prevent') >= 0) e.preventDefault();
|
|
784
1148
|
if (mods.indexOf('stop') >= 0) e.stopPropagation();
|
|
785
|
-
var s2 =
|
|
1149
|
+
var s2 = childScope(scope, { $event: e });
|
|
786
1150
|
execAction(s2, actions || {}, code);
|
|
787
1151
|
if (mods.indexOf('once') >= 0) { try { /** @type {any} */ (el).removeAttribute(a.name); } catch (err) {} }
|
|
788
1152
|
});
|
|
@@ -792,16 +1156,18 @@
|
|
|
792
1156
|
// data-init runs once as action
|
|
793
1157
|
root.querySelectorAll('[data-init]').forEach(function (node) {
|
|
794
1158
|
var el = /** @type {HTMLElement} */ (node);
|
|
1159
|
+
if (claimed(el, 'init')) return;
|
|
795
1160
|
var code = el.getAttribute('data-init') || '';
|
|
796
|
-
try { execAction(scope, actions || {}, code); } catch (e) {}
|
|
1161
|
+
try { execAction(scope, actions || {}, code); } catch (e) { reportError(e); }
|
|
797
1162
|
});
|
|
798
1163
|
directives.forEach(function (fn, name) {
|
|
799
1164
|
root.querySelectorAll('[data-' + name + ']').forEach(function (node) {
|
|
800
1165
|
var el = /** @type {HTMLElement} */ (node);
|
|
1166
|
+
if (claimed(el, 'dir:' + name)) return;
|
|
801
1167
|
try {
|
|
802
1168
|
var out = fn({ el: el, get: function () { return evalExpr(scope, el.getAttribute('data-' + name) || ''); }, effect: effect, scope: scope });
|
|
803
1169
|
if (typeof out === 'function') onCleanup(el, out);
|
|
804
|
-
} catch (e) {
|
|
1170
|
+
} catch (e) { reportError(e); }
|
|
805
1171
|
});
|
|
806
1172
|
});
|
|
807
1173
|
// fetch triggers: data-get/post/put/patch/delete
|
|
@@ -817,16 +1183,24 @@
|
|
|
817
1183
|
if (!url && !el.hasAttribute('data-get') && !el.hasAttribute('data-post') && !el.hasAttribute('data-put') && !el.hasAttribute('data-patch') && !el.hasAttribute('data-delete')) return;
|
|
818
1184
|
if (el.hasAttribute('data-method')) method = String(el.getAttribute('data-method') || method).toUpperCase();
|
|
819
1185
|
if (!method) method = 'GET';
|
|
820
|
-
|
|
1186
|
+
if (claimed(el, 'fetch')) return;
|
|
1187
|
+
setupFetch(el, scope, method, url, actions);
|
|
821
1188
|
});
|
|
822
1189
|
}
|
|
823
1190
|
/**
|
|
824
|
-
* @param {HTMLElement} el @param {any} scope @param {string} method @param {string} url
|
|
1191
|
+
* @param {HTMLElement} el @param {any} scope @param {string} method @param {string} url @param {Record<string, Function>} [actions]
|
|
825
1192
|
*/
|
|
826
|
-
function setupFetch(el, scope, method, url) {
|
|
1193
|
+
function setupFetch(el, scope, method, url, actions) {
|
|
827
1194
|
var targetSel = el.getAttribute('data-target') || '';
|
|
828
1195
|
var swap = el.getAttribute('data-swap') || 'inner';
|
|
829
1196
|
var triggerSpec = el.getAttribute('data-trigger') || defaultEvent(el);
|
|
1197
|
+
/** @type {any} */ (el)._bActions = actions || {};
|
|
1198
|
+
// Seed the shared error slot so bindings like data-bind="$error.status"
|
|
1199
|
+
// subscribe before the first request ever runs. Writes land on the
|
|
1200
|
+
// owning scope through setPath, never as a child shadow.
|
|
1201
|
+
var hasErr = false;
|
|
1202
|
+
try { hasErr = ('$error' in Object(scope)); } catch (e) {}
|
|
1203
|
+
if (!hasErr) setPath(scope, '$error', null);
|
|
830
1204
|
var fire = function () { doFetch(el, scope, method, url, targetSel, swap); };
|
|
831
1205
|
bindTrigger(el, triggerSpec, fire);
|
|
832
1206
|
}
|
|
@@ -862,24 +1236,38 @@
|
|
|
862
1236
|
function doFetch(el, scope, method, url, targetSel, swap) {
|
|
863
1237
|
var m = parseTrigger(el.getAttribute('data-trigger') || defaultEvent(el), el);
|
|
864
1238
|
var self = /** @type {any} */ (el);
|
|
865
|
-
if (self.
|
|
866
|
-
if (
|
|
1239
|
+
if (!self._bReqs) self._bReqs = new Set();
|
|
1240
|
+
if (self._bN > 0 && (m.queue === 'first' || m.queue === 'none')) return;
|
|
1241
|
+
if (m.queue === 'last') {
|
|
1242
|
+
self._bReqs.forEach(function (/** @type {any} */ c) { try { c.abort(); } catch (e) {} });
|
|
1243
|
+
self._bReqs.clear();
|
|
1244
|
+
}
|
|
867
1245
|
var ctrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
868
|
-
if (ctrl) self.
|
|
869
|
-
self.
|
|
1246
|
+
if (ctrl) self._bReqs.add(ctrl);
|
|
1247
|
+
self._bN = (self._bN || 0) + 1;
|
|
1248
|
+
var timedOut = false;
|
|
1249
|
+
var timeoutMs = parseMs(el.getAttribute('data-timeout') || '');
|
|
1250
|
+
var timer = 0;
|
|
1251
|
+
if (timeoutMs > 0 && ctrl) {
|
|
1252
|
+
timer = /** @type {any} */ (setTimeout(function () {
|
|
1253
|
+
timedOut = true;
|
|
1254
|
+
try { if (ctrl) ctrl.abort(); } catch (e) {}
|
|
1255
|
+
}, timeoutMs));
|
|
1256
|
+
}
|
|
1257
|
+
var actions = self._bActions || {};
|
|
870
1258
|
var setBusy = function (/** @type {boolean} */ on) {
|
|
871
1259
|
var indSel = el.getAttribute('data-indicator');
|
|
872
1260
|
var ind = indSel ? document.querySelector(indSel) : null;
|
|
873
1261
|
if (ind && ind instanceof HTMLElement) ind.hidden = !on ? true : false;
|
|
874
1262
|
if (el instanceof HTMLElement) {
|
|
875
|
-
if (on) el.setAttribute('data-request', '');
|
|
876
|
-
else el.removeAttribute('data-request');
|
|
1263
|
+
if (on) { el.setAttribute('data-request', ''); el.setAttribute('data-pending', ''); }
|
|
1264
|
+
else { el.removeAttribute('data-request'); el.removeAttribute('data-pending'); }
|
|
877
1265
|
if (el.hasAttribute('data-disable')) el.toggleAttribute('disabled', on);
|
|
878
1266
|
}
|
|
879
1267
|
};
|
|
880
1268
|
setBusy(true);
|
|
881
|
-
var before = new CustomEvent('laghu:before-request', { cancelable: true, detail: { url: url, method: method } });
|
|
882
|
-
if (!el.dispatchEvent(before)) {
|
|
1269
|
+
var before = new CustomEvent('laghu:before-request', { bubbles: true, cancelable: true, detail: { url: url, method: method } });
|
|
1270
|
+
if (!el.dispatchEvent(before)) { finish(); return; }
|
|
883
1271
|
/** @type {any} */
|
|
884
1272
|
var opts = { method: method, headers: { Accept: 'application/json, text/html' }, signal: ctrl ? ctrl.signal : undefined };
|
|
885
1273
|
var valsRaw = el.getAttribute('data-vals');
|
|
@@ -893,8 +1281,17 @@
|
|
|
893
1281
|
for (var k in fv) v[k] = fv[k];
|
|
894
1282
|
}
|
|
895
1283
|
if (method === 'GET') {
|
|
896
|
-
var
|
|
897
|
-
|
|
1284
|
+
var parts = [];
|
|
1285
|
+
for (var qk in v) {
|
|
1286
|
+
if (Array.isArray(v[qk])) {
|
|
1287
|
+
for (var qi = 0; qi < v[qk].length; qi++) {
|
|
1288
|
+
parts.push(encodeURIComponent(qk) + '=' + encodeURIComponent(String(v[qk][qi])));
|
|
1289
|
+
}
|
|
1290
|
+
} else {
|
|
1291
|
+
parts.push(encodeURIComponent(qk) + '=' + encodeURIComponent(String(v[qk])));
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
if (parts.length) url += (url.indexOf('?') >= 0 ? '&' : '?') + parts.join('&');
|
|
898
1295
|
} else {
|
|
899
1296
|
opts.headers['Content-Type'] = 'application/json';
|
|
900
1297
|
body = JSON.stringify(v);
|
|
@@ -912,16 +1309,26 @@
|
|
|
912
1309
|
var hh = tryJSON(hRaw) || {};
|
|
913
1310
|
for (var hk in hh) opts.headers[hk] = hh[hk];
|
|
914
1311
|
}
|
|
915
|
-
var
|
|
1312
|
+
var finished = false;
|
|
1313
|
+
function finish() {
|
|
1314
|
+
if (finished) return;
|
|
1315
|
+
finished = true;
|
|
1316
|
+
if (timer) clearTimeout(timer);
|
|
1317
|
+
if (ctrl) self._bReqs.delete(ctrl);
|
|
1318
|
+
self._bN = Math.max(0, (self._bN || 1) - 1);
|
|
1319
|
+
setBusy(false);
|
|
1320
|
+
}
|
|
916
1321
|
fetch(url, opts).then(function (res) {
|
|
917
|
-
|
|
918
|
-
el.dispatchEvent(new CustomEvent('laghu:after-request', { detail: { status: res.status, url: url } }));
|
|
1322
|
+
finish();
|
|
1323
|
+
el.dispatchEvent(new CustomEvent('laghu:after-request', { bubbles: true, detail: { status: res.status, url: url } }));
|
|
919
1324
|
if (!res.ok) {
|
|
920
|
-
|
|
1325
|
+
setPath(scope, '$error', { status: res.status, url: url });
|
|
1326
|
+
el.dispatchEvent(new CustomEvent('laghu:request-error', { bubbles: true, detail: { status: res.status, url: url } }));
|
|
921
1327
|
return;
|
|
922
1328
|
}
|
|
923
|
-
var ct = res.headers.get('content-type') || '';
|
|
924
|
-
if (ct.indexOf('
|
|
1329
|
+
var ct = (res.headers.get('content-type') || '').toLowerCase();
|
|
1330
|
+
if (ct.indexOf('json') >= 0) return res.json().then(function (data) {
|
|
1331
|
+
setPath(scope, '$error', null);
|
|
925
1332
|
if (data && typeof data === 'object' && !Array.isArray(data)) Object.assign(scope, data);
|
|
926
1333
|
else setPath(scope, 'result', data);
|
|
927
1334
|
});
|
|
@@ -935,13 +1342,14 @@
|
|
|
935
1342
|
}
|
|
936
1343
|
var target = targetSel ? document.querySelector(targetSel) : el;
|
|
937
1344
|
if (!target) return;
|
|
938
|
-
swapHtml(/** @type {Element} */ (target), html, swap, scope);
|
|
1345
|
+
swapHtml(/** @type {Element} */ (target), html, swap, scope, actions);
|
|
939
1346
|
});
|
|
940
1347
|
}).catch(function (err) {
|
|
941
|
-
|
|
942
|
-
if (err && err.name === 'AbortError') return;
|
|
943
|
-
|
|
944
|
-
|
|
1348
|
+
finish();
|
|
1349
|
+
if (err && err.name === 'AbortError' && !timedOut) return;
|
|
1350
|
+
setPath(scope, '$error', { message: String((err && err.message) || err), timeout: timedOut });
|
|
1351
|
+
el.dispatchEvent(new CustomEvent('laghu:request-error', { bubbles: true, detail: { error: err, timeout: timedOut } }));
|
|
1352
|
+
reportError(err);
|
|
945
1353
|
});
|
|
946
1354
|
}
|
|
947
1355
|
/**
|
|
@@ -992,22 +1400,28 @@
|
|
|
992
1400
|
* @param {string} html Response HTML (already selected if data-select used).
|
|
993
1401
|
* @param {string} swap Mode name, case insensitive.
|
|
994
1402
|
* @param {any} scope State the swapped-in content hydrates against.
|
|
1403
|
+
* @param {Record<string, Function>} [actions] Named actions for handlers
|
|
1404
|
+
* in the new content. Fetch swaps reuse the requesting element's
|
|
1405
|
+
* actions automatically; without them swapped-in data-click goes
|
|
1406
|
+
* quiet with an unknown-action warning.
|
|
995
1407
|
*/
|
|
996
|
-
function swapHtml(target, html, swap, scope) {
|
|
1408
|
+
function swapHtml(target, html, swap, scope, actions) {
|
|
1409
|
+
actions = actions || {};
|
|
1410
|
+
var s = String(swap || 'inner').toLowerCase();
|
|
997
1411
|
var s = String(swap || 'inner').toLowerCase();
|
|
998
|
-
if (s === 'none') { target.dispatchEvent(new CustomEvent('laghu:after-swap', { detail: { swap: s } })); return; }
|
|
1412
|
+
if (s === 'none') { target.dispatchEvent(new CustomEvent('laghu:after-swap', { bubbles: true, detail: { swap: s } })); return; }
|
|
999
1413
|
var focusId = document.activeElement && /** @type {any} */ (document.activeElement).id ? /** @type {any} */ (document.activeElement).id : null;
|
|
1000
1414
|
var ae = /** @type {any} */ (document.activeElement);
|
|
1001
1415
|
var selStart = ae && typeof ae.selectionStart === 'number' ? ae.selectionStart : -1;
|
|
1002
1416
|
var selEnd = ae && typeof ae.selectionEnd === 'number' ? ae.selectionEnd : -1;
|
|
1003
|
-
target.dispatchEvent(new CustomEvent('laghu:before-swap', { cancelable: false, detail: { swap: s } }));
|
|
1417
|
+
target.dispatchEvent(new CustomEvent('laghu:before-swap', { bubbles: true, cancelable: false, detail: { swap: s } }));
|
|
1004
1418
|
var isMorph = s.indexOf('morph') === 0;
|
|
1005
1419
|
/** @type {any} */ var M = typeof window !== 'undefined' ? /** @type {any} */ (window).LaghuMorph : null;
|
|
1006
1420
|
if (isMorph) {
|
|
1007
1421
|
if (M && M.morph) {
|
|
1008
|
-
M.morph(target, sanitize(html), {});
|
|
1009
|
-
hydrate(target, scope,
|
|
1010
|
-
target.dispatchEvent(new CustomEvent('laghu:after-swap', { detail: { swap: s } }));
|
|
1422
|
+
M.morph(target, sanitize(html), { mode: s === 'morph:outer' ? 'outer' : 'inner' });
|
|
1423
|
+
hydrate(target, scope, actions);
|
|
1424
|
+
target.dispatchEvent(new CustomEvent('laghu:after-swap', { bubbles: true, detail: { swap: s } }));
|
|
1011
1425
|
restoreFocus(focusId, selStart, selEnd);
|
|
1012
1426
|
return;
|
|
1013
1427
|
}
|
|
@@ -1052,8 +1466,8 @@
|
|
|
1052
1466
|
}
|
|
1053
1467
|
});
|
|
1054
1468
|
var root = s === 'outer' ? (target.parentNode instanceof Element ? target.parentNode : document.body) : target;
|
|
1055
|
-
hydrate(root, scope,
|
|
1056
|
-
target.dispatchEvent(new CustomEvent('laghu:after-swap', { detail: { swap: s } }));
|
|
1469
|
+
hydrate(root, scope, actions);
|
|
1470
|
+
target.dispatchEvent(new CustomEvent('laghu:after-swap', { bubbles: true, detail: { swap: s } }));
|
|
1057
1471
|
restoreFocus(focusId, selStart, selEnd);
|
|
1058
1472
|
}
|
|
1059
1473
|
/**
|
|
@@ -1146,17 +1560,18 @@
|
|
|
1146
1560
|
var state = reactive(initial);
|
|
1147
1561
|
/** @type {any} */ (this).state = state;
|
|
1148
1562
|
/** @type {any} */ (this).actions = r.actions || {};
|
|
1563
|
+
if (typeof r.onUnmount === 'function') /** @type {any} */ (this)._bOnUnmount = r.onUnmount;
|
|
1149
1564
|
hydrate(this, state, r.actions || {});
|
|
1150
|
-
try { if (typeof r.onMount === 'function') r.onMount.call(state); } catch (e) {}
|
|
1565
|
+
try { if (typeof r.onMount === 'function') r.onMount.call(state); } catch (e) { reportError(e); }
|
|
1151
1566
|
this.classList.add('is-defined');
|
|
1152
1567
|
this.dispatchEvent(new CustomEvent('laghu:mount'));
|
|
1153
1568
|
}
|
|
1154
1569
|
disconnectedCallback() {
|
|
1155
1570
|
try {
|
|
1156
1571
|
var st = /** @type {any} */ (this).state;
|
|
1157
|
-
var
|
|
1158
|
-
|
|
1159
|
-
} catch (e) {}
|
|
1572
|
+
var un = /** @type {any} */ (this)._bOnUnmount;
|
|
1573
|
+
if (typeof un === 'function' && st) un.call(st);
|
|
1574
|
+
} catch (e) { reportError(e); }
|
|
1160
1575
|
destroyTree(this);
|
|
1161
1576
|
this.dispatchEvent(new CustomEvent('laghu:unmount'));
|
|
1162
1577
|
/** @type {any} */ (this)._bInit = false;
|
|
@@ -1181,6 +1596,12 @@
|
|
|
1181
1596
|
function autoObserve() {
|
|
1182
1597
|
if (observer || typeof MutationObserver === 'undefined') return;
|
|
1183
1598
|
observer = new MutationObserver(function (muts) {
|
|
1599
|
+
// Nodes moved within one batch appear in both added and removed.
|
|
1600
|
+
// Moving must preserve bindings, so only truly removed nodes die.
|
|
1601
|
+
var added = new Set();
|
|
1602
|
+
muts.forEach(function (m) {
|
|
1603
|
+
m.addedNodes.forEach(function (n) { if (n instanceof Element) added.add(n); });
|
|
1604
|
+
});
|
|
1184
1605
|
muts.forEach(function (m) {
|
|
1185
1606
|
m.addedNodes.forEach(function (n) {
|
|
1186
1607
|
if (n instanceof Element) {
|
|
@@ -1188,11 +1609,11 @@
|
|
|
1188
1609
|
try {
|
|
1189
1610
|
var api2 = /** @type {any} */ (window).Laghu;
|
|
1190
1611
|
if (api2) api2.hydrate(n, parseScope(n.getAttribute('data-scope')), {});
|
|
1191
|
-
} catch (e) {}
|
|
1612
|
+
} catch (e) { reportError(e); }
|
|
1192
1613
|
}
|
|
1193
1614
|
}
|
|
1194
1615
|
});
|
|
1195
|
-
m.removedNodes.forEach(function (n) { if (n instanceof Element) destroyTree(n); });
|
|
1616
|
+
m.removedNodes.forEach(function (n) { if (n instanceof Element && !added.has(n)) destroyTree(n); });
|
|
1196
1617
|
});
|
|
1197
1618
|
});
|
|
1198
1619
|
observer.observe(document.documentElement, { childList: true, subtree: true });
|
|
@@ -1248,6 +1669,75 @@
|
|
|
1248
1669
|
if (init !== undefined) { var s = reactive(init); stores.set(name, s); return s; }
|
|
1249
1670
|
return stores.get(name);
|
|
1250
1671
|
},
|
|
1672
|
+
/**
|
|
1673
|
+
* Derived value kept fresh by an effect. Call the getter inside
|
|
1674
|
+
* bindings or other effects to subscribe; eager recompute keeps
|
|
1675
|
+
* it simple and predictable at this scale.
|
|
1676
|
+
* @example
|
|
1677
|
+
* var doubled = Laghu.computed(function () { return state.count * 2; });
|
|
1678
|
+
* Laghu.effect(function () { el.textContent = doubled(); });
|
|
1679
|
+
*/
|
|
1680
|
+
computed: function (/** @type {Function} */ fn) {
|
|
1681
|
+
var box = reactive({ v: undefined });
|
|
1682
|
+
effect(function () { box.v = fn(); });
|
|
1683
|
+
return function () { return box.v; };
|
|
1684
|
+
},
|
|
1685
|
+
/**
|
|
1686
|
+
* Call back with (next, prev) whenever an expression's value
|
|
1687
|
+
* changes. Reads without subscribing the caller's own effect.
|
|
1688
|
+
* @example
|
|
1689
|
+
* Laghu.watch(state, 'user.name', function (n, o) { console.log(o, 'becomes', n); });
|
|
1690
|
+
*/
|
|
1691
|
+
watch: function (/** @type {any} */ scope, /** @type {string} */ expr, /** @type {Function} */ cb) {
|
|
1692
|
+
var started = false;
|
|
1693
|
+
/** @type {any} */
|
|
1694
|
+
var prev;
|
|
1695
|
+
return effect(function () {
|
|
1696
|
+
var next = evalExpr(scope, expr);
|
|
1697
|
+
if (!started) { started = true; prev = next; return; }
|
|
1698
|
+
if (next !== prev) {
|
|
1699
|
+
var o = prev;
|
|
1700
|
+
prev = next;
|
|
1701
|
+
cb(next, o);
|
|
1702
|
+
}
|
|
1703
|
+
});
|
|
1704
|
+
},
|
|
1705
|
+
/**
|
|
1706
|
+
* Persist a named store to localStorage (debounced) and restore it
|
|
1707
|
+
* on next boot. Version mismatch reseeds from init. No-op without
|
|
1708
|
+
* localStorage (file:// included where available).
|
|
1709
|
+
* @example
|
|
1710
|
+
* Laghu.store('cart', { n: 0 });
|
|
1711
|
+
* Laghu.persist('cart', { key: 'cart:v1', version: 1 });
|
|
1712
|
+
*/
|
|
1713
|
+
persist: function (/** @type {string} */ name, /** @type {{key?: string, version?: number, debounce?: number}} */ opts) {
|
|
1714
|
+
opts = opts || {};
|
|
1715
|
+
var key = opts.key || ('laghu:' + name);
|
|
1716
|
+
var ver = opts.version === undefined ? 1 : opts.version;
|
|
1717
|
+
var wait = opts.debounce === undefined ? 300 : opts.debounce;
|
|
1718
|
+
var st = stores.get(name);
|
|
1719
|
+
if (!st) return null;
|
|
1720
|
+
try {
|
|
1721
|
+
var raw = typeof localStorage !== 'undefined' ? localStorage.getItem(key) : null;
|
|
1722
|
+
if (raw) {
|
|
1723
|
+
var saved = JSON.parse(raw);
|
|
1724
|
+
if (saved && saved.v === ver && saved.state && typeof saved.state === 'object') {
|
|
1725
|
+
Object.assign(st, saved.state);
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
} catch (e) { reportError(e); }
|
|
1729
|
+
var timer = 0;
|
|
1730
|
+
effect(function () {
|
|
1731
|
+
JSON.stringify(st);
|
|
1732
|
+
if (typeof localStorage === 'undefined') return;
|
|
1733
|
+
if (timer) clearTimeout(timer);
|
|
1734
|
+
timer = /** @type {any} */ (setTimeout(function () {
|
|
1735
|
+
try { localStorage.setItem(key, JSON.stringify({ v: ver, state: st })); }
|
|
1736
|
+
catch (e) { reportError(e); }
|
|
1737
|
+
}, wait));
|
|
1738
|
+
});
|
|
1739
|
+
return st;
|
|
1740
|
+
},
|
|
1251
1741
|
directive: function (/** @type {string} */ name, /** @type {Function} */ fn) { directives.set(name, fn); }
|
|
1252
1742
|
};
|
|
1253
1743
|
// @ts-ignore
|