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