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

Commit aa4c2d9

Browse files
committed
feat(blog): add /blog/[slug] post page with dynamic rendering
- Create dynamic route for individual blog posts - Use force-dynamic for request-time publish gating - Require Node.js runtime for filesystem reads - Return 404 for drafts, future posts, or invalid slugs - Render Markdown with react-markdown + remark-gfm - No raw HTML rendering (safe by default) - Include consistent typography and styling Closes: MKT-69
1 parent 1bfa919 commit aa4c2d9

1 file changed

Lines changed: 173 additions & 0 deletions

File tree

  • apps/web-roo-code/src/app/blog/[slug]
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import type { Metadata } from "next"
2+
import { notFound } from "next/navigation"
3+
import Link from "next/link"
4+
import ReactMarkdown from "react-markdown"
5+
import remarkGfm from "remark-gfm"
6+
import { ArrowLeft } from "lucide-react"
7+
import { SEO } from "@/lib/seo"
8+
import { ogImageUrl } from "@/lib/og"
9+
import { getBlogPostBySlug, formatPostDatePt } from "@/lib/blog"
10+
11+
// Force dynamic rendering to evaluate publish gating at request-time
12+
export const dynamic = "force-dynamic"
13+
14+
// Require Node.js runtime for filesystem reads
15+
export const runtime = "nodejs"
16+
17+
interface BlogPostPageProps {
18+
params: Promise<{ slug: string }>
19+
}
20+
21+
export async function generateMetadata({ params }: BlogPostPageProps): Promise<Metadata> {
22+
const { slug } = await params
23+
const post = getBlogPostBySlug(slug)
24+
25+
if (!post) {
26+
return {
27+
title: "Post Not Found",
28+
}
29+
}
30+
31+
const PATH = `/blog/${post.slug}`
32+
33+
return {
34+
title: post.title,
35+
description: post.description,
36+
alternates: {
37+
canonical: `${SEO.url}${PATH}`,
38+
},
39+
openGraph: {
40+
title: post.title,
41+
description: post.description,
42+
url: `${SEO.url}${PATH}`,
43+
siteName: SEO.name,
44+
images: [
45+
{
46+
url: ogImageUrl(post.title, post.description),
47+
width: 1200,
48+
height: 630,
49+
alt: post.title,
50+
},
51+
],
52+
locale: SEO.locale,
53+
type: "article",
54+
publishedTime: post.publish_date,
55+
},
56+
twitter: {
57+
card: SEO.twitterCard,
58+
title: post.title,
59+
description: post.description,
60+
images: [ogImageUrl(post.title, post.description)],
61+
},
62+
keywords: [...SEO.keywords, ...post.tags],
63+
}
64+
}
65+
66+
export default async function BlogPostPage({ params }: BlogPostPageProps) {
67+
const { slug } = await params
68+
const post = getBlogPostBySlug(slug)
69+
70+
// Return 404 if post not found or not published
71+
if (!post) {
72+
notFound()
73+
}
74+
75+
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+
{/* 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>
105+
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) {
135+
return (
136+
<code className="rounded bg-muted px-1.5 py-0.5 text-sm font-mono" {...props}>
137+
{children}
138+
</code>
139+
)
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>
160+
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>
172+
)
173+
}

0 commit comments

Comments
 (0)