feat(export): add single PDF export for sections and events with chapter ordering
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
This commit is contained in:
@@ -186,9 +186,19 @@ function FolderIndexPage({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-[var(--text-muted)] bg-[var(--bg-elevated)] border border-[var(--border-color)] px-2.5 py-1 rounded-md">
|
||||
{immediateDocs.length + folders.length} item{folders.length + immediateDocs.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono text-[var(--text-muted)] bg-[var(--bg-elevated)] border border-[var(--border-color)] px-2.5 py-1 rounded-md">
|
||||
{immediateDocs.length + folders.length} item{folders.length + immediateDocs.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<Link
|
||||
href={`/export/pdf?path=${section}/${slug}`}
|
||||
className="inline-flex items-center gap-1.5 text-xs bg-[var(--brand)] hover:bg-[var(--brand-hover)] text-white px-3 py-1.5 rounded-lg font-medium transition-all shadow-xs"
|
||||
title={`Export all ${folderName} documents as a single PDF report`}
|
||||
>
|
||||
<span>📄</span>
|
||||
<span>Export PDF Report</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -151,10 +151,20 @@ export default async function SectionIndexPage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap sm:flex-nowrap">
|
||||
<span className="text-xs font-mono text-[var(--brand)] dark:text-[var(--accent)] bg-[var(--brand)]/10 border border-[var(--brand)]/25 px-2.5 py-1 rounded-md">
|
||||
{sectionDocs.length} document{sectionDocs.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{sectionDocs.length > 0 && (
|
||||
<Link
|
||||
href={`/export/pdf?section=${section}`}
|
||||
className="inline-flex items-center gap-1.5 text-xs bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] hover:border-[var(--brand)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] px-3 py-1 rounded-md font-medium transition-all shadow-xs"
|
||||
title={`Export all ${sectionInfo.label} documents to PDF`}
|
||||
>
|
||||
<span>📄</span>
|
||||
<span>Export PDF</span>
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href="/create"
|
||||
className="inline-flex items-center gap-1 text-xs bg-[var(--brand)] hover:bg-[var(--brand-hover)] text-white px-3 py-1 rounded-md font-medium transition-all"
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getExportDocuments, compileExportMarkdown, type ExportSortOption } from "@mcpedia/core";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const path = searchParams.get("path") || undefined;
|
||||
const section = searchParams.get("section") || undefined;
|
||||
const format = searchParams.get("format") || "json";
|
||||
const sortBy = (searchParams.get("sort") as ExportSortOption) || "category_points";
|
||||
const slugsParam = searchParams.get("slugs");
|
||||
const slugs = slugsParam ? slugsParam.split(",").map((s) => 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,18 @@ export default function DocActions({ slug, body, canEdit }: DocActionsProps) {
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Export PDF */}
|
||||
<Link
|
||||
href={`/export/pdf?path=${slug}`}
|
||||
title="Export document or parent folder to PDF"
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--brand)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-all shadow-sm"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
|
||||
</svg>
|
||||
<span>PDF</span>
|
||||
</Link>
|
||||
|
||||
{/* Edit Link */}
|
||||
<Link
|
||||
href={canEdit ? `/${slug}?edit=1` : `/login?redirect=/${slug}`}
|
||||
|
||||
@@ -0,0 +1,765 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import Markdown from "./Markdown";
|
||||
import {
|
||||
extractDocCategory,
|
||||
extractDocPoints,
|
||||
extractDocDifficulty,
|
||||
extractDocSolved,
|
||||
sortExportDocuments,
|
||||
compileExportMarkdown,
|
||||
type Document,
|
||||
type ExportData,
|
||||
type ExportSortOption,
|
||||
type ExportCategorySummary,
|
||||
} from "@mcpedia/core/export-helpers";
|
||||
import { getSectionMeta } from "@mcpedia/config/sections";
|
||||
|
||||
interface PdfExportViewProps {
|
||||
exportData: ExportData;
|
||||
backHref: string;
|
||||
}
|
||||
|
||||
export default function PdfExportView({
|
||||
exportData,
|
||||
backHref,
|
||||
}: PdfExportViewProps) {
|
||||
const { summary, documents: initialDocs } = exportData;
|
||||
|
||||
// Configuration and interactive state
|
||||
const [sortBy, setSortBy] = useState<ExportSortOption>("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<string>("all");
|
||||
const [copiedMd, setCopiedMd] = useState(false);
|
||||
|
||||
const sectionMeta = getSectionMeta(summary.section);
|
||||
|
||||
// Available categories for filtering
|
||||
const allCategories = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
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 (
|
||||
<div className="pb-16 print:pb-0">
|
||||
{/* =========================================================================
|
||||
Export Control Toolbar (Hidden in Print)
|
||||
========================================================================= */}
|
||||
<aside className="no-print export-toolbar sticky top-16 z-30 mb-8 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-2xl p-4 sm:p-5 shadow-md">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
||||
{/* Left info & Back button */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href={backHref}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] rounded-lg text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-all shadow-xs"
|
||||
>
|
||||
<span>←</span>
|
||||
<span>Back</span>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono text-[var(--brand)] dark:text-[var(--accent)] font-semibold uppercase">
|
||||
{sectionMeta.icon} {sectionMeta.label}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--text-dim)]">·</span>
|
||||
<span className="text-xs text-[var(--text-muted)] font-mono">
|
||||
{filteredAndSortedDocs.length} challenge{filteredAndSortedDocs.length !== 1 ? "s" : ""}
|
||||
{currentTotalPoints > 0 ? ` (${currentTotalPoints} pts)` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-sm font-bold text-[var(--text-primary)] truncate max-w-md">
|
||||
{summary.title}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2 flex-wrap sm:flex-nowrap">
|
||||
{/* Copy All Markdown */}
|
||||
<button
|
||||
onClick={handleCopyMarkdown}
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-xs font-medium bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] hover:border-[var(--brand)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-lg transition-all shadow-xs cursor-pointer"
|
||||
title="Copy compiled markdown document"
|
||||
>
|
||||
{copiedMd ? (
|
||||
<>
|
||||
<span className="text-emerald-500 font-bold">✓ Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>📋</span>
|
||||
<span>Copy Markdown</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Download Markdown */}
|
||||
<button
|
||||
onClick={handleDownloadMarkdown}
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-xs font-medium bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] hover:border-[var(--brand)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-lg transition-all shadow-xs cursor-pointer"
|
||||
title="Download compiled .md file"
|
||||
>
|
||||
<span>💾</span>
|
||||
<span>Download .md</span>
|
||||
</button>
|
||||
|
||||
{/* Print / Save as PDF Button */}
|
||||
<button
|
||||
onClick={handlePrint}
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-xs font-semibold bg-[var(--brand)] hover:bg-[var(--brand-hover)] text-white rounded-lg shadow-sm transition-all cursor-pointer hover:shadow-md active:scale-98"
|
||||
title="Open browser print dialog to save as PDF"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Print / Save as PDF</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options & Filters Bar */}
|
||||
<div className="mt-4 pt-3.5 border-t border-[var(--border-color)] flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs">
|
||||
{/* Sort Order Selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[var(--text-muted)] font-medium">Sort by:</span>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as ExportSortOption)}
|
||||
aria-label="Sort documents"
|
||||
className="bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-primary)] rounded-md px-2.5 py-1 text-xs focus:outline-none focus:border-[var(--brand)] cursor-pointer"
|
||||
>
|
||||
<option value="category_points">Category & Points (CTF Standard)</option>
|
||||
<option value="points_desc">Points (Highest First)</option>
|
||||
<option value="difficulty">Difficulty (Easy to Hard)</option>
|
||||
<option value="title">Challenge Title (A-Z)</option>
|
||||
<option value="path">Directory / Chapter Path</option>
|
||||
<option value="updated_at">Recently Updated</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Toggle Switches */}
|
||||
<div className="flex items-center gap-4 flex-wrap text-[var(--text-muted)]">
|
||||
<label className="inline-flex items-center gap-1.5 cursor-pointer hover:text-[var(--text-primary)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showCover}
|
||||
onChange={(e) => setShowCover(e.target.checked)}
|
||||
className="rounded border-[var(--border-color)] text-[var(--brand)] focus:ring-[var(--brand)]"
|
||||
/>
|
||||
<span>Cover Page</span>
|
||||
</label>
|
||||
|
||||
{initialDocs.length > 1 && (
|
||||
<>
|
||||
<label className="inline-flex items-center gap-1.5 cursor-pointer hover:text-[var(--text-primary)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showScoreboard}
|
||||
onChange={(e) => setShowScoreboard(e.target.checked)}
|
||||
className="rounded border-[var(--border-color)] text-[var(--brand)] focus:ring-[var(--brand)]"
|
||||
/>
|
||||
<span>Scoreboard Table</span>
|
||||
</label>
|
||||
|
||||
<label className="inline-flex items-center gap-1.5 cursor-pointer hover:text-[var(--text-primary)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showTOC}
|
||||
onChange={(e) => setShowTOC(e.target.checked)}
|
||||
className="rounded border-[var(--border-color)] text-[var(--brand)] focus:ring-[var(--brand)]"
|
||||
/>
|
||||
<span>Table of Contents</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
<label className="inline-flex items-center gap-1.5 cursor-pointer hover:text-[var(--text-primary)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={pageBreaks}
|
||||
onChange={(e) => setPageBreaks(e.target.checked)}
|
||||
className="rounded border-[var(--border-color)] text-[var(--brand)] focus:ring-[var(--brand)]"
|
||||
/>
|
||||
<span>Page Break per Ch</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Filter Chips */}
|
||||
{allCategories.length > 1 && (
|
||||
<div className="mt-3 pt-3 border-t border-[var(--border-color)] flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[11px] text-[var(--text-muted)] font-medium mr-1">Category:</span>
|
||||
<button
|
||||
onClick={() => setSelectedCategory("all")}
|
||||
className={`px-2 py-0.5 rounded text-xs font-mono transition-colors ${
|
||||
selectedCategory === "all"
|
||||
? "bg-[var(--brand)] text-white font-medium"
|
||||
: "bg-[var(--bg-elevated)] text-[var(--text-muted)] hover:text-[var(--text-primary)] border border-[var(--border-color)]"
|
||||
}`}
|
||||
>
|
||||
All ({initialDocs.length})
|
||||
</button>
|
||||
{allCategories.map((cat: string) => {
|
||||
const count = initialDocs.filter((d: Document) => extractDocCategory(d) === cat).length;
|
||||
const isActive = selectedCategory.toLowerCase() === cat.toLowerCase();
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setSelectedCategory(isActive ? "all" : cat)}
|
||||
className={`px-2 py-0.5 rounded text-xs font-mono transition-colors flex items-center gap-1 ${
|
||||
isActive
|
||||
? "bg-[var(--brand)] text-white font-medium"
|
||||
: "bg-[var(--bg-elevated)] text-[var(--text-muted)] hover:text-[var(--text-primary)] border border-[var(--border-color)]"
|
||||
}`}
|
||||
>
|
||||
<span>{getCategoryIcon(cat)}</span>
|
||||
<span>{cat}</span>
|
||||
<span className="text-[10px] opacity-75 font-sans">({count})</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* =========================================================================
|
||||
Main Printable Document
|
||||
========================================================================= */}
|
||||
<div className="print-container bg-[var(--bg-surface)] print:bg-white border border-[var(--border-color)] print:border-none rounded-2xl print:rounded-none p-6 sm:p-10 print:p-0 shadow-sm print:shadow-none space-y-12 print:space-y-0">
|
||||
|
||||
{/* =======================================================================
|
||||
1. Cover Page
|
||||
======================================================================= */}
|
||||
{showCover && (
|
||||
<section className="cover-page flex flex-col justify-between min-h-[600px] print:min-h-[92vh] p-8 sm:p-12 print:p-10 border border-[var(--border-color)] print:border-none rounded-xl print:rounded-none bg-[var(--bg-elevated)] print:bg-transparent">
|
||||
<div>
|
||||
{/* Header Badge */}
|
||||
<div className="flex items-center justify-between gap-4 mb-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-[var(--brand)] text-white flex items-center justify-center font-bold text-sm">
|
||||
M
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-[var(--text-primary)]">
|
||||
MCPedia
|
||||
</span>
|
||||
<p className="text-[10px] text-[var(--text-dim)] font-mono">
|
||||
Knowledge Base Documentation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-mono font-semibold bg-[var(--brand)]/15 text-[var(--brand)] dark:text-[var(--accent)] border border-[var(--brand)]/30">
|
||||
<span>{sectionMeta.icon}</span>
|
||||
<span className="uppercase">{sectionMeta.label}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title and Subtitle */}
|
||||
<div className="space-y-3 mb-10 pt-4">
|
||||
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-extrabold text-[var(--text-primary)] tracking-tight leading-tight">
|
||||
{summary.title}
|
||||
</h1>
|
||||
<p className="text-sm sm:text-base text-[var(--text-muted)] leading-relaxed max-w-2xl">
|
||||
Comprehensive challenge solutions, technical writeups, and security research findings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Metric Cards Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 my-8">
|
||||
<div className="p-4 bg-[var(--bg-surface)] print:bg-slate-50 border border-[var(--border-color)] rounded-xl">
|
||||
<div className="text-2xl font-bold text-[var(--text-primary)] font-mono">
|
||||
{filteredAndSortedDocs.length}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mt-0.5">
|
||||
Challenges / Ch
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentTotalPoints > 0 && (
|
||||
<div className="p-4 bg-[var(--bg-surface)] print:bg-slate-50 border border-[var(--border-color)] rounded-xl">
|
||||
<div className="text-2xl font-bold text-[var(--brand)] dark:text-[var(--accent)] font-mono">
|
||||
{currentTotalPoints.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mt-0.5">
|
||||
Total Points
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 bg-[var(--bg-surface)] print:bg-slate-50 border border-[var(--border-color)] rounded-xl">
|
||||
<div className="text-2xl font-bold text-[var(--text-primary)] font-mono">
|
||||
{summary.categories.length}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mt-0.5">
|
||||
Categories
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-[var(--bg-surface)] print:bg-slate-50 border border-[var(--border-color)] rounded-xl">
|
||||
<div className="text-2xl font-bold text-[var(--text-primary)] font-mono">
|
||||
{summary.authors.length || 1}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mt-0.5">
|
||||
Contributors
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories Distribution */}
|
||||
{summary.categories.length > 0 && (
|
||||
<div className="space-y-2 mt-6">
|
||||
<span className="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider">
|
||||
Categories Covered:
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{summary.categories.map((cat: ExportCategorySummary) => (
|
||||
<span
|
||||
key={cat.name}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-mono border ${getCategoryBadgeClass(cat.name)}`}
|
||||
>
|
||||
<span>{getCategoryIcon(cat.name)}</span>
|
||||
<span className="font-semibold">{cat.name}:</span>
|
||||
<span>{cat.count} chall{cat.count !== 1 ? "s" : ""}</span>
|
||||
{cat.points > 0 && <span className="opacity-75 font-sans">({cat.points} pts)</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cover Footer */}
|
||||
<div className="pt-8 border-t border-[var(--border-color)] flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs text-[var(--text-muted)]">
|
||||
<div>
|
||||
{summary.authors.length > 0 && (
|
||||
<p>
|
||||
<span className="font-medium text-[var(--text-secondary)]">Authors:</span>{" "}
|
||||
{summary.authors.map((a: string) => `@${a}`).join(", ")}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-0.5 font-mono text-[11px] text-[var(--text-dim)]">
|
||||
Path: {summary.scope}
|
||||
</p>
|
||||
</div>
|
||||
<div className="font-mono text-right sm:text-right">
|
||||
<p>
|
||||
{new Date(summary.generatedAt).toLocaleDateString(undefined, {
|
||||
dateStyle: "long",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-[10px] text-[var(--text-dim)]">Generated by MCPedia</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* =======================================================================
|
||||
2. Scoreboard / Challenge Matrix Table
|
||||
======================================================================= */}
|
||||
{showScoreboard && filteredAndSortedDocs.length > 1 && (
|
||||
<section className="scoreboard-page py-6 print:py-8 border-t border-[var(--border-color)] print:border-none">
|
||||
<div className="flex items-center justify-between gap-4 mb-4">
|
||||
<h2 className="text-lg font-bold text-[var(--text-primary)] tracking-tight flex items-center gap-2">
|
||||
<span>📊</span>
|
||||
<span>Challenge Overview & Scoreboard</span>
|
||||
</h2>
|
||||
<span className="text-xs font-mono text-[var(--text-muted)]">
|
||||
{filteredAndSortedDocs.length} Total Challenges
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto border border-[var(--border-color)] rounded-xl shadow-xs">
|
||||
<table className="w-full text-xs text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-[var(--bg-elevated)] print:bg-slate-100 border-b border-[var(--border-color)] text-[var(--text-muted)] font-mono uppercase">
|
||||
<th className="py-2.5 px-3 w-12 text-center">#</th>
|
||||
<th className="py-2.5 px-3">Challenge</th>
|
||||
<th className="py-2.5 px-3">Category</th>
|
||||
<th className="py-2.5 px-3">Difficulty</th>
|
||||
<th className="py-2.5 px-3 text-right">Points</th>
|
||||
<th className="py-2.5 px-3">Author</th>
|
||||
<th className="py-2.5 px-3 text-center">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-color)]">
|
||||
{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 (
|
||||
<tr
|
||||
key={doc.slug}
|
||||
className="hover:bg-[var(--bg-elevated)]/50 transition-colors"
|
||||
>
|
||||
<td className="py-2.5 px-3 text-center font-mono text-[var(--text-dim)]">
|
||||
{chNum}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 font-medium text-[var(--text-primary)]">
|
||||
<a
|
||||
href={`#ch-${chNum}`}
|
||||
className="hover:text-[var(--brand)] dark:hover:text-[var(--accent)] hover:underline"
|
||||
>
|
||||
{doc.title}
|
||||
</a>
|
||||
</td>
|
||||
<td className="py-2.5 px-3">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-mono border ${getCategoryBadgeClass(cat)}`}
|
||||
>
|
||||
<span>{getCategoryIcon(cat)}</span>
|
||||
<span>{cat}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2.5 px-3">
|
||||
{diff ? (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium border ${getDifficultyBadgeClass(diff)}`}
|
||||
>
|
||||
{diff}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[var(--text-dim)]">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-right font-mono font-semibold text-[var(--brand)] dark:text-[var(--accent)]">
|
||||
{pts > 0 ? `${pts} pts` : "-"}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-[var(--text-secondary)]">
|
||||
{doc.author ? `@${doc.author}` : "-"}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-center">
|
||||
{isSolved ? (
|
||||
<span className="text-emerald-600 font-bold" title="Solved">
|
||||
✓
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[var(--text-dim)]">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* =======================================================================
|
||||
3. Table of Contents (TOC)
|
||||
======================================================================= */}
|
||||
{showTOC && filteredAndSortedDocs.length > 1 && (
|
||||
<section className="toc-page py-6 print:py-8 border-t border-[var(--border-color)] print:border-none">
|
||||
<h2 className="text-lg font-bold text-[var(--text-primary)] tracking-tight mb-4 flex items-center gap-2">
|
||||
<span>📑</span>
|
||||
<span>Table of Contents</span>
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2.5">
|
||||
{filteredAndSortedDocs.map((doc, idx) => {
|
||||
const chNum = idx + 1;
|
||||
const cat = extractDocCategory(doc);
|
||||
const pts = extractDocPoints(doc);
|
||||
|
||||
return (
|
||||
<a
|
||||
key={doc.slug}
|
||||
href={`#ch-${chNum}`}
|
||||
className="group flex items-center justify-between p-3 rounded-lg bg-[var(--bg-elevated)] print:bg-slate-50 hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-xs transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0 pr-2">
|
||||
<span className="font-mono text-[11px] font-bold text-[var(--brand)] dark:text-[var(--accent)] bg-[var(--brand)]/10 px-1.5 py-0.5 rounded shrink-0">
|
||||
Ch {chNum}
|
||||
</span>
|
||||
<span className="font-medium text-[var(--text-primary)] group-hover:text-[var(--brand)] dark:group-hover:text-[var(--accent)] truncate">
|
||||
{doc.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0 font-mono text-[11px]">
|
||||
<span className="text-[var(--text-muted)]">{cat}</span>
|
||||
{pts > 0 && (
|
||||
<span className="text-[var(--brand)] dark:text-[var(--accent)] font-semibold">
|
||||
· {pts}p
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* =======================================================================
|
||||
4. Challenge Chapters ("Urutan Rapih Per Ch")
|
||||
======================================================================= */}
|
||||
<div className="space-y-12 print:space-y-0">
|
||||
{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 (
|
||||
<article
|
||||
key={doc.slug}
|
||||
id={`ch-${chNum}`}
|
||||
className={`chapter-page ${
|
||||
pageBreaks && idx > 0 ? "page-break-before" : ""
|
||||
} pt-8 first:pt-0 print:pt-6`}
|
||||
>
|
||||
{/* Chapter Header Card */}
|
||||
<div className="challenge-header-card bg-[var(--bg-elevated)] print:bg-slate-50 border border-[var(--border-color)] print:border-slate-300 rounded-xl p-5 sm:p-6 mb-6 shadow-xs">
|
||||
{/* Chapter index label */}
|
||||
<div className="flex items-center justify-between gap-3 mb-2 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-0.5 rounded text-xs font-mono font-bold bg-[var(--brand)] text-white">
|
||||
CHAPTER {chNum}
|
||||
</span>
|
||||
<span className="text-xs font-mono text-[var(--text-dim)]">
|
||||
Challenge {chNum} of {filteredAndSortedDocs.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="text-xs font-mono text-[var(--text-dim)]">
|
||||
{doc.slug}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Challenge Title */}
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-[var(--text-primary)] tracking-tight leading-snug mb-4">
|
||||
{doc.title}
|
||||
</h2>
|
||||
|
||||
{/* Metadata Chips Bar */}
|
||||
<div className="badge-container flex flex-wrap items-center gap-2 pt-3 border-t border-[var(--border-color)]">
|
||||
{/* Category Chip */}
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-mono font-semibold border ${getCategoryBadgeClass(cat)}`}
|
||||
>
|
||||
<span>{getCategoryIcon(cat)}</span>
|
||||
<span>Category: {cat}</span>
|
||||
</span>
|
||||
|
||||
{/* Points Chip */}
|
||||
{pts > 0 && (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-mono font-semibold bg-[var(--brand)]/10 text-[var(--brand)] dark:text-[var(--accent)] border border-[var(--brand)]/30">
|
||||
<span>🎯</span>
|
||||
<span>{pts} pts</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Difficulty Chip */}
|
||||
{diff && (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium border ${getDifficultyBadgeClass(diff)}`}
|
||||
>
|
||||
<span>Difficulty: {diff}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Solved Status */}
|
||||
{isSolved && (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30">
|
||||
<span>✓</span>
|
||||
<span>Solved</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Author Chip */}
|
||||
{doc.author && (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs bg-[var(--bg-surface)] border border-[var(--border-color)] text-[var(--text-secondary)]">
|
||||
<span>👤</span>
|
||||
<span>@{doc.author}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Date Chip */}
|
||||
{doc.updatedAt && (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs bg-[var(--bg-surface)] border border-[var(--border-color)] text-[var(--text-dim)] font-mono">
|
||||
<span>📅</span>
|
||||
<span>{new Date(doc.updatedAt).toLocaleDateString()}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 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 (
|
||||
<span
|
||||
key={k}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs bg-[var(--bg-surface)] border border-[var(--border-color)] text-[var(--text-muted)] font-mono"
|
||||
>
|
||||
<span className="text-[var(--text-dim)]">{k}:</span>
|
||||
<span className="text-[var(--text-secondary)] font-semibold">
|
||||
{String(v)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Markdown Writeup Body */}
|
||||
<div className="prose-content mb-10">
|
||||
<Markdown source={doc.body} />
|
||||
</div>
|
||||
|
||||
{/* Chapter End Divider */}
|
||||
<div className="print:hidden border-b border-[var(--border-color)] my-8" />
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="py-16 text-center space-y-4 max-w-lg mx-auto">
|
||||
<div className="text-4xl">📄</div>
|
||||
<h1 className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
No Documents Found for Export
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--text-muted)]">
|
||||
Could not find any published documents matching the path{" "}
|
||||
<code className="text-[var(--brand)] font-mono">
|
||||
{targetPath || section || "root"}
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
<div className="pt-4">
|
||||
<Link
|
||||
href={backHref}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 bg-[var(--brand)] hover:bg-[var(--brand-hover)] text-white text-xs font-semibold rounded-lg shadow-sm transition-all"
|
||||
>
|
||||
<span>← Return to Knowledge Base</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <PdfExportView exportData={exportData} backHref={backHref} />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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<string, number> = {
|
||||
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<string, number> = {
|
||||
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<string, { count: number; points: number }>();
|
||||
const authorsSet = new Set<string>();
|
||||
|
||||
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(`<a id="ch-${chNum}"></a>`);
|
||||
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");
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<ExportData> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user