@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.
@@ -0,0 +1,123 @@
1
+ "use client";
2
+ import { normalizePathname } from "./paths.mjs";
3
+ import { createContext, startTransition, useEffect, useRef, useState } from "react";
4
+ //#region src/navigation.ts
5
+ /**
6
+ * Whether this navigation is the application's to handle at all.
7
+ *
8
+ * A reload is a request for a fresh document, and a POST submission carries a
9
+ * body only the server can act on: intercepting either means silently doing
10
+ * nothing where the platform would have done the obvious thing. A GET form
11
+ * has no `formData`, so the search-shaped navigations `@k8ordo/state` builds
12
+ * still come through here.
13
+ */
14
+ const isOurs = (event) => event.canIntercept && !event.hashChange && event.downloadRequest === null && event.navigationType !== "reload" && event.formData === null;
15
+ const scrollPlanFor = (navigationType, hash) => {
16
+ if (navigationType === "traverse") return null;
17
+ if (hash.length > 1) {
18
+ let id = hash.slice(1);
19
+ try {
20
+ id = decodeURIComponent(id);
21
+ } catch {}
22
+ return {
23
+ kind: "fragment",
24
+ id
25
+ };
26
+ }
27
+ return { kind: "top" };
28
+ };
29
+ const applyScroll = (plan) => {
30
+ if (plan.kind === "fragment") {
31
+ const selector = `#${CSS.escape(plan.id)}, [name=${JSON.stringify(plan.id)}]`;
32
+ const target = document.querySelector(selector);
33
+ if (target !== null) {
34
+ target.scrollIntoView();
35
+ return;
36
+ }
37
+ }
38
+ window.scrollTo({
39
+ left: 0,
40
+ top: -1
41
+ });
42
+ };
43
+ /**
44
+ * Which navigation put the tree on screen — a number that changes exactly
45
+ * when a new tree is applied, and not when only the URL moved. It is the
46
+ * identity an error boundary keys on: leaving the page that failed is what
47
+ * should clear the failure, and the URL commits before the tree arrives, so
48
+ * the pathname would clear it one render too early and let the old tree
49
+ * fail again under the new key.
50
+ */
51
+ const NavigationGeneration = createContext(-1);
52
+ /**
53
+ * The navigation half of the router, on its own: intercept, load, apply in a
54
+ * transition, and resolve the platform's handler only once the new tree is on
55
+ * screen — which is what makes `navigation.navigate().finished` mean "the
56
+ * page is showing", and what `@k8ordo/state`'s `update().finished` inherits.
57
+ *
58
+ * What gets loaded is the caller's business: a route table match for a plain
59
+ * client app, an RSC payload under the framework. Neither has to teach this
60
+ * hook anything about the other.
61
+ */
62
+ function useInterceptedNavigation(handler) {
63
+ const latest = useRef(handler);
64
+ latest.current = handler;
65
+ const pending = useRef(/* @__PURE__ */ new Map());
66
+ const count = useRef(0);
67
+ const [applied, setApplied] = useState(-1);
68
+ const shown = useRef(null);
69
+ useEffect(() => {
70
+ shown.current = normalizePathname(location.pathname);
71
+ const onNavigate = (event) => {
72
+ if (!isOurs(event)) return;
73
+ const url = new URL(event.destination.url);
74
+ const pathname = normalizePathname(url.pathname);
75
+ if (pathname === shown.current) {
76
+ event.intercept({
77
+ scroll: "manual",
78
+ focusReset: "manual"
79
+ });
80
+ return;
81
+ }
82
+ if (!latest.current.claim(url)) return;
83
+ const id = count.current++;
84
+ const scroll = scrollPlanFor(event.navigationType, url.hash);
85
+ event.intercept({
86
+ scroll: scroll === null ? "after-transition" : "manual",
87
+ handler: async () => {
88
+ const value = await latest.current.load(url, event.signal);
89
+ if (event.signal.aborted) throw event.signal.reason;
90
+ await new Promise((resolve, reject) => {
91
+ pending.current.set(id, {
92
+ resolve,
93
+ scroll
94
+ });
95
+ event.signal.addEventListener("abort", () => {
96
+ pending.current.delete(id);
97
+ reject(event.signal.reason);
98
+ }, { once: true });
99
+ startTransition(() => {
100
+ shown.current = pathname;
101
+ latest.current.apply(value);
102
+ setApplied(id);
103
+ });
104
+ });
105
+ }
106
+ });
107
+ };
108
+ navigation.addEventListener("navigate", onNavigate);
109
+ return () => {
110
+ navigation.removeEventListener("navigate", onNavigate);
111
+ };
112
+ }, []);
113
+ useEffect(() => {
114
+ const entry = pending.current.get(applied);
115
+ if (entry === void 0) return;
116
+ pending.current.delete(applied);
117
+ if (entry.scroll !== null) applyScroll(entry.scroll);
118
+ entry.resolve();
119
+ }, [applied]);
120
+ return { generation: applied };
121
+ }
122
+ //#endregion
123
+ export { NavigationGeneration, isOurs, scrollPlanFor, useInterceptedNavigation };
@@ -0,0 +1,67 @@
1
+ //#region src/paths.d.ts
2
+ /**
3
+ * The type half of the route table: everything here is derived from the
4
+ * pattern strings by inference alone — no code generation. A pattern like
5
+ * `'/:locale/products/:id'` yields its params (`{ locale, id }`), its
6
+ * concrete-path type (`` `/${string}/products/${string}` ``), and its place
7
+ * in the app-wide route union.
8
+ */
9
+ type SegParam<Segment extends string> = Segment extends `:${infer Name}` ? Record<Name, string> : Record<never, never>;
10
+ type SegListParams<Rest extends string> = Rest extends `${infer Segment}/${infer Tail}` ? SegParam<Segment> & SegListParams<Tail> : SegParam<Rest>;
11
+ /** `'/:locale/products/:id'` → `{ locale: string; id: string }` */
12
+ type ParamsOf<Pattern extends string> = Pattern extends `/${infer Rest}` ? SegListParams<Rest> : Record<never, never>;
13
+ /**
14
+ * The shape every validation library agrees on (Standard Schema): a value
15
+ * under `~standard` whose `types` carry the input and output. Only the
16
+ * output is read here — the router itself never runs a schema; the framework
17
+ * does, and this package only has to type what comes out.
18
+ */
19
+ type StandardSchemaLike<Output = unknown> = {
20
+ readonly '~standard': {
21
+ readonly types?: {
22
+ readonly output: Output;
23
+ } | undefined;
24
+ };
25
+ };
26
+ /** What a schema produces. */
27
+ type SchemaOutput<Schema> = Schema extends StandardSchemaLike<infer Output> ? Output : never;
28
+ /**
29
+ * A schema a route file may declare for the params its pattern names: its
30
+ * output is an object whose keys are a subset of those params — a key the
31
+ * pattern does not name has nothing to validate.
32
+ */
33
+ type ParamsSchemaFor<Pattern extends string> = StandardSchemaLike<Partial<Record<keyof ParamsOf<Pattern> & string, unknown>>>;
34
+ type MergedOutput<Schemas extends readonly unknown[]> = Schemas extends readonly [infer Head, ...infer Tail] ? Omit<MergedOutput<Tail>, keyof SchemaOutput<Head>> & SchemaOutput<Head> : Record<never, never>;
35
+ /**
36
+ * A pattern's params after the schemas along its stack have run — outer
37
+ * layouts first, the page last — each replacing the strings it names with
38
+ * what it produced. Params no schema names stay strings.
39
+ */
40
+ type ParsedParams<Pattern extends string, Schemas extends readonly unknown[]> = Omit<ParamsOf<Pattern>, keyof MergedOutput<Schemas>> & MergedOutput<Schemas>;
41
+ /** `{ '/products/:id': [schema, …] }` → `{ '/products/:id': { id: number } }` */
42
+ type ParsedParamsMap<Schemas extends Record<string, readonly unknown[]>> = { [Pattern in keyof Schemas & string]: ParsedParams<Pattern, Schemas[Pattern]>; };
43
+ type SegPath<Segment extends string> = Segment extends `:${string}` ? string : Segment;
44
+ type SegListPath<Rest extends string> = Rest extends `${infer Segment}/${infer Tail}` ? `${SegPath<Segment>}/${SegListPath<Tail>}` : SegPath<Rest>;
45
+ /** `'/:locale/products/:id'` → `` `/${string}/products/${string}` `` */
46
+ type PathFor<Pattern extends string> = Pattern extends `/${infer Rest}` ? `/${SegListPath<Rest>}` : never;
47
+ /**
48
+ * Keys that address the parent's own place instead of a place below it:
49
+ * `'/'` (the branch's index) and `'/(name)'` (a group, which structures the
50
+ * table — its own layout, its own subtree — without touching the URL).
51
+ */
52
+ type Transparent<Key extends string> = Key extends '/' ? true : Key extends `/(${string})` ? true : false;
53
+ /** Composes a parent prefix with a child key. */
54
+ type Join<Prefix extends string, Key extends string> = Transparent<Key> extends true ? Prefix extends '' ? '/' : Prefix : `${Prefix}${Key}`;
55
+ declare const isGroupKey: (key: string) => boolean;
56
+ declare const joinPattern: (prefix: string, key: string) => string;
57
+ /**
58
+ * URLPattern treats `/products` and `/products/` as different pathnames; the
59
+ * router does not. Everything is matched against the slashless canonical
60
+ * form, root excepted.
61
+ */
62
+ declare const normalizePathname: (pathname: string) => string;
63
+ /** What a param value may be on the way into a link: anything with one spelling. */
64
+ type ParamValue = string | number | bigint | boolean;
65
+ declare const buildHref: (pattern: string, params: Readonly<Record<string, unknown>> | undefined) => string;
66
+ //#endregion
67
+ export { Join, ParamValue, ParamsOf, ParamsSchemaFor, ParsedParams, ParsedParamsMap, PathFor, SchemaOutput, StandardSchemaLike, buildHref, isGroupKey, joinPattern, normalizePathname };
package/dist/paths.mjs ADDED
@@ -0,0 +1,26 @@
1
+ //#region src/paths.ts
2
+ const isGroupKey = (key) => /^\/\([^)]+\)$/u.test(key);
3
+ const joinPattern = (prefix, key) => key === "/" || isGroupKey(key) ? prefix === "" ? "/" : prefix : `${prefix}${key}`;
4
+ /**
5
+ * URLPattern treats `/products` and `/products/` as different pathnames; the
6
+ * router does not. Everything is matched against the slashless canonical
7
+ * form, root excepted.
8
+ */
9
+ const normalizePathname = (pathname) => {
10
+ let end = pathname.length;
11
+ while (end > 1 && pathname.charAt(end - 1) === "/") end -= 1;
12
+ return pathname.slice(0, end);
13
+ };
14
+ const buildHref = (pattern, params) => {
15
+ if (pattern.includes("*")) throw new TypeError(`"${pattern}" is a wildcard — it has no href`);
16
+ return pattern.split("/").map((segment) => {
17
+ if (!segment.startsWith(":")) return segment;
18
+ const name = segment.slice(1);
19
+ const value = params?.[name];
20
+ if (value === void 0) throw new TypeError(`"${pattern}" needs a value for ":${name}"`);
21
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "bigint" && typeof value !== "boolean") throw new TypeError(`"${pattern}" got a value for ":${name}" that has no URL spelling`);
22
+ return encodeURIComponent(String(value));
23
+ }).join("/");
24
+ };
25
+ //#endregion
26
+ export { buildHref, isGroupKey, joinPattern, normalizePathname };
@@ -0,0 +1,46 @@
1
+ import { ParamValue, ParamsOf } from "./paths.mjs";
2
+ import { NavigablePatternOf, PatternOf, Routes, RoutesRecord } from "./define-routes.mjs";
3
+ //#region src/register.d.ts
4
+ /**
5
+ * The app-side hook for the route table's type. An application augments this
6
+ * once:
7
+ *
8
+ * ```ts
9
+ * declare module '@k8ordo/router' {
10
+ * interface Register { routes: typeof routes }
11
+ * }
12
+ * ```
13
+ *
14
+ * and `href` / `navigateTo` / `useParams` verify their pattern against the
15
+ * table everywhere — without any component importing the table's value.
16
+ * Only `<Router>` touches the value; everything else needs just the string
17
+ * pattern, so the routes-module → pages → routes-module import cycle never
18
+ * forms. Declared as an interface — the one exception to the repository's
19
+ * type-only rule — because declaration merging is the entire mechanism.
20
+ */
21
+ interface Register {}
22
+ /** Every pattern in the registered table; any `/`-pattern before Register. */
23
+ type RegisteredPattern = Register extends {
24
+ routes: Routes<infer R extends RoutesRecord>;
25
+ } ? PatternOf<R> : `/${string}`;
26
+ /** Linkable patterns of the registered table (wildcards excluded). */
27
+ type RegisteredNavigablePattern = Register extends {
28
+ routes: Routes<infer R extends RoutesRecord>;
29
+ } ? NavigablePatternOf<R> : `/${string}`;
30
+ type RegisteredParamsMap = Register extends {
31
+ params: infer M;
32
+ } ? M : null;
33
+ /** The pattern's params as anything with one spelling, before any schema. */
34
+ type LooseParamsOf<P extends string> = { [K in keyof ParamsOf<P>]: ParamValue; };
35
+ /**
36
+ * A pattern's params as a link takes them: where the registered `params`
37
+ * map — written by the framework from the schemas the route files declared
38
+ * — says a page receives something, a link takes that same value (a number
39
+ * in, a number's one spelling out); a param no schema covers takes anything
40
+ * with one spelling. Before `Register` is augmented — a client application
41
+ * that has not, or the framework's generated file not yet written — the
42
+ * same loose shape applies, so a link written for a schema still compiles.
43
+ */
44
+ type RegisteredParams<P extends string> = RegisteredParamsMap extends null ? LooseParamsOf<P> : P extends keyof RegisteredParamsMap ? Omit<LooseParamsOf<P>, keyof RegisteredParamsMap[P]> & RegisteredParamsMap[P] : LooseParamsOf<P>;
45
+ //#endregion
46
+ export { Register, RegisteredNavigablePattern, RegisteredParams, RegisteredPattern };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,32 @@
1
+ import { ParamsOf } from "./paths.mjs";
2
+ import { Match, Routes } from "./define-routes.mjs";
3
+ import { RegisteredPattern } from "./register.mjs";
4
+ import { FC, ReactNode } from "react";
5
+ //#region src/router.d.ts
6
+ /** Renders the next element of the matched stack — a layout's hole. */
7
+ declare const Outlet: FC;
8
+ /**
9
+ * Mounts a route table on the Navigation API: every same-origin navigation
10
+ * that the table claims is handled in the client, and the rest is left to the
11
+ * server. Layout components render their children through `<Outlet />`.
12
+ *
13
+ * This is the whole router for an application that renders on the client.
14
+ * Under the framework the tree comes from the server instead, and what is
15
+ * shared is `useInterceptedNavigation` and the `<PathnameProvider>` this
16
+ * mounts.
17
+ */
18
+ declare function Router({ routes }: {
19
+ routes: Routes;
20
+ }): ReactNode;
21
+ /** The winning pattern and its params, untyped — `useParams` narrows. */
22
+ declare function useRoute(): Pick<Match, 'pattern' | 'params'>;
23
+ /**
24
+ * Typed params for the pattern this component believes it renders under —
25
+ * inferred from the pattern literal, verified against `Register`, and read
26
+ * from context at runtime, so no component imports the route table. The
27
+ * belief is checked: rendering under any other pattern throws instead of
28
+ * silently returning params of the wrong shape.
29
+ */
30
+ declare function useParams<P extends RegisteredPattern>(pattern: P): ParamsOf<P>;
31
+ //#endregion
32
+ export { Outlet, Router, useParams, useRoute };
@@ -0,0 +1,89 @@
1
+ "use client";
2
+ import { NavigationGeneration, useInterceptedNavigation } from "./navigation.mjs";
3
+ import { setBoundaryOutlet } from "./define-routes.mjs";
4
+ import { PathnameProvider } from "./location.mjs";
5
+ import { createContext, use, useMemo, useState } from "react";
6
+ import { jsx } from "react/jsx-runtime";
7
+ //#region src/router.tsx
8
+ const RouterContext = createContext(null);
9
+ const StackContext = createContext(null);
10
+ const RenderStack = ({ stack, index }) => {
11
+ const value = useMemo(() => ({
12
+ stack,
13
+ index: index + 1
14
+ }), [stack, index]);
15
+ const next = stack[index];
16
+ if (next === void 0) return null;
17
+ return /* @__PURE__ */ jsx(StackContext, {
18
+ value,
19
+ children: /* @__PURE__ */ jsx(next, {})
20
+ });
21
+ };
22
+ /** Renders the next element of the matched stack — a layout's hole. */
23
+ const Outlet = () => {
24
+ const ctx = use(StackContext);
25
+ if (ctx === null) throw new Error("Outlet must render inside <Router>");
26
+ return /* @__PURE__ */ jsx(RenderStack, {
27
+ stack: ctx.stack,
28
+ index: ctx.index
29
+ });
30
+ };
31
+ setBoundaryOutlet(() => /* @__PURE__ */ jsx(Outlet, {}));
32
+ /**
33
+ * Mounts a route table on the Navigation API: every same-origin navigation
34
+ * that the table claims is handled in the client, and the rest is left to the
35
+ * server. Layout components render their children through `<Outlet />`.
36
+ *
37
+ * This is the whole router for an application that renders on the client.
38
+ * Under the framework the tree comes from the server instead, and what is
39
+ * shared is `useInterceptedNavigation` and the `<PathnameProvider>` this
40
+ * mounts.
41
+ */
42
+ function Router({ routes }) {
43
+ const [match, setMatch] = useState(() => routes.match(location.pathname));
44
+ const { generation } = useInterceptedNavigation({
45
+ claim: (url) => routes.match(url.pathname) !== null,
46
+ load: (url) => routes.match(url.pathname),
47
+ apply: setMatch
48
+ });
49
+ const value = useMemo(() => ({
50
+ routes,
51
+ match
52
+ }), [routes, match]);
53
+ return /* @__PURE__ */ jsx(PathnameProvider, {
54
+ pathname: location.pathname,
55
+ children: /* @__PURE__ */ jsx(NavigationGeneration, {
56
+ value: generation,
57
+ children: /* @__PURE__ */ jsx(RouterContext, {
58
+ value,
59
+ children: match === null ? null : /* @__PURE__ */ jsx(RenderStack, {
60
+ stack: match.stack,
61
+ index: 0
62
+ })
63
+ })
64
+ })
65
+ });
66
+ }
67
+ /** The winning pattern and its params, untyped — `useParams` narrows. */
68
+ function useRoute() {
69
+ const current = use(RouterContext)?.match ?? null;
70
+ if (current === null) throw new Error("useRoute must render inside a matched <Router>");
71
+ return {
72
+ pattern: current.pattern,
73
+ params: current.params
74
+ };
75
+ }
76
+ /**
77
+ * Typed params for the pattern this component believes it renders under —
78
+ * inferred from the pattern literal, verified against `Register`, and read
79
+ * from context at runtime, so no component imports the route table. The
80
+ * belief is checked: rendering under any other pattern throws instead of
81
+ * silently returning params of the wrong shape.
82
+ */
83
+ function useParams(pattern) {
84
+ const { pattern: current, params } = useRoute();
85
+ if (current !== pattern) throw new Error(`useParams("${pattern}") rendered under "${current}"`);
86
+ return params;
87
+ }
88
+ //#endregion
89
+ export { Outlet, Router, useParams, useRoute };