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 ( +
+
+ + お知らせ + +

更新情報・告知

+

+ このページは Markdown で作成したお知らせを表示します。 +

+
+ +
+ {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.name} @@ -63,19 +64,19 @@ export default function Home() { Java版 / 統合版 対応
- +

{server.name} - - {server.description} - - +

+

{server.description}

+
+

サーバアドレス

{server.address}

- - +
+
- - +
+
); })}
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 = 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",