Convert from JSX to TSX
CI Pipeline / test (20) (pull_request) Failing after 1m17s
CI Pipeline / test (18) (pull_request) Failing after 1m28s
CI Pipeline / e2e (chromium) (pull_request) Failing after 1m33s
CI Pipeline / e2e (firefox) (pull_request) Failing after 1m27s
CI Pipeline / e2e (webkit) (pull_request) Failing after 1m34s
CI Pipeline / visual-regression (pull_request) Failing after 2m9s
CI Pipeline / storybook (pull_request) Failing after 1m5s
CI Pipeline / performance (pull_request) Failing after 1m42s
CI Pipeline / lint (pull_request) Failing after 49s
CI Pipeline / build (pull_request) Failing after 1m29s
CI Pipeline / test (20) (pull_request) Failing after 1m17s
CI Pipeline / test (18) (pull_request) Failing after 1m28s
CI Pipeline / e2e (chromium) (pull_request) Failing after 1m33s
CI Pipeline / e2e (firefox) (pull_request) Failing after 1m27s
CI Pipeline / e2e (webkit) (pull_request) Failing after 1m34s
CI Pipeline / visual-regression (pull_request) Failing after 2m9s
CI Pipeline / storybook (pull_request) Failing after 1m5s
CI Pipeline / performance (pull_request) Failing after 1m42s
CI Pipeline / lint (pull_request) Failing after 49s
CI Pipeline / build (pull_request) Failing after 1m29s
This commit is contained in:
@@ -6,10 +6,10 @@
|
||||
|
||||
/**
|
||||
* Get the correct asset path based on environment
|
||||
* @param {string} assetPath - The asset path (e.g., "assets/Logo.svg")
|
||||
* @returns {string} - The correct path for the current environment
|
||||
* @param assetPath - The asset path (e.g., "assets/Logo.svg")
|
||||
* @returns The correct path for the current environment
|
||||
*/
|
||||
export function getAssetPath(assetPath) {
|
||||
export function getAssetPath(assetPath: string): string {
|
||||
// Check if we're in Storybook environment
|
||||
const isStorybook =
|
||||
typeof window !== "undefined" &&
|
||||
@@ -55,4 +55,4 @@ export const ASSETS = {
|
||||
// Content page decorative shapes
|
||||
CONTENT_SHAPE_1: "assets/Content_Shape_1.svg",
|
||||
CONTENT_SHAPE_2: "assets/Content_Shape_2.svg",
|
||||
};
|
||||
} as const;
|
||||
+67
-50
@@ -3,10 +3,10 @@
|
||||
*/
|
||||
|
||||
// In-memory cache for blog posts
|
||||
const blogPostCache = new Map();
|
||||
const blogListCache = new Map();
|
||||
const tagCache = new Map();
|
||||
const authorCache = new Map();
|
||||
const blogPostCache = new Map<string, CacheEntry<unknown>>();
|
||||
const blogListCache = new Map<string, CacheEntry<unknown[]>>();
|
||||
const tagCache = new Map<string, CacheEntry<string[]>>();
|
||||
const authorCache = new Map<string, CacheEntry<string[]>>();
|
||||
|
||||
// Cache configuration
|
||||
const isDevelopment =
|
||||
@@ -17,13 +17,16 @@ const MAX_CACHE_SIZE = 100; // Maximum number of cached items
|
||||
/**
|
||||
* Cache entry with timestamp
|
||||
*/
|
||||
class CacheEntry {
|
||||
constructor(data) {
|
||||
class CacheEntry<T> {
|
||||
data: T;
|
||||
timestamp: number;
|
||||
|
||||
constructor(data: T) {
|
||||
this.data = data;
|
||||
this.timestamp = Date.now();
|
||||
}
|
||||
|
||||
isExpired() {
|
||||
isExpired(): boolean {
|
||||
// In development, always consider cache expired (no caching)
|
||||
if (isDevelopment) return true;
|
||||
return Date.now() - this.timestamp > CACHE_TTL;
|
||||
@@ -32,11 +35,11 @@ class CacheEntry {
|
||||
|
||||
/**
|
||||
* Get cached blog post data
|
||||
* @param {string} key - Cache key
|
||||
* @returns {Object|null} Cached data or null if not found/expired
|
||||
* @param key - Cache key
|
||||
* @returns Cached data or null if not found/expired
|
||||
*/
|
||||
function getCached(key) {
|
||||
const entry = blogPostCache.get(key);
|
||||
function getCached<T>(key: string): T | null {
|
||||
const entry = blogPostCache.get(key) as CacheEntry<T> | undefined;
|
||||
if (!entry || entry.isExpired()) {
|
||||
blogPostCache.delete(key);
|
||||
return null;
|
||||
@@ -46,10 +49,10 @@ function getCached(key) {
|
||||
|
||||
/**
|
||||
* Set cached blog post data
|
||||
* @param {string} key - Cache key
|
||||
* @param {Object} data - Data to cache
|
||||
* @param key - Cache key
|
||||
* @param data - Data to cache
|
||||
*/
|
||||
function setCached(key, data) {
|
||||
function setCached<T>(key: string, data: T): void {
|
||||
// Implement LRU eviction if cache is full
|
||||
if (blogPostCache.size >= MAX_CACHE_SIZE) {
|
||||
const oldestKey = blogPostCache.keys().next().value;
|
||||
@@ -62,7 +65,7 @@ function setCached(key, data) {
|
||||
/**
|
||||
* Clear expired cache entries
|
||||
*/
|
||||
function clearExpiredCache() {
|
||||
function clearExpiredCache(): void {
|
||||
for (const [key, entry] of blogPostCache.entries()) {
|
||||
if (entry.isExpired()) {
|
||||
blogPostCache.delete(key);
|
||||
@@ -73,7 +76,7 @@ function clearExpiredCache() {
|
||||
/**
|
||||
* Clear all caches
|
||||
*/
|
||||
export function clearAllCaches() {
|
||||
export function clearAllCaches(): void {
|
||||
blogPostCache.clear();
|
||||
blogListCache.clear();
|
||||
tagCache.clear();
|
||||
@@ -82,50 +85,50 @@ export function clearAllCaches() {
|
||||
|
||||
/**
|
||||
* Get cached blog post by slug
|
||||
* @param {string} slug - Blog post slug
|
||||
* @returns {Object|null} Cached blog post or null
|
||||
* @param slug - Blog post slug
|
||||
* @returns Cached blog post or null
|
||||
*/
|
||||
export function getCachedBlogPost(slug) {
|
||||
return getCached(`post:${slug}`);
|
||||
export function getCachedBlogPost<T>(slug: string): T | null {
|
||||
return getCached<T>(`post:${slug}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache blog post data
|
||||
* @param {string} slug - Blog post slug
|
||||
* @param {Object} postData - Blog post data
|
||||
* @param slug - Blog post slug
|
||||
* @param postData - Blog post data
|
||||
*/
|
||||
export function cacheBlogPost(slug, postData) {
|
||||
export function cacheBlogPost<T>(slug: string, postData: T): void {
|
||||
setCached(`post:${slug}`, postData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached blog post list
|
||||
* @param {string} key - Cache key for list (e.g., 'all', 'recent', 'tag:governance')
|
||||
* @returns {Array|null} Cached list or null
|
||||
* @param key - Cache key for list (e.g., 'all', 'recent', 'tag:governance')
|
||||
* @returns Cached list or null
|
||||
*/
|
||||
export function getCachedBlogList(key) {
|
||||
export function getCachedBlogList<T>(key: string): T[] | null {
|
||||
const entry = blogListCache.get(key);
|
||||
if (!entry || entry.isExpired()) {
|
||||
blogListCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.data;
|
||||
return entry.data as T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache blog post list
|
||||
* @param {string} key - Cache key
|
||||
* @param {Array} listData - List data to cache
|
||||
* @param key - Cache key
|
||||
* @param listData - List data to cache
|
||||
*/
|
||||
export function cacheBlogList(key, listData) {
|
||||
export function cacheBlogList<T>(key: string, listData: T[]): void {
|
||||
blogListCache.set(key, new CacheEntry(listData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached tags
|
||||
* @returns {Array|null} Cached tags or null
|
||||
* @returns Cached tags or null
|
||||
*/
|
||||
export function getCachedTags() {
|
||||
export function getCachedTags(): string[] | null {
|
||||
const entry = tagCache.get("all");
|
||||
if (!entry || entry.isExpired()) {
|
||||
tagCache.delete("all");
|
||||
@@ -136,17 +139,17 @@ export function getCachedTags() {
|
||||
|
||||
/**
|
||||
* Cache tags
|
||||
* @param {Array} tags - Tags to cache
|
||||
* @param tags - Tags to cache
|
||||
*/
|
||||
export function cacheTags(tags) {
|
||||
export function cacheTags(tags: string[]): void {
|
||||
tagCache.set("all", new CacheEntry(tags));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached authors
|
||||
* @returns {Array|null} Cached authors or null
|
||||
* @returns Cached authors or null
|
||||
*/
|
||||
export function getCachedAuthors() {
|
||||
export function getCachedAuthors(): string[] | null {
|
||||
const entry = authorCache.get("all");
|
||||
if (!entry || entry.isExpired()) {
|
||||
authorCache.delete("all");
|
||||
@@ -157,17 +160,17 @@ export function getCachedAuthors() {
|
||||
|
||||
/**
|
||||
* Cache authors
|
||||
* @param {Array} authors - Authors to cache
|
||||
* @param authors - Authors to cache
|
||||
*/
|
||||
export function cacheAuthors(authors) {
|
||||
export function cacheAuthors(authors: string[]): void {
|
||||
authorCache.set("all", new CacheEntry(authors));
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache for a specific blog post
|
||||
* @param {string} slug - Blog post slug
|
||||
* @param slug - Blog post slug
|
||||
*/
|
||||
export function invalidateBlogPostCache(slug) {
|
||||
export function invalidateBlogPostCache(slug: string): void {
|
||||
blogPostCache.delete(`post:${slug}`);
|
||||
// Also invalidate list caches since they might contain this post
|
||||
blogListCache.clear();
|
||||
@@ -176,15 +179,25 @@ export function invalidateBlogPostCache(slug) {
|
||||
/**
|
||||
* Invalidate all caches
|
||||
*/
|
||||
export function invalidateAllCaches() {
|
||||
export function invalidateAllCaches(): void {
|
||||
clearAllCaches();
|
||||
}
|
||||
|
||||
export interface CacheStats {
|
||||
blogPostCacheSize: number;
|
||||
blogListCacheSize: number;
|
||||
tagCacheSize: number;
|
||||
authorCacheSize: number;
|
||||
totalCacheSize: number;
|
||||
maxCacheSize: number;
|
||||
cacheTTL: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
* @returns {Object} Cache statistics
|
||||
* @returns Cache statistics
|
||||
*/
|
||||
export function getCacheStats() {
|
||||
export function getCacheStats(): CacheStats {
|
||||
clearExpiredCache();
|
||||
|
||||
return {
|
||||
@@ -193,7 +206,7 @@ export function getCacheStats() {
|
||||
tagCacheSize: tagCache.size,
|
||||
authorCacheSize: authorCache.size,
|
||||
totalCacheSize:
|
||||
blogPostCache.size + blogListCache.size + tagCache.size + authorCacheSize,
|
||||
blogPostCache.size + blogListCache.size + tagCache.size + authorCache.size,
|
||||
maxCacheSize: MAX_CACHE_SIZE,
|
||||
cacheTTL: CACHE_TTL,
|
||||
};
|
||||
@@ -201,10 +214,13 @@ export function getCacheStats() {
|
||||
|
||||
/**
|
||||
* Warm up cache with frequently accessed data
|
||||
* @param {Function} getAllPosts - Function to get all blog posts
|
||||
* @param {Function} getAllTags - Function to get all tags
|
||||
* @param getAllPosts - Function to get all blog posts
|
||||
* @param getAllTags - Function to get all tags
|
||||
*/
|
||||
export async function warmCache(getAllPosts, getAllTags) {
|
||||
export async function warmCache<T>(
|
||||
getAllPosts: () => T[],
|
||||
getAllTags: () => string[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Cache all blog posts
|
||||
const allPosts = getAllPosts();
|
||||
@@ -220,7 +236,8 @@ export async function warmCache(getAllPosts, getAllTags) {
|
||||
|
||||
// Cache individual posts (first 10)
|
||||
allPosts.slice(0, 10).forEach((post) => {
|
||||
cacheBlogPost(post.slug, post);
|
||||
const postWithSlug = post as { slug: string };
|
||||
cacheBlogPost(postWithSlug.slug, post);
|
||||
});
|
||||
|
||||
console.log("Cache warmed up successfully");
|
||||
@@ -231,9 +248,9 @@ export async function warmCache(getAllPosts, getAllTags) {
|
||||
|
||||
/**
|
||||
* Check if cache is healthy
|
||||
* @returns {boolean} True if cache is healthy
|
||||
* @returns True if cache is healthy
|
||||
*/
|
||||
export function isCacheHealthy() {
|
||||
export function isCacheHealthy(): boolean {
|
||||
try {
|
||||
clearExpiredCache();
|
||||
return blogPostCache.size < MAX_CACHE_SIZE;
|
||||
@@ -1,18 +1,31 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import matter from "gray-matter";
|
||||
import { validateBlogPost, sanitizeBlogPost } from "./validation.js";
|
||||
import {
|
||||
validateBlogPost,
|
||||
sanitizeBlogPost,
|
||||
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 {string} text - Text to convert to slug
|
||||
* @returns {string} URL-friendly slug
|
||||
* @param text - Text to convert to slug
|
||||
* @returns URL-friendly slug
|
||||
*/
|
||||
function generateSlug(text) {
|
||||
function generateSlug(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, "") // Remove special characters
|
||||
@@ -23,9 +36,9 @@ function generateSlug(text) {
|
||||
|
||||
/**
|
||||
* Get all blog post files from the content directory
|
||||
* @returns {Array} Array of file paths
|
||||
* @returns Array of file paths
|
||||
*/
|
||||
export function markdownToHtml(markdown) {
|
||||
export function markdownToHtml(markdown: string): string {
|
||||
if (!markdown) return "";
|
||||
|
||||
return (
|
||||
@@ -54,13 +67,13 @@ export function markdownToHtml(markdown) {
|
||||
);
|
||||
}
|
||||
|
||||
export function getBlogPostFiles() {
|
||||
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"),
|
||||
(file) => file.endsWith(".md") || file.endsWith(".mdx")
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error reading blog content directory:", error);
|
||||
@@ -70,10 +83,10 @@ export function getBlogPostFiles() {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param filePath - Path to the markdown file
|
||||
* @returns Parsed blog post data or null if invalid
|
||||
*/
|
||||
export function parseBlogPost(filePath) {
|
||||
export function parseBlogPost(filePath: string): BlogPost | null {
|
||||
const fullPath = path.join(process.cwd(), "content/blog", filePath);
|
||||
|
||||
try {
|
||||
@@ -84,7 +97,7 @@ export function parseBlogPost(filePath) {
|
||||
if (!validationResult.isValid) {
|
||||
console.error(
|
||||
`Validation errors for ${filePath}:`,
|
||||
validationResult.errors,
|
||||
validationResult.errors
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -108,51 +121,53 @@ export function parseBlogPost(filePath) {
|
||||
|
||||
/**
|
||||
* Get all blog posts, sorted by date
|
||||
* @returns {Array} Array of parsed blog post objects
|
||||
* @returns Array of parsed blog post objects
|
||||
*/
|
||||
export function getAllBlogPosts() {
|
||||
export function getAllBlogPosts(): BlogPost[] {
|
||||
const fileNames = getBlogPostFiles();
|
||||
const allPosts = fileNames
|
||||
.map((fileName) => parseBlogPost(fileName))
|
||||
.filter(Boolean) // Filter out nulls (invalid posts)
|
||||
.filter((post): post is BlogPost => post !== null) // Filter out nulls (invalid posts)
|
||||
.sort(
|
||||
(a, b) => new Date(b.frontmatter.date) - new Date(a.frontmatter.date),
|
||||
(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 {string} slug - The slug of the blog post
|
||||
* @returns {Object|null} The parsed blog post data or null if not found
|
||||
* @param slug - The slug of the blog post
|
||||
* @returns The parsed blog post data or null if not found
|
||||
*/
|
||||
export function getBlogPostBySlug(slug) {
|
||||
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 {string} currentPostSlug - The slug of the current post to exclude.
|
||||
* @param {string[]} relatedSlugs - Array of slugs for explicitly related posts.
|
||||
* @param {number} limit - Maximum number of related posts to return.
|
||||
* @returns {Array} Array of related blog post objects.
|
||||
* @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,
|
||||
relatedSlugs = [],
|
||||
limit = 3,
|
||||
) {
|
||||
currentPostSlug: string,
|
||||
relatedSlugs: string[] = [],
|
||||
limit: number = 3
|
||||
): BlogPost[] {
|
||||
const allPosts = getAllBlogPosts();
|
||||
const filteredPosts = allPosts.filter(
|
||||
(post) => post.slug !== currentPostSlug,
|
||||
(post) => post.slug !== currentPostSlug
|
||||
);
|
||||
|
||||
let related = [];
|
||||
let related: BlogPost[] = [];
|
||||
if (relatedSlugs && relatedSlugs.length > 0) {
|
||||
related = relatedSlugs
|
||||
.map((slug) => filteredPosts.find((post) => post.slug === slug))
|
||||
.filter(Boolean); // Filter out any related slugs that don't exist
|
||||
.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
|
||||
@@ -170,11 +185,11 @@ export function getRelatedBlogPosts(
|
||||
|
||||
/**
|
||||
* Get all unique tags from all blog posts.
|
||||
* @returns {string[]} Array of unique tags.
|
||||
* @returns Array of unique tags.
|
||||
*/
|
||||
export function getAllTags() {
|
||||
export function getAllTags(): string[] {
|
||||
const allPosts = getAllBlogPosts();
|
||||
const tags = new Set();
|
||||
const tags = new Set<string>();
|
||||
allPosts.forEach((post) => {
|
||||
if (post.frontmatter.tags) {
|
||||
post.frontmatter.tags.forEach((tag) => tags.add(tag));
|
||||
@@ -185,23 +200,23 @@ export function getAllTags() {
|
||||
|
||||
/**
|
||||
* Get blog posts filtered by a specific tag.
|
||||
* @param {string} tag - The tag to filter by.
|
||||
* @returns {Object[]} Array of blog post objects matching the tag.
|
||||
* @param tag - The tag to filter by.
|
||||
* @returns Array of blog post objects matching the tag.
|
||||
*/
|
||||
export function getBlogPostsByTag(tag) {
|
||||
export function getBlogPostsByTag(tag: string): BlogPost[] {
|
||||
const allPosts = getAllBlogPosts();
|
||||
return allPosts.filter(
|
||||
(post) => post.frontmatter.tags && post.frontmatter.tags.includes(tag),
|
||||
(post) => post.frontmatter.tags && post.frontmatter.tags.includes(tag)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search blog posts by text content
|
||||
* @param {string} query - Search query
|
||||
* @param {number} limit - Maximum number of results
|
||||
* @returns {Object[]} Array of matching blog post objects
|
||||
* @param query - Search query
|
||||
* @param limit - Maximum number of results
|
||||
* @returns Array of matching blog post objects
|
||||
*/
|
||||
export function searchBlogPosts(query, limit = 10) {
|
||||
export function searchBlogPosts(query: string, limit: number = 10): BlogPost[] {
|
||||
if (!query || query.trim() === "") return [];
|
||||
|
||||
const searchTerm = query.toLowerCase().trim();
|
||||
@@ -216,7 +231,7 @@ export function searchBlogPosts(query, limit = 10) {
|
||||
.includes(searchTerm);
|
||||
const contentMatch = post.content.toLowerCase().includes(searchTerm);
|
||||
const tagMatch = post.frontmatter.tags?.some((tag) =>
|
||||
tag.toLowerCase().includes(searchTerm),
|
||||
tag.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
|
||||
return titleMatch || descriptionMatch || contentMatch || tagMatch;
|
||||
@@ -227,31 +242,42 @@ export function searchBlogPosts(query, limit = 10) {
|
||||
|
||||
/**
|
||||
* Get blog posts by author
|
||||
* @param {string} author - Author name to filter by
|
||||
* @returns {Object[]} Array of blog post objects by the author
|
||||
* @param author - Author name to filter by
|
||||
* @returns Array of blog post objects by the author
|
||||
*/
|
||||
export function getBlogPostsByAuthor(author) {
|
||||
export function getBlogPostsByAuthor(author: string): BlogPost[] {
|
||||
const allPosts = getAllBlogPosts();
|
||||
return allPosts.filter(
|
||||
(post) => post.frontmatter.author.toLowerCase() === author.toLowerCase(),
|
||||
(post) => post.frontmatter.author.toLowerCase() === author.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent blog posts
|
||||
* @param {number} limit - Maximum number of posts to return
|
||||
* @returns {Object[]} Array of recent blog post objects
|
||||
* @param limit - Maximum number of posts to return
|
||||
* @returns Array of recent blog post objects
|
||||
*/
|
||||
export function getRecentBlogPosts(limit = 5) {
|
||||
export function getRecentBlogPosts(limit: number = 5): BlogPost[] {
|
||||
const allPosts = getAllBlogPosts();
|
||||
return allPosts.slice(0, limit);
|
||||
}
|
||||
|
||||
export interface BlogStats {
|
||||
totalPosts: number;
|
||||
totalTags: number;
|
||||
totalAuthors: number;
|
||||
dateRange: {
|
||||
earliest: string | null;
|
||||
latest: string | null;
|
||||
};
|
||||
averagePostsPerMonth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get blog post statistics
|
||||
* @returns {Object} Statistics about blog posts
|
||||
* @returns Statistics about blog posts
|
||||
*/
|
||||
export function getBlogStats() {
|
||||
export function getBlogStats(): BlogStats {
|
||||
const allPosts = getAllBlogPosts();
|
||||
const tags = getAllTags();
|
||||
|
||||
@@ -272,11 +298,13 @@ export function getBlogStats() {
|
||||
(allPosts.length /
|
||||
Math.max(
|
||||
1,
|
||||
(new Date(allPosts[0].frontmatter.date) -
|
||||
new Date(allPosts[allPosts.length - 1].frontmatter.date)) /
|
||||
(1000 * 60 * 60 * 24 * 30),
|
||||
(new Date(allPosts[0].frontmatter.date).getTime() -
|
||||
new Date(
|
||||
allPosts[allPosts.length - 1].frontmatter.date
|
||||
).getTime()) /
|
||||
(1000 * 60 * 60 * 24 * 30)
|
||||
)) *
|
||||
10,
|
||||
10
|
||||
) / 10
|
||||
: 0,
|
||||
};
|
||||
+79
-40
@@ -2,11 +2,47 @@
|
||||
* MDX processing utilities for enhanced markdown content
|
||||
*/
|
||||
|
||||
export interface Heading {
|
||||
level: number;
|
||||
text: string;
|
||||
id: string;
|
||||
line: number;
|
||||
}
|
||||
|
||||
export interface Link {
|
||||
text: string;
|
||||
url: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface Image {
|
||||
alt: string;
|
||||
src: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface ProcessedMarkdown {
|
||||
content: string;
|
||||
htmlContent: string;
|
||||
headings: Heading[];
|
||||
links: Link[];
|
||||
images: Image[];
|
||||
}
|
||||
|
||||
export interface ProcessedFrontmatter {
|
||||
publishedDate: Date;
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
isRecent: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date consistently across the markdown pipeline
|
||||
* Uses "Month Year" format (e.g., "April 2025")
|
||||
*/
|
||||
export function formatDate(dateString) {
|
||||
export function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
@@ -16,10 +52,10 @@ export function formatDate(dateString) {
|
||||
|
||||
/**
|
||||
* Process markdown content and extract metadata
|
||||
* @param {string} markdown - Raw markdown content
|
||||
* @returns {object} Processed content with metadata
|
||||
* @param markdown - Raw markdown content
|
||||
* @returns Processed content with metadata
|
||||
*/
|
||||
export function processMarkdown(markdown) {
|
||||
export function processMarkdown(markdown: string): ProcessedMarkdown {
|
||||
if (!markdown) {
|
||||
return {
|
||||
content: "",
|
||||
@@ -53,13 +89,13 @@ export function processMarkdown(markdown) {
|
||||
|
||||
/**
|
||||
* Extract all headings from markdown content
|
||||
* @param {string} markdown - Raw markdown content
|
||||
* @returns {Array} Array of heading objects with level, text, and id
|
||||
* @param markdown - Raw markdown content
|
||||
* @returns Array of heading objects with level, text, and id
|
||||
*/
|
||||
function extractHeadings(markdown) {
|
||||
function extractHeadings(markdown: string): Heading[] {
|
||||
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
|
||||
const headings = [];
|
||||
let match;
|
||||
const headings: Heading[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = headingRegex.exec(markdown)) !== null) {
|
||||
const level = match[1].length;
|
||||
@@ -79,13 +115,13 @@ function extractHeadings(markdown) {
|
||||
|
||||
/**
|
||||
* Extract all links from markdown content
|
||||
* @param {string} markdown - Raw markdown content
|
||||
* @returns {Array} Array of link objects
|
||||
* @param markdown - Raw markdown content
|
||||
* @returns Array of link objects
|
||||
*/
|
||||
function extractLinks(markdown) {
|
||||
function extractLinks(markdown: string): Link[] {
|
||||
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
const links = [];
|
||||
let match;
|
||||
const links: Link[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = linkRegex.exec(markdown)) !== null) {
|
||||
links.push({
|
||||
@@ -100,13 +136,13 @@ function extractLinks(markdown) {
|
||||
|
||||
/**
|
||||
* Extract all images from markdown content
|
||||
* @param {string} markdown - Raw markdown content
|
||||
* @returns {Array} Array of image objects
|
||||
* @param markdown - Raw markdown content
|
||||
* @returns Array of image objects
|
||||
*/
|
||||
function extractImages(markdown) {
|
||||
function extractImages(markdown: string): Image[] {
|
||||
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||
const images = [];
|
||||
let match;
|
||||
const images: Image[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = imageRegex.exec(markdown)) !== null) {
|
||||
images.push({
|
||||
@@ -121,10 +157,10 @@ function extractImages(markdown) {
|
||||
|
||||
/**
|
||||
* Generate a unique ID for a heading
|
||||
* @param {string} text - Heading text
|
||||
* @returns {string} Unique ID
|
||||
* @param text - Heading text
|
||||
* @returns Unique ID
|
||||
*/
|
||||
function generateHeadingId(text) {
|
||||
function generateHeadingId(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
@@ -137,10 +173,10 @@ function generateHeadingId(text) {
|
||||
* Convert markdown to HTML with enhanced formatting
|
||||
* - Preserves extra blank lines between paragraphs as visible gaps
|
||||
* (each extra blank line becomes <p class="md-gap"> </p>)
|
||||
* @param {string} markdown - Raw markdown content
|
||||
* @returns {string} HTML content
|
||||
* @param markdown - Raw markdown content
|
||||
* @returns HTML content
|
||||
*/
|
||||
function markdownToHtml(markdown) {
|
||||
function markdownToHtml(markdown: string): string {
|
||||
if (!markdown) return "";
|
||||
|
||||
// Normalize line endings
|
||||
@@ -266,10 +302,10 @@ function markdownToHtml(markdown) {
|
||||
|
||||
/**
|
||||
* Generate a table of contents from headings
|
||||
* @param {Array} headings - Array of heading objects
|
||||
* @returns {string} HTML table of contents
|
||||
* @param headings - Array of heading objects
|
||||
* @returns HTML table of contents
|
||||
*/
|
||||
export function generateTableOfContents(headings) {
|
||||
export function generateTableOfContents(headings: Heading[]): string {
|
||||
if (!headings || headings.length === 0) return "";
|
||||
|
||||
let toc = '<nav class="table-of-contents"><h4>Table of Contents</h4><ul>';
|
||||
@@ -285,18 +321,21 @@ export function generateTableOfContents(headings) {
|
||||
|
||||
/**
|
||||
* Process frontmatter with enhanced validation
|
||||
* @param {Object} frontmatter - Raw frontmatter data
|
||||
* @returns {Object} Processed and validated frontmatter
|
||||
* @param frontmatter - Raw frontmatter data
|
||||
* @returns Processed and validated frontmatter
|
||||
*/
|
||||
export function processFrontmatter(frontmatter) {
|
||||
export function processFrontmatter(
|
||||
frontmatter: Record<string, unknown>,
|
||||
): ProcessedFrontmatter {
|
||||
// Add computed fields
|
||||
const processed = {
|
||||
const date = frontmatter.date as string;
|
||||
const processed: ProcessedFrontmatter = {
|
||||
...frontmatter,
|
||||
publishedDate: new Date(frontmatter.date),
|
||||
year: new Date(frontmatter.date).getFullYear(),
|
||||
month: new Date(frontmatter.date).getMonth() + 1,
|
||||
day: new Date(frontmatter.date).getDate(),
|
||||
isRecent: isRecentPost(frontmatter.date),
|
||||
publishedDate: new Date(date),
|
||||
year: new Date(date).getFullYear(),
|
||||
month: new Date(date).getMonth() + 1,
|
||||
day: new Date(date).getDate(),
|
||||
isRecent: isRecentPost(date),
|
||||
};
|
||||
|
||||
return processed;
|
||||
@@ -304,10 +343,10 @@ export function processFrontmatter(frontmatter) {
|
||||
|
||||
/**
|
||||
* Check if a post is recent (within last 30 days)
|
||||
* @param {string} date - Post date string
|
||||
* @returns {boolean} True if post is recent
|
||||
* @param date - Post date string
|
||||
* @returns True if post is recent
|
||||
*/
|
||||
function isRecentPost(date) {
|
||||
function isRecentPost(date: string): boolean {
|
||||
const postDate = new Date(date);
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Shared type definitions for the CommunityRule application
|
||||
*/
|
||||
|
||||
// Re-export types from other modules for convenience
|
||||
export type {
|
||||
BlogPost,
|
||||
BlogStats,
|
||||
} from "./content";
|
||||
|
||||
export type {
|
||||
BlogPostFrontmatter,
|
||||
ValidationResult,
|
||||
} from "./validation";
|
||||
|
||||
export type {
|
||||
Heading,
|
||||
Link,
|
||||
Image,
|
||||
ProcessedMarkdown,
|
||||
ProcessedFrontmatter,
|
||||
} from "./mdx";
|
||||
|
||||
export type {
|
||||
CacheStats,
|
||||
} from "./cache";
|
||||
|
||||
// Additional shared types
|
||||
export interface ComponentProps {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface PageProps {
|
||||
params?: Record<string, string | string[]>;
|
||||
searchParams?: Record<string, string | string[]>;
|
||||
}
|
||||
@@ -2,10 +2,54 @@
|
||||
* Content validation utilities for blog posts
|
||||
*/
|
||||
|
||||
export interface BlogPostSchemaField {
|
||||
type: "string" | "array" | "object";
|
||||
required: boolean;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
pattern?: RegExp;
|
||||
default?: unknown;
|
||||
items?: {
|
||||
type: string;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
};
|
||||
properties?: Record<string, BlogPostSchemaField>;
|
||||
}
|
||||
|
||||
export interface BlogPostSchema {
|
||||
[key: string]: BlogPostSchemaField;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface BlogPostFrontmatter {
|
||||
title: string;
|
||||
description: string;
|
||||
author: string;
|
||||
date: string;
|
||||
related?: string[];
|
||||
thumbnail?: {
|
||||
vertical?: string;
|
||||
horizontal?: string;
|
||||
};
|
||||
banner?: {
|
||||
horizontal?: string;
|
||||
};
|
||||
background?: {
|
||||
color?: string;
|
||||
};
|
||||
tags?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blog post frontmatter schema
|
||||
*/
|
||||
export const BLOG_POST_SCHEMA = {
|
||||
export const BLOG_POST_SCHEMA: BlogPostSchema = {
|
||||
title: {
|
||||
type: "string",
|
||||
required: true,
|
||||
@@ -80,11 +124,13 @@ export const BLOG_POST_SCHEMA = {
|
||||
|
||||
/**
|
||||
* Validate a blog post's frontmatter
|
||||
* @param {Object} frontmatter - The frontmatter object to validate
|
||||
* @returns {Object} Validation result with isValid boolean and errors array
|
||||
* @param frontmatter - The frontmatter object to validate
|
||||
* @returns Validation result with isValid boolean and errors array
|
||||
*/
|
||||
export function validateBlogPost(frontmatter) {
|
||||
const errors = [];
|
||||
export function validateBlogPost(
|
||||
frontmatter: Record<string, unknown>,
|
||||
): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Check required fields first
|
||||
for (const [field, config] of Object.entries(BLOG_POST_SCHEMA)) {
|
||||
@@ -116,12 +162,13 @@ export function validateBlogPost(frontmatter) {
|
||||
|
||||
// Length validation for strings
|
||||
if (config.type === "string" && typeof frontmatter[field] === "string") {
|
||||
if (config.minLength && frontmatter[field].length < config.minLength) {
|
||||
const fieldValue = frontmatter[field] as string;
|
||||
if (config.minLength && fieldValue.length < config.minLength) {
|
||||
errors.push(
|
||||
`Field ${field} must be at least ${config.minLength} characters`,
|
||||
);
|
||||
}
|
||||
if (config.maxLength && frontmatter[field].length > config.maxLength) {
|
||||
if (config.maxLength && fieldValue.length > config.maxLength) {
|
||||
errors.push(
|
||||
`Field ${field} must be no more than ${config.maxLength} characters`,
|
||||
);
|
||||
@@ -129,23 +176,38 @@ export function validateBlogPost(frontmatter) {
|
||||
}
|
||||
|
||||
// Pattern validation
|
||||
if (config.pattern && !config.pattern.test(frontmatter[field])) {
|
||||
if (
|
||||
config.pattern &&
|
||||
typeof frontmatter[field] === "string" &&
|
||||
!config.pattern.test(frontmatter[field] as string)
|
||||
) {
|
||||
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") {
|
||||
const fieldArray = frontmatter[field] as unknown[];
|
||||
for (let i = 0; i < fieldArray.length; i++) {
|
||||
const item = fieldArray[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) {
|
||||
if (
|
||||
config.items &&
|
||||
typeof item === "string" &&
|
||||
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) {
|
||||
if (
|
||||
config.items &&
|
||||
typeof item === "string" &&
|
||||
config.items.maxLength &&
|
||||
item.length > config.items.maxLength
|
||||
) {
|
||||
errors.push(
|
||||
`Item ${i} in ${field} must be no more than ${config.items.maxLength} characters`,
|
||||
);
|
||||
@@ -163,11 +225,13 @@ export function validateBlogPost(frontmatter) {
|
||||
|
||||
/**
|
||||
* Sanitize and normalize frontmatter data
|
||||
* @param {Object} frontmatter - Raw frontmatter data
|
||||
* @returns {Object} Sanitized frontmatter
|
||||
* @param frontmatter - Raw frontmatter data
|
||||
* @returns Sanitized frontmatter
|
||||
*/
|
||||
export function sanitizeBlogPost(frontmatter) {
|
||||
const sanitized = {};
|
||||
export function sanitizeBlogPost(
|
||||
frontmatter: Record<string, unknown>,
|
||||
): BlogPostFrontmatter {
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
|
||||
for (const [field, config] of Object.entries(BLOG_POST_SCHEMA)) {
|
||||
if (frontmatter[field] !== undefined) {
|
||||
@@ -187,5 +251,5 @@ export function sanitizeBlogPost(frontmatter) {
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
return sanitized as BlogPostFrontmatter;
|
||||
}
|
||||
Reference in New Issue
Block a user