@k8ordo/router 0.1.0 → 1.0.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/README.md CHANGED
@@ -21,18 +21,27 @@ npm install @k8ordo/router
21
21
  pnpm add @k8ordo/router
22
22
  ```
23
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.
24
+ Under `@k8ordo/framework` this package is what the framework builds on: the
25
+ table is generated from `src/routes/`, and what an application uses of it is
26
+ the half that needs no table — `href`, `navigateTo` and `bindParams`;
27
+ `usePathname`, `useMatch` and `matchPath` — plus the route files' props types,
28
+ `PageProps` and `LayoutProps`. There it is installed as the framework's peer,
29
+ and the application imports that half from `@k8ordo/framework`, which
30
+ re-exports it. On its own it is the whole router for an application that
31
+ renders in the browser.
28
32
 
29
33
  ## Peer Dependencies
30
34
 
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 |
35
+ <!-- peers -->
36
+
37
+ | Package | Version | Required | Needed for |
38
+ | -------------- | ------- | -------- | ------------------------------- |
39
+ | `react` | ≥19.3.0 | yes | `<Router>` and the hooks |
40
+ | `react-dom` | ≥19.3.0 | yes | `BrowserPathname` (`browser()`) |
41
+ | `typescript` | ≥7.0.0 | optional | the shipped type declarations |
42
+ | `@types/react` | ≥19.3.0 | optional | the shipped type declarations |
43
+
44
+ <!-- /peers -->
36
45
 
37
46
  No runtime dependencies. The Navigation API and URLPattern are the platform's.
38
47
 
@@ -0,0 +1,16 @@
1
+ //#region src/base.d.ts
2
+ /**
3
+ * A pathname in the table's terms, as the URL spells it: the base in front.
4
+ * Under `base: '/docs/'`, `/products` → `/docs/products` and `/` → `/docs/`.
5
+ * `href` and `navigateTo` build every link through it, so a table written
6
+ * from the root works wherever the application is served.
7
+ */
8
+ export declare const withBase: (pathname: string, base?: string) => string;
9
+ /**
10
+ * The inverse: a URL's pathname in the table's terms, or `null` when it is
11
+ * not under the base — a URL the application does not serve. Under
12
+ * `base: '/docs/'`, `/docs/products` → `/products`, and `/docs` and `/docs/`
13
+ * → `/`.
14
+ */
15
+ export declare const withoutBase: (pathname: string, base?: string) => string | null;
16
+ //#endregion
package/dist/base.mjs ADDED
@@ -0,0 +1,45 @@
1
+ import { normalizePathname } from "./paths.mjs";
2
+ //#region src/base.ts
3
+ /**
4
+ * Where Vite serves the application — its `base`, as `import.meta.env.BASE_URL`
5
+ * spells it (`/`, `/docs/`). Read when a caller does not pass a base: code
6
+ * Node loads without Vite (the static build, `serve`) has no `import.meta.env`
7
+ * and passes the configured base instead.
8
+ */
9
+ const viteBase = () => import.meta.env.BASE_URL;
10
+ /**
11
+ * The base as a prefix of the URL's pathname: `''` at the root, `/docs` under
12
+ * `/docs/`. Only a path names one — Vite's relative `./` does not say where
13
+ * the pages are, so it adds nothing.
14
+ */
15
+ const prefixOf = (base) => {
16
+ if (!base.startsWith("/")) return "";
17
+ const prefix = normalizePathname(base);
18
+ return prefix === "/" ? "" : prefix;
19
+ };
20
+ /**
21
+ * A pathname in the table's terms, as the URL spells it: the base in front.
22
+ * Under `base: '/docs/'`, `/products` → `/docs/products` and `/` → `/docs/`.
23
+ * `href` and `navigateTo` build every link through it, so a table written
24
+ * from the root works wherever the application is served.
25
+ */
26
+ const withBase = (pathname, base = viteBase()) => {
27
+ const prefix = prefixOf(base);
28
+ if (prefix === "") return pathname;
29
+ return pathname === "/" ? `${prefix}/` : `${prefix}${pathname}`;
30
+ };
31
+ /**
32
+ * The inverse: a URL's pathname in the table's terms, or `null` when it is
33
+ * not under the base — a URL the application does not serve. Under
34
+ * `base: '/docs/'`, `/docs/products` → `/products`, and `/docs` and `/docs/`
35
+ * → `/`.
36
+ */
37
+ const withoutBase = (pathname, base = viteBase()) => {
38
+ const prefix = prefixOf(base);
39
+ if (prefix === "") return pathname;
40
+ if (pathname === prefix) return "/";
41
+ if (!pathname.startsWith(`${prefix}/`)) return null;
42
+ return pathname.slice(prefix.length);
43
+ };
44
+ //#endregion
45
+ export { withBase, withoutBase };
@@ -4,21 +4,24 @@ import { ComponentType, ReactNode } from "react";
4
4
  * What a route table's `error` component receives: what was thrown, and a
5
5
  * way to try the subtree again in place.
6
6
  */
7
- type ErrorProps = {
7
+ export type ErrorProps = {
8
8
  readonly error: unknown;
9
9
  readonly reset: () => void;
10
10
  };
11
- type ErrorComponent = ComponentType<ErrorProps>;
12
- type CatchProps = {
11
+ export type ErrorComponent = ComponentType<ErrorProps>;
12
+ type BoundaryProps = {
13
13
  readonly fallback: ErrorComponent;
14
14
  readonly children?: ReactNode;
15
15
  };
16
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.
17
+ * The boundary a table's `error` component renders inside. The navigation
18
+ * that puts the next tree on screen clears the failure, so leaving the page
19
+ * that failed leaves it behind — and not the pathname, which commits before
20
+ * the tree arrives and would clear it onto the old tree.
21
+ *
22
+ * Cleared, not keyed: a key would remount everything below the boundary on
23
+ * every page change, failed or not, and a root boundary would take the whole
24
+ * application's layouts with it.
21
25
  */
22
- declare const RouteErrorBoundary: ({ fallback, children }: CatchProps) => ReactNode;
23
- //#endregion
24
- export { ErrorComponent, ErrorProps, RouteErrorBoundary };
26
+ export declare const RouteErrorBoundary: ({ fallback, children }: BoundaryProps) => ReactNode;
27
+ //#endregion
package/dist/boundary.mjs CHANGED
@@ -5,7 +5,8 @@ import { Component, Suspense, createElement, use } from "react";
5
5
  var Catch = class extends Component {
6
6
  state = {
7
7
  error: void 0,
8
- failed: false
8
+ failed: false,
9
+ generation: this.props.generation
9
10
  };
10
11
  static getDerivedStateFromError(error) {
11
12
  return {
@@ -13,6 +14,14 @@ var Catch = class extends Component {
13
14
  failed: true
14
15
  };
15
16
  }
17
+ static getDerivedStateFromProps(props, state) {
18
+ if (props.generation === state.generation) return null;
19
+ return {
20
+ error: void 0,
21
+ failed: false,
22
+ generation: props.generation
23
+ };
24
+ }
16
25
  reset = () => {
17
26
  this.setState({
18
27
  error: void 0,
@@ -28,15 +37,19 @@ var Catch = class extends Component {
28
37
  }
29
38
  };
30
39
  /**
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.
40
+ * The boundary a table's `error` component renders inside. The navigation
41
+ * that puts the next tree on screen clears the failure, so leaving the page
42
+ * that failed leaves it behind — and not the pathname, which commits before
43
+ * the tree arrives and would clear it onto the old tree.
44
+ *
45
+ * Cleared, not keyed: a key would remount everything below the boundary on
46
+ * every page change, failed or not, and a root boundary would take the whole
47
+ * application's layouts with it.
35
48
  */
36
49
  const RouteErrorBoundary = ({ fallback, children }) => {
37
50
  const generation = use(NavigationGeneration);
38
51
  return createElement(Suspense, { fallback: null }, createElement(Catch, {
39
- key: generation,
52
+ generation,
40
53
  fallback
41
54
  }, children));
42
55
  };
@@ -1,5 +1,5 @@
1
1
  import { ErrorComponent } from "./boundary.mjs";
2
- import { Join, PathFor } from "./paths.mjs";
2
+ import { Join, PathMatching } from "./paths.mjs";
3
3
  import { ComponentType, ReactNode } from "react";
4
4
  //#region src/define-routes.d.ts
5
5
  /**
@@ -9,41 +9,46 @@ import { ComponentType, ReactNode } from "react";
9
9
  * their `children` — so the stored type has to admit them all. `never` props
10
10
  * is the type-safe spelling of that; each renderer states what it passes.
11
11
  */
12
- type RouteComponent = ComponentType<never>;
12
+ export type RouteComponent = ComponentType<never>;
13
13
  /**
14
14
  * A leaf renders; a branch wraps its children in an optional layout, and
15
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`.
16
+ * throws — inside the layout, so the frame survives the failure — and a
17
+ * `loading` component to show while what is below suspends. Lazy components
18
+ * (`React.lazy`) are objects, not functions, so a branch is recognized by
19
+ * its `children` key rather than by `typeof`.
19
20
  */
20
- type RouteNode = RouteComponent | {
21
+ export type RouteNode = RouteComponent | {
21
22
  layout?: RouteComponent;
22
23
  error?: ErrorComponent;
24
+ loading?: ComponentType;
23
25
  children: RoutesRecord;
24
26
  };
25
- type RoutesRecord = Record<`/${string}`, RouteNode>;
27
+ export type RoutesRecord = Record<`/${string}`, RouteNode>;
26
28
  type Below<Prefix extends string, Key extends string> = Join<Prefix, Key> extends '/' ? '' : Join<Prefix, Key>;
27
29
  type PatternsIn<Record_, Prefix extends string> = { [K in keyof Record_ & string]: Record_[K] extends {
28
30
  children: infer C;
29
31
  } ? PatternsIn<C, Below<Prefix, K>> : Join<Prefix, K>; }[keyof Record_ & string];
30
32
  /** Every full leaf pattern in the table, as written. */
31
- type PatternOf<R extends RoutesRecord> = PatternsIn<R, ''> & string;
33
+ export type PatternOf<R extends RoutesRecord> = PatternsIn<R, ''> & string;
32
34
  /** Patterns a link can point at — wildcards are matched, never linked. */
33
- type NavigablePatternOf<R extends RoutesRecord> = Exclude<PatternOf<R>, `${string}*${string}`>;
35
+ export type NavigablePatternOf<R extends RoutesRecord> = Exclude<PatternOf<R>, `${string}*${string}`>;
34
36
  /**
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
+ * `Path` when one of the table's navigable patterns matches it, segment by
38
+ * segment — a literal spelled as the pattern spells it, a `:param` taking any
39
+ * one non-empty segment — and `never` when none does. What `@k8ordo/state`
40
+ * checks a path against with the `routes` its `Register` is given, and what
41
+ * any other typed-path consumer takes.
37
42
  */
38
- type RouteOf<D> = D extends Routes<infer R> ? PathFor<NavigablePatternOf<R>> : never;
39
- type Match = {
43
+ export type NavigablePath<D, Path extends string> = D extends Routes<infer R extends RoutesRecord> ? PathMatching<Path, NavigablePatternOf<R>> : never;
44
+ export type Match = {
40
45
  /** The full pattern that won, as written in the table. */
41
46
  pattern: string;
42
47
  params: Readonly<Record<string, string>>;
43
48
  /** Layouts outer-first, the leaf last. */
44
49
  stack: readonly RouteComponent[];
45
50
  };
46
- type Routes<R extends RoutesRecord = RoutesRecord> = {
51
+ export type Routes<R extends RoutesRecord = RoutesRecord> = {
47
52
  kind: 'routes';
48
53
  /** The table as passed. */
49
54
  record: R;
@@ -61,7 +66,6 @@ type Routes<R extends RoutesRecord = RoutesRecord> = {
61
66
  */
62
67
  match: (pathname: string, accept?: (match: Match) => boolean) => Match | null;
63
68
  };
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 };
69
+ export declare const setBoundaryOutlet: (render: () => ReactNode) => void;
70
+ export declare function defineRoutes<R extends RoutesRecord>(record: R): Routes<R>;
71
+ //#endregion
@@ -1,6 +1,6 @@
1
1
  import { isGroupKey, joinPattern, normalizePathname } from "./paths.mjs";
2
2
  import { RouteErrorBoundary } from "./boundary.mjs";
3
- import { createElement } from "react";
3
+ import { Suspense, createElement } from "react";
4
4
  //#region src/define-routes.ts
5
5
  const isBranch = (node) => typeof node === "object" && "children" in node;
6
6
  /**
@@ -14,6 +14,18 @@ const boundaryFor = (fallback) => {
14
14
  const Boundary = ({ children }) => createElement(RouteErrorBoundary, { fallback }, children === void 0 ? outlet() : children);
15
15
  return Boundary;
16
16
  };
17
+ /**
18
+ * A branch's `loading` becomes an element of the stack too — after the
19
+ * layout and inside the error boundary, so what the boundary catches is what
20
+ * the fallback stood in for. A plain `<Suspense>`: a page change under one
21
+ * already showing keeps the page on screen while the next one loads, as
22
+ * every page change does, and the fallback shows while it first streams in
23
+ * and when a navigation mounts it anew.
24
+ */
25
+ const loadingFor = (Fallback) => {
26
+ const Loading = ({ children }) => createElement(Suspense, { fallback: createElement(Fallback) }, children === void 0 ? outlet() : children);
27
+ return Loading;
28
+ };
17
29
  const decode = (value) => {
18
30
  try {
19
31
  return decodeURIComponent(value);
@@ -43,7 +55,8 @@ function defineRoutes(record) {
43
55
  const below = [
44
56
  ...stack,
45
57
  ...node.layout === void 0 ? [] : [node.layout],
46
- ...node.error === void 0 ? [] : [boundaryFor(node.error)]
58
+ ...node.error === void 0 ? [] : [boundaryFor(node.error)],
59
+ ...node.loading === void 0 ? [] : [loadingFor(node.loading)]
47
60
  ];
48
61
  walk(node.children, pattern === "/" ? "" : pattern, below);
49
62
  } else {
package/dist/index.d.mts CHANGED
@@ -1,10 +1,12 @@
1
+ import { withBase, withoutBase } from "./base.mjs";
1
2
  import { ErrorComponent, ErrorProps } from "./boundary.mjs";
2
3
  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";
4
+ import { Match, NavigablePath, NavigablePatternOf, PatternOf, RouteComponent, RouteNode, Routes, RoutesRecord, defineRoutes } from "./define-routes.mjs";
5
+ import { LayoutProps, PageProps, Register, RegisteredNavigablePattern, RegisteredPageParams, RegisteredParams, RegisteredPattern, RouteContext } from "./register.mjs";
6
+ import { BoundLinks, BoundParams, NavigateToOptions, bindParams, href, navigateTo } from "./links.mjs";
7
+ import { BrowserPathname, PathnameProvider, usePathname } from "./location.mjs";
8
+ import { MatchOptions, MatchablePattern, matchPath, useMatch } from "./match.mjs";
9
+ import { isNotFound, notFound } from "./not-found.mjs";
10
+ import { NavigationGeneration, NavigationHandler, useInterceptedNavigation, usePendingPathname } from "./navigation.mjs";
9
11
  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 };
12
+ export { type BoundLinks, type BoundParams, BrowserPathname, type ErrorComponent, type ErrorProps, type LayoutProps, type Match, type MatchOptions, type MatchablePattern, type NavigablePath, type NavigablePatternOf, type NavigateToOptions, NavigationGeneration, type NavigationHandler, Outlet, type PageProps, type ParamValue, type ParamsOf, type ParamsSchemaFor, type ParsedParams, type ParsedParamsMap, type PathFor, PathnameProvider, type PatternOf, type Register, type RegisteredNavigablePattern, type RegisteredPageParams, type RegisteredParams, type RegisteredPattern, type RouteComponent, type RouteContext, type RouteNode, Router, type Routes, type RoutesRecord, type SchemaOutput, type StandardSchemaLike, bindParams, defineRoutes, href, isNotFound, matchPath, navigateTo, normalizePathname, notFound, useInterceptedNavigation, useMatch, useParams, usePathname, usePendingPathname, useRoute, withBase, withoutBase };
package/dist/index.mjs CHANGED
@@ -1,8 +1,10 @@
1
1
  import { normalizePathname } from "./paths.mjs";
2
- import { NavigationGeneration, useInterceptedNavigation } from "./navigation.mjs";
2
+ import { withBase, withoutBase } from "./base.mjs";
3
+ import { NavigationGeneration, useInterceptedNavigation, usePendingPathname } from "./navigation.mjs";
3
4
  import { defineRoutes } from "./define-routes.mjs";
4
- import { href, navigateTo } from "./links.mjs";
5
- import { PathnameProvider, usePathname } from "./location.mjs";
5
+ import { bindParams, href, navigateTo } from "./links.mjs";
6
+ import { BrowserPathname, PathnameProvider, usePathname } from "./location.mjs";
6
7
  import { matchPath, useMatch } from "./match.mjs";
8
+ import { isNotFound, notFound } from "./not-found.mjs";
7
9
  import { Outlet, Router, useParams, useRoute } from "./router.mjs";
8
- export { NavigationGeneration, Outlet, PathnameProvider, Router, defineRoutes, href, matchPath, navigateTo, normalizePathname, useInterceptedNavigation, useMatch, useParams, usePathname, useRoute };
10
+ export { BrowserPathname, NavigationGeneration, Outlet, PathnameProvider, Router, bindParams, defineRoutes, href, isNotFound, matchPath, navigateTo, normalizePathname, notFound, useInterceptedNavigation, useMatch, useParams, usePathname, usePendingPathname, useRoute, withBase, withoutBase };
package/dist/links.d.mts CHANGED
@@ -1,8 +1,8 @@
1
- import { PathFor } from "./paths.mjs";
1
+ import { ParamValue } from "./paths.mjs";
2
2
  import { RegisteredNavigablePattern, RegisteredParams } from "./register.mjs";
3
3
  //#region src/links.d.ts
4
4
  type HrefArgs<Params> = keyof Params extends never ? [] : [params: Readonly<Params & Record<never, never>>];
5
- type NavigateToOptions = {
5
+ export type NavigateToOptions = {
6
6
  /**
7
7
  * `push` by default: going to a page is what the back button undoes —
8
8
  * the opposite default from a state refinement.
@@ -11,21 +11,50 @@ type NavigateToOptions = {
11
11
  };
12
12
  type NavigateToArgs<Params> = keyof Params extends never ? [options?: NavigateToOptions] : [params: Readonly<Params & Record<never, never>>, options?: NavigateToOptions];
13
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).
14
+ * Builds the URL a link points at from a pattern and its params — no route
15
+ * table involved, which is why pages never import one. Params are inferred
16
+ * from the pattern literal itself; `Register` adds the check that the
17
+ * pattern exists in the app's table, and — under the framework — the type a
18
+ * page's schema gave each param, so a link takes what the page received.
19
+ *
20
+ * What comes back is a URL, with Vite's `base` in front (`withBase`), not a
21
+ * pathname in the table's terms — which is why it is typed `string`: handed
22
+ * to something that adds the base itself, it would carry it twice.
21
23
  */
22
- declare const href: <P extends RegisteredNavigablePattern>(pattern: P, ...params: HrefArgs<RegisteredParams<P>>) => PathFor<P>;
24
+ export declare const href: <P extends RegisteredNavigablePattern>(pattern: P, ...params: HrefArgs<RegisteredParams<P>>) => string;
23
25
  /**
24
26
  * Typed imperative navigation: `href` composed with
25
27
  * `navigation.navigate()`, returning the platform's own
26
28
  * `{ committed, finished }`. Changing pages goes through here; changing
27
29
  * state goes through `@k8ordo/state`'s `update()`.
28
30
  */
29
- declare const navigateTo: <P extends RegisteredNavigablePattern>(pattern: P, ...rest: NavigateToArgs<RegisteredParams<P>>) => ReturnType<Navigation['navigate']>;
30
- //#endregion
31
- export { NavigateToOptions, href, navigateTo };
31
+ export declare const navigateTo: <P extends RegisteredNavigablePattern>(pattern: P, ...rest: NavigateToArgs<RegisteredParams<P>>) => ReturnType<Navigation['navigate']>;
32
+ /** What a params source supplies: values with one URL spelling, by name. */
33
+ export type BoundParams = Readonly<Record<string, ParamValue>>;
34
+ /**
35
+ * The params a bound link still takes: what the source does not supply is
36
+ * required as before, and what it does supply may be given to override it.
37
+ */
38
+ type Unbound<Params, Bound> = Omit<Params, keyof Bound> & Partial<Pick<Params, Extract<keyof Bound, keyof Params>>>;
39
+ type BoundHrefArgs<Params, Bound> = Exclude<keyof Params, keyof Bound> extends never ? [params?: Readonly<Partial<Params> & Record<never, never>>] : [params: Readonly<Unbound<Params, Bound> & Record<never, never>>];
40
+ type BoundNavigateToArgs<Params, Bound> = keyof Params extends never ? [options?: NavigateToOptions] : [...BoundHrefArgs<Params, Bound>, options?: NavigateToOptions];
41
+ export type BoundLinks<Bound extends BoundParams> = {
42
+ readonly href: <P extends RegisteredNavigablePattern>(pattern: P, ...rest: BoundHrefArgs<RegisteredParams<P>, Bound>) => string;
43
+ readonly navigateTo: <P extends RegisteredNavigablePattern>(pattern: P, ...rest: BoundNavigateToArgs<RegisteredParams<P>, Bound>) => ReturnType<Navigation['navigate']>;
44
+ };
45
+ /**
46
+ * `href` and `navigateTo` with some params supplied by a function instead of
47
+ * by every call — a segment the whole application shares, such as a locale
48
+ * or a tenant. The source is read at each call, so a value that differs per
49
+ * request or per URL is read where it is current. Patterns keep their full
50
+ * spelling (`'/:locale/products'`), so the table's types apply unchanged and
51
+ * a bound param can still be given to point at another value.
52
+ *
53
+ * ```ts
54
+ * const { href, navigateTo } = bindParams(() => ({ locale: locales.getLocale() }));
55
+ * href('/:locale/products/:id', { id }); // locale comes from the source
56
+ * navigateTo('/:locale', { locale: 'en' }, { history: 'replace' }); // or is overridden
57
+ * ```
58
+ */
59
+ export declare const bindParams: <const Bound extends BoundParams>(source: () => Bound) => BoundLinks<Bound>;
60
+ //#endregion
package/dist/links.mjs CHANGED
@@ -1,15 +1,19 @@
1
1
  import { buildHref } from "./paths.mjs";
2
+ import { withBase } from "./base.mjs";
2
3
  //#region src/links.ts
4
+ const navigate = (pattern, params, options) => navigation.navigate(withBase(buildHref(pattern, params)), { history: options?.history ?? "push" });
3
5
  /**
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).
6
+ * Builds the URL a link points at from a pattern and its params — no route
7
+ * table involved, which is why pages never import one. Params are inferred
8
+ * from the pattern literal itself; `Register` adds the check that the
9
+ * pattern exists in the app's table, and — under the framework — the type a
10
+ * page's schema gave each param, so a link takes what the page received.
11
+ *
12
+ * What comes back is a URL, with Vite's `base` in front (`withBase`), not a
13
+ * pathname in the table's terms — which is why it is typed `string`: handed
14
+ * to something that adds the base itself, it would carry it twice.
11
15
  */
12
- const href = (pattern, ...params) => buildHref(pattern, params[0]);
16
+ const href = (pattern, ...params) => withBase(buildHref(pattern, params[0]));
13
17
  /**
14
18
  * Typed imperative navigation: `href` composed with
15
19
  * `navigation.navigate()`, returning the platform's own
@@ -21,7 +25,37 @@ const navigateTo = (pattern, ...rest) => {
21
25
  const args = rest;
22
26
  const params = wantsParams ? args[0] : void 0;
23
27
  const options = wantsParams ? args[1] : args[0];
24
- return navigation.navigate(buildHref(pattern, params), { history: options?.history ?? "push" });
28
+ return navigate(pattern, params, options);
25
29
  };
30
+ /**
31
+ * `href` and `navigateTo` with some params supplied by a function instead of
32
+ * by every call — a segment the whole application shares, such as a locale
33
+ * or a tenant. The source is read at each call, so a value that differs per
34
+ * request or per URL is read where it is current. Patterns keep their full
35
+ * spelling (`'/:locale/products'`), so the table's types apply unchanged and
36
+ * a bound param can still be given to point at another value.
37
+ *
38
+ * ```ts
39
+ * const { href, navigateTo } = bindParams(() => ({ locale: locales.getLocale() }));
40
+ * href('/:locale/products/:id', { id }); // locale comes from the source
41
+ * navigateTo('/:locale', { locale: 'en' }, { history: 'replace' }); // or is overridden
42
+ * ```
43
+ */
44
+ const bindParams = (source) => ({
45
+ href: (pattern, ...rest) => withBase(buildHref(pattern, {
46
+ ...source(),
47
+ ...rest[0]
48
+ })),
49
+ navigateTo: (pattern, ...rest) => {
50
+ const wantsParams = pattern.includes(":");
51
+ const args = rest;
52
+ const params = wantsParams ? args[0] : void 0;
53
+ const options = wantsParams ? args[1] : args[0];
54
+ return navigate(pattern, {
55
+ ...source(),
56
+ ...params
57
+ }, options);
58
+ }
59
+ });
26
60
  //#endregion
27
- export { href, navigateTo };
61
+ export { bindParams, href, navigateTo };
@@ -8,13 +8,30 @@ import { ReactNode } from "react";
8
8
  * `<Router>` supplies this on its own. Under the framework the mode's runtime
9
9
  * supplies it; an application never writes this itself.
10
10
  */
11
- declare const PathnameProvider: ({ pathname, children }: {
11
+ export declare const PathnameProvider: ({ pathname, children }: {
12
12
  pathname: string;
13
13
  children: ReactNode;
14
14
  }) => ReactNode;
15
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
16
+ * Below this, a server render has no pathname to give: `usePathname` — and
17
+ * `useMatch`, which reads it — asks for the browser with `use(browser())`, so
18
+ * the nearest `<Suspense>` is left for the browser to render. Under
19
+ * `@k8ordo/framework` a fallback.tsx renders inside it; an application never
20
+ * writes this itself.
21
+ */
22
+ export declare const BrowserPathname: ({ children }: {
23
+ children: ReactNode;
24
+ }) => ReactNode;
25
+ /**
26
+ * The document's pathname in the table's terms — Vite's `base` removed. A
27
+ * document outside its own base is a deployment that went wrong, and the
28
+ * pathname is shown as it is rather than failing the render over it.
29
+ */
30
+ export declare const appPathname: () => string;
31
+ /**
32
+ * Where the browser currently is, as a pathname in the table's terms — the
33
+ * one axis this package owns, with Vite's `base` removed so it compares with
34
+ * the patterns. It reads the platform rather than a table, so it works the same in a
18
35
  * client application and under the framework, where there is no route table
19
36
  * in the browser at all.
20
37
  *
@@ -26,6 +43,5 @@ declare const PathnameProvider: ({ pathname, children }: {
26
43
  * every search change would defeat `@k8ordo/state`'s keyed subscriptions, and
27
44
  * the split at the `?` is the whole boundary between the two packages.
28
45
  */
29
- declare function usePathname(): string;
30
- //#endregion
31
- export { PathnameProvider, usePathname };
46
+ export declare function usePathname(): string;
47
+ //#endregion
package/dist/location.mjs CHANGED
@@ -1,8 +1,12 @@
1
1
  "use client";
2
2
  import { normalizePathname } from "./paths.mjs";
3
+ import { withoutBase } from "./base.mjs";
3
4
  import { createContext, use, useMemo, useSyncExternalStore } from "react";
5
+ import { browser } from "react-dom";
4
6
  import { jsx } from "react/jsx-runtime";
5
7
  //#region src/location.tsx
8
+ /** Below `BrowserPathname`: the render has no pathname, the browser does. */
9
+ const IN_BROWSER = Symbol("k8ordo.browser-pathname");
6
10
  const ServerPathname = createContext(null);
7
11
  /**
8
12
  * The pathname the render started from. A client component's first render can
@@ -19,16 +23,33 @@ const PathnameProvider = ({ pathname, children }) => {
19
23
  children
20
24
  });
21
25
  };
26
+ /**
27
+ * Below this, a server render has no pathname to give: `usePathname` — and
28
+ * `useMatch`, which reads it — asks for the browser with `use(browser())`, so
29
+ * the nearest `<Suspense>` is left for the browser to render. Under
30
+ * `@k8ordo/framework` a fallback.tsx renders inside it; an application never
31
+ * writes this itself.
32
+ */
33
+ const BrowserPathname = ({ children }) => /* @__PURE__ */ jsx(ServerPathname, {
34
+ value: IN_BROWSER,
35
+ children
36
+ });
22
37
  const subscribe = (onChange) => {
23
38
  navigation.addEventListener("currententrychange", onChange);
24
39
  return () => {
25
40
  navigation.removeEventListener("currententrychange", onChange);
26
41
  };
27
42
  };
28
- const readPathname = () => normalizePathname(location.pathname);
29
43
  /**
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
44
+ * The document's pathname in the table's terms — Vite's `base` removed. A
45
+ * document outside its own base is a deployment that went wrong, and the
46
+ * pathname is shown as it is rather than failing the render over it.
47
+ */
48
+ const appPathname = () => normalizePathname(withoutBase(location.pathname) ?? location.pathname);
49
+ /**
50
+ * Where the browser currently is, as a pathname in the table's terms — the
51
+ * one axis this package owns, with Vite's `base` removed so it compares with
52
+ * the patterns. It reads the platform rather than a table, so it works the same in a
32
53
  * client application and under the framework, where there is no route table
33
54
  * in the browser at all.
34
55
  *
@@ -42,10 +63,12 @@ const readPathname = () => normalizePathname(location.pathname);
42
63
  */
43
64
  function usePathname() {
44
65
  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");
66
+ if (fromServer === IN_BROWSER) use(browser("usePathname reads the URL in the browser here: the server render was for a pathname no visitor is at"));
67
+ return useSyncExternalStore(subscribe, appPathname, () => {
68
+ if (fromServer === IN_BROWSER) return appPathname();
69
+ if (fromServer === null) throw new Error("usePathname needs <Router> above it, or a page rendered by @k8ordo/framework");
47
70
  return fromServer;
48
71
  });
49
72
  }
50
73
  //#endregion
51
- export { PathnameProvider, usePathname };
74
+ export { BrowserPathname, PathnameProvider, appPathname, usePathname };
package/dist/match.d.mts CHANGED
@@ -7,20 +7,28 @@ import { RegisteredNavigablePattern, RegisteredPattern } from "./register.mjs";
7
7
  * to know which section of the site is showing. The pattern's own page is
8
8
  * not below it: `/x/*` matches `/x/y` and not `/x`.
9
9
  */
10
- type MatchablePattern = RegisteredPattern | `${RegisteredNavigablePattern}/*`;
10
+ export type MatchablePattern = RegisteredPattern | `${RegisteredNavigablePattern}/*`;
11
+ export type MatchOptions = {
12
+ /**
13
+ * For a `/*` pattern: count the pattern's own page as well, so `/x/*`
14
+ * answers `/x` too — what a section link asks when it wants to be marked
15
+ * on the index as much as below it. Off by default, where `/x/*` is
16
+ * strictly below.
17
+ */
18
+ readonly inclusive?: boolean;
19
+ };
11
20
  /**
12
21
  * Whether a pathname is the one a pattern names, and with which params. Pure
13
22
  * and table-free — the pattern is checked against `Register`, the pathname
14
23
  * is whatever the caller holds — which is why it works where no table is
15
24
  * mounted, under the framework included.
16
25
  */
17
- declare const matchPath: <P extends MatchablePattern>(pattern: P, pathname: string) => ParamsOf<P> | null;
26
+ export declare const matchPath: <P extends MatchablePattern>(pattern: P, pathname: string, options?: MatchOptions) => ParamsOf<P> | null;
18
27
  /**
19
28
  * `matchPath` against where the browser is. It re-renders when the pathname
20
29
  * changes and never on the search, like `usePathname` it is built on — so a
21
30
  * sidebar can ask "is a page under `/docs/*` showing" without a table in the
22
31
  * browser, which is the question `useRoute` cannot answer under the framework.
23
32
  */
24
- declare function useMatch<P extends MatchablePattern>(pattern: P): ParamsOf<P> | null;
25
- //#endregion
26
- export { MatchablePattern, matchPath, useMatch };
33
+ export declare function useMatch<P extends MatchablePattern>(pattern: P, options?: MatchOptions): ParamsOf<P> | null;
34
+ //#endregion