feat(reports): dynamic per-report SEO + share image#4897
Merged
Conversation
Report pages (/report/:domain) served the generic "deco Studio" title,
description and OG image on link unfurls. The server-side head-rewrite route
existed but only used domain-derived copy and — critically — stripped the
generic og:image without adding a replacement, so a share card had no image.
Now the shell fetches the public (no-auth) diagnostics server-side (2.5s
budget, fail-soft to domain-only copy) and emits complete per-report metadata:
- title with the real score (e.g. "Americanas commerce report — 68/100")
- description from the report's actual verdict
- the brand's own favicon
- og:image + twitter:image (summary_large_image):
- the brand's homepage screenshot when it is share-safe (a stable,
re-fetchable URL — signed/expiring object-storage URLs are rejected so a
crawler cache never ends up with a 403'd broken card)
- otherwise a designed static fallback card (public/report-og-fallback.png)
No in-process image rendering is added — the screenshot is linked from the
engine's edge worker, honoring the existing prohibition. Covers Open Graph
(Facebook, LinkedIn, WhatsApp, Slack, Discord, iMessage) and Twitter/X.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…score) The share image was either the generic Studio card, the raw homepage screenshot (no score), or a marketing fallback with a headline + pills — none showed what a shared report is about. Add a dedicated share card rendered per report: the scanned site's favicon, its domain, the commerce-health score front-and-center, and the decocms wordmark. Clean and simple. - New `GET /report/:domain/og.png` renders the card with satori + resvg from the public diagnostics (score + favicon). Cached per (domain, score) in memory and via the CDN (s-maxage=1d), so the origin renders each report at most once. Unscanned domains / render errors fall back to a designed static card (also 1200×630). - `og:image` / `twitter:image` now point at `<canonical>/og.png` (summary_large_image, absolute, honest 1200×630 dims). - Inter (latin, 400/600/700) embedded as base64 so the bundled server needs no font files on disk. satori + @resvg/resvg-js added as runtime deps (installed in the image via `bun add`, same pattern as other native deps). This reintroduces in-process image rendering (previously removed for health- check stability) but safely: text-only card, cached, CDN-fronted, never throws to the page. Can move to an edge worker later without touching the page. Verified: satori/resvg survive the externalized bundle with fonts inlined; live render for americanas.com.br; static fallback for unscanned domains. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The OG-card renderer's deps aren't traced by @vercel/nft (their `exports`
maps hit the same blindspot already documented for OTel/better-auth), so
`bun build` tried to INLINE them into server.js — and @resvg/resvg-js's native
`.node` made bun need a second output file, failing with "cannot write
multiple output files without an output directory". This broke both the `build`
job and the multi-pod Docker image build.
Add `satori` and `@resvg/resvg-js` to ALWAYS_INCLUDE so nft root-resolves and
copies them (→ externalized, never inlined), and add `@resvg/resvg-js-` to
EXCLUDE_COPY_PREFIXES so the host's platform binary isn't shipped — the
Docker `bun add` installs the matching one, same as @embedded-postgres/.
Verified locally: `bun run --cwd=apps/mesh build:server` now completes; both
packages appear as `--external` and `server.js` imports them at runtime; the
darwin binary is skipped ("resolved from consumer install").
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…omains Prod-parity testing (rendering the card in oven/bun:1-slim, linux/amd64) surfaced a layout bug: a long domain's text column shrank the fixed-size favicon/initial tile to ~0 width, so brands with long domains lost their favicon. Add `flexShrink: 0` to the tile. Adds a network-free render test (valid PNG for typical/0/100/fractional scores + a long domain) guarding the render path against crashes. Verified: renders correctly on linux/amd64 (the prod runtime) with and without a favicon; long domain now keeps its tile and wraps cleanly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ort-seo # Conflicts: # bun.lock
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Report pages (
/report/:domain) unfurled with the generic "deco Studio" card. This makes the whole share experience per-report: dynamic title/description/favicon and a clean, custom share image.The share card
A dedicated per-report card — the scanned site's favicon, its domain, the commerce-health score front-and-center, and the decocms wordmark. Clean and simple (no marketing headline, no pills):
GET /report/:domain/og.pngrenders it withsatori+@resvg/resvg-jsfrom the public diagnostics (score + favicon).(domain, score)in memory and at the CDN (s-maxage=1d) → the origin renders each report at most once.report-og-fallback.png, also 1200×630).og:image/twitter:imagenow point at<canonical>/og.png(summary_large_image, absolute, honest 1200×630 dims).Text metadata (from the first commit)
Americanas commerce report — 68/100 · decocmsnoindex, followRendering notes
satori+@resvg/resvg-jsadded as runtime dependencies — installed in the image viabun add(same pattern as node-pty / duckdb / embedded-postgres).Testing
report-pages.test.ts) — title/score, verdict description,og:image→/og.png, dims, favicon, canonical,noindex, HTML-escaping.og:imageat/og.png, genericog-image.pnggone.satori/@resvg/resvg-jsexternalized and fonts inlined, then ran it →BUNDLE_RENDER_OK(confirms it survives the production server bundle).bun run fmt/bun run lint(0 errors) /tsc/knipall clean.Card preview (Americanas, live data)
Favicon +
americanas.com.br+ big score +decocmswordmark on the deco-green card.A crawler fetch of the live URL currently returns the raw SPA shell (
cache-control: no-cache— the asset-server's header, not this route's). Production hasn't yet deployed the origin code that routes/report/*to the Hono head-rewrite (isServerPathalready treats/report/as a server path inmain).cf-cache-status: DYNAMICconfirms Cloudflare proxies to origin, so once this deploys, the route runs and the dynamic card takes effect.🤖 Generated with Claude Code