From b7fb0638a28f175e3887429926151bfe7184157a Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 27 Jan 2026 19:32:33 +0000 Subject: [PATCH 1/5] 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 --- apps/web-roo-code/content/blog/.gitkeep | 2 + apps/web-roo-code/package.json | 1 + apps/web-roo-code/src/lib/blog/content.ts | 211 +++++++++++++++++++ apps/web-roo-code/src/lib/blog/index.ts | 37 ++++ apps/web-roo-code/src/lib/blog/pt-time.ts | 111 ++++++++++ apps/web-roo-code/src/lib/blog/publishing.ts | 48 +++++ apps/web-roo-code/src/lib/blog/types.ts | 66 ++++++ pnpm-lock.yaml | 6 +- 8 files changed, 481 insertions(+), 1 deletion(-) create mode 100644 apps/web-roo-code/content/blog/.gitkeep create mode 100644 apps/web-roo-code/src/lib/blog/content.ts create mode 100644 apps/web-roo-code/src/lib/blog/index.ts create mode 100644 apps/web-roo-code/src/lib/blog/pt-time.ts create mode 100644 apps/web-roo-code/src/lib/blog/publishing.ts create mode 100644 apps/web-roo-code/src/lib/blog/types.ts diff --git a/apps/web-roo-code/content/blog/.gitkeep b/apps/web-roo-code/content/blog/.gitkeep new file mode 100644 index 00000000000..70f0a800372 --- /dev/null +++ b/apps/web-roo-code/content/blog/.gitkeep @@ -0,0 +1,2 @@ +# This directory contains blog post markdown files. +# See docs/blog.md for the specification. diff --git a/apps/web-roo-code/package.json b/apps/web-roo-code/package.json index d82cad56ab8..30a33cd377f 100644 --- a/apps/web-roo-code/package.json +++ b/apps/web-roo-code/package.json @@ -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", diff --git a/apps/web-roo-code/src/lib/blog/content.ts b/apps/web-roo-code/src/lib/blog/content.ts new file mode 100644 index 00000000000..8d80b4d29ca --- /dev/null +++ b/apps/web-roo-code/src/lib/blog/content.ts @@ -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() + + 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) +} diff --git a/apps/web-roo-code/src/lib/blog/index.ts b/apps/web-roo-code/src/lib/blog/index.ts new file mode 100644 index 00000000000..f1febcd42d7 --- /dev/null +++ b/apps/web-roo-code/src/lib/blog/index.ts @@ -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" diff --git a/apps/web-roo-code/src/lib/blog/pt-time.ts b/apps/web-roo-code/src/lib/blog/pt-time.ts new file mode 100644 index 00000000000..bfb3f9bd0ab --- /dev/null +++ b/apps/web-roo-code/src/lib/blog/pt-time.ts @@ -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 +} diff --git a/apps/web-roo-code/src/lib/blog/publishing.ts b/apps/web-roo-code/src/lib/blog/publishing.ts new file mode 100644 index 00000000000..d67685e89bb --- /dev/null +++ b/apps/web-roo-code/src/lib/blog/publishing.ts @@ -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 +} diff --git a/apps/web-roo-code/src/lib/blog/types.ts b/apps/web-roo-code/src/lib/blog/types.ts new file mode 100644 index 00000000000..be1fbddb8ad --- /dev/null +++ b/apps/web-roo-code/src/lib/blog/types.ts @@ -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 + +/** + * 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 +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 944c4918897..64a1e139474 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: From 2baa447331cc1df2eb59596018d56d77abf31cad Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 27 Jan 2026 20:15:32 +0000 Subject: [PATCH 2/5] fix: add blog library to knip ignore list --- knip.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/knip.json b/knip.json index e15c62bda1b..09e277bee36 100644 --- a/knip.json +++ b/knip.json @@ -8,7 +8,8 @@ "src/workers/countTokens.ts", "src/extension.ts", "scripts/**", - "apps/web-roo-code/next-sitemap.config.cjs" + "apps/web-roo-code/next-sitemap.config.cjs", + "apps/web-roo-code/src/lib/blog/**" ], "workspaces": { "src": { From b741563582065a0933e9dcea85139bdb42e12a93 Mon Sep 17 00:00:00 2001 From: MP Date: Tue, 27 Jan 2026 16:58:31 -0800 Subject: [PATCH 3/5] Update apps/web-roo-code/src/lib/blog/content.ts Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com> --- apps/web-roo-code/src/lib/blog/content.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web-roo-code/src/lib/blog/content.ts b/apps/web-roo-code/src/lib/blog/content.ts index 8d80b4d29ca..125f2a5da6f 100644 --- a/apps/web-roo-code/src/lib/blog/content.ts +++ b/apps/web-roo-code/src/lib/blog/content.ts @@ -158,7 +158,7 @@ export function getAllBlogPosts(options: GetAllBlogPostsOptions = {}): BlogPost[ return dateCompare } // Same date - compare times (descending) - return b.publish_time_pt.localeCompare(a.publish_time_pt) + return parsePublishTimePt(b.publish_time_pt) - parsePublishTimePt(a.publish_time_pt) }) } From 616a7ed6c6d26598344098c9423b13906d819158 Mon Sep 17 00:00:00 2001 From: MP Date: Tue, 27 Jan 2026 17:08:35 -0800 Subject: [PATCH 4/5] Update apps/web-roo-code/src/lib/blog/content.ts Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com> --- apps/web-roo-code/src/lib/blog/content.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web-roo-code/src/lib/blog/content.ts b/apps/web-roo-code/src/lib/blog/content.ts index 125f2a5da6f..76753a59bac 100644 --- a/apps/web-roo-code/src/lib/blog/content.ts +++ b/apps/web-roo-code/src/lib/blog/content.ts @@ -158,7 +158,7 @@ export function getAllBlogPosts(options: GetAllBlogPostsOptions = {}): BlogPost[ return dateCompare } // Same date - compare times (descending) - return parsePublishTimePt(b.publish_time_pt) - parsePublishTimePt(a.publish_time_pt) +import { getNowPt, parsePublishTimePt } from "./pt-time" }) } From 7170595893214e0c86e45dfce0ecc7672ef1111b Mon Sep 17 00:00:00 2001 From: Roo Code Date: Wed, 28 Jan 2026 21:14:17 +0000 Subject: [PATCH 5/5] fix(blog): correct import placement and add missing parsePublishTimePt import --- apps/web-roo-code/src/lib/blog/content.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web-roo-code/src/lib/blog/content.ts b/apps/web-roo-code/src/lib/blog/content.ts index 76753a59bac..bb264ec355e 100644 --- a/apps/web-roo-code/src/lib/blog/content.ts +++ b/apps/web-roo-code/src/lib/blog/content.ts @@ -4,7 +4,7 @@ import matter from "gray-matter" import { ZodError } from "zod" import type { BlogPost } from "./types" import { blogFrontmatterSchema } from "./types" -import { getNowPt } from "./pt-time" +import { getNowPt, parsePublishTimePt } from "./pt-time" import { isPublished } from "./publishing" /** @@ -158,7 +158,7 @@ export function getAllBlogPosts(options: GetAllBlogPostsOptions = {}): BlogPost[ return dateCompare } // Same date - compare times (descending) -import { getNowPt, parsePublishTimePt } from "./pt-time" + return parsePublishTimePt(b.publish_time_pt) - parsePublishTimePt(a.publish_time_pt) }) }