import fs from "fs"; import path from "path"; import matter from "gray-matter"; import { validateBlogPost, sanitizeBlogPost } from "./validation"; import type { BlogPostFrontmatter } from "./validation"; /** * Content processing utilities for blog posts */ export interface BlogPost { slug: string; frontmatter: BlogPostFrontmatter; content: string; htmlContent: string; filePath: string; lastModified: Date; } /** * Generate a URL-friendly slug from a string * @param text - Text to convert to slug * @returns URL-friendly slug */ function generateSlug(text: string): string { return text .toLowerCase() .replace(/[^\w\s-]/g, "") // Remove special characters .replace(/\s+/g, "-") // Replace spaces with hyphens .replace(/-+/g, "-") // Replace multiple hyphens with single .trim(); } /** * Get all blog post files from the content directory * @returns Array of file paths */ export function markdownToHtml(markdown: string): string { if (!markdown) return ""; return ( markdown // Headers .replace(/^### (.*$)/gim, "
") .replace(/^(?!<[h|u|li])(.*$)/gim, "
$1
") // Clean up empty paragraphs .replace(/<\/p>/g, "") .replace(/
(.*?)<\/p>/g, (match, content) => {
return content.trim() ? match : "";
})
);
}
export function getBlogPostFiles(): string[] {
const contentDirectory = path.join(process.cwd(), "content/blog");
try {
const files = fs.readdirSync(contentDirectory);
return files.filter(
(file) => file.endsWith(".md") || file.endsWith(".mdx"),
);
} catch (error) {
console.error("Error reading blog content directory:", error);
return [];
}
}
/**
* Parse a single blog post file
* @param filePath - Path to the markdown file
* @returns Parsed blog post data or null if invalid
*/
export function parseBlogPost(filePath: string): BlogPost | null {
const fullPath = path.join(process.cwd(), "content/blog", filePath);
try {
const fileContents = fs.readFileSync(fullPath, "utf8");
const { data, content } = matter(fileContents);
const validationResult = validateBlogPost(data);
if (!validationResult.isValid) {
console.error(
`Validation errors for ${filePath}:`,
validationResult.errors,
);
return null;
}
const sanitizedFrontmatter = sanitizeBlogPost(data);
const slug = generateSlug(filePath.replace(/\.mdx?$/, ""));
return {
slug,
frontmatter: sanitizedFrontmatter,
content,
htmlContent: markdownToHtml(content),
filePath,
lastModified: fs.statSync(fullPath).mtime,
};
} catch (error) {
console.error(`Error parsing blog post file ${filePath}:`, error);
return null;
}
}
/**
* Get all blog posts, sorted by date
* @returns Array of parsed blog post objects
*/
export function getAllBlogPosts(): BlogPost[] {
const fileNames = getBlogPostFiles();
const allPosts = fileNames
.map((fileName) => parseBlogPost(fileName))
.filter((post): post is BlogPost => post !== null) // Filter out nulls (invalid posts)
.sort(
(a, b) =>
new Date(b.frontmatter.date).getTime() -
new Date(a.frontmatter.date).getTime(),
); // Sort by date descending
return allPosts;
}
/**
* Get a single blog post by its slug
* @param slug - The slug of the blog post
* @returns The parsed blog post data or null if not found
*/
export function getBlogPostBySlug(slug: string): BlogPost | null {
const allPosts = getAllBlogPosts();
return allPosts.find((post) => post.slug === slug) || null;
}
/**
* Get related blog posts based on provided slugs or fallback to recent posts.
* @param currentPostSlug - The slug of the current post to exclude.
* @param relatedSlugs - Array of slugs for explicitly related posts.
* @param limit - Maximum number of related posts to return.
* @returns Array of related blog post objects.
*/
export function getRelatedBlogPosts(
currentPostSlug: string,
relatedSlugs: string[] = [],
limit: number = 3,
): BlogPost[] {
const allPosts = getAllBlogPosts();
const filteredPosts = allPosts.filter(
(post) => post.slug !== currentPostSlug,
);
let related: BlogPost[] = [];
if (relatedSlugs && relatedSlugs.length > 0) {
related = relatedSlugs
.map((slug) => filteredPosts.find((post) => post.slug === slug))
.filter((post): post is BlogPost => post !== undefined); // Filter out any related slugs that don't exist
}
// If not enough related posts, or no related slugs provided, fill with recent posts
if (related.length < limit) {
const remainingSlots = limit - related.length;
const existingRelatedSlugs = new Set(related.map((p) => p.slug));
const recentPosts = filteredPosts
.filter((post) => !existingRelatedSlugs.has(post.slug))
.slice(0, remainingSlots);
related = [...related, ...recentPosts];
}
return related.slice(0, limit);
}
/**
* Get all unique tags from all blog posts.
* @returns Array of unique tags.
*/
export function getAllTags(): string[] {
const allPosts = getAllBlogPosts();
const tags = new Set