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 ` + a page${socialMeta(canonical, type, extra)} + ${body}` +} + /** A page with everything verify-dist wants, so a test can remove one thing. */ function goodIndex(over = {}) { const { @@ -40,7 +102,7 @@ function goodIndex(over = {}) { } = over const faces = fonts.map((f) => `@font-face{src:url(/assets/${f})}`).join('') return ` - + a page${socialMeta('https://atgc.codes/')} @@ -57,11 +119,9 @@ function goodDist(over = {}) { 'atom.xml': 'genetic drafts @ atgc.codes' + 'https://atgc.codes/posts/one', - 'posts/one.html': - '', - 'posts/index.html': - '', - '404.html': 'not found', + 'posts/one.html': goodPage('https://atgc.codes/posts/one', 'article'), + 'posts/index.html': goodPage('https://atgc.codes/posts'), + '404.html': goodPage('https://atgc.codes/404'), ...(over.files ?? {}), }) } @@ -70,6 +130,72 @@ test('a complete build has no problems', () => { assert.deepEqual(problemsWith({ dist: goodDist(), facts: FACTS }), []) }) +test('a page with no social card is caught', () => { + const page = goodIndex().replace( + //, + '', + ) + const dist = goodDist({ files: { 'index.html': page } }) + const problems = problemsWith({ dist, facts: FACTS }) + assert.ok(problems.some((p) => p.includes('has no og:image')), problems) +}) + +test('a social card with a relative address is caught', () => { + const page = goodIndex().replace(`content="${CARD_URL}"`, `content="${CARD_PATH}"`) + const dist = goodDist({ files: { 'index.html': page } }) + const problems = problemsWith({ dist, facts: FACTS }) + assert.ok(problems.some((p) => p.includes('relative og:image')), problems) +}) + +test('a social card that was not built is caught', () => { + const dist = goodDist() + rmSync(join(dist, CARD_PATH)) + const problems = problemsWith({ dist, facts: FACTS }) + assert.ok(problems.some((p) => p.includes('which was not built')), problems) +}) + +test('a social card of the wrong size is caught', () => { + const dist = goodDist({ files: { [CARD_PATH]: png(600, 600) } }) + const problems = problemsWith({ dist, facts: FACTS }) + assert.ok(problems.some((p) => p.includes('not a 1200x630 PNG')), problems) +}) + +test('a card that is not the brand render is caught', () => { + // Same dimensions, different bytes: the shape a checked-in lookalike has. + const lookalike = Buffer.from(CARD) + lookalike[32] ^= 0xff + const problems = problemsWith({ + dist: goodDist(), + facts: FACTS, + socialCard: lookalike, + }) + assert.ok( + problems.some((p) => p.includes('is not brand/png/social-card-1200x630.png')), + problems, + ) +}) + +test('an article with no published time is caught', () => { + const page = goodPage('https://atgc.codes/posts/one', 'article').replace( + /]*>/, + '', + ) + const dist = goodDist({ files: { 'posts/one.html': page } }) + const problems = problemsWith({ dist, facts: FACTS }) + assert.ok( + problems.some((p) => p.includes('no article:published_time')), + problems, + ) +}) + +test('a PNG header answers its size, and anything else is null', () => { + assert.deepEqual(pngSize(png(1200, 630)), [1200, 630]) + assert.equal(pngSize(Buffer.from('not a png')), null) + assert.equal(metaContent('', 'og:type'), 'article') + assert.equal(metaContent('', 'twitter:card'), 'x') + assert.equal(metaContent('

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': 'genetic drafts @ atgc.codes', - '404.html': 'not found', + '404.html': goodPage('https://atgc.codes/404'), }) assert.deepEqual(problemsWith({ dist: withLink, facts: FACTS }), []) }) @@ -187,9 +313,8 @@ test('a note built as a directory satisfies the feed', () => { 'genetic drafts @ atgc.codes' + 'https://atgc.codes/atom.xml' + 'https://atgc.codes/posts/one', - 'posts/one/index.html': - '', - '404.html': 'not found', + 'posts/one/index.html': goodPage('https://atgc.codes/posts/one', 'article'), + '404.html': goodPage('https://atgc.codes/404'), }) assert.deepEqual(problemsWith({ dist, facts: FACTS }), []) }) @@ -203,7 +328,7 @@ test("the feed's own id is not mistaken for a page", () => { 'atom.xml': 'genetic drafts @ atgc.codes' + 'https://atgc.codes/atom.xml', - '404.html': 'not found', + '404.html': goodPage('https://atgc.codes/404'), }) assert.deepEqual(problemsWith({ dist, facts: FACTS }), []) }) @@ -231,7 +356,7 @@ function publishedDist() { 'assets/ComicNeue-Regular.def.woff2': 'x', 'atom.xml': 'genetic drafts @ atgc.codes', '.well-known/site.standard.publication': `${PUB}\n`, - '404.html': `${tag}not found`, + '404.html': goodPage('https://atgc.codes/404', 'website', 'not found', tag), }) } @@ -286,8 +411,14 @@ test('a missing well-known file is caught', () => { }) test('walk returns paths relative to the directory it was given', () => { + // `build` plants the social card every dist has, so it is part of the tree + // this walks; the nesting it is about is the point. const dist = build({ 'a.html': 'x', 'deep/b.woff2': 'y' }) - assert.deepEqual(walk(dist).sort(), ['a.html', 'deep/b.woff2']) + assert.deepEqual(walk(dist).sort(), [ + 'a.html', + CARD_PATH, + 'deep/b.woff2', + ]) assert.deepEqual(walk(join(dist, 'nope')), []) }) @@ -388,10 +519,16 @@ function withMenu(menu, { notesMenu = menu, sections = ['about'] } = {}) { return goodDist({ files: { 'index.html': goodIndex().replace('', `${nav(menu)}${anchors}`), - 'posts/index.html': - '' + nav(notesMenu), - 'posts/one.html': - '' + nav(menu), + 'posts/index.html': goodPage( + 'https://atgc.codes/posts', + 'website', + nav(notesMenu), + ), + 'posts/one.html': goodPage( + 'https://atgc.codes/posts/one', + 'article', + nav(menu), + ), }, }) } diff --git a/www/src/layouts/Layout.astro b/www/src/layouts/Layout.astro index 7eb9fa6..c70fc3d 100644 --- a/www/src/layouts/Layout.astro +++ b/www/src/layouts/Layout.astro @@ -18,6 +18,13 @@ import '../generated/theme.css' import '../generated/field.css' import '../styles/site.css' +// The card somebody sees when the link is pasted into a chat. Imported from +// `brand/` rather than copied into this tree: that PNG is a pure function of +// `brand/build.py`, and a second copy under `www/` would be the copy nobody +// rebuilds. Vite emits it under `assets/`, where the deploy script caches it +// forever because the filename carries its content hash. +import socialCard from '../../../brand/png/social-card-1200x630.png?url' + // `?url` and not a copy in `public/`: the favicon is generated, and a // checked-in copy of a generated file is a copy to go stale — the // end-of-file hook rewrote one within a minute of it existing. @@ -47,6 +54,14 @@ interface Props { * which record this address is the rendering of. */ atUri?: string + /** + * What a card should make of this page: a note is an `article` and can + * carry its dates and tags; everything else is the `website` itself. + */ + kind?: 'website' | 'article' + published?: Date + updated?: Date + tags?: string[] } const { @@ -54,6 +69,10 @@ const { description, nav = [...SECTIONS], atUri, + kind = 'website', + published, + updated, + tags = [], } = Astro.props const bases = ['a', 't', 'g', 'c'] as const @@ -61,6 +80,15 @@ const bases = ['a', 't', 'g', 'c'] as const // Absolute, from `site` in astro.config.mjs. `trailingSlash: "never"` is // what keeps this equal to the address a document record claims. const canonical = new URL(Astro.url.pathname, Astro.site) +// Absolute for the same reason: a card fetcher is not on this origin and +// resolves a relative image against nowhere. `socialCard` is the emitted +// asset path; the filename carries a hash, so a rebuilt card is a new URL +// rather than a cached old one. +const socialImage = new URL(socialCard, Astro.site) +// What the image is, for anybody who cannot see it. Stated once here rather +// than at each page, because it is the same picture everywhere. +const socialAlt = + 'atgc — the base-pair mark and the name, on the field of DNA' --- @@ -71,6 +99,31 @@ const canonical = new URL(Astro.url.pathname, Astro.site) + + {/* The link as it looks somewhere else. Open Graph is what chat clients + and social networks read; `twitter:card` is what makes the image large + rather than a thumbnail. The image's size and alt are stated because a + card that names neither is laid out by guesswork, and the file itself + is the one `brand/` renders — `verify-dist` holds it to 1200x630 and to + the bytes beside this tree. */} + + + + + + + + + + + + {published && ( + + )} + {updated && ( + + )} + {tags.map((tag) => )}