feat: build-time OpenGraph images (satori-free, resvg + vendored TTFs)
- one 1200x630 PNG per page per locale, design-system styled - hand-built SVG rasterised by resvg (handles variable fonts; satori cannot) - vendored Space Grotesk / Inter / JetBrains Mono TTFs for deterministic offline builds Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,8 @@
|
|||||||
*.pdf binary
|
*.pdf binary
|
||||||
*.woff binary
|
*.woff binary
|
||||||
*.woff2 binary
|
*.woff2 binary
|
||||||
|
*.ttf binary
|
||||||
|
*.otf binary
|
||||||
*.ico binary
|
*.ico binary
|
||||||
|
|
||||||
# Shell scripts and container files must stay LF even on Windows checkout.
|
# Shell scripts and container files must stay LF even on Windows checkout.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,76 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { Resvg } from '@resvg/resvg-js';
|
||||||
|
import { PAGE_IDS, type PageId } from '@i18n/slugMap';
|
||||||
|
import { LOCALES, type Locale } from '@i18n/locales';
|
||||||
|
import { getProfile, getMeta } from '@lib/content';
|
||||||
|
|
||||||
|
/*
|
||||||
|
Build-time OpenGraph images (SEO_SPEC §4): one per page per locale, styled in the
|
||||||
|
design system (dark surface, name, role line, trace motif, monogram + page tag).
|
||||||
|
The SVG is hand-built (the design is simple and fully controlled) and rasterised by
|
||||||
|
resvg, which handles the vendored variable TTFs via its own font DB — satori's
|
||||||
|
opentype parser cannot read variable fonts.
|
||||||
|
Fonts read from the project source (cwd = site/ at build).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fontBuffers = ['SpaceGrotesk.ttf', 'Inter.ttf', 'JetBrainsMono.ttf'].map((n) =>
|
||||||
|
readFileSync(resolve(process.cwd(), 'src/assets/og-fonts', n)),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function getStaticPaths() {
|
||||||
|
const paths: { params: { image: string }; props: { pageId: PageId; locale: Locale } }[] = [];
|
||||||
|
for (const pageId of PAGE_IDS) {
|
||||||
|
for (const locale of LOCALES) {
|
||||||
|
paths.push({ params: { image: `${pageId}-${locale}` }, props: { pageId, locale } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
const esc = (s: string) =>
|
||||||
|
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
|
export const GET: APIRoute = ({ props }) => {
|
||||||
|
const { pageId, locale } = props as { pageId: PageId; locale: Locale };
|
||||||
|
const profile = getProfile(locale);
|
||||||
|
const roleLine = profile.roleLine;
|
||||||
|
const tag = pageId === 'home' ? '' : (getMeta(pageId, locale).title.split(' · ')[0] ?? '');
|
||||||
|
const tagW = Math.min(tag.length * 15 + 44, 1000);
|
||||||
|
|
||||||
|
const svg = `<svg width="1200" height="630" viewBox="0 0 1200 630" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="1200" height="630" fill="#0B0E14"/>
|
||||||
|
<rect x="64" y="64" width="52" height="52" rx="12" fill="#11151D" stroke="#5C6675"/>
|
||||||
|
<text x="90" y="99" font-family="JetBrains Mono" font-size="22" font-weight="700" fill="#E8ECF2" text-anchor="middle">CB</text>
|
||||||
|
<text x="134" y="98" font-family="JetBrains Mono" font-size="20" fill="#9AA4B2">cesnimda.co.uk</text>
|
||||||
|
|
||||||
|
<text x="64" y="330" font-family="Space Grotesk" font-size="80" font-weight="600" fill="#E8ECF2" letter-spacing="-1">${esc(profile.name)}</text>
|
||||||
|
<text x="64" y="382" font-family="Inter" font-size="30" fill="#9AA4B2">${esc(roleLine)}</text>
|
||||||
|
|
||||||
|
<rect x="64" y="420" width="240" height="2" fill="#3ECFAE"/>
|
||||||
|
<path d="M304 421 l12 12 H420" fill="none" stroke="#3ECFAE" stroke-width="2"/>
|
||||||
|
<circle cx="424" cy="433" r="5" fill="#3ECFAE"/>
|
||||||
|
|
||||||
|
${
|
||||||
|
tag
|
||||||
|
? `<rect x="64" y="500" width="${tagW}" height="48" rx="24" fill="none" stroke="#3ECFAE"/>
|
||||||
|
<text x="${64 + tagW / 2}" y="530" font-family="JetBrains Mono" font-size="22" fill="#3ECFAE" text-anchor="middle">${esc(tag)}</text>`
|
||||||
|
: `<text x="64" y="530" font-family="JetBrains Mono" font-size="22" fill="#5C6675">systems developer · tønsberg, norway</text>`
|
||||||
|
}
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
const png = new Resvg(svg, {
|
||||||
|
fitTo: { mode: 'width', value: 1200 },
|
||||||
|
font: { fontBuffers, loadSystemFonts: false, defaultFontFamily: 'Inter' },
|
||||||
|
})
|
||||||
|
.render()
|
||||||
|
.asPng();
|
||||||
|
|
||||||
|
return new Response(png, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'image/png',
|
||||||
|
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user