micra.js 2.3.2 → 2.4.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 CHANGED
@@ -4,6 +4,63 @@ All notable changes to Micra.js will be documented in this file. Format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versioning follows
5
5
  [SemVer](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [2.4.0] — 2026-06-14
8
+
9
+ ### Added — CSP-safe expression evaluator (works under strict CSP)
10
+
11
+ - **Directive expressions are now parsed and interpreted by a built-in
12
+ evaluator — no `new Function`, no `eval`.** Micra runs under a strict
13
+ Content-Security-Policy (`default-src 'self'`, no `unsafe-eval`): the
14
+ exact policy used by security-sensitive server-rendered apps (banking,
15
+ gov, healthcare). Previously any expression beyond a bare property path
16
+ (`count > 0`, ternaries, comparisons, method calls) compiled via
17
+ `new Function` and was blocked under such a CSP.
18
+ - The build now fails if `eval` / `new Function` ever reappears in the
19
+ bundle (`🔒 CSP guard`).
20
+ - **Stronger security model, by construction.** Globals like `window`,
21
+ `fetch`, `constructor` are unreachable because no scope contains them —
22
+ not because they're shadowed. Member access additionally blocks
23
+ `__proto__` / `constructor` / `prototype`, closing the
24
+ `item.constructor.constructor("…")()` escape the old `with()`-based
25
+ evaluator left open.
26
+
27
+ ### Added — call expressions in `@event`
28
+
29
+ - `@event` handlers accept call expressions with arguments, evaluated
30
+ against an event scope (the row `item` inside `data-each`, `$event` /
31
+ `event`, and component methods):
32
+
33
+ ```html
34
+ <button @click="select(item.id)">pick</button>
35
+ <input @input="set($event.target.value)">
36
+ ```
37
+
38
+ Bare method names (`@click="save"`) work as before. `data-on` keeps
39
+ bare method names only (its handler separator is `,`).
40
+
41
+ ### Changed
42
+
43
+ - **Bundle: ~5.5 KB → ~6.6 KB gzip** (size guard raised to 7 KB). The
44
+ eval-based path was *removed*, not kept as a fallback, so this is the
45
+ whole cost of the parser/interpreter. Micra is no longer the very
46
+ smallest in its class (petite-vue ~6 KB) — the trade is CSP-safety,
47
+ a stronger security model, and call-args in events.
48
+
49
+ ### Fixed
50
+
51
+ - `data-each` row root detection counted whitespace text nodes around a
52
+ single element, wrapping pretty-printed `<tr>` rows in
53
+ `<micra-each-item>` (invalid inside `<tbody>`). Carried over from 2.3.2;
54
+ now also covered by the new evaluator's tests.
55
+
56
+ ### Migration
57
+
58
+ - No API changes. Existing expressions evaluate identically (full parity
59
+ suite). If you relied on an expression feature outside the documented
60
+ grammar (assignments, `new`, computed `[]` indexing, arrow functions —
61
+ none of which were ever recommended in directives), it no longer works;
62
+ move that logic into a component method.
63
+
7
64
  ## [2.3.2] — 2026-06-10
8
65
 
9
66
  ### Fixed — `data-each` row root detection
package/README.md CHANGED
@@ -6,14 +6,17 @@
6
6
  [![types included](https://img.shields.io/badge/types-included-blue)](./dist/index.d.ts)
7
7
  [![license MIT](https://img.shields.io/npm/l/micra.js)](./LICENSE)
8
8
 
9
- Micra.js is a lightweight reactive TypeScript framework for small sites and SaaS apps. It gives you reactive state, DOM directives, keyed list rendering, an event bus, SSR-friendly props, and auto-mounting in about 5 KB gzip.
9
+ Micra.js is a lightweight reactive TypeScript framework for small sites and SaaS apps. It gives you reactive state, DOM directives, keyed list rendering, an event bus, SSR-friendly props, and auto-mounting in about 7 KB gzip.
10
10
 
11
11
  ## Project status
12
12
 
13
13
  - **Stable, SemVer-disciplined.** Breaking changes only in majors; every
14
14
  release documented in [CHANGELOG.md](./CHANGELOG.md) with migration notes.
15
- - **Tested.** 255 tests across 14 suites run on every push and before every
16
- npm publish; the build fails if the bundle exceeds **5.5 KB gzip**.
15
+ - **Tested.** 267 tests across 15 suites run on every push and before every
16
+ npm publish; the build fails if the bundle exceeds **7 KB gzip** or if
17
+ `eval` / `new Function` ever reappears (CSP guard).
18
+ - **CSP-safe.** Runs under a strict `default-src 'self'` Content-Security-Policy —
19
+ directive expressions are parsed and interpreted, never `eval`'d.
17
20
  - **Typed.** Ships its own `.d.ts` — state, methods, and event-bus payloads
18
21
  are checked end-to-end (see [TypeScript](#typescript)).
19
22
  - **Security policy.** See [SECURITY.md](./SECURITY.md) — private reporting,
@@ -25,7 +28,7 @@ Built for **server-rendered apps** (Rails, Laravel, Django, Phoenix, ASP.NET) an
25
28
 
26
29
  Reach for Micra.js instead of React/Vue when:
27
30
 
28
- - ~5 KB gzip matters (full bundle, not "core")
31
+ - ~7 KB gzip matters (full bundle, not "core")
29
32
  - you want to drop a `<script>` tag on an existing page and go — no toolchain
30
33
  - you have HTML rendered by your server template engine that needs reactive directives
31
34
  - you don't need client-side routing or a full SPA
package/dist/index.d.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  * - SSR-friendly: Micra.start() is safe to call multiple times
18
18
  * - Directive cache: O(1) re-renders after first mount
19
19
  *
20
- * Size target: < 5.5 KB minified+gzipped
20
+ * Size target: < 7 KB minified+gzipped
21
21
  *
22
22
  * @module Micra
23
23
  */
package/dist/micra.cjs.js CHANGED
@@ -1,4 +1,4 @@
1
- /* Micra.js v2.3.2 — https://github.com/micra-js/micra — MIT */
1
+ /* Micra.js v2.4.0 — https://github.com/micra-js/micra — MIT */
2
2
  "use strict";
3
3
  var __defProp = Object.defineProperty;
4
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -116,42 +116,196 @@ function debug() {
116
116
  }
117
117
 
118
118
  // src/utils/expr.ts
119
- var exprCache = /* @__PURE__ */ new Map();
120
- var warnedRuntime = /* @__PURE__ */ new Set();
121
- var SIMPLE_PATH = /^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/;
122
119
  var ALLOWED_GLOBALS = new Set(
123
120
  "Math,JSON,Date,String,Number,Boolean,Array,Object,parseInt,parseFloat,isNaN,isFinite,NaN,Infinity,undefined".split(",")
124
121
  );
125
- var PARAM_S = "$s";
126
- var PARAM_SAFE = "$safe";
127
- var SAFE_OUTER = new Proxy(/* @__PURE__ */ Object.create(null), {
128
- has(_target, key) {
129
- if (typeof key !== "string") return false;
130
- if (key === PARAM_S || key === PARAM_SAFE) return false;
131
- return !ALLOWED_GLOBALS.has(key);
132
- },
133
- get() {
134
- return void 0;
135
- }
136
- });
137
- var safeWrapCache = /* @__PURE__ */ new WeakMap();
122
+ var BLOCKED_PROPS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
138
123
  var OBJ_PROTO_KEYS = new Set(Object.getOwnPropertyNames(Object.prototype));
139
- function safeStateWrap(state) {
140
- const cached = safeWrapCache.get(state);
141
- if (cached) return cached;
142
- const wrapped = new Proxy(state, {
143
- has(target, key) {
144
- return safeStateHas(target, key);
145
- },
146
- get(target, key) {
147
- return Reflect.get(target, key);
124
+ var PUNCT = [
125
+ "===",
126
+ "!==",
127
+ "==",
128
+ "!=",
129
+ "<=",
130
+ ">=",
131
+ "&&",
132
+ "||",
133
+ "(",
134
+ ")",
135
+ ".",
136
+ ",",
137
+ "?",
138
+ ":",
139
+ "!",
140
+ "<",
141
+ ">",
142
+ "+",
143
+ "-",
144
+ "*",
145
+ "/",
146
+ "%"
147
+ ];
148
+ function tokenize(src) {
149
+ var _a;
150
+ const toks = [];
151
+ let i = 0;
152
+ const n = src.length;
153
+ while (i < n) {
154
+ const c = src[i];
155
+ if (c === " " || c === " " || c === "\n" || c === "\r" || c === "\f") {
156
+ i++;
157
+ continue;
148
158
  }
149
- });
150
- safeWrapCache.set(state, wrapped);
151
- return wrapped;
159
+ if (c === '"' || c === "'") {
160
+ let s = "";
161
+ i++;
162
+ while (i < n && src[i] !== c) {
163
+ if (src[i] === "\\") {
164
+ s += (_a = src[i + 1]) != null ? _a : "";
165
+ i += 2;
166
+ } else {
167
+ s += src[i];
168
+ i++;
169
+ }
170
+ }
171
+ if (src[i] !== c) throw 0;
172
+ i++;
173
+ toks.push({ t: "str", v: s });
174
+ continue;
175
+ }
176
+ if (c >= "0" && c <= "9") {
177
+ let s = "";
178
+ while (i < n && (src[i] >= "0" && src[i] <= "9" || src[i] === ".")) {
179
+ s += src[i];
180
+ i++;
181
+ }
182
+ toks.push({ t: "num", v: s });
183
+ continue;
184
+ }
185
+ if (/[A-Za-z_$]/.test(c)) {
186
+ let s = "";
187
+ while (i < n && /[A-Za-z0-9_$]/.test(src[i])) {
188
+ s += src[i];
189
+ i++;
190
+ }
191
+ toks.push({ t: "id", v: s });
192
+ continue;
193
+ }
194
+ const m = PUNCT.find((p) => src.startsWith(p, i));
195
+ if (!m) throw 0;
196
+ toks.push({ t: "p", v: m });
197
+ i += m.length;
198
+ }
199
+ return toks;
200
+ }
201
+ var BIN_PREC = {
202
+ "||": 1,
203
+ "&&": 2,
204
+ "==": 3,
205
+ "!=": 3,
206
+ "===": 3,
207
+ "!==": 3,
208
+ "<": 4,
209
+ "<=": 4,
210
+ ">": 4,
211
+ ">=": 4,
212
+ "+": 5,
213
+ "-": 5,
214
+ "*": 6,
215
+ "/": 6,
216
+ "%": 6
217
+ };
218
+ function parse(toks) {
219
+ let pos = 0;
220
+ const peek = () => toks[pos];
221
+ const next = () => toks[pos++];
222
+ const eat = (v) => {
223
+ var _a;
224
+ if (((_a = peek()) == null ? void 0 : _a.v) !== v) throw 0;
225
+ pos++;
226
+ };
227
+ function parseExpr() {
228
+ var _a;
229
+ const c = parseBin(1);
230
+ if (((_a = peek()) == null ? void 0 : _a.v) === "?") {
231
+ next();
232
+ const a = parseExpr();
233
+ eat(":");
234
+ const b = parseExpr();
235
+ return { k: "tern", c, a, b };
236
+ }
237
+ return c;
238
+ }
239
+ function parseBin(minPrec) {
240
+ let left = parseUnary();
241
+ for (; ; ) {
242
+ const t = peek();
243
+ const prec = t && t.t === "p" ? BIN_PREC[t.v] : void 0;
244
+ if (prec === void 0 || prec < minPrec) break;
245
+ next();
246
+ const right = parseBin(prec + 1);
247
+ left = { k: "bin", op: t.v, l: left, r: right };
248
+ }
249
+ return left;
250
+ }
251
+ function parseUnary() {
252
+ const t = peek();
253
+ if (t && t.t === "p" && (t.v === "!" || t.v === "-")) {
254
+ next();
255
+ return { k: "un", op: t.v, x: parseUnary() };
256
+ }
257
+ return parsePostfix();
258
+ }
259
+ function parsePostfix() {
260
+ var _a, _b;
261
+ let node = parsePrimary();
262
+ for (; ; ) {
263
+ const t = peek();
264
+ if ((t == null ? void 0 : t.v) === ".") {
265
+ next();
266
+ const id = next();
267
+ if (!id || id.t !== "id") throw 0;
268
+ node = { k: "mem", o: node, p: id.v };
269
+ } else if ((t == null ? void 0 : t.v) === "(") {
270
+ next();
271
+ const args = [];
272
+ if (((_a = peek()) == null ? void 0 : _a.v) !== ")") {
273
+ args.push(parseExpr());
274
+ while (((_b = peek()) == null ? void 0 : _b.v) === ",") {
275
+ next();
276
+ args.push(parseExpr());
277
+ }
278
+ }
279
+ eat(")");
280
+ node = { k: "call", c: node, a: args };
281
+ } else break;
282
+ }
283
+ return node;
284
+ }
285
+ function parsePrimary() {
286
+ const t = next();
287
+ if (!t) throw 0;
288
+ if (t.t === "num") return { k: "lit", v: Number(t.v) };
289
+ if (t.t === "str") return { k: "lit", v: t.v };
290
+ if (t.v === "(") {
291
+ const e = parseExpr();
292
+ eat(")");
293
+ return e;
294
+ }
295
+ if (t.t === "id") {
296
+ if (t.v === "true") return { k: "lit", v: true };
297
+ if (t.v === "false") return { k: "lit", v: false };
298
+ if (t.v === "null") return { k: "lit", v: null };
299
+ if (t.v === "undefined") return { k: "lit", v: void 0 };
300
+ return { k: "id", n: t.v };
301
+ }
302
+ throw 0;
303
+ }
304
+ const ast = parseExpr();
305
+ if (pos !== toks.length) throw 0;
306
+ return ast;
152
307
  }
153
308
  function safeStateHas(state, key) {
154
- if (typeof key !== "string") return false;
155
309
  if (!Reflect.has(state, key)) return false;
156
310
  if (!OBJ_PROTO_KEYS.has(key)) return true;
157
311
  let obj = state;
@@ -161,6 +315,87 @@ function safeStateHas(state, key) {
161
315
  }
162
316
  return false;
163
317
  }
318
+ function resolveIdent(name, scope) {
319
+ if (safeStateHas(scope, name)) return scope[name];
320
+ if (ALLOWED_GLOBALS.has(name)) return globalThis[name];
321
+ return void 0;
322
+ }
323
+ function evalNode(node, scope) {
324
+ switch (node.k) {
325
+ case "lit":
326
+ return node.v;
327
+ case "id":
328
+ return resolveIdent(node.n, scope);
329
+ case "mem": {
330
+ const o = evalNode(node.o, scope);
331
+ if (o == null || BLOCKED_PROPS.has(node.p)) return void 0;
332
+ return o[node.p];
333
+ }
334
+ case "un": {
335
+ const x = evalNode(node.x, scope);
336
+ return node.op === "!" ? !x : -x;
337
+ }
338
+ case "tern":
339
+ return evalNode(node.c, scope) ? evalNode(node.a, scope) : evalNode(node.b, scope);
340
+ case "bin": {
341
+ const op = node.op;
342
+ if (op === "&&") {
343
+ const l2 = evalNode(node.l, scope);
344
+ return l2 ? evalNode(node.r, scope) : l2;
345
+ }
346
+ if (op === "||") {
347
+ const l2 = evalNode(node.l, scope);
348
+ return l2 ? l2 : evalNode(node.r, scope);
349
+ }
350
+ const l = evalNode(node.l, scope);
351
+ const r = evalNode(node.r, scope);
352
+ switch (op) {
353
+ case "+":
354
+ return l + r;
355
+ case "-":
356
+ return l - r;
357
+ case "*":
358
+ return l * r;
359
+ case "/":
360
+ return l / r;
361
+ case "%":
362
+ return l % r;
363
+ case "<":
364
+ return l < r;
365
+ case "<=":
366
+ return l <= r;
367
+ case ">":
368
+ return l > r;
369
+ case ">=":
370
+ return l >= r;
371
+ case "==":
372
+ return l == r;
373
+ case "!=":
374
+ return l != r;
375
+ case "===":
376
+ return l === r;
377
+ case "!==":
378
+ return l !== r;
379
+ }
380
+ return void 0;
381
+ }
382
+ case "call": {
383
+ let fn;
384
+ let self;
385
+ if (node.c.k === "mem") {
386
+ self = evalNode(node.c.o, scope);
387
+ fn = self == null || BLOCKED_PROPS.has(node.c.p) ? void 0 : self[node.c.p];
388
+ } else {
389
+ fn = evalNode(node.c, scope);
390
+ }
391
+ if (typeof fn !== "function") throw new TypeError("not a function");
392
+ return fn.apply(self, node.a.map((x) => evalNode(x, scope)));
393
+ }
394
+ }
395
+ }
396
+ var exprCache = /* @__PURE__ */ new Map();
397
+ var warnedRuntime = /* @__PURE__ */ new Set();
398
+ var SIMPLE_PATH = /^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/;
164
399
  function evalExpr(expr, state) {
165
400
  let cached = exprCache.get(expr);
166
401
  if (!cached) {
@@ -168,26 +403,24 @@ function evalExpr(expr, state) {
168
403
  cached = { kind: "path", parts: expr.split(".") };
169
404
  } else {
170
405
  try {
171
- cached = {
172
- kind: "fn",
173
- fn: new Function("$s", "$safe", `with($safe){with($s){return (${expr})}}`)
174
- };
406
+ cached = { kind: "ast", ast: parse(tokenize(expr)) };
175
407
  } catch {
176
408
  warn(`invalid expression "${expr}"`);
177
- cached = { kind: "fn", fn: () => void 0 };
409
+ cached = { kind: "err" };
178
410
  }
179
411
  }
180
412
  exprCache.set(expr, cached);
181
413
  }
182
414
  if (cached.kind === "path") {
183
- if (!safeStateHas(state, cached.parts[0])) return void 0;
184
- return cached.parts.reduce(
185
- (obj, key) => obj != null ? obj[key] : void 0,
186
- state
187
- );
415
+ const parts = cached.parts;
416
+ if (!safeStateHas(state, parts[0])) return void 0;
417
+ let obj = state;
418
+ for (const key of parts) obj = obj != null ? obj[key] : void 0;
419
+ return obj;
188
420
  }
421
+ if (cached.kind === "err") return void 0;
189
422
  try {
190
- return cached.fn(safeStateWrap(state), SAFE_OUTER);
423
+ return evalNode(cached.ast, state);
191
424
  } catch (e) {
192
425
  if (!warnedRuntime.has(expr)) {
193
426
  warnedRuntime.add(expr);
@@ -347,6 +580,23 @@ function track(instance, el, type, fn) {
347
580
  el.addEventListener(type, fn);
348
581
  ((_a = instance.__micraListeners) != null ? _a : instance.__micraListeners = []).push({ el, type, fn });
349
582
  }
583
+ function runHandler(instance, el, value, e) {
584
+ var _a;
585
+ if (value.includes("(")) {
586
+ let base;
587
+ for (let n = el; n && !base; n = n.parentElement) {
588
+ base = n._itemState;
589
+ }
590
+ const scope = Object.create((_a = base != null ? base : instance.__micraExpr) != null ? _a : null);
591
+ scope["$event"] = e;
592
+ scope["event"] = e;
593
+ evalExpr(value, scope);
594
+ return;
595
+ }
596
+ const fn = instance[value];
597
+ if (typeof fn === "function") fn.call(instance, e);
598
+ else warn(`method "${value}" not found`);
599
+ }
350
600
  function bindDataOn(els, instance) {
351
601
  var _a;
352
602
  for (const el of els) {
@@ -358,13 +608,12 @@ function bindDataOn(els, instance) {
358
608
  const [evSpec, method] = part.trim().split(":");
359
609
  if (!evSpec || !method) continue;
360
610
  const [evName, ...mods] = evSpec.split(".");
611
+ const handler = method.trim();
361
612
  track(instance, el, evName, (e) => {
362
613
  if (mods.includes("prevent")) e.preventDefault();
363
614
  if (mods.includes("stop")) e.stopPropagation();
364
615
  if (mods.includes("self") && e.target !== el) return;
365
- const fn = instance[method.trim()];
366
- if (typeof fn === "function") fn.call(instance, e);
367
- else warn(`method "${method.trim()}" not found`);
616
+ runHandler(instance, el, handler, e);
368
617
  });
369
618
  }
370
619
  }
@@ -377,14 +626,12 @@ function bindAtEvents(els, instance) {
377
626
  for (const attr of Array.from(el.attributes)) {
378
627
  if (!attr.name.startsWith("@")) continue;
379
628
  const [evSpec, ...rest] = attr.name.slice(1).split(".");
380
- const method = attr.value.trim();
629
+ const handler = attr.value.trim();
381
630
  track(instance, el, evSpec, (e) => {
382
631
  if (rest.includes("prevent")) e.preventDefault();
383
632
  if (rest.includes("stop")) e.stopPropagation();
384
633
  if (rest.includes("self") && e.target !== el) return;
385
- const fn = instance[method];
386
- if (typeof fn === "function") fn.call(instance, e);
387
- else warn(`method "${method}" not found`);
634
+ runHandler(instance, el, handler, e);
388
635
  });
389
636
  bound = true;
390
637
  }
@@ -794,6 +1041,7 @@ function mount(selector, definition) {
794
1041
  return Object.prototype.hasOwnProperty.call(instance, key) && typeof instance[key] === "function";
795
1042
  }
796
1043
  });
1044
+ instance.__micraExpr = exprState;
797
1045
  let warnedReentry = false;
798
1046
  instance.render = function() {
799
1047
  var _a2;