mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-25 01:01:18 +00:00
* feat(web): add blog section with 4 initial posts Implements MKT-66 through MKT-74: Content Layer (MKT-67): - Markdown files in src/content/blog with Zod-validated frontmatter - Pacific Time scheduling evaluated at request-time (no deploy needed) - gray-matter for parsing, react-markdown + remark-gfm for rendering Blog Pages (MKT-68, MKT-69): - Index page at /blog with dynamic SSR - Post page at /blog/[slug] with dynamic SSR - Breadcrumb navigation and prev/next post navigation SEO (MKT-70): - Full OpenGraph and Twitter card metadata - Schema.org JSON-LD (Article, BreadcrumbList, CollectionPage) - Canonical URLs pointing to roocode.com/blog Analytics (MKT-74): - PostHog blog_post_viewed and blog_index_viewed events - Referrer tracking for attribution Navigation (MKT-72): - Updated nav-bar and footer to link to internal /blog - Blog link in Resources dropdown Sitemap (MKT-71): - Dynamic blog paths with PT scheduling check Initial Posts: - PRDs Are Becoming Artifacts of the Past (Jan 12) - Code Review Got Faster, Not Easier (Jan 19) - Vibe Coders Build and Rebuild (Jan 26) - Async Agents Change the Speed vs Quality Calculus (Feb 2) * fix(test): update HistoryPreview tests to match refactored component The HistoryPreview component was refactored to use useGroupedTasks and TaskGroupItem instead of rendering TaskItem directly. This updates the test file to properly mock the new dependencies: - Mock useGroupedTasks hook to provide grouped task data - Mock TaskGroupItem instead of TaskItem - Update assertions to test for task groups instead of individual tasks * feat(blog): add Vercel-inspired patterns and Tone of Voice alignment - Add reading time display to blog posts - Create BlogPostCTA component with 4 variants (default, extension, cloud, enterprise) - Add zebra striping to tables in blog posts - Add CTA to blog landing and paginated pages - Remove 'Posted' prefix from dates - Update blog description: 'How teams use agents to iterate, review, and ship PRs with proof' - Add BlogPostList and BlogPagination components - Add 100+ new blog posts from content pipeline * feat(blog): add source badges for podcast content (Office Hours, After Hours, Roo Cast) - Add BlogSource type to types.ts - Export BlogSource from blog index - Add SourceBadge component to BlogPostList with colored badges - Each podcast has distinct color: blue (Office Hours), purple (After Hours), emerald (Roo Cast) * feat(blog): add source field to all blog posts (Roo Cast, Office Hours, After Hours) - Add add-blog-sources.ts script to build title→source mapping - Updated 122 blog posts with correct podcast sources - Sources: Roo Cast (52), Office Hours (62), After Hours (8) * feat(blog): add source badges with consistent styling - Add source field to Zod validation schema - Source badges use same styling as tag badges (rounded, greyscale) - Badges display on /blog landing page for Office Hours, After Hours, Roo Cast * feat(blog): improve schema.org structured data for SEO - Change @type from Article to BlogPosting (more specific) - Add image property using OG image URL - Add wordCount for AEO optimization * feat(blog): timestamped YouTube quotes + attribution polish * chore(blog): update 'Series A team' to 'Series A - C team' and fix 'Tovin' to 'Tovan' - Changed 22 instances of 'Series A team' to 'Series A - C team' across 20 blog posts - Changed 12 instances of 'Tovin' to 'Tovan' across 4 blog posts This broadens the messaging to better represent teams that Roo Code serves (Series A through C). * ci: retry CI after timeout * blog: featured posts + copy edits * blog: remove draft posts from web content * fix(blog): loop HTML tag stripping to prevent incomplete sanitization The single-pass .replace(/<[^>]+>/g, "") in calculateReadingTime() was flagged by CodeQL as vulnerable to incomplete multi-character sanitization. Input like "<scr<script>ipt>" would still contain "<script" after one pass. Added a stripHtmlTags() helper that loops the replacement until stable, plus a final pass to remove any remaining angle brackets. * fix(blog): replace iterative HTML tag stripping with single-pass angle bracket removal The CodeQL scanner flagged the iterative stripHtmlTags function for incomplete multi-character sanitization. The regex /<[^>]+>/g only matches complete tags, so partial fragments like <script (without a closing >) could survive intermediate loop iterations. Since this function is only used for word counting in calculateReadingTime, replace the multi-step approach with a simple single-pass removal of all < and > characters. This eliminates the incomplete sanitization pattern entirely. --------- Co-authored-by: Roo Code <roomote@roocode.com> Co-authored-by: Michael Preuss <michael@roocode.com>
205 lines
5.6 KiB
TypeScript
205 lines
5.6 KiB
TypeScript
/**
|
|
* Blog content loading from Markdown files
|
|
* MKT-67: Blog Content Layer
|
|
*/
|
|
|
|
import fs from "fs"
|
|
import path from "path"
|
|
import matter from "gray-matter"
|
|
import { BlogFrontmatterSchema } from "./validation"
|
|
import type { BlogPost } from "./types"
|
|
import { getNowPt, isPublished, parsePublishTimePt } from "./time"
|
|
import { filterFeaturedPosts } from "./curated"
|
|
|
|
const BLOG_DIR = path.join(process.cwd(), "src/content/blog")
|
|
|
|
/** Posts per page for pagination */
|
|
export const POSTS_PER_PAGE = 12
|
|
|
|
/** Pagination result type */
|
|
export interface PaginatedBlogPosts {
|
|
posts: BlogPost[]
|
|
currentPage: number
|
|
totalPages: number
|
|
totalPosts: number
|
|
hasNextPage: boolean
|
|
hasPreviousPage: boolean
|
|
}
|
|
|
|
/**
|
|
* Get all blog posts from the content directory
|
|
* @param options.includeDrafts - If true, include draft and future posts
|
|
* @returns Array of blog posts sorted by publish date (newest first)
|
|
*/
|
|
export function getAllBlogPosts(options?: { includeDrafts?: boolean }): BlogPost[] {
|
|
const nowPt = getNowPt()
|
|
|
|
// Ensure blog directory exists
|
|
if (!fs.existsSync(BLOG_DIR)) {
|
|
return []
|
|
}
|
|
|
|
const files = fs.readdirSync(BLOG_DIR).filter((f) => f.endsWith(".md"))
|
|
|
|
const posts: BlogPost[] = []
|
|
const slugs = new Map<string, string>() // slug -> filepath for duplicate detection
|
|
|
|
for (const file of files) {
|
|
const filepath = path.join(BLOG_DIR, file)
|
|
const raw = fs.readFileSync(filepath, "utf8")
|
|
const { data, content } = matter(raw)
|
|
|
|
// Validate frontmatter
|
|
const result = BlogFrontmatterSchema.safeParse(data)
|
|
if (!result.success) {
|
|
const errors = result.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join(", ")
|
|
throw new Error(`Invalid frontmatter in ${file}: ${errors}`)
|
|
}
|
|
|
|
const frontmatter = result.data
|
|
|
|
// Check for duplicate slugs
|
|
if (slugs.has(frontmatter.slug)) {
|
|
throw new Error(`Duplicate slug "${frontmatter.slug}" found in ${file} and ${slugs.get(frontmatter.slug)}`)
|
|
}
|
|
slugs.set(frontmatter.slug, file)
|
|
|
|
const post: BlogPost = {
|
|
...frontmatter,
|
|
content,
|
|
filepath: file,
|
|
}
|
|
|
|
// Filter based on options
|
|
if (options?.includeDrafts) {
|
|
posts.push(post)
|
|
} else if (isPublished(post, nowPt)) {
|
|
posts.push(post)
|
|
}
|
|
}
|
|
|
|
// Sort by publish_date desc, then publish_time_pt desc
|
|
return posts.sort((a, b) => {
|
|
if (a.publish_date !== b.publish_date) {
|
|
return b.publish_date.localeCompare(a.publish_date)
|
|
}
|
|
const aMinutes = parsePublishTimePt(a.publish_time_pt)
|
|
const bMinutes = parsePublishTimePt(b.publish_time_pt)
|
|
return bMinutes - aMinutes
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Get paginated blog posts
|
|
* @param page - Page number (1-indexed)
|
|
* @param options.includeDrafts - If true, include draft and future posts
|
|
* @returns Paginated result with posts and pagination metadata
|
|
*/
|
|
export function getPaginatedBlogPosts(page: number = 1, options?: { includeDrafts?: boolean }): PaginatedBlogPosts {
|
|
const allPosts = getAllBlogPosts(options)
|
|
const totalPosts = allPosts.length
|
|
const totalPages = Math.ceil(totalPosts / POSTS_PER_PAGE)
|
|
|
|
// Clamp page to valid range
|
|
const currentPage = Math.max(1, Math.min(page, totalPages || 1))
|
|
|
|
const startIndex = (currentPage - 1) * POSTS_PER_PAGE
|
|
const endIndex = startIndex + POSTS_PER_PAGE
|
|
const posts = allPosts.slice(startIndex, endIndex)
|
|
|
|
return {
|
|
posts,
|
|
currentPage,
|
|
totalPages,
|
|
totalPosts,
|
|
hasNextPage: currentPage < totalPages,
|
|
hasPreviousPage: currentPage > 1,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a single blog post by slug
|
|
* Returns null if not found or not published
|
|
* @param slug - The post slug
|
|
* @returns The blog post or null
|
|
*/
|
|
export function getBlogPostBySlug(slug: string): BlogPost | null {
|
|
const nowPt = getNowPt()
|
|
|
|
// Ensure blog directory exists
|
|
if (!fs.existsSync(BLOG_DIR)) {
|
|
return null
|
|
}
|
|
|
|
const files = fs.readdirSync(BLOG_DIR).filter((f) => f.endsWith(".md"))
|
|
|
|
for (const file of files) {
|
|
const filepath = path.join(BLOG_DIR, file)
|
|
const raw = fs.readFileSync(filepath, "utf8")
|
|
const { data, content } = matter(raw)
|
|
|
|
// Validate frontmatter
|
|
const result = BlogFrontmatterSchema.safeParse(data)
|
|
if (!result.success) {
|
|
continue // Skip invalid posts when looking up by slug
|
|
}
|
|
|
|
const frontmatter = result.data
|
|
|
|
if (frontmatter.slug === slug) {
|
|
const post: BlogPost = {
|
|
...frontmatter,
|
|
content,
|
|
filepath: file,
|
|
}
|
|
|
|
// Only return if published
|
|
if (isPublished(post, nowPt)) {
|
|
return post
|
|
}
|
|
|
|
// Post exists but is not published (draft or scheduled)
|
|
return null
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Get adjacent posts (previous and next) for navigation
|
|
* Posts are ordered newest-first, so:
|
|
* - "previous" = newer post (earlier in the array)
|
|
* - "next" = older post (later in the array)
|
|
* @param slug - The current post's slug
|
|
* @returns Object with previous and next posts (or null if they don't exist)
|
|
*/
|
|
export function getAdjacentPosts(slug: string): { previous: BlogPost | null; next: BlogPost | null } {
|
|
const posts = getAllBlogPosts()
|
|
const currentIndex = posts.findIndex((p) => p.slug === slug)
|
|
|
|
if (currentIndex === -1) {
|
|
return { previous: null, next: null }
|
|
}
|
|
|
|
// Posts are sorted newest-first
|
|
// "previous" = newer post (index - 1)
|
|
// "next" = older post (index + 1)
|
|
const previous = currentIndex > 0 ? (posts[currentIndex - 1] ?? null) : null
|
|
const next = currentIndex < posts.length - 1 ? (posts[currentIndex + 1] ?? null) : null
|
|
|
|
return { previous, next }
|
|
}
|
|
|
|
/**
|
|
* Get featured blog posts
|
|
*
|
|
* Returns published posts with `featured: true` in frontmatter,
|
|
* sorted by publish_date (newest first).
|
|
*
|
|
* @returns Array of featured blog posts
|
|
*/
|
|
export function getCuratedBlogPosts(): BlogPost[] {
|
|
const allPosts = getAllBlogPosts()
|
|
return filterFeaturedPosts(allPosts)
|
|
}
|