@getforma/core 0.2.0 → 0.3.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.
Files changed (38) hide show
  1. package/README.md +265 -13
  2. package/dist/{chunk-VKKPX4YU.js → chunk-5H52PKGF.js} +62 -12
  3. package/dist/chunk-5H52PKGF.js.map +1 -0
  4. package/dist/{chunk-VP5EOGM5.cjs → chunk-TSQ7AKFT.cjs} +62 -11
  5. package/dist/chunk-TSQ7AKFT.cjs.map +1 -0
  6. package/dist/forma-runtime-csp.js +2 -0
  7. package/dist/forma-runtime.js +2 -0
  8. package/dist/formajs-runtime-hardened.global.js +1 -1
  9. package/dist/formajs-runtime-hardened.global.js.map +1 -1
  10. package/dist/formajs-runtime.global.js +1 -1
  11. package/dist/formajs-runtime.global.js.map +1 -1
  12. package/dist/formajs.global.js +1 -1
  13. package/dist/formajs.global.js.map +1 -1
  14. package/dist/index.cjs +100 -69
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +43 -27
  17. package/dist/index.d.ts +43 -27
  18. package/dist/index.js +57 -27
  19. package/dist/index.js.map +1 -1
  20. package/dist/runtime-hardened.cjs +166 -50
  21. package/dist/runtime-hardened.cjs.map +1 -1
  22. package/dist/runtime-hardened.d.cts +86 -0
  23. package/dist/runtime-hardened.d.ts +86 -0
  24. package/dist/runtime-hardened.js +166 -50
  25. package/dist/runtime-hardened.js.map +1 -1
  26. package/dist/runtime.cjs +188 -72
  27. package/dist/runtime.cjs.map +1 -1
  28. package/dist/runtime.js +167 -51
  29. package/dist/runtime.js.map +1 -1
  30. package/dist/ssr/index.cjs +59 -71
  31. package/dist/ssr/index.cjs.map +1 -1
  32. package/dist/ssr/index.d.cts +4 -0
  33. package/dist/ssr/index.d.ts +4 -0
  34. package/dist/ssr/index.js +59 -71
  35. package/dist/ssr/index.js.map +1 -1
  36. package/package.json +26 -3
  37. package/dist/chunk-VKKPX4YU.js.map +0 -1
  38. package/dist/chunk-VP5EOGM5.cjs.map +0 -1
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![npm](https://img.shields.io/npm/v/@getforma/core)](https://www.npmjs.com/package/@getforma/core)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
6
 
7
- Reactive DOM library with fine-grained signals, islands architecture, and SSR hydration. ~15KB gzipped.
7
+ Reactive DOM library with fine-grained signals. No virtual DOM — `h()` creates real elements, signals update only what changed. ~15KB gzipped.
8
8
 
9
9
  ## Install
10
10
 
@@ -12,7 +12,71 @@ Reactive DOM library with fine-grained signals, islands architecture, and SSR hy
12
12
  npm install @getforma/core
13
13
  ```
14
14
 
15
- ## Quick Start
15
+ Or use the CDN (no build step required):
16
+
17
+ ```html
18
+ <script src="https://unpkg.com/@getforma/core/dist/formajs-runtime.global.js"></script>
19
+ ```
20
+
21
+ ## Why FormaJS?
22
+
23
+ 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.
24
+
25
+ **Design principles:**
26
+
27
+ - **Real DOM, not virtual DOM.** `h('div')` returns an actual `HTMLDivElement`. Signals mutate it directly. No diffing pass, no reconciliation overhead for simple updates. Inspired by [Solid](https://www.solidjs.com/).
28
+ - **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.
29
+ - **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.
30
+ - **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.
31
+ - **Islands over SPAs.** `activateIslands()` hydrates independent regions of server-rendered HTML. Each island is self-contained. Ship less JavaScript, keep server-rendered content instant.
32
+
33
+ **What FormaJS is not:** It's not a framework with opinions about routing, data fetching, or state management patterns. It's a reactive DOM library. You bring the architecture.
34
+
35
+ ## Three Ways to Use FormaJS
36
+
37
+ ### 1. HTML Runtime (no build step)
38
+
39
+ Drop a script tag, write `data-*` attributes. Zero config, zero tooling.
40
+
41
+ ```html
42
+ <script src="https://unpkg.com/@getforma/core/dist/formajs-runtime.global.js"></script>
43
+
44
+ <div data-forma-state='{"count": 0}'>
45
+ <p data-text="{count}"></p>
46
+ <button data-on:click="{count++}">+1</button>
47
+ <button data-on:click="{count = 0}">Reset</button>
48
+ </div>
49
+ ```
50
+
51
+ #### Supported Directives
52
+
53
+ | Directive | Description | Example |
54
+ |-----------|-------------|---------|
55
+ | `data-forma-state` | Declare reactive state (JSON) | `data-forma-state='{"count": 0}'` |
56
+ | `data-text` | Bind text content | `data-text="{count}"` |
57
+ | `data-show` | Toggle visibility (display) | `data-show="{isOpen}"` |
58
+ | `data-if` | Conditional render (add/remove from DOM) | `data-if="{loggedIn}"` |
59
+ | `data-model` | Two-way binding (inputs) | `data-model="{email}"` |
60
+ | `data-on:event` | Event handler | `data-on:click="{count++}"` |
61
+ | `data-class:name` | Conditional CSS class | `data-class:active="{isActive}"` |
62
+ | `data-bind:attr` | Dynamic attribute | `data-bind:href="{url}"` |
63
+ | `data-list` | List rendering with keyed reconciliation | `data-list="{items}"` |
64
+ | `data-computed` | Computed value | `data-computed="doubled = count * 2"` |
65
+ | `data-persist` | Persist state to localStorage | `data-persist="{count}"` |
66
+ | `data-fetch` | Fetch data from URL | `data-fetch="GET /api/items → items"` |
67
+ | `data-transition:*` | Enter/leave CSS transitions | `data-transition:enter="fade-in"` |
68
+
69
+ CSP-safe expression parser — no `eval()` or `new Function()` by default. For strict CSP environments, use the hardened build:
70
+
71
+ ```html
72
+ <script src="https://unpkg.com/@getforma/core/dist/formajs-runtime-hardened.global.js"></script>
73
+ ```
74
+
75
+ ### 2. Hyperscript — `h()`
76
+
77
+ ```bash
78
+ npm install @getforma/core
79
+ ```
16
80
 
17
81
  ```typescript
18
82
  import { createSignal, h, mount } from '@getforma/core';
@@ -27,21 +91,209 @@ mount(() =>
27
91
  );
28
92
  ```
29
93
 
30
- ## Features
94
+ ### 3. JSX
95
+
96
+ Same `h()` function, JSX syntax. Configure your bundler:
97
+
98
+ ```json
99
+ // tsconfig.json
100
+ {
101
+ "compilerOptions": {
102
+ "jsx": "react",
103
+ "jsxFactory": "h",
104
+ "jsxFragmentFactory": "Fragment"
105
+ }
106
+ }
107
+ ```
108
+
109
+ ```tsx
110
+ import { createSignal, h, Fragment, mount } from '@getforma/core';
111
+
112
+ const [count, setCount] = createSignal(0);
113
+
114
+ function Counter() {
115
+ return (
116
+ <>
117
+ <p>{() => `Count: ${count()}`}</p>
118
+ <button onClick={() => setCount(count() + 1)}>+1</button>
119
+ </>
120
+ );
121
+ }
122
+
123
+ mount(() => <Counter />, '#app');
124
+ ```
125
+
126
+ ## CDN Usage
127
+
128
+ ### Standard (recommended)
129
+ ```html
130
+ <script src="https://unpkg.com/@getforma/core/dist/forma-runtime.js"></script>
131
+ ```
132
+
133
+ ### CSP-Safe (strict Content-Security-Policy)
134
+ ```html
135
+ <script src="https://unpkg.com/@getforma/core/dist/forma-runtime-csp.js"></script>
136
+ ```
137
+
138
+ > The CSP build uses a hand-written expression parser and never calls `new Function`.
139
+ > It supports most common patterns. See [examples/csp](./examples/csp) for a working demo.
140
+
141
+ ## Core API
142
+
143
+ ### Signals
144
+
145
+ ```typescript
146
+ import { createSignal, createEffect, createComputed, batch } from '@getforma/core';
147
+
148
+ const [count, setCount] = createSignal(0);
149
+ const doubled = createComputed(() => count() * 2);
150
+
151
+ createEffect(() => console.log('count:', count()));
152
+
153
+ batch(() => {
154
+ setCount(1);
155
+ setCount(2); // effect fires once with value 2
156
+ });
157
+ ```
158
+
159
+ ### Conditional Rendering
160
+
161
+ ```typescript
162
+ import { createSignal, createShow, createSwitch, h } from '@getforma/core';
163
+
164
+ const [loggedIn, setLoggedIn] = createSignal(false);
165
+
166
+ // createShow — toggle between two branches
167
+ createShow(loggedIn,
168
+ () => h('p', null, 'Welcome back'),
169
+ () => h('p', null, 'Please sign in'),
170
+ );
171
+
172
+ // createSwitch — multi-branch with caching
173
+ const [view, setView] = createSignal('home');
174
+ createSwitch(view, [
175
+ { match: 'home', render: () => h('div', null, 'Home') },
176
+ { match: 'settings', render: () => h('div', null, 'Settings') },
177
+ ], () => h('div', null, '404 Not Found'));
178
+ ```
179
+
180
+ ### List Rendering
181
+
182
+ ```typescript
183
+ import { createSignal, createList, h } from '@getforma/core';
184
+
185
+ const [items, setItems] = createSignal([
186
+ { id: 1, name: 'Alice' },
187
+ { id: 2, name: 'Bob' },
188
+ ]);
189
+
190
+ createList(
191
+ items,
192
+ (item) => item.id, // key function
193
+ (item) => h('li', null, item.name),
194
+ );
195
+ ```
196
+
197
+ ### Store (deep reactivity)
198
+
199
+ ```typescript
200
+ import { createStore } from '@getforma/core';
201
+
202
+ const [state, setState] = createStore({
203
+ user: { name: 'Alice', prefs: { theme: 'dark' } },
204
+ items: [1, 2, 3],
205
+ });
206
+
207
+ // Read reactively
208
+ state.user.name; // 'Alice'
209
+
210
+ // Mutate — only affected subscribers update
211
+ setState('user', 'name', 'Bob');
212
+ setState('items', items => [...items, 4]);
213
+ ```
214
+
215
+ ### Components
216
+
217
+ ```typescript
218
+ import { defineComponent, onMount, onUnmount, h } from '@getforma/core';
219
+
220
+ const Timer = defineComponent(() => {
221
+ const [seconds, setSeconds] = createSignal(0);
222
+
223
+ onMount(() => {
224
+ const id = setInterval(() => setSeconds(s => s + 1), 1000);
225
+ return () => clearInterval(id); // cleanup on unmount
226
+ });
227
+
228
+ return h('span', null, () => `${seconds()}s`);
229
+ });
230
+
231
+ document.body.appendChild(Timer());
232
+ ```
233
+
234
+ ### Context (Dependency Injection)
235
+
236
+ ```typescript
237
+ import { createContext, provide, inject } from '@getforma/core';
238
+
239
+ const ThemeCtx = createContext('light');
240
+
241
+ provide(ThemeCtx, 'dark');
242
+ const theme = inject(ThemeCtx); // 'dark'
243
+ ```
244
+
245
+ ## Islands Architecture
246
+
247
+ For server-rendered HTML, activate independent interactive regions:
248
+
249
+ ```typescript
250
+ import { activateIslands, createSignal, h } from '@getforma/core';
251
+
252
+ activateIslands({
253
+ Counter: (el, props) => {
254
+ const [count, setCount] = createSignal(props.initial ?? 0);
255
+ // Hydrate: attach reactivity to existing server-rendered DOM
256
+ },
257
+ });
258
+ ```
259
+
260
+ ```html
261
+ <!-- Server-rendered HTML -->
262
+ <div data-forma-island="Counter" data-forma-props='{"initial": 5}'>
263
+ <span>5</span>
264
+ <button>+1</button>
265
+ </div>
266
+ ```
267
+
268
+ ## Subpath Exports
269
+
270
+ | Import | Description |
271
+ |--------|-------------|
272
+ | `@getforma/core` | Signals, `h()`, `mount()`, lists, stores, components |
273
+ | `@getforma/core/runtime` | HTML Runtime — `initRuntime()`, `mount()`, `unmount()` |
274
+ | `@getforma/core/runtime/global` | HTML Runtime global build (IIFE, for `<script>` tags) |
275
+ | `@getforma/core/runtime-hardened` | Runtime with `new Function()` locked off (strict CSP) |
276
+ | `@getforma/core/runtime-csp` | Alias for `runtime-hardened` (CSP-safe build) |
277
+ | `@getforma/core/runtime-csp/global` | CSP-safe global build (IIFE, for `<script>` tags) |
278
+ | `@getforma/core/ssr` | Server-side rendering — `renderToString()`, `renderToStream()` |
279
+ | `@getforma/core/tc39` | TC39-compatible `Signal.State` and `Signal.Computed` classes |
280
+
281
+ ## Examples
282
+
283
+ See the [`examples/`](./examples) directory:
31
284
 
32
- - **Real DOM** — `h()` creates actual DOM elements with reactive bindings. No virtual DOM, no diffing overhead.
33
- - **Fine-grained reactivity** — signals, effects, computed values via alien-signals. Only what changed updates.
34
- - **Islands architecture** — `activateIslands()` for independent hydration of server-rendered HTML regions.
35
- - **SSR hydration** — `adoptNode()` walks server-rendered DOM and attaches reactive bindings without re-creating elements.
36
- - **Conditional rendering** — `createShow()`, `createSwitch()` with branch caching for O(1) toggle.
37
- - **List rendering** — `createList()` with LIS-based keyed reconciliation, handles 50K+ rows.
38
- - **State management** — `createStore()` with deep reactivity, history, persistence.
285
+ - **counter** — minimal `h()` counter
286
+ - **counter-jsx** — same counter with JSX syntax
287
+ - **csp** — CSP-safe runtime with strict Content-Security-Policy meta tag
288
+ - **todo** — todo list with `createList` and keyed reconciliation
289
+ - **data-table** — sortable table with `createList`
39
290
 
40
291
  ## Ecosystem
41
292
 
42
- - [forma](https://github.com/getforma-dev/forma) Rust server framework (forma-ir + forma-server)
43
- - [forma-tools](https://github.com/getforma-dev/forma-tools) — Build tooling (@getforma/compiler + @getforma/build)
44
- - [create-forma-app](https://github.com/getforma-dev/create-forma-app) — CLI scaffolder
293
+ | Package | Description |
294
+ |---------|-------------|
295
+ | [@getforma/core](https://www.npmjs.com/package/@getforma/core) | This library `npm install @getforma/core` |
296
+ | [forma](https://github.com/getforma-dev/forma) | Rust server framework (forma-ir + forma-server) |
45
297
 
46
298
  ## License
47
299
 
@@ -21,7 +21,7 @@ function createRoot(fn) {
21
21
  return fn(dispose);
22
22
  } finally {
23
23
  currentRoot = rootStack.pop() ?? null;
24
- if (rootStack.length === 0 && rootStack.length !== rootStack.__capacity) {
24
+ if (rootStack.length === 0) {
25
25
  rootStack.length = 0;
26
26
  }
27
27
  }
@@ -562,8 +562,47 @@ function handleEvent(el, key, value2) {
562
562
  );
563
563
  }
564
564
  function handleInnerHTML(el, _key, value2) {
565
- const v = value2;
566
- el.innerHTML = v.__html;
565
+ if (typeof value2 === "function") {
566
+ internalEffect(() => {
567
+ const resolved = value2();
568
+ if (resolved == null) {
569
+ el.innerHTML = "";
570
+ return;
571
+ }
572
+ if (typeof resolved !== "object" || !("__html" in resolved)) {
573
+ throw new TypeError(
574
+ "dangerouslySetInnerHTML: expected { __html: string }, got " + typeof resolved
575
+ );
576
+ }
577
+ const html = resolved.__html;
578
+ if (typeof html !== "string") {
579
+ throw new TypeError(
580
+ "dangerouslySetInnerHTML: __html must be a string, got " + typeof html
581
+ );
582
+ }
583
+ const cache = getCache(el);
584
+ if (cache["innerHTML"] === html) return;
585
+ cache["innerHTML"] = html;
586
+ el.innerHTML = html;
587
+ });
588
+ } else {
589
+ if (value2 == null) {
590
+ el.innerHTML = "";
591
+ return;
592
+ }
593
+ if (typeof value2 !== "object" || !("__html" in value2)) {
594
+ throw new TypeError(
595
+ "dangerouslySetInnerHTML: expected { __html: string }, got " + typeof value2
596
+ );
597
+ }
598
+ const html = value2.__html;
599
+ if (typeof html !== "string") {
600
+ throw new TypeError(
601
+ "dangerouslySetInnerHTML: __html must be a string, got " + typeof html
602
+ );
603
+ }
604
+ el.innerHTML = html;
605
+ }
567
606
  }
568
607
  function handleXLink(el, key, value2) {
569
608
  const localName = key.slice(6);
@@ -685,7 +724,18 @@ function applyStaticProp(el, key, value2) {
685
724
  return;
686
725
  }
687
726
  if (key === "dangerouslySetInnerHTML") {
688
- el.innerHTML = value2.__html;
727
+ if (typeof value2 !== "object" || !("__html" in value2)) {
728
+ throw new TypeError(
729
+ "dangerouslySetInnerHTML: expected { __html: string }, got " + typeof value2
730
+ );
731
+ }
732
+ const html = value2.__html;
733
+ if (typeof html !== "string") {
734
+ throw new TypeError(
735
+ "dangerouslySetInnerHTML: __html must be a string, got " + typeof html
736
+ );
737
+ }
738
+ el.innerHTML = html;
689
739
  return;
690
740
  }
691
741
  if (key.charCodeAt(0) === 120 && key.startsWith("xlink:")) {
@@ -843,10 +893,10 @@ function createShow(when, thenFn, elseFn) {
843
893
  let currentNode = null;
844
894
  let lastTruthy = null;
845
895
  let currentDispose = null;
846
- const DEBUG_LABEL = thenFn.toString().slice(0, 60);
847
896
  const showDispose = internalEffect(() => {
848
897
  const truthy = !!when();
849
898
  const DEBUG = typeof globalThis.__FORMA_DEBUG__ !== "undefined";
899
+ const DEBUG_LABEL = DEBUG ? thenFn.toString().slice(0, 60) : "";
850
900
  if (truthy === lastTruthy) {
851
901
  if (DEBUG) console.log("[forma:show] skip (same)", truthy, DEBUG_LABEL);
852
902
  return;
@@ -903,13 +953,13 @@ function setHydrating(value2) {
903
953
  hydrating = value2;
904
954
  }
905
955
  function isDescriptor(v) {
906
- return v != null && typeof v === "object" && v.type === "element";
956
+ return v != null && typeof v === "object" && "type" in v && v.type === "element";
907
957
  }
908
958
  function isShowDescriptor(v) {
909
- return v != null && typeof v === "object" && v.type === "show";
959
+ return v != null && typeof v === "object" && "type" in v && v.type === "show";
910
960
  }
911
961
  function isListDescriptor(v) {
912
- return v != null && typeof v === "object" && v.type === "list";
962
+ return v != null && typeof v === "object" && "type" in v && v.type === "list";
913
963
  }
914
964
  function applyDynamicProps(el, props) {
915
965
  if (!props) return;
@@ -1087,7 +1137,7 @@ function adoptBranchContent(desc, regionStart, regionEnd) {
1087
1137
  }
1088
1138
  function adoptNode(desc, ssrEl) {
1089
1139
  if (!ssrEl || ssrEl.tagName !== desc.tag.toUpperCase()) {
1090
- console.warn(`Hydration mismatch: expected <${desc.tag}>, got <${ssrEl?.tagName?.toLowerCase() ?? "nothing"}>`);
1140
+ if (__DEV__) console.warn(`Hydration mismatch: expected <${desc.tag}>, got <${ssrEl?.tagName?.toLowerCase() ?? "nothing"}>`);
1091
1141
  const fresh = descriptorToElement(desc);
1092
1142
  if (ssrEl) ssrEl.replaceWith(fresh);
1093
1143
  return;
@@ -1754,6 +1804,6 @@ function createList(items, keyFn, renderFn, options) {
1754
1804
  return fragment2;
1755
1805
  }
1756
1806
 
1757
- export { Fragment, batch, cleanup, createEffect, createList, createMemo, createReducer, createRef, createResource, createRoot, createShow, fragment, h, hydrateIsland, internalEffect, longestIncreasingSubsequence, on, onCleanup, onError, popSuspenseContext, pushSuspenseContext, reconcileList, untrack };
1758
- //# sourceMappingURL=chunk-VKKPX4YU.js.map
1759
- //# sourceMappingURL=chunk-VKKPX4YU.js.map
1807
+ export { Fragment, __DEV__, batch, cleanup, createEffect, createList, createMemo, createReducer, createRef, createResource, createRoot, createShow, fragment, h, hydrateIsland, internalEffect, longestIncreasingSubsequence, on, onCleanup, onError, popSuspenseContext, pushSuspenseContext, reconcileList, untrack };
1808
+ //# sourceMappingURL=chunk-5H52PKGF.js.map
1809
+ //# sourceMappingURL=chunk-5H52PKGF.js.map