🪛 markdown renderer

This commit is contained in:
RJ
2025-11-07 16:13:10 +02:00
parent d29853c07d
commit 651beb2de6
10 changed files with 667 additions and 2 deletions

149
lib/markdown.ts Normal file
View File

@@ -0,0 +1,149 @@
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[]): Post {
const sanitized = slug.map(s => sanitizePath(s));
const fullPath = path.join(POSTS_PATH, ...sanitized) + '.md';
if (!fs.existsSync(fullPath)) {
throw new Error('Post not found');
}
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.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 function getRelatedPosts(currentSlug: string, category: string, tags: string[], limit = 3): Post[] {
const allPosts = getAllPosts(false);
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;
}

22
lib/types/frontmatter.ts Normal file
View File

@@ -0,0 +1,22 @@
export interface FrontMatter {
title: string;
description: string;
date: string;
author: string;
category: string;
tags: string[];
image?: string;
draft?: boolean;
}
export interface Post {
slug: string;
frontmatter: FrontMatter;
content: string;
readingTime: number;
excerpt: string;
}
export interface BlogParams {
slug: string[];
}

53
lib/utils.ts Normal file
View File

@@ -0,0 +1,53 @@
export function formatDate(dateString: string): string {
const date = new Date(dateString);
const months = [
'ianuarie', 'februarie', 'martie', 'aprilie', 'mai', 'iunie',
'iulie', 'august', 'septembrie', 'octombrie', 'noiembrie', 'decembrie'
];
return `${date.getDate()} ${months[date.getMonth()]} ${date.getFullYear()}`;
}
export function formatRelativeDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffTime = Math.abs(now.getTime() - date.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'astăzi';
if (diffDays === 1) return 'ieri';
if (diffDays < 7) return `acum ${diffDays} zile`;
if (diffDays < 30) return `acum ${Math.floor(diffDays / 7)} săptămâni`;
if (diffDays < 365) return `acum ${Math.floor(diffDays / 30)} luni`;
return `acum ${Math.floor(diffDays / 365)} ani`;
}
export function generateExcerpt(content: string, maxLength = 160): string {
const text = content
.replace(/^---[\s\S]*?---/, '')
.replace(/!\[.*?\]\(.*?\)/g, '')
.replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1')
.replace(/[#*`]/g, '')
.trim();
if (text.length <= maxLength) return text;
const truncated = text.slice(0, maxLength);
const lastSpace = truncated.lastIndexOf(' ');
return truncated.slice(0, lastSpace) + '...';
}
export function generateSlug(title: string): string {
const romanianMap: Record<string, string> = {
'ă': 'a', 'â': 'a', 'î': 'i', 'ș': 's', 'ț': 't',
'Ă': 'a', 'Â': 'a', 'Î': 'i', 'Ș': 's', 'Ț': 't'
};
return title
.split('')
.map(char => romanianMap[char] || char)
.join('')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}