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
This commit is contained in:
Roo Code 2026-01-27 19:32:33 +00:00
parent 17d3456e96
commit b7fb0638a2
8 changed files with 481 additions and 1 deletions

View file

@ -0,0 +1,2 @@
# This directory contains blog post markdown files.
# See docs/blog.md for the specification.

View file

@ -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",

View file

@ -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 b.publish_time_pt.localeCompare(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)
}

View file

@ -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"

View file

@ -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
}

View file

@ -0,0 +1,48 @@
import type { BlogPost, PtMoment } from "./types"
import { parsePublishTimePt } from "./pt-time"
/**
* Check if a blog post is published and visible.
*
* A post is public when:
* 1. status is "published"
* 2. Current PT date/time is at or past the scheduled publish moment
*
* Publish logic comparison (Pacific Time):
* - now_pt_date > publish_date published
* - now_pt_date === publish_date AND now_pt_minutes >= publish_time_pt_minutes published
* - Otherwise not published
*
* @param post - The blog post to check
* @param nowPt - Current moment in Pacific Time
* @returns true if the post should be publicly visible
*
* @example
* ```ts
* const post = { status: 'published', publish_date: '2026-01-29', publish_time_pt: '9:00am', ... };
* const now = { date: '2026-01-29', minutes: 540 }; // 9:00am
* isPublished(post, now); // true
* ```
*/
export function isPublished(post: BlogPost, nowPt: PtMoment): boolean {
// Draft posts are never visible
if (post.status !== "published") {
return false
}
const publishMinutes = parsePublishTimePt(post.publish_time_pt)
// Compare dates first
if (nowPt.date > post.publish_date) {
// Current date is after publish date - published
return true
}
if (nowPt.date < post.publish_date) {
// Current date is before publish date - not published
return false
}
// Same date - compare minutes
return nowPt.minutes >= publishMinutes
}

View file

@ -0,0 +1,66 @@
import { z } from "zod"
/**
* Regex pattern for valid slugs: lowercase letters, numbers, and hyphens.
* Must not start or end with hyphen, no consecutive hyphens.
* Examples: "hello-world", "post-123", "my-great-article"
*/
export const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
/**
* Regex pattern for publish_time_pt: h:mmam/pm format.
* Examples: "9:00am", "12:30pm", "11:59pm"
*/
export const PUBLISH_TIME_PT_PATTERN = /^(1[0-2]|[1-9]):[0-5][0-9](am|pm)$/
/**
* Maximum number of tags allowed per post.
*/
export const MAX_TAGS = 15
/**
* Zod schema for blog post frontmatter validation.
*/
export const blogFrontmatterSchema = z.object({
title: z.string().min(1, "Title is required"),
slug: z.string().regex(SLUG_PATTERN, {
message: "Slug must be lowercase letters, numbers, and hyphens only (e.g., 'my-post-123')",
}),
description: z.string().min(1, "Description is required"),
tags: z
.array(z.string())
.max(MAX_TAGS, `Maximum ${MAX_TAGS} tags allowed`)
.transform((tags) => tags.map((tag) => tag.toLowerCase().trim())),
status: z.enum(["draft", "published"]),
publish_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, {
message: "publish_date must be in YYYY-MM-DD format",
}),
publish_time_pt: z.string().regex(PUBLISH_TIME_PT_PATTERN, {
message: "publish_time_pt must be in h:mmam/pm format (e.g., '9:00am', '12:30pm')",
}),
})
/**
* Inferred type from the frontmatter schema.
*/
export type BlogFrontmatter = z.infer<typeof blogFrontmatterSchema>
/**
* Full blog post object with parsed content.
*/
export interface BlogPost extends BlogFrontmatter {
/** Raw markdown content (without frontmatter) */
content: string
/** Source filename (for error messages) */
filename: string
}
/**
* Pacific Time moment representation for publish gating.
*/
export interface PtMoment {
/** Date in YYYY-MM-DD format */
date: string
/** Minutes since midnight (0-1439) */
minutes: number
}

6
pnpm-lock.yaml generated
View file

@ -372,6 +372,9 @@ importers:
framer-motion:
specifier: 12.15.0
version: 12.15.0(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
gray-matter:
specifier: ^4.0.3
version: 4.0.3
lucide-react:
specifier: ^0.518.0
version: 0.518.0(react@18.3.1)
@ -9742,6 +9745,7 @@ packages:
tar@7.4.3:
resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
engines: {node: '>=18'}
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me
term-size@2.2.1:
resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==}
@ -14544,7 +14548,7 @@ snapshots:
sirv: 3.0.1
tinyglobby: 0.2.14
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.57)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
'@vitest/utils@3.2.4':
dependencies: