feat: prerender crawlable portfolio artifacts
This commit is contained in:
199
scripts/prerender.mjs
Normal file
199
scripts/prerender.mjs
Normal file
@@ -0,0 +1,199 @@
|
||||
import { cp, lstat, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, relative, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { loadProductionInputs } from '../src/content/load-production-inputs.ts';
|
||||
import { createPageMetadata, createStaticHostArtifacts } from '../src/content/metadata.ts';
|
||||
import { serializeJsonLd } from '../src/security/serialize-json-ld.ts';
|
||||
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
const repositoryRoot = resolve(dirname(scriptPath), '..');
|
||||
|
||||
const escapeHtml = (value) => value
|
||||
.replace(/&/gu, '&')
|
||||
.replace(/</gu, '<')
|
||||
.replace(/>/gu, '>')
|
||||
.replace(/"/gu, '"');
|
||||
|
||||
const exactlyOne = (source, expression, label) => {
|
||||
const matches = source.match(expression) ?? [];
|
||||
if (matches.length !== 1) throw new Error(`Expected exactly one ${label}.`);
|
||||
};
|
||||
|
||||
export const parseExactConfigArgument = (arguments_) => {
|
||||
if (arguments_.length !== 2 || arguments_[0] !== '--config') {
|
||||
throw new Error('Prerender accepts exactly --config <path>.');
|
||||
}
|
||||
const configPath = arguments_[1];
|
||||
if (
|
||||
configPath.length === 0 ||
|
||||
configPath.startsWith('/') ||
|
||||
configPath.includes('\\') ||
|
||||
configPath.split('/').some((part) => part === '' || part === '.' || part === '..') ||
|
||||
!/^config\/[a-z0-9][a-z0-9._-]*\.json$/u.test(configPath)
|
||||
) {
|
||||
throw new Error('Config must be a safe repository-relative JSON path inside config/.');
|
||||
}
|
||||
return configPath;
|
||||
};
|
||||
|
||||
const manifestEntry = (manifest) => {
|
||||
const entries = Object.values(manifest).filter((entry) => entry.isEntry === true);
|
||||
if (entries.length !== 1) throw new Error('Vite manifest must contain exactly one client entry.');
|
||||
const entry = entries[0];
|
||||
if (typeof entry.file !== 'string' || !/^assets\/[a-zA-Z0-9._-]+\.js$/u.test(entry.file)) {
|
||||
throw new Error('Vite manifest client entry is unsafe.');
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
|
||||
const metadataMarkup = (metadata) => {
|
||||
const image = metadata.openGraph.image === undefined
|
||||
? ''
|
||||
: `\n <meta property="og:image" content="${escapeHtml(metadata.openGraph.image)}" />`;
|
||||
return [
|
||||
` <meta name="description" content="${escapeHtml(metadata.description)}" />`,
|
||||
` <meta name="robots" content="${escapeHtml(metadata.robots)}" />`,
|
||||
` <link rel="canonical" href="${escapeHtml(metadata.canonicalUrl)}" />`,
|
||||
` <meta property="og:type" content="${metadata.openGraph.type}" />`,
|
||||
` <meta property="og:title" content="${escapeHtml(metadata.openGraph.title)}" />`,
|
||||
` <meta property="og:description" content="${escapeHtml(metadata.openGraph.description)}" />`,
|
||||
` <meta property="og:url" content="${escapeHtml(metadata.openGraph.url)}" />${image}`,
|
||||
` <script type="application/ld+json">${metadata.serializedJsonLd}</script>`,
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
export const assemblePrerenderedHtml = (template, manifest, markup, inputs) => {
|
||||
const entry = manifestEntry(manifest);
|
||||
const entryUrl = `/${entry.file}`;
|
||||
exactlyOne(template, /<title>[^<]*<\/title>/gu, 'title');
|
||||
exactlyOne(template, /<div id="app"><\/div>/gu, 'empty app root');
|
||||
exactlyOne(template, /<script type="module"[^>]*><\/script>/gu, 'client module script');
|
||||
exactlyOne(template, /<!--portfolio-head-->/gu, 'portfolio head marker');
|
||||
exactlyOne(template, /<!--portfolio-body-->/gu, 'portfolio body marker');
|
||||
|
||||
const metadata = createPageMetadata(inputs.content, inputs.manifest, inputs.site);
|
||||
const snapshot = serializeJsonLd(inputs);
|
||||
const preloads = [entry.file, ...(entry.imports ?? []).map((key) => manifest[key]?.file)]
|
||||
.filter((file, index, files) => typeof file === 'string' && files.indexOf(file) === index)
|
||||
.map((file) => ` <link rel="modulepreload" href="/${file}" />`)
|
||||
.join('\n');
|
||||
const withoutModule = template.replace(/\s*<script type="module"[^>]*><\/script>/u, '');
|
||||
const withHead = withoutModule
|
||||
.replace(/<title>[^<]*<\/title>/u, `<title>${escapeHtml(metadata.title)}</title>`)
|
||||
.replace('<!--portfolio-head-->', `${preloads}\n${metadataMarkup(metadata)}`)
|
||||
.replace('<div id="app"></div>', `<div id="app">${markup}</div>`);
|
||||
const clientBootstrap = [
|
||||
`<script id="portfolio-inputs" type="application/json">${snapshot}</script>`,
|
||||
`<script type="module" crossorigin src="${entryUrl}"></script>`,
|
||||
].join('\n ');
|
||||
return withHead.replace('<!--portfolio-body-->', clientBootstrap);
|
||||
};
|
||||
|
||||
const assertDirectoryMembers = async (root, expected, label) => {
|
||||
const entries = await readdir(root, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!expected.has(entry.name)) throw new Error(`Unexpected build output in ${label}: ${entry.name}`);
|
||||
if (entry.isSymbolicLink()) throw new Error(`Unexpected linked build output in ${label}: ${entry.name}`);
|
||||
}
|
||||
for (const name of expected) {
|
||||
if (!entries.some((entry) => entry.name === name)) throw new Error(`Missing build output in ${label}: ${name}`);
|
||||
}
|
||||
return entries;
|
||||
};
|
||||
|
||||
const manifestAssetFiles = (manifest) => {
|
||||
const files = new Set();
|
||||
for (const entry of Object.values(manifest)) {
|
||||
for (const file of [entry.file, ...(entry.css ?? []), ...(entry.assets ?? [])]) {
|
||||
if (typeof file !== 'string' || !/^assets\/[a-zA-Z0-9._-]+$/u.test(file)) {
|
||||
throw new Error('Vite manifest contains an unsafe output path.');
|
||||
}
|
||||
files.add(file.slice('assets/'.length));
|
||||
}
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
export const assertExpectedDistInputs = async (distRoot) => {
|
||||
const allowed = new Set([
|
||||
'client', 'assets', 'index.html', 'robots.txt', 'sitemap.xml', '_headers.example', '_redirects.example',
|
||||
]);
|
||||
const rootEntries = await readdir(distRoot, { withFileTypes: true });
|
||||
for (const entry of rootEntries) {
|
||||
if (!allowed.has(entry.name)) throw new Error(`Unexpected build output: ${entry.name}`);
|
||||
if (entry.isSymbolicLink()) throw new Error(`Unexpected linked build output: ${entry.name}`);
|
||||
}
|
||||
const clientStats = await lstat(resolve(distRoot, 'client'));
|
||||
if (clientStats.isSymbolicLink() || !clientStats.isDirectory()) {
|
||||
throw new Error('Client output must be a non-symlink directory.');
|
||||
}
|
||||
|
||||
await assertDirectoryMembers(resolve(distRoot, 'client'), new Set(['.vite', 'assets', 'index.html']), 'client');
|
||||
await assertDirectoryMembers(resolve(distRoot, 'client/.vite'), new Set(['manifest.json']), 'client/.vite');
|
||||
const manifest = JSON.parse(await readFile(resolve(distRoot, 'client/.vite/manifest.json'), 'utf8'));
|
||||
await assertDirectoryMembers(resolve(distRoot, 'client/assets'), manifestAssetFiles(manifest), 'client/assets');
|
||||
|
||||
if (rootEntries.some((entry) => entry.name === 'assets')) {
|
||||
if (!rootEntries.some((entry) => entry.name === 'index.html')) {
|
||||
throw new Error('Existing root assets require their generated index.html inventory.');
|
||||
}
|
||||
const existingHtml = await readFile(resolve(distRoot, 'index.html'), 'utf8');
|
||||
const referencedAssets = new Set(
|
||||
[...existingHtml.matchAll(/(?:src|href)="\/assets\/([a-zA-Z0-9._-]+)"/gu)].map((match) => match[1]),
|
||||
);
|
||||
await assertDirectoryMembers(resolve(distRoot, 'assets'), referencedAssets, 'assets');
|
||||
}
|
||||
};
|
||||
|
||||
const assertInsideRepository = (path) => {
|
||||
const pathRelativeToRoot = relative(repositoryRoot, path);
|
||||
if (pathRelativeToRoot === '..' || pathRelativeToRoot.startsWith(`..${sep}`)) {
|
||||
throw new Error('Build input escaped the repository root.');
|
||||
}
|
||||
};
|
||||
|
||||
export const prerender = async (configPath) => {
|
||||
const distRoot = resolve(repositoryRoot, 'dist');
|
||||
const clientRoot = resolve(distRoot, 'client');
|
||||
const configAbsolutePath = resolve(repositoryRoot, configPath);
|
||||
assertInsideRepository(configAbsolutePath);
|
||||
await assertExpectedDistInputs(distRoot);
|
||||
|
||||
const inputs = await loadProductionInputs({
|
||||
contentJson: pathToFileURL(resolve(repositoryRoot, 'content/public/portfolio-content.json')),
|
||||
assetManifestJson: pathToFileURL(resolve(repositoryRoot, 'content/public/asset-manifest.json')),
|
||||
preparedAssetRoot: pathToFileURL(`${resolve(repositoryRoot, 'inputs/prepared-assets')}${sep}`),
|
||||
siteConfigJson: pathToFileURL(configAbsolutePath),
|
||||
});
|
||||
const [{ render }, template, manifestSource] = await Promise.all([
|
||||
import(pathToFileURL(resolve(repositoryRoot, 'dist-ssr/entry-server.js')).href),
|
||||
readFile(resolve(clientRoot, 'index.html'), 'utf8'),
|
||||
readFile(resolve(clientRoot, '.vite/manifest.json'), 'utf8'),
|
||||
]);
|
||||
if (typeof render !== 'function') throw new Error('SSR entry does not export render().');
|
||||
const manifest = JSON.parse(manifestSource);
|
||||
const markup = await render('/', inputs);
|
||||
const html = assemblePrerenderedHtml(template, manifest, markup, inputs);
|
||||
const metadata = createPageMetadata(inputs.content, inputs.manifest, inputs.site);
|
||||
const artifacts = createStaticHostArtifacts(metadata, inputs.site);
|
||||
|
||||
for (const path of ['assets', 'index.html', 'robots.txt', 'sitemap.xml', '_headers.example', '_redirects.example']) {
|
||||
await rm(resolve(distRoot, path), { recursive: true, force: true });
|
||||
}
|
||||
await cp(resolve(clientRoot, 'assets'), resolve(distRoot, 'assets'), { recursive: true, errorOnExist: true });
|
||||
await Promise.all([
|
||||
writeFile(resolve(distRoot, 'index.html'), html),
|
||||
writeFile(resolve(distRoot, 'robots.txt'), artifacts.robots),
|
||||
writeFile(resolve(distRoot, '_headers.example'), artifacts.headers),
|
||||
writeFile(resolve(distRoot, '_redirects.example'), artifacts.redirects),
|
||||
...(artifacts.sitemap === undefined
|
||||
? []
|
||||
: [writeFile(resolve(distRoot, 'sitemap.xml'), artifacts.sitemap)]),
|
||||
]);
|
||||
await rm(clientRoot, { recursive: true });
|
||||
};
|
||||
|
||||
if (process.argv[1] !== undefined && resolve(process.argv[1]) === scriptPath) {
|
||||
const configPath = parseExactConfigArgument(process.argv.slice(2));
|
||||
await prerender(configPath);
|
||||
}
|
||||
Reference in New Issue
Block a user