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 +}