feat: prerender crawlable portfolio artifacts
This commit is contained in:
@@ -4,9 +4,11 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Portfolio</title>
|
<title>Portfolio</title>
|
||||||
|
<!--portfolio-head-->
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
<!--portfolio-body-->
|
||||||
<script type="module" src="/src/entry-client.ts"></script>
|
<script type="module" src="/src/entry-client.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -14,8 +14,9 @@
|
|||||||
"build:client": "vite build --outDir dist/client --manifest",
|
"build:client": "vite build --outDir dist/client --manifest",
|
||||||
"build:ssr": "vite build --ssr src/entry-server.ts --outDir dist-ssr",
|
"build:ssr": "vite build --ssr src/entry-server.ts --outDir dist-ssr",
|
||||||
"prerender": "node scripts/prerender.mjs",
|
"prerender": "node scripts/prerender.mjs",
|
||||||
|
"verify:artifacts": "node scripts/verify-artifacts.mjs",
|
||||||
"prepare:preview-assets": "node scripts/materialize-empty-asset-set.mjs",
|
"prepare:preview-assets": "node scripts/materialize-empty-asset-set.mjs",
|
||||||
"build:preview": "npm run prepare:preview-assets && npm run typecheck && npm run test:unit && npm run build:client && npm run build:ssr && npm run prerender -- --config config/site.preview.json"
|
"build:preview": "npm run prepare:preview-assets && npm run typecheck && npm run test:unit && npm run build:client && npm run build:ssr && npm run prerender -- --config config/site.preview.json && npm run verify:artifacts -- --config config/site.preview.json"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
21
scripts/prerender.d.mts
Normal file
21
scripts/prerender.d.mts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { ProductionInputs } from '../src/content/types.ts';
|
||||||
|
|
||||||
|
export interface ViteManifestEntry {
|
||||||
|
file: string;
|
||||||
|
src?: string;
|
||||||
|
isEntry?: boolean;
|
||||||
|
css?: string[];
|
||||||
|
imports?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ViteManifest = Record<string, ViteManifestEntry>;
|
||||||
|
|
||||||
|
export function parseExactConfigArgument(arguments_: string[]): string;
|
||||||
|
export function assertExpectedDistInputs(distRoot: string): Promise<void>;
|
||||||
|
export function assemblePrerenderedHtml(
|
||||||
|
template: string,
|
||||||
|
manifest: ViteManifest,
|
||||||
|
markup: string,
|
||||||
|
inputs: Readonly<ProductionInputs>,
|
||||||
|
): string;
|
||||||
|
export function prerender(configPath: string): Promise<void>;
|
||||||
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);
|
||||||
|
}
|
||||||
7
scripts/verify-artifacts.d.mts
Normal file
7
scripts/verify-artifacts.d.mts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { ProductionInputs } from '../src/content/types.ts';
|
||||||
|
|
||||||
|
export function verifyArtifactDirectory(
|
||||||
|
root: string,
|
||||||
|
inputs: Readonly<ProductionInputs>,
|
||||||
|
): Promise<void>;
|
||||||
|
export function verifyArtifacts(configPath: string): Promise<void>;
|
||||||
293
scripts/verify-artifacts.mjs
Normal file
293
scripts/verify-artifacts.mjs
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { lstat, readFile, readdir } 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 { parseExactConfigArgument } from './prerender.mjs';
|
||||||
|
|
||||||
|
const scriptPath = fileURLToPath(import.meta.url);
|
||||||
|
const repositoryRoot = resolve(dirname(scriptPath), '..');
|
||||||
|
const canonicalSectionIds = [
|
||||||
|
'start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'additional-work', 'toolkit', 'contact',
|
||||||
|
];
|
||||||
|
const prohibitedSurfaces = [
|
||||||
|
/__PORTFOLIO_INPUTS__/iu,
|
||||||
|
/(?:evidenceId|sourcePath|sourceFingerprint|approvalOwner|privacyClassification|denylist|originalFilename)/iu,
|
||||||
|
/sourceMappingURL/iu,
|
||||||
|
];
|
||||||
|
|
||||||
|
const escapeRegularExpression = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
||||||
|
const escapeHtml = (value) => value
|
||||||
|
.replace(/&/gu, '&')
|
||||||
|
.replace(/</gu, '<')
|
||||||
|
.replace(/>/gu, '>')
|
||||||
|
.replace(/"/gu, '"');
|
||||||
|
|
||||||
|
const readRequiredText = async (root, name) => {
|
||||||
|
try {
|
||||||
|
return await readFile(resolve(root, name), 'utf8');
|
||||||
|
} catch (error) {
|
||||||
|
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') {
|
||||||
|
throw new Error(`Missing required artifact: ${name}`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const listArtifacts = async (root) => {
|
||||||
|
const rootStats = await lstat(root);
|
||||||
|
if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) {
|
||||||
|
throw new Error('Artifact root must be a non-symlink directory.');
|
||||||
|
}
|
||||||
|
const files = [];
|
||||||
|
for (const entry of await readdir(root, { withFileTypes: true })) {
|
||||||
|
const entryPath = resolve(root, entry.name);
|
||||||
|
if (entry.isSymbolicLink()) throw new Error(`Unexpected linked artifact: ${entry.name}`);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (entry.name !== 'assets') throw new Error(`Unexpected artifact directory: ${entry.name}`);
|
||||||
|
for (const asset of await readdir(entryPath, { withFileTypes: true })) {
|
||||||
|
if (asset.isSymbolicLink() || !asset.isFile()) {
|
||||||
|
throw new Error(`Unexpected linked or nested artifact: assets/${asset.name}`);
|
||||||
|
}
|
||||||
|
files.push(`assets/${asset.name}`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!entry.isFile()) throw new Error(`Unexpected special artifact: ${entry.name}`);
|
||||||
|
files.push(entry.name);
|
||||||
|
}
|
||||||
|
return files.sort();
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertArtifactInventory = (files, html, inputs) => {
|
||||||
|
const expectedRootFiles = new Set(['index.html', 'robots.txt', '_headers.example', '_redirects.example']);
|
||||||
|
if (inputs.site.environment === 'production') expectedRootFiles.add('sitemap.xml');
|
||||||
|
const referencedAssets = new Set(
|
||||||
|
[...html.matchAll(/(?:src|href)="\/assets\/([a-zA-Z0-9._-]+)"/gu)].map((match) => `assets/${match[1]}`),
|
||||||
|
);
|
||||||
|
for (const asset of inputs.manifest.assets) referencedAssets.add(`assets/${asset.fileName}`);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.endsWith('.map')) throw new Error(`Source map artifact is prohibited: ${file}`);
|
||||||
|
if (file.startsWith('assets/')) {
|
||||||
|
if (!referencedAssets.has(file)) throw new Error(`Unexpected artifact: ${file}`);
|
||||||
|
} else if (!expectedRootFiles.has(file)) {
|
||||||
|
if (inputs.site.environment === 'preview' && file === 'sitemap.xml') {
|
||||||
|
throw new Error('Preview artifacts must not publish a sitemap.');
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected artifact: ${file}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const required of [...expectedRootFiles, ...referencedAssets]) {
|
||||||
|
if (!files.includes(required)) throw new Error(`Missing required artifact: ${required}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertNoProhibitedSurfaces = async (root, files) => {
|
||||||
|
for (const file of files) {
|
||||||
|
const bytes = await readFile(resolve(root, file));
|
||||||
|
const text = bytes.toString('utf8');
|
||||||
|
for (const pattern of prohibitedSurfaces) {
|
||||||
|
if (pattern.test(text)) throw new Error(`Prohibited surface detected in ${file}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exactlyOneMatch = (text, expression, label) => {
|
||||||
|
const matches = [...text.matchAll(expression)];
|
||||||
|
if (matches.length !== 1) throw new Error(`Expected exactly one ${label}.`);
|
||||||
|
return matches[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertSemanticHtml = (html, inputs) => {
|
||||||
|
if (!/<html lang="en">/u.test(html)) throw new Error('Final HTML must declare lang=en.');
|
||||||
|
const h1 = exactlyOneMatch(html, /<h1\b[^>]*>([\s\S]*?)<\/h1>/gu, 'H1');
|
||||||
|
if (h1[1] !== escapeHtml(inputs.content.identity.name)) throw new Error('H1 identity does not match public content.');
|
||||||
|
const adjacentIdentity = `<h1>${escapeHtml(inputs.content.identity.name)}</h1><p>${escapeHtml(inputs.content.identity.role)}</p>`;
|
||||||
|
if (!html.includes(adjacentIdentity)) throw new Error('The canonical role must be immediately adjacent to the H1.');
|
||||||
|
|
||||||
|
for (const id of canonicalSectionIds) {
|
||||||
|
const sectionMatches = html.match(new RegExp(`<section id="${escapeRegularExpression(id)}"(?:\\s|>)`, 'gu')) ?? [];
|
||||||
|
if (sectionMatches.length !== 1) throw new Error(`Expected exactly one section: ${id}`);
|
||||||
|
const navigationMatches = html.match(new RegExp(`href="#${escapeRegularExpression(id)}"`, 'gu')) ?? [];
|
||||||
|
if (navigationMatches.length !== 1) throw new Error(`Expected one native navigation link for section: ${id}`);
|
||||||
|
const label = inputs.content.navigation.find((item) => item.id === id)?.label;
|
||||||
|
if (label === undefined || !html.includes(`<h2 id="${id}-heading">${escapeHtml(label.toUpperCase())}</h2>`)) {
|
||||||
|
throw new Error(`Missing canonical section heading: ${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredVisibleCopy = [
|
||||||
|
inputs.content.systemsOperations.title,
|
||||||
|
inputs.content.infrastructure.title,
|
||||||
|
...inputs.content.infrastructure.bands.flatMap((band) => [band.title, band.summary]),
|
||||||
|
...inputs.content.projects.selected.flatMap((project) => [project.title, project.summary]),
|
||||||
|
...inputs.content.toolkit.flatMap((group) => [group.title, ...group.technologies.map((technology) => technology.name)]),
|
||||||
|
inputs.content.contact.email,
|
||||||
|
];
|
||||||
|
for (const copy of requiredVisibleCopy) {
|
||||||
|
if (!html.includes(escapeHtml(copy))) throw new Error(`Missing required semantic evidence: ${copy}`);
|
||||||
|
}
|
||||||
|
if (!html.includes(`href="mailto:${escapeHtml(inputs.content.contact.email)}"`)) {
|
||||||
|
throw new Error('Missing visible mailto contact link.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const moduleScript = exactlyOneMatch(html, /<script type="module"[^>]*src="\/assets\/[a-zA-Z0-9._-]+\.js"[^>]*><\/script>/gu, 'client module script');
|
||||||
|
const snapshot = exactlyOneMatch(
|
||||||
|
html,
|
||||||
|
/<script id="portfolio-inputs" type="application\/json">([\s\S]*?)<\/script>/gu,
|
||||||
|
'inert input snapshot',
|
||||||
|
);
|
||||||
|
let parsedSnapshot;
|
||||||
|
try {
|
||||||
|
parsedSnapshot = JSON.parse(snapshot[1]);
|
||||||
|
} catch {
|
||||||
|
throw new Error('The inert input snapshot is malformed.');
|
||||||
|
}
|
||||||
|
if (JSON.stringify(parsedSnapshot) !== JSON.stringify(inputs)) {
|
||||||
|
throw new Error('Hydration snapshot does not match the server input bytes and meaning.');
|
||||||
|
}
|
||||||
|
if (snapshot.index >= moduleScript.index || h1.index >= moduleScript.index) {
|
||||||
|
throw new Error('Semantic HTML and inert snapshot must precede the client module script.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertMetadata = (html, inputs) => {
|
||||||
|
const expectedTitle = escapeHtml(inputs.content.metadata.title);
|
||||||
|
const title = exactlyOneMatch(html, /<title>([\s\S]*?)<\/title>/gu, 'page title');
|
||||||
|
if (title[1] !== expectedTitle) throw new Error('Page title does not match public content.');
|
||||||
|
const canonical = exactlyOneMatch(html, /<link rel="canonical" href="([^"]+)" \/>/gu, 'canonical link');
|
||||||
|
if (canonical[1] !== inputs.site.canonicalUrl) throw new Error('Canonical URL does not match site config.');
|
||||||
|
const description = exactlyOneMatch(html, /<meta name="description" content="([^"]+)" \/>/gu, 'meta description');
|
||||||
|
if (description[1] !== escapeHtml(inputs.content.metadata.description)) {
|
||||||
|
throw new Error('Meta description does not match public content.');
|
||||||
|
}
|
||||||
|
for (const property of ['og:type', 'og:title', 'og:description', 'og:url']) {
|
||||||
|
exactlyOneMatch(html, new RegExp(`<meta property="${property}" content="[^"]+" \\/>`, 'gu'), `${property} metadata`);
|
||||||
|
}
|
||||||
|
const ogImages = [...html.matchAll(/<meta property="og:image"/gu)];
|
||||||
|
if (inputs.content.metadata.socialPreviewAssetId === undefined && ogImages.length !== 0) {
|
||||||
|
throw new Error('Open Graph image must be omitted without an approved manifest asset.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonLdMatch = exactlyOneMatch(
|
||||||
|
html,
|
||||||
|
/<script type="application\/ld\+json">([\s\S]*?)<\/script>/gu,
|
||||||
|
'JSON-LD block',
|
||||||
|
);
|
||||||
|
let jsonLd;
|
||||||
|
try {
|
||||||
|
jsonLd = JSON.parse(jsonLdMatch[1]);
|
||||||
|
} catch {
|
||||||
|
throw new Error('JSON-LD is malformed.');
|
||||||
|
}
|
||||||
|
if (jsonLd['@type'] !== 'ProfilePage' || jsonLd.mainEntity?.['@type'] !== 'Person') {
|
||||||
|
throw new Error('JSON-LD must contain one ProfilePage with one nested Person.');
|
||||||
|
}
|
||||||
|
const person = jsonLd.mainEntity;
|
||||||
|
if (
|
||||||
|
person.name !== inputs.content.identity.name ||
|
||||||
|
person.jobTitle !== inputs.content.identity.role ||
|
||||||
|
person.url !== inputs.site.canonicalUrl ||
|
||||||
|
jsonLd.url !== inputs.site.canonicalUrl
|
||||||
|
) {
|
||||||
|
throw new Error('JSON-LD identity or canonical URL is inconsistent.');
|
||||||
|
}
|
||||||
|
const expectedSameAs = inputs.content.contact.social.map((link) => link.url);
|
||||||
|
if (JSON.stringify(person.sameAs ?? []) !== JSON.stringify(expectedSameAs)) {
|
||||||
|
throw new Error('JSON-LD sameAs contains an unverified or missing profile.');
|
||||||
|
}
|
||||||
|
if (jsonLdMatch[1].toLowerCase().includes('</script')) throw new Error('JSON-LD contains a script-closing hazard.');
|
||||||
|
return jsonLdMatch[1];
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertSecurityHeaders = (headers, inputs, serializedJsonLd) => {
|
||||||
|
const required = [
|
||||||
|
"default-src 'self'", "script-src 'self'", "style-src 'self'", "img-src 'self'", "media-src 'self'",
|
||||||
|
"font-src 'self'", "connect-src 'none'", "object-src 'none'", "base-uri 'none'", "frame-ancestors 'none'",
|
||||||
|
"form-action 'none'", 'upgrade-insecure-requests', 'X-Content-Type-Options: nosniff',
|
||||||
|
'Referrer-Policy: no-referrer', 'X-Frame-Options: DENY',
|
||||||
|
'Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()',
|
||||||
|
];
|
||||||
|
for (const value of required) {
|
||||||
|
if (!headers.includes(value)) throw new Error(`Missing security header policy: ${value}`);
|
||||||
|
}
|
||||||
|
const expectedHash = `sha256-${createHash('sha256').update(serializedJsonLd).digest('base64')}`;
|
||||||
|
const hashes = headers.match(/sha256-[A-Za-z0-9+/=]+/gu) ?? [];
|
||||||
|
if (hashes.length !== 1 || hashes[0] !== expectedHash) {
|
||||||
|
throw new Error('CSP hash does not match the actual JSON-LD bytes.');
|
||||||
|
}
|
||||||
|
if (inputs.site.environment === 'preview') {
|
||||||
|
if (!headers.includes('Cache-Control: private, no-store') ||
|
||||||
|
!headers.includes('X-Robots-Tag: noindex, nofollow, noarchive')) {
|
||||||
|
throw new Error('Preview headers must be private and noindex.');
|
||||||
|
}
|
||||||
|
if (headers.includes('Strict-Transport-Security')) throw new Error('Preview headers must omit HSTS.');
|
||||||
|
} else {
|
||||||
|
if (!headers.includes('Strict-Transport-Security: max-age=31536000')) {
|
||||||
|
throw new Error('Production headers must include HSTS.');
|
||||||
|
}
|
||||||
|
if (headers.includes('X-Robots-Tag') || headers.includes('private, no-store')) {
|
||||||
|
throw new Error('Production headers must omit preview directives.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertCrawlArtifacts = async (root, inputs, headers) => {
|
||||||
|
const robots = await readRequiredText(root, 'robots.txt');
|
||||||
|
const redirects = await readRequiredText(root, '_redirects.example');
|
||||||
|
if (/https?:\/\//iu.test(redirects)) throw new Error('Redirect example must not invent a host.');
|
||||||
|
if (inputs.site.environment === 'preview') {
|
||||||
|
if (robots !== 'User-agent: *\nDisallow: /\n' || /sitemap/iu.test(robots)) {
|
||||||
|
throw new Error('Preview robots must disallow crawling without advertising a sitemap.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sitemapUrl = new URL('/sitemap.xml', inputs.site.canonicalUrl).href;
|
||||||
|
if (robots !== `User-agent: *\nAllow: /\nSitemap: ${sitemapUrl}\n`) {
|
||||||
|
throw new Error('Production robots policy must allow the canonical page and advertise its sitemap.');
|
||||||
|
}
|
||||||
|
const sitemap = await readRequiredText(root, 'sitemap.xml');
|
||||||
|
if (!sitemap.includes(`<loc>${inputs.site.canonicalUrl}</loc>`)) {
|
||||||
|
throw new Error('Production sitemap must contain the canonical site URL.');
|
||||||
|
}
|
||||||
|
if (!headers.includes('Cache-Control: public, max-age=0, must-revalidate')) {
|
||||||
|
throw new Error('Production HTML requires revalidation caching.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyArtifactDirectory = async (root, inputs) => {
|
||||||
|
const html = await readRequiredText(root, 'index.html');
|
||||||
|
const files = await listArtifacts(root);
|
||||||
|
assertArtifactInventory(files, html, inputs);
|
||||||
|
await assertNoProhibitedSurfaces(root, files);
|
||||||
|
assertSemanticHtml(html, inputs);
|
||||||
|
const serializedJsonLd = assertMetadata(html, inputs);
|
||||||
|
const headers = await readRequiredText(root, '_headers.example');
|
||||||
|
assertSecurityHeaders(headers, inputs, serializedJsonLd);
|
||||||
|
await assertCrawlArtifacts(root, inputs, headers);
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertInsideRepository = (path) => {
|
||||||
|
const pathRelativeToRoot = relative(repositoryRoot, path);
|
||||||
|
if (pathRelativeToRoot === '..' || pathRelativeToRoot.startsWith(`..${sep}`)) {
|
||||||
|
throw new Error('Verification input escaped the repository root.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyArtifacts = async (configPath) => {
|
||||||
|
const configAbsolutePath = resolve(repositoryRoot, configPath);
|
||||||
|
assertInsideRepository(configAbsolutePath);
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
await verifyArtifactDirectory(resolve(repositoryRoot, 'dist'), inputs);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (process.argv[1] !== undefined && resolve(process.argv[1]) === scriptPath) {
|
||||||
|
const configPath = parseExactConfigArgument(process.argv.slice(2));
|
||||||
|
await verifyArtifacts(configPath);
|
||||||
|
}
|
||||||
171
src/content/metadata.ts
Normal file
171
src/content/metadata.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import type {
|
||||||
|
PortfolioContent,
|
||||||
|
PublicAssetManifest,
|
||||||
|
SiteConfig,
|
||||||
|
} from './types.ts';
|
||||||
|
import { serializeJsonLd } from '../security/serialize-json-ld.ts';
|
||||||
|
|
||||||
|
interface PersonJsonLd {
|
||||||
|
'@type': 'Person';
|
||||||
|
name: string;
|
||||||
|
jobTitle: string;
|
||||||
|
description: string;
|
||||||
|
url: string;
|
||||||
|
email: string;
|
||||||
|
sameAs?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfilePageJsonLd {
|
||||||
|
'@context': 'https://schema.org';
|
||||||
|
'@type': 'ProfilePage';
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
url: string;
|
||||||
|
mainEntity: PersonJsonLd;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageMetadata {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
canonicalUrl: string;
|
||||||
|
robots: string;
|
||||||
|
openGraph: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
type: 'profile';
|
||||||
|
url: string;
|
||||||
|
image?: string;
|
||||||
|
};
|
||||||
|
jsonLd: ProfilePageJsonLd;
|
||||||
|
serializedJsonLd: string;
|
||||||
|
jsonLdCspHash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StaticHostArtifacts {
|
||||||
|
robots: string;
|
||||||
|
sitemap?: string;
|
||||||
|
headers: string;
|
||||||
|
redirects: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const absoluteAssetUrl = (
|
||||||
|
assetId: string | undefined,
|
||||||
|
manifest: Readonly<PublicAssetManifest>,
|
||||||
|
canonicalUrl: string,
|
||||||
|
): string | undefined => {
|
||||||
|
if (assetId === undefined) return undefined;
|
||||||
|
const asset = manifest.assets.find((candidate) => candidate.id === assetId);
|
||||||
|
if (asset === undefined) throw new Error(`Unknown social preview asset: ${assetId}`);
|
||||||
|
return new URL(`/assets/${encodeURIComponent(asset.fileName)}`, canonicalUrl).href;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createPageMetadata = (
|
||||||
|
content: Readonly<PortfolioContent>,
|
||||||
|
manifest: Readonly<PublicAssetManifest>,
|
||||||
|
site: Readonly<SiteConfig>,
|
||||||
|
): PageMetadata => {
|
||||||
|
const canonicalTitle = `${content.identity.name} — ${content.identity.role}`;
|
||||||
|
if (content.metadata.title !== canonicalTitle) {
|
||||||
|
throw new Error('Metadata title must equal the canonical identity and role.');
|
||||||
|
}
|
||||||
|
const sameAs = content.contact.social.map((link) => link.url);
|
||||||
|
const mainEntity: PersonJsonLd = {
|
||||||
|
'@type': 'Person',
|
||||||
|
name: content.identity.name,
|
||||||
|
jobTitle: content.identity.role,
|
||||||
|
description: content.metadata.description,
|
||||||
|
url: site.canonicalUrl,
|
||||||
|
email: `mailto:${content.contact.email}`,
|
||||||
|
...(sameAs.length === 0 ? {} : { sameAs }),
|
||||||
|
};
|
||||||
|
const jsonLd: ProfilePageJsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'ProfilePage',
|
||||||
|
name: canonicalTitle,
|
||||||
|
description: content.metadata.description,
|
||||||
|
url: site.canonicalUrl,
|
||||||
|
mainEntity,
|
||||||
|
};
|
||||||
|
const serializedJsonLd = serializeJsonLd(jsonLd);
|
||||||
|
const image = absoluteAssetUrl(content.metadata.socialPreviewAssetId, manifest, site.canonicalUrl);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: canonicalTitle,
|
||||||
|
description: content.metadata.description,
|
||||||
|
canonicalUrl: site.canonicalUrl,
|
||||||
|
robots: site.environment === 'preview' ? 'noindex, nofollow, noarchive' : 'index, follow',
|
||||||
|
openGraph: {
|
||||||
|
title: canonicalTitle,
|
||||||
|
description: content.metadata.description,
|
||||||
|
type: 'profile',
|
||||||
|
url: site.canonicalUrl,
|
||||||
|
...(image === undefined ? {} : { image }),
|
||||||
|
},
|
||||||
|
jsonLd,
|
||||||
|
serializedJsonLd,
|
||||||
|
jsonLdCspHash: `sha256-${createHash('sha256').update(serializedJsonLd).digest('base64')}`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const contentSecurityPolicy = (jsonLdCspHash: string): string => [
|
||||||
|
"default-src 'self'",
|
||||||
|
`script-src 'self' '${jsonLdCspHash}'`,
|
||||||
|
"style-src 'self'",
|
||||||
|
"img-src 'self'",
|
||||||
|
"media-src 'self'",
|
||||||
|
"font-src 'self'",
|
||||||
|
"connect-src 'none'",
|
||||||
|
"object-src 'none'",
|
||||||
|
"base-uri 'none'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"form-action 'none'",
|
||||||
|
'upgrade-insecure-requests',
|
||||||
|
].join('; ');
|
||||||
|
|
||||||
|
const commonHeaders = (metadata: Readonly<PageMetadata>): string[] => [
|
||||||
|
` Content-Security-Policy: ${contentSecurityPolicy(metadata.jsonLdCspHash)}`,
|
||||||
|
' X-Content-Type-Options: nosniff',
|
||||||
|
' Referrer-Policy: no-referrer',
|
||||||
|
' Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()',
|
||||||
|
' X-Frame-Options: DENY',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const createStaticHostArtifacts = (
|
||||||
|
metadata: Readonly<PageMetadata>,
|
||||||
|
site: Readonly<SiteConfig>,
|
||||||
|
): StaticHostArtifacts => {
|
||||||
|
if (site.environment === 'preview') {
|
||||||
|
return {
|
||||||
|
robots: 'User-agent: *\nDisallow: /\n',
|
||||||
|
headers: [
|
||||||
|
'/*',
|
||||||
|
...commonHeaders(metadata),
|
||||||
|
' Cache-Control: private, no-store',
|
||||||
|
' X-Robots-Tag: noindex, nofollow, noarchive',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
redirects: '# Preview access requires provider authentication; no host redirects are configured.\n',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sitemapUrl = new URL('/sitemap.xml', site.canonicalUrl).href;
|
||||||
|
return {
|
||||||
|
robots: `User-agent: *\nAllow: /\nSitemap: ${sitemapUrl}\n`,
|
||||||
|
sitemap: [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||||
|
` <url><loc>${site.canonicalUrl}</loc></url>`,
|
||||||
|
'</urlset>',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
headers: [
|
||||||
|
'/*',
|
||||||
|
...commonHeaders(metadata),
|
||||||
|
' Cache-Control: public, max-age=0, must-revalidate',
|
||||||
|
' Strict-Transport-Security: max-age=31536000',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
redirects: '# Configure one-hop preferred-host redirects after the production host is approved.\n',
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,16 +1,25 @@
|
|||||||
import { createApp } from './app';
|
import { createApp } from './app';
|
||||||
import type { ProductionInputs } from './content/types';
|
import type { ProductionInputs } from './content/types';
|
||||||
|
|
||||||
declare global {
|
export const readProductionInputsSnapshot = (source: Document): Readonly<ProductionInputs> => {
|
||||||
interface Window {
|
const snapshot = source.getElementById('portfolio-inputs');
|
||||||
__PORTFOLIO_INPUTS__?: Readonly<ProductionInputs>;
|
if (
|
||||||
|
snapshot === null ||
|
||||||
|
snapshot.tagName !== 'SCRIPT' ||
|
||||||
|
snapshot.getAttribute('type') !== 'application/json'
|
||||||
|
) {
|
||||||
|
throw new Error('The inert portfolio input snapshot is unavailable.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const serializedInputs = snapshot.textContent;
|
||||||
|
if (serializedInputs === null || serializedInputs.length === 0) {
|
||||||
|
throw new Error('The inert portfolio input snapshot is empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(serializedInputs) as Readonly<ProductionInputs>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const appRoot = document.getElementById('app');
|
||||||
|
if (appRoot !== null) {
|
||||||
|
createApp(readProductionInputsSnapshot(document)).mount(appRoot);
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputs = window.__PORTFOLIO_INPUTS__;
|
|
||||||
|
|
||||||
if (inputs === undefined) {
|
|
||||||
throw new Error('The serialized portfolio input snapshot is unavailable.');
|
|
||||||
}
|
|
||||||
|
|
||||||
createApp(inputs).mount('#app');
|
|
||||||
|
|||||||
7
src/security/serialize-json-ld.ts
Normal file
7
src/security/serialize-json-ld.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const serializeJsonLd = (value: unknown): string =>
|
||||||
|
JSON.stringify(value)
|
||||||
|
.replace(/</gu, '\\u003c')
|
||||||
|
.replace(/>/gu, '\\u003e')
|
||||||
|
.replace(/&/gu, '\\u0026')
|
||||||
|
.replace(/\u2028/gu, '\\u2028')
|
||||||
|
.replace(/\u2029/gu, '\\u2029');
|
||||||
163
tests/integration/prerender.test.ts
Normal file
163
tests/integration/prerender.test.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { loadProductionInputs } from '@/content/load-production-inputs';
|
||||||
|
import { readProductionInputsSnapshot } from '@/entry-client';
|
||||||
|
import { render } from '@/entry-server';
|
||||||
|
import {
|
||||||
|
assemblePrerenderedHtml,
|
||||||
|
assertExpectedDistInputs,
|
||||||
|
parseExactConfigArgument,
|
||||||
|
type ViteManifest,
|
||||||
|
} from '../../scripts/prerender.mjs';
|
||||||
|
|
||||||
|
const repositoryRoot = process.cwd();
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
const locations = {
|
||||||
|
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')}/`),
|
||||||
|
siteConfigJson: pathToFileURL(resolve(repositoryRoot, 'config/site.preview.json')),
|
||||||
|
};
|
||||||
|
const builtTemplate = `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Portfolio</title>
|
||||||
|
<!--portfolio-head-->
|
||||||
|
<script type="module" crossorigin src="/assets/index-test.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-test.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<!--portfolio-body-->
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
const manifest: ViteManifest = {
|
||||||
|
'index.html': {
|
||||||
|
file: 'assets/index-test.js',
|
||||||
|
src: 'index.html',
|
||||||
|
isEntry: true,
|
||||||
|
css: ['assets/index-test.css'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRawArtifact = async () => {
|
||||||
|
const inputs = await loadProductionInputs(locations);
|
||||||
|
const markup = await render('/', inputs);
|
||||||
|
const html = assemblePrerenderedHtml(builtTemplate, manifest, markup, inputs);
|
||||||
|
return { html, inputs };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('raw prerendered document', () => {
|
||||||
|
it('places complete semantic content and the inert snapshot before the executable module', async () => {
|
||||||
|
const { html, inputs } = await renderRawArtifact();
|
||||||
|
const document = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
const moduleScript = document.querySelector('script[type="module"]');
|
||||||
|
const snapshot = document.querySelector('#portfolio-inputs[type="application/json"]');
|
||||||
|
|
||||||
|
expect(document.documentElement.lang).toBe('en');
|
||||||
|
expect(document.title).toBe('Luca Pîslaru — System Administrator & Full-Stack Developer');
|
||||||
|
expect(document.querySelectorAll('h1')).toHaveLength(1);
|
||||||
|
expect(document.querySelector('h1')?.textContent).toBe('Luca Pîslaru');
|
||||||
|
expect(document.querySelector('h1 + p')?.textContent).toBe('System Administrator & Full-Stack Developer');
|
||||||
|
expect([...document.querySelectorAll('main > section')].map((section) => section.id)).toEqual([
|
||||||
|
'start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'additional-work', 'toolkit', 'contact',
|
||||||
|
]);
|
||||||
|
expect(document.body.textContent).toContain('Secure self-hosted platform and lifecycle');
|
||||||
|
expect(document.body.textContent).toContain('Private self-hosted medication tracker');
|
||||||
|
expect(document.body.textContent).toContain('Linux');
|
||||||
|
expect(document.querySelector('a[href="mailto:luca.pislaru@gmail.com"]')?.textContent)
|
||||||
|
.toBe('luca.pislaru@gmail.com');
|
||||||
|
expect(document.querySelector('nav a[href="#infrastructure"]')).not.toBeNull();
|
||||||
|
expect(snapshot).not.toBeNull();
|
||||||
|
expect(moduleScript).not.toBeNull();
|
||||||
|
expect(html.indexOf('id="portfolio-inputs"')).toBeLessThan(html.indexOf('<script type="module"'));
|
||||||
|
expect(html.indexOf('Luca Pîslaru')).toBeLessThan(html.indexOf('<script type="module"'));
|
||||||
|
expect(html).not.toContain('window.__PORTFOLIO_INPUTS__');
|
||||||
|
expect(html).not.toContain('<!--portfolio-');
|
||||||
|
expect(JSON.parse(snapshot!.textContent ?? '')).toEqual(inputs);
|
||||||
|
expect(readProductionInputsSnapshot(document)).toEqual(inputs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits one canonical, one safe ProfilePage, and no fabricated preview image', async () => {
|
||||||
|
const { html } = await renderRawArtifact();
|
||||||
|
const document = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
const jsonLdNodes = document.querySelectorAll('script[type="application/ld+json"]');
|
||||||
|
|
||||||
|
expect(document.querySelectorAll('link[rel="canonical"]')).toHaveLength(1);
|
||||||
|
expect(document.querySelector('link[rel="canonical"]')?.getAttribute('href')).toBe('https://portfolio.localhost/');
|
||||||
|
expect(document.querySelectorAll('meta[property="og:image"]')).toHaveLength(0);
|
||||||
|
expect(jsonLdNodes).toHaveLength(1);
|
||||||
|
const jsonLd = JSON.parse(jsonLdNodes[0]!.textContent ?? '');
|
||||||
|
expect(jsonLd['@type']).toBe('ProfilePage');
|
||||||
|
expect(jsonLd.mainEntity.name).toBe('Luca Pîslaru');
|
||||||
|
expect(jsonLd.mainEntity.jobTitle).toBe('System Administrator & Full-Stack Developer');
|
||||||
|
expect((jsonLdNodes[0]!.textContent ?? '').toLowerCase()).not.toContain('</script');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds deterministic entry preloads without duplicating the executable module', async () => {
|
||||||
|
const { html } = await renderRawArtifact();
|
||||||
|
|
||||||
|
expect(html.match(/rel="modulepreload"/gu)).toHaveLength(1);
|
||||||
|
expect(html.match(/<script type="module"/gu)).toHaveLength(1);
|
||||||
|
expect(html).toContain('<link rel="modulepreload" href="/assets/index-test.js" />');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('strict prerender command line', () => {
|
||||||
|
it('accepts exactly one explicit safe repository-relative config path', () => {
|
||||||
|
expect(parseExactConfigArgument(['--config', 'config/site.preview.json']))
|
||||||
|
.toBe('config/site.preview.json');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ arguments_: [] },
|
||||||
|
{ arguments_: ['--config'] },
|
||||||
|
{ arguments_: ['--config', 'config/site.preview.json', '--config', 'config/site.preview.json'] },
|
||||||
|
{ arguments_: ['--config', 'config/site.preview.json', '--unknown'] },
|
||||||
|
{ arguments_: ['--config', '../site.preview.json'] },
|
||||||
|
{ arguments_: ['--config', '/tmp/site.preview.json'] },
|
||||||
|
{ arguments_: ['--config', 'config\\site.preview.json'] },
|
||||||
|
])('rejects missing, duplicate, unknown, or unsafe arguments: $arguments_', ({ arguments_ }) => {
|
||||||
|
expect(() => parseExactConfigArgument(arguments_)).toThrow(/exactly --config|safe repository-relative/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('approved build template', () => {
|
||||||
|
it('declares English and contains no executable input assignment', async () => {
|
||||||
|
const template = await readFile(resolve(repositoryRoot, 'index.html'), 'utf8');
|
||||||
|
|
||||||
|
expect(template).toContain('<html lang="en">');
|
||||||
|
expect(template).not.toContain('window.__PORTFOLIO_INPUTS__');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deterministic staging inventory', () => {
|
||||||
|
it.each([
|
||||||
|
{ location: 'client/unknown.txt' },
|
||||||
|
{ location: 'assets/unknown.txt' },
|
||||||
|
])('rejects an unknown staged output at $location', async ({ location }) => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), 'portfolio-dist-'));
|
||||||
|
temporaryDirectories.push(directory);
|
||||||
|
await mkdir(join(directory, 'client/.vite'), { recursive: true });
|
||||||
|
await mkdir(join(directory, 'client/assets'), { recursive: true });
|
||||||
|
await mkdir(join(directory, 'assets'), { recursive: true });
|
||||||
|
await writeFile(join(directory, 'client/index.html'), builtTemplate);
|
||||||
|
await writeFile(join(directory, 'client/.vite/manifest.json'), JSON.stringify(manifest));
|
||||||
|
await writeFile(join(directory, 'client/assets/index-test.js'), 'export {};');
|
||||||
|
await writeFile(join(directory, 'client/assets/index-test.css'), 'body {}');
|
||||||
|
await writeFile(join(directory, 'index.html'), builtTemplate);
|
||||||
|
await writeFile(join(directory, 'assets/index-test.js'), 'export {};');
|
||||||
|
await writeFile(join(directory, 'assets/index-test.css'), 'body {}');
|
||||||
|
await writeFile(join(directory, location), 'unknown');
|
||||||
|
|
||||||
|
await expect(assertExpectedDistInputs(directory)).rejects.toThrow(/unexpected build output/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
156
tests/integration/verify-artifacts.test.ts
Normal file
156
tests/integration/verify-artifacts.test.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { loadProductionInputs } from '@/content/load-production-inputs';
|
||||||
|
import { createPageMetadata, createStaticHostArtifacts } from '@/content/metadata';
|
||||||
|
import { render } from '@/entry-server';
|
||||||
|
import { assemblePrerenderedHtml, type ViteManifest } from '../../scripts/prerender.mjs';
|
||||||
|
import { verifyArtifactDirectory } from '../../scripts/verify-artifacts.mjs';
|
||||||
|
import type { ProductionInputs, SiteConfig } from '@/content/types';
|
||||||
|
|
||||||
|
const repositoryRoot = process.cwd();
|
||||||
|
const locations = {
|
||||||
|
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')}/`),
|
||||||
|
siteConfigJson: pathToFileURL(resolve(repositoryRoot, 'config/site.preview.json')),
|
||||||
|
};
|
||||||
|
const manifest: ViteManifest = {
|
||||||
|
'index.html': {
|
||||||
|
file: 'assets/index-test.js',
|
||||||
|
src: 'index.html',
|
||||||
|
isEntry: true,
|
||||||
|
css: ['assets/index-test.css'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const template = `<!doctype html><html lang="en"><head><meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Portfolio</title>
|
||||||
|
<!--portfolio-head-->
|
||||||
|
<script type="module" crossorigin src="/assets/index-test.js"></script>
|
||||||
|
<link rel="stylesheet" href="/assets/index-test.css"></head><body><div id="app"></div>
|
||||||
|
<!--portfolio-body--></body></html>`;
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
const writeValidArtifacts = async (site?: SiteConfig) => {
|
||||||
|
const loaded = await loadProductionInputs(locations);
|
||||||
|
const inputs: Readonly<ProductionInputs> = site === undefined ? loaded : { ...loaded, site };
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), 'portfolio-artifacts-'));
|
||||||
|
temporaryDirectories.push(directory);
|
||||||
|
await mkdir(join(directory, 'assets'));
|
||||||
|
const html = assemblePrerenderedHtml(template, manifest, await render('/', inputs), inputs);
|
||||||
|
const hostArtifacts = createStaticHostArtifacts(createPageMetadata(inputs.content, inputs.manifest, inputs.site), inputs.site);
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(join(directory, 'index.html'), html),
|
||||||
|
writeFile(join(directory, 'robots.txt'), hostArtifacts.robots),
|
||||||
|
writeFile(join(directory, '_headers.example'), hostArtifacts.headers),
|
||||||
|
writeFile(join(directory, '_redirects.example'), hostArtifacts.redirects),
|
||||||
|
writeFile(join(directory, 'assets/index-test.js'), 'export {};\n'),
|
||||||
|
writeFile(join(directory, 'assets/index-test.css'), 'body { margin: 0; }\n'),
|
||||||
|
...(hostArtifacts.sitemap === undefined
|
||||||
|
? []
|
||||||
|
: [writeFile(join(directory, 'sitemap.xml'), hostArtifacts.sitemap)]),
|
||||||
|
]);
|
||||||
|
return { directory, html, inputs };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('artifact verification', () => {
|
||||||
|
it('accepts the complete private preview artifact set', async () => {
|
||||||
|
const { directory, inputs } = await writeValidArtifacts();
|
||||||
|
|
||||||
|
await expect(verifyArtifactDirectory(directory, inputs)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the pure production artifact path with canonical sitemap and allow policy', async () => {
|
||||||
|
const productionSite: SiteConfig = {
|
||||||
|
environment: 'production',
|
||||||
|
canonicalUrl: 'https://portfolio.example.test/',
|
||||||
|
buildId: 'production-test',
|
||||||
|
};
|
||||||
|
const { directory, inputs } = await writeValidArtifacts(productionSite);
|
||||||
|
|
||||||
|
await expect(verifyArtifactDirectory(directory, inputs)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
name: 'an unexpected preview sitemap',
|
||||||
|
mutate: async (directory: string) => writeFile(join(directory, 'sitemap.xml'), '<urlset />'),
|
||||||
|
error: /preview.*sitemap/i,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'a source map',
|
||||||
|
mutate: async (directory: string) => writeFile(join(directory, 'assets/index-test.js.map'), '{}'),
|
||||||
|
error: /source map|unexpected artifact/i,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'an unknown artifact',
|
||||||
|
mutate: async (directory: string) => writeFile(join(directory, 'private-notes.txt'), 'notes'),
|
||||||
|
error: /unexpected artifact/i,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'a prohibited private surface',
|
||||||
|
mutate: async (directory: string) => writeFile(join(directory, 'assets/index-test.js'), 'const evidenceId = "fixture";'),
|
||||||
|
error: /prohibited surface/i,
|
||||||
|
},
|
||||||
|
])('rejects $name', async ({ mutate, error }) => {
|
||||||
|
const { directory, inputs } = await writeValidArtifacts();
|
||||||
|
await mutate(directory);
|
||||||
|
|
||||||
|
await expect(verifyArtifactDirectory(directory, inputs)).rejects.toThrow(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
name: 'duplicate canonical metadata',
|
||||||
|
mutate: (html: string) => html.replace('</head>', '<link rel="canonical" href="https://portfolio.localhost/" /></head>'),
|
||||||
|
error: /exactly one canonical/i,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'duplicate H1 identity',
|
||||||
|
mutate: (html: string) => html.replace('</main>', '<h1>Luca Pîslaru</h1></main>'),
|
||||||
|
error: /exactly one h1/i,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'malformed JSON-LD',
|
||||||
|
mutate: (html: string) => html.replace(/<script type="application\/ld\+json">.*?<\/script>/u, '<script type="application/ld+json">{</script>'),
|
||||||
|
error: /json-ld/i,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'missing infrastructure evidence',
|
||||||
|
mutate: (html: string) => html.replace('id="infrastructure"', 'id="removed-infrastructure"'),
|
||||||
|
error: /section.*infrastructure/i,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'executable snapshot assignment',
|
||||||
|
mutate: (html: string) => html.replace('<script id="portfolio-inputs" type="application/json">', '<script>window.__PORTFOLIO_INPUTS__='),
|
||||||
|
error: /inert input snapshot|prohibited surface/i,
|
||||||
|
},
|
||||||
|
])('rejects raw HTML with $name', async ({ mutate, error }) => {
|
||||||
|
const { directory, html, inputs } = await writeValidArtifacts();
|
||||||
|
await writeFile(join(directory, 'index.html'), mutate(html));
|
||||||
|
|
||||||
|
await expect(verifyArtifactDirectory(directory, inputs)).rejects.toThrow(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a missing crawl artifact', async () => {
|
||||||
|
const { directory, inputs } = await writeValidArtifacts();
|
||||||
|
await rm(join(directory, 'robots.txt'));
|
||||||
|
|
||||||
|
await expect(verifyArtifactDirectory(directory, inputs)).rejects.toThrow(/robots/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks the CSP hash against the actual serialized JSON-LD bytes', async () => {
|
||||||
|
const { directory, inputs } = await writeValidArtifacts();
|
||||||
|
const headersPath = join(directory, '_headers.example');
|
||||||
|
const headers = await readFile(headersPath, 'utf8');
|
||||||
|
await writeFile(headersPath, headers.replace(/sha256-[A-Za-z0-9+/=]+/u, 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='));
|
||||||
|
|
||||||
|
await expect(verifyArtifactDirectory(directory, inputs)).rejects.toThrow(/csp.*json-ld|json-ld.*csp/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
123
tests/unit/metadata.test.ts
Normal file
123
tests/unit/metadata.test.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { createPageMetadata, createStaticHostArtifacts } from '@/content/metadata';
|
||||||
|
import { parsePortfolioContent, parsePublicAssetManifest } from '@/content/schemas';
|
||||||
|
import { serializeJsonLd } from '@/security/serialize-json-ld';
|
||||||
|
import type { SiteConfig } from '@/content/types';
|
||||||
|
|
||||||
|
const fixture = (path: string): unknown =>
|
||||||
|
JSON.parse(readFileSync(resolve(process.cwd(), path), 'utf8')) as unknown;
|
||||||
|
|
||||||
|
const content = parsePortfolioContent(fixture('content/public/portfolio-content.json'));
|
||||||
|
const manifest = parsePublicAssetManifest(fixture('content/public/asset-manifest.json'));
|
||||||
|
const previewSite: SiteConfig = {
|
||||||
|
environment: 'preview',
|
||||||
|
canonicalUrl: 'https://portfolio.localhost/',
|
||||||
|
buildId: 'preview-test',
|
||||||
|
};
|
||||||
|
const productionSite: SiteConfig = {
|
||||||
|
environment: 'production',
|
||||||
|
canonicalUrl: 'https://portfolio.example.test/',
|
||||||
|
buildId: 'production-test',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('safe metadata', () => {
|
||||||
|
it('escapes every HTML-significant and JavaScript line-separator code point', () => {
|
||||||
|
const serialized = serializeJsonLd({ value: '</script><tag>&\u2028\u2029' });
|
||||||
|
|
||||||
|
expect(serialized).toBe('{"value":"\\u003c/script\\u003e\\u003ctag\\u003e\\u0026\\u2028\\u2029"}');
|
||||||
|
expect(serialized.toLowerCase()).not.toContain('</script');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives canonical identity and verified profiles from public content only', () => {
|
||||||
|
const metadata = createPageMetadata(content, manifest, productionSite);
|
||||||
|
|
||||||
|
expect(metadata.title).toBe('Luca Pîslaru — System Administrator & Full-Stack Developer');
|
||||||
|
expect(metadata.description).toBe(
|
||||||
|
'Portfolio of Luca Pîslaru, a System Administrator & Full-Stack Developer building and operating reliable systems and software.',
|
||||||
|
);
|
||||||
|
expect(metadata.canonicalUrl).toBe('https://portfolio.example.test/');
|
||||||
|
expect(metadata.openGraph.image).toBeUndefined();
|
||||||
|
expect(metadata.jsonLd).toEqual({
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'ProfilePage',
|
||||||
|
name: 'Luca Pîslaru — System Administrator & Full-Stack Developer',
|
||||||
|
description: metadata.description,
|
||||||
|
url: 'https://portfolio.example.test/',
|
||||||
|
mainEntity: {
|
||||||
|
'@type': 'Person',
|
||||||
|
name: 'Luca Pîslaru',
|
||||||
|
jobTitle: 'System Administrator & Full-Stack Developer',
|
||||||
|
description: metadata.description,
|
||||||
|
url: 'https://portfolio.example.test/',
|
||||||
|
email: 'mailto:luca.pislaru@gmail.com',
|
||||||
|
sameAs: [
|
||||||
|
'https://github.com/LucaPisl',
|
||||||
|
'https://gitlab.com/luca_pisl',
|
||||||
|
'https://linkedin.com/in/luca-pisl',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const expectedHash = createHash('sha256').update(metadata.serializedJsonLd).digest('base64');
|
||||||
|
expect(metadata.jsonLdCspHash).toBe(`sha256-${expectedHash}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits sameAs when no verified social profiles exist', () => {
|
||||||
|
const withoutSocial = structuredClone(content);
|
||||||
|
withoutSocial.contact.social = [];
|
||||||
|
|
||||||
|
const metadata = createPageMetadata(withoutSocial, manifest, productionSite);
|
||||||
|
|
||||||
|
expect(metadata.jsonLd.mainEntity).not.toHaveProperty('sameAs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects metadata title copy that diverges from the canonical identity and role', () => {
|
||||||
|
const inconsistent = structuredClone(content);
|
||||||
|
inconsistent.metadata.title = 'Luca Pîslaru — Developer';
|
||||||
|
|
||||||
|
expect(() => createPageMetadata(inconsistent, manifest, productionSite)).toThrow(/canonical identity.*role/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('environment-aware crawl and response artifacts', () => {
|
||||||
|
it('keeps preview private, noindex, sitemap-free, and without HSTS', () => {
|
||||||
|
const artifacts = createStaticHostArtifacts(createPageMetadata(content, manifest, previewSite), previewSite);
|
||||||
|
|
||||||
|
expect(artifacts.robots).toBe('User-agent: *\nDisallow: /\n');
|
||||||
|
expect(artifacts.sitemap).toBeUndefined();
|
||||||
|
expect(artifacts.headers).toContain('Cache-Control: private, no-store');
|
||||||
|
expect(artifacts.headers).toContain('X-Robots-Tag: noindex, nofollow, noarchive');
|
||||||
|
expect(artifacts.headers).not.toContain('Strict-Transport-Security');
|
||||||
|
expect(artifacts.headers).not.toContain('Sitemap:');
|
||||||
|
expect(artifacts.redirects).not.toContain('portfolio.example.test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates production-only sitemap, allow policy, HSTS, and no preview directives', () => {
|
||||||
|
const artifacts = createStaticHostArtifacts(createPageMetadata(content, manifest, productionSite), productionSite);
|
||||||
|
|
||||||
|
expect(artifacts.robots).toBe(
|
||||||
|
'User-agent: *\nAllow: /\nSitemap: https://portfolio.example.test/sitemap.xml\n',
|
||||||
|
);
|
||||||
|
expect(artifacts.sitemap).toContain('<loc>https://portfolio.example.test/</loc>');
|
||||||
|
expect(artifacts.headers).toContain('Strict-Transport-Security: max-age=31536000');
|
||||||
|
expect(artifacts.headers).not.toContain('X-Robots-Tag');
|
||||||
|
expect(artifacts.headers).not.toContain('private, no-store');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits the least-privilege browser security baseline', () => {
|
||||||
|
const artifacts = createStaticHostArtifacts(createPageMetadata(content, manifest, previewSite), previewSite);
|
||||||
|
|
||||||
|
expect(artifacts.headers).toContain("default-src 'self'");
|
||||||
|
expect(artifacts.headers).toContain("script-src 'self' 'sha256-");
|
||||||
|
expect(artifacts.headers).toContain("connect-src 'none'");
|
||||||
|
expect(artifacts.headers).toContain("frame-ancestors 'none'");
|
||||||
|
expect(artifacts.headers).toContain('X-Frame-Options: DENY');
|
||||||
|
expect(artifacts.headers).toContain('X-Content-Type-Options: nosniff');
|
||||||
|
expect(artifacts.headers).toContain('Referrer-Policy: no-referrer');
|
||||||
|
expect(artifacts.headers).toContain(
|
||||||
|
'Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,9 @@ import vue from '@vitejs/plugin-vue';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
|
build: {
|
||||||
|
sourcemap: false,
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
|
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user