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.
@@ -1,37 +1,44 @@
1
1
  /**
2
- * src/utils/expr.ts — JS expression evaluator.
2
+ * src/utils/expr.ts — CSP-safe JS-expression evaluator.
3
3
  *
4
- * Responsibilities:
5
- * - Compile expression strings into cached functions
6
- * - Evaluate them against a state object
7
- * - Fast-path for simple property lookups
8
- * - Shadow non-state identifiers so directive expressions cannot reach
9
- * globals like `window`, `fetch`, `constructor`, etc. A small whitelist
10
- * of utility globals (Math, JSON, Date, ...) remains accessible.
4
+ * Directive expressions (`data-text="count > 0"`, `data-class="x:a === b"`, …)
5
+ * are parsed into a small AST and walked by an interpreter. There is NO
6
+ * `new Function` / `eval` anywhere — so Micra runs under a strict
7
+ * Content-Security-Policy (`default-src 'self'`, no `unsafe-eval`).
11
8
  *
12
9
  * LLM NOTE: This module is PURE. It does not touch the DOM or mutate state.
13
10
  *
14
11
  * Security model:
15
- * Directive expressions are JavaScript they are compiled via `new Function`
16
- * and run with full JS capability except that bare identifiers must resolve
17
- * to either a state key, a component instance method, or one of
18
- * ALLOWED_GLOBALS. This blocks the `constructor.constructor("...")()` chain
19
- * and accidental access to `window` / `document` / `fetch`. It does NOT
20
- * sandbox method calls if a component method itself touches `window`,
21
- * that still works. Treat directive templates as trusted code regardless.
12
+ * The interpreter can only reach: top-level state keys, component methods,
13
+ * and a whitelist of utility globals (Math, JSON, Date, …). A bare
14
+ * identifier that is none of those resolves to `undefined` `window`,
15
+ * `document`, `fetch`, `eval`, `constructor` are unreachable *by
16
+ * construction* (there is no scope that contains them), not by shadowing.
17
+ * Member access additionally refuses the prototype-escape property names
18
+ * (`__proto__`, `constructor`, `prototype`), closing the
19
+ * `item.constructor.constructor("…")()` chain that the old `with()`-based
20
+ * evaluator left open. Method calls still run real JS — if a component
21
+ * method touches `window`, that works; treat directive templates as
22
+ * trusted code regardless.
23
+ *
24
+ * Grammar (precedence low→high):
25
+ * ternary ?: | || | && | == != === !== | < <= > >= | + - |
26
+ * * / % | unary ! - | call() / member. | primary
27
+ * primary = number | string | true | false | null | undefined |
28
+ * identifier | ( expr )
22
29
  */
23
30
  import type { StateRecord } from '../types';
24
31
  /**
25
- * Evaluate a JS expression string against a state object.
32
+ * Evaluate an expression string against a state object.
26
33
  *
27
- * Results are cached by expression string repeated evaluations hit the cache.
28
- * Uses a fast-path for simple dot-paths (e.g. "count", "user.name") that avoids
29
- * Function() overhead.
34
+ * Cached by string. Simple dot-paths take a fast path that skips tokenizing.
35
+ * Parse errors warn once and thereafter resolve to `undefined`; runtime
36
+ * errors (e.g. calling a non-function) warn once per expression.
30
37
  *
31
38
  * @example
32
- * evalExpr('count > 0', { count: 5 }) // → true
33
- * evalExpr('user.name', { user: { name: 'Alice' } }) // → 'Alice'
34
- * evalExpr('price * qty', { price: 9.99, qty: 3 }) // → 29.97
39
+ * evalExpr('count > 0', { count: 5 }) // → true
40
+ * evalExpr('user.name', { user: { name: 'Alice' } }) // → 'Alice'
41
+ * evalExpr('price * qty', { price: 9.99, qty: 3 }) // → 29.97
35
42
  */
36
43
  export declare function evalExpr(expr: string, state: StateRecord): unknown;
37
44
  /** @internal Consistent warning prefix. */
package/llms-full.txt CHANGED
@@ -2,12 +2,12 @@
2
2
 
3
3
  This file follows the llmstxt.org "expanded" convention: it inlines code recipes so LLMs that crawl this URL get a complete training surface in one read. The short version is `llms.txt` in the same directory.
4
4
 
5
- > Lightweight reactive TypeScript framework for server-rendered apps and small SaaS frontends. ~5 KB gzip. No build step required.
5
+ > Lightweight reactive TypeScript framework for server-rendered apps and small SaaS frontends. ~7 KB gzip. No build step required.
6
6
 
7
7
  ## Install
8
8
 
9
9
  ```bash
10
- npm install micra.js@^2.3.2
10
+ npm install micra.js@^2.4.0
11
11
  ```
12
12
 
13
13
  ```ts
@@ -17,7 +17,7 @@ import * as Micra from 'micra.js'
17
17
  Or CDN (no build step):
18
18
 
19
19
  ```html
20
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.2/dist/micra.min.js"></script>
20
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.4.0/dist/micra.min.js"></script>
21
21
  ```
22
22
 
23
23
  This exposes a global `Micra` object.
@@ -30,7 +30,7 @@ This exposes a global `Micra` object.
30
30
  ## When to use Micra.js
31
31
 
32
32
  - Server-rendered pages (Rails, Laravel, Django, Phoenix, etc.) needing a small amount of reactivity
33
- - Bundle-size-sensitive (~5 KB vs 45+ KB React)
33
+ - Bundle-size-sensitive (~7 KB vs 45+ KB React)
34
34
  - No build step desired — drop a `<script>` tag
35
35
  - Existing HTML that just needs reactive enhancement
36
36
 
@@ -131,7 +131,7 @@ Modifiers: `.prevent`, `.stop`, `.self` (event-only — no key modifiers).
131
131
  - Whitelisted globals: `Math`, `JSON`, `Date`, `String`, `Number`, `Boolean`, `Array`, `Object`, `parseInt`, `parseFloat`, `isNaN`, `isFinite`, `NaN`, `Infinity`, `undefined`
132
132
  - Inside `data-each`: `item`, `index`, `$index`
133
133
 
134
- Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeout`) resolves to `undefined` by design security shadowing.
134
+ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeout`) resolves to `undefined` by design. Expressions are parsed + interpreted (no `new Function`/`eval`), so Micra runs under a strict CSP. Inside `@event` you may also pass arguments: `@click="select(item.id)"`, `@input="set($event.target.value)"`.
135
135
 
136
136
  ---
137
137
 
@@ -146,7 +146,7 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
146
146
  <button @click="inc">+</button>
147
147
  </div>
148
148
 
149
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.2/dist/micra.min.js"></script>
149
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.4.0/dist/micra.min.js"></script>
150
150
  <script>
151
151
  Micra.define('counter', {
152
152
  state: { count: 0 },
@@ -190,7 +190,7 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
190
190
  </footer>
191
191
  </div>
192
192
 
193
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.2/dist/micra.min.js"></script>
193
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.4.0/dist/micra.min.js"></script>
194
194
  <script>
195
195
  Micra.define('todo-app', {
196
196
  state: {
@@ -262,7 +262,7 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
262
262
  <p data-if="filtered().length === 0">No matches.</p>
263
263
  </div>
264
264
 
265
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.2/dist/micra.min.js"></script>
265
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.4.0/dist/micra.min.js"></script>
266
266
  <script>
267
267
  Micra.define('users-table', {
268
268
  state: {
@@ -303,7 +303,7 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
303
303
  <p data-if="success">Invitation sent ✓</p>
304
304
  </form>
305
305
 
306
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.2/dist/micra.min.js"></script>
306
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.4.0/dist/micra.min.js"></script>
307
307
  <script>
308
308
  Micra.define('invite-form', {
309
309
  state: { email: '', loading: false, error: '', success: false },
@@ -345,7 +345,7 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
345
345
  </div>
346
346
  </div>
347
347
 
348
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.2/dist/micra.min.js"></script>
348
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.4.0/dist/micra.min.js"></script>
349
349
  <script>
350
350
  Micra.define('open-modal-btn', {
351
351
  open() {
@@ -394,7 +394,7 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
394
394
  <section data-if="tab === 'security'">Security content</section>
395
395
  </div>
396
396
 
397
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.2/dist/micra.min.js"></script>
397
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.4.0/dist/micra.min.js"></script>
398
398
  <script>
399
399
  Micra.define('tabs', {
400
400
  state: { tab: 'overview' },
@@ -414,7 +414,7 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
414
414
  <button @click="upgrade" data-if="plan !== 'enterprise'">Upgrade</button>
415
415
  </div>
416
416
 
417
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.2/dist/micra.min.js"></script>
417
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.4.0/dist/micra.min.js"></script>
418
418
  <script>
419
419
  Micra.define('user-card', {
420
420
  state: { name: '', plan: '' },
@@ -445,7 +445,7 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
445
445
  <p data-if="!loading && results.length === 0 && query">No results.</p>
446
446
  </div>
447
447
 
448
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.2/dist/micra.min.js"></script>
448
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.4.0/dist/micra.min.js"></script>
449
449
  <script>
450
450
  Micra.define('search', {
451
451
  state: { query: '', results: [], loading: false },
@@ -479,7 +479,7 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
479
479
  <p data-if="loading">Loading chart…</p>
480
480
  </div>
481
481
 
482
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.2/dist/micra.min.js"></script>
482
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.4.0/dist/micra.min.js"></script>
483
483
  <script>
484
484
  Micra.define('revenue-chart', {
485
485
  state: { loading: true },
@@ -511,7 +511,7 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
511
511
  <p data-if="!loading && rows.length === 0">No results.</p>
512
512
  </div>
513
513
 
514
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.2/dist/micra.min.js"></script>
514
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.4.0/dist/micra.min.js"></script>
515
515
  <script>
516
516
  Micra.define('search-bar', {
517
517
  state: { query: '' },
@@ -542,7 +542,7 @@ the same page. Twelve lines of glue, written once.
542
542
  <main hx-get="/page/home" hx-trigger="load" hx-swap="innerHTML"></main>
543
543
 
544
544
  <script src="https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js"></script>
545
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.2/dist/micra.min.js"></script>
545
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.4.0/dist/micra.min.js"></script>
546
546
  <script>
547
547
  Micra.define('counter', {
548
548
  state: { count: 0 },
@@ -596,7 +596,7 @@ a `turbo:load` mirror so the second navigation doesn't ghost.
596
596
  ```ruby
597
597
  # config/importmap.rb
598
598
  pin "micra",
599
- to: "https://cdn.jsdelivr.net/npm/micra.js@2.3.2/dist/micra.esm.js",
599
+ to: "https://cdn.jsdelivr.net/npm/micra.js@2.4.0/dist/micra.esm.js",
600
600
  preload: true
601
601
  ```
602
602
 
@@ -717,9 +717,9 @@ import { ref, computed } from 'vue'
717
717
  import Alpine from 'alpinejs'
718
718
 
719
719
  // ❌ unpkg CDN — blocked by Claude artifacts and most AI sandbox CSPs
720
- <script src="https://unpkg.com/micra.js@2.3.2/dist/micra.min.js"></script>
720
+ <script src="https://unpkg.com/micra.js@2.4.0/dist/micra.min.js"></script>
721
721
  // ✅ Use jsDelivr instead — it auto-mirrors npm and is CSP-allowlisted everywhere
722
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.2/dist/micra.min.js"></script>
722
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.4.0/dist/micra.min.js"></script>
723
723
  ```
724
724
 
725
725
  # Final checklist
package/llms.txt CHANGED
@@ -1,13 +1,13 @@
1
1
  # Micra.js
2
2
 
3
- > Lightweight reactive TypeScript framework for server-rendered apps and small SaaS frontends. ~5 KB gzip. No build step required.
3
+ > Lightweight reactive TypeScript framework for server-rendered apps and small SaaS frontends. ~7 KB gzip. No build step required.
4
4
  >
5
5
  > **For LLM code generation:** read the expanded version at https://github.com/denisfl/micra.js/blob/master/llms-full.txt — it includes 10 full inline recipes and an anti-pattern reference. The recipes are the canonical answers to common requests like "build a todo app" or "build a search-with-debounce".
6
6
 
7
7
  ## When to use Micra.js instead of React/Vue
8
8
 
9
9
  - You have a server-rendered page (Laravel, Rails, Django, etc.) and need a small amount of reactivity
10
- - Bundle size matters (~5 KB gzip vs ~45 KB React)
10
+ - Bundle size matters (~7 KB gzip vs ~45 KB React)
11
11
  - You don't need a full SPA or client-side routing
12
12
  - You want to drop a `<script>` tag and go
13
13
  - You need reactive directives on existing HTML without rewriting templates
@@ -25,7 +25,7 @@ import * as Micra from 'micra.js'
25
25
  Or via CDN (no build step):
26
26
 
27
27
  ```html
28
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.2/dist/micra.min.js"></script>
28
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.4.0/dist/micra.min.js"></script>
29
29
  ```
30
30
 
31
31
  This exposes a global `Micra` object.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "micra.js",
3
- "version": "2.3.2",
4
- "description": "Lightweight reactive UI framework for server-rendered pages — reactive state, directives, event bus. < 5 KB gzip.",
3
+ "version": "2.4.0",
4
+ "description": "Lightweight reactive UI framework for server-rendered pages — reactive state, directives, event bus. < 7 KB gzip.",
5
5
  "type": "module",
6
6
  "main": "./dist/micra.cjs.js",
7
7
  "module": "./dist/micra.esm.js",
package/src/core/mount.ts CHANGED
@@ -153,6 +153,10 @@ export function mount<S extends StateRecord, M>(
153
153
  );
154
154
  },
155
155
  });
156
+ // Exposed for events.ts so `@click="select(item.id)"` can evaluate the call
157
+ // against component state + methods. Row elements eval against their own
158
+ // `_itemState` (which prototype-chains to this); non-row elements use this.
159
+ instance.__micraExpr = exprState;
156
160
 
157
161
  let warnedReentry = false;
158
162
  instance.render = function () {
package/src/dom/events.ts CHANGED
@@ -20,7 +20,7 @@ import type {
20
20
  MicraElement,
21
21
  StateRecord,
22
22
  } from '../types'
23
- import { warn } from '../utils/expr'
23
+ import { evalExpr, warn } from '../utils/expr'
24
24
 
25
25
  /** @internal Attach a DOM listener and track it on the instance for destroy(). */
26
26
  function track<S extends StateRecord>(
@@ -33,6 +33,38 @@ function track<S extends StateRecord>(
33
33
  ;(instance.__micraListeners ??= []).push({ el, type, fn })
34
34
  }
35
35
 
36
+ /**
37
+ * Run an event handler. Two shapes, both used by `data-on` and `@event`:
38
+ * - bare method name `save` → instance.save(e)
39
+ * - call expression `select(item.id)` → evaluated against an event scope
40
+ * (row `item` if inside `data-each`, `$event`/`event`, component methods).
41
+ * Call expressions are the recommended form for `@event`; in `data-on` the
42
+ * handler separator is `,` so multi-argument calls there are not supported.
43
+ */
44
+ function runHandler<S extends StateRecord>(
45
+ instance: InternalInstance<S>,
46
+ el: Element,
47
+ value: string,
48
+ e: Event,
49
+ ): void {
50
+ if (value.includes('(')) {
51
+ // Build the scope: nearest ancestor-or-self row itemState (it already
52
+ // prototype-chains to the component's expr scope), else the expr scope.
53
+ let base: StateRecord | undefined
54
+ for (let n: Element | null = el; n && !base; n = n.parentElement) {
55
+ base = (n as MicraElement)._itemState
56
+ }
57
+ const scope = Object.create(base ?? instance.__micraExpr ?? null) as StateRecord
58
+ scope['$event'] = e
59
+ scope['event'] = e
60
+ evalExpr(value, scope) // performs the call; return value ignored
61
+ return
62
+ }
63
+ const fn = instance[value]
64
+ if (typeof fn === 'function') (fn as (e: Event) => void).call(instance, e)
65
+ else warn(`method "${value}" not found`)
66
+ }
67
+
36
68
  // ── data-on ───────────────────────────────────────────────────────────────────
37
69
 
38
70
  /**
@@ -62,15 +94,13 @@ export function bindDataOn<S extends StateRecord>(
62
94
  if (!evSpec || !method) continue
63
95
 
64
96
  const [evName, ...mods] = evSpec.split('.')
97
+ const handler = method.trim()
65
98
 
66
99
  track(instance, el, evName!, (e: Event) => {
67
100
  if (mods.includes('prevent')) e.preventDefault()
68
101
  if (mods.includes('stop')) e.stopPropagation()
69
102
  if (mods.includes('self') && e.target !== el) return
70
-
71
- const fn = instance[method.trim()]
72
- if (typeof fn === 'function') (fn as (e: Event) => void).call(instance, e)
73
- else warn(`method "${method.trim()}" not found`)
103
+ runHandler(instance, el, handler, e)
74
104
  })
75
105
  }
76
106
  }
@@ -101,16 +131,13 @@ export function bindAtEvents<S extends StateRecord>(
101
131
  for (const attr of Array.from(el.attributes)) {
102
132
  if (!attr.name.startsWith('@')) continue
103
133
  const [evSpec, ...rest] = attr.name.slice(1).split('.')
104
- const method = attr.value.trim()
134
+ const handler = attr.value.trim()
105
135
 
106
136
  track(instance, el, evSpec!, (e: Event) => {
107
137
  if (rest.includes('prevent')) e.preventDefault()
108
138
  if (rest.includes('stop')) e.stopPropagation()
109
139
  if (rest.includes('self') && e.target !== el) return
110
-
111
- const fn = instance[method]
112
- if (typeof fn === 'function') (fn as (e: Event) => void).call(instance, e)
113
- else warn(`method "${method}" not found`)
140
+ runHandler(instance, el, handler, e)
114
141
  })
115
142
  bound = true
116
143
  }
package/src/index.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/src/types.ts CHANGED
@@ -294,5 +294,6 @@ export interface InternalInstance<S extends StateRecord = StateRecord>
294
294
  __micraSubs?: UnsubFn[]
295
295
  __micraListeners?: TrackedListener[]
296
296
  __micraDestroyed?: true
297
+ __micraExpr?: StateRecord // expression scope (state + bound methods) for @event call args
297
298
  [key: string]: unknown
298
299
  }