182 lines
5.3 KiB
TypeScript
182 lines
5.3 KiB
TypeScript
import fs from 'fs'
|
|
import path from 'path'
|
|
import matter from 'gray-matter'
|
|
import { remark } from 'remark'
|
|
import remarkGfm from 'remark-gfm'
|
|
import { FrontMatter, Post } from './types/frontmatter'
|
|
import { generateExcerpt } from './utils'
|
|
import { remarkCopyImages } from './remark-copy-images'
|
|
import { remarkInternalLinks } from './remark-internal-links'
|
|
|
|
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')
|
|
}
|
|
|
|
// CRITICAL: Verify resolved path stays within content directory
|
|
const resolvedPath = path.resolve(POSTS_PATH, normalized)
|
|
const allowedBasePath = path.resolve(POSTS_PATH)
|
|
|
|
if (!resolvedPath.startsWith(allowedBasePath)) {
|
|
throw new Error('Path traversal attempt detected')
|
|
}
|
|
|
|
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 async function getPostBySlug(slug: string | string[]): Promise<Post | null> {
|
|
const slugArray = Array.isArray(slug) ? slug : slug.split('/')
|
|
const sanitized = slugArray.map(s => sanitizePath(s))
|
|
const fullPath = path.join(POSTS_PATH, ...sanitized) + '.md'
|
|
|
|
if (!fs.existsSync(fullPath)) {
|
|
return null
|
|
}
|
|
|
|
const fileContents = fs.readFileSync(fullPath, 'utf8')
|
|
const { data, content } = matter(fileContents)
|
|
const frontmatter = validateFrontmatter(data)
|
|
|
|
const processed = await remark()
|
|
.use(remarkGfm)
|
|
.use(remarkCopyImages, {
|
|
contentDir: 'content/blog',
|
|
publicDir: 'public/blog',
|
|
currentSlug: sanitized.join('/'),
|
|
})
|
|
.use(remarkInternalLinks)
|
|
.process(content)
|
|
|
|
const processedContent = processed.toString()
|
|
|
|
return {
|
|
slug: sanitized.join('/'),
|
|
frontmatter,
|
|
content: processedContent,
|
|
readingTime: calculateReadingTime(processedContent),
|
|
excerpt: generateExcerpt(processedContent),
|
|
}
|
|
}
|
|
|
|
export async function getAllPosts(includeContent = false): Promise<Post[]> {
|
|
const posts: Post[] = []
|
|
|
|
async function walkDir(dir: string, prefix = ''): Promise<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()) {
|
|
await walkDir(filePath, prefix ? `${prefix}/${file}` : file)
|
|
} else if (file.endsWith('.md')) {
|
|
const slug = prefix ? `${prefix}/${file.replace(/\.md$/, '')}` : file.replace(/\.md$/, '')
|
|
try {
|
|
const post = await getPostBySlug(slug.split('/'))
|
|
if (post && !post.frontmatter.draft) {
|
|
posts.push(includeContent ? post : { ...post, content: '' })
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error loading post ${slug}:`, error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (fs.existsSync(POSTS_PATH)) {
|
|
await walkDir(POSTS_PATH)
|
|
}
|
|
|
|
return posts.sort(
|
|
(a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime()
|
|
)
|
|
}
|
|
|
|
export async function getRelatedPosts(currentSlug: string, limit = 3): Promise<Post[]> {
|
|
const currentPost = await getPostBySlug(currentSlug)
|
|
if (!currentPost) return []
|
|
|
|
const allPosts = await getAllPosts(false)
|
|
const { category, tags } = currentPost.frontmatter
|
|
|
|
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
|
|
}
|