-
Notifications
You must be signed in to change notification settings - Fork 2.9k
MKT-67: Add blog markdown content pipeline #11016
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from 3 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
b7fb063
feat(blog): add markdown content pipeline with PT scheduling
roomote 2baa447
fix: add blog library to knip ignore list
roomote b741563
Update apps/web-roo-code/src/lib/blog/content.ts
mp-roocode 616a7ed
Update apps/web-roo-code/src/lib/blog/content.ts
mp-roocode 7170595
fix(blog): correct import placement and add missing parsePublishTimeP…
roomote 3ae06b6
Merge branch 'main' into feature/MKT-67-blog-content-pipeline
mp-roocode File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.