skills/og-image-dynamic/SKILL.md
--- name: og-image-dynamic description: Generate per-URL Open Graph images at runtime so every shared link unfurls with a distinct, branded preview card. Supports Cloudflare Workers (Satori), Vercel Edge (@vercel/og), and TanStack Start static builds. Use after shipping any app that gets shared on X/Reddit/LinkedIn/WhatsApp. category: quality-review argument-hint: [--runtime cf|vercel|node] [--route /api/og] [--template <file>] [--test] allowed-tools: Bash(*) Read Write Edit Glob Grep content-pi
npx skillsauth add RonanCodes/ronan-skills skills/og-image-dynamicInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Every share needs a preview card. A single static og-image.png for all URLs looks lazy and tanks click-through on Twitter/Reddit/LinkedIn previews. This skill wires up a /api/og.png endpoint that renders a per-URL, per-content image in <100ms and caches it at the edge.
/ro:og-image-dynamic # auto-detect runtime, wire default template
/ro:og-image-dynamic --runtime cf # Cloudflare Workers (Satori + resvg)
/ro:og-image-dynamic --runtime vercel # Vercel Edge (@vercel/og)
/ro:og-image-dynamic --route /api/og # custom route path
/ro:og-image-dynamic --test # open the endpoint with sample params
src/routes/api/og.$slug.ts or /api/og.png) that accepts query params and returns image/png.src/routes/__root.tsx (or the page-level route) so each route gets its own OG image URL.Cache-Control: public, max-age=86400, s-maxage=604800).@vercel/og pulls in @resvg/resvg-wasm which runs fine on Workers, but the lighter-weight approach uses satori + @resvg/resvg-wasm directly:
pnpm add satori @resvg/resvg-wasm
Route:
// src/routes/api/og.ts
import { createFileRoute } from '@tanstack/react-router'
import satori from 'satori'
import { Resvg } from '@resvg/resvg-wasm'
// Load a font and the wasm binary once (module-level); Workers isolates reuse them.
// Put Inter-Regular.woff and resvg.wasm in src/assets/ and import with `?url` or inline.
export const Route = createFileRoute('/api/og')({
server: {
handlers: {
GET: async ({ request }) => {
const { searchParams } = new URL(request.url)
const title = searchParams.get('title') ?? 'Default title'
const subtitle = searchParams.get('subtitle') ?? ''
const svg = await satori(
{
type: 'div',
props: {
style: {
display: 'flex',
flexDirection: 'column',
width: '1200px',
height: '630px',
padding: '80px',
background: 'linear-gradient(135deg, #0f172a 0%, #1e3a8a 100%)',
color: 'white',
fontFamily: 'Inter',
},
children: [
{
type: 'div',
props: {
style: { fontSize: 72, fontWeight: 700, lineHeight: 1.1 },
children: title,
},
},
subtitle && {
type: 'div',
props: {
style: { fontSize: 36, marginTop: 24, opacity: 0.8 },
children: subtitle,
},
},
].filter(Boolean),
},
},
{
width: 1200,
height: 630,
fonts: [{ name: 'Inter', data: INTER_FONT, weight: 700, style: 'normal' }],
},
)
const png = new Resvg(svg).render().asPng()
return new Response(png, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=86400, s-maxage=604800, immutable',
},
})
},
},
},
})
Font loading: satori requires a font binary passed as ArrayBuffer. On Workers, bundle via Vite:
import InterRegular from '@/assets/Inter-Regular.woff?url'
const INTER_FONT = await fetch(new URL(InterRegular, import.meta.url)).then((r) => r.arrayBuffer())
Wasm loading: @resvg/resvg-wasm needs explicit init on Workers. Follow their README's initWasm() step at module load.
Simpler — @vercel/og ships everything:
pnpm add @vercel/og
// app/api/og/route.tsx
import { ImageResponse } from '@vercel/og'
export const runtime = 'edge'
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const title = searchParams.get('title') ?? 'Default title'
return new ImageResponse(
(
<div style={{ display: 'flex', fontSize: 72, color: 'white', background: '#0f172a', width: '100%', height: '100%', padding: 80 }}>
{title}
</div>
),
{ width: 1200, height: 630 },
)
}
In TanStack Start, set per-route meta in the file-based route:
// src/routes/posts/$slug.tsx
export const Route = createFileRoute('/posts/$slug')({
loader: async ({ params }) => fetchPost(params.slug),
head: ({ loaderData }) => ({
meta: [
{ property: 'og:title', content: loaderData.title },
{ property: 'og:image', content: `/api/og?title=${encodeURIComponent(loaderData.title)}` },
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:image', content: `/api/og?title=${encodeURIComponent(loaderData.title)}` },
],
}),
})
# Local dev
curl -sL 'http://localhost:3000/api/og?title=Hello&subtitle=World' -o /tmp/og.png && open /tmp/og.png
# Production unfurl — real test uses platform debuggers:
# - Twitter/X: https://cards-dev.twitter.com/validator
# - Facebook/Meta: https://developers.facebook.com/tools/debug/
# - LinkedIn: https://www.linkedin.com/post-inspector/
# - Discord unfurls on paste into any channel.
Always verify on the actual platform. OG unfurl caches are aggressive (LinkedIn 7+ days); if the preview looks stale, add a cache-buster ?v=2 to the final URL.
title.slice(0, 80) + '…' in the route.Cache-Control: public, max-age=86400, s-maxage=604800, immutable
max-age=86400 — browser caches 1 day.s-maxage=604800 — Cloudflare / Vercel edge caches 1 week.immutable — tells caches never to revalidate (safe because any content change changes the URL via query params).If using query params for dynamic content, the URL is the cache key — different params = different cached image. No need for a cache-invalidation strategy.
glyphhanger or pre-subset with fontTools.twitter:image, not og:image. Set both.?v=N during dev.ratelimit binding; Vercel: use @upstash/ratelimit)./ro:posthog — track which OG-image URLs actually get shared (share events with item_id)/ro:seo-launch-ready — complementary meta-tag + sitemap setup/ro:cf-ship / /ro:fly-deploy — deploy targets where this runs/ro:app-polish — umbrella skill that invokes this as check #3development
Close the loop on a Linear ticket when its work ships - move the status and post a deploy comment with the PR link, what shipped, and a try-it link, mentioning the collaborator. Used as the tail of /ro:linear-nightshift for every merged mirror, or manually after an ad-hoc build. Triggers on "linear update", "update the linear ticket", "mark NUT-x done", "tell eoin it shipped", "/ro:linear-update".
devops
Run a night-shift against a collaborator's Linear board. Pulls the team's Grilled tickets (/ro:linear-grill moves a ticket to Grilled once its questions are answered), VERIFIES the questions were actually answered (unanswered → bounce the ticket to the "Question for <name>" state), mirrors verified tickets to ephemeral GitHub issues with ready-for-agent, then runs the standard /ro:night-shift machinery on GitHub. Tail-calls /ro:linear-update for everything that merged + deployed. Triggers on "linear nightshift", "nightshift linear", "drain the linear board", "run the shift off linear", "/ro:linear-nightshift".
development
Grill a collaborator's Linear tickets and move every processed ticket to where it belongs. Resolves the board from the repo's .ro-linear.json, reads the collaborator's Backlog / Ready-for-agent issues, then per ticket either posts 3-5 decision-extracting questions (state moves to "Question for <name>") or confirms it build-ready (state moves to "Grilled", the gate /ro:linear-nightshift consumes); shipped-and-confirmed tickets close as Done. The async-collaborator counterpart of /ro:day-shift for people who never touch GitHub. Triggers on "grill linear", "grill eoin's tickets", "linear grill", "add questions to the linear tickets", "/ro:linear-grill".
development
--- name: about-page description: Add a standard About page to any web app, what it is, the tech stack, and an FAQ, wired into a footer link with a sticky footer. Built with Spartan + Tailwind (the canonical component layer) and falls back to semantic HTML so it ships reliably. Use whenever building, polishing, or shipping an app, every app should have one. Triggers on "add an about page", "about page", "footer about link", or as a standard step in app build/polish. category: frontend argument-h