toiljs 0.0.11 → 0.0.14

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 (120) hide show
  1. package/README.md +3 -1
  2. package/build/cli/.tsbuildinfo +1 -1
  3. package/build/cli/configure.js +10 -4
  4. package/build/cli/create.js +58 -30
  5. package/build/cli/diagnostics.d.ts +55 -0
  6. package/build/cli/diagnostics.js +333 -0
  7. package/build/cli/doctor.d.ts +6 -0
  8. package/build/cli/doctor.js +249 -0
  9. package/build/cli/index.js +26 -0
  10. package/build/cli/proc.d.ts +5 -0
  11. package/build/cli/proc.js +20 -0
  12. package/build/cli/ui.d.ts +1 -0
  13. package/build/cli/ui.js +1 -0
  14. package/build/cli/update.d.ts +7 -0
  15. package/build/cli/update.js +117 -0
  16. package/build/cli/updates.d.ts +10 -0
  17. package/build/cli/updates.js +45 -0
  18. package/build/client/.tsbuildinfo +1 -1
  19. package/build/client/dev/error-overlay.js +1 -1
  20. package/build/client/head/metadata.js +3 -1
  21. package/build/client/index.d.ts +5 -1
  22. package/build/client/index.js +2 -0
  23. package/build/client/navigation/navigation.js +1 -1
  24. package/build/client/routing/Router.js +2 -2
  25. package/build/client/search/search.d.ts +26 -0
  26. package/build/client/search/search.js +101 -0
  27. package/build/client/search/use-page-search.d.ts +8 -0
  28. package/build/client/search/use-page-search.js +21 -0
  29. package/build/compiler/.tsbuildinfo +1 -1
  30. package/build/compiler/generate.js +33 -24
  31. package/build/compiler/index.d.ts +2 -0
  32. package/build/compiler/index.js +1 -0
  33. package/build/compiler/pages.d.ts +8 -0
  34. package/build/compiler/pages.js +37 -0
  35. package/build/compiler/plugin.js +3 -1
  36. package/build/compiler/prerender.d.ts +1 -0
  37. package/build/compiler/prerender.js +11 -5
  38. package/build/compiler/seo.js +10 -3
  39. package/build/io/.tsbuildinfo +1 -1
  40. package/examples/basic/client/components/Header.tsx +43 -41
  41. package/examples/basic/client/components/HoneycombBackground.tsx +223 -230
  42. package/examples/basic/client/public/index.html +18 -16
  43. package/examples/basic/client/routes/(legal)/privacy.tsx +18 -19
  44. package/examples/basic/client/routes/(legal)/terms.tsx +15 -16
  45. package/examples/basic/client/routes/about.tsx +21 -22
  46. package/examples/basic/client/routes/blog/[id].tsx +26 -18
  47. package/examples/basic/client/routes/features/actions.tsx +67 -67
  48. package/examples/basic/client/routes/features/error/index.tsx +27 -27
  49. package/examples/basic/client/routes/features/head.tsx +38 -38
  50. package/examples/basic/client/routes/features/index.tsx +83 -75
  51. package/examples/basic/client/routes/features/realtime.tsx +34 -32
  52. package/examples/basic/client/routes/features/script.tsx +31 -31
  53. package/examples/basic/client/routes/features/seo.tsx +39 -39
  54. package/examples/basic/client/routes/features/template/index.tsx +20 -20
  55. package/examples/basic/client/routes/features/template/template.tsx +16 -18
  56. package/examples/basic/client/routes/gallery/@modal/(.)photo/[id].tsx +23 -23
  57. package/examples/basic/client/routes/gallery/index.tsx +42 -42
  58. package/examples/basic/client/routes/gallery/photo/[id].tsx +18 -18
  59. package/examples/basic/client/routes/get-started.tsx +157 -84
  60. package/examples/basic/client/routes/index.tsx +137 -96
  61. package/examples/basic/client/routes/loader-demo/index.tsx +59 -52
  62. package/examples/basic/client/routes/search.tsx +61 -0
  63. package/examples/basic/client/routes/test.tsx +7 -8
  64. package/examples/basic/client/styles/main.css +624 -552
  65. package/package.json +2 -2
  66. package/presets/eslint.js +10 -3
  67. package/src/cli/configure.ts +363 -353
  68. package/src/cli/create.ts +563 -530
  69. package/src/cli/diagnostics.ts +421 -0
  70. package/src/cli/doctor.ts +318 -0
  71. package/src/cli/features.ts +166 -160
  72. package/src/cli/index.ts +242 -211
  73. package/src/cli/proc.ts +30 -0
  74. package/src/cli/ui.ts +111 -103
  75. package/src/cli/update.ts +150 -0
  76. package/src/cli/updates.ts +69 -0
  77. package/src/client/components/Image.tsx +91 -89
  78. package/src/client/dev/error-overlay.tsx +193 -197
  79. package/src/client/head/metadata.ts +94 -92
  80. package/src/client/index.ts +79 -64
  81. package/src/client/navigation/Link.tsx +94 -100
  82. package/src/client/navigation/navigation.ts +215 -218
  83. package/src/client/routing/Router.tsx +210 -193
  84. package/src/client/routing/hooks.ts +110 -114
  85. package/src/client/routing/lazy.ts +77 -81
  86. package/src/client/search/search.ts +189 -0
  87. package/src/client/search/use-page-search.ts +73 -0
  88. package/src/compiler/config.ts +173 -171
  89. package/src/compiler/fonts.ts +89 -87
  90. package/src/compiler/generate.ts +45 -27
  91. package/src/compiler/image-report.ts +88 -85
  92. package/src/compiler/index.ts +2 -0
  93. package/src/compiler/pages.ts +70 -0
  94. package/src/compiler/plugin.ts +51 -47
  95. package/src/compiler/prerender.ts +152 -130
  96. package/src/compiler/routes.ts +132 -131
  97. package/src/compiler/seo.ts +381 -356
  98. package/src/compiler/vite.ts +155 -145
  99. package/src/io/FastSet.ts +99 -96
  100. package/test/configure.test.ts +94 -90
  101. package/test/doctor.test.ts +140 -0
  102. package/test/dom/Image.test.tsx +73 -46
  103. package/test/dom/Script.test.tsx +48 -45
  104. package/test/dom/action.test.tsx +146 -129
  105. package/test/dom/error-overlay.test.tsx +1 -1
  106. package/test/dom/loader.test.tsx +2 -2
  107. package/test/dom/revalidate.test.tsx +1 -1
  108. package/test/dom/route-head.test.tsx +1 -2
  109. package/test/dom/router-loading.test.tsx +1 -1
  110. package/test/dom/slot.test.tsx +131 -109
  111. package/test/dom/view-transitions.test.tsx +53 -51
  112. package/test/features.test.ts +149 -142
  113. package/test/fonts.test.ts +28 -26
  114. package/test/head.test.ts +45 -35
  115. package/test/metadata.test.ts +42 -41
  116. package/test/pages.test.ts +105 -0
  117. package/test/prerender.test.ts +54 -46
  118. package/test/search.test.ts +114 -0
  119. package/test/seo.test.ts +30 -8
  120. 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
+ }