toiljs 0.0.7 → 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 (135) hide show
  1. package/build/backend/.tsbuildinfo +1 -1
  2. package/build/cli/.tsbuildinfo +1 -1
  3. package/build/cli/configure.d.ts +1 -0
  4. package/build/cli/configure.js +85 -20
  5. package/build/cli/create.d.ts +1 -0
  6. package/build/cli/create.js +18 -7
  7. package/build/cli/features.d.ts +2 -0
  8. package/build/cli/features.js +22 -0
  9. package/build/cli/index.js +8 -0
  10. package/build/client/.tsbuildinfo +1 -1
  11. package/build/client/components/Form.d.ts +12 -0
  12. package/build/client/components/Form.js +23 -0
  13. package/build/client/components/Image.d.ts +13 -0
  14. package/build/client/components/Image.js +22 -0
  15. package/build/client/components/Script.d.ts +13 -0
  16. package/build/client/components/Script.js +68 -0
  17. package/build/client/components/Slot.d.ts +6 -0
  18. package/build/client/components/Slot.js +6 -0
  19. package/build/client/dev/error-overlay.d.ts +20 -0
  20. package/build/client/dev/error-overlay.js +123 -0
  21. package/build/client/head/head.d.ts +2 -0
  22. package/build/client/head/head.js +17 -2
  23. package/build/client/head/metadata.d.ts +29 -0
  24. package/build/client/head/metadata.js +38 -0
  25. package/build/client/index.d.ts +15 -3
  26. package/build/client/index.js +8 -2
  27. package/build/client/navigation/navigation.d.ts +3 -0
  28. package/build/client/navigation/navigation.js +42 -1
  29. package/build/client/routing/Router.d.ts +1 -0
  30. package/build/client/routing/Router.js +56 -34
  31. package/build/client/routing/action.d.ts +17 -0
  32. package/build/client/routing/action.js +55 -0
  33. package/build/client/routing/hooks.d.ts +1 -0
  34. package/build/client/routing/hooks.js +6 -7
  35. package/build/client/routing/loader.d.ts +10 -2
  36. package/build/client/routing/loader.js +83 -24
  37. package/build/client/routing/mount.d.ts +1 -1
  38. package/build/client/routing/mount.js +12 -4
  39. package/build/client/routing/slot-context.d.ts +2 -0
  40. package/build/client/routing/slot-context.js +2 -0
  41. package/build/client/types.d.ts +1 -0
  42. package/build/compiler/.tsbuildinfo +1 -1
  43. package/build/compiler/config.d.ts +10 -0
  44. package/build/compiler/config.js +5 -1
  45. package/build/compiler/docs.js +26 -26
  46. package/build/compiler/fonts.d.ts +4 -0
  47. package/build/compiler/fonts.js +64 -0
  48. package/build/compiler/generate.js +67 -32
  49. package/build/compiler/image-report.d.ts +2 -0
  50. package/build/compiler/image-report.js +62 -0
  51. package/build/compiler/plugin.js +1 -1
  52. package/build/compiler/prerender.d.ts +7 -0
  53. package/build/compiler/prerender.js +111 -0
  54. package/build/compiler/routes.d.ts +3 -0
  55. package/build/compiler/routes.js +50 -5
  56. package/build/compiler/seo.d.ts +70 -0
  57. package/build/compiler/seo.js +221 -0
  58. package/build/compiler/vite.js +13 -1
  59. package/build/io/.tsbuildinfo +1 -1
  60. package/build/shared/.tsbuildinfo +1 -1
  61. package/examples/basic/client/404.tsx +1 -1
  62. package/examples/basic/client/components/Header.tsx +38 -0
  63. package/examples/basic/client/components/HoneycombBackground.tsx +86 -18
  64. package/examples/basic/client/global-error.tsx +3 -3
  65. package/examples/basic/client/layout.tsx +2 -33
  66. package/examples/basic/client/public/images/test_image.webp +0 -0
  67. package/examples/basic/client/routes/about.tsx +8 -0
  68. package/examples/basic/client/routes/get-started.tsx +1 -1
  69. package/examples/basic/client/routes/index.tsx +8 -1
  70. package/examples/basic/client/routes/io.tsx +1 -1
  71. package/examples/basic/client/routes/loader-demo/index.tsx +29 -1
  72. package/examples/basic/client/routes/test.tsx +8 -0
  73. package/examples/basic/client/styles/main.css +48 -1
  74. package/package.json +8 -6
  75. package/presets/eslint.js +7 -4
  76. package/presets/tsconfig.json +1 -1
  77. package/src/backend/index.ts +1 -1
  78. package/src/cli/configure.ts +102 -21
  79. package/src/cli/create.ts +25 -9
  80. package/src/cli/features.ts +33 -1
  81. package/src/cli/index.ts +10 -1
  82. package/src/cli/ui.ts +1 -1
  83. package/src/cli/validate.ts +1 -1
  84. package/src/client/components/Form.tsx +65 -0
  85. package/src/client/components/Image.tsx +89 -0
  86. package/src/client/components/Script.tsx +113 -0
  87. package/src/client/components/Slot.tsx +21 -0
  88. package/src/client/dev/error-overlay.tsx +197 -0
  89. package/src/client/head/head.ts +28 -3
  90. package/src/client/head/metadata.ts +92 -0
  91. package/src/client/index.ts +20 -3
  92. package/src/client/navigation/Link.tsx +1 -1
  93. package/src/client/navigation/navigation.ts +74 -4
  94. package/src/client/navigation/prefetch.ts +2 -2
  95. package/src/client/routing/Router.tsx +128 -62
  96. package/src/client/routing/action.ts +122 -0
  97. package/src/client/routing/error-boundary.tsx +1 -1
  98. package/src/client/routing/hooks.ts +17 -23
  99. package/src/client/routing/loader.ts +158 -35
  100. package/src/client/routing/mount.tsx +25 -3
  101. package/src/client/routing/slot-context.ts +7 -0
  102. package/src/client/types.ts +6 -4
  103. package/src/compiler/config.ts +40 -3
  104. package/src/compiler/docs.ts +26 -26
  105. package/src/compiler/fonts.ts +87 -0
  106. package/src/compiler/generate.ts +69 -31
  107. package/src/compiler/image-report.ts +85 -0
  108. package/src/compiler/plugin.ts +2 -2
  109. package/src/compiler/prerender.ts +130 -0
  110. package/src/compiler/routes.ts +62 -7
  111. package/src/compiler/seo.ts +356 -0
  112. package/src/compiler/vite.ts +21 -4
  113. package/src/io/FastSet.ts +1 -1
  114. package/src/io/index.ts +1 -1
  115. package/src/io/types.ts +1 -1
  116. package/src/server/index.ts +1 -1
  117. package/src/server/main.ts +1 -1
  118. package/src/shared/index.ts +1 -1
  119. package/test/dom/Image.test.tsx +46 -0
  120. package/test/dom/Script.test.tsx +45 -0
  121. package/test/dom/action.test.tsx +129 -0
  122. package/test/dom/error-overlay.test.tsx +44 -0
  123. package/test/dom/loader.test.tsx +121 -0
  124. package/test/dom/revalidate.test.tsx +38 -0
  125. package/test/dom/route-head.test.tsx +34 -0
  126. package/test/dom/router-loading.test.tsx +44 -0
  127. package/test/dom/slot.test.tsx +109 -0
  128. package/test/dom/view-transitions.test.tsx +51 -0
  129. package/test/features.test.ts +31 -0
  130. package/test/fonts.test.ts +26 -0
  131. package/test/metadata.test.ts +41 -0
  132. package/test/prerender.test.ts +46 -0
  133. package/test/routes.test.ts +20 -1
  134. package/test/seo.test.ts +142 -0
  135. package/examples/basic/client/template.tsx +0 -7
@@ -0,0 +1,113 @@
1
+ import { useEffect, type ReactNode } from 'react';
2
+
3
+ /**
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
8
+ * runs after hydration, but synchronously on first mount with high fetch priority.
9
+ */
10
+ export type ScriptStrategy = 'beforeInteractive' | 'afterInteractive' | 'lazyOnload';
11
+
12
+ /** Props for {@link Script}. Provide either `src` (external) or inline `children` (script body). */
13
+ export interface ScriptProps {
14
+ /** URL of an external script. Omit when providing an inline script body via `children`. */
15
+ src?: string;
16
+ /** When to load the script. Default `'afterInteractive'`. */
17
+ strategy?: ScriptStrategy;
18
+ /** Stable identity for dedup (required for inline scripts; defaults to `src` for external ones). */
19
+ id?: string;
20
+ /** `type` attribute (e.g. `'module'`, `'application/json'`). */
21
+ type?: string;
22
+ /** Fired once the script has loaded (external) or been inserted (inline). */
23
+ onLoad?: () => void;
24
+ /** Fired after load, and on every later mount once the script is already loaded. */
25
+ onReady?: () => void;
26
+ /** Fired if an external script fails to load. */
27
+ onError?: (error: unknown) => void;
28
+ /** Inline script body. Mutually exclusive with `src`. */
29
+ children?: string;
30
+ }
31
+
32
+ type LoadState = 'loading' | 'ready';
33
+ /** Module-level registry so a given script is injected/executed at most once across the app. */
34
+ const registry = new Map<string, LoadState>();
35
+
36
+ function inject(props: ScriptProps, key: string): void {
37
+ const { src, type, onLoad, onReady, onError, children } = props;
38
+ const el = document.createElement('script');
39
+ el.dataset.toilScript = key;
40
+ if (type !== undefined) el.type = type;
41
+
42
+ if (src !== undefined) {
43
+ el.src = src;
44
+ el.async = true;
45
+ el.addEventListener('load', () => {
46
+ registry.set(key, 'ready');
47
+ onLoad?.();
48
+ onReady?.();
49
+ });
50
+ el.addEventListener('error', (event) => {
51
+ registry.delete(key); // allow a later remount to retry
52
+ onError?.(event);
53
+ });
54
+ document.head.appendChild(el);
55
+ } else {
56
+ el.textContent = children ?? '';
57
+ document.head.appendChild(el);
58
+ registry.set(key, 'ready');
59
+ onLoad?.();
60
+ onReady?.();
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Loads an external or inline `<script>` with a load `strategy`, deduplicated across the app so the
66
+ * same script never executes twice. Renders nothing. Mirrors the ergonomics of Next.js `next/script`
67
+ * for a client-only SPA.
68
+ */
69
+ export function Script(props: ScriptProps): ReactNode {
70
+ const { src, id, strategy = 'afterInteractive', onReady } = props;
71
+ const key = id ?? src;
72
+
73
+ useEffect(() => {
74
+ if (key === undefined) {
75
+ // No id and no src: nothing to dedup or load (an inline script needs at least an id).
76
+ return;
77
+ }
78
+
79
+ const state = registry.get(key);
80
+ if (state === 'ready') {
81
+ onReady?.();
82
+ return;
83
+ }
84
+ if (state === 'loading') {
85
+ return; // another instance is already injecting it
86
+ }
87
+
88
+ registry.set(key, 'loading');
89
+ const run = (): void => {
90
+ inject(props, key);
91
+ };
92
+
93
+ if (strategy === 'lazyOnload') {
94
+ if (document.readyState === 'complete') {
95
+ const idle = window.requestIdleCallback?.bind(window);
96
+ if (idle) idle(run);
97
+ else setTimeout(run, 0);
98
+ } else {
99
+ window.addEventListener('load', run, { once: true });
100
+ }
101
+ return () => {
102
+ window.removeEventListener('load', run);
103
+ };
104
+ }
105
+
106
+ // beforeInteractive + afterInteractive: inject now (on mount).
107
+ run();
108
+ // Intentionally keyed on identity only: inject once per script key; later prop changes
109
+ // (handlers, body) are read at inject time and must not re-run/re-inject the script.
110
+ }, [key, strategy]);
111
+
112
+ return null;
113
+ }
@@ -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,
@@ -26,8 +26,15 @@ export {
26
26
  useNavigationPending,
27
27
  } from './routing/hooks.js';
28
28
  export type { RouterInstance } from './routing/hooks.js';
29
- export { useLoaderData } from './routing/loader.js';
30
- export type { LoaderArgs, LoaderFunction } from './routing/loader.js';
29
+ export { useLoaderData, revalidate, invalidateLoaderData } from './routing/loader.js';
30
+ export type { LoaderArgs, LoaderFunction, LoaderData, Revalidate } from './routing/loader.js';
31
+ export { useAction } from './routing/action.js';
32
+ export type {
33
+ UseActionOptions,
34
+ ActionState,
35
+ ActionHandle,
36
+ RevalidateTarget,
37
+ } from './routing/action.js';
31
38
  export { prefetch } from './navigation/prefetch.js';
32
39
  export type {
33
40
  RouteDef,
@@ -45,3 +52,13 @@ export { connectChannel, useChannel, resolveChannelUrl } from './channel/channel
45
52
  export type { Channel, ChannelOptions, ChannelHook, ChannelData } from './channel/channel.js';
46
53
  export { useHead, useTitle, Head, mergeHead } from './head/head.js';
47
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';
57
+ export { Image } from './components/Image.js';
58
+ export type { ImageProps } from './components/Image.js';
59
+ export { Script } from './components/Script.js';
60
+ export type { ScriptProps, ScriptStrategy } from './components/Script.js';
61
+ export { Form } from './components/Form.js';
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
  */
@@ -3,6 +3,9 @@
3
3
  * the per-entry history keys used for scroll restoration. Consumed by `useLocation` (to re-render),
4
4
  * `Link` / `navigate` (to change location), and `Router` (which calls `applyScroll` after commit).
5
5
  */
6
+ import { startTransition } from 'react';
7
+ import { flushSync } from 'react-dom';
8
+
6
9
  import {
7
10
  enableManualScrollRestoration,
8
11
  planScroll,
@@ -13,6 +16,26 @@ import type { Href } from '../types.js';
13
16
  const listeners = new Set<() => void>();
14
17
  let popstateBound = false;
15
18
 
19
+ /** `document.startViewTransition`, present only where the View Transitions API is supported. */
20
+ interface ViewTransitionDocument {
21
+ startViewTransition?: (callback: () => void) => unknown;
22
+ }
23
+ let viewTransitions = false;
24
+
25
+ /** Enables animated View Transitions for navigation. Called once by `mount` from `client.viewTransitions`. */
26
+ export function setViewTransitions(enabled: boolean): void {
27
+ viewTransitions = enabled;
28
+ }
29
+
30
+ /** Whether the current navigation should animate via the View Transitions API. */
31
+ function shouldViewTransition(): boolean {
32
+ if (!viewTransitions || typeof document === 'undefined' || typeof window === 'undefined') {
33
+ return false;
34
+ }
35
+ if (typeof (document as ViewTransitionDocument).startViewTransition !== 'function') return false;
36
+ return !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
37
+ }
38
+
16
39
  interface ToilHistoryState {
17
40
  __toilKey?: string;
18
41
  }
@@ -23,11 +46,51 @@ function nextKey(): string {
23
46
  return `t${String(keyCounter)}`;
24
47
  }
25
48
 
26
- /** Notifies every subscriber that the location may have changed. */
27
- function notify(): void {
49
+ function runListeners(): void {
28
50
  for (const listener of listeners) listener();
29
51
  }
30
52
 
53
+ /**
54
+ * Re-renders subscribers for a location change. Normally wrapped in `startTransition` (smooth: the
55
+ * current page stays while the next route loads). When View Transitions are enabled and supported,
56
+ * the commit runs synchronously inside `document.startViewTransition` so the browser animates the
57
+ * old and new DOM (a crossfade, or shared-element transitions via `view-transition-name`).
58
+ */
59
+ function notify(): void {
60
+ if (shouldViewTransition()) {
61
+ (document as ViewTransitionDocument).startViewTransition?.(() => {
62
+ flushSync(runListeners);
63
+ });
64
+ } else {
65
+ startTransition(runListeners);
66
+ }
67
+ }
68
+
69
+ // Soft vs hard navigation, for intercepting routes. The initial page load (and any full refresh) is
70
+ // "hard"; client navigations (`navigate` / back / forward) are "soft". `previousPath` is the path we
71
+ // were on before the latest soft navigation, the route the main view keeps showing while an
72
+ // intercepting route fills a slot (the modal overlay).
73
+ let softNav = false;
74
+ let currentPath = typeof window === 'undefined' ? '/' : window.location.pathname;
75
+ let previousPath = currentPath;
76
+
77
+ /** Records a transition to the live location; `soft` is false only for the initial load. */
78
+ function recordTransition(soft: boolean): void {
79
+ previousPath = currentPath;
80
+ currentPath = typeof window === 'undefined' ? '/' : window.location.pathname;
81
+ softNav = soft;
82
+ }
83
+
84
+ /** Whether the current location was reached by a client navigation (not an initial load / refresh). */
85
+ export function isSoftNavigation(): boolean {
86
+ return softNav;
87
+ }
88
+
89
+ /** The path the app was on before the latest navigation (what the main view keeps during an intercept). */
90
+ export function previousPathname(): string {
91
+ return previousPath;
92
+ }
93
+
31
94
  // Navigation-pending tracking: a navigation is "pending" from when it starts until the new route
32
95
  // commits. Drives useNavigationPending() (e.g. a top loading bar).
33
96
  let startedTick = 0;
@@ -54,7 +117,7 @@ export function isNavigationPending(): boolean {
54
117
  return startedTick !== committedTick;
55
118
  }
56
119
 
57
- /** Monotonic id incremented on each navigation used to key/revalidate per-navigation route data. */
120
+ /** Monotonic id incremented on each navigation, used to key/revalidate per-navigation route data. */
58
121
  export function navigationEpoch(): number {
59
122
  return startedTick;
60
123
  }
@@ -103,6 +166,7 @@ export function navigate(href: Href, options?: NavigateOptions): void {
103
166
  currentKey = nextKey();
104
167
  window.history.pushState({ __toilKey: currentKey }, '', href);
105
168
  }
169
+ recordTransition(true);
106
170
  planScroll({ hash, toTop: options?.scroll !== false });
107
171
  notify();
108
172
  }
@@ -117,8 +181,13 @@ export function forward(): void {
117
181
  window.history.forward();
118
182
  }
119
183
 
120
- /** Re-renders the current route without changing the URL (there is no server data to refetch). */
184
+ /**
185
+ * Re-renders the current route, bumping the navigation epoch so a revalidation of the *same* URL
186
+ * re-keys its Suspense boundary (its `loading.tsx` shows while the loader re-runs) and
187
+ * `useNavigationPending` reports the in-flight refetch, instead of silently freezing the old page.
188
+ */
121
189
  export function refresh(): void {
190
+ beginNavigation();
122
191
  notify();
123
192
  }
124
193
 
@@ -128,6 +197,7 @@ function handlePopState(event: PopStateEvent): void {
128
197
  rememberScroll(currentKey);
129
198
  const state = event.state as ToilHistoryState | null;
130
199
  currentKey = state?.__toilKey ?? 'initial';
200
+ recordTransition(true);
131
201
  planScroll({ restoreKey: currentKey, hash: window.location.hash, toTop: false });
132
202
  notify();
133
203
  }
@@ -45,7 +45,7 @@ function warm(route: RouteDef): void {
45
45
 
46
46
  /**
47
47
  * Prefetches the route chunk for an internal `href` so a later navigation resolves instantly.
48
- * No-op for external, unknown, or already-prefetched targets safe to call from anywhere,
48
+ * No-op for external, unknown, or already-prefetched targets, safe to call from anywhere,
49
49
  * including before an imperative {@link navigate} (e.g. `prefetch('/dashboard')` on hover/intent).
50
50
  */
51
51
  export function prefetch(href: string): void {
@@ -83,7 +83,7 @@ function shouldSkipForConnection(): boolean {
83
83
 
84
84
  /**
85
85
  * Starts idle-time prefetching of internal links. As each `<a>` pointing at a known route scrolls
86
- * into view (or near it 200px margin) its chunk is warmed once; links added later by client
86
+ * into view (or near it, 200px margin) its chunk is warmed once; links added later by client
87
87
  * navigation are picked up via a MutationObserver. Called by {@link mount}; runs once per app.
88
88
  */
89
89
  export function startPrefetcher(routes: RouteDef[]): void {