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:
@@ -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)} />
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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' } });
|
||||
};
|
||||
@@ -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' } });
|
||||
};
|
||||
@@ -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' } },
|
||||
);
|
||||
@@ -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' } },
|
||||
);
|
||||
@@ -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,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] *));
|
||||
|
||||
Reference in New Issue
Block a user