micra.js 2.2.0 → 2.3.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 +88 -0
- package/README.md +1 -0
- package/dist/core/bus.d.ts +6 -4
- package/dist/core/reactive.d.ts +1 -1
- package/dist/dom/each.d.ts +11 -7
- package/dist/dom/scan.d.ts +2 -6
- package/dist/index.d.ts +1 -1
- package/dist/micra.cjs.js +172 -86
- package/dist/micra.cjs.js.map +3 -3
- package/dist/micra.esm.js +172 -86
- package/dist/micra.esm.js.map +3 -3
- package/dist/micra.js +172 -86
- package/dist/micra.js.map +3 -3
- package/dist/micra.min.js +2 -2
- package/dist/types.d.ts +50 -5
- package/llms-full.txt +67 -14
- package/llms.txt +1 -1
- package/package.json +2 -2
- package/src/core/bus.ts +15 -6
- package/src/core/mount.ts +17 -7
- package/src/core/reactive.ts +2 -1
- package/src/dom/directives.ts +5 -2
- package/src/dom/each.ts +190 -59
- package/src/dom/refs.ts +1 -0
- package/src/dom/scan.ts +2 -22
- package/src/index.ts +3 -0
- package/src/types.ts +61 -5
- package/src/utils/expr.ts +34 -21
package/llms-full.txt
CHANGED
|
@@ -7,7 +7,7 @@ This file follows the llmstxt.org "expanded" convention: it inlines code recipes
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npm install micra.js@^2.
|
|
10
|
+
npm install micra.js@^2.3.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.
|
|
20
|
+
<script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.0/dist/micra.min.js"></script>
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
This exposes a global `Micra` object.
|
|
@@ -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.
|
|
149
|
+
<script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.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.
|
|
193
|
+
<script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.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.
|
|
265
|
+
<script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.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.
|
|
306
|
+
<script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.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.
|
|
348
|
+
<script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.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.
|
|
397
|
+
<script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.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.
|
|
417
|
+
<script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.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.
|
|
448
|
+
<script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.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.
|
|
482
|
+
<script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.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.
|
|
514
|
+
<script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.0/dist/micra.min.js"></script>
|
|
515
515
|
<script>
|
|
516
516
|
Micra.define('search-bar', {
|
|
517
517
|
state: { query: '' },
|
|
@@ -533,6 +533,59 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
|
|
|
533
533
|
</script>
|
|
534
534
|
```
|
|
535
535
|
|
|
536
|
+
## Recipe 11 — htmx bridge (server-driven HTML swaps + Micra islands)
|
|
537
|
+
|
|
538
|
+
Wire htmx for server-driven DOM swaps and Micra for local reactivity on
|
|
539
|
+
the same page. Twelve lines of glue, written once.
|
|
540
|
+
|
|
541
|
+
```html
|
|
542
|
+
<main hx-get="/page/home" hx-trigger="load" hx-swap="innerHTML"></main>
|
|
543
|
+
|
|
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.0/dist/micra.min.js"></script>
|
|
546
|
+
<script>
|
|
547
|
+
Micra.define('counter', {
|
|
548
|
+
state: { count: 0 },
|
|
549
|
+
inc() { this.state.count++ },
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
Micra.start() // initial mount
|
|
553
|
+
|
|
554
|
+
// Mount new [data-component] arriving via htmx swap.
|
|
555
|
+
document.body.addEventListener('htmx:afterSettle', (e) => {
|
|
556
|
+
Micra.start(e.target)
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
// Destroy Micra instances inside HTML about to be replaced.
|
|
560
|
+
document.body.addEventListener('htmx:beforeSwap', (e) => {
|
|
561
|
+
Micra.instances().forEach((inst, root) => {
|
|
562
|
+
if (e.target.contains(root)) inst.destroy()
|
|
563
|
+
})
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
// Bridge server-sent HX-Trigger events into the Micra bus.
|
|
567
|
+
document.body.addEventListener('htmx:trigger', (e) => {
|
|
568
|
+
const d = e.detail
|
|
569
|
+
if (typeof d === 'string') return Micra.emit(d)
|
|
570
|
+
for (const [k, v] of Object.entries(d)) Micra.emit(k, v)
|
|
571
|
+
})
|
|
572
|
+
</script>
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
Rules of thumb:
|
|
576
|
+
|
|
577
|
+
- **Never put `hx-swap` directly on a `[data-component]` element that
|
|
578
|
+
swaps its own `innerHTML`** — the cached directive scan points at gone
|
|
579
|
+
DOM. Use a wrapper as the swap target.
|
|
580
|
+
- **`Micra.start()` is idempotent**, so re-scanning a subtree that
|
|
581
|
+
contains already-mounted siblings is safe.
|
|
582
|
+
- **Always scope to `e.target`** in the bridge, not `document` — scanning
|
|
583
|
+
the whole page on every swap is wasteful.
|
|
584
|
+
|
|
585
|
+
Full reference with state-survival patterns, `hx-vals`/`hx-include`
|
|
586
|
+
bridging, and per-component loading state lives in
|
|
587
|
+
`docs/recipes/htmx.md`.
|
|
588
|
+
|
|
536
589
|
---
|
|
537
590
|
|
|
538
591
|
# Anti-pattern reference (what LLMs gravitate to — DO NOT)
|
|
@@ -569,9 +622,9 @@ import { ref, computed } from 'vue'
|
|
|
569
622
|
import Alpine from 'alpinejs'
|
|
570
623
|
|
|
571
624
|
// ❌ unpkg CDN — blocked by Claude artifacts and most AI sandbox CSPs
|
|
572
|
-
<script src="https://unpkg.com/micra.js@2.
|
|
625
|
+
<script src="https://unpkg.com/micra.js@2.3.0/dist/micra.min.js"></script>
|
|
573
626
|
// ✅ Use jsDelivr instead — it auto-mirrors npm and is CSP-allowlisted everywhere
|
|
574
|
-
<script src="https://cdn.jsdelivr.net/npm/micra.js@2.
|
|
627
|
+
<script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.0/dist/micra.min.js"></script>
|
|
575
628
|
```
|
|
576
629
|
|
|
577
630
|
# Final checklist
|
package/llms.txt
CHANGED
|
@@ -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.
|
|
28
|
+
<script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.0/dist/micra.min.js"></script>
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
This exposes a global `Micra` object.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "micra.js",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "Lightweight reactive UI framework for server-rendered pages — reactive state, directives, event bus. < 5 KB gzip.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/micra.cjs.js",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
],
|
|
26
26
|
"scripts": {
|
|
27
27
|
"build": "node build.mjs",
|
|
28
|
-
"typecheck": "tsc --noEmit",
|
|
28
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p tests/tsconfig.json",
|
|
29
29
|
"dev": "node build.mjs --watch",
|
|
30
30
|
"test": "vitest run",
|
|
31
31
|
"test:watch": "vitest",
|
package/src/core/bus.ts
CHANGED
|
@@ -11,41 +11,50 @@
|
|
|
11
11
|
* the unsub token in `instance.__micraSubs` for cleanup on destroy().
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import type { EventHandler, UnsubFn } from '../types'
|
|
14
|
+
import type { EmitArgs, EventHandler, EventPayload, UnsubFn } from '../types'
|
|
15
15
|
|
|
16
16
|
// Module-level bus state — one bus per page load.
|
|
17
17
|
const _bus = new Map<string, Set<EventHandler>>()
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* Subscribe to a named event. Returns an unsubscribe function.
|
|
21
|
+
* Payload is typed via the `MicraEvents` interface (augmentable).
|
|
21
22
|
*
|
|
22
23
|
* @example
|
|
23
24
|
* const unsub = on('user:login', (user) => console.log(user))
|
|
24
25
|
* unsub() // stop listening
|
|
25
26
|
*/
|
|
26
|
-
export function on<
|
|
27
|
+
export function on<K extends string>(
|
|
28
|
+
event: K,
|
|
29
|
+
handler: (payload: EventPayload<K>) => void,
|
|
30
|
+
): UnsubFn {
|
|
27
31
|
if (!_bus.has(event)) _bus.set(event, new Set())
|
|
28
32
|
_bus.get(event)!.add(handler as EventHandler)
|
|
29
|
-
return () => off(event, handler
|
|
33
|
+
return () => off(event, handler)
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
/**
|
|
33
37
|
* Unsubscribe a specific handler from an event.
|
|
34
38
|
*/
|
|
35
|
-
export function off
|
|
39
|
+
export function off<K extends string>(
|
|
40
|
+
event: K,
|
|
41
|
+
handler: (payload: EventPayload<K>) => void,
|
|
42
|
+
): void {
|
|
36
43
|
const set = _bus.get(event)
|
|
37
44
|
if (!set) return
|
|
38
|
-
set.delete(handler)
|
|
45
|
+
set.delete(handler as EventHandler)
|
|
39
46
|
if (set.size === 0) _bus.delete(event)
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
/**
|
|
43
50
|
* Publish an event to all subscribers. Errors are caught per-handler.
|
|
51
|
+
* Payload is typed via the `MicraEvents` interface (augmentable).
|
|
44
52
|
*
|
|
45
53
|
* @example
|
|
46
54
|
* emit('user:updated', { id: 1, name: 'Alice' })
|
|
47
55
|
*/
|
|
48
|
-
export function emit(event:
|
|
56
|
+
export function emit<K extends string>(event: K, ...args: EmitArgs<K>): void {
|
|
57
|
+
const payload = args[0]
|
|
49
58
|
_bus.get(event)?.forEach(h => {
|
|
50
59
|
try { h(payload) } catch (e) { console.error(`[Micra] bus error [${event}]:`, e) }
|
|
51
60
|
})
|
package/src/core/mount.ts
CHANGED
|
@@ -17,8 +17,10 @@ import type {
|
|
|
17
17
|
ComponentInstance,
|
|
18
18
|
ComponentMethods,
|
|
19
19
|
EventHandler,
|
|
20
|
+
EventPayload,
|
|
20
21
|
InternalInstance,
|
|
21
22
|
MicraElement,
|
|
23
|
+
|
|
22
24
|
StateRecord,
|
|
23
25
|
UnsubFn,
|
|
24
26
|
} from "../types";
|
|
@@ -95,11 +97,11 @@ export function mount<S extends StateRecord, M>(
|
|
|
95
97
|
instance.fetch = micraFetch;
|
|
96
98
|
instance.emit = busEmit;
|
|
97
99
|
|
|
98
|
-
instance.on = <
|
|
99
|
-
event:
|
|
100
|
-
handler:
|
|
100
|
+
instance.on = <K extends string>(
|
|
101
|
+
event: K,
|
|
102
|
+
handler: (payload: EventPayload<K>) => void,
|
|
101
103
|
): UnsubFn => {
|
|
102
|
-
const unsub = busOn(event, handler);
|
|
104
|
+
const unsub = busOn(event, handler as EventHandler);
|
|
103
105
|
if (!instance.__micraSubs) instance.__micraSubs = [];
|
|
104
106
|
instance.__micraSubs.push(unsub);
|
|
105
107
|
return unsub;
|
|
@@ -107,8 +109,14 @@ export function mount<S extends StateRecord, M>(
|
|
|
107
109
|
|
|
108
110
|
// ── Render ────────────────────────────────────────────────────────────────
|
|
109
111
|
let isRendering = false;
|
|
112
|
+
// Track which state key triggered the current render cycle.
|
|
113
|
+
// 'MULTIPLE' means more than one key was written before the microtask fired.
|
|
114
|
+
let _triggerKey: string | null | "MULTIPLE" = null;
|
|
110
115
|
const schedule = createScheduler(() => instance.render());
|
|
111
|
-
instance.state = createReactiveState(rawState, schedule)
|
|
116
|
+
instance.state = createReactiveState(rawState, schedule, (key) => {
|
|
117
|
+
if (_triggerKey === null) _triggerKey = key;
|
|
118
|
+
else if (_triggerKey !== key) _triggerKey = "MULTIPLE";
|
|
119
|
+
}) as S;
|
|
112
120
|
|
|
113
121
|
// Expression state: proxy that falls back to instance methods so expressions
|
|
114
122
|
// like `data-text="formatDate(item.date)"` can call component methods.
|
|
@@ -149,6 +157,8 @@ export function mount<S extends StateRecord, M>(
|
|
|
149
157
|
let warnedReentry = false;
|
|
150
158
|
instance.render = function () {
|
|
151
159
|
if (instance.__micraDestroyed) return;
|
|
160
|
+
const triggerKey = _triggerKey;
|
|
161
|
+
_triggerKey = null;
|
|
152
162
|
if (isRendering) {
|
|
153
163
|
if (!warnedReentry) {
|
|
154
164
|
warn(
|
|
@@ -166,7 +176,7 @@ export function mount<S extends StateRecord, M>(
|
|
|
166
176
|
const scan =
|
|
167
177
|
mRoot.__micraScan ?? (mRoot.__micraScan = scanComponent(root));
|
|
168
178
|
applyDirectives(scan, exprState, rawState, instance);
|
|
169
|
-
renderList(scan.each, exprState, rawState, instance);
|
|
179
|
+
renderList(scan.each, exprState, rawState, instance, triggerKey);
|
|
170
180
|
bindDataOn(scan.on, instance);
|
|
171
181
|
bindAtEvents(scan.atEvents, instance);
|
|
172
182
|
bindModels(scan.model, instance);
|
|
@@ -187,7 +197,7 @@ export function mount<S extends StateRecord, M>(
|
|
|
187
197
|
);
|
|
188
198
|
instance.__micraListeners = [];
|
|
189
199
|
|
|
190
|
-
// Clear per-element flags & cached
|
|
200
|
+
// Clear per-element flags & cached scan so a future re-mount of the same DOM works.
|
|
191
201
|
const clearFlags = (el: Element) => {
|
|
192
202
|
const m = el as MicraElement;
|
|
193
203
|
delete m.__micraEvents;
|
package/src/core/reactive.ts
CHANGED
|
@@ -20,11 +20,12 @@ import type { StateRecord } from '../types'
|
|
|
20
20
|
* const state = createReactiveState(raw, render)
|
|
21
21
|
* state.count = 5 // triggers render() in next microtask
|
|
22
22
|
*/
|
|
23
|
-
export function createReactiveState<S extends StateRecord>(obj: S, schedule: () => void): S {
|
|
23
|
+
export function createReactiveState<S extends StateRecord>(obj: S, schedule: () => void, onKey?: (key: string) => void): S {
|
|
24
24
|
return new Proxy(obj, {
|
|
25
25
|
set(target, key: string, value: unknown) {
|
|
26
26
|
// Cast through StateRecord — TypeScript cannot write through a generic index
|
|
27
27
|
;(target as StateRecord)[key] = value
|
|
28
|
+
onKey?.(key)
|
|
28
29
|
schedule()
|
|
29
30
|
return true
|
|
30
31
|
},
|
package/src/dom/directives.ts
CHANGED
|
@@ -36,7 +36,8 @@ function applyText(el: Element, expr: string, state: StateRecord): void {
|
|
|
36
36
|
* for the full security model.
|
|
37
37
|
*/
|
|
38
38
|
function applyHtml(el: Element, expr: string, state: StateRecord): void {
|
|
39
|
-
|
|
39
|
+
const html = String(evalExpr(expr, state) ?? '')
|
|
40
|
+
if (el.innerHTML !== html) el.innerHTML = html
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
/**
|
|
@@ -72,7 +73,9 @@ function applyIf(binding: CachedIfBinding, state: StateRecord): void {
|
|
|
72
73
|
* data-show — visibility toggle via `style.display`. Element stays in the DOM.
|
|
73
74
|
*/
|
|
74
75
|
function applyShow(el: Element, expr: string, state: StateRecord): void {
|
|
75
|
-
|
|
76
|
+
const desired = evalExpr(expr, state) ? '' : 'none'
|
|
77
|
+
const htmlEl = el as HTMLElement
|
|
78
|
+
if (htmlEl.style.display !== desired) htmlEl.style.display = desired
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
function applyBind(
|