@gusnips/vite 0.1.0

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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +123 -0
  3. package/dist/escape.d.ts +6 -0
  4. package/dist/escape.d.ts.map +1 -0
  5. package/dist/escape.js +6 -0
  6. package/dist/escape.js.map +1 -0
  7. package/dist/head.d.ts +110 -0
  8. package/dist/head.d.ts.map +1 -0
  9. package/dist/head.js +183 -0
  10. package/dist/head.js.map +1 -0
  11. package/dist/index.d.ts +23 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +22 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/node.d.ts +57 -0
  16. package/dist/node.d.ts.map +1 -0
  17. package/dist/node.js +104 -0
  18. package/dist/node.js.map +1 -0
  19. package/dist/og.d.ts +70 -0
  20. package/dist/og.d.ts.map +1 -0
  21. package/dist/og.js +60 -0
  22. package/dist/og.js.map +1 -0
  23. package/dist/preset.d.ts +37 -0
  24. package/dist/preset.d.ts.map +1 -0
  25. package/dist/preset.js +43 -0
  26. package/dist/preset.js.map +1 -0
  27. package/dist/render.d.ts +36 -0
  28. package/dist/render.d.ts.map +1 -0
  29. package/dist/render.js +50 -0
  30. package/dist/render.js.map +1 -0
  31. package/dist/sitemap.d.ts +82 -0
  32. package/dist/sitemap.d.ts.map +1 -0
  33. package/dist/sitemap.js +91 -0
  34. package/dist/sitemap.js.map +1 -0
  35. package/package.json +105 -0
  36. package/src/escape.ts +8 -0
  37. package/src/head.test.ts +258 -0
  38. package/src/head.ts +288 -0
  39. package/src/index.ts +59 -0
  40. package/src/node.test.ts +102 -0
  41. package/src/node.ts +140 -0
  42. package/src/og.test.ts +53 -0
  43. package/src/og.ts +103 -0
  44. package/src/preset.ts +78 -0
  45. package/src/render.test.ts +67 -0
  46. package/src/render.ts +75 -0
  47. package/src/sitemap.test.ts +124 -0
  48. package/src/sitemap.ts +159 -0
package/package.json ADDED
@@ -0,0 +1,105 @@
1
+ {
2
+ "name": "@gusnips/vite",
3
+ "version": "0.1.0",
4
+ "description": "The build-time half of a prerendered Vite SPA: head baking, sitemap, robots, the prerender driver, and a shared vite preset.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Gustavo Salomé",
8
+ "homepage": "https://github.com/gusnips/frontkit/tree/main/vite#readme",
9
+ "bugs": {
10
+ "url": "https://github.com/gusnips/frontkit/issues"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ },
17
+ "./preset": {
18
+ "types": "./dist/preset.d.ts",
19
+ "default": "./dist/preset.js"
20
+ },
21
+ "./render": {
22
+ "types": "./dist/render.d.ts",
23
+ "default": "./dist/render.js"
24
+ }
25
+ },
26
+ "types": "./dist/index.d.ts",
27
+ "files": [
28
+ "dist",
29
+ "src",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "sideEffects": false,
34
+ "engines": {
35
+ "node": ">=22"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "keywords": [
41
+ "vite",
42
+ "prerender",
43
+ "ssg",
44
+ "sitemap",
45
+ "open-graph",
46
+ "seo",
47
+ "react"
48
+ ],
49
+ "scripts": {
50
+ "build": "tsc",
51
+ "typecheck": "tsc --noEmit",
52
+ "test": "vitest run --passWithNoTests",
53
+ "test:watch": "vitest",
54
+ "lint": "eslint src",
55
+ "sync:docs": "cp ../LICENSE .",
56
+ "prepublishOnly": "bun run lint && bun run typecheck && bun run test && bun run build && bun run sync:docs",
57
+ "release:patch": "bun pm version patch && bun publish --access public",
58
+ "release:minor": "bun pm version minor && bun publish --access public",
59
+ "release:major": "bun pm version major && bun publish --access public"
60
+ },
61
+ "dependencies": {
62
+ "@gusnips/react": "0.1.0"
63
+ },
64
+ "peerDependencies": {
65
+ "@tailwindcss/vite": "^4",
66
+ "@vitejs/plugin-react": "^6",
67
+ "react": "^19",
68
+ "react-dom": "^19",
69
+ "vite": "^8"
70
+ },
71
+ "peerDependenciesMeta": {
72
+ "@tailwindcss/vite": {
73
+ "optional": true
74
+ },
75
+ "@vitejs/plugin-react": {
76
+ "optional": true
77
+ },
78
+ "react": {
79
+ "optional": true
80
+ },
81
+ "react-dom": {
82
+ "optional": true
83
+ },
84
+ "vite": {
85
+ "optional": true
86
+ }
87
+ },
88
+ "devDependencies": {
89
+ "@tailwindcss/vite": "^4.2.2",
90
+ "@types/node": "^24.12.0",
91
+ "@types/react": "^19.2.14",
92
+ "@types/react-dom": "^19.2.3",
93
+ "@vitejs/plugin-react": "^6.0.1",
94
+ "react": "^19.2.4",
95
+ "react-dom": "^19.2.4",
96
+ "typescript": "^5.9.3",
97
+ "vite": "^8.0.1",
98
+ "vitest": "^4.1.2"
99
+ },
100
+ "repository": {
101
+ "type": "git",
102
+ "url": "git+https://github.com/gusnips/frontkit.git",
103
+ "directory": "vite"
104
+ }
105
+ }
package/src/escape.ts ADDED
@@ -0,0 +1,8 @@
1
+ /** Escaping shared by the head baker and the sitemap writer. Its own file so neither has to
2
+ * import the other, and so both stay free of every dependency this package has. */
3
+
4
+ /** `&` first, or the ampersands introduced by the later rules get escaped twice. */
5
+ export const escapeAttr = (s: string): string =>
6
+ s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
7
+
8
+ export const escapeRegex = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -0,0 +1,258 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { PRERENDERED_ROUTE_ATTR } from "@gusnips/react";
3
+ import { assertRendered, bakeHead, ogLocale } from "./head.ts";
4
+
5
+ /**
6
+ * Every failure `bakeHead` has is invisible until somebody reads a search result months later
7
+ * — a canonical that survived onto a 404, an `og:locale` still claiming English on a Portuguese
8
+ * file, a body that never went in. `assertRendered` checks a written page as a whole; this
9
+ * checks the branches it cannot see the inside of.
10
+ */
11
+
12
+ /** A template carrying the full donor tag set: OG, Twitter, and the `name="title"` two of the
13
+ * three donors ship. */
14
+ const TEMPLATE = `<!doctype html>
15
+ <html lang="en">
16
+ <head>
17
+ <title>front</title>
18
+ <meta name="title" content="front" />
19
+ <meta name="description" content="front" />
20
+ <link rel="canonical" href="https://acme.com/" />
21
+ <meta property="og:title" content="front" />
22
+ <meta property="og:description" content="front" />
23
+ <meta property="og:url" content="https://acme.com/" />
24
+ <meta property="og:image" content="https://acme.com/og.png" />
25
+ <meta property="twitter:title" content="front" />
26
+ <meta property="twitter:description" content="front" />
27
+ <meta property="twitter:url" content="https://acme.com/" />
28
+ <meta property="twitter:image" content="https://acme.com/og.png" />
29
+ </head>
30
+ <body>
31
+ <div id="root"></div>
32
+ </body>
33
+ </html>`;
34
+
35
+ /** The leaner set a third donor ships: no `name="title"`, no `twitter:` pair. */
36
+ const LEAN_TEMPLATE = `<!doctype html>
37
+ <html lang="en">
38
+ <head>
39
+ <title>front</title>
40
+ <meta name="description" content="front" />
41
+ <link rel="canonical" href="https://acme.com/" />
42
+ <meta property="og:title" content="front" />
43
+ <meta property="og:description" content="front" />
44
+ <meta property="og:url" content="https://acme.com/" />
45
+ </head>
46
+ <body>
47
+ <div id="root"></div>
48
+ </body>
49
+ </html>`;
50
+
51
+ const BASE = { title: "T", description: "D", canonical: "https://acme.com/pricing" };
52
+
53
+ describe("bakeHead", () => {
54
+ it("writes the page's own title, description and share text", () => {
55
+ const html = bakeHead(TEMPLATE, { ...BASE, ogTitle: "share", ogDescription: "sub" });
56
+ expect(html).toContain("<title>T</title>");
57
+ expect(html).toContain('<meta name="description" content="D" />');
58
+ expect(html).toContain('<meta property="og:title" content="share" />');
59
+ expect(html).toContain('<meta property="twitter:description" content="sub" />');
60
+ expect(html).toContain('<link rel="canonical" href="https://acme.com/pricing" />');
61
+ expect(html).toContain('<meta property="og:url" content="https://acme.com/pricing" />');
62
+ });
63
+
64
+ it("falls back to the page's own words for the share tags", () => {
65
+ const html = bakeHead(TEMPLATE, BASE);
66
+ expect(html).toContain('<meta property="og:title" content="T" />');
67
+ expect(html).toContain('<meta property="twitter:title" content="T" />');
68
+ });
69
+
70
+ it("escapes what it writes into an attribute", () => {
71
+ const html = bakeHead(TEMPLATE, { ...BASE, description: 'a "quote" & <tag>' });
72
+ expect(html).toContain('content="a &quot;quote&quot; &amp; &lt;tag&gt;"');
73
+ });
74
+
75
+ // The whole reason `setMeta` throws: editing index.html must fail the build, never ship a
76
+ // page quietly wearing the front door's description.
77
+ it("refuses a template missing a tag a crawler depends on", () => {
78
+ const stripped = TEMPLATE.replace(/<meta property="og:title"[^>]*>/, "");
79
+ expect(() => bakeHead(stripped, BASE)).toThrow(/og:title/);
80
+ });
81
+
82
+ // …and the exception: nothing reads `name="title"`, and X reads `og:` when `twitter:` is
83
+ // absent. A template without them is correct, so demanding them would break a real app.
84
+ it("skips the optional tags a template does not carry", () => {
85
+ const html = bakeHead(LEAN_TEMPLATE, BASE);
86
+ expect(html).toContain('<meta property="og:title" content="T" />');
87
+ expect(html).not.toContain("twitter:");
88
+ expect(html).not.toContain('name="title"');
89
+ });
90
+
91
+ it("strips the canonical and the share URL rather than blanking them", () => {
92
+ const html = bakeHead(TEMPLATE, { ...BASE, canonical: null, noindex: true });
93
+ expect(html).not.toContain('rel="canonical"');
94
+ expect(html).not.toContain('property="og:url"');
95
+ expect(html).not.toContain('property="twitter:url"');
96
+ expect(html).toContain('<meta name="robots" content="noindex" />');
97
+ });
98
+
99
+ // An empty canonical is a claim about "" and an empty og:url is a share card pointing at the
100
+ // origin root, which is what the strip exists to avoid.
101
+ it("leaves no blank URL behind on a page with no canonical", () => {
102
+ const html = bakeHead(TEMPLATE, { ...BASE, canonical: null });
103
+ expect(html).not.toContain('href=""');
104
+ expect(html).not.toContain('content=""');
105
+ });
106
+
107
+ // The card is a picture, not a claim about this address: a dead link that still unfurls the
108
+ // brand card is fine, and a 404 with no image at all is not better.
109
+ it("keeps the share card on a page with no canonical", () => {
110
+ const html = bakeHead(TEMPLATE, { ...BASE, canonical: null });
111
+ expect(html).toContain('<meta property="og:image" content="https://acme.com/og.png" />');
112
+ });
113
+
114
+ it("lists every alternate, reciprocally, including this page's own", () => {
115
+ const html = bakeHead(TEMPLATE, {
116
+ ...BASE,
117
+ alternates: [
118
+ { hreflang: "en", href: "https://acme.com/pricing" },
119
+ { hreflang: "pt-BR", href: "https://acme.com/pt/pricing" },
120
+ { hreflang: "x-default", href: "https://acme.com/pricing" },
121
+ ],
122
+ });
123
+ expect(html.match(/rel="alternate"/g)).toHaveLength(3);
124
+ expect(html).toContain('hreflang="pt-BR" href="https://acme.com/pt/pricing"');
125
+ });
126
+
127
+ it("marks the file's own language for a crawler and a screen reader", () => {
128
+ const html = bakeHead(TEMPLATE, { ...BASE, lang: "pt-BR" });
129
+ expect(html).toContain('<html lang="pt-BR">');
130
+ });
131
+
132
+ it("refuses a template whose <html> has no lang to rewrite", () => {
133
+ const stripped = TEMPLATE.replace('<html lang="en">', "<html>");
134
+ expect(() => bakeHead(stripped, { ...BASE, lang: "pt-BR" })).toThrow(/<html lang>/);
135
+ });
136
+
137
+ // Invariant 6: absent, `og:locale` does not default to "unknown" — it defaults to en_US, so
138
+ // a Portuguese page with a Portuguese og:title told every share crawler the card was English.
139
+ it("states og:locale, and the other languages the page exists in", () => {
140
+ const html = bakeHead(TEMPLATE, {
141
+ ...BASE,
142
+ lang: "pt-BR",
143
+ alternates: [
144
+ { hreflang: "en", href: "https://acme.com/pricing" },
145
+ { hreflang: "pt-BR", href: "https://acme.com/pt/pricing" },
146
+ { hreflang: "x-default", href: "https://acme.com/pricing" },
147
+ ],
148
+ });
149
+ expect(html).toContain('<meta property="og:locale" content="pt_BR" />');
150
+ expect(html).toContain('<meta property="og:locale:alternate" content="en_US" />');
151
+ // Not itself, and not x-default — neither is another language this page exists in.
152
+ expect(html.match(/og:locale:alternate/g)).toHaveLength(1);
153
+ });
154
+
155
+ // A template that already carries one would otherwise end up with two, and a crawler reading
156
+ // the first would get the template's language on every page.
157
+ it("replaces an og:locale the template already carries", () => {
158
+ const withLocale = TEMPLATE.replace(
159
+ "</head>",
160
+ ' <meta property="og:locale" content="en_US" />\n </head>',
161
+ );
162
+ const html = bakeHead(withLocale, { ...BASE, lang: "pt-BR" });
163
+ expect(html.match(/property="og:locale"/g)).toHaveLength(1);
164
+ expect(html).toContain('content="pt_BR"');
165
+ });
166
+
167
+ it("puts the render inside the root and names the route it is a render of", () => {
168
+ const html = bakeHead(TEMPLATE, {
169
+ ...BASE,
170
+ body: { route: "/pricing", html: "<main>hi</main>" },
171
+ });
172
+ expect(html).toContain(
173
+ `<div id="root" ${PRERENDERED_ROUTE_ATTR}="/pricing"><main>hi</main></div>`,
174
+ );
175
+ });
176
+
177
+ it("refuses to bake a page into a page", () => {
178
+ const once = bakeHead(TEMPLATE, { ...BASE, body: { route: "/", html: "<main>hi</main>" } });
179
+ expect(() =>
180
+ bakeHead(once, { ...BASE, body: { route: "/x", html: "<main>x</main>" } }),
181
+ ).toThrow(/root/);
182
+ });
183
+
184
+ it("carries structured data through untouched", () => {
185
+ const html = bakeHead(TEMPLATE, {
186
+ ...BASE,
187
+ headExtra: '<script type="application/ld+json">{"@type":"FAQPage"}</script>',
188
+ });
189
+ expect(html).toContain('<script type="application/ld+json">{"@type":"FAQPage"}</script>');
190
+ });
191
+ });
192
+
193
+ describe("ogLocale", () => {
194
+ it("gives en and es the territory Open Graph will not infer", () => {
195
+ expect(ogLocale("en")).toBe("en_US");
196
+ expect(ogLocale("es")).toBe("es_ES");
197
+ });
198
+
199
+ it("respells a tag that already carries its territory", () => {
200
+ expect(ogLocale("pt-BR")).toBe("pt_BR");
201
+ });
202
+ });
203
+
204
+ describe("assertRendered", () => {
205
+ const shell = `<html lang="en"><head><title>T</title></head><body><div id="root"></div></body></html>`;
206
+ const page = (body: string): string => shell.replace('<div id="root"></div>', body);
207
+ const long = `<main>${"x".repeat(800)}</main>`;
208
+
209
+ it("passes a page with a real body in it", () => {
210
+ expect(() =>
211
+ assertRendered("pricing.html", page(`<div id="root">${long}</div>`), shell),
212
+ ).not.toThrow();
213
+ });
214
+
215
+ // Invariant 1: a router whose basename does not match its location renders "" with no error
216
+ // and no warning. Only the bytes catch it.
217
+ it("catches a root that rendered to nothing", () => {
218
+ expect(() => assertRendered("pricing.html", shell, shell)).toThrow(/nothing rendered/);
219
+ });
220
+
221
+ // The size floor is not enough, and a donor proved it: a route rendered its LoadingScreen at
222
+ // 1,174 bytes, comfortably over the floor, with an element inside the root and a good title.
223
+ it("catches a file whose body is the loading screen", () => {
224
+ const spinner = `<div id="root"><div role="status" aria-label="Loading">${"x".repeat(800)}</div></div>`;
225
+ expect(() => assertRendered("pricing.html", page(spinner), shell)).toThrow(/loading screen/);
226
+ });
227
+
228
+ // The reason the check slices rather than matching in one pattern: a regex that skips
229
+ // Suspense markers can backtrack across the whole document and find a `role="status"` 40 KB
230
+ // down in a perfectly good page.
231
+ it("does not mistake a status role deep in a real page for a spinner", () => {
232
+ const deep = `<div id="root"><main>${"x".repeat(2000)}<div role="status">saved</div></main></div>`;
233
+ expect(() => assertRendered("pricing.html", page(deep), shell)).not.toThrow();
234
+ });
235
+
236
+ it("sees past React's Suspense comment markers", () => {
237
+ const suspended = `<div id="root"><!--$--><main>${"x".repeat(800)}</main><!--/$--></div>`;
238
+ expect(() => assertRendered("pricing.html", page(suspended), shell)).not.toThrow();
239
+ });
240
+
241
+ it("catches an unsubstituted html placeholder", () => {
242
+ const raw = page(`<div id="root"><main>%BRAND_NAME% ${"x".repeat(800)}</main></div>`);
243
+ expect(() => assertRendered("pricing.html", raw, shell)).toThrow(/%BRAND_NAME%/);
244
+ });
245
+
246
+ // `/caf%C3%A9` contains `%C3%`, and an accented slug is not a broken build.
247
+ it("does not mistake a percent-encoded link for a placeholder", () => {
248
+ const link = `<div id="root"><main><a href="/caf%C3%A9">x</a>${"x".repeat(800)}</main></div>`;
249
+ expect(() => assertRendered("pricing.html", page(link), shell)).not.toThrow();
250
+ });
251
+
252
+ it("catches a file that is not marked as the language it was baked for", () => {
253
+ const html = page(`<div id="root">${long}</div>`);
254
+ expect(() => assertRendered("pt/pricing.html", html, shell, { lang: "pt-BR" })).toThrow(
255
+ /not marked as pt-BR/,
256
+ );
257
+ });
258
+ });
package/src/head.ts ADDED
@@ -0,0 +1,288 @@
1
+ /**
2
+ * The head a crawler sees, baked at build time.
3
+ *
4
+ * A Vite SPA ships one `index.html` and draws everything else with JavaScript. Nothing that
5
+ * reads a link for a living runs that bundle — not Google's first pass, not Bing, not an LLM
6
+ * crawler, not whatever draws the preview card in Slack. So the build rewrites the template
7
+ * once per route and writes a real file per page, with a real `<head>` AND a real body.
8
+ *
9
+ * Pure string work, on purpose: no `node:` import anywhere in this file, so every branch is
10
+ * unit-testable without a filesystem. The reading and writing lives in `node.ts`.
11
+ */
12
+ // The `/contract` subpath, not the barrel: the barrel reaches `react-dom/client` through
13
+ // `hydrate.ts`, and a build script asking for one string should not pull the browser renderer
14
+ // into a Node process. That file imports nothing at all.
15
+ import { PRERENDERED_ROUTE_ATTR } from "@gusnips/react/contract";
16
+ import { escapeAttr, escapeRegex } from "./escape.ts";
17
+
18
+ /** The empty root a template must carry — `bakeHead` fills it, and refuses a filled one. */
19
+ export const EMPTY_ROOT = '<div id="root"></div>';
20
+
21
+ /** `[^>]*` on both sides tolerates the attributes being split across lines, as Vite emits them. */
22
+ const metaRe = (selector: string): RegExp =>
23
+ new RegExp(`(<meta[^>]*${escapeRegex(selector)}[^>]*content=")[^"]*(")`);
24
+
25
+ /**
26
+ * Replace the `content` of one `<meta …>`. Throws when the tag is missing, so an edit to
27
+ * `index.html` fails the build instead of silently shipping the front door's description to
28
+ * every crawler.
29
+ */
30
+ function setMeta(html: string, selector: string, value: string): string {
31
+ const re = metaRe(selector);
32
+ if (!re.test(html)) throw new Error(`prerender: <meta ${selector}> not found in index.html`);
33
+ return html.replace(re, `$1${escapeAttr(value)}$2`);
34
+ }
35
+
36
+ /**
37
+ * The same, for a tag a template is allowed not to carry.
38
+ *
39
+ * Only the `twitter:` pair and `name="title"` get this. X reads the `og:` tags when the
40
+ * `twitter:` ones are absent, and no crawler reads `name="title"` at all — so a template that
41
+ * omits them is correct, and demanding them would break an app that never had them. A template
42
+ * that DOES carry one and gets a stale value is still a bug, which is why they are written
43
+ * rather than ignored. Everything a crawler actually depends on goes through `setMeta`.
44
+ */
45
+ function setMetaIfPresent(html: string, selector: string, value: string): string {
46
+ const re = metaRe(selector);
47
+ return re.test(html) ? html.replace(re, `$1${escapeAttr(value)}$2`) : html;
48
+ }
49
+
50
+ /** One `<link rel="alternate" hreflang>` — this page's address in another language. */
51
+ export interface Alternate {
52
+ /** A BCP-47 tag, or `x-default` for the address a crawler should show when it has no reason
53
+ * to prefer one. */
54
+ hreflang: string;
55
+ href: string;
56
+ }
57
+
58
+ export interface HeadTags {
59
+ /** `<title>`, and the share title unless overridden. */
60
+ title: string;
61
+ /** `name="description"`, and the share description unless overridden. */
62
+ description: string;
63
+ /**
64
+ * The page's own URL, for `<link rel="canonical">` and the share tags.
65
+ *
66
+ * `null` means this page has no canonical URL and must not claim one — the 404 shell, which
67
+ * is served for every address that does not exist and would otherwise tell a crawler that
68
+ * all of them are the front page.
69
+ */
70
+ canonical: string | null;
71
+ ogTitle?: string;
72
+ ogDescription?: string;
73
+ /** Absolute URL of the share card. Omit where the template carries no `og:image` tag at all
74
+ * — setting one there is a build error, by design. */
75
+ image?: string;
76
+ /** Markup inserted before `</head>` — structured data, and nothing else so far. Emitted here
77
+ * because its only reader is a crawler. */
78
+ headExtra?: string;
79
+ /** Keep this page out of every index. Pairs with `canonical: null`. */
80
+ noindex?: boolean;
81
+ /**
82
+ * `<html lang>`, when this page is not in the template's language, and the `og:locale` that
83
+ * goes with it.
84
+ *
85
+ * A crawler and a screen reader both read `lang`, and neither runs the bundle that would
86
+ * otherwise set it — so a Portuguese file whose `<html>` still says `en` is announced in the
87
+ * wrong voice and indexed as the wrong language.
88
+ */
89
+ lang?: string;
90
+ /**
91
+ * Every language this page exists in, INCLUDING this one, plus `x-default`.
92
+ *
93
+ * Reciprocal by contract: a crawler ignores the whole set unless each address in it points
94
+ * back at the others, which is why the caller passes the full list to every page rather than
95
+ * "the other two".
96
+ */
97
+ alternates?: readonly Alternate[];
98
+ /**
99
+ * The rendered page, injected into `<div id="root">`, and the route it is a render OF.
100
+ *
101
+ * The head alone was never enough. A shipped `<div id="root"></div>` is a page whose entire
102
+ * content is a description tag — it can be listed, and it can never be read, quoted or
103
+ * answered from.
104
+ *
105
+ * The two travel together because markup alone is not enough to hydrate against. A static
106
+ * host answers every unpublished address with the nearest `404.html`, and that file has a
107
+ * rendered body in it — so a route served from the shell would find a full root and hydrate
108
+ * the not-found page into a page that is not it. The marker is what the browser entry
109
+ * compares its own route against before deciding to hydrate or to mount fresh.
110
+ */
111
+ body?: { route: string; html: string };
112
+ }
113
+
114
+ /**
115
+ * A BCP-47 tag in Open Graph's spelling: underscore, and a TERRITORY it will not infer.
116
+ *
117
+ * `og:locale` wants `language_TERRITORY` and quietly ignores anything else, which is the same
118
+ * outcome as omitting it — the `en_US` default. So a Portuguese page with a Portuguese
119
+ * `og:title` and no `og:locale` tells every share crawler the card is English. `pt-BR` already
120
+ * carries its territory; `en` and `es` do not, so one is chosen here rather than left to a
121
+ * crawler. `es_ES` is not a claim that the copy is peninsular Spanish — it is the most widely
122
+ * recognised Spanish value, and the tag's job is to be understood, not precise about dialect.
123
+ */
124
+ export function ogLocale(tag: string): string {
125
+ const TERRITORY: Record<string, string> = { en: "en_US", es: "es_ES" };
126
+ return TERRITORY[tag] ?? tag.replace("-", "_");
127
+ }
128
+
129
+ /**
130
+ * One page's `<head>` and body, written into the built template.
131
+ *
132
+ * Every tag is SET rather than appended, and `setMeta` throws on a tag the template does not
133
+ * carry — so the failure mode of editing `index.html` is a red build, never a page quietly
134
+ * shipping somebody else's description.
135
+ */
136
+ export function bakeHead(template: string, tags: HeadTags): string {
137
+ const shareTitle = tags.ogTitle ?? tags.title;
138
+ const shareDescription = tags.ogDescription ?? tags.description;
139
+
140
+ let html = template.replace(/<title>[^<]*<\/title>/, `<title>${escapeAttr(tags.title)}</title>`);
141
+ html = setMeta(html, 'name="description"', tags.description);
142
+ html = setMeta(html, 'property="og:title"', shareTitle);
143
+ html = setMeta(html, 'property="og:description"', shareDescription);
144
+ html = setMetaIfPresent(html, 'name="title"', tags.title);
145
+ html = setMetaIfPresent(html, 'property="twitter:title"', shareTitle);
146
+ html = setMetaIfPresent(html, 'property="twitter:description"', shareDescription);
147
+
148
+ if (tags.image !== undefined) {
149
+ html = setMeta(html, 'property="og:image"', tags.image);
150
+ html = setMetaIfPresent(html, 'property="twitter:image"', tags.image);
151
+ }
152
+
153
+ if (tags.canonical === null) {
154
+ // Strip rather than blank: an empty canonical is a claim about "" and an empty og:url is a
155
+ // share card pointing at the origin root. The image is left alone — it is a picture, not a
156
+ // claim about this address, and a dead link that still unfurls the brand card is fine.
157
+ html = html
158
+ .replace(/\s*<link rel="canonical"[^>]*>/, "")
159
+ .replace(/\s*<meta property="(?:og|twitter):url"[^>]*>/g, "");
160
+ } else {
161
+ html = setMeta(html, 'property="og:url"', tags.canonical);
162
+ html = setMetaIfPresent(html, 'property="twitter:url"', tags.canonical);
163
+ html = html.replace(
164
+ /(<link rel="canonical" href=")[^"]*(")/,
165
+ `$1${escapeAttr(tags.canonical)}$2`,
166
+ );
167
+ }
168
+
169
+ if (tags.alternates?.length) {
170
+ const links = tags.alternates
171
+ .map(
172
+ (alt) =>
173
+ ` <link rel="alternate" hreflang="${escapeAttr(alt.hreflang)}" href="${escapeAttr(alt.href)}" />`,
174
+ )
175
+ .join("\n");
176
+ html = html.replace("</head>", `${links}\n </head>`);
177
+ }
178
+
179
+ if (tags.lang !== undefined) {
180
+ // `og:locale` is not the same claim as `<html lang>` and is read by different machines.
181
+ // APPENDED rather than set, because a Vite template carries no such tag — but one that
182
+ // does would end up with two, and a crawler reading the first would get the template's
183
+ // language on every page. So drop whatever is there before writing this page's own.
184
+ html = html.replace(/\s*<meta property="og:locale(?::alternate)?"[^>]*>/g, "");
185
+ const alternates = (tags.alternates ?? [])
186
+ .filter((alt) => alt.hreflang !== "x-default" && alt.hreflang !== tags.lang)
187
+ .map(
188
+ (alt) =>
189
+ ` <meta property="og:locale:alternate" content="${escapeAttr(ogLocale(alt.hreflang))}" />`,
190
+ );
191
+ html = html.replace(
192
+ "</head>",
193
+ ` <meta property="og:locale" content="${escapeAttr(ogLocale(tags.lang))}" />\n${
194
+ alternates.length ? `${alternates.join("\n")}\n` : ""
195
+ } </head>`,
196
+ );
197
+ }
198
+
199
+ if (tags.noindex)
200
+ html = html.replace("</head>", ` <meta name="robots" content="noindex" />\n </head>`);
201
+ if (tags.headExtra) html = html.replace("</head>", ` ${tags.headExtra}\n </head>`);
202
+
203
+ if (tags.lang !== undefined) {
204
+ const re = /(<html[^>]*\blang=")[^"]*(")/;
205
+ if (!re.test(html)) throw new Error("prerender: <html lang> not found in index.html");
206
+ html = html.replace(re, `$1${escapeAttr(tags.lang)}$2`);
207
+ }
208
+
209
+ if (tags.body !== undefined) {
210
+ // Empty on purpose in the template, and it must STAY empty there: a second bake over an
211
+ // already-filled root would nest one render inside another.
212
+ if (!html.includes(EMPTY_ROOT))
213
+ throw new Error(`prerender: ${EMPTY_ROOT} not found in index.html`);
214
+ html = html.replace(
215
+ EMPTY_ROOT,
216
+ `<div id="root" ${PRERENDERED_ROUTE_ATTR}="${escapeAttr(tags.body.route)}">${tags.body.html}</div>`,
217
+ );
218
+ }
219
+
220
+ return html;
221
+ }
222
+
223
+ export interface RenderedChecks {
224
+ /** The BCP-47 tag this file must be marked with — the same `lang` passed to `bakeHead`. */
225
+ lang?: string;
226
+ /** Bytes the page must have gained over the template before it counts as rendered.
227
+ * 500 is the floor both donor builds ran with. */
228
+ minGrowth?: number;
229
+ }
230
+
231
+ /**
232
+ * What every written file must be true of before the build is allowed to pass.
233
+ *
234
+ * The regression this exists for is a root that renders to nothing. A router whose location
235
+ * does not match its routes yields empty markup with no error and no warning — it looks fine
236
+ * in every browser and is invisible to everything that reads a link. Checking the bytes we
237
+ * actually wrote is the only thing that catches it, and it needs no browser, so it gates the
238
+ * BUILD rather than sitting in a test suite.
239
+ *
240
+ * The loading-screen check is the half a size floor misses. With `lazy()` routes behind one
241
+ * `<Suspense fallback={<Spinner />}>`, a render that resolves nothing still produces a
242
+ * plausible body: one donor shipped a 1,174-byte spinner with a perfect title and an element
243
+ * inside the root. A file whose body is a spinner is WORSE than an empty one, because every
244
+ * cheap check passes.
245
+ */
246
+ export function assertRendered(
247
+ file: string,
248
+ html: string,
249
+ template: string,
250
+ checks: RenderedChecks = {},
251
+ ): void {
252
+ function fail(why: string): never {
253
+ throw new Error(`prerender: ${file} ${why}`);
254
+ }
255
+
256
+ // Measured against the template rather than by matching the root's closing tag — the body is
257
+ // thousands of nested `</div>`s and no regex should be asked to find the right one.
258
+ // Everything this file has over the shell it was baked from is the page.
259
+ const floor = checks.minGrowth ?? 500;
260
+ const grew = html.length - template.length;
261
+ if (grew < floor) fail(`is only ${String(grew)} bytes bigger than the shell — nothing rendered`);
262
+ if (/<title>\s*<\/title>/.test(html)) fail("has an empty <title>");
263
+ if (checks.lang !== undefined && !html.includes(`<html lang="${checks.lang}"`))
264
+ fail(`is not marked as ${checks.lang}`);
265
+
266
+ // A `%NAME%` the HTML transform never filled in. Three inner characters minimum, because two
267
+ // is the shape of percent-encoding: `/caf%C3%A9` contains `%C3%`, and an accented slug is not
268
+ // a broken build.
269
+ const placeholder = /%[A-Z][A-Z0-9_]{2,}%/.exec(html);
270
+ if (placeholder) fail(`still carries an unsubstituted placeholder, ${placeholder[0]}`);
271
+
272
+ // What the root ACTUALLY opens with. Sliced rather than matched in one pattern, because a
273
+ // regex that skips React's `<!--$-->` Suspense markers with `(?:<!--.*?-->|\s)*` can backtrack
274
+ // across the whole document — it will happily skip 40 KB of real page to find a
275
+ // `role="status"` further down and report a perfectly good file as a spinner. It did exactly
276
+ // that in the donor before this was rewritten as a slice.
277
+ const opened = /<div id="root"[^>]*>/.exec(html);
278
+ if (!opened) fail("has no root element at all");
279
+ const start = opened.index + opened[0].length;
280
+ const head = html
281
+ .slice(start, start + 600)
282
+ .replace(/<!--.*?-->/g, "")
283
+ .trimStart();
284
+
285
+ if (!/^<[a-z]/.test(head)) fail("has no element inside its root");
286
+ if (/^<[^>]*role="status"/.test(head))
287
+ fail("rendered the loading screen, not the page — something suspended and never resolved");
288
+ }