@k8ordo/router 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/docs/GUIDE.md ADDED
@@ -0,0 +1,372 @@
1
+ # @k8ordo/router
2
+
3
+ The URL's pathname axis, owned. One route table is the application's pathname
4
+ schema, and from it come the types, the matching, the links, and the
5
+ navigation — over the Navigation API and URLPattern.
6
+
7
+ Like every k8ordo package it assumes React 19 and Server Components, uses only
8
+ what has reached Baseline newly available, and ships no polyfills or legacy
9
+ fallbacks. Both the Navigation API and URLPattern reached Baseline in 2025–26;
10
+ this package treats them as simply present.
11
+
12
+ ## What it does not own
13
+
14
+ Search params and history-entry state belong to `@k8ordo/state`. This package
15
+ has no `useSearchParams`, and it never hands out the raw search string: a
16
+ component that reads one field of the search should re-render when that field
17
+ changes and not otherwise, which is a job for keyed subscriptions, not for a
18
+ router. The division is the URL itself — pathname here, everything after it
19
+ there.
20
+
21
+ It also does not fetch. There is no loader, no route-level data API, and no
22
+ cache. Data belongs to the component that needs it — `use()` and `<Suspense>`
23
+ in a client app, the server in a framework one — and a router that owned
24
+ fetching would be a second, competing answer to a question React already
25
+ answers.
26
+
27
+ ## The shape of it
28
+
29
+ ```
30
+ routes.ts defineRoutes({ … }) the pathname schema, one place
31
+ ↓ mounted once
32
+ <Router routes> match → stack → render layouts wrap through <Outlet />
33
+ ↑ never imported by pages
34
+ pages href / navigateTo / useParams
35
+ take the pattern string; Register supplies the check
36
+ ```
37
+
38
+ The table's value is held by `<Router>` and nothing else. Everything a page
39
+ needs — building a link, going somewhere, reading its own params — works from
40
+ the pattern **string**, so the module that imports the pages is never imported
41
+ back by them.
42
+
43
+ ## The route table
44
+
45
+ ```ts
46
+ // routes.ts
47
+ import { defineRoutes } from '@k8ordo/router';
48
+
49
+ import { DocsLayout } from './docs-layout';
50
+ import { Guide } from './guide';
51
+ import { Home } from './home';
52
+ import { NotFound } from './not-found';
53
+ import { ProductList } from './product-list';
54
+ import { ProductPage } from './product-page';
55
+
56
+ export const routes = defineRoutes({
57
+ '/': Home,
58
+ '/products': {
59
+ children: {
60
+ '/': ProductList,
61
+ '/:id': ProductPage,
62
+ },
63
+ },
64
+ '/(docs)': {
65
+ layout: DocsLayout,
66
+ children: { '/guide': Guide },
67
+ },
68
+ '/*': NotFound,
69
+ });
70
+ ```
71
+
72
+ - A **leaf** is the component to render. A **branch** is `{ layout?, children }`,
73
+ and a child key of `'/'` is the branch's own index page.
74
+ - **`/:name`** captures a segment, decoded. **`/*`** matches whatever nothing
75
+ before it did.
76
+ - **`/(name)`** is a route group: it structures the table — its own layout, its
77
+ own subtree — without contributing a URL segment. It exists because `'/'` can
78
+ only appear once in an object, so two sections at the same depth could not
79
+ otherwise have different layouts.
80
+ - **A trailing slash is the same pathname.** `/products/` matches `/products`.
81
+ - A branch may name an **`error`** component beside its layout:
82
+ `{ layout, error, children }`. When anything below throws, it renders in
83
+ the layout's hole instead — with `{ error, reset }` as props — and the
84
+ frame around it survives. Leaving the page that failed clears the failure.
85
+
86
+ ### Order is the rule
87
+
88
+ Matching walks the table top to bottom and takes the first pattern that fits.
89
+ Precedence is what you wrote — there is no specificity ranking to reason
90
+ backwards from, which means a table can be read like the code it is.
91
+
92
+ ```ts
93
+ defineRoutes({ '/:slug': Article, '/about': About }); // /about → Article
94
+ defineRoutes({ '/about': About, '/:slug': Article }); // /about → About
95
+ ```
96
+
97
+ Put `/*` last, for the same reason.
98
+
99
+ ### What fails at definition time
100
+
101
+ A table is checked when the module loads, not when someone first navigates:
102
+
103
+ | written | error |
104
+ | --------------------------------------------------------- | --------------------------------------------------------------- |
105
+ | the same full pattern twice, wherever the copies nest | `route pattern "/x" is declared twice` |
106
+ | a group with no children (it would redeclare the index) | `route group "/(oops)" must have children` |
107
+ | a key not starting with `/` | `route pattern "x" must start with "/"` |
108
+ | parentheses that are not exactly a group (`/(admin)/new`) | `route group "/(admin)/new" must be "/(name)" and nothing else` |
109
+ | a pattern URLPattern cannot parse | URLPattern's own `TypeError` |
110
+
111
+ ## Mounting it
112
+
113
+ ```tsx
114
+ import { Outlet, Router } from '@k8ordo/router';
115
+
116
+ import { routes } from './routes';
117
+
118
+ export function App() {
119
+ return <Router routes={routes} />;
120
+ }
121
+
122
+ // a layout renders what it wraps through Outlet
123
+ export const DocsLayout = () => (
124
+ <section>
125
+ <nav>…</nav>
126
+ <Outlet />
127
+ </section>
128
+ );
129
+ ```
130
+
131
+ Every same-origin link or programmatic navigation the table claims is handled
132
+ in the browser; the rest is left alone, so a real document load — and a real
133
+ 404 — stays the server's answer. A pathname the table does not match renders
134
+ nothing rather than guessing.
135
+
136
+ Four kinds of navigation are never the application's, whatever the table says:
137
+ a reload, a form submitted with a body (POST), a download, and a fragment-only
138
+ change. Intercepting any of them would silently do nothing where the platform
139
+ would have done the obvious thing — a POST body only the server can act on, an
140
+ `F5` that stops reloading. A GET form carries no body, so the search-shaped
141
+ submissions `@k8ordo/state` builds still come through. Mark a link to a file
142
+ the host serves as an attachment with `download`, so the browser tells the
143
+ router before the click rather than after the answer.
144
+
145
+ A leaf can be `React.lazy(...)`, which the table stores as any other component;
146
+ put a `<Suspense>` in the layout above it so there is somewhere to fall back
147
+ to while the chunk arrives.
148
+
149
+ ## Links and navigation
150
+
151
+ ```tsx
152
+ import {
153
+ href,
154
+ navigateTo,
155
+ useParams,
156
+ usePathname,
157
+ useRoute,
158
+ } from '@k8ordo/router';
159
+
160
+ <a href={href('/products/:id', { id })}>…</a>;
161
+ <a href={href('/products')}>…</a>; // no params, no second argument
162
+
163
+ navigateTo('/products/:id', { id }); // pushes: the back button undoes it
164
+ navigateTo('/products', { history: 'replace' });
165
+
166
+ const { id } = useParams('/products/:id');
167
+ const { pattern } = useRoute(); // untyped: the winning pattern and its params
168
+ const pathname = usePathname(); // where the browser is, table or no table
169
+ ```
170
+
171
+ **There is no `<Link>`.** Under the Navigation API a plain `<a>` is already a
172
+ client navigation — the router intercepts the event the browser was going to
173
+ send anyway. A component wrapping it would add a second way to write the same
174
+ thing and nothing else. An active link is a question you ask, not a prop:
175
+
176
+ ```tsx
177
+ import { matchPath, useMatch } from '@k8ordo/router';
178
+
179
+ useMatch('/products/:id'); // { id } when that page is showing, else null
180
+ useMatch('/products/*'); // {} anywhere under /products, else null
181
+ matchPath('/products/:id', pathname); // the same, pure, for a pathname in hand
182
+ ```
183
+
184
+ `useMatch` takes a pattern from the table, or a table pattern followed by
185
+ `/*` to mean "everything below it" — what a sidebar asks when it wants to
186
+ know which section of the site is open. The pattern's own page is not below
187
+ it: `/products/*` matches `/products/42` and not `/products`, which is
188
+ `useMatch('/products')`; ask both when a section includes its index. It is built on `usePathname`, so it
189
+ re-renders on the pathname and never on the search, and it needs no table in
190
+ the browser — which is what makes it the one of these that also works under
191
+ the framework, where `useRoute` has no match to read.
192
+
193
+ **`usePathname` changes when the URL changes, not when the new page appears.**
194
+ Interception commits the URL first and the tree arrives when it has loaded, so
195
+ on a slow navigation a link marks itself active while the previous page is
196
+ still on screen — the same order the browser's own address bar follows. Pair it
197
+ with `useTransition` if the wait needs showing — `navigateTo`'s `finished`
198
+ resolves when the tree is on screen (below).
199
+
200
+ `usePathname` reads the platform rather than the table, which is why it is the
201
+ one that also works under the framework, where the browser holds no table at
202
+ all. It answers _where the URL is_; `useRoute` answers _which route won_, and
203
+ only something holding the table can say that. The search string is
204
+ deliberately not on offer here: a component re-rendering on every search change
205
+ would defeat `@k8ordo/state`'s keyed subscriptions, and the split at the `?` is
206
+ the boundary between the two packages.
207
+
208
+ `href` refuses a wildcard: `/*` is something to match, never something to link
209
+ to. Param values are URL-encoded on the way in and decoded on the way out.
210
+ `normalizePathname` is the router's own reading of a pathname — a trailing
211
+ slash dropped, root excepted — for code that compares pathnames the way the
212
+ table does.
213
+
214
+ `navigateTo` returns the platform's own `{ committed, finished }`, so it
215
+ composes with React 19's async transitions:
216
+
217
+ ```tsx
218
+ const [isPending, startTransition] = useTransition();
219
+
220
+ startTransition(async () => {
221
+ await navigateTo('/products/:id', { id }).finished;
222
+ });
223
+ ```
224
+
225
+ Its default is `push`, the opposite of `@k8ordo/state`'s `update()`, and for
226
+ the same reason: going to a page is what the back button should undo, while
227
+ refining what is on the page is not. **Changing pages goes through
228
+ `navigateTo`; changing state goes through `update`.**
229
+
230
+ ## Checking patterns against the real table
231
+
232
+ Params are inferred from the pattern literal on their own, so a missing or
233
+ misspelled one fails to compile with no setup at all. To have the **pattern**
234
+ checked against the app's actual table as well, augment `Register` once:
235
+
236
+ ```ts
237
+ // types/k8ordo-router.d.ts
238
+ import type { routes } from '../src/routes';
239
+
240
+ declare module '@k8ordo/router' {
241
+ interface Register {
242
+ routes: typeof routes;
243
+ }
244
+ }
245
+ ```
246
+
247
+ ```ts
248
+ href('/products/:id', { id }); // ok
249
+ href('/prodcuts/:id', { id }); // ✗ not a pattern in the table
250
+ href('/products/:id'); // ✗ ":id" is missing
251
+ ```
252
+
253
+ Without the augmentation the constraint is any `/`-prefixed string. Augment
254
+ only in an application — a library doing it would impose its table on every
255
+ consumer.
256
+
257
+ Under `@k8ordo/static` or `@k8ordo/server` this file is generated into
258
+ `.k8ordo/register.gen.ts` from `routes/` — for `@k8ordo/state` too, when the
259
+ application depends on it — so hand-writing it there is writing a second
260
+ answer to a question already answered. Hand-write it in a client application
261
+ that mounts `<Router>` itself.
262
+
263
+ The framework's generated `Register` also carries `params`: per pattern, the
264
+ type the route file's `paramsSchema` produces. With it, `href` and
265
+ `navigateTo` take a param as the page receives it — `{ id: 42 }` for a
266
+ schema that said number — and spell it the one way the schema reads back. A
267
+ value with no URL spelling (an object) is refused. Before `Register` is
268
+ augmented — or where no schema covers a param — a link takes any value with
269
+ one spelling (a string, a number, a boolean), so a link written for a schema
270
+ compiles before the generated file exists. `useParams` is unaffected: what a
271
+ hand-written table matches is always a string.
272
+
273
+ ## Typed paths for @k8ordo/state
274
+
275
+ `@k8ordo/state`'s `Register` takes the same line this one does:
276
+
277
+ ```ts
278
+ declare module '@k8ordo/state' {
279
+ interface Register {
280
+ routes: typeof routes;
281
+ }
282
+ }
283
+ ```
284
+
285
+ With that, `listState.href('/products', { q })` is checked against the same
286
+ table this router matches against, and the two packages agree on what a path
287
+ is. `RouteOf<typeof routes>` is that pathname space as a union, for any other
288
+ typed-path consumer.
289
+
290
+ ## What navigation guarantees
291
+
292
+ **`finished` means the page is on screen.** The intercept handler resolves in
293
+ an effect after React commits the new tree, so anything awaiting the platform's
294
+ promise — including `@k8ordo/state`'s `update().finished` — is awaiting the
295
+ render, not the URL write.
296
+
297
+ **A state change is not a page change.** When only the search or the entry
298
+ state moved, the pathname is the one whose tree is on screen: the route tree
299
+ is left alone, nothing remounts, and scroll and focus are not disturbed. This
300
+ is why a search update never scrolls the page back to the top. The comparison
301
+ is against the page showing, not against the address bar — interception
302
+ commits the URL first, so a state update issued while another page is still
303
+ loading is a page change and lets that page finish arriving.
304
+
305
+ **Route changes run in a transition.** The new tree is applied inside
306
+ `startTransition`, so React can keep the old page interactive while the new
307
+ one prepares.
308
+
309
+ **A new page starts at the top.** Once the new tree is on screen, the
310
+ viewport goes where a document load would have put it: the top, or the
311
+ element a `#fragment` names. Back and forward are left to the browser, which
312
+ restores the position it saved. A state change never moves the viewport —
313
+ same pathname, same place — so a filter update does not scroll the reader
314
+ back to the top.
315
+
316
+ **Superseded navigations abort.** A second navigation aborts the first through
317
+ the platform's own signal: the overtaken `finished` rejects with the abort
318
+ reason, and its tree never reaches the screen even if its load had already
319
+ come back. `load` receives that signal, so a payload fetch under the framework
320
+ is cancelled outright. A `React.lazy` chunk cannot be — a dynamic import takes
321
+ no signal — so in a client application it finishes in the background and is
322
+ kept for the next visit, while the page it belonged to is never shown.
323
+
324
+ ## Testing
325
+
326
+ Nothing here is mocked, so a test needs a browser environment (this package's
327
+ own suite uses Vitest's Chromium browser mode). The one thing to know: a test
328
+ that navigates must intercept, or `navigation.navigate()` is a cross-document
329
+ load that takes the test runner with it.
330
+
331
+ ```tsx
332
+ // a test that mounts <Router> is already intercepting; one that sets up a URL
333
+ // outside the table has to play the router itself
334
+ const interceptEverything = (event: NavigateEvent) => {
335
+ if (event.canIntercept) event.intercept();
336
+ };
337
+ ```
338
+
339
+ `match` is a pure function and needs no browser: a table's shape, its params
340
+ and its precedence can all be asserted directly.
341
+
342
+ ## Under the framework
343
+
344
+ `@k8ordo/static` and `@k8ordo/server` render pages on the server, so the
345
+ browser receives a tree rather than building one from a table — there is no
346
+ route table in the client bundle at all, and layouts nest through `children`
347
+ instead of `<Outlet />`. What stays is navigation: both build on
348
+ `useInterceptedNavigation`, the primitive `<Router>` itself uses.
349
+
350
+ ```tsx
351
+ const { generation } = useInterceptedNavigation<Value>({
352
+ claim: (url) => boolean, // synchronous: the only moment interception is possible
353
+ load: (url, signal) => Value | Promise<Value>,
354
+ apply: (value) => void, // called inside a transition
355
+ });
356
+ ```
357
+
358
+ `generation` changes exactly when a new tree is applied — not when the URL
359
+ moved — and a host provides it through `<NavigationGeneration value>` so the
360
+ table's `error` boundaries know when to let a failure go. `<Router>` does
361
+ this itself; the framework's runtime does too.
362
+
363
+ What carries across unchanged is everything that needs no table: `href`,
364
+ `navigateTo`, `usePathname` and `useMatch`. `usePathname` needs one thing on the server,
365
+ where there is no Navigation API to read: the pathname the render is for,
366
+ supplied by `<PathnameProvider pathname>`. `<Router>` mounts one itself and
367
+ both mode runtimes supply it, so an application never writes it — only a host
368
+ building its own seam out of `useInterceptedNavigation` has to. What does not
369
+ carry across is `useRoute` and `useParams` —
370
+ both read the match from context, and under the framework there is no match in
371
+ the browser to read. A framework page receives its `params` as a prop from the
372
+ server instead, which is the only form Server Components can take them in.
package/docs/llms.txt ADDED
@@ -0,0 +1,54 @@
1
+ # @k8ordo/router
2
+
3
+ > The URL's pathname axis, owned. One `defineRoutes` table is the application's
4
+ > pathname schema: types, matching, links and navigation all come from it, over
5
+ > the Navigation API and URLPattern. Search params and history-entry state are
6
+ > not its business — those belong to `@k8ordo/state`.
7
+ >
8
+ > Like every k8ordo package it assumes React 19 and Server Components, uses
9
+ > only what has reached Baseline newly available, and ships no polyfills or
10
+ > legacy fallbacks.
11
+
12
+ When installed via npm, these docs live under `node_modules/@k8ordo/router/docs/`.
13
+
14
+ ## Guide
15
+
16
+ - [Design Guide](GUIDE.md): the table's grammar (leaves, branches, params,
17
+ wildcards, route groups, declaration order), mounting `<Router>` and
18
+ `<Outlet>`, links without a `<Link>` component, `useMatch` for active
19
+ sections, `Register` for checked patterns, what navigation guarantees
20
+ (`finished` after commit, scroll to top or fragment on a new page), and
21
+ how the framework reuses the navigation half.
22
+
23
+ ## Entry point
24
+
25
+ - `@k8ordo/router` — `defineRoutes(record)` returns a table whose one operation
26
+ is `match(pathname)`; `<Router routes>` mounts it on the Navigation API and
27
+ `<Outlet />` renders the rest of the matched stack; a branch's `error`
28
+ component (`{ error, reset }` props, type `ErrorComponent`) renders in the
29
+ layout's hole when what is below throws, keyed by `NavigationGeneration`
30
+ so leaving the page clears it. `href(pattern, params?)`
31
+ and `navigateTo(pattern, params?, options?)` build and perform navigations
32
+ from the pattern **string** alone — no table import, so no cycle — with
33
+ params inferred from the literal and the pattern itself checked once
34
+ `interface Register { routes }` is augmented. `useParams(pattern)` returns
35
+ typed params and throws if rendered under a different pattern; under the
36
+ framework `Register` also carries `params` — the type each pattern's
37
+ `paramsSchema` produces — so `href` takes a param as the page receives it
38
+ (`ParsedParams` / `ParsedParamsMap` / `ParamsSchemaFor` are the types the
39
+ generated code uses); `useRoute()`
40
+ is the untyped form. `RouteOf<typeof routes>` is the pathname union to feed
41
+ `@k8ordo/state`'s `Register`. `usePathname()` reads the platform's
42
+ pathname — never the search — so it works with or without a table, which
43
+ makes it the location hook that also works under the framework;
44
+ `useMatch(pattern)` / `matchPath(pattern, pathname)` answer whether that
45
+ pattern — or, with a trailing `/*`, anything strictly below it — is the pathname
46
+ showing, with its params, table-free, so a sidebar can ask which section is
47
+ open under the framework too; `normalizePathname(pathname)` is the router's
48
+ own trailing-slash rule;
49
+ `<PathnameProvider pathname>` seeds it for a server render and hydration,
50
+ and is mounted by `<Router>` and by both mode runtimes, never by an
51
+ application. `useInterceptedNavigation({claim, load, apply})` returns
52
+ `{ generation }` and is the
53
+ navigation primitive `<Router>` is built on, and the seam the framework
54
+ uses to render server-produced trees instead.
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "@k8ordo/router",
3
+ "version": "0.1.0",
4
+ "description": "The pathname axis of the URL, owned. One route table derives the types, the matching, the links, and the navigation, over the Navigation API and URLPattern.",
5
+ "keywords": [
6
+ "navigation-api",
7
+ "react",
8
+ "router",
9
+ "routing",
10
+ "server-components",
11
+ "typescript",
12
+ "urlpattern"
13
+ ],
14
+ "homepage": "https://ordo.k8o.me",
15
+ "bugs": {
16
+ "url": "https://github.com/k35o/k8ordo/issues"
17
+ },
18
+ "license": "MIT",
19
+ "author": "k8o <kosakanoki@gmail.com>",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/k35o/k8ordo.git"
23
+ },
24
+ "files": [
25
+ "dist/**",
26
+ "docs/**"
27
+ ],
28
+ "type": "module",
29
+ "sideEffects": false,
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.mts",
33
+ "default": "./dist/index.mjs"
34
+ },
35
+ "./package.json": "./package.json"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public",
39
+ "registry": "https://registry.npmjs.org"
40
+ },
41
+ "devDependencies": {
42
+ "@arethetypeswrong/cli": "0.18.5",
43
+ "@types/react": "19.2.18",
44
+ "@vitejs/plugin-react": "6.0.5",
45
+ "@vitest/browser-playwright": "4.1.10",
46
+ "publint": "0.3.23",
47
+ "react": "19.2.8",
48
+ "react-dom": "19.2.8",
49
+ "typescript": "7.0.2",
50
+ "vite": "8.2.1",
51
+ "vite-plus": "0.2.9",
52
+ "vitest": "4.1.10",
53
+ "vitest-browser-react": "2.2.0"
54
+ },
55
+ "peerDependencies": {
56
+ "@types/react": ">=19.2.18",
57
+ "react": ">=19.2.6",
58
+ "typescript": ">=7.0.2"
59
+ },
60
+ "peerDependenciesMeta": {
61
+ "@types/react": {
62
+ "optional": true
63
+ },
64
+ "typescript": {
65
+ "optional": true
66
+ }
67
+ },
68
+ "scripts": {
69
+ "build": "vp pack",
70
+ "test": "vp test",
71
+ "typecheck": "tsc --noEmit",
72
+ "check": "vp check",
73
+ "check:write": "vp check --fix",
74
+ "check:package": "publint && attw --pack . --profile esm-only"
75
+ }
76
+ }