Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions apps/web-roo-code/docs/blog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# Blog Specification for roocode.com/blog

This document captures all decisions for the canonical blog on roocode.com/blog so implementation can proceed without ambiguity.

## Canonical URL

- **Primary:** `https://roocode.com/blog`
- **Substack:** `https://blog.roocode.com` (syndication + subscribe/community)
- Substack posts should link back to canonical roocode.com URLs

## Content Source

- **Location:** `src/content/blog/`
- **Format:** Markdown files (`.md`)
- **Naming Convention:** `{slug}.md` (e.g., `prds-are-becoming-artifacts-of-the-past.md`)

## Frontmatter Schema

All fields are **required** for both draft and published posts.

```yaml
---
title: "Post Title"
slug: "post-slug"
description: "Brief description for SEO and previews"
tags:
- tag1
- tag2
status: "draft" | "published"
publish_date: "YYYY-MM-DD"
publish_time_pt: "h:mmam/pm"
---
```

### Field Details

| Field | Type | Format | Example |
| ----------------- | -------- | ---------------------------- | --------------------------------------------------------- |
| `title` | string | Any text | `"PRDs Are Becoming Artifacts of the Past"` |
| `slug` | string | `^[a-z0-9]+(?:-[a-z0-9]+)*$` | `"prds-are-becoming-artifacts-of-the-past"` |
| `description` | string | Any text | `"The economics of software specification have flipped."` |
| `tags` | string[] | Array of strings (max 15) | `["product-management", "ai"]` |
| `status` | enum | `"draft"` or `"published"` | `"published"` |
| `publish_date` | string | `YYYY-MM-DD` | `"2026-01-12"` |
| `publish_time_pt` | string | `h:mmam/pm` only | `"9:00am"` |

### Time Format Rules

- **Allowed:** `h:mmam/pm` (e.g., `9:00am`, `12:30pm`, `1:00pm`)
- **NOT allowed:** 24-hour format (`HH:mm`), `h:mma` without minutes

### Slug Rules

- Must match regex: `^[a-z0-9]+(?:-[a-z0-9]+)*$`
- Must be unique across all posts
- Duplicate slugs will fail fast with a clear error

## Publish Gating Rules

Publishing is evaluated in **Pacific Time (PT)**.

A post is public when:

```
status = "published"
AND (
now_pt_date > publish_date
OR (now_pt_date == publish_date AND now_pt_minutes >= publish_time_pt_minutes)
)
```

### Scheduling Behavior

- A committed + deployed post becomes visible automatically on/after its scheduled publish moment **without a deploy**
- Adding a brand-new post file still requires merge/deploy
- "No deploy" means the time-gate flips automatically at request-time

## Rendering Strategy

- **Mode:** Dynamic SSR (not static generation)
- **Runtime:** Node.js (required for filesystem reads)
- **Route Config:**
```typescript
export const dynamic = "force-dynamic"
export const runtime = "nodejs"
```
- Do NOT implement `generateStaticParams` (conflicts with request-time gating)

## Display Rules

- UI shows **date only** (no time)
- Format: `Posted YYYY-MM-DD` (e.g., `Posted 2026-01-29`)
- Date is displayed in PT

## Markdown Rendering

- **Allowed:** Standard Markdown + GFM (GitHub Flavored Markdown)
- **NOT allowed:** Raw HTML
- Use `react-markdown` with `remark-gfm` plugin
- Do NOT include `rehype-raw`

## Substack Syndication Checklist

When syndicating to Substack (`blog.roocode.com`):

1. Use shorter excerpts on Substack
2. Add a link back to canonical URL at the top: `Originally published at roocode.com/blog/[slug]`
3. Ensure canonical URL in Substack post settings points to roocode.com

## Containment Rules

Blog changes should be limited to:

1. `/blog` pages (`src/app/blog/`)
2. Blog content layer (`src/lib/blog/`)
3. Blog content (`src/content/blog/`)
4. Minimal glue:
- Navigation links (nav-bar, footer)
- Sitemap configuration
- Analytics events

Do NOT modify:

- Unrelated page components
- Site-wide layout (except adding breadcrumbs/scripts)
- Unrelated route behavior

## Sitemap Behavior

- Sitemap is generated at **build time** via `next-sitemap`
- Newly-unlocked scheduled posts may lag until the next deploy
- This lag is acceptable for MVP
- For real-time sitemap updates, a dynamic sitemap route would be needed (future enhancement)

## File Structure

```
apps/web-roo-code/
├── src/
│ ├── app/
│ │ └── blog/
│ │ ├── page.tsx # Blog index
│ │ └── [slug]/
│ │ └── page.tsx # Blog post page
│ ├── content/
│ │ └── blog/
│ │ └── *.md # Blog post files
│ ├── lib/
│ │ └── blog/
│ │ ├── index.ts # Exports
│ │ ├── types.ts # TypeScript types
│ │ ├── content.ts # Content loading
│ │ ├── time.ts # PT utilities
│ │ ├── validation.ts # Zod schema
│ │ └── analytics.ts # PostHog events
│ └── components/
│ └── blog/
│ └── BlogAnalytics.tsx # Client analytics
└── docs/
└── blog.md # This file
```

## Related Issues

- MKT-66: Blog Spec Document (this file)
- MKT-67: Blog Content Layer
- MKT-68: Blog Index Page
- MKT-69: Blog Post Page
- MKT-70: Blog SEO & Structured Data
- MKT-71: Blog Sitemap
- MKT-72: Blog Navigation Links
- MKT-73: First Published Blog Post
- MKT-74: Blog Analytics (PostHog)
102 changes: 94 additions & 8 deletions apps/web-roo-code/next-sitemap.config.cjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,68 @@
const path = require('path');
const fs = require('fs');
const matter = require('gray-matter');

/**
* Get published blog posts for sitemap
* Note: This runs at build time, so recently-scheduled posts may lag
*/
function getPublishedBlogPosts() {
const BLOG_DIR = path.join(process.cwd(), 'src/content/blog');

if (!fs.existsSync(BLOG_DIR)) {
return [];
}

const files = fs.readdirSync(BLOG_DIR).filter(f => f.endsWith('.md'));
const posts = [];

// Get current time in PT for publish check
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) => parts.find(p => p.type === type)?.value ?? '';
const nowDate = `${get('year')}-${get('month')}-${get('day')}`;
const nowMinutes = parseInt(get('hour')) * 60 + parseInt(get('minute'));

for (const file of files) {
const filepath = path.join(BLOG_DIR, file);
const raw = fs.readFileSync(filepath, 'utf8');
const { data } = matter(raw);

// Check if post is published
if (data.status !== 'published') continue;

// Parse publish time
const timeMatch = data.publish_time_pt?.match(/^(1[0-2]|[1-9]):([0-5][0-9])(am|pm)$/i);
if (!timeMatch) continue;

let hours = parseInt(timeMatch[1]);
const mins = parseInt(timeMatch[2]);
const isPm = timeMatch[3].toLowerCase() === 'pm';
if (hours === 12) hours = isPm ? 12 : 0;
else if (isPm) hours += 12;
const postMinutes = hours * 60 + mins;

// Check if post is past publish date/time
const isPublished = nowDate > data.publish_date ||
(nowDate === data.publish_date && nowMinutes >= postMinutes);

if (isPublished && data.slug) {
posts.push(data.slug);
}
}

return posts;
}

/** @type {import('next-sitemap').IConfig} */
module.exports = {
siteUrl: process.env.NEXT_PUBLIC_SITE_URL || 'https://roocode.com',
Expand Down Expand Up @@ -39,6 +104,12 @@ module.exports = {
} else if (path === '/privacy' || path === '/terms') {
priority = 0.5;
changefreq = 'yearly';
} else if (path === '/blog') {
priority = 0.8;
changefreq = 'weekly';
} else if (path.startsWith('/blog/')) {
priority = 0.7;
changefreq = 'monthly';
}

return {
Expand All @@ -50,24 +121,39 @@ module.exports = {
};
},
additionalPaths: async (config) => {
// Add any additional paths that might not be automatically discovered
// This is useful for dynamic routes or API-generated pages
const result = [];

// Add the /evals page since it's a dynamic route
return [{
result.push({
loc: '/evals',
changefreq: 'monthly',
priority: 0.8,
lastmod: new Date().toISOString(),
}];
});

// Add the /evals page since it's a dynamic route
// Add /blog index
result.push({
loc: '/evals',
changefreq: 'monthly',
loc: '/blog',
changefreq: 'weekly',
priority: 0.8,
lastmod: new Date().toISOString(),
});

// Add published blog posts
try {
const slugs = getPublishedBlogPosts();
for (const slug of slugs) {
result.push({
loc: `/blog/${slug}`,
changefreq: 'monthly',
priority: 0.7,
lastmod: new Date().toISOString(),
});
}
} catch (e) {
console.warn('Could not load blog posts for sitemap:', e.message);
}

return result;
},
};
};
1 change: 1 addition & 0 deletions apps/web-roo-code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.29.2",
"gray-matter": "^4.0.3",
"lucide-react": "^0.563.0",
"next": "^16.1.6",
"next-themes": "^0.4.6",
Expand Down
Loading
Loading