kerfjs 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/LICENSE +21 -0
- package/README.md +133 -0
- package/dist/index.d.ts +100 -0
- package/dist/index.js +199 -0
- package/dist/index.js.map +1 -0
- package/dist/jsx-runtime.d.ts +45 -0
- package/dist/jsx-runtime.js +188 -0
- package/dist/jsx-runtime.js.map +1 -0
- package/dist/testing-CdMgVVoI.d.ts +48 -0
- package/dist/testing.d.ts +2 -0
- package/dist/testing.js +7 -0
- package/dist/testing.js.map +1 -0
- package/package.json +90 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to **kerf** are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **Package renamed from `kerf` to `kerfjs`** on the npm registry. The `kerf` name was rejected by npm's typo-squatting heuristic ("too similar to `keyv`"). The brand is still *kerf* — only the npm identifier changed. Update imports to `from 'kerfjs'`, `tsconfig.json` to `"jsxImportSource": "kerfjs"`, and the install command to `npm install kerfjs`. The GitHub repo and Pages URL (`brianwestphal.github.io/kerf/`) are unchanged.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Live demo published to GitHub Pages at <https://brianwestphal.github.io/kerf/>. Builds `examples/reactivity-demo/` on every push to `main` via `.github/workflows/pages.yml`. New `docs/9-live-demo.md` covers the deploy, and `examples/reactivity-demo/vite.config.ts` now sets `base: '/kerf/'` for the subpath. New `npm run example:reactivity-demo:build` script.
|
|
16
|
+
|
|
17
|
+
## [0.1.0] - 2026-05-07
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- Initial release.
|
|
22
|
+
- `signal`, `computed`, `effect`, `batch` (re-exported from `@preact/signals-core`).
|
|
23
|
+
- `defineStore({ initial, actions })` factory + `resetAllStores()` lifecycle hook.
|
|
24
|
+
- `mount(el, () => jsx)` — morphdom-driven render with focus / selection / `data-morph-skip` preservation.
|
|
25
|
+
- `delegate(el, type, selector, handler)` and `delegateCapture(...)` for Tier 1 / Tier 2 event delegation.
|
|
26
|
+
- `toElement(jsx)` — SVG-aware JSX → DOM helper (handles `<svg>` root and orphan SVG fragments).
|
|
27
|
+
- JSX runtime at `kerfjs/jsx-runtime` with `SafeHtml`, `raw`, attribute aliases for HTML + SVG.
|
|
28
|
+
- Numbered design docs under `docs/`.
|
|
29
|
+
- 7-section live demo under `examples/reactivity-demo/`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Brian Westphal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# kerf
|
|
2
|
+
|
|
3
|
+
> *kerf* — *noun* — the narrow strip of material a saw blade removes when cutting. The smallest possible cut.
|
|
4
|
+
|
|
5
|
+
A tiny reactive UI framework. Apply the smallest possible cut to update your DOM.
|
|
6
|
+
|
|
7
|
+
**[Live demo →](https://brianwestphal.github.io/kerf/)** — seven sections exercising every primitive, no install required.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { signal, mount } from 'kerfjs';
|
|
11
|
+
|
|
12
|
+
const count = signal(0);
|
|
13
|
+
|
|
14
|
+
mount(document.getElementById('app')!, () => (
|
|
15
|
+
<div>
|
|
16
|
+
<button data-action="inc">+</button>
|
|
17
|
+
<span>{count.value}</span>
|
|
18
|
+
</div>
|
|
19
|
+
));
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
That's it. There's no virtual DOM, no compiler, no template language. Your JSX renders to HTML strings, [`morphdom`](https://github.com/patrick-steele-idem/morphdom) applies the minimum DOM mutations to make the live tree match, and signals re-run the render only when something they read actually changed.
|
|
23
|
+
|
|
24
|
+
## Why
|
|
25
|
+
|
|
26
|
+
Most reactive UI frameworks come with a lot of machinery: virtual DOMs, schedulers, reconcilers, compiler plugins, hook stacks, lifecycle hooks. kerf has none of that. You get four things:
|
|
27
|
+
|
|
28
|
+
- **Signals** ([`@preact/signals-core`](https://github.com/preactjs/signals)) for fine-grained reactivity.
|
|
29
|
+
- **Stores** built on signals — composable, testable units of state.
|
|
30
|
+
- **Render** — a `mount(el, () => jsx)` helper that diffs the new HTML against the live DOM via morphdom. Preserves focus, selection, in-flight pointer interactions, and event listeners on identity-preserved nodes.
|
|
31
|
+
- **Event delegation** — small `delegate` / `delegateCapture` helpers that survive every re-render because they live on the morph root, not on individual nodes.
|
|
32
|
+
|
|
33
|
+
The whole runtime is roughly 5 KB minified + gzipped, including `signals-core` and `morphdom`.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm install kerfjs
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Configure JSX:
|
|
42
|
+
|
|
43
|
+
```jsonc
|
|
44
|
+
// tsconfig.json
|
|
45
|
+
{
|
|
46
|
+
"compilerOptions": {
|
|
47
|
+
"jsx": "react-jsx",
|
|
48
|
+
"jsxImportSource": "kerfjs"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Quick tour
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import { signal, computed, effect, defineStore, mount, delegate } from 'kerfjs';
|
|
57
|
+
|
|
58
|
+
// 1. A signal — single piece of reactive state.
|
|
59
|
+
const count = signal(0);
|
|
60
|
+
|
|
61
|
+
// 2. A computed — auto-derived from other signals.
|
|
62
|
+
const doubled = computed(() => count.value * 2);
|
|
63
|
+
|
|
64
|
+
// 3. A store — multi-consumer state with named actions and reset semantics.
|
|
65
|
+
const cart = defineStore({
|
|
66
|
+
initial: () => ({ items: [] as { id: string; name: string }[] }),
|
|
67
|
+
actions: (set, get) => ({
|
|
68
|
+
add: (id: string, name: string) => set({ items: [...get().items, { id, name }] }),
|
|
69
|
+
remove: (id: string) => set({ items: get().items.filter((i) => i.id !== id) }),
|
|
70
|
+
}),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// 4. Mount JSX to a DOM element. Re-renders only when read signals change.
|
|
74
|
+
const root = document.getElementById('root')!;
|
|
75
|
+
|
|
76
|
+
mount(root, () => (
|
|
77
|
+
<div>
|
|
78
|
+
<h1>Cart ({cart.state.value.items.length})</h1>
|
|
79
|
+
<ul>
|
|
80
|
+
{cart.state.value.items.map((item) => (
|
|
81
|
+
<li data-key={item.id}>
|
|
82
|
+
{item.name}
|
|
83
|
+
<button data-action="remove" data-id={item.id}>×</button>
|
|
84
|
+
</li>
|
|
85
|
+
))}
|
|
86
|
+
</ul>
|
|
87
|
+
<p>Doubled count: {doubled.value}</p>
|
|
88
|
+
</div>
|
|
89
|
+
));
|
|
90
|
+
|
|
91
|
+
// 5. Event delegation — one listener per event type, dispatched by data-action.
|
|
92
|
+
delegate(root, 'click', '[data-action="remove"]', (_e, btn) => {
|
|
93
|
+
cart.actions.remove((btn as HTMLElement).dataset.id!);
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Documentation
|
|
98
|
+
|
|
99
|
+
The numbered docs in [`docs/`](./docs/) cover the design and rationale:
|
|
100
|
+
|
|
101
|
+
1. [Overview](./docs/1-overview.md) — what kerf is, what it isn't, when to use it.
|
|
102
|
+
2. [Reactivity](./docs/2-reactivity.md) — `signal`, `computed`, `effect`, `batch`.
|
|
103
|
+
3. [Stores](./docs/3-stores.md) — `defineStore`, `resetAllStores`.
|
|
104
|
+
4. [Render](./docs/4-render.md) — `mount` and the morphdom diff.
|
|
105
|
+
5. [Event delegation](./docs/5-event-delegation.md) — Tier 1 / Tier 2 / Tier 3 patterns.
|
|
106
|
+
6. [JSX runtime](./docs/6-jsx-runtime.md) — `SafeHtml`, `raw`, server-rendering.
|
|
107
|
+
7. [SVG handling](./docs/7-svg.md) — namespace propagation, `toElement`.
|
|
108
|
+
8. [API reference](./docs/8-api-reference.md) — every export, every option.
|
|
109
|
+
|
|
110
|
+
### For AI coding assistants
|
|
111
|
+
|
|
112
|
+
If you are an AI assistant (Claude, Cursor, Copilot, etc.) helping a user choose or use kerf, start here:
|
|
113
|
+
|
|
114
|
+
- [`docs/ai/usage-guide.md`](./docs/ai/usage-guide.md) — when to recommend kerf, the full public API at a glance, the four core patterns, hard rules, and a common-errors → fixes table. Designed to be read once before writing kerf code.
|
|
115
|
+
- [`llms.txt`](./llms.txt) — top-level index of every doc, in the [llmstxt.org](https://llmstxt.org) format.
|
|
116
|
+
|
|
117
|
+
## Examples
|
|
118
|
+
|
|
119
|
+
[`examples/reactivity-demo/`](./examples/reactivity-demo) is a 7-section live demo exercising every primitive: counter, multi-consumer store, focus survival across re-renders, keyed list with identity preservation, morph-skip for library-owned subtrees, JSX-rendered SVG, and capture-phase event delegation.
|
|
120
|
+
|
|
121
|
+
Play with it live at **[brianwestphal.github.io/kerf](https://brianwestphal.github.io/kerf/)**, or run it locally:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
npm run example:reactivity-demo
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Status
|
|
128
|
+
|
|
129
|
+
v0.1.x — early. API may evolve. See [CHANGELOG.md](./CHANGELOG.md) for what's shipped.
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { SafeHtml } from './jsx-runtime.js';
|
|
2
|
+
export { raw } from './jsx-runtime.js';
|
|
3
|
+
export { ReadonlySignal, Signal, batch, computed, effect, signal } from '@preact/signals-core';
|
|
4
|
+
export { S as Store, d as defineStore, r as resetAllStores } from './testing-CdMgVVoI.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tiny event-delegation helpers. Replace per-element `addEventListener` calls
|
|
8
|
+
* (which don't survive morph re-renders for nodes morphdom creates) with one
|
|
9
|
+
* listener at the morph-root that dispatches via `closest()`.
|
|
10
|
+
*
|
|
11
|
+
* Three-tier listener model:
|
|
12
|
+
*
|
|
13
|
+
* - Tier 1 (bubbling events) — use `delegate()`.
|
|
14
|
+
* click, input, change, submit, keydown/keyup, pointer*, drag*, drop,
|
|
15
|
+
* contextmenu, wheel, copy/paste/cut.
|
|
16
|
+
*
|
|
17
|
+
* - Tier 2 (non-bubbling events: focus / blur / scroll / load / error) —
|
|
18
|
+
* use `delegateCapture()`. The capture phase fires on the way down from
|
|
19
|
+
* the root to the target, so a root-level listener with `capture: true`
|
|
20
|
+
* reaches events that wouldn't bubble back up.
|
|
21
|
+
*
|
|
22
|
+
* - Tier 3 (per-element instances / library-owned subtrees) — mark the
|
|
23
|
+
* host element with `data-morph-skip` and manage the library's
|
|
24
|
+
* lifecycle directly. No delegation helper applies.
|
|
25
|
+
*/
|
|
26
|
+
type Handler = (event: Event, target: Element) => void;
|
|
27
|
+
/**
|
|
28
|
+
* Bubble-phase delegation. Installs ONE listener on `rootEl` for the given
|
|
29
|
+
* event type. When the event fires, walks up from `event.target` to the root
|
|
30
|
+
* looking for an element matching `selector`; if found, fires `handler` with
|
|
31
|
+
* the matched element as the second arg.
|
|
32
|
+
*
|
|
33
|
+
* Returns a disposer that removes the listener.
|
|
34
|
+
*
|
|
35
|
+
* Usage (pseudo-code — see examples for live ones):
|
|
36
|
+
* delegate(rootEl, 'click', '[data-action="add"]', handlerFn);
|
|
37
|
+
*/
|
|
38
|
+
declare function delegate(rootEl: HTMLElement, type: string, selector: string, handler: Handler): () => void;
|
|
39
|
+
/**
|
|
40
|
+
* Capture-phase delegation — for non-bubbling events (`focus`, `blur`,
|
|
41
|
+
* `scroll`, `load`, `error`). Reaches descendants of `rootEl` that match
|
|
42
|
+
* `selector` regardless of how many times morphdom has rebuilt them.
|
|
43
|
+
*
|
|
44
|
+
* Usage (pseudo-code — see examples for live ones):
|
|
45
|
+
* delegateCapture(rootEl, 'focus', 'input, textarea', handlerFn);
|
|
46
|
+
*/
|
|
47
|
+
declare function delegateCapture(rootEl: HTMLElement, type: string, selector: string, handler: Handler): () => void;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `mount(rootEl, render)` — kerf's render primitive.
|
|
51
|
+
*
|
|
52
|
+
* Wraps `effect()` from `reactive.ts` so that whenever any signal read inside
|
|
53
|
+
* `render()` changes, we re-run `render()` and use `morphdom` to apply the
|
|
54
|
+
* minimal set of DOM mutations against the live tree. Element identity (and
|
|
55
|
+
* thus focus, selection, in-flight pointer interactions, and event listeners
|
|
56
|
+
* on preserved nodes) is preserved wherever the keyed/positional diff matches.
|
|
57
|
+
*
|
|
58
|
+
* Compared to a `replaceChildren(...rows.map(toElement))` rebuild pattern, the
|
|
59
|
+
* user-visible win is that an `<input>` the user is typing into survives an
|
|
60
|
+
* unrelated re-render — its DOM node, focus state, and cursor position are
|
|
61
|
+
* not destroyed and recreated on each tick.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Bind `render()` to the children of `rootEl`. Re-runs whenever any signal
|
|
66
|
+
* read inside `render()` changes. Returns a disposer that tears down the
|
|
67
|
+
* effect; call it when the host element is removed from the DOM.
|
|
68
|
+
*
|
|
69
|
+
* Conventions:
|
|
70
|
+
*
|
|
71
|
+
* - Diff keys: `id` and `data-key` are matched across the morph by key
|
|
72
|
+
* rather than positionally, so list reorders move existing nodes instead
|
|
73
|
+
* of churning unrelated siblings.
|
|
74
|
+
* - `data-morph-skip`: any element with this attribute is left untouched
|
|
75
|
+
* inside on subsequent renders. Used for library-owned subtrees (xterm-
|
|
76
|
+
* style widgets, charts, third-party editors) where the library's own
|
|
77
|
+
* lifecycle manages the children.
|
|
78
|
+
* - Focused text-entry inputs (`<input>` of typing kinds, `<textarea>`,
|
|
79
|
+
* `[contenteditable]`) keep their current value + selection range across
|
|
80
|
+
* morphs while focused. The user never sees their cursor jump mid-keystroke.
|
|
81
|
+
*/
|
|
82
|
+
declare function mount(rootEl: HTMLElement, render: () => SafeHtml | string): () => void;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `toElement(jsx)` — JSX → DOM, with SVG-aware namespace handling.
|
|
86
|
+
*
|
|
87
|
+
* The naive implementation parses JSX through a `<template>` element's
|
|
88
|
+
* `innerHTML`. That works for HTML and for SVG fragments whose root tag is
|
|
89
|
+
* `<svg>` (the parser switches to "foreign content" mode). It silently
|
|
90
|
+
* fails for SVG fragments WITHOUT an `<svg>` wrapper — descendants come out
|
|
91
|
+
* as `HTMLUnknownElement` and never paint.
|
|
92
|
+
*
|
|
93
|
+
* `toElement` detects SVG content and routes through `DOMParser` with the
|
|
94
|
+
* `image/svg+xml` MIME, which guarantees correct namespacing for all
|
|
95
|
+
* descendants. HTML content takes the original `<template>` path unchanged.
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
declare function toElement(jsx: SafeHtml | string): Element;
|
|
99
|
+
|
|
100
|
+
export { SafeHtml, delegate, delegateCapture, mount, toElement };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import morphdom from 'morphdom';
|
|
2
|
+
import { effect, signal } from '@preact/signals-core';
|
|
3
|
+
export { batch, computed, effect, signal } from '@preact/signals-core';
|
|
4
|
+
|
|
5
|
+
// src/delegate.ts
|
|
6
|
+
function assertValidSelector(selector, fn) {
|
|
7
|
+
try {
|
|
8
|
+
document.createElement("div").matches(selector);
|
|
9
|
+
} catch {
|
|
10
|
+
throw new Error(
|
|
11
|
+
`${fn}: invalid selector "${selector}". Pass a valid CSS selector (e.g. '[data-action="add"]', '.btn', 'input').`
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function delegate(rootEl, type, selector, handler) {
|
|
16
|
+
assertValidSelector(selector, "delegate");
|
|
17
|
+
const listener = (event) => {
|
|
18
|
+
const target = event.target;
|
|
19
|
+
if (!(target instanceof Element)) return;
|
|
20
|
+
const matched = target.closest(selector);
|
|
21
|
+
if (matched !== null && rootEl.contains(matched)) {
|
|
22
|
+
handler(event, matched);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
rootEl.addEventListener(type, listener);
|
|
26
|
+
return () => {
|
|
27
|
+
rootEl.removeEventListener(type, listener);
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function delegateCapture(rootEl, type, selector, handler) {
|
|
31
|
+
assertValidSelector(selector, "delegateCapture");
|
|
32
|
+
const listener = (event) => {
|
|
33
|
+
const target = event.target;
|
|
34
|
+
if (!(target instanceof Element)) return;
|
|
35
|
+
if (target.matches(selector) && rootEl.contains(target)) {
|
|
36
|
+
handler(event, target);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
rootEl.addEventListener(type, listener, true);
|
|
40
|
+
return () => {
|
|
41
|
+
rootEl.removeEventListener(type, listener, true);
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// src/jsx-runtime.ts
|
|
46
|
+
var SafeHtml = class {
|
|
47
|
+
__html;
|
|
48
|
+
constructor(html) {
|
|
49
|
+
this.__html = html;
|
|
50
|
+
}
|
|
51
|
+
toString() {
|
|
52
|
+
return this.__html;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
function raw(html) {
|
|
56
|
+
return new SafeHtml(html);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// src/mount.ts
|
|
60
|
+
function mount(rootEl, render) {
|
|
61
|
+
return effect(() => {
|
|
62
|
+
const next = render();
|
|
63
|
+
const html = next instanceof SafeHtml ? next.toString() : next;
|
|
64
|
+
const template = rootEl.cloneNode(false);
|
|
65
|
+
template.innerHTML = html;
|
|
66
|
+
morphdom(rootEl, template, {
|
|
67
|
+
childrenOnly: true,
|
|
68
|
+
getNodeKey: (node) => {
|
|
69
|
+
if (node.nodeType !== 1) return void 0;
|
|
70
|
+
const el = node;
|
|
71
|
+
if (el.id !== "") return el.id;
|
|
72
|
+
if (el.dataset.key != null) return `key:${el.dataset.key}`;
|
|
73
|
+
return void 0;
|
|
74
|
+
},
|
|
75
|
+
onBeforeElUpdated: (fromEl, toEl) => {
|
|
76
|
+
if (fromEl.dataset.morphSkip != null) return false;
|
|
77
|
+
if (fromEl.isEqualNode(toEl)) return false;
|
|
78
|
+
if (fromEl === document.activeElement && isTextEntry(fromEl)) {
|
|
79
|
+
preserveTextEntryState(fromEl, toEl);
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
function isTextEntry(el) {
|
|
87
|
+
if (el.tagName === "TEXTAREA") return true;
|
|
88
|
+
if (el.tagName === "INPUT") {
|
|
89
|
+
const type = el.type;
|
|
90
|
+
return type === "text" || type === "search" || type === "url" || type === "email" || type === "tel" || type === "password" || type === "";
|
|
91
|
+
}
|
|
92
|
+
return el.isContentEditable;
|
|
93
|
+
}
|
|
94
|
+
function preserveTextEntryState(fromEl, toEl) {
|
|
95
|
+
if (fromEl.tagName === "TEXTAREA" || fromEl.tagName === "INPUT") {
|
|
96
|
+
const fromInput = fromEl;
|
|
97
|
+
const toInput = toEl;
|
|
98
|
+
toInput.value = fromInput.value;
|
|
99
|
+
try {
|
|
100
|
+
toInput.setSelectionRange(fromInput.selectionStart, fromInput.selectionEnd);
|
|
101
|
+
} catch {
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// src/store.ts
|
|
107
|
+
var REGISTRY = [];
|
|
108
|
+
function defineStore(spec) {
|
|
109
|
+
const internal = signal(spec.initial());
|
|
110
|
+
const set = (next) => {
|
|
111
|
+
internal.value = next;
|
|
112
|
+
};
|
|
113
|
+
const get = () => internal.value;
|
|
114
|
+
const actions = spec.actions(set, get);
|
|
115
|
+
const store = {
|
|
116
|
+
state: internal,
|
|
117
|
+
actions,
|
|
118
|
+
reset() {
|
|
119
|
+
internal.value = spec.initial();
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
REGISTRY.push(store);
|
|
123
|
+
return store;
|
|
124
|
+
}
|
|
125
|
+
function resetAllStores() {
|
|
126
|
+
for (const s of REGISTRY) s.reset();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// src/toElement.ts
|
|
130
|
+
var SVG_NS = "http://www.w3.org/2000/svg";
|
|
131
|
+
var SVG_FRAGMENT_TAGS = /* @__PURE__ */ new Set([
|
|
132
|
+
"g",
|
|
133
|
+
"path",
|
|
134
|
+
"circle",
|
|
135
|
+
"rect",
|
|
136
|
+
"line",
|
|
137
|
+
"polygon",
|
|
138
|
+
"polyline",
|
|
139
|
+
"ellipse",
|
|
140
|
+
"text",
|
|
141
|
+
"tspan",
|
|
142
|
+
"defs",
|
|
143
|
+
"use",
|
|
144
|
+
"symbol",
|
|
145
|
+
"clipPath",
|
|
146
|
+
"mask",
|
|
147
|
+
"pattern",
|
|
148
|
+
"filter",
|
|
149
|
+
"marker",
|
|
150
|
+
"linearGradient",
|
|
151
|
+
"radialGradient",
|
|
152
|
+
"stop",
|
|
153
|
+
"image",
|
|
154
|
+
"foreignObject"
|
|
155
|
+
]);
|
|
156
|
+
function leadingTag(html) {
|
|
157
|
+
const match = /^\s*<([a-zA-Z][a-zA-Z0-9]*)\b/.exec(html);
|
|
158
|
+
return match !== null ? match[1] : null;
|
|
159
|
+
}
|
|
160
|
+
function excerpt(html) {
|
|
161
|
+
const trimmed = html.trim();
|
|
162
|
+
return trimmed.length > 100 ? `${trimmed.slice(0, 100)}\u2026` : trimmed;
|
|
163
|
+
}
|
|
164
|
+
function toElement(jsx) {
|
|
165
|
+
const html = typeof jsx === "string" ? jsx : jsx.toString();
|
|
166
|
+
const tag = leadingTag(html);
|
|
167
|
+
if (tag === "svg") {
|
|
168
|
+
const doc = new DOMParser().parseFromString(html, "image/svg+xml");
|
|
169
|
+
const err = doc.querySelector("parsererror");
|
|
170
|
+
if (err !== null) {
|
|
171
|
+
throw new Error(`toElement: SVG parse error \u2014 ${err.textContent}
|
|
172
|
+
input: ${excerpt(html)}`);
|
|
173
|
+
}
|
|
174
|
+
return doc.documentElement;
|
|
175
|
+
}
|
|
176
|
+
if (tag !== null && SVG_FRAGMENT_TAGS.has(tag)) {
|
|
177
|
+
const wrapped = `<svg xmlns="${SVG_NS}">${html}</svg>`;
|
|
178
|
+
const doc = new DOMParser().parseFromString(wrapped, "image/svg+xml");
|
|
179
|
+
const err = doc.querySelector("parsererror");
|
|
180
|
+
if (err !== null) {
|
|
181
|
+
throw new Error(`toElement: SVG fragment parse error \u2014 ${err.textContent}
|
|
182
|
+
input: ${excerpt(html)}`);
|
|
183
|
+
}
|
|
184
|
+
const first = doc.documentElement.firstElementChild;
|
|
185
|
+
if (first === null) throw new Error(`toElement: SVG fragment produced no element
|
|
186
|
+
input: ${excerpt(html)}`);
|
|
187
|
+
return first;
|
|
188
|
+
}
|
|
189
|
+
const t = document.createElement("template");
|
|
190
|
+
t.innerHTML = html;
|
|
191
|
+
const child = t.content.firstElementChild;
|
|
192
|
+
if (child === null) throw new Error(`toElement: produced no element
|
|
193
|
+
input: ${excerpt(html)}`);
|
|
194
|
+
return child;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export { SafeHtml, defineStore, delegate, delegateCapture, mount, raw, resetAllStores, toElement };
|
|
198
|
+
//# sourceMappingURL=index.js.map
|
|
199
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/delegate.ts","../src/jsx-runtime.ts","../src/mount.ts","../src/store.ts","../src/toElement.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Tiny event-delegation helpers. Replace per-element `addEventListener` calls\n * (which don't survive morph re-renders for nodes morphdom creates) with one\n * listener at the morph-root that dispatches via `closest()`.\n *\n * Three-tier listener model:\n *\n * - Tier 1 (bubbling events) — use `delegate()`.\n * click, input, change, submit, keydown/keyup, pointer*, drag*, drop,\n * contextmenu, wheel, copy/paste/cut.\n *\n * - Tier 2 (non-bubbling events: focus / blur / scroll / load / error) —\n * use `delegateCapture()`. The capture phase fires on the way down from\n * the root to the target, so a root-level listener with `capture: true`\n * reaches events that wouldn't bubble back up.\n *\n * - Tier 3 (per-element instances / library-owned subtrees) — mark the\n * host element with `data-morph-skip` and manage the library's\n * lifecycle directly. No delegation helper applies.\n */\n\ntype Handler = (event: Event, target: Element) => void;\n\n/**\n * Validate a CSS selector at registration time, so a typo throws immediately\n * with the bad selector quoted instead of producing a cryptic DOMException\n * the first time a matching event fires.\n */\nfunction assertValidSelector(selector: string, fn: string): void {\n try {\n document.createElement('div').matches(selector);\n } catch {\n throw new Error(\n `${fn}: invalid selector \"${selector}\". `\n + 'Pass a valid CSS selector (e.g. \\'[data-action=\"add\"]\\', \\'.btn\\', \\'input\\').',\n );\n }\n}\n\n/**\n * Bubble-phase delegation. Installs ONE listener on `rootEl` for the given\n * event type. When the event fires, walks up from `event.target` to the root\n * looking for an element matching `selector`; if found, fires `handler` with\n * the matched element as the second arg.\n *\n * Returns a disposer that removes the listener.\n *\n * Usage (pseudo-code — see examples for live ones):\n * delegate(rootEl, 'click', '[data-action=\"add\"]', handlerFn);\n */\nexport function delegate(\n rootEl: HTMLElement,\n type: string,\n selector: string,\n handler: Handler,\n): () => void {\n assertValidSelector(selector, 'delegate');\n const listener = (event: Event): void => {\n const target = event.target;\n if (!(target instanceof Element)) return;\n const matched = target.closest(selector);\n if (matched !== null && rootEl.contains(matched)) {\n handler(event, matched);\n }\n };\n rootEl.addEventListener(type, listener);\n return () => {\n rootEl.removeEventListener(type, listener);\n };\n}\n\n/**\n * Capture-phase delegation — for non-bubbling events (`focus`, `blur`,\n * `scroll`, `load`, `error`). Reaches descendants of `rootEl` that match\n * `selector` regardless of how many times morphdom has rebuilt them.\n *\n * Usage (pseudo-code — see examples for live ones):\n * delegateCapture(rootEl, 'focus', 'input, textarea', handlerFn);\n */\nexport function delegateCapture(\n rootEl: HTMLElement,\n type: string,\n selector: string,\n handler: Handler,\n): () => void {\n assertValidSelector(selector, 'delegateCapture');\n const listener = (event: Event): void => {\n const target = event.target;\n if (!(target instanceof Element)) return;\n if (target.matches(selector) && rootEl.contains(target)) {\n handler(event, target);\n }\n };\n rootEl.addEventListener(type, listener, true);\n return () => {\n rootEl.removeEventListener(type, listener, true);\n };\n}\n","/**\n * kerf JSX runtime.\n *\n * JSX renders to `SafeHtml` — a wrapped HTML string. `SafeHtml.toString()`\n * is what the consumer eventually feeds into `mount()` (which morphs the\n * live DOM toward the new tree) or into `toElement()` (which parses it\n * to a single DOM node).\n *\n * Configure in your `tsconfig.json`:\n *\n * \"jsx\": \"react-jsx\",\n * \"jsxImportSource\": \"kerfjs\"\n *\n * Then write JSX as you normally would — kerf provides the `jsx` /\n * `jsxs` / `jsxDEV` / `Fragment` exports the JSX transform looks for.\n */\n\nimport { escapeAttr, escapeHtml } from './utils/escapeHtml.js';\n\nexport class SafeHtml {\n readonly __html: string;\n constructor(html: string) {\n this.__html = html;\n }\n toString(): string {\n return this.__html;\n }\n}\n\n/** Inject a pre-escaped HTML string. Use sparingly — caller is responsible for escaping. */\nexport function raw(html: string): SafeHtml {\n return new SafeHtml(html);\n}\n\ntype Child = SafeHtml | string | number | boolean | null | undefined;\ntype Children = Child | Children[];\n\ninterface Props {\n children?: Children;\n [key: string]: unknown;\n}\n\nconst VOID_TAGS = new Set([\n 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',\n 'link', 'meta', 'source', 'track', 'wbr',\n]);\n\nfunction renderChildren(children: Children): string {\n if (children == null || typeof children === 'boolean') return '';\n if (children instanceof SafeHtml) return children.__html;\n if (typeof children === 'string') return escapeHtml(children);\n if (typeof children === 'number') return String(children);\n if (Array.isArray(children)) return children.map(renderChildren).join('');\n // Catch the common mistake of passing a DOM element (e.g. the result of\n // toElement(...)) as a JSX child. The runtime renders to HTML strings, so\n // DOM nodes can't be composed — they'd silently serialize to \"\" and their\n // event listeners would be lost. Throw loudly so this can't sneak in.\n const maybeNode = children as unknown;\n if (typeof maybeNode === 'object' && maybeNode !== null\n && ('nodeType' in maybeNode || 'outerHTML' in maybeNode)) {\n throw new Error(\n 'JSX: DOM elements cannot be passed as children (the JSX runtime renders to HTML strings). '\n + 'Build the tree in one JSX expression and use querySelector after toElement() to get element refs.',\n );\n }\n throw new Error(\n `JSX: unsupported child of type ${describeValue(children)}. `\n + 'Children must be SafeHtml, string, number, boolean, null, undefined, or an array of those. '\n + 'Common mistakes: passing a Signal/Store object directly (use signal.value or store.state.value), '\n + 'passing a function (call it first), or passing a Promise (await it before render).',\n );\n}\n\nfunction describeValue(v: unknown): string {\n if (Array.isArray(v)) return 'array';\n if (typeof v === 'object' && v !== null) {\n const ctor = (v as { constructor?: { name?: string } }).constructor?.name;\n return ctor && ctor !== 'Object' ? `object (${ctor})` : 'object';\n }\n return typeof v;\n}\n\nconst ATTR_ALIASES: Record<string, string> = {\n // HTML attributes\n className: 'class',\n htmlFor: 'for',\n httpEquiv: 'http-equiv',\n acceptCharset: 'accept-charset',\n accessKey: 'accesskey',\n autoCapitalize: 'autocapitalize',\n autoComplete: 'autocomplete',\n autoFocus: 'autofocus',\n autoPlay: 'autoplay',\n colSpan: 'colspan',\n contentEditable: 'contenteditable',\n crossOrigin: 'crossorigin',\n dateTime: 'datetime',\n defaultChecked: 'checked',\n defaultValue: 'value',\n encType: 'enctype',\n formAction: 'formaction',\n formEncType: 'formenctype',\n formMethod: 'formmethod',\n formNoValidate: 'formnovalidate',\n formTarget: 'formtarget',\n hrefLang: 'hreflang',\n inputMode: 'inputmode',\n maxLength: 'maxlength',\n minLength: 'minlength',\n noModule: 'nomodule',\n noValidate: 'novalidate',\n readOnly: 'readonly',\n referrerPolicy: 'referrerpolicy',\n rowSpan: 'rowspan',\n spellCheck: 'spellcheck',\n srcDoc: 'srcdoc',\n srcLang: 'srclang',\n srcSet: 'srcset',\n tabIndex: 'tabindex',\n useMap: 'usemap',\n\n // SVG presentation attributes (camelCase → kebab-case)\n strokeWidth: 'stroke-width',\n strokeLinecap: 'stroke-linecap',\n strokeLinejoin: 'stroke-linejoin',\n strokeDasharray: 'stroke-dasharray',\n strokeDashoffset: 'stroke-dashoffset',\n strokeMiterlimit: 'stroke-miterlimit',\n strokeOpacity: 'stroke-opacity',\n fillOpacity: 'fill-opacity',\n fillRule: 'fill-rule',\n clipPath: 'clip-path',\n clipRule: 'clip-rule',\n colorInterpolation: 'color-interpolation',\n colorInterpolationFilters: 'color-interpolation-filters',\n floodColor: 'flood-color',\n floodOpacity: 'flood-opacity',\n lightingColor: 'lighting-color',\n stopColor: 'stop-color',\n stopOpacity: 'stop-opacity',\n shapeRendering: 'shape-rendering',\n imageRendering: 'image-rendering',\n textRendering: 'text-rendering',\n pointerEvents: 'pointer-events',\n vectorEffect: 'vector-effect',\n paintOrder: 'paint-order',\n\n // SVG text/font attributes\n fontFamily: 'font-family',\n fontSize: 'font-size',\n fontStyle: 'font-style',\n fontVariant: 'font-variant',\n fontWeight: 'font-weight',\n fontStretch: 'font-stretch',\n textAnchor: 'text-anchor',\n textDecoration: 'text-decoration',\n dominantBaseline: 'dominant-baseline',\n alignmentBaseline: 'alignment-baseline',\n baselineShift: 'baseline-shift',\n letterSpacing: 'letter-spacing',\n wordSpacing: 'word-spacing',\n writingMode: 'writing-mode',\n\n // SVG marker attributes\n markerStart: 'marker-start',\n markerMid: 'marker-mid',\n markerEnd: 'marker-end',\n\n // SVG xlink (legacy but still used)\n xlinkHref: 'xlink:href',\n xlinkShow: 'xlink:show',\n xlinkActuate: 'xlink:actuate',\n xlinkType: 'xlink:type',\n xlinkRole: 'xlink:role',\n xlinkTitle: 'xlink:title',\n xlinkArcrole: 'xlink:arcrole',\n xmlBase: 'xml:base',\n xmlLang: 'xml:lang',\n xmlSpace: 'xml:space',\n xmlnsXlink: 'xmlns:xlink',\n};\n\nfunction renderAttr(key: string, value: unknown): string {\n const name = ATTR_ALIASES[key] ?? key;\n if (value == null || value === false) return '';\n if (value === true) return ` ${name}`;\n let strValue: string;\n if (value instanceof SafeHtml) {\n strValue = value.__html;\n } else if (typeof value === 'number') {\n strValue = String(value);\n } else if (typeof value === 'string') {\n strValue = escapeAttr(value);\n } else {\n throw new Error(\n `JSX: unsupported value for attribute \"${key}\" — got ${describeValue(value)}. `\n + 'Attribute values must be string, number, boolean, null, undefined, or SafeHtml. '\n + 'Did you mean to read .value off a Signal, or stringify the object first?',\n );\n }\n return ` ${name}=\"${strValue}\"`;\n}\n\nexport function jsx(tag: string | ((props: Props) => SafeHtml), props: Props): SafeHtml {\n if (typeof tag === 'function') return tag(props);\n\n const { children, ...attrs } = props;\n const attrStr = Object.entries(attrs)\n .map(([k, v]) => renderAttr(k, v))\n .join('');\n\n if (VOID_TAGS.has(tag)) return new SafeHtml(`<${tag}${attrStr}>`);\n\n const childStr = children != null ? renderChildren(children) : '';\n return new SafeHtml(`<${tag}${attrStr}>${childStr}</${tag}>`);\n}\n\nexport { jsx as jsxs };\n// vitest's dev-mode JSX transform emits `jsxDEV(tag, props, ...)`; the\n// alias lets tests import this module without the production build pipeline\n// caring.\nexport { jsx as jsxDEV };\n\nexport function Fragment({ children }: { children?: Children }): SafeHtml {\n return new SafeHtml(children != null ? renderChildren(children) : '');\n}\n\n// eslint-disable-next-line @typescript-eslint/no-namespace\nexport namespace JSX {\n export type Element = SafeHtml;\n export interface ElementChildrenAttribute {\n children: unknown;\n }\n export interface IntrinsicElements {\n [elemName: string]: Record<string, unknown>;\n }\n}\n","/**\n * `mount(rootEl, render)` — kerf's render primitive.\n *\n * Wraps `effect()` from `reactive.ts` so that whenever any signal read inside\n * `render()` changes, we re-run `render()` and use `morphdom` to apply the\n * minimal set of DOM mutations against the live tree. Element identity (and\n * thus focus, selection, in-flight pointer interactions, and event listeners\n * on preserved nodes) is preserved wherever the keyed/positional diff matches.\n *\n * Compared to a `replaceChildren(...rows.map(toElement))` rebuild pattern, the\n * user-visible win is that an `<input>` the user is typing into survives an\n * unrelated re-render — its DOM node, focus state, and cursor position are\n * not destroyed and recreated on each tick.\n */\n\nimport morphdom from 'morphdom';\n\nimport { SafeHtml } from './jsx-runtime.js';\nimport { effect } from './reactive.js';\n\n/**\n * Bind `render()` to the children of `rootEl`. Re-runs whenever any signal\n * read inside `render()` changes. Returns a disposer that tears down the\n * effect; call it when the host element is removed from the DOM.\n *\n * Conventions:\n *\n * - Diff keys: `id` and `data-key` are matched across the morph by key\n * rather than positionally, so list reorders move existing nodes instead\n * of churning unrelated siblings.\n * - `data-morph-skip`: any element with this attribute is left untouched\n * inside on subsequent renders. Used for library-owned subtrees (xterm-\n * style widgets, charts, third-party editors) where the library's own\n * lifecycle manages the children.\n * - Focused text-entry inputs (`<input>` of typing kinds, `<textarea>`,\n * `[contenteditable]`) keep their current value + selection range across\n * morphs while focused. The user never sees their cursor jump mid-keystroke.\n */\nexport function mount(rootEl: HTMLElement, render: () => SafeHtml | string): () => void {\n return effect(() => {\n const next = render();\n const html = next instanceof SafeHtml ? next.toString() : next;\n\n const template = rootEl.cloneNode(false) as HTMLElement;\n template.innerHTML = html;\n\n morphdom(rootEl, template, {\n childrenOnly: true,\n getNodeKey: (node) => {\n if (node.nodeType !== 1) return undefined;\n const el = node as HTMLElement;\n if (el.id !== '') return el.id;\n if (el.dataset.key != null) return `key:${el.dataset.key}`;\n return undefined;\n },\n onBeforeElUpdated: (fromEl, toEl) => {\n if (fromEl.dataset.morphSkip != null) return false;\n if (fromEl.isEqualNode(toEl)) return false;\n if (fromEl === document.activeElement && isTextEntry(fromEl)) {\n preserveTextEntryState(fromEl, toEl);\n }\n return true;\n },\n });\n });\n}\n\nfunction isTextEntry(el: Element): boolean {\n if (el.tagName === 'TEXTAREA') return true;\n if (el.tagName === 'INPUT') {\n const type = (el as HTMLInputElement).type;\n return type === 'text' || type === 'search' || type === 'url' || type === 'email'\n || type === 'tel' || type === 'password' || type === '';\n }\n return (el as HTMLElement).isContentEditable;\n}\n\nfunction preserveTextEntryState(fromEl: HTMLElement, toEl: HTMLElement): void {\n if (fromEl.tagName === 'TEXTAREA' || fromEl.tagName === 'INPUT') {\n const fromInput = fromEl as HTMLInputElement;\n const toInput = toEl as HTMLInputElement;\n toInput.value = fromInput.value;\n try {\n toInput.setSelectionRange(fromInput.selectionStart, fromInput.selectionEnd);\n } catch {\n // Some input types (number, range, color, …) reject selection APIs.\n }\n }\n}\n","/**\n * `defineStore({ initial, actions })` — composable testable stores layered on\n * top of `reactive.ts`'s signals.\n *\n * Three rules:\n * 1. `state` is read-only. Consumers read via `state.value` or subscribe via\n * `effect()`. They cannot write directly.\n * 2. `actions` is the only mutation surface. All writes go through named\n * action functions. This is what makes stores testable — assert against\n * actions, not against arbitrary writes.\n * 3. `reset()` resets to `initial()`. Always defined; tests use it for\n * setup, lifecycle hooks (route change, sign-out, etc.) use it for\n * tear-down.\n *\n * A module-level registry tracks every store created via `defineStore()`;\n * `resetAllStores()` walks the registry and calls each `reset()`. Useful for\n * tests + project-switch / logout / route-reset scenarios where every piece\n * of client state should return to its initial shape.\n */\n\nimport type { ReadonlySignal, Signal } from './reactive.js';\nimport { signal } from './reactive.js';\n\nexport interface Store<TState, TActions> {\n /** Read-only reactive view. Consumers read `state.value` or subscribe via `effect()`. */\n readonly state: ReadonlySignal<TState>;\n /** Named mutators — the only way to change state. */\n readonly actions: TActions;\n /** Reset state to `initial()`. Used by tests and lifecycle hooks. */\n reset(): void;\n}\n\ninterface DefineStoreSpec<TState, TActions> {\n initial: () => TState;\n actions: (set: (next: TState) => void, get: () => TState) => TActions;\n}\n\nconst REGISTRY: Array<{ reset: () => void }> = [];\n\nexport function defineStore<TState, TActions>(\n spec: DefineStoreSpec<TState, TActions>,\n): Store<TState, TActions> {\n const internal: Signal<TState> = signal(spec.initial());\n\n const set = (next: TState): void => {\n internal.value = next;\n };\n const get = (): TState => internal.value;\n\n const actions = spec.actions(set, get);\n\n const store: Store<TState, TActions> = {\n state: internal,\n actions,\n reset() {\n internal.value = spec.initial();\n },\n };\n\n REGISTRY.push(store);\n return store;\n}\n\n/**\n * Reset every store registered via `defineStore()` to its `initial()` value.\n * Used by tests and by application lifecycle hooks (project switch, logout,\n * route reset).\n */\nexport function resetAllStores(): void {\n for (const s of REGISTRY) s.reset();\n}\n\n/**\n * Test helper — clears the registry. Exposed via the `kerfjs/testing` subpath,\n * not the main `kerfjs` entry. Unit tests use it to isolate stores between cases.\n */\nexport function clearStoreRegistry(): void {\n REGISTRY.length = 0;\n}\n","/**\n * `toElement(jsx)` — JSX → DOM, with SVG-aware namespace handling.\n *\n * The naive implementation parses JSX through a `<template>` element's\n * `innerHTML`. That works for HTML and for SVG fragments whose root tag is\n * `<svg>` (the parser switches to \"foreign content\" mode). It silently\n * fails for SVG fragments WITHOUT an `<svg>` wrapper — descendants come out\n * as `HTMLUnknownElement` and never paint.\n *\n * `toElement` detects SVG content and routes through `DOMParser` with the\n * `image/svg+xml` MIME, which guarantees correct namespacing for all\n * descendants. HTML content takes the original `<template>` path unchanged.\n */\n\nimport type { SafeHtml } from './jsx-runtime.js';\n\nconst SVG_NS = 'http://www.w3.org/2000/svg';\n\nconst SVG_FRAGMENT_TAGS = new Set([\n 'g', 'path', 'circle', 'rect', 'line', 'polygon', 'polyline', 'ellipse',\n 'text', 'tspan', 'defs', 'use', 'symbol', 'clipPath', 'mask', 'pattern',\n 'filter', 'marker', 'linearGradient', 'radialGradient', 'stop', 'image',\n 'foreignObject',\n]);\n\nfunction leadingTag(html: string): string | null {\n const match = /^\\s*<([a-zA-Z][a-zA-Z0-9]*)\\b/.exec(html);\n return match !== null ? match[1] : null;\n}\n\nfunction excerpt(html: string): string {\n const trimmed = html.trim();\n return trimmed.length > 100 ? `${trimmed.slice(0, 100)}…` : trimmed;\n}\n\nexport function toElement(jsx: SafeHtml | string): Element {\n const html = typeof jsx === 'string' ? jsx : jsx.toString();\n const tag = leadingTag(html);\n\n if (tag === 'svg') {\n // SVG root — parse as XML to guarantee namespace propagation.\n const doc = new DOMParser().parseFromString(html, 'image/svg+xml');\n const err = doc.querySelector('parsererror');\n if (err !== null) {\n throw new Error(`toElement: SVG parse error — ${err.textContent}\\n input: ${excerpt(html)}`);\n }\n return doc.documentElement;\n }\n\n if (tag !== null && SVG_FRAGMENT_TAGS.has(tag)) {\n // SVG fragment without an <svg> wrapper — wrap, parse, unwrap.\n const wrapped = `<svg xmlns=\"${SVG_NS}\">${html}</svg>`;\n const doc = new DOMParser().parseFromString(wrapped, 'image/svg+xml');\n const err = doc.querySelector('parsererror');\n if (err !== null) {\n throw new Error(`toElement: SVG fragment parse error — ${err.textContent}\\n input: ${excerpt(html)}`);\n }\n const first = doc.documentElement.firstElementChild;\n /* c8 ignore next 2 — defensive: a successful XML parse of a wrapped svg always yields ≥1 child. */\n if (first === null) throw new Error(`toElement: SVG fragment produced no element\\n input: ${excerpt(html)}`);\n return first;\n }\n\n // HTML — `<template>`-based parse.\n const t = document.createElement('template');\n t.innerHTML = html;\n const child = t.content.firstElementChild;\n if (child === null) throw new Error(`toElement: produced no element\\n input: ${excerpt(html)}`);\n return child;\n}\n"]}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kerf JSX runtime.
|
|
3
|
+
*
|
|
4
|
+
* JSX renders to `SafeHtml` — a wrapped HTML string. `SafeHtml.toString()`
|
|
5
|
+
* is what the consumer eventually feeds into `mount()` (which morphs the
|
|
6
|
+
* live DOM toward the new tree) or into `toElement()` (which parses it
|
|
7
|
+
* to a single DOM node).
|
|
8
|
+
*
|
|
9
|
+
* Configure in your `tsconfig.json`:
|
|
10
|
+
*
|
|
11
|
+
* "jsx": "react-jsx",
|
|
12
|
+
* "jsxImportSource": "kerfjs"
|
|
13
|
+
*
|
|
14
|
+
* Then write JSX as you normally would — kerf provides the `jsx` /
|
|
15
|
+
* `jsxs` / `jsxDEV` / `Fragment` exports the JSX transform looks for.
|
|
16
|
+
*/
|
|
17
|
+
declare class SafeHtml {
|
|
18
|
+
readonly __html: string;
|
|
19
|
+
constructor(html: string);
|
|
20
|
+
toString(): string;
|
|
21
|
+
}
|
|
22
|
+
/** Inject a pre-escaped HTML string. Use sparingly — caller is responsible for escaping. */
|
|
23
|
+
declare function raw(html: string): SafeHtml;
|
|
24
|
+
type Child = SafeHtml | string | number | boolean | null | undefined;
|
|
25
|
+
type Children = Child | Children[];
|
|
26
|
+
interface Props {
|
|
27
|
+
children?: Children;
|
|
28
|
+
[key: string]: unknown;
|
|
29
|
+
}
|
|
30
|
+
declare function jsx(tag: string | ((props: Props) => SafeHtml), props: Props): SafeHtml;
|
|
31
|
+
|
|
32
|
+
declare function Fragment({ children }: {
|
|
33
|
+
children?: Children;
|
|
34
|
+
}): SafeHtml;
|
|
35
|
+
declare namespace JSX {
|
|
36
|
+
type Element = SafeHtml;
|
|
37
|
+
interface ElementChildrenAttribute {
|
|
38
|
+
children: unknown;
|
|
39
|
+
}
|
|
40
|
+
interface IntrinsicElements {
|
|
41
|
+
[elemName: string]: Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export { Fragment, JSX, SafeHtml, jsx, jsx as jsxDEV, jsx as jsxs, raw };
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// src/utils/escapeHtml.ts
|
|
2
|
+
function escapeHtml(str) {
|
|
3
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
4
|
+
}
|
|
5
|
+
function escapeAttr(str) {
|
|
6
|
+
return str.replace(/&/g, "&").replace(/"/g, """).replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// src/jsx-runtime.ts
|
|
10
|
+
var SafeHtml = class {
|
|
11
|
+
__html;
|
|
12
|
+
constructor(html) {
|
|
13
|
+
this.__html = html;
|
|
14
|
+
}
|
|
15
|
+
toString() {
|
|
16
|
+
return this.__html;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
function raw(html) {
|
|
20
|
+
return new SafeHtml(html);
|
|
21
|
+
}
|
|
22
|
+
var VOID_TAGS = /* @__PURE__ */ new Set([
|
|
23
|
+
"area",
|
|
24
|
+
"base",
|
|
25
|
+
"br",
|
|
26
|
+
"col",
|
|
27
|
+
"embed",
|
|
28
|
+
"hr",
|
|
29
|
+
"img",
|
|
30
|
+
"input",
|
|
31
|
+
"link",
|
|
32
|
+
"meta",
|
|
33
|
+
"source",
|
|
34
|
+
"track",
|
|
35
|
+
"wbr"
|
|
36
|
+
]);
|
|
37
|
+
function renderChildren(children) {
|
|
38
|
+
if (children == null || typeof children === "boolean") return "";
|
|
39
|
+
if (children instanceof SafeHtml) return children.__html;
|
|
40
|
+
if (typeof children === "string") return escapeHtml(children);
|
|
41
|
+
if (typeof children === "number") return String(children);
|
|
42
|
+
if (Array.isArray(children)) return children.map(renderChildren).join("");
|
|
43
|
+
const maybeNode = children;
|
|
44
|
+
if (typeof maybeNode === "object" && maybeNode !== null && ("nodeType" in maybeNode || "outerHTML" in maybeNode)) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
"JSX: DOM elements cannot be passed as children (the JSX runtime renders to HTML strings). Build the tree in one JSX expression and use querySelector after toElement() to get element refs."
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
throw new Error(
|
|
50
|
+
`JSX: unsupported child of type ${describeValue(children)}. Children must be SafeHtml, string, number, boolean, null, undefined, or an array of those. Common mistakes: passing a Signal/Store object directly (use signal.value or store.state.value), passing a function (call it first), or passing a Promise (await it before render).`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
function describeValue(v) {
|
|
54
|
+
if (Array.isArray(v)) return "array";
|
|
55
|
+
if (typeof v === "object" && v !== null) {
|
|
56
|
+
const ctor = v.constructor?.name;
|
|
57
|
+
return ctor && ctor !== "Object" ? `object (${ctor})` : "object";
|
|
58
|
+
}
|
|
59
|
+
return typeof v;
|
|
60
|
+
}
|
|
61
|
+
var ATTR_ALIASES = {
|
|
62
|
+
// HTML attributes
|
|
63
|
+
className: "class",
|
|
64
|
+
htmlFor: "for",
|
|
65
|
+
httpEquiv: "http-equiv",
|
|
66
|
+
acceptCharset: "accept-charset",
|
|
67
|
+
accessKey: "accesskey",
|
|
68
|
+
autoCapitalize: "autocapitalize",
|
|
69
|
+
autoComplete: "autocomplete",
|
|
70
|
+
autoFocus: "autofocus",
|
|
71
|
+
autoPlay: "autoplay",
|
|
72
|
+
colSpan: "colspan",
|
|
73
|
+
contentEditable: "contenteditable",
|
|
74
|
+
crossOrigin: "crossorigin",
|
|
75
|
+
dateTime: "datetime",
|
|
76
|
+
defaultChecked: "checked",
|
|
77
|
+
defaultValue: "value",
|
|
78
|
+
encType: "enctype",
|
|
79
|
+
formAction: "formaction",
|
|
80
|
+
formEncType: "formenctype",
|
|
81
|
+
formMethod: "formmethod",
|
|
82
|
+
formNoValidate: "formnovalidate",
|
|
83
|
+
formTarget: "formtarget",
|
|
84
|
+
hrefLang: "hreflang",
|
|
85
|
+
inputMode: "inputmode",
|
|
86
|
+
maxLength: "maxlength",
|
|
87
|
+
minLength: "minlength",
|
|
88
|
+
noModule: "nomodule",
|
|
89
|
+
noValidate: "novalidate",
|
|
90
|
+
readOnly: "readonly",
|
|
91
|
+
referrerPolicy: "referrerpolicy",
|
|
92
|
+
rowSpan: "rowspan",
|
|
93
|
+
spellCheck: "spellcheck",
|
|
94
|
+
srcDoc: "srcdoc",
|
|
95
|
+
srcLang: "srclang",
|
|
96
|
+
srcSet: "srcset",
|
|
97
|
+
tabIndex: "tabindex",
|
|
98
|
+
useMap: "usemap",
|
|
99
|
+
// SVG presentation attributes (camelCase → kebab-case)
|
|
100
|
+
strokeWidth: "stroke-width",
|
|
101
|
+
strokeLinecap: "stroke-linecap",
|
|
102
|
+
strokeLinejoin: "stroke-linejoin",
|
|
103
|
+
strokeDasharray: "stroke-dasharray",
|
|
104
|
+
strokeDashoffset: "stroke-dashoffset",
|
|
105
|
+
strokeMiterlimit: "stroke-miterlimit",
|
|
106
|
+
strokeOpacity: "stroke-opacity",
|
|
107
|
+
fillOpacity: "fill-opacity",
|
|
108
|
+
fillRule: "fill-rule",
|
|
109
|
+
clipPath: "clip-path",
|
|
110
|
+
clipRule: "clip-rule",
|
|
111
|
+
colorInterpolation: "color-interpolation",
|
|
112
|
+
colorInterpolationFilters: "color-interpolation-filters",
|
|
113
|
+
floodColor: "flood-color",
|
|
114
|
+
floodOpacity: "flood-opacity",
|
|
115
|
+
lightingColor: "lighting-color",
|
|
116
|
+
stopColor: "stop-color",
|
|
117
|
+
stopOpacity: "stop-opacity",
|
|
118
|
+
shapeRendering: "shape-rendering",
|
|
119
|
+
imageRendering: "image-rendering",
|
|
120
|
+
textRendering: "text-rendering",
|
|
121
|
+
pointerEvents: "pointer-events",
|
|
122
|
+
vectorEffect: "vector-effect",
|
|
123
|
+
paintOrder: "paint-order",
|
|
124
|
+
// SVG text/font attributes
|
|
125
|
+
fontFamily: "font-family",
|
|
126
|
+
fontSize: "font-size",
|
|
127
|
+
fontStyle: "font-style",
|
|
128
|
+
fontVariant: "font-variant",
|
|
129
|
+
fontWeight: "font-weight",
|
|
130
|
+
fontStretch: "font-stretch",
|
|
131
|
+
textAnchor: "text-anchor",
|
|
132
|
+
textDecoration: "text-decoration",
|
|
133
|
+
dominantBaseline: "dominant-baseline",
|
|
134
|
+
alignmentBaseline: "alignment-baseline",
|
|
135
|
+
baselineShift: "baseline-shift",
|
|
136
|
+
letterSpacing: "letter-spacing",
|
|
137
|
+
wordSpacing: "word-spacing",
|
|
138
|
+
writingMode: "writing-mode",
|
|
139
|
+
// SVG marker attributes
|
|
140
|
+
markerStart: "marker-start",
|
|
141
|
+
markerMid: "marker-mid",
|
|
142
|
+
markerEnd: "marker-end",
|
|
143
|
+
// SVG xlink (legacy but still used)
|
|
144
|
+
xlinkHref: "xlink:href",
|
|
145
|
+
xlinkShow: "xlink:show",
|
|
146
|
+
xlinkActuate: "xlink:actuate",
|
|
147
|
+
xlinkType: "xlink:type",
|
|
148
|
+
xlinkRole: "xlink:role",
|
|
149
|
+
xlinkTitle: "xlink:title",
|
|
150
|
+
xlinkArcrole: "xlink:arcrole",
|
|
151
|
+
xmlBase: "xml:base",
|
|
152
|
+
xmlLang: "xml:lang",
|
|
153
|
+
xmlSpace: "xml:space",
|
|
154
|
+
xmlnsXlink: "xmlns:xlink"
|
|
155
|
+
};
|
|
156
|
+
function renderAttr(key, value) {
|
|
157
|
+
const name = ATTR_ALIASES[key] ?? key;
|
|
158
|
+
if (value == null || value === false) return "";
|
|
159
|
+
if (value === true) return ` ${name}`;
|
|
160
|
+
let strValue;
|
|
161
|
+
if (value instanceof SafeHtml) {
|
|
162
|
+
strValue = value.__html;
|
|
163
|
+
} else if (typeof value === "number") {
|
|
164
|
+
strValue = String(value);
|
|
165
|
+
} else if (typeof value === "string") {
|
|
166
|
+
strValue = escapeAttr(value);
|
|
167
|
+
} else {
|
|
168
|
+
throw new Error(
|
|
169
|
+
`JSX: unsupported value for attribute "${key}" \u2014 got ${describeValue(value)}. Attribute values must be string, number, boolean, null, undefined, or SafeHtml. Did you mean to read .value off a Signal, or stringify the object first?`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
return ` ${name}="${strValue}"`;
|
|
173
|
+
}
|
|
174
|
+
function jsx(tag, props) {
|
|
175
|
+
if (typeof tag === "function") return tag(props);
|
|
176
|
+
const { children, ...attrs } = props;
|
|
177
|
+
const attrStr = Object.entries(attrs).map(([k, v]) => renderAttr(k, v)).join("");
|
|
178
|
+
if (VOID_TAGS.has(tag)) return new SafeHtml(`<${tag}${attrStr}>`);
|
|
179
|
+
const childStr = children != null ? renderChildren(children) : "";
|
|
180
|
+
return new SafeHtml(`<${tag}${attrStr}>${childStr}</${tag}>`);
|
|
181
|
+
}
|
|
182
|
+
function Fragment({ children }) {
|
|
183
|
+
return new SafeHtml(children != null ? renderChildren(children) : "");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export { Fragment, SafeHtml, jsx, jsx as jsxDEV, jsx as jsxs, raw };
|
|
187
|
+
//# sourceMappingURL=jsx-runtime.js.map
|
|
188
|
+
//# sourceMappingURL=jsx-runtime.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/utils/escapeHtml.ts","../src/jsx-runtime.ts"],"names":[],"mappings":"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","file":"jsx-runtime.js","sourcesContent":["/**\n * HTML / attribute escaping for the JSX runtime. Identical to the helpers\n * used in any reasonable HTML emitter — included here so kerf has no extra\n * runtime dependencies beyond `@preact/signals-core` and `morphdom`.\n */\n\nexport function escapeHtml(str: string): string {\n return str\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"');\n}\n\nexport function escapeAttr(str: string): string {\n return str\n .replace(/&/g, '&')\n .replace(/\"/g, '"')\n .replace(/'/g, ''')\n .replace(/</g, '<')\n .replace(/>/g, '>');\n}\n","/**\n * kerf JSX runtime.\n *\n * JSX renders to `SafeHtml` — a wrapped HTML string. `SafeHtml.toString()`\n * is what the consumer eventually feeds into `mount()` (which morphs the\n * live DOM toward the new tree) or into `toElement()` (which parses it\n * to a single DOM node).\n *\n * Configure in your `tsconfig.json`:\n *\n * \"jsx\": \"react-jsx\",\n * \"jsxImportSource\": \"kerfjs\"\n *\n * Then write JSX as you normally would — kerf provides the `jsx` /\n * `jsxs` / `jsxDEV` / `Fragment` exports the JSX transform looks for.\n */\n\nimport { escapeAttr, escapeHtml } from './utils/escapeHtml.js';\n\nexport class SafeHtml {\n readonly __html: string;\n constructor(html: string) {\n this.__html = html;\n }\n toString(): string {\n return this.__html;\n }\n}\n\n/** Inject a pre-escaped HTML string. Use sparingly — caller is responsible for escaping. */\nexport function raw(html: string): SafeHtml {\n return new SafeHtml(html);\n}\n\ntype Child = SafeHtml | string | number | boolean | null | undefined;\ntype Children = Child | Children[];\n\ninterface Props {\n children?: Children;\n [key: string]: unknown;\n}\n\nconst VOID_TAGS = new Set([\n 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',\n 'link', 'meta', 'source', 'track', 'wbr',\n]);\n\nfunction renderChildren(children: Children): string {\n if (children == null || typeof children === 'boolean') return '';\n if (children instanceof SafeHtml) return children.__html;\n if (typeof children === 'string') return escapeHtml(children);\n if (typeof children === 'number') return String(children);\n if (Array.isArray(children)) return children.map(renderChildren).join('');\n // Catch the common mistake of passing a DOM element (e.g. the result of\n // toElement(...)) as a JSX child. The runtime renders to HTML strings, so\n // DOM nodes can't be composed — they'd silently serialize to \"\" and their\n // event listeners would be lost. Throw loudly so this can't sneak in.\n const maybeNode = children as unknown;\n if (typeof maybeNode === 'object' && maybeNode !== null\n && ('nodeType' in maybeNode || 'outerHTML' in maybeNode)) {\n throw new Error(\n 'JSX: DOM elements cannot be passed as children (the JSX runtime renders to HTML strings). '\n + 'Build the tree in one JSX expression and use querySelector after toElement() to get element refs.',\n );\n }\n throw new Error(\n `JSX: unsupported child of type ${describeValue(children)}. `\n + 'Children must be SafeHtml, string, number, boolean, null, undefined, or an array of those. '\n + 'Common mistakes: passing a Signal/Store object directly (use signal.value or store.state.value), '\n + 'passing a function (call it first), or passing a Promise (await it before render).',\n );\n}\n\nfunction describeValue(v: unknown): string {\n if (Array.isArray(v)) return 'array';\n if (typeof v === 'object' && v !== null) {\n const ctor = (v as { constructor?: { name?: string } }).constructor?.name;\n return ctor && ctor !== 'Object' ? `object (${ctor})` : 'object';\n }\n return typeof v;\n}\n\nconst ATTR_ALIASES: Record<string, string> = {\n // HTML attributes\n className: 'class',\n htmlFor: 'for',\n httpEquiv: 'http-equiv',\n acceptCharset: 'accept-charset',\n accessKey: 'accesskey',\n autoCapitalize: 'autocapitalize',\n autoComplete: 'autocomplete',\n autoFocus: 'autofocus',\n autoPlay: 'autoplay',\n colSpan: 'colspan',\n contentEditable: 'contenteditable',\n crossOrigin: 'crossorigin',\n dateTime: 'datetime',\n defaultChecked: 'checked',\n defaultValue: 'value',\n encType: 'enctype',\n formAction: 'formaction',\n formEncType: 'formenctype',\n formMethod: 'formmethod',\n formNoValidate: 'formnovalidate',\n formTarget: 'formtarget',\n hrefLang: 'hreflang',\n inputMode: 'inputmode',\n maxLength: 'maxlength',\n minLength: 'minlength',\n noModule: 'nomodule',\n noValidate: 'novalidate',\n readOnly: 'readonly',\n referrerPolicy: 'referrerpolicy',\n rowSpan: 'rowspan',\n spellCheck: 'spellcheck',\n srcDoc: 'srcdoc',\n srcLang: 'srclang',\n srcSet: 'srcset',\n tabIndex: 'tabindex',\n useMap: 'usemap',\n\n // SVG presentation attributes (camelCase → kebab-case)\n strokeWidth: 'stroke-width',\n strokeLinecap: 'stroke-linecap',\n strokeLinejoin: 'stroke-linejoin',\n strokeDasharray: 'stroke-dasharray',\n strokeDashoffset: 'stroke-dashoffset',\n strokeMiterlimit: 'stroke-miterlimit',\n strokeOpacity: 'stroke-opacity',\n fillOpacity: 'fill-opacity',\n fillRule: 'fill-rule',\n clipPath: 'clip-path',\n clipRule: 'clip-rule',\n colorInterpolation: 'color-interpolation',\n colorInterpolationFilters: 'color-interpolation-filters',\n floodColor: 'flood-color',\n floodOpacity: 'flood-opacity',\n lightingColor: 'lighting-color',\n stopColor: 'stop-color',\n stopOpacity: 'stop-opacity',\n shapeRendering: 'shape-rendering',\n imageRendering: 'image-rendering',\n textRendering: 'text-rendering',\n pointerEvents: 'pointer-events',\n vectorEffect: 'vector-effect',\n paintOrder: 'paint-order',\n\n // SVG text/font attributes\n fontFamily: 'font-family',\n fontSize: 'font-size',\n fontStyle: 'font-style',\n fontVariant: 'font-variant',\n fontWeight: 'font-weight',\n fontStretch: 'font-stretch',\n textAnchor: 'text-anchor',\n textDecoration: 'text-decoration',\n dominantBaseline: 'dominant-baseline',\n alignmentBaseline: 'alignment-baseline',\n baselineShift: 'baseline-shift',\n letterSpacing: 'letter-spacing',\n wordSpacing: 'word-spacing',\n writingMode: 'writing-mode',\n\n // SVG marker attributes\n markerStart: 'marker-start',\n markerMid: 'marker-mid',\n markerEnd: 'marker-end',\n\n // SVG xlink (legacy but still used)\n xlinkHref: 'xlink:href',\n xlinkShow: 'xlink:show',\n xlinkActuate: 'xlink:actuate',\n xlinkType: 'xlink:type',\n xlinkRole: 'xlink:role',\n xlinkTitle: 'xlink:title',\n xlinkArcrole: 'xlink:arcrole',\n xmlBase: 'xml:base',\n xmlLang: 'xml:lang',\n xmlSpace: 'xml:space',\n xmlnsXlink: 'xmlns:xlink',\n};\n\nfunction renderAttr(key: string, value: unknown): string {\n const name = ATTR_ALIASES[key] ?? key;\n if (value == null || value === false) return '';\n if (value === true) return ` ${name}`;\n let strValue: string;\n if (value instanceof SafeHtml) {\n strValue = value.__html;\n } else if (typeof value === 'number') {\n strValue = String(value);\n } else if (typeof value === 'string') {\n strValue = escapeAttr(value);\n } else {\n throw new Error(\n `JSX: unsupported value for attribute \"${key}\" — got ${describeValue(value)}. `\n + 'Attribute values must be string, number, boolean, null, undefined, or SafeHtml. '\n + 'Did you mean to read .value off a Signal, or stringify the object first?',\n );\n }\n return ` ${name}=\"${strValue}\"`;\n}\n\nexport function jsx(tag: string | ((props: Props) => SafeHtml), props: Props): SafeHtml {\n if (typeof tag === 'function') return tag(props);\n\n const { children, ...attrs } = props;\n const attrStr = Object.entries(attrs)\n .map(([k, v]) => renderAttr(k, v))\n .join('');\n\n if (VOID_TAGS.has(tag)) return new SafeHtml(`<${tag}${attrStr}>`);\n\n const childStr = children != null ? renderChildren(children) : '';\n return new SafeHtml(`<${tag}${attrStr}>${childStr}</${tag}>`);\n}\n\nexport { jsx as jsxs };\n// vitest's dev-mode JSX transform emits `jsxDEV(tag, props, ...)`; the\n// alias lets tests import this module without the production build pipeline\n// caring.\nexport { jsx as jsxDEV };\n\nexport function Fragment({ children }: { children?: Children }): SafeHtml {\n return new SafeHtml(children != null ? renderChildren(children) : '');\n}\n\n// eslint-disable-next-line @typescript-eslint/no-namespace\nexport namespace JSX {\n export type Element = SafeHtml;\n export interface ElementChildrenAttribute {\n children: unknown;\n }\n export interface IntrinsicElements {\n [elemName: string]: Record<string, unknown>;\n }\n}\n"]}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ReadonlySignal } from '@preact/signals-core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `defineStore({ initial, actions })` — composable testable stores layered on
|
|
5
|
+
* top of `reactive.ts`'s signals.
|
|
6
|
+
*
|
|
7
|
+
* Three rules:
|
|
8
|
+
* 1. `state` is read-only. Consumers read via `state.value` or subscribe via
|
|
9
|
+
* `effect()`. They cannot write directly.
|
|
10
|
+
* 2. `actions` is the only mutation surface. All writes go through named
|
|
11
|
+
* action functions. This is what makes stores testable — assert against
|
|
12
|
+
* actions, not against arbitrary writes.
|
|
13
|
+
* 3. `reset()` resets to `initial()`. Always defined; tests use it for
|
|
14
|
+
* setup, lifecycle hooks (route change, sign-out, etc.) use it for
|
|
15
|
+
* tear-down.
|
|
16
|
+
*
|
|
17
|
+
* A module-level registry tracks every store created via `defineStore()`;
|
|
18
|
+
* `resetAllStores()` walks the registry and calls each `reset()`. Useful for
|
|
19
|
+
* tests + project-switch / logout / route-reset scenarios where every piece
|
|
20
|
+
* of client state should return to its initial shape.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
interface Store<TState, TActions> {
|
|
24
|
+
/** Read-only reactive view. Consumers read `state.value` or subscribe via `effect()`. */
|
|
25
|
+
readonly state: ReadonlySignal<TState>;
|
|
26
|
+
/** Named mutators — the only way to change state. */
|
|
27
|
+
readonly actions: TActions;
|
|
28
|
+
/** Reset state to `initial()`. Used by tests and lifecycle hooks. */
|
|
29
|
+
reset(): void;
|
|
30
|
+
}
|
|
31
|
+
interface DefineStoreSpec<TState, TActions> {
|
|
32
|
+
initial: () => TState;
|
|
33
|
+
actions: (set: (next: TState) => void, get: () => TState) => TActions;
|
|
34
|
+
}
|
|
35
|
+
declare function defineStore<TState, TActions>(spec: DefineStoreSpec<TState, TActions>): Store<TState, TActions>;
|
|
36
|
+
/**
|
|
37
|
+
* Reset every store registered via `defineStore()` to its `initial()` value.
|
|
38
|
+
* Used by tests and by application lifecycle hooks (project switch, logout,
|
|
39
|
+
* route reset).
|
|
40
|
+
*/
|
|
41
|
+
declare function resetAllStores(): void;
|
|
42
|
+
/**
|
|
43
|
+
* Test helper — clears the registry. Exposed via the `kerfjs/testing` subpath,
|
|
44
|
+
* not the main `kerfjs` entry. Unit tests use it to isolate stores between cases.
|
|
45
|
+
*/
|
|
46
|
+
declare function clearStoreRegistry(): void;
|
|
47
|
+
|
|
48
|
+
export { type Store as S, clearStoreRegistry as c, defineStore as d, resetAllStores as r };
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/store.ts"],"names":[],"mappings":";AA4EO,SAAS,kBAAA,GAA2B;AAE3C","file":"testing.js","sourcesContent":["/**\n * `defineStore({ initial, actions })` — composable testable stores layered on\n * top of `reactive.ts`'s signals.\n *\n * Three rules:\n * 1. `state` is read-only. Consumers read via `state.value` or subscribe via\n * `effect()`. They cannot write directly.\n * 2. `actions` is the only mutation surface. All writes go through named\n * action functions. This is what makes stores testable — assert against\n * actions, not against arbitrary writes.\n * 3. `reset()` resets to `initial()`. Always defined; tests use it for\n * setup, lifecycle hooks (route change, sign-out, etc.) use it for\n * tear-down.\n *\n * A module-level registry tracks every store created via `defineStore()`;\n * `resetAllStores()` walks the registry and calls each `reset()`. Useful for\n * tests + project-switch / logout / route-reset scenarios where every piece\n * of client state should return to its initial shape.\n */\n\nimport type { ReadonlySignal, Signal } from './reactive.js';\nimport { signal } from './reactive.js';\n\nexport interface Store<TState, TActions> {\n /** Read-only reactive view. Consumers read `state.value` or subscribe via `effect()`. */\n readonly state: ReadonlySignal<TState>;\n /** Named mutators — the only way to change state. */\n readonly actions: TActions;\n /** Reset state to `initial()`. Used by tests and lifecycle hooks. */\n reset(): void;\n}\n\ninterface DefineStoreSpec<TState, TActions> {\n initial: () => TState;\n actions: (set: (next: TState) => void, get: () => TState) => TActions;\n}\n\nconst REGISTRY: Array<{ reset: () => void }> = [];\n\nexport function defineStore<TState, TActions>(\n spec: DefineStoreSpec<TState, TActions>,\n): Store<TState, TActions> {\n const internal: Signal<TState> = signal(spec.initial());\n\n const set = (next: TState): void => {\n internal.value = next;\n };\n const get = (): TState => internal.value;\n\n const actions = spec.actions(set, get);\n\n const store: Store<TState, TActions> = {\n state: internal,\n actions,\n reset() {\n internal.value = spec.initial();\n },\n };\n\n REGISTRY.push(store);\n return store;\n}\n\n/**\n * Reset every store registered via `defineStore()` to its `initial()` value.\n * Used by tests and by application lifecycle hooks (project switch, logout,\n * route reset).\n */\nexport function resetAllStores(): void {\n for (const s of REGISTRY) s.reset();\n}\n\n/**\n * Test helper — clears the registry. Exposed via the `kerfjs/testing` subpath,\n * not the main `kerfjs` entry. Unit tests use it to isolate stores between cases.\n */\nexport function clearStoreRegistry(): void {\n REGISTRY.length = 0;\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kerfjs",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Tiny reactive UI framework — fine-grained signals + DOM morphing + JSX. Apply the smallest possible cut to update your DOM.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "Brian Westphal <brian.westphal@bleugris.com>",
|
|
9
|
+
"homepage": "https://github.com/brianwestphal/kerf",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "https://github.com/brianwestphal/kerf"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/brianwestphal/kerf/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"reactive",
|
|
19
|
+
"signals",
|
|
20
|
+
"morphdom",
|
|
21
|
+
"jsx",
|
|
22
|
+
"dom",
|
|
23
|
+
"ui-framework",
|
|
24
|
+
"tiny",
|
|
25
|
+
"fine-grained-reactivity"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=20"
|
|
29
|
+
},
|
|
30
|
+
"main": "./dist/index.js",
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"exports": {
|
|
33
|
+
".": {
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"import": "./dist/index.js"
|
|
36
|
+
},
|
|
37
|
+
"./jsx-runtime": {
|
|
38
|
+
"types": "./dist/jsx-runtime.d.ts",
|
|
39
|
+
"import": "./dist/jsx-runtime.js"
|
|
40
|
+
},
|
|
41
|
+
"./jsx-dev-runtime": {
|
|
42
|
+
"types": "./dist/jsx-runtime.d.ts",
|
|
43
|
+
"import": "./dist/jsx-runtime.js"
|
|
44
|
+
},
|
|
45
|
+
"./testing": {
|
|
46
|
+
"types": "./dist/testing.d.ts",
|
|
47
|
+
"import": "./dist/testing.js"
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"files": [
|
|
51
|
+
"dist",
|
|
52
|
+
"README.md",
|
|
53
|
+
"CHANGELOG.md",
|
|
54
|
+
"LICENSE"
|
|
55
|
+
],
|
|
56
|
+
"scripts": {
|
|
57
|
+
"build": "tsup",
|
|
58
|
+
"dev": "tsup --watch",
|
|
59
|
+
"test": "vitest run --coverage",
|
|
60
|
+
"test:watch": "vitest",
|
|
61
|
+
"test:unit": "vitest run tests/unit --coverage",
|
|
62
|
+
"test:integration": "vitest run tests/integration --coverage",
|
|
63
|
+
"lint": "eslint src tests",
|
|
64
|
+
"typecheck": "tsc --noEmit",
|
|
65
|
+
"clean": "rm -rf dist coverage node_modules/.cache",
|
|
66
|
+
"release": "bash scripts/release.sh",
|
|
67
|
+
"release:beta": "bash scripts/release.sh --beta",
|
|
68
|
+
"prepublishOnly": "npm run build",
|
|
69
|
+
"example:reactivity-demo": "cd examples/reactivity-demo && npm install && npm run dev",
|
|
70
|
+
"example:reactivity-demo:build": "cd examples/reactivity-demo && npm install && npm run build"
|
|
71
|
+
},
|
|
72
|
+
"dependencies": {
|
|
73
|
+
"@preact/signals-core": "^1.14.1",
|
|
74
|
+
"morphdom": "^2.7.8"
|
|
75
|
+
},
|
|
76
|
+
"devDependencies": {
|
|
77
|
+
"@types/jsdom": "^28.0.1",
|
|
78
|
+
"@types/node": "^22.10.0",
|
|
79
|
+
"@typescript-eslint/eslint-plugin": "^8.18.0",
|
|
80
|
+
"@typescript-eslint/parser": "^8.18.0",
|
|
81
|
+
"@vitest/coverage-v8": "^3.0.0",
|
|
82
|
+
"eslint": "^9.16.0",
|
|
83
|
+
"eslint-plugin-simple-import-sort": "^12.1.1",
|
|
84
|
+
"happy-dom": "^15.11.0",
|
|
85
|
+
"jsdom": "^29.1.1",
|
|
86
|
+
"tsup": "^8.3.0",
|
|
87
|
+
"typescript": "^5.7.0",
|
|
88
|
+
"vitest": "^3.0.0"
|
|
89
|
+
}
|
|
90
|
+
}
|