feat(blog): add Schema.org JSON-LD structured data for SEO

- Add structured-data.ts with Article, CollectionPage, and BreadcrumbList schemas
- Integrate JSON-LD into /blog index page (Blog collection + breadcrumbs)
- Integrate JSON-LD into /blog/[slug] page (Article + post breadcrumbs)
- Export structured data utilities from blog module index

MKT-70
This commit is contained in:
Roo Code 2026-01-27 19:48:21 +00:00
parent aa4c2d921f
commit 6f782056ad
4 changed files with 357 additions and 138 deletions

View file

@ -6,7 +6,12 @@ import remarkGfm from "remark-gfm"
import { ArrowLeft } from "lucide-react"
import { SEO } from "@/lib/seo"
import { ogImageUrl } from "@/lib/og"
import { getBlogPostBySlug, formatPostDatePt } from "@/lib/blog"
import {
getBlogPostBySlug,
formatPostDatePt,
getArticleStructuredData,
getBlogPostBreadcrumbStructuredData,
} from "@/lib/blog"
// Force dynamic rendering to evaluate publish gating at request-time
export const dynamic = "force-dynamic"
@ -72,102 +77,117 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
notFound()
}
// Generate structured data for SEO
const articleSchema = getArticleStructuredData(post)
const breadcrumbSchema = getBlogPostBreadcrumbStructuredData(post)
return (
<div className="container mx-auto px-4 py-12 sm:px-6 lg:px-8">
<article className="mx-auto max-w-3xl">
{/* Back link */}
<Link
href="/blog"
className="mb-8 inline-flex items-center text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="mr-2 size-4" />
Back to Blog
</Link>
<>
{/* JSON-LD Structured Data */}
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }} />
{/* Post Header */}
<header className="mb-8">
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl">{post.title}</h1>
<div className="mt-4 flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
<time dateTime={post.publish_date}>Posted {formatPostDatePt(post.publish_date)}</time>
{post.tags.length > 0 && (
<>
<span className="text-border"></span>
<div className="flex flex-wrap gap-2">
{post.tags.map((tag) => (
<span key={tag} className="rounded-full bg-muted px-2 py-0.5 text-xs">
{tag}
</span>
))}
</div>
</>
)}
</div>
</header>
{/* Post Content */}
<div className="prose prose-lg max-w-none dark:prose-invert">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// Do not allow raw HTML - all components render safely
h1: ({ ...props }) => (
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl" {...props} />
),
h2: ({ ...props }) => <h2 className="mt-10 text-xl font-bold sm:text-2xl" {...props} />,
h3: ({ ...props }) => <h3 className="mt-8 text-lg font-semibold" {...props} />,
a: ({ ...props }) => (
<a
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
{...props}
/>
),
blockquote: ({ ...props }) => (
<blockquote
className="border-l-4 border-primary/50 pl-4 italic text-muted-foreground"
{...props}
/>
),
code: ({ className, children, ...props }) => {
// Check if this is an inline code or code block
const isInline = !className
if (isInline) {
return (
<code className="rounded bg-muted px-1.5 py-0.5 text-sm font-mono" {...props}>
{children}
</code>
)
}
return (
<code className={className} {...props}>
{children}
</code>
)
},
pre: ({ ...props }) => (
<pre className="overflow-x-auto rounded-lg bg-muted p-4 text-sm font-mono" {...props} />
),
ul: ({ ...props }) => <ul className="list-disc pl-6" {...props} />,
ol: ({ ...props }) => <ol className="list-decimal pl-6" {...props} />,
li: ({ ...props }) => <li className="mt-2" {...props} />,
p: ({ ...props }) => <p className="mt-4 leading-7" {...props} />,
strong: ({ ...props }) => <strong className="font-semibold" {...props} />,
hr: ({ ...props }) => <hr className="my-8 border-border" {...props} />,
}}>
{post.content}
</ReactMarkdown>
</div>
{/* Footer */}
<footer className="mt-12 border-t border-border pt-8">
<div className="container mx-auto px-4 py-12 sm:px-6 lg:px-8">
<article className="mx-auto max-w-3xl">
{/* Back link */}
<Link
href="/blog"
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground">
className="mb-8 inline-flex items-center text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="mr-2 size-4" />
Back to Blog
</Link>
</footer>
</article>
</div>
{/* Post Header */}
<header className="mb-8">
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl">{post.title}</h1>
<div className="mt-4 flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
<time dateTime={post.publish_date}>Posted {formatPostDatePt(post.publish_date)}</time>
{post.tags.length > 0 && (
<>
<span className="text-border"></span>
<div className="flex flex-wrap gap-2">
{post.tags.map((tag) => (
<span key={tag} className="rounded-full bg-muted px-2 py-0.5 text-xs">
{tag}
</span>
))}
</div>
</>
)}
</div>
</header>
{/* Post Content */}
<div className="prose prose-lg max-w-none dark:prose-invert">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// Do not allow raw HTML - all components render safely
h1: ({ ...props }) => (
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl" {...props} />
),
h2: ({ ...props }) => <h2 className="mt-10 text-xl font-bold sm:text-2xl" {...props} />,
h3: ({ ...props }) => <h3 className="mt-8 text-lg font-semibold" {...props} />,
a: ({ ...props }) => (
<a
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
{...props}
/>
),
blockquote: ({ ...props }) => (
<blockquote
className="border-l-4 border-primary/50 pl-4 italic text-muted-foreground"
{...props}
/>
),
code: ({ className, children, ...props }) => {
// Check if this is an inline code or code block
const isInline = !className
if (isInline) {
return (
<code
className="rounded bg-muted px-1.5 py-0.5 text-sm font-mono"
{...props}>
{children}
</code>
)
}
return (
<code className={className} {...props}>
{children}
</code>
)
},
pre: ({ ...props }) => (
<pre
className="overflow-x-auto rounded-lg bg-muted p-4 text-sm font-mono"
{...props}
/>
),
ul: ({ ...props }) => <ul className="list-disc pl-6" {...props} />,
ol: ({ ...props }) => <ol className="list-decimal pl-6" {...props} />,
li: ({ ...props }) => <li className="mt-2" {...props} />,
p: ({ ...props }) => <p className="mt-4 leading-7" {...props} />,
strong: ({ ...props }) => <strong className="font-semibold" {...props} />,
hr: ({ ...props }) => <hr className="my-8 border-border" {...props} />,
}}>
{post.content}
</ReactMarkdown>
</div>
{/* Footer */}
<footer className="mt-12 border-t border-border pt-8">
<Link
href="/blog"
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="mr-2 size-4" />
Back to Blog
</Link>
</footer>
</article>
</div>
</>
)
}

View file

@ -2,7 +2,12 @@ import type { Metadata } from "next"
import Link from "next/link"
import { SEO } from "@/lib/seo"
import { ogImageUrl } from "@/lib/og"
import { getAllBlogPosts, formatPostDatePt } from "@/lib/blog"
import {
getAllBlogPosts,
formatPostDatePt,
getBlogCollectionStructuredData,
getBlogBreadcrumbStructuredData,
} from "@/lib/blog"
// Force dynamic rendering to evaluate publish gating at request-time
export const dynamic = "force-dynamic"
@ -50,55 +55,65 @@ export default function BlogIndex() {
// Get all published posts (sorted newest first)
const posts = getAllBlogPosts()
return (
<div className="container mx-auto px-4 py-12 sm:px-6 lg:px-8">
<div className="mx-auto max-w-4xl">
{/* Page Header */}
<div className="mb-12 text-center">
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl">Blog</h1>
<p className="mt-4 text-lg text-muted-foreground">{DESCRIPTION}</p>
</div>
// Generate structured data for SEO
const collectionSchema = getBlogCollectionStructuredData(posts)
const breadcrumbSchema = getBlogBreadcrumbStructuredData()
{/* Posts List */}
{posts.length === 0 ? (
<EmptyState />
) : (
<div className="space-y-8">
{posts.map((post) => (
<article
key={post.slug}
className="group rounded-lg border border-border bg-card p-6 transition-colors hover:border-primary/50">
<Link href={`/blog/${post.slug}`} className="block">
<h2 className="text-xl font-semibold tracking-tight group-hover:text-primary sm:text-2xl">
{post.title}
</h2>
<p className="mt-2 text-muted-foreground line-clamp-2">{post.description}</p>
<div className="mt-4 flex items-center gap-4 text-sm text-muted-foreground">
<time dateTime={post.publish_date}>
Posted {formatPostDatePt(post.publish_date)}
</time>
{post.tags.length > 0 && (
<>
<span className="text-border"></span>
<div className="flex flex-wrap gap-2">
{post.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="rounded-full bg-muted px-2 py-0.5 text-xs">
{tag}
</span>
))}
</div>
</>
)}
</div>
</Link>
</article>
))}
return (
<>
{/* JSON-LD Structured Data */}
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionSchema) }} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }} />
<div className="container mx-auto px-4 py-12 sm:px-6 lg:px-8">
<div className="mx-auto max-w-4xl">
{/* Page Header */}
<div className="mb-12 text-center">
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl">Blog</h1>
<p className="mt-4 text-lg text-muted-foreground">{DESCRIPTION}</p>
</div>
)}
{/* Posts List */}
{posts.length === 0 ? (
<EmptyState />
) : (
<div className="space-y-8">
{posts.map((post) => (
<article
key={post.slug}
className="group rounded-lg border border-border bg-card p-6 transition-colors hover:border-primary/50">
<Link href={`/blog/${post.slug}`} className="block">
<h2 className="text-xl font-semibold tracking-tight group-hover:text-primary sm:text-2xl">
{post.title}
</h2>
<p className="mt-2 text-muted-foreground line-clamp-2">{post.description}</p>
<div className="mt-4 flex items-center gap-4 text-sm text-muted-foreground">
<time dateTime={post.publish_date}>
Posted {formatPostDatePt(post.publish_date)}
</time>
{post.tags.length > 0 && (
<>
<span className="text-border"></span>
<div className="flex flex-wrap gap-2">
{post.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="rounded-full bg-muted px-2 py-0.5 text-xs">
{tag}
</span>
))}
</div>
</>
)}
</div>
</Link>
</article>
))}
</div>
)}
</div>
</div>
</div>
</>
)
}

View file

@ -35,3 +35,12 @@ export { getNowPt, parsePublishTimePt, formatPostDatePt } from "./pt-time"
// Publishing helpers
export { isPublished } from "./publishing"
// Structured data (JSON-LD)
export {
getArticleStructuredData,
getBlogCollectionStructuredData,
getBlogBreadcrumbStructuredData,
getBlogPostBreadcrumbStructuredData,
getBlogPostUrl,
} from "./structured-data"

View file

@ -0,0 +1,175 @@
import { SEO } from "../seo"
import type { BlogPost } from "./types"
/**
* Organization reference used across all blog structured data
*/
const ORG_ID = `${SEO.url}#org`
/**
* Blog section reference
*/
const BLOG_ID = `${SEO.url}/blog#blog`
/**
* Get the canonical URL for a blog post
*/
export function getBlogPostUrl(slug: string): string {
return `${SEO.url}/blog/${slug}`
}
/**
* Generate Article JSON-LD for a blog post
*
* @see https://schema.org/Article
* @see https://developers.google.com/search/docs/appearance/structured-data/article
*/
export function getArticleStructuredData(post: BlogPost) {
const url = getBlogPostUrl(post.slug)
return {
"@context": "https://schema.org",
"@type": "Article",
"@id": `${url}#article`,
mainEntityOfPage: {
"@type": "WebPage",
"@id": url,
},
url,
headline: post.title,
description: post.description,
datePublished: post.publish_date,
// dateModified not available in MVP - use publish_date
dateModified: post.publish_date,
author: {
"@type": "Organization",
"@id": ORG_ID,
name: SEO.name,
},
publisher: {
"@type": "Organization",
"@id": ORG_ID,
name: SEO.name,
logo: {
"@type": "ImageObject",
url: `${SEO.url}/android-chrome-512x512.png`,
width: 512,
height: 512,
},
},
isPartOf: {
"@type": "Blog",
"@id": BLOG_ID,
},
// Keywords from tags
keywords: post.tags.join(", "),
}
}
/**
* Generate CollectionPage + ItemList JSON-LD for blog index
*
* @see https://schema.org/CollectionPage
* @see https://schema.org/ItemList
*/
export function getBlogCollectionStructuredData(posts: BlogPost[]) {
const blogUrl = `${SEO.url}/blog`
return {
"@context": "https://schema.org",
"@graph": [
{
"@type": "Blog",
"@id": BLOG_ID,
name: `${SEO.name} Blog`,
description:
"Insights on AI-powered software development, engineering workflows, and the future of coding with Roo Code.",
url: blogUrl,
publisher: {
"@type": "Organization",
"@id": ORG_ID,
},
},
{
"@type": "CollectionPage",
"@id": `${blogUrl}#collection`,
url: blogUrl,
name: "Blog",
description:
"Insights on AI-powered software development, engineering workflows, and the future of coding with Roo Code.",
isPartOf: {
"@type": "WebSite",
"@id": `${SEO.url}#website`,
},
mainEntity: {
"@type": "ItemList",
numberOfItems: posts.length,
itemListElement: posts.map((post, index) => ({
"@type": "ListItem",
position: index + 1,
url: getBlogPostUrl(post.slug),
name: post.title,
})),
},
},
],
}
}
/**
* Generate BreadcrumbList JSON-LD for blog index
*
* @see https://schema.org/BreadcrumbList
*/
export function getBlogBreadcrumbStructuredData() {
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: SEO.url,
},
{
"@type": "ListItem",
position: 2,
name: "Blog",
item: `${SEO.url}/blog`,
},
],
}
}
/**
* Generate BreadcrumbList JSON-LD for a blog post
*
* @see https://schema.org/BreadcrumbList
*/
export function getBlogPostBreadcrumbStructuredData(post: BlogPost) {
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: SEO.url,
},
{
"@type": "ListItem",
position: 2,
name: "Blog",
item: `${SEO.url}/blog`,
},
{
"@type": "ListItem",
position: 3,
name: post.title,
item: getBlogPostUrl(post.slug),
},
],
}
}