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 e0c0dd6d857c9b50c18d7b0314693aa22da89413 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 27 Jan 2026 19:36:00 +0000 Subject: [PATCH 2/5] content(blog): add first blog post - PRDs Are Becoming Artifacts of the Past Adds the first canonical blog post to validate the content pipeline: - Title: PRDs Are Becoming Artifacts of the Past - Slug: prds-are-becoming-artifacts-of-the-past - Status: published - Publish date: 2026-01-12 at 9:00am PT Content sourced from Office Hours S01E15 with Paige Bailey. Closes: MKT-73 --- ...prds-are-becoming-artifacts-of-the-past.md | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 apps/web-roo-code/content/blog/prds-are-becoming-artifacts-of-the-past.md diff --git a/apps/web-roo-code/content/blog/prds-are-becoming-artifacts-of-the-past.md b/apps/web-roo-code/content/blog/prds-are-becoming-artifacts-of-the-past.md new file mode 100644 index 00000000000..43f964fb8f5 --- /dev/null +++ b/apps/web-roo-code/content/blog/prds-are-becoming-artifacts-of-the-past.md @@ -0,0 +1,78 @@ +--- +title: "PRDs Are Becoming Artifacts of the Past" +slug: prds-are-becoming-artifacts-of-the-past +description: "The velocity of AI change has outpaced the traditional PRD's shelf life. Teams are shifting to prototype-first workflows where documentation emerges from shipped work rather than preceding it." +tags: + - product-management + - ai-workflows + - engineering-velocity +status: published +publish_date: 2026-01-12 +publish_time_pt: 9:00am +--- + +Forty-seven pages. Three months of stakeholder reviews. One product requirements document. + +By the time it shipped, the model it described was two generations behind. + +## The document that ages out + +You've seen this loop. A PM spends weeks gathering requirements, aligning stakeholders, formatting sections. The PRD becomes a ceremony: cover page, executive summary, user stories, acceptance criteria, and fourteen appendices. + +Then Claude 4 ships. Or the API you were planning around deprecates. Or your team discovers a workflow that makes half the document irrelevant. + +The PRD that was perfect in January is not perfect in February. + +This is what Paige Bailey observed at Google: teams are abandoning the rigorous PRD process not because they've gotten sloppy, but because the velocity of change has outpaced the document's shelf life. + +> "Now things are moving so fast that even if you had a PRD that was perfect as of January, it would not be perfect as of February." +> +> Paige Bailey, [Office Hours S01E15](https://www.youtube.com/watch?v=sAFQIqmDFL4) + +## The shift: prototypes as specification + +The replacement is not "no documentation." The replacement is documentation that emerges from working software. + +Instead of writing a spec and then building, teams build something small and iterate against real feedback. The prototype becomes the specification. The commit history becomes the decision log. The PR comments become the rationale. + +> "For software, it's much more effective to get something out and keep iterating on it really really quickly." +> +> Paige Bailey, [Office Hours S01E15](https://www.youtube.com/watch?v=sAFQIqmDFL4) + +This works because AI tooling has compressed the cost of producing working code. When you can generate a functional prototype in an afternoon, the economics of "plan first, build second" flip. The sunk cost of writing a detailed PRD becomes harder to justify when you could have shipped the first version instead. + +## The tradeoff is real + +Abandoning upfront planning does not mean abandoning coordination. Teams still need to align on scope, surface constraints, and communicate with stakeholders. + +The difference is when that alignment happens. Waterfall-style PRDs front-load alignment before any code exists. Prototype-first workflows back-load alignment: you ship something, learn what breaks, and document the decisions retrospectively. + +This approach has failure modes: + +**Scope creep without anchors.** If there's no initial constraint document, the prototype can drift in directions that no stakeholder wanted. + +**Lost rationale.** If you don't capture why decisions were made, you lose institutional memory. This matters when teammates leave or when you need to revisit a choice six months later. + +**Stakeholder whiplash.** Executives who expect a polished plan before greenlighting work may not trust a "we'll figure it out as we build" pitch. + +The mitigation is lightweight decision records: ADRs, RFC-style docs, or even structured commit messages that capture the why, not just the what. The goal is not zero documentation. The goal is documentation that emerges from shipped work rather than preceding it. + +## Why this matters for your team + +For a Series A team with five engineers, a three-week PRD cycle is a significant tax. That's three weeks where no code ships while stakeholders negotiate requirements that will change anyway. + +If your team is shipping 8-10 PRs a week, the prototype-first model lets you compress the feedback loop. Instead of aligning on a document and then discovering problems in production, you discover problems in the prototype and align on fixes that already work. + +The compounding effect: teams that treat prototypes as the specification ship more iterations per quarter. Teams that cling to waterfall-style documentation lose velocity to teams that iterate against real user feedback. + +> "I also feel like we don't have nearly as rigorous of a process around PRDs. Like PRDs kind of feel like an artifact of the past." +> +> Paige Bailey, [Office Hours S01E15](https://www.youtube.com/watch?v=sAFQIqmDFL4) + +## The decision + +The question is not "should we have documentation?" The question is "when does documentation happen?" + +If your PRDs take longer to write than your prototypes take to ship, the economics have flipped. Start with a working prototype. Capture decisions as you make them. Align stakeholders around something they can touch, not something they have to imagine. + +The artifact that matters is the diff, not the document. From 1bfa919b0c402edb49bd73197c1e205a79dc652d Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 27 Jan 2026 19:40:02 +0000 Subject: [PATCH 3/5] feat(blog): add /blog index page with dynamic rendering - Create blog index page at /blog - Use force-dynamic for request-time publish gating - Require Node.js runtime for filesystem reads - Display posts with title, description, and date - Add empty state when no posts are published - Include tags display (up to 3) Closes: MKT-68 --- apps/web-roo-code/src/app/blog/page.tsx | 112 ++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 apps/web-roo-code/src/app/blog/page.tsx diff --git a/apps/web-roo-code/src/app/blog/page.tsx b/apps/web-roo-code/src/app/blog/page.tsx new file mode 100644 index 00000000000..318d47a6403 --- /dev/null +++ b/apps/web-roo-code/src/app/blog/page.tsx @@ -0,0 +1,112 @@ +import type { Metadata } from "next" +import Link from "next/link" +import { SEO } from "@/lib/seo" +import { ogImageUrl } from "@/lib/og" +import { getAllBlogPosts, formatPostDatePt } from "@/lib/blog" + +// Force dynamic rendering to evaluate publish gating at request-time +export const dynamic = "force-dynamic" + +// Require Node.js runtime for filesystem reads +export const runtime = "nodejs" + +const TITLE = "Blog" +const DESCRIPTION = + "Insights on AI-powered software development, engineering workflows, and the future of coding with Roo Code." +const PATH = "/blog" + +export const metadata: Metadata = { + title: TITLE, + description: DESCRIPTION, + alternates: { + canonical: `${SEO.url}${PATH}`, + }, + openGraph: { + title: TITLE, + description: DESCRIPTION, + url: `${SEO.url}${PATH}`, + siteName: SEO.name, + images: [ + { + url: ogImageUrl(TITLE, DESCRIPTION), + width: 1200, + height: 630, + alt: TITLE, + }, + ], + locale: SEO.locale, + type: "website", + }, + twitter: { + card: SEO.twitterCard, + title: TITLE, + description: DESCRIPTION, + images: [ogImageUrl(TITLE, DESCRIPTION)], + }, + keywords: [...SEO.keywords, "blog", "engineering blog", "AI development"], +} + +export default function BlogIndex() { + // Get all published posts (sorted newest first) + const posts = getAllBlogPosts() + + return ( +
+
+ {/* Page Header */} +
+

Blog

+

{DESCRIPTION}

+
+ + {/* Posts List */} + {posts.length === 0 ? ( + + ) : ( +
+ {posts.map((post) => ( +
+ +

+ {post.title} +

+

{post.description}

+
+ + {post.tags.length > 0 && ( + <> + +
+ {post.tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} +
+ + )} +
+ +
+ ))} +
+ )} +
+
+ ) +} + +function EmptyState() { + return ( +
+

No posts yet

+

Check back soon for new content.

+
+ ) +} From aa4c2d921ffa6088f961078ee8494346ff674695 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 27 Jan 2026 19:43:12 +0000 Subject: [PATCH 4/5] feat(blog): add /blog/[slug] post page with dynamic rendering - Create dynamic route for individual blog posts - Use force-dynamic for request-time publish gating - Require Node.js runtime for filesystem reads - Return 404 for drafts, future posts, or invalid slugs - Render Markdown with react-markdown + remark-gfm - No raw HTML rendering (safe by default) - Include consistent typography and styling Closes: MKT-69 --- .../web-roo-code/src/app/blog/[slug]/page.tsx | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 apps/web-roo-code/src/app/blog/[slug]/page.tsx diff --git a/apps/web-roo-code/src/app/blog/[slug]/page.tsx b/apps/web-roo-code/src/app/blog/[slug]/page.tsx new file mode 100644 index 00000000000..82645a5cc97 --- /dev/null +++ b/apps/web-roo-code/src/app/blog/[slug]/page.tsx @@ -0,0 +1,173 @@ +import type { Metadata } from "next" +import { notFound } from "next/navigation" +import Link from "next/link" +import ReactMarkdown from "react-markdown" +import remarkGfm from "remark-gfm" +import { ArrowLeft } from "lucide-react" +import { SEO } from "@/lib/seo" +import { ogImageUrl } from "@/lib/og" +import { getBlogPostBySlug, formatPostDatePt } from "@/lib/blog" + +// Force dynamic rendering to evaluate publish gating at request-time +export const dynamic = "force-dynamic" + +// Require Node.js runtime for filesystem reads +export const runtime = "nodejs" + +interface BlogPostPageProps { + params: Promise<{ slug: string }> +} + +export async function generateMetadata({ params }: BlogPostPageProps): Promise { + const { slug } = await params + const post = getBlogPostBySlug(slug) + + if (!post) { + return { + title: "Post Not Found", + } + } + + const PATH = `/blog/${post.slug}` + + return { + title: post.title, + description: post.description, + alternates: { + canonical: `${SEO.url}${PATH}`, + }, + openGraph: { + title: post.title, + description: post.description, + url: `${SEO.url}${PATH}`, + siteName: SEO.name, + images: [ + { + url: ogImageUrl(post.title, post.description), + width: 1200, + height: 630, + alt: post.title, + }, + ], + locale: SEO.locale, + type: "article", + publishedTime: post.publish_date, + }, + twitter: { + card: SEO.twitterCard, + title: post.title, + description: post.description, + images: [ogImageUrl(post.title, post.description)], + }, + keywords: [...SEO.keywords, ...post.tags], + } +} + +export default async function BlogPostPage({ params }: BlogPostPageProps) { + const { slug } = await params + const post = getBlogPostBySlug(slug) + + // Return 404 if post not found or not published + if (!post) { + notFound() + } + + return ( +
+
+ {/* Back link */} + + + Back to Blog + + + {/* Post Header */} +
+

{post.title}

+
+ + {post.tags.length > 0 && ( + <> + +
+ {post.tags.map((tag) => ( + + {tag} + + ))} +
+ + )} +
+
+ + {/* Post Content */} + + + {/* Footer */} +
+ + + Back to Blog + +
+
+
+ ) +} From 363a46dd2e4e189e814b1d296b10cc76706c2834 Mon Sep 17 00:00:00 2001 From: MP Date: Tue, 27 Jan 2026 16:57:39 -0800 Subject: [PATCH 5/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 | 3 +-- 1 file changed, 1 insertion(+), 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 8d80b4d29ca..79c90d64bc3 100644 --- a/apps/web-roo-code/src/lib/blog/content.ts +++ b/apps/web-roo-code/src/lib/blog/content.ts @@ -157,8 +157,7 @@ export function getAllBlogPosts(options: GetAllBlogPostsOptions = {}): BlogPost[ if (dateCompare !== 0) { 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) }) }