@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Koki Sakano
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,113 @@
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. Search params and
6
+ history-entry state are not its business; those belong to `@k8ordo/state`,
7
+ and the division is the URL's own `?`.
8
+
9
+ Like every [k8ordo](https://ordo.k8o.me) package it assumes React 19 and Server
10
+ Components, uses only what has reached Baseline newly available, and ships no
11
+ polyfills or legacy fallbacks.
12
+
13
+ - **Documentation**: https://ordo.k8o.me/router
14
+ - **Design guide**: [docs/GUIDE.md](docs/GUIDE.md) — shipped inside this package
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ npm install @k8ordo/router
20
+ # or
21
+ pnpm add @k8ordo/router
22
+ ```
23
+
24
+ Under `@k8ordo/static` or `@k8ordo/server` this package is what the framework
25
+ builds on: the table is generated from `src/routes/`, and what an application
26
+ uses of it is `href`, `usePathname` and `useMatch`. On its own it is the whole
27
+ router for an application that renders in the browser.
28
+
29
+ ## Peer Dependencies
30
+
31
+ | Package | Version | Needed for |
32
+ | -------------- | -------- | ----------------------------- |
33
+ | `react` | ≥19.2.6 | `<Router>` and the hooks |
34
+ | `typescript` | ≥7.0.2 | the shipped type declarations |
35
+ | `@types/react` | ≥19.2.18 | the shipped type declarations |
36
+
37
+ No runtime dependencies. The Navigation API and URLPattern are the platform's.
38
+
39
+ ## Quick Start
40
+
41
+ ```ts
42
+ // routes.ts — the pathname schema, in one place
43
+ import { defineRoutes } from '@k8ordo/router';
44
+
45
+ export const routes = defineRoutes({
46
+ '/': Home,
47
+ '/products': {
48
+ layout: Shell,
49
+ error: ProductsError, // shown inside Shell when what is below throws
50
+ children: { '/': ProductList, '/:id': ProductPage },
51
+ },
52
+ '/*': NotFound,
53
+ });
54
+ ```
55
+
56
+ ```tsx
57
+ // app.tsx — mounted once; layouts render what they wrap through <Outlet />
58
+ import { Outlet, Router } from '@k8ordo/router';
59
+
60
+ export const App = () => <Router routes={routes} />;
61
+ export const Shell = () => (
62
+ <section>
63
+ <nav>…</nav>
64
+ <Outlet />
65
+ </section>
66
+ );
67
+ ```
68
+
69
+ ```tsx
70
+ // anywhere — from the pattern string alone, never importing the table
71
+ import { href, navigateTo, useMatch, useParams } from '@k8ordo/router';
72
+
73
+ <a href={href('/products/:id', { id })}>…</a>; // a plain <a> is a client navigation
74
+ navigateTo('/products'); // push; the back button undoes it
75
+ const { id } = useParams('/products/:id'); // typed from the pattern
76
+ const inProducts = useMatch('/products/*'); // which section is showing
77
+ ```
78
+
79
+ Augment `Register` once (`interface Register { routes: typeof routes }`) and
80
+ every pattern is checked against the table. The [design guide](docs/GUIDE.md)
81
+ covers the grammar (params, wildcards, route groups, declaration order), what
82
+ navigation guarantees (`finished` after the tree is on screen, scroll to top
83
+ or fragment on a new page, superseded navigations abort), error boundaries in
84
+ the table, and how the framework reuses the navigation half.
85
+
86
+ ## AI Agent Documentation
87
+
88
+ The docs ship **inside the package**, so an agent always reads the exact
89
+ version you installed — there is no snapshot to copy or re-sync on upgrade.
90
+
91
+ Point your agent at them once by pasting this into your project's `CLAUDE.md` /
92
+ `AGENTS.md`:
93
+
94
+ ```markdown
95
+ Use `@k8ordo/router` for routing. Before touching routes or links, read
96
+ `node_modules/@k8ordo/router/docs/GUIDE.md`. The route table is the one
97
+ place the pathname space is declared; pages build links with
98
+ `href(pattern, params)` from the pattern string and never import the table.
99
+ There is no `<Link>` — a plain `<a>` is a client navigation. Search params
100
+ are `@k8ordo/state`'s, never the router's.
101
+ ```
102
+
103
+ What each surface gives an agent:
104
+
105
+ | Surface | Where |
106
+ | -------------------------- | ---------------------------------------------- |
107
+ | Design guide (entry point) | `node_modules/@k8ordo/router/docs/GUIDE.md` |
108
+ | Docs index for LLMs | `docs/llms.txt` · https://ordo.k8o.me/llms.txt |
109
+ | Markdown twin on the web | https://ordo.k8o.me/router/docs/GUIDE.md |
110
+
111
+ ## License
112
+
113
+ MIT License - see [LICENSE](https://github.com/k35o/k8ordo/blob/main/LICENSE) for details.
@@ -0,0 +1,24 @@
1
+ import { ComponentType, ReactNode } from "react";
2
+ //#region src/boundary.d.ts
3
+ /**
4
+ * What a route table's `error` component receives: what was thrown, and a
5
+ * way to try the subtree again in place.
6
+ */
7
+ type ErrorProps = {
8
+ readonly error: unknown;
9
+ readonly reset: () => void;
10
+ };
11
+ type ErrorComponent = ComponentType<ErrorProps>;
12
+ type CatchProps = {
13
+ readonly fallback: ErrorComponent;
14
+ readonly children?: ReactNode;
15
+ };
16
+ /**
17
+ * The boundary a table's `error` component renders inside. Keyed by the
18
+ * navigation that put the tree on screen, so leaving the page that failed
19
+ * leaves the failure behind — and not by the pathname, which commits before
20
+ * the tree arrives and would remount the boundary onto the old tree.
21
+ */
22
+ declare const RouteErrorBoundary: ({ fallback, children }: CatchProps) => ReactNode;
23
+ //#endregion
24
+ export { ErrorComponent, ErrorProps, RouteErrorBoundary };
@@ -0,0 +1,44 @@
1
+ "use client";
2
+ import { NavigationGeneration } from "./navigation.mjs";
3
+ import { Component, Suspense, createElement, use } from "react";
4
+ //#region src/boundary.tsx
5
+ var Catch = class extends Component {
6
+ state = {
7
+ error: void 0,
8
+ failed: false
9
+ };
10
+ static getDerivedStateFromError(error) {
11
+ return {
12
+ error,
13
+ failed: true
14
+ };
15
+ }
16
+ reset = () => {
17
+ this.setState({
18
+ error: void 0,
19
+ failed: false
20
+ });
21
+ };
22
+ render() {
23
+ if (!this.state.failed) return this.props.children;
24
+ return createElement(this.props.fallback, {
25
+ error: this.state.error,
26
+ reset: this.reset
27
+ });
28
+ }
29
+ };
30
+ /**
31
+ * The boundary a table's `error` component renders inside. Keyed by the
32
+ * navigation that put the tree on screen, so leaving the page that failed
33
+ * leaves the failure behind — and not by the pathname, which commits before
34
+ * the tree arrives and would remount the boundary onto the old tree.
35
+ */
36
+ const RouteErrorBoundary = ({ fallback, children }) => {
37
+ const generation = use(NavigationGeneration);
38
+ return createElement(Suspense, { fallback: null }, createElement(Catch, {
39
+ key: generation,
40
+ fallback
41
+ }, children));
42
+ };
43
+ //#endregion
44
+ export { RouteErrorBoundary };
@@ -0,0 +1,67 @@
1
+ import { ErrorComponent } from "./boundary.mjs";
2
+ import { Join, PathFor } from "./paths.mjs";
3
+ import { ComponentType, ReactNode } from "react";
4
+ //#region src/define-routes.d.ts
5
+ /**
6
+ * Any component, whatever props it declares. The table is written once and
7
+ * rendered by different renderers — a client app renders leaves with no
8
+ * props, the framework hands server components their `params` and layouts
9
+ * their `children` — so the stored type has to admit them all. `never` props
10
+ * is the type-safe spelling of that; each renderer states what it passes.
11
+ */
12
+ type RouteComponent = ComponentType<never>;
13
+ /**
14
+ * A leaf renders; a branch wraps its children in an optional layout, and
15
+ * may name an `error` component to show in place of what is below when it
16
+ * throws — inside the layout, so the frame survives the failure. Lazy
17
+ * components (`React.lazy`) are objects, not functions, so a branch is
18
+ * recognized by its `children` key rather than by `typeof`.
19
+ */
20
+ type RouteNode = RouteComponent | {
21
+ layout?: RouteComponent;
22
+ error?: ErrorComponent;
23
+ children: RoutesRecord;
24
+ };
25
+ type RoutesRecord = Record<`/${string}`, RouteNode>;
26
+ type Below<Prefix extends string, Key extends string> = Join<Prefix, Key> extends '/' ? '' : Join<Prefix, Key>;
27
+ type PatternsIn<Record_, Prefix extends string> = { [K in keyof Record_ & string]: Record_[K] extends {
28
+ children: infer C;
29
+ } ? PatternsIn<C, Below<Prefix, K>> : Join<Prefix, K>; }[keyof Record_ & string];
30
+ /** Every full leaf pattern in the table, as written. */
31
+ type PatternOf<R extends RoutesRecord> = PatternsIn<R, ''> & string;
32
+ /** Patterns a link can point at — wildcards are matched, never linked. */
33
+ type NavigablePatternOf<R extends RoutesRecord> = Exclude<PatternOf<R>, `${string}*${string}`>;
34
+ /**
35
+ * The app's pathname space as a type: the union state's `Register` (and any
36
+ * other typed-path consumer) is fed with.
37
+ */
38
+ type RouteOf<D> = D extends Routes<infer R> ? PathFor<NavigablePatternOf<R>> : never;
39
+ type Match = {
40
+ /** The full pattern that won, as written in the table. */
41
+ pattern: string;
42
+ params: Readonly<Record<string, string>>;
43
+ /** Layouts outer-first, the leaf last. */
44
+ stack: readonly RouteComponent[];
45
+ };
46
+ type Routes<R extends RoutesRecord = RoutesRecord> = {
47
+ kind: 'routes';
48
+ /** The table as passed. */
49
+ record: R;
50
+ /**
51
+ * Matches a pathname in declaration order, first match wins — precedence
52
+ * is what you wrote, not a specificity ranking to second-guess. The one
53
+ * operation that needs the table's value: links and navigation build from
54
+ * the pattern string alone (`href` / `navigateTo`), so only `<Router>`
55
+ * ever holds this object.
56
+ *
57
+ * `accept` lets the caller decline a match, in which case the walk goes
58
+ * on to the next pattern as if this one had not fit — a param a schema
59
+ * refuses is a pathname the pattern does not answer, and what answers it
60
+ * instead is whatever the table declares next, the catch-all included.
61
+ */
62
+ match: (pathname: string, accept?: (match: Match) => boolean) => Match | null;
63
+ };
64
+ declare const setBoundaryOutlet: (render: () => ReactNode) => void;
65
+ declare function defineRoutes<R extends RoutesRecord>(record: R): Routes<R>;
66
+ //#endregion
67
+ export { Match, NavigablePatternOf, PatternOf, RouteComponent, RouteNode, RouteOf, Routes, RoutesRecord, defineRoutes, setBoundaryOutlet };
@@ -0,0 +1,84 @@
1
+ import { isGroupKey, joinPattern, normalizePathname } from "./paths.mjs";
2
+ import { RouteErrorBoundary } from "./boundary.mjs";
3
+ import { createElement } from "react";
4
+ //#region src/define-routes.ts
5
+ const isBranch = (node) => typeof node === "object" && "children" in node;
6
+ /**
7
+ * A branch's `error` becomes an element of the stack — after the layout, so
8
+ * it wraps what the layout wraps. It takes what any renderer passes and
9
+ * renders what it is given; a renderer that passes nothing (the client
10
+ * router, which nests through `<Outlet />`) is told so by the absence of
11
+ * `children`, and the boundary leaves the hole to the next element.
12
+ */
13
+ const boundaryFor = (fallback) => {
14
+ const Boundary = ({ children }) => createElement(RouteErrorBoundary, { fallback }, children === void 0 ? outlet() : children);
15
+ return Boundary;
16
+ };
17
+ const decode = (value) => {
18
+ try {
19
+ return decodeURIComponent(value);
20
+ } catch {
21
+ return value;
22
+ }
23
+ };
24
+ /**
25
+ * What a boundary renders when a renderer hands it no children. Set once by
26
+ * the client router, which is the one renderer that nests by context rather
27
+ * than by prop; the framework always passes `children`.
28
+ */
29
+ let outlet = () => null;
30
+ const setBoundaryOutlet = (render) => {
31
+ outlet = render;
32
+ };
33
+ function defineRoutes(record) {
34
+ const entries = [];
35
+ const seen = /* @__PURE__ */ new Set();
36
+ const walk = (current, prefix, stack) => {
37
+ for (const [key, node] of Object.entries(current)) {
38
+ if (!key.startsWith("/")) throw new TypeError(`route pattern "${key}" must start with "/"`);
39
+ if (key.includes("(") && !isGroupKey(key)) throw new TypeError(`route group "${key}" must be "/(name)" and nothing else — a regular expression is not part of the grammar`);
40
+ if (isGroupKey(key) && !isBranch(node)) throw new TypeError(`route group "${key}" must have children`);
41
+ const pattern = joinPattern(prefix, key);
42
+ if (isBranch(node)) {
43
+ const below = [
44
+ ...stack,
45
+ ...node.layout === void 0 ? [] : [node.layout],
46
+ ...node.error === void 0 ? [] : [boundaryFor(node.error)]
47
+ ];
48
+ walk(node.children, pattern === "/" ? "" : pattern, below);
49
+ } else {
50
+ if (seen.has(pattern)) throw new TypeError(`route pattern "${pattern}" is declared twice`);
51
+ seen.add(pattern);
52
+ entries.push({
53
+ pattern,
54
+ matcher: new URLPattern({ pathname: pattern }),
55
+ stack: [...stack, node]
56
+ });
57
+ }
58
+ }
59
+ };
60
+ walk(record, "", []);
61
+ const match = (pathname, accept) => {
62
+ const normalized = normalizePathname(pathname);
63
+ for (const entry of entries) {
64
+ const result = entry.matcher.exec({ pathname: normalized });
65
+ if (result === null) continue;
66
+ const params = {};
67
+ for (const [name, value] of Object.entries(result.pathname.groups)) if (!/^\d+$/u.test(name) && value !== void 0) params[name] = decode(value);
68
+ const found = {
69
+ pattern: entry.pattern,
70
+ params,
71
+ stack: entry.stack
72
+ };
73
+ if (accept === void 0 || accept(found)) return found;
74
+ }
75
+ return null;
76
+ };
77
+ return {
78
+ kind: "routes",
79
+ record,
80
+ match
81
+ };
82
+ }
83
+ //#endregion
84
+ export { defineRoutes, setBoundaryOutlet };
@@ -0,0 +1,10 @@
1
+ import { ErrorComponent, ErrorProps } from "./boundary.mjs";
2
+ import { ParamValue, ParamsOf, ParamsSchemaFor, ParsedParams, ParsedParamsMap, PathFor, SchemaOutput, StandardSchemaLike, normalizePathname } from "./paths.mjs";
3
+ import { Match, NavigablePatternOf, PatternOf, RouteComponent, RouteNode, RouteOf, Routes, RoutesRecord, defineRoutes } from "./define-routes.mjs";
4
+ import { Register, RegisteredNavigablePattern, RegisteredParams, RegisteredPattern } from "./register.mjs";
5
+ import { NavigateToOptions, href, navigateTo } from "./links.mjs";
6
+ import { PathnameProvider, usePathname } from "./location.mjs";
7
+ import { MatchablePattern, matchPath, useMatch } from "./match.mjs";
8
+ import { NavigationGeneration, NavigationHandler, useInterceptedNavigation } from "./navigation.mjs";
9
+ import { Outlet, Router, useParams, useRoute } from "./router.mjs";
10
+ export { type ErrorComponent, type ErrorProps, type Match, type MatchablePattern, type NavigablePatternOf, type NavigateToOptions, NavigationGeneration, type NavigationHandler, Outlet, type ParamValue, type ParamsOf, type ParamsSchemaFor, type ParsedParams, type ParsedParamsMap, type PathFor, PathnameProvider, type PatternOf, type Register, type RegisteredNavigablePattern, type RegisteredParams, type RegisteredPattern, type RouteComponent, type RouteNode, type RouteOf, Router, type Routes, type RoutesRecord, type SchemaOutput, type StandardSchemaLike, defineRoutes, href, matchPath, navigateTo, normalizePathname, useInterceptedNavigation, useMatch, useParams, usePathname, useRoute };
package/dist/index.mjs ADDED
@@ -0,0 +1,8 @@
1
+ import { normalizePathname } from "./paths.mjs";
2
+ import { NavigationGeneration, useInterceptedNavigation } from "./navigation.mjs";
3
+ import { defineRoutes } from "./define-routes.mjs";
4
+ import { href, navigateTo } from "./links.mjs";
5
+ import { PathnameProvider, usePathname } from "./location.mjs";
6
+ import { matchPath, useMatch } from "./match.mjs";
7
+ import { Outlet, Router, useParams, useRoute } from "./router.mjs";
8
+ export { NavigationGeneration, Outlet, PathnameProvider, Router, defineRoutes, href, matchPath, navigateTo, normalizePathname, useInterceptedNavigation, useMatch, useParams, usePathname, useRoute };
@@ -0,0 +1,31 @@
1
+ import { PathFor } from "./paths.mjs";
2
+ import { RegisteredNavigablePattern, RegisteredParams } from "./register.mjs";
3
+ //#region src/links.d.ts
4
+ type HrefArgs<Params> = keyof Params extends never ? [] : [params: Readonly<Params & Record<never, never>>];
5
+ type NavigateToOptions = {
6
+ /**
7
+ * `push` by default: going to a page is what the back button undoes —
8
+ * the opposite default from a state refinement.
9
+ */
10
+ history?: 'push' | 'replace';
11
+ };
12
+ type NavigateToArgs<Params> = keyof Params extends never ? [options?: NavigateToOptions] : [params: Readonly<Params & Record<never, never>>, options?: NavigateToOptions];
13
+ /**
14
+ * Builds a concrete path from a pattern and its params — no route table
15
+ * involved, which is why pages never import one. Params are inferred from
16
+ * the pattern literal itself; `Register` adds the check that the pattern
17
+ * exists in the app's table, and — under the framework — the type a page's
18
+ * schema gave each param, so a link takes what the page received. The path
19
+ * shape survives in the return type for typed-path consumers
20
+ * (`@k8ordo/state`'s `Register` among them).
21
+ */
22
+ declare const href: <P extends RegisteredNavigablePattern>(pattern: P, ...params: HrefArgs<RegisteredParams<P>>) => PathFor<P>;
23
+ /**
24
+ * Typed imperative navigation: `href` composed with
25
+ * `navigation.navigate()`, returning the platform's own
26
+ * `{ committed, finished }`. Changing pages goes through here; changing
27
+ * state goes through `@k8ordo/state`'s `update()`.
28
+ */
29
+ declare const navigateTo: <P extends RegisteredNavigablePattern>(pattern: P, ...rest: NavigateToArgs<RegisteredParams<P>>) => ReturnType<Navigation['navigate']>;
30
+ //#endregion
31
+ export { NavigateToOptions, href, navigateTo };
package/dist/links.mjs ADDED
@@ -0,0 +1,27 @@
1
+ import { buildHref } from "./paths.mjs";
2
+ //#region src/links.ts
3
+ /**
4
+ * Builds a concrete path from a pattern and its params — no route table
5
+ * involved, which is why pages never import one. Params are inferred from
6
+ * the pattern literal itself; `Register` adds the check that the pattern
7
+ * exists in the app's table, and — under the framework — the type a page's
8
+ * schema gave each param, so a link takes what the page received. The path
9
+ * shape survives in the return type for typed-path consumers
10
+ * (`@k8ordo/state`'s `Register` among them).
11
+ */
12
+ const href = (pattern, ...params) => buildHref(pattern, params[0]);
13
+ /**
14
+ * Typed imperative navigation: `href` composed with
15
+ * `navigation.navigate()`, returning the platform's own
16
+ * `{ committed, finished }`. Changing pages goes through here; changing
17
+ * state goes through `@k8ordo/state`'s `update()`.
18
+ */
19
+ const navigateTo = (pattern, ...rest) => {
20
+ const wantsParams = pattern.includes(":");
21
+ const args = rest;
22
+ const params = wantsParams ? args[0] : void 0;
23
+ const options = wantsParams ? args[1] : args[0];
24
+ return navigation.navigate(buildHref(pattern, params), { history: options?.history ?? "push" });
25
+ };
26
+ //#endregion
27
+ export { href, navigateTo };
@@ -0,0 +1,31 @@
1
+ import { ReactNode } from "react";
2
+ //#region src/location.d.ts
3
+ /**
4
+ * The pathname the render started from. A client component's first render can
5
+ * happen where there is no Navigation API — on the server, and again during
6
+ * hydration — so the value has to arrive from the renderer that did know it.
7
+ *
8
+ * `<Router>` supplies this on its own. Under the framework the mode's runtime
9
+ * supplies it; an application never writes this itself.
10
+ */
11
+ declare const PathnameProvider: ({ pathname, children }: {
12
+ pathname: string;
13
+ children: ReactNode;
14
+ }) => ReactNode;
15
+ /**
16
+ * Where the browser currently is, as a pathname — the one axis this package
17
+ * owns. It reads the platform rather than a table, so it works the same in a
18
+ * client application and under the framework, where there is no route table
19
+ * in the browser at all.
20
+ *
21
+ * It is not `useRoute`: this answers "what is the URL", which a navigation
22
+ * link needs, while `useRoute` answers "which route won", which only a table
23
+ * can say.
24
+ *
25
+ * The search string is deliberately absent. A component that re-rendered on
26
+ * every search change would defeat `@k8ordo/state`'s keyed subscriptions, and
27
+ * the split at the `?` is the whole boundary between the two packages.
28
+ */
29
+ declare function usePathname(): string;
30
+ //#endregion
31
+ export { PathnameProvider, usePathname };
@@ -0,0 +1,51 @@
1
+ "use client";
2
+ import { normalizePathname } from "./paths.mjs";
3
+ import { createContext, use, useMemo, useSyncExternalStore } from "react";
4
+ import { jsx } from "react/jsx-runtime";
5
+ //#region src/location.tsx
6
+ const ServerPathname = createContext(null);
7
+ /**
8
+ * The pathname the render started from. A client component's first render can
9
+ * happen where there is no Navigation API — on the server, and again during
10
+ * hydration — so the value has to arrive from the renderer that did know it.
11
+ *
12
+ * `<Router>` supplies this on its own. Under the framework the mode's runtime
13
+ * supplies it; an application never writes this itself.
14
+ */
15
+ const PathnameProvider = ({ pathname, children }) => {
16
+ const value = useMemo(() => normalizePathname(pathname), [pathname]);
17
+ return /* @__PURE__ */ jsx(ServerPathname, {
18
+ value,
19
+ children
20
+ });
21
+ };
22
+ const subscribe = (onChange) => {
23
+ navigation.addEventListener("currententrychange", onChange);
24
+ return () => {
25
+ navigation.removeEventListener("currententrychange", onChange);
26
+ };
27
+ };
28
+ const readPathname = () => normalizePathname(location.pathname);
29
+ /**
30
+ * Where the browser currently is, as a pathname — the one axis this package
31
+ * owns. It reads the platform rather than a table, so it works the same in a
32
+ * client application and under the framework, where there is no route table
33
+ * in the browser at all.
34
+ *
35
+ * It is not `useRoute`: this answers "what is the URL", which a navigation
36
+ * link needs, while `useRoute` answers "which route won", which only a table
37
+ * can say.
38
+ *
39
+ * The search string is deliberately absent. A component that re-rendered on
40
+ * every search change would defeat `@k8ordo/state`'s keyed subscriptions, and
41
+ * the split at the `?` is the whole boundary between the two packages.
42
+ */
43
+ function usePathname() {
44
+ const fromServer = use(ServerPathname);
45
+ return useSyncExternalStore(subscribe, readPathname, () => {
46
+ if (fromServer === null) throw new Error("usePathname needs <Router> above it, or a page rendered by @k8ordo/static or @k8ordo/server");
47
+ return fromServer;
48
+ });
49
+ }
50
+ //#endregion
51
+ export { PathnameProvider, usePathname };
@@ -0,0 +1,26 @@
1
+ import { ParamsOf } from "./paths.mjs";
2
+ import { RegisteredNavigablePattern, RegisteredPattern } from "./register.mjs";
3
+ //#region src/match.d.ts
4
+ /**
5
+ * A pattern from the table, or a table pattern followed by `/*` to mean
6
+ * "everything below it" — the shape a navigation asks about when it wants
7
+ * to know which section of the site is showing. The pattern's own page is
8
+ * not below it: `/x/*` matches `/x/y` and not `/x`.
9
+ */
10
+ type MatchablePattern = RegisteredPattern | `${RegisteredNavigablePattern}/*`;
11
+ /**
12
+ * Whether a pathname is the one a pattern names, and with which params. Pure
13
+ * and table-free — the pattern is checked against `Register`, the pathname
14
+ * is whatever the caller holds — which is why it works where no table is
15
+ * mounted, under the framework included.
16
+ */
17
+ declare const matchPath: <P extends MatchablePattern>(pattern: P, pathname: string) => ParamsOf<P> | null;
18
+ /**
19
+ * `matchPath` against where the browser is. It re-renders when the pathname
20
+ * changes and never on the search, like `usePathname` it is built on — so a
21
+ * sidebar can ask "is a page under `/docs/*` showing" without a table in the
22
+ * browser, which is the question `useRoute` cannot answer under the framework.
23
+ */
24
+ declare function useMatch<P extends MatchablePattern>(pattern: P): ParamsOf<P> | null;
25
+ //#endregion
26
+ export { MatchablePattern, matchPath, useMatch };
package/dist/match.mjs ADDED
@@ -0,0 +1,35 @@
1
+ "use client";
2
+ import { normalizePathname } from "./paths.mjs";
3
+ import { usePathname } from "./location.mjs";
4
+ //#region src/match.ts
5
+ const decode = (value) => {
6
+ try {
7
+ return decodeURIComponent(value);
8
+ } catch {
9
+ return value;
10
+ }
11
+ };
12
+ /**
13
+ * Whether a pathname is the one a pattern names, and with which params. Pure
14
+ * and table-free — the pattern is checked against `Register`, the pathname
15
+ * is whatever the caller holds — which is why it works where no table is
16
+ * mounted, under the framework included.
17
+ */
18
+ const matchPath = (pattern, pathname) => {
19
+ const result = new URLPattern({ pathname: pattern }).exec({ pathname: normalizePathname(pathname) });
20
+ if (result === null) return null;
21
+ const params = {};
22
+ for (const [name, value] of Object.entries(result.pathname.groups)) if (!/^\d+$/u.test(name) && value !== void 0) params[name] = decode(value);
23
+ return params;
24
+ };
25
+ /**
26
+ * `matchPath` against where the browser is. It re-renders when the pathname
27
+ * changes and never on the search, like `usePathname` it is built on — so a
28
+ * sidebar can ask "is a page under `/docs/*` showing" without a table in the
29
+ * browser, which is the question `useRoute` cannot answer under the framework.
30
+ */
31
+ function useMatch(pattern) {
32
+ return matchPath(pattern, usePathname());
33
+ }
34
+ //#endregion
35
+ export { matchPath, useMatch };
@@ -0,0 +1,69 @@
1
+ //#region src/navigation.d.ts
2
+ /**
3
+ * What the decision below reads. Spelled out rather than taking the event,
4
+ * because a `NavigateEvent` cannot be constructed in a test — its destination
5
+ * is not constructible — and this decision is the whole contract with the
6
+ * platform.
7
+ */
8
+ type NavigationFacts = Pick<NavigateEvent, 'canIntercept' | 'hashChange' | 'downloadRequest' | 'navigationType'> & {
9
+ readonly formData: FormData | null;
10
+ };
11
+ /**
12
+ * Whether this navigation is the application's to handle at all.
13
+ *
14
+ * A reload is a request for a fresh document, and a POST submission carries a
15
+ * body only the server can act on: intercepting either means silently doing
16
+ * nothing where the platform would have done the obvious thing. A GET form
17
+ * has no `formData`, so the search-shaped navigations `@k8ordo/state` builds
18
+ * still come through here.
19
+ */
20
+ declare const isOurs: (event: NavigationFacts) => boolean;
21
+ type NavigationHandler<T> = {
22
+ /**
23
+ * Whether this navigation is the application's to handle. Answered
24
+ * synchronously, because that is the only moment the platform lets anyone
25
+ * intercept. Saying no leaves a real document load — and a real 404 — to
26
+ * the server.
27
+ */
28
+ claim: (url: URL) => boolean;
29
+ /** Produces whatever the application renders for this URL. */
30
+ load: (url: URL, signal: AbortSignal) => T | Promise<T>;
31
+ /** Applies it. Called inside a transition. */
32
+ apply: (value: T) => void;
33
+ };
34
+ /**
35
+ * Where the viewport goes once the new page is on screen. A traversal is the
36
+ * browser's to restore, so it carries no instruction; a fragment scrolls to
37
+ * its target; anything else starts at the top, the way a document load does.
38
+ */
39
+ type ScrollPlan = {
40
+ kind: 'top';
41
+ } | {
42
+ kind: 'fragment';
43
+ id: string;
44
+ };
45
+ declare const scrollPlanFor: (navigationType: NavigationType, hash: string) => ScrollPlan | null;
46
+ /**
47
+ * Which navigation put the tree on screen — a number that changes exactly
48
+ * when a new tree is applied, and not when only the URL moved. It is the
49
+ * identity an error boundary keys on: leaving the page that failed is what
50
+ * should clear the failure, and the URL commits before the tree arrives, so
51
+ * the pathname would clear it one render too early and let the old tree
52
+ * fail again under the new key.
53
+ */
54
+ declare const NavigationGeneration: import("react").Context<number>;
55
+ /**
56
+ * The navigation half of the router, on its own: intercept, load, apply in a
57
+ * transition, and resolve the platform's handler only once the new tree is on
58
+ * screen — which is what makes `navigation.navigate().finished` mean "the
59
+ * page is showing", and what `@k8ordo/state`'s `update().finished` inherits.
60
+ *
61
+ * What gets loaded is the caller's business: a route table match for a plain
62
+ * client app, an RSC payload under the framework. Neither has to teach this
63
+ * hook anything about the other.
64
+ */
65
+ declare function useInterceptedNavigation<T>(handler: NavigationHandler<T>): {
66
+ readonly generation: number;
67
+ };
68
+ //#endregion
69
+ export { NavigationFacts, NavigationGeneration, NavigationHandler, ScrollPlan, isOurs, scrollPlanFor, useInterceptedNavigation };