toiljs 0.0.10 → 0.0.12

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 (128) hide show
  1. package/README.md +315 -1
  2. package/assets/logo.svg +37 -0
  3. package/build/cli/.tsbuildinfo +1 -1
  4. package/build/cli/configure.js +10 -4
  5. package/build/cli/create.js +60 -32
  6. package/build/cli/diagnostics.d.ts +55 -0
  7. package/build/cli/diagnostics.js +333 -0
  8. package/build/cli/doctor.d.ts +6 -0
  9. package/build/cli/doctor.js +249 -0
  10. package/build/cli/index.js +26 -0
  11. package/build/cli/proc.d.ts +5 -0
  12. package/build/cli/proc.js +20 -0
  13. package/build/cli/ui.d.ts +1 -0
  14. package/build/cli/ui.js +1 -0
  15. package/build/cli/update.d.ts +7 -0
  16. package/build/cli/update.js +117 -0
  17. package/build/cli/updates.d.ts +10 -0
  18. package/build/cli/updates.js +45 -0
  19. package/build/client/.tsbuildinfo +1 -1
  20. package/build/client/dev/error-overlay.js +1 -1
  21. package/build/client/head/metadata.js +3 -1
  22. package/build/client/index.d.ts +5 -1
  23. package/build/client/index.js +2 -0
  24. package/build/client/navigation/navigation.js +1 -1
  25. package/build/client/routing/Router.js +2 -2
  26. package/build/client/search/search.d.ts +26 -0
  27. package/build/client/search/search.js +101 -0
  28. package/build/client/search/use-page-search.d.ts +8 -0
  29. package/build/client/search/use-page-search.js +21 -0
  30. package/build/compiler/.tsbuildinfo +1 -1
  31. package/build/compiler/generate.js +35 -26
  32. package/build/compiler/index.d.ts +2 -0
  33. package/build/compiler/index.js +1 -0
  34. package/build/compiler/pages.d.ts +8 -0
  35. package/build/compiler/pages.js +37 -0
  36. package/build/compiler/plugin.js +3 -1
  37. package/build/compiler/prerender.d.ts +1 -0
  38. package/build/compiler/prerender.js +11 -5
  39. package/build/compiler/seo.js +10 -3
  40. package/build/compiler/vite.js +7 -0
  41. package/build/io/.tsbuildinfo +1 -1
  42. package/examples/basic/client/components/Header.tsx +43 -38
  43. package/examples/basic/client/components/HoneycombBackground.tsx +223 -230
  44. package/examples/basic/client/layout.tsx +4 -1
  45. package/examples/basic/client/public/index.html +18 -16
  46. package/examples/basic/client/routes/(legal)/privacy.tsx +18 -0
  47. package/examples/basic/client/routes/(legal)/terms.tsx +15 -0
  48. package/examples/basic/client/routes/about.tsx +21 -19
  49. package/examples/basic/client/routes/blog/[id].tsx +26 -12
  50. package/examples/basic/client/routes/features/actions.tsx +67 -0
  51. package/examples/basic/client/routes/features/error/error.tsx +16 -0
  52. package/examples/basic/client/routes/features/error/index.tsx +27 -0
  53. package/examples/basic/client/routes/features/head.tsx +38 -0
  54. package/examples/basic/client/routes/features/index.tsx +83 -0
  55. package/examples/basic/client/routes/features/realtime.tsx +34 -0
  56. package/examples/basic/client/routes/features/script.tsx +31 -0
  57. package/examples/basic/client/routes/features/seo.tsx +39 -0
  58. package/examples/basic/client/routes/features/template/b.tsx +14 -0
  59. package/examples/basic/client/routes/features/template/index.tsx +20 -0
  60. package/examples/basic/client/routes/features/template/template.tsx +16 -0
  61. package/examples/basic/client/routes/files/[[...slug]].tsx +21 -0
  62. package/examples/basic/client/routes/gallery/@modal/(.)photo/[id].tsx +23 -0
  63. package/examples/basic/client/routes/gallery/index.tsx +42 -0
  64. package/examples/basic/client/routes/gallery/layout.tsx +13 -0
  65. package/examples/basic/client/routes/gallery/photo/[id].tsx +18 -0
  66. package/examples/basic/client/routes/get-started.tsx +157 -84
  67. package/examples/basic/client/routes/index.tsx +137 -87
  68. package/examples/basic/client/routes/loader-demo/index.tsx +59 -50
  69. package/examples/basic/client/routes/search.tsx +61 -0
  70. package/examples/basic/client/routes/test.tsx +7 -8
  71. package/examples/basic/client/styles/main.css +624 -552
  72. package/examples/basic/client/toil.tsx +2 -4
  73. package/package.json +3 -2
  74. package/presets/eslint.js +10 -3
  75. package/src/cli/configure.ts +363 -353
  76. package/src/cli/create.ts +563 -530
  77. package/src/cli/diagnostics.ts +421 -0
  78. package/src/cli/doctor.ts +318 -0
  79. package/src/cli/features.ts +166 -160
  80. package/src/cli/index.ts +242 -211
  81. package/src/cli/proc.ts +30 -0
  82. package/src/cli/ui.ts +111 -103
  83. package/src/cli/update.ts +150 -0
  84. package/src/cli/updates.ts +69 -0
  85. package/src/client/components/Image.tsx +91 -89
  86. package/src/client/dev/error-overlay.tsx +193 -197
  87. package/src/client/head/metadata.ts +94 -92
  88. package/src/client/index.ts +79 -64
  89. package/src/client/navigation/Link.tsx +94 -100
  90. package/src/client/navigation/navigation.ts +215 -218
  91. package/src/client/routing/Router.tsx +210 -193
  92. package/src/client/routing/hooks.ts +110 -114
  93. package/src/client/routing/lazy.ts +77 -81
  94. package/src/client/search/search.ts +189 -0
  95. package/src/client/search/use-page-search.ts +73 -0
  96. package/src/compiler/config.ts +173 -171
  97. package/src/compiler/fonts.ts +89 -87
  98. package/src/compiler/generate.ts +378 -364
  99. package/src/compiler/image-report.ts +88 -85
  100. package/src/compiler/index.ts +2 -0
  101. package/src/compiler/pages.ts +70 -0
  102. package/src/compiler/plugin.ts +51 -47
  103. package/src/compiler/prerender.ts +152 -130
  104. package/src/compiler/routes.ts +132 -131
  105. package/src/compiler/seo.ts +381 -356
  106. package/src/compiler/vite.ts +155 -130
  107. package/src/io/FastSet.ts +99 -96
  108. package/test/configure.test.ts +94 -90
  109. package/test/doctor.test.ts +140 -0
  110. package/test/dom/Image.test.tsx +73 -46
  111. package/test/dom/Script.test.tsx +48 -45
  112. package/test/dom/action.test.tsx +146 -129
  113. package/test/dom/error-overlay.test.tsx +44 -44
  114. package/test/dom/loader.test.tsx +2 -2
  115. package/test/dom/revalidate.test.tsx +1 -1
  116. package/test/dom/route-head.test.tsx +35 -2
  117. package/test/dom/slot.test.tsx +131 -109
  118. package/test/dom/view-transitions.test.tsx +53 -51
  119. package/test/features.test.ts +149 -142
  120. package/test/fonts.test.ts +28 -26
  121. package/test/head.test.ts +45 -35
  122. package/test/metadata.test.ts +42 -41
  123. package/test/pages.test.ts +105 -0
  124. package/test/prerender.test.ts +54 -46
  125. package/test/search.test.ts +114 -0
  126. package/test/seo.test.ts +164 -142
  127. package/test/slot-layouts.test.ts +69 -0
  128. package/test/update.test.ts +44 -0
@@ -1,197 +1,193 @@
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
- }
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 { Component, type CSSProperties, type ErrorInfo, type ReactNode, useSyncExternalStore, } from 'react';
8
+
9
+ /** A captured dev error. */
10
+ interface DevError {
11
+ readonly error: Error;
12
+ readonly componentStack?: string;
13
+ /** Where it came from, a render boundary, a window `error`, or an unhandled rejection. */
14
+ readonly source: 'render' | 'window' | 'unhandledrejection';
15
+ }
16
+
17
+ let current: DevError | null = null;
18
+ const listeners = new Set<() => void>();
19
+
20
+ function emit(): void {
21
+ for (const listener of listeners) listener();
22
+ }
23
+ function setDevError(next: DevError | null): void {
24
+ current = next;
25
+ emit();
26
+ }
27
+ function subscribe(listener: () => void): () => void {
28
+ listeners.add(listener);
29
+ return () => {
30
+ listeners.delete(listener);
31
+ };
32
+ }
33
+
34
+ /** True when running under Vite's dev server (replaced at build time; falsy in production). */
35
+ export function isDevMode(): boolean {
36
+ try {
37
+ return Boolean((import.meta as { env?: { DEV?: boolean } }).env?.DEV);
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ let windowBound = false;
44
+ /** Wires `window` error / unhandledrejection into the overlay (idempotent; dev only). */
45
+ export function initDevErrorOverlay(): void {
46
+ if (windowBound || typeof window === 'undefined') return;
47
+ windowBound = true;
48
+ window.addEventListener('error', (event) => {
49
+ if (event.error instanceof Error) setDevError({ error: event.error, source: 'window' });
50
+ });
51
+ window.addEventListener('unhandledrejection', (event) => {
52
+ const reason: unknown = event.reason;
53
+ const error = reason instanceof Error ? reason : new Error(String(reason));
54
+ setDevError({ error, source: 'unhandledrejection' });
55
+ });
56
+ }
57
+
58
+ interface BoundaryProps {
59
+ readonly children: ReactNode;
60
+ }
61
+ interface BoundaryState {
62
+ readonly crashed: boolean;
63
+ }
64
+
65
+ /**
66
+ * Catches render errors in its subtree and reports them to the overlay. While crashed it renders
67
+ * nothing (the subtree threw); it recovers when the overlay is dismissed. Class component because
68
+ * React error boundaries have no hook equivalent.
69
+ */
70
+ export class DevErrorBoundary extends Component<BoundaryProps, BoundaryState> {
71
+ public state: BoundaryState = { crashed: false };
72
+ private unsubscribe: (() => void) | undefined;
73
+
74
+ public static getDerivedStateFromError(): BoundaryState {
75
+ return { crashed: true };
76
+ }
77
+
78
+ public override componentDidCatch(error: Error, info: ErrorInfo): void {
79
+ setDevError({ error, componentStack: info.componentStack ?? undefined, source: 'render' });
80
+ }
81
+
82
+ public override componentDidMount(): void {
83
+ // Recover (re-render children) once the error is dismissed from the overlay.
84
+ this.unsubscribe = subscribe(() => {
85
+ if (current === null && this.state.crashed) this.setState({ crashed: false });
86
+ });
87
+ }
88
+
89
+ public override componentWillUnmount(): void {
90
+ this.unsubscribe?.();
91
+ }
92
+
93
+ public override render(): ReactNode {
94
+ return this.state.crashed ? null : this.props.children;
95
+ }
96
+ }
97
+
98
+ const overlayStyle: CSSProperties = {
99
+ position: 'fixed',
100
+ inset: 0,
101
+ zIndex: 2147483647,
102
+ background: 'rgba(8, 8, 12, 0.88)',
103
+ color: '#f5f6fa',
104
+ font: '13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace',
105
+ padding: '2rem',
106
+ overflow: 'auto',
107
+ display: 'flex',
108
+ justifyContent: 'center',
109
+ alignItems: 'flex-start',
110
+ };
111
+ const panelStyle: CSSProperties = {
112
+ maxWidth: 900,
113
+ width: '100%',
114
+ background: '#15151c',
115
+ border: '1px solid #ef4444',
116
+ borderRadius: 10,
117
+ padding: '1.25rem 1.5rem',
118
+ boxShadow: '0 12px 48px rgba(0,0,0,0.5)',
119
+ };
120
+ const titleStyle: CSSProperties = {
121
+ margin: 0,
122
+ color: '#ff6b6b',
123
+ fontSize: '1rem',
124
+ fontWeight: 700,
125
+ wordBreak: 'break-word',
126
+ };
127
+ const preStyle: CSSProperties = {
128
+ whiteSpace: 'pre-wrap',
129
+ wordBreak: 'break-word',
130
+ margin: '0.75rem 0 0',
131
+ color: '#c8cee0',
132
+ };
133
+ const buttonStyle: CSSProperties = {
134
+ font: 'inherit',
135
+ color: '#f5f6fa',
136
+ background: '#2a2a36',
137
+ border: '1px solid #3a3a48',
138
+ borderRadius: 6,
139
+ padding: '0.4em 1em',
140
+ cursor: 'pointer',
141
+ marginRight: '0.5rem',
142
+ };
143
+
144
+ const SOURCE_LABEL: Record<DevError['source'], string> = {
145
+ render: 'Render error',
146
+ window: 'Uncaught error',
147
+ unhandledrejection: 'Unhandled promise rejection',
148
+ };
149
+
150
+ /** Renders the overlay when a dev error is captured. Mount once at the app root (dev only). */
151
+ export function DevErrorOverlay(): ReactNode {
152
+ const devError = useSyncExternalStore(
153
+ subscribe,
154
+ () => current,
155
+ () => null,
156
+ );
157
+ if (!devError) return null;
158
+ return (
159
+ <div
160
+ style={overlayStyle}
161
+ role="alert">
162
+ <div style={panelStyle}>
163
+ <p style={titleStyle}>
164
+ {SOURCE_LABEL[devError.source]}, {devError.error.name}: {devError.error.message}
165
+ </p>
166
+ {devError.error.stack !== undefined && (
167
+ <pre style={preStyle}>{devError.error.stack}</pre>
168
+ )}
169
+ {devError.componentStack !== undefined && (
170
+ <pre style={{ ...preStyle, color: '#8b9ab4' }}>{devError.componentStack}</pre>
171
+ )}
172
+ <div style={{ marginTop: '1.25rem' }}>
173
+ <button
174
+ type="button"
175
+ style={buttonStyle}
176
+ onClick={() => {
177
+ setDevError(null);
178
+ }}>
179
+ Dismiss
180
+ </button>
181
+ <button
182
+ type="button"
183
+ style={buttonStyle}
184
+ onClick={() => {
185
+ window.location.reload();
186
+ }}>
187
+ Reload
188
+ </button>
189
+ </div>
190
+ </div>
191
+ </div>
192
+ );
193
+ }
@@ -1,92 +1,94 @@
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
- }
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'
65
+ ? metadata.keywords
66
+ : metadata.keywords.join(', ');
67
+ meta.push({ name: 'keywords', content });
68
+ }
69
+ if (metadata.robots !== undefined) meta.push({ name: 'robots', content: metadata.robots });
70
+ if (metadata.themeColor !== undefined) {
71
+ meta.push({ name: 'theme-color', content: metadata.themeColor });
72
+ }
73
+ const og = metadata.openGraph;
74
+ if (og) {
75
+ const pairs: readonly [string, string | undefined][] = [
76
+ ['og:title', og.title],
77
+ ['og:description', og.description],
78
+ ['og:type', og.type],
79
+ ['og:url', og.url],
80
+ ['og:image', og.image],
81
+ ['og:site_name', og.siteName],
82
+ ];
83
+ for (const [property, content] of pairs) {
84
+ if (content !== undefined) meta.push({ property, content });
85
+ }
86
+ }
87
+ if (metadata.meta) meta.push(...metadata.meta);
88
+
89
+ const link: LinkTag[] = [];
90
+ if (metadata.canonical !== undefined) link.push({ rel: 'canonical', href: metadata.canonical });
91
+ if (metadata.link) link.push(...metadata.link);
92
+
93
+ return { title: metadata.title, titleTemplate: metadata.titleTemplate, meta, link };
94
+ }