mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-10 22:41:14 +00:00
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:
parent
aa4c2d921f
commit
6f782056ad
4 changed files with 357 additions and 138 deletions
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
175
apps/web-roo-code/src/lib/blog/structured-data.ts
Normal file
175
apps/web-roo-code/src/lib/blog/structured-data.ts
Normal 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),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue