shelving 1.229.0 → 1.230.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shelving",
3
- "version": "1.229.0",
3
+ "version": "1.230.1",
4
4
  "author": "Dave Houlbrooke <dave@shax.com>",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,20 +1,30 @@
1
+ import type { AnyCaller } from "../../util/function.js";
1
2
  import type { AbsolutePath } from "../../util/path.js";
2
3
  import { type URIParams } from "../../util/uri.js";
4
+ import { type ImmutableURL } from "../../util/url.js";
3
5
  import { type Meta, type PossibleMeta } from "../util/meta.js";
4
- import type { ChildProps } from "../util/props.js";
5
6
  /** Context to store the `Config` object. */
6
7
  export declare const MetaContext: import("react").Context<Meta>;
7
- export interface MetaProps extends PossibleMeta, ChildProps {
8
- }
9
- /** Require the current meta context in a component. */
8
+ /**
9
+ * Use the current meta context in a component.
10
+ *
11
+ * @param meta A set of new possible meta data to combine into the current meta context.
12
+ */
10
13
  export declare function requireMeta(meta?: PossibleMeta): Meta;
11
- /** Get all URI/route params from the current meta context's URL. */
12
- export declare function requireMetaParams(): URIParams;
14
+ /** A `Meta` object with a defined `url` object, and `path` and `params` properties combined in. */
15
+ export interface MetaURL extends Meta {
16
+ url: ImmutableURL;
17
+ /** The path of `url` relative to `meta.root` (i.e. the _site-root-relative_ path). */
18
+ path: AbsolutePath;
19
+ /** The `?query` params of `url` extracted as a flat set of parameters. */
20
+ params: URIParams;
21
+ }
13
22
  /**
14
- * Get the current page's path relative to the site root.
15
- * - Strips the meta `root` (site base) prefix from the meta `url`, leaving a site-root-relative `AbsolutePath`.
16
- * - Returns `undefined` when `url` or `root` is unset, or they sit on different origins — the path is then unknowable.
23
+ * Use the current meta context in a component with some additional URL helpers.
17
24
  *
18
- * @returns The site-root-relative path (e.g. `/util/array`), or `undefined` if it can't be determined.
25
+ * @param meta A set of new possible meta data to combine into the current meta context.
26
+ * @returns A `Meta` object with a defined `url` object, and `path` and `params` properties combined in.
27
+ * @throws {RequiredError} if the current meta has no `url`
28
+ * @throws {RequiredError} if the current meta `url` does not match the origin of the current meta `root`
19
29
  */
20
- export declare function getMetaPath(): AbsolutePath | undefined;
30
+ export declare function requireMetaURL(meta?: PossibleMeta, caller?: AnyCaller): MetaURL;
@@ -1,27 +1,35 @@
1
1
  import { createContext, use } from "react";
2
+ import { RequiredError } from "../../error/RequiredError.js";
2
3
  import { getURIParams } from "../../util/uri.js";
3
4
  import { matchURLPrefix } from "../../util/url.js";
4
5
  import { mergeMeta } from "../util/meta.js";
5
6
  /** Context to store the `Config` object. */
6
7
  export const MetaContext = createContext({});
7
8
  MetaContext.displayName = "MetaContext";
8
- /** Require the current meta context in a component. */
9
+ /**
10
+ * Use the current meta context in a component.
11
+ *
12
+ * @param meta A set of new possible meta data to combine into the current meta context.
13
+ */
9
14
  export function requireMeta(meta) {
10
15
  const current = use(MetaContext);
11
16
  return meta ? mergeMeta(current, meta) : current;
12
17
  }
13
- /** Get all URI/route params from the current meta context's URL. */
14
- export function requireMetaParams() {
15
- return getURIParams(requireMeta().url ?? {}, requireMetaParams);
16
- }
17
18
  /**
18
- * Get the current page's path relative to the site root.
19
- * - Strips the meta `root` (site base) prefix from the meta `url`, leaving a site-root-relative `AbsolutePath`.
20
- * - Returns `undefined` when `url` or `root` is unset, or they sit on different origins — the path is then unknowable.
19
+ * Use the current meta context in a component with some additional URL helpers.
21
20
  *
22
- * @returns The site-root-relative path (e.g. `/util/array`), or `undefined` if it can't be determined.
21
+ * @param meta A set of new possible meta data to combine into the current meta context.
22
+ * @returns A `Meta` object with a defined `url` object, and `path` and `params` properties combined in.
23
+ * @throws {RequiredError} if the current meta has no `url`
24
+ * @throws {RequiredError} if the current meta `url` does not match the origin of the current meta `root`
23
25
  */
24
- export function getMetaPath() {
25
- const { url, root } = requireMeta();
26
- return matchURLPrefix(url, root, getMetaPath);
26
+ export function requireMetaURL(meta, caller = requireMetaURL) {
27
+ const { url, root, ...combined } = requireMeta(meta);
28
+ if (!url)
29
+ throw new RequiredError("Meta URL is required", { received: url, caller });
30
+ const path = matchURLPrefix(url, root, caller);
31
+ if (!path)
32
+ throw new RequiredError("Meta URL and meta root must share an origin", { url, root, caller });
33
+ const params = getURIParams(url, caller);
34
+ return { ...combined, url, root, path, params };
27
35
  }
@@ -1,15 +1,16 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { ReactNode } from "react";
3
3
  import { renderToStaticMarkup } from "react-dom/server";
4
+ import { RequiredError } from "../../error/RequiredError.js";
4
5
  import { createMeta } from "../util/meta.js";
5
- import { getMetaPath, MetaContext } from "./MetaContext.js";
6
+ import { MetaContext, requireMetaURL } from "./MetaContext.js";
6
7
 
7
- /** Render `getMetaPath()` from inside a component so its `use(MetaContext)` call is valid. */
8
+ /** Render `requireMetaURL().path` from inside a component so its `use(MetaContext)` call is valid. */
8
9
  function Probe(): ReactNode {
9
- return getMetaPath() ?? "none";
10
+ return requireMetaURL().path;
10
11
  }
11
12
 
12
- describe("getMetaPath", () => {
13
+ describe("requireMetaURL", () => {
13
14
  test("returns the page path relative to the site root", () => {
14
15
  const html = renderToStaticMarkup(
15
16
  <MetaContext value={createMeta({ root: "http://x.com/sub/", url: "./util/array" })}>
@@ -28,16 +29,17 @@ describe("getMetaPath", () => {
28
29
  expect(html).toBe("/");
29
30
  });
30
31
 
31
- test("returns undefined when url or root is unset", () => {
32
- expect(renderToStaticMarkup(<Probe />)).toBe("none");
32
+ test("throws RequiredError when url is unset", () => {
33
+ expect(() => renderToStaticMarkup(<Probe />)).toThrow(RequiredError);
33
34
  });
34
35
 
35
- test("returns undefined when url and root are on different origins", () => {
36
- const html = renderToStaticMarkup(
37
- <MetaContext value={createMeta({ root: "http://x.com/", url: "http://y.com/foo" })}>
38
- <Probe />
39
- </MetaContext>,
40
- );
41
- expect(html).toBe("none");
36
+ test("throws RequiredError when url and root are on different origins", () => {
37
+ expect(() =>
38
+ renderToStaticMarkup(
39
+ <MetaContext value={createMeta({ root: "http://x.com/", url: "http://y.com/foo" })}>
40
+ <Probe />
41
+ </MetaContext>,
42
+ ),
43
+ ).toThrow(RequiredError);
42
44
  });
43
45
  });
@@ -1,35 +1,47 @@
1
1
  import { createContext, use } from "react";
2
+ import { RequiredError } from "../../error/RequiredError.js";
3
+ import type { AnyCaller } from "../../util/function.js";
2
4
  import type { AbsolutePath } from "../../util/path.js";
3
5
  import { getURIParams, type URIParams } from "../../util/uri.js";
4
- import { matchURLPrefix } from "../../util/url.js";
6
+ import { type ImmutableURL, matchURLPrefix } from "../../util/url.js";
5
7
  import { type Meta, mergeMeta, type PossibleMeta } from "../util/meta.js";
6
- import type { ChildProps } from "../util/props.js";
7
8
 
8
9
  /** Context to store the `Config` object. */
9
10
  export const MetaContext = createContext<Meta>({});
10
11
  MetaContext.displayName = "MetaContext";
11
12
 
12
- export interface MetaProps extends PossibleMeta, ChildProps {}
13
-
14
- /** Require the current meta context in a component. */
13
+ /**
14
+ * Use the current meta context in a component.
15
+ *
16
+ * @param meta A set of new possible meta data to combine into the current meta context.
17
+ */
15
18
  export function requireMeta(meta?: PossibleMeta): Meta {
16
19
  const current = use(MetaContext);
17
20
  return meta ? mergeMeta(current, meta) : current;
18
21
  }
19
22
 
20
- /** Get all URI/route params from the current meta context's URL. */
21
- export function requireMetaParams(): URIParams {
22
- return getURIParams(requireMeta().url ?? {}, requireMetaParams);
23
+ /** A `Meta` object with a defined `url` object, and `path` and `params` properties combined in. */
24
+ export interface MetaURL extends Meta {
25
+ url: ImmutableURL;
26
+ /** The path of `url` relative to `meta.root` (i.e. the _site-root-relative_ path). */
27
+ path: AbsolutePath;
28
+ /** The `?query` params of `url` extracted as a flat set of parameters. */
29
+ params: URIParams;
23
30
  }
24
31
 
25
32
  /**
26
- * Get the current page's path relative to the site root.
27
- * - Strips the meta `root` (site base) prefix from the meta `url`, leaving a site-root-relative `AbsolutePath`.
28
- * - Returns `undefined` when `url` or `root` is unset, or they sit on different origins — the path is then unknowable.
33
+ * Use the current meta context in a component with some additional URL helpers.
29
34
  *
30
- * @returns The site-root-relative path (e.g. `/util/array`), or `undefined` if it can't be determined.
35
+ * @param meta A set of new possible meta data to combine into the current meta context.
36
+ * @returns A `Meta` object with a defined `url` object, and `path` and `params` properties combined in.
37
+ * @throws {RequiredError} if the current meta has no `url`
38
+ * @throws {RequiredError} if the current meta `url` does not match the origin of the current meta `root`
31
39
  */
32
- export function getMetaPath(): AbsolutePath | undefined {
33
- const { url, root } = requireMeta();
34
- return matchURLPrefix(url, root, getMetaPath);
40
+ export function requireMetaURL(meta?: PossibleMeta, caller: AnyCaller = requireMetaURL): MetaURL {
41
+ const { url, root, ...combined } = requireMeta(meta);
42
+ if (!url) throw new RequiredError("Meta URL is required", { received: url, caller });
43
+ const path = matchURLPrefix(url, root, caller);
44
+ if (!path) throw new RequiredError("Meta URL and meta root must share an origin", { url, root, caller });
45
+ const params = getURIParams(url, caller);
46
+ return { ...combined, url, root, path, params };
35
47
  }
package/ui/misc/README.md CHANGED
@@ -111,7 +111,7 @@ const [TreeMapping, TreeMapper] = createMapper({
111
111
 
112
112
  `requireMeta` reads the context and optionally merges in override props — use this in components that need to resolve URLs against the current page.
113
113
 
114
- `requireMetaParams` extracts route placeholder and query params from the current URL.
114
+ `requireMetaURL` resolves the current `url` against the meta `root` and returns the meta extended with the site-root-relative `path` and the extracted query `params`. Throws if `url` is unset or sits on a different origin to `root`.
115
115
 
116
116
  ## See also
117
117
 
package/ui/page/HTML.js CHANGED
@@ -6,6 +6,6 @@ import { MetaContext, requireMeta } from "../misc/MetaContext.js";
6
6
  */
7
7
  export function HTML({ children, ...meta }) {
8
8
  const merged = requireMeta(meta);
9
- const { language, root: base, app } = merged;
10
- return (_jsxs("html", { lang: language, children: [_jsxs("head", { children: [_jsx("meta", { charSet: "utf-8" }), base && _jsx("base", { href: base.href }), app && _jsx("title", { children: app })] }), _jsx("body", { children: _jsx(MetaContext, { value: merged, children: children }) })] }));
9
+ const { language, root, app } = merged;
10
+ return (_jsxs("html", { lang: language, children: [_jsxs("head", { children: [_jsx("meta", { charSet: "utf-8" }), root && _jsx("base", { href: root.href }), app && _jsx("title", { children: app })] }), _jsx("body", { children: _jsx(MetaContext, { value: merged, children: children }) })] }));
11
11
  }
package/ui/page/HTML.tsx CHANGED
@@ -11,12 +11,12 @@ export interface HTMLProps extends PossibleMeta, ChildProps {}
11
11
  */
12
12
  export function HTML({ children, ...meta }: HTMLProps): ReactElement {
13
13
  const merged = requireMeta(meta);
14
- const { language, root: base, app } = merged;
14
+ const { language, root, app } = merged;
15
15
  return (
16
16
  <html lang={language}>
17
17
  <head>
18
18
  <meta charSet="utf-8" />
19
- {base && <base href={base.href} />}
19
+ {root && <base href={root.href} />}
20
20
  {app && <title>{app}</title>}
21
21
  </head>
22
22
  <body>
@@ -17,8 +17,8 @@ import { NavigationStore } from "./NavigationStore.js";
17
17
  * TODO: switch click/popstate handling to the browser Navigation API when broadly supported.
18
18
  */
19
19
  export function Navigation({ children, ...meta }) {
20
- const { url, root: base, ...merged } = requireMeta(meta);
21
- const nav = useInstance(NavigationStore, url, base);
20
+ const { url, root, ...merged } = requireMeta(meta);
21
+ const nav = useInstance(NavigationStore, url, root);
22
22
  useStore(nav);
23
23
  useEffect(() => {
24
24
  if (typeof document === "undefined" || typeof window === "undefined")
@@ -30,8 +30,8 @@ export function Navigation({ children, ...meta }) {
30
30
  if (anchor instanceof HTMLAnchorElement && anchor.origin === window.location.origin && !anchor.hasAttribute("download")) {
31
31
  e.preventDefault();
32
32
  nav.forward(anchor.href);
33
- return false;
34
- } // `return false` stops iOS web app opening every link in a new window.
33
+ return false; // `return false` stops iOS web app opening every link in a new window.
34
+ }
35
35
  }
36
36
  };
37
37
  const onPopState = () => {
@@ -44,7 +44,7 @@ export function Navigation({ children, ...meta }) {
44
44
  window.removeEventListener("popstate", onPopState);
45
45
  };
46
46
  }, [nav]);
47
- return (_jsx(NavigationContext, { value: nav, children: _jsx(MetaContext, { value: { root: base, url: nav.value, ...merged }, children: children }) }));
47
+ return (_jsx(NavigationContext, { value: nav, children: _jsx(MetaContext, { value: { url: nav.value, root, ...merged }, children: children }) }));
48
48
  }
49
49
  /**
50
50
  * Force a full remount of children whenever the navigation URL changes.
@@ -21,8 +21,8 @@ export interface NavigationProps extends PossibleMeta, OptionalChildProps {}
21
21
  * TODO: switch click/popstate handling to the browser Navigation API when broadly supported.
22
22
  */
23
23
  export function Navigation({ children, ...meta }: NavigationProps): ReactElement {
24
- const { url, root: base, ...merged } = requireMeta(meta);
25
- const nav = useInstance(NavigationStore, url, base);
24
+ const { url, root, ...merged } = requireMeta(meta);
25
+ const nav = useInstance(NavigationStore, url, root);
26
26
  useStore(nav);
27
27
 
28
28
  useEffect(() => {
@@ -36,8 +36,8 @@ export function Navigation({ children, ...meta }: NavigationProps): ReactElement
36
36
  if (anchor instanceof HTMLAnchorElement && anchor.origin === window.location.origin && !anchor.hasAttribute("download")) {
37
37
  e.preventDefault();
38
38
  nav.forward(anchor.href);
39
- return false;
40
- } // `return false` stops iOS web app opening every link in a new window.
39
+ return false; // `return false` stops iOS web app opening every link in a new window.
40
+ }
41
41
  }
42
42
  };
43
43
  const onPopState = () => {
@@ -55,7 +55,7 @@ export function Navigation({ children, ...meta }: NavigationProps): ReactElement
55
55
 
56
56
  return (
57
57
  <NavigationContext value={nav}>
58
- <MetaContext value={{ root: base, url: nav.value, ...merged }}>{children}</MetaContext>
58
+ <MetaContext value={{ url: nav.value, root, ...merged }}>{children}</MetaContext>
59
59
  </NavigationContext>
60
60
  );
61
61
  }
@@ -2,7 +2,13 @@ import type { ReactElement } from "react";
2
2
  import type { PossibleMeta } from "../util/meta.js";
3
3
  import type { Routes } from "./Routes.js";
4
4
  export interface RouterProps extends PossibleMeta {
5
+ /** List of routes for the router to match against. */
5
6
  readonly routes: Routes;
7
+ /**
8
+ * Optional fallback element.
9
+ * - Explicit `null` means fallback to nothing (router will not throw `NotFoundError`).
10
+ */
11
+ readonly fallback?: ReactElement | undefined | null;
6
12
  }
7
13
  /**
8
14
  * Match the current URL against `routes` and render the matched element.
@@ -11,5 +17,7 @@ export interface RouterProps extends PossibleMeta {
11
17
  * - Nest by putting another `<Router>` inside a route's value; pass `base="/section"` (or wrap in `<Meta>`) to scope.
12
18
  * - Route `{placeholders}` are passed as props to function/component route values along with merged URL `?query` params. They are not published into context — descendants of a `ReactElement`-valued route can't see them automatically.
13
19
  * - Returns `null` when there's no URL in context or the URL is outside the base.
20
+ *
21
+ * @throws {NotFoundError} if no route matches and `fallback` is `undefined`
14
22
  */
15
- export declare function Router({ routes, ...meta }: RouterProps): ReactElement | null;
23
+ export declare function Router({ routes, fallback, ...meta }: RouterProps): ReactElement | null;
@@ -1,9 +1,9 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { NotFoundError } from "../../error/RequestError.js";
2
3
  import { UnexpectedError } from "../../error/UnexpectedError.js";
4
+ import { getProps } from "../../util/index.js";
3
5
  import { matchPathTemplate, renderPathTemplate } from "../../util/template.js";
4
- import { getURIParams } from "../../util/uri.js";
5
- import { matchURLPrefix } from "../../util/url.js";
6
- import { requireMeta } from "../misc/MetaContext.js";
6
+ import { MetaContext, requireMetaURL } from "../misc/MetaContext.js";
7
7
  /**
8
8
  * Match the current URL against `routes` and render the matched element.
9
9
  * - Reads `url` and `base` from the surrounding `<Meta>` context (override via props).
@@ -11,18 +11,20 @@ import { requireMeta } from "../misc/MetaContext.js";
11
11
  * - Nest by putting another `<Router>` inside a route's value; pass `base="/section"` (or wrap in `<Meta>`) to scope.
12
12
  * - Route `{placeholders}` are passed as props to function/component route values along with merged URL `?query` params. They are not published into context — descendants of a `ReactElement`-valued route can't see them automatically.
13
13
  * - Returns `null` when there's no URL in context or the URL is outside the base.
14
+ *
15
+ * @throws {NotFoundError} if no route matches and `fallback` is `undefined`
14
16
  */
15
- export function Router({ routes, ...meta }) {
16
- const { url, root: base } = requireMeta(meta);
17
- if (!url)
18
- return null;
19
- const path = base ? matchURLPrefix(url, base) : url.pathname;
20
- if (!path)
21
- return null;
22
- return _matchRoute(routes, path, url);
17
+ export function Router({ routes, fallback, ...meta }) {
18
+ const combined = requireMetaURL(meta);
19
+ const path = combined;
20
+ const route = _matchRoute(routes, fallback, combined);
21
+ if (route)
22
+ return _jsx(MetaContext, { value: combined, children: route });
23
+ throw new NotFoundError("Tree route not found", { received: path });
23
24
  }
24
- function _matchRoute(routes, path, url, depth = 0) {
25
- for (const [route, Route] of Object.entries(routes)) {
25
+ function _matchRoute(routes, fallback, meta, path = meta.path, depth = 0) {
26
+ for (const [route, Route] of getProps(routes)) {
27
+ // Try to match this path.
26
28
  const placeholders = matchPathTemplate(route, path);
27
29
  if (!placeholders)
28
30
  continue;
@@ -33,13 +35,17 @@ function _matchRoute(routes, path, url, depth = 0) {
33
35
  if (typeof Route === "string") {
34
36
  if (depth > 10)
35
37
  throw new UnexpectedError("Infinite redirect loop", { received: route, expected: path, caller: _matchRoute });
36
- return _matchRoute(routes, renderPathTemplate(Route, placeholders), url, depth + 1);
38
+ return _matchRoute(routes, fallback, meta, renderPathTemplate(Route, placeholders), depth + 1);
37
39
  }
38
40
  // React element — render as-is.
39
41
  if (typeof Route !== "function")
40
42
  return Route;
41
- // Function / component — render with merged URL query params and route placeholders as props (placeholders win on conflict).
42
- return _jsx(Route, { ...getURIParams(url), ...placeholders });
43
+ // Component — render with merged URL query params and route placeholders as props (placeholders win on conflict).
44
+ _jsx(Route, { ...meta.params, ...placeholders }, path);
43
45
  }
44
- return null;
46
+ // No match, try the fallback.
47
+ if (fallback !== undefined)
48
+ return fallback;
49
+ // Fallback is undefined, throw a `NotFoundError`
50
+ throw new NotFoundError("No matching route found", { received: path, caller: _matchRoute });
45
51
  }
@@ -1,15 +1,21 @@
1
1
  import type { ReactElement } from "react";
2
+ import { NotFoundError } from "../../error/RequestError.js";
2
3
  import { UnexpectedError } from "../../error/UnexpectedError.js";
3
- import type { AbsolutePath } from "../../util/path.js";
4
+ import { getProps } from "../../util/index.js";
4
5
  import { matchPathTemplate, renderPathTemplate } from "../../util/template.js";
5
- import { getURIParams } from "../../util/uri.js";
6
- import { type ImmutableURL, matchURLPrefix } from "../../util/url.js";
7
- import { requireMeta } from "../misc/MetaContext.js";
6
+ import { MetaContext, type MetaURL, requireMetaURL } from "../misc/MetaContext.js";
8
7
  import type { PossibleMeta } from "../util/meta.js";
9
8
  import type { Routes } from "./Routes.js";
10
9
 
11
10
  export interface RouterProps extends PossibleMeta {
11
+ /** List of routes for the router to match against. */
12
12
  readonly routes: Routes;
13
+
14
+ /**
15
+ * Optional fallback element.
16
+ * - Explicit `null` means fallback to nothing (router will not throw `NotFoundError`).
17
+ */
18
+ readonly fallback?: ReactElement | undefined | null;
13
19
  }
14
20
 
15
21
  /**
@@ -19,18 +25,27 @@ export interface RouterProps extends PossibleMeta {
19
25
  * - Nest by putting another `<Router>` inside a route's value; pass `base="/section"` (or wrap in `<Meta>`) to scope.
20
26
  * - Route `{placeholders}` are passed as props to function/component route values along with merged URL `?query` params. They are not published into context — descendants of a `ReactElement`-valued route can't see them automatically.
21
27
  * - Returns `null` when there's no URL in context or the URL is outside the base.
28
+ *
29
+ * @throws {NotFoundError} if no route matches and `fallback` is `undefined`
22
30
  */
23
- export function Router({ routes, ...meta }: RouterProps): ReactElement | null {
24
- const { url, root: base } = requireMeta(meta);
25
- if (!url) return null;
26
- const path = base ? matchURLPrefix(url, base) : url.pathname;
27
- if (!path) return null;
28
- return _matchRoute(routes, path, url);
31
+ export function Router({ routes, fallback, ...meta }: RouterProps): ReactElement | null {
32
+ const combined = requireMetaURL(meta);
33
+ const path = combined;
34
+ const route = _matchRoute(routes, fallback, combined);
35
+ if (route) return <MetaContext value={combined}>{route}</MetaContext>;
36
+ throw new NotFoundError("Tree route not found", { received: path });
29
37
  }
30
38
 
31
- function _matchRoute(routes: Routes, path: AbsolutePath, url: ImmutableURL, depth = 0): ReactElement | null {
32
- for (const [route, Route] of Object.entries(routes)) {
33
- const placeholders = matchPathTemplate(route as AbsolutePath, path);
39
+ function _matchRoute(
40
+ routes: Routes,
41
+ fallback: ReactElement | null | undefined,
42
+ meta: MetaURL,
43
+ path = meta.path,
44
+ depth = 0,
45
+ ): ReactElement | null | undefined {
46
+ for (const [route, Route] of getProps(routes)) {
47
+ // Try to match this path.
48
+ const placeholders = matchPathTemplate(route, path);
34
49
  if (!placeholders) continue;
35
50
 
36
51
  // Skip falsy. Allows a route to be conditionally disabled by setting its value to `null` or `false`.
@@ -39,14 +54,19 @@ function _matchRoute(routes: Routes, path: AbsolutePath, url: ImmutableURL, dept
39
54
  // String value is a redirect; re-run matching with the new path. Guard against infinite redirect loops by limiting depth.
40
55
  if (typeof Route === "string") {
41
56
  if (depth > 10) throw new UnexpectedError("Infinite redirect loop", { received: route, expected: path, caller: _matchRoute });
42
- return _matchRoute(routes, renderPathTemplate(Route, placeholders), url, depth + 1);
57
+ return _matchRoute(routes, fallback, meta, renderPathTemplate(Route, placeholders), depth + 1);
43
58
  }
44
59
 
45
60
  // React element — render as-is.
46
61
  if (typeof Route !== "function") return Route;
47
62
 
48
- // Function / component — render with merged URL query params and route placeholders as props (placeholders win on conflict).
49
- return <Route {...getURIParams(url)} {...placeholders} />;
63
+ // Component — render with merged URL query params and route placeholders as props (placeholders win on conflict).
64
+ <Route key={path} {...meta.params} {...placeholders} />;
50
65
  }
51
- return null;
66
+
67
+ // No match, try the fallback.
68
+ if (fallback !== undefined) return fallback;
69
+
70
+ // Fallback is undefined, throw a `NotFoundError`
71
+ throw new NotFoundError("No matching route found", { received: path, caller: _matchRoute });
52
72
  }
@@ -2,11 +2,12 @@ import type { ReactElement } from "react";
2
2
  import type { TreeElement } from "../../util/index.js";
3
3
  import type { Routes } from "../router/Routes.js";
4
4
  import type { PossibleMeta } from "../util/index.js";
5
- import type { OptionalChildProps } from "../util/props.js";
6
- export interface TreeAppProps extends PossibleMeta, OptionalChildProps {
5
+ export interface TreeAppProps extends PossibleMeta {
7
6
  /** The tree elements to display. */
8
7
  tree: TreeElement;
9
- /** Additional routes (merged with the default tree route). */
8
+ /**
9
+ * Additional routes.
10
+ */
10
11
  routes?: Routes | undefined;
11
12
  }
12
13
  /**
@@ -17,4 +18,4 @@ export interface TreeAppProps extends PossibleMeta, OptionalChildProps {
17
18
  * - Element rendering uses the default mappings on `<TreePage>`, `<TreeMenu>`, `<TreeCards>`.
18
19
  * Override by wrapping with `<TreePageMapping>`, `<TreeMenuMapping>`, or `<TreeCardMapping>`.
19
20
  */
20
- export declare function TreeApp({ tree, routes, children, ...appProps }: TreeAppProps): ReactElement;
21
+ export declare function TreeApp({ tree, routes: extraRoutes, ...meta }: TreeAppProps): ReactElement;
@@ -2,8 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { App } from "../app/App.js";
3
3
  import { SidebarLayout } from "../layout/SidebarLayout.js";
4
4
  import { PageCatcher } from "../misc/Catcher.js";
5
- import { Router } from "../router/Router.js";
6
- import { TreePage } from "./TreePage.js";
5
+ import { TreeRouter } from "./TreeRouter.js";
7
6
  import { TreeSidebar } from "./TreeSidebar.js";
8
7
  /**
9
8
  * Top-level app component for a tree-based documentation site.
@@ -13,12 +12,6 @@ import { TreeSidebar } from "./TreeSidebar.js";
13
12
  * - Element rendering uses the default mappings on `<TreePage>`, `<TreeMenu>`, `<TreeCards>`.
14
13
  * Override by wrapping with `<TreePageMapping>`, `<TreeMenuMapping>`, or `<TreeCardMapping>`.
15
14
  */
16
- export function TreeApp({ tree, routes = {}, children, ...appProps }) {
17
- const allRoutes = {
18
- ...routes,
19
- "/": () => _jsx(TreePage, { tree: tree }),
20
- // `{...path}` is a named catchall — captures any remaining segments (including empty) as `path`.
21
- "/{...path}": ({ path = "" }) => _jsx(TreePage, { path: `/${path}`, tree: tree }),
22
- };
23
- return (_jsx(App, { ...appProps, children: _jsx(PageCatcher, { children: _jsx(SidebarLayout, { sidebar: _jsx(TreeSidebar, { tree: tree }), children: children ?? _jsx(Router, { routes: allRoutes }) }) }) }));
15
+ export function TreeApp({ tree, routes: extraRoutes, ...meta }) {
16
+ return (_jsx(App, { ...meta, children: _jsx(PageCatcher, { children: _jsx(SidebarLayout, { sidebar: _jsx(TreeSidebar, { tree: tree }), children: _jsx(TreeRouter, { tree: tree }) }) }) }));
24
17
  }
@@ -3,17 +3,17 @@ import type { TreeElement } from "../../util/index.js";
3
3
  import { App } from "../app/App.js";
4
4
  import { SidebarLayout } from "../layout/SidebarLayout.js";
5
5
  import { PageCatcher } from "../misc/Catcher.js";
6
- import { Router } from "../router/Router.js";
7
6
  import type { Routes } from "../router/Routes.js";
8
7
  import type { PossibleMeta } from "../util/index.js";
9
- import type { OptionalChildProps } from "../util/props.js";
10
- import { TreePage } from "./TreePage.js";
8
+ import { TreeRouter } from "./TreeRouter.js";
11
9
  import { TreeSidebar } from "./TreeSidebar.js";
12
10
 
13
- export interface TreeAppProps extends PossibleMeta, OptionalChildProps {
11
+ export interface TreeAppProps extends PossibleMeta {
14
12
  /** The tree elements to display. */
15
13
  tree: TreeElement;
16
- /** Additional routes (merged with the default tree route). */
14
+ /**
15
+ * Additional routes.
16
+ */
17
17
  routes?: Routes | undefined;
18
18
  }
19
19
 
@@ -25,18 +25,13 @@ export interface TreeAppProps extends PossibleMeta, OptionalChildProps {
25
25
  * - Element rendering uses the default mappings on `<TreePage>`, `<TreeMenu>`, `<TreeCards>`.
26
26
  * Override by wrapping with `<TreePageMapping>`, `<TreeMenuMapping>`, or `<TreeCardMapping>`.
27
27
  */
28
- export function TreeApp({ tree, routes = {}, children, ...appProps }: TreeAppProps): ReactElement {
29
- const allRoutes: Routes = {
30
- ...routes,
31
- "/": () => <TreePage tree={tree} />,
32
- // `{...path}` is a named catchall — captures any remaining segments (including empty) as `path`.
33
- "/{...path}": ({ path = "" }) => <TreePage path={`/${path}`} tree={tree} />,
34
- };
35
-
28
+ export function TreeApp({ tree, routes: extraRoutes, ...meta }: TreeAppProps): ReactElement {
36
29
  return (
37
- <App {...appProps}>
30
+ <App {...meta}>
38
31
  <PageCatcher>
39
- <SidebarLayout sidebar={<TreeSidebar tree={tree} />}>{children ?? <Router routes={allRoutes} />}</SidebarLayout>
32
+ <SidebarLayout sidebar={<TreeSidebar tree={tree} />}>
33
+ <TreeRouter tree={tree} />
34
+ </SidebarLayout>
40
35
  </PageCatcher>
41
36
  </App>
42
37
  );
@@ -0,0 +1,30 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import { type TreeElement } from "../../util/element.js";
3
+ import { type AbsolutePath } from "../../util/path.js";
4
+ import type { PossibleMeta } from "../util/index.js";
5
+ /** Extras threaded through `TreeRouterMapper` to the page renderer — the site-root-relative path of the page. */
6
+ interface TreeRouterExtras {
7
+ /** Site-root-relative URL path of the page being rendered. Each page forwards it to its child cards. */
8
+ readonly path: AbsolutePath;
9
+ }
10
+ /** Mapping + Mapper pair for tree routers — wrap children in `<TreeRouterMapping>` to override. */
11
+ export declare const TreeRouterMapping: import("react").FunctionComponent<import("../misc/Mapper.js").MappingProps<TreeRouterExtras>>, TreeRouterMapper: import("react").FunctionComponent<import("../misc/Mapper.js").MapperProps<TreeRouterExtras>>;
12
+ export interface TreeRouterProps extends PossibleMeta {
13
+ /** The tree of elements to match routes for. */
14
+ readonly tree: TreeElement;
15
+ /**
16
+ * Optional fallback element.
17
+ * - Explicit `null` means fallback to nothing (router will not throw `NotFoundError`).
18
+ */
19
+ readonly fallback?: ReactElement | undefined | null;
20
+ }
21
+ /**
22
+ * Resolve a URL path to a tree element and render it as a full page.
23
+ * - Walks the tree by matching each path segment to a descendant's `key` (via `resolveElementPath()`).
24
+ * - `/` renders the root itself; deeper paths render the matching descendant.
25
+ * - `path` is the site-root-relative path (already stripped of any `APP_URL` subfolder by `<Router>`); it is threaded to the page renderer so child cards build correct hrefs.
26
+ * - Throws `NotFoundError` if no element matches at any level.
27
+ * - To override the renderer for a specific element type, wrap in `<TreeRouterMapping mapping={…}>`.
28
+ */
29
+ export declare function TreeRouter({ tree, fallback, ...meta }: TreeRouterProps): ReactNode;
30
+ export {};
@@ -6,8 +6,9 @@ import { DirectoryPage } from "../docs/DirectoryPage.js";
6
6
  import { DocumentationPage } from "../docs/DocumentationPage.js";
7
7
  import { FilePage } from "../docs/FilePage.js";
8
8
  import { createMapper } from "../misc/Mapper.js";
9
- /** Mapping + Mapper pair for tree pages — wrap children in `<TreePageMapping>` to override. */
10
- export const [TreePageMapping, TreePageMapper] = createMapper({
9
+ import { MetaContext, requireMetaURL } from "../misc/MetaContext.js";
10
+ /** Mapping + Mapper pair for tree routers — wrap children in `<TreeRouterMapping>` to override. */
11
+ export const [TreeRouterMapping, TreeRouterMapper] = createMapper({
11
12
  "tree-directory": DirectoryPage,
12
13
  "tree-file": FilePage,
13
14
  "tree-documentation": DocumentationPage,
@@ -18,12 +19,15 @@ export const [TreePageMapping, TreePageMapper] = createMapper({
18
19
  * - `/` renders the root itself; deeper paths render the matching descendant.
19
20
  * - `path` is the site-root-relative path (already stripped of any `APP_URL` subfolder by `<Router>`); it is threaded to the page renderer so child cards build correct hrefs.
20
21
  * - Throws `NotFoundError` if no element matches at any level.
21
- * - To override the renderer for a specific element type, wrap in `<TreePageMapping mapping={…}>`.
22
+ * - To override the renderer for a specific element type, wrap in `<TreeRouterMapping mapping={…}>`.
22
23
  */
23
- export function TreePage({ path = "/", tree }) {
24
- const segments = splitPath(path);
25
- const element = resolveElementPath(tree, segments);
26
- if (!element)
27
- throw new NotFoundError("Element not found", { received: path });
28
- return _jsx(TreePageMapper, { path: path, children: element });
24
+ export function TreeRouter({ tree, fallback, ...meta }) {
25
+ const { path, ...combined } = requireMetaURL(meta);
26
+ // Find a `TreeElement` matching the current URL meta path.
27
+ const element = resolveElementPath(tree, splitPath(path));
28
+ // We render either a mapped version of the tree element, or the fallback element.
29
+ const route = element ? _jsx(TreeRouterMapper, { path: path, children: element }) : fallback;
30
+ if (route !== undefined)
31
+ return _jsx(MetaContext, { value: combined, children: route });
32
+ throw new NotFoundError("Tree route not found", { received: path });
29
33
  }
@@ -3,7 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server";
3
3
  import type { TreeElement } from "../../util/element.js";
4
4
  import { MetaContext } from "../misc/MetaContext.js";
5
5
  import { createMeta } from "../util/meta.js";
6
- import { TreePage } from "./TreePage.js";
6
+ import { TreeRouter } from "./TreeRouter.js";
7
7
 
8
8
  /** Minimal tree: root → `util` directory → `array` file. */
9
9
  const tree: TreeElement = {
@@ -21,12 +21,12 @@ const tree: TreeElement = {
21
21
  },
22
22
  };
23
23
 
24
- describe("TreePage", () => {
24
+ describe("TreeRouter", () => {
25
25
  // When `APP_URL` has a subfolder, the page threads its site-root-relative path to child cards so hrefs include the subfolder exactly once.
26
26
  test("card links include an APP_URL subfolder exactly once", () => {
27
27
  const html = renderToStaticMarkup(
28
28
  <MetaContext value={createMeta({ root: "http://x.com/sub/", url: "./util" })}>
29
- <TreePage path="/util" tree={tree} />
29
+ <TreeRouter tree={tree} />
30
30
  </MetaContext>,
31
31
  );
32
32
  expect(html).toContain('href="http://x.com/sub/util/array"');
@@ -36,7 +36,7 @@ describe("TreePage", () => {
36
36
  test("root page card links include an APP_URL subfolder exactly once", () => {
37
37
  const html = renderToStaticMarkup(
38
38
  <MetaContext value={createMeta({ root: "http://x.com/sub/", url: "./" })}>
39
- <TreePage path="/" tree={tree} />
39
+ <TreeRouter tree={tree} />
40
40
  </MetaContext>,
41
41
  );
42
42
  expect(html).toContain('href="http://x.com/sub/util"');
@@ -0,0 +1,55 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import { NotFoundError } from "../../error/RequestError.js";
3
+ import { resolveElementPath, type TreeElement } from "../../util/element.js";
4
+ import { type AbsolutePath, splitPath } from "../../util/path.js";
5
+ import { DirectoryPage } from "../docs/DirectoryPage.js";
6
+ import { DocumentationPage } from "../docs/DocumentationPage.js";
7
+ import { FilePage } from "../docs/FilePage.js";
8
+ import { createMapper } from "../misc/Mapper.js";
9
+ import { MetaContext, requireMetaURL } from "../misc/MetaContext.js";
10
+
11
+ import type { PossibleMeta } from "../util/index.js";
12
+
13
+ /** Extras threaded through `TreeRouterMapper` to the page renderer — the site-root-relative path of the page. */
14
+ interface TreeRouterExtras {
15
+ /** Site-root-relative URL path of the page being rendered. Each page forwards it to its child cards. */
16
+ readonly path: AbsolutePath;
17
+ }
18
+
19
+ /** Mapping + Mapper pair for tree routers — wrap children in `<TreeRouterMapping>` to override. */
20
+ export const [TreeRouterMapping, TreeRouterMapper] = createMapper<TreeRouterExtras>({
21
+ "tree-directory": DirectoryPage,
22
+ "tree-file": FilePage,
23
+ "tree-documentation": DocumentationPage,
24
+ });
25
+
26
+ export interface TreeRouterProps extends PossibleMeta {
27
+ /** The tree of elements to match routes for. */
28
+ readonly tree: TreeElement;
29
+
30
+ /**
31
+ * Optional fallback element.
32
+ * - Explicit `null` means fallback to nothing (router will not throw `NotFoundError`).
33
+ */
34
+ readonly fallback?: ReactElement | undefined | null;
35
+ }
36
+
37
+ /**
38
+ * Resolve a URL path to a tree element and render it as a full page.
39
+ * - Walks the tree by matching each path segment to a descendant's `key` (via `resolveElementPath()`).
40
+ * - `/` renders the root itself; deeper paths render the matching descendant.
41
+ * - `path` is the site-root-relative path (already stripped of any `APP_URL` subfolder by `<Router>`); it is threaded to the page renderer so child cards build correct hrefs.
42
+ * - Throws `NotFoundError` if no element matches at any level.
43
+ * - To override the renderer for a specific element type, wrap in `<TreeRouterMapping mapping={…}>`.
44
+ */
45
+ export function TreeRouter({ tree, fallback, ...meta }: TreeRouterProps): ReactNode {
46
+ const { path, ...combined } = requireMetaURL(meta);
47
+
48
+ // Find a `TreeElement` matching the current URL meta path.
49
+ const element = resolveElementPath(tree, splitPath(path));
50
+
51
+ // We render either a mapped version of the tree element, or the fallback element.
52
+ const route = element ? <TreeRouterMapper path={path}>{element}</TreeRouterMapper> : fallback;
53
+ if (route !== undefined) return <MetaContext value={combined}>{route}</MetaContext>;
54
+ throw new NotFoundError("Tree route not found", { received: path });
55
+ }
@@ -1,5 +1,5 @@
1
1
  export * from "./TreeApp.js";
2
2
  export * from "./TreeCards.js";
3
3
  export * from "./TreeMenu.js";
4
- export * from "./TreePage.js";
4
+ export * from "./TreeRouter.js";
5
5
  export * from "./TreeSidebar.js";
package/ui/tree/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export * from "./TreeApp.js";
2
2
  export * from "./TreeCards.js";
3
3
  export * from "./TreeMenu.js";
4
- export * from "./TreePage.js";
4
+ export * from "./TreeRouter.js";
5
5
  export * from "./TreeSidebar.js";
package/ui/tree/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export * from "./TreeApp.js";
2
2
  export * from "./TreeCards.js";
3
3
  export * from "./TreeMenu.js";
4
- export * from "./TreePage.js";
4
+ export * from "./TreeRouter.js";
5
5
  export * from "./TreeSidebar.js";
@@ -1,24 +0,0 @@
1
- import type { ReactNode } from "react";
2
- import { type TreeElement } from "../../util/element.js";
3
- import { type AbsolutePath } from "../../util/path.js";
4
- /** Extras threaded through `TreePageMapper` to the page renderer — the site-root-relative path of the page. */
5
- interface TreePageExtras {
6
- /** Site-root-relative URL path of the page being rendered. Each page forwards it to its child cards. */
7
- readonly path: AbsolutePath;
8
- }
9
- /** Mapping + Mapper pair for tree pages — wrap children in `<TreePageMapping>` to override. */
10
- export declare const TreePageMapping: import("react").FunctionComponent<import("../misc/Mapper.js").MappingProps<TreePageExtras>>, TreePageMapper: import("react").FunctionComponent<import("../misc/Mapper.js").MapperProps<TreePageExtras>>;
11
- export interface TreePageProps {
12
- readonly path?: AbsolutePath;
13
- readonly tree: TreeElement;
14
- }
15
- /**
16
- * Resolve a URL path to a tree element and render it as a full page.
17
- * - Walks the tree by matching each path segment to a descendant's `key` (via `resolveElementPath()`).
18
- * - `/` renders the root itself; deeper paths render the matching descendant.
19
- * - `path` is the site-root-relative path (already stripped of any `APP_URL` subfolder by `<Router>`); it is threaded to the page renderer so child cards build correct hrefs.
20
- * - Throws `NotFoundError` if no element matches at any level.
21
- * - To override the renderer for a specific element type, wrap in `<TreePageMapping mapping={…}>`.
22
- */
23
- export declare function TreePage({ path, tree }: TreePageProps): ReactNode;
24
- export {};
@@ -1,41 +0,0 @@
1
- import type { ReactNode } from "react";
2
- import { NotFoundError } from "../../error/RequestError.js";
3
- import { resolveElementPath, type TreeElement } from "../../util/element.js";
4
- import { type AbsolutePath, splitPath } from "../../util/path.js";
5
- import { DirectoryPage } from "../docs/DirectoryPage.js";
6
- import { DocumentationPage } from "../docs/DocumentationPage.js";
7
- import { FilePage } from "../docs/FilePage.js";
8
- import { createMapper } from "../misc/Mapper.js";
9
-
10
- /** Extras threaded through `TreePageMapper` to the page renderer — the site-root-relative path of the page. */
11
- interface TreePageExtras {
12
- /** Site-root-relative URL path of the page being rendered. Each page forwards it to its child cards. */
13
- readonly path: AbsolutePath;
14
- }
15
-
16
- /** Mapping + Mapper pair for tree pages — wrap children in `<TreePageMapping>` to override. */
17
- export const [TreePageMapping, TreePageMapper] = createMapper<TreePageExtras>({
18
- "tree-directory": DirectoryPage,
19
- "tree-file": FilePage,
20
- "tree-documentation": DocumentationPage,
21
- });
22
-
23
- export interface TreePageProps {
24
- readonly path?: AbsolutePath;
25
- readonly tree: TreeElement;
26
- }
27
-
28
- /**
29
- * Resolve a URL path to a tree element and render it as a full page.
30
- * - Walks the tree by matching each path segment to a descendant's `key` (via `resolveElementPath()`).
31
- * - `/` renders the root itself; deeper paths render the matching descendant.
32
- * - `path` is the site-root-relative path (already stripped of any `APP_URL` subfolder by `<Router>`); it is threaded to the page renderer so child cards build correct hrefs.
33
- * - Throws `NotFoundError` if no element matches at any level.
34
- * - To override the renderer for a specific element type, wrap in `<TreePageMapping mapping={…}>`.
35
- */
36
- export function TreePage({ path = "/", tree }: TreePageProps): ReactNode {
37
- const segments = splitPath(path);
38
- const element = resolveElementPath(tree, segments);
39
- if (!element) throw new NotFoundError("Element not found", { received: path });
40
- return <TreePageMapper path={path}>{element}</TreePageMapper>;
41
- }