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:
2026-10-16 02:36:56 +03:00
parent df9c40847d
commit 7d8fcce2e1
28 changed files with 914 additions and 41 deletions
+164
View File
@@ -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();
}