import fs from "fs"; import path from "path"; import matter from "gray-matter"; import { CONTENT_DIR, ITEMS_PER_PAGE } from "./config"; export interface PostFrontMatter { pid?: number; title: string; date: string; slug: string; draft: boolean; categories?: string[]; tags?: string[]; author?: string; layout?: string; [key: string]: unknown; } export interface Post { slug: string; frontMatter: PostFrontMatter; content: string; filePath: string; dirPath: string; } export interface PostListItem { slug: string; title: string; date: string; draft: boolean; categories: string[]; tags: string[]; author: string; } function parseDate(dateStr: string): Date { const d = new Date(dateStr); return isNaN(d.getTime()) ? new Date(0) : d; } function findPostDirectories(): string[] { const result: string[] = []; if (!fs.existsSync(CONTENT_DIR)) return result; const years = fs.readdirSync(CONTENT_DIR).filter((f) => { const full = path.join(CONTENT_DIR, f); return fs.statSync(full).isDirectory() && /^\d{4}$/.test(f); }); for (const year of years) { const yearDir = path.join(CONTENT_DIR, year); const posts = fs.readdirSync(yearDir).filter((f) => { return fs.statSync(path.join(yearDir, f)).isDirectory(); }); for (const post of posts) { result.push(path.join(yearDir, post)); } } return result; } function slugFromDirName(dirName: string): string { // e.g. "2026-01-21-how-to-implement-friend-circle-in-hugo" -> "how-to-implement-friend-circle-in-hugo" const match = dirName.match(/^\d{4}-\d{2}-\d{2}-(.+)$/); return match ? match[1] : dirName; } function yearFromDirName(dirName: string): string { const match = dirName.match(/^(\d{4})/); return match ? match[1] : ""; } export function listPosts(options?: { search?: string; page?: number; perPage?: number; includeDrafts?: boolean; }): { posts: PostListItem[]; total: number; totalPages: number } { const { search = "", page = 1, perPage = ITEMS_PER_PAGE, includeDrafts = true } = options || {}; const dirs = findPostDirectories(); const allPosts: PostListItem[] = []; for (const dirPath of dirs) { const indexPath = path.join(dirPath, "index.md"); if (!fs.existsSync(indexPath)) continue; const raw = fs.readFileSync(indexPath, "utf-8"); const { data } = matter(raw); const dirName = path.basename(dirPath); const post: PostListItem = { slug: String(data.slug || slugFromDirName(dirName)), title: data.title || "Untitled", date: data.date ? new Date(data.date).toISOString() : "", draft: data.draft === true, categories: Array.isArray(data.categories) ? data.categories : [], tags: Array.isArray(data.tags) ? data.tags : [], author: data.author || "", }; if (!includeDrafts && post.draft) continue; if (search) { const q = search.toLowerCase(); const match = post.title.toLowerCase().includes(q) || post.slug.toLowerCase().includes(q) || post.categories.some((c) => c.toLowerCase().includes(q)) || post.tags.some((t) => t.toLowerCase().includes(q)); if (!match) continue; } allPosts.push(post); } // Sort by date descending allPosts.sort((a, b) => parseDate(b.date).getTime() - parseDate(a.date).getTime()); const total = allPosts.length; const totalPages = Math.ceil(total / perPage); const start = (page - 1) * perPage; const posts = allPosts.slice(start, start + perPage); return { posts, total, totalPages }; } export function getPost(slug: string): Post | null { const dirs = findPostDirectories(); for (const dirPath of dirs) { const indexPath = path.join(dirPath, "index.md"); if (!fs.existsSync(indexPath)) continue; const raw = fs.readFileSync(indexPath, "utf-8"); const { data, content } = matter(raw); const existingSlug = String(data.slug || slugFromDirName(path.basename(dirPath))); if (existingSlug === slug) { return { slug: existingSlug, frontMatter: data as PostFrontMatter, content, filePath: indexPath, dirPath, }; } } return null; } export function createPost(frontMatter: PostFrontMatter, content: string): string { const date = new Date(frontMatter.date || new Date()); const year = date.getFullYear().toString(); const dateStr = date.toISOString().slice(0, 10); // Folder name: date + sanitized title (human-readable on disk) const titlePart = (frontMatter.title || "untitled") .replace(/[^\w一-鿿㐀-䶿]+/g, "-") .replace(/^-+|-+$/g, "") .toLowerCase() || "post"; const dirName = `${dateStr}-${titlePart}`; const dirPath = path.join(CONTENT_DIR, year, dirName); if (fs.existsSync(path.join(dirPath, "index.md"))) { throw new Error(`Post already exists at ${dirPath}`); } fs.mkdirSync(dirPath, { recursive: true }); const fm: Record = { title: frontMatter.title, date: frontMatter.date, slug: frontMatter.slug, draft: frontMatter.draft ?? false, categories: frontMatter.categories || [], tags: frontMatter.tags || [], author: frontMatter.author || "", layout: frontMatter.layout || "post", }; const fileContent = matter.stringify(content, fm); const indexPath = path.join(dirPath, "index.md"); fs.writeFileSync(indexPath, fileContent, "utf-8"); return dirPath; } export function updatePost( slug: string, frontMatter: Partial, content?: string ): { success: true; dirPath: string } | { success: false } { const post = getPost(slug); if (!post) return { success: false }; const merged = { ...post.frontMatter, ...frontMatter }; // If slug changed, we need to rename the directory if (frontMatter.slug && frontMatter.slug !== post.frontMatter.slug) { const newDate = new Date(merged.date || post.frontMatter.date); const newYear = newDate.getFullYear().toString(); const newDateStr = newDate.toISOString().slice(0, 10); const titlePart = (merged.title || "untitled") .replace(/[^\w一-鿿㐀-䶿]+/g, "-") .replace(/^-+|-+$/g, "") .toLowerCase() || "post"; const newDirName = `${newDateStr}-${titlePart}`; const newDirPath = path.join(CONTENT_DIR, newYear, newDirName); if (fs.existsSync(newDirPath) && newDirPath !== post.dirPath) { throw new Error(`Target path already exists: ${newDirPath}`); } fs.mkdirSync(path.dirname(newDirPath), { recursive: true }); fs.renameSync(post.dirPath, newDirPath); const fm: Record = { title: merged.title, date: merged.date, slug: frontMatter.slug, draft: merged.draft ?? false, categories: merged.categories || [], tags: merged.tags || [], author: merged.author || "", layout: merged.layout || "post", }; const fileContent = matter.stringify(content ?? post.content, fm); fs.writeFileSync(path.join(newDirPath, "index.md"), fileContent, "utf-8"); return { success: true, dirPath: newDirPath }; } // Update in place const fm: Record = { title: merged.title, date: merged.date, slug: merged.slug, draft: merged.draft ?? false, categories: merged.categories || [], tags: merged.tags || [], author: merged.author || "", layout: merged.layout || "post", }; const fileContent = matter.stringify(content ?? post.content, fm); fs.writeFileSync(post.filePath, fileContent, "utf-8"); return { success: true, dirPath: post.dirPath }; } export function getPostCount(): number { return findPostDirectories().filter((dir) => fs.existsSync(path.join(dir, "index.md")) ).length; }