feat: add OG images, favicon set, manifest and performance tuning
- Build-time Open Graph cards per post and a default card (Satori + Sharp) - Favicon/ICO/apple-touch/manifest icons generated from one SVG (npm run icons) - robots.txt and web manifest generated from site.json - Subset self-hosted fonts to Latin + Turkish (188 KB -> 66 KB) with preload - Inline the stylesheet to remove the render-blocking request - Post-build site audit (npm run check:site): h1, alt, SEO tags, links - Omit canonical on noindex pages Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+164
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Build-time Open Graph images (1200×630 PNG) with Satori + Sharp.
|
||||
* Used by the prerendered /og/<slug>.png and /og-default.png endpoints.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import satori from 'satori';
|
||||
import sharp from 'sharp';
|
||||
import { site } from './data';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const font = (pkg: string, file: string) => readFileSync(require.resolve(`${pkg}/files/${file}`));
|
||||
|
||||
// Satori needs woff/ttf (not woff2). latin-ext (İ, Ş, Ğ…) gets its own family name and is
|
||||
// listed as a fallback in fontFamily; same-name entries are not used as glyph fallbacks.
|
||||
const fonts = [
|
||||
{
|
||||
name: 'Rubik',
|
||||
weight: 900 as const,
|
||||
data: font('@fontsource/rubik', 'rubik-latin-900-normal.woff'),
|
||||
},
|
||||
{
|
||||
name: 'RubikExt',
|
||||
weight: 900 as const,
|
||||
data: font('@fontsource/rubik', 'rubik-latin-ext-900-normal.woff'),
|
||||
},
|
||||
{
|
||||
name: 'Rubik',
|
||||
weight: 700 as const,
|
||||
data: font('@fontsource/rubik', 'rubik-latin-700-normal.woff'),
|
||||
},
|
||||
{
|
||||
name: 'RubikExt',
|
||||
weight: 700 as const,
|
||||
data: font('@fontsource/rubik', 'rubik-latin-ext-700-normal.woff'),
|
||||
},
|
||||
{
|
||||
name: 'Inter',
|
||||
weight: 600 as const,
|
||||
data: font('@fontsource/inter', 'inter-latin-600-normal.woff'),
|
||||
},
|
||||
{
|
||||
name: 'InterExt',
|
||||
weight: 600 as const,
|
||||
data: font('@fontsource/inter', 'inter-latin-ext-600-normal.woff'),
|
||||
},
|
||||
];
|
||||
|
||||
// Image-only palette (Huemint Magazine); theme tokens are CSS-only.
|
||||
const C = {
|
||||
paper: '#f2efee',
|
||||
khaki: '#d5d09b',
|
||||
ink: '#111012',
|
||||
red: '#c95748',
|
||||
line: 'rgba(17,16,18,0.18)',
|
||||
};
|
||||
|
||||
function towerIcon(color: string): string {
|
||||
const { icons, width = 24, height = 24 } = require('@iconify-json/tabler/icons.json');
|
||||
const body: string = icons['building-broadcast-tower'].body.replaceAll('currentColor', color);
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}">${body}</svg>`;
|
||||
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`;
|
||||
}
|
||||
|
||||
type Node = { type: string; props: Record<string, unknown> & { children?: unknown } };
|
||||
const h = (type: string, style: Record<string, unknown>, children?: unknown, extra = {}): Node => ({
|
||||
type,
|
||||
props: { style, children, ...extra },
|
||||
});
|
||||
|
||||
export interface OgInput {
|
||||
title: string;
|
||||
eyebrow?: string;
|
||||
}
|
||||
|
||||
export async function renderOg({ title, eyebrow }: OgInput): Promise<Buffer> {
|
||||
const upper = title.toLocaleUpperCase('tr-TR');
|
||||
const size = upper.length <= 28 ? 96 : upper.length <= 48 ? 78 : 62;
|
||||
|
||||
const tree = h(
|
||||
'div',
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
backgroundColor: C.khaki,
|
||||
color: C.ink,
|
||||
fontFamily: 'Rubik, RubikExt',
|
||||
},
|
||||
[
|
||||
// Magazine grid lines
|
||||
...[140, 380, 700, 1000].map((x) =>
|
||||
h('div', {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: x,
|
||||
width: 2,
|
||||
backgroundColor: C.line,
|
||||
}),
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{ display: 'flex', flexDirection: 'column', flex: 1, padding: '56px 72px 40px 72px' },
|
||||
[
|
||||
h('div', { display: 'flex', alignItems: 'center', justifyContent: 'space-between' }, [
|
||||
h('div', { display: 'flex', alignItems: 'center', gap: 16 }, [
|
||||
h('img', { width: 56, height: 56 }, undefined, {
|
||||
src: towerIcon(C.ink),
|
||||
width: 56,
|
||||
height: 56,
|
||||
}),
|
||||
h('div', { fontSize: 44, fontWeight: 900, letterSpacing: -1 }, site.author.callsign),
|
||||
]),
|
||||
eyebrow
|
||||
? h('div', { display: 'flex', alignItems: 'center', gap: 16 }, [
|
||||
h(
|
||||
'div',
|
||||
{ fontSize: 26, fontWeight: 700, letterSpacing: 1 },
|
||||
eyebrow.toLocaleUpperCase('tr-TR'),
|
||||
),
|
||||
h('div', { width: 64, height: 4, backgroundColor: C.red }),
|
||||
])
|
||||
: h('div', {}, ''),
|
||||
]),
|
||||
h('div', { display: 'flex', flex: 1, alignItems: 'center' }, [
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
fontSize: size,
|
||||
fontWeight: 900,
|
||||
lineHeight: 0.98,
|
||||
letterSpacing: -2,
|
||||
maxWidth: 1040,
|
||||
},
|
||||
upper,
|
||||
),
|
||||
]),
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
fontFamily: 'Inter, InterExt',
|
||||
fontWeight: 600,
|
||||
fontSize: 26,
|
||||
},
|
||||
[h('div', {}, new URL(site.url).host), h('div', {}, `${site.author.locator} · 73`)],
|
||||
),
|
||||
],
|
||||
),
|
||||
h('div', { height: 16, backgroundColor: C.red }),
|
||||
],
|
||||
);
|
||||
|
||||
const svg = await satori(tree as Parameters<typeof satori>[0], {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
fonts,
|
||||
});
|
||||
return sharp(Buffer.from(svg)).png({ compressionLevel: 9 }).toBuffer();
|
||||
}
|
||||
Reference in New Issue
Block a user