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
1013import type { Metadata } from "next"
@@ -13,11 +16,19 @@ import { notFound } from "next/navigation"
1316import Script from "next/script"
1417import ReactMarkdown from "react-markdown"
1518import 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"
1827import { SEO } from "@/lib/seo"
1928import { ogImageUrl } from "@/lib/og"
2029import { 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
2334export 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 = / ^ # # F r e q u e n t l y a s k e d q u e s t i o n s \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+
3093export 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 */ }
0 commit comments