feat: add announcements feature with dynamic routing and markdown support
This commit is contained in:
93
lib/announcements.ts
Normal file
93
lib/announcements.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { markdownToHtml } from "@/lib/markdown";
|
||||
|
||||
const ANNOUNCEMENTS_DIR = path.join(process.cwd(), "content", "announcements");
|
||||
|
||||
type AnnouncementFrontmatter = {
|
||||
title?: string;
|
||||
date?: string;
|
||||
summary?: string;
|
||||
};
|
||||
|
||||
export type AnnouncementListItem = {
|
||||
slug: string;
|
||||
title: string;
|
||||
date: string;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
export type AnnouncementDetail = AnnouncementListItem & {
|
||||
contentHtml: string;
|
||||
};
|
||||
|
||||
function parseFrontmatter(markdown: string): {
|
||||
frontmatter: AnnouncementFrontmatter;
|
||||
body: string;
|
||||
} {
|
||||
if (!markdown.startsWith("---\n")) {
|
||||
return { frontmatter: {}, body: markdown };
|
||||
}
|
||||
|
||||
const end = markdown.indexOf("\n---\n", 4);
|
||||
if (end === -1) {
|
||||
return { frontmatter: {}, body: markdown };
|
||||
}
|
||||
|
||||
const rawFrontmatter = markdown.slice(4, end);
|
||||
const body = markdown.slice(end + 5);
|
||||
const frontmatter: AnnouncementFrontmatter = {};
|
||||
|
||||
for (const line of rawFrontmatter.split("\n")) {
|
||||
const delimiterIndex = line.indexOf(":");
|
||||
if (delimiterIndex <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = line.slice(0, delimiterIndex).trim();
|
||||
const value = line.slice(delimiterIndex + 1).trim().replace(/^"|"$/g, "");
|
||||
|
||||
if (key === "title" || key === "date" || key === "summary") {
|
||||
frontmatter[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return { frontmatter, body: body.trim() };
|
||||
}
|
||||
|
||||
export async function getAnnouncementSlugs(): Promise<string[]> {
|
||||
const entries = await fs.readdir(ANNOUNCEMENTS_DIR, { withFileTypes: true });
|
||||
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
|
||||
.map((entry) => entry.name.replace(/\.md$/, ""));
|
||||
}
|
||||
|
||||
export async function getAnnouncementBySlug(slug: string): Promise<AnnouncementDetail> {
|
||||
const filePath = path.join(ANNOUNCEMENTS_DIR, `${slug}.md`);
|
||||
const markdown = await fs.readFile(filePath, "utf-8");
|
||||
const { frontmatter, body } = parseFrontmatter(markdown);
|
||||
|
||||
return {
|
||||
slug,
|
||||
title: frontmatter.title ?? slug,
|
||||
date: frontmatter.date ?? "日付未設定",
|
||||
summary: frontmatter.summary ?? "",
|
||||
contentHtml: markdownToHtml(body),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAllAnnouncements(): Promise<AnnouncementListItem[]> {
|
||||
const slugs = await getAnnouncementSlugs();
|
||||
const items = await Promise.all(slugs.map((slug) => getAnnouncementBySlug(slug)));
|
||||
|
||||
return items
|
||||
.map((item) => ({
|
||||
slug: item.slug,
|
||||
title: item.title,
|
||||
date: item.date,
|
||||
summary: item.summary,
|
||||
}))
|
||||
.sort((a, b) => b.date.localeCompare(a.date));
|
||||
}
|
||||
144
lib/markdown.ts
Normal file
144
lib/markdown.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
type ParseState = {
|
||||
inCodeBlock: boolean;
|
||||
codeLang: string;
|
||||
paragraphBuffer: string[];
|
||||
listType: "ul" | "ol" | null;
|
||||
listBuffer: string[];
|
||||
};
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function applyInlineMarkdown(value: string): string {
|
||||
const escaped = escapeHtml(value);
|
||||
|
||||
return escaped
|
||||
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
|
||||
.replace(/~~([^~]+)~~/g, "<del>$1</del>")
|
||||
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||
}
|
||||
|
||||
function flushParagraph(state: ParseState, html: string[]): void {
|
||||
if (state.paragraphBuffer.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
html.push(`<p>${applyInlineMarkdown(state.paragraphBuffer.join(" "))}</p>`);
|
||||
state.paragraphBuffer = [];
|
||||
}
|
||||
|
||||
function flushList(state: ParseState, html: string[]): void {
|
||||
if (!state.listType || state.listBuffer.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
html.push(`<${state.listType}>${state.listBuffer.join("")}</${state.listType}>`);
|
||||
state.listType = null;
|
||||
state.listBuffer = [];
|
||||
}
|
||||
|
||||
export function markdownToHtml(markdown: string): string {
|
||||
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
||||
const html: string[] = [];
|
||||
const state: ParseState = {
|
||||
inCodeBlock: false,
|
||||
codeLang: "",
|
||||
paragraphBuffer: [],
|
||||
listType: null,
|
||||
listBuffer: [],
|
||||
};
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trimEnd();
|
||||
|
||||
if (line.startsWith("```")) {
|
||||
flushParagraph(state, html);
|
||||
flushList(state, html);
|
||||
|
||||
if (!state.inCodeBlock) {
|
||||
state.inCodeBlock = true;
|
||||
state.codeLang = line.slice(3).trim();
|
||||
const className = state.codeLang ? ` class="language-${escapeHtml(state.codeLang)}"` : "";
|
||||
html.push(`<pre><code${className}>`);
|
||||
} else {
|
||||
state.inCodeBlock = false;
|
||||
state.codeLang = "";
|
||||
html.push("</code></pre>");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (state.inCodeBlock) {
|
||||
html.push(`${escapeHtml(rawLine)}\n`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.length === 0) {
|
||||
flushParagraph(state, html);
|
||||
flushList(state, html);
|
||||
continue;
|
||||
}
|
||||
|
||||
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (headingMatch) {
|
||||
flushParagraph(state, html);
|
||||
flushList(state, html);
|
||||
const level = headingMatch[1].length;
|
||||
html.push(`<h${level}>${applyInlineMarkdown(headingMatch[2])}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^---$/.test(line)) {
|
||||
flushParagraph(state, html);
|
||||
flushList(state, html);
|
||||
html.push("<hr />");
|
||||
continue;
|
||||
}
|
||||
|
||||
const blockquoteMatch = line.match(/^>\s?(.*)$/);
|
||||
if (blockquoteMatch) {
|
||||
flushParagraph(state, html);
|
||||
flushList(state, html);
|
||||
html.push(`<blockquote><p>${applyInlineMarkdown(blockquoteMatch[1])}</p></blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ulMatch = line.match(/^[-*+]\s+(.+)$/);
|
||||
if (ulMatch) {
|
||||
flushParagraph(state, html);
|
||||
if (state.listType !== "ul") {
|
||||
flushList(state, html);
|
||||
state.listType = "ul";
|
||||
}
|
||||
state.listBuffer.push(`<li>${applyInlineMarkdown(ulMatch[1])}</li>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const olMatch = line.match(/^\d+\.\s+(.+)$/);
|
||||
if (olMatch) {
|
||||
flushParagraph(state, html);
|
||||
if (state.listType !== "ol") {
|
||||
flushList(state, html);
|
||||
state.listType = "ol";
|
||||
}
|
||||
state.listBuffer.push(`<li>${applyInlineMarkdown(olMatch[1])}</li>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
flushList(state, html);
|
||||
state.paragraphBuffer.push(line.trim());
|
||||
}
|
||||
|
||||
flushParagraph(state, html);
|
||||
flushList(state, html);
|
||||
|
||||
return html.join("\n");
|
||||
}
|
||||
Reference in New Issue
Block a user