diff --git a/apps/web/app/[section]/[...slug]/page.tsx b/apps/web/app/[section]/[...slug]/page.tsx index cf6eab3..18503dc 100644 --- a/apps/web/app/[section]/[...slug]/page.tsx +++ b/apps/web/app/[section]/[...slug]/page.tsx @@ -186,9 +186,19 @@ function FolderIndexPage({

- - {immediateDocs.length + folders.length} item{folders.length + immediateDocs.length !== 1 ? "s" : ""} - +
+ + {immediateDocs.length + folders.length} item{folders.length + immediateDocs.length !== 1 ? "s" : ""} + + + ๐Ÿ“„ + Export PDF Report + +
diff --git a/apps/web/app/[section]/page.tsx b/apps/web/app/[section]/page.tsx index fd97a81..ccec5fd 100644 --- a/apps/web/app/[section]/page.tsx +++ b/apps/web/app/[section]/page.tsx @@ -151,10 +151,20 @@ export default async function SectionIndexPage({ -
+
{sectionDocs.length} document{sectionDocs.length !== 1 ? "s" : ""} + {sectionDocs.length > 0 && ( + + ๐Ÿ“„ + Export PDF + + )} s.trim()) : undefined; + + try { + const data = await getExportDocuments({ + path, + section, + slugs, + sortBy, + }); + + if (format === "markdown" || format === "md") { + const md = compileExportMarkdown(data); + const safeFilename = data.summary.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + + return new NextResponse(md, { + headers: { + "Content-Type": "text/markdown; charset=utf-8", + "Content-Disposition": `attachment; filename="${safeFilename}.md"`, + }, + }); + } + + return NextResponse.json(data); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Export failed" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/components/DocActions.tsx b/apps/web/app/components/DocActions.tsx index 0b6af1a..5bf5b8f 100644 --- a/apps/web/app/components/DocActions.tsx +++ b/apps/web/app/components/DocActions.tsx @@ -75,6 +75,18 @@ export default function DocActions({ slug, body, canEdit }: DocActionsProps) { )} + {/* Export PDF */} + + + + + PDF + + {/* Edit Link */} ("category_points"); + const [showCover, setShowCover] = useState(true); + const [showScoreboard, setShowScoreboard] = useState(initialDocs.length > 1); + const [showTOC, setShowTOC] = useState(initialDocs.length > 1); + const [pageBreaks, setPageBreaks] = useState(true); + const [selectedCategory, setSelectedCategory] = useState("all"); + const [copiedMd, setCopiedMd] = useState(false); + + const sectionMeta = getSectionMeta(summary.section); + + // Available categories for filtering + const allCategories = useMemo(() => { + const set = new Set(); + for (const doc of initialDocs) { + set.add(extractDocCategory(doc)); + } + return Array.from(set).sort(); + }, [initialDocs]); + + // Filtered & Sorted documents + const filteredAndSortedDocs = useMemo(() => { + let docs = [...initialDocs]; + if (selectedCategory !== "all") { + docs = docs.filter( + (d) => extractDocCategory(d).toLowerCase() === selectedCategory.toLowerCase(), + ); + } + return sortExportDocuments(docs, sortBy); + }, [initialDocs, selectedCategory, sortBy]); + + // Calculate dynamic stats for currently filtered view + const currentTotalPoints = useMemo(() => { + return filteredAndSortedDocs.reduce( + (sum, d) => sum + extractDocPoints(d), + 0, + ); + }, [filteredAndSortedDocs]); + + function handlePrint() { + window.print(); + } + + function handleCopyMarkdown() { + const md = compileExportMarkdown({ + summary, + documents: filteredAndSortedDocs, + }); + navigator.clipboard.writeText(md); + setCopiedMd(true); + setTimeout(() => setCopiedMd(false), 2000); + } + + function handleDownloadMarkdown() { + const md = compileExportMarkdown({ + summary, + documents: filteredAndSortedDocs, + }); + const blob = new Blob([md], { type: "text/markdown;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + const safeName = summary.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + a.download = `${safeName}.md`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + function getCategoryBadgeClass(category: string): string { + const cat = category.toLowerCase(); + if (cat.includes("web")) { + return "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/30"; + } + if (cat.includes("crypto")) { + return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/30"; + } + if (cat.includes("pwn") || cat.includes("binary")) { + return "bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-500/30"; + } + if (cat.includes("reverse") || cat.includes("rev")) { + return "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/30"; + } + if (cat.includes("forensic") || cat.includes("dfir")) { + return "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30"; + } + if (cat.includes("osint")) { + return "bg-teal-500/10 text-teal-600 dark:text-teal-400 border-teal-500/30"; + } + return "bg-[var(--brand)]/10 text-[var(--brand)] dark:text-[var(--accent)] border-[var(--brand)]/30"; + } + + function getCategoryIcon(category: string): string { + const cat = category.toLowerCase(); + if (cat.includes("web")) return "๐ŸŒ"; + if (cat.includes("crypto")) return "๐Ÿ”"; + if (cat.includes("pwn") || cat.includes("binary")) return "โšก"; + if (cat.includes("reverse") || cat.includes("rev")) return "โš™๏ธ"; + if (cat.includes("forensic") || cat.includes("dfir")) return "๐Ÿ”"; + if (cat.includes("osint")) return "๐Ÿ›ฐ๏ธ"; + if (cat.includes("mobile")) return "๐Ÿ“ฑ"; + if (cat.includes("hardware")) return "๐Ÿ”Œ"; + return "๐Ÿšฉ"; + } + + function getDifficultyBadgeClass(difficulty: string): string { + const diff = difficulty.toLowerCase(); + if (/easy|simple|beginner/i.test(diff)) { + return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/30"; + } + if (/medium|intermediate/i.test(diff)) { + return "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30"; + } + if (/hard|expert|advanced|insane/i.test(diff)) { + return "bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-500/30"; + } + return "bg-[var(--bg-elevated)] text-[var(--text-muted)] border-[var(--border-color)]"; + } + + return ( +
+ {/* ========================================================================= + Export Control Toolbar (Hidden in Print) + ========================================================================= */} + + + {/* ========================================================================= + Main Printable Document + ========================================================================= */} +
+ + {/* ======================================================================= + 1. Cover Page + ======================================================================= */} + {showCover && ( +
+
+ {/* Header Badge */} +
+
+
+ M +
+
+ + MCPedia + +

+ Knowledge Base Documentation +

+
+
+ + + {sectionMeta.icon} + {sectionMeta.label} + +
+ + {/* Title and Subtitle */} +
+

+ {summary.title} +

+

+ Comprehensive challenge solutions, technical writeups, and security research findings. +

+
+ + {/* Stats Metric Cards Grid */} +
+
+
+ {filteredAndSortedDocs.length} +
+
+ Challenges / Ch +
+
+ + {currentTotalPoints > 0 && ( +
+
+ {currentTotalPoints.toLocaleString()} +
+
+ Total Points +
+
+ )} + +
+
+ {summary.categories.length} +
+
+ Categories +
+
+ +
+
+ {summary.authors.length || 1} +
+
+ Contributors +
+
+
+ + {/* Categories Distribution */} + {summary.categories.length > 0 && ( +
+ + Categories Covered: + +
+ {summary.categories.map((cat: ExportCategorySummary) => ( + + {getCategoryIcon(cat.name)} + {cat.name}: + {cat.count} chall{cat.count !== 1 ? "s" : ""} + {cat.points > 0 && ({cat.points} pts)} + + ))} +
+
+ )} +
+ + {/* Cover Footer */} +
+
+ {summary.authors.length > 0 && ( +

+ Authors:{" "} + {summary.authors.map((a: string) => `@${a}`).join(", ")} +

+ )} +

+ Path: {summary.scope} +

+
+
+

+ {new Date(summary.generatedAt).toLocaleDateString(undefined, { + dateStyle: "long", + })} +

+

Generated by MCPedia

+
+
+
+ )} + + {/* ======================================================================= + 2. Scoreboard / Challenge Matrix Table + ======================================================================= */} + {showScoreboard && filteredAndSortedDocs.length > 1 && ( +
+
+

+ ๐Ÿ“Š + Challenge Overview & Scoreboard +

+ + {filteredAndSortedDocs.length} Total Challenges + +
+ +
+ + + + + + + + + + + + + + {filteredAndSortedDocs.map((doc, idx) => { + const chNum = idx + 1; + const cat = extractDocCategory(doc); + const pts = extractDocPoints(doc); + const diff = extractDocDifficulty(doc); + const isSolved = extractDocSolved(doc); + + return ( + + + + + + + + + + ); + })} + +
#ChallengeCategoryDifficultyPointsAuthorStatus
+ {chNum} + + + {doc.title} + + + + {getCategoryIcon(cat)} + {cat} + + + {diff ? ( + + {diff} + + ) : ( + - + )} + + {pts > 0 ? `${pts} pts` : "-"} + + {doc.author ? `@${doc.author}` : "-"} + + {isSolved ? ( + + โœ“ + + ) : ( + - + )} +
+
+
+ )} + + {/* ======================================================================= + 3. Table of Contents (TOC) + ======================================================================= */} + {showTOC && filteredAndSortedDocs.length > 1 && ( +
+

+ ๐Ÿ“‘ + Table of Contents +

+ +
+ {filteredAndSortedDocs.map((doc, idx) => { + const chNum = idx + 1; + const cat = extractDocCategory(doc); + const pts = extractDocPoints(doc); + + return ( + +
+ + Ch {chNum} + + + {doc.title} + +
+ +
+ {cat} + {pts > 0 && ( + + ยท {pts}p + + )} +
+
+ ); + })} +
+
+ )} + + {/* ======================================================================= + 4. Challenge Chapters ("Urutan Rapih Per Ch") + ======================================================================= */} +
+ {filteredAndSortedDocs.map((doc, idx) => { + const chNum = idx + 1; + const cat = extractDocCategory(doc); + const pts = extractDocPoints(doc); + const diff = extractDocDifficulty(doc); + const isSolved = extractDocSolved(doc); + const extra = doc.extraFields || {}; + + return ( +
0 ? "page-break-before" : "" + } pt-8 first:pt-0 print:pt-6`} + > + {/* Chapter Header Card */} +
+ {/* Chapter index label */} +
+
+ + CHAPTER {chNum} + + + Challenge {chNum} of {filteredAndSortedDocs.length} + +
+ + + {doc.slug} + +
+ + {/* Challenge Title */} +

+ {doc.title} +

+ + {/* Metadata Chips Bar */} +
+ {/* Category Chip */} + + {getCategoryIcon(cat)} + Category: {cat} + + + {/* Points Chip */} + {pts > 0 && ( + + ๐ŸŽฏ + {pts} pts + + )} + + {/* Difficulty Chip */} + {diff && ( + + Difficulty: {diff} + + )} + + {/* Solved Status */} + {isSolved && ( + + โœ“ + Solved + + )} + + {/* Author Chip */} + {doc.author && ( + + ๐Ÿ‘ค + @{doc.author} + + )} + + {/* Date Chip */} + {doc.updatedAt && ( + + ๐Ÿ“… + {new Date(doc.updatedAt).toLocaleDateString()} + + )} + + {/* Custom Extra Fields badges (excluding already rendered keys) */} + {Object.entries(extra).map(([k, v]) => { + const lowerKey = k.toLowerCase(); + if ( + [ + "category", + "points", + "score", + "pts", + "difficulty", + "solved", + "author", + "event", + ].includes(lowerKey) || + v === null || + v === undefined || + v === "" + ) { + return null; + } + return ( + + {k}: + + {String(v)} + + + ); + })} +
+
+ + {/* Markdown Writeup Body */} +
+ +
+ + {/* Chapter End Divider */} +
+
+ ); + })} +
+
+
+ ); +} diff --git a/apps/web/app/export/pdf/page.tsx b/apps/web/app/export/pdf/page.tsx new file mode 100644 index 0000000..b167af8 --- /dev/null +++ b/apps/web/app/export/pdf/page.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { getExportDocuments, formatScopeTitle, type ExportSortOption } from "@mcpedia/core"; +import PdfExportView from "@/components/PdfExportView"; + +export const dynamic = "force-dynamic"; + +interface ExportPdfPageProps { + searchParams: Promise<{ + path?: string; + section?: string; + slug?: string; + sort?: string; + }>; +} + +export async function generateMetadata({ searchParams }: ExportPdfPageProps) { + const { path, section, slug } = await searchParams; + const target = path || slug || section || "All Documents"; + const title = formatScopeTitle(target); + return { + title: `Export PDF: ${title} โ€” MCPedia`, + description: `Export and print documentation and CTF writeups for ${title}`, + }; +} + +export default async function ExportPdfPage({ + searchParams, +}: ExportPdfPageProps) { + const { path, section, slug, sort } = await searchParams; + + const targetPath = path || slug; + const sortBy = (sort as ExportSortOption) || "category_points"; + + const exportData = await getExportDocuments({ + path: targetPath, + section: !targetPath ? section : undefined, + sortBy, + }); + + const backHref = targetPath ? `/${targetPath}` : section ? `/${section}` : "/"; + + if (exportData.documents.length === 0) { + return ( +
+
๐Ÿ“„
+

+ No Documents Found for Export +

+

+ Could not find any published documents matching the path{" "} + + {targetPath || section || "root"} + + . +

+
+ + โ† Return to Knowledge Base + +
+
+ ); + } + + return ; +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 48c94f0..d0d13f5 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -374,3 +374,145 @@ body { .animate-fade-in { animation: fadeIn 0.2s ease-out forwards; } + +/* ========================================================================== + Print & PDF Export Stylesheet + Optimized for publication-grade PDF generation via browser / window.print() + ========================================================================== */ +@media print { + @page { + size: A4 portrait; + margin: 15mm 15mm 15mm 15mm; + } + + /* Force exact background colors and graphics */ + *, *::before, *::after { + -webkit-print-color-adjust: exact !important; + print-color-adjust: exact !important; + } + + /* Reset document canvas to white for crisp printing & ink economy */ + html, body { + background-color: #ffffff !important; + color: #0f172a !important; + font-size: 10.5pt !important; + line-height: 1.5 !important; + margin: 0 !important; + padding: 0 !important; + width: 100% !important; + } + + /* Hide app navigation, headers, sidebars, interactive controls */ + header, + aside, + nav.site-header, + .no-print, + .export-toolbar, + #a11y-live-region { + display: none !important; + } + + /* Full width container in print */ + main, + .print-container { + max-width: 100% !important; + width: 100% !important; + margin: 0 !important; + padding: 0 !important; + border: none !important; + box-shadow: none !important; + } + + /* Page break rules */ + .page-break-before, + .chapter-page { + break-before: page !important; + page-break-before: always !important; + } + + .page-break-after, + .cover-page, + .scoreboard-page, + .toc-page { + break-after: page !important; + page-break-after: always !important; + } + + .avoid-break-inside, + .challenge-header-card, + .badge-container, + table, + pre, + blockquote { + break-inside: avoid !important; + page-break-inside: avoid !important; + } + + /* Code block wrapping and styling in print */ + pre, + pre code, + code { + white-space: pre-wrap !important; + word-break: break-all !important; + overflow-wrap: anywhere !important; + font-size: 8.5pt !important; + line-height: 1.4 !important; + } + + pre { + background-color: #f8fafc !important; + color: #0f172a !important; + border: 1px solid #cbd5e1 !important; + border-radius: 6px !important; + padding: 8pt 10pt !important; + margin: 10pt 0 !important; + } + + /* Typography tweaks for high-contrast print */ + .prose { + color: #1e293b !important; + font-size: 10.5pt !important; + } + + .prose h1, + .prose h2, + .prose h3, + .prose h4 { + color: #0f172a !important; + page-break-after: avoid !important; + break-after: avoid !important; + } + + .prose p, + .prose li { + color: #334155 !important; + } + + .prose a { + color: #312e81 !important; + text-decoration: underline !important; + } + + /* Links display clean href only if needed or retain normal link text */ + .prose blockquote { + background-color: #f1f5f9 !important; + border-left: 3.5pt solid #4f46e5 !important; + color: #334155 !important; + padding: 6pt 10pt !important; + } + + .prose table { + border: 1px solid #cbd5e1 !important; + font-size: 9pt !important; + } + + .prose th { + background-color: #f1f5f9 !important; + color: #0f172a !important; + border-bottom: 1.5px solid #94a3b8 !important; + } + + .prose td { + border-bottom: 1px solid #e2e8f0 !important; + } +} diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index d071c81..a695497 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -3,7 +3,13 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { // @mcpedia/db pulls in the `postgres` driver + node:fs; keep it external to // the server bundle so it runs in the Node runtime as-is. - serverExternalPackages: ["@mcpedia/db", "@mcpedia/config", "@mcpedia/parser"], + serverExternalPackages: [ + "@mcpedia/core", + "@mcpedia/db", + "@mcpedia/config", + "@mcpedia/parser", + "@mcpedia/search", + ], }; export default nextConfig; diff --git a/packages/core/package.json b/packages/core/package.json index 00a5d1e..4f49d6b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -5,7 +5,8 @@ "type": "module", "exports": { ".": "./src/index.ts", - "./row-map": "./src/row-map.ts" + "./row-map": "./src/row-map.ts", + "./export-helpers": "./src/export.helpers.ts" }, "dependencies": { "@mcpedia/config": "workspace:*", diff --git a/packages/core/src/export.helpers.ts b/packages/core/src/export.helpers.ts new file mode 100644 index 0000000..d337520 --- /dev/null +++ b/packages/core/src/export.helpers.ts @@ -0,0 +1,434 @@ +import { getSectionMeta } from "@mcpedia/config/sections"; +import type { + Document, + DocumentMeta, + ExportCategorySummary, + ExportData, + ExportSortOption, + ExportSummary, +} from "@mcpedia/types"; + +export type { + Document, + DocumentMeta, + ExportCategorySummary, + ExportData, + ExportSortOption, + ExportSummary, +}; + +/** + * Standard category order priority for CTF challenges & writeups. + */ +export const CTF_CATEGORY_PRIORITY: Record = { + web: 10, + "web exploitation": 10, + "web security": 10, + crypto: 20, + cryptography: 20, + rev: 30, + reverse: 30, + "reverse engineering": 30, + reversing: 30, + pwn: 40, + "binary exploitation": 40, + binary: 40, + pwnable: 40, + forensics: 50, + forensic: 50, + dfir: 50, + osint: 60, + recon: 60, + mobile: 70, + android: 70, + ios: 70, + hardware: 80, + iot: 80, + blockchain: 90, + web3: 90, + smartcontract: 90, + ai: 100, + ml: 100, + misc: 110, + miscellaneous: 110, + general: 120, +}; + +/** + * Extract the category name from a document (checking extraFields, tags, and slug). + */ +export function extractDocCategory(doc: DocumentMeta): string { + const extra = doc.extraFields || {}; + if (typeof extra.category === "string" && extra.category.trim()) { + return extra.category.trim(); + } + if (typeof extra.Category === "string" && extra.Category.trim()) { + return extra.Category.trim(); + } + + // Check tags against known CTF categories + const categoryKeywords = [ + "web", + "crypto", + "cryptography", + "pwn", + "binary", + "reverse", + "reversing", + "forensics", + "forensic", + "misc", + "miscellaneous", + "osint", + "hardware", + "mobile", + "blockchain", + "ai", + ]; + for (const tag of doc.tags || []) { + const lower = tag.toLowerCase().trim(); + if (categoryKeywords.includes(lower)) { + return lower.charAt(0).toUpperCase() + lower.slice(1); + } + } + + // Check slug parts (e.g. ctf/gemastik-2026-warmup/web/chall-1) + const parts = doc.slug.split("/"); + if (parts.length >= 3) { + const candidate = parts[parts.length - 2].toLowerCase(); + if (categoryKeywords.includes(candidate)) { + return candidate.charAt(0).toUpperCase() + candidate.slice(1); + } + } + + return "General"; +} + +/** + * Extract integer points from a document (e.g., 500 pts). + */ +export function extractDocPoints(doc: DocumentMeta): number { + const extra = doc.extraFields || {}; + const val = extra.points ?? extra.Points ?? extra.pts ?? extra.score; + if (typeof val === "number") return val; + if (typeof val === "string") { + const parsed = parseInt(val.replace(/[^0-9]/g, ""), 10); + if (!isNaN(parsed)) return parsed; + } + return 0; +} + +/** + * Extract difficulty string from a document. + */ +export function extractDocDifficulty(doc: DocumentMeta): string { + const extra = doc.extraFields || {}; + const val = extra.difficulty ?? extra.Difficulty ?? extra.level; + if (typeof val === "string" && val.trim()) { + return val.trim(); + } + return ""; +} + +/** + * Difficulty priority map for sorting (Easy -> Medium -> Hard -> Insane). + */ +const DIFFICULTY_ORDER: Record = { + beginner: 1, + easy: 2, + simple: 2, + medium: 3, + intermediate: 3, + hard: 4, + expert: 4, + advanced: 4, + insane: 5, +}; + +/** + * Extract solved / challenge completion status. + */ +export function extractDocSolved(doc: DocumentMeta): boolean { + const extra = doc.extraFields || {}; + if (typeof extra.solved === "boolean") return extra.solved; + if (typeof extra.Solved === "boolean") return extra.Solved; + if (typeof extra.flag === "string" && extra.flag.trim().length > 0) return true; + return true; // Default to true for writeups +} + +/** + * Get category priority number (lower = earlier in order). + */ +export function getCategoryPriority(category: string): number { + const clean = category.toLowerCase().trim(); + return CTF_CATEGORY_PRIORITY[clean] ?? 150; +} + +/** + * Sort documents for PDF export neatly per challenge / chapter. + */ +export function sortExportDocuments( + docs: Document[], + sortBy: ExportSortOption = "category_points", +): Document[] { + const copy = [...docs]; + + switch (sortBy) { + case "category_points": + return copy.sort((a, b) => { + const catA = extractDocCategory(a); + const catB = extractDocCategory(b); + const prioA = getCategoryPriority(catA); + const prioB = getCategoryPriority(catB); + + // 1. Sort by category priority + if (prioA !== prioB) return prioA - prioB; + + // 2. If same priority, alphabetical category name + if (catA.toLowerCase() !== catB.toLowerCase()) { + return catA.localeCompare(catB); + } + + // 3. Within category, sort by points ascending + const ptsA = extractDocPoints(a); + const ptsB = extractDocPoints(b); + if (ptsA !== ptsB) return ptsA - ptsB; + + // 4. Then by difficulty + const diffA = DIFFICULTY_ORDER[extractDocDifficulty(a).toLowerCase()] ?? 99; + const diffB = DIFFICULTY_ORDER[extractDocDifficulty(b).toLowerCase()] ?? 99; + if (diffA !== diffB) return diffA - diffB; + + // 5. Fallback to title + return a.title.localeCompare(b.title); + }); + + case "points_desc": + return copy.sort((a, b) => { + const ptsA = extractDocPoints(a); + const ptsB = extractDocPoints(b); + if (ptsA !== ptsB) return ptsB - ptsA; + return a.title.localeCompare(b.title); + }); + + case "difficulty": + return copy.sort((a, b) => { + const diffA = DIFFICULTY_ORDER[extractDocDifficulty(a).toLowerCase()] ?? 99; + const diffB = DIFFICULTY_ORDER[extractDocDifficulty(b).toLowerCase()] ?? 99; + if (diffA !== diffB) return diffA - diffB; + return a.title.localeCompare(b.title); + }); + + case "title": + return copy.sort((a, b) => a.title.localeCompare(b.title)); + + case "updated_at": + return copy.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); + + case "path": + default: + return copy.sort((a, b) => a.slug.localeCompare(b.slug)); + } +} + +/** + * Known acronyms to preserve in uppercase when formatting titles. + */ +const ACRONYMS = new Set([ + "CTF", + "GEMASTIK", + "DEFCON", + "HTB", + "THM", + "PICOCTF", + "TCP", + "UDP", + "HTTP", + "HTTPS", + "API", + "REST", + "SSH", + "SQL", + "XSS", + "CSRF", + "SSRF", + "RCE", + "LFI", + "RFI", + "PWN", + "RSA", + "AES", + "ECC", + "DES", + "TLS", + "SSL", + "OSINT", + "DFIR", + "AI", + "ML", +]); + +/** + * Format a human-readable title from a path or section name. + * e.g., "ctf/gemastik-2026-warmup" -> "GEMASTIK 2026 Warmup โ€” CTF Writeups Report" + */ +export function formatScopeTitle(pathOrSection: string): string { + if (!pathOrSection) return "MCPedia Document Report"; + + const clean = pathOrSection.replace(/^\/+|\/+$/g, ""); + const parts = clean.split("/"); + const last = parts[parts.length - 1]; + + // Capitalize words nicely + const words = last.split(/[-_]/).map((w) => { + const upper = w.toUpperCase(); + if (ACRONYMS.has(upper)) return upper; + if (/^[0-9]+$/.test(w)) return w; + return w.charAt(0).toUpperCase() + w.slice(1); + }); + + const formatted = words.join(" "); + + if (parts.length > 1 && (parts[0].toLowerCase() === "ctf" || parts[0].toLowerCase() === "writeups")) { + if (!/ctf|writeup/i.test(formatted)) { + return `${formatted} โ€” CTF Writeups Report`; + } + } + + return formatted; +} + +/** + * Compile export summary statistics. + */ +export function buildExportSummary( + docs: Document[], + scopePath?: string, + section?: string, +): ExportSummary { + const effectiveSection = section || (docs[0]?.section ?? "docs"); + const sectionMeta = getSectionMeta(effectiveSection); + const scopeTitle = scopePath + ? formatScopeTitle(scopePath) + : `${sectionMeta.label} Export`; + + let totalPoints = 0; + const categoryMap = new Map(); + const authorsSet = new Set(); + + for (const doc of docs) { + const pts = extractDocPoints(doc); + totalPoints += pts; + + const cat = extractDocCategory(doc); + const existing = categoryMap.get(cat) || { count: 0, points: 0 }; + existing.count += 1; + existing.points += pts; + categoryMap.set(cat, existing); + + if (doc.author && doc.author.trim()) { + authorsSet.add(doc.author.trim()); + } + } + + const categories: ExportCategorySummary[] = Array.from(categoryMap.entries()) + .map(([name, stat]) => ({ + name, + count: stat.count, + points: stat.points, + })) + .sort((a, b) => getCategoryPriority(a.name) - getCategoryPriority(b.name)); + + return { + title: scopeTitle, + scope: scopePath || effectiveSection, + section: effectiveSection, + totalDocuments: docs.length, + totalPoints, + categories, + authors: Array.from(authorsSet), + generatedAt: new Date().toISOString(), + }; +} + +/** + * Compile all documents into a single standalone Markdown document. + */ +export function compileExportMarkdown(exportData: ExportData): string { + const { summary, documents: docs } = exportData; + const lines: string[] = []; + + // Frontmatter + lines.push("---"); + lines.push(`title: "${summary.title}"`); + lines.push(`section: "${summary.section}"`); + lines.push(`total_challenges: ${summary.totalDocuments}`); + if (summary.totalPoints > 0) { + lines.push(`total_points: ${summary.totalPoints}`); + } + lines.push(`generated_at: "${summary.generatedAt}"`); + if (summary.authors.length > 0) { + lines.push(`authors: [${summary.authors.map((a) => `"${a}"`).join(", ")}]`); + } + lines.push("---"); + lines.push(""); + + // Cover / Header + lines.push(`# ${summary.title}`); + lines.push(""); + lines.push(`*Generated from MCPedia Knowledge Base on ${new Date(summary.generatedAt).toLocaleDateString(undefined, { dateStyle: "long" })}*`); + lines.push(""); + + if (summary.totalDocuments > 1) { + // Scoreboard / Challenge Matrix + lines.push("## Challenge Overview"); + lines.push(""); + lines.push("| # | Challenge | Category | Points | Difficulty | Author |"); + lines.push("|---|-----------|----------|--------|------------|--------|"); + + docs.forEach((doc, idx) => { + const chNum = idx + 1; + const cat = extractDocCategory(doc); + const pts = extractDocPoints(doc); + const diff = extractDocDifficulty(doc) || "-"; + const author = doc.author || "-"; + lines.push( + `| ${chNum} | [${doc.title}](#ch-${chNum}) | ${cat} | ${pts > 0 ? `${pts} pts` : "-"} | ${diff} | ${author} |`, + ); + }); + lines.push(""); + lines.push("---"); + lines.push(""); + } + + // Chapters per Challenge + docs.forEach((doc, idx) => { + const chNum = idx + 1; + const cat = extractDocCategory(doc); + const pts = extractDocPoints(doc); + const diff = extractDocDifficulty(doc); + + lines.push(``); + lines.push(`## Chapter ${chNum}: ${doc.title}`); + lines.push(""); + + const badges: string[] = []; + if (cat) badges.push(`**Category:** \`${cat}\``); + if (pts > 0) badges.push(`**Points:** \`${pts} pts\``); + if (diff) badges.push(`**Difficulty:** \`${diff}\``); + if (doc.author) badges.push(`**Author:** @${doc.author}`); + if (doc.slug) badges.push(`**Slug:** \`${doc.slug}\``); + + if (badges.length > 0) { + lines.push(badges.join(" ยท ")); + lines.push(""); + } + + lines.push(doc.body.trim()); + lines.push(""); + lines.push("---"); + lines.push(""); + }); + + return lines.join("\n"); +} diff --git a/packages/core/src/export.service.test.ts b/packages/core/src/export.service.test.ts new file mode 100644 index 0000000..a7829d1 --- /dev/null +++ b/packages/core/src/export.service.test.ts @@ -0,0 +1,230 @@ +import { test, expect, describe } from "bun:test"; +import { + extractDocCategory, + extractDocPoints, + extractDocDifficulty, + sortExportDocuments, + buildExportSummary, + compileExportMarkdown, + formatScopeTitle, +} from "./export.service"; +import type { Document } from "@mcpedia/types"; + +describe("Export Service", () => { + const sampleDocs: Document[] = [ + { + id: "ctf/gemastik-2026-warmup/pwn-baby-heap", + slug: "ctf/gemastik-2026-warmup/pwn-baby-heap", + title: "Baby Heap", + type: "writeup", + section: "ctf", + status: "published", + author: "asepharyana", + tags: ["pwn", "heap", "rop"], + path: "ctf/gemastik-2026-warmup/pwn-baby-heap.md", + createdAt: "2026-08-20T10:00:00Z", + updatedAt: "2026-08-21T12:00:00Z", + extraFields: { + category: "Pwn", + points: 500, + difficulty: "Hard", + event: "GEMASTIK 2026 Warmup", + }, + body: "Heap exploitation walkthrough...", + }, + { + id: "ctf/gemastik-2026-warmup/web-login-bypass", + slug: "ctf/gemastik-2026-warmup/web-login-bypass", + title: "Login Bypass 101", + type: "writeup", + section: "ctf", + status: "published", + author: "asepharyana", + tags: ["web", "sqli"], + path: "ctf/gemastik-2026-warmup/web-login-bypass.md", + createdAt: "2026-08-20T10:00:00Z", + updatedAt: "2026-08-21T12:00:00Z", + extraFields: { + category: "Web", + points: 100, + difficulty: "Easy", + event: "GEMASTIK 2026 Warmup", + }, + body: "SQL injection in authentication handler...", + }, + { + id: "ctf/gemastik-2026-warmup/web-ssrf-vault", + slug: "ctf/gemastik-2026-warmup/web-ssrf-vault", + title: "SSRF Vault", + type: "writeup", + section: "ctf", + status: "published", + author: "team-member", + tags: ["web", "ssrf"], + path: "ctf/gemastik-2026-warmup/web-ssrf-vault.md", + createdAt: "2026-08-20T10:00:00Z", + updatedAt: "2026-08-21T12:00:00Z", + extraFields: { + category: "Web", + points: 300, + difficulty: "Medium", + event: "GEMASTIK 2026 Warmup", + }, + body: "Blind SSRF to AWS metadata...", + }, + { + id: "ctf/gemastik-2026-warmup/crypto-quantum-rsa", + slug: "ctf/gemastik-2026-warmup/crypto-quantum-rsa", + title: "Quantum RSA", + type: "writeup", + section: "ctf", + status: "published", + author: "asepharyana", + tags: ["crypto", "rsa"], + path: "ctf/gemastik-2026-warmup/crypto-quantum-rsa.md", + createdAt: "2026-08-20T10:00:00Z", + updatedAt: "2026-08-21T12:00:00Z", + extraFields: { + category: "Crypto", + points: 250, + difficulty: "Easy", + event: "GEMASTIK 2026 Warmup", + }, + body: "Coppersmith theorem applied to small roots...", + }, + { + id: "ctf/gemastik-2026-warmup/rev-rusty-keygen", + slug: "ctf/gemastik-2026-warmup/rev-rusty-keygen", + title: "Rusty Keygen", + type: "writeup", + section: "ctf", + status: "published", + author: "team-member", + tags: ["reverse", "rust"], + path: "ctf/gemastik-2026-warmup/rev-rusty-keygen.md", + createdAt: "2026-08-20T10:00:00Z", + updatedAt: "2026-08-21T12:00:00Z", + extraFields: { + category: "Reverse", + points: 400, + difficulty: "Medium", + event: "GEMASTIK 2026 Warmup", + }, + body: "Decompiling Rust binary using Ghidra...", + }, + ]; + + test("extractDocCategory: identifies category from extraFields, tags, or slug", () => { + expect(extractDocCategory(sampleDocs[0]!)).toBe("Pwn"); + expect(extractDocCategory(sampleDocs[1]!)).toBe("Web"); + + // Tag fallback + const tagDoc: Document = { + ...sampleDocs[0]!, + extraFields: {}, + tags: ["forensics", "wireshark"], + }; + expect(extractDocCategory(tagDoc)).toBe("Forensics"); + + // Slug fallback + const slugDoc: Document = { + ...sampleDocs[0]!, + slug: "ctf/event/crypto/chall1", + extraFields: {}, + tags: [], + }; + expect(extractDocCategory(slugDoc)).toBe("Crypto"); + }); + + test("extractDocPoints: extracts numeric points correctly", () => { + expect(extractDocPoints(sampleDocs[0]!)).toBe(500); + expect(extractDocPoints(sampleDocs[1]!)).toBe(100); + + const stringPtsDoc: Document = { + ...sampleDocs[0]!, + extraFields: { points: "250 pts" }, + }; + expect(extractDocPoints(stringPtsDoc)).toBe(250); + }); + + test("sortExportDocuments: category_points sorts Web -> Crypto -> Rev -> Pwn and ascending points", () => { + const sorted = sortExportDocuments(sampleDocs, "category_points"); + const titles = sorted.map((d) => d.title); + + // Expected order: + // 1. Web: Login Bypass 101 (100 pts) + // 2. Web: SSRF Vault (300 pts) + // 3. Crypto: Quantum RSA (250 pts) + // 4. Reverse: Rusty Keygen (400 pts) + // 5. Pwn: Baby Heap (500 pts) + expect(titles).toEqual([ + "Login Bypass 101", + "SSRF Vault", + "Quantum RSA", + "Rusty Keygen", + "Baby Heap", + ]); + }); + + test("sortExportDocuments: points_desc sorts highest points first", () => { + const sorted = sortExportDocuments(sampleDocs, "points_desc"); + const points = sorted.map((d) => extractDocPoints(d)); + expect(points).toEqual([500, 400, 300, 250, 100]); + }); + + test("sortExportDocuments: title sorts alphabetically", () => { + const sorted = sortExportDocuments(sampleDocs, "title"); + const titles = sorted.map((d) => d.title); + expect(titles).toEqual([ + "Baby Heap", + "Login Bypass 101", + "Quantum RSA", + "Rusty Keygen", + "SSRF Vault", + ]); + }); + + test("formatScopeTitle: formats event paths into clean report titles", () => { + expect(formatScopeTitle("ctf/gemastik-2026-warmup")).toBe( + "GEMASTIK 2026 Warmup โ€” CTF Writeups Report", + ); + expect(formatScopeTitle("docs/getting-started")).toBe("Getting Started"); + }); + + test("buildExportSummary: aggregates total points, categories, and authors", () => { + const summary = buildExportSummary( + sampleDocs, + "ctf/gemastik-2026-warmup", + "ctf", + ); + expect(summary.totalDocuments).toBe(5); + expect(summary.totalPoints).toBe(1550); // 500+100+300+250+400 + expect(summary.authors).toContain("asepharyana"); + expect(summary.authors).toContain("team-member"); + expect(summary.categories.map((c) => c.name)).toEqual([ + "Web", + "Crypto", + "Reverse", + "Pwn", + ]); + }); + + test("compileExportMarkdown: creates complete single markdown report", () => { + const exportData = { + summary: buildExportSummary( + sampleDocs, + "ctf/gemastik-2026-warmup", + "ctf", + ), + documents: sortExportDocuments(sampleDocs, "category_points"), + }; + const md = compileExportMarkdown(exportData); + + expect(md).toContain("# GEMASTIK 2026 Warmup โ€” CTF Writeups Report"); + expect(md).toContain("## Challenge Overview"); + expect(md).toContain("## Chapter 1: Login Bypass 101"); + expect(md).toContain("**Category:** `Web`"); + expect(md).toContain("**Points:** `100 pts`"); + expect(md).toContain("## Chapter 5: Baby Heap"); + }); +}); diff --git a/packages/core/src/export.service.ts b/packages/core/src/export.service.ts new file mode 100644 index 0000000..deca57a --- /dev/null +++ b/packages/core/src/export.service.ts @@ -0,0 +1,115 @@ +import { and, eq, like, or, sql } from "drizzle-orm"; +import { db } from "@mcpedia/db"; +import { documents, type DocumentRow } from "@mcpedia/db/schema"; +import { CONTENT_ROOT } from "@mcpedia/config"; +import { parseFile } from "@mcpedia/parser"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import type { + Document, + ExportData, + ExportQueryOptions, +} from "@mcpedia/types"; +import { toMeta } from "./row-map"; +import { + buildExportSummary, + sortExportDocuments, +} from "./export.helpers"; + +export * from "./export.helpers"; + +/** + * Fetch all documents for export with full bodies directly from PostgreSQL + * (with fallback to disk if empty). + */ +export async function getExportDocuments( + opts: ExportQueryOptions = {}, +): Promise { + const status = opts.status ?? "published"; + const sortBy = opts.sortBy ?? "category_points"; + + let rows: DocumentRow[] = []; + + if (opts.slugs && opts.slugs.length > 0) { + // Explicit list of slugs + rows = await db + .select() + .from(documents) + .where( + and( + eq(documents.status, status), + sql`${documents.slug} IN ${opts.slugs}`, + ), + ); + } else if (opts.path) { + const cleanPath = opts.path.replace(/^\/+|\/+$/g, ""); + + // 1. Try exact match for single document + const exactMatch = await db + .select() + .from(documents) + .where(and(eq(documents.status, status), eq(documents.slug, cleanPath))); + + if (exactMatch.length === 1 && exactMatch[0]) { + rows = exactMatch; + } else { + // 2. Try prefix match for folder/event or section + rows = await db + .select() + .from(documents) + .where( + and( + eq(documents.status, status), + or( + eq(documents.slug, cleanPath), + like(documents.slug, `${cleanPath}/%`), + eq(documents.section, cleanPath), + ), + ), + ); + } + } else if (opts.section) { + // Section-wide match + rows = await db + .select() + .from(documents) + .where( + and( + eq(documents.status, status), + eq(documents.section, opts.section), + ), + ); + } else { + // All published documents + rows = await db + .select() + .from(documents) + .where(eq(documents.status, status)); + } + + // Map rows to Document with bodies + const loadedDocs: Document[] = rows.map((row) => { + let body = row.body; + if (!body) { + const abs = join(CONTENT_ROOT, row.path); + if (existsSync(abs)) { + try { + const parsed = parseFile(abs, row.path); + body = parsed.body; + } catch { + body = ""; + } + } + } + return { ...toMeta(row), body: body || "" }; + }); + + // Sort neatly + const sortedDocs = sortExportDocuments(loadedDocs, sortBy); + const summary = buildExportSummary(sortedDocs, opts.path, opts.section); + + return { + summary, + documents: sortedDocs, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 93b7513..c944e2e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,6 +3,7 @@ export * from "./document.service"; export * from "./search.service"; export * from "./index.service"; export * from "./revision.service"; +export * from "./export.service"; export { toMeta } from "./row-map"; export type { @@ -13,5 +14,10 @@ export type { DocumentMeta, SearchHit, SectionInfo, + ExportSortOption, + ExportCategorySummary, + ExportSummary, + ExportData, + ExportQueryOptions, } from "@mcpedia/types"; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index b5a40a7..5f1cd99 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -40,3 +40,41 @@ export interface SearchHit { snippet: string; } +export type ExportSortOption = + | "category_points" + | "path" + | "title" + | "points_desc" + | "difficulty" + | "updated_at"; + +export interface ExportCategorySummary { + name: string; + count: number; + points: number; +} + +export interface ExportSummary { + title: string; + scope: string; + section: string; + totalDocuments: number; + totalPoints: number; + categories: ExportCategorySummary[]; + authors: string[]; + generatedAt: string; +} + +export interface ExportData { + summary: ExportSummary; + documents: Document[]; +} + +export interface ExportQueryOptions { + path?: string; + section?: string; + slugs?: string[]; + status?: string; + sortBy?: ExportSortOption; +} +