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
+23 -8
View File
@@ -5,9 +5,12 @@ export interface Props {
/** Page title without the site suffix. Omit on the home page. */
title?: string;
description?: string;
/** Absolute or root-relative image URL for OG/Twitter. */
/** Absolute or root-relative image URL for OG/Twitter. Defaults to the generated /og-default.png. */
image?: string;
imageAlt?: string;
/** Pixel size of `image`, when known. */
imageWidth?: number;
imageHeight?: number;
type?: 'website' | 'article';
publishedTime?: Date;
modifiedTime?: Date;
@@ -21,6 +24,8 @@ const {
description = site.description,
image,
imageAlt,
imageWidth,
imageHeight,
type = 'website',
publishedTime,
modifiedTime,
@@ -30,7 +35,11 @@ const {
const fullTitle = title ? `${title}${site.titleSeparator}${site.name}` : site.title;
const canonical = new URL(Astro.url.pathname, Astro.site ?? site.url).href;
const imageUrl = image ? new URL(image, Astro.site ?? site.url).href : undefined;
// Pages without their own image share the default card (1200×630).
const ogImage = image ?? '/og-default.png';
const imageUrl = new URL(ogImage, Astro.site ?? site.url).href;
const [ogWidth, ogHeight] = image ? [imageWidth, imageHeight] : [1200, 630];
const ogAlt = imageAlt ?? (image ? undefined : site.title);
const twitterHandle = social.find((s) => s.id === 'x')?.handle;
const personId = `${site.url}/#person`;
@@ -61,8 +70,11 @@ const graph = {
<title>{fullTitle}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
{noindex && <meta name="robots" content="noindex, nofollow" />}
{noindex ? (
<meta name="robots" content="noindex, nofollow" />
) : (
<link rel="canonical" href={canonical} />
)}
<meta property="og:type" content={type} />
<meta property="og:site_name" content={site.title} />
@@ -70,16 +82,19 @@ const graph = {
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
{imageUrl && <meta property="og:image" content={imageUrl} />}
{imageUrl && imageAlt && <meta property="og:image:alt" content={imageAlt} />}
<meta property="og:image" content={imageUrl} />
{ogWidth && <meta property="og:image:width" content={String(ogWidth)} />}
{ogHeight && <meta property="og:image:height" content={String(ogHeight)} />}
{ogAlt && <meta property="og:image:alt" content={ogAlt} />}
{publishedTime && <meta property="article:published_time" content={publishedTime.toISOString()} />}
{modifiedTime && <meta property="article:modified_time" content={modifiedTime.toISOString()} />}
<meta name="twitter:card" content={imageUrl ? 'summary_large_image' : 'summary'} />
<meta name="twitter:card" content="summary_large_image" />
{twitterHandle && <meta name="twitter:site" content={twitterHandle} />}
{twitterHandle && <meta name="twitter:creator" content={twitterHandle} />}
<meta name="twitter:title" content={fullTitle} />
<meta name="twitter:description" content={description} />
{imageUrl && <meta name="twitter:image" content={imageUrl} />}
<meta name="twitter:image" content={imageUrl} />
{ogAlt && <meta name="twitter:image:alt" content={ogAlt} />}
<script type="application/ld+json" set:html={JSON.stringify(graph)} />
+5 -1
View File
@@ -17,10 +17,14 @@ const props = Astro.props;
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<ThemeInit />
<link rel="preload" href="/fonts/rubik-latin.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/fonts/inter-latin.woff2" as="font" type="font/woff2" crossorigin />
<meta name="theme-color" content="#f2efee" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#111012" media="(prefers-color-scheme: dark)" />
<link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" sizes="32x32" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="alternate" type="application/rss+xml" title={site.title} href="/rss.xml" />
<meta name="generator" content={Astro.generator} />
<Seo {...props} />
+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();
}
+10 -6
View File
@@ -37,10 +37,12 @@ const { Content, headings } = await render(post);
const category = categoryOf(post);
const url = new URL(postUrl(post), site.url).href;
// 1200px OG/Twitter image from the cover (absolute URL in Seo).
const ogImage = cover
? (await getImage({ src: cover, width: 1200, format: 'jpeg' })).src
: undefined;
// Share image: 1200px JPEG of the cover, else the generated /og/<slug>.png card.
const coverOg = cover ? await getImage({ src: cover, width: 1200, format: 'jpeg' }) : undefined;
const ogImage = coverOg?.src ?? `/og/${post.id}.png`;
const [ogWidth, ogHeight] = coverOg
? [Number(coverOg.attributes.width), Number(coverOg.attributes.height)]
: [1200, 630];
const crumbs = [
{ label: site.ui.home, href: '/' },
@@ -64,7 +66,7 @@ const jsonLd = [
publisher: { '@id': `${site.url}/#person` },
articleSection: category.name,
keywords: tags.join(', '),
...(ogImage && { image: new URL(ogImage, site.url).href }),
image: new URL(ogImage, site.url).href,
},
{
'@type': 'BreadcrumbList',
@@ -83,7 +85,9 @@ const jsonLd = [
description={description}
type="article"
image={ogImage}
imageAlt={coverAlt}
imageAlt={coverAlt ?? title}
imageWidth={ogWidth}
imageHeight={ogHeight}
publishedTime={pubDate}
modifiedTime={updatedDate}
jsonLd={jsonLd}
+3 -4
View File
@@ -1,4 +1,6 @@
---
// Static import: inlined on this page only (a dynamic CSS import breaks with inlineStylesheets).
import 'leaflet/dist/leaflet.css';
import { Icon } from 'astro-icon/components';
import Container from '../components/Container.astro';
import ExternalLink from '../components/ExternalLink.astro';
@@ -116,10 +118,7 @@ const sectionTitle =
const el = document.getElementById('map');
if (el) {
const init = async () => {
const [{ default: L }] = await Promise.all([
import('leaflet'),
import('leaflet/dist/leaflet.css'),
]);
const { default: L } = await import('leaflet');
const { lat, lng, zoom, popup = '' } = el.dataset;
const center: [number, number] = [Number(lat), Number(lng)];
el.querySelector('[data-map-loading]')?.remove();
+9
View File
@@ -0,0 +1,9 @@
import type { APIRoute } from 'astro';
import { home } from '../lib/data';
import { renderOg } from '../lib/og';
// /og-default.png — share image for pages without their own.
export const GET: APIRoute = async () => {
const png = await renderOg({ title: home.hero.title });
return new Response(new Uint8Array(png), { headers: { 'Content-Type': 'image/png' } });
};
+17
View File
@@ -0,0 +1,17 @@
import type { APIRoute, GetStaticPaths } from 'astro';
import { renderOg } from '../../lib/og';
import { categoryOf, getPosts, type Post } from '../../lib/posts';
// /og/<slug>.png — per-post share image, generated at build time.
export const getStaticPaths = (async () => {
const posts = await getPosts();
return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
}) satisfies GetStaticPaths;
export const GET: APIRoute<{ post: Post }> = async ({ props }) => {
const png = await renderOg({
title: props.post.data.title,
eyebrow: categoryOf(props.post).name,
});
return new Response(new Uint8Array(png), { headers: { 'Content-Type': 'image/png' } });
};
+15
View File
@@ -0,0 +1,15 @@
import type { APIRoute } from 'astro';
import { site } from '../lib/data';
export const GET: APIRoute = () =>
new Response(
[
'User-agent: *',
'Allow: /',
'Disallow: /api/',
'',
`Sitemap: ${site.url}/sitemap-index.xml`,
'',
].join('\n'),
{ headers: { 'Content-Type': 'text/plain; charset=utf-8' } },
);
+22
View File
@@ -0,0 +1,22 @@
import type { APIRoute } from 'astro';
import { site } from '../lib/data';
export const GET: APIRoute = () =>
Response.json(
{
name: site.title,
short_name: site.name,
description: site.description,
lang: site.lang,
start_url: '/',
display: 'minimal-ui',
background_color: '#f2efee',
theme_color: '#d5d09b',
icons: [
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
{ src: '/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
},
{ headers: { 'Content-Type': 'application/manifest+json' } },
);
+43
View File
@@ -0,0 +1,43 @@
/* Generated by scripts/subset-fonts.mjs — do not edit. Fonts: SIL OFL 1.1 (Rubik, Inter). */
@font-face {
font-family: 'Rubik Variable';
font-style: normal;
font-display: swap;
font-weight: 400 900;
src: url('/fonts/rubik-latin.woff2') format('woff2-variations');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Rubik Variable';
font-style: normal;
font-display: swap;
font-weight: 400 900;
src: url('/fonts/rubik-latin-ext.woff2') format('woff2-variations');
unicode-range:
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
U+A720-A7FF;
}
@font-face {
font-family: 'Inter Variable';
font-style: normal;
font-display: swap;
font-weight: 400 700;
src: url('/fonts/inter-latin.woff2') format('woff2-variations');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Inter Variable';
font-style: normal;
font-display: swap;
font-weight: 400 700;
src: url('/fonts/inter-latin-ext.woff2') format('woff2-variations');
unicode-range:
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
U+A720-A7FF;
}
+1 -2
View File
@@ -1,6 +1,5 @@
@import 'tailwindcss';
@import '@fontsource-variable/rubik';
@import '@fontsource-variable/inter';
@import './fonts.css'; /* generated: npm run fonts */
@plugin '@tailwindcss/typography';
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));