From a1942b51a5922d4a0e6ff42dbab6b1314d9229c1 Mon Sep 17 00:00:00 2001
From: Anurag Verma <78868769+anurag629@users.noreply.github.com>
Date: Wed, 8 Jul 2026 13:50:58 +0530
Subject: [PATCH 1/5] feat: per-page social share images for every page (#12)
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"],
+ });
+}
From 434414052c433a4efde68868f9b50ec4f884d211 Mon Sep 17 00:00:00 2001
From: Anurag Verma <78868769+anurag629@users.noreply.github.com>
Date: Wed, 8 Jul 2026 14:15:38 +0530
Subject: [PATCH 2/5] feat: structured data (JSON-LD) and metadata completeness
(#13)
Add schema.org structured data and fill metadata gaps so search and AI
crawlers can understand the site and each tool.
- lib/jsonLd.ts (new): one home for all JSON-LD builders, each derived from
the TOOLS registry so it can't drift from what renders.
- FAQPage from the registry FAQs (the FAQs were already written but nothing
emitted them); gated to pages that actually render an FAQ section
- Organization + WebSite + ItemList @graph on the homepage, linked by @id
- BreadcrumbList on tool pages (Home > Tool)
- toolJsonLd (SoftwareApplication) moved here from lib/toolMetadata.ts
- ToolPageLayout: emit the three JSON-LD blocks and render a real visible
Home > Tool breadcrumb (so the schema represents visible content). The
heroBreadcrumb tagline sits outside the so it isn't announced as a
crumb, and the current crumb uses --fg to clear WCAG AA contrast.
- Homepage: canonical, inheriting the root OpenGraph rather than clobbering it.
- Root metadata: OpenGraph locale/title/description and twitter:site.
- Per-tool OpenGraph gains siteName + locale.
Deliberately skipped: SearchAction/sitelinks searchbox (no site search;
deprecated in 2024), and any aggregateRating (no honest ratings to claim).
All JSON-LD verified as valid against the rendered HTML. build, lint, and
27 tests pass.
---
app/layout.tsx | 5 ++
app/page.tsx | 13 ++++
components/shared/ToolPageLayout.tsx | 46 +++++++++----
lib/jsonLd.ts | 97 ++++++++++++++++++++++++++++
lib/toolMetadata.ts | 20 ++----
5 files changed, 154 insertions(+), 27 deletions(-)
create mode 100644 lib/jsonLd.ts
diff --git a/app/layout.tsx b/app/layout.tsx
index 16a6a06..185d7f5 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -51,9 +51,14 @@ export const metadata: Metadata = {
type: "website",
siteName: "CODERCOPS Tools",
url: "https://tools.codercops.com",
+ locale: "en_US",
+ title: "CODERCOPS Tools — Developer Utilities",
+ description:
+ "Free, fast, privacy-first developer tools: JSON formatter, JWT decoder, Base64 encoder, and more. Runs entirely in your browser.",
},
twitter: {
card: "summary_large_image",
+ site: "@codercops",
creator: "@codercops",
},
};
diff --git a/app/page.tsx b/app/page.tsx
index 88c2e8e..d8ab0a4 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,11 +1,24 @@
+import type { Metadata } from "next";
import { ToolCard } from "@/components/shared/ToolCard";
import { CtaBanner } from "@/components/shared/CtaBanner";
import { Footer } from "@/components/shared/Footer";
import { TOOLS } from "@/lib/tools";
+import { homeJsonLd } from "@/lib/jsonLd";
+
+// Only the canonical is set here; title, description, and OpenGraph are inherited
+// from the root layout (Next merges metadata shallowly, so re-declaring openGraph
+// would drop the root's siteName/locale/type from the homepage).
+export const metadata: Metadata = {
+ alternates: { canonical: "/" },
+};
export default function HomePage() {
return (
<>
+
diff --git a/components/shared/ToolPageLayout.tsx b/components/shared/ToolPageLayout.tsx
index d2fffe8..10a45ab 100644
--- a/components/shared/ToolPageLayout.tsx
+++ b/components/shared/ToolPageLayout.tsx
@@ -1,5 +1,6 @@
+import Link from "next/link";
import { cn } from "@/lib/cn";
-import { toolJsonLd } from "@/lib/toolMetadata";
+import { toolJsonLd, breadcrumbJsonLd, faqJsonLd } from "@/lib/jsonLd";
import type { Tool } from "@/lib/tools";
import { CtaBanner } from "./CtaBanner";
import { Faq } from "./Faq";
@@ -29,18 +30,41 @@ export function ToolPageLayout({
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(toolJsonLd(tool)) }}
/>
+
+ {tool.faqs.length > 0 && (
+
+ )}
-
-
- /{tool.slug}
-
-
-
- {tool.heroBreadcrumb}
+ {/* The breadcrumb trail is only Home > Tool (matching the JSON-LD). The
+ heroBreadcrumb tagline sits outside the / so assistive tech
+ doesn't announce it as a third crumb. The current crumb uses --fg
+ (not the accent) so it clears WCAG AA contrast in both themes. */}
+
+
+
+
+
+ Home
+
+
+
+ /
+
+
+ {tool.jsonLdName}
+
+
+
+
+ ·
+
{tool.heroBreadcrumb}
{before}
diff --git a/lib/jsonLd.ts b/lib/jsonLd.ts
new file mode 100644
index 0000000..a36cd6d
--- /dev/null
+++ b/lib/jsonLd.ts
@@ -0,0 +1,97 @@
+// All schema.org JSON-LD builders in one place. Everything derives from the
+// TOOLS registry or SITE_URL, so the structured data can never drift from what
+// the pages actually render. Emitted as inline