Infrastructure setup

This commit is contained in:
adilallo
2025-09-04 10:27:29 -06:00
parent 0ee9725f3f
commit 3d6d4ed251
9 changed files with 2709 additions and 12 deletions
+155
View File
@@ -0,0 +1,155 @@
import fs from "fs";
import path from "path";
import matter from "gray-matter";
import { validateBlogPost, sanitizeBlogPost } from "./validation.js";
/**
* Content processing utilities for blog posts
*/
/**
* Get all blog post files from the content directory
* @returns {Array} Array of file paths
*/
export function getBlogPostFiles() {
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 {string} filePath - Path to the markdown file
* @returns {Object|null} Parsed blog post data or null if invalid
*/
export function parseBlogPost(filePath) {
try {
const fullPath = path.join(process.cwd(), "content/blog", filePath);
const fileContents = fs.readFileSync(fullPath, "utf8");
const { data: frontmatter, content } = matter(fileContents);
// Validate frontmatter
const validation = validateBlogPost(frontmatter);
if (!validation.isValid) {
console.error(`Validation failed for ${filePath}:`, validation.errors);
return null;
}
// Sanitize frontmatter
const sanitized = sanitizeBlogPost(frontmatter);
// Generate slug from filename
const slug = filePath.replace(/\.(md|mdx)$/, "");
return {
slug,
frontmatter: sanitized,
content,
filePath,
};
} catch (error) {
console.error(`Error parsing blog post ${filePath}:`, error);
return null;
}
}
/**
* Get all blog posts with parsed data
* @returns {Array} Array of parsed blog post objects
*/
export function getAllBlogPosts() {
const files = getBlogPostFiles();
const posts = files
.map((file) => parseBlogPost(file))
.filter((post) => post !== null)
.sort(
(a, b) => new Date(b.frontmatter.date) - new Date(a.frontmatter.date)
);
return posts;
}
/**
* Get a single blog post by slug
* @param {string} slug - The post slug
* @returns {Object|null} Parsed blog post or null if not found
*/
export function getBlogPostBySlug(slug) {
const files = getBlogPostFiles();
const file = files.find((f) => f.replace(/\.(md|mdx)$/, "") === slug);
if (!file) {
return null;
}
return parseBlogPost(file);
}
/**
* Get related blog posts
* @param {string} currentSlug - Current post slug
* @param {Array} relatedSlugs - Array of related post slugs
* @param {number} limit - Maximum number of related posts to return
* @returns {Array} Array of related blog posts
*/
export function getRelatedBlogPosts(currentSlug, relatedSlugs = [], limit = 3) {
if (!relatedSlugs || relatedSlugs.length === 0) {
// Fallback: get posts with similar tags or recent posts
const allPosts = getAllBlogPosts();
return allPosts.filter((post) => post.slug !== currentSlug).slice(0, limit);
}
const allPosts = getAllBlogPosts();
const related = allPosts
.filter((post) => relatedSlugs.includes(post.slug))
.slice(0, limit);
// If we don't have enough related posts, fill with recent ones
if (related.length < limit) {
const recent = allPosts
.filter(
(post) => post.slug !== currentSlug && !relatedSlugs.includes(post.slug)
)
.slice(0, limit - related.length);
return [...related, ...recent];
}
return related;
}
/**
* Get all unique tags from blog posts
* @returns {Array} Array of unique tags
*/
export function getAllTags() {
const posts = getAllBlogPosts();
const tags = new Set();
posts.forEach((post) => {
if (post.frontmatter.tags) {
post.frontmatter.tags.forEach((tag) => tags.add(tag));
}
});
return Array.from(tags).sort();
}
/**
* Get blog posts by tag
* @param {string} tag - Tag to filter by
* @returns {Array} Array of blog posts with the specified tag
*/
export function getBlogPostsByTag(tag) {
const posts = getAllBlogPosts();
return posts.filter(
(post) => post.frontmatter.tags && post.frontmatter.tags.includes(tag)
);
}
+154
View File
@@ -0,0 +1,154 @@
/**
* Content validation utilities for blog posts
*/
/**
* Blog post frontmatter schema
*/
export const BLOG_POST_SCHEMA = {
title: {
type: "string",
required: true,
minLength: 1,
maxLength: 100,
},
description: {
type: "string",
required: true,
minLength: 10,
maxLength: 200,
},
author: {
type: "string",
required: true,
minLength: 1,
maxLength: 50,
},
date: {
type: "string",
required: true,
pattern: /^\d{4}-\d{2}-\d{2}$/, // YYYY-MM-DD format
},
tags: {
type: "array",
required: false,
default: [],
items: {
type: "string",
minLength: 1,
maxLength: 20,
},
},
related: {
type: "array",
required: false,
default: [],
items: {
type: "string",
minLength: 1,
maxLength: 50,
},
},
};
/**
* Validate a blog post's frontmatter
* @param {Object} frontmatter - The frontmatter object to validate
* @returns {Object} Validation result with isValid boolean and errors array
*/
export function validateBlogPost(frontmatter) {
const errors = [];
// Check required fields first
for (const [field, config] of Object.entries(BLOG_POST_SCHEMA)) {
if (config.required && !frontmatter[field]) {
errors.push(`Missing required field: ${field}`);
}
}
// If we have missing required fields, don't continue with other validations
if (errors.length > 0) {
return {
isValid: false,
errors,
};
}
// Now validate field types and constraints
for (const [field, config] of Object.entries(BLOG_POST_SCHEMA)) {
if (frontmatter[field] !== undefined) {
// Type validation
if (config.type === "string" && typeof frontmatter[field] !== "string") {
errors.push(`Field ${field} must be a string`);
} else if (
config.type === "array" &&
!Array.isArray(frontmatter[field])
) {
errors.push(`Field ${field} must be an array`);
}
// Length validation for strings
if (config.type === "string" && typeof frontmatter[field] === "string") {
if (config.minLength && frontmatter[field].length < config.minLength) {
errors.push(
`Field ${field} must be at least ${config.minLength} characters`
);
}
if (config.maxLength && frontmatter[field].length > config.maxLength) {
errors.push(
`Field ${field} must be no more than ${config.maxLength} characters`
);
}
}
// Pattern validation
if (config.pattern && !config.pattern.test(frontmatter[field])) {
errors.push(`Field ${field} format is invalid`);
}
// Array item validation
if (config.type === "array" && Array.isArray(frontmatter[field])) {
for (let i = 0; i < frontmatter[field].length; i++) {
const item = frontmatter[field][i];
if (config.items.type === "string" && typeof item !== "string") {
errors.push(`Item ${i} in ${field} must be a string`);
}
if (config.items.minLength && item.length < config.items.minLength) {
errors.push(
`Item ${i} in ${field} must be at least ${config.items.minLength} characters`
);
}
if (config.items.maxLength && item.length > config.items.maxLength) {
errors.push(
`Item ${i} in ${field} must be no more than ${config.items.maxLength} characters`
);
}
}
}
}
}
return {
isValid: errors.length === 0,
errors,
};
}
/**
* Sanitize and normalize frontmatter data
* @param {Object} frontmatter - Raw frontmatter data
* @returns {Object} Sanitized frontmatter
*/
export function sanitizeBlogPost(frontmatter) {
const sanitized = {};
for (const [field, config] of Object.entries(BLOG_POST_SCHEMA)) {
if (frontmatter[field] !== undefined) {
sanitized[field] = frontmatter[field];
} else if (config.default !== undefined) {
sanitized[field] = config.default;
}
}
return sanitized;
}