diff --git a/apps/web-roo-code/next-sitemap.config.cjs b/apps/web-roo-code/next-sitemap.config.cjs index e9b0ca3c473..e2b1e47e2c5 100644 --- a/apps/web-roo-code/next-sitemap.config.cjs +++ b/apps/web-roo-code/next-sitemap.config.cjs @@ -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', @@ -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 { @@ -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; }, -}; \ No newline at end of file +}; diff --git a/apps/web-roo-code/package.json b/apps/web-roo-code/package.json index 90b6e9e3069..8ad1a950517 100644 --- a/apps/web-roo-code/package.json +++ b/apps/web-roo-code/package.json @@ -9,7 +9,9 @@ "build": "next build", "postbuild": "next-sitemap --config next-sitemap.config.cjs", "start": "next start", - "clean": "rimraf .next .turbo" + "clean": "rimraf .next .turbo", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@radix-ui/react-dialog": "^1.1.15", @@ -25,6 +27,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", @@ -52,6 +55,7 @@ "autoprefixer": "^10.4.23", "next-sitemap": "^4.2.3", "postcss": "^8.5.6", - "tailwindcss": "^3.4.17" + "tailwindcss": "^3.4.17", + "vitest": "^4.0.18" } } diff --git a/apps/web-roo-code/src/app/blog/[slug]/page.tsx b/apps/web-roo-code/src/app/blog/[slug]/page.tsx new file mode 100644 index 00000000000..985ee95b2b4 --- /dev/null +++ b/apps/web-roo-code/src/app/blog/[slug]/page.tsx @@ -0,0 +1,337 @@ +/** + * Blog Post Page + * MKT-69: Blog Post Page + * + * Renders a single blog post from Markdown. + * Uses dynamic rendering (force-dynamic) for request-time publish gating. + * Does NOT use generateStaticParams to avoid static generation. + * + * AEO Enhancement: Parses FAQ sections from markdown, renders as accordion, + * and generates FAQPage JSON-LD schema for AI search optimization. + */ + +import type { Metadata } from "next" +import Link from "next/link" +import { notFound } from "next/navigation" +import Script from "next/script" +import { ChevronLeft, ChevronRight, Clock } from "lucide-react" +import { + getBlogPostBySlug, + getAdjacentPosts, + formatPostDatePt, + calculateReadingTime, + formatReadingTime, +} from "@/lib/blog" +import { SEO } from "@/lib/seo" +import { ogImageUrl } from "@/lib/og" +import { BlogPostAnalytics } from "@/components/blog/BlogAnalytics" +import { BlogContent } from "@/components/blog/BlogContent" +import { BlogFAQ, type FAQItem } from "@/components/blog/BlogFAQ" +import { BlogPostCTA } from "@/components/blog/BlogPostCTA" + +// Force dynamic rendering for request-time publish gating +export const dynamic = "force-dynamic" +export const runtime = "nodejs" + +interface Props { + params: Promise<{ slug: string }> +} + +/** + * Parse FAQ section from markdown content + * + * Looks for a section starting with "## Frequently asked questions" + * and extracts H3 questions with their content as answers. + * + * Returns the FAQ items and the content with FAQ section removed. + */ +function parseFAQFromMarkdown(content: string): { + faqItems: FAQItem[] + contentWithoutFAQ: string +} { + // Match FAQ section: ## Frequently asked questions (case-insensitive) + const faqSectionRegex = /^## Frequently asked questions\s*$/im + const faqMatch = content.match(faqSectionRegex) + + if (!faqMatch || faqMatch.index === undefined) { + return { faqItems: [], contentWithoutFAQ: content } + } + + const faqStartIndex = faqMatch.index + const beforeFAQ = content.slice(0, faqStartIndex).trim() + const faqSection = content.slice(faqStartIndex) + + // Find where FAQ section ends (next H2 or end of content) + const nextH2Match = faqSection.slice(faqMatch[0].length).match(/^## /m) + const faqContent = + nextH2Match && nextH2Match.index !== undefined + ? faqSection.slice(0, faqMatch[0].length + nextH2Match.index) + : faqSection + + const afterFAQ = + nextH2Match && nextH2Match.index !== undefined ? faqSection.slice(faqMatch[0].length + nextH2Match.index) : "" + + // Parse individual FAQ items (### Question followed by content) + const faqItems: FAQItem[] = [] + const questionRegex = /^### (.+?)$\s*([\s\S]*?)(?=^### |$(?![\s\S]))/gm + let match + + while ((match = questionRegex.exec(faqContent)) !== null) { + const question = match[1]?.trim() + const answer = match[2]?.trim() + if (question && answer) { + faqItems.push({ question, answer }) + } + } + + const contentWithoutFAQ = (beforeFAQ + "\n\n" + afterFAQ).trim() + + return { faqItems, contentWithoutFAQ } +} + +export async function generateMetadata({ params }: Props): Promise { + const { slug } = await params + const post = getBlogPostBySlug(slug) + + if (!post) { + return {} + } + + const path = `/blog/${post.slug}` + + return { + title: post.title, + description: post.description, + alternates: { + canonical: `${SEO.url}${path}`, + }, + openGraph: { + title: post.title, + description: post.description, + url: `${SEO.url}${path}`, + siteName: SEO.name, + images: [ + { + url: ogImageUrl(post.title, post.description), + width: 1200, + height: 630, + alt: post.title, + }, + ], + locale: SEO.locale, + type: "article", + publishedTime: post.publish_date, + }, + twitter: { + card: SEO.twitterCard, + title: post.title, + description: post.description, + images: [ogImageUrl(post.title, post.description)], + }, + keywords: [...SEO.keywords, ...post.tags], + } +} + +export default async function BlogPostPage({ params }: Props) { + const { slug } = await params + const post = getBlogPostBySlug(slug) + + if (!post) { + notFound() + } + + const { previous, next } = getAdjacentPosts(slug) + + // Calculate reading time + const readingTime = calculateReadingTime(post.content) + const readingTimeDisplay = formatReadingTime(readingTime) + + // Parse FAQ section from markdown content + const { faqItems, contentWithoutFAQ } = parseFAQFromMarkdown(post.content) + const hasFAQ = faqItems.length > 0 + + // BlogPosting JSON-LD schema (more specific than Article for SEO) + const articleSchema = { + "@context": "https://schema.org", + "@type": "BlogPosting", + headline: post.title, + description: post.description, + datePublished: post.publish_date, + image: ogImageUrl(post.title, post.description), + wordCount: post.content.split(/\s+/).filter(Boolean).length, + mainEntityOfPage: { + "@type": "WebPage", + "@id": `${SEO.url}/blog/${post.slug}`, + }, + url: `${SEO.url}/blog/${post.slug}`, + author: { + "@type": "Organization", + "@id": `${SEO.url}#org`, + name: SEO.name, + }, + publisher: { + "@type": "Organization", + "@id": `${SEO.url}#org`, + name: SEO.name, + logo: { + "@type": "ImageObject", + url: `${SEO.url}/android-chrome-512x512.png`, + }, + }, + } + + // Breadcrumb schema + const breadcrumbSchema = { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + itemListElement: [ + { + "@type": "ListItem", + position: 1, + name: "Home", + item: SEO.url, + }, + { + "@type": "ListItem", + position: 2, + name: "Blog", + item: `${SEO.url}/blog`, + }, + { + "@type": "ListItem", + position: 3, + name: post.title, + item: `${SEO.url}/blog/${post.slug}`, + }, + ], + } + + // FAQPage schema (only if post has FAQ section) - AEO optimization + const faqSchema = hasFAQ + ? { + "@context": "https://schema.org", + "@type": "FAQPage", + mainEntity: faqItems.map((item) => ({ + "@type": "Question", + name: item.question, + acceptedAnswer: { + "@type": "Answer", + text: item.answer, + }, + })), + } + : null + + return ( + <> +