Roo-Code/apps/web-roo-code/src/lib/blog/time.ts
roomote[bot] dc243e4cf9
feat(web): add blog section with initial posts (#11127)
* 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>
2026-02-16 20:37:54 -05:00

158 lines
4.3 KiB
TypeScript

/**
* Pacific Time utilities for blog publishing
* MKT-67: Blog Content Layer
*/
import type { BlogPost, NowPt } from "./types"
/**
* Get the current time in Pacific Time
* Returns date as YYYY-MM-DD and minutes since midnight
*/
export function getNowPt(): NowPt {
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: "America/Los_Angeles",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
const parts = formatter.formatToParts(new Date())
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? ""
const date = `${get("year")}-${get("month")}-${get("day")}`
const minutes = parseInt(get("hour"), 10) * 60 + parseInt(get("minute"), 10)
return { date, minutes }
}
/**
* Parse publish_time_pt string to minutes since midnight
* @param time - Time string in h:mmam/pm format (e.g., "9:00am")
* @returns Minutes since midnight
* @throws Error if format is invalid
*/
export function parsePublishTimePt(time: string): number {
const match = time.match(/^(1[0-2]|[1-9]):([0-5][0-9])(am|pm)$/i)
if (!match) {
throw new Error(`Invalid time format: ${time}. Expected h:mmam/pm (e.g., 9:00am)`)
}
const hoursStr = match[1]
const minsStr = match[2]
const amPm = match[3]
if (!hoursStr || !minsStr || !amPm) {
throw new Error(`Invalid time format: ${time}. Expected h:mmam/pm (e.g., 9:00am)`)
}
let hours = parseInt(hoursStr, 10)
const mins = parseInt(minsStr, 10)
const isPm = amPm.toLowerCase() === "pm"
// Convert 12-hour to 24-hour
if (hours === 12) {
hours = isPm ? 12 : 0
} else if (isPm) {
hours += 12
}
return hours * 60 + mins
}
/**
* Check if a blog post is published based on PT time
* A post is public when:
* - status is "published"
* - AND (now_pt_date > publish_date OR (now_pt_date == publish_date AND now_pt_minutes >= publish_time_pt_minutes))
*/
export function isPublished(post: BlogPost, nowPt: NowPt): boolean {
if (post.status !== "published") {
return false
}
const postMinutes = parsePublishTimePt(post.publish_time_pt)
// Public when: now_pt_date > publish_date
if (nowPt.date > post.publish_date) {
return true
}
// OR (now_pt_date == publish_date AND now_pt_minutes >= publish_time_pt_minutes)
if (nowPt.date === post.publish_date && nowPt.minutes >= postMinutes) {
return true
}
return false
}
/**
* Format publish date for display
* Returns the date as-is in YYYY-MM-DD format
*/
export function formatPostDatePt(publishDate: string): string {
return publishDate
}
/**
* Strip all angle brackets from text to remove any HTML tags or fragments.
* This is used only for word-count purposes in reading-time calculation,
* so a single-pass removal of every `<` and `>` is sufficient and
* avoids the incomplete multi-character sanitization pattern that
* iterative tag-stripping is vulnerable to.
*/
function stripHtmlTags(text: string): string {
return text.replace(/[<>]/g, "")
}
/**
* Calculate reading time for a piece of content
* Uses average reading speed of 200 words per minute
* @param content - The markdown content to calculate reading time for
* @returns Reading time in minutes (minimum 1)
*/
export function calculateReadingTime(content: string): number {
// Strip markdown syntax for more accurate word count
const plainText = stripHtmlTags(
content
// Remove code blocks
.replace(/```[\s\S]*?```/g, "")
// Remove inline code
.replace(/`[^`]+`/g, "")
// Remove images
.replace(/!\[.*?\]\(.*?\)/g, "")
// Remove links but keep text
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
// Remove headers markers
.replace(/^#{1,6}\s+/gm, "")
// Remove emphasis
.replace(/[*_]{1,2}([^*_]+)[*_]{1,2}/g, "$1")
// Remove horizontal rules
.replace(/^[-*_]{3,}\s*$/gm, ""),
)
// Count words (split on whitespace)
const words = plainText
.trim()
.split(/\s+/)
.filter((word) => word.length > 0)
const wordCount = words.length
// Calculate reading time (200 words per minute average)
const readingTime = Math.ceil(wordCount / 200)
// Return minimum of 1 minute
return Math.max(1, readingTime)
}
/**
* Format reading time for display
* @param minutes - Reading time in minutes
* @returns Formatted string (e.g., "5 min read")
*/
export function formatReadingTime(minutes: number): string {
return `${minutes} min read`
}