toiljs 0.0.15 → 0.0.19

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 (273) hide show
  1. package/.babelrc +13 -13
  2. package/.gitattributes +2 -2
  3. package/.github/ISSUE_TEMPLATE/bug_report.md +38 -38
  4. package/.github/ISSUE_TEMPLATE/bug_report.yml +90 -90
  5. package/.github/ISSUE_TEMPLATE/config.yml +8 -8
  6. package/.github/ISSUE_TEMPLATE/feature_request.md +20 -20
  7. package/.github/PULL_REQUEST_TEMPLATE.md +43 -43
  8. package/.github/changelog-config.json +45 -45
  9. package/.github/dependabot.yml +27 -27
  10. package/.github/workflows/ci.yml +191 -191
  11. package/.prettierrc.json +11 -11
  12. package/.vscode/settings.json +9 -9
  13. package/CHANGELOG.md +116 -5
  14. package/LICENSE +187 -187
  15. package/README.md +524 -315
  16. package/as-pect.asconfig.json +34 -34
  17. package/as-pect.config.js +65 -65
  18. package/assets/logo.svg +36 -36
  19. package/build/backend/.tsbuildinfo +1 -1
  20. package/build/backend/index.d.ts +1 -0
  21. package/build/backend/index.js +20 -1
  22. package/build/cli/.tsbuildinfo +1 -1
  23. package/build/cli/index.js +1320 -696
  24. package/build/client/.tsbuildinfo +1 -1
  25. package/build/client/dev/devtools.d.ts +6 -0
  26. package/build/client/dev/devtools.js +479 -0
  27. package/build/client/dev/error-overlay.d.ts +9 -0
  28. package/build/client/dev/error-overlay.js +19 -4
  29. package/build/client/errors.d.ts +1 -0
  30. package/build/client/errors.js +3 -0
  31. package/build/client/index.d.ts +2 -0
  32. package/build/client/index.js +2 -0
  33. package/build/client/navigation/prefetch.d.ts +1 -0
  34. package/build/client/navigation/prefetch.js +35 -0
  35. package/build/client/routing/Router.js +1 -1
  36. package/build/client/routing/hooks.js +6 -2
  37. package/build/client/routing/loader.d.ts +23 -0
  38. package/build/client/routing/loader.js +53 -7
  39. package/build/client/routing/mount.js +4 -3
  40. package/build/client/rpc.d.ts +1 -0
  41. package/build/client/rpc.js +37 -0
  42. package/build/compiler/.tsbuildinfo +1 -1
  43. package/build/compiler/config.d.ts +16 -0
  44. package/build/compiler/config.js +9 -0
  45. package/build/compiler/docs.js +78 -21
  46. package/build/compiler/generate.js +5 -4
  47. package/build/compiler/index.d.ts +3 -2
  48. package/build/compiler/index.js +2 -2
  49. package/build/compiler/plugin.js +228 -0
  50. package/build/compiler/prerender.d.ts +1 -0
  51. package/build/compiler/prerender.js +1 -1
  52. package/build/compiler/seo.d.ts +1 -1
  53. package/build/compiler/seo.js +20 -5
  54. package/build/compiler/ssg.js +39 -2
  55. package/build/compiler/vite.js +25 -0
  56. package/build/io/.tsbuildinfo +1 -1
  57. package/build/io/codec.d.ts +54 -0
  58. package/build/io/codec.js +143 -0
  59. package/build/io/index.d.ts +1 -2
  60. package/build/io/index.js +1 -2
  61. package/build/logger/.tsbuildinfo +1 -1
  62. package/build/shared/.tsbuildinfo +1 -1
  63. package/eslint.config.js +48 -48
  64. package/examples/basic/client/404.tsx +11 -11
  65. package/examples/basic/client/components/.gitkeep +1 -1
  66. package/examples/basic/client/global-error.tsx +13 -13
  67. package/examples/basic/client/layout.tsx +25 -25
  68. package/examples/basic/client/public/images/.gitkeep +1 -1
  69. package/examples/basic/client/public/images/logo.svg +36 -36
  70. package/examples/basic/client/public/robots.txt +2 -2
  71. package/examples/basic/client/routes/docs/[...slug].tsx +12 -12
  72. package/examples/basic/client/routes/features/error/error.tsx +16 -16
  73. package/examples/basic/client/routes/features/index.tsx +1 -1
  74. package/examples/basic/client/routes/features/template/b.tsx +14 -14
  75. package/examples/basic/client/routes/files/[[...slug]].tsx +21 -21
  76. package/examples/basic/client/routes/gallery/layout.tsx +13 -13
  77. package/examples/basic/client/routes/io.tsx +23 -24
  78. package/examples/basic/client/routes/loader-demo/loading.tsx +13 -13
  79. package/examples/basic/client/routes/rest.tsx +74 -0
  80. package/examples/basic/client/routes/rpc.tsx +43 -0
  81. package/examples/basic/client/routes/search.tsx +61 -61
  82. package/examples/basic/client/toil.tsx +5 -5
  83. package/package.json +167 -148
  84. package/presets/eslint.js +88 -88
  85. package/presets/no-uint8array-tostring.js +200 -200
  86. package/presets/prettier-plugin.js +51 -0
  87. package/presets/prettier.json +19 -18
  88. package/presets/tsconfig.json +37 -37
  89. package/server/runtime/README.md +97 -0
  90. package/server/runtime/abort/abort.ts +27 -0
  91. package/server/runtime/env/Server.ts +61 -0
  92. package/server/runtime/envelope.ts +191 -0
  93. package/server/runtime/exports/index.ts +52 -0
  94. package/server/runtime/handlers/ToilHandler.ts +34 -0
  95. package/server/runtime/index.ts +26 -0
  96. package/server/runtime/lang/Potential.ts +5 -0
  97. package/server/runtime/memory.ts +81 -0
  98. package/server/runtime/request.ts +55 -0
  99. package/server/runtime/response.ts +86 -0
  100. package/server/runtime/rest/Rest.ts +39 -0
  101. package/server/runtime/rest/RestHandler.ts +20 -0
  102. package/server/runtime/rest/RouteContext.ts +82 -0
  103. package/server/runtime/rest/match.ts +48 -0
  104. package/server/runtime/tsconfig.json +7 -0
  105. package/src/backend/index.ts +202 -160
  106. package/src/cli/create.ts +15 -5
  107. package/src/cli/diagnostics.ts +81 -0
  108. package/src/cli/doctor.ts +384 -7
  109. package/src/cli/index.ts +11 -2
  110. package/src/cli/proc.ts +50 -50
  111. package/src/cli/updates.ts +69 -69
  112. package/src/cli/validate.ts +31 -31
  113. package/src/client/channel/channel.ts +146 -146
  114. package/src/client/components/Form.tsx +65 -65
  115. package/src/client/components/Script.tsx +113 -113
  116. package/src/client/components/Slot.tsx +21 -21
  117. package/src/client/dev/devtools.tsx +1018 -0
  118. package/src/client/dev/error-overlay.tsx +30 -4
  119. package/src/client/errors.ts +11 -0
  120. package/src/client/head/head.ts +167 -167
  121. package/src/client/head/metadata.ts +112 -112
  122. package/src/client/index.ts +91 -89
  123. package/src/client/navigation/NavLink.tsx +86 -86
  124. package/src/client/navigation/navigation.ts +235 -235
  125. package/src/client/navigation/prefetch.ts +169 -130
  126. package/src/client/navigation/scroll.ts +53 -53
  127. package/src/client/routing/Router.tsx +8 -2
  128. package/src/client/routing/action.ts +122 -122
  129. package/src/client/routing/error-boundary.tsx +43 -43
  130. package/src/client/routing/hooks.ts +21 -6
  131. package/src/client/routing/loader.ts +325 -235
  132. package/src/client/routing/match.ts +47 -47
  133. package/src/client/routing/mount.tsx +54 -52
  134. package/src/client/routing/params-context.ts +10 -10
  135. package/src/client/routing/slot-context.ts +7 -7
  136. package/src/client/rpc.ts +64 -0
  137. package/src/client/search/search.ts +189 -189
  138. package/src/client/search/use-page-search.ts +73 -73
  139. package/src/client/types.ts +73 -73
  140. package/src/compiler/config.ts +221 -182
  141. package/src/compiler/docs.ts +285 -228
  142. package/src/compiler/generate.ts +395 -394
  143. package/src/compiler/index.ts +66 -57
  144. package/src/compiler/pages.ts +70 -70
  145. package/src/compiler/plugin.ts +258 -2
  146. package/src/compiler/prerender.ts +156 -156
  147. package/src/compiler/seo.ts +417 -390
  148. package/src/compiler/ssg.ts +171 -126
  149. package/src/compiler/vite.ts +34 -0
  150. package/src/io/FastMap.ts +151 -127
  151. package/src/io/FastSet.ts +15 -1
  152. package/src/io/codec.ts +217 -0
  153. package/src/io/index.ts +10 -11
  154. package/src/io/lengths.ts +14 -14
  155. package/src/io/types.ts +19 -18
  156. package/src/logger/index.ts +22 -22
  157. package/src/shared/index.ts +10 -10
  158. package/std/client/index.d.ts +15 -15
  159. package/std/client/package.json +3 -3
  160. package/test/assembly/example.spec.ts +17 -7
  161. package/test/channel.test.ts +21 -21
  162. package/test/doctor.test.ts +65 -0
  163. package/test/dom/Link.test.tsx +47 -47
  164. package/test/dom/NavLink.test.tsx +37 -37
  165. package/test/dom/error-overlay.test.tsx +44 -44
  166. package/test/dom/loader.test.tsx +121 -121
  167. package/test/dom/navigation.test.ts +59 -59
  168. package/test/dom/revalidate.test.tsx +38 -38
  169. package/test/dom/route-head.test.tsx +78 -78
  170. package/test/dom/router-loading.test.tsx +44 -44
  171. package/test/dom/scroll.test.ts +56 -56
  172. package/test/dom/use-metadata.test.tsx +58 -58
  173. package/test/errors.test.ts +21 -0
  174. package/test/io.test.ts +117 -93
  175. package/test/navlink.test.ts +28 -28
  176. package/test/placeholder.test.ts +9 -9
  177. package/test/prettier-plugin.test.ts +46 -0
  178. package/test/routes.test.ts +76 -76
  179. package/test/rpc.test.ts +50 -0
  180. package/test/seo.test.ts +175 -164
  181. package/test/slot-layouts.test.ts +69 -69
  182. package/test/ssg.test.ts +36 -36
  183. package/test/update.test.ts +44 -44
  184. package/test/validate.test.ts +42 -42
  185. package/tests/data-parity/generated-parity.ts +99 -0
  186. package/tests/data-parity/parity.ts +80 -0
  187. package/tests/data-parity/spec.ts +46 -0
  188. package/toil-routes.d.ts +7 -0
  189. package/tsconfig.backend.json +13 -13
  190. package/tsconfig.base.json +35 -35
  191. package/tsconfig.cli.json +13 -13
  192. package/tsconfig.client.json +14 -14
  193. package/tsconfig.compiler.json +13 -13
  194. package/tsconfig.io.json +12 -12
  195. package/tsconfig.json +22 -22
  196. package/tsconfig.logger.json +12 -12
  197. package/tsconfig.server.json +10 -10
  198. package/tsconfig.shared.json +12 -12
  199. package/vitest.config.ts +26 -26
  200. package/.idea/codeStyles/Project.xml +0 -54
  201. package/.idea/codeStyles/codeStyleConfig.xml +0 -5
  202. package/.idea/inspectionProfiles/Project_Default.xml +0 -6
  203. package/.idea/modules.xml +0 -8
  204. package/.idea/prettier.xml +0 -7
  205. package/.idea/toiljs.iml +0 -8
  206. package/.idea/vcs.xml +0 -6
  207. package/.toil/entry.tsx +0 -9
  208. package/.toil/index.html +0 -12
  209. package/.toil/routes.ts +0 -9
  210. package/build/cli/configure.d.ts +0 -16
  211. package/build/cli/configure.js +0 -272
  212. package/build/cli/create.d.ts +0 -16
  213. package/build/cli/create.js +0 -420
  214. package/build/cli/diagnostics.d.ts +0 -55
  215. package/build/cli/diagnostics.js +0 -333
  216. package/build/cli/doctor.d.ts +0 -6
  217. package/build/cli/doctor.js +0 -249
  218. package/build/cli/features.d.ts +0 -25
  219. package/build/cli/features.js +0 -107
  220. package/build/cli/index.d.ts +0 -2
  221. package/build/cli/proc.d.ts +0 -6
  222. package/build/cli/proc.js +0 -31
  223. package/build/cli/ui.d.ts +0 -9
  224. package/build/cli/ui.js +0 -75
  225. package/build/cli/update.d.ts +0 -7
  226. package/build/cli/update.js +0 -117
  227. package/build/cli/updates.d.ts +0 -10
  228. package/build/cli/updates.js +0 -45
  229. package/build/cli/validate.d.ts +0 -4
  230. package/build/cli/validate.js +0 -19
  231. package/build/client/Link.d.ts +0 -8
  232. package/build/client/Link.js +0 -44
  233. package/build/client/NavLink.d.ts +0 -14
  234. package/build/client/NavLink.js +0 -37
  235. package/build/client/Router.d.ts +0 -7
  236. package/build/client/Router.js +0 -55
  237. package/build/client/channel.d.ts +0 -23
  238. package/build/client/channel.js +0 -94
  239. package/build/client/error-boundary.d.ts +0 -16
  240. package/build/client/error-boundary.js +0 -19
  241. package/build/client/head.d.ts +0 -26
  242. package/build/client/head.js +0 -87
  243. package/build/client/hooks.d.ts +0 -17
  244. package/build/client/hooks.js +0 -48
  245. package/build/client/lazy.d.ts +0 -16
  246. package/build/client/lazy.js +0 -53
  247. package/build/client/match.d.ts +0 -2
  248. package/build/client/match.js +0 -32
  249. package/build/client/mount.d.ts +0 -2
  250. package/build/client/mount.js +0 -13
  251. package/build/client/navigation.d.ts +0 -13
  252. package/build/client/navigation.js +0 -97
  253. package/build/client/params-context.d.ts +0 -2
  254. package/build/client/params-context.js +0 -2
  255. package/build/client/prefetch.d.ts +0 -11
  256. package/build/client/prefetch.js +0 -100
  257. package/build/client/runtime.d.ts +0 -31
  258. package/build/client/runtime.js +0 -112
  259. package/build/client/scroll.d.ts +0 -8
  260. package/build/client/scroll.js +0 -36
  261. package/build/io/BinaryReader.d.ts +0 -44
  262. package/build/io/BinaryReader.js +0 -244
  263. package/build/io/BinaryWriter.d.ts +0 -44
  264. package/build/io/BinaryWriter.js +0 -297
  265. package/build/server/release.wasm +0 -0
  266. package/build/server/release.wat +0 -9
  267. package/src/io/BinaryReader.ts +0 -340
  268. package/src/io/BinaryWriter.ts +0 -385
  269. package/src/server/index.ts +0 -10
  270. package/src/server/main.ts +0 -13
  271. package/src/server/tsconfig.json +0 -4
  272. package/toil-env.d.ts +0 -16
  273. package/toilconfig.json +0 -30
@@ -1,390 +1,417 @@
1
- import type { ScannedRoute } from './routes.js';
2
-
3
- /**
4
- * Build-time SEO for the (otherwise JS-only) SPA: bakes site-level metadata into the HTML `<head>`
5
- * so JS-less crawlers and AI bots see real tags, and generates `robots.txt`, `sitemap.xml`, and
6
- * `llms.txt`. All pure string builders here; `generate.ts` wires them into the build output.
7
- */
8
-
9
- /**
10
- * OpenGraph defaults baked into the HTML, read by Facebook, Discord, Slack, LinkedIn, GitHub, and
11
- * most link-preview crawlers. `image` should be an absolute URL (≥1200×630 for a large card).
12
- */
13
- export interface SeoOpenGraph {
14
- readonly title?: string;
15
- readonly description?: string;
16
- /** `og:type`, e.g. `'website'` or `'article'`. */
17
- readonly type?: string;
18
- readonly siteName?: string;
19
- /** `og:locale`, e.g. `'en_US'`. */
20
- readonly locale?: string;
21
- /** `og:image`, the preview image (absolute URL). */
22
- readonly image?: string;
23
- /** `og:image:alt`. */
24
- readonly imageAlt?: string;
25
- /** `og:image:width` in px (helps Facebook/LinkedIn render without a re-fetch). */
26
- readonly imageWidth?: number;
27
- /** `og:image:height` in px. */
28
- readonly imageHeight?: number;
29
- /** `og:image:type`, e.g. `'image/png'`. */
30
- readonly imageType?: string;
31
- }
32
-
33
- /** Twitter / X card. Unset fields fall back to the OpenGraph / top-level values. */
34
- export interface SeoTwitter {
35
- /** `'summary'` | `'summary_large_image'` | … Defaults by whether an image is present. */
36
- readonly card?: string;
37
- readonly site?: string;
38
- readonly creator?: string;
39
- readonly title?: string;
40
- readonly description?: string;
41
- readonly image?: string;
42
- readonly imageAlt?: string;
43
- }
44
-
45
- /** A `robots.txt` group. */
46
- export interface RobotsRule {
47
- readonly userAgent?: string | readonly string[];
48
- readonly allow?: readonly string[];
49
- readonly disallow?: readonly string[];
50
- }
51
-
52
- /** `robots.txt` configuration. */
53
- export interface RobotsConfig {
54
- readonly rules?: readonly RobotsRule[];
55
- /** How to treat known AI crawlers (GPTBot, ClaudeBot, Google-Extended, …). Default `'allow'`. */
56
- readonly ai?: 'allow' | 'disallow';
57
- /** Explicit `Sitemap:` URL (defaults to `<url>/sitemap.xml` when `seo.url` is set). */
58
- readonly sitemap?: string;
59
- }
60
-
61
- /** A page listed in `llms.txt`. */
62
- export interface LlmsPage {
63
- readonly title: string;
64
- readonly url: string;
65
- readonly description?: string;
66
- }
67
-
68
- /** `llms.txt` configuration (the AI-crawler guidance file). */
69
- export interface LlmsConfig {
70
- readonly title?: string;
71
- readonly summary?: string;
72
- /** Free-form instructions for AI/LLM crawlers. */
73
- readonly instructions?: string;
74
- /** Key pages; defaults to the site's static routes. */
75
- readonly pages?: readonly LlmsPage[];
76
- }
77
-
78
- /** Build-time SEO configuration (under `client.seo`). */
79
- export interface SeoConfig {
80
- /** Absolute site base URL, e.g. `https://toil.dev`, required for `sitemap.xml` and canonical/OG URLs. */
81
- readonly url?: string;
82
- /** Default document title baked into the HTML. */
83
- readonly title?: string;
84
- /** Default meta description. */
85
- readonly description?: string;
86
- /** Default robots directive, e.g. `'index, follow'`. */
87
- readonly robotsMeta?: string;
88
- /** `<meta name="theme-color">`, also the accent color of Discord/Slack link embeds. */
89
- readonly themeColor?: string;
90
- readonly openGraph?: SeoOpenGraph;
91
- readonly twitter?: SeoTwitter;
92
- /** Facebook-specific tags (`fb:app_id`). OpenGraph above covers the rest of the FB card. */
93
- readonly facebook?: { readonly appId?: string };
94
- /** JSON-LD structured data injected as `<script type="application/ld+json">`. */
95
- readonly jsonLd?: Record<string, unknown> | readonly Record<string, unknown>[];
96
- /** Origins to `<link rel="preconnect">` (early connection hints). */
97
- readonly preconnect?: readonly string[];
98
- /** Origins to `<link rel="dns-prefetch">`. */
99
- readonly dnsPrefetch?: readonly string[];
100
- /** `robots.txt` generation; `false` to skip. */
101
- readonly robots?: RobotsConfig | false;
102
- /** `sitemap.xml` generation; defaults to on when `url` is set. */
103
- readonly sitemap?: boolean;
104
- /** `llms.txt` (AI guidance) generation; `false` to skip, `true`/object to configure. */
105
- readonly llms?: LlmsConfig | boolean;
106
- }
107
-
108
- /** Known AI / LLM crawler user-agents, for explicit allow/disallow in `robots.txt`. */
109
- const AI_CRAWLERS: readonly string[] = [
110
- 'GPTBot',
111
- 'OAI-SearchBot',
112
- 'ChatGPT-User',
113
- 'ClaudeBot',
114
- 'Claude-Web',
115
- 'anthropic-ai',
116
- 'Google-Extended',
117
- 'PerplexityBot',
118
- 'CCBot',
119
- 'Applebot-Extended',
120
- 'Bytespider',
121
- 'Amazonbot',
122
- 'Meta-ExternalAgent',
123
- ];
124
-
125
- /** Escapes a value for use inside a double-quoted HTML attribute (prevents attribute-breakout XSS). */
126
- function escapeAttr(value: string): string {
127
- return value
128
- .replace(/&/g, '&amp;')
129
- .replace(/"/g, '&quot;')
130
- .replace(/'/g, '&#39;')
131
- .replace(/</g, '&lt;')
132
- .replace(/>/g, '&gt;');
133
- }
134
- /** Escapes a value for HTML text content (e.g. `<title>`, XML text). */
135
- export function escapeHtml(value: string): string {
136
- return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
137
- }
138
- /**
139
- * Serializes a value for embedding in an inline `<script>` (JSON-LD). Escapes `<`, `>`, and `&`,
140
- * which neutralizes `</script>` and `<!--` (the only HTML-significant sequences inside a script),
141
- * so attacker-controlled data can't break out of the script element.
142
- */
143
- function escapeJsonForScript(value: unknown): string {
144
- return JSON.stringify(value)
145
- .replace(/</g, '\\u003c')
146
- .replace(/>/g, '\\u003e')
147
- .replace(/&/g, '\\u0026');
148
- }
149
- function meta(attrs: Record<string, string | number | undefined>): string {
150
- const pairs = Object.entries(attrs)
151
- .filter((entry): entry is [string, string | number] => entry[1] !== undefined)
152
- .map(([k, v]) => `${k}="${escapeAttr(String(v))}"`);
153
- return ` <meta ${pairs.join(' ')} />`;
154
- }
155
-
156
- /** Joins a base URL and a route path into a clean absolute URL. */
157
- export function joinUrl(base: string, path: string): string {
158
- return `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`.replace(/\/$/, '') || base;
159
- }
160
-
161
- /** Static (parameter-free) route patterns, the ones that can be listed in a sitemap. */
162
- function staticPaths(routes: readonly ScannedRoute[]): string[] {
163
- return routes
164
- .filter((r) => r.slot === undefined && !r.intercept && !/[:*]/.test(r.pattern))
165
- .map((r) => r.pattern)
166
- .sort();
167
- }
168
-
169
- /** The site-level `<head>` fragment baked into the built HTML (title is handled separately). */
170
- export function seoHeadTags(seo: SeoConfig): string {
171
- const lines: string[] = [];
172
- if (seo.description !== undefined)
173
- lines.push(meta({ name: 'description', content: seo.description }));
174
- if (seo.robotsMeta !== undefined) lines.push(meta({ name: 'robots', content: seo.robotsMeta }));
175
- if (seo.themeColor !== undefined)
176
- lines.push(meta({ name: 'theme-color', content: seo.themeColor }));
177
- if (seo.url !== undefined)
178
- lines.push(` <link rel="canonical" href="${escapeAttr(seo.url)}" />`);
179
-
180
- // OpenGraph (also drives Facebook, Discord, Slack, LinkedIn, GitHub previews).
181
- const og = seo.openGraph;
182
- const ogTitle = og?.title ?? seo.title;
183
- const ogDesc = og?.description ?? seo.description;
184
- if (ogTitle !== undefined) lines.push(meta({ property: 'og:title', content: ogTitle }));
185
- if (ogDesc !== undefined) lines.push(meta({ property: 'og:description', content: ogDesc }));
186
- lines.push(meta({ property: 'og:type', content: og?.type ?? 'website' }));
187
- if (seo.url !== undefined) lines.push(meta({ property: 'og:url', content: seo.url }));
188
- if (og?.siteName !== undefined)
189
- lines.push(meta({ property: 'og:site_name', content: og.siteName }));
190
- if (og?.locale !== undefined) lines.push(meta({ property: 'og:locale', content: og.locale }));
191
- if (og?.image !== undefined) {
192
- lines.push(meta({ property: 'og:image', content: og.image }));
193
- if (og.imageAlt !== undefined)
194
- lines.push(meta({ property: 'og:image:alt', content: og.imageAlt }));
195
- if (og.imageType !== undefined)
196
- lines.push(meta({ property: 'og:image:type', content: og.imageType }));
197
- if (og.imageWidth !== undefined)
198
- lines.push(meta({ property: 'og:image:width', content: og.imageWidth }));
199
- if (og.imageHeight !== undefined) {
200
- lines.push(meta({ property: 'og:image:height', content: og.imageHeight }));
201
- }
202
- }
203
- if (seo.facebook?.appId !== undefined) {
204
- lines.push(meta({ property: 'fb:app_id', content: seo.facebook.appId }));
205
- }
206
-
207
- // Twitter / X card. Unset fields fall back to OpenGraph / top-level values.
208
- const tw = seo.twitter;
209
- if (tw) {
210
- const twImage = tw.image ?? og?.image;
211
- const card = tw.card ?? (twImage !== undefined ? 'summary_large_image' : 'summary');
212
- lines.push(meta({ name: 'twitter:card', content: card }));
213
- if (tw.site !== undefined) lines.push(meta({ name: 'twitter:site', content: tw.site }));
214
- if (tw.creator !== undefined)
215
- lines.push(meta({ name: 'twitter:creator', content: tw.creator }));
216
- const twTitle = tw.title ?? ogTitle;
217
- const twDesc = tw.description ?? ogDesc;
218
- if (twTitle !== undefined) lines.push(meta({ name: 'twitter:title', content: twTitle }));
219
- if (twDesc !== undefined)
220
- lines.push(meta({ name: 'twitter:description', content: twDesc }));
221
- if (twImage !== undefined) lines.push(meta({ name: 'twitter:image', content: twImage }));
222
- const twImageAlt = tw.imageAlt ?? og?.imageAlt;
223
- if (twImageAlt !== undefined)
224
- lines.push(meta({ name: 'twitter:image:alt', content: twImageAlt }));
225
- }
226
-
227
- for (const origin of seo.preconnect ?? []) {
228
- lines.push(` <link rel="preconnect" href="${escapeAttr(origin)}" />`);
229
- }
230
- for (const origin of seo.dnsPrefetch ?? []) {
231
- lines.push(` <link rel="dns-prefetch" href="${escapeAttr(origin)}" />`);
232
- }
233
- if (seo.jsonLd !== undefined) {
234
- lines.push(
235
- ` <script type="application/ld+json">${escapeJsonForScript(seo.jsonLd)}</script>`,
236
- );
237
- }
238
- return lines.join('\n');
239
- }
240
-
241
- /** The default document title to bake into the HTML, if any. */
242
- export function seoTitle(seo: SeoConfig): string | undefined {
243
- return seo.title;
244
- }
245
-
246
- /**
247
- * Bakes the SEO `<head>` into an HTML document: replaces the existing `<title>` and `description`
248
- * meta (so they aren't duplicated) and inserts the rest before `</head>`. Used for the shell and,
249
- * per route, by the prerenderer.
250
- */
251
- export function injectSeoHtml(html: string, seo: SeoConfig): string {
252
- let out = html;
253
- const title = seoTitle(seo);
254
- if (title !== undefined) {
255
- const tag = `<title>${escapeHtml(title)}</title>`;
256
- out = /<title>[\s\S]*?<\/title>/i.test(out)
257
- ? out.replace(/<title>[\s\S]*?<\/title>/i, tag)
258
- : out.replace(/<\/head>/i, ` ${tag}\n </head>`);
259
- }
260
- if (seo.description !== undefined) {
261
- out = out.replace(/[ \t]*<meta\s+name=["']description["'][^>]*>\s*\n?/i, '');
262
- }
263
- const tags = seoHeadTags(seo);
264
- if (tags) {
265
- out = out.includes('</head>')
266
- ? out.replace(/<\/head>/i, `${tags}\n </head>`)
267
- : `${tags}\n${out}`;
268
- }
269
- return out;
270
- }
271
-
272
- function asString(value: unknown): string | undefined {
273
- return typeof value === 'string' ? value : undefined;
274
- }
275
- function asRecord(value: unknown): Record<string, unknown> {
276
- return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};
277
- }
278
-
279
- /**
280
- * Overlays a route's extracted `metadata` (title/description/openGraph/…) onto the site-wide
281
- * {@link SeoConfig}, and points the canonical/`og:url` at the route's own URL. The result is what
282
- * the prerenderer bakes into that route's HTML, per-file metadata winning over the site defaults.
283
- */
284
- export function routeSeo(
285
- seo: SeoConfig,
286
- metadata: Record<string, unknown> | null,
287
- pattern: string,
288
- ): SeoConfig {
289
- const routeUrl = seo.url !== undefined ? joinUrl(seo.url, pattern) : undefined;
290
- if (!metadata) return { ...seo, url: routeUrl };
291
- const og = asRecord(metadata.openGraph);
292
- return {
293
- ...seo,
294
- url: asString(metadata.canonical) ?? routeUrl,
295
- title: asString(metadata.title) ?? seo.title,
296
- description: asString(metadata.description) ?? seo.description,
297
- robotsMeta: asString(metadata.robots) ?? seo.robotsMeta,
298
- themeColor: asString(metadata.themeColor) ?? seo.themeColor,
299
- openGraph: {
300
- ...seo.openGraph,
301
- title: asString(og.title) ?? asString(metadata.title) ?? seo.openGraph?.title,
302
- description:
303
- asString(og.description) ??
304
- asString(metadata.description) ??
305
- seo.openGraph?.description,
306
- type: asString(og.type) ?? seo.openGraph?.type,
307
- image: asString(og.image) ?? seo.openGraph?.image,
308
- imageAlt: asString(og.imageAlt) ?? seo.openGraph?.imageAlt,
309
- siteName: asString(og.siteName) ?? seo.openGraph?.siteName,
310
- },
311
- };
312
- }
313
-
314
- /** `robots.txt` contents. */
315
- export function robotsTxt(seo: SeoConfig): string {
316
- if (seo.robots === false) return '';
317
- const cfg: RobotsConfig = seo.robots ?? {};
318
- const blocks: string[] = [];
319
-
320
- const rules = cfg.rules ?? [{ userAgent: '*', allow: ['/'] }];
321
- for (const rule of rules) {
322
- const agents = rule.userAgent === undefined ? ['*'] : [rule.userAgent].flat();
323
- const lines = agents.map((a) => `User-agent: ${a}`);
324
- for (const p of rule.allow ?? []) lines.push(`Allow: ${p}`);
325
- for (const p of rule.disallow ?? []) lines.push(`Disallow: ${p}`);
326
- blocks.push(lines.join('\n'));
327
- }
328
-
329
- const aiDirective = cfg.ai === 'disallow' ? 'Disallow: /' : 'Allow: /';
330
- blocks.push(
331
- ['# AI / LLM crawlers', ...AI_CRAWLERS.map((a) => `User-agent: ${a}\n${aiDirective}`)].join(
332
- '\n\n',
333
- ),
334
- );
335
-
336
- const sitemap =
337
- cfg.sitemap ?? (seo.url !== undefined ? joinUrl(seo.url, 'sitemap.xml') : undefined);
338
- if (sitemap !== undefined) blocks.push(`Sitemap: ${sitemap}`);
339
-
340
- return blocks.join('\n\n') + '\n';
341
- }
342
-
343
- /**
344
- * `sitemap.xml` from the site's static routes plus any `extra` concrete paths (e.g. SSG URLs from
345
- * `generateStaticParams`); requires `seo.url`, empty when no base URL. `extra` is deduped against the
346
- * static paths.
347
- */
348
- export function sitemapXml(
349
- seo: SeoConfig,
350
- routes: readonly ScannedRoute[],
351
- extra: readonly string[] = [],
352
- ): string {
353
- if (seo.url === undefined || seo.sitemap === false) return '';
354
- const paths = [...new Set([...staticPaths(routes), ...extra])];
355
- const urls = paths
356
- .map((p) => ` <url><loc>${escapeHtml(joinUrl(seo.url ?? '', p))}</loc></url>`)
357
- .join('\n');
358
- return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`;
359
- }
360
-
361
- /** `llms.txt` (AI-crawler guidance) contents; empty when disabled. */
362
- export function llmsTxt(seo: SeoConfig, routes: readonly ScannedRoute[]): string {
363
- if (seo.llms === false) return '';
364
- const cfg: LlmsConfig = seo.llms === true || seo.llms === undefined ? {} : seo.llms;
365
- const title = cfg.title ?? seo.title ?? seo.url ?? 'Site';
366
- const out: string[] = [`# ${title}`];
367
- const summary = cfg.summary ?? seo.description;
368
- if (summary !== undefined) out.push(`\n> ${summary}`);
369
- if (cfg.instructions !== undefined) out.push(`\n${cfg.instructions}`);
370
-
371
- const pages: readonly LlmsPage[] =
372
- cfg.pages ??
373
- (seo.url !== undefined
374
- ? staticPaths(routes).map(
375
- (p): LlmsPage => ({
376
- title: p === '/' ? 'Home' : p,
377
- url: joinUrl(seo.url ?? '', p),
378
- }),
379
- )
380
- : []);
381
- if (pages.length) {
382
- out.push('\n## Pages\n');
383
- for (const page of pages) {
384
- out.push(
385
- `- [${page.title}](${page.url})${page.description !== undefined ? `: ${page.description}` : ''}`,
386
- );
387
- }
388
- }
389
- return out.join('\n') + '\n';
390
- }
1
+ import type { ScannedRoute } from './routes.js';
2
+
3
+ /**
4
+ * Build-time SEO for the (otherwise JS-only) SPA: bakes site-level metadata into the HTML `<head>`
5
+ * so JS-less crawlers and AI bots see real tags, and generates `robots.txt`, `sitemap.xml`, and
6
+ * `llms.txt`. All pure string builders here; `generate.ts` wires them into the build output.
7
+ */
8
+
9
+ /**
10
+ * OpenGraph defaults baked into the HTML, read by Facebook, Discord, Slack, LinkedIn, GitHub, and
11
+ * most link-preview crawlers. `image` should be an absolute URL (≥1200×630 for a large card).
12
+ */
13
+ export interface SeoOpenGraph {
14
+ readonly title?: string;
15
+ readonly description?: string;
16
+ /** `og:type`, e.g. `'website'` or `'article'`. */
17
+ readonly type?: string;
18
+ readonly siteName?: string;
19
+ /** `og:locale`, e.g. `'en_US'`. */
20
+ readonly locale?: string;
21
+ /** `og:image`, the preview image (absolute URL). */
22
+ readonly image?: string;
23
+ /** `og:image:alt`. */
24
+ readonly imageAlt?: string;
25
+ /** `og:image:width` in px (helps Facebook/LinkedIn render without a re-fetch). */
26
+ readonly imageWidth?: number;
27
+ /** `og:image:height` in px. */
28
+ readonly imageHeight?: number;
29
+ /** `og:image:type`, e.g. `'image/png'`. */
30
+ readonly imageType?: string;
31
+ }
32
+
33
+ /** Twitter / X card. Unset fields fall back to the OpenGraph / top-level values. */
34
+ export interface SeoTwitter {
35
+ /** `'summary'` | `'summary_large_image'` | … Defaults by whether an image is present. */
36
+ readonly card?: string;
37
+ readonly site?: string;
38
+ readonly creator?: string;
39
+ readonly title?: string;
40
+ readonly description?: string;
41
+ readonly image?: string;
42
+ readonly imageAlt?: string;
43
+ }
44
+
45
+ /** A `robots.txt` group. */
46
+ export interface RobotsRule {
47
+ readonly userAgent?: string | readonly string[];
48
+ readonly allow?: readonly string[];
49
+ readonly disallow?: readonly string[];
50
+ }
51
+
52
+ /** `robots.txt` configuration. */
53
+ export interface RobotsConfig {
54
+ readonly rules?: readonly RobotsRule[];
55
+ /** How to treat known AI crawlers (GPTBot, ClaudeBot, Google-Extended, …). Default `'allow'`. */
56
+ readonly ai?: 'allow' | 'disallow';
57
+ /** Explicit `Sitemap:` URL (defaults to `<url>/sitemap.xml` when `seo.url` is set). */
58
+ readonly sitemap?: string;
59
+ }
60
+
61
+ /** A page listed in `llms.txt`. */
62
+ export interface LlmsPage {
63
+ readonly title: string;
64
+ readonly url: string;
65
+ readonly description?: string;
66
+ }
67
+
68
+ /** `llms.txt` configuration (the AI-crawler guidance file). */
69
+ export interface LlmsConfig {
70
+ readonly title?: string;
71
+ readonly summary?: string;
72
+ /** Free-form instructions for AI/LLM crawlers. */
73
+ readonly instructions?: string;
74
+ /** Key pages; defaults to the site's static routes. */
75
+ readonly pages?: readonly LlmsPage[];
76
+ }
77
+
78
+ /** Build-time SEO configuration (under `client.seo`). */
79
+ export interface SeoConfig {
80
+ /** Absolute site base URL, e.g. `https://toil.dev`, required for `sitemap.xml` and canonical/OG URLs. */
81
+ readonly url?: string;
82
+ /** Default document title baked into the HTML. */
83
+ readonly title?: string;
84
+ /** Default meta description. */
85
+ readonly description?: string;
86
+ /** Default robots directive, e.g. `'index, follow'`. */
87
+ readonly robotsMeta?: string;
88
+ /** `<meta name="theme-color">`, also the accent color of Discord/Slack link embeds. */
89
+ readonly themeColor?: string;
90
+ readonly openGraph?: SeoOpenGraph;
91
+ readonly twitter?: SeoTwitter;
92
+ /** Facebook-specific tags (`fb:app_id`). OpenGraph above covers the rest of the FB card. */
93
+ readonly facebook?: { readonly appId?: string };
94
+ /** JSON-LD structured data injected as `<script type="application/ld+json">`. */
95
+ readonly jsonLd?: Record<string, unknown> | readonly Record<string, unknown>[];
96
+ /** Origins to `<link rel="preconnect">` (early connection hints). */
97
+ readonly preconnect?: readonly string[];
98
+ /** Origins to `<link rel="dns-prefetch">`. */
99
+ readonly dnsPrefetch?: readonly string[];
100
+ /** `robots.txt` generation; `false` to skip. */
101
+ readonly robots?: RobotsConfig | false;
102
+ /** `sitemap.xml` generation; defaults to on when `url` is set. */
103
+ readonly sitemap?: boolean;
104
+ /** `llms.txt` (AI guidance) generation; `false` to skip, `true`/object to configure. */
105
+ readonly llms?: LlmsConfig | boolean;
106
+ }
107
+
108
+ /** Known AI / LLM crawler user-agents, for explicit allow/disallow in `robots.txt`. */
109
+ const AI_CRAWLERS: readonly string[] = [
110
+ 'GPTBot',
111
+ 'OAI-SearchBot',
112
+ 'ChatGPT-User',
113
+ 'ClaudeBot',
114
+ 'Claude-Web',
115
+ 'anthropic-ai',
116
+ 'Google-Extended',
117
+ 'PerplexityBot',
118
+ 'CCBot',
119
+ 'Applebot-Extended',
120
+ 'Bytespider',
121
+ 'Amazonbot',
122
+ 'Meta-ExternalAgent',
123
+ ];
124
+
125
+ /** Escapes a value for use inside a double-quoted HTML attribute (prevents attribute-breakout XSS). */
126
+ function escapeAttr(value: string): string {
127
+ return value
128
+ .replace(/&/g, '&amp;')
129
+ .replace(/"/g, '&quot;')
130
+ .replace(/'/g, '&#39;')
131
+ .replace(/</g, '&lt;')
132
+ .replace(/>/g, '&gt;');
133
+ }
134
+ /** Escapes a value for HTML text content (e.g. `<title>`, XML text). */
135
+ export function escapeHtml(value: string): string {
136
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
137
+ }
138
+ /**
139
+ * Neutralizes a string for safe single-line Markdown interpolation (e.g. `llms.txt`): collapses
140
+ * newlines/control chars to a space and backslash-escapes the characters that would break out of a
141
+ * `[text](url)` link or start a new block (`[ ] ( ) < > \``). Without this, a page title/description
142
+ * from `generateMetadata` could inject extra list items, links, or headings.
143
+ */
144
+ function escapeMarkdownInline(value: string): string {
145
+ return value
146
+ .replace(/[\r\n\t\f\v]+/g, ' ')
147
+ .replace(/[\\[\]()<>`]/g, (c) => `\\${c}`)
148
+ .trim();
149
+ }
150
+ /** Escapes a URL for a Markdown link target: strips whitespace/control chars and parens. */
151
+ function escapeMarkdownUrl(value: string): string {
152
+ return value.replace(/\s+/g, '').replace(/[()<>]/g, encodeURIComponent);
153
+ }
154
+ /**
155
+ * Serializes a value for embedding in an inline `<script>` (JSON-LD). Escapes `<`, `>`, and `&`,
156
+ * which neutralizes `</script>` and `<!--` (the only HTML-significant sequences inside a script),
157
+ * so attacker-controlled data can't break out of the script element.
158
+ */
159
+ function escapeJsonForScript(value: unknown): string {
160
+ return JSON.stringify(value)
161
+ .replace(/</g, '\\u003c')
162
+ .replace(/>/g, '\\u003e')
163
+ .replace(/&/g, '\\u0026');
164
+ }
165
+ function meta(attrs: Record<string, string | number | undefined>): string {
166
+ const pairs = Object.entries(attrs)
167
+ .filter((entry): entry is [string, string | number] => entry[1] !== undefined)
168
+ .map(([k, v]) => `${k}="${escapeAttr(String(v))}"`);
169
+ return ` <meta ${pairs.join(' ')} />`;
170
+ }
171
+
172
+ /** Joins a base URL and a route path into a clean absolute URL. */
173
+ export function joinUrl(base: string, path: string): string {
174
+ return `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`.replace(/\/$/, '') || base;
175
+ }
176
+
177
+ /** Static (parameter-free) route patterns, the ones that can be listed in a sitemap. */
178
+ function staticPaths(routes: readonly ScannedRoute[]): string[] {
179
+ return routes
180
+ .filter((r) => r.slot === undefined && !r.intercept && !/[:*]/.test(r.pattern))
181
+ .map((r) => r.pattern)
182
+ .sort();
183
+ }
184
+
185
+ /** The site-level `<head>` fragment baked into the built HTML (title is handled separately). */
186
+ export function seoHeadTags(seo: SeoConfig): string {
187
+ const lines: string[] = [];
188
+ if (seo.description !== undefined)
189
+ lines.push(meta({ name: 'description', content: seo.description }));
190
+ if (seo.robotsMeta !== undefined) lines.push(meta({ name: 'robots', content: seo.robotsMeta }));
191
+ if (seo.themeColor !== undefined)
192
+ lines.push(meta({ name: 'theme-color', content: seo.themeColor }));
193
+ if (seo.url !== undefined)
194
+ lines.push(` <link rel="canonical" href="${escapeAttr(seo.url)}" />`);
195
+
196
+ // OpenGraph (also drives Facebook, Discord, Slack, LinkedIn, GitHub previews).
197
+ const og = seo.openGraph;
198
+ const ogTitle = og?.title ?? seo.title;
199
+ const ogDesc = og?.description ?? seo.description;
200
+ if (ogTitle !== undefined) lines.push(meta({ property: 'og:title', content: ogTitle }));
201
+ if (ogDesc !== undefined) lines.push(meta({ property: 'og:description', content: ogDesc }));
202
+ lines.push(meta({ property: 'og:type', content: og?.type ?? 'website' }));
203
+ if (seo.url !== undefined) lines.push(meta({ property: 'og:url', content: seo.url }));
204
+ if (og?.siteName !== undefined)
205
+ lines.push(meta({ property: 'og:site_name', content: og.siteName }));
206
+ if (og?.locale !== undefined) lines.push(meta({ property: 'og:locale', content: og.locale }));
207
+ if (og?.image !== undefined) {
208
+ lines.push(meta({ property: 'og:image', content: og.image }));
209
+ if (og.imageAlt !== undefined)
210
+ lines.push(meta({ property: 'og:image:alt', content: og.imageAlt }));
211
+ if (og.imageType !== undefined)
212
+ lines.push(meta({ property: 'og:image:type', content: og.imageType }));
213
+ if (og.imageWidth !== undefined)
214
+ lines.push(meta({ property: 'og:image:width', content: og.imageWidth }));
215
+ if (og.imageHeight !== undefined) {
216
+ lines.push(meta({ property: 'og:image:height', content: og.imageHeight }));
217
+ }
218
+ }
219
+ if (seo.facebook?.appId !== undefined) {
220
+ lines.push(meta({ property: 'fb:app_id', content: seo.facebook.appId }));
221
+ }
222
+
223
+ // Twitter / X card. Unset fields fall back to OpenGraph / top-level values.
224
+ const tw = seo.twitter;
225
+ if (tw) {
226
+ const twImage = tw.image ?? og?.image;
227
+ const card = tw.card ?? (twImage !== undefined ? 'summary_large_image' : 'summary');
228
+ lines.push(meta({ name: 'twitter:card', content: card }));
229
+ if (tw.site !== undefined) lines.push(meta({ name: 'twitter:site', content: tw.site }));
230
+ if (tw.creator !== undefined)
231
+ lines.push(meta({ name: 'twitter:creator', content: tw.creator }));
232
+ const twTitle = tw.title ?? ogTitle;
233
+ const twDesc = tw.description ?? ogDesc;
234
+ if (twTitle !== undefined) lines.push(meta({ name: 'twitter:title', content: twTitle }));
235
+ if (twDesc !== undefined)
236
+ lines.push(meta({ name: 'twitter:description', content: twDesc }));
237
+ if (twImage !== undefined) lines.push(meta({ name: 'twitter:image', content: twImage }));
238
+ const twImageAlt = tw.imageAlt ?? og?.imageAlt;
239
+ if (twImageAlt !== undefined)
240
+ lines.push(meta({ name: 'twitter:image:alt', content: twImageAlt }));
241
+ }
242
+
243
+ for (const origin of seo.preconnect ?? []) {
244
+ lines.push(` <link rel="preconnect" href="${escapeAttr(origin)}" />`);
245
+ }
246
+ for (const origin of seo.dnsPrefetch ?? []) {
247
+ lines.push(` <link rel="dns-prefetch" href="${escapeAttr(origin)}" />`);
248
+ }
249
+ if (seo.jsonLd !== undefined) {
250
+ lines.push(
251
+ ` <script type="application/ld+json">${escapeJsonForScript(seo.jsonLd)}</script>`,
252
+ );
253
+ }
254
+ return lines.join('\n');
255
+ }
256
+
257
+ /** The default document title to bake into the HTML, if any. */
258
+ export function seoTitle(seo: SeoConfig): string | undefined {
259
+ return seo.title;
260
+ }
261
+
262
+ /**
263
+ * Bakes the SEO `<head>` into an HTML document: replaces the existing `<title>` and `description`
264
+ * meta (so they aren't duplicated) and inserts the rest before `</head>`. Used for the shell and,
265
+ * per route, by the prerenderer.
266
+ */
267
+ export function injectSeoHtml(html: string, seo: SeoConfig): string {
268
+ let out = html;
269
+ const title = seoTitle(seo);
270
+ if (title !== undefined) {
271
+ const tag = `<title>${escapeHtml(title)}</title>`;
272
+ out = /<title>[\s\S]*?<\/title>/i.test(out)
273
+ ? out.replace(/<title>[\s\S]*?<\/title>/i, tag)
274
+ : out.replace(/<\/head>/i, ` ${tag}\n </head>`);
275
+ }
276
+ if (seo.description !== undefined) {
277
+ out = out.replace(/[ \t]*<meta\s+name=["']description["'][^>]*>\s*\n?/i, '');
278
+ }
279
+ const tags = seoHeadTags(seo);
280
+ if (tags) {
281
+ out = out.includes('</head>')
282
+ ? out.replace(/<\/head>/i, `${tags}\n </head>`)
283
+ : `${tags}\n${out}`;
284
+ }
285
+ return out;
286
+ }
287
+
288
+ function asString(value: unknown): string | undefined {
289
+ return typeof value === 'string' ? value : undefined;
290
+ }
291
+ function asRecord(value: unknown): Record<string, unknown> {
292
+ return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};
293
+ }
294
+
295
+ /**
296
+ * Overlays a route's extracted `metadata` (title/description/openGraph/…) onto the site-wide
297
+ * {@link SeoConfig}, and points the canonical/`og:url` at the route's own URL. The result is what
298
+ * the prerenderer bakes into that route's HTML, per-file metadata winning over the site defaults.
299
+ */
300
+ export function routeSeo(
301
+ seo: SeoConfig,
302
+ metadata: Record<string, unknown> | null,
303
+ pattern: string,
304
+ ): SeoConfig {
305
+ const routeUrl = seo.url !== undefined ? joinUrl(seo.url, pattern) : undefined;
306
+ if (!metadata) return { ...seo, url: routeUrl };
307
+ const og = asRecord(metadata.openGraph);
308
+ return {
309
+ ...seo,
310
+ url: asString(metadata.canonical) ?? routeUrl,
311
+ title: asString(metadata.title) ?? seo.title,
312
+ description: asString(metadata.description) ?? seo.description,
313
+ robotsMeta: asString(metadata.robots) ?? seo.robotsMeta,
314
+ themeColor: asString(metadata.themeColor) ?? seo.themeColor,
315
+ openGraph: {
316
+ ...seo.openGraph,
317
+ title: asString(og.title) ?? asString(metadata.title) ?? seo.openGraph?.title,
318
+ description:
319
+ asString(og.description) ??
320
+ asString(metadata.description) ??
321
+ seo.openGraph?.description,
322
+ type: asString(og.type) ?? seo.openGraph?.type,
323
+ image: asString(og.image) ?? seo.openGraph?.image,
324
+ imageAlt: asString(og.imageAlt) ?? seo.openGraph?.imageAlt,
325
+ siteName: asString(og.siteName) ?? seo.openGraph?.siteName,
326
+ },
327
+ };
328
+ }
329
+
330
+ /** `robots.txt` contents. */
331
+ export function robotsTxt(seo: SeoConfig): string {
332
+ if (seo.robots === false) return '';
333
+ const cfg: RobotsConfig = seo.robots ?? {};
334
+ const blocks: string[] = [];
335
+
336
+ const rules = cfg.rules ?? [{ userAgent: '*', allow: ['/'] }];
337
+ for (const rule of rules) {
338
+ const agents = rule.userAgent === undefined ? ['*'] : [rule.userAgent].flat();
339
+ const lines = agents.map((a) => `User-agent: ${a}`);
340
+ for (const p of rule.allow ?? []) lines.push(`Allow: ${p}`);
341
+ for (const p of rule.disallow ?? []) lines.push(`Disallow: ${p}`);
342
+ blocks.push(lines.join('\n'));
343
+ }
344
+
345
+ const aiDirective = cfg.ai === 'disallow' ? 'Disallow: /' : 'Allow: /';
346
+ blocks.push(
347
+ ['# AI / LLM crawlers', ...AI_CRAWLERS.map((a) => `User-agent: ${a}\n${aiDirective}`)].join(
348
+ '\n\n',
349
+ ),
350
+ );
351
+
352
+ const sitemap =
353
+ cfg.sitemap ?? (seo.url !== undefined ? joinUrl(seo.url, 'sitemap.xml') : undefined);
354
+ if (sitemap !== undefined) blocks.push(`Sitemap: ${sitemap}`);
355
+
356
+ return blocks.join('\n\n') + '\n';
357
+ }
358
+
359
+ /**
360
+ * `sitemap.xml` from the site's static routes plus any `extra` concrete paths (e.g. SSG URLs from
361
+ * `generateStaticParams`); requires `seo.url`, empty when no base URL. `extra` is deduped against the
362
+ * static paths.
363
+ */
364
+ export function sitemapXml(
365
+ seo: SeoConfig,
366
+ routes: readonly ScannedRoute[],
367
+ extra: readonly string[] = [],
368
+ ): string {
369
+ if (seo.url === undefined || seo.sitemap === false) return '';
370
+ const paths = [...new Set([...staticPaths(routes), ...extra])];
371
+ const urls = paths
372
+ .map((p) => ` <url><loc>${escapeHtml(joinUrl(seo.url ?? '', p))}</loc></url>`)
373
+ .join('\n');
374
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`;
375
+ }
376
+
377
+ /** `llms.txt` (AI-crawler guidance) contents; empty when disabled. */
378
+ export function llmsTxt(
379
+ seo: SeoConfig,
380
+ routes: readonly ScannedRoute[],
381
+ pages?: readonly LlmsPage[],
382
+ ): string {
383
+ if (seo.llms === false) return '';
384
+ const cfg: LlmsConfig = seo.llms === true || seo.llms === undefined ? {} : seo.llms;
385
+ const title = cfg.title ?? seo.title ?? seo.url ?? 'Site';
386
+ const out: string[] = [`# ${title}`];
387
+ const summary = cfg.summary ?? seo.description;
388
+ if (summary !== undefined) out.push(`\n> ${summary}`);
389
+ if (cfg.instructions !== undefined) out.push(`\n${cfg.instructions}`);
390
+
391
+ // Page list precedence: an explicit `seo.llms.pages`, else the build-supplied list (every route's
392
+ // resolved title/description, including SSG-enumerated dynamic pages), else just the static paths.
393
+ const resolvedPages: readonly LlmsPage[] =
394
+ cfg.pages ??
395
+ pages ??
396
+ (seo.url !== undefined
397
+ ? staticPaths(routes).map(
398
+ (p): LlmsPage => ({
399
+ title: p === '/' ? 'Home' : p,
400
+ url: joinUrl(seo.url ?? '', p),
401
+ }),
402
+ )
403
+ : []);
404
+ if (resolvedPages.length) {
405
+ out.push('\n## Pages\n');
406
+ for (const page of resolvedPages) {
407
+ const title = escapeMarkdownInline(page.title);
408
+ const url = escapeMarkdownUrl(page.url);
409
+ const desc =
410
+ page.description !== undefined
411
+ ? `: ${escapeMarkdownInline(page.description)}`
412
+ : '';
413
+ out.push(`- [${title}](${url})${desc}`);
414
+ }
415
+ }
416
+ return out.join('\n') + '\n';
417
+ }