Skip to content

✨ Generate per-rule Open Graph images - closes #2698 - #2700

Merged
isaaclombardssw merged 16 commits into
mainfrom
feature/dynamic-og-images-2698
Aug 6, 2026
Merged

✨ Generate per-rule Open Graph images - closes #2698#2700
isaaclombardssw merged 16 commits into
mainfrom
feature/dynamic-og-images-2698

Conversation

@isaaclombardssw

@isaaclombardssw isaaclombardssw commented Aug 5, 2026

Copy link
Copy Markdown
Member

Closes #2698. Supersedes #2699 and #2692 (both reference a /og-image.jpg that 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.

curl -sL https://www.ssw.com.au/rules/reply-done | grep -i 'og:\|twitter:'
# (no output)

This is a regression from the Gatsby → Next migration. The old src/components/head/head.js set the full tag set with a default image; it was deleted in 3c0fd0b ("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:

  • MetadatametadataBase, openGraph and twitter defaults in app/layout.tsx, with per-page overrides in generateMetadata
  • Generated cardsapp/[filename]/opengraph-image.tsx renders a card per rule with the title and author photos; app/opengraph-image.tsx covers 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 to lib/authorImage.ts so it lives in one place instead of two.

No new dependencies — next/og ships with Next.

TODO before review

  • Attach screenshots (in ~/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 pages
    • 4-longest-content.png — longest real title (107 chars) + longest real name pair
    • 5-missing-photos.png — placeholder fallback
  • Confirm the polygon treatment with whoever owns the brand (see "Background asset")
  • Verify a real paste into Teams/Slack once a preview environment is up

Design decisions worth a look

Background asset. Uses SSW's own polygonBackground.png from SSW.Website, but that asset is near-black and this card is light. Fading it down crushes its facet contrast proportionally — at opacity: 0.15 only 7 RGB levels survived, which is invisible. It's inverted once offline instead and committed as public/og-polygon.png (53 levels of contrast, 116KB). The recipe is in a comment in components/og/card.tsx so 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 createdBy only 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

  • 5 of 203 profile photos in SSW.People.Profiles are PNGs with a .jpg extension (Chris-Briggs, Florent-Dezettre, Eli-Kent, Igor-Goldobin, Stanley-Sidik). GitHub serves them as content-type: image/jpeg from the extension, and Satori throws Invalid JPEG if 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.
  • 13 of 216 author URLs have no profile photo — alumni whose profile directory has no Images/ folder, plus non-person URLs like /people/alumni, /people/static, /people/sample in the content. These fall back to the existing placeholder.
  • Two author slugs are missing hyphens/people/warwickleahy and /people/RobThomlinson don't title-case correctly, so their photos 404 on the authors card today. Content bug, not fixed here.
  • One author name has quotes and a trailing space baked in'Jeoffrey Fischer '. Trimmed at render, still wrong in source.

Known gaps

  • Category cards show the site title, not the category name. Resolving a category title needs the paginated topCategoryWithIndexQuery walk that page.tsx does, which is too heavy per-image. Rules — the actual ask — are fully handled.
  • Cards use Satori's default font, not Nunito/Lato. Brand fonts need the font files loaded into ImageResponse.
  • output: "standalone" + Docker. next/og uses 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 tsc errors in app/api/revalidate* are on main already and untouched here.

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>
@isaaclombardssw

isaaclombardssw commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Screenshots

1-single-author

Figure: single author

2-many-authors

Figure: many authors

3-no-authors

Figure: no author

4-longest-content

Figure: long content

5-missing-photos

Figure: missing photos

7-category

Figure: category

6-homepage

Figure: homepage

isaaclombardssw and others added 6 commits August 5, 2026 12:56
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>
isaaclombardssw and others added 4 commits August 5, 2026 16:06
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>
@isaaclombardssw
isaaclombardssw marked this pull request as ready for review August 5, 2026 07:34
@isaaclombardssw

Copy link
Copy Markdown
Member Author

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.

@Aibono1225

Copy link
Copy Markdown
Member

/deploy

Comment thread app/api/revalidate/route.ts Outdated
}

if (shouldRevalidateCategoryTitles) {
revalidateTag("category-rule-data");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 |     }

@isaaclombardssw isaaclombardssw Aug 6, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, my local package was on Next 15 which created a whole slew of problems 😮‍💨

Comment thread lib/pageMetadata.ts
Comment on lines +27 to +30
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}` },

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@isaaclombardssw isaaclombardssw Aug 6, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and thanks - this one was invisible to me.

Comment thread lib/og/response.ts
Comment on lines +18 to +25
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 }));
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@isaaclombardssw isaaclombardssw Aug 6, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, good improvement. Fixed

Comment thread app/layout.tsx Outdated
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 }),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@isaaclombardssw isaaclombardssw Aug 6, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved.

isaaclombardssw and others added 2 commits August 6, 2026 09:43
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>
@isaaclombardssw

Copy link
Copy Markdown
Member Author

/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>
@isaaclombardssw

Copy link
Copy Markdown
Member Author

/deploy

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

PR Preview Deployed

Preview URL https://app-sswrules-staging-pr-2700.azurewebsites.net/rules
Slot Name pr-2700
Branch feature/dynamic-og-images-2698
Commit ebdcd0c29d87952e5c3e66c9fdbf934c7ed1707b

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>
@isaaclombardssw

Copy link
Copy Markdown
Member Author

/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>
@isaaclombardssw

Copy link
Copy Markdown
Member Author

/deploy

@isaaclombardssw

Copy link
Copy Markdown
Member Author

@Aibono1225 please look into this again. Appreciate it!

@isaaclombardssw

Copy link
Copy Markdown
Member Author

Heads-up for anyone validating this on the preview slot: opengraph.xyz will report og:image as invalid, and that is expected.

metadataBase is baked at build time and set to the production URL, so a preview page emits:

og:image  https://www.ssw.com.au/rules/opengraph-image-12gd74?...

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/png

To 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 build-artifacts.yml, which is shared with production deploys - left out of this PR deliberately.

Two unrelated warnings that validator also raises are pre-existing content, not from this change: the site title is 74 characters and siteDescription is 204, both over the ~60/~125 truncation points. Worth a separate look if we care about how they read in search results.

@Aibono1225 Aibono1225 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@isaaclombardssw
isaaclombardssw merged commit 5c68a42 into main Aug 6, 2026
@isaaclombardssw
isaaclombardssw deleted the feature/dynamic-og-images-2698 branch August 6, 2026 23:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

🐛 Rules pages have no Open Graph image - shared links show no preview

2 participants