import fs from 'fs' import path from 'path' import matter from 'gray-matter' import { cache } from 'react' const postsDirectory = path.join(process.cwd(), 'content/posts') export interface PostMeta { slug: string title: string date: string excerpt: string tags: string[] author: string | null coverImage: string | null readingTime: number } export type Post = PostMeta export interface TagMeta { slug: string label: string count: number } const normalizeString = (value: unknown, fallback: string): string => { if (typeof value === 'string') return value if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString() if (typeof value === 'number' || typeof value === 'boolean') return String(value) return fallback } const normalizeOptionalString = (value: unknown): string | null => { if (typeof value !== 'string') return null const normalized = value.trim() return normalized.length > 0 ? normalized : null } const normalizeTags = (value: unknown): string[] => { const values = Array.isArray(value) ? value : typeof value === 'string' ? [value] : [] return values .map((tag) => normalizeString(tag, '').trim()) .filter((tag) => tag.length > 0) } export const normalizePostData = ( slug: string, data: Record, readingTime: number ): PostMeta => ({ slug, title: normalizeString(data.title, slug), date: normalizeString(data.date, 'Unknown'), excerpt: normalizeString(data.excerpt, ''), tags: normalizeTags(data.tags), author: normalizeOptionalString(data.author), coverImage: normalizeOptionalString(data.coverImage), readingTime, }) export const tagToSlug = (tag: string): string => tag .normalize('NFKD') .toLowerCase() .replace(/[\u0300-\u036f]/g, '') .replace(/\+/g, ' plus ') .replace(/#/g, ' sharp ') .replace(/[\\/]+/g, ' ') .replace(/[^\p{L}\p{N}]+/gu, '-') .replace(/-{2,}/g, '-') .replace(/^-|-$/g, '') const getMdxFiles = cache(async () => { try { const files = await fs.promises.readdir(postsDirectory) return files.filter((file) => file.endsWith('.mdx')).sort((a, b) => a.localeCompare(b)) } catch { return [] } }) export const getPosts = cache(async (): Promise => { const files = await getMdxFiles() return Promise.all( files.map(async (file) => { const filePath = path.join(postsDirectory, file) const raw = await fs.promises.readFile(filePath, 'utf-8') const { data, content } = matter(raw) const slug = file.replace(/\.mdx$/, '') const readingTime = getReadingTime(content) return normalizePostData(slug, data, readingTime) }) ).then((posts) => posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) ) }) export const getPost = async (slug: string): Promise => { const files = await getMdxFiles() const file = files.find((f) => f.replace(/\.mdx$/, '') === slug) if (!file) return null const filePath = path.join(postsDirectory, file) const raw = await fs.promises.readFile(filePath, 'utf8') const { data, content } = matter(raw) return normalizePostData(slug, data, getReadingTime(content)) satisfies Post } export const getReadingTime = (content: string): number => { const words = content.replace(/<[^>]*>/g, '').trim().split(/\s+/).filter(Boolean).length return Math.max(1, Math.ceil(words / 200)) } export const getAllTags = cache(async (): Promise => { const posts = await getPosts() const tags = new Map() posts.forEach((post) => { const postTagSlugs = new Set() post.tags.forEach((tag) => { const slug = tagToSlug(tag) if (!slug || postTagSlugs.has(slug)) return postTagSlugs.add(slug) const existing = tags.get(slug) if (existing) { existing.count += 1 if (tag.localeCompare(existing.label) < 0) existing.label = tag return } tags.set(slug, { slug, label: tag, count: 1 }) }) }) return Array.from(tags.values()).sort( (a, b) => a.label.localeCompare(b.label) || a.slug.localeCompare(b.slug) ) }) export const getPostsByTag = cache(async (tagSlug: string): Promise => { const posts = await getPosts() const normalizedTagSlug = tagToSlug(tagSlug) return posts.filter((post) => post.tags.some((tag) => tagToSlug(tag) === normalizedTagSlug)) })