import fs from 'fs' import path from 'path' export type Metadata = { title: string publishedAt: string summary: string image?: string tags?: string[] } export type Post = { metadata: Metadata slug: string content: string source: 'fs' | 'github' } function parseFrontmatter(fileContent: string) { const frontmatterRegex = /---\s*([\s\S]*?)\s*---/ const match = frontmatterRegex.exec(fileContent) if (!match) { // No frontmatter, treat entire content as body with minimal metadata return { metadata: { title: 'Untitled', publishedAt: new Date().toISOString(), summary: '', } as Metadata, content: fileContent.trim(), } } const frontMatterBlock = match[1] const content = fileContent.replace(frontmatterRegex, '').trim() const frontMatterLines = frontMatterBlock.trim().split('\n') const metadata: Partial = {} frontMatterLines.forEach((line) => { const [rawKey, ...valueArr] = line.split(': ') const key = rawKey?.trim() if (!key) return let value = valueArr.join(': ').trim() // Remove surrounding quotes value = value.replace(/^['"](.*)['"]$/, '$1') // Support simple array syntax for tags: [tag1, tag2] if (key === 'tags') { const arr = value.startsWith('[') && value.endsWith(']') ? value .slice(1, -1) .split(',') .map((v) => v.trim().replace(/^['"](.*)['"]$/, '$1')) .filter(Boolean) : value .split(',') .map((v) => v.trim()) .filter(Boolean) ;(metadata as Record)[key] = arr } else { ;(metadata as Record)[key] = value } }) return { metadata: metadata as Metadata, content } } // ============ Local FS provider ============ function getMDXFiles(dir: string) { return fs.existsSync(dir) ? fs.readdirSync(dir).filter((file) => path.extname(file) === '.mdx') : [] } function readMDXFile(filePath: string) { const rawContent = fs.readFileSync(filePath, 'utf-8') return parseFrontmatter(rawContent) } function getMDXData(dir: string): Post[] { const mdxFiles = getMDXFiles(dir) return mdxFiles.map((file) => { const { metadata, content } = readMDXFile(path.join(dir, file)) const slug = path.basename(file, path.extname(file)) return { metadata, slug, content, source: 'fs', } }) } function getFSPosts(): Post[] { return getMDXData(path.join(process.cwd(), 'app', 'blog', 'posts')) } // ============ GitHub provider (optional) ============ const BLOG_REPO = process.env.BLOG_REPO // 'owner/repo' const BLOG_PATH = process.env.BLOG_PATH || '' const BLOG_BRANCH = process.env.BLOG_BRANCH || 'main' const GITHUB_TOKEN = process.env.GITHUB_TOKEN const BLOG_REVALIDATE_SECONDS = Number(process.env.BLOG_REVALIDATE_SECONDS || 300) const BLOG_CACHE_TAG = process.env.BLOG_CACHE_TAG || 'blog-content' type GithubContentItem = { name: string path: string type: 'file' | 'dir' } type GithubFileResponse = { content?: string } async function githubApi(url: string): Promise { const headers: Record = { Accept: 'application/vnd.github+json', } if (GITHUB_TOKEN) headers.Authorization = `Bearer ${GITHUB_TOKEN}` const res = await fetch(url, { headers, next: { revalidate: BLOG_REVALIDATE_SECONDS, tags: [BLOG_CACHE_TAG] }, }) if (!res.ok) { throw new Error(`GitHub API error ${res.status} on ${url}`) } return (await res.json()) as T } async function getGithubPosts(): Promise { if (!BLOG_REPO) return [] const [owner, repo] = BLOG_REPO.split('/') const base = `https://api.github.com/repos/${owner}/${repo}/contents` const dir = BLOG_PATH ? `/${BLOG_PATH}` : '' const listUrl = `${base}${dir}?ref=${encodeURIComponent(BLOG_BRANCH)}` let items: GithubContentItem[] try { items = await githubApi(listUrl) } catch { return [] } const mdxItems = items.filter((it) => it.type === 'file' && it.name.endsWith('.mdx')) const posts: Post[] = [] for (const item of mdxItems) { // Fetch file content (base64) via contents API const fileUrl = `${base}/${encodeURIComponent(item.path)}?ref=${encodeURIComponent(BLOG_BRANCH)}` try { const fileJson = await githubApi(fileUrl) const contentBase64: string | undefined = fileJson.content if (!contentBase64) continue const raw = Buffer.from(contentBase64, 'base64').toString('utf8') const { metadata, content } = parseFrontmatter(raw) const slug = path.basename(item.name, '.mdx') posts.push({ metadata, slug, content, source: 'github', }) } catch { // skip on individual file failure continue } } return posts } // ============ Public API ============ // Backward-compatible local-only function (used by older imports) export function getBlogPosts(): Post[] { return getFSPosts() } // New unified provider that merges FS with optional GitHub content export async function getAllPosts(): Promise { const fsPosts = getFSPosts() const ghPosts = await getGithubPosts().catch(() => []) // Merge by slug, with GitHub taking precedence on duplicates const map = new Map() for (const p of fsPosts) map.set(p.slug, p) for (const p of ghPosts) map.set(p.slug, p) return Array.from(map.values()) .filter((p) => !!p.metadata?.publishedAt && !!p.metadata?.title) .sort((a, b) => { const da = new Date(a.metadata.publishedAt).getTime() const db = new Date(b.metadata.publishedAt).getTime() return db - da }) } export function formatDate(date: string, includeRelative = false) { const currentDate = new Date() if (!date.includes('T')) { date = `${date}T00:00:00` } const targetDate = new Date(date) const yearsAgo = currentDate.getFullYear() - targetDate.getFullYear() const monthsAgo = currentDate.getMonth() - targetDate.getMonth() + yearsAgo * 12 const daysAgo = Math.floor( (currentDate.getTime() - targetDate.getTime()) / (1000 * 60 * 60 * 24) ) let formattedDate = '' if (yearsAgo > 0) { formattedDate = `${yearsAgo}y ago` } else if (monthsAgo > 0) { formattedDate = `${monthsAgo}mo ago` } else if (daysAgo > 0) { formattedDate = `${daysAgo}d ago` } else { formattedDate = 'Today' } const fullDate = targetDate.toLocaleString('en-us', { month: 'long', day: 'numeric', year: 'numeric', }) if (!includeRelative) { return fullDate } return `${fullDate} (${formattedDate})` } export function getReadingTime(content: string) { const words = (content || '') .trim() .split(/\s+/) .filter(Boolean).length const minutes = Math.max(1, Math.ceil(words / 200)) return { minutes, text: `${minutes} min read`, words } } export function getExcerpt(summary?: string, content?: string, maxChars = 220) { if (summary && summary.trim().length > 0) return summary.trim() if (!content) return '' // Get first non-empty paragraph const firstPara = content .split(/\n{2,}/) .map((s) => s.trim()) .find((p) => p.length > 0) || '' const clean = firstPara .replace(/```[\s\S]*?```/g, '') // remove code fences .replace(/`[^`]*`/g, '') // remove inline code .replace(/[#>*_~\[\]()\-]/g, '') // remove some md tokens .replace(/\s+/g, ' ') .trim() if (clean.length <= maxChars) return clean return clean.slice(0, maxChars).replace(/\s+\S*$/, '') + '…' } export function findAdjacentPosts(posts: Post[], slug: string) { const idx = posts.findIndex((p) => p.slug === slug) if (idx === -1) return { prev: undefined, next: undefined } // posts expected sorted desc (newest to oldest) const prev = idx > 0 ? posts[idx - 1] : undefined // newer const next = idx < posts.length - 1 ? posts[idx + 1] : undefined // older return { prev, next } }