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"; import { SECTIONS_BY_ID } from "@mcpedia/config/sections"; import type { DocumentMeta } 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 }>; } /** * Get section label and icon from the centralized config. */ function getSectionInfo(section: string) { const info = SECTIONS_BY_ID.get(section); if (!info) { return { label: section.charAt(0).toUpperCase() + section.slice(1), icon: "๐Ÿ“„", }; } return { label: info.label, icon: info.icon, }; } /** * Render any extra/custom frontmatter fields as labeled badges. * This makes the system dynamic โ€” the content creator decides what metadata * to include. 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; }) { 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") { colorClass = "bg-[#5e6ad2]/10 border-[#5e6ad2]/30 text-[#7170ff]"; displayValue = `${value}`; } else if (typeof value === "boolean") { 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)) { colorClass = "bg-[#5e6ad2]/10 border-[#5e6ad2]/30 text-[#7170ff]"; displayValue = value.join(", "); } else if (typeof value === "object" && value !== null) { displayValue = JSON.stringify(value).slice(0, 40) + (JSON.stringify(value).length > 40 ? "โ€ฆ" : ""); } else { const v = String(value).toLowerCase(); 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]; } 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); } 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); } 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); } 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, allDocs, }: { section: string; slug: string; allDocs: DocumentMeta[]; }) { const prefix = `${section}/${slug}/`; const folderDocPaths = allDocs .filter((d) => d.path.startsWith(prefix)) .map((d) => ({ rel: d.path.slice(prefix.length).replace(/\.md$/, ""), doc: d, })) .sort((a, b) => a.rel.localeCompare(b.rel)); // Group into immediate children: folders vs leaf docs const immediateFolders = new Map(); const immediateDocs: DocumentMeta[] = []; for (const { rel, doc } of folderDocPaths) { const parts = rel.split("/"); if (parts.length === 1) { immediateDocs.push(doc); } else { const folder = parts[0]; const existing = immediateFolders.get(folder) || { docCount: 0 }; existing.docCount += 1; immediateFolders.set(folder, existing); } } const folders = [...immediateFolders.entries()].sort(([a], [b]) => a.localeCompare(b)); const sectionInfo = getSectionInfo(section); return (
{/* Breadcrumb */} {/* Header */}
๐Ÿ“

{slug.split("/").pop()!.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")}

{immediateDocs.length + folders.length} item{folders.length + immediateDocs.length !== 1 ? "s" : ""}
{(folders.length > 0 || immediateDocs.length > 0) ? (
{folders.length > 0 && (

Subfolders

{folders.map(([folder, info]) => ( ๐Ÿ“
{folder.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")}
{info.docCount} document{info.docCount !== 1 ? "s" : ""}
))}
)} {immediateDocs.length > 0 && (

Documents

{immediateDocs.map((doc) => (
๐Ÿ“„
{doc.title}
{doc.tags.length > 0 && (
{doc.tags.slice(0, 3).map((t) => ( #{t} ))}
)}
))}
)}
) : (

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", "body", ]); // Edit mode: inline form if (edit === "1" && canEdit) { 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); const sectionInfo = getSectionInfo(doc.section); return (
{/* Breadcrumb โ€” dynamic based on the doc's actual path */} {/* Metadata Card */}
{sectionInfo.icon}
{sectionInfo.label} ยท {doc.type}

{doc.title}

{/* Author + date + tags */}
{doc.author && ( <> by {doc.author} ยท )} updated {new Date(doc.updatedAt).toLocaleDateString(undefined, { month: "long", day: "numeric", year: "numeric", })} {doc.tags.length > 0 && ( <> ยท
{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 && ( )}
); }