diff --git a/app/announcements/[slug]/page.tsx b/app/announcements/[slug]/page.tsx
new file mode 100644
index 0000000..d93fde0
--- /dev/null
+++ b/app/announcements/[slug]/page.tsx
@@ -0,0 +1,66 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+
+import { Badge } from "@/components/ui/badge";
+import { getAnnouncementBySlug, getAnnouncementSlugs } from "@/lib/announcements";
+
+type PageProps = {
+ params: Promise<{ slug: string }>;
+};
+
+export async function generateStaticParams() {
+ const slugs = await getAnnouncementSlugs();
+ return slugs.map((slug) => ({ slug }));
+}
+
+export async function generateMetadata({ params }: PageProps): Promise {
+ const { slug } = await params;
+
+ try {
+ const announcement = await getAnnouncementBySlug(slug);
+ return {
+ title: `${announcement.title} | お知らせ`,
+ description: announcement.summary || `${announcement.title} のお知らせです。`,
+ };
+ } catch {
+ return {
+ title: "お知らせ",
+ description: "お知らせページ",
+ };
+ }
+}
+
+export default async function AnnouncementDetailPage({ params }: PageProps) {
+ const { slug } = await params;
+
+ let announcement;
+ try {
+ announcement = await getAnnouncementBySlug(slug);
+ } catch {
+ notFound();
+ }
+
+ return (
+
+
+
+ お知らせ詳細
+
+ {announcement.date}
+ {announcement.title}
+
+
+
+
+
+ お知らせ一覧へ戻る
+
+
+ );
+}
diff --git a/app/announcements/page.tsx b/app/announcements/page.tsx
new file mode 100644
index 0000000..e9472bb
--- /dev/null
+++ b/app/announcements/page.tsx
@@ -0,0 +1,49 @@
+import Link from "next/link";
+
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { getAllAnnouncements } from "@/lib/announcements";
+
+export const metadata = {
+ title: "お知らせ | Takasumi-Neodyマイクラサーバプロジェクト",
+ description: "Takasumi-Neodyマイクラサーバプロジェクトのお知らせ一覧です。",
+};
+
+export default async function AnnouncementsPage() {
+ const announcements = await getAllAnnouncements();
+
+ return (
+
+
+
+
+ {announcements.map((item) => (
+
+
+ {item.date}
+
+
+ {item.title}
+
+
+
+
+ {item.summary}
+
+
+ ))}
+
+
+ );
+}
diff --git a/app/page.tsx b/app/page.tsx
index 65c64f5..e4e206c 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,17 +1,10 @@
"use client";
import { ExternalLink, Pickaxe, Sprout, Wifi } from "lucide-react";
+import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
-import {
- Card,
- CardContent,
- CardDescription,
- CardFooter,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
@@ -48,14 +41,22 @@ export default function Home() {
サーバー追加画面で下記アドレスを入力してください。Java版 と
統合版のどちらにも対応しています。
+
+
+ お知らせを見る
+
+
{servers.map((server) => {
const Icon = server.icon;
return (
-
-
+
+
-
+
+ {server.description}
+
+
+
+
);
})}
diff --git a/bun.lock b/bun.lock
index 67914a8..f12a5f1 100644
--- a/bun.lock
+++ b/bun.lock
@@ -8,7 +8,9 @@
"@base-ui/react": "^1.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "gray-matter": "^4.0.3",
"lucide-react": "^1.7.0",
+ "marked": "^17.0.5",
"next": "16.2.1",
"react": "19.2.4",
"react-dom": "19.2.4",
@@ -391,7 +393,7 @@
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
- "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
+ "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
@@ -619,6 +621,8 @@
"express-rate-limit": ["express-rate-limit@8.3.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw=="],
+ "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="],
+
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
@@ -699,6 +703,8 @@
"graphql": ["graphql@16.13.2", "", {}, "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig=="],
+ "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="],
+
"has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
@@ -765,6 +771,8 @@
"is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
+ "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="],
+
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="],
@@ -835,7 +843,7 @@
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
- "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
+ "js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
@@ -857,6 +865,8 @@
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
+ "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
+
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="],
@@ -905,6 +915,8 @@
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+ "marked": ["marked@17.0.5", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg=="],
+
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
@@ -1091,6 +1103,8 @@
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
+ "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="],
+
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
@@ -1129,6 +1143,8 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+ "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
+
"stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
@@ -1159,6 +1175,8 @@
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
+ "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="],
+
"strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
@@ -1297,6 +1315,8 @@
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
+ "@eslint/eslintrc/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
+
"@modelcontextprotocol/sdk/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
@@ -1333,6 +1353,8 @@
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
+ "cosmiconfig/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
+
"eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
"eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
@@ -1393,6 +1415,8 @@
"@dotenvx/dotenvx/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
+ "@eslint/eslintrc/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
+
"@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"@next/eslint-plugin-next/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -1407,6 +1431,8 @@
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+ "cosmiconfig/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
+
"eslint-plugin-import/tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
"wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
diff --git a/content/announcements/2026-03-29-project-start.md b/content/announcements/2026-03-29-project-start.md
new file mode 100644
index 0000000..0336233
--- /dev/null
+++ b/content/announcements/2026-03-29-project-start.md
@@ -0,0 +1,17 @@
+---
+title: "お知らせページを公開しました"
+date: "2026-03-29"
+summary: "Takasumi-Neodyマイクラサーバプロジェクトのお知らせページを公開しました。"
+---
+
+# お知らせページ公開
+
+Takasumi-Neodyマイクラサーバプロジェクトの **お知らせページ** を公開しました。
+
+今後の更新情報やイベント告知は、このページで案内します。
+
+- サーバーのメンテナンス情報
+- 新機能の追加情報
+- イベント開催のお知らせ
+
+最新情報はこのページを確認してください。
diff --git a/lib/announcements.ts b/lib/announcements.ts
new file mode 100644
index 0000000..3f62b1d
--- /dev/null
+++ b/lib/announcements.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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));
+}
diff --git a/lib/markdown.ts b/lib/markdown.ts
new file mode 100644
index 0000000..3491868
--- /dev/null
+++ b/lib/markdown.ts
@@ -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, "$1")
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
+ .replace(/\*([^*]+)\*/g, "$1")
+ .replace(/~~([^~]+)~~/g, "$1")
+ .replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '$1');
+}
+
+function flushParagraph(state: ParseState, html: string[]): void {
+ if (state.paragraphBuffer.length === 0) {
+ return;
+ }
+
+ html.push(`${applyInlineMarkdown(state.paragraphBuffer.join(" "))}
`);
+ 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(``);
+ } else {
+ state.inCodeBlock = false;
+ state.codeLang = "";
+ html.push("
");
+ }
+ 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(`${applyInlineMarkdown(headingMatch[2])}`);
+ continue;
+ }
+
+ if (/^---$/.test(line)) {
+ flushParagraph(state, html);
+ flushList(state, html);
+ html.push("
");
+ continue;
+ }
+
+ const blockquoteMatch = line.match(/^>\s?(.*)$/);
+ if (blockquoteMatch) {
+ flushParagraph(state, html);
+ flushList(state, html);
+ html.push(`${applyInlineMarkdown(blockquoteMatch[1])}
`);
+ continue;
+ }
+
+ const ulMatch = line.match(/^[-*+]\s+(.+)$/);
+ if (ulMatch) {
+ flushParagraph(state, html);
+ if (state.listType !== "ul") {
+ flushList(state, html);
+ state.listType = "ul";
+ }
+ state.listBuffer.push(`${applyInlineMarkdown(ulMatch[1])}`);
+ 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(`${applyInlineMarkdown(olMatch[1])}`);
+ continue;
+ }
+
+ flushList(state, html);
+ state.paragraphBuffer.push(line.trim());
+ }
+
+ flushParagraph(state, html);
+ flushList(state, html);
+
+ return html.join("\n");
+}
diff --git a/package.json b/package.json
index b8c2afe..6325745 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,9 @@
"@base-ui/react": "^1.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "gray-matter": "^4.0.3",
"lucide-react": "^1.7.0",
+ "marked": "^17.0.5",
"next": "16.2.1",
"react": "19.2.4",
"react-dom": "19.2.4",