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

Commit e9c598b

Browse files
committed
feat(blog): add Vercel-inspired patterns and Tone of Voice alignment
- Add reading time display to blog posts - Create BlogPostCTA component with 4 variants (default, extension, cloud, enterprise) - Add zebra striping to tables in blog posts - Add CTA to blog landing and paginated pages - Remove 'Posted' prefix from dates - Update blog description: 'How teams use agents to iterate, review, and ship PRs with proof' - Add BlogPostList and BlogPagination components - Add 100+ new blog posts from content pipeline
1 parent 85736c8 commit e9c598b

132 files changed

Lines changed: 15380 additions & 56 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 130 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
* Renders a single blog post from Markdown.
66
* Uses dynamic rendering (force-dynamic) for request-time publish gating.
77
* Does NOT use generateStaticParams to avoid static generation.
8+
*
9+
* AEO Enhancement: Parses FAQ sections from markdown, renders as accordion,
10+
* and generates FAQPage JSON-LD schema for AI search optimization.
811
*/
912

1013
import type { Metadata } from "next"
@@ -13,11 +16,19 @@ import { notFound } from "next/navigation"
1316
import Script from "next/script"
1417
import ReactMarkdown from "react-markdown"
1518
import remarkGfm from "remark-gfm"
16-
import { ChevronLeft, ChevronRight } from "lucide-react"
17-
import { getBlogPostBySlug, getAdjacentPosts, formatPostDatePt } from "@/lib/blog"
19+
import { ChevronLeft, ChevronRight, Clock } from "lucide-react"
20+
import {
21+
getBlogPostBySlug,
22+
getAdjacentPosts,
23+
formatPostDatePt,
24+
calculateReadingTime,
25+
formatReadingTime,
26+
} from "@/lib/blog"
1827
import { SEO } from "@/lib/seo"
1928
import { ogImageUrl } from "@/lib/og"
2029
import { BlogPostAnalytics } from "@/components/blog/BlogAnalytics"
30+
import { BlogFAQ, type FAQItem } from "@/components/blog/BlogFAQ"
31+
import { BlogPostCTA } from "@/components/blog/BlogPostCTA"
2132

2233
// Force dynamic rendering for request-time publish gating
2334
export const dynamic = "force-dynamic"
@@ -27,6 +38,58 @@ interface Props {
2738
params: Promise<{ slug: string }>
2839
}
2940

41+
/**
42+
* Parse FAQ section from markdown content
43+
*
44+
* Looks for a section starting with "## Frequently asked questions"
45+
* and extracts H3 questions with their content as answers.
46+
*
47+
* Returns the FAQ items and the content with FAQ section removed.
48+
*/
49+
function parseFAQFromMarkdown(content: string): {
50+
faqItems: FAQItem[]
51+
contentWithoutFAQ: string
52+
} {
53+
// Match FAQ section: ## Frequently asked questions (case-insensitive)
54+
const faqSectionRegex = /^## Frequently asked questions\s*$/im
55+
const faqMatch = content.match(faqSectionRegex)
56+
57+
if (!faqMatch || faqMatch.index === undefined) {
58+
return { faqItems: [], contentWithoutFAQ: content }
59+
}
60+
61+
const faqStartIndex = faqMatch.index
62+
const beforeFAQ = content.slice(0, faqStartIndex).trim()
63+
const faqSection = content.slice(faqStartIndex)
64+
65+
// Find where FAQ section ends (next H2 or end of content)
66+
const nextH2Match = faqSection.slice(faqMatch[0].length).match(/^## /m)
67+
const faqContent =
68+
nextH2Match && nextH2Match.index !== undefined
69+
? faqSection.slice(0, faqMatch[0].length + nextH2Match.index)
70+
: faqSection
71+
72+
const afterFAQ =
73+
nextH2Match && nextH2Match.index !== undefined ? faqSection.slice(faqMatch[0].length + nextH2Match.index) : ""
74+
75+
// Parse individual FAQ items (### Question followed by content)
76+
const faqItems: FAQItem[] = []
77+
const questionRegex = /^### (.+?)$\s*([\s\S]*?)(?=^### |$(?![\s\S]))/gm
78+
let match
79+
80+
while ((match = questionRegex.exec(faqContent)) !== null) {
81+
const question = match[1]?.trim()
82+
const answer = match[2]?.trim()
83+
if (question && answer) {
84+
faqItems.push({ question, answer })
85+
}
86+
}
87+
88+
const contentWithoutFAQ = (beforeFAQ + "\n\n" + afterFAQ).trim()
89+
90+
return { faqItems, contentWithoutFAQ }
91+
}
92+
3093
export async function generateMetadata({ params }: Props): Promise<Metadata> {
3194
const { slug } = await params
3295
const post = getBlogPostBySlug(slug)
@@ -80,6 +143,14 @@ export default async function BlogPostPage({ params }: Props) {
80143

81144
const { previous, next } = getAdjacentPosts(slug)
82145

146+
// Calculate reading time
147+
const readingTime = calculateReadingTime(post.content)
148+
const readingTimeDisplay = formatReadingTime(readingTime)
149+
150+
// Parse FAQ section from markdown content
151+
const { faqItems, contentWithoutFAQ } = parseFAQFromMarkdown(post.content)
152+
const hasFAQ = faqItems.length > 0
153+
83154
// Article JSON-LD schema
84155
const articleSchema = {
85156
"@context": "https://schema.org",
@@ -134,6 +205,22 @@ export default async function BlogPostPage({ params }: Props) {
134205
],
135206
}
136207

208+
// FAQPage schema (only if post has FAQ section) - AEO optimization
209+
const faqSchema = hasFAQ
210+
? {
211+
"@context": "https://schema.org",
212+
"@type": "FAQPage",
213+
mainEntity: faqItems.map((item) => ({
214+
"@type": "Question",
215+
name: item.question,
216+
acceptedAnswer: {
217+
"@type": "Answer",
218+
text: item.answer,
219+
},
220+
})),
221+
}
222+
: null
223+
137224
return (
138225
<>
139226
<Script
@@ -146,6 +233,13 @@ export default async function BlogPostPage({ params }: Props) {
146233
type="application/ld+json"
147234
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
148235
/>
236+
{faqSchema && (
237+
<Script
238+
id="faq-schema"
239+
type="application/ld+json"
240+
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }}
241+
/>
242+
)}
149243

150244
<BlogPostAnalytics post={post} />
151245

@@ -171,7 +265,14 @@ export default async function BlogPostPage({ params }: Props) {
171265
<div className="prose prose-lg dark:prose-invert">
172266
<header className="not-prose mb-8">
173267
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl">{post.title}</h1>
174-
<p className="mt-4 text-muted-foreground">Posted {formatPostDatePt(post.publish_date)}</p>
268+
<div className="mt-4 flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
269+
<span>{formatPostDatePt(post.publish_date)}</span>
270+
<span className="text-border"></span>
271+
<span className="flex items-center gap-1">
272+
<Clock className="h-4 w-4" />
273+
{readingTimeDisplay}
274+
</span>
275+
</div>
175276
{post.tags.length > 0 && (
176277
<div className="mt-4 flex flex-wrap gap-2">
177278
{post.tags.map((tag) => (
@@ -231,9 +332,34 @@ export default async function BlogPostPage({ params }: Props) {
231332
// Lists
232333
ul: ({ ...props }) => <ul className="my-6 ml-6 list-disc [&>li]:mt-2" {...props} />,
233334
ol: ({ ...props }) => <ol className="my-6 ml-6 list-decimal [&>li]:mt-2" {...props} />,
335+
// Tables with zebra striping (visible in both light and dark mode)
336+
table: ({ ...props }) => (
337+
<div className="not-prose my-6 w-full overflow-x-auto rounded-lg border border-border">
338+
<table className="w-full border-collapse text-sm" {...props} />
339+
</div>
340+
),
341+
thead: ({ ...props }) => <thead className="bg-muted" {...props} />,
342+
tbody: ({ ...props }) => <tbody {...props} />,
343+
tr: ({ ...props }) => (
344+
<tr
345+
className="border-b border-border last:border-b-0 transition-colors even:bg-muted/70 hover:bg-muted"
346+
{...props}
347+
/>
348+
),
349+
th: ({ ...props }) => (
350+
<th className="px-4 py-3 text-left font-semibold text-foreground" {...props} />
351+
),
352+
td: ({ ...props }) => <td className="px-4 py-3" {...props} />,
234353
}}>
235-
{post.content}
354+
{contentWithoutFAQ}
236355
</ReactMarkdown>
356+
357+
{/* FAQ Section rendered as accordion */}
358+
{hasFAQ && <BlogFAQ items={faqItems} />}
359+
360+
{/* Product CTA Module - Inspired by Vercel's blog design
361+
Default variant prioritizes Roo Code Cloud sign-up */}
362+
<BlogPostCTA />
237363
</div>
238364

239365
{/* Previous/Next Post Navigation */}

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

Lines changed: 21 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,26 @@
22
* Blog Index Page
33
* MKT-68: Blog Index Page
44
*
5-
* Lists all published blog posts, sorted newest-first.
5+
* Lists published blog posts with pagination (12 posts per page).
66
* Uses dynamic rendering (force-dynamic) for request-time publish gating.
77
*/
88

99
import type { Metadata } from "next"
10-
import Link from "next/link"
1110
import Script from "next/script"
12-
import { getAllBlogPosts, formatPostDatePt } from "@/lib/blog"
11+
import { getPaginatedBlogPosts, getAllBlogPosts } from "@/lib/blog"
1312
import { SEO } from "@/lib/seo"
1413
import { ogImageUrl } from "@/lib/og"
1514
import { BlogIndexAnalytics } from "@/components/blog/BlogAnalytics"
15+
import { BlogPostList } from "@/components/blog/BlogPostList"
16+
import { BlogPagination } from "@/components/blog/BlogPagination"
17+
import { BlogPostCTA } from "@/components/blog/BlogPostCTA"
1618

1719
// Force dynamic rendering for request-time publish gating
1820
export const dynamic = "force-dynamic"
1921
export const runtime = "nodejs"
2022

2123
const TITLE = "Blog"
22-
const DESCRIPTION =
23-
"Insights on AI-powered development, engineering practices, and building better software with Roo Code."
24+
const DESCRIPTION = "How teams use agents to iterate, review, and ship PRs with proof."
2425
const PATH = "/blog"
2526

2627
export const metadata: Metadata = {
@@ -55,9 +56,10 @@ export const metadata: Metadata = {
5556
}
5657

5758
export default function BlogIndexPage() {
58-
const posts = getAllBlogPosts()
59+
const { posts, currentPage, totalPages, totalPosts } = getPaginatedBlogPosts(1)
60+
const allPosts = getAllBlogPosts()
5961

60-
// Schema.org CollectionPage + ItemList
62+
// Schema.org CollectionPage + ItemList (includes all posts for SEO)
6163
const blogSchema = {
6264
"@context": "https://schema.org",
6365
"@type": "CollectionPage",
@@ -66,7 +68,7 @@ export default function BlogIndexPage() {
6668
url: `${SEO.url}${PATH}`,
6769
mainEntity: {
6870
"@type": "ItemList",
69-
itemListElement: posts.map((post, index) => ({
71+
itemListElement: allPosts.map((post, index) => ({
7072
"@type": "ListItem",
7173
position: index + 1,
7274
url: `${SEO.url}/blog/${post.slug}`,
@@ -115,43 +117,18 @@ export default function BlogIndexPage() {
115117
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl">Blog</h1>
116118
<p className="mt-4 text-lg text-muted-foreground">{DESCRIPTION}</p>
117119

118-
{posts.length === 0 ? (
119-
<div className="mt-12 text-center">
120-
<p className="text-muted-foreground">No posts published yet. Check back soon!</p>
121-
</div>
122-
) : (
123-
<div className="mt-12 space-y-12">
124-
{posts.map((post) => (
125-
<article key={post.slug} className="border-b border-border pb-12 last:border-b-0">
126-
<Link href={`/blog/${post.slug}`} className="group">
127-
<h2 className="text-xl font-semibold tracking-tight transition-colors group-hover:text-primary sm:text-2xl">
128-
{post.title}
129-
</h2>
130-
</Link>
131-
<p className="mt-2 text-sm text-muted-foreground">
132-
Posted {formatPostDatePt(post.publish_date)}
133-
</p>
134-
<p className="mt-3 text-muted-foreground">{post.description}</p>
135-
{post.tags.length > 0 && (
136-
<div className="mt-4 flex flex-wrap gap-2">
137-
{post.tags.map((tag) => (
138-
<span
139-
key={tag}
140-
className="rounded bg-muted px-2 py-1 text-xs text-muted-foreground">
141-
{tag}
142-
</span>
143-
))}
144-
</div>
145-
)}
146-
<Link
147-
href={`/blog/${post.slug}`}
148-
className="mt-4 inline-block text-sm font-medium text-primary hover:underline">
149-
Read more →
150-
</Link>
151-
</article>
152-
))}
153-
</div>
120+
{totalPosts > 0 && totalPages > 1 && (
121+
<p className="mt-2 text-sm text-muted-foreground">
122+
Showing {posts.length} of {totalPosts} posts
123+
</p>
154124
)}
125+
126+
<BlogPostList posts={posts} />
127+
128+
<BlogPagination currentPage={currentPage} totalPages={totalPages} />
129+
130+
{/* Cloud CTA - shown after pagination */}
131+
<BlogPostCTA />
155132
</div>
156133
</div>
157134
</>

0 commit comments

Comments
 (0)