shelving 1.205.0 → 1.206.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.
Files changed (72) hide show
  1. package/api/endpoint/Endpoint.js +3 -3
  2. package/package.json +8 -7
  3. package/react/index.d.ts +0 -1
  4. package/react/index.js +0 -1
  5. package/react/useInstance.js +10 -7
  6. package/react/useLazy.js +10 -7
  7. package/react/useMap.d.ts +1 -1
  8. package/react/useMap.js +2 -5
  9. package/react/useReduce.js +3 -3
  10. package/react/useStore.js +14 -11
  11. package/store/Store.d.ts +7 -2
  12. package/store/Store.js +8 -0
  13. package/ui/app/App.d.ts +2 -2
  14. package/ui/app/App.js +4 -3
  15. package/ui/app/App.tsx +5 -4
  16. package/ui/block/Section.d.ts +2 -2
  17. package/ui/block/Section.js +1 -1
  18. package/ui/block/Section.tsx +2 -2
  19. package/ui/misc/MetaContext.d.ts +12 -0
  20. package/ui/misc/MetaContext.js +15 -0
  21. package/ui/misc/MetaContext.tsx +22 -0
  22. package/ui/misc/index.d.ts +1 -1
  23. package/ui/misc/index.js +1 -1
  24. package/ui/misc/index.tsx +1 -1
  25. package/ui/page/HTML.d.ts +5 -5
  26. package/ui/page/HTML.js +8 -8
  27. package/ui/page/HTML.tsx +16 -8
  28. package/ui/page/Head.d.ts +6 -1
  29. package/ui/page/Head.js +20 -13
  30. package/ui/page/Head.tsx +26 -20
  31. package/ui/page/Page.d.ts +4 -4
  32. package/ui/page/Page.js +6 -5
  33. package/ui/page/Page.tsx +8 -7
  34. package/ui/router/Navigation.d.ts +25 -0
  35. package/ui/router/Navigation.js +56 -0
  36. package/ui/router/Navigation.tsx +75 -0
  37. package/ui/router/NavigationContext.d.ts +5 -0
  38. package/ui/router/NavigationContext.js +9 -0
  39. package/ui/router/NavigationContext.tsx +12 -0
  40. package/ui/router/NavigationStore.d.ts +11 -0
  41. package/ui/router/{RouterStore.js → NavigationStore.js} +6 -10
  42. package/ui/router/NavigationStore.tsx +22 -0
  43. package/ui/router/README.md +186 -0
  44. package/ui/router/Router.d.ts +10 -28
  45. package/ui/router/Router.js +39 -63
  46. package/ui/router/Router.tsx +38 -79
  47. package/ui/router/Routes.d.ts +10 -21
  48. package/ui/router/Routes.js +1 -35
  49. package/ui/router/Routes.tsx +10 -40
  50. package/ui/router/index.d.ts +3 -2
  51. package/ui/router/index.js +3 -2
  52. package/ui/router/index.ts +3 -2
  53. package/ui/tree/TreeApp.d.ts +4 -4
  54. package/ui/tree/TreeApp.js +6 -6
  55. package/ui/tree/TreeApp.tsx +11 -14
  56. package/ui/util/meta.d.ts +3 -3
  57. package/ui/util/meta.ts +3 -3
  58. package/util/start.d.ts +3 -1
  59. package/util/start.js +3 -0
  60. package/util/template.d.ts +18 -10
  61. package/util/template.js +70 -21
  62. package/react/useProps.d.ts +0 -6
  63. package/react/useProps.js +0 -5
  64. package/ui/misc/Meta.d.ts +0 -9
  65. package/ui/misc/Meta.js +0 -14
  66. package/ui/misc/Meta.tsx +0 -20
  67. package/ui/router/RouterContext.d.ts +0 -5
  68. package/ui/router/RouterContext.js +0 -9
  69. package/ui/router/RouterContext.tsx +0 -12
  70. package/ui/router/RouterStore.d.ts +0 -13
  71. package/ui/router/RouterStore.test.tsx +0 -43
  72. package/ui/router/RouterStore.tsx +0 -29
@@ -1,7 +1,7 @@
1
1
  import { RequiredError } from "../../error/RequiredError.js";
2
2
  import { UNDEFINED } from "../../schema/Schema.js";
3
3
  import { isData } from "../../util/data.js";
4
- import { getPlaceholders, matchTemplate, renderTemplate } from "../../util/template.js";
4
+ import { getPlaceholders, matchPathTemplate, renderPathTemplate } from "../../util/template.js";
5
5
  /**
6
6
  * An abstract API resource definition, used to specify types for e.g. serverless functions.
7
7
  *
@@ -40,7 +40,7 @@ export class Endpoint {
40
40
  // Placeholders.
41
41
  if (this.placeholders.length) {
42
42
  assertPlaceholderPayload(payload, this, caller);
43
- return renderTemplate(this.path, payload, caller);
43
+ return renderPathTemplate(this.path, payload, caller);
44
44
  }
45
45
  // No placeholders.
46
46
  return this.path;
@@ -51,7 +51,7 @@ export class Endpoint {
51
51
  match(method, path, caller = this.match) {
52
52
  if (method !== this.method)
53
53
  return undefined;
54
- return matchTemplate(this.path, path, caller);
54
+ return matchPathTemplate(this.path, path, caller);
55
55
  }
56
56
  /**
57
57
  * Create an endpoint handler pairing for this endpoint.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shelving",
3
- "version": "1.205.0",
3
+ "version": "1.206.0",
4
4
  "author": "Dave Houlbrooke <dave@shax.com>",
5
5
  "repository": {
6
6
  "type": "git",
@@ -10,22 +10,23 @@
10
10
  "module": "./index.js",
11
11
  "devDependencies": {
12
12
  "@biomejs/biome": "^2.4.15",
13
- "@google-cloud/firestore": "^8.5.0",
13
+ "@google-cloud/firestore": "^8.6.0",
14
14
  "@heroicons/react": "^2.2.0",
15
- "@types/bun": "^1.3.13",
15
+ "@types/bun": "^1.3.14",
16
16
  "@types/react": "^19.2.14",
17
17
  "@types/react-dom": "^19.2.3",
18
- "@typescript/native-preview": "^7.0.0-dev.20260509.2",
18
+ "@typescript/native-preview": "^7.0.0-dev.20260514.1",
19
19
  "firebase": "^12.13.0",
20
- "react": "^19.3.0-canary-d5736f09-20260507",
21
- "react-dom": "canary",
20
+ "react": "^19.3.0-canary-fef12a01-20260413",
21
+ "react-dom": "^19.3.0-canary-fef12a01-20260413",
22
22
  "typescript": "^5.9.3"
23
23
  },
24
24
  "peerDependencies": {
25
25
  "@google-cloud/firestore": ">=7.0.0",
26
26
  "@heroicons/react": ">=2.0.0",
27
27
  "firebase": ">=11.0.0",
28
- "react": ">=19.0.0"
28
+ "react": ">=19.0.0",
29
+ "react-dom": ">=19.0.0"
29
30
  },
30
31
  "exports": {
31
32
  ".": "./index.js",
package/react/index.d.ts CHANGED
@@ -3,7 +3,6 @@ export * from "./createDBContext.js";
3
3
  export * from "./useInstance.js";
4
4
  export * from "./useLazy.js";
5
5
  export * from "./useMap.js";
6
- export * from "./useProps.js";
7
6
  export * from "./useReduce.js";
8
7
  export * from "./useSequence.js";
9
8
  export * from "./useStore.js";
package/react/index.js CHANGED
@@ -3,7 +3,6 @@ export * from "./createDBContext.js";
3
3
  export * from "./useInstance.js";
4
4
  export * from "./useLazy.js";
5
5
  export * from "./useMap.js";
6
- export * from "./useProps.js";
7
6
  export * from "./useReduce.js";
8
7
  export * from "./useSequence.js";
9
8
  export * from "./useStore.js";
@@ -1,16 +1,19 @@
1
+ import { useRef } from "react";
1
2
  import { isArrayEqual } from "../util/equal.js";
2
- import { useProps } from "./useProps.js";
3
3
  /**
4
4
  * Use a memoised class instance.
5
5
  * - Creates a new instance of `Constructor` using `args`
6
6
  * - Returns same instance for as long as `args` is equal to previous `args`.
7
7
  */
8
8
  export function useInstance(Constructor, ...args) {
9
- const internals = useProps();
10
- // Update `internals` if `args` changes or `instance` is not set.
11
- if (!internals.args || !internals.instance || !isArrayEqual(args, internals.args)) {
12
- internals.instance = new Constructor(...args);
13
- internals.args = args;
9
+ const _internals = (useRef(undefined).current ??= {
10
+ instance: new Constructor(...args),
11
+ args,
12
+ });
13
+ // Update `_internals` if `args` changes.
14
+ if (!isArrayEqual(args, _internals.args)) {
15
+ _internals.instance = new Constructor(...args);
16
+ _internals.args = args;
14
17
  }
15
- return internals.instance;
18
+ return _internals.instance;
16
19
  }
package/react/useLazy.js CHANGED
@@ -1,12 +1,15 @@
1
+ import { useRef } from "react";
1
2
  import { isArrayEqual } from "../util/equal.js";
2
3
  import { getLazy } from "../util/lazy.js";
3
- import { useProps } from "./useProps.js";
4
4
  export function useLazy(value, ...args) {
5
- const internals = useProps();
6
- // Update `internals` if `args` changes.
7
- if (!internals.args || !isArrayEqual(args, internals.args)) {
8
- internals.value = getLazy(value, ...args);
9
- internals.args = args;
5
+ const _internals = (useRef(undefined).current ??= {
6
+ value: getLazy(value, ...args),
7
+ args,
8
+ });
9
+ // Update `_internals` if `args` changes.
10
+ if (!isArrayEqual(args, _internals.args)) {
11
+ _internals.value = getLazy(value, ...args);
12
+ _internals.args = args;
10
13
  }
11
- return internals.value;
14
+ return _internals.value;
12
15
  }
package/react/useMap.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- /** Create a mutable Map that persist for the lifetime of the component. */
1
+ /** Create a mutable Map that persists for the lifetime of the component. */
2
2
  export declare function useMap<K, V>(): Map<K, V>;
package/react/useMap.js CHANGED
@@ -1,8 +1,5 @@
1
1
  import { useRef } from "react";
2
- /** Create a mutable Map that persist for the lifetime of the component. */
2
+ /** Create a mutable Map that persists for the lifetime of the component. */
3
3
  export function useMap() {
4
- const ref = useRef(undefined);
5
- if (!ref.current)
6
- ref.current = new Map();
7
- return ref.current;
4
+ return (useRef(undefined).current ??= new Map());
8
5
  }
@@ -1,6 +1,6 @@
1
1
  import { useRef } from "react";
2
2
  export function useReduce(reduce, ...args) {
3
- const ref = useRef(undefined);
4
- ref.current = reduce(ref.current, ...args);
5
- return ref.current;
3
+ const _ref = useRef(undefined);
4
+ _ref.current = reduce(_ref.current, ...args);
5
+ return _ref.current;
6
6
  }
package/react/useStore.js CHANGED
@@ -1,16 +1,19 @@
1
1
  import { useSyncExternalStore } from "react";
2
2
  import { BLACKHOLE } from "../util/function.js";
3
- import { runSequence } from "../util/sequence.js";
4
- import { useProps } from "./useProps.js";
3
+ import { STOPHOLE } from "../util/index.js";
4
+ // We add an `[EXTERNAL_STORE]` symbol key to the `Store` instance to cache the subscribe and getSnapshot functions for `useSyncExternalStore()`.
5
+ const EXTERNAL_STORE = Symbol();
6
+ const EXTERNAL_BLACKHOLE = {
7
+ subscribe: STOPHOLE,
8
+ getSnapshot: BLACKHOLE,
9
+ };
5
10
  export function useStore(store) {
6
- // Store memoized versions of `subscribe()` and `getSnapshot()` so `useSyncExternalStore()` doesn't re-subscribe on every render.
7
- const internals = useProps();
8
- // Update `internals` if `store` changes.
9
- if (store !== internals.store || !internals.subscribe || !internals.getSnapshot) {
10
- internals.subscribe = onStoreChange => (store ? runSequence(store, onStoreChange, onStoreChange) : BLACKHOLE);
11
- internals.getSnapshot = () => store?.snapshot;
12
- internals.store = store;
13
- }
14
- useSyncExternalStore(internals.subscribe, internals.getSnapshot);
11
+ const { subscribe, getSnapshot } = !store
12
+ ? EXTERNAL_BLACKHOLE
13
+ : (store[EXTERNAL_STORE] ??= {
14
+ subscribe: c => store.subscribe(c, c),
15
+ getSnapshot: () => store.snapshot,
16
+ });
17
+ useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
15
18
  return store;
16
19
  }
package/store/Store.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { DeferredSequence } from "../sequence/DeferredSequence.js";
2
2
  import { NONE, SKIP } from "../util/constants.js";
3
- import type { AnyCaller, Arguments } from "../util/function.js";
4
- import { type PossibleStarter } from "../util/start.js";
3
+ import type { AnyCaller, Arguments, Callback, ErrorCallback, ValueCallback } from "../util/function.js";
4
+ import { type PossibleStarter, type StopCallback } from "../util/start.js";
5
5
  /** Any `Store` instance. */
6
6
  export type AnyStore = Store<any, any>;
7
7
  /** Values that a store natively knows how to process as inputs. */
@@ -166,4 +166,9 @@ export declare class Store<T, TT = T> implements AsyncIterable<T, void, void>, A
166
166
  [Symbol.asyncIterator](): AsyncIterator<T, void, void>;
167
167
  private _iterating;
168
168
  [Symbol.asyncDispose](): Promise<void>;
169
+ /**
170
+ * Subscribe to this store with handlers.
171
+ * - Returns a `StopCallback` to stop the subscription.
172
+ */
173
+ subscribe(onNext?: ValueCallback<T>, onError?: ErrorCallback, onReturn?: Callback): StopCallback;
169
174
  }
package/store/Store.js CHANGED
@@ -3,6 +3,7 @@ import { isAsync } from "../util/async.js";
3
3
  import { NONE, SKIP } from "../util/constants.js";
4
4
  import { awaitDispose } from "../util/dispose.js";
5
5
  import { isDeepEqual } from "../util/equal.js";
6
+ import { runSequence } from "../util/index.js";
6
7
  import { getStarter } from "../util/start.js";
7
8
  /**
8
9
  * Store that retains its most recent value and is async-iterable to allow values to be observed.
@@ -289,6 +290,13 @@ export class Store {
289
290
  await awaitDispose(this._starter, // Stop the starter.
290
291
  this.next);
291
292
  }
293
+ /**
294
+ * Subscribe to this store with handlers.
295
+ * - Returns a `StopCallback` to stop the subscription.
296
+ */
297
+ subscribe(onNext, onError, onReturn) {
298
+ return runSequence(this, onNext, onError, onReturn);
299
+ }
292
300
  }
293
301
  /** Call a callback but always return or resolve to `SKIP` */
294
302
  function _callSkipped(callback, ...args) {
package/ui/app/App.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type ReactElement, type ReactNode } from "react";
2
- import type { PossibleMeta } from "../util/meta.js";
2
+ import type { PossibleMeta } from "../util/index.js";
3
3
  export interface AppProps extends PossibleMeta {
4
4
  children: ReactNode;
5
5
  }
@@ -8,4 +8,4 @@ export interface AppProps extends PossibleMeta {
8
8
  * - Adds the theme CSS class (which sets CSS token variables on `:root`) to `document.body` on mount and removes it on unmount.
9
9
  * - Provides a `Meta` context to its children so descendants can read or update metadata.
10
10
  */
11
- export declare function App({ children, ...metadata }: AppProps): ReactElement;
11
+ export declare function App({ children, ...meta }: AppProps): ReactElement;
package/ui/app/App.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useEffect } from "react";
3
- import { Meta } from "../misc/Meta.js";
3
+ import { MetaContext, requireMeta } from "../misc/MetaContext.js";
4
4
  import APP_CSS from "./App.module.css";
5
5
  const APP_CLASS = APP_CSS.app;
6
6
  /**
@@ -8,12 +8,13 @@ const APP_CLASS = APP_CSS.app;
8
8
  * - Adds the theme CSS class (which sets CSS token variables on `:root`) to `document.body` on mount and removes it on unmount.
9
9
  * - Provides a `Meta` context to its children so descendants can read or update metadata.
10
10
  */
11
- export function App({ children, ...metadata }) {
11
+ export function App({ children, ...meta }) {
12
+ const merged = requireMeta(meta);
12
13
  useEffect(() => {
13
14
  if (!APP_CLASS)
14
15
  return;
15
16
  document.body.classList.add(APP_CLASS);
16
17
  return () => document.body.classList.remove(APP_CLASS);
17
18
  }, []);
18
- return _jsx(Meta, { ...metadata, children: children });
19
+ return _jsx(MetaContext, { value: merged, children: children });
19
20
  }
package/ui/app/App.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type ReactElement, type ReactNode, useEffect } from "react";
2
- import { Meta } from "../misc/Meta.js";
3
- import type { PossibleMeta } from "../util/meta.js";
2
+ import { MetaContext, requireMeta } from "../misc/MetaContext.js";
3
+ import type { PossibleMeta } from "../util/index.js";
4
4
  import APP_CSS from "./App.module.css";
5
5
 
6
6
  export interface AppProps extends PossibleMeta {
@@ -14,11 +14,12 @@ const APP_CLASS = APP_CSS.app;
14
14
  * - Adds the theme CSS class (which sets CSS token variables on `:root`) to `document.body` on mount and removes it on unmount.
15
15
  * - Provides a `Meta` context to its children so descendants can read or update metadata.
16
16
  */
17
- export function App({ children, ...metadata }: AppProps): ReactElement {
17
+ export function App({ children, ...meta }: AppProps): ReactElement {
18
+ const merged = requireMeta(meta);
18
19
  useEffect(() => {
19
20
  if (!APP_CLASS) return;
20
21
  document.body.classList.add(APP_CLASS);
21
22
  return () => document.body.classList.remove(APP_CLASS);
22
23
  }, []);
23
- return <Meta {...metadata}>{children}</Meta>;
24
+ return <MetaContext value={merged}>{children}</MetaContext>;
24
25
  }
@@ -14,10 +14,10 @@ export interface HeaderProps extends SectionProps {
14
14
  export declare function Header(props: HeaderProps): ReactElement;
15
15
  /** A single HTML `<section>` with correct spacing. */
16
16
  export declare function Section(props: SectionProps): ReactElement;
17
- export interface NavigationProps extends SectionProps {
17
+ export interface NavProps extends SectionProps {
18
18
  }
19
19
  /** A single HTML `<nav>` with correct spacing. */
20
- export declare function Navigation(props: NavigationProps): ReactElement;
20
+ export declare function Nav(props: NavProps): ReactElement;
21
21
  export interface AsideProps extends SectionProps {
22
22
  }
23
23
  /** A single HTML `<aside>` with correct spacing. */
@@ -13,7 +13,7 @@ export function Section(props) {
13
13
  return renderSection("section", props);
14
14
  }
15
15
  /** A single HTML `<nav>` with correct spacing. */
16
- export function Navigation(props) {
16
+ export function Nav(props) {
17
17
  return renderSection("nav", props);
18
18
  }
19
19
  /** A single HTML `<aside>` with correct spacing. */
@@ -31,10 +31,10 @@ export function Section(props: SectionProps): ReactElement {
31
31
  return renderSection("section", props);
32
32
  }
33
33
 
34
- export interface NavigationProps extends SectionProps {}
34
+ export interface NavProps extends SectionProps {}
35
35
 
36
36
  /** A single HTML `<nav>` with correct spacing. */
37
- export function Navigation(props: NavigationProps): ReactElement {
37
+ export function Nav(props: NavProps): ReactElement {
38
38
  return renderSection("nav", props);
39
39
  }
40
40
 
@@ -0,0 +1,12 @@
1
+ import { type ReactNode } from "react";
2
+ import { type URIParams } from "../../util/uri.js";
3
+ import { type Meta, type PossibleMeta } from "../util/meta.js";
4
+ /** Context to store the `Config` object. */
5
+ export declare const MetaContext: import("react").Context<Meta>;
6
+ export interface MetaProps extends PossibleMeta {
7
+ children: ReactNode;
8
+ }
9
+ /** Require the current meta context in a component. */
10
+ 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;
@@ -0,0 +1,15 @@
1
+ import { createContext, use } from "react";
2
+ import { getURIParams } from "../../util/uri.js";
3
+ import { mergeMeta } from "../util/meta.js";
4
+ /** Context to store the `Config` object. */
5
+ export const MetaContext = createContext({});
6
+ MetaContext.displayName = "MetaContext";
7
+ /** Require the current meta context in a component. */
8
+ export function requireMeta(meta) {
9
+ const current = use(MetaContext);
10
+ return meta ? mergeMeta(current, meta) : current;
11
+ }
12
+ /** Get all URI/route params from the current meta context's URL. */
13
+ export function requireMetaParams() {
14
+ return getURIParams(requireMeta().url ?? {}, requireMetaParams);
15
+ }
@@ -0,0 +1,22 @@
1
+ import { createContext, type ReactNode, use } from "react";
2
+ import { getURIParams, type URIParams } from "../../util/uri.js";
3
+ import { type Meta, mergeMeta, type PossibleMeta } from "../util/meta.js";
4
+
5
+ /** Context to store the `Config` object. */
6
+ export const MetaContext = createContext<Meta>({});
7
+ MetaContext.displayName = "MetaContext";
8
+
9
+ export interface MetaProps extends PossibleMeta {
10
+ children: ReactNode;
11
+ }
12
+
13
+ /** Require the current meta context in a component. */
14
+ export function requireMeta(meta?: PossibleMeta): Meta {
15
+ const current = use(MetaContext);
16
+ return meta ? mergeMeta(current, meta) : current;
17
+ }
18
+
19
+ /** Get all URI/route params from the current meta context's URL. */
20
+ export function requireMetaParams(): URIParams {
21
+ return getURIParams(requireMeta().url ?? {}, requireMetaParams);
22
+ }
@@ -1,4 +1,4 @@
1
1
  export * from "./Catcher.js";
2
2
  export * from "./Loading.js";
3
3
  export * from "./Mapper.js";
4
- export * from "./Meta.js";
4
+ export * from "./MetaContext.js";
package/ui/misc/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export * from "./Catcher.js";
2
2
  export * from "./Loading.js";
3
3
  export * from "./Mapper.js";
4
- export * from "./Meta.js";
4
+ export * from "./MetaContext.js";
package/ui/misc/index.tsx CHANGED
@@ -1,4 +1,4 @@
1
1
  export * from "./Catcher.js";
2
2
  export * from "./Loading.js";
3
3
  export * from "./Mapper.js";
4
- export * from "./Meta.js";
4
+ export * from "./MetaContext.js";
package/ui/page/HTML.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import type { ReactElement, ReactNode } from "react";
2
- export interface HTMLProps {
2
+ import type { PossibleMeta } from "../util/index.js";
3
+ export interface HTMLProps extends PossibleMeta {
3
4
  children: ReactNode;
4
5
  }
5
6
  /**
6
- * Output a `<html>` element wrapping `<body id="root">`.
7
- * - No `<head>` element is rendered. Head tags (`<title>`, `<meta>`, `<link>`, `<script>`) are emitted inline by `<Page>` / `<Head>` lower in the tree, and React 19 hoists them automatically to the document `<head>` on the client, and to a generated `<head>` element during `renderToString` SSR.
8
- * - This means the same component tree works for both modes without any shell-aware logic.
7
+ * Output a `<html>` element wrapping `<head>` (via `<Head>`) and `<body id="root">`.
8
+ * - `<Head>` renders the literal `<head>` with `<base>` and other shell-level metadata; per-page hoistable elements (title, meta, links, stylesheets, scripts) come from `<PageHead>` inside `<Page>` and are hoisted into this `<head>` by React 19.
9
9
  */
10
- export declare function HTML({ children }: HTMLProps): ReactElement;
10
+ export declare function HTML({ children, ...meta }: HTMLProps): ReactElement;
package/ui/page/HTML.js CHANGED
@@ -1,11 +1,11 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { requireMeta } from "../misc/Meta.js";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { MetaContext, requireMeta } from "../misc/MetaContext.js";
3
3
  /**
4
- * Output a `<html>` element wrapping `<body id="root">`.
5
- * - No `<head>` element is rendered. Head tags (`<title>`, `<meta>`, `<link>`, `<script>`) are emitted inline by `<Page>` / `<Head>` lower in the tree, and React 19 hoists them automatically to the document `<head>` on the client, and to a generated `<head>` element during `renderToString` SSR.
6
- * - This means the same component tree works for both modes without any shell-aware logic.
4
+ * Output a `<html>` element wrapping `<head>` (via `<Head>`) and `<body id="root">`.
5
+ * - `<Head>` renders the literal `<head>` with `<base>` and other shell-level metadata; per-page hoistable elements (title, meta, links, stylesheets, scripts) come from `<PageHead>` inside `<Page>` and are hoisted into this `<head>` by React 19.
7
6
  */
8
- export function HTML({ children }) {
9
- const { language } = requireMeta();
10
- return (_jsx("html", { lang: language, children: _jsx("body", { id: "root", children: children }) }));
7
+ export function HTML({ children, ...meta }) {
8
+ const merged = requireMeta(meta);
9
+ const { language, 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", { id: "root", children: _jsx(MetaContext, { value: merged, children: children }) })] }));
11
11
  }
package/ui/page/HTML.tsx CHANGED
@@ -1,20 +1,28 @@
1
1
  import type { ReactElement, ReactNode } from "react";
2
- import { requireMeta } from "../misc/Meta.js";
2
+ import { MetaContext, requireMeta } from "../misc/MetaContext.js";
3
+ import type { PossibleMeta } from "../util/index.js";
3
4
 
4
- export interface HTMLProps {
5
+ export interface HTMLProps extends PossibleMeta {
5
6
  children: ReactNode;
6
7
  }
7
8
 
8
9
  /**
9
- * Output a `<html>` element wrapping `<body id="root">`.
10
- * - No `<head>` element is rendered. Head tags (`<title>`, `<meta>`, `<link>`, `<script>`) are emitted inline by `<Page>` / `<Head>` lower in the tree, and React 19 hoists them automatically to the document `<head>` on the client, and to a generated `<head>` element during `renderToString` SSR.
11
- * - This means the same component tree works for both modes without any shell-aware logic.
10
+ * Output a `<html>` element wrapping `<head>` (via `<Head>`) and `<body id="root">`.
11
+ * - `<Head>` renders the literal `<head>` with `<base>` and other shell-level metadata; per-page hoistable elements (title, meta, links, stylesheets, scripts) come from `<PageHead>` inside `<Page>` and are hoisted into this `<head>` by React 19.
12
12
  */
13
- export function HTML({ children }: HTMLProps): ReactElement {
14
- const { language } = requireMeta();
13
+ export function HTML({ children, ...meta }: HTMLProps): ReactElement {
14
+ const merged = requireMeta(meta);
15
+ const { language, base, app } = merged;
15
16
  return (
16
17
  <html lang={language}>
17
- <body id="root">{children}</body>
18
+ <head>
19
+ <meta charSet="utf-8" />
20
+ {base && <base href={base.href} />}
21
+ {app && <title>{app}</title>}
22
+ </head>
23
+ <body id="root">
24
+ <MetaContext value={merged}>{children}</MetaContext>
25
+ </body>
18
26
  </html>
19
27
  );
20
28
  }
package/ui/page/Head.d.ts CHANGED
@@ -1,3 +1,8 @@
1
1
  import { type ReactElement } from "react";
2
- /** Use the details from the current page data context to set the document `<title>`, meta tags, and history state. */
2
+ /**
3
+ * Per-page meta tags plus history navigation.
4
+ * - Emits hoistable head elements (title, meta, links, stylesheets, scripts) inline; React 19 hoists each one into the document `<head>`.
5
+ * - Does not render `<base>` (not hoistable — that lives in `<Head>` in the `<HTML>` shell component).
6
+ * - Updates `window.history` to match the page URL.
7
+ */
3
8
  export declare function Head(): ReactElement;
package/ui/page/Head.js CHANGED
@@ -1,23 +1,30 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect } from "react";
3
3
  import { isNullish, notNullish } from "../../util/null.js";
4
4
  import { getProps } from "../../util/object.js";
5
- import { requireMeta } from "../misc/Meta.js";
5
+ import { requireMeta } from "../misc/MetaContext.js";
6
6
  import { joinTitles } from "../util/meta.js";
7
7
  /** Meta tags with a capital first letter and hyphens, e.g. `Content-Security-Policy` or `Accept`, are `http-equiv=""` tags. */
8
8
  const R_HTTP_EQUIV = /^[A-Z][a-zA-Z0-9]*(-[A-Z][a-zA-Z0-9]*)*$/;
9
- /** Use the details from the current page data context to set the document `<title>`, meta tags, and history state. */
9
+ /**
10
+ * Per-page meta tags plus history navigation.
11
+ * - Emits hoistable head elements (title, meta, links, stylesheets, scripts) inline; React 19 hoists each one into the document `<head>`.
12
+ * - Does not render `<base>` (not hoistable — that lives in `<Head>` in the `<HTML>` shell component).
13
+ * - Updates `window.history` to match the page URL.
14
+ */
10
15
  export function Head() {
11
- const { url, title, base, app, links, tags, stylesheets, modules, scripts } = requireMeta();
16
+ const meta = requireMeta();
17
+ const { url, title, app, links, tags, stylesheets, modules, scripts } = meta;
12
18
  useEffect(() => {
13
19
  if (typeof window === "undefined")
14
20
  return;
15
21
  if (url)
16
22
  window.history.replaceState(null, "", url);
17
23
  }, [url]);
18
- return (_jsxs("head", { children: [_jsx("title", { children: joinTitles(title, app) }), base && _jsx("base", { href: base.href }), tags && getProps(tags).map(_renderTags), links && getProps(links).map(_renderLinks), stylesheets?.map(_renderStylesheets), modules?.map(_renderModules), scripts?.map(_renderScripts)] }));
24
+ const fullTitle = joinTitles(title, app);
25
+ return (_jsxs(_Fragment, { children: [fullTitle && _jsx("title", { children: fullTitle }), tags && getProps(tags).map(_renderTag), links && getProps(links).map(_renderLink), stylesheets?.map(_renderStylesheet), modules?.map(_renderModule), scripts?.map(_renderScript)] }));
19
26
  }
20
- function _renderTags([k, x]) {
27
+ function _renderTag([k, x]) {
21
28
  if (notNullish(x)) {
22
29
  const y = x === true ? "yes" : x === false ? "no" : x;
23
30
  if (k.startsWith("og:"))
@@ -28,19 +35,19 @@ function _renderTags([k, x]) {
28
35
  }
29
36
  return null;
30
37
  }
31
- function _renderLinks([k, v]) {
38
+ function _renderLink([k, v]) {
32
39
  if (notNullish(v)) {
33
40
  const type = k.endsWith("icon") ? "image/x-icon" : "text/css";
34
41
  return _jsx("link", { rel: k, href: v, type: type }, k);
35
42
  }
36
43
  return null;
37
44
  }
38
- function _renderStylesheets(v) {
39
- return isNullish(v) ? null : _jsx("link", { rel: "stylesheet", type: "text/css", href: v }, v);
45
+ function _renderStylesheet(v) {
46
+ return isNullish(v) ? null : _jsx("link", { rel: "stylesheet", type: "text/css", href: v, precedence: "default" }, v);
40
47
  }
41
- function _renderModules(v) {
42
- return isNullish(v) ? null : _jsx("script", { type: "module", src: v, defer: true }, v);
48
+ function _renderModule(v) {
49
+ return isNullish(v) ? null : _jsx("script", { type: "module", src: v, async: true }, v);
43
50
  }
44
- function _renderScripts(v) {
45
- return isNullish(v) ? null : _jsx("script", { type: "text/javascript", src: v, defer: true }, v);
51
+ function _renderScript(v) {
52
+ return isNullish(v) ? null : _jsx("script", { type: "text/javascript", src: v, async: true }, v);
46
53
  }