/** * Blog Post Management Service * Zentrale Verwaltung aller Blog-Beiträge mit automatischer Kategorie-Erkennung */ import fs from 'fs'; import path from 'path'; import { cache } from '@/utils/cache'; /** * Blog Post Interface * Definiert die Struktur eines Blog-Beitrags */ export interface BlogPost { /** URL-freundlicher Slug */ slug: string; /** Titel des Beitrags */ title: string; /** Veröffentlichungsdatum (ISO 8601) */ date: string; /** Kurze Zusammenfassung */ excerpt: string; /** Pfad zum Cover-Bild */ coverImage: string; /** Kategorie des Beitrags */ category?: string; /** Schlagwörter/Tags */ tags?: string[]; /** Vollständiger Markdown-Inhalt */ content?: string; readingTime?: number; } // Verzeichnis der Blog-Beiträge const BLOG_POSTS_DIR = path.join(process.cwd(), 'blog-posts'); /** * Kategorie-Mapping basierend auf Dateinamen- und Inhaltsmustern * Ordnet Keywords automatisch passenden Kategorien zu */ const categoryMap: Record = { 'agentic': 'KI-Agenten', 'ai-agent': 'KI-Agenten', 'deepseek': 'KI-Entwicklung', 'openai': 'KI-Entwicklung', 'gpt': 'KI-Entwicklung', 'claude': 'KI-Entwicklung', 'langchain': 'KI-Entwicklung', 'langgraph': 'KI-Entwicklung', 'rag': 'KI-Entwicklung', 'llm': 'KI-Entwicklung', 'voice': 'Voice AI', 'vapi': 'Voice AI', 'speech': 'Voice AI', 'elevenlabs': 'Voice AI', 'whisper': 'Voice AI', 'n8n': 'Automatisierung', 'make': 'Automatisierung', 'zapier': 'Automatisierung', 'automation': 'Automatisierung', 'workflow': 'Automatisierung', 'scraping': 'Web Scraping', 'playwright': 'Web Scraping', 'puppeteer': 'Web Scraping', 'nextjs': 'Web Development', 'react': 'Web Development', 'typescript': 'Web Development', 'api': 'Backend', 'database': 'Backend', 'postgres': 'Backend', 'prisma': 'Backend', 'supabase': 'Backend', 'saas': 'SaaS', 'stripe': 'SaaS', 'subscription': 'SaaS', 'seo': 'SEO', 'performance': 'Performance', 'iot': 'IoT', 'raspberry': 'IoT', 'mqtt': 'IoT', 'home-assistant': 'Smart Home', }; /** * Erkennt automatisch die Kategorie eines Blog-Posts * Analysiert Dateiname und Inhalt auf Keywords * @param filename - Dateiname des Blog-Posts * @param content - Inhalt des Blog-Posts * @returns Die erkannte Kategorie oder 'Technologie' als Fallback */ function detectCategory(filename: string, content: string): string { const lowerFilename = filename.toLowerCase(); const lowerContent = content.toLowerCase().slice(0, 500); for (const [keyword, category] of Object.entries(categoryMap)) { if (lowerFilename.includes(keyword) || lowerContent.includes(keyword)) { return category; } } return 'Technologie'; } /** * Berechnet die geschätzte Lesezeit für einen Blog-Post * @param content - Der Markdown-Inhalt des Posts * @returns Lesezeit in Minuten (mindestens 1) */ export function calculateReadingTime(content: string): number { // Remove markdown syntax for more accurate word count const text = content .replace(/```[\s\S]*?```/g, '') // Remove code blocks .replace(/`[^`]*`/g, '') // Remove inline code .replace(/!\[.*?\]\(.*?\)/g, '') // Remove images .replace(/\[.*?\]\(.*?\)/g, '') // Remove links .replace(/[#*_~]/g, '') // Remove markdown formatting .replace(/\s+/g, ' ') // Normalize whitespace .trim(); // Count words const words = text.split(/\s+/).filter(word => word.length > 0).length; // Calculate reading time (average reading speed: 200 words per minute) const minutes = Math.ceil(words / 200); return Math.max(1, minutes); // Minimum 1 minute } /** * Parst einen Markdown-Blog-Post und extrahiert alle Metadaten * Extrahiert Titel, Meta-Description, Keywords, Kategorie und generiert Slug * @param filename - Dateiname des Markdown-Posts * @param content - Vollständiger Markdown-Inhalt * @returns BlogPost-Objekt oder null bei Fehler */ function parseMarkdownPost(filename: string, content: string): BlogPost | null { try { // Extract title from first H1 const titleMatch = content.match(/^#\s+(.+)$/m); const title = titleMatch ? titleMatch[1].trim() : filename.replace(/\.md$/, ''); // Extract meta description const metaMatch = content.match(/\*\*Meta-Description:\*\*\s*(.+)/i); const metaDescription = metaMatch ? metaMatch[1].trim() : ''; // Extract keywords const keywordsMatch = content.match(/\*\*Keywords?:\*\*\s*(.+)/i); const keywordsStr = keywordsMatch ? keywordsMatch[1].trim() : ''; const tags = keywordsStr ? keywordsStr.split(',').map(k => k.trim()).slice(0, 6) : []; // Extract excerpt from introduction or meta description let excerpt = metaDescription; if (!excerpt) { const introMatch = content.match(/## Einführung\s*\n\n([\s\S]+?)(?:\n\n|$)/); if (introMatch) { excerpt = introMatch[1].trim().slice(0, 200) + '...'; } } // Generate slug from filename (remove number prefix) const slug = filename .replace(/\.md$/, '') .replace(/^\d+-/, ''); // Extract date from filename number (approximate) const numMatch = filename.match(/^(\d+)-/); const postNum = numMatch ? parseInt(numMatch[1], 10) : 1; // Generate dates starting from 2026-01-19 going backwards const baseDate = new Date('2026-01-19'); baseDate.setDate(baseDate.getDate() - (postNum - 1)); const date = baseDate.toISOString().split('T')[0]; // Detect category const category = detectCategory(filename, content); // Cover image path const coverImage = `/images/posts/${slug}/cover.jpg`; // Calculate reading time const readingTime = calculateReadingTime(content); return { slug, title, date, excerpt: excerpt || title, coverImage, category, tags, content, readingTime, }; } catch (error) { console.error(`Error parsing ${filename}:`, error); return null; } } /** * Lädt alle Blog-Posts aus dem Dateisystem * Posts werden nach Datum absteigend sortiert (neueste zuerst) * @returns Array aller Blog-Posts */ export function getAllBlogPosts(): BlogPost[] { const cacheKey = 'blog:all-posts'; // Check cache first const cachedPosts = cache.get(cacheKey); if (cachedPosts) { return cachedPosts; } if (!fs.existsSync(BLOG_POSTS_DIR)) { console.warn('Blog posts directory not found:', BLOG_POSTS_DIR); return []; } const files = fs.readdirSync(BLOG_POSTS_DIR) .filter(file => file.endsWith('.md')) .sort(); const posts: BlogPost[] = []; for (const file of files) { try { const filePath = path.join(BLOG_POSTS_DIR, file); const content = fs.readFileSync(filePath, 'utf-8'); const post = parseMarkdownPost(file, content); if (post) { posts.push(post); } } catch (error) { // Skip files that fail to parse console.warn(`Failed to parse blog post: ${file}`, error); continue; } } // Sort by date descending (newest first) const sortedPosts = posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); // Cache the results cache.set(cacheKey, sortedPosts); return sortedPosts; } /** * Sucht einen Blog-Post anhand seines Slugs * @param slug - Der URL-Slug des gesuchten Posts * @returns Der gefundene Blog-Post oder null */ export function getBlogPostBySlug(slug: string): BlogPost | null { const posts = getAllBlogPosts(); return posts.find(post => post.slug === slug) || null; } /** * Gibt alle verfügbaren Blog-Post Slugs zurück * Nützlich für statische Seitengenerierung * @returns Array aller Post-Slugs */ export function getBlogPostSlugs(): string[] { return getAllBlogPosts().map(post => post.slug); }