From abc968fe6f69487f54702a053d457a9813672410 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sat, 5 Sep 2026 23:42:40 -0400 Subject: [PATCH] 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. --- apps/web/package.json | 4 +- apps/web/scripts/check-metadata.mjs | 93 +++++++++++++++++++++++++++++ package.json | 5 +- turbo.json | 31 ++++++++-- 4 files changed, 124 insertions(+), 9 deletions(-) create mode 100644 apps/web/scripts/check-metadata.mjs diff --git a/apps/web/package.json b/apps/web/package.json index 02e1632..b3dce32 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/scripts/check-metadata.mjs b/apps/web/scripts/check-metadata.mjs new file mode 100644 index 0000000..58626ff --- /dev/null +++ b/apps/web/scripts/check-metadata.mjs @@ -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`); diff --git a/package.json b/package.json index c40bccb..4528b8f 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "start": "turbo run start", "lint": "turbo run lint", "clean": "turbo run clean && rm -rf node_modules", - "format": "prettier --write \"**/*.{ts,tsx,md}\"" + "format": "prettier --write \"**/*.{ts,tsx,md}\"", + "check": "turbo run check" }, "devDependencies": { "eslint": "^9.0.0", @@ -39,4 +40,4 @@ "fast-uri": "^3.1.2" } } -} \ No newline at end of file +} diff --git a/turbo.json b/turbo.json index 9983fed..6090e0a 100644 --- a/turbo.json +++ b/turbo.json @@ -1,14 +1,27 @@ { "$schema": "https://turbo.build/schema.json", - "globalDependencies": ["**/.env.*local"], + "globalDependencies": [ + "**/.env.*local" + ], "tasks": { "build": { - "dependsOn": ["^build"], - "outputs": [".next/**", "!.next/cache/**", "dist/**", ".docusaurus/**"], - "env": ["NEXT_PUBLIC_TURNSTILE_SITE_KEY"] + "dependsOn": [ + "^build" + ], + "outputs": [ + ".next/**", + "!.next/cache/**", + "dist/**", + ".docusaurus/**" + ], + "env": [ + "NEXT_PUBLIC_TURNSTILE_SITE_KEY" + ] }, "lint": { - "dependsOn": ["^lint"] + "dependsOn": [ + "^lint" + ] }, "dev": { "cache": false, @@ -17,6 +30,12 @@ "start": { "cache": false, "persistent": true + }, + "check": { + "dependsOn": [ + "^build" + ], + "cache": false } } -} \ No newline at end of file +}