Skip to content

feat(reports): dynamic per-report SEO + share image#4897

Merged
rafavalls merged 5 commits into
mainfrom
rafavalls/dynamic-report-seo
Jul 23, 2026
Merged

feat(reports): dynamic per-report SEO + share image#4897
rafavalls merged 5 commits into
mainfrom
rafavalls/dynamic-report-seo

Conversation

@rafavalls

@rafavalls rafavalls commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

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.png renders it with satori + @resvg/resvg-js from the public diagnostics (score + favicon).
  • Cached per (domain, score) in memory and at the CDN (s-maxage=1d) → the origin renders each report at most once.
  • Unscanned domains / any render error fall back to a designed static card (report-og-fallback.png, also 1200×630).
  • og:image / twitter:image now point at <canonical>/og.png (summary_large_image, absolute, honest 1200×630 dims).

Text metadata (from the first commit)

  • Title with the real score — Americanas commerce report — 68/100 · decocms
  • Description from the report's actual verdict
  • Favicon = the scanned brand's own; normalized canonical; noindex, follow

Rendering notes

  • Inter (latin, 400/600/700) is embedded as base64 so the bundled server needs no font files on disk.
  • satori + @resvg/resvg-js added as runtime dependencies — installed in the image via bun add (same pattern as node-pty / duckdb / embedded-postgres).
  • This reintroduces in-process image rendering (previously removed to protect the health-check event loop) but safely: text-only card, cached, CDN-fronted, never throws to the page. The liveness probe tolerates ~30s (3×10s, 5s timeout); a cached render is tens of ms. It can move to an edge worker later without touching the page.

Testing

  • Unit tests (report-pages.test.ts) — title/score, verdict description, og:image/og.png, dims, favicon, canonical, noindex, HTML-escaping.
  • End-to-end route test: scanned domain → rendered 1200×630 card (+ cache hit), unscanned → static fallback, HTML shell rewrites and points og:image at /og.png, generic og-image.png gone.
  • Bundle check: bundled a renderer entry with satori/@resvg/resvg-js externalized 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 / knip all clean.

Card preview (Americanas, live data)

Favicon + americanas.com.br + big score + decocms wordmark on the deco-green card.

⚠️ Production note (not a code issue)

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 (isServerPath already treats /report/ as a server path in main). cf-cache-status: DYNAMIC confirms Cloudflare proxies to origin, so once this deploys, the route runs and the dynamic card takes effect.

🤖 Generated with Claude Code

rafavalls and others added 5 commits July 20, 2026 19:17
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>
@rafavalls
rafavalls merged commit 4f85a2f into main Jul 23, 2026
15 checks passed
@rafavalls
rafavalls deleted the rafavalls/dynamic-report-seo branch July 23, 2026 20:57
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.

1 participant