diff --git a/plan/www.md b/plan/www.md index dbef755..50e75e4 100644 --- a/plan/www.md +++ b/plan/www.md @@ -48,10 +48,6 @@ spindle — and neither is blocked on the other. - [ ] Build and publish it on a release, which is the same spindle [publishing](publishing.md) needs for the docs and does not have. Build one, use it for both -- [ ] A social card, for the link somebody pastes. `brand/` already renders - `png/social-card-1200x630.png`, and the page has no `og:image` because - an absolute URL to it is a claim about a host that does not answer yet. - Worth doing the moment one does ## Done @@ -112,3 +108,12 @@ spindle — and neither is blocked on the other. held by a test: a feature is one line and a command name, and a card that grew a paragraph fails it. `--help` has the flags, `docs/` has the model, `git log` has what changed +- [x] A social card, for the link somebody pastes. Every page advertises + `brand/png/social-card-1200x630.png` as its `og:image`, with the size, + alt text and a `summary_large_image` Twitter card beside it; a note also + carries its publication date, modified date and tags as `article:*`. + The image is imported straight from `brand/` rather than copied into + `www/`, so the one file the page advertises is the one + `brand/build.py` renders. `verify-dist` holds every page's `og:url` to + its canonical link, and the advertised image to 1200x630 and — when the + tree is there — to the brand render's exact bytes diff --git a/www/README.md b/www/README.md index e44794f..33c94aa 100644 --- a/www/README.md +++ b/www/README.md @@ -35,6 +35,13 @@ git. It holds only what the page cannot be trusted to state for itself: | `terminal.html` | `atgc about`'s own drawing, not a screenshot of it | | `fonts/` | Comic Neue, vendored in `src/html/site/fonts/` | +One image is deliberately not in that table. The social card the page +advertises as `og:image` is imported from `brand/png/social-card-1200x630.png` +by `Layout.astro`, because that PNG is already a pure function of +`brand/build.py` and a copy under `www/` would be the copy nobody rebuilds. +`verify-dist` checks that every page's image is a 1200x630 PNG and, when +`brand/` is present, that its bytes are the brand render's. + ## Updates The blog. Two names, both in `src/lib/feed.ts`: the menu says **updates**, @@ -171,8 +178,9 @@ identical bytes out for Astro. There is no second copy of `--a` anywhere, and version come from `facts.json`. A Rust test walks these sources and fails if an `.astro` file spells either of them out itself; `npm run build` then runs `scripts/verify-dist.mjs`, which fails if the built site is missing the -generated half, ships a font nothing references, reaches a third party, or -serves a note at an address its feed and its record would disagree with. +generated half, ships a font nothing references, reaches a third party, +serves a note at an address its feed and its record would disagree with, or +advertises a social card that is not the one `brand/` renders. **Nobody's dependency.** No atgc command fetches this page, no build needs it, and no error message sends anybody here for a fix. diff --git a/www/scripts/verify-dist.mjs b/www/scripts/verify-dist.mjs index 9fa9471..f9c2bdb 100644 --- a/www/scripts/verify-dist.mjs +++ b/www/scripts/verify-dist.mjs @@ -67,6 +67,38 @@ export function errorPageFrom(terraform) { return terraform.match(/response_page_path\s*=\s*"([^"]+)"/)?.[1] ?? null } +/** + * The content of a `` tag named by `property` or `name`. + * + * A regex rather than a parser because this reads one `
` of our own + * markup, and the only shapes in it are the ones this site emits. `null` is + * "the page does not say it", which is the answer the checks care about. + */ +export function metaContent(html, key) { + const pattern = new RegExp( + ` bytes[i] !== byte)) { + return null + } + return [bytes.readUInt32BE(16), bytes.readUInt32BE(20)] +} + /** The path the deployed distribution actually serves for a miss. */ const ERROR_PAGE = (() => { const config = resolve(webRoot, '../infra/main.tf') @@ -84,7 +116,13 @@ export function menuOf(html) { return [...nav.matchAll(/href="([^"]+)"/g)].map((m) => m[1]) } -export function problemsWith({ dist, facts, publicationUri = null, errorPagePath = ERROR_PAGE }) { +export function problemsWith({ + dist, + facts, + publicationUri = null, + errorPagePath = ERROR_PAGE, + socialCard = null, +}) { const problems = [] const files = walk(dist) const html = files.filter((f) => f.endsWith('.html')) @@ -278,6 +316,92 @@ export function problemsWith({ dist, facts, publicationUri = null, errorPagePath } } + // The link somebody pastes. Every page says the same three things about + // itself: a title and description a card can show, the address the card + // should point at, and the one image that stands in for atgc where nobody + // has opened anything yet. None of it is visible in a browser, which is + // exactly why it is checked here. + // + // `og:url` is held to the canonical link rather than recomputed: the two + // are the same claim about where this page lives, and a build that let + // them drift would hand a reader a card pointing at a different page. + const cardFiles = new Map() + for (const [address, page] of pages) { + const text = readFileSync(join(dist, page), 'utf8') + const canonical = text.match(/([^<]*)<\/title>/)?.[1] + + for (const [property, want] of [ + ['og:type', type], + ['og:title', title], + ['og:description', metaContent(text, 'description')], + ['og:url', canonical], + ]) { + const got = metaContent(text, property) + if (!got) { + problems.push(`${page} has no ${property}`) + } else if (want && got !== want) { + problems.push(`${page} has ${property} ${got}, want ${want}`) + } + } + if ( + metaContent(text, 'og:image:width') !== '1200' || + metaContent(text, 'og:image:height') !== '630' + ) { + problems.push(`${page} does not advertise a 1200x630 card`) + } + if (!metaContent(text, 'og:image:alt')) { + problems.push(`${page} has no og:image:alt`) + } + if (metaContent(text, 'twitter:card') !== 'summary_large_image') { + problems.push(`${page} has no summary_large_image twitter card`) + } + if (type === 'article' && !metaContent(text, 'article:published_time')) { + problems.push(`${page} is an article with no article:published_time`) + } + + const image = metaContent(text, 'og:image') + if (!image) { + problems.push(`${page} has no og:image`) + continue + } + let url + try { + url = new URL(image) + } catch { + problems.push(`${page} has a relative og:image ${image}`) + continue + } + if (url.origin !== new URL(facts.site).origin) { + problems.push(`${page} advertises a card on another origin: ${image}`) + continue + } + if (metaContent(text, 'twitter:image') !== image) { + problems.push(`${page} advertises different og: and twitter: images`) + } + cardFiles.set(url.pathname.replace(/^\//, ''), page) + } + + // The image itself, once however many pages name it. Existence, the + // dimensions the page claims, and — when the caller can supply them — the + // bytes `brand/build.py` rendered. A lookalike checked into `www/` would + // pass the first two and fail the third. + for (const [file, page] of cardFiles) { + if (!files.includes(file)) { + problems.push(`${page} advertises ${file}, which was not built`) + continue + } + const bytes = readFileSync(join(dist, file)) + const size = pngSize(bytes) + if (!size || size[0] !== 1200 || size[1] !== 630) { + problems.push(`${file} is not a 1200x630 PNG (${page} advertises it)`) + } + if (socialCard && Buffer.compare(bytes, socialCard) !== 0) { + problems.push(`${file} is not brand/png/social-card-1200x630.png`) + } + } + return problems } @@ -304,7 +428,19 @@ if (import.meta.url === `file://${process.argv[1]}`) { 'site.standard link tags are not checked. See README.md.', ) } - const problems = problemsWith({ dist, facts, publicationUri }) + // The one card the site advertises, as the generator wrote it. Read here + // rather than inside `problemsWith` so the test beside this file can build + // a fake tree with a fake card and still exercise every other check. + const brandCard = resolve(webRoot, '../brand/png/social-card-1200x630.png') + const socialCard = existsSync(brandCard) ? readFileSync(brandCard) : null + if (!socialCard) { + console.warn( + `verify-dist: ${brandCard} is missing, so the card is checked for size ` + + 'and not for being the file brand/build.py renders.', + ) + } + + const problems = problemsWith({ dist, facts, publicationUri, socialCard }) if (problems.length) { console.error('verify-dist: the built site is not deployable\n') for (const problem of problems) console.error(` - ${problem}`) diff --git a/www/scripts/verify-dist.test.mjs b/www/scripts/verify-dist.test.mjs index f3cadaf..6a7f6ab 100644 --- a/www/scripts/verify-dist.test.mjs +++ b/www/scripts/verify-dist.test.mjs @@ -11,7 +11,14 @@ import { join, dirname } from 'node:path' import { fileURLToPath } from 'node:url' import { test } from 'node:test' -import { addressOf, errorPageFrom, problemsWith, walk } from './verify-dist.mjs' +import { + addressOf, + errorPageFrom, + metaContent, + pngSize, + problemsWith, + walk, +} from './verify-dist.mjs' const FACTS = { version: '9.9.9', @@ -19,16 +26,71 @@ const FACTS = { site: 'https://atgc.codes', } +/** A PNG header of the given size. Only the IHDR is read, so this is one. */ +function png(width, height) { + const bytes = Buffer.alloc(33) + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(bytes) + bytes.writeUInt32BE(13, 8) + bytes.write('IHDR', 12, 'ascii') + bytes.writeUInt32BE(width, 16) + bytes.writeUInt32BE(height, 20) + bytes.writeUInt8(8, 24) + bytes.writeUInt8(6, 25) + return bytes +} + +const CARD_PATH = 'assets/social-card.test.png' +const CARD_URL = `https://atgc.codes/${CARD_PATH}` +const CARD = png(1200, 630) +const DESCRIPTION = 'a page about atgc' + /** A dist directory whose files are given as path → contents. */ function build(files) { const dist = mkdtempSync(join(tmpdir(), 'atgc-verify-')) - for (const [path, body] of Object.entries(files)) { + const all = { [CARD_PATH]: CARD, ...files } + for (const [path, body] of Object.entries(all)) { mkdirSync(dirname(join(dist, path)), { recursive: true }) writeFileSync(join(dist, path), body) } return dist } +/** + * The head a page needs to advertise the card. The og: field and the + * canonical link are the same claim about where the page lives, so this + * writes both from one argument rather than letting a fixture disagree with + * itself. + */ +function socialMeta(canonical, type = 'website', extra = '') { + return ` + + + + + + + + + + + + + + ${ + type === 'article' + ? '' + : '' + } + ${extra}` +} + +/** A page with a full head, for the pages that need nothing else. */ +function goodPage(canonical, type = 'website', body = '', extra = '') { + return ` +nothing
', 'og:type'), null) +}) + test('a missing index is the only thing reported', () => { const problems = problemsWith({ dist: build({}), facts: FACTS }) assert.equal(problems.length, 1) @@ -130,7 +256,7 @@ test('a third-party fetch is caught, and a link out is not', () => { 'assets/ComicNeue-Bold.abc.woff2': 'x', 'assets/ComicNeue-Regular.def.woff2': 'x', 'atom.xml': '