Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
};
Expand Down
13 changes: 13 additions & 0 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(homeJsonLd()) }}
/>
<section className="relative">
<div className="absolute inset-0 grid-bg opacity-50" />
<div className="relative mx-auto max-w-7xl px-4 sm:px-6 pt-20 pb-14">
Expand Down
46 changes: 35 additions & 11 deletions components/shared/ToolPageLayout.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -29,18 +30,41 @@ export function ToolPageLayout({
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(toolJsonLd(tool)) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd(tool)) }}
/>
{tool.faqs.length > 0 && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd(tool)) }}
/>
)}
<section className="mx-auto max-w-7xl w-full px-4 sm:px-6 pt-10 pb-6">
<div className="flex items-center gap-2 mb-4">
<span
className="font-mono text-[10.5px] tracking-[0.22em] uppercase"
style={{ color: `var(${tool.accentVar})` }}
>
/{tool.slug}
</span>
<span className="w-1 h-1 rounded-full bg-[var(--fg-dim)]" />
<span className="font-mono text-[10.5px] tracking-[0.22em] uppercase text-[var(--fg-dim)]">
{tool.heroBreadcrumb}
{/* The breadcrumb trail is only Home > Tool (matching the JSON-LD). The
heroBreadcrumb tagline sits outside the <nav>/<ol> 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. */}
<div className="mb-4 flex flex-wrap items-center gap-2 font-mono text-[10.5px] tracking-[0.22em] uppercase">
<nav aria-label="Breadcrumb">
<ol className="flex items-center gap-2">
<li>
<Link href="/" className="text-[var(--fg-dim)] hover:text-[var(--fg)] transition-colors">
Home
</Link>
</li>
<li aria-hidden="true" className="text-[var(--fg-dim)]">
/
</li>
<li aria-current="page" className="text-[var(--fg)]">
{tool.jsonLdName}
</li>
</ol>
</nav>
<span aria-hidden="true" className="text-[var(--fg-dim)]">
·
</span>
<span className="text-[var(--fg-dim)]">{tool.heroBreadcrumb}</span>
</div>
<h1 className="font-display text-4xl sm:text-5xl font-semibold tracking-tight text-[var(--fg)] leading-tight">
{before}
Expand Down
97 changes: 97 additions & 0 deletions lib/jsonLd.ts
Original file line number Diff line number Diff line change
@@ -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 <script type="application/ld+json">,
// which is inert data (not executable JS), so the nonce CSP does not apply.

import { SITE_URL, TOOLS, type Tool } from "./tools";

const ORG_ID = `${SITE_URL}/#organization`;
const WEBSITE_ID = `${SITE_URL}/#website`;

// The publisher is CODERCOPS (the agency). Keyed by an @id on this domain so the
// WebSite node can reference it. sameAs lists only identities we can honestly
// claim — the agency site is `url`; the GitHub org is the verified profile.
const ORG_NODE = {
"@type": "Organization",
"@id": ORG_ID,
name: "CODERCOPS",
url: "https://www.codercops.com",
sameAs: ["https://github.com/codercops"],
};

// SoftwareApplication for a tool. No aggregateRating/review — there are no
// accounts and no ratings we could honestly claim, and fabricating them is
// against Google's policy.
export function toolJsonLd(tool: Tool) {
return {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: tool.jsonLdName,
applicationCategory: tool.applicationCategory,
operatingSystem: "Any (Browser)",
description: tool.jsonLdDescription,
url: `${SITE_URL}${tool.path}`,
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
publisher: { "@type": "Organization", name: "CODERCOPS", url: "https://www.codercops.com" },
};
}

// A truthful two-level Home > Tool trail. The current page (position 2) omits
// `item` per Google's spec. `name` uses jsonLdName so it matches both the
// SoftwareApplication node and the visible breadcrumb rendered on the page.
export function breadcrumbJsonLd(tool: Tool) {
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
// No trailing slash, to match the homepage canonical Next emits (SITE_URL).
{ "@type": "ListItem", position: 1, name: "Home", item: SITE_URL },
{ "@type": "ListItem", position: 2, name: tool.jsonLdName },
],
};
}

// FAQPage. Only call this for tools that actually render an FAQ section
// (tool.faqs.length > 0), so the markup is always a true representation of the
// visible page. json-formatter has no FAQs and must not emit this.
export function faqJsonLd(tool: Tool) {
return {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: tool.faqs.map((f) => ({
"@type": "Question",
name: f.q,
acceptedAnswer: { "@type": "Answer", text: f.a },
})),
};
}

// Homepage @graph: Organization + WebSite + an ItemList of the tool collection.
// The ItemList is generated from TOOLS in the same order the ToolCard grid
// renders, so the structured list and the visible cards are always identical.
export function homeJsonLd() {
return {
"@context": "https://schema.org",
"@graph": [
ORG_NODE,
{
"@type": "WebSite",
"@id": WEBSITE_ID,
url: SITE_URL,
name: "CODERCOPS Tools",
description: "Free, fast, privacy-first developer tools that run entirely in your browser.",
publisher: { "@id": ORG_ID },
},
{
"@type": "ItemList",
name: "CODERCOPS developer tools",
itemListElement: TOOLS.map((t, i) => ({
"@type": "ListItem",
position: i + 1,
name: t.jsonLdName,
url: `${SITE_URL}${t.path}`,
})),
},
],
};
}
20 changes: 4 additions & 16 deletions lib/toolMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import type { Metadata } from "next";
import { SITE_URL, type Tool } from "./tools";

// Builds the per-tool Next Metadata from a manifest entry, so every tool page's
// metadata block is identical in shape and can never drift.
// metadata block is identical in shape and can never drift. The JSON-LD builders
// live in lib/jsonLd.ts.
export function generateToolMetadata(tool: Tool): Metadata {
const url = `${SITE_URL}${tool.path}`;
return {
Expand All @@ -14,21 +15,8 @@ export function generateToolMetadata(tool: Tool): Metadata {
description: tool.ogDescription,
url,
type: "website",
siteName: "CODERCOPS Tools",
locale: "en_US",
},
};
}

// SoftwareApplication structured data for a tool.
export function toolJsonLd(tool: Tool) {
return {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: tool.jsonLdName,
applicationCategory: tool.applicationCategory,
operatingSystem: "Any (Browser)",
description: tool.jsonLdDescription,
url: `${SITE_URL}${tool.path}`,
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
publisher: { "@type": "Organization", name: "CODERCOPS", url: "https://www.codercops.com" },
};
}