初步建设主页和新闻页面

This commit is contained in:
2026-08-22 22:23:38 +08:00
parent 4238b81205
commit de9982c9eb
75 changed files with 2913 additions and 427 deletions
+204
View File
@@ -0,0 +1,204 @@
import fs from "fs/promises";
import path from "path";
export type NewsItem = {
id: string;
slug: string;
title: string;
date: string;
cover: string;
href: string;
category: string;
tags: string[];
markdownFile: string;
body: string;
};
type NewsFrontmatter = {
title?: string;
date?: string;
cover?: string;
category?: string | string[];
tags?: string[];
slug?: string;
};
function parseFrontmatter(markdown: string): NewsFrontmatter {
const frontmatterMatch = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
if (!frontmatterMatch) {
return {};
}
const frontmatter = frontmatterMatch[1];
const result: Record<string, string | string[]> = {};
let listKey: "tags" | "category" | null = null;
for (const line of frontmatter.split(/\r?\n/)) {
const listItem = line.match(/^\s+-\s+(.+)$/);
if (listItem && listKey) {
const values = Array.isArray(result[listKey]) ? result[listKey] as string[] : [];
values.push(listItem[1].trim());
result[listKey] = values;
continue;
}
const separatorIndex = line.indexOf(":");
if (separatorIndex === -1) continue;
const key = line.slice(0, separatorIndex).trim();
const value = line.slice(separatorIndex + 1).trim().replace(/^['\"]|['\"]$/g, "");
if (key) {
listKey = value === "" && (key === "tags" || key === "category") ? key : null;
result[key] = value;
}
}
return result;
}
function stripFrontmatter(markdown: string): string {
return markdown.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trim();
}
function normalizeNewsItem(fileName: string, raw: NewsFrontmatter, body: string): NewsItem | null {
const title = raw.title?.trim();
const date = raw.date?.trim() || "未注明日期";
const category = (Array.isArray(raw.category) ? raw.category[0] : raw.category)?.trim() || "未分类";
const tags = raw.tags ?? [];
const cover = raw.cover?.trim() || "https://images.unsplash.com/photo-1497366754035-f200968a6e72?auto=format&fit=crop&w=1200&q=80";
const slug = raw.slug?.trim() || fileName.replace(/\.md$/i, "");
if (!title) {
return null;
}
return {
id: slug,
slug,
title,
date,
cover,
href: `/news/${slug}`,
category,
tags,
markdownFile: `src/data/news/posts/${fileName}`,
body,
};
}
export function markdownToHtml(markdown: string): string {
const lines = markdown.replace(/\r\n?/g, "\n").trim().split("\n");
const escapeHtml = (text: string) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const renderInline = (text: string) =>
escapeHtml(text)
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt: string, source: string) =>
`<img src="${source.replace(/"/g, "&quot;")}" alt="${alt.replace(/"/g, "&quot;")}" />`,
)
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.+?)\*/g, "<em>$1</em>")
.replace(/`(.+?)`/g, "<code>$1</code>");
const html: string[] = [];
let paragraph: string[] = [];
let listType: "ol" | "ul" | null = null;
let listItems: string[] = [];
const flushParagraph = () => {
if (paragraph.length > 0) {
html.push(`<p>${renderInline(paragraph.join(" ").trim())}</p>`);
paragraph = [];
}
};
const flushList = () => {
if (listType && listItems.length > 0) {
html.push(`<${listType}>${listItems.map((item) => `<li>${renderInline(item)}</li>`).join("")}</${listType}>`);
}
listType = null;
listItems = [];
};
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
flushParagraph();
flushList();
continue;
}
const headingMatch = trimmed.match(/^(#{1,3})\s+(.+)$/);
if (headingMatch) {
flushParagraph();
flushList();
const level = headingMatch[1].length;
html.push(`<h${level}>${renderInline(headingMatch[2])}</h${level}>`);
continue;
}
const imageMatch = trimmed.match(/^!\[([^\]]*)\]\(([^)]+)\)$/);
if (imageMatch) {
flushParagraph();
flushList();
html.push(`<p><img src="${imageMatch[2].replace(/"/g, "&quot;")}" alt="${imageMatch[1].replace(/"/g, "&quot;")}" /></p>`);
continue;
}
const unorderedMatch = trimmed.match(/^[-*]\s+(.+)$/);
const orderedMatch = trimmed.match(/^\d+[.)]\s+(.+)$/);
if (unorderedMatch || orderedMatch) {
flushParagraph();
const nextListType = unorderedMatch ? "ul" : "ol";
if (listType && listType !== nextListType) flushList();
listType = nextListType;
listItems.push((unorderedMatch ?? orderedMatch)?.[1] ?? "");
continue;
}
flushList();
paragraph.push(trimmed);
}
flushParagraph();
flushList();
return html.join("");
}
export async function getNewsList(): Promise<NewsItem[]> {
const directory = path.join(process.cwd(), "src", "data", "news", "posts");
try {
const files = (await fs.readdir(directory))
.filter((file) => file.toLowerCase().endsWith(".md"))
.sort();
const items = await Promise.all(
files.map(async (fileName) => {
const filePath = path.join(directory, fileName);
const content = await fs.readFile(filePath, "utf-8");
const frontmatter = parseFrontmatter(content);
const body = stripFrontmatter(content);
return normalizeNewsItem(fileName, frontmatter, body);
}),
);
const validItems = items.filter(Boolean) as NewsItem[];
return validItems.sort((first, second) => {
const secondTime = Date.parse(second.date);
const firstTime = Date.parse(first.date);
return (Number.isNaN(secondTime) ? 0 : secondTime) - (Number.isNaN(firstTime) ? 0 : firstTime);
});
} catch {
return [];
}
}
export async function getNewsBySlug(slug: string): Promise<NewsItem | null> {
const items = await getNewsList();
return items.find((item) => item.slug === slug) ?? null;
}
export async function getLatestNews() {
const items = await getNewsList();
return items.slice(0, 3);
}