Files
blog/write/src/lib/posts.ts
T

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;
}