import Link from "next/link"; import { notFound } from "next/navigation"; import { cookies } from "next/headers"; import { getDocument, getRelated, listRevisions, listDocuments } from "@mcpedia/core"; import { WEBHOOK_SECRET } from "@mcpedia/config"; import Markdown from "@/components/Markdown"; import DocForm from "@/components/DocForm"; import TOC from "@/components/TOC"; import { classifyPath, extractFoldersForSection } from "@mcpedia/core"; // Render at request time (content in Postgres, not available at build time). export const dynamic = "force-dynamic"; interface DocPageProps { params: Promise<{ section: string; slug: string[] }>; searchParams: Promise<{ edit?: string }>; } const SECTION_LABEL: Record = { docs: "Documentation", writeups: "Writeup", research: "Research", notes: "Note", }; const SECTION_ICON: Record = { docs: "๐Ÿ“„", writeups: "๐Ÿ“", research: "๐Ÿ”ฌ", notes: "๐Ÿ“Œ", }; /** * Render any extra/custom frontmatter fields as badges. * This makes the system dynamic โ€” the content creator decides what metadata * to include, not the UI template. Standard fields (title, author, tags, etc.) * are handled explicitly; any OTHER keys in frontmatter become badges here. */ function CustomFieldBadges({ doc, standardKeys, }: { doc: Record; standardKeys: Set; }) { // Convert doc to a plain record to get index signature const docRecord: Record = { ...doc } as Record; const customEntries = Object.entries(docRecord).filter( ([k, v]) => !standardKeys.has(k) && v !== undefined && v !== null && v !== "" && !k.startsWith("_"), ); if (customEntries.length === 0) return null; return (
{customEntries.map(([key, value]) => { const label = key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " "); let colorClass = "bg-[#191a1b] border-[#23252a] text-[#d0d6e0]"; let displayValue: string; // --- Auto-detect styling based on VALUE TYPE + VALUE CONTENTS --- // (NOT key name โ€” content creators determine appearance via values, // not by using specific key names) if (typeof value === "number") { // Numeric values โ†’ purple "points"-style badge with + prefix colorClass = "bg-[#5e6ad2]/10 border-[#5e6ad2]/30 text-[#7170ff]"; displayValue = `${value}`; } else if (typeof value === "boolean") { // Boolean values โ†’ green check/X colorClass = value ? "bg-green-500/15 border-green-500/25 text-green-400" : "bg-red-500/15 border-red-500/25 text-red-400"; displayValue = value ? "Yes" : "No"; } else if (Array.isArray(value)) { // Arrays โ†’ purple badge, joined values colorClass = "bg-[#5e6ad2]/10 border-[#5e6ad2]/30 text-[#7170ff]"; displayValue = value.join(", "); } else if (typeof value === "object" && value !== null) { // Objects โ†’ neutral badge, truncated JSON displayValue = JSON.stringify(value).slice(0, 40) + (JSON.stringify(value).length > 40 ? "โ€ฆ" : ""); } else { // String values โ€” auto-detect content type for styling const v = String(value).toLowerCase(); // Difficulty-like values (easy/medium/hard/etc) OR numeric with suffix const diffMatch = v.match( /^(easy|simple|beginner)\b|^(medium|intermediate)\b|^(hard|expert|advanced)\b/i ); if (diffMatch) { if (/^easy|simple|beginner/i.test(v)) colorClass = "bg-green-500/10 border-green-500/30 text-green-400"; else if (/^medium|intermediate/i.test(v)) colorClass = "bg-yellow-500/10 border-yellow-500/30 text-yellow-400"; else if (/^hard|expert|advanced/i.test(v)) colorClass = "bg-red-500/10 border-red-500/30 text-red-400"; displayValue = diffMatch[0]; } // Event-like values (contains CTF, DEF CON, hack, etc.) else if (/ctf|def.?con|hack|game|competition|tournament|qualifier/i.test(v)) { colorClass = "bg-[#5e6ad2]/10 border-[#5e6ad2]/30 text-[#7170ff]"; displayValue = String(value); } // Points-like values (number + "pts" suffix) else if (/(\d+)\s*pts?$/i.test(v)) { const match = v.match(/(\d+)\s*pts?$/i); colorClass = "bg-[#5e6ad2]/10 border-[#5e6ad2]/30 text-[#7170ff]"; displayValue = match ? `${match[1]} pts` : String(value); } // Category-like values (pwn/web/crypto/etc) else if (/^(pwn|web|crypto|misc|forensic|reverse|pwnable|binary|webexploit)$/i.test(v)) { colorClass = "bg-orange-500/15 border-orange-500/25 text-orange-400"; displayValue = v.charAt(0).toUpperCase() + v.slice(1); } // Status-like values (solved/pending/wip) else if (/^(solved|pending|wip|in.?progress|completed|todo)$/i.test(v)) { colorClass = v.includes("solved") || v.includes("completed") ? "bg-green-500/10 border-green-500/30 text-green-400" : v.includes("wip") || v.includes("progress") ? "bg-yellow-500/10 border-yellow-500/30 text-yellow-400" : "bg-red-500/10 border-red-500/30 text-red-400"; displayValue = v.charAt(0).toUpperCase() + v.slice(1); } else { displayValue = String(value); } } return ( {displayValue} ); })}
); } /** * Folder index page โ€” lists all docs (and subfolders) whose path starts with * the given prefix. This enables GitHub-style nested folder browsing. */ function FolderIndexPage({ section, slug, docPaths, }: { section: string; slug: string; docPaths: string[]; }) { const prefix = `${section}/${slug}/`; const folderDocs = docPaths .filter((p) => p.startsWith(prefix)) .map((p) => p.slice(prefix.length).replace(/\.md$/, "")) .sort(); // Group into immediate children: folders vs leaf docs const immediateFolders = new Set(); const immediateDocs: { name: string; slug: string }[] = []; for (const relPath of folderDocs) { const parts = relPath.split("/"); if (parts.length === 1) { // Leaf doc directly in this folder immediateDocs.push({ name: parts[0], slug: `${section}/${slug}/${parts[0]}`.replace(/\/+/g, "/") }); } else { // Deeper โ€” register the immediate folder immediateFolders.add(parts[0]); } } const folders = [...immediateFolders].sort(); return (
๐Ÿ“

{slug.split("/").pop()}

{(folders.length > 0 || immediateDocs.length > 0) ? (
{folders.length > 0 && (

Subfolders

{folders.map((folder) => ( ๐Ÿ“ {folder} ))}
)} {immediateDocs.length > 0 && (

Documents

{immediateDocs.map((doc) => ( ๐Ÿ“„ {doc.name} ))}
)}
) : (

No documents found in this folder.

)}
); } export default async function DocPage({ params, searchParams }: DocPageProps) { const { section, slug } = await params; const { edit } = await searchParams; const fullSlug = `${section}/${slug.join("/")}`; // Fetch all docs to classify the path as "doc" or "folder" const allDocs = await listDocuments(); const docPaths = allDocs.map((d) => d.path); const classification = classifyPath(docPaths, fullSlug); // If the slug path is a folder (not a leaf doc), render the folder index if (classification === "folder") { return ; } // Otherwise, treat as a document (existing behavior) const doc = await getDocument(fullSlug); if (!doc) notFound(); const cookieStore = await cookies(); const canEdit = cookieStore.get("mcpedia_admin")?.value != null; // Standard frontmatter keys that are rendered explicitly in the template. // Any other key in the document metadata becomes a dynamic badge. const STANDARD_KEYS = new Set([ "id", "slug", "title", "type", "section", "status", "author", "tags", "path", "createdAt", "updatedAt", "extraFields", ]); // Edit mode: inline form if (edit === "1" && canEdit) { // Load existing folders for the folder picker in edit mode const existingFolders = extractFoldersForSection(docPaths, doc.section); return (
โ† Back to {doc.title}

Edit: {doc.title}

); } const related = await getRelated(fullSlug, 5); const revisions = await listRevisions(fullSlug, 10); return (
{/* Breadcrumb */} {/* Header */}
{SECTION_ICON[doc.section] || "๐Ÿ“„"} {SECTION_LABEL[doc.section] || doc.section}

{doc.title}

{doc.author || "unknown"} ยท {doc.tags.map((t) => ( #{t} ))}
{/* Dynamic custom field badges โ€” content creator controls what shows */}
{/* Content + TOC */}
{/* Related docs (card-style) */} {related.length > 0 && ( )} {/* History */} {revisions.length > 0 && ( )}
); }