import fs from 'fs'; import path from 'path'; import matter from 'gray-matter'; import { FrontMatter, Post } from './types/frontmatter'; import { generateExcerpt } from './utils'; const POSTS_PATH = path.join(process.cwd(), 'content', 'blog'); export function sanitizePath(inputPath: string): string { const normalized = path.normalize(inputPath).replace(/^(\.\.[\/\\])+/, ''); if (normalized.includes('..') || path.isAbsolute(normalized)) { throw new Error('Invalid path'); } return normalized; } export function calculateReadingTime(content: string): number { const wordsPerMinute = 200; const words = content.trim().split(/\s+/).length; return Math.ceil(words / wordsPerMinute); } export function validateFrontmatter(data: any): FrontMatter { if (!data.title || typeof data.title !== 'string') { throw new Error('Invalid title'); } if (!data.description || typeof data.description !== 'string') { throw new Error('Invalid description'); } if (!data.date || typeof data.date !== 'string') { throw new Error('Invalid date'); } if (!data.author || typeof data.author !== 'string') { throw new Error('Invalid author'); } if (!data.category || typeof data.category !== 'string') { throw new Error('Invalid category'); } if (!Array.isArray(data.tags) || data.tags.length === 0 || data.tags.length > 3) { throw new Error('Tags must be array with 1-3 items'); } return { title: data.title, description: data.description, date: data.date, author: data.author, category: data.category, tags: data.tags, image: data.image, draft: data.draft || false, }; } export function getPostBySlug(slug: string | string[]): Post | null { const slugArray = Array.isArray(slug) ? slug : [slug]; const sanitized = slugArray.map(s => sanitizePath(s)); const fullPath = path.join(POSTS_PATH, ...sanitized) + '.md'; if (!fs.existsSync(fullPath)) { return null; } const fileContents = fs.readFileSync(fullPath, 'utf8'); const { data, content } = matter(fileContents); const frontmatter = validateFrontmatter(data); return { slug: sanitized.join('/'), frontmatter, content, readingTime: calculateReadingTime(content), excerpt: generateExcerpt(content), }; } export function getAllPosts(includeContent = false): Post[] { const posts: Post[] = []; function walkDir(dir: string, prefix = ''): void { const files = fs.readdirSync(dir); for (const file of files) { const filePath = path.join(dir, file); const stat = fs.statSync(filePath); if (stat.isDirectory()) { walkDir(filePath, prefix ? `${prefix}/${file}` : file); } else if (file.endsWith('.md')) { const slug = prefix ? `${prefix}/${file.replace(/\.md$/, '')}` : file.replace(/\.md$/, ''); try { const post = getPostBySlug(slug.split('/')); if (post && !post.frontmatter.draft) { posts.push(includeContent ? post : { ...post, content: '' }); } } catch (error) { console.error(`Error loading post ${slug}:`, error); } } } } if (fs.existsSync(POSTS_PATH)) { walkDir(POSTS_PATH); } return posts.sort((a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime()); } export async function getRelatedPosts(currentSlug: string, limit = 3): Promise { const currentPost = getPostBySlug(currentSlug); if (!currentPost) return []; const allPosts = getAllPosts(false); const { category, tags } = currentPost.frontmatter; const scored = allPosts .filter(post => post.slug !== currentSlug) .map(post => { let score = 0; if (post.frontmatter.category === category) score += 3; score += post.frontmatter.tags.filter(tag => tags.includes(tag)).length * 2; return { post, score }; }) .filter(({ score }) => score > 0) .sort((a, b) => b.score - a.score); return scored.slice(0, limit).map(({ post }) => post); } export function getAllPostSlugs(): string[][] { const slugs: string[][] = []; function walkDir(dir: string, prefix: string[] = []): void { const files = fs.readdirSync(dir); for (const file of files) { const filePath = path.join(dir, file); const stat = fs.statSync(filePath); if (stat.isDirectory()) { walkDir(filePath, [...prefix, file]); } else if (file.endsWith('.md')) { slugs.push([...prefix, file.replace(/\.md$/, '')]); } } } if (fs.existsSync(POSTS_PATH)) { walkDir(POSTS_PATH); } return slugs; }