Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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, parsePublishTimePt } 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