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

This commit is contained in:
asepharyana
2026-08-22 11:41:49 +07:00
parent 2a70a111a2
commit 5cd9ea92a7
14 changed files with 1891 additions and 6 deletions
+2 -1
View File
@@ -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:*",
+434
View File
@@ -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");
}
+230
View File
@@ -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");
});
});
+115
View File
@@ -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,
};
}
+6
View File
@@ -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";
+38
View File
@@ -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;
}