kerfjs 4.3.0 → 4.4.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 +8 -0
- package/README.md +20 -2
- package/ai/manifest.json +1 -1
- package/dist/router.d.ts +88 -0
- package/dist/router.js +135 -0
- package/dist/router.js.map +1 -0
- package/llms.txt +2 -1
- package/package.json +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [4.4.0] - 2026-08-23
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
- Reworked the router example app to run inside a fake browser window — traffic-light chrome, working Back/Forward buttons wired to `router.back()`/`forward()` via one delegated listener, and a live address bar bound to `router.route` that updates as you navigate — making the URL-driven, no-reload story clearer.
|
|
14
|
+
|
|
15
|
+
- **New `kerfjs/router` subpath — a client-side router (the "postcard router").** `createRouter({ routes, mode?, base?, interceptLinks? })` returns a handle over three things kerf already has: a reactive `route` signal (`{ path, params, query, hash }`), `delegate()`-based `<a href>` link interception, and a keyed **outlet** — `router.outlet()` renders the matched route in a `data-key`ed wrapper, so kerf's keyed morph **replaces the page wholesale on a route change** (fresh DOM) and **reconciles in place on a same-route param change** (preserving scroll / focus). Route patterns are static, `:param`, a trailing `*rest` wildcard, and `*` catch-all; the handle also gives `navigate(path, { replace?, state? })`, `back()`/`forward()`, `match(pattern)` / `activeClass(pattern, className)` reactive active-link helpers, hash **or** history mode, an optional base path, and `dispose()`. Link interception is automatic (same-origin, left-click, no modifier/`target`/`download`, opt out per-link with `data-router-ignore` / `rel="external"` or globally with `interceptLinks: false`). **Deliberately scoped** — no nested layouts, data loaders, lazy routes, guards, or SSR matching; compose those with kerf primitives (`resource` for loading, an `effect` on `route` for guards). The kerf **core stays router-free** — this is opt-in and tree-shakeable, adding nothing to the main barrel until imported, and docs/1's "Not a router" is about the runtime. See [`docs/20-router.md`](docs/20-router.md).
|
|
16
|
+
|
|
9
17
|
## [4.3.0] - 2026-08-22
|
|
10
18
|
|
|
11
19
|
|
package/README.md
CHANGED
|
@@ -53,7 +53,7 @@ Here's the whole development loop — write a component, run the dev server, cli
|
|
|
53
53
|
|
|
54
54
|
7. **Small public API.** ~18 exports from the main barrel (plus `arraySignal`, the `html` tagged template, and the companion-utility subpaths below — each opt-in, none in the core). No hooks, no lifecycle, no per-instance state. Components are plain functions that return JSX.
|
|
55
55
|
|
|
56
|
-
8. **Batteries on their own subpaths.**
|
|
56
|
+
8. **Batteries on their own subpaths.** Nine optional, tree-shakeable subpaths cover the patterns every real app otherwise hand-rolls — **`kerfjs/list`** (a keyed list with per-row fine-grained mounts and fixed / app-declared / measured-height viewport **virtualization**, plus a `content-visibility` mode that keeps every row find-in-page-able), **`kerfjs/router`** (a "postcard **router**": route matching, `navigate`, auto `<a>` link interception, and a keyed outlet — the *core* stays router-free, this is opt-in), **`kerfjs/overlay`** (modals, `confirm` / `prompt` / `form` / `choice`, anchored popovers + tooltips, toasts — with opt-in native **top-layer** backing that stacks above any `z-index`), **`kerfjs/async`** (`resource` async-state with a built-in stale-response guard + SWR cache), **`kerfjs/scope`** (dispose-scopes that tie teardown to a DOM node's lifetime), plus `timing`, `remount`, `attach`, and `actions`. None of them grows the ~12 KB core until you import it.
|
|
57
57
|
|
|
58
58
|
9. **Plain TS, plain JSX, plain ESM.** Drops into anything using esbuild / Vite / tsup. No plugin chain. And with the `html` tagged template (`import { html } from 'kerfjs/html'` — identical runtime semantics to JSX), a CDN / importmap project needs no build step at all.
|
|
59
59
|
|
|
@@ -227,7 +227,25 @@ const list = bindList(scrollEl, messages, {
|
|
|
227
227
|
observeRowHeights(list); // one ResizeObserver → kerf anchor-corrects scroll
|
|
228
228
|
```
|
|
229
229
|
|
|
230
|
-
|
|
230
|
+
And a whole client-side router in one call — `kerfjs/router`, the "postcard router." A route table, a keyed `outlet()`, and automatic `<a href>` interception; the *core* stays router-free (this is opt-in):
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
import { createRouter } from 'kerfjs/router';
|
|
234
|
+
|
|
235
|
+
const router = createRouter({
|
|
236
|
+
routes: [
|
|
237
|
+
{ path: '/', component: () => <Home /> },
|
|
238
|
+
{ path: '/users/:id', component: ({ id }) => <User id={id} /> },
|
|
239
|
+
{ path: '*', component: () => <NotFound /> },
|
|
240
|
+
],
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
mount(app, () => <div><nav>{/* <a href> links, auto-intercepted */}</nav>{router.outlet()}</div>);
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
`router.route` is a signal; `router.outlet()` swaps the page wholesale across routes and morphs in place within one. Deliberately small — no nested layouts, loaders, or guards; compose those with the primitives above.
|
|
247
|
+
|
|
248
|
+
Each subpath adds nothing to the main barrel until it's imported. See [`docs/8-api-reference.md`](./docs/8-api-reference.md) for the full list (`list`, `router`, `overlay`, `scope`, `async`, `timing`, `remount`, `attach`, `actions`).
|
|
231
249
|
|
|
232
250
|
## Install
|
|
233
251
|
|
package/ai/manifest.json
CHANGED
package/dist/router.d.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { M as MountResult } from './mount-Bo2qOx25.js';
|
|
2
|
+
import { ReadonlySignal } from '@preact/signals-core';
|
|
3
|
+
import './jsx-runtime.js';
|
|
4
|
+
import './bindings-CYwoJpQb.js';
|
|
5
|
+
|
|
6
|
+
/** The reactive current-route snapshot (`router.route.value`). */
|
|
7
|
+
interface RouteState {
|
|
8
|
+
/** The matched pathname, base stripped (history mode) or the hash body (hash mode). Always starts with `/`. */
|
|
9
|
+
path: string;
|
|
10
|
+
/** Path parameters from the matched pattern — `/users/:id` on `/users/7` → `{ id: '7' }`. */
|
|
11
|
+
params: Record<string, string>;
|
|
12
|
+
/** The parsed query string (`?a=1` → `URLSearchParams`). Empty when there is none. */
|
|
13
|
+
query: URLSearchParams;
|
|
14
|
+
/** The raw location hash including `#` (history mode), or `''`. In hash mode the hash IS the route, so this is `''`. */
|
|
15
|
+
hash: string;
|
|
16
|
+
}
|
|
17
|
+
/** A route's view: receives the matched `params` and the full `route` snapshot, returns kerf content. */
|
|
18
|
+
type RouteComponent = (params: Record<string, string>, route: RouteState) => MountResult;
|
|
19
|
+
/** One route in the table. `path` is a pattern: `/`, `/users/:id`, `/files/*rest`, or `*` (catch-all). */
|
|
20
|
+
interface RouteDef {
|
|
21
|
+
/** Pattern: static segments, `:param` captures, a trailing `*rest` wildcard, or `*` (matches anything — put last). */
|
|
22
|
+
path: string;
|
|
23
|
+
/** The view rendered in the outlet when this route matches. */
|
|
24
|
+
component: RouteComponent;
|
|
25
|
+
}
|
|
26
|
+
/** Options for {@link navigate}. */
|
|
27
|
+
interface NavigateOptions {
|
|
28
|
+
/** Replace the current history entry instead of pushing a new one. Default `false`. */
|
|
29
|
+
replace?: boolean;
|
|
30
|
+
/** Arbitrary state stored on the history entry (readable via `history.state`). */
|
|
31
|
+
state?: unknown;
|
|
32
|
+
}
|
|
33
|
+
/** Options for {@link createRouter}. */
|
|
34
|
+
interface RouterOptions {
|
|
35
|
+
/** The route table, tried in order; the first match wins. Include a `path: '*'` entry last for a fallback. */
|
|
36
|
+
routes: readonly RouteDef[];
|
|
37
|
+
/**
|
|
38
|
+
* `'history'` (default) uses the real pathname (`/users/7`) via the History
|
|
39
|
+
* API; `'hash'` keeps the route after `#` (`#/users/7`) for static hosts with
|
|
40
|
+
* no server rewrite.
|
|
41
|
+
*/
|
|
42
|
+
mode?: 'history' | 'hash';
|
|
43
|
+
/** History mode only: a base path every route sits under (`/app`), stripped from `route.path` and prepended on navigation. */
|
|
44
|
+
base?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Auto-intercept clicks on in-app `<a href>` links (same-origin, left-click, no
|
|
47
|
+
* modifier keys / `target` / `download`) and route them instead of reloading.
|
|
48
|
+
* Opt a single link out with `data-router-ignore` or `rel="external"`. Default
|
|
49
|
+
* `true`; set `false` to wire navigation entirely yourself.
|
|
50
|
+
*/
|
|
51
|
+
interceptLinks?: boolean;
|
|
52
|
+
}
|
|
53
|
+
/** The handle {@link createRouter} returns. Holds no module-global state — it's a closure. */
|
|
54
|
+
interface RouterHandle {
|
|
55
|
+
/** The reactive current route. Read `.value` (tracked) in a render / `computed` / `effect`. */
|
|
56
|
+
route: ReadonlySignal<RouteState>;
|
|
57
|
+
/** Navigate to `path` (may include `?query` / `#hash`). Pushes history (or replaces, per options). */
|
|
58
|
+
navigate: (path: string, options?: NavigateOptions) => void;
|
|
59
|
+
/** History back — `history.back()`. */
|
|
60
|
+
back: () => void;
|
|
61
|
+
/** History forward — `history.forward()`. */
|
|
62
|
+
forward: () => void;
|
|
63
|
+
/**
|
|
64
|
+
* A reactive "is this path active?" — true when the current path equals
|
|
65
|
+
* `pattern` or is nested under it (`match('/users')` is true on `/users/7`).
|
|
66
|
+
* `match('/')` is exact (only true on `/`). Bind it for active-nav styling.
|
|
67
|
+
*/
|
|
68
|
+
match: (pattern: string) => ReadonlySignal<boolean>;
|
|
69
|
+
/** Convenience: a bound class signal — `className` while {@link match}`(pattern)` is active, else `''`. */
|
|
70
|
+
activeClass: (pattern: string, className: string) => ReadonlySignal<string>;
|
|
71
|
+
/**
|
|
72
|
+
* The routed view. Call it inside a `mount()` render: it renders the matched
|
|
73
|
+
* route's component in a keyed wrapper, so a route change swaps the page
|
|
74
|
+
* wholesale (fresh DOM) while a param change updates it in place.
|
|
75
|
+
*/
|
|
76
|
+
outlet: () => MountResult;
|
|
77
|
+
/** Tear down the popstate / link listeners. Idempotent. */
|
|
78
|
+
dispose: () => void;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Create a router bound to the browser history. Reads the current location
|
|
82
|
+
* immediately (so `route.value` is correct before first paint), installs a
|
|
83
|
+
* `popstate` listener (+ `hashchange` in hash mode) and, unless disabled, a
|
|
84
|
+
* single delegated link interceptor. See {@link RouterOptions} / {@link RouterHandle}.
|
|
85
|
+
*/
|
|
86
|
+
declare function createRouter(options: RouterOptions): RouterHandle;
|
|
87
|
+
|
|
88
|
+
export { type NavigateOptions, type RouteComponent, type RouteDef, type RouteState, type RouterHandle, type RouterOptions, createRouter };
|
package/dist/router.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { delegate } from './chunk-KEZTD6H4.js';
|
|
2
|
+
import { jsx } from './chunk-SUPUPSBE.js';
|
|
3
|
+
import { signal, computed } from './chunk-3APBEVHF.js';
|
|
4
|
+
import './chunk-GY4XV2UV.js';
|
|
5
|
+
import './chunk-VVDJLWMP.js';
|
|
6
|
+
|
|
7
|
+
// src/router.ts
|
|
8
|
+
function matchPattern(pattern, path) {
|
|
9
|
+
if (pattern === "*") return {};
|
|
10
|
+
const pp = pattern.split("/").filter(Boolean);
|
|
11
|
+
const ps = path.split("/").filter(Boolean);
|
|
12
|
+
const params = {};
|
|
13
|
+
for (let i = 0; i < pp.length; i++) {
|
|
14
|
+
const seg = pp[i];
|
|
15
|
+
if (seg.startsWith("*")) {
|
|
16
|
+
const name = seg.slice(1);
|
|
17
|
+
if (name.length > 0) params[name] = ps.slice(i).map(decodeURIComponent).join("/");
|
|
18
|
+
return params;
|
|
19
|
+
}
|
|
20
|
+
if (i >= ps.length) return null;
|
|
21
|
+
if (seg.startsWith(":")) {
|
|
22
|
+
params[seg.slice(1)] = decodeURIComponent(ps[i]);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (seg !== ps[i]) return null;
|
|
26
|
+
}
|
|
27
|
+
return ps.length === pp.length ? params : null;
|
|
28
|
+
}
|
|
29
|
+
function createRouter(options) {
|
|
30
|
+
const { routes, mode = "history", base = "", interceptLinks = true } = options;
|
|
31
|
+
const normBase = base === "/" ? "" : base.replace(/\/$/, "");
|
|
32
|
+
let matched = null;
|
|
33
|
+
const readLocation = () => {
|
|
34
|
+
let path;
|
|
35
|
+
let query;
|
|
36
|
+
let hash;
|
|
37
|
+
if (mode === "hash") {
|
|
38
|
+
const raw = location.hash.slice(1) || "/";
|
|
39
|
+
const qIndex = raw.indexOf("?");
|
|
40
|
+
path = qIndex === -1 ? raw : raw.slice(0, qIndex);
|
|
41
|
+
query = new URLSearchParams(qIndex === -1 ? "" : raw.slice(qIndex + 1));
|
|
42
|
+
hash = "";
|
|
43
|
+
} else {
|
|
44
|
+
path = location.pathname;
|
|
45
|
+
if (normBase.length > 0 && path.startsWith(normBase)) path = path.slice(normBase.length) || "/";
|
|
46
|
+
query = new URLSearchParams(location.search);
|
|
47
|
+
hash = location.hash;
|
|
48
|
+
}
|
|
49
|
+
if (!path.startsWith("/")) path = "/" + path;
|
|
50
|
+
return { path, params: {}, query, hash };
|
|
51
|
+
};
|
|
52
|
+
const resolve = (state) => {
|
|
53
|
+
for (const def of routes) {
|
|
54
|
+
const params = matchPattern(def.path, state.path);
|
|
55
|
+
if (params !== null) {
|
|
56
|
+
matched = { def, params };
|
|
57
|
+
return { ...state, params };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
matched = null;
|
|
61
|
+
return state;
|
|
62
|
+
};
|
|
63
|
+
const route = signal(resolve(readLocation()));
|
|
64
|
+
const sync = () => {
|
|
65
|
+
const next = resolve(readLocation());
|
|
66
|
+
if (next.path !== route.value.path || next.query.toString() !== route.value.query.toString() || next.hash !== route.value.hash) {
|
|
67
|
+
route.value = next;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const navigate = (path, opts = {}) => {
|
|
71
|
+
const url = mode === "hash" ? "#" + (path.startsWith("/") ? path : "/" + path) : normBase + (path.startsWith("/") ? path : "/" + path);
|
|
72
|
+
history[opts.replace === true ? "replaceState" : "pushState"](opts.state ?? null, "", url);
|
|
73
|
+
route.value = resolve(readLocation());
|
|
74
|
+
};
|
|
75
|
+
const match = (pattern) => computed(() => {
|
|
76
|
+
const p = route.value.path;
|
|
77
|
+
if (pattern === "/") return p === "/";
|
|
78
|
+
const base2 = pattern.replace(/\/$/, "");
|
|
79
|
+
return p === base2 || p.startsWith(base2 + "/");
|
|
80
|
+
});
|
|
81
|
+
const activeClass = (pattern, className) => {
|
|
82
|
+
const active = match(pattern);
|
|
83
|
+
return computed(() => active.value ? className : "");
|
|
84
|
+
};
|
|
85
|
+
const outlet = () => {
|
|
86
|
+
void route.value;
|
|
87
|
+
if (matched === null) return null;
|
|
88
|
+
return jsx("div", {
|
|
89
|
+
"data-router-outlet": "",
|
|
90
|
+
"data-key": matched.def.path,
|
|
91
|
+
children: matched.def.component(matched.params, route.value)
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
const removers = [];
|
|
95
|
+
globalThis.addEventListener("popstate", sync);
|
|
96
|
+
removers.push(() => globalThis.removeEventListener("popstate", sync));
|
|
97
|
+
if (mode === "hash") {
|
|
98
|
+
globalThis.addEventListener("hashchange", sync);
|
|
99
|
+
removers.push(() => globalThis.removeEventListener("hashchange", sync));
|
|
100
|
+
}
|
|
101
|
+
if (interceptLinks && typeof document !== "undefined") {
|
|
102
|
+
const onClick = (event, anchor) => {
|
|
103
|
+
const e = event;
|
|
104
|
+
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
105
|
+
if (anchor.hasAttribute("download") || anchor.hasAttribute("data-router-ignore")) return;
|
|
106
|
+
const target = anchor.getAttribute("target");
|
|
107
|
+
if (target !== null && target !== "" && target !== "_self") return;
|
|
108
|
+
const rel = anchor.getAttribute("rel");
|
|
109
|
+
if (rel !== null && /\bexternal\b/.test(rel)) return;
|
|
110
|
+
const url = new URL(anchor.href, location.href);
|
|
111
|
+
if (url.origin !== location.origin) return;
|
|
112
|
+
if (mode === "history") {
|
|
113
|
+
if (normBase.length > 0 && !url.pathname.startsWith(normBase)) return;
|
|
114
|
+
event.preventDefault();
|
|
115
|
+
navigate(url.pathname.slice(normBase.length) + url.search + url.hash);
|
|
116
|
+
} else {
|
|
117
|
+
if (url.pathname !== location.pathname || !url.hash.startsWith("#/")) return;
|
|
118
|
+
event.preventDefault();
|
|
119
|
+
navigate(url.hash.slice(1));
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
removers.push(delegate(document.body, "click", "a[href]", onClick));
|
|
123
|
+
}
|
|
124
|
+
let disposed = false;
|
|
125
|
+
const dispose = () => {
|
|
126
|
+
if (disposed) return;
|
|
127
|
+
disposed = true;
|
|
128
|
+
for (const remove of removers) remove();
|
|
129
|
+
};
|
|
130
|
+
return { route, navigate, back: () => history.back(), forward: () => history.forward(), match, activeClass, outlet, dispose };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export { createRouter };
|
|
134
|
+
//# sourceMappingURL=router.js.map
|
|
135
|
+
//# sourceMappingURL=router.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/router.ts"],"names":["base"],"mappings":"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","file":"router.js","sourcesContent":["/**\n * `kerfjs/router` — the \"postcard router\": the smallest client-side router that\n * is still a router. The kerf *core* stays router-free (docs/1 \"Not a router\" is\n * about the runtime); this is an opt-in, tree-shakeable subpath, on the same\n * footing as `kerfjs/list` / `kerfjs/overlay` — it adds nothing to the main\n * barrel until you import it.\n *\n * import { createRouter } from 'kerfjs/router';\n *\n * const router = createRouter({\n * routes: [\n * { path: '/', component: () => <Home /> },\n * { path: '/users/:id', component: ({ id }) => <User id={id} /> },\n * { path: '*', component: () => <NotFound /> }, // catch-all\n * ],\n * });\n *\n * mount(app, () => (\n * <div>\n * <nav> … in-app links here are auto-intercepted … </nav>\n * {router.outlet()} // renders the matched route's component\n * </div>\n * ));\n *\n * The whole model is three moving parts kerf already has: a `signal` for the\n * current route, `delegate()` for link interception, and the keyed morph for the\n * outlet (a route change swaps the page wholesale; a param change updates in\n * place). Everything a full framework's router adds — nested layouts, data\n * loaders, lazy routes, guards, SSR matching — is deliberately OUT of scope;\n * compose those with kerf primitives (an `effect` on `router.route`, a `resource`\n * from `kerfjs/async`) when you need them.\n */\nimport { delegate } from './delegate.js';\nimport { jsx } from './jsx-runtime.js';\nimport { type MountResult } from './mount.js';\nimport { computed, type ReadonlySignal, signal } from './reactive.js';\n\n/** The reactive current-route snapshot (`router.route.value`). */\nexport interface RouteState {\n /** The matched pathname, base stripped (history mode) or the hash body (hash mode). Always starts with `/`. */\n path: string;\n /** Path parameters from the matched pattern — `/users/:id` on `/users/7` → `{ id: '7' }`. */\n params: Record<string, string>;\n /** The parsed query string (`?a=1` → `URLSearchParams`). Empty when there is none. */\n query: URLSearchParams;\n /** The raw location hash including `#` (history mode), or `''`. In hash mode the hash IS the route, so this is `''`. */\n hash: string;\n}\n\n/** A route's view: receives the matched `params` and the full `route` snapshot, returns kerf content. */\nexport type RouteComponent = (params: Record<string, string>, route: RouteState) => MountResult;\n\n/** One route in the table. `path` is a pattern: `/`, `/users/:id`, `/files/*rest`, or `*` (catch-all). */\nexport interface RouteDef {\n /** Pattern: static segments, `:param` captures, a trailing `*rest` wildcard, or `*` (matches anything — put last). */\n path: string;\n /** The view rendered in the outlet when this route matches. */\n component: RouteComponent;\n}\n\n/** Options for {@link navigate}. */\nexport interface NavigateOptions {\n /** Replace the current history entry instead of pushing a new one. Default `false`. */\n replace?: boolean;\n /** Arbitrary state stored on the history entry (readable via `history.state`). */\n state?: unknown;\n}\n\n/** Options for {@link createRouter}. */\nexport interface RouterOptions {\n /** The route table, tried in order; the first match wins. Include a `path: '*'` entry last for a fallback. */\n routes: readonly RouteDef[];\n /**\n * `'history'` (default) uses the real pathname (`/users/7`) via the History\n * API; `'hash'` keeps the route after `#` (`#/users/7`) for static hosts with\n * no server rewrite.\n */\n mode?: 'history' | 'hash';\n /** History mode only: a base path every route sits under (`/app`), stripped from `route.path` and prepended on navigation. */\n base?: string;\n /**\n * Auto-intercept clicks on in-app `<a href>` links (same-origin, left-click, no\n * modifier keys / `target` / `download`) and route them instead of reloading.\n * Opt a single link out with `data-router-ignore` or `rel=\"external\"`. Default\n * `true`; set `false` to wire navigation entirely yourself.\n */\n interceptLinks?: boolean;\n}\n\n/** The handle {@link createRouter} returns. Holds no module-global state — it's a closure. */\nexport interface RouterHandle {\n /** The reactive current route. Read `.value` (tracked) in a render / `computed` / `effect`. */\n route: ReadonlySignal<RouteState>;\n /** Navigate to `path` (may include `?query` / `#hash`). Pushes history (or replaces, per options). */\n navigate: (path: string, options?: NavigateOptions) => void;\n /** History back — `history.back()`. */\n back: () => void;\n /** History forward — `history.forward()`. */\n forward: () => void;\n /**\n * A reactive \"is this path active?\" — true when the current path equals\n * `pattern` or is nested under it (`match('/users')` is true on `/users/7`).\n * `match('/')` is exact (only true on `/`). Bind it for active-nav styling.\n */\n match: (pattern: string) => ReadonlySignal<boolean>;\n /** Convenience: a bound class signal — `className` while {@link match}`(pattern)` is active, else `''`. */\n activeClass: (pattern: string, className: string) => ReadonlySignal<string>;\n /**\n * The routed view. Call it inside a `mount()` render: it renders the matched\n * route's component in a keyed wrapper, so a route change swaps the page\n * wholesale (fresh DOM) while a param change updates it in place.\n */\n outlet: () => MountResult;\n /** Tear down the popstate / link listeners. Idempotent. */\n dispose: () => void;\n}\n\n/**\n * Match `path` against a route `pattern`. Returns the captured params on a match,\n * or `null` on no match. `*` matches anything; a trailing `*name` captures the\n * remaining segments joined by `/`; `:name` captures one segment.\n */\nfunction matchPattern(pattern: string, path: string): Record<string, string> | null {\n if (pattern === '*') return {};\n const pp = pattern.split('/').filter(Boolean);\n const ps = path.split('/').filter(Boolean);\n const params: Record<string, string> = {};\n for (let i = 0; i < pp.length; i++) {\n const seg = pp[i];\n if (seg.startsWith('*')) {\n // Wildcard rest — consumes every remaining segment.\n const name = seg.slice(1);\n if (name.length > 0) params[name] = ps.slice(i).map(decodeURIComponent).join('/');\n return params;\n }\n if (i >= ps.length) return null;\n if (seg.startsWith(':')) {\n params[seg.slice(1)] = decodeURIComponent(ps[i]);\n continue;\n }\n if (seg !== ps[i]) return null;\n }\n // No wildcard matched, so the segment counts must be exactly equal.\n return ps.length === pp.length ? params : null;\n}\n\n/**\n * Create a router bound to the browser history. Reads the current location\n * immediately (so `route.value` is correct before first paint), installs a\n * `popstate` listener (+ `hashchange` in hash mode) and, unless disabled, a\n * single delegated link interceptor. See {@link RouterOptions} / {@link RouterHandle}.\n */\nexport function createRouter(options: RouterOptions): RouterHandle {\n const { routes, mode = 'history', base = '', interceptLinks = true } = options;\n // Normalize base to '' or '/foo' (no trailing slash), so `base + path` is clean.\n const normBase = base === '/' ? '' : base.replace(/\\/$/, '');\n\n // The current matched route def, kept in step with the signal so `outlet()`\n // doesn't have to re-match — it reads `route.value` only to subscribe.\n let matched: { def: RouteDef; params: Record<string, string> } | null = null;\n\n const readLocation = (): RouteState => {\n let path: string;\n let query: URLSearchParams;\n let hash: string;\n if (mode === 'hash') {\n // Everything after '#': '#/users/7?a=1' → path '/users/7', query 'a=1'.\n const raw = location.hash.slice(1) || '/';\n const qIndex = raw.indexOf('?');\n path = qIndex === -1 ? raw : raw.slice(0, qIndex);\n query = new URLSearchParams(qIndex === -1 ? '' : raw.slice(qIndex + 1));\n hash = '';\n } else {\n path = location.pathname;\n if (normBase.length > 0 && path.startsWith(normBase)) path = path.slice(normBase.length) || '/';\n query = new URLSearchParams(location.search);\n hash = location.hash;\n }\n if (!path.startsWith('/')) path = '/' + path;\n return { path, params: {}, query, hash };\n };\n\n const resolve = (state: RouteState): RouteState => {\n for (const def of routes) {\n const params = matchPattern(def.path, state.path);\n if (params !== null) {\n matched = { def, params };\n return { ...state, params };\n }\n }\n matched = null;\n return state;\n };\n\n const route = signal<RouteState>(resolve(readLocation()));\n\n // Re-read location → re-match → publish. Only writes when the path actually\n // changed, so a doubled popstate/hashchange is a harmless no-op.\n const sync = (): void => {\n const next = resolve(readLocation());\n if (next.path !== route.value.path || next.query.toString() !== route.value.query.toString()\n || next.hash !== route.value.hash) {\n route.value = next;\n }\n };\n\n const navigate = (path: string, opts: NavigateOptions = {}): void => {\n const url = mode === 'hash'\n ? '#' + (path.startsWith('/') ? path : '/' + path)\n : normBase + (path.startsWith('/') ? path : '/' + path);\n // pushState/replaceState do NOT fire popstate, so publish the new route ourselves.\n history[opts.replace === true ? 'replaceState' : 'pushState'](opts.state ?? null, '', url);\n route.value = resolve(readLocation());\n };\n\n const match = (pattern: string): ReadonlySignal<boolean> =>\n computed(() => {\n const p = route.value.path;\n if (pattern === '/') return p === '/';\n const base = pattern.replace(/\\/$/, '');\n return p === base || p.startsWith(base + '/');\n });\n\n const activeClass = (pattern: string, className: string): ReadonlySignal<string> => {\n const active = match(pattern);\n return computed(() => (active.value ? className : ''));\n };\n\n const outlet = (): MountResult => {\n void route.value; // tracked read — subscribes the enclosing mount to navigation\n if (matched === null) return null;\n // Keyed by the route PATTERN: a different pattern → the morph replaces the\n // wrapper wholesale (fresh DOM for the new page); the same pattern (only the\n // params changed) → the morph reconciles the children in place.\n return jsx('div', {\n 'data-router-outlet': '',\n 'data-key': matched.def.path,\n children: matched.def.component(matched.params, route.value),\n });\n };\n\n // --- Listeners ---------------------------------------------------------\n const removers: Array<() => void> = [];\n globalThis.addEventListener('popstate', sync);\n removers.push(() => globalThis.removeEventListener('popstate', sync));\n if (mode === 'hash') {\n globalThis.addEventListener('hashchange', sync);\n removers.push(() => globalThis.removeEventListener('hashchange', sync));\n }\n\n if (interceptLinks && typeof document !== 'undefined') {\n const onClick = (event: Event, anchor: HTMLAnchorElement): void => {\n const e = event as MouseEvent;\n // Let the browser handle anything that isn't a plain left-click navigation.\n if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;\n if (anchor.hasAttribute('download') || anchor.hasAttribute('data-router-ignore')) return;\n const target = anchor.getAttribute('target');\n if (target !== null && target !== '' && target !== '_self') return;\n const rel = anchor.getAttribute('rel');\n if (rel !== null && /\\bexternal\\b/.test(rel)) return;\n const url = new URL(anchor.href, location.href);\n if (url.origin !== location.origin) return;\n if (mode === 'history') {\n if (normBase.length > 0 && !url.pathname.startsWith(normBase)) return; // outside the app's base\n event.preventDefault();\n navigate(url.pathname.slice(normBase.length) + url.search + url.hash);\n } else {\n // Hash mode: only intercept in-app hash links (`#/...`), leave others alone.\n if (url.pathname !== location.pathname || !url.hash.startsWith('#/')) return;\n event.preventDefault();\n navigate(url.hash.slice(1));\n }\n };\n removers.push(delegate<HTMLAnchorElement>(document.body, 'click', 'a[href]', onClick));\n }\n\n let disposed = false;\n const dispose = (): void => {\n if (disposed) return;\n disposed = true;\n for (const remove of removers) remove();\n };\n\n return { route, navigate, back: () => history.back(), forward: () => history.forward(), match, activeClass, outlet, dispose };\n}\n"]}
|
package/llms.txt
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> A tiny (~12 KB minified + gzipped including its one runtime dependency `@preact/signals-core`; ~13 KB with `arraySignal`) reactive UI framework — fine-grained signals + DOM morphing + JSX. No virtual DOM, no compiler. Apply the smallest possible cut to update your DOM.
|
|
4
4
|
|
|
5
|
-
kerf renders JSX to a structured `SafeHtml` (string for static content; tagged "list"/"mixed" segments where `each(...)` was used) and reconciles it against the live tree with a custom segment-aware morph. Static surrounds go through a general-purpose tree-morph; list contents go through a keyed reconciler that operates directly on live children — partial-update on huge lists is O(changes), not O(rows). Reactivity is provided by [@preact/signals-core](https://github.com/preactjs/signals). It pairs well with server-rendered HTML, embedded widgets, and any UI where preserving focus / selection across re-renders matters. Public API is one import: `signal`, `computed`, `effect`, `batch`, `defineStore`, `resetAllStores`, `mount`, `morph`, `each`, `attr`, `delegate`, `delegateCapture`, `toElement`, `renderDocument`, `SafeHtml`, `isSafeHtml`, `raw`, `Fragment`. (Two more subpaths: `kerfjs/testing` exposes `clearStoreRegistry` for unit-test isolation; `kerfjs/jsx-runtime` exposes the typed JSX building blocks for declaration-merging custom-element types.) An optional subpath at `kerfjs/array-signal` adds `arraySignal()` — a granular keyed-list signal whose patch events let `each()` reconcile in O(patches) instead of O(N). An optional subpath at `kerfjs/dev` installs the development diagnostics — kerf does NOT infer dev mode, so you import it behind your own build's dev flag (`if (import.meta.env.DEV) await import('kerfjs/dev');`); omitting it is production and sheds ~4.7 KB min+gzip. Another optional subpath at `kerfjs/html` adds the `html` tagged template — JSX-identical runtime semantics with no JSX transform, so CDN/importmap projects can author kerf UIs with literally no build step. A family of optional, tree-shakeable **companion-utility** subpaths cover patterns real apps hand-roll: `kerfjs/list` (`bindList` — a keyed list with per-row fine-grained mounts and fixed / declared / measured-height viewport virtualization, plus `observeRowHeights`), `kerfjs/overlay` (`overlay` / `confirm` / `prompt` / `form` / `choice` / `popover` / `tooltip` / `toast` + `positionAnchored` / `autoReposition`, with opt-in `native: true` top-layer backing via `<dialog>` / the Popover API), `kerfjs/scope` (`disposeScope` / `disposeSubtree` / `observeRemovals`), `kerfjs/async` (`resource` async-state with a stale-response guard + SWR cache), `kerfjs/timing` (`debounce` / `throttle` / `debouncedSignal`), `kerfjs/remount` (`remountOn` — key-driven wholesale subtree replacement), `kerfjs/attach` (`attach` — bind a non-kerf widget's lifecycle to one node), and `kerfjs/actions` (`action` / `delegateActions` — the delegated `data-action` table idiom).
|
|
5
|
+
kerf renders JSX to a structured `SafeHtml` (string for static content; tagged "list"/"mixed" segments where `each(...)` was used) and reconciles it against the live tree with a custom segment-aware morph. Static surrounds go through a general-purpose tree-morph; list contents go through a keyed reconciler that operates directly on live children — partial-update on huge lists is O(changes), not O(rows). Reactivity is provided by [@preact/signals-core](https://github.com/preactjs/signals). It pairs well with server-rendered HTML, embedded widgets, and any UI where preserving focus / selection across re-renders matters. Public API is one import: `signal`, `computed`, `effect`, `batch`, `defineStore`, `resetAllStores`, `mount`, `morph`, `each`, `attr`, `delegate`, `delegateCapture`, `toElement`, `renderDocument`, `SafeHtml`, `isSafeHtml`, `raw`, `Fragment`. (Two more subpaths: `kerfjs/testing` exposes `clearStoreRegistry` for unit-test isolation; `kerfjs/jsx-runtime` exposes the typed JSX building blocks for declaration-merging custom-element types.) An optional subpath at `kerfjs/array-signal` adds `arraySignal()` — a granular keyed-list signal whose patch events let `each()` reconcile in O(patches) instead of O(N). An optional subpath at `kerfjs/dev` installs the development diagnostics — kerf does NOT infer dev mode, so you import it behind your own build's dev flag (`if (import.meta.env.DEV) await import('kerfjs/dev');`); omitting it is production and sheds ~4.7 KB min+gzip. Another optional subpath at `kerfjs/html` adds the `html` tagged template — JSX-identical runtime semantics with no JSX transform, so CDN/importmap projects can author kerf UIs with literally no build step. A family of optional, tree-shakeable **companion-utility** subpaths cover patterns real apps hand-roll: `kerfjs/list` (`bindList` — a keyed list with per-row fine-grained mounts and fixed / declared / measured-height viewport virtualization, plus `observeRowHeights`), `kerfjs/router` (`createRouter` — the opt-in "postcard router": route matching + `navigate` + `<a>` link interception + a keyed outlet, core stays router-free), `kerfjs/overlay` (`overlay` / `confirm` / `prompt` / `form` / `choice` / `popover` / `tooltip` / `toast` + `positionAnchored` / `autoReposition`, with opt-in `native: true` top-layer backing via `<dialog>` / the Popover API), `kerfjs/scope` (`disposeScope` / `disposeSubtree` / `observeRemovals`), `kerfjs/async` (`resource` async-state with a stale-response guard + SWR cache), `kerfjs/timing` (`debounce` / `throttle` / `debouncedSignal`), `kerfjs/remount` (`remountOn` — key-driven wholesale subtree replacement), `kerfjs/attach` (`attach` — bind a non-kerf widget's lifecycle to one node), and `kerfjs/actions` (`action` / `delegateActions` — the delegated `data-action` table idiom).
|
|
6
6
|
|
|
7
7
|
## For humans new to the codebase
|
|
8
8
|
|
|
@@ -39,6 +39,7 @@ kerf renders JSX to a structured `SafeHtml` (string for static content; tagged "
|
|
|
39
39
|
- [List virtualization](https://github.com/brianwestphal/kerf/blob/main/docs/17-list-virtualization.md): `bindList`'s virtualization — the `window` (default) height models (fixed `number`, app-declared `(item, index) => number`, and measured `{ estimate }` + `setHeight`) with kerf owning the cumulative-offset math and scroll anchoring while the app owns measurement (the `observeRowHeights` helper), the `minRows` render-all threshold and container/resize ergonomics, and the `content-visibility` mode that keeps every row in the DOM (full find-in-page / a11y) while the browser skips off-screen layout, plus the findability/a11y tradeoff of the default `window` mode.
|
|
40
40
|
- [State-preserving moves](https://github.com/brianwestphal/kerf/blob/main/docs/18-state-preserving-moves.md): connected-row reorders (every `each()` / `bindList` / `morph` move site) use `Node.prototype.moveBefore()` where the engine supports it — an atomic move that keeps focus, selection, `<iframe>` state, playing media, and running CSS animations across the reorder — falling back to `insertBefore()` otherwise. Transparent internal `moveNode` helper; no API change.
|
|
41
41
|
- [Native overlay backing](https://github.com/brianwestphal/kerf/blob/main/docs/19-native-overlay-backing.md): opt-in `native: true` on every `kerfjs/overlay` surface hosts the overlay in the browser top layer — a `<dialog>.showModal()` for modal surfaces, the Popover API for non-modal — feature-detected, falling back to today's plain `<div>` where unsupported. Fixes stacking (top layer beats any `z-index`), real inerting, and native light-dismiss; opt-in because the native elements carry UA styles kerf's zero-CSS contract won't reset.
|
|
42
|
+
- [Router](https://github.com/brianwestphal/kerf/blob/main/docs/20-router.md): the opt-in, tree-shakeable `kerfjs/router` subpath — the "postcard router". `createRouter({ routes, mode?, base?, interceptLinks? })` → a reactive `route` signal, `navigate`/`back`/`forward`, `match`/`activeClass` active-link helpers, a keyed `outlet()` (a route change swaps the page wholesale, a same-route param change morphs in place), and `dispose()`. Route matching (`:param` / `*rest` / `*`), `delegate()`-based `<a href>` interception, history + hash modes, optional base. The core stays router-free (docs/1's "Not a router" is about the runtime); deliberately excludes nested layouts / loaders / lazy routes / guards / SSR.
|
|
42
43
|
|
|
43
44
|
## Examples
|
|
44
45
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kerfjs",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.0",
|
|
4
4
|
"description": "Tiny reactive UI framework — fine-grained signals + DOM morphing + JSX. Apply the smallest possible cut to update your DOM.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -84,6 +84,10 @@
|
|
|
84
84
|
"types": "./dist/list.d.ts",
|
|
85
85
|
"import": "./dist/list.js"
|
|
86
86
|
},
|
|
87
|
+
"./router": {
|
|
88
|
+
"types": "./dist/router.d.ts",
|
|
89
|
+
"import": "./dist/router.js"
|
|
90
|
+
},
|
|
87
91
|
"./timing": {
|
|
88
92
|
"types": "./dist/timing.d.ts",
|
|
89
93
|
"import": "./dist/timing.js"
|