257 lines
7.6 KiB
TypeScript
257 lines
7.6 KiB
TypeScript
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<string, unknown> = {
|
|
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<PostFrontMatter>,
|
|
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<string, unknown> = {
|
|
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<string, unknown> = {
|
|
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;
|
|
}
|