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

Commit b7fb063

Browse files
committed
feat(blog): add markdown content pipeline with PT scheduling
- Add gray-matter for frontmatter parsing - Create BlogPost type and zod schema for validation - Implement PT timezone helpers (getNowPt, parsePublishTimePt) - Implement content loading (getAllBlogPosts, getBlogPostBySlug) - Implement isPublished for request-time publish gating - Add validation for frontmatter and duplicate slugs - Create content/blog directory for markdown files Closes: MKT-67
1 parent 17d3456 commit b7fb063

8 files changed

Lines changed: 481 additions & 1 deletion

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# This directory contains blog post markdown files.
2+
# See docs/blog.md for the specification.

apps/web-roo-code/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"embla-carousel-autoplay": "^8.6.0",
2525
"embla-carousel-react": "^8.6.0",
2626
"framer-motion": "12.15.0",
27+
"gray-matter": "^4.0.3",
2728
"lucide-react": "^0.518.0",
2829
"next": "~15.2.8",
2930
"next-themes": "^0.4.6",
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import fs from "fs"
2+
import path from "path"
3+
import matter from "gray-matter"
4+
import { ZodError } from "zod"
5+
import type { BlogPost } from "./types"
6+
import { blogFrontmatterSchema } from "./types"
7+
import { getNowPt } from "./pt-time"
8+
import { isPublished } from "./publishing"
9+
10+
/**
11+
* Path to the blog content directory (relative to project root).
12+
*/
13+
const CONTENT_DIR = "content/blog"
14+
15+
/**
16+
* Get the absolute path to the blog content directory.
17+
*/
18+
function getContentDir(): string {
19+
return path.join(process.cwd(), CONTENT_DIR)
20+
}
21+
22+
/**
23+
* Error thrown when blog content validation fails.
24+
*/
25+
export class BlogContentError extends Error {
26+
constructor(
27+
message: string,
28+
public filename?: string,
29+
) {
30+
super(filename ? `[${filename}] ${message}` : message)
31+
this.name = "BlogContentError"
32+
}
33+
}
34+
35+
/**
36+
* Parse a single markdown file into a BlogPost object.
37+
*
38+
* @param filename - Name of the markdown file (e.g., "my-post.md")
39+
* @returns Parsed BlogPost object
40+
* @throws BlogContentError if frontmatter is invalid
41+
*/
42+
function parseMarkdownFile(filename: string): BlogPost {
43+
const filePath = path.join(getContentDir(), filename)
44+
const fileContent = fs.readFileSync(filePath, "utf8")
45+
46+
// Parse frontmatter using gray-matter
47+
const { data, content } = matter(fileContent)
48+
49+
// Validate frontmatter with zod
50+
try {
51+
const frontmatter = blogFrontmatterSchema.parse(data)
52+
53+
// Verify slug matches filename (without .md extension)
54+
const expectedSlug = filename.replace(/\.md$/, "")
55+
if (frontmatter.slug !== expectedSlug) {
56+
throw new BlogContentError(
57+
`Slug mismatch: frontmatter slug "${frontmatter.slug}" does not match filename "${expectedSlug}"`,
58+
filename,
59+
)
60+
}
61+
62+
return {
63+
...frontmatter,
64+
content,
65+
filename,
66+
}
67+
} catch (error) {
68+
if (error instanceof ZodError) {
69+
const issues = error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n")
70+
throw new BlogContentError(`Invalid frontmatter:\n${issues}`, filename)
71+
}
72+
throw error
73+
}
74+
}
75+
76+
/**
77+
* Load all markdown files from the content directory.
78+
*
79+
* @returns Array of all parsed blog posts (including drafts)
80+
* @throws BlogContentError if any file has invalid frontmatter or duplicate slugs
81+
*/
82+
function loadAllPosts(): BlogPost[] {
83+
const contentDir = getContentDir()
84+
85+
// Check if content directory exists
86+
if (!fs.existsSync(contentDir)) {
87+
return []
88+
}
89+
90+
// Get all .md files
91+
const files = fs.readdirSync(contentDir).filter((file) => file.endsWith(".md"))
92+
93+
// Parse all files
94+
const posts: BlogPost[] = []
95+
const slugToFilename = new Map<string, string>()
96+
97+
for (const filename of files) {
98+
const post = parseMarkdownFile(filename)
99+
100+
// Check for duplicate slugs
101+
const existingFilename = slugToFilename.get(post.slug)
102+
if (existingFilename) {
103+
throw new BlogContentError(
104+
`Duplicate slug "${post.slug}" found in files: "${existingFilename}" and "${filename}"`,
105+
)
106+
}
107+
slugToFilename.set(post.slug, filename)
108+
109+
posts.push(post)
110+
}
111+
112+
return posts
113+
}
114+
115+
/**
116+
* Options for getAllBlogPosts.
117+
*/
118+
export interface GetAllBlogPostsOptions {
119+
/**
120+
* Include draft posts in the results.
121+
* @default false
122+
*/
123+
includeDrafts?: boolean
124+
}
125+
126+
/**
127+
* Get all blog posts, optionally filtered by publish status.
128+
*
129+
* By default, only returns published posts that are past their scheduled
130+
* publish time (evaluated at request time in Pacific Time).
131+
*
132+
* @param options - Options for filtering posts
133+
* @returns Array of blog posts, sorted by publish_date (newest first)
134+
*
135+
* @example
136+
* ```ts
137+
* // Get only published posts (default)
138+
* const posts = getAllBlogPosts();
139+
*
140+
* // Include drafts (e.g., for preview in CMS)
141+
* const allPosts = getAllBlogPosts({ includeDrafts: true });
142+
* ```
143+
*/
144+
export function getAllBlogPosts(options: GetAllBlogPostsOptions = {}): BlogPost[] {
145+
const { includeDrafts = false } = options
146+
147+
const allPosts = loadAllPosts()
148+
const nowPt = getNowPt()
149+
150+
// Filter posts based on publish status
151+
const filteredPosts = includeDrafts ? allPosts : allPosts.filter((post) => isPublished(post, nowPt))
152+
153+
// Sort by publish_date (newest first), then by publish_time_pt
154+
return filteredPosts.sort((a, b) => {
155+
// Compare dates first (descending)
156+
const dateCompare = b.publish_date.localeCompare(a.publish_date)
157+
if (dateCompare !== 0) {
158+
return dateCompare
159+
}
160+
// Same date - compare times (descending)
161+
return b.publish_time_pt.localeCompare(a.publish_time_pt)
162+
})
163+
}
164+
165+
/**
166+
* Get a single blog post by its slug.
167+
*
168+
* Only returns the post if it's published and past its scheduled publish time.
169+
* Draft posts and future-scheduled posts will return null.
170+
*
171+
* @param slug - The URL slug of the post
172+
* @returns The blog post if found and published, null otherwise
173+
*
174+
* @example
175+
* ```ts
176+
* const post = getBlogPostBySlug('my-great-article');
177+
* if (post) {
178+
* // Render the post
179+
* } else {
180+
* // Show 404
181+
* }
182+
* ```
183+
*/
184+
export function getBlogPostBySlug(slug: string): BlogPost | null {
185+
const allPosts = loadAllPosts()
186+
const nowPt = getNowPt()
187+
188+
const post = allPosts.find((p) => p.slug === slug)
189+
190+
// Post not found
191+
if (!post) {
192+
return null
193+
}
194+
195+
// Check if published
196+
if (!isPublished(post, nowPt)) {
197+
return null
198+
}
199+
200+
return post
201+
}
202+
203+
/**
204+
* Get all valid slugs for published posts.
205+
* Useful for generating static paths or sitemaps.
206+
*
207+
* @returns Array of slugs for published posts
208+
*/
209+
export function getPublishedSlugs(): string[] {
210+
return getAllBlogPosts().map((post) => post.slug)
211+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Blog content pipeline for roocode.com/blog
3+
*
4+
* This module provides functions to load and manage blog posts from
5+
* markdown files with frontmatter.
6+
*
7+
* @see docs/blog.md for the full specification
8+
*
9+
* @example
10+
* ```ts
11+
* import { getAllBlogPosts, getBlogPostBySlug, formatPostDatePt } from '@/lib/blog';
12+
*
13+
* // Get all published posts
14+
* const posts = getAllBlogPosts();
15+
*
16+
* // Get a specific post
17+
* const post = getBlogPostBySlug('my-article');
18+
*
19+
* // Format date for display
20+
* const displayDate = formatPostDatePt(post.publish_date);
21+
* // "2026-01-29"
22+
* ```
23+
*/
24+
25+
// Types
26+
export type { BlogPost, BlogFrontmatter, PtMoment } from "./types"
27+
export { blogFrontmatterSchema, SLUG_PATTERN, PUBLISH_TIME_PT_PATTERN, MAX_TAGS } from "./types"
28+
29+
// Content loading
30+
export { getAllBlogPosts, getBlogPostBySlug, getPublishedSlugs, BlogContentError } from "./content"
31+
export type { GetAllBlogPostsOptions } from "./content"
32+
33+
// PT timezone helpers
34+
export { getNowPt, parsePublishTimePt, formatPostDatePt } from "./pt-time"
35+
36+
// Publishing helpers
37+
export { isPublished } from "./publishing"
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import type { PtMoment } from "./types"
2+
import { PUBLISH_TIME_PT_PATTERN } from "./types"
3+
4+
/**
5+
* Pacific Time timezone identifier.
6+
*/
7+
const PT_TIMEZONE = "America/Los_Angeles"
8+
9+
/**
10+
* Get the current moment in Pacific Time.
11+
*
12+
* @returns PtMoment with date (YYYY-MM-DD) and minutes since midnight
13+
*
14+
* @example
15+
* ```ts
16+
* const now = getNowPt();
17+
* // { date: '2026-01-29', minutes: 540 } // 9:00am PT
18+
* ```
19+
*/
20+
export function getNowPt(): PtMoment {
21+
const now = new Date()
22+
23+
// Format date as YYYY-MM-DD in PT
24+
const dateFormatter = new Intl.DateTimeFormat("en-CA", {
25+
timeZone: PT_TIMEZONE,
26+
year: "numeric",
27+
month: "2-digit",
28+
day: "2-digit",
29+
})
30+
const date = dateFormatter.format(now)
31+
32+
// Get hours and minutes in PT
33+
const timeFormatter = new Intl.DateTimeFormat("en-US", {
34+
timeZone: PT_TIMEZONE,
35+
hour: "numeric",
36+
minute: "numeric",
37+
hour12: false,
38+
})
39+
const timeParts = timeFormatter.formatToParts(now)
40+
const hour = parseInt(timeParts.find((p) => p.type === "hour")?.value ?? "0", 10)
41+
const minute = parseInt(timeParts.find((p) => p.type === "minute")?.value ?? "0", 10)
42+
const minutes = hour * 60 + minute
43+
44+
return { date, minutes }
45+
}
46+
47+
/**
48+
* Parse a publish_time_pt string (h:mmam/pm) to minutes since midnight.
49+
*
50+
* @param time - Time string in h:mmam/pm format (e.g., "9:00am", "12:30pm")
51+
* @returns Minutes since midnight (0-1439)
52+
* @throws Error if the time format is invalid
53+
*
54+
* @example
55+
* ```ts
56+
* parsePublishTimePt('9:00am'); // 540 (9 * 60)
57+
* parsePublishTimePt('12:30pm'); // 750 (12 * 60 + 30)
58+
* parsePublishTimePt('12:00am'); // 0 (midnight)
59+
* parsePublishTimePt('11:59pm'); // 1439 (23 * 60 + 59)
60+
* ```
61+
*/
62+
export function parsePublishTimePt(time: string): number {
63+
if (!PUBLISH_TIME_PT_PATTERN.test(time)) {
64+
throw new Error(`Invalid publish_time_pt format: "${time}". Must be h:mmam/pm (e.g., "9:00am", "12:30pm")`)
65+
}
66+
67+
// Extract components: "9:00am" -> ["9", "00", "am"]
68+
const match = time.match(/^(\d{1,2}):(\d{2})(am|pm)$/)
69+
if (!match || !match[1] || !match[2] || !match[3]) {
70+
throw new Error(`Failed to parse publish_time_pt: "${time}"`)
71+
}
72+
73+
let hour = parseInt(match[1], 10)
74+
const minute = parseInt(match[2], 10)
75+
const period = match[3]
76+
77+
// Convert 12-hour to 24-hour format
78+
if (period === "am") {
79+
// 12:xxam = 0:xx (midnight hour)
80+
if (hour === 12) {
81+
hour = 0
82+
}
83+
} else {
84+
// pm
85+
// 12:xxpm = 12:xx (noon hour)
86+
// 1:xxpm = 13:xx, etc.
87+
if (hour !== 12) {
88+
hour += 12
89+
}
90+
}
91+
92+
return hour * 60 + minute
93+
}
94+
95+
/**
96+
* Format a publish_date for display.
97+
* Returns the date as-is since it's already in YYYY-MM-DD format.
98+
*
99+
* @param publishDate - Date string in YYYY-MM-DD format
100+
* @returns Formatted date string (YYYY-MM-DD)
101+
*
102+
* @example
103+
* ```ts
104+
* formatPostDatePt('2026-01-29'); // '2026-01-29'
105+
* ```
106+
*/
107+
export function formatPostDatePt(publishDate: string): string {
108+
// The publish_date is already in YYYY-MM-DD format (Pacific Time)
109+
// Per spec, we display date only, no time shown to users
110+
return publishDate
111+
}

0 commit comments

Comments
 (0)