Skip to content

Give the reveals, playstyle and home pages their own link preview images #1135

Closed

Description

@Doberjohn

Context

Since #1133, each card page with art previews with its own image. Every other page still previews with the site banner, https://inkweave.ink/og-image.png (the INKWEAVE wordmark over "Master Lorcana Synergies", no cards), which apps/web/index.html sets once for the whole site.

Vercel Web Analytics' referrers for 6 Sep to 5 Nov 2026 put /reveals first for Facebook and Reddit visits, almost all of them in the two reveal seasons, and the January reveals are the next wave. Home is second. The playstyle pages get mostly search visits today, and they are the pages the marketing plan's weekly synergy posts will link.

The owner compared samples of six candidates on 2026-11-05 (a private page) and picked reveals, the 25 playstyle pages and a new home banner, which also becomes the image of every page without one of its own.

Decisions, with the alternative each turns down:

  • Drawn in the deploy build, by the Give each card page its own link preview image, with its art and three best partners #1133 generator, before the card images and in the same Chromium. 27 more images add seconds. A request-time function is turned down for Give each card page its own link preview image, with its art and three best partners #1133's reasons: card art exists only as AVIF, and the data changes only when a deploy does.
  • The new home banner replaces /og-image.png on every captured page that has no image of its own: the playstyles gallery, the ink pages, /browse, /vote, /about, the legal pages, release notes, and a card without art. apps/web/index.html keeps /og-image.png for builds without the crawl (staging, SKIP_PRERENDER=1) and for 404.html. The routes the crawl never captures (compare, in-depth vote, account) already get the home page's capture from vercel.json, so they get the new banner too.
  • Reveals is drawn only in season: the same test the crawl uses to decide whether to capture /reveals at all (isRevealSeasonActive in scripts/prerender.mjs). Off season /reveals serves the home capture, so it shows the home banner. Every reveal already deploys, so the image is never older than the newest reveal.
  • The image's numbers come from the same places the pages read: a playstyle's cards from data/synergies/_playstyles.json, sorted by full name as the gallery sorts them; its hero and accent from PLAYSTYLE_UI in apps/web/src/shared/constants/playstyleUi.ts; the reveal set and its total from REVEAL_SET_CODE and PER_INK in apps/web/src/shared/constants/revealSet.ts. A root script can't import the app's TypeScript, so the helper reads those two files as text, and its tests fail when either can't be read or misses a playstyle the engine lists (the pattern of INK_GLOW's test).
  • Each file is named {key}.{hash}.jpg under /og/page/, the hash being the first 16 hex characters of its sha256, so vercel.json's existing immutable rule for /og/ stays truthful (Content-address card image URLs to fix Vercel cache poisoning #323) and a changed image reaches Facebook as a new URL.

The layouts are the approved samples:

  • Reveals: "{Set name} reveals" kicker, the set's name, "{n} of {total} cards revealed" with a gold progress bar, a line on what the page offers, and the five newest revealed cards with art fanned out on the right. With fewer than five, the fan shows what there is.
  • Playstyle: the gallery tile's fan (the hero in front, then the next four cards with art by name; Locations, which are printed sideways, left out), "Lorcana playstyle · {n} cards", the playstyle's name and tagline, and "Key cards" naming the fan's first three, glowing in the playstyle's accent.
  • Home: the Inkweave lockup and inkweave.ink, "Disney Lorcana synergy finder", "Find the cards that work together", and the six featured cards (data/featuredCards.json, which Featured cards in admin writes) in an arc, each glowing in its ink.

Out of scope: an image of its own for the playstyles gallery, the ink pages or /vote (each costs little later, on the same template); compare and in-depth vote links, which aren't prerendered, so a pair would need a request-time function; refreshing Facebook's cached previews of links already shared.

Success: a link to /, a playstyle page or, in season, /reveals pasted into Facebook or Discord shows that page's own image, and every other page shows the new home banner instead of the old wordmark.

Prerequisites

  • Write access to Doberjohn/inkweave, on a branch off master
  • Node 24 and pnpm 10.34.7, after pnpm install --frozen-lockfile, and Chromium for Playwright
  • A local build with card art: node scripts/download-card-images.mjs, pnpm build:engine, node scripts/precompute-synergies.mjs, pnpm build:web
  • Know Give each card page its own link preview image, with its art and three best partners #1133's pieces: scripts/generate-og-images.mjs, scripts/lib/cardOgImage.mjs, the crawl's tag swap in scripts/prerender.mjs, and check 12 of scripts/check-rendered-html.mjs

Implementation Steps

Step 1: The pure helpers

scripts/lib/pageOgImage.mjs exports:

  • parsePlaystyleUi(tsText): {[id]: {accent, heroId}} from playstyleUi.ts.
  • parseRevealSet(tsText): {code, total} from revealSet.ts, the total being the sum of PER_INK.
  • revealSeasonSet(preview, now): the code of a set in previewCards.json whose release date is still ahead, or null. isRevealSeasonActive in scripts/prerender.mjs calls it, so the crawl and the generator never disagree.
  • homeOgData, playstyleOgData, revealsOgData: what the template fills, each with its alt text: "Inkweave: find the Lorcana cards that work together", "{Playstyle} Lorcana playstyle on Inkweave, with {hero} and more of its cards", "{Set} reveals on Inkweave: {n} of {total} cards revealed".
  • pageOgImagesByRoute(record): each page's {url, alt} by route, and the home image as the default.

withCardOgImage in scripts/lib/cardOgImage.mjs becomes withOgImage, since pages use it too.

pnpm test:scripts -- pageOgImage

Verify: scripts/lib/__tests__/pageOgImage.test.mjs passes: the real playstyleUi.ts parses with an entry for every engine playstyle, the real revealSet.ts parses to a positive total, the season test, a playstyle's fan (name order, hero first, no Location, no card without art), reveals with fewer than five cards, home's six cards, the alt texts, and the default.

Step 2: The template

scripts/og/page-template.html: the three layouts at 1200x630 as sampled, with a window.fillPage(kind, data) that waits for its fonts and images like card-template.html's fillCard. A name or title steps down in size until it fits.

Step 3: The generator

scripts/generate-og-images.mjs draws the page images on one page with the page template before it draws the cards, writes dist/og/page/{home|reveals|playstyle-<id>}.{hash}.jpg, and records them in apps/web/node_modules/.cache/og-pages.json, {[route]: {file, alt}}, removing an earlier build's record first. One that fails to draw is logged and left out, as a card is. The log line gives the count, size and time for pages and cards.

Verify: after pnpm build:web && node scripts/generate-og-images.mjs, dist/og/page/ holds 26 JPEGs off season (home and 25 playstyles), each 1200x630.

Step 4: The crawl writes them

scripts/prerender.mjs reads both records and passes each captured page through withOgImage with its own image: a card page its card's, /, each playstyle page and /reveals their own, and every other page the home image.

Verify: after a sample crawl, dist/index.html and dist/playstyles/bounce/index.html name their own /og/page/ image in both image tags, and dist/browse/index.html names the home image.

Step 5: Deploy check 13

scripts/check-rendered-html.mjs adds check 13: / and each playstyle page name their own /og/page/ image, /reveals too when the crawl wrote it, and every other route in apps/web/src/prerenderedRoutes.json names the home image; twitter:image equals og:image; the build holds the file. Tests cover a passing build, a page that kept /og-image.png, a playstyle page with the home image, and a missing file.

Verify: the tests pass, and deleting dist/og/page/playstyle-bounce.*.jpg from a sample output makes node scripts/check-rendered-html.mjs apps/web/dist fail naming that page.

Step 6: Docs and the dry run

CLAUDE.md's Card link previews note and apps/web/src/features/cards/IMAGES.md cover the page images; deploy.yml's dry-run paths add scripts/lib/pageOgImage.mjs.

Files Affected

Created

File Purpose
scripts/lib/pageOgImage.mjs What each page image shows, its alt text, the parsers, the season test
scripts/lib/__tests__/pageOgImage.test.mjs Their cases
scripts/og/page-template.html The three 1200x630 layouts

Modified

File Change
scripts/generate-og-images.mjs Draws the page images and writes their record
scripts/lib/cardOgImage.mjs, its test withCardOgImage becomes withOgImage
scripts/prerender.mjs, scripts/prerender.test.mjs Every page gets its image; the season test moves to the helper
scripts/check-rendered-html.mjs, scripts/check-rendered-html.test.mjs Check 13
.github/workflows/deploy.yml The new helper in the dry run's paths
CLAUDE.md, apps/web/src/features/cards/IMAGES.md Page images

Testing / Verification

Gates

  • pnpm test:scripts, pnpm lint:root, knip, and the CodeScene gate pass
  • The PR's Deploy dry run passes, its log shows the page images' count, and check 13 passes
  • No app JavaScript or card data changes

Images

  • A sheet shown to the owner before merge: the home banner, /reveals drawn over a faked season (Set 14's cards standing in, once with five reveals and once with two), and playstyles with a long name (Location Control), a long tagline, a dual-ink hero (Self-Discard), and one whose first cards include a Location → nothing overlaps or runs out of its box
  • Each JPEG is 1200x630 and under 300 KB
  • Two identical runs give the same file names

After deploy

  • curl -s https://inkweave.ink/ | grep og:image → /og/page/home.<hash>.jpg, and /browse names the same
  • curl -s https://inkweave.ink/playstyles/bounce | grep og:image → /og/page/playstyle-bounce.<hash>.jpg
  • curl -sI on one → 200, image/jpeg, max-age=31536000, immutable
  • Facebook's Sharing Debugger shows the home and a playstyle link with their images (Scrape Again for home, which Facebook has cached)

Acceptance Criteria

  • / and each of the 25 playstyle pages name their own 1200x630 JPEG in og:image and twitter:image, with alt text
  • In reveal season, /reveals names its own image, showing the set, its revealed count against the set's total, and its newest revealed cards
  • Every other prerendered page names the home image; none names /og-image.png
  • Image URLs carry a hash of their bytes and are served immutable
  • The deploy fails when a page lacks its image, and a planted fault proves it
  • The tests fail when playstyleUi.ts or revealSet.ts stops parsing, or a playstyle has no entry
  • Staging's build (SKIP_PRERENDER=1) draws no images
  • The app's JavaScript and card data are unchanged

Rollback

<merge> is the merge commit of the PR that closes this issue; each command runs on a branch off master, followed by a commit and a PR.

  • Full rollback: git revert -m 1 <merge>, then merge that PR. The pieces depend on each other (the crawl names what the generator records, and check 13 reads what the crawl wrote), so a partial revert isn't offered. Card images stay; every other page shows /og-image.png again. Links Facebook already scraped keep the new image until it scrapes them again.

References

Activity

  1. added this to the Fixes and upkeep milestone on Nov 5, 2026
  2. added a commit that references this issue on Nov 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions