@getforma/core 1.0.1 → 1.0.3
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 +514 -287
- package/dist/formajs-runtime-hardened.global.js.map +1 -1
- package/dist/formajs-runtime.global.js.map +1 -1
- package/dist/formajs.global.js +0 -4
- package/dist/formajs.global.js.map +1 -1
- package/dist/index.cjs +0 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -4
- package/dist/index.d.ts +1 -4
- package/dist/index.js +1 -4
- package/dist/index.js.map +1 -1
- package/dist/runtime-hardened.cjs.map +1 -1
- package/dist/runtime-hardened.js.map +1 -1
- package/dist/runtime.cjs.map +1 -1
- package/dist/runtime.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,9 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/getforma-dev/formajs/actions/workflows/ci.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/@getforma/core)
|
|
5
|
+
[](https://socket.dev/npm/package/@getforma/core)
|
|
5
6
|
[](https://opensource.org/licenses/MIT)
|
|
6
7
|
|
|
7
|
-
Reactive DOM library with fine-grained signals. No virtual DOM —
|
|
8
|
+
Reactive DOM library with fine-grained signals. No virtual DOM — signals update only the DOM nodes that changed. Components run once. ~15 KB gzipped.
|
|
9
|
+
|
|
10
|
+
```tsx
|
|
11
|
+
import { createSignal, h, mount } from "@getforma/core";
|
|
12
|
+
|
|
13
|
+
const [count, setCount] = createSignal(0);
|
|
14
|
+
|
|
15
|
+
function Counter() {
|
|
16
|
+
return (
|
|
17
|
+
<button onClick={() => setCount((c) => c + 1)}>
|
|
18
|
+
{() => `Clicked ${count()} times`}
|
|
19
|
+
</button>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
mount(() => <Counter />, "#app");
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
No re-renders. No dependency arrays. No `useMemo`. The button text updates because it reads `count()` inside a reactive function — nothing else in the tree is touched.
|
|
27
|
+
|
|
28
|
+
---
|
|
8
29
|
|
|
9
30
|
## Install
|
|
10
31
|
|
|
@@ -12,15 +33,23 @@ Reactive DOM library with fine-grained signals. No virtual DOM — `h()` creates
|
|
|
12
33
|
npm install @getforma/core
|
|
13
34
|
```
|
|
14
35
|
|
|
15
|
-
Or use
|
|
36
|
+
Or use a CDN — no build step, no bundler:
|
|
16
37
|
|
|
17
38
|
```html
|
|
39
|
+
<!-- jsDelivr (recommended) -->
|
|
40
|
+
<script src="https://cdn.jsdelivr.net/npm/@getforma/core@latest/dist/formajs-runtime.global.js"></script>
|
|
41
|
+
|
|
42
|
+
<!-- unpkg -->
|
|
18
43
|
<script src="https://unpkg.com/@getforma/core/dist/formajs-runtime.global.js"></script>
|
|
19
44
|
```
|
|
20
45
|
|
|
21
|
-
|
|
46
|
+
> **Production:** Pin the version (e.g., `@getforma/core@1.0.1`) instead of `@latest`.
|
|
47
|
+
|
|
48
|
+
---
|
|
22
49
|
|
|
23
|
-
|
|
50
|
+
## Getting Started with a Bundler
|
|
51
|
+
|
|
52
|
+
After `npm install`, you need a bundler to resolve ES module imports. Here's a minimal Vite setup:
|
|
24
53
|
|
|
25
54
|
```bash
|
|
26
55
|
npm install @getforma/core
|
|
@@ -33,17 +62,20 @@ npm install -D vite
|
|
|
33
62
|
<script type="module" src="./main.ts"></script>
|
|
34
63
|
```
|
|
35
64
|
|
|
36
|
-
```
|
|
65
|
+
```ts
|
|
37
66
|
// main.ts
|
|
38
|
-
import { createSignal, h, mount } from
|
|
67
|
+
import { createSignal, h, mount } from "@getforma/core";
|
|
39
68
|
|
|
40
69
|
const [count, setCount] = createSignal(0);
|
|
41
70
|
|
|
42
|
-
mount(
|
|
43
|
-
|
|
44
|
-
(
|
|
45
|
-
|
|
46
|
-
|
|
71
|
+
mount(
|
|
72
|
+
() =>
|
|
73
|
+
h(
|
|
74
|
+
"button",
|
|
75
|
+
{ onClick: () => setCount((c) => c + 1) },
|
|
76
|
+
() => `Clicked ${count()} times`,
|
|
77
|
+
),
|
|
78
|
+
"#app",
|
|
47
79
|
);
|
|
48
80
|
```
|
|
49
81
|
|
|
@@ -51,167 +83,333 @@ mount(() =>
|
|
|
51
83
|
npx vite
|
|
52
84
|
```
|
|
53
85
|
|
|
54
|
-
|
|
86
|
+
**Any bundler works.** Vite, esbuild, tsup, webpack, Rollup — FormaJS ships standard ESM and CJS via `package.json` exports. No plugins, no special config.
|
|
55
87
|
|
|
56
|
-
|
|
88
|
+
---
|
|
57
89
|
|
|
58
|
-
|
|
90
|
+
## Coming from React?
|
|
59
91
|
|
|
60
|
-
|
|
92
|
+
If you know React, you already know ~80% of FormaJS. Components are functions. Props flow down. You import, export, and compose the same way. The difference is *how reactivity works* — and it's simpler.
|
|
61
93
|
|
|
62
|
-
-
|
|
63
|
-
- **Fine-grained reactivity.** Powered by [alien-signals](https://github.com/nicolo-ribaudo/alien-signals). When a signal changes, only the specific DOM text node or attribute that depends on it updates — not the whole component tree.
|
|
64
|
-
- **Three entry points, one engine.** HTML Runtime (like Alpine — zero build step), `h()` hyperscript (like Preact), or JSX. All share the same signal graph. Pick the right tool for the job, upgrade without rewriting.
|
|
65
|
-
- **CSP-safe capable.** The HTML Runtime includes a hand-written expression parser. The standard build enables `new Function()` as a fallback for complex expressions; the hardened build (`forma.hardened.js`) locks it off entirely for strict CSP environments.
|
|
66
|
-
- **Islands over SPAs.** `activateIslands()` hydrates independent regions of server-rendered HTML. Each island is self-contained. Ship less JavaScript, keep server-rendered content instant.
|
|
94
|
+
React re-runs your entire component function on every state change, diffs a virtual DOM, and patches the real one. FormaJS runs each component **once**. Signals update only the specific DOM nodes that read them. No reconciliation, no stale closures, no `useCallback`.
|
|
67
95
|
|
|
68
|
-
|
|
96
|
+
| React | FormaJS | What changes |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| `useState` | `createSignal` | Same `[value, setter]` tuple |
|
|
99
|
+
| `useMemo` | `createComputed` | No dependency array — auto-tracks |
|
|
100
|
+
| `useEffect` | `createEffect` | No dependency array — auto-tracks |
|
|
101
|
+
| `useReducer` | `createReducer` | Same dispatch pattern |
|
|
102
|
+
| `useContext` | `createContext` / `inject` | Same provider pattern |
|
|
103
|
+
| `React.memo` | *Not needed* | Components already run once |
|
|
104
|
+
| Component functions | Same | `function Counter(props) { ... }` |
|
|
105
|
+
| Props | Same | `<Counter count={count} />` |
|
|
106
|
+
| Children | Same | Rest params or `props.children` |
|
|
107
|
+
| Import / export | Same | Standard ES modules |
|
|
108
|
+
|
|
109
|
+
**The mental model:** "I write components the same way, pass props the same way, compose the same way — but I never think about re-renders, dependency arrays, or memoization. Signals just work."
|
|
110
|
+
|
|
111
|
+
---
|
|
69
112
|
|
|
70
113
|
## Three Ways to Use FormaJS
|
|
71
114
|
|
|
72
|
-
|
|
115
|
+
All three share the same signal graph and reactive engine. Pick the one that fits your project — or mix them.
|
|
116
|
+
|
|
117
|
+
### 1. JSX
|
|
118
|
+
|
|
119
|
+
The most familiar path for React and Solid developers. JSX compiles to `h()` calls — it's syntactic sugar, not a different system.
|
|
120
|
+
|
|
121
|
+
Configure your bundler (TypeScript or Babel):
|
|
122
|
+
|
|
123
|
+
```jsonc
|
|
124
|
+
// tsconfig.json
|
|
125
|
+
{
|
|
126
|
+
"compilerOptions": {
|
|
127
|
+
"jsx": "react",
|
|
128
|
+
"jsxFactory": "h",
|
|
129
|
+
"jsxFragmentFactory": "Fragment"
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
```tsx
|
|
135
|
+
import { createSignal, h, Fragment, mount } from "@getforma/core";
|
|
136
|
+
|
|
137
|
+
const [count, setCount] = createSignal(0);
|
|
138
|
+
|
|
139
|
+
function Counter() {
|
|
140
|
+
return (
|
|
141
|
+
<>
|
|
142
|
+
<p>{() => `Count: ${count()}`}</p>
|
|
143
|
+
<button onClick={() => setCount((c) => c + 1)}>+1</button>
|
|
144
|
+
</>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
mount(() => <Counter />, "#app");
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Under the hood, the JSX above compiles to the exact `h()` calls shown in the next section. There's no JSX-specific runtime — it's the same function.
|
|
152
|
+
|
|
153
|
+
### 2. Hyperscript — `h()`
|
|
154
|
+
|
|
155
|
+
No JSX transform needed. Same reactive behavior, explicit function calls.
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
import { createSignal, h, mount } from "@getforma/core";
|
|
159
|
+
|
|
160
|
+
const [count, setCount] = createSignal(0);
|
|
161
|
+
|
|
162
|
+
mount(
|
|
163
|
+
() =>
|
|
164
|
+
h(
|
|
165
|
+
"button",
|
|
166
|
+
{ onClick: () => setCount((c) => c + 1) },
|
|
167
|
+
() => `Clicked ${count()} times`,
|
|
168
|
+
),
|
|
169
|
+
"#app",
|
|
170
|
+
);
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
See [The `h()` function](#the-h-function) below for the full signature and all call patterns.
|
|
73
174
|
|
|
74
|
-
|
|
175
|
+
### 3. HTML Runtime (no build step)
|
|
176
|
+
|
|
177
|
+
One `<script>` tag. One HTML file. No npm, no bundler, no `node_modules`, no config files. Just open it in a browser.
|
|
75
178
|
|
|
76
179
|
```html
|
|
77
|
-
<script src="https://
|
|
180
|
+
<script src="https://cdn.jsdelivr.net/npm/@getforma/core@latest/dist/formajs-runtime.global.js"></script>
|
|
78
181
|
|
|
79
|
-
<div data-forma-state='{"count": 0}'>
|
|
182
|
+
<div data-forma-state='{ "count": 0 }'>
|
|
80
183
|
<p data-text="{count}"></p>
|
|
81
184
|
<button data-on:click="{count++}">+1</button>
|
|
82
185
|
<button data-on:click="{count = 0}">Reset</button>
|
|
83
186
|
</div>
|
|
84
187
|
```
|
|
85
188
|
|
|
86
|
-
|
|
189
|
+
That's a working reactive counter. No JavaScript file. No build step. Just HTML.
|
|
190
|
+
|
|
191
|
+
**Here's what you get from a single HTML file with one script tag:**
|
|
192
|
+
|
|
193
|
+
```html
|
|
194
|
+
<script src="https://cdn.jsdelivr.net/npm/@getforma/core@latest/dist/formajs-runtime.global.js"></script>
|
|
195
|
+
|
|
196
|
+
<div data-forma-state='{
|
|
197
|
+
"query": "",
|
|
198
|
+
"items": ["Apples", "Bananas", "Cherries", "Dates", "Elderberries"],
|
|
199
|
+
"darkMode": false
|
|
200
|
+
}'>
|
|
201
|
+
|
|
202
|
+
<!-- Two-way binding: type in the input, the list filters instantly -->
|
|
203
|
+
<input data-model="{query}" placeholder="Search fruits...">
|
|
204
|
+
|
|
205
|
+
<!-- Computed value: derived from query, updates automatically -->
|
|
206
|
+
<p data-computed="matchCount = items.filter(i => i.toLowerCase().includes(query.toLowerCase())).length"
|
|
207
|
+
data-text="{'Found ' + matchCount + ' results'}"></p>
|
|
208
|
+
|
|
209
|
+
<!-- Conditional rendering: show/hide based on state -->
|
|
210
|
+
<p data-show="{query.length > 0 && matchCount === 0}">No matches found.</p>
|
|
211
|
+
|
|
212
|
+
<!-- List rendering: keyed reconciliation, only changed items re-render -->
|
|
213
|
+
<ul data-list="{items.filter(i => i.toLowerCase().includes(query.toLowerCase()))}">
|
|
214
|
+
<li>{item}</li>
|
|
215
|
+
</ul>
|
|
216
|
+
|
|
217
|
+
<!-- Event handling with $dispatch: cross-component communication -->
|
|
218
|
+
<button data-on:click="{darkMode = !darkMode}">
|
|
219
|
+
Toggle Dark Mode
|
|
220
|
+
</button>
|
|
221
|
+
|
|
222
|
+
<!-- Dynamic classes and attributes -->
|
|
223
|
+
<div data-class:dark="{darkMode}" data-bind:data-theme="{darkMode ? 'dark' : 'light'}">
|
|
224
|
+
Theme is: <span data-text="{darkMode ? 'Dark' : 'Light'}"></span>
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
<!-- Persist to localStorage: survives page refresh -->
|
|
228
|
+
<div data-persist="{darkMode}"></div>
|
|
229
|
+
</div>
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
That single HTML file gives you: reactive state, two-way data binding, computed values, conditional rendering, list rendering with filtering, event handling, dynamic CSS classes, dynamic attributes, and localStorage persistence. **No JavaScript written. No build tools installed.**
|
|
233
|
+
|
|
234
|
+
The expression parser is hand-written and CSP-safe — no `eval()`, no `new Function()` by default. For strict CSP environments, use the hardened build:
|
|
235
|
+
|
|
236
|
+
```html
|
|
237
|
+
<script src="https://cdn.jsdelivr.net/npm/@getforma/core@latest/dist/formajs-runtime-hardened.global.js"></script>
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
<details>
|
|
241
|
+
<summary><strong>Full directive reference</strong></summary>
|
|
87
242
|
|
|
88
243
|
| Directive | Description | Example |
|
|
89
|
-
|
|
244
|
+
|---|---|---|
|
|
90
245
|
| `data-forma-state` | Declare reactive state (JSON) | `data-forma-state='{"count": 0}'` |
|
|
91
246
|
| `data-text` | Bind text content | `data-text="{count}"` |
|
|
92
247
|
| `data-show` | Toggle visibility (display) | `data-show="{isOpen}"` |
|
|
93
|
-
| `data-if` | Conditional render (add/remove
|
|
248
|
+
| `data-if` | Conditional render (add/remove DOM) | `data-if="{loggedIn}"` |
|
|
94
249
|
| `data-model` | Two-way binding (inputs) | `data-model="{email}"` |
|
|
95
250
|
| `data-on:event` | Event handler | `data-on:click="{count++}"` |
|
|
96
251
|
| `data-class:name` | Conditional CSS class | `data-class:active="{isActive}"` |
|
|
97
252
|
| `data-bind:attr` | Dynamic attribute | `data-bind:href="{url}"` |
|
|
98
|
-
| `data-list` | List rendering
|
|
253
|
+
| `data-list` | List rendering (keyed reconciliation) | `data-list="{items}"` |
|
|
99
254
|
| `data-computed` | Computed value | `data-computed="doubled = count * 2"` |
|
|
100
255
|
| `data-persist` | Persist state to localStorage | `data-persist="{count}"` |
|
|
101
256
|
| `data-fetch` | Fetch data from URL | `data-fetch="GET /api/items → items"` |
|
|
102
257
|
| `data-transition:*` | Enter/leave CSS transitions | `data-transition:enter="fade-in"` |
|
|
103
258
|
| `data-ref` | Register element for `$refs` access | `data-ref="myInput"` |
|
|
104
|
-
| `$el` |
|
|
105
|
-
| `$dispatch` | Fire
|
|
259
|
+
| `$el` | Current DOM element | `data-on:click="{$el.classList.toggle('active')}"` |
|
|
260
|
+
| `$dispatch` | Fire CustomEvent (bubbles, crosses Shadow DOM) | `data-on:click="{$dispatch('selected', {id})}"` |
|
|
106
261
|
| `$refs` | Named element references | `data-on:click="{$refs.myInput.focus()}"` |
|
|
107
262
|
|
|
108
|
-
|
|
263
|
+
</details>
|
|
109
264
|
|
|
110
|
-
|
|
111
|
-
<script src="https://unpkg.com/@getforma/core/dist/formajs-runtime-hardened.global.js"></script>
|
|
112
|
-
```
|
|
265
|
+
---
|
|
113
266
|
|
|
114
|
-
|
|
267
|
+
## The `h()` Function
|
|
115
268
|
|
|
116
|
-
|
|
117
|
-
|
|
269
|
+
`h()` is the core of FormaJS rendering. Every component — whether written in JSX, hyperscript, or compiled from the HTML Runtime — resolves to `h()` calls that create real DOM elements.
|
|
270
|
+
|
|
271
|
+
### Signature
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
h(tag, props?, ...children)
|
|
118
275
|
```
|
|
119
276
|
|
|
120
|
-
|
|
121
|
-
|
|
277
|
+
| Parameter | Type | Description |
|
|
278
|
+
|---|---|---|
|
|
279
|
+
| `tag` | `string \| Function` | An HTML tag name (`'div'`, `'button'`) or a component function (`Counter`) |
|
|
280
|
+
| `props` | `object \| null` | Attributes, event handlers, and component props. Pass `null` or `{}` to skip. |
|
|
281
|
+
| `children` | `string \| number \| () => string \| Node \| Array` | Zero or more children — static text, reactive functions, elements, or arrays of any of these. |
|
|
122
282
|
|
|
123
|
-
|
|
283
|
+
### The key rule
|
|
124
284
|
|
|
125
|
-
|
|
126
|
-
h('button', { onClick: () => setCount(count() + 1) },
|
|
127
|
-
() => `Clicked ${count()} times`
|
|
128
|
-
),
|
|
129
|
-
'#app'
|
|
130
|
-
);
|
|
131
|
-
```
|
|
285
|
+
**If a child is a function, it's reactive.** FormaJS wraps it in an effect so the DOM text node or subtree updates automatically when signals inside it change. If a child is a plain string or number, it's static — rendered once, never touched again.
|
|
132
286
|
|
|
133
|
-
###
|
|
287
|
+
### Patterns
|
|
134
288
|
|
|
135
|
-
|
|
289
|
+
```ts
|
|
290
|
+
// Static text child
|
|
291
|
+
h("footer", { class: "text-sm" }, "Built with Forma")
|
|
136
292
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
{
|
|
140
|
-
"compilerOptions": {
|
|
141
|
-
"jsx": "react",
|
|
142
|
-
"jsxFactory": "h",
|
|
143
|
-
"jsxFragmentFactory": "Fragment"
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
```
|
|
293
|
+
// Reactive text child — updates when count() changes
|
|
294
|
+
h("button", { onClick: fn }, () => `Count: ${count()}`)
|
|
147
295
|
|
|
148
|
-
|
|
149
|
-
|
|
296
|
+
// Multiple children
|
|
297
|
+
h("div", { class: "card" },
|
|
298
|
+
h("h2", null, "Title"),
|
|
299
|
+
h("p", null, "Body text"),
|
|
300
|
+
h("button", { onClick: fn }, "Click"),
|
|
301
|
+
)
|
|
150
302
|
|
|
151
|
-
|
|
303
|
+
// Children as an array (useful for dynamic lists)
|
|
304
|
+
h("ul", null, items.map(item => h("li", null, item.name)))
|
|
152
305
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
<>
|
|
156
|
-
<p>{() => `Count: ${count()}`}</p>
|
|
157
|
-
<button onClick={() => setCount(count() + 1)}>+1</button>
|
|
158
|
-
</>
|
|
159
|
-
);
|
|
160
|
-
}
|
|
306
|
+
// No props, just children
|
|
307
|
+
h("p", null, "Hello world")
|
|
161
308
|
|
|
162
|
-
|
|
309
|
+
// Component function with props
|
|
310
|
+
h(Counter, { initial: 5 })
|
|
311
|
+
|
|
312
|
+
// Nested reactive children
|
|
313
|
+
h("div", null,
|
|
314
|
+
() => showHeader() ? h("h1", null, "Welcome") : null,
|
|
315
|
+
h("p", null, () => `You have ${count()} items`),
|
|
316
|
+
)
|
|
163
317
|
```
|
|
164
318
|
|
|
165
|
-
|
|
319
|
+
### JSX equivalence
|
|
166
320
|
|
|
167
|
-
|
|
321
|
+
JSX is syntactic sugar that compiles to `h()` calls. These are identical:
|
|
168
322
|
|
|
169
|
-
```
|
|
170
|
-
|
|
171
|
-
<
|
|
323
|
+
```tsx
|
|
324
|
+
// JSX
|
|
325
|
+
<button class="btn" onClick={() => setCount((c) => c + 1)}>
|
|
326
|
+
{() => `Count: ${count()}`}
|
|
327
|
+
</button>
|
|
172
328
|
|
|
173
|
-
|
|
174
|
-
|
|
329
|
+
// h()
|
|
330
|
+
h("button", { class: "btn", onClick: () => setCount((c) => c + 1) },
|
|
331
|
+
() => `Count: ${count()}`
|
|
332
|
+
)
|
|
175
333
|
```
|
|
176
334
|
|
|
177
|
-
|
|
335
|
+
---
|
|
178
336
|
|
|
179
|
-
|
|
180
|
-
<script type="module">
|
|
181
|
-
import { createSignal, h, mount } from 'https://cdn.jsdelivr.net/npm/@getforma/core@1.0.0/dist/index.js';
|
|
337
|
+
## Why FormaJS?
|
|
182
338
|
|
|
183
|
-
|
|
184
|
-
mount(() => h('button', { onClick: () => setCount(count() + 1) }, () => `${count()}`), '#app');
|
|
185
|
-
</script>
|
|
186
|
-
```
|
|
339
|
+
Most UI libraries force a choice: simple but limited (Alpine, htmx), or powerful but heavy (React, Vue, Svelte). FormaJS gives you a single reactive core that scales from a CDN script tag to a compiled Rust SSR pipeline.
|
|
187
340
|
|
|
188
|
-
|
|
341
|
+
**Components run once.** No virtual DOM, no diffing, no reconciliation overhead. `h('div')` returns an actual `HTMLDivElement`. When a signal changes, only the specific text node or attribute that reads it updates — not the component, not the tree.
|
|
189
342
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
343
|
+
**Fine-grained reactivity.** Powered by [alien-signals](https://github.com/nicolo-ribaudo/alien-signals) 3.x. The signal graph tracks dependencies automatically. No dependency arrays, no stale closures, no `useCallback` / `useMemo` ceremony.
|
|
344
|
+
|
|
345
|
+
**Three entry points, one engine.** HTML Runtime (like Alpine — zero build step), `h()` hyperscript (like Preact), or JSX (like React/Solid). All share the same signal graph. Start with a CDN script tag, graduate to a full build pipeline without rewriting.
|
|
346
|
+
|
|
347
|
+
**Islands over SPAs.** `activateIslands()` hydrates independent regions of server-rendered HTML. Each island is self-contained with error isolation, deferred hydration triggers (`visible`, `idle`, `interaction`), and disposal for module swaps.
|
|
348
|
+
|
|
349
|
+
**CSP-safe.** The HTML Runtime includes a hand-written expression parser — no `eval()`, no `new Function()`. The hardened build locks it off entirely, with zero `new Function` in the dist (verified by dead code elimination).
|
|
350
|
+
|
|
351
|
+
**What FormaJS is not:** It's not a framework with opinions about routing, data fetching, or state management. It's a reactive DOM library. You bring the architecture.
|
|
352
|
+
|
|
353
|
+
---
|
|
196
354
|
|
|
197
|
-
|
|
355
|
+
## The Rust Compiler (Optional)
|
|
198
356
|
|
|
199
|
-
|
|
357
|
+
Everything above works without the Rust compiler. You can build a complete application with just `npm install @getforma/core` and a bundler. The compiler is an **optimization layer** — you add it when performance and deployment constraints demand it.
|
|
200
358
|
|
|
201
|
-
|
|
202
|
-
|
|
359
|
+
### What the compiler does
|
|
360
|
+
|
|
361
|
+
| Without compiler | With compiler |
|
|
362
|
+
|---|---|
|
|
363
|
+
| `h()` calls create DOM elements at runtime | `h()` calls are pre-compiled to `template()` + `cloneNode()` for faster initial render |
|
|
364
|
+
| SSR requires Node.js (`renderToString`) | SSR runs natively in Rust via the FMIR binary walker — no JS runtime on the server |
|
|
365
|
+
| Standard JS bundle shipped to the client | Components compile to FMIR (Forma Module IR), a compact binary format sent over the wire |
|
|
366
|
+
| Islands hydrate from HTML + JS | Islands hydrate from FMIR binary — smaller payload, faster parse |
|
|
367
|
+
|
|
368
|
+
### When to add it
|
|
369
|
+
|
|
370
|
+
You don't need the compiler to get started, prototype, or even ship to production. Add it when:
|
|
371
|
+
|
|
372
|
+
- **SSR without Node.js** — your backend is Rust/Axum and you don't want a Node.js sidecar just for rendering.
|
|
373
|
+
- **Faster initial render** — pre-compiled templates skip the `h()` → `createElement` path and go straight to `cloneNode()`.
|
|
374
|
+
- **Smaller payloads** — FMIR binary is more compact than the equivalent JavaScript for complex component trees.
|
|
375
|
+
- **The full Forma stack** — `@getforma/compiler` → FMIR → `forma-ir` (Rust parser) → `forma-server` (Axum SSR) gives you a complete pipeline at ~$18/month deployment cost.
|
|
376
|
+
|
|
377
|
+
### Architecture
|
|
378
|
+
|
|
379
|
+
```
|
|
380
|
+
TypeScript/JSX components
|
|
381
|
+
↓
|
|
382
|
+
@getforma/compiler (TS → FMIR binary)
|
|
383
|
+
↓
|
|
384
|
+
forma-ir (Rust: parse + walk FMIR)
|
|
385
|
+
↓
|
|
386
|
+
forma-server (Rust/Axum: SSR + asset serving + CSP)
|
|
387
|
+
↓
|
|
388
|
+
HTML response (server-rendered, islands hydrate on client)
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
All entry points — JSX, `h()`, and the HTML Runtime — work both with and without the compiler:
|
|
392
|
+
|
|
393
|
+
| | Without Compiler | With Compiler |
|
|
394
|
+
|---|---|---|
|
|
395
|
+
| HTML Runtime | `data-*` directives | + SSR from IR walker |
|
|
396
|
+
| `h()` hyperscript | `createSignal` + `h()` | + compiled templates |
|
|
397
|
+
| JSX | `createSignal` + JSX | + compiled templates + SSR |
|
|
398
|
+
| Islands | `activateIslands()` | + FMIR hydration |
|
|
399
|
+
|
|
400
|
+
---
|
|
203
401
|
|
|
204
402
|
## Core API
|
|
205
403
|
|
|
206
404
|
### Signals
|
|
207
405
|
|
|
208
|
-
```
|
|
209
|
-
import { createSignal, createEffect, createComputed, batch } from
|
|
406
|
+
```ts
|
|
407
|
+
import { createSignal, createEffect, createComputed, batch } from "@getforma/core";
|
|
210
408
|
|
|
211
409
|
const [count, setCount] = createSignal(0);
|
|
212
410
|
const doubled = createComputed(() => count() * 2);
|
|
213
411
|
|
|
214
|
-
createEffect(() => console.log(
|
|
412
|
+
createEffect(() => console.log("count:", count()));
|
|
215
413
|
|
|
216
414
|
batch(() => {
|
|
217
415
|
setCount(1);
|
|
@@ -219,25 +417,21 @@ batch(() => {
|
|
|
219
417
|
});
|
|
220
418
|
```
|
|
221
419
|
|
|
222
|
-
|
|
420
|
+
**Custom equality** — skip updates when the value hasn't meaningfully changed:
|
|
223
421
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
```typescript
|
|
422
|
+
```ts
|
|
227
423
|
const [pos, setPos] = createSignal(
|
|
228
424
|
{ x: 0, y: 0 },
|
|
229
425
|
{ equals: (a, b) => a.x === b.x && a.y === b.y },
|
|
230
426
|
);
|
|
231
427
|
|
|
232
|
-
setPos({ x: 0, y: 0 }); // skipped —
|
|
233
|
-
setPos({ x: 1, y: 0 }); // applied —
|
|
428
|
+
setPos({ x: 0, y: 0 }); // skipped — equal
|
|
429
|
+
setPos({ x: 1, y: 0 }); // applied — different
|
|
234
430
|
```
|
|
235
431
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
The computed getter receives the previous value for efficient diffing:
|
|
432
|
+
**Computed with previous value** — the getter receives the previous result:
|
|
239
433
|
|
|
240
|
-
```
|
|
434
|
+
```ts
|
|
241
435
|
const changes = createComputed((prev) => {
|
|
242
436
|
const current = items();
|
|
243
437
|
if (prev) console.log(`${prev.length} → ${current.length} items`);
|
|
@@ -245,218 +439,217 @@ const changes = createComputed((prev) => {
|
|
|
245
439
|
});
|
|
246
440
|
```
|
|
247
441
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
Type guards and utilities from alien-signals 3.x:
|
|
442
|
+
**Reactive introspection** — type guards and utilities from alien-signals 3.x:
|
|
251
443
|
|
|
252
|
-
```
|
|
253
|
-
import { isSignal, isComputed, getBatchDepth, trigger } from
|
|
444
|
+
```ts
|
|
445
|
+
import { isSignal, isComputed, getBatchDepth, trigger } from "@getforma/core";
|
|
254
446
|
|
|
255
|
-
isSignal(count);
|
|
256
|
-
isComputed(doubled);
|
|
257
|
-
getBatchDepth();
|
|
258
|
-
trigger(doubled);
|
|
447
|
+
isSignal(count); // true
|
|
448
|
+
isComputed(doubled); // true
|
|
449
|
+
getBatchDepth(); // 0 outside batch, 1+ inside
|
|
450
|
+
trigger(doubled); // force recomputation
|
|
259
451
|
```
|
|
260
452
|
|
|
261
453
|
### Conditional Rendering
|
|
262
454
|
|
|
263
|
-
```
|
|
264
|
-
import { createSignal, createShow, createSwitch, h } from
|
|
455
|
+
```ts
|
|
456
|
+
import { createSignal, createShow, createSwitch, h } from "@getforma/core";
|
|
265
457
|
|
|
266
458
|
const [loggedIn, setLoggedIn] = createSignal(false);
|
|
267
459
|
|
|
268
|
-
//
|
|
269
|
-
createShow(
|
|
270
|
-
|
|
271
|
-
() => h(
|
|
460
|
+
// Two branches
|
|
461
|
+
createShow(
|
|
462
|
+
loggedIn,
|
|
463
|
+
() => h("p", null, "Welcome back"),
|
|
464
|
+
() => h("p", null, "Please sign in"),
|
|
272
465
|
);
|
|
273
466
|
|
|
274
|
-
//
|
|
275
|
-
const [view, setView] = createSignal(
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
467
|
+
// Multi-branch with caching
|
|
468
|
+
const [view, setView] = createSignal("home");
|
|
469
|
+
|
|
470
|
+
createSwitch(
|
|
471
|
+
view,
|
|
472
|
+
[
|
|
473
|
+
{ match: "home", render: () => h("div", null, "Home") },
|
|
474
|
+
{ match: "settings", render: () => h("div", null, "Settings") },
|
|
475
|
+
],
|
|
476
|
+
() => h("div", null, "404 Not Found"),
|
|
477
|
+
);
|
|
280
478
|
```
|
|
281
479
|
|
|
282
480
|
### List Rendering
|
|
283
481
|
|
|
284
|
-
```
|
|
285
|
-
import { createSignal, createList, h } from
|
|
482
|
+
```ts
|
|
483
|
+
import { createSignal, createList, h } from "@getforma/core";
|
|
286
484
|
|
|
287
485
|
const [items, setItems] = createSignal([
|
|
288
|
-
{ id: 1, name:
|
|
289
|
-
{ id: 2, name:
|
|
486
|
+
{ id: 1, name: "Alice" },
|
|
487
|
+
{ id: 2, name: "Bob" },
|
|
290
488
|
]);
|
|
291
489
|
|
|
292
490
|
createList(
|
|
293
491
|
items,
|
|
294
|
-
(item) => item.id,
|
|
295
|
-
(item) => h(
|
|
492
|
+
(item) => item.id,
|
|
493
|
+
(item) => h("li", null, item.name),
|
|
296
494
|
);
|
|
297
495
|
```
|
|
298
496
|
|
|
299
|
-
### Store (
|
|
497
|
+
### Store (Deep Reactivity)
|
|
300
498
|
|
|
301
|
-
```
|
|
302
|
-
import { createStore } from
|
|
499
|
+
```ts
|
|
500
|
+
import { createStore } from "@getforma/core";
|
|
303
501
|
|
|
304
502
|
const [state, setState] = createStore({
|
|
305
|
-
user: { name:
|
|
503
|
+
user: { name: "Alice", prefs: { theme: "dark" } },
|
|
306
504
|
items: [1, 2, 3],
|
|
307
505
|
});
|
|
308
506
|
|
|
309
507
|
// Read reactively — tracked at the exact property path
|
|
310
|
-
state.user.name;
|
|
311
|
-
state.items[0];
|
|
508
|
+
state.user.name; // "Alice"
|
|
509
|
+
state.items[0]; // 1
|
|
312
510
|
|
|
313
|
-
// Setter API — partial
|
|
314
|
-
setState({ user: { ...state.user, name:
|
|
315
|
-
setState(prev => ({ items: [...prev.items, 4] }));
|
|
511
|
+
// Setter API — partial merge
|
|
512
|
+
setState({ user: { ...state.user, name: "Bob" } });
|
|
513
|
+
setState((prev) => ({ items: [...prev.items, 4] }));
|
|
316
514
|
|
|
317
|
-
// Or mutate directly
|
|
318
|
-
state.user.name =
|
|
319
|
-
state.items.push(4);
|
|
515
|
+
// Or mutate directly — only affected subscribers update
|
|
516
|
+
state.user.name = "Bob";
|
|
517
|
+
state.items.push(4);
|
|
320
518
|
```
|
|
321
519
|
|
|
322
|
-
|
|
520
|
+
> **Note:** `Object.keys(state)`, `for...in`, and spread (`{...state}`) are NOT reactive. Use signals or explicit arrays for collections that need to react to membership changes.
|
|
323
521
|
|
|
324
|
-
|
|
325
|
-
|
|
522
|
+
### Components & Lifecycle
|
|
523
|
+
|
|
524
|
+
```ts
|
|
525
|
+
import { createSignal, defineComponent, onMount, onUnmount, h } from "@getforma/core";
|
|
326
526
|
|
|
327
527
|
const Timer = defineComponent(() => {
|
|
328
528
|
const [seconds, setSeconds] = createSignal(0);
|
|
329
529
|
|
|
330
530
|
onMount(() => {
|
|
331
|
-
const id = setInterval(() => setSeconds(s => s + 1), 1000);
|
|
531
|
+
const id = setInterval(() => setSeconds((s) => s + 1), 1000);
|
|
332
532
|
return () => clearInterval(id); // cleanup on unmount
|
|
333
533
|
});
|
|
334
534
|
|
|
335
|
-
return h(
|
|
535
|
+
return h("span", null, () => `${seconds()}s`);
|
|
336
536
|
});
|
|
337
537
|
|
|
338
538
|
document.body.appendChild(Timer());
|
|
339
539
|
```
|
|
340
540
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
- **`onMount(fn)`** — runs after the component's DOM is created. If `fn` returns a function, that function is automatically registered as an unmount callback.
|
|
344
|
-
- **`onUnmount(fn)`** — explicitly registers a cleanup function that runs when the component is disposed.
|
|
541
|
+
`onMount(fn)` runs after DOM creation. If `fn` returns a function, it registers as an unmount callback. `onUnmount(fn)` explicitly registers cleanup. Both feed the same cleanup queue:
|
|
345
542
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
```typescript
|
|
543
|
+
```ts
|
|
349
544
|
// These are equivalent:
|
|
350
545
|
onMount(() => {
|
|
351
546
|
const id = setInterval(tick, 1000);
|
|
352
547
|
return () => clearInterval(id);
|
|
353
548
|
});
|
|
354
549
|
|
|
355
|
-
// vs.
|
|
356
550
|
onMount(() => {
|
|
357
551
|
const id = setInterval(tick, 1000);
|
|
358
552
|
onUnmount(() => clearInterval(id));
|
|
359
553
|
});
|
|
360
554
|
```
|
|
361
555
|
|
|
362
|
-
###
|
|
363
|
-
|
|
364
|
-
**`mount()` fails fast.** If the container selector doesn't match any element, it throws:
|
|
365
|
-
|
|
366
|
-
```typescript
|
|
367
|
-
mount(() => h('p', null, 'hello'), '#nonexistent');
|
|
368
|
-
// Error: mount: container not found — "#nonexistent"
|
|
369
|
-
```
|
|
556
|
+
### Context (Dependency Injection)
|
|
370
557
|
|
|
371
|
-
|
|
558
|
+
```ts
|
|
559
|
+
import { createContext, provide, inject } from "@getforma/core";
|
|
372
560
|
|
|
373
|
-
|
|
374
|
-
import { onError } from '@getforma/core';
|
|
561
|
+
const ThemeCtx = createContext("light");
|
|
375
562
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
});
|
|
563
|
+
provide(ThemeCtx, "dark");
|
|
564
|
+
const theme = inject(ThemeCtx); // "dark"
|
|
379
565
|
```
|
|
380
566
|
|
|
381
|
-
|
|
567
|
+
### Reducer
|
|
382
568
|
|
|
383
|
-
```
|
|
384
|
-
import {
|
|
569
|
+
```ts
|
|
570
|
+
import { createReducer } from "@getforma/core";
|
|
385
571
|
|
|
386
|
-
|
|
387
|
-
() =>
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
572
|
+
const [state, dispatch] = createReducer(
|
|
573
|
+
(state, action) => {
|
|
574
|
+
switch (action.type) {
|
|
575
|
+
case "INCREMENT": return { count: state.count + 1 };
|
|
576
|
+
case "DECREMENT": return { count: state.count - 1 };
|
|
577
|
+
default: return state;
|
|
578
|
+
}
|
|
579
|
+
},
|
|
580
|
+
{ count: 0 },
|
|
392
581
|
);
|
|
582
|
+
|
|
583
|
+
dispatch({ type: "INCREMENT" }); // state() === { count: 1 }
|
|
393
584
|
```
|
|
394
585
|
|
|
395
|
-
###
|
|
586
|
+
### History (Undo / Redo)
|
|
587
|
+
|
|
588
|
+
```ts
|
|
589
|
+
import { createHistory } from "@getforma/core";
|
|
396
590
|
|
|
397
|
-
|
|
398
|
-
import { createContext, provide, inject } from '@getforma/core';
|
|
591
|
+
const [state, setState, { undo, redo, canUndo, canRedo }] = createHistory({ text: "" });
|
|
399
592
|
|
|
400
|
-
|
|
593
|
+
setState({ text: "hello" });
|
|
594
|
+
setState({ text: "hello world" });
|
|
401
595
|
|
|
402
|
-
|
|
403
|
-
|
|
596
|
+
undo(); // state.text === "hello"
|
|
597
|
+
canUndo(); // true
|
|
598
|
+
redo(); // state.text === "hello world"
|
|
404
599
|
```
|
|
405
600
|
|
|
406
|
-
###
|
|
601
|
+
### Error Handling
|
|
407
602
|
|
|
408
|
-
|
|
409
|
-
import { createHistory } from '@getforma/core';
|
|
603
|
+
`mount()` fails fast — if the selector doesn't match, it throws:
|
|
410
604
|
|
|
411
|
-
|
|
605
|
+
```ts
|
|
606
|
+
mount(() => h("p", null, "hello"), "#nonexistent");
|
|
607
|
+
// Error: mount: container not found — "#nonexistent"
|
|
608
|
+
```
|
|
412
609
|
|
|
413
|
-
|
|
414
|
-
setState({ text: 'hello world' });
|
|
610
|
+
**Global error handler** for effects and lifecycle callbacks:
|
|
415
611
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
612
|
+
```ts
|
|
613
|
+
import { onError } from "@getforma/core";
|
|
614
|
+
|
|
615
|
+
onError((error, info) => {
|
|
616
|
+
console.error(`[${info?.source}]`, error);
|
|
617
|
+
});
|
|
419
618
|
```
|
|
420
619
|
|
|
421
|
-
|
|
620
|
+
**Error boundaries** — catch rendering errors with fallback UI:
|
|
422
621
|
|
|
423
|
-
```
|
|
424
|
-
import {
|
|
622
|
+
```ts
|
|
623
|
+
import { createErrorBoundary, h } from "@getforma/core";
|
|
425
624
|
|
|
426
|
-
|
|
427
|
-
(
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
},
|
|
434
|
-
{ count: 0 },
|
|
625
|
+
createErrorBoundary(
|
|
626
|
+
() => h(UnstableComponent),
|
|
627
|
+
(error, retry) =>
|
|
628
|
+
h("div", null,
|
|
629
|
+
h("p", null, `Something went wrong: ${error.message}`),
|
|
630
|
+
h("button", { onClick: retry }, "Retry"),
|
|
631
|
+
),
|
|
435
632
|
);
|
|
436
|
-
|
|
437
|
-
dispatch({ type: 'INCREMENT' }); // state() === { count: 1 }
|
|
438
633
|
```
|
|
439
634
|
|
|
635
|
+
---
|
|
636
|
+
|
|
440
637
|
## Islands Architecture
|
|
441
638
|
|
|
442
|
-
|
|
639
|
+
Hydrate independent interactive regions of server-rendered HTML. Each island callback receives the root element and parsed props, then returns a component tree. The hydration system walks the tree against existing SSR DOM, attaching handlers and reactive bindings without recreating elements.
|
|
443
640
|
|
|
444
|
-
```
|
|
445
|
-
import { activateIslands, createSignal, h } from
|
|
641
|
+
```ts
|
|
642
|
+
import { activateIslands, createSignal, h } from "@getforma/core";
|
|
446
643
|
|
|
447
644
|
activateIslands({
|
|
448
645
|
Counter: (el, props) => {
|
|
449
646
|
const [count, setCount] = createSignal(props?.initial ?? 0);
|
|
450
647
|
|
|
451
|
-
|
|
452
|
-
// focus management, CSS classes, or reading extra data-* attributes.
|
|
453
|
-
el.classList.add('is-hydrated');
|
|
648
|
+
el.classList.add("is-hydrated");
|
|
454
649
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
h('span', null, () => String(count())),
|
|
459
|
-
h('button', { onClick: () => setCount(c => c + 1) }, '+1'),
|
|
650
|
+
return h("div", null,
|
|
651
|
+
h("span", null, () => String(count())),
|
|
652
|
+
h("button", { onClick: () => setCount((c) => c + 1) }, "+1"),
|
|
460
653
|
);
|
|
461
654
|
},
|
|
462
655
|
});
|
|
@@ -470,135 +663,169 @@ activateIslands({
|
|
|
470
663
|
</div>
|
|
471
664
|
```
|
|
472
665
|
|
|
473
|
-
Each island
|
|
666
|
+
Each island runs in its own `createRoot` scope with error isolation — a broken island never takes down its siblings.
|
|
474
667
|
|
|
475
668
|
### Hydration Triggers
|
|
476
669
|
|
|
477
670
|
Control when an island hydrates via `data-forma-hydrate`:
|
|
478
671
|
|
|
479
672
|
| Trigger | When it hydrates | Use case |
|
|
480
|
-
|
|
481
|
-
| `load` (default) | Immediately on page load | Above-the-fold
|
|
673
|
+
|---|---|---|
|
|
674
|
+
| `load` (default) | Immediately on page load | Above-the-fold content |
|
|
482
675
|
| `visible` | When island enters viewport | Below-the-fold components |
|
|
483
|
-
| `idle` | During browser idle time
|
|
484
|
-
| `interaction` | On first `pointerdown` or `focusin` | Skeleton+skin pattern |
|
|
676
|
+
| `idle` | During browser idle time | Non-critical functionality |
|
|
677
|
+
| `interaction` | On first `pointerdown` or `focusin` | Skeleton + skin pattern |
|
|
485
678
|
|
|
486
679
|
```html
|
|
487
680
|
<div data-forma-island="1" data-forma-component="Comments" data-forma-hydrate="visible">
|
|
488
|
-
<!--
|
|
681
|
+
<!-- JS loads only when scrolled into view -->
|
|
489
682
|
</div>
|
|
490
683
|
```
|
|
491
684
|
|
|
492
685
|
### Island Disposal
|
|
493
686
|
|
|
494
|
-
When swapping
|
|
687
|
+
When swapping content (e.g., inside `<forma-stage>` Shadow DOM), dispose islands to prevent leaked effects:
|
|
495
688
|
|
|
496
|
-
```
|
|
497
|
-
import { deactivateIsland, deactivateAllIslands } from
|
|
689
|
+
```ts
|
|
690
|
+
import { deactivateIsland, deactivateAllIslands } from "@getforma/core";
|
|
498
691
|
|
|
499
|
-
// Dispose all active islands under a root
|
|
500
692
|
deactivateAllIslands(shadowRoot);
|
|
501
|
-
|
|
502
|
-
// Or dispose a single island
|
|
503
693
|
deactivateIsland(islandElement);
|
|
504
694
|
```
|
|
505
695
|
|
|
696
|
+
---
|
|
697
|
+
|
|
698
|
+
## CDN Builds
|
|
699
|
+
|
|
700
|
+
### Script tag (IIFE — auto-initializes)
|
|
701
|
+
|
|
702
|
+
```html
|
|
703
|
+
<!-- jsDelivr (recommended) -->
|
|
704
|
+
<script src="https://cdn.jsdelivr.net/npm/@getforma/core@1.0.1/dist/formajs-runtime.global.js"></script>
|
|
705
|
+
|
|
706
|
+
<!-- unpkg -->
|
|
707
|
+
<script src="https://unpkg.com/@getforma/core@1.0.1/dist/formajs-runtime.global.js"></script>
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
### ESM import (modern browsers, no bundler)
|
|
711
|
+
|
|
712
|
+
```html
|
|
713
|
+
<script type="module">
|
|
714
|
+
import { createSignal, h, mount } from "https://cdn.jsdelivr.net/npm/@getforma/core@1.0.1/dist/index.js";
|
|
715
|
+
|
|
716
|
+
const [count, setCount] = createSignal(0);
|
|
717
|
+
mount(() => h("button", { onClick: () => setCount((c) => c + 1) }, () => `${count()}`), "#app");
|
|
718
|
+
</script>
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
### All builds
|
|
722
|
+
|
|
723
|
+
| Build | Filename |
|
|
724
|
+
|---|---|
|
|
725
|
+
| Standard (recommended) | `formajs-runtime.global.js` |
|
|
726
|
+
| CSP-safe (no `new Function`) | `formajs-runtime-hardened.global.js` |
|
|
727
|
+
| Standard (short alias) | `forma-runtime.js` |
|
|
728
|
+
| CSP-safe (short alias) | `forma-runtime-csp.js` |
|
|
729
|
+
|
|
730
|
+
Available from `unpkg.com/@getforma/core@VERSION/dist/` and `cdn.jsdelivr.net/npm/@getforma/core@VERSION/dist/`.
|
|
731
|
+
|
|
732
|
+
---
|
|
733
|
+
|
|
506
734
|
## Subpath Exports
|
|
507
735
|
|
|
508
|
-
The main entry point (`@getforma/core`) has **zero network code** — no
|
|
736
|
+
The main entry point (`@getforma/core`) has **zero network code** — no fetch, no WebSocket, no `process.env`. Network-capable modules are separate imports:
|
|
509
737
|
|
|
510
738
|
| Import | Description |
|
|
511
|
-
|
|
512
|
-
| `@getforma/core` | Signals, `h()`,
|
|
739
|
+
|---|---|
|
|
740
|
+
| `@getforma/core` | Signals, `h()`, mount, lists, stores, components, islands, events, DOM utils |
|
|
513
741
|
| `@getforma/core/http` | `createFetch`, `fetchJSON`, `createSSE`, `createWebSocket` |
|
|
514
742
|
| `@getforma/core/storage` | `createLocalStorage`, `createSessionStorage`, `createIndexedDB` |
|
|
515
743
|
| `@getforma/core/server` | `createAction`, `$$serverFunction`, `handleRPC`, `createRPCMiddleware` |
|
|
516
744
|
| `@getforma/core/runtime` | HTML Runtime — `initRuntime()`, `mount()`, `unmount()` |
|
|
517
|
-
| `@getforma/core/runtime/global` | HTML Runtime global build (IIFE, for `<script>` tags) |
|
|
518
745
|
| `@getforma/core/runtime-hardened` | Runtime with `new Function()` locked off (strict CSP) |
|
|
519
|
-
| `@getforma/core/runtime-csp` | Alias for `runtime-hardened` (CSP-safe build) |
|
|
520
|
-
| `@getforma/core/runtime-csp/global` | CSP-safe global build (IIFE, for `<script>` tags) |
|
|
521
746
|
| `@getforma/core/ssr` | Server-side rendering — `renderToString()`, `renderToStream()` |
|
|
522
747
|
| `@getforma/core/tc39` | TC39-compatible `Signal.State` and `Signal.Computed` classes |
|
|
523
748
|
|
|
524
|
-
```
|
|
525
|
-
// Core
|
|
526
|
-
import { createSignal, h, mount, createStore } from
|
|
749
|
+
```ts
|
|
750
|
+
// Core — zero network code
|
|
751
|
+
import { createSignal, h, mount, createStore } from "@getforma/core";
|
|
527
752
|
|
|
528
|
-
// HTTP
|
|
529
|
-
import { createFetch, createSSE } from
|
|
753
|
+
// HTTP — only when needed
|
|
754
|
+
import { createFetch, createSSE } from "@getforma/core/http";
|
|
530
755
|
|
|
531
|
-
// Storage
|
|
532
|
-
import { createLocalStorage } from
|
|
756
|
+
// Storage — only when needed
|
|
757
|
+
import { createLocalStorage } from "@getforma/core/storage";
|
|
533
758
|
|
|
534
|
-
// Server
|
|
535
|
-
import { createAction, $$serverFunction } from
|
|
759
|
+
// Server — only when needed
|
|
760
|
+
import { createAction, $$serverFunction } from "@getforma/core/server";
|
|
536
761
|
```
|
|
537
762
|
|
|
538
|
-
|
|
763
|
+
---
|
|
539
764
|
|
|
540
|
-
|
|
765
|
+
## How Is This Different from Solid?
|
|
541
766
|
|
|
542
|
-
|
|
543
|
-
- **counter-jsx** — same counter with JSX syntax
|
|
544
|
-
- **csp** — CSP-safe runtime with strict Content-Security-Policy meta tag
|
|
545
|
-
- **todo** — todo list with `createList` and keyed reconciliation
|
|
546
|
-
- **data-table** — sortable table with `createList`
|
|
767
|
+
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.
|
|
547
768
|
|
|
548
|
-
|
|
769
|
+
The differences are in scope and delivery: FormaJS adds built-in islands hydration without a meta-framework, CSP compliance without a build step, three entry points (CDN, hyperscript, JSX) sharing one signal graph, and a Rust SSR path that eliminates Node.js from the server. Solid gives you a mature JavaScript ecosystem with routing, a meta-framework (SolidStart), devtools, and community component libraries.
|
|
549
770
|
|
|
550
|
-
FormaJS
|
|
771
|
+
**Choose FormaJS** when you want islands baked in, CSP safety out of the box, a Rust backend without a Node.js sidecar, or a CDN-first starting point that scales to a full compiled pipeline.
|
|
551
772
|
|
|
552
|
-
|
|
553
|
-
|-|-------|---------|
|
|
554
|
-
| **Build requirement** | Always needs a compiler (JSX transform) | CDN runtime works with zero build step; bundler is optional |
|
|
555
|
-
| **Entry points** | JSX-first | HTML Runtime (`data-*` attributes), `h()` hyperscript, or JSX |
|
|
556
|
-
| **CSP** | Relies on compiler output | Hand-written expression parser; hardened build has no `new Function()` |
|
|
557
|
-
| **Islands** | Via [solid-start](https://start.solidjs.com/) meta-framework | Built-in `activateIslands()` — no meta-framework needed |
|
|
558
|
-
| **Ecosystem** | Mature (router, meta-framework, devtools) | Minimal — reactive core only, you bring the architecture |
|
|
559
|
-
| **SSR runtime** | Node.js required | Node.js via `renderToString`, or Rust walker (no JS runtime on the server) |
|
|
560
|
-
| **Size** | ~7KB | ~15KB (includes runtime parser, stores, SSR) |
|
|
773
|
+
**Choose Solid** when you want a mature JS ecosystem, SolidStart for full-stack JS, community devtools, and your backend is already Node.js.
|
|
561
774
|
|
|
562
|
-
|
|
775
|
+
> FormaJS is the reactive layer of the [Forma stack](https://getforma.dev). The full pipeline compiles components to FMIR binary, 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.
|
|
563
776
|
|
|
564
|
-
|
|
777
|
+
---
|
|
565
778
|
|
|
566
|
-
|
|
779
|
+
## Examples
|
|
567
780
|
|
|
568
|
-
|
|
781
|
+
See the [`examples/`](./examples) directory:
|
|
782
|
+
|
|
783
|
+
| Example | Description |
|
|
784
|
+
|---|---|
|
|
785
|
+
| **counter** | Minimal `h()` counter |
|
|
786
|
+
| **counter-jsx** | Same counter with JSX syntax |
|
|
787
|
+
| **csp** | CSP-safe runtime with strict `Content-Security-Policy` |
|
|
788
|
+
| **todo** | Todo list with `createList` and keyed reconciliation |
|
|
789
|
+
| **data-table** | Sortable table with `createList` |
|
|
790
|
+
|
|
791
|
+
---
|
|
569
792
|
|
|
570
|
-
|
|
793
|
+
## Stability
|
|
571
794
|
|
|
572
795
|
| Feature | Status | Notes |
|
|
573
|
-
|
|
574
|
-
| Signals (`createSignal`, `createEffect`, `createComputed`, `batch`) | **Stable** | Core primitive
|
|
796
|
+
|---|---|---|
|
|
797
|
+
| Signals (`createSignal`, `createEffect`, `createComputed`, `batch`) | **Stable** | Core primitive. Custom `equals` supported. |
|
|
575
798
|
| Reactive introspection (`isSignal`, `isComputed`, `trigger`, `getBatchDepth`) | **Stable** | alien-signals 3.x type guards |
|
|
576
|
-
| `h()` / JSX rendering | **Stable** | |
|
|
799
|
+
| `h()` / JSX rendering | **Stable** | Function components supported |
|
|
577
800
|
| `mount()`, `createShow`, `createSwitch`, `createList` | **Stable** | |
|
|
578
|
-
| HTML Runtime (`data-*` directives) | **Stable** |
|
|
579
|
-
| CSP-hardened runtime | **Stable** |
|
|
801
|
+
| HTML Runtime (`data-*` directives) | **Stable** | CSP-safe expression parser |
|
|
802
|
+
| CSP-hardened runtime | **Stable** | Zero `new Function()` in dist |
|
|
580
803
|
| `createStore` (deep reactivity) | **Stable** | |
|
|
581
804
|
| Components (`defineComponent`, lifecycle) | **Stable** | |
|
|
582
805
|
| Context (`createContext`, `provide`, `inject`) | **Stable** | |
|
|
583
806
|
| Islands (`activateIslands`, disposal, triggers) | **Stable** | 10 activation + 88 hydration + 10 trigger tests |
|
|
584
807
|
| `createHistory` (undo/redo) | **Stable** | |
|
|
585
|
-
| `createReducer` | **Stable** |
|
|
586
|
-
| `data-fetch`, `data-transition
|
|
808
|
+
| `createReducer` | **Stable** | |
|
|
809
|
+
| `data-fetch`, `data-transition:*`, `data-ref` | **Stable** | |
|
|
587
810
|
| SSR (`renderToString`, `renderToStream`) | **Beta** | Functional, API may evolve |
|
|
588
|
-
| TC39 Signals compat (`Signal.State`, `Signal.Computed`) | **Beta** |
|
|
811
|
+
| TC39 Signals compat (`Signal.State`, `Signal.Computed`) | **Beta** | Tracks an evolving TC39 proposal |
|
|
812
|
+
|
|
813
|
+
---
|
|
589
814
|
|
|
590
815
|
## Ecosystem
|
|
591
816
|
|
|
592
|
-
FormaJS is the reactive frontend layer of a full-stack Rust + TypeScript framework.
|
|
817
|
+
FormaJS is the reactive frontend layer of a full-stack Rust + TypeScript framework.
|
|
593
818
|
|
|
594
819
|
| Package | Language | Description |
|
|
595
|
-
|
|
820
|
+
|---|---|---|
|
|
596
821
|
| [@getforma/core](https://www.npmjs.com/package/@getforma/core) | TypeScript | This library — reactive DOM, signals, islands, SSR hydration |
|
|
597
822
|
| [@getforma/compiler](https://github.com/getforma-dev/forma-tools) | TypeScript | TypeScript-to-FMIR compiler, Vite plugin, esbuild SSR plugin |
|
|
598
823
|
| [@getforma/build](https://github.com/getforma-dev/forma-tools) | TypeScript | esbuild pipeline with content hashing, compression, manifest |
|
|
599
824
|
| [@getforma/create-app](https://github.com/getforma-dev/create-forma-app) | TypeScript | `npx @getforma/create-app` — scaffold a new Forma project |
|
|
600
825
|
| [forma-ir](https://crates.io/crates/forma-ir) | Rust | FMIR binary format: parser, walker, WASM exports |
|
|
601
|
-
| [forma-server](https://crates.io/crates/forma-server) | Rust | Axum middleware for SSR
|
|
826
|
+
| [forma-server](https://crates.io/crates/forma-server) | Rust | Axum middleware for SSR, asset serving, CSP |
|
|
827
|
+
|
|
828
|
+
---
|
|
602
829
|
|
|
603
830
|
## License
|
|
604
831
|
|