feat(blog): add blog pages and API endpoints

- Add /blog index page showing all posts + featured posts
- Add /blog/[slug] dynamic route for individual posts
- Add /api/blog/featured endpoint
- Add HomeBlogSection component for homepage
- Add blog utility functions (markdown processing with remark)
- Dark theme styling matching site aesthetic
- SEO metadata (Open Graph, Twitter Cards, JSON-LD)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-11-07 08:49:33 -06:00
parent 1da3358db1
commit 1886ea0e73
5 changed files with 1034 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
import { NextResponse } from 'next/server'
import { getFeaturedPosts } from '@/lib/blog'
export async function GET() {
try {
const posts = await getFeaturedPosts()
return NextResponse.json(posts)
} catch (error) {
console.error('Error fetching featured posts:', error)
return NextResponse.json({ error: 'Failed to fetch featured posts' }, { status: 500 })
}
}

View File

@@ -0,0 +1,365 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { getPostBySlug, getAllPostSlugs } from '@/lib/blog'
import { css } from '../../../../styled-system/css'
interface Props {
params: {
slug: string
}
}
// Generate static params for all blog posts
export async function generateStaticParams() {
const slugs = getAllPostSlugs()
return slugs.map((slug) => ({ slug }))
}
// Generate metadata for SEO
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await getPostBySlug(params.slug)
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://abaci.one'
const postUrl = `${siteUrl}/blog/${params.slug}`
return {
title: `${post.title} | Abaci.one Blog`,
description: post.description,
authors: [{ name: post.author }],
openGraph: {
title: post.title,
description: post.description,
url: postUrl,
siteName: 'Abaci.one',
type: 'article',
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
authors: [post.author],
tags: post.tags,
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.description,
},
alternates: {
canonical: postUrl,
},
}
}
export default async function BlogPost({ params }: Props) {
let post
try {
post = await getPostBySlug(params.slug)
} catch {
notFound()
}
// Format date for display
const publishedDate = new Date(post.publishedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
const updatedDate = new Date(post.updatedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
const showUpdatedDate = post.publishedAt !== post.updatedAt
return (
<div
data-component="blog-post-page"
className={css({
minH: '100vh',
bg: 'gray.900',
pt: 'var(--app-nav-height-full)',
})}
>
{/* Background pattern */}
<div
className={css({
position: 'fixed',
inset: 0,
opacity: 0.05,
backgroundImage:
'radial-gradient(circle at 2px 2px, rgba(255, 255, 255, 0.15) 1px, transparent 0)',
backgroundSize: '40px 40px',
pointerEvents: 'none',
zIndex: 0,
})}
/>
<div
className={css({
position: 'relative',
zIndex: 1,
maxW: '48rem',
mx: 'auto',
px: { base: '1rem', md: '2rem' },
py: { base: '2rem', md: '4rem' },
})}
>
{/* Back link */}
<Link
href="/blog"
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '0.5rem',
mb: '2rem',
color: 'rgba(196, 181, 253, 0.8)',
fontSize: '0.875rem',
fontWeight: '500',
textDecoration: 'none',
transition: 'color 0.2s',
_hover: {
color: 'rgba(196, 181, 253, 1)',
},
})}
>
<span></span>
<span>Back to Blog</span>
</Link>
{/* Article */}
<article data-element="blog-article">
<header
data-section="article-header"
className={css({
mb: '3rem',
pb: '2rem',
borderBottom: '1px solid',
borderColor: 'rgba(75, 85, 99, 0.5)',
})}
>
<h1
className={css({
fontSize: { base: '2rem', md: '2.5rem', lg: '3rem' },
fontWeight: 'bold',
lineHeight: '1.2',
mb: '1rem',
color: 'white',
})}
>
{post.title}
</h1>
<p
className={css({
fontSize: { base: '1.125rem', md: '1.25rem' },
color: 'rgba(209, 213, 219, 0.8)',
lineHeight: '1.6',
mb: '1.5rem',
})}
>
{post.description}
</p>
<div
data-element="article-meta"
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '1rem',
alignItems: 'center',
fontSize: '0.875rem',
color: 'rgba(196, 181, 253, 0.8)',
})}
>
<span data-element="author">{post.author}</span>
<span></span>
<time dateTime={post.publishedAt}>{publishedDate}</time>
{showUpdatedDate && (
<>
<span></span>
<span>Updated: {updatedDate}</span>
</>
)}
</div>
{post.tags.length > 0 && (
<div
data-element="tags"
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
mt: '1rem',
})}
>
{post.tags.map((tag) => (
<span
key={tag}
className={css({
px: '0.75rem',
py: '0.25rem',
bg: 'rgba(139, 92, 246, 0.2)',
color: 'rgba(196, 181, 253, 1)',
borderRadius: '0.25rem',
fontSize: '0.875rem',
fontWeight: '500',
})}
>
{tag}
</span>
))}
</div>
)}
</header>
{/* Article Content */}
<div
data-section="article-content"
className={css({
fontSize: { base: '1rem', md: '1.125rem' },
lineHeight: '1.75',
color: 'rgba(229, 231, 235, 0.95)',
// Typography styles for markdown content
'& h1': {
fontSize: { base: '1.875rem', md: '2.25rem' },
fontWeight: 'bold',
mt: '2.5rem',
mb: '1rem',
lineHeight: '1.25',
color: 'white',
},
'& h2': {
fontSize: { base: '1.5rem', md: '1.875rem' },
fontWeight: 'bold',
mt: '2rem',
mb: '0.875rem',
lineHeight: '1.3',
color: 'rgba(196, 181, 253, 1)',
},
'& h3': {
fontSize: { base: '1.25rem', md: '1.5rem' },
fontWeight: '600',
mt: '1.75rem',
mb: '0.75rem',
lineHeight: '1.4',
color: 'rgba(196, 181, 253, 0.9)',
},
'& p': {
mb: '1.25rem',
},
'& strong': {
fontWeight: '600',
color: 'white',
},
'& a': {
color: 'rgba(147, 197, 253, 1)',
textDecoration: 'underline',
_hover: {
color: 'rgba(59, 130, 246, 1)',
},
},
'& ul, & ol': {
pl: '1.5rem',
mb: '1.25rem',
},
'& li': {
mb: '0.5rem',
},
'& code': {
bg: 'rgba(0, 0, 0, 0.4)',
px: '0.375rem',
py: '0.125rem',
borderRadius: '0.25rem',
fontSize: '0.875em',
fontFamily: 'monospace',
color: 'rgba(196, 181, 253, 1)',
border: '1px solid',
borderColor: 'rgba(139, 92, 246, 0.3)',
},
'& pre': {
bg: 'rgba(0, 0, 0, 0.5)',
border: '1px solid',
borderColor: 'rgba(139, 92, 246, 0.3)',
color: 'rgba(229, 231, 235, 0.95)',
p: '1rem',
borderRadius: '0.5rem',
overflow: 'auto',
mb: '1.25rem',
},
'& pre code': {
bg: 'transparent',
p: '0',
border: 'none',
color: 'inherit',
fontSize: '0.875rem',
},
'& blockquote': {
borderLeft: '4px solid',
borderColor: 'rgba(139, 92, 246, 0.5)',
pl: '1rem',
py: '0.5rem',
my: '1.5rem',
color: 'rgba(209, 213, 219, 0.8)',
fontStyle: 'italic',
bg: 'rgba(139, 92, 246, 0.05)',
borderRadius: '0 0.25rem 0.25rem 0',
},
'& hr': {
my: '2rem',
borderColor: 'rgba(75, 85, 99, 0.5)',
},
'& table': {
width: '100%',
mb: '1.25rem',
borderCollapse: 'collapse',
},
'& th': {
bg: 'rgba(139, 92, 246, 0.2)',
px: '1rem',
py: '0.75rem',
textAlign: 'left',
fontWeight: '600',
borderBottom: '2px solid',
borderColor: 'rgba(139, 92, 246, 0.5)',
color: 'rgba(196, 181, 253, 1)',
},
'& td': {
px: '1rem',
py: '0.75rem',
borderBottom: '1px solid',
borderColor: 'rgba(75, 85, 99, 0.3)',
color: 'rgba(209, 213, 219, 0.9)',
},
'& tr:hover td': {
bg: 'rgba(139, 92, 246, 0.05)',
},
})}
dangerouslySetInnerHTML={{ __html: post.html }}
/>
</article>
{/* JSON-LD Structured Data */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: post.description,
author: {
'@type': 'Person',
name: post.author,
},
datePublished: post.publishedAt,
dateModified: post.updatedAt,
keywords: post.tags.join(', '),
}),
}}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,334 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { getAllPostsMetadata, getFeaturedPosts } from '@/lib/blog'
import { css } from '../../../styled-system/css'
export const metadata: Metadata = {
title: 'Blog | Abaci.one',
description:
'Articles about educational technology, pedagogy, and innovative approaches to learning with the abacus.',
openGraph: {
title: 'Abaci.one Blog',
description:
'Articles about educational technology, pedagogy, and innovative approaches to learning with the abacus.',
url: `${process.env.NEXT_PUBLIC_SITE_URL || 'https://abaci.one'}/blog`,
siteName: 'Abaci.one',
type: 'website',
},
}
export default async function BlogIndex() {
const featuredPosts = await getFeaturedPosts()
const allPosts = await getAllPostsMetadata()
return (
<div
data-component="blog-index-page"
className={css({
minH: '100vh',
bg: 'gray.900',
pt: 'var(--app-nav-height-full)',
})}
>
{/* Background pattern */}
<div
className={css({
position: 'fixed',
inset: 0,
opacity: 0.05,
backgroundImage:
'radial-gradient(circle at 2px 2px, rgba(255, 255, 255, 0.15) 1px, transparent 0)',
backgroundSize: '40px 40px',
pointerEvents: 'none',
zIndex: 0,
})}
/>
<div
className={css({
position: 'relative',
zIndex: 1,
maxW: '64rem',
mx: 'auto',
px: { base: '1rem', md: '2rem' },
py: { base: '2rem', md: '4rem' },
})}
>
{/* Page Header */}
<header
data-section="page-header"
className={css({
mb: '3rem',
textAlign: 'center',
})}
>
<h1
className={css({
fontSize: { base: '2.5rem', md: '3.5rem' },
fontWeight: 'bold',
mb: '1rem',
background: 'linear-gradient(135deg, #fbbf24 0%, #f59e0b 50%, #fbbf24 100%)',
backgroundClip: 'text',
color: 'transparent',
})}
>
Blog
</h1>
<p
className={css({
fontSize: { base: '1.125rem', md: '1.25rem' },
color: 'rgba(209, 213, 219, 0.8)',
maxW: '42rem',
mx: 'auto',
})}
>
Exploring educational technology, pedagogy, and innovative approaches to learning.
</p>
</header>
{/* Featured Posts */}
{featuredPosts.length > 0 && (
<section
data-section="featured-posts"
className={css({
mb: '4rem',
})}
>
<h2
className={css({
fontSize: { base: '1.5rem', md: '1.875rem' },
fontWeight: 'bold',
mb: '1.5rem',
color: 'rgba(196, 181, 253, 1)',
})}
>
Featured
</h2>
<div
className={css({
display: 'grid',
gridTemplateColumns: { base: '1fr', md: 'repeat(auto-fit, minmax(300px, 1fr))' },
gap: '1.5rem',
})}
>
{featuredPosts.map((post) => {
const publishedDate = new Date(post.publishedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
return (
<Link
key={post.slug}
href={`/blog/${post.slug}`}
data-action="view-featured-post"
className={css({
display: 'block',
p: '1.5rem',
bg: 'rgba(139, 92, 246, 0.1)',
backdropFilter: 'blur(10px)',
borderRadius: '0.75rem',
border: '1px solid',
borderColor: 'rgba(139, 92, 246, 0.3)',
transition: 'all 0.3s',
_hover: {
bg: 'rgba(139, 92, 246, 0.15)',
borderColor: 'rgba(139, 92, 246, 0.5)',
transform: 'translateY(-4px)',
boxShadow: '0 8px 24px rgba(139, 92, 246, 0.2)',
},
})}
>
<h3
className={css({
fontSize: { base: '1.25rem', md: '1.5rem' },
fontWeight: '600',
mb: '0.5rem',
color: 'white',
})}
>
{post.title}
</h3>
<p
className={css({
color: 'rgba(209, 213, 219, 0.8)',
mb: '1rem',
lineHeight: '1.6',
})}
>
{post.excerpt || post.description}
</p>
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '0.75rem',
alignItems: 'center',
fontSize: '0.875rem',
color: 'rgba(196, 181, 253, 0.8)',
})}
>
<span>{post.author}</span>
<span></span>
<time dateTime={post.publishedAt}>{publishedDate}</time>
</div>
{post.tags.length > 0 && (
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
mt: '1rem',
})}
>
{post.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className={css({
px: '0.5rem',
py: '0.125rem',
bg: 'rgba(139, 92, 246, 0.2)',
color: 'rgba(196, 181, 253, 1)',
borderRadius: '0.25rem',
fontSize: '0.75rem',
fontWeight: '500',
})}
>
{tag}
</span>
))}
</div>
)}
</Link>
)
})}
</div>
</section>
)}
{/* All Posts */}
<section data-section="all-posts">
<h2
className={css({
fontSize: { base: '1.5rem', md: '1.875rem' },
fontWeight: 'bold',
mb: '1.5rem',
color: 'rgba(196, 181, 253, 1)',
})}
>
All Posts
</h2>
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '2rem',
})}
>
{allPosts.map((post) => {
const publishedDate = new Date(post.publishedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
return (
<article
key={post.slug}
data-element="post-preview"
className={css({
pb: '2rem',
borderBottom: '1px solid',
borderColor: 'rgba(75, 85, 99, 0.5)',
_last: {
borderBottom: 'none',
},
})}
>
<Link
href={`/blog/${post.slug}`}
data-action="view-post"
className={css({
display: 'block',
_hover: {
'& h3': {
color: 'rgba(196, 181, 253, 1)',
},
},
})}
>
<h3
className={css({
fontSize: { base: '1.5rem', md: '1.875rem' },
fontWeight: '600',
mb: '0.5rem',
color: 'white',
transition: 'color 0.2s',
})}
>
{post.title}
</h3>
</Link>
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '0.75rem',
alignItems: 'center',
fontSize: '0.875rem',
color: 'rgba(196, 181, 253, 0.7)',
mb: '1rem',
})}
>
<span>{post.author}</span>
<span></span>
<time dateTime={post.publishedAt}>{publishedDate}</time>
</div>
<p
className={css({
color: 'rgba(209, 213, 219, 0.8)',
lineHeight: '1.6',
mb: '1rem',
})}
>
{post.excerpt || post.description}
</p>
{post.tags.length > 0 && (
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
})}
>
{post.tags.map((tag) => (
<span
key={tag}
className={css({
px: '0.5rem',
py: '0.125rem',
bg: 'rgba(75, 85, 99, 0.5)',
color: 'rgba(209, 213, 219, 0.8)',
borderRadius: '0.25rem',
fontSize: '0.75rem',
fontWeight: '500',
})}
>
{tag}
</span>
))}
</div>
)}
</article>
)
})}
</div>
</section>
</div>
</div>
)
}

View File

@@ -0,0 +1,213 @@
'use client'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import type { BlogPostMetadata } from '@/lib/blog'
import { css } from '../../styled-system/css'
export function HomeBlogSection() {
const [featuredPosts, setFeaturedPosts] = useState<BlogPostMetadata[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
// Fetch featured posts from API
async function fetchPosts() {
try {
const response = await fetch('/api/blog/featured')
if (response.ok) {
const posts = await response.json()
setFeaturedPosts(posts)
}
} catch (error) {
console.error('Failed to fetch featured blog posts:', error)
} finally {
setLoading(false)
}
}
fetchPosts()
}, [])
if (loading) {
return null // Don't show anything while loading
}
if (featuredPosts.length === 0) {
return null // Don't show section if no posts
}
return (
<section
data-section="blog-preview"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '8',
})}
>
{/* Section Header */}
<div
className={css({
textAlign: 'center',
})}
>
<h2
className={css({
fontSize: { base: '2xl', md: '3xl' },
fontWeight: 'bold',
mb: '2',
background: 'linear-gradient(135deg, #fbbf24 0%, #f59e0b 50%, #fbbf24 100%)',
backgroundClip: 'text',
color: 'transparent',
})}
>
From the Blog
</h2>
<p
className={css({
fontSize: { base: 'sm', md: 'md' },
color: 'rgba(209, 213, 219, 0.8)',
})}
>
Insights on ed-tech and pedagogy
</p>
</div>
{/* Featured Posts List */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '4',
})}
>
{featuredPosts.map((post) => {
const publishedDate = new Date(post.publishedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
return (
<Link
key={post.slug}
href={`/blog/${post.slug}`}
className={css({
display: 'block',
p: '4',
bg: 'rgba(139, 92, 246, 0.1)',
backdropFilter: 'blur(10px)',
borderRadius: '0.5rem',
border: '1px solid',
borderColor: 'rgba(139, 92, 246, 0.3)',
transition: 'all 0.3s',
_hover: {
bg: 'rgba(139, 92, 246, 0.15)',
borderColor: 'rgba(139, 92, 246, 0.5)',
transform: 'translateY(-2px)',
boxShadow: '0 8px 24px rgba(139, 92, 246, 0.2)',
},
})}
>
<h3
className={css({
fontSize: { base: 'lg', md: 'xl' },
fontWeight: '600',
mb: '2',
color: 'white',
lineHeight: '1.3',
})}
>
{post.title}
</h3>
<p
className={css({
color: 'rgba(209, 213, 219, 0.8)',
mb: '3',
lineHeight: '1.5',
fontSize: 'sm',
})}
>
{post.excerpt || post.description}
</p>
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '2',
alignItems: 'center',
fontSize: 'xs',
color: 'rgba(196, 181, 253, 0.8)',
})}
>
<span>{post.author}</span>
<span></span>
<time dateTime={post.publishedAt}>{publishedDate}</time>
</div>
{post.tags.length > 0 && (
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '1.5',
mt: '2',
})}
>
{post.tags.slice(0, 2).map((tag) => (
<span
key={tag}
className={css({
px: '1.5',
py: '0.25',
bg: 'rgba(139, 92, 246, 0.2)',
color: 'rgba(196, 181, 253, 1)',
borderRadius: '0.25rem',
fontSize: '2xs',
fontWeight: '500',
})}
>
{tag}
</span>
))}
</div>
)}
</Link>
)
})}
</div>
{/* View All Link */}
<div
className={css({
textAlign: 'center',
})}
>
<Link
href="/blog"
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '2',
px: '4',
py: '2',
bg: 'rgba(139, 92, 246, 0.2)',
color: 'rgba(196, 181, 253, 1)',
fontWeight: '600',
fontSize: 'sm',
borderRadius: '0.5rem',
border: '1px solid',
borderColor: 'rgba(139, 92, 246, 0.3)',
transition: 'all 0.2s',
_hover: {
bg: 'rgba(139, 92, 246, 0.3)',
borderColor: 'rgba(139, 92, 246, 0.5)',
},
})}
>
<span>View All Posts</span>
<span></span>
</Link>
</div>
</section>
)
}

110
apps/web/src/lib/blog.ts Normal file
View File

@@ -0,0 +1,110 @@
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
import { remark } from 'remark'
import remarkGfm from 'remark-gfm'
import remarkHtml from 'remark-html'
const postsDirectory = path.join(process.cwd(), 'content', 'blog')
export interface BlogPost {
slug: string
title: string
description: string
author: string
publishedAt: string
updatedAt: string
tags: string[]
featured: boolean
content: string
html: string
}
export interface BlogPostMetadata extends Omit<BlogPost, 'content' | 'html'> {
excerpt?: string
}
/**
* Get all blog post slugs (filenames without .md extension)
*/
export function getAllPostSlugs(): string[] {
try {
const fileNames = fs.readdirSync(postsDirectory)
return fileNames
.filter((fileName) => fileName.endsWith('.md'))
.map((fileName) => fileName.replace(/\.md$/, ''))
} catch {
// Directory doesn't exist yet or is empty
return []
}
}
/**
* Get metadata for all posts (without full content)
*/
export async function getAllPostsMetadata(): Promise<BlogPostMetadata[]> {
const slugs = getAllPostSlugs()
const posts = await Promise.all(
slugs.map(async (slug) => {
const post = await getPostBySlug(slug)
const { content, html, ...metadata } = post
// Create excerpt from first paragraph
const firstPara = content.split('\n\n')[0]
const excerpt = `${firstPara.replace(/^#+\s+/, '').substring(0, 200)}...`
return { ...metadata, excerpt }
})
)
// Sort by published date, newest first
return posts.sort((a, b) => {
return new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()
})
}
/**
* Get a single post by slug with full content
*/
export async function getPostBySlug(slug: string): Promise<BlogPost> {
const fullPath = path.join(postsDirectory, `${slug}.md`)
const fileContents = fs.readFileSync(fullPath, 'utf8')
// Parse frontmatter
const { data, content } = matter(fileContents)
// Convert markdown to HTML
const processedContent = await remark()
.use(remarkGfm) // GitHub Flavored Markdown (tables, strikethrough, etc.)
.use(remarkHtml, { sanitize: false })
.process(content)
const html = processedContent.toString()
return {
slug,
title: data.title || 'Untitled',
description: data.description || '',
author: data.author || 'Anonymous',
publishedAt: data.publishedAt || new Date().toISOString(),
updatedAt: data.updatedAt || data.publishedAt || new Date().toISOString(),
tags: data.tags || [],
featured: data.featured || false,
content,
html,
}
}
/**
* Get featured posts for homepage
*/
export async function getFeaturedPosts(): Promise<BlogPostMetadata[]> {
const allPosts = await getAllPostsMetadata()
return allPosts.filter((post) => post.featured).slice(0, 3)
}
/**
* Get posts by tag
*/
export async function getPostsByTag(tag: string): Promise<BlogPostMetadata[]> {
const allPosts = await getAllPostsMetadata()
return allPosts.filter((post) => post.tags.includes(tag))
}