Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 836f480

Browse files
committed
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)
1 parent 946ae80 commit 836f480

21 files changed

Lines changed: 1466 additions & 17 deletions

apps/web-roo-code/docs/blog.md

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
# Blog Specification for roocode.com/blog
2+
3+
This document captures all decisions for the canonical blog on roocode.com/blog so implementation can proceed without ambiguity.
4+
5+
## Canonical URL
6+
7+
- **Primary:** `https://roocode.com/blog`
8+
- **Substack:** `https://blog.roocode.com` (syndication + subscribe/community)
9+
- Substack posts should link back to canonical roocode.com URLs
10+
11+
## Content Source
12+
13+
- **Location:** `src/content/blog/`
14+
- **Format:** Markdown files (`.md`)
15+
- **Naming Convention:** `{slug}.md` (e.g., `prds-are-becoming-artifacts-of-the-past.md`)
16+
17+
## Frontmatter Schema
18+
19+
All fields are **required** for both draft and published posts.
20+
21+
```yaml
22+
---
23+
title: "Post Title"
24+
slug: "post-slug"
25+
description: "Brief description for SEO and previews"
26+
tags:
27+
- tag1
28+
- tag2
29+
status: "draft" | "published"
30+
publish_date: "YYYY-MM-DD"
31+
publish_time_pt: "h:mmam/pm"
32+
---
33+
```
34+
35+
### Field Details
36+
37+
| Field | Type | Format | Example |
38+
| ----------------- | -------- | ---------------------------- | --------------------------------------------------------- |
39+
| `title` | string | Any text | `"PRDs Are Becoming Artifacts of the Past"` |
40+
| `slug` | string | `^[a-z0-9]+(?:-[a-z0-9]+)*$` | `"prds-are-becoming-artifacts-of-the-past"` |
41+
| `description` | string | Any text | `"The economics of software specification have flipped."` |
42+
| `tags` | string[] | Array of strings (max 15) | `["product-management", "ai"]` |
43+
| `status` | enum | `"draft"` or `"published"` | `"published"` |
44+
| `publish_date` | string | `YYYY-MM-DD` | `"2026-01-12"` |
45+
| `publish_time_pt` | string | `h:mmam/pm` only | `"9:00am"` |
46+
47+
### Time Format Rules
48+
49+
- **Allowed:** `h:mmam/pm` (e.g., `9:00am`, `12:30pm`, `1:00pm`)
50+
- **NOT allowed:** 24-hour format (`HH:mm`), `h:mma` without minutes
51+
52+
### Slug Rules
53+
54+
- Must match regex: `^[a-z0-9]+(?:-[a-z0-9]+)*$`
55+
- Must be unique across all posts
56+
- Duplicate slugs will fail fast with a clear error
57+
58+
## Publish Gating Rules
59+
60+
Publishing is evaluated in **Pacific Time (PT)**.
61+
62+
A post is public when:
63+
64+
```
65+
status = "published"
66+
AND (
67+
now_pt_date > publish_date
68+
OR (now_pt_date == publish_date AND now_pt_minutes >= publish_time_pt_minutes)
69+
)
70+
```
71+
72+
### Scheduling Behavior
73+
74+
- A committed + deployed post becomes visible automatically on/after its scheduled publish moment **without a deploy**
75+
- Adding a brand-new post file still requires merge/deploy
76+
- "No deploy" means the time-gate flips automatically at request-time
77+
78+
## Rendering Strategy
79+
80+
- **Mode:** Dynamic SSR (not static generation)
81+
- **Runtime:** Node.js (required for filesystem reads)
82+
- **Route Config:**
83+
```typescript
84+
export const dynamic = "force-dynamic"
85+
export const runtime = "nodejs"
86+
```
87+
- Do NOT implement `generateStaticParams` (conflicts with request-time gating)
88+
89+
## Display Rules
90+
91+
- UI shows **date only** (no time)
92+
- Format: `Posted YYYY-MM-DD` (e.g., `Posted 2026-01-29`)
93+
- Date is displayed in PT
94+
95+
## Markdown Rendering
96+
97+
- **Allowed:** Standard Markdown + GFM (GitHub Flavored Markdown)
98+
- **NOT allowed:** Raw HTML
99+
- Use `react-markdown` with `remark-gfm` plugin
100+
- Do NOT include `rehype-raw`
101+
102+
## Substack Syndication Checklist
103+
104+
When syndicating to Substack (`blog.roocode.com`):
105+
106+
1. Use shorter excerpts on Substack
107+
2. Add a link back to canonical URL at the top: `Originally published at roocode.com/blog/[slug]`
108+
3. Ensure canonical URL in Substack post settings points to roocode.com
109+
110+
## Containment Rules
111+
112+
Blog changes should be limited to:
113+
114+
1. `/blog` pages (`src/app/blog/`)
115+
2. Blog content layer (`src/lib/blog/`)
116+
3. Blog content (`src/content/blog/`)
117+
4. Minimal glue:
118+
- Navigation links (nav-bar, footer)
119+
- Sitemap configuration
120+
- Analytics events
121+
122+
Do NOT modify:
123+
124+
- Unrelated page components
125+
- Site-wide layout (except adding breadcrumbs/scripts)
126+
- Unrelated route behavior
127+
128+
## Sitemap Behavior
129+
130+
- Sitemap is generated at **build time** via `next-sitemap`
131+
- Newly-unlocked scheduled posts may lag until the next deploy
132+
- This lag is acceptable for MVP
133+
- For real-time sitemap updates, a dynamic sitemap route would be needed (future enhancement)
134+
135+
## File Structure
136+
137+
```
138+
apps/web-roo-code/
139+
├── src/
140+
│ ├── app/
141+
│ │ └── blog/
142+
│ │ ├── page.tsx # Blog index
143+
│ │ └── [slug]/
144+
│ │ └── page.tsx # Blog post page
145+
│ ├── content/
146+
│ │ └── blog/
147+
│ │ └── *.md # Blog post files
148+
│ ├── lib/
149+
│ │ └── blog/
150+
│ │ ├── index.ts # Exports
151+
│ │ ├── types.ts # TypeScript types
152+
│ │ ├── content.ts # Content loading
153+
│ │ ├── time.ts # PT utilities
154+
│ │ ├── validation.ts # Zod schema
155+
│ │ └── analytics.ts # PostHog events
156+
│ └── components/
157+
│ └── blog/
158+
│ └── BlogAnalytics.tsx # Client analytics
159+
└── docs/
160+
└── blog.md # This file
161+
```
162+
163+
## Related Issues
164+
165+
- MKT-66: Blog Spec Document (this file)
166+
- MKT-67: Blog Content Layer
167+
- MKT-68: Blog Index Page
168+
- MKT-69: Blog Post Page
169+
- MKT-70: Blog SEO & Structured Data
170+
- MKT-71: Blog Sitemap
171+
- MKT-72: Blog Navigation Links
172+
- MKT-73: First Published Blog Post
173+
- MKT-74: Blog Analytics (PostHog)

apps/web-roo-code/next-sitemap.config.cjs

Lines changed: 94 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,68 @@
1+
const path = require('path');
2+
const fs = require('fs');
3+
const matter = require('gray-matter');
4+
5+
/**
6+
* Get published blog posts for sitemap
7+
* Note: This runs at build time, so recently-scheduled posts may lag
8+
*/
9+
function getPublishedBlogPosts() {
10+
const BLOG_DIR = path.join(process.cwd(), 'src/content/blog');
11+
12+
if (!fs.existsSync(BLOG_DIR)) {
13+
return [];
14+
}
15+
16+
const files = fs.readdirSync(BLOG_DIR).filter(f => f.endsWith('.md'));
17+
const posts = [];
18+
19+
// Get current time in PT for publish check
20+
const formatter = new Intl.DateTimeFormat('en-US', {
21+
timeZone: 'America/Los_Angeles',
22+
year: 'numeric',
23+
month: '2-digit',
24+
day: '2-digit',
25+
hour: '2-digit',
26+
minute: '2-digit',
27+
hour12: false,
28+
});
29+
30+
const parts = formatter.formatToParts(new Date());
31+
const get = (type) => parts.find(p => p.type === type)?.value ?? '';
32+
const nowDate = `${get('year')}-${get('month')}-${get('day')}`;
33+
const nowMinutes = parseInt(get('hour')) * 60 + parseInt(get('minute'));
34+
35+
for (const file of files) {
36+
const filepath = path.join(BLOG_DIR, file);
37+
const raw = fs.readFileSync(filepath, 'utf8');
38+
const { data } = matter(raw);
39+
40+
// Check if post is published
41+
if (data.status !== 'published') continue;
42+
43+
// Parse publish time
44+
const timeMatch = data.publish_time_pt?.match(/^(1[0-2]|[1-9]):([0-5][0-9])(am|pm)$/i);
45+
if (!timeMatch) continue;
46+
47+
let hours = parseInt(timeMatch[1]);
48+
const mins = parseInt(timeMatch[2]);
49+
const isPm = timeMatch[3].toLowerCase() === 'pm';
50+
if (hours === 12) hours = isPm ? 12 : 0;
51+
else if (isPm) hours += 12;
52+
const postMinutes = hours * 60 + mins;
53+
54+
// Check if post is past publish date/time
55+
const isPublished = nowDate > data.publish_date ||
56+
(nowDate === data.publish_date && nowMinutes >= postMinutes);
57+
58+
if (isPublished && data.slug) {
59+
posts.push(data.slug);
60+
}
61+
}
62+
63+
return posts;
64+
}
65+
166
/** @type {import('next-sitemap').IConfig} */
267
module.exports = {
368
siteUrl: process.env.NEXT_PUBLIC_SITE_URL || 'https://roocode.com',
@@ -39,6 +104,12 @@ module.exports = {
39104
} else if (path === '/privacy' || path === '/terms') {
40105
priority = 0.5;
41106
changefreq = 'yearly';
107+
} else if (path === '/blog') {
108+
priority = 0.8;
109+
changefreq = 'weekly';
110+
} else if (path.startsWith('/blog/')) {
111+
priority = 0.7;
112+
changefreq = 'monthly';
42113
}
43114

44115
return {
@@ -50,24 +121,39 @@ module.exports = {
50121
};
51122
},
52123
additionalPaths: async (config) => {
53-
// Add any additional paths that might not be automatically discovered
54-
// This is useful for dynamic routes or API-generated pages
124+
const result = [];
125+
55126
// Add the /evals page since it's a dynamic route
56-
return [{
127+
result.push({
57128
loc: '/evals',
58129
changefreq: 'monthly',
59130
priority: 0.8,
60131
lastmod: new Date().toISOString(),
61-
}];
132+
});
62133

63-
// Add the /evals page since it's a dynamic route
134+
// Add /blog index
64135
result.push({
65-
loc: '/evals',
66-
changefreq: 'monthly',
136+
loc: '/blog',
137+
changefreq: 'weekly',
67138
priority: 0.8,
68139
lastmod: new Date().toISOString(),
69140
});
70141

142+
// Add published blog posts
143+
try {
144+
const slugs = getPublishedBlogPosts();
145+
for (const slug of slugs) {
146+
result.push({
147+
loc: `/blog/${slug}`,
148+
changefreq: 'monthly',
149+
priority: 0.7,
150+
lastmod: new Date().toISOString(),
151+
});
152+
}
153+
} catch (e) {
154+
console.warn('Could not load blog posts for sitemap:', e.message);
155+
}
156+
71157
return result;
72158
},
73-
};
159+
};

apps/web-roo-code/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"embla-carousel-autoplay": "^8.6.0",
2626
"embla-carousel-react": "^8.6.0",
2727
"framer-motion": "^12.29.2",
28+
"gray-matter": "^4.0.3",
2829
"lucide-react": "^0.563.0",
2930
"next": "^16.1.6",
3031
"next-themes": "^0.4.6",

0 commit comments

Comments
 (0)