Files
Portfolio/src/lib/blog.ts
T

265 lines
7.6 KiB
TypeScript

/**
* 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<string, string> = {
'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<BlogPost[]>(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);
}