✨ Generate per-rule Open Graph images - closes #2698 - #2700
Conversation
Rules pages emitted zero og:/twitter: meta tags, so shared links had no preview. This was a regression from the Gatsby migration: the old src/components/head/head.js set the full tag set with a default image, and nothing replaced it when it was deleted in 3c0fd0b. Adds Next metadata (metadataBase, openGraph, twitter) plus opengraph-image.tsx routes that render a card per rule with the author photos pulled from SSW.People.Profiles, reusing the slug convention already in AuthorsCard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven pages set only `title` in generateMetadata, so they inherited the layout's generic openGraph block - every one of them shared as "SSW.Rules | Secret Ingredients..." regardless of what the page actually was. Next does not derive og:title from `title`. Routes them all through lib/pageMetadata.ts, which builds title, description, canonical, openGraph and twitter together so the three cannot drift apart. It deliberately never sets openGraph.images: Next merges the opengraph-image.tsx card only when the page's openGraph has no `images` key (resolve-metadata.js:117). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Categories are served by the [filename] route, so they hit the same image handler as rules. The rule query throws for them, which previously left every category sharing an identical "SSW.Rules" card. mainCategoryQuery returns every top and child category with its title and filename in one un-paginated call, so the title resolves without the topCategoryWithIndexQuery pagination walk that page.tsx needs for a full relativePath. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Categories and the home page sit above individual rules but read lighter than them, because they have no author row to fill the bottom of the card. They now get a hub treatment: a larger title (their titles are shorter - the longest category is 62 chars against 107 for a rule) and the rule count in place of the byline. Counts come from the existing cached fetchCategoryRuleCounts and fetchRuleCount helpers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Satori's `alignItems: "baseline"` aligns line boxes rather than text baselines, so "rules" sat 7px below the digits. Pins both to lineHeight 1 with flex-end and offsets the label by the descender difference between the two font sizes. Verified by measuring glyph baselines in the rendered PNG: digits and label now both land on row 559. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Contributors are summarised as "N contributors" with a "+N" disc closing the avatar row, instead of listing names (which needed clamping for long ones) - The left slot is left empty when a page has no authors, rather than falling back to the site URL - Site-wide rule total and the URL now sit bottom-right on every card - Drops the large red per-category count, now redundant against that total Hub pages are flagged explicitly with isHub rather than inferred from having a count, so the 235 authorless rules keep the rule-sized title. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The card used site-config's homepageTitle, which carries "(Open Source on GitHub)" for search results. That reads as clutter on a social card and pushed the title onto three lines. The page <title> keeps the full version. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Blocking:
- metadataBase used siteUrl (".../rules"), but Next joins basePath into the image
path AND joins metadataBase.pathname on top, so every og:image resolved to
/rules/rules/<slug>/opengraph-image and 404d in production. Dev hid it by
substituting a localhost base. Now uses parentSiteUrl (origin only).
Material:
- Page-level openGraph/twitter replace the layout's rather than merging, so
siteName, locale and the Twitter handles were emitted on no page at all. They
are set in pageMetadata now.
- The Tina webhook never purged /<slug>/opengraph-image, leaving stale cards for
up to 24h after an edit.
- Rule vs category was dispatched by catching an exception, so a transient Tina
failure shipped a hub card titled "SSW.Rules" for a real rule. resolveOgTarget
dispatches on the result and returns an explicit "unknown".
- mainCategoryQuery ran uncached on every category card; now unstable_cache'd on
the existing category-rule-data tag.
- Unknown filenames rendered a card, an unbounded cache key and Satori render per
junk path. They now notFound().
- No tests covered the new pure logic; adds unit tests for authorImage and
sniffImageType, the two functions that broke during development.
- Brand colours were scattered hex; named at the top of the card with a note that
Satori cannot reach the ssw-* tokens.
Also: restores the GitHub avatar fallback and the ssw.com.au host guard the
AuthorsCard extraction dropped, moves IO out of the card into lib/og/images.ts,
renames OgCard to buildOgCard (Satori cannot render async components), drops
seven dead siteUrl imports, and declares esbuild with a verify:og script.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Blocking: - esbuild was added to package.json without updating pnpm-lock.yaml, so the Dockerfile and CI (both `pnpm install --frozen-lockfile`) would fail with ERR_PNPM_OUTDATED_LOCKFILE. Two reviewers independently suggested the dep was avoidable, and it was: jest already transforms TSX with the @/ mapper, so the preview is now an opt-in jest test and the dependency is gone entirely. The lockfile is untouched by this PR again. Material: - The category cache was tagged only `category-rule-data`, which the webhook never fires, so with no TTL it was effectively cached for a year. Tagged with `rule-count` as well and given a 24h revalidate. - A Tina outage collapsed to `unknown` and 404d a real rule's card. `unavailable` is now distinct from `unknown`, and only a genuine miss 404s. - The image route 404d on filenames where page.tsx still serves a 200. Those now render the generic card - a plain preview beats a broken one. - The avatar fetch had no timeout, so a slow GitHub could stall past a crawler deadline and yield no preview at all. Bounded to 3s. - Colour constants claimed to mirror the ssw-* tokens but did not; INK was #111111 against --color-ssw-black #333333. They are the real token values now. - resolveOgTarget, which decides 404-vs-card, had no tests. It and buildOgCard now do: 35 passing. - Dispatch order now matches page.tsx (category first) so the card and the page cannot disagree about what a filename is. Also: cards render in Nunito rather than Satori's default sans, one canonical `tagline` in site-config replaces four spellings of the homepage title, the layout reuses pageMetadata instead of restating it, and authorImageUrl replaces the resolve chain that was spelled out in both callers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Material: - Cards inherited ImageResponse's default `public, immutable, max-age=31536000`. The image URL's ?hash comes from the source file, not the rule content, so it never changes on an edit - the proxy and every scraper would have held a stale card for a year, somewhere revalidatePath cannot reach. Both routes now go through ogImageResponse, which sets s-maxage with stale-while-revalidate. - The round 2 outage guard never fired: getCategoryTitles is cached for 24h, so a warm cache does not throw, lookupFailed stayed false, and a real rule's card 404d during a Tina outage anyway. The unknown/unavailable split is gone - anything unresolved renders the generic card, which is also what page.tsx does for those paths. Deletes a state and the reason for the two-try/catch dance. - The vendored Nunito TTFs shipped without their license. OFL 1.1 requires it to accompany each copy; lib/og/fonts/OFL.txt added. Also: the webhook now fires the category-rule-data tag so a renamed category does not re-render from a stale title map, verify:og uses cross-env like the repo's other env-setting scripts, and the .gitignore comment no longer points at a script that was deleted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- The category-title purge added in round 3 sat inside the rule-count branch,
which only fires when a rule is ADDED, so a category rename never busted it and
the image-path purge re-rendered from the stale 24h map. Moved to the categories
branch behind its own flag, which also purges the root card (it carries the
site-wide rule total).
- That same line passed `{ expire: 0 }`, which is not in revalidateTag's signature.
It added a fifth tsc error that my own `grep -v app/api/revalidate` filter had
been hiding since round 1. Back to the 4 pre-existing.
- The Satori render had no guard. ImageResponse fetches emoji glyphs from a CDN
mid-render with no timeout of its own, so one blip 500d the route; it now falls
back to a plain card. Rule titles carrying emoji are common.
- Lookup and avatar failures were silent, so a Tina or GitHub outage produced
generic cards site-wide with no signal. They log now, as page.tsx does.
- outputFileTracingIncludes pins the OG fonts into the standalone bundle rather
than relying on the tracer inferring a runtime-built path.
- The "+N" chip asked for weight 600 with only 400/700 registered, so Satori was
silently snapping it.
Cosmetic-only changes from this round were dropped deliberately - every prior
round's regressions came from churn beyond the findings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Cards have been test passed by Pravin, may need some layout shifts to optimise for every platform but this is the generic sizing for now. |
|
/deploy |
| } | ||
|
|
||
| if (shouldRevalidateCategoryTitles) { | ||
| revalidateTag("category-rule-data"); |
There was a problem hiding this comment.
This breaks the build, the whole log can be found here: https://github.com/SSWConsulting/SSW.Rules/actions/runs/31004400052/job/92301202635
#18 97.31 Type error: Expected 2 arguments, but got 1.
#18 97.31
#18 97.31 86 |
#18 97.31 87 | if (shouldRevalidateCategoryTitles) {
#18 97.31 > 88 | revalidateTag("category-rule-data");
#18 97.31 | ^
#18 97.31 89 | // The root card carries the site-wide rule total
#18 97.31 90 | revalidatePath("/opengraph-image");
#18 97.31 91 | }
There was a problem hiding this comment.
Good catch, my local package was on Next 15 which created a whole slew of problems 😮💨
| description, | ||
| alternates: { canonical: url }, | ||
| openGraph: { title, description, url, type, siteName: siteTitle, locale: "en_AU" }, | ||
| twitter: { card: "summary_large_image", title, description, site: `@${social.twitter}`, creator: `@${social.twitter}` }, |
There was a problem hiding this comment.
Right now this returns description: undefined, which stops Next from inheriting siteDescription from the root layout. That leaves the homepage and several other routes without either <meta name="description"> or og:description.
There was a problem hiding this comment.
Confirmed, and thanks - this one was invisible to me.
| try { | ||
| return await render(element); | ||
| } catch (error) { | ||
| // Satori fetches emoji glyphs from a CDN mid-render with no timeout of its own, so | ||
| // a blip there would otherwise 500 the route. A plain card beats no card. | ||
| console.error("[og] render failed, falling back to the generic card:", error); | ||
| return render(await buildOgCard({ title: fallbackTitle ?? tagline, isHub: true })); | ||
| } |
There was a problem hiding this comment.
ImageResponse returns a 200 before the image is actually rendered. If rendering fails later, the try/catch has already finished, so a broken image may be returned and cached by the CDN.
There was a problem hiding this comment.
True, good improvement. Fixed
| title: "SSW.Rules | Secret Ingredients to Quality Software (Open Source on GitHub)", | ||
| description: | ||
| "Secret Ingredients to Quality Software | SSW Rules provides best practices for developing secure, reliable, and efficient .NET, Azure, CRM, Angular, React, Dynamics, and AI applications. Learn more today!", | ||
| ...pageMetadata({ title: defaultTitle, description: siteDescription }), |
There was a problem hiding this comment.
The root layout now uses the homepage URL as the default canonical:
https://www.ssw.com.au/rules/
This means any page without its own canonical, such as /preview, may be treated by search engines as a duplicate of the homepage. The 404 page may be affected as well.
Build break (thanks @Aibono1225 - caught by /deploy): - revalidateTag takes (tag, profile) on Next 16; I dropped the second argument last round to match a signature my local node_modules reported. Restored. Root cause worth recording: my node_modules was Next 15.5.15 while package.json and the lockfile are on 16.2.4 - stale since main was last pulled. Every local typecheck this branch has had was against the wrong Next, which is why the "4 pre-existing revalidate errors" I kept dismissing appeared to exist. With the correct version the repo has zero, and this line was the only real error. - pageMetadata returned `description: undefined` when a page did not supply one. Next's merge iterates the source's own keys and assigns `metadata[key] ?? null`, so that nulled the layout's description instead of inheriting it - the home page, search, latest-rules, user and categories were shipping without a description or og:description. It now defaults to siteDescription. - The try/catch around ImageResponse caught nothing. The constructor returns a 200 immediately and renders while the body streams, so a Satori failure landed after the catch had returned. The render is now buffered inside the try, which is what surfaces it. Also drops the custom s-maxage: Next 16 already defaults to must-revalidate, and a long edge cache outlives the webhook's purge. - The root layout no longer passes its canonical down. Spreading pageMetadata gave every page without its own - /preview, not-found, error - a canonical pointing at the home page, which reads as duplicate content. Adds __tests__/lib/pageMetadata.test.ts covering the description and canonical behaviour so both stay locked in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel/og bundles only a regular weight, so `fontWeight: 700` has been silently ignored on every version of this card - including the one attached to the PR. Verified by rendering the same text at 400 and 700 and getting byte-identical PNGs. The look everyone preferred is regular weight in the bundled default, so the vendored fonts were buying nothing. Removes lib/og/fonts (2 TTFs + OFL.txt), loadFonts, the fonts option, and the outputFileTracingIncludes that existed only to carry them. That also retires the OFL redistribution obligation and the standalone font-tracing risk. The fontWeight declarations go too rather than staying as no-ops: if a future Next bundled a bold face they would take effect and change the design without anyone touching the card. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
/deploy |
`export const revalidate = 60 * 60 * 24` fails the production build with "Invalid segment configuration export detected". Next extracts segment config statically from the AST (extractExportedConstValue) and cannot evaluate an expression, so the value is reported unsupported and the build exits. Only `revalidate` is affected - `size`, `contentType` and `alt` are metadata exports, not segment config, so importing them is fine. Every other revalidate in the repo is already a plain literal; these two were the exception. Caught by the pr-deploy run, which got past the TypeScript stage for the first time and reached page-data collection. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
/deploy |
PR Preview Deployed
This preview will be automatically deleted when the PR is closed. |
Reverts the round 1 change. The og:image on the deployed preview came out as https://www.ssw.com.au/reply-done/opengraph-image - no /rules - while the card actually serves from /rules/reply-done/opengraph-image, so every og:image would have 404d in production. Next emits the image path WITHOUT basePath and resolveUrl joins metadataBase.pathname onto it, so the base has to carry /rules. The original code was right. Why it was changed: the analysis behind that round 1 finding was done against Next 15.5.15, which was what node_modules held while package.json and CI were on 16.2.4. Four reviewers independently "confirmed" it, all reading the same stale copy. Only a real deployment settled it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
/deploy |
The deployed preview showed og:image on rules and categories only. Home, search, latest-rules, archived, categories-index and user emitted none - despite /rules/opengraph-image itself returning a valid PNG. Next's metadata image files apply to the segment they sit in and are not inherited by nested segments, so app/opengraph-image.tsx was covering the root and nothing else. Rules worked only because their file sits in app/[filename]/ beside the page. Adds a thin opengraph-image.tsx per segment re-exporting a shared lib/og/siteCard. `revalidate` is redeclared as a literal in each rather than re-exported, because segment config is extracted statically from the AST. Caught by the deploy - no local check could see it, since it depends on how Next assembles metadata across segments at build time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
/deploy |
|
@Aibono1225 please look into this again. Appreciate it! |
|
Heads-up for anyone validating this on the preview slot: opengraph.xyz will report
That path does not exist on production until this merges, so the validator fetches HTML instead of an image. The card itself is fine - the same path on the slot host returns a real PNG: curl -sI https://app-sswrules-staging-pr-2700.azurewebsites.net/rules/opengraph-image-12gd74
# 200 image/pngTo check a preview, request the card path on the slot host directly rather than pasting the page into a validator. All 8 page types were verified this way. Making previews externally validatable would mean passing the slot origin as a build arg through Two unrelated warnings that validator also raises are pre-existing content, not from this change: the site title is 74 characters and |







Closes #2698. Supersedes #2699 and #2692 (both reference a
/og-image.jpgthat was never added to the repo — see below).Pain
Rules pages emitted zero
og:/twitter:meta tags, so every shared link fell back to a plain text card.This is a regression from the Gatsby → Next migration. The old
src/components/head/head.jsset the full tag set with a default image; it was deleted in3c0fd0b("removing gatsby files") and nothing replaced it.What this does
Both phases from #2698, since the dynamic card turned out to be roughly the same work as a static default:
metadataBase,openGraphandtwitterdefaults inapp/layout.tsx, with per-page overrides ingenerateMetadataapp/[filename]/opengraph-image.tsxrenders a card per rule with the title and author photos;app/opengraph-image.tsxcovers everything else (home, search, latest-rules, archived, user, orphaned)Author photos reuse the slug → profile convention that already existed in
components/AuthorsCard.tsx. That logic moved tolib/authorImage.tsso it lives in one place instead of two.No new dependencies —
next/ogships with Next.TODO before review
~/Downloads/ssw-rules-og-screenshots/):1-single-author.png— the common case (54% of rules)2-many-authors.png— 2 faces + "+N more"3-no-authors.png— 235 rules and all category pages4-longest-content.png— longest real title (107 chars) + longest real name pair5-missing-photos.png— placeholder fallbackDesign decisions worth a look
Background asset. Uses SSW's own
polygonBackground.pngfrom SSW.Website, but that asset is near-black and this card is light. Fading it down crushes its facet contrast proportionally — atopacity: 0.15only 7 RGB levels survived, which is invisible. It's inverted once offline instead and committed aspublic/og-polygon.png(53 levels of contrast, 116KB). The recipe is in a comment incomponents/og/card.tsxso it's reproducible. This is not a use the asset was designed for and is worth a brand check.Two authors, not one. #1379 proposed "the image of the first author", but 46% of rules have co-authors, so one face misrepresents them. Capped at 2 faces + "+N more" — 78% of rules show every author. Note the order is just frontmatter order; it matches
createdByonly 9% of the time, so it's an editorial convention rather than anything enforced.Overlapping avatars are masked, not ringed. A white separator ring only works on a white background. The photo behind has a hole masked out of it so the polygon shows through.
Things found along the way
.jpgextension (Chris-Briggs, Florent-Dezettre, Eli-Kent, Igor-Goldobin, Stanley-Sidik). GitHub serves them ascontent-type: image/jpegfrom the extension, and Satori throwsInvalid JPEGif you believe the header — this would have failed the card for every rule those five authored. Fixed by sniffing magic bytes. Worth fixing upstream in the profiles repo too.Images/folder, plus non-person URLs like/people/alumni,/people/static,/people/samplein the content. These fall back to the existing placeholder./people/warwickleahyand/people/RobThomlinsondon't title-case correctly, so their photos 404 on the authors card today. Content bug, not fixed here.'Jeoffrey Fischer '. Trimmed at render, still wrong in source.Known gaps
topCategoryWithIndexQuerywalk thatpage.tsxdoes, which is too heavy per-image. Rules — the actual ask — are fully handled.ImageResponse.output: "standalone"+ Docker.next/oguses WASM; worth confirming it traces into the standalone bundle on the first deploy.Testing
node scripts/og-verify.mjs [outDir]renders all five cases without needing Tina or a running site — it's how the screenshots above were produced, and how the mislabelled-PNG bug was caught.Typecheck and Biome are clean. The 4 pre-existing
tscerrors inapp/api/revalidate*are onmainalready and untouched here.