7d8fcce2e1
- 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>
76 lines
3.0 KiB
JavaScript
76 lines
3.0 KiB
JavaScript
// 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');
|