toiljs 0.0.8 → 0.0.9

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 (105) hide show
  1. package/build/backend/.tsbuildinfo +1 -1
  2. package/build/cli/.tsbuildinfo +1 -1
  3. package/build/cli/configure.js +5 -5
  4. package/build/cli/create.js +4 -4
  5. package/build/client/.tsbuildinfo +1 -1
  6. package/build/client/components/Slot.d.ts +6 -0
  7. package/build/client/components/Slot.js +6 -0
  8. package/build/client/dev/error-overlay.d.ts +20 -0
  9. package/build/client/dev/error-overlay.js +123 -0
  10. package/build/client/head/head.d.ts +2 -0
  11. package/build/client/head/head.js +17 -2
  12. package/build/client/head/metadata.d.ts +29 -0
  13. package/build/client/head/metadata.js +38 -0
  14. package/build/client/index.d.ts +5 -1
  15. package/build/client/index.js +3 -1
  16. package/build/client/navigation/navigation.d.ts +3 -0
  17. package/build/client/navigation/navigation.js +42 -1
  18. package/build/client/routing/Router.d.ts +1 -0
  19. package/build/client/routing/Router.js +55 -33
  20. package/build/client/routing/hooks.js +2 -6
  21. package/build/client/routing/loader.d.ts +2 -0
  22. package/build/client/routing/loader.js +9 -1
  23. package/build/client/routing/mount.d.ts +1 -1
  24. package/build/client/routing/mount.js +12 -4
  25. package/build/client/routing/slot-context.d.ts +2 -0
  26. package/build/client/routing/slot-context.js +2 -0
  27. package/build/client/types.d.ts +1 -0
  28. package/build/compiler/.tsbuildinfo +1 -1
  29. package/build/compiler/config.d.ts +8 -0
  30. package/build/compiler/config.js +4 -1
  31. package/build/compiler/docs.js +26 -26
  32. package/build/compiler/fonts.d.ts +4 -0
  33. package/build/compiler/fonts.js +64 -0
  34. package/build/compiler/generate.js +65 -32
  35. package/build/compiler/plugin.js +1 -1
  36. package/build/compiler/prerender.d.ts +7 -0
  37. package/build/compiler/prerender.js +111 -0
  38. package/build/compiler/routes.d.ts +3 -0
  39. package/build/compiler/routes.js +50 -5
  40. package/build/compiler/seo.d.ts +70 -0
  41. package/build/compiler/seo.js +221 -0
  42. package/build/compiler/vite.js +5 -1
  43. package/build/io/.tsbuildinfo +1 -1
  44. package/build/shared/.tsbuildinfo +1 -1
  45. package/examples/basic/client/404.tsx +1 -1
  46. package/examples/basic/client/global-error.tsx +1 -1
  47. package/examples/basic/client/routes/about.tsx +8 -0
  48. package/examples/basic/client/routes/get-started.tsx +1 -1
  49. package/examples/basic/client/routes/io.tsx +1 -1
  50. package/examples/basic/client/routes/loader-demo/index.tsx +7 -2
  51. package/package.json +1 -1
  52. package/presets/eslint.js +7 -4
  53. package/presets/tsconfig.json +1 -1
  54. package/src/backend/index.ts +1 -1
  55. package/src/cli/configure.ts +7 -7
  56. package/src/cli/create.ts +7 -7
  57. package/src/cli/features.ts +2 -2
  58. package/src/cli/index.ts +1 -1
  59. package/src/cli/ui.ts +1 -1
  60. package/src/cli/validate.ts +1 -1
  61. package/src/client/components/Form.tsx +2 -2
  62. package/src/client/components/Image.tsx +2 -2
  63. package/src/client/components/Script.tsx +3 -3
  64. package/src/client/components/Slot.tsx +21 -0
  65. package/src/client/dev/error-overlay.tsx +197 -0
  66. package/src/client/head/head.ts +28 -3
  67. package/src/client/head/metadata.ts +92 -0
  68. package/src/client/index.ts +5 -1
  69. package/src/client/navigation/Link.tsx +1 -1
  70. package/src/client/navigation/navigation.ts +74 -4
  71. package/src/client/navigation/prefetch.ts +2 -2
  72. package/src/client/routing/Router.tsx +121 -67
  73. package/src/client/routing/action.ts +4 -4
  74. package/src/client/routing/error-boundary.tsx +1 -1
  75. package/src/client/routing/hooks.ts +6 -25
  76. package/src/client/routing/loader.ts +20 -8
  77. package/src/client/routing/mount.tsx +25 -3
  78. package/src/client/routing/slot-context.ts +7 -0
  79. package/src/client/types.ts +6 -4
  80. package/src/compiler/config.ts +31 -3
  81. package/src/compiler/docs.ts +26 -26
  82. package/src/compiler/fonts.ts +87 -0
  83. package/src/compiler/generate.ts +66 -31
  84. package/src/compiler/image-report.ts +1 -1
  85. package/src/compiler/plugin.ts +2 -2
  86. package/src/compiler/prerender.ts +130 -0
  87. package/src/compiler/routes.ts +62 -7
  88. package/src/compiler/seo.ts +356 -0
  89. package/src/compiler/vite.ts +9 -4
  90. package/src/io/FastSet.ts +1 -1
  91. package/src/io/index.ts +1 -1
  92. package/src/io/types.ts +1 -1
  93. package/src/server/index.ts +1 -1
  94. package/src/server/main.ts +1 -1
  95. package/src/shared/index.ts +1 -1
  96. package/test/dom/error-overlay.test.tsx +44 -0
  97. package/test/dom/revalidate.test.tsx +38 -0
  98. package/test/dom/route-head.test.tsx +34 -0
  99. package/test/dom/slot.test.tsx +109 -0
  100. package/test/dom/view-transitions.test.tsx +51 -0
  101. package/test/fonts.test.ts +26 -0
  102. package/test/metadata.test.ts +41 -0
  103. package/test/prerender.test.ts +46 -0
  104. package/test/routes.test.ts +20 -1
  105. package/test/seo.test.ts +142 -0
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `toiljs configure` toggle a project's client styling features (CSS preprocessor + Tailwind) on
2
+ * `toiljs configure`, toggle a project's client styling features (CSS preprocessor + Tailwind) on
3
3
  * an existing app. Detects the current setup, prompts for the desired one, then rewrites the
4
4
  * stylesheet(s) + the `client/toil.tsx` imports, edits `package.json`, and syncs node_modules with
5
5
  * the project's package manager (so removed features are fully cleaned, not just disabled).
@@ -173,7 +173,7 @@ async function applyStyleFiles(
173
173
  const newPath = path.join(clientDir, styleEntry(to.preprocessor));
174
174
  await fs.mkdir(path.dirname(newPath), { recursive: true });
175
175
  // Rename whatever main stylesheet actually exists (preserving its content), not an assumed
176
- // name so we never blow away the user's styles when the on-disk extension differs.
176
+ // name, so we never blow away the user's styles when the on-disk extension differs.
177
177
  const existing = await findMainStylesheet(clientDir);
178
178
  if (existing && path.resolve(existing) !== path.resolve(newPath)) {
179
179
  await fs.rename(existing, newPath);
@@ -265,7 +265,7 @@ export async function runConfigure(opts: ConfigureOptions): Promise<void> {
265
265
  try {
266
266
  pkg = JSON.parse(await fs.readFile(pkgPath, 'utf8')) as PackageJson;
267
267
  } catch {
268
- cancel(`No package.json in ${pc.cyan(root)} run this inside a toiljs project.`);
268
+ cancel(`No package.json in ${pc.cyan(root)}, run this inside a toiljs project.`);
269
269
  process.exit(1);
270
270
  }
271
271
 
@@ -310,7 +310,7 @@ export async function runConfigure(opts: ConfigureOptions): Promise<void> {
310
310
  target.preprocessor !== current.preprocessor || target.tailwind !== current.tailwind;
311
311
  const imagesChanged = targetImages !== currentImages;
312
312
  if (!styleChanged && !imagesChanged) {
313
- outro('No changes your setup is already up to date.');
313
+ outro('No changes, your setup is already up to date.');
314
314
  return;
315
315
  }
316
316
 
@@ -320,7 +320,7 @@ export async function runConfigure(opts: ConfigureOptions): Promise<void> {
320
320
  let imagesWarning = '';
321
321
  if (imagesChanged && !(await writeImagesFlag(root, targetImages))) {
322
322
  imagesWarning = pc.yellow(
323
- ' Could not edit toil.config automatically set `client.images` by hand.',
323
+ ' Could not edit toil.config automatically, set `client.images` by hand.',
324
324
  );
325
325
  }
326
326
  s.stop('Updated project files');
@@ -336,7 +336,7 @@ export async function runConfigure(opts: ConfigureOptions): Promise<void> {
336
336
  await run(pm, ['install'], root);
337
337
  i.stop('Dependencies synced');
338
338
  } catch {
339
- i.stop(pc.yellow(`Could not run \`${pm} install\` run it yourself to finish`));
339
+ i.stop(pc.yellow(`Could not run \`${pm} install\`, run it yourself to finish`));
340
340
  }
341
341
  }
342
342
  }
@@ -349,5 +349,5 @@ export async function runConfigure(opts: ConfigureOptions): Promise<void> {
349
349
  .filter(Boolean)
350
350
  .join('\n');
351
351
  note(summary, 'Updated');
352
- outro(`Reconfigured restart \`${accent('toiljs dev')}\` to pick up the changes.`);
352
+ outro(`Reconfigured, restart \`${accent('toiljs dev')}\` to pick up the changes.`);
353
353
  }
package/src/cli/create.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `toiljs create` an interactive project scaffolder (Clack-powered) that wires a new
2
+ * `toiljs create`, an interactive project scaffolder (Clack-powered) that wires a new
3
3
  * app to the enforced toiljs presets (tsconfig / eslint / prettier) and file-based routing.
4
4
  * Supports a non-interactive path via flags (`--yes`, `--template`, …) for scripting/CI.
5
5
  */
@@ -161,7 +161,7 @@ function scaffold(
161
161
  JSON.stringify({ 'typescript.tsdk': 'node_modules/typescript/lib' }, null, 4) + '\n',
162
162
  'toil-env.d.ts': TOIL_ENV_DTS,
163
163
  // Stub typed-routes augmentation (RoutePath = string until the first dev/build regenerates it).
164
- 'toil-routes.d.ts': '// AUTO-GENERATED by toil do not edit.\nexport {};\n',
164
+ 'toil-routes.d.ts': '// AUTO-GENERATED by toil, do not edit.\nexport {};\n',
165
165
  'toilconfig.json':
166
166
  JSON.stringify(
167
167
  {
@@ -312,7 +312,7 @@ export default function Layout({ children }: { children?: ReactNode }) {
312
312
 
313
313
  /**
314
314
  * Absolute path to the `app` starter client UI. There is a single source: `examples/basic/client`
315
- * (shipped in the package) the runnable example IS the create template, so there's nothing to
315
+ * (shipped in the package), the runnable example IS the create template, so there's nothing to
316
316
  * keep in sync.
317
317
  */
318
318
  function appClientDir(): string {
@@ -331,7 +331,7 @@ function appClientDir(): string {
331
331
  * preprocessor's extension, adds the Tailwind entry, and rewrites `toil.tsx`'s style imports.
332
332
  */
333
333
  async function applyStyling(clientDir: string, features: StyleFeatures): Promise<void> {
334
- // Plain CSS without Tailwind is exactly what the template ships leave it byte-for-byte.
334
+ // Plain CSS without Tailwind is exactly what the template ships, leave it byte-for-byte.
335
335
  if (features.preprocessor === 'css' && !features.tailwind) return;
336
336
  const entry = styleEntry(features.preprocessor);
337
337
  if (entry !== 'styles/main.css') {
@@ -406,7 +406,7 @@ export async function runCreate(opts: CreateOptions): Promise<void> {
406
406
  let template: Template = opts.template ?? 'app';
407
407
  if (!opts.template && !opts.yes) {
408
408
  const templateOptions: TemplateOption[] = [
409
- { value: 'app', label: 'App', hint: 'the full ToilJS starter landing page, layout, styles, demo routes' },
409
+ { value: 'app', label: 'App', hint: 'the full ToilJS starter, landing page, layout, styles, demo routes' },
410
410
  { value: 'minimal', label: 'Minimal', hint: 'just a layout and a home route' },
411
411
  ];
412
412
  const choice = await select({ message: 'Which template?', options: templateOptions, initialValue: 'app' });
@@ -514,7 +514,7 @@ export async function runCreate(opts: CreateOptions): Promise<void> {
514
514
  await run(pm, ['install'], targetDir);
515
515
  i.stop('Installed dependencies');
516
516
  } catch {
517
- i.stop(pc.yellow(`Could not install with ${pm} run it yourself later`));
517
+ i.stop(pc.yellow(`Could not install with ${pm}, run it yourself later`));
518
518
  install = false;
519
519
  }
520
520
  }
@@ -526,5 +526,5 @@ export async function runCreate(opts: CreateOptions): Promise<void> {
526
526
  steps.push(`${accent('npm run build')} ${dim('build for production')}`);
527
527
  note(steps.map((l) => dim(' ') + l).join('\n'), 'Next steps');
528
528
 
529
- outro(`Created ${accent(path.basename(name))} happy building! ${dim('· v' + version())}`);
529
+ outro(`Created ${accent(path.basename(name))}, happy building! ${dim('· v' + version())}`);
530
530
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Pure description of toiljs's optional client styling features a CSS preprocessor and Tailwind
2
+ * Pure description of toiljs's optional client styling features, a CSS preprocessor and Tailwind ,
3
3
  * shared by `create` (scaffold) and `configure` (toggle on existing projects). Dependency-light
4
4
  * (no node IO) so it can be unit-tested; the file writes and package-manager calls live in the
5
5
  * commands. Preprocessor and Tailwind are independent: Tailwind lives in its own `.css` entry so
@@ -128,7 +128,7 @@ export function defaultConfigSource(images: boolean): string {
128
128
  }
129
129
 
130
130
  /**
131
- * Sets the `client.images` flag in a `toil.config` source, returning the updated source or `null`
131
+ * Sets the `client.images` flag in a `toil.config` source, returning the updated source, or `null`
132
132
  * if the file's shape isn't recognized (the caller should then fall back to a manual note). Handles
133
133
  * an existing `images:` value, an existing `client: {` block, or a bare `defineConfig({ … })`.
134
134
  */
package/src/cli/index.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * toiljs CLI. Routes `create` / `dev` / `build` and wraps them in the toiljs brand banner.
4
4
  * The compiler stays presentation-free (imported via the package's own `toiljs/compiler`
5
- * export); the epic bits banner, the Clack scaffolding wizard live here.
5
+ * export); the epic bits, banner, the Clack scaffolding wizard, live here.
6
6
  */
7
7
  import { build, dev, start } from 'toiljs/compiler';
8
8
 
package/src/cli/ui.ts CHANGED
@@ -54,7 +54,7 @@ export function success(s: string): string {
54
54
  return rgb(ACCENT, s);
55
55
  }
56
56
 
57
- /** Error accent (red kept outside the brand palette since errors should read as errors). */
57
+ /** Error accent (red, kept outside the brand palette since errors should read as errors). */
58
58
  export const danger = pc.red;
59
59
 
60
60
  function lerp(a: number, b: number, t: number): number {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Pure input validation for `toiljs create` kept dependency-light (only node:path) so it can be
2
+ * Pure input validation for `toiljs create`, kept dependency-light (only node:path) so it can be
3
3
  * unit-tested without pulling in the rest of the CLI.
4
4
  */
5
5
  import path from 'node:path';
@@ -16,7 +16,7 @@ export interface FormProps {
16
16
  resetOnSuccess?: boolean;
17
17
  className?: string;
18
18
  /**
19
- * Form contents. Pass a render function to receive live submit state e.g. to disable the
19
+ * Form contents. Pass a render function to receive live submit state, e.g. to disable the
20
20
  * button while pending: `{({ pending }) => <button disabled={pending}>Save</button>}`.
21
21
  */
22
22
  children?: ReactNode | ((state: ActionState<void>) => ReactNode);
@@ -24,7 +24,7 @@ export interface FormProps {
24
24
 
25
25
  /**
26
26
  * A `<form>` that runs an {@link useAction} on submit (no page reload) and revalidates loader data
27
- * on success the write half of the loader/action data loop. Tracks pending/error state, which a
27
+ * on success, the write half of the loader/action data loop. Tracks pending/error state, which a
28
28
  * render-function child can read.
29
29
  */
30
30
  export function Form({
@@ -2,7 +2,7 @@ import { useState, type CSSProperties, type ComponentPropsWithRef, type ReactNod
2
2
 
3
3
  /**
4
4
  * Props for {@link Image}: every standard `<img>` attribute, plus toil's layout/loading controls.
5
- * `src` and `alt` are required (`alt` is enforced for accessibility pass `alt=""` for decorative
5
+ * `src` and `alt` are required (`alt` is enforced for accessibility, pass `alt=""` for decorative
6
6
  * images). `width`/`height` (or `fill`) reserve space to prevent layout shift.
7
7
  */
8
8
  export interface ImageProps
@@ -35,7 +35,7 @@ export interface ImageProps
35
35
  /**
36
36
  * A drop-in `<img>` replacement that prevents layout shift and lazy-loads by default. It reserves
37
37
  * space from `width`/`height` (or fills its container with `fill`), decodes async, lazy-loads unless
38
- * `priority`, and can fade in from a `blur` placeholder. This is a client-only component there is
38
+ * `priority`, and can fade in from a `blur` placeholder. This is a client-only component, there is
39
39
  * no server-side resizing; pass an already-optimized `src` (Vite hashes imported assets for you).
40
40
  */
41
41
  export function Image(props: ImageProps): ReactNode {
@@ -2,9 +2,9 @@ import { useEffect, type ReactNode } from 'react';
2
2
 
3
3
  /**
4
4
  * When a {@link Script} is injected, relative to the app becoming interactive:
5
- * - `afterInteractive` (default) on mount, once the app is running. Good for analytics, widgets.
6
- * - `lazyOnload` deferred until the browser is idle (after `window.load`). For low-priority scripts.
7
- * - `beforeInteractive` as early as possible. In a client-only SPA there is no SSR, so this still
5
+ * - `afterInteractive` (default), on mount, once the app is running. Good for analytics, widgets.
6
+ * - `lazyOnload`, deferred until the browser is idle (after `window.load`). For low-priority scripts.
7
+ * - `beforeInteractive`, as early as possible. In a client-only SPA there is no SSR, so this still
8
8
  * runs after hydration, but synchronously on first mount with high fetch priority.
9
9
  */
10
10
  export type ScriptStrategy = 'beforeInteractive' | 'afterInteractive' | 'lazyOnload';
@@ -0,0 +1,21 @@
1
+ import { useContext, type ReactNode } from 'react';
2
+
3
+ import { SlotContext } from '../routing/slot-context.js';
4
+
5
+ /** Props for {@link Slot}. */
6
+ export interface SlotProps {
7
+ /** The parallel-slot name, the `@name` directory under `routes/` (without the `@`). */
8
+ name: string;
9
+ /** Rendered when the slot has no match for the current URL. Default `null`. */
10
+ fallback?: ReactNode;
11
+ }
12
+
13
+ /**
14
+ * Renders the parallel-route slot named `name` for the current URL. Place it in a layout or page to
15
+ * show an `@name` route tree alongside the main content (e.g. a persistent sidebar, or a modal that
16
+ * an intercepting route fills). Renders `fallback` (default nothing) when no slot route matches.
17
+ */
18
+ export function Slot({ name, fallback = null }: SlotProps): ReactNode {
19
+ const slots = useContext(SlotContext);
20
+ return slots[name] ?? fallback;
21
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Development-only error overlay. In dev, surfaces errors that would otherwise leave a blank page or
3
+ * live only in the console: uncaught render errors (incl. those thrown by a loader during render),
4
+ * plus `window` `error` / `unhandledrejection` events. Shows the message, stack, and (for render
5
+ * errors) the React component stack, with Dismiss / Reload. Inert in production builds.
6
+ */
7
+ import {
8
+ Component,
9
+ useSyncExternalStore,
10
+ type CSSProperties,
11
+ type ErrorInfo,
12
+ type ReactNode,
13
+ } from 'react';
14
+
15
+ /** A captured dev error. */
16
+ interface DevError {
17
+ readonly error: Error;
18
+ readonly componentStack?: string;
19
+ /** Where it came from, a render boundary, a window `error`, or an unhandled rejection. */
20
+ readonly source: 'render' | 'window' | 'unhandledrejection';
21
+ }
22
+
23
+ let current: DevError | null = null;
24
+ const listeners = new Set<() => void>();
25
+
26
+ function emit(): void {
27
+ for (const listener of listeners) listener();
28
+ }
29
+ function setDevError(next: DevError | null): void {
30
+ current = next;
31
+ emit();
32
+ }
33
+ function subscribe(listener: () => void): () => void {
34
+ listeners.add(listener);
35
+ return () => {
36
+ listeners.delete(listener);
37
+ };
38
+ }
39
+
40
+ /** True when running under Vite's dev server (replaced at build time; falsy in production). */
41
+ export function isDevMode(): boolean {
42
+ try {
43
+ return Boolean((import.meta as { env?: { DEV?: boolean } }).env?.DEV);
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ let windowBound = false;
50
+ /** Wires `window` error / unhandledrejection into the overlay (idempotent; dev only). */
51
+ export function initDevErrorOverlay(): void {
52
+ if (windowBound || typeof window === 'undefined') return;
53
+ windowBound = true;
54
+ window.addEventListener('error', (event) => {
55
+ if (event.error instanceof Error) setDevError({ error: event.error, source: 'window' });
56
+ });
57
+ window.addEventListener('unhandledrejection', (event) => {
58
+ const reason: unknown = event.reason;
59
+ const error = reason instanceof Error ? reason : new Error(String(reason));
60
+ setDevError({ error, source: 'unhandledrejection' });
61
+ });
62
+ }
63
+
64
+ interface BoundaryProps {
65
+ readonly children: ReactNode;
66
+ }
67
+ interface BoundaryState {
68
+ readonly crashed: boolean;
69
+ }
70
+
71
+ /**
72
+ * Catches render errors in its subtree and reports them to the overlay. While crashed it renders
73
+ * nothing (the subtree threw); it recovers when the overlay is dismissed. Class component because
74
+ * React error boundaries have no hook equivalent.
75
+ */
76
+ export class DevErrorBoundary extends Component<BoundaryProps, BoundaryState> {
77
+ public state: BoundaryState = { crashed: false };
78
+ private unsubscribe: (() => void) | undefined;
79
+
80
+ public static getDerivedStateFromError(): BoundaryState {
81
+ return { crashed: true };
82
+ }
83
+
84
+ public override componentDidCatch(error: Error, info: ErrorInfo): void {
85
+ setDevError({ error, componentStack: info.componentStack ?? undefined, source: 'render' });
86
+ }
87
+
88
+ public override componentDidMount(): void {
89
+ // Recover (re-render children) once the error is dismissed from the overlay.
90
+ this.unsubscribe = subscribe(() => {
91
+ if (current === null && this.state.crashed) this.setState({ crashed: false });
92
+ });
93
+ }
94
+
95
+ public override componentWillUnmount(): void {
96
+ this.unsubscribe?.();
97
+ }
98
+
99
+ public override render(): ReactNode {
100
+ return this.state.crashed ? null : this.props.children;
101
+ }
102
+ }
103
+
104
+ const overlayStyle: CSSProperties = {
105
+ position: 'fixed',
106
+ inset: 0,
107
+ zIndex: 2147483647,
108
+ background: 'rgba(8, 8, 12, 0.88)',
109
+ color: '#f5f6fa',
110
+ font: '13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace',
111
+ padding: '2rem',
112
+ overflow: 'auto',
113
+ display: 'flex',
114
+ justifyContent: 'center',
115
+ alignItems: 'flex-start',
116
+ };
117
+ const panelStyle: CSSProperties = {
118
+ maxWidth: 900,
119
+ width: '100%',
120
+ background: '#15151c',
121
+ border: '1px solid #ef4444',
122
+ borderRadius: 10,
123
+ padding: '1.25rem 1.5rem',
124
+ boxShadow: '0 12px 48px rgba(0,0,0,0.5)',
125
+ };
126
+ const titleStyle: CSSProperties = {
127
+ margin: 0,
128
+ color: '#ff6b6b',
129
+ fontSize: '1rem',
130
+ fontWeight: 700,
131
+ wordBreak: 'break-word',
132
+ };
133
+ const preStyle: CSSProperties = {
134
+ whiteSpace: 'pre-wrap',
135
+ wordBreak: 'break-word',
136
+ margin: '0.75rem 0 0',
137
+ color: '#c8cee0',
138
+ };
139
+ const buttonStyle: CSSProperties = {
140
+ font: 'inherit',
141
+ color: '#f5f6fa',
142
+ background: '#2a2a36',
143
+ border: '1px solid #3a3a48',
144
+ borderRadius: 6,
145
+ padding: '0.4em 1em',
146
+ cursor: 'pointer',
147
+ marginRight: '0.5rem',
148
+ };
149
+
150
+ const SOURCE_LABEL: Record<DevError['source'], string> = {
151
+ render: 'Render error',
152
+ window: 'Uncaught error',
153
+ unhandledrejection: 'Unhandled promise rejection',
154
+ };
155
+
156
+ /** Renders the overlay when a dev error is captured. Mount once at the app root (dev only). */
157
+ export function DevErrorOverlay(): ReactNode {
158
+ const devError = useSyncExternalStore(
159
+ subscribe,
160
+ () => current,
161
+ () => null,
162
+ );
163
+ if (!devError) return null;
164
+ return (
165
+ <div
166
+ style={overlayStyle}
167
+ role="alert">
168
+ <div style={panelStyle}>
169
+ <p style={titleStyle}>
170
+ {SOURCE_LABEL[devError.source]}, {devError.error.name}: {devError.error.message}
171
+ </p>
172
+ {devError.error.stack !== undefined && <pre style={preStyle}>{devError.error.stack}</pre>}
173
+ {devError.componentStack !== undefined && (
174
+ <pre style={{ ...preStyle, color: '#8b9ab4' }}>{devError.componentStack}</pre>
175
+ )}
176
+ <div style={{ marginTop: '1.25rem' }}>
177
+ <button
178
+ type="button"
179
+ style={buttonStyle}
180
+ onClick={() => {
181
+ setDevError(null);
182
+ }}>
183
+ Dismiss
184
+ </button>
185
+ <button
186
+ type="button"
187
+ style={buttonStyle}
188
+ onClick={() => {
189
+ window.location.reload();
190
+ }}>
191
+ Reload
192
+ </button>
193
+ </div>
194
+ </div>
195
+ </div>
196
+ );
197
+ }
@@ -4,7 +4,7 @@
4
4
  * (later/deeper entries win per key) and are reverted when the component unmounts. Pure
5
5
  * `mergeHead` resolves the active entries; the manager reconciles `document.head`.
6
6
  */
7
- import { useEffect } from 'react';
7
+ import { useEffect, useLayoutEffect } from 'react';
8
8
 
9
9
  /** A `<meta>` tag. Use `name` or `property` (OpenGraph) as the dedup key; extra attrs pass through. */
10
10
  export interface MetaTag {
@@ -70,6 +70,9 @@ const entries = new Map<number, HeadSpec>();
70
70
  let order: number[] = [];
71
71
  let seq = 0;
72
72
  let baseTitle: string | null = null;
73
+ // The current route's resolved metadata, the lowest-priority spec, so component `useHead`/`<Head>`
74
+ // always compose on top of it. Set by the router via `setRouteHead` on each navigation.
75
+ let routeHead: HeadSpec | null = null;
73
76
 
74
77
  function setAttrs(el: Element, attrs: Record<string, string | undefined>): void {
75
78
  el.setAttribute('data-toil-head', '');
@@ -83,7 +86,8 @@ function apply(): void {
83
86
  if (typeof document === 'undefined') return;
84
87
  if (baseTitle === null) baseTitle = document.title;
85
88
 
86
- const resolved = mergeHead(order.map((id) => entries.get(id)).filter((s): s is HeadSpec => !!s));
89
+ const specs = [routeHead, ...order.map((id) => entries.get(id))];
90
+ const resolved = mergeHead(specs.filter((s): s is HeadSpec => !!s));
87
91
 
88
92
  document.title = resolved.title ?? baseTitle;
89
93
 
@@ -116,7 +120,7 @@ function removeHead(id: number): void {
116
120
 
117
121
  /**
118
122
  * Applies a head contribution for the lifetime of the calling component: title, `<meta>`, `<link>`.
119
- * Reverts on unmount. Compose freely a root layout can set defaults a page overrides.
123
+ * Reverts on unmount. Compose freely, a root layout can set defaults a page overrides.
120
124
  */
121
125
  export function useHead(spec: HeadSpec): void {
122
126
  const json = JSON.stringify(spec);
@@ -138,3 +142,24 @@ export function Head(props: HeadSpec): null {
138
142
  useHead(props);
139
143
  return null;
140
144
  }
145
+
146
+ /** Sets the current route's baseline head (lowest priority). Pass `null` to clear it. */
147
+ export function setRouteHead(spec: HeadSpec | null): void {
148
+ routeHead = spec;
149
+ apply();
150
+ }
151
+
152
+ /**
153
+ * Applies a route's resolved `metadata` as the baseline head for the calling route's lifetime, and
154
+ * clears it on unmount. Used internally by the router; a layout-effect so the title updates before
155
+ * paint (no flicker).
156
+ */
157
+ export function useRouteHead(spec: HeadSpec | undefined): void {
158
+ const json = spec ? JSON.stringify(spec) : '';
159
+ useLayoutEffect(() => {
160
+ setRouteHead(json ? (JSON.parse(json) as HeadSpec) : null);
161
+ return () => {
162
+ setRouteHead(null);
163
+ };
164
+ }, [json]);
165
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Route metadata, the declarative SEO counterpart to `useHead`/`<Head>`. A route file may
3
+ * `export const metadata` (static) or `export const generateMetadata` (dynamic, using its loader
4
+ * data); the compiler-driven loader resolves it to a {@link HeadSpec} that the router applies as the
5
+ * route's baseline head (component-level `useHead`/`<Head>` still compose on top and can override).
6
+ */
7
+ import type { HeadSpec, LinkTag, MetaTag } from './head.js';
8
+ import type { RouteParams } from '../routing/match.js';
9
+
10
+ /** OpenGraph fields, expanded to `og:*` meta tags. */
11
+ export interface OpenGraph {
12
+ readonly title?: string;
13
+ readonly description?: string;
14
+ readonly type?: string;
15
+ readonly url?: string;
16
+ readonly image?: string;
17
+ readonly siteName?: string;
18
+ }
19
+
20
+ /** A route's metadata. Convenience fields expand to the right `<meta>`/`<link>` tags. */
21
+ export interface Metadata {
22
+ /** Document title. */
23
+ readonly title?: string;
24
+ /** Template applied to the title (`%s` = the title), e.g. `'%s · toiljs'`. */
25
+ readonly titleTemplate?: string;
26
+ /** `<meta name="description">`. */
27
+ readonly description?: string;
28
+ /** `<meta name="keywords">`, joined with `, ` if an array. */
29
+ readonly keywords?: string | readonly string[];
30
+ /** `<link rel="canonical">`. */
31
+ readonly canonical?: string;
32
+ /** `<meta name="robots">`, e.g. `'noindex, nofollow'`. */
33
+ readonly robots?: string;
34
+ /** `<meta name="theme-color">`. */
35
+ readonly themeColor?: string;
36
+ /** OpenGraph (`og:*`) tags. */
37
+ readonly openGraph?: OpenGraph;
38
+ /** Escape hatch: extra raw `<meta>` tags. */
39
+ readonly meta?: readonly MetaTag[];
40
+ /** Escape hatch: extra raw `<link>` tags. */
41
+ readonly link?: readonly LinkTag[];
42
+ }
43
+
44
+ /** Arguments passed to {@link GenerateMetadata}: route params, query, and the loader's data. */
45
+ export interface GenerateMetadataArgs<T = unknown> {
46
+ readonly params: RouteParams;
47
+ readonly searchParams: URLSearchParams;
48
+ readonly data: T;
49
+ }
50
+
51
+ /** A route's `export const generateMetadata`, dynamic metadata derived from params/query/loader data. */
52
+ export type GenerateMetadata<T = unknown> = (
53
+ args: GenerateMetadataArgs<T>,
54
+ ) => Metadata | Promise<Metadata>;
55
+
56
+ /** Expands a {@link Metadata} into a {@link HeadSpec} (title + concrete meta/link tags). */
57
+ export function resolveMetadata(metadata: Metadata): HeadSpec {
58
+ const meta: MetaTag[] = [];
59
+ if (metadata.description !== undefined) {
60
+ meta.push({ name: 'description', content: metadata.description });
61
+ }
62
+ if (metadata.keywords !== undefined) {
63
+ const content =
64
+ typeof metadata.keywords === 'string' ? metadata.keywords : metadata.keywords.join(', ');
65
+ meta.push({ name: 'keywords', content });
66
+ }
67
+ if (metadata.robots !== undefined) meta.push({ name: 'robots', content: metadata.robots });
68
+ if (metadata.themeColor !== undefined) {
69
+ meta.push({ name: 'theme-color', content: metadata.themeColor });
70
+ }
71
+ const og = metadata.openGraph;
72
+ if (og) {
73
+ const pairs: readonly [string, string | undefined][] = [
74
+ ['og:title', og.title],
75
+ ['og:description', og.description],
76
+ ['og:type', og.type],
77
+ ['og:url', og.url],
78
+ ['og:image', og.image],
79
+ ['og:site_name', og.siteName],
80
+ ];
81
+ for (const [property, content] of pairs) {
82
+ if (content !== undefined) meta.push({ property, content });
83
+ }
84
+ }
85
+ if (metadata.meta) meta.push(...metadata.meta);
86
+
87
+ const link: LinkTag[] = [];
88
+ if (metadata.canonical !== undefined) link.push({ rel: 'canonical', href: metadata.canonical });
89
+ if (metadata.link) link.push(...metadata.link);
90
+
91
+ return { title: metadata.title, titleTemplate: metadata.titleTemplate, meta, link };
92
+ }
@@ -14,7 +14,7 @@ export { Link } from './navigation/Link.js';
14
14
  export type { LinkProps } from './navigation/Link.js';
15
15
  export { NavLink, matchActive } from './navigation/NavLink.js';
16
16
  export type { NavLinkProps, NavLinkState } from './navigation/NavLink.js';
17
- export { navigate, back, forward, refresh } from './navigation/navigation.js';
17
+ export { navigate, back, forward, refresh, setViewTransitions } from './navigation/navigation.js';
18
18
  export type { NavigateOptions } from './navigation/navigation.js';
19
19
  export {
20
20
  useParams,
@@ -52,9 +52,13 @@ export { connectChannel, useChannel, resolveChannelUrl } from './channel/channel
52
52
  export type { Channel, ChannelOptions, ChannelHook, ChannelData } from './channel/channel.js';
53
53
  export { useHead, useTitle, Head, mergeHead } from './head/head.js';
54
54
  export type { HeadSpec, MetaTag, LinkTag, ResolvedHead } from './head/head.js';
55
+ export { resolveMetadata } from './head/metadata.js';
56
+ export type { Metadata, GenerateMetadata, GenerateMetadataArgs, OpenGraph } from './head/metadata.js';
55
57
  export { Image } from './components/Image.js';
56
58
  export type { ImageProps } from './components/Image.js';
57
59
  export { Script } from './components/Script.js';
58
60
  export type { ScriptProps, ScriptStrategy } from './components/Script.js';
59
61
  export { Form } from './components/Form.js';
60
62
  export type { FormProps } from './components/Form.js';
63
+ export { Slot } from './components/Slot.js';
64
+ export type { SlotProps } from './components/Slot.js';
@@ -37,7 +37,7 @@ function isExternalHref(href: string): boolean {
37
37
 
38
38
  /**
39
39
  * Client-side navigation link. Forwards all anchor attributes to the underlying `<a>`, and
40
- * prefetches the target route's chunk on hover/focus. Intercepts only plain same-origin clicks
40
+ * prefetches the target route's chunk on hover/focus. Intercepts only plain same-origin clicks ,
41
41
  * modified clicks, `target=_blank`, `download`, in-page `#hash`, and external URLs fall through to
42
42
  * native browser behavior.
43
43
  */