-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathprerender.mjs
More file actions
101 lines (87 loc) · 4.16 KB
/
Copy pathprerender.mjs
File metadata and controls
101 lines (87 loc) · 4.16 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
/**
* Writes a real HTML file for every indexable route, so the published site is
* a set of pages rather than one page pretending to be many.
*
* Two things were wrong without this. A crawler that doesn't run JavaScript
* saw index.html's site-wide title on all 44 URLs — and, worse, every URL
* except the home page was served through 404.html, so it answered with an
* HTTP 404. A URL that 404s does not get indexed, no matter what the sitemap
* says. Writing dist/shop/laptop/index.html makes that URL a 200 with its own
* title, description, canonical, social tags and JSON-LD.
*
* This is metadata prerendering, not server rendering: the <body> is still the
* empty #root div, and React fills it in exactly as before. That is enough for
* search engines (which execute JS to read the content) and for link
* unfurlers (which never do, and only ever read the head).
*
* Runs as part of `npm run build`, after vite build.
*/
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { absoluteUrl, buildRoutes, siteName } from "./lib/routes.mjs";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const dist = join(root, "dist");
const escapeAttr = (value) =>
String(value).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
/** Replaces the content="" of the one <meta> tag carrying `attr="name"`. */
function setMeta(html, attr, name, value) {
const tag = new RegExp(`<meta\\b[^>]*\\b${attr}="${name}"[^>]*>`, "s");
const match = html.match(tag);
if (!match) throw new Error(`prerender: no <meta ${attr}="${name}"> in index.html`);
const updated = match[0].replace(/content="[^"]*"/s, `content="${escapeAttr(value)}"`);
return html.replace(tag, updated);
}
function setCanonical(html, url) {
const tag = /<link\b[^>]*rel="canonical"[^>]*>/s;
if (!tag.test(html)) throw new Error("prerender: no <link rel=canonical> in index.html");
return html.replace(tag, `<link rel="canonical" href="${escapeAttr(url)}" />`);
}
function withJsonLd(html, blocks, path) {
const scripts = blocks
.map(
(block) =>
`<script type="application/ld+json" data-route="${escapeAttr(path)}">` +
// "<" inside a script element would end it early; JSON has no other
// way to produce a closing tag.
`${JSON.stringify(block).replace(/</g, "\\u003c")}</script>`
)
.join("\n ");
return html.replace("</head>", ` ${scripts}\n </head>`);
}
const template = readFileSync(join(dist, "index.html"), "utf8");
let written = 0;
for (const route of buildRoutes()) {
const url = absoluteUrl(route.path);
let html = template;
// The home page's title and description are already the site-wide ones in
// index.html; every other route overrides them the same way PageMeta does
// at runtime, so the static HTML and the booted app agree.
if (route.title) {
const fullTitle = `${route.title} | ${siteName}`;
html = html.replace(/<title>[\s\S]*?<\/title>/, `<title>${escapeAttr(fullTitle)}</title>`);
html = setMeta(html, "property", "og:title", fullTitle);
html = setMeta(html, "name", "twitter:title", fullTitle);
}
if (route.description) {
html = setMeta(html, "name", "description", route.description);
html = setMeta(html, "property", "og:description", route.description);
html = setMeta(html, "name", "twitter:description", route.description);
}
html = setCanonical(html, url);
html = setMeta(html, "property", "og:url", url);
html = withJsonLd(html, route.jsonLd, route.path);
const dir = route.path === "/" ? dist : join(dist, route.path);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "index.html"), html);
written++;
}
// vite.config.ts already wrote 404.html as a copy of the built index.html.
// Rewrite it from the untouched template instead: the fallback stands in for
// URLs that do not exist, so it must not carry the home page's canonical or
// structured data, and it should ask not to be indexed.
writeFileSync(
join(dist, "404.html"),
setMeta(template, "name", "robots", "noindex, follow")
);
console.log(`prerender: ${written} HTML files + 404.html`);