feat: prerender crawlable portfolio artifacts
This commit is contained in:
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 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');
|
||||
|
||||
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');
|
||||
Reference in New Issue
Block a user