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, '"'); 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 .'); } 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 `; return [ ` `, ` `, ` `, ` `, ` `, ` `, ` ${image}`, ` `, ].join('\n'); }; export const assemblePrerenderedHtml = (template, manifest, markup, inputs) => { const entry = manifestEntry(manifest); const entryUrl = `/${entry.file}`; exactlyOne(template, /[^<]*<\/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)}`) .replace('', `${preloads}\n${metadataMarkup(metadata)}`) .replace('
', `
${markup}
`); const clientBootstrap = [ ``, ``, ].join('\n '); return withHead.replace('', 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); }