Files
triqura-ecd/lib/mdx/blog.ts
colinislit 7df92dd2fb feat(blog): enhance blog functionality and SEO
- Added a new '/blog' route to middleware for improved navigation.
- Updated sitemap to dynamically include blog posts and series URLs.
- Enhanced blog page metadata with Open Graph and Twitter card information.
- Implemented structured data for blog posts and series to improve SEO.
- Removed outdated blog post "Week 1: De Kickoff" from content.
- Introduced optional OG image field in blog frontmatter for better sharing visuals.
2026-01-01 10:56:34 +01:00

260 lines
6.4 KiB
TypeScript

/**
* Blog MDX Utilities
*
* Functions for loading and parsing blog MDX files with series support
* Based on lib/mdx/documentatie.ts
*/
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
const BLOG_DIR = path.join(process.cwd(), 'content/nl/blog')
// ============================================================================
// Types
// ============================================================================
export interface BlogSeries {
id: string
title: string
description: string
color: 'teal' | 'amber' | 'slate'
order: number
status: 'active' | 'completed' | 'planned'
}
export interface BlogFrontmatter {
title: string
description: string
date: string
published?: boolean
seriesOrder: number
tags?: string[]
image?: string // Optional OG image path (relative to /public, e.g. "/images/blog/my-post.png")
}
export interface BlogPost {
slug: string
seriesId: string
frontmatter: BlogFrontmatter
content: string
readingTime: number
}
export interface SeriesNavigation {
previous: { slug: string; title: string } | null
next: { slug: string; title: string } | null
current: number
total: number
}
// ============================================================================
// Helper Functions
// ============================================================================
function calculateReadingTime(content: string): number {
const wordsPerMinute = 200
const wordCount = content.split(/\s+/).length
return Math.ceil(wordCount / wordsPerMinute)
}
// ============================================================================
// Series Functions
// ============================================================================
/**
* Get all series metadata
*/
export async function getAllSeries(): Promise<BlogSeries[]> {
try {
const seriesPath = path.join(BLOG_DIR, '_series.json')
if (!fs.existsSync(seriesPath)) {
console.warn('_series.json not found')
return []
}
const content = fs.readFileSync(seriesPath, 'utf-8')
const data = JSON.parse(content)
return (data.series || []).sort(
(a: BlogSeries, b: BlogSeries) => a.order - b.order
)
} catch (error) {
console.error('Error loading series:', error)
return []
}
}
/**
* Get a single series by ID
*/
export async function getSeries(seriesId: string): Promise<BlogSeries | null> {
const allSeries = await getAllSeries()
return allSeries.find((s) => s.id === seriesId) || null
}
// ============================================================================
// Post Functions
// ============================================================================
/**
* Get all blog posts from all series
*/
export async function getAllPosts(): Promise<BlogPost[]> {
if (!fs.existsSync(BLOG_DIR)) return []
const series = await getAllSeries()
const allPosts: BlogPost[] = []
for (const serie of series) {
const serieDir = path.join(BLOG_DIR, serie.id)
if (!fs.existsSync(serieDir)) continue
const files = fs.readdirSync(serieDir).filter((f) => f.endsWith('.mdx'))
for (const file of files) {
const slug = file.replace('.mdx', '')
const filePath = path.join(serieDir, file)
const fileContent = fs.readFileSync(filePath, 'utf-8')
const { data, content } = matter(fileContent)
const frontmatter = data as BlogFrontmatter
// Skip unpublished in production
if (
process.env.NODE_ENV !== 'development' &&
frontmatter.published === false
) {
continue
}
allPosts.push({
slug,
seriesId: serie.id,
frontmatter,
content,
readingTime: calculateReadingTime(content),
})
}
}
// Sort by date, newest first
return allPosts.sort(
(a, b) =>
new Date(b.frontmatter.date).getTime() -
new Date(a.frontmatter.date).getTime()
)
}
/**
* Get all posts for a specific series
*/
export async function getPostsBySeries(seriesId: string): Promise<BlogPost[]> {
const serieDir = path.join(BLOG_DIR, seriesId)
if (!fs.existsSync(serieDir)) return []
const files = fs.readdirSync(serieDir).filter((f) => f.endsWith('.mdx'))
const posts: BlogPost[] = []
for (const file of files) {
const slug = file.replace('.mdx', '')
const filePath = path.join(serieDir, file)
const fileContent = fs.readFileSync(filePath, 'utf-8')
const { data, content } = matter(fileContent)
const frontmatter = data as BlogFrontmatter
// Skip unpublished in production
if (
process.env.NODE_ENV !== 'development' &&
frontmatter.published === false
) {
continue
}
posts.push({
slug,
seriesId,
frontmatter,
content,
readingTime: calculateReadingTime(content),
})
}
// Sort by seriesOrder
return posts.sort(
(a, b) => a.frontmatter.seriesOrder - b.frontmatter.seriesOrder
)
}
/**
* Get a single post
*/
export async function getPost(
seriesId: string,
slug: string
): Promise<BlogPost | null> {
const filePath = path.join(BLOG_DIR, seriesId, `${slug}.mdx`)
if (!fs.existsSync(filePath)) return null
const fileContent = fs.readFileSync(filePath, 'utf-8')
const { data, content } = matter(fileContent)
const frontmatter = data as BlogFrontmatter
return {
slug,
seriesId,
frontmatter,
content,
readingTime: calculateReadingTime(content),
}
}
/**
* Get navigation for a post within its series
*/
export async function getSeriesNavigation(
seriesId: string,
slug: string
): Promise<SeriesNavigation> {
const posts = await getPostsBySeries(seriesId)
const currentIndex = posts.findIndex((p) => p.slug === slug)
return {
previous:
currentIndex > 0
? { slug: posts[currentIndex - 1].slug, title: posts[currentIndex - 1].frontmatter.title }
: null,
next:
currentIndex < posts.length - 1
? { slug: posts[currentIndex + 1].slug, title: posts[currentIndex + 1].frontmatter.title }
: null,
current: currentIndex + 1,
total: posts.length,
}
}
/**
* Get series with post counts
*/
export async function getSeriesWithCounts(): Promise<
(BlogSeries & { postCount: number })[]
> {
const series = await getAllSeries()
const result = []
for (const serie of series) {
const posts = await getPostsBySeries(serie.id)
result.push({
...serie,
postCount: posts.length,
})
}
return result
}