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
+75
View File
@@ -0,0 +1,75 @@
// Post-build audit of dist/client: one h1, alt text, SEO tags, unique ids, internal links/anchors.
// Usage: npm run build && npm run check:site
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
const ROOT = 'dist/client';
const SERVER_ROUTES = [/^\/api\//]; // on-demand routes, not files
const walk = (dir) =>
readdirSync(dir).flatMap((f) => {
const p = join(dir, f);
return statSync(p).isDirectory() ? walk(p) : [p];
});
const pages = walk(ROOT).filter((f) => f.endsWith('.html') && !f.includes('/pagefind/'));
const idsByPage = new Map();
const problems = [];
const report = (page, msg) => problems.push(`${page}: ${msg}`);
const urlOf = (file) =>
'/' +
relative(ROOT, file)
.replace(/index\.html$/, '')
.replace(/\.html$/, '');
const html = new Map(pages.map((f) => [urlOf(f), readFileSync(f, 'utf8')]));
for (const [url, src] of html) {
const ids = [...src.matchAll(/\sid="([^"]+)"/g)].map((m) => m[1]);
idsByPage.set(url, new Set(ids));
const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);
if (dupes.length) report(url, `duplicate id(s): ${[...new Set(dupes)].join(', ')}`);
const h1 = (src.match(/<h1[\s>]/g) ?? []).length;
if (h1 !== 1) report(url, `${h1} <h1> elements`);
if (!/<html[^>]+lang="/.test(src)) report(url, 'missing <html lang>');
if (!/<title>[^<]+<\/title>/.test(src)) report(url, 'missing <title>');
if (url !== '/404' && !/<meta name="description" content="[^"]+"/.test(src))
report(url, 'missing meta description');
if (url !== '/404' && !/<link rel="canonical" href="https:\/\/ta1sph\.info\//.test(src))
report(url, 'missing canonical');
for (const img of src.match(/<img\b[^>]*>/g) ?? []) {
if (!/\salt="/.test(img)) report(url, `img without alt: ${img.slice(0, 80)}`);
}
}
for (const [url, src] of html) {
const refs = [...src.matchAll(/\s(?:href|src)="([^"]+)"/g)].map((m) => m[1]);
for (const ref of refs) {
if (/^(https?:|mailto:|tel:|data:|javascript:)/.test(ref) || ref.startsWith('//')) {
if (!ref.startsWith('https://ta1sph.info')) continue;
}
const target = new URL(ref.replaceAll('&amp;', '&'), `https://ta1sph.info${url}`);
if (target.host !== 'ta1sph.info') continue;
const path = decodeURIComponent(target.pathname);
if (SERVER_ROUTES.some((r) => r.test(path))) continue;
const file = path.endsWith('/')
? join(ROOT, path, 'index.html')
: ([join(ROOT, path), join(ROOT, `${path}.html`)].find(existsSync) ?? join(ROOT, path));
if (!existsSync(file)) {
report(url, `broken link → ${path}`);
continue;
}
if (target.hash && idsByPage.has(path)) {
const id = decodeURIComponent(target.hash.slice(1));
if (!idsByPage.get(path).has(id)) report(url, `missing anchor → ${path}${target.hash}`);
}
}
}
console.log(`Checked ${html.size} pages.`);
if (problems.length) {
console.error(problems.map((p) => ` ✗ ${p}`).join('\n'));
process.exit(1);
}
console.log(' ✓ no problems found');
+76
View File
@@ -0,0 +1,76 @@
// Regenerates the favicon set in public/ from the Tabler antenna-tower icon.
// Usage: npm run icons
import { writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import sharp from 'sharp';
const require = createRequire(import.meta.url);
const { icons } = require('@iconify-json/tabler/icons.json');
const RED = '#c95748';
const INK = '#111012';
const tower = (stroke) =>
icons['building-broadcast-tower'].body
.replaceAll('currentColor', INK)
.replaceAll('stroke-width="2"', `stroke-width="${stroke}"`);
/**
* Square icon: red background, ink tower.
* `pad` = icon inset (0..1), `radius` in viewBox units, `stroke` = Tabler stroke width.
*/
const svg = ({ pad = 0.14, radius = 6, stroke = 2.4 } = {}) => {
const size = 32;
const inner = size * (1 - pad * 2);
const offset = size * pad;
const scale = inner / 24;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}"><rect width="${size}" height="${size}" rx="${radius}" fill="${RED}"/><g transform="translate(${offset} ${offset}) scale(${scale})">${tower(stroke)}</g></svg>`;
};
const png = (source, px) =>
sharp(Buffer.from(source), { density: 72 * (px / 32) * 4 })
.resize(px, px)
.png()
.toBuffer();
/** ICO container with embedded PNG images. */
function ico(images) {
const header = Buffer.alloc(6 + images.length * 16);
header.writeUInt16LE(0, 0);
header.writeUInt16LE(1, 2);
header.writeUInt16LE(images.length, 4);
let offset = header.length;
images.forEach(({ size, data }, i) => {
const e = 6 + i * 16;
header.writeUInt8(size >= 256 ? 0 : size, e);
header.writeUInt8(size >= 256 ? 0 : size, e + 1);
header.writeUInt16LE(1, e + 4); // colour planes
header.writeUInt16LE(32, e + 6); // bits per pixel
header.writeUInt32LE(data.length, e + 8);
header.writeUInt32LE(offset, e + 12);
offset += data.length;
});
return Buffer.concat([header, ...images.map((i) => i.data)]);
}
const rounded = svg();
const tiny = svg({ pad: 0.06, radius: 4, stroke: 3.2 }); // 16 px tab icon: bigger, bolder
const square = svg({ radius: 0 }); // platforms apply their own mask
const maskable = svg({ pad: 0.24, radius: 0 }); // keep the icon inside the 80% safe zone
writeFileSync('public/favicon.svg', rounded);
writeFileSync(
'public/favicon.ico',
ico(
await Promise.all(
[16, 32, 48].map(async (size) => ({
size,
data: await png(size === 16 ? tiny : rounded, size),
})),
),
),
);
writeFileSync('public/apple-touch-icon.png', await png(svg({ pad: 0.18, radius: 0 }), 180));
writeFileSync('public/icon-192.png', await png(square, 192));
writeFileSync('public/icon-512.png', await png(square, 512));
writeFileSync('public/icon-maskable-512.png', await png(maskable, 512));
console.log('Icons written to public/');
+82
View File
@@ -0,0 +1,82 @@
// Builds small self-hosted variable fonts (Latin + Turkish + symbols used in posts) into public/fonts/.
// Sources: @fontsource-variable/{rubik,inter} (SIL OFL 1.1). Usage: npm run fonts
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import subsetFont from 'subset-font';
const require = createRequire(import.meta.url);
const ranges = [
[0x20, 0x7e], // Basic Latin
[0xa0, 0xff], // Latin-1 (ç ö ü · ° × …)
[0x11e, 0x11f], // Ğ ğ
[0x130, 0x131], // İ ı
[0x15e, 0x15f], // Ş ş
[0x394, 0x394], // Δ
[0x3a9, 0x3a9], // Ω
[0x3bb, 0x3bc], // λ μ
[0x2013, 0x2014], // – —
[0x2018, 0x201e], // ‘ ’ ‚ “ ” „
[0x2022, 0x2022], // •
[0x2026, 0x2026], // …
[0x2032, 0x2033], // ′ ″
[0x20ba, 0x20ba], // ₺
[0x2122, 0x2122], // ™
[0x2190, 0x2193], // ← ↑ → ↓
[0x2212, 0x2212], // −
[0x2248, 0x2248], // ≈
[0x2264, 0x2265], // ≤ ≥
];
const text = ranges
.flatMap(([a, b]) => Array.from({ length: b - a + 1 }, (_, i) => String.fromCodePoint(a + i)))
.join('');
// Fontsource ships each family split by unicode-range (latin / latin-ext). Each part is subset
// on its own and keeps Fontsource's unicode-range, so browsers fetch only the parts a page needs.
const families = [
{ pkg: '@fontsource-variable/rubik', family: 'Rubik Variable', slug: 'rubik', wght: [400, 900] },
{ pkg: '@fontsource-variable/inter', family: 'Inter Variable', slug: 'inter', wght: [400, 700] },
];
const parts = ['latin', 'latin-ext'];
/** unicode-range of one Fontsource part, read from the package's own CSS. */
function unicodeRange(pkg, slug, part) {
const css = readFileSync(require.resolve(`${pkg}/index.css`), 'utf8');
const block = css
.split('@font-face')
.find((b) => b.includes(`${slug}-${part}-wght-normal.woff2`));
const m = block?.match(/unicode-range:\s*([^;]+);/);
if (!m) throw new Error(`unicode-range not found for ${slug} ${part}`);
return m[1].trim();
}
mkdirSync('public/fonts', { recursive: true });
const faces = [];
for (const f of families) {
for (const part of parts) {
const source = readFileSync(
require.resolve(`${f.pkg}/files/${f.slug}-${part}-wght-normal.woff2`),
);
const out = await subsetFont(source, text, {
targetFormat: 'woff2',
variationAxes: { wght: { min: f.wght[0], max: f.wght[1] } },
});
const file = `${f.slug}-${part}.woff2`;
writeFileSync(`public/fonts/${file}`, out);
console.log(`${file}: ${source.length} → ${out.length} bytes`);
faces.push(`@font-face {
font-family: '${f.family}';
font-style: normal;
font-display: swap;
font-weight: ${f.wght[0]} ${f.wght[1]};
src: url('/fonts/${file}') format('woff2-variations');
unicode-range: ${unicodeRange(f.pkg, f.slug, part)};
}`);
}
}
writeFileSync(
'src/styles/fonts.css',
`/* Generated by scripts/subset-fonts.mjs — do not edit. Fonts: SIL OFL 1.1 (Rubik, Inter). */\n${faces.join('\n')}\n`,
);
console.log('Wrote src/styles/fonts.css');