feat(seo): give each card link its own preview image (#1133)

Merged
DO
4d ago
+939-15

Closes #1133

What changes

A card link pasted into Facebook, Discord, Reddit or X now previews with an image of that card, not the site banner. Each image is 1200x630 and shows:

  • the card's art, glowing in its ink
  • its name and version
  • "Pairs with N cards"
  • its three best partners, each with a tag word

A card with no synergies shows "{Ink} · {Rarity} {type}" and no partner row. Locations are turned upright, both as the main card and as a partner.

  • scripts/generate-og-images.mjs draws every card in build:vercel, after pnpm build:web and before the crawl, in the Chromium the crawl installs.
    • It fills scripts/og/card-template.html from the build's own card data, synergy files, fonts and art. The page is served from a made-up origin, with every path answered from dist.
    • It writes dist/og/card/{id}.{sha256 prefix}.jpg.
    • It writes a record (apps/web/node_modules/.cache/og-cards.json, outside dist) of each card's file and alt text, removing an earlier build's record first.
    • It logs the count, size and time. Locally: 1,228 of 1,228 cards, 120.9 MiB, 29 s. The largest file is 124 KiB.
  • scripts/lib/cardOgImage.mjs holds the pure parts: the partner pick, what the template fills, the file name, the alt text and the tag swap. Its tests are in scripts/lib/__tests__/cardOgImage.test.mjs.
  • scripts/prerender.mjs swaps each card page's og:image, twitter:image and both alt tags for the card's own. Every other page keeps the banner. The swap throws when the page doesn't hold exactly one of each tag.
  • Check 12 of scripts/check-rendered-html.mjs fails the deploy when a card page with art kept the banner, when its twitter:image differs from its og:image, or when the image it names isn't in the build.
  • vercel.json serves /og/ immutable, since every name carries its hash. vite.config.ts keeps /og/ out of the worker's navigation fallback.
  • apps/web/index.html adds og:site_name "Inkweave" and alt text for the banner on every page.
  • deploy.yml runs the PR dry run when the generator, its template, check 12 or the root package.json (which holds build:vercel) changes. The lockfile stays out, since every Dependabot bump changes it.
  • The docs are updated in CLAUDE.md and IMAGES.md.

Where it differs from the issue

  • Tags come from the card's synergy file: each partner gets the label of the group it scores highest in. The labels are the same playstyle and rule names the issue named, but this way the engine's catalog isn't needed.
  • pairs is an object keyed by partner id, not an array.
  • The generator reads dist, not public/data, so it draws exactly the data and art the deploy ships.
  • A card that fails to draw doesn't stop the run. It is logged, its page keeps the banner, and check 12 then fails the deploy naming the card. The crawl runs even when the whole image step fails, and a drawing page that can't open leaves its cards to the others.
  • The crawl reads a record, not the folder, so it gets each image's alt text too: "{card} and its best Lorcana partners on Inkweave", or "{card}, a Lorcana card on Inkweave" without partners.
  • A card without art keeps the banner (none today: 1,228 of 1,228 drew). There's nothing card-specific to draw for a reveal before its scan, and failing the deploy over it would block shipping reveals.
  • Chromium runs with --disable-partial-raster. Without it, about 70 of 1,228 images changed bytes between identical builds because the logo's masks antialiased differently, so unchanged cards got new URLs. Three full runs now match byte for byte.
  • No prerender.test.mjs or knip.jsonc change was needed. The swap is tested in the helper's tests, and knip sees the generator through package.json.

Verification

  • pnpm test:scripts: 1176 passed (12 new helper tests, 6 for check 12, 2 for the build order)
  • lint:root, web lint and typecheck, knip, CodeScene gate (Code Health 10 on the new and changed scripts)
  • A sample crawl: card pages name https://inkweave.ink/og/card/{id}.{hash}.jpg in both image tags with the card's alt text, and /browse keeps /og-image.png
  • Planted fault: deleting one JPEG makes check 12 fail naming that card
  • A sheet of 12 rendered cards (Max Goof, a song, an item, Locations as hero and partner, dual ink, no partners, the longest name, Waternoose) looked right to me. It goes to the owner before merge.
  • Every JPEG is 1200x630 and under 300 KB
  • No app JavaScript or card data changes
  • The PR's Deploy dry run on 1103dab: 1,228 of 1,228 images in 31 s, 1,269 pages crawled with 0 failed, and check 12 passed on 1,228 card pages

After deploy: curl the og:image of /card/3354/max-goof-karaoke-star, check the image's headers, and try the link in Facebook's Sharing Debugger and a Discord message.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DjrxDUg8t6RHUrGiKw1neQ