From 5b117207d3299e33ee19a16066cc37c0cef3caee Mon Sep 17 00:00:00 2001 From: anurag629 Date: Wed, 8 Jul 2026 13:11:05 +0530 Subject: [PATCH] feat: per-page social share images for every page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a registry-driven Open Graph / Twitter card renderer so the homepage and each tool page get a distinct, on-brand 1200x630 image instead of all sharing one generic root card. - lib/og.tsx: shared renderer that embeds the bundled subset Noto Sans for crisp text; accent colour and copy come from each tool's registry entry - one opengraph-image route per tool, plus a rewritten homepage card - sanitize glyphs absent from the font subset (the arrow in "Image → URI" becomes "Image to URI") Next mirrors each file image to twitter:image automatically. The images prerender as static PNGs at build time. --- app/(tools)/base64/opengraph-image.tsx | 12 + .../invoice-generator/opengraph-image.tsx | 12 + .../json-formatter/opengraph-image.tsx | 12 + app/(tools)/jwt-decoder/opengraph-image.tsx | 12 + app/opengraph-image.tsx | 60 +---- lib/og.tsx | 217 ++++++++++++++++++ 6 files changed, 269 insertions(+), 56 deletions(-) create mode 100644 app/(tools)/base64/opengraph-image.tsx create mode 100644 app/(tools)/invoice-generator/opengraph-image.tsx create mode 100644 app/(tools)/json-formatter/opengraph-image.tsx create mode 100644 app/(tools)/jwt-decoder/opengraph-image.tsx create mode 100644 lib/og.tsx diff --git a/app/(tools)/base64/opengraph-image.tsx b/app/(tools)/base64/opengraph-image.tsx new file mode 100644 index 0000000..7eb1de4 --- /dev/null +++ b/app/(tools)/base64/opengraph-image.tsx @@ -0,0 +1,12 @@ +import { getTool } from "@/lib/tools"; +import { renderToolOgImage, OG_SIZE, OG_CONTENT_TYPE } from "@/lib/og"; + +const tool = getTool("base64")!; + +export const alt = tool.ogTitle; +export const size = OG_SIZE; +export const contentType = OG_CONTENT_TYPE; + +export default function Image() { + return renderToolOgImage(tool); +} diff --git a/app/(tools)/invoice-generator/opengraph-image.tsx b/app/(tools)/invoice-generator/opengraph-image.tsx new file mode 100644 index 0000000..9e1b53e --- /dev/null +++ b/app/(tools)/invoice-generator/opengraph-image.tsx @@ -0,0 +1,12 @@ +import { getTool } from "@/lib/tools"; +import { renderToolOgImage, OG_SIZE, OG_CONTENT_TYPE } from "@/lib/og"; + +const tool = getTool("invoice-generator")!; + +export const alt = tool.ogTitle; +export const size = OG_SIZE; +export const contentType = OG_CONTENT_TYPE; + +export default function Image() { + return renderToolOgImage(tool); +} diff --git a/app/(tools)/json-formatter/opengraph-image.tsx b/app/(tools)/json-formatter/opengraph-image.tsx new file mode 100644 index 0000000..fecc74e --- /dev/null +++ b/app/(tools)/json-formatter/opengraph-image.tsx @@ -0,0 +1,12 @@ +import { getTool } from "@/lib/tools"; +import { renderToolOgImage, OG_SIZE, OG_CONTENT_TYPE } from "@/lib/og"; + +const tool = getTool("json-formatter")!; + +export const alt = tool.ogTitle; +export const size = OG_SIZE; +export const contentType = OG_CONTENT_TYPE; + +export default function Image() { + return renderToolOgImage(tool); +} diff --git a/app/(tools)/jwt-decoder/opengraph-image.tsx b/app/(tools)/jwt-decoder/opengraph-image.tsx new file mode 100644 index 0000000..790bede --- /dev/null +++ b/app/(tools)/jwt-decoder/opengraph-image.tsx @@ -0,0 +1,12 @@ +import { getTool } from "@/lib/tools"; +import { renderToolOgImage, OG_SIZE, OG_CONTENT_TYPE } from "@/lib/og"; + +const tool = getTool("jwt-decoder")!; + +export const alt = tool.ogTitle; +export const size = OG_SIZE; +export const contentType = OG_CONTENT_TYPE; + +export default function Image() { + return renderToolOgImage(tool); +} diff --git a/app/opengraph-image.tsx b/app/opengraph-image.tsx index bf49007..f57cb25 100644 --- a/app/opengraph-image.tsx +++ b/app/opengraph-image.tsx @@ -1,61 +1,9 @@ -import { ImageResponse } from "next/og"; +import { renderHomeOgImage, OG_SIZE, OG_CONTENT_TYPE } from "@/lib/og"; export const alt = "CODERCOPS Tools — fast, privacy-first developer utilities"; -export const size = { width: 1200, height: 630 }; -export const contentType = "image/png"; +export const size = OG_SIZE; +export const contentType = OG_CONTENT_TYPE; export default function OpengraphImage() { - return new ImageResponse( - ( -
-
-
- {"{}"} -
-
CODERCOPS · TOOLS
-
- -
-
- Sharp, focused tools for shipping software -
-
- JSON formatter · JWT decoder · Base64 · Invoice generator. Runs entirely in your browser. -
-
- -
- {["#5AB5FF", "#9E7BFF", "#FFB547", "#A6F36B"].map((c) => ( -
- ))} -
-
- ), - { ...size } - ); + return renderHomeOgImage(); } diff --git a/lib/og.tsx b/lib/og.tsx new file mode 100644 index 0000000..4a3a2bf --- /dev/null +++ b/lib/og.tsx @@ -0,0 +1,217 @@ +import { readFileSync } from "fs"; +import { join } from "path"; +import { ImageResponse } from "next/og"; +import type { Tool } from "./tools"; + +// Shared social-card renderer. Every page's opengraph-image route derives its +// card from here, so the brand, typography, and layout are identical and can +// never drift between pages. Copy and accent come from the tool registry. + +export const OG_SIZE = { width: 1200, height: 630 }; +export const OG_CONTENT_TYPE = "image/png"; + +// The subset Noto Sans bundled for invoice PDFs (Latin + currency) is reused for +// every card, so text is real, hinted glyphs rather than the satori fallback. +// These routes are prerendered at build time, so reading from the working +// directory is safe (and avoids the edge-only fetch(new URL(import.meta.url))). +let fonts: { name: string; data: Buffer; weight: 400 | 700; style: "normal" }[] | null = null; +function ogFonts() { + if (!fonts) { + const dir = join(process.cwd(), "public", "fonts"); + fonts = [ + { name: "Noto Sans", data: readFileSync(join(dir, "NotoSans-Regular.ttf")), weight: 400, style: "normal" }, + { name: "Noto Sans", data: readFileSync(join(dir, "NotoSans-Bold.ttf")), weight: 700, style: "normal" }, + ]; + } + return fonts; +} + +const BG = "#0A0B0F"; +const FG = "#E8E6E1"; +const MUTED = "#8A93A8"; +const HAIRLINE = "rgba(255,255,255,0.12)"; + +// The subset font has no arrow glyph, so keep card text to Latin + punctuation. +function safe(text: string): string { + return text.replace(/\s*(?:→|->)\s*/g, " to "); +} + +interface OgCardProps { + badge: string; + title: string; + description: string; + chips: string[]; + url: string; + accent: string; + /** Four bars in the footer. Tools pass one accent; the home card passes four. */ + accentBars: string[]; +} + +function OgCard({ badge, title, description, chips, url, accent, accentBars }: OgCardProps) { + return ( +
+ {/* Faint grid texture */} +
+ {/* Accent glow, top-right */} +
+ + {/* Header */} +
+
+
+ {"{}"} +
+
+ CODERCOPS · TOOLS +
+
+
+ {badge} +
+
+ + {/* Middle */} +
+
+
+ {title} +
+
+ {description} +
+
+ {chips.map((c) => ( +
+ {c} +
+ ))} +
+
+ + {/* Footer */} +
+
{url}
+
+ {accentBars.map((c, i) => ( +
+ ))} +
+
+
+ ); +} + +function render(props: OgCardProps) { + return new ImageResponse(, { ...OG_SIZE, fonts: ogFonts() }); +} + +/** Social card for a tool page, driven entirely by its registry entry. */ +export function renderToolOgImage(tool: Tool) { + const a = tool.accentHex; + return render({ + badge: tool.tagline.toUpperCase(), + title: tool.title, + description: safe(tool.ogDescription), + chips: tool.features.map(safe), + url: `tools.codercops.com${tool.path}`, + accent: a, + accentBars: [`${a}ff`, `${a}b3`, `${a}73`, `${a}40`], + }); +} + +/** Social card for the homepage. */ +export function renderHomeOgImage() { + return render({ + badge: "OPEN SOURCE", + title: "Sharp, focused tools for shipping software", + description: + "Free, privacy-first developer utilities. JSON formatter, JWT decoder, Base64, invoice generator. Everything runs in your browser.", + chips: ["No login", "No uploads", "No tracking", "Open source", "MIT"], + url: "tools.codercops.com", + accent: "#00E5C7", + accentBars: ["#5AB5FF", "#9E7BFF", "#FFB547", "#A6F36B"], + }); +}