@getforma/core 0.3.1 → 0.8.1
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/README.md +279 -20
- package/dist/chunk-3U57L2TY.cjs +38 -0
- package/dist/chunk-3U57L2TY.cjs.map +1 -0
- package/dist/{chunk-5H52PKGF.js → chunk-N522P3G6.js} +37 -19
- package/dist/chunk-N522P3G6.js.map +1 -0
- package/dist/chunk-OZCHIVAZ.js +35 -0
- package/dist/chunk-OZCHIVAZ.js.map +1 -0
- package/dist/{chunk-TSQ7AKFT.cjs → chunk-YMIMKO4W.cjs} +66 -25
- package/dist/chunk-YMIMKO4W.cjs.map +1 -0
- package/dist/forma-runtime-csp.js +1 -1
- package/dist/forma-runtime.js +1 -1
- package/dist/formajs-runtime-hardened.global.js +1 -1
- package/dist/formajs-runtime-hardened.global.js.map +1 -1
- package/dist/formajs-runtime.global.js +1 -1
- package/dist/formajs-runtime.global.js.map +1 -1
- package/dist/formajs.global.js +1 -1
- package/dist/formajs.global.js.map +1 -1
- package/dist/index.cjs +142 -118
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +70 -29
- package/dist/index.d.ts +70 -29
- package/dist/index.js +64 -60
- package/dist/index.js.map +1 -1
- package/dist/runtime-hardened.cjs +166 -39
- package/dist/runtime-hardened.cjs.map +1 -1
- package/dist/runtime-hardened.js +167 -40
- package/dist/runtime-hardened.js.map +1 -1
- package/dist/runtime.cjs +184 -57
- package/dist/runtime.cjs.map +1 -1
- package/dist/runtime.js +162 -35
- package/dist/runtime.js.map +1 -1
- package/dist/signal-B4_wQJHs.d.cts +53 -0
- package/dist/signal-B4_wQJHs.d.ts +53 -0
- package/dist/ssr/index.cjs +37 -12
- package/dist/ssr/index.cjs.map +1 -1
- package/dist/ssr/index.js +37 -12
- package/dist/ssr/index.js.map +1 -1
- package/dist/tc39-compat.cjs +4 -12
- package/dist/tc39-compat.cjs.map +1 -1
- package/dist/tc39-compat.d.cts +1 -1
- package/dist/tc39-compat.d.ts +1 -1
- package/dist/tc39-compat.js +3 -11
- package/dist/tc39-compat.js.map +1 -1
- package/package.json +2 -2
- package/dist/chunk-5H52PKGF.js.map +0 -1
- package/dist/chunk-FPSLC62A.cjs +0 -31
- package/dist/chunk-FPSLC62A.cjs.map +0 -1
- package/dist/chunk-KX5WRZH7.js +0 -27
- package/dist/chunk-KX5WRZH7.js.map +0 -1
- package/dist/chunk-TSQ7AKFT.cjs.map +0 -1
- package/dist/signal-CfLDwMyg.d.cts +0 -29
- package/dist/signal-CfLDwMyg.d.ts +0 -29
package/README.md
CHANGED
|
@@ -18,6 +18,41 @@ Or use the CDN (no build step required):
|
|
|
18
18
|
<script src="https://unpkg.com/@getforma/core/dist/formajs-runtime.global.js"></script>
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
+
### Getting Started with a Bundler
|
|
22
|
+
|
|
23
|
+
After `npm install`, you need a bundler to resolve the ES module imports. Here's a minimal Vite setup:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install @getforma/core
|
|
27
|
+
npm install -D vite
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```html
|
|
31
|
+
<!-- index.html -->
|
|
32
|
+
<div id="app"></div>
|
|
33
|
+
<script type="module" src="./main.ts"></script>
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
// main.ts
|
|
38
|
+
import { createSignal, h, mount } from '@getforma/core';
|
|
39
|
+
|
|
40
|
+
const [count, setCount] = createSignal(0);
|
|
41
|
+
|
|
42
|
+
mount(() =>
|
|
43
|
+
h('button', { onClick: () => setCount(count() + 1) },
|
|
44
|
+
() => `Clicked ${count()} times`
|
|
45
|
+
),
|
|
46
|
+
'#app'
|
|
47
|
+
);
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npx vite
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
For **esbuild**, **tsup**, or other bundlers — no special config is needed. FormaJS ships standard ESM and CJS via `package.json` exports.
|
|
55
|
+
|
|
21
56
|
## Why FormaJS?
|
|
22
57
|
|
|
23
58
|
Most UI libraries make you choose: simple but limited (Alpine, htmx), or powerful but complex (React, Vue, Svelte). FormaJS gives you a single reactive core that scales from a CDN script tag to a full-stack Rust SSR pipeline.
|
|
@@ -65,6 +100,8 @@ Drop a script tag, write `data-*` attributes. Zero config, zero tooling.
|
|
|
65
100
|
| `data-persist` | Persist state to localStorage | `data-persist="{count}"` |
|
|
66
101
|
| `data-fetch` | Fetch data from URL | `data-fetch="GET /api/items → items"` |
|
|
67
102
|
| `data-transition:*` | Enter/leave CSS transitions | `data-transition:enter="fade-in"` |
|
|
103
|
+
| `$el` | Reference to the current DOM element | `data-on:click="{$el.classList.toggle('active')}"` |
|
|
104
|
+
| `$dispatch` | Fire a CustomEvent (bubbles, crosses Shadow DOM) | `data-on:click="{$dispatch('selected', {id: itemId})}"` |
|
|
68
105
|
|
|
69
106
|
CSP-safe expression parser — no `eval()` or `new Function()` by default. For strict CSP environments, use the hardened build:
|
|
70
107
|
|
|
@@ -125,15 +162,14 @@ mount(() => <Counter />, '#app');
|
|
|
125
162
|
|
|
126
163
|
## CDN Usage
|
|
127
164
|
|
|
128
|
-
|
|
129
|
-
```html
|
|
130
|
-
<script src="https://unpkg.com/@getforma/core/dist/forma-runtime.js"></script>
|
|
131
|
-
```
|
|
165
|
+
Both long and short filenames are provided. They are identical files — use whichever you prefer:
|
|
132
166
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
167
|
+
| Build | URL |
|
|
168
|
+
|-------|-----|
|
|
169
|
+
| **Standard** (recommended) | `unpkg.com/@getforma/core/dist/formajs-runtime.global.js` |
|
|
170
|
+
| Standard (short alias) | `unpkg.com/@getforma/core/dist/forma-runtime.js` |
|
|
171
|
+
| **CSP-safe** (no `new Function`) | `unpkg.com/@getforma/core/dist/formajs-runtime-hardened.global.js` |
|
|
172
|
+
| CSP-safe (short alias) | `unpkg.com/@getforma/core/dist/forma-runtime-csp.js` |
|
|
137
173
|
|
|
138
174
|
> The CSP build uses a hand-written expression parser and never calls `new Function`.
|
|
139
175
|
> It supports most common patterns. See [examples/csp](./examples/csp) for a working demo.
|
|
@@ -156,6 +192,45 @@ batch(() => {
|
|
|
156
192
|
});
|
|
157
193
|
```
|
|
158
194
|
|
|
195
|
+
#### Custom Equality
|
|
196
|
+
|
|
197
|
+
Skip updates when the new value is equal to the current value:
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
const [pos, setPos] = createSignal(
|
|
201
|
+
{ x: 0, y: 0 },
|
|
202
|
+
{ equals: (a, b) => a.x === b.x && a.y === b.y },
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
setPos({ x: 0, y: 0 }); // skipped — values are equal
|
|
206
|
+
setPos({ x: 1, y: 0 }); // applied — values differ
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
#### Computed with Previous Value
|
|
210
|
+
|
|
211
|
+
The computed getter receives the previous value for efficient diffing:
|
|
212
|
+
|
|
213
|
+
```typescript
|
|
214
|
+
const changes = createComputed((prev) => {
|
|
215
|
+
const current = items();
|
|
216
|
+
if (prev) console.log(`${prev.length} → ${current.length} items`);
|
|
217
|
+
return current;
|
|
218
|
+
});
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
#### Reactive Introspection
|
|
222
|
+
|
|
223
|
+
Type guards and utilities from alien-signals 3.x:
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
import { isSignal, isComputed, getBatchDepth, trigger } from '@getforma/core';
|
|
227
|
+
|
|
228
|
+
isSignal(count); // true — is this a signal getter?
|
|
229
|
+
isComputed(doubled); // true — is this a computed value?
|
|
230
|
+
getBatchDepth(); // 0 outside batch, 1+ inside
|
|
231
|
+
trigger(doubled); // force recomputation even if deps unchanged
|
|
232
|
+
```
|
|
233
|
+
|
|
159
234
|
### Conditional Rendering
|
|
160
235
|
|
|
161
236
|
```typescript
|
|
@@ -204,12 +279,17 @@ const [state, setState] = createStore({
|
|
|
204
279
|
items: [1, 2, 3],
|
|
205
280
|
});
|
|
206
281
|
|
|
207
|
-
// Read reactively
|
|
282
|
+
// Read reactively — tracked at the exact property path
|
|
208
283
|
state.user.name; // 'Alice'
|
|
284
|
+
state.items[0]; // 1
|
|
285
|
+
|
|
286
|
+
// Setter API — partial object merge (batched)
|
|
287
|
+
setState({ user: { ...state.user, name: 'Bob' } });
|
|
288
|
+
setState(prev => ({ items: [...prev.items, 4] }));
|
|
209
289
|
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
290
|
+
// Or mutate directly via proxy — only affected subscribers update
|
|
291
|
+
state.user.name = 'Bob'; // only "user.name" subscribers notified
|
|
292
|
+
state.items.push(4); // array mutation batched automatically
|
|
213
293
|
```
|
|
214
294
|
|
|
215
295
|
### Components
|
|
@@ -231,6 +311,60 @@ const Timer = defineComponent(() => {
|
|
|
231
311
|
document.body.appendChild(Timer());
|
|
232
312
|
```
|
|
233
313
|
|
|
314
|
+
#### Lifecycle: `onMount` vs `onUnmount`
|
|
315
|
+
|
|
316
|
+
- **`onMount(fn)`** — runs after the component's DOM is created. If `fn` returns a function, that function is automatically registered as an unmount callback.
|
|
317
|
+
- **`onUnmount(fn)`** — explicitly registers a cleanup function that runs when the component is disposed.
|
|
318
|
+
|
|
319
|
+
Both mechanisms feed into the same cleanup queue — the `onMount` return shorthand is convenience for the common pattern of setting up and tearing down in one place:
|
|
320
|
+
|
|
321
|
+
```typescript
|
|
322
|
+
// These are equivalent:
|
|
323
|
+
onMount(() => {
|
|
324
|
+
const id = setInterval(tick, 1000);
|
|
325
|
+
return () => clearInterval(id);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// vs.
|
|
329
|
+
onMount(() => {
|
|
330
|
+
const id = setInterval(tick, 1000);
|
|
331
|
+
onUnmount(() => clearInterval(id));
|
|
332
|
+
});
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### Error Handling
|
|
336
|
+
|
|
337
|
+
**`mount()` fails fast.** If the container selector doesn't match any element, it throws:
|
|
338
|
+
|
|
339
|
+
```typescript
|
|
340
|
+
mount(() => h('p', null, 'hello'), '#nonexistent');
|
|
341
|
+
// Error: mount: container not found — "#nonexistent"
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
**Global error handler.** Register a handler for errors in effects and lifecycle callbacks:
|
|
345
|
+
|
|
346
|
+
```typescript
|
|
347
|
+
import { onError } from '@getforma/core';
|
|
348
|
+
|
|
349
|
+
onError((error, info) => {
|
|
350
|
+
console.error(`[${info?.source}]`, error);
|
|
351
|
+
});
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
**Error boundaries.** Catch rendering errors and display fallback UI with a retry option:
|
|
355
|
+
|
|
356
|
+
```typescript
|
|
357
|
+
import { createErrorBoundary, h } from '@getforma/core';
|
|
358
|
+
|
|
359
|
+
createErrorBoundary(
|
|
360
|
+
() => h(UnstableComponent),
|
|
361
|
+
(error, retry) => h('div', null,
|
|
362
|
+
h('p', null, `Something went wrong: ${error.message}`),
|
|
363
|
+
h('button', { onClick: retry }, 'Retry'),
|
|
364
|
+
),
|
|
365
|
+
);
|
|
366
|
+
```
|
|
367
|
+
|
|
234
368
|
### Context (Dependency Injection)
|
|
235
369
|
|
|
236
370
|
```typescript
|
|
@@ -242,29 +376,106 @@ provide(ThemeCtx, 'dark');
|
|
|
242
376
|
const theme = inject(ThemeCtx); // 'dark'
|
|
243
377
|
```
|
|
244
378
|
|
|
379
|
+
### History (undo/redo)
|
|
380
|
+
|
|
381
|
+
```typescript
|
|
382
|
+
import { createHistory } from '@getforma/core';
|
|
383
|
+
|
|
384
|
+
const [state, setState, { undo, redo, canUndo, canRedo }] = createHistory({ text: '' });
|
|
385
|
+
|
|
386
|
+
setState({ text: 'hello' });
|
|
387
|
+
setState({ text: 'hello world' });
|
|
388
|
+
|
|
389
|
+
undo(); // state.text === 'hello'
|
|
390
|
+
canUndo(); // true
|
|
391
|
+
redo(); // state.text === 'hello world'
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
### Reducer
|
|
395
|
+
|
|
396
|
+
```typescript
|
|
397
|
+
import { createReducer } from '@getforma/core';
|
|
398
|
+
|
|
399
|
+
const [state, dispatch] = createReducer(
|
|
400
|
+
(state, action) => {
|
|
401
|
+
switch (action.type) {
|
|
402
|
+
case 'INCREMENT': return { count: state.count + 1 };
|
|
403
|
+
case 'DECREMENT': return { count: state.count - 1 };
|
|
404
|
+
default: return state;
|
|
405
|
+
}
|
|
406
|
+
},
|
|
407
|
+
{ count: 0 },
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
dispatch({ type: 'INCREMENT' }); // state() === { count: 1 }
|
|
411
|
+
```
|
|
412
|
+
|
|
245
413
|
## Islands Architecture
|
|
246
414
|
|
|
247
|
-
For server-rendered HTML, activate independent interactive regions
|
|
415
|
+
For server-rendered HTML, activate independent interactive regions. Each island callback receives the root DOM element and parsed props, then returns a component tree — the same `h()` calls you'd use for client-side rendering. The hydration system walks the descriptor tree against the existing SSR DOM, attaching event handlers and reactive bindings without recreating elements.
|
|
248
416
|
|
|
249
417
|
```typescript
|
|
250
418
|
import { activateIslands, createSignal, h } from '@getforma/core';
|
|
251
419
|
|
|
252
420
|
activateIslands({
|
|
253
421
|
Counter: (el, props) => {
|
|
254
|
-
const [count, setCount] = createSignal(props
|
|
255
|
-
|
|
422
|
+
const [count, setCount] = createSignal(props?.initial ?? 0);
|
|
423
|
+
|
|
424
|
+
// el is the island's root HTMLElement — useful for layout measurement,
|
|
425
|
+
// focus management, CSS classes, or reading extra data-* attributes.
|
|
426
|
+
el.classList.add('is-hydrated');
|
|
427
|
+
|
|
428
|
+
// Return the same tree shape as the SSR output.
|
|
429
|
+
// Hydration matches this against existing DOM — no elements are created.
|
|
430
|
+
return h('div', null,
|
|
431
|
+
h('span', null, () => String(count())),
|
|
432
|
+
h('button', { onClick: () => setCount(c => c + 1) }, '+1'),
|
|
433
|
+
);
|
|
256
434
|
},
|
|
257
435
|
});
|
|
258
436
|
```
|
|
259
437
|
|
|
260
438
|
```html
|
|
261
439
|
<!-- Server-rendered HTML -->
|
|
262
|
-
<div data-forma-island="Counter" data-forma-props='{"initial": 5}'>
|
|
440
|
+
<div data-forma-island="0" data-forma-component="Counter" data-forma-props='{"initial": 5}'>
|
|
263
441
|
<span>5</span>
|
|
264
442
|
<button>+1</button>
|
|
265
443
|
</div>
|
|
266
444
|
```
|
|
267
445
|
|
|
446
|
+
Each island is activated inside its own `createRoot` scope with error isolation — a broken island never takes down its siblings.
|
|
447
|
+
|
|
448
|
+
### Hydration Triggers
|
|
449
|
+
|
|
450
|
+
Control when an island hydrates via `data-forma-hydrate`:
|
|
451
|
+
|
|
452
|
+
| Trigger | When it hydrates | Use case |
|
|
453
|
+
|---------|-----------------|----------|
|
|
454
|
+
| `load` (default) | Immediately on page load | Above-the-fold interactive content |
|
|
455
|
+
| `visible` | When island enters viewport | Below-the-fold components |
|
|
456
|
+
| `idle` | During browser idle time (`requestIdleCallback`) | Non-critical functionality |
|
|
457
|
+
| `interaction` | On first `pointerdown` or `focusin` | Skeleton+skin pattern |
|
|
458
|
+
|
|
459
|
+
```html
|
|
460
|
+
<div data-forma-island="1" data-forma-component="Comments" data-forma-hydrate="visible">
|
|
461
|
+
<!-- Only loads JS when scrolled into view -->
|
|
462
|
+
</div>
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
### Island Disposal
|
|
466
|
+
|
|
467
|
+
When swapping module content (e.g., inside `<forma-stage>` Shadow DOM), dispose islands to prevent leaked effects and listeners:
|
|
468
|
+
|
|
469
|
+
```typescript
|
|
470
|
+
import { deactivateIsland, deactivateAllIslands } from '@getforma/core';
|
|
471
|
+
|
|
472
|
+
// Dispose all active islands under a root
|
|
473
|
+
deactivateAllIslands(shadowRoot);
|
|
474
|
+
|
|
475
|
+
// Or dispose a single island
|
|
476
|
+
deactivateIsland(islandElement);
|
|
477
|
+
```
|
|
478
|
+
|
|
268
479
|
## Subpath Exports
|
|
269
480
|
|
|
270
481
|
| Import | Description |
|
|
@@ -288,12 +499,60 @@ See the [`examples/`](./examples) directory:
|
|
|
288
499
|
- **todo** — todo list with `createList` and keyed reconciliation
|
|
289
500
|
- **data-table** — sortable table with `createList`
|
|
290
501
|
|
|
502
|
+
## How Is This Different from Solid?
|
|
503
|
+
|
|
504
|
+
FormaJS shares Solid's core insight — fine-grained signals updating the real DOM without a virtual DOM. If you know Solid, you'll feel at home. The differences are in scope and delivery:
|
|
505
|
+
|
|
506
|
+
| | Solid | FormaJS |
|
|
507
|
+
|-|-------|---------|
|
|
508
|
+
| **Build requirement** | Always needs a compiler (JSX transform) | CDN runtime works with zero build step; bundler is optional |
|
|
509
|
+
| **Entry points** | JSX-first | HTML Runtime (`data-*` attributes), `h()` hyperscript, or JSX |
|
|
510
|
+
| **CSP** | Relies on compiler output | Hand-written expression parser; hardened build has no `new Function()` |
|
|
511
|
+
| **Islands** | Via [solid-start](https://start.solidjs.com/) meta-framework | Built-in `activateIslands()` — no meta-framework needed |
|
|
512
|
+
| **Ecosystem** | Mature (router, meta-framework, devtools) | Minimal — reactive core only, you bring the architecture |
|
|
513
|
+
| **SSR runtime** | Node.js required | Node.js via `renderToString`, or Rust walker (no JS runtime on the server) |
|
|
514
|
+
| **Size** | ~7KB | ~15KB (includes runtime parser, stores, SSR) |
|
|
515
|
+
|
|
516
|
+
**When to choose FormaJS:** You want islands hydration built into the library, not bolted on through a meta-framework. You need CSP compliance without a build step. You want three entry points (CDN, hyperscript, JSX) sharing one signal graph. Or you're building on a Rust backend and want your frontend reactive layer to integrate natively with the server stack.
|
|
517
|
+
|
|
518
|
+
**When to choose Solid:** You want a mature JavaScript ecosystem with routing, SSR meta-framework (SolidStart), devtools, and community-built component libraries. Your backend is Node.js and you want SSR in the same language as your frontend.
|
|
519
|
+
|
|
520
|
+
> FormaJS is the reactive layer of the [Forma stack](https://getforma.dev). The full pipeline compiles components to a binary IR (FMIR), renders them in Rust via `forma-ir`, and serves pages through `forma-server` — SSR without Node.js, binary IR over the wire, deployed for ~$18/month.
|
|
521
|
+
|
|
522
|
+
## Stability
|
|
523
|
+
|
|
524
|
+
Some features are more battle-tested than others:
|
|
525
|
+
|
|
526
|
+
| Feature | Status | Notes |
|
|
527
|
+
|---------|--------|-------|
|
|
528
|
+
| Signals (`createSignal`, `createEffect`, `createComputed`, `batch`) | **Stable** | Core primitive, well-tested. Custom `equals` option supported. |
|
|
529
|
+
| Reactive introspection (`isSignal`, `isComputed`, `trigger`, `getBatchDepth`) | **Stable** | alien-signals 3.x type guards |
|
|
530
|
+
| `h()` / JSX rendering | **Stable** | |
|
|
531
|
+
| `mount()`, `createShow`, `createSwitch`, `createList` | **Stable** | |
|
|
532
|
+
| HTML Runtime (`data-*` directives) | **Stable** | Expression parser covers common patterns |
|
|
533
|
+
| CSP-hardened runtime | **Stable** | No `new Function()`, tested with strict CSP headers |
|
|
534
|
+
| `createStore` (deep reactivity) | **Stable** | |
|
|
535
|
+
| Components (`defineComponent`, lifecycle) | **Stable** | |
|
|
536
|
+
| Context (`createContext`, `provide`, `inject`) | **Stable** | |
|
|
537
|
+
| Islands (`activateIslands`, disposal, triggers) | **Stable** | 10 activation + 88 hydration + 10 trigger tests |
|
|
538
|
+
| `createHistory` (undo/redo) | **Stable** | |
|
|
539
|
+
| `createReducer` | **Stable** | 8 tests |
|
|
540
|
+
| `data-fetch`, `data-transition:*` | **Stable** | Fully implemented in HTML Runtime |
|
|
541
|
+
| SSR (`renderToString`, `renderToStream`) | **Beta** | Functional, API may evolve |
|
|
542
|
+
| TC39 Signals compat (`Signal.State`, `Signal.Computed`) | **Beta** | 9 tests, but tracks an evolving TC39 proposal |
|
|
543
|
+
|
|
291
544
|
## Ecosystem
|
|
292
545
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
|
296
|
-
|
|
546
|
+
FormaJS is the reactive frontend layer of a full-stack Rust + TypeScript framework. The pipeline flows: TypeScript components → `@getforma/compiler` → FMIR binary → `forma-ir` (parse) → `forma-server` (render) → Axum HTTP response.
|
|
547
|
+
|
|
548
|
+
| Package | Language | Description |
|
|
549
|
+
|---------|----------|-------------|
|
|
550
|
+
| [@getforma/core](https://www.npmjs.com/package/@getforma/core) | TypeScript | This library — reactive DOM, signals, islands, SSR hydration |
|
|
551
|
+
| [@getforma/compiler](https://github.com/getforma-dev/forma-tools) | TypeScript | TypeScript-to-FMIR compiler, Vite plugin, esbuild SSR plugin |
|
|
552
|
+
| [@getforma/build](https://github.com/getforma-dev/forma-tools) | TypeScript | esbuild pipeline with content hashing, compression, manifest |
|
|
553
|
+
| [@getforma/create-app](https://github.com/getforma-dev/create-forma-app) | TypeScript | `npx @getforma/create-app` — scaffold a new Forma project |
|
|
554
|
+
| [forma-ir](https://crates.io/crates/forma-ir) | Rust | FMIR binary format: parser, walker, WASM exports |
|
|
555
|
+
| [forma-server](https://crates.io/crates/forma-server) | Rust | Axum middleware for SSR page rendering, asset serving, CSP |
|
|
297
556
|
|
|
298
557
|
## License
|
|
299
558
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var alienSignals = require('alien-signals');
|
|
4
|
+
|
|
5
|
+
// src/reactive/signal.ts
|
|
6
|
+
function applySignalSet(s, v, equals) {
|
|
7
|
+
if (typeof v !== "function") {
|
|
8
|
+
if (equals) {
|
|
9
|
+
const prevSub2 = alienSignals.setActiveSub(void 0);
|
|
10
|
+
const prev2 = s();
|
|
11
|
+
alienSignals.setActiveSub(prevSub2);
|
|
12
|
+
if (equals(prev2, v)) return;
|
|
13
|
+
}
|
|
14
|
+
s(v);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const prevSub = alienSignals.setActiveSub(void 0);
|
|
18
|
+
const prev = s();
|
|
19
|
+
alienSignals.setActiveSub(prevSub);
|
|
20
|
+
const next = v(prev);
|
|
21
|
+
if (equals && equals(prev, next)) return;
|
|
22
|
+
s(next);
|
|
23
|
+
}
|
|
24
|
+
function createSignal(initialValue, options) {
|
|
25
|
+
const s = alienSignals.signal(initialValue);
|
|
26
|
+
const getter = s;
|
|
27
|
+
const eq = options?.equals;
|
|
28
|
+
const setter = (v) => applySignalSet(s, v, eq);
|
|
29
|
+
return [getter, setter];
|
|
30
|
+
}
|
|
31
|
+
function createComputed(fn) {
|
|
32
|
+
return alienSignals.computed(fn);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
exports.createComputed = createComputed;
|
|
36
|
+
exports.createSignal = createSignal;
|
|
37
|
+
//# sourceMappingURL=chunk-3U57L2TY.cjs.map
|
|
38
|
+
//# sourceMappingURL=chunk-3U57L2TY.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/reactive/signal.ts","../src/reactive/computed.ts"],"names":["prevSub","setActiveSub","prev","createRawSignal","rawComputed"],"mappings":";;;;;AAkEA,SAAS,cAAA,CACP,CAAA,EACA,CAAA,EACA,MAAA,EACM;AACN,EAAA,IAAI,OAAO,MAAM,UAAA,EAAY;AAC3B,IAAA,IAAI,MAAA,EAAQ;AAEV,MAAA,MAAMA,QAAAA,GAAUC,0BAAa,MAAS,CAAA;AACtC,MAAA,MAAMC,QAAO,CAAA,EAAE;AACf,MAAAD,yBAAA,CAAaD,QAAO,CAAA;AACpB,MAAA,IAAI,MAAA,CAAOE,KAAAA,EAAM,CAAC,CAAA,EAAG;AAAA,IACvB;AACA,IAAA,CAAA,CAAE,CAAC,CAAA;AACH,IAAA;AAAA,EACF;AAGA,EAAA,MAAM,OAAA,GAAUD,0BAAa,MAAS,CAAA;AACtC,EAAA,MAAM,OAAO,CAAA,EAAE;AACf,EAAAA,yBAAA,CAAa,OAAO,CAAA;AACpB,EAAA,MAAM,IAAA,GAAQ,EAAqB,IAAI,CAAA;AACvC,EAAA,IAAI,MAAA,IAAU,MAAA,CAAO,IAAA,EAAM,IAAI,CAAA,EAAG;AAClC,EAAA,CAAA,CAAE,IAAI,CAAA;AACR;AAqBO,SAAS,YAAA,CAAgB,cAAiB,OAAA,EAA0E;AACzH,EAAA,MAAM,CAAA,GAAIE,oBAAmB,YAAY,CAAA;AACzC,EAAA,MAAM,MAAA,GAAS,CAAA;AACf,EAAA,MAAM,KAAK,OAAA,EAAS,MAAA;AACpB,EAAA,MAAM,SAA0B,CAAC,CAAA,KAA4B,cAAA,CAAe,CAAA,EAAG,GAAG,EAAE,CAAA;AAEpF,EAAA,OAAO,CAAC,QAAQ,MAAM,CAAA;AACxB;AC7EO,SAAS,eAAkB,EAAA,EAAuC;AACvE,EAAA,OAAOC,sBAAY,EAAE,CAAA;AACvB","file":"chunk-3U57L2TY.cjs","sourcesContent":["/**\n * Forma Reactive - Signal\n *\n * Fine-grained reactive primitive backed by alien-signals.\n * API: createSignal returns [getter, setter] tuple following SolidJS conventions.\n *\n * TC39 Signals equivalent: Signal.State\n */\n\nimport { signal as createRawSignal, setActiveSub } from 'alien-signals';\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\nexport type SignalGetter<T> = () => T;\nexport type SignalSetter<T> = (v: T | ((prev: T) => T)) => void;\n\nexport interface SignalOptions<T> {\n /** Debug name — attached to getter in dev mode for devtools inspection. */\n name?: string;\n /**\n * Custom equality check. When provided, the setter will read the current\n * value and only update the signal if `equals(prev, next)` returns `false`.\n *\n * Default: none (alien-signals uses strict inequality `!==` internally).\n *\n * ```ts\n * const [pos, setPos] = createSignal(\n * { x: 0, y: 0 },\n * { equals: (a, b) => a.x === b.x && a.y === b.y },\n * );\n *\n * setPos({ x: 0, y: 0 }); // skipped — equals returns true\n * setPos({ x: 1, y: 0 }); // applied — equals returns false\n * ```\n */\n equals?: (prev: T, next: T) => boolean;\n}\n\n/**\n * Wrap a value so the setter treats it as a literal value, not a functional updater.\n *\n * When `T` is itself a function type, passing a function to the setter is\n * ambiguous -- it looks like a functional update (`prev => next`). Use\n * `value()` to disambiguate:\n *\n * ```ts\n * const [getFn, setFn] = createSignal<() => void>(() => console.log('a'));\n *\n * // BUG: interpreted as a functional update -- calls the arrow with prev\n * // setFn(() => console.log('b'));\n *\n * // Correct: wraps in a thunk so the setter stores it as-is\n * setFn(value(() => console.log('b')));\n * ```\n */\nexport function value<T>(v: T): () => T {\n return () => v;\n}\n\ntype RawSignal<T> = {\n (): T;\n (value: T): void;\n};\n\nfunction applySignalSet<T>(\n s: RawSignal<T>,\n v: T | ((prev: T) => T),\n equals?: (prev: T, next: T) => boolean,\n): void {\n if (typeof v !== 'function') {\n if (equals) {\n // Read current value without tracking\n const prevSub = setActiveSub(undefined);\n const prev = s();\n setActiveSub(prevSub);\n if (equals(prev, v)) return; // skip — values are equal\n }\n s(v);\n return;\n }\n\n // Functional update: read prev without tracking\n const prevSub = setActiveSub(undefined);\n const prev = s();\n setActiveSub(prevSub);\n const next = (v as (prev: T) => T)(prev);\n if (equals && equals(prev, next)) return; // skip — values are equal\n s(next);\n}\n\n/**\n * Create a reactive signal.\n *\n * ```ts\n * const [count, setCount] = createSignal(0);\n * console.log(count()); // 0\n * setCount(1);\n * setCount(prev => prev + 1);\n * ```\n *\n * With custom equality:\n *\n * ```ts\n * const [pos, setPos] = createSignal(\n * { x: 0, y: 0 },\n * { equals: (a, b) => a.x === b.x && a.y === b.y },\n * );\n * ```\n */\nexport function createSignal<T>(initialValue: T, options?: SignalOptions<T>): [get: SignalGetter<T>, set: SignalSetter<T>] {\n const s = createRawSignal<T>(initialValue) as RawSignal<T>;\n const getter = s as unknown as SignalGetter<T>;\n const eq = options?.equals;\n const setter: SignalSetter<T> = (v: T | ((prev: T) => T)) => applySignalSet(s, v, eq);\n\n return [getter, setter];\n}\n","/**\n * Forma Reactive - Computed\n *\n * Lazy, cached derived value that participates in the reactive graph.\n * Backed by alien-signals for automatic dependency tracking\n * and cache invalidation.\n *\n * TC39 Signals equivalent: Signal.Computed\n */\n\nimport { computed as rawComputed } from 'alien-signals';\n\n/**\n * Create a lazy, cached computed value.\n *\n * Note: Unlike SolidJS's createComputed (which is an eager synchronous\n * side effect), this is a lazy cached derivation — equivalent to\n * SolidJS's createMemo. Both createComputed and createMemo in FormaJS\n * are identical.\n *\n * The getter receives the previous value as an argument, enabling\n * efficient diffing patterns without a separate signal:\n *\n * ```ts\n * const [count, setCount] = createSignal(0);\n * const doubled = createComputed(() => count() * 2);\n * console.log(doubled()); // 0\n * setCount(5);\n * console.log(doubled()); // 10\n * ```\n *\n * With previous value (for diffing):\n *\n * ```ts\n * const changes = createComputed((prev) => {\n * const next = items();\n * if (prev) console.log(`changed from ${prev.length} to ${next.length} items`);\n * return next;\n * });\n * ```\n */\nexport function createComputed<T>(fn: (previousValue?: T) => T): () => T {\n return rawComputed(fn);\n}\n"]}
|
|
@@ -1,14 +1,21 @@
|
|
|
1
|
-
import { createComputed, createSignal
|
|
2
|
-
import { effect, startBatch, endBatch,
|
|
1
|
+
import { createComputed, createSignal } from './chunk-OZCHIVAZ.js';
|
|
2
|
+
import { effect, effectScope, startBatch, endBatch, setActiveSub } from 'alien-signals';
|
|
3
|
+
export { getBatchDepth, isComputed, isEffect, isEffectScope, isSignal, trigger } from 'alien-signals';
|
|
3
4
|
|
|
4
|
-
// src/reactive/root.ts
|
|
5
5
|
var currentRoot = null;
|
|
6
6
|
var rootStack = [];
|
|
7
7
|
function createRoot(fn) {
|
|
8
|
-
const scope = { disposers: [] };
|
|
8
|
+
const scope = { disposers: [], scopeDispose: null };
|
|
9
9
|
rootStack.push(currentRoot);
|
|
10
10
|
currentRoot = scope;
|
|
11
11
|
const dispose = () => {
|
|
12
|
+
if (scope.scopeDispose) {
|
|
13
|
+
try {
|
|
14
|
+
scope.scopeDispose();
|
|
15
|
+
} catch {
|
|
16
|
+
}
|
|
17
|
+
scope.scopeDispose = null;
|
|
18
|
+
}
|
|
12
19
|
for (const d of scope.disposers) {
|
|
13
20
|
try {
|
|
14
21
|
d();
|
|
@@ -17,14 +24,15 @@ function createRoot(fn) {
|
|
|
17
24
|
}
|
|
18
25
|
scope.disposers.length = 0;
|
|
19
26
|
};
|
|
27
|
+
let result;
|
|
20
28
|
try {
|
|
21
|
-
|
|
29
|
+
scope.scopeDispose = effectScope(() => {
|
|
30
|
+
result = fn(dispose);
|
|
31
|
+
});
|
|
22
32
|
} finally {
|
|
23
33
|
currentRoot = rootStack.pop() ?? null;
|
|
24
|
-
if (rootStack.length === 0) {
|
|
25
|
-
rootStack.length = 0;
|
|
26
|
-
}
|
|
27
34
|
}
|
|
35
|
+
return result;
|
|
28
36
|
}
|
|
29
37
|
function registerDisposer(dispose) {
|
|
30
38
|
if (currentRoot) {
|
|
@@ -237,11 +245,11 @@ function batch(fn) {
|
|
|
237
245
|
}
|
|
238
246
|
}
|
|
239
247
|
function untrack(fn) {
|
|
240
|
-
|
|
248
|
+
const prev = setActiveSub(void 0);
|
|
241
249
|
try {
|
|
242
250
|
return fn();
|
|
243
251
|
} finally {
|
|
244
|
-
|
|
252
|
+
setActiveSub(prev);
|
|
245
253
|
}
|
|
246
254
|
}
|
|
247
255
|
|
|
@@ -293,8 +301,8 @@ function getSuspenseContext() {
|
|
|
293
301
|
// src/reactive/resource.ts
|
|
294
302
|
function createResource(source, fetcher, options) {
|
|
295
303
|
const [data, setData] = createSignal(options?.initialValue);
|
|
296
|
-
const [loading, setLoading] =
|
|
297
|
-
const [error, setError] =
|
|
304
|
+
const [loading, setLoading] = createSignal(false);
|
|
305
|
+
const [error, setError] = createSignal(void 0);
|
|
298
306
|
const suspenseCtx = getSuspenseContext();
|
|
299
307
|
let abortController = null;
|
|
300
308
|
let fetchVersion = 0;
|
|
@@ -806,6 +814,10 @@ function appendChild(parent, child) {
|
|
|
806
814
|
}
|
|
807
815
|
}
|
|
808
816
|
function h(tag, props, ...children) {
|
|
817
|
+
if (typeof tag === "function" && tag !== Fragment) {
|
|
818
|
+
const mergedProps = { ...props ?? {}, children };
|
|
819
|
+
return tag(mergedProps);
|
|
820
|
+
}
|
|
809
821
|
if (tag === Fragment) {
|
|
810
822
|
const frag = document.createDocumentFragment();
|
|
811
823
|
for (const child of children) {
|
|
@@ -948,6 +960,7 @@ function createShow(when, thenFn, elseFn) {
|
|
|
948
960
|
}
|
|
949
961
|
|
|
950
962
|
// src/dom/hydrate.ts
|
|
963
|
+
var ABORT_SYM2 = /* @__PURE__ */ Symbol.for("forma-abort");
|
|
951
964
|
var hydrating = false;
|
|
952
965
|
function setHydrating(value2) {
|
|
953
966
|
hydrating = value2;
|
|
@@ -967,7 +980,12 @@ function applyDynamicProps(el, props) {
|
|
|
967
980
|
const value2 = props[key];
|
|
968
981
|
if (typeof value2 !== "function") continue;
|
|
969
982
|
if (key.charCodeAt(0) === 111 && key.charCodeAt(1) === 110 && key.length > 2) {
|
|
970
|
-
el
|
|
983
|
+
let ac = el[ABORT_SYM2];
|
|
984
|
+
if (!ac) {
|
|
985
|
+
ac = new AbortController();
|
|
986
|
+
el[ABORT_SYM2] = ac;
|
|
987
|
+
}
|
|
988
|
+
el.addEventListener(key.slice(2).toLowerCase(), value2, { signal: ac.signal });
|
|
971
989
|
continue;
|
|
972
990
|
}
|
|
973
991
|
const fn = value2;
|
|
@@ -1469,11 +1487,11 @@ function longestIncreasingSubsequence(arr) {
|
|
|
1469
1487
|
return result;
|
|
1470
1488
|
}
|
|
1471
1489
|
var SMALL_LIST_THRESHOLD = 32;
|
|
1472
|
-
var
|
|
1490
|
+
var ABORT_SYM3 = /* @__PURE__ */ Symbol.for("forma-abort");
|
|
1473
1491
|
var CACHE_SYM2 = /* @__PURE__ */ Symbol.for("forma-attr-cache");
|
|
1474
1492
|
var DYNAMIC_CHILD_SYM2 = /* @__PURE__ */ Symbol.for("forma-dynamic-child");
|
|
1475
1493
|
function canPatchStaticElement(target, source) {
|
|
1476
|
-
return target instanceof HTMLElement && source instanceof HTMLElement && target.tagName === source.tagName && !target[
|
|
1494
|
+
return target instanceof HTMLElement && source instanceof HTMLElement && target.tagName === source.tagName && !target[ABORT_SYM3] && !target[CACHE_SYM2] && !target[DYNAMIC_CHILD_SYM2] && !source[ABORT_SYM3] && !source[CACHE_SYM2] && !source[DYNAMIC_CHILD_SYM2];
|
|
1477
1495
|
}
|
|
1478
1496
|
function patchStaticElement(target, source) {
|
|
1479
1497
|
const sourceAttrNames = /* @__PURE__ */ new Set();
|
|
@@ -1757,7 +1775,7 @@ function createList(items, keyFn, renderFn, options) {
|
|
|
1757
1775
|
if (cached.item === item) return;
|
|
1758
1776
|
cached.item = item;
|
|
1759
1777
|
if (!(node instanceof HTMLElement)) return;
|
|
1760
|
-
if (node[
|
|
1778
|
+
if (node[ABORT_SYM3] || node[CACHE_SYM2] || node[DYNAMIC_CHILD_SYM2]) {
|
|
1761
1779
|
return;
|
|
1762
1780
|
}
|
|
1763
1781
|
const next = untrack(() => renderFn(item, cached.getIndex));
|
|
@@ -1804,6 +1822,6 @@ function createList(items, keyFn, renderFn, options) {
|
|
|
1804
1822
|
return fragment2;
|
|
1805
1823
|
}
|
|
1806
1824
|
|
|
1807
|
-
export { Fragment, __DEV__, batch, cleanup, createEffect, createList, createMemo, createReducer, createRef, createResource, createRoot, createShow, fragment, h, hydrateIsland, internalEffect,
|
|
1808
|
-
//# sourceMappingURL=chunk-
|
|
1809
|
-
//# sourceMappingURL=chunk-
|
|
1825
|
+
export { Fragment, __DEV__, batch, cleanup, createEffect, createList, createMemo, createReducer, createRef, createResource, createRoot, createShow, fragment, h, hydrateIsland, internalEffect, on, onCleanup, onError, popSuspenseContext, pushSuspenseContext, reconcileList, reportError, untrack };
|
|
1826
|
+
//# sourceMappingURL=chunk-N522P3G6.js.map
|
|
1827
|
+
//# sourceMappingURL=chunk-N522P3G6.js.map
|