Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 6f78205

Browse files
committed
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
1 parent aa4c2d9 commit 6f78205

4 files changed

Lines changed: 355 additions & 136 deletions

File tree

apps/web-roo-code/src/app/blog/[slug]/page.tsx

Lines changed: 110 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ import remarkGfm from "remark-gfm"
66
import { ArrowLeft } from "lucide-react"
77
import { SEO } from "@/lib/seo"
88
import { ogImageUrl } from "@/lib/og"
9-
import { getBlogPostBySlug, formatPostDatePt } from "@/lib/blog"
9+
import {
10+
getBlogPostBySlug,
11+
formatPostDatePt,
12+
getArticleStructuredData,
13+
getBlogPostBreadcrumbStructuredData,
14+
} from "@/lib/blog"
1015

1116
// Force dynamic rendering to evaluate publish gating at request-time
1217
export const dynamic = "force-dynamic"
@@ -72,102 +77,117 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
7277
notFound()
7378
}
7479

80+
// Generate structured data for SEO
81+
const articleSchema = getArticleStructuredData(post)
82+
const breadcrumbSchema = getBlogPostBreadcrumbStructuredData(post)
83+
7584
return (
76-
<div className="container mx-auto px-4 py-12 sm:px-6 lg:px-8">
77-
<article className="mx-auto max-w-3xl">
78-
{/* Back link */}
79-
<Link
80-
href="/blog"
81-
className="mb-8 inline-flex items-center text-sm text-muted-foreground hover:text-foreground">
82-
<ArrowLeft className="mr-2 size-4" />
83-
Back to Blog
84-
</Link>
85+
<>
86+
{/* JSON-LD Structured Data */}
87+
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }} />
88+
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }} />
8589

86-
{/* Post Header */}
87-
<header className="mb-8">
88-
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl">{post.title}</h1>
89-
<div className="mt-4 flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
90-
<time dateTime={post.publish_date}>Posted {formatPostDatePt(post.publish_date)}</time>
91-
{post.tags.length > 0 && (
92-
<>
93-
<span className="text-border"></span>
94-
<div className="flex flex-wrap gap-2">
95-
{post.tags.map((tag) => (
96-
<span key={tag} className="rounded-full bg-muted px-2 py-0.5 text-xs">
97-
{tag}
98-
</span>
99-
))}
100-
</div>
101-
</>
102-
)}
103-
</div>
104-
</header>
90+
<div className="container mx-auto px-4 py-12 sm:px-6 lg:px-8">
91+
<article className="mx-auto max-w-3xl">
92+
{/* Back link */}
93+
<Link
94+
href="/blog"
95+
className="mb-8 inline-flex items-center text-sm text-muted-foreground hover:text-foreground">
96+
<ArrowLeft className="mr-2 size-4" />
97+
Back to Blog
98+
</Link>
10599

106-
{/* Post Content */}
107-
<div className="prose prose-lg max-w-none dark:prose-invert">
108-
<ReactMarkdown
109-
remarkPlugins={[remarkGfm]}
110-
components={{
111-
// Do not allow raw HTML - all components render safely
112-
h1: ({ ...props }) => (
113-
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl" {...props} />
114-
),
115-
h2: ({ ...props }) => <h2 className="mt-10 text-xl font-bold sm:text-2xl" {...props} />,
116-
h3: ({ ...props }) => <h3 className="mt-8 text-lg font-semibold" {...props} />,
117-
a: ({ ...props }) => (
118-
<a
119-
className="text-primary hover:underline"
120-
target="_blank"
121-
rel="noopener noreferrer"
122-
{...props}
123-
/>
124-
),
125-
blockquote: ({ ...props }) => (
126-
<blockquote
127-
className="border-l-4 border-primary/50 pl-4 italic text-muted-foreground"
128-
{...props}
129-
/>
130-
),
131-
code: ({ className, children, ...props }) => {
132-
// Check if this is an inline code or code block
133-
const isInline = !className
134-
if (isInline) {
100+
{/* Post Header */}
101+
<header className="mb-8">
102+
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl">{post.title}</h1>
103+
<div className="mt-4 flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
104+
<time dateTime={post.publish_date}>Posted {formatPostDatePt(post.publish_date)}</time>
105+
{post.tags.length > 0 && (
106+
<>
107+
<span className="text-border"></span>
108+
<div className="flex flex-wrap gap-2">
109+
{post.tags.map((tag) => (
110+
<span key={tag} className="rounded-full bg-muted px-2 py-0.5 text-xs">
111+
{tag}
112+
</span>
113+
))}
114+
</div>
115+
</>
116+
)}
117+
</div>
118+
</header>
119+
120+
{/* Post Content */}
121+
<div className="prose prose-lg max-w-none dark:prose-invert">
122+
<ReactMarkdown
123+
remarkPlugins={[remarkGfm]}
124+
components={{
125+
// Do not allow raw HTML - all components render safely
126+
h1: ({ ...props }) => (
127+
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl" {...props} />
128+
),
129+
h2: ({ ...props }) => <h2 className="mt-10 text-xl font-bold sm:text-2xl" {...props} />,
130+
h3: ({ ...props }) => <h3 className="mt-8 text-lg font-semibold" {...props} />,
131+
a: ({ ...props }) => (
132+
<a
133+
className="text-primary hover:underline"
134+
target="_blank"
135+
rel="noopener noreferrer"
136+
{...props}
137+
/>
138+
),
139+
blockquote: ({ ...props }) => (
140+
<blockquote
141+
className="border-l-4 border-primary/50 pl-4 italic text-muted-foreground"
142+
{...props}
143+
/>
144+
),
145+
code: ({ className, children, ...props }) => {
146+
// Check if this is an inline code or code block
147+
const isInline = !className
148+
if (isInline) {
149+
return (
150+
<code
151+
className="rounded bg-muted px-1.5 py-0.5 text-sm font-mono"
152+
{...props}>
153+
{children}
154+
</code>
155+
)
156+
}
135157
return (
136-
<code className="rounded bg-muted px-1.5 py-0.5 text-sm font-mono" {...props}>
158+
<code className={className} {...props}>
137159
{children}
138160
</code>
139161
)
140-
}
141-
return (
142-
<code className={className} {...props}>
143-
{children}
144-
</code>
145-
)
146-
},
147-
pre: ({ ...props }) => (
148-
<pre className="overflow-x-auto rounded-lg bg-muted p-4 text-sm font-mono" {...props} />
149-
),
150-
ul: ({ ...props }) => <ul className="list-disc pl-6" {...props} />,
151-
ol: ({ ...props }) => <ol className="list-decimal pl-6" {...props} />,
152-
li: ({ ...props }) => <li className="mt-2" {...props} />,
153-
p: ({ ...props }) => <p className="mt-4 leading-7" {...props} />,
154-
strong: ({ ...props }) => <strong className="font-semibold" {...props} />,
155-
hr: ({ ...props }) => <hr className="my-8 border-border" {...props} />,
156-
}}>
157-
{post.content}
158-
</ReactMarkdown>
159-
</div>
162+
},
163+
pre: ({ ...props }) => (
164+
<pre
165+
className="overflow-x-auto rounded-lg bg-muted p-4 text-sm font-mono"
166+
{...props}
167+
/>
168+
),
169+
ul: ({ ...props }) => <ul className="list-disc pl-6" {...props} />,
170+
ol: ({ ...props }) => <ol className="list-decimal pl-6" {...props} />,
171+
li: ({ ...props }) => <li className="mt-2" {...props} />,
172+
p: ({ ...props }) => <p className="mt-4 leading-7" {...props} />,
173+
strong: ({ ...props }) => <strong className="font-semibold" {...props} />,
174+
hr: ({ ...props }) => <hr className="my-8 border-border" {...props} />,
175+
}}>
176+
{post.content}
177+
</ReactMarkdown>
178+
</div>
160179

161-
{/* Footer */}
162-
<footer className="mt-12 border-t border-border pt-8">
163-
<Link
164-
href="/blog"
165-
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground">
166-
<ArrowLeft className="mr-2 size-4" />
167-
Back to Blog
168-
</Link>
169-
</footer>
170-
</article>
171-
</div>
180+
{/* Footer */}
181+
<footer className="mt-12 border-t border-border pt-8">
182+
<Link
183+
href="/blog"
184+
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground">
185+
<ArrowLeft className="mr-2 size-4" />
186+
Back to Blog
187+
</Link>
188+
</footer>
189+
</article>
190+
</div>
191+
</>
172192
)
173193
}

apps/web-roo-code/src/app/blog/page.tsx

Lines changed: 61 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import type { Metadata } from "next"
22
import Link from "next/link"
33
import { SEO } from "@/lib/seo"
44
import { ogImageUrl } from "@/lib/og"
5-
import { getAllBlogPosts, formatPostDatePt } from "@/lib/blog"
5+
import {
6+
getAllBlogPosts,
7+
formatPostDatePt,
8+
getBlogCollectionStructuredData,
9+
getBlogBreadcrumbStructuredData,
10+
} from "@/lib/blog"
611

712
// Force dynamic rendering to evaluate publish gating at request-time
813
export const dynamic = "force-dynamic"
@@ -50,55 +55,65 @@ export default function BlogIndex() {
5055
// Get all published posts (sorted newest first)
5156
const posts = getAllBlogPosts()
5257

58+
// Generate structured data for SEO
59+
const collectionSchema = getBlogCollectionStructuredData(posts)
60+
const breadcrumbSchema = getBlogBreadcrumbStructuredData()
61+
5362
return (
54-
<div className="container mx-auto px-4 py-12 sm:px-6 lg:px-8">
55-
<div className="mx-auto max-w-4xl">
56-
{/* Page Header */}
57-
<div className="mb-12 text-center">
58-
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl">Blog</h1>
59-
<p className="mt-4 text-lg text-muted-foreground">{DESCRIPTION}</p>
60-
</div>
63+
<>
64+
{/* JSON-LD Structured Data */}
65+
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionSchema) }} />
66+
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }} />
6167

62-
{/* Posts List */}
63-
{posts.length === 0 ? (
64-
<EmptyState />
65-
) : (
66-
<div className="space-y-8">
67-
{posts.map((post) => (
68-
<article
69-
key={post.slug}
70-
className="group rounded-lg border border-border bg-card p-6 transition-colors hover:border-primary/50">
71-
<Link href={`/blog/${post.slug}`} className="block">
72-
<h2 className="text-xl font-semibold tracking-tight group-hover:text-primary sm:text-2xl">
73-
{post.title}
74-
</h2>
75-
<p className="mt-2 text-muted-foreground line-clamp-2">{post.description}</p>
76-
<div className="mt-4 flex items-center gap-4 text-sm text-muted-foreground">
77-
<time dateTime={post.publish_date}>
78-
Posted {formatPostDatePt(post.publish_date)}
79-
</time>
80-
{post.tags.length > 0 && (
81-
<>
82-
<span className="text-border"></span>
83-
<div className="flex flex-wrap gap-2">
84-
{post.tags.slice(0, 3).map((tag) => (
85-
<span
86-
key={tag}
87-
className="rounded-full bg-muted px-2 py-0.5 text-xs">
88-
{tag}
89-
</span>
90-
))}
91-
</div>
92-
</>
93-
)}
94-
</div>
95-
</Link>
96-
</article>
97-
))}
68+
<div className="container mx-auto px-4 py-12 sm:px-6 lg:px-8">
69+
<div className="mx-auto max-w-4xl">
70+
{/* Page Header */}
71+
<div className="mb-12 text-center">
72+
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl">Blog</h1>
73+
<p className="mt-4 text-lg text-muted-foreground">{DESCRIPTION}</p>
9874
</div>
99-
)}
75+
76+
{/* Posts List */}
77+
{posts.length === 0 ? (
78+
<EmptyState />
79+
) : (
80+
<div className="space-y-8">
81+
{posts.map((post) => (
82+
<article
83+
key={post.slug}
84+
className="group rounded-lg border border-border bg-card p-6 transition-colors hover:border-primary/50">
85+
<Link href={`/blog/${post.slug}`} className="block">
86+
<h2 className="text-xl font-semibold tracking-tight group-hover:text-primary sm:text-2xl">
87+
{post.title}
88+
</h2>
89+
<p className="mt-2 text-muted-foreground line-clamp-2">{post.description}</p>
90+
<div className="mt-4 flex items-center gap-4 text-sm text-muted-foreground">
91+
<time dateTime={post.publish_date}>
92+
Posted {formatPostDatePt(post.publish_date)}
93+
</time>
94+
{post.tags.length > 0 && (
95+
<>
96+
<span className="text-border"></span>
97+
<div className="flex flex-wrap gap-2">
98+
{post.tags.slice(0, 3).map((tag) => (
99+
<span
100+
key={tag}
101+
className="rounded-full bg-muted px-2 py-0.5 text-xs">
102+
{tag}
103+
</span>
104+
))}
105+
</div>
106+
</>
107+
)}
108+
</div>
109+
</Link>
110+
</article>
111+
))}
112+
</div>
113+
)}
114+
</div>
100115
</div>
101-
</div>
116+
</>
102117
)
103118
}
104119

apps/web-roo-code/src/lib/blog/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,12 @@ export { getNowPt, parsePublishTimePt, formatPostDatePt } from "./pt-time"
3535

3636
// Publishing helpers
3737
export { isPublished } from "./publishing"
38+
39+
// Structured data (JSON-LD)
40+
export {
41+
getArticleStructuredData,
42+
getBlogCollectionStructuredData,
43+
getBlogBreadcrumbStructuredData,
44+
getBlogPostBreadcrumbStructuredData,
45+
getBlogPostUrl,
46+
} from "./structured-data"

0 commit comments

Comments
 (0)