feat: prerender crawlable portfolio artifacts

This commit is contained in:
2026-09-02 02:10:26 +03:00
parent 967a3d4d6c
commit 3dd4a818d0
13 changed files with 1167 additions and 12 deletions

171
src/content/metadata.ts Normal file
View 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',
};
};

View File

@@ -1,16 +1,25 @@
import { createApp } from './app';
import type { ProductionInputs } from './content/types';
declare global {
interface Window {
__PORTFOLIO_INPUTS__?: Readonly<ProductionInputs>;
export const readProductionInputsSnapshot = (source: Document): Readonly<ProductionInputs> => {
const snapshot = source.getElementById('portfolio-inputs');
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');

View 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');