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.
- package/build/backend/.tsbuildinfo +1 -1
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/configure.d.ts +1 -0
- package/build/cli/configure.js +85 -20
- package/build/cli/create.d.ts +1 -0
- package/build/cli/create.js +18 -7
- package/build/cli/features.d.ts +2 -0
- package/build/cli/features.js +22 -0
- package/build/cli/index.js +8 -0
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/components/Form.d.ts +12 -0
- package/build/client/components/Form.js +23 -0
- package/build/client/components/Image.d.ts +13 -0
- package/build/client/components/Image.js +22 -0
- package/build/client/components/Script.d.ts +13 -0
- package/build/client/components/Script.js +68 -0
- package/build/client/components/Slot.d.ts +6 -0
- package/build/client/components/Slot.js +6 -0
- package/build/client/dev/error-overlay.d.ts +20 -0
- package/build/client/dev/error-overlay.js +123 -0
- package/build/client/head/head.d.ts +2 -0
- package/build/client/head/head.js +17 -2
- package/build/client/head/metadata.d.ts +29 -0
- package/build/client/head/metadata.js +38 -0
- package/build/client/index.d.ts +15 -3
- package/build/client/index.js +8 -2
- package/build/client/navigation/navigation.d.ts +3 -0
- package/build/client/navigation/navigation.js +42 -1
- package/build/client/routing/Router.d.ts +1 -0
- package/build/client/routing/Router.js +56 -34
- package/build/client/routing/action.d.ts +17 -0
- package/build/client/routing/action.js +55 -0
- package/build/client/routing/hooks.d.ts +1 -0
- package/build/client/routing/hooks.js +6 -7
- package/build/client/routing/loader.d.ts +10 -2
- package/build/client/routing/loader.js +83 -24
- package/build/client/routing/mount.d.ts +1 -1
- package/build/client/routing/mount.js +12 -4
- package/build/client/routing/slot-context.d.ts +2 -0
- package/build/client/routing/slot-context.js +2 -0
- package/build/client/types.d.ts +1 -0
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.d.ts +10 -0
- package/build/compiler/config.js +5 -1
- package/build/compiler/docs.js +26 -26
- package/build/compiler/fonts.d.ts +4 -0
- package/build/compiler/fonts.js +64 -0
- package/build/compiler/generate.js +67 -32
- package/build/compiler/image-report.d.ts +2 -0
- package/build/compiler/image-report.js +62 -0
- package/build/compiler/plugin.js +1 -1
- package/build/compiler/prerender.d.ts +7 -0
- package/build/compiler/prerender.js +111 -0
- package/build/compiler/routes.d.ts +3 -0
- package/build/compiler/routes.js +50 -5
- package/build/compiler/seo.d.ts +70 -0
- package/build/compiler/seo.js +221 -0
- package/build/compiler/vite.js +13 -1
- package/build/io/.tsbuildinfo +1 -1
- package/build/shared/.tsbuildinfo +1 -1
- package/examples/basic/client/404.tsx +1 -1
- package/examples/basic/client/components/Header.tsx +38 -0
- package/examples/basic/client/components/HoneycombBackground.tsx +86 -18
- package/examples/basic/client/global-error.tsx +3 -3
- package/examples/basic/client/layout.tsx +2 -33
- package/examples/basic/client/public/images/test_image.webp +0 -0
- package/examples/basic/client/routes/about.tsx +8 -0
- package/examples/basic/client/routes/get-started.tsx +1 -1
- package/examples/basic/client/routes/index.tsx +8 -1
- package/examples/basic/client/routes/io.tsx +1 -1
- package/examples/basic/client/routes/loader-demo/index.tsx +29 -1
- package/examples/basic/client/routes/test.tsx +8 -0
- package/examples/basic/client/styles/main.css +48 -1
- package/package.json +8 -6
- package/presets/eslint.js +7 -4
- package/presets/tsconfig.json +1 -1
- package/src/backend/index.ts +1 -1
- package/src/cli/configure.ts +102 -21
- package/src/cli/create.ts +25 -9
- package/src/cli/features.ts +33 -1
- package/src/cli/index.ts +10 -1
- package/src/cli/ui.ts +1 -1
- package/src/cli/validate.ts +1 -1
- package/src/client/components/Form.tsx +65 -0
- package/src/client/components/Image.tsx +89 -0
- package/src/client/components/Script.tsx +113 -0
- package/src/client/components/Slot.tsx +21 -0
- package/src/client/dev/error-overlay.tsx +197 -0
- package/src/client/head/head.ts +28 -3
- package/src/client/head/metadata.ts +92 -0
- package/src/client/index.ts +20 -3
- package/src/client/navigation/Link.tsx +1 -1
- package/src/client/navigation/navigation.ts +74 -4
- package/src/client/navigation/prefetch.ts +2 -2
- package/src/client/routing/Router.tsx +128 -62
- package/src/client/routing/action.ts +122 -0
- package/src/client/routing/error-boundary.tsx +1 -1
- package/src/client/routing/hooks.ts +17 -23
- package/src/client/routing/loader.ts +158 -35
- package/src/client/routing/mount.tsx +25 -3
- package/src/client/routing/slot-context.ts +7 -0
- package/src/client/types.ts +6 -4
- package/src/compiler/config.ts +40 -3
- package/src/compiler/docs.ts +26 -26
- package/src/compiler/fonts.ts +87 -0
- package/src/compiler/generate.ts +69 -31
- package/src/compiler/image-report.ts +85 -0
- package/src/compiler/plugin.ts +2 -2
- package/src/compiler/prerender.ts +130 -0
- package/src/compiler/routes.ts +62 -7
- package/src/compiler/seo.ts +356 -0
- package/src/compiler/vite.ts +21 -4
- package/src/io/FastSet.ts +1 -1
- package/src/io/index.ts +1 -1
- package/src/io/types.ts +1 -1
- package/src/server/index.ts +1 -1
- package/src/server/main.ts +1 -1
- package/src/shared/index.ts +1 -1
- package/test/dom/Image.test.tsx +46 -0
- package/test/dom/Script.test.tsx +45 -0
- package/test/dom/action.test.tsx +129 -0
- package/test/dom/error-overlay.test.tsx +44 -0
- package/test/dom/loader.test.tsx +121 -0
- package/test/dom/revalidate.test.tsx +38 -0
- package/test/dom/route-head.test.tsx +34 -0
- package/test/dom/router-loading.test.tsx +44 -0
- package/test/dom/slot.test.tsx +109 -0
- package/test/dom/view-transitions.test.tsx +51 -0
- package/test/features.test.ts +31 -0
- package/test/fonts.test.ts +26 -0
- package/test/metadata.test.ts +41 -0
- package/test/prerender.test.ts +46 -0
- package/test/routes.test.ts +20 -1
- package/test/seo.test.ts +142 -0
- 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
|
+
}
|
package/src/client/head/head.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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 {
|