mirror of
https://github.com/Such-Software/such.software.git
synced 2026-10-17 05:32:05 +02:00
feat(web): a gate for the repository, starting with the tags in the built HTML
The site had no CI of any kind: no .github, no .gitea, no workflow. The only automation touching production is the redeploy timer, and it runs no checks. So `pnpm run check` now exists as the single command, lint then build then the metadata assertions, and it is the thing a CI lane will eventually call. The first assertion reads the emitted HTML, because that is the only place the defect it catches is visible. Next composes metadata at build time and merges it shallowly, so a page declaring its own `openGraph` silently replaces the parent's image. No diff, no type error and no lint rule shows that; only the output does. Reintroducing the bug on /support was confirmed to fail this check, and the refusal names the route, the missing tag, and what breaks without it. It refuses rather than passing vacuously when there is no build output, or when the output holds no HTML. A metadata checker that reports success over zero pages is worse than none, because it is quoted. Four tags per page, each with its reason: og:image, twitter:image, canonical, description. 51 of 51 today.
This commit is contained in:
@@ -9,7 +9,9 @@
|
||||
"lint": "eslint .",
|
||||
"test": "playwright test",
|
||||
"test:a11y": "playwright test tests/a11y.spec.ts",
|
||||
"test:ui": "playwright test --ui"
|
||||
"test:ui": "playwright test --ui",
|
||||
"check:metadata": "node scripts/check-metadata.mjs",
|
||||
"check": "pnpm run lint && pnpm run build && pnpm run check:metadata"
|
||||
},
|
||||
"dependencies": {
|
||||
"@marsidev/react-turnstile": "^1.4.1",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Assert the social and canonical tags on the pages we actually built.
|
||||
*
|
||||
* Metadata in Next is composed at build time from a parent object and whatever each page
|
||||
* exports, and it merges shallowly: a page that declares its own `openGraph` block replaces the
|
||||
* parent's, image included. That is not visible in a diff, in a type error, or in lint. It is
|
||||
* only visible in the HTML, which is why this reads the HTML.
|
||||
*
|
||||
* Seventeen pages had done it before `pageMetadata()` landed, twelve of them with no
|
||||
* `opengraph-image.tsx` to fall back on, so those shared to Slack and LinkedIn as a bare grey
|
||||
* rectangle for however long it had been true. This is the check that would have said so.
|
||||
*
|
||||
* node scripts/check-metadata.mjs # after `pnpm build`
|
||||
*/
|
||||
import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
|
||||
const BUILD = join(process.cwd(), ".next", "server", "app");
|
||||
|
||||
/** Every tag a page must carry, and why it is not optional. */
|
||||
const REQUIRED = [
|
||||
{
|
||||
name: "og:image",
|
||||
test: /property="og:image"/,
|
||||
why: "a shared link renders as a blank card without it",
|
||||
},
|
||||
{
|
||||
name: "twitter:image",
|
||||
test: /name="twitter:image"/,
|
||||
why: "X and iMessage read this one, not og:image",
|
||||
},
|
||||
{
|
||||
name: "canonical",
|
||||
test: /rel="canonical"/,
|
||||
why: "without it a page competes with its own query-string variants",
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
test: /name="description"/,
|
||||
why: "the search snippet is otherwise chosen for us",
|
||||
},
|
||||
];
|
||||
|
||||
function htmlFiles(dir, out = []) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) htmlFiles(full, out);
|
||||
else if (entry.name.endsWith(".html")) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (!existsSync(BUILD) || !statSync(BUILD).isDirectory()) {
|
||||
console.error(
|
||||
`check-metadata: no build output at ${relative(process.cwd(), BUILD)}. ` +
|
||||
"This reads the HTML Next actually emitted, so it needs `pnpm build` to have run. " +
|
||||
"Refusing rather than reporting a pass over zero pages.",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const pages = htmlFiles(BUILD);
|
||||
if (pages.length === 0) {
|
||||
console.error(
|
||||
`check-metadata: ${relative(process.cwd(), BUILD)} exists but contains no .html, so the ` +
|
||||
"build produced nothing to check. Refusing rather than passing vacuously.",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let failed = 0;
|
||||
for (const file of pages.sort()) {
|
||||
const html = readFileSync(file, "utf8");
|
||||
const route = "/" + relative(BUILD, file).replace(/\.html$/, "").replace(/^index$/, "");
|
||||
for (const { name, test, why } of REQUIRED) {
|
||||
if (!test.test(html)) {
|
||||
console.error(`check-metadata: ${route} has no ${name}: ${why}`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
console.error(
|
||||
`\ncheck-metadata: ${failed} missing tag(s) across ${pages.length} built pages. ` +
|
||||
"Build the page's metadata with `pageMetadata()` from src/lib/page-metadata.ts, which " +
|
||||
"fills these in, rather than hand-writing an openGraph block that drops them.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`check-metadata: ${pages.length} built pages carry og:image, twitter:image, canonical and description`);
|
||||
Reference in New Issue
Block a user