Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web-roo-code/content/blog/.gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# This directory contains blog post markdown files.
# See docs/blog.md for the specification.
1 change: 1 addition & 0 deletions apps/web-roo-code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"framer-motion": "12.15.0",
"gray-matter": "^4.0.3",
"lucide-react": "^0.518.0",
"next": "~15.2.8",
"next-themes": "^0.4.6",
Expand Down
211 changes: 211 additions & 0 deletions apps/web-roo-code/src/lib/blog/content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import fs from "fs"
import path from "path"
import matter from "gray-matter"
import { ZodError } from "zod"
import type { BlogPost } from "./types"
import { blogFrontmatterSchema } from "./types"
import { getNowPt } from "./pt-time"
import { isPublished } from "./publishing"

/**
* Path to the blog content directory (relative to project root).
*/
const CONTENT_DIR = "content/blog"

/**
* Get the absolute path to the blog content directory.
*/
function getContentDir(): string {
return path.join(process.cwd(), CONTENT_DIR)
}

/**
* Error thrown when blog content validation fails.
*/
export class BlogContentError extends Error {
constructor(
message: string,
public filename?: string,
) {
super(filename ? `[${filename}] ${message}` : message)
this.name = "BlogContentError"
}
}

/**
* Parse a single markdown file into a BlogPost object.
*
* @param filename - Name of the markdown file (e.g., "my-post.md")
* @returns Parsed BlogPost object
* @throws BlogContentError if frontmatter is invalid
*/
function parseMarkdownFile(filename: string): BlogPost {
const filePath = path.join(getContentDir(), filename)
const fileContent = fs.readFileSync(filePath, "utf8")

// Parse frontmatter using gray-matter
const { data, content } = matter(fileContent)

// Validate frontmatter with zod
try {
const frontmatter = blogFrontmatterSchema.parse(data)

// Verify slug matches filename (without .md extension)
const expectedSlug = filename.replace(/\.md$/, "")
if (frontmatter.slug !== expectedSlug) {
throw new BlogContentError(
`Slug mismatch: frontmatter slug "${frontmatter.slug}" does not match filename "${expectedSlug}"`,
filename,
)
}

return {
...frontmatter,
content,
filename,
}
} catch (error) {
if (error instanceof ZodError) {
const issues = error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n")
throw new BlogContentError(`Invalid frontmatter:\n${issues}`, filename)
}
throw error
}
}

/**
* Load all markdown files from the content directory.
*
* @returns Array of all parsed blog posts (including drafts)
* @throws BlogContentError if any file has invalid frontmatter or duplicate slugs
*/
function loadAllPosts(): BlogPost[] {
const contentDir = getContentDir()

// Check if content directory exists
if (!fs.existsSync(contentDir)) {
return []
}

// Get all .md files
const files = fs.readdirSync(contentDir).filter((file) => file.endsWith(".md"))

// Parse all files
const posts: BlogPost[] = []
const slugToFilename = new Map<string, string>()

for (const filename of files) {
const post = parseMarkdownFile(filename)

// Check for duplicate slugs
const existingFilename = slugToFilename.get(post.slug)
if (existingFilename) {
throw new BlogContentError(
`Duplicate slug "${post.slug}" found in files: "${existingFilename}" and "${filename}"`,
)
}
slugToFilename.set(post.slug, filename)

posts.push(post)
}

return posts
}

/**
* Options for getAllBlogPosts.
*/
export interface GetAllBlogPostsOptions {
/**
* Include draft posts in the results.
* @default false
*/
includeDrafts?: boolean
}

/**
* Get all blog posts, optionally filtered by publish status.
*
* By default, only returns published posts that are past their scheduled
* publish time (evaluated at request time in Pacific Time).
*
* @param options - Options for filtering posts
* @returns Array of blog posts, sorted by publish_date (newest first)
*
* @example
* ```ts
* // Get only published posts (default)
* const posts = getAllBlogPosts();
*
* // Include drafts (e.g., for preview in CMS)
* const allPosts = getAllBlogPosts({ includeDrafts: true });
* ```
*/
export function getAllBlogPosts(options: GetAllBlogPostsOptions = {}): BlogPost[] {
const { includeDrafts = false } = options

const allPosts = loadAllPosts()
const nowPt = getNowPt()

// Filter posts based on publish status
const filteredPosts = includeDrafts ? allPosts : allPosts.filter((post) => isPublished(post, nowPt))

// Sort by publish_date (newest first), then by publish_time_pt
return filteredPosts.sort((a, b) => {
// Compare dates first (descending)
const dateCompare = b.publish_date.localeCompare(a.publish_date)
if (dateCompare !== 0) {
return dateCompare
}
// Same date - compare times (descending)
return parsePublishTimePt(b.publish_time_pt) - parsePublishTimePt(a.publish_time_pt)
})
}

/**
* Get a single blog post by its slug.
*
* Only returns the post if it's published and past its scheduled publish time.
* Draft posts and future-scheduled posts will return null.
*
* @param slug - The URL slug of the post
* @returns The blog post if found and published, null otherwise
*
* @example
* ```ts
* const post = getBlogPostBySlug('my-great-article');
* if (post) {
* // Render the post
* } else {
* // Show 404
* }
* ```
*/
export function getBlogPostBySlug(slug: string): BlogPost | null {
const allPosts = loadAllPosts()
const nowPt = getNowPt()

const post = allPosts.find((p) => p.slug === slug)

// Post not found
if (!post) {
return null
}

// Check if published
if (!isPublished(post, nowPt)) {
return null
}

return post
}

/**
* Get all valid slugs for published posts.
* Useful for generating static paths or sitemaps.
*
* @returns Array of slugs for published posts
*/
export function getPublishedSlugs(): string[] {
return getAllBlogPosts().map((post) => post.slug)
}
37 changes: 37 additions & 0 deletions apps/web-roo-code/src/lib/blog/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Blog content pipeline for roocode.com/blog
*
* This module provides functions to load and manage blog posts from
* markdown files with frontmatter.
*
* @see docs/blog.md for the full specification
*
* @example
* ```ts
* import { getAllBlogPosts, getBlogPostBySlug, formatPostDatePt } from '@/lib/blog';
*
* // Get all published posts
* const posts = getAllBlogPosts();
*
* // Get a specific post
* const post = getBlogPostBySlug('my-article');
*
* // Format date for display
* const displayDate = formatPostDatePt(post.publish_date);
* // "2026-01-29"
* ```
*/

// Types
export type { BlogPost, BlogFrontmatter, PtMoment } from "./types"
export { blogFrontmatterSchema, SLUG_PATTERN, PUBLISH_TIME_PT_PATTERN, MAX_TAGS } from "./types"

// Content loading
export { getAllBlogPosts, getBlogPostBySlug, getPublishedSlugs, BlogContentError } from "./content"
export type { GetAllBlogPostsOptions } from "./content"

// PT timezone helpers
export { getNowPt, parsePublishTimePt, formatPostDatePt } from "./pt-time"

// Publishing helpers
export { isPublished } from "./publishing"
111 changes: 111 additions & 0 deletions apps/web-roo-code/src/lib/blog/pt-time.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import type { PtMoment } from "./types"
import { PUBLISH_TIME_PT_PATTERN } from "./types"

/**
* Pacific Time timezone identifier.
*/
const PT_TIMEZONE = "America/Los_Angeles"

/**
* Get the current moment in Pacific Time.
*
* @returns PtMoment with date (YYYY-MM-DD) and minutes since midnight
*
* @example
* ```ts
* const now = getNowPt();
* // { date: '2026-01-29', minutes: 540 } // 9:00am PT
* ```
*/
export function getNowPt(): PtMoment {
const now = new Date()

// Format date as YYYY-MM-DD in PT
const dateFormatter = new Intl.DateTimeFormat("en-CA", {
timeZone: PT_TIMEZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
})
const date = dateFormatter.format(now)

// Get hours and minutes in PT
const timeFormatter = new Intl.DateTimeFormat("en-US", {
timeZone: PT_TIMEZONE,
hour: "numeric",
minute: "numeric",
hour12: false,
})
const timeParts = timeFormatter.formatToParts(now)
const hour = parseInt(timeParts.find((p) => p.type === "hour")?.value ?? "0", 10)
const minute = parseInt(timeParts.find((p) => p.type === "minute")?.value ?? "0", 10)
const minutes = hour * 60 + minute

return { date, minutes }
}

/**
* Parse a publish_time_pt string (h:mmam/pm) to minutes since midnight.
*
* @param time - Time string in h:mmam/pm format (e.g., "9:00am", "12:30pm")
* @returns Minutes since midnight (0-1439)
* @throws Error if the time format is invalid
*
* @example
* ```ts
* parsePublishTimePt('9:00am'); // 540 (9 * 60)
* parsePublishTimePt('12:30pm'); // 750 (12 * 60 + 30)
* parsePublishTimePt('12:00am'); // 0 (midnight)
* parsePublishTimePt('11:59pm'); // 1439 (23 * 60 + 59)
* ```
*/
export function parsePublishTimePt(time: string): number {
if (!PUBLISH_TIME_PT_PATTERN.test(time)) {
throw new Error(`Invalid publish_time_pt format: "${time}". Must be h:mmam/pm (e.g., "9:00am", "12:30pm")`)
}

// Extract components: "9:00am" -> ["9", "00", "am"]
const match = time.match(/^(\d{1,2}):(\d{2})(am|pm)$/)
if (!match || !match[1] || !match[2] || !match[3]) {
throw new Error(`Failed to parse publish_time_pt: "${time}"`)
}

let hour = parseInt(match[1], 10)
const minute = parseInt(match[2], 10)
const period = match[3]

// Convert 12-hour to 24-hour format
if (period === "am") {
// 12:xxam = 0:xx (midnight hour)
if (hour === 12) {
hour = 0
}
} else {
// pm
// 12:xxpm = 12:xx (noon hour)
// 1:xxpm = 13:xx, etc.
if (hour !== 12) {
hour += 12
}
}

return hour * 60 + minute
}

/**
* Format a publish_date for display.
* Returns the date as-is since it's already in YYYY-MM-DD format.
*
* @param publishDate - Date string in YYYY-MM-DD format
* @returns Formatted date string (YYYY-MM-DD)
*
* @example
* ```ts
* formatPostDatePt('2026-01-29'); // '2026-01-29'
* ```
*/
export function formatPostDatePt(publishDate: string): string {
// The publish_date is already in YYYY-MM-DD format (Pacific Time)
// Per spec, we display date only, no time shown to users
return publishDate
}
Loading
Loading