feat: add Word (.docx) export, enhance print density, and fix auth security guards
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 17:59:32 +07:00
parent 067115ba13
commit 5ccba958d2
16 changed files with 1374 additions and 85 deletions
+13 -8
View File
@@ -13,15 +13,23 @@ function signCookie(value: string): string {
return `${value}.${sig}`; return `${value}.${sig}`;
} }
function safeTimingEqual(a: string, b: string): boolean {
try {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
} catch {
return false;
}
}
function verifyCookie(cookieValue: string | undefined): boolean { function verifyCookie(cookieValue: string | undefined): boolean {
if (!cookieValue) return false; if (!cookieValue) return false;
const [value, sig] = cookieValue.split("."); const [value, sig] = cookieValue.split(".");
if (!value || !sig) return false; if (!value || !sig) return false;
const expected = signCookie(value); const expected = signCookie(value);
return timingSafeEqual( return safeTimingEqual(cookieValue, expected);
Buffer.from(cookieValue),
Buffer.from(expected),
);
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
@@ -32,10 +40,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
} }
const ok = timingSafeEqual( const ok = safeTimingEqual(password, ADMIN_PASSWORD);
Buffer.from(password),
Buffer.from(ADMIN_PASSWORD),
);
if (!ok) { if (!ok) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
+6 -2
View File
@@ -6,8 +6,12 @@ import { timingSafeEqual } from "node:crypto";
function isAuthorized(req: NextRequest): boolean { function isAuthorized(req: NextRequest): boolean {
if (!WEBHOOK_SECRET) return false; if (!WEBHOOK_SECRET) return false;
const headerSecret = req.headers.get("x-webhook-secret") ?? ""; const headerSecret = req.headers.get("x-webhook-secret") ?? "";
if (headerSecret && timingSafeEqual(Buffer.from(headerSecret), Buffer.from(WEBHOOK_SECRET))) { if (headerSecret) {
return true; const bufA = Buffer.from(headerSecret);
const bufB = Buffer.from(WEBHOOK_SECRET);
if (bufA.length === bufB.length && timingSafeEqual(bufA, bufB)) {
return true;
}
} }
const cookie = req.cookies.get("mcpedia_admin")?.value ?? ""; const cookie = req.cookies.get("mcpedia_admin")?.value ?? "";
return cookie.startsWith("admin."); return cookie.startsWith("admin.");
+6 -2
View File
@@ -13,8 +13,12 @@ import { timingSafeEqual } from "node:crypto";
function isAuthorized(req: NextRequest): boolean { function isAuthorized(req: NextRequest): boolean {
if (!WEBHOOK_SECRET) return false; if (!WEBHOOK_SECRET) return false;
const headerSecret = req.headers.get("x-webhook-secret") ?? ""; const headerSecret = req.headers.get("x-webhook-secret") ?? "";
if (headerSecret && timingSafeEqual(Buffer.from(headerSecret), Buffer.from(WEBHOOK_SECRET))) { if (headerSecret) {
return true; const bufA = Buffer.from(headerSecret);
const bufB = Buffer.from(WEBHOOK_SECRET);
if (bufA.length === bufB.length && timingSafeEqual(bufA, bufB)) {
return true;
}
} }
const cookie = req.cookies.get("mcpedia_admin")?.value ?? ""; const cookie = req.cookies.get("mcpedia_admin")?.value ?? "";
return cookie.startsWith("admin."); return cookie.startsWith("admin.");
+25 -6
View File
@@ -1,5 +1,10 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getExportDocuments, compileExportMarkdown, type ExportSortOption } from "@mcpedia/core"; import {
getExportDocuments,
compileExportMarkdown,
generateExportDocx,
type ExportSortOption,
} from "@mcpedia/core";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -11,6 +16,8 @@ export async function GET(req: NextRequest) {
const sortBy = (searchParams.get("sort") as ExportSortOption) || "category_points"; const sortBy = (searchParams.get("sort") as ExportSortOption) || "category_points";
const slugsParam = searchParams.get("slugs"); const slugsParam = searchParams.get("slugs");
const slugs = slugsParam ? slugsParam.split(",").map((s) => s.trim()) : undefined; const slugs = slugsParam ? slugsParam.split(",").map((s) => s.trim()) : undefined;
const pageBreaksParam = searchParams.get("pageBreaks");
const pageBreaks = pageBreaksParam !== "false" && pageBreaksParam !== "0";
try { try {
const data = await getExportDocuments({ const data = await getExportDocuments({
@@ -20,13 +27,25 @@ export async function GET(req: NextRequest) {
sortBy, sortBy,
}); });
const safeFilename = data.summary.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") || "mcpedia-export";
if (format === "docx" || format === "word") {
const buffer = await generateExportDocx(data, { pageBreaks });
const uint8 = new Uint8Array(buffer);
return new NextResponse(uint8, {
headers: {
"Content-Type":
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"Content-Disposition": `attachment; filename="${safeFilename}.docx"`,
},
});
}
if (format === "markdown" || format === "md") { if (format === "markdown" || format === "md") {
const md = compileExportMarkdown(data); const md = compileExportMarkdown(data);
const safeFilename = data.summary.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
return new NextResponse(md, { return new NextResponse(md, {
headers: { headers: {
"Content-Type": "text/markdown; charset=utf-8", "Content-Type": "text/markdown; charset=utf-8",
@@ -1,12 +1,32 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { restoreRevision, getRevision } from "@mcpedia/core"; import { restoreRevision, getRevision } from "@mcpedia/core";
import { WEBHOOK_SECRET } from "@mcpedia/config";
import { timingSafeEqual } from "node:crypto";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
function isAuthorized(req: NextRequest): boolean {
if (!WEBHOOK_SECRET) return false;
const headerSecret = req.headers.get("x-webhook-secret") ?? "";
if (headerSecret) {
const bufA = Buffer.from(headerSecret);
const bufB = Buffer.from(WEBHOOK_SECRET);
if (bufA.length === bufB.length && timingSafeEqual(bufA, bufB)) {
return true;
}
}
const cookie = req.cookies.get("mcpedia_admin")?.value ?? "";
return cookie.startsWith("admin.");
}
// POST /api/revisions/restore — restore a document to a past revision. // POST /api/revisions/restore — restore a document to a past revision.
// Body (form-urlencoded): id=<revision uuid> // Body (form-urlencoded): id=<revision uuid>
// After restoring, we rebuild semantic chunks (handled inside restoreRevision) // After restoring, we rebuild semantic chunks (handled inside restoreRevision)
// and revalidate the doc page so the Web UI reflects the restored body. // and revalidate the doc page so the Web UI reflects the restored body.
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
if (!isAuthorized(req)) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const form = await req.formData().catch(() => null); const form = await req.formData().catch(() => null);
const id = form?.get("id"); const id = form?.get("id");
if (typeof id !== "string" || id.length === 0) { if (typeof id !== "string" || id.length === 0) {
+11
View File
@@ -7,6 +7,7 @@ import {
Check, Check,
Share2, Share2,
FileDown, FileDown,
FileText,
Edit3, Edit3,
} from "lucide-react"; } from "lucide-react";
@@ -84,6 +85,16 @@ export default function DocActions({ slug, body, canEdit }: DocActionsProps) {
<span>PDF</span> <span>PDF</span>
</Link> </Link>
{/* Export Word (.docx) */}
<a
href={`/api/export?path=${encodeURIComponent(slug)}&format=docx`}
title="Download editable Microsoft Word (.docx) file"
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-colors shadow-xs cursor-pointer"
>
<FileText className="w-3.5 h-3.5" />
<span>Word (.docx)</span>
</a>
{/* Edit Link */} {/* Edit Link */}
<Link <Link
href={canEdit ? `/${slug}?edit=1` : `/login?redirect=/${slug}`} href={canEdit ? `/${slug}?edit=1` : `/login?redirect=/${slug}`}
+1 -1
View File
@@ -31,7 +31,7 @@ export default function Header() {
}, []); }, []);
return ( return (
<header className="sticky top-0 z-40 border-b border-[var(--border-color)] glass-nav transition-all"> <header className="site-header no-print sticky top-0 z-40 border-b border-[var(--border-color)] glass-nav transition-all">
<div className="mx-auto max-w-7xl px-4 sm:px-6 h-14 flex items-center justify-between gap-3 sm:gap-4"> <div className="mx-auto max-w-7xl px-4 sm:px-6 h-14 flex items-center justify-between gap-3 sm:gap-4">
{/* Brand */} {/* Brand */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
+23 -23
View File
@@ -51,16 +51,16 @@ function PreBlock({ children, ...props }: React.HTMLAttributes<HTMLPreElement>)
} }
return ( return (
<div className="code-block-wrapper relative group my-6 rounded-xl overflow-hidden border border-[var(--border-code)] bg-[var(--code-block-bg)] shadow-sm print:my-3 print:rounded-md print:border-slate-300 print:bg-slate-50"> <div className="code-block-wrapper relative group my-6 print:my-2 rounded-xl print:rounded border border-[var(--border-code)] print:border-slate-300 bg-[var(--code-block-bg)] print:bg-slate-50 shadow-sm print:shadow-none">
{/* Code block header bar */} {/* Code block header bar */}
<div className="code-block-header flex items-center justify-between px-4 py-2.5 bg-[var(--code-header-bg)] border-b border-[var(--border-code)] text-xs font-mono select-none print:px-3 print:py-1 print:bg-slate-100 print:border-slate-300"> <div className="code-block-header flex items-center justify-between px-4 py-2.5 print:px-2.5 print:py-0.5 bg-[var(--code-header-bg)] print:bg-slate-100 border-b border-[var(--border-code)] print:border-slate-300 text-xs font-mono select-none">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="no-print flex items-center gap-1.5 opacity-75 group-hover:opacity-100 transition-opacity"> <div className="no-print flex items-center gap-1.5 opacity-75 group-hover:opacity-100 transition-opacity">
<span className="w-2.5 h-2.5 rounded-full bg-rose-500/80 inline-block" /> <span className="w-2.5 h-2.5 rounded-full bg-rose-500/80 inline-block" />
<span className="w-2.5 h-2.5 rounded-full bg-amber-500/80 inline-block" /> <span className="w-2.5 h-2.5 rounded-full bg-amber-500/80 inline-block" />
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500/80 inline-block" /> <span className="w-2.5 h-2.5 rounded-full bg-emerald-500/80 inline-block" />
</div> </div>
<span className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-muted)] print:text-slate-700 group-hover:text-[var(--text-primary)] transition-colors"> <span className="text-[11px] print:text-[7pt] font-semibold uppercase tracking-wider text-[var(--text-muted)] print:text-slate-700 group-hover:text-[var(--text-primary)] transition-colors">
{language} {language}
</span> </span>
</div> </div>
@@ -85,7 +85,7 @@ function PreBlock({ children, ...props }: React.HTMLAttributes<HTMLPreElement>)
</button> </button>
</div> </div>
<pre className="!m-0 !bg-transparent p-4 sm:p-5 print:p-3 overflow-x-auto text-[13px] sm:text-[13.5px] print:text-[8.5pt] text-[var(--pre-text)] print:text-slate-900 font-mono leading-relaxed" {...props}> <pre className="!m-0 !bg-transparent p-4 sm:p-5 print:p-2 overflow-x-auto text-[13px] sm:text-[13.5px] print:text-[7.5pt] text-[var(--pre-text)] print:text-slate-900 font-mono leading-relaxed print:leading-snug" {...props}>
{children} {children}
</pre> </pre>
</div> </div>
@@ -97,15 +97,15 @@ export default function Markdown({ source }: { source: string }) {
<div <div
className="prose max-w-none className="prose max-w-none
prose-headings:text-[var(--text-primary)] prose-headings:font-bold prose-headings:tracking-tight prose-headings:text-[var(--text-primary)] prose-headings:font-bold prose-headings:tracking-tight
prose-h1:text-2xl sm:prose-h1:text-3xl prose-h1:mb-6 prose-h1:mt-8 prose-h1:pb-3 prose-h1:border-b prose-h1:border-[var(--border-color)] prose-h1:text-2xl sm:prose-h1:text-3xl prose-h1:mb-6 prose-h1:mt-8 prose-h1:pb-3 prose-h1:border-b prose-h1:border-[var(--border-color)] print:prose-h1:text-[13pt] print:prose-h1:mt-2 print:prose-h1:mb-1 print:prose-h1:pb-0.5
prose-h2:text-xl sm:prose-h2:text-2xl prose-h2:mb-4 prose-h2:mt-8 prose-h2:pb-2 prose-h2:border-b prose-h2:border-[var(--border-subtle)] prose-h2:text-xl sm:prose-h2:text-2xl prose-h2:mb-4 prose-h2:mt-8 prose-h2:pb-2 prose-h2:border-b prose-h2:border-[var(--border-subtle)] print:prose-h2:text-[10.5pt] print:prose-h2:mt-2 print:prose-h2:mb-1 print:prose-h2:pb-0.5
prose-h3:text-lg prose-h3:mb-3 prose-h3:mt-6 prose-h3:text-lg prose-h3:mb-3 prose-h3:mt-6 print:prose-h3:text-[9.5pt] print:prose-h3:mt-1.5 print:prose-h3:mb-0.5
prose-p:text-[var(--text-secondary)] prose-p:leading-relaxed prose-p:mb-4 prose-p:text-[var(--text-secondary)] prose-p:leading-relaxed prose-p:mb-4 print:prose-p:text-[8.5pt] print:prose-p:leading-snug print:prose-p:mb-1.5
prose-a:text-[var(--accent)] prose-a:font-medium prose-a:no-underline hover:prose-a:underline hover:prose-a:text-[var(--accent-hover)] prose-a:text-[var(--accent)] prose-a:font-medium prose-a:no-underline hover:prose-a:underline hover:prose-a:text-[var(--accent-hover)]
prose-ul:text-[var(--text-secondary)] prose-ul:my-4 prose-li:my-1.5 prose-ul:text-[var(--text-secondary)] prose-ul:my-4 prose-li:my-1.5 print:prose-ul:my-1 print:prose-li:my-0.5
prose-ol:text-[var(--text-secondary)] prose-ol:my-4 prose-ol:text-[var(--text-secondary)] prose-ol:my-4 print:prose-ol:my-1
prose-strong:text-[var(--text-primary)] prose-strong:font-semibold prose-strong:text-[var(--text-primary)] prose-strong:font-semibold
prose-hr:border-[var(--border-color)] prose-hr:my-8" prose-hr:border-[var(--border-color)] prose-hr:my-8 print:prose-hr:my-2"
> >
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
@@ -122,7 +122,7 @@ export default function Markdown({ source }: { source: string }) {
} }
return ( return (
<code <code
className="px-1.5 py-0.5 rounded-md bg-[var(--code-inline-bg)] text-[var(--code-inline-text)] border border-[var(--code-inline-border)] font-mono text-[0.875em] font-medium" className="px-1.5 py-0.5 rounded-md print:rounded-none bg-[var(--code-inline-bg)] print:bg-slate-100 text-[var(--code-inline-text)] print:text-slate-900 border border-[var(--code-inline-border)] print:border-slate-200 font-mono text-[0.875em] print:text-[7.5pt] font-medium"
{...props} {...props}
> >
{children} {children}
@@ -130,7 +130,7 @@ export default function Markdown({ source }: { source: string }) {
); );
}, },
table: ({ node, ...props }) => ( table: ({ node, ...props }) => (
<div className="overflow-x-auto my-6 border border-[var(--border-color)] rounded-xl shadow-xs"> <div className="overflow-x-auto my-6 print:my-1.5 border border-[var(--border-color)] print:border-slate-300 rounded-xl print:rounded-none shadow-xs print:shadow-none">
<table className="w-full border-collapse" {...props} /> <table className="w-full border-collapse" {...props} />
</div> </div>
), ),
@@ -140,35 +140,35 @@ export default function Markdown({ source }: { source: string }) {
if (alertMatch) { if (alertMatch) {
const alertType = alertMatch[1].toUpperCase(); const alertType = alertMatch[1].toUpperCase();
let borderClass = "border-indigo-500 bg-indigo-500/10 text-indigo-900 dark:text-indigo-200"; let borderClass = "border-indigo-500 bg-indigo-500/10 text-indigo-900 dark:text-indigo-200 print:bg-indigo-50 print:text-indigo-950";
let Icon = Info; let Icon = Info;
let title = "Note"; let title = "Note";
if (alertType === "TIP") { if (alertType === "TIP") {
borderClass = "border-emerald-500 bg-emerald-500/10 text-emerald-900 dark:text-emerald-200"; borderClass = "border-emerald-500 bg-emerald-500/10 text-emerald-900 dark:text-emerald-200 print:bg-emerald-50 print:text-emerald-950";
Icon = Lightbulb; Icon = Lightbulb;
title = "Tip"; title = "Tip";
} else if (alertType === "IMPORTANT") { } else if (alertType === "IMPORTANT") {
borderClass = "border-violet-500 bg-violet-500/10 text-violet-900 dark:text-violet-200"; borderClass = "border-violet-500 bg-violet-500/10 text-violet-900 dark:text-violet-200 print:bg-violet-50 print:text-violet-950";
Icon = Flame; Icon = Flame;
title = "Important"; title = "Important";
} else if (alertType === "WARNING") { } else if (alertType === "WARNING") {
borderClass = "border-amber-500 bg-amber-500/10 text-amber-900 dark:text-amber-200"; borderClass = "border-amber-500 bg-amber-500/10 text-amber-900 dark:text-amber-200 print:bg-amber-50 print:text-amber-950";
Icon = AlertTriangle; Icon = AlertTriangle;
title = "Warning"; title = "Warning";
} else if (alertType === "CAUTION") { } else if (alertType === "CAUTION") {
borderClass = "border-rose-500 bg-rose-500/10 text-rose-900 dark:text-rose-200"; borderClass = "border-rose-500 bg-rose-500/10 text-rose-900 dark:text-rose-200 print:bg-rose-50 print:text-rose-950";
Icon = ShieldAlert; Icon = ShieldAlert;
title = "Caution"; title = "Caution";
} }
return ( return (
<div className={`my-5 p-4 rounded-xl border-l-4 ${borderClass} shadow-xs`}> <div className={`my-5 print:my-1.5 p-4 print:p-2 rounded-xl print:rounded border-l-4 ${borderClass} shadow-xs print:shadow-none`}>
<div className="flex items-center gap-2 font-bold text-xs uppercase tracking-wider mb-1.5"> <div className="flex items-center gap-2 font-bold text-xs print:text-[7.5pt] uppercase tracking-wider mb-1.5 print:mb-0.5">
<Icon className="w-4 h-4" /> <Icon className="w-4 h-4 print:w-3 print:h-3" />
<span>{title}</span> <span>{title}</span>
</div> </div>
<div className="text-xs sm:text-sm text-[var(--text-secondary)] [&>p]:mb-1 [&>p:last-child]:mb-0"> <div className="text-xs sm:text-sm print:text-[8pt] text-[var(--text-secondary)] print:text-slate-900 [&>p]:mb-1 [&>p:last-child]:mb-0 print:[&>p]:mb-0.5">
{children} {children}
</div> </div>
</div> </div>
@@ -176,7 +176,7 @@ export default function Markdown({ source }: { source: string }) {
} }
return ( return (
<blockquote className="border-l-4 border-[var(--brand)] bg-[var(--brand)]/5 dark:bg-[var(--brand)]/10 pl-4 py-2.5 my-5 rounded-r-xl text-[var(--text-secondary)] not-italic"> <blockquote className="border-l-4 border-[var(--brand)] print:border-slate-500 bg-[var(--brand)]/5 dark:bg-[var(--brand)]/10 print:bg-slate-50 pl-4 print:pl-2.5 py-2.5 print:py-1 my-5 print:my-1.5 rounded-r-xl print:rounded-none text-[var(--text-secondary)] print:text-slate-900 not-italic print:text-[8pt]">
{children} {children}
</blockquote> </blockquote>
); );
+63 -15
View File
@@ -17,6 +17,7 @@ import { getSectionMeta } from "@mcpedia/config/sections";
import { import {
Printer, Printer,
FileDown, FileDown,
FileText,
Copy, Copy,
Check, Check,
ArrowLeft, ArrowLeft,
@@ -119,6 +120,37 @@ export default function PdfExportView({
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
const [downloadingDocx, setDownloadingDocx] = useState(false);
async function handleDownloadDocx() {
setDownloadingDocx(true);
try {
const slugs = filteredAndSortedDocs.map((d) => d.slug).join(",");
const safeName =
summary.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") || "export";
const url = `/api/export?format=docx&slugs=${encodeURIComponent(slugs)}&sort=${sortBy}&pageBreaks=${pageBreaks}`;
const res = await fetch(url);
if (!res.ok) throw new Error("Failed to generate Word document");
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = blobUrl;
a.download = `${safeName}.docx`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
} catch (err) {
console.error("DOCX download error:", err);
alert("Failed to generate .docx file. Please try again.");
} finally {
setDownloadingDocx(false);
}
}
return ( return (
<div className="pb-16 print:pb-0"> <div className="pb-16 print:pb-0">
{/* Export Control Toolbar (Screen Only — Hidden in Print) */} {/* Export Control Toolbar (Screen Only — Hidden in Print) */}
@@ -183,6 +215,22 @@ export default function PdfExportView({
<span>Download .md</span> <span>Download .md</span>
</button> </button>
{/* Download Word (.docx) */}
<button
onClick={handleDownloadDocx}
disabled={downloadingDocx}
type="button"
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-md transition-colors shadow-xs cursor-pointer disabled:opacity-50"
title="Download editable Microsoft Word (.docx) document"
>
{downloadingDocx ? (
<div className="w-3.5 h-3.5 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : (
<FileText className="w-3.5 h-3.5" />
)}
<span>Download .docx</span>
</button>
{/* Print / Save as PDF Button */} {/* Print / Save as PDF Button */}
<button <button
onClick={handlePrint} onClick={handlePrint}
@@ -280,13 +328,13 @@ export default function PdfExportView({
</aside> </aside>
{/* Writeup Document Flow */} {/* Writeup Document Flow */}
<main className="print-container bg-white text-slate-900 max-w-4xl mx-auto px-4 sm:px-8 py-6 print:p-0 print:max-w-none rounded-xl border border-[var(--border-color)] print:border-none shadow-xs print:shadow-none"> <main className="print-container bg-white text-slate-900 max-w-4xl mx-auto px-4 sm:px-8 py-6 print:p-0 print:m-0 print:max-w-none rounded-xl border border-[var(--border-color)] print:border-none shadow-xs print:shadow-none">
{/* Document Header */} {/* Document Header */}
<header className="mb-8 pb-4 border-b border-slate-300"> <header className="doc-header mb-8 pb-4 print:mb-2.5 print:pb-1.5 border-b border-slate-300">
<h1 className="text-3xl sm:text-4xl font-extrabold text-slate-950 tracking-tight mb-2"> <h1 className="text-3xl sm:text-4xl print:text-lg font-extrabold text-slate-950 tracking-tight mb-2 print:mb-0.5">
{summary.title} {summary.title}
</h1> </h1>
<div className="text-xs text-slate-500 font-mono flex flex-wrap items-center gap-x-2 gap-y-1"> <div className="text-xs print:text-[7.5pt] text-slate-500 font-mono flex flex-wrap items-center gap-x-2 gap-y-1 print:gap-x-1.5 print:gap-y-0.5">
<span>{filteredAndSortedDocs.length} Challenges</span> <span>{filteredAndSortedDocs.length} Challenges</span>
{currentTotalPoints > 0 && ( {currentTotalPoints > 0 && (
<span>· {currentTotalPoints.toLocaleString()} Total Points</span> <span>· {currentTotalPoints.toLocaleString()} Total Points</span>
@@ -297,25 +345,25 @@ export default function PdfExportView({
{/* Table of Contents */} {/* Table of Contents */}
{showTOC && filteredAndSortedDocs.length > 1 && ( {showTOC && filteredAndSortedDocs.length > 1 && (
<section className="toc-section mb-10 pb-6 border-b border-slate-200"> <section className="toc-section mb-10 pb-6 print:mb-2.5 print:pb-1.5 border-b border-slate-200 print:border-slate-300">
<h2 className="text-xs font-bold uppercase font-mono text-slate-700 mb-3 tracking-wider flex items-center gap-1.5"> <h2 className="text-xs print:text-[8pt] font-bold uppercase font-mono text-slate-700 mb-3 print:mb-1 tracking-wider flex items-center gap-1.5">
<BookOpen className="w-3.5 h-3.5" /> <BookOpen className="w-3.5 h-3.5 print:w-3 print:h-3" />
<span>Table of Contents</span> <span>Table of Contents</span>
</h2> </h2>
<ol className="list-decimal list-inside space-y-1.5 text-xs text-slate-800"> <ol className="list-decimal list-inside space-y-1.5 print:space-y-0.5 text-xs print:text-[7.5pt] text-slate-800">
{filteredAndSortedDocs.map((doc, idx) => { {filteredAndSortedDocs.map((doc, idx) => {
const cat = extractDocCategory(doc); const cat = extractDocCategory(doc);
const pts = extractDocPoints(doc); const pts = extractDocPoints(doc);
return ( return (
<li key={doc.slug}> <li key={doc.slug} className="print:leading-tight">
<a <a
href={`#ch-${idx + 1}`} href={`#ch-${idx + 1}`}
className="hover:underline font-medium text-slate-900" className="hover:underline font-medium text-slate-900"
> >
{doc.title} {doc.title}
</a> </a>
<span className="text-slate-500 font-mono ml-2"> <span className="text-slate-500 font-mono ml-2 print:ml-1">
[{cat}{pts > 0 ? ` · ${pts} pts` : ""}] [{cat}{pts > 0 ? ` · ${pts} pts` : ""}]
</span> </span>
</li> </li>
@@ -340,14 +388,14 @@ export default function PdfExportView({
id={`ch-${chNum}`} id={`ch-${chNum}`}
className={`challenge-entry ${ className={`challenge-entry ${
pageBreaks && idx > 0 ? "page-break-before" : "" pageBreaks && idx > 0 ? "page-break-before" : ""
} pt-6 first:pt-0`} } pt-6 first:pt-0 print:pt-2.5 print:pb-2 print:border-b print:border-slate-200 print:last:border-none`}
> >
{/* Challenge Title & Metadata */} {/* Challenge Title & Metadata */}
<div className="mb-4 pb-2 border-b border-slate-200"> <div className="challenge-header mb-4 pb-2 print:mb-1.5 print:pb-1 border-b border-slate-200 print:border-slate-300">
<h2 className="text-2xl font-bold text-slate-950 tracking-tight leading-tight mb-1.5"> <h2 className="text-2xl print:text-[11pt] font-bold text-slate-950 tracking-tight leading-tight mb-1.5 print:mb-0.5">
{chNum}. {doc.title} {chNum}. {doc.title}
</h2> </h2>
<div className="text-xs font-mono text-slate-600 flex flex-wrap items-center gap-x-3 gap-y-1"> <div className="text-xs print:text-[7.5pt] font-mono text-slate-600 flex flex-wrap items-center gap-x-3 print:gap-x-2 gap-y-1 print:gap-y-0.5">
<span><strong>Category:</strong> {cat}</span> <span><strong>Category:</strong> {cat}</span>
{pts > 0 && <span><strong>Points:</strong> {pts} pts</span>} {pts > 0 && <span><strong>Points:</strong> {pts} pts</span>}
{diff && <span><strong>Difficulty:</strong> {diff}</span>} {diff && <span><strong>Difficulty:</strong> {diff}</span>}
@@ -387,7 +435,7 @@ export default function PdfExportView({
</div> </div>
{/* Markdown Writeup Content */} {/* Markdown Writeup Content */}
<div className="prose prose-slate max-w-none text-slate-900 mb-8"> <div className="prose prose-slate max-w-none text-slate-900 mb-8 print:mb-1">
<Markdown source={doc.body} /> <Markdown source={doc.body} />
</div> </div>
+175 -24
View File
@@ -437,27 +437,31 @@ body {
@media print { @media print {
@page { @page {
size: A4 portrait; size: A4 portrait;
margin: 18mm 15mm 18mm 15mm; margin: 10mm 12mm 10mm 12mm;
} }
*, *::before, *::after { *, *::before, *::after {
-webkit-print-color-adjust: exact !important; -webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important; print-color-adjust: exact !important;
box-sizing: border-box !important;
} }
html, body { html, body {
background-color: #ffffff !important; background-color: #ffffff !important;
color: #09090b !important; color: #0f172a !important;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
font-size: 10pt !important; font-size: 8.5pt !important;
line-height: 1.5 !important; line-height: 1.35 !important;
margin: 0 !important; margin: 0 !important;
padding: 0 !important; padding: 0 !important;
width: 100% !important; width: 100% !important;
} }
header, /* Hide navigation, toolbars, sidebar, and web UI controls */
aside, header.site-header,
header.glass-nav,
aside.site-sidebar,
aside:not(.print-include),
footer, footer,
nav.site-header, nav.site-header,
.no-print, .no-print,
@@ -467,7 +471,8 @@ body {
} }
main, main,
.print-container { .print-container,
main > div {
max-width: 100% !important; max-width: 100% !important;
width: 100% !important; width: 100% !important;
margin: 0 !important; margin: 0 !important;
@@ -477,53 +482,190 @@ body {
background: transparent !important; background: transparent !important;
} }
/* Explicit page break classes */
.page-break-before, .page-break-before,
.chapter-page, .chapter-page {
.challenge-entry {
break-before: page !important; break-before: page !important;
page-break-before: always !important; page-break-before: always !important;
} }
/* Prevent awkward mid-element page breaks */
.avoid-break-inside, .avoid-break-inside,
.code-block-wrapper, .code-block-wrapper,
.code-block-container, .code-block-container,
.challenge-entry, .callout,
blockquote,
table, table,
tr, tr,
img, img {
pre,
blockquote {
break-inside: avoid !important; break-inside: avoid !important;
page-break-inside: avoid !important; page-break-inside: avoid !important;
} }
/* Prevent headings from being orphaned at the bottom of pages */
.challenge-header,
h1, h2, h3, h4, h5, h6 {
break-after: avoid !important;
page-break-after: avoid !important;
}
/* Dense / compact challenge flow */
.challenge-entry {
break-inside: auto !important;
page-break-inside: auto !important;
}
/* Compact typography & prose overrides */
p,
.prose p {
margin-top: 0 !important;
margin-bottom: 2.5pt !important;
font-size: 8.5pt !important;
line-height: 1.35 !important;
color: #1e293b !important;
}
h1,
.prose h1 {
font-size: 13pt !important;
line-height: 1.2 !important;
font-weight: 800 !important;
color: #020617 !important;
margin-top: 6pt !important;
margin-bottom: 2pt !important;
padding-bottom: 1pt !important;
border-bottom: 1px solid #cbd5e1 !important;
}
h2,
.prose h2 {
font-size: 10.5pt !important;
line-height: 1.2 !important;
font-weight: 700 !important;
color: #020617 !important;
margin-top: 5pt !important;
margin-bottom: 2pt !important;
padding-bottom: 1pt !important;
border-bottom: 1px solid #e2e8f0 !important;
}
h3,
.prose h3 {
font-size: 9.5pt !important;
line-height: 1.2 !important;
font-weight: 700 !important;
color: #0f172a !important;
margin-top: 4pt !important;
margin-bottom: 1.5pt !important;
}
h4, h5, h6,
.prose h4, .prose h5, .prose h6 {
font-size: 8.5pt !important;
line-height: 1.2 !important;
font-weight: 600 !important;
color: #1e293b !important;
margin-top: 3pt !important;
margin-bottom: 1pt !important;
}
ul, ol,
.prose ul, .prose ol {
margin-top: 1pt !important;
margin-bottom: 2.5pt !important;
padding-left: 13pt !important;
}
li,
.prose li {
margin-top: 0.5pt !important;
margin-bottom: 0.5pt !important;
font-size: 8.5pt !important;
line-height: 1.3 !important;
}
li > p {
margin: 0 !important;
}
blockquote,
.prose blockquote,
.callout {
margin: 2.5pt 0 !important;
padding: 2pt 5pt !important;
font-size: 8pt !important;
line-height: 1.3 !important;
border-left: 2.5px solid #64748b !important;
background-color: #f8fafc !important;
border-radius: 0 2px 2px 0 !important;
}
table,
.prose table {
margin: 2.5pt 0 !important;
font-size: 7.5pt !important;
line-height: 1.25 !important;
border-collapse: collapse !important;
width: 100% !important;
border: 1px solid #cbd5e1 !important;
}
th, td,
.prose th, .prose td {
padding: 2pt 4pt !important;
border: 1px solid #cbd5e1 !important;
color: #1e293b !important;
}
th,
.prose th {
background-color: #f1f5f9 !important;
font-weight: 700 !important;
text-transform: uppercase !important;
letter-spacing: 0.04em !important;
}
hr,
.prose hr {
margin: 3pt 0 !important;
border-color: #e2e8f0 !important;
}
img { img {
max-width: 100% !important; max-width: 100% !important;
max-height: 220px !important;
height: auto !important; height: auto !important;
object-fit: contain !important;
margin: 2.5pt auto !important;
display: block !important;
} }
.code-block-wrapper, .code-block-wrapper,
.code-block-container { .code-block-container {
break-inside: avoid !important; break-inside: avoid !important;
page-break-inside: avoid !important; page-break-inside: avoid !important;
border: 1px solid #d4d4d8 !important; border: 1px solid #cbd5e1 !important;
background-color: #f4f4f5 !important; background-color: #f8fafc !important;
border-radius: 4px !important; border-radius: 3px !important;
margin: 8pt 0 !important; margin: 2.5pt 0 !important;
overflow: hidden !important; overflow: hidden !important;
} }
.code-block-header { .code-block-header {
border-bottom: 1px solid #d4d4d8 !important; border-bottom: 1px solid #cbd5e1 !important;
background-color: #e4e4e7 !important; background-color: #f1f5f9 !important;
padding: 3pt 8pt !important; padding: 1.5pt 5pt !important;
color: #27272a !important; color: #334155 !important;
font-size: 7pt !important;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
font-weight: 700 !important;
text-transform: uppercase !important;
} }
.code-block-wrapper pre { .code-block-wrapper pre {
border: none !important; border: none !important;
background-color: transparent !important; background-color: transparent !important;
padding: 6pt 8pt !important; padding: 3pt 5pt !important;
margin: 0 !important; margin: 0 !important;
border-radius: 0 !important; border-radius: 0 !important;
} }
@@ -535,7 +677,16 @@ body {
word-break: break-all !important; word-break: break-all !important;
overflow-wrap: anywhere !important; overflow-wrap: anywhere !important;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
font-size: 8.5pt !important; font-size: 7.5pt !important;
line-height: 1.4 !important; line-height: 1.3 !important;
color: #0f172a !important;
}
:not(pre) > code {
padding: 0.5pt 2pt !important;
font-size: 7.5pt !important;
background-color: #f1f5f9 !important;
border: 1px solid #e2e8f0 !important;
border-radius: 2px !important;
} }
} }
+2 -2
View File
@@ -46,11 +46,11 @@ export default function RootLayout({
<div className="mx-auto max-w-7xl w-full flex-1 flex flex-col"> <div className="mx-auto max-w-7xl w-full flex-1 flex flex-col">
<div className="flex flex-1"> <div className="flex flex-1">
<aside className="hidden lg:block w-72 shrink-0 border-r border-[var(--border-color)] sticky top-14 h-[calc(100vh-3.5rem)] overflow-y-auto bg-[var(--bg-app)]"> <aside className="no-print hidden lg:block w-72 shrink-0 border-r border-[var(--border-color)] sticky top-14 h-[calc(100vh-3.5rem)] overflow-y-auto bg-[var(--bg-app)]">
<Sidebar /> <Sidebar />
</aside> </aside>
<main className="flex-1 min-w-0"> <main className="flex-1 min-w-0">
<div className="px-4 sm:px-8 py-8 sm:py-10 max-w-4xl mx-auto xl:max-w-5xl"> <div className="px-4 sm:px-8 py-8 sm:py-10 print:p-0 print:m-0 print:max-w-none max-w-4xl mx-auto xl:max-w-5xl">
{children} {children}
</div> </div>
</main> </main>
+49 -2
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -15,6 +15,7 @@
"@mcpedia/parser": "workspace:*", "@mcpedia/parser": "workspace:*",
"@mcpedia/search": "workspace:*", "@mcpedia/search": "workspace:*",
"@mcpedia/types": "workspace:*", "@mcpedia/types": "workspace:*",
"docx": "^9.7.1",
"drizzle-orm": "^0.38.0" "drizzle-orm": "^0.38.0"
}, },
"scripts": { "scripts": {
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from "bun:test";
import {
parseInlineMarkdown,
convertMarkdownToDocx,
generateExportDocx,
} from "./export.docx";
import type { ExportData } from "@mcpedia/types";
describe("DOCX Export Generator", () => {
it("parseInlineMarkdown: formats plain, bold, italic, inline code, and links", () => {
const text = "Hello **world** and *italic* with `const x = 1;` and [Link](https://example.com)";
const runs = parseInlineMarkdown(text);
expect(runs.length).toBeGreaterThan(1);
});
it("convertMarkdownToDocx: handles headings, tables, callouts, and code blocks", () => {
const markdown = `
# Title
Some paragraph text with **bold** words.
## Subsection
> [!NOTE]
> This is a crucial note for testing.
\`\`\`python
def solve():
return "flag{test}"
\`\`\`
| Challenge | Points |
| --- | --- |
| Chall 1 | 100 |
| Chall 2 | 200 |
- Item 1
- Item 2
1. First
2. Second
`;
const elements = convertMarkdownToDocx(markdown);
expect(elements.length).toBeGreaterThan(5);
});
it("generateExportDocx: builds a valid Word document buffer with OpenXML signature", async () => {
const mockExportData: ExportData = {
summary: {
title: "CTF 2026 Writeups",
scope: "ctf/2026",
section: "ctf",
totalDocuments: 2,
totalPoints: 600,
categories: [
{ name: "Web", count: 1, points: 200 },
{ name: "Pwn", count: 1, points: 400 },
],
authors: ["asepharyana"],
generatedAt: new Date().toISOString(),
},
documents: [
{
id: "ctf/2026/web/baby-web",
slug: "ctf/2026/web/baby-web",
title: "Baby Web",
section: "ctf",
type: "writeup",
status: "published",
author: "asepharyana",
tags: ["web", "sqli"],
path: "ctf/2026/web/baby-web.md",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
extraFields: { category: "Web", points: 200, difficulty: "Easy" },
body: "# Baby Web\n\nSolution explanation here.\n\n```sql\nSELECT * FROM users;\n```",
},
{
id: "ctf/2026/pwn/ret2win",
slug: "ctf/2026/pwn/ret2win",
title: "Ret2Win Challenge",
section: "ctf",
type: "writeup",
status: "published",
author: "asepharyana",
tags: ["pwn", "rop"],
path: "ctf/2026/pwn/ret2win.md",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
extraFields: { category: "Pwn", points: 400, difficulty: "Medium" },
body: "## Challenge Overview\n\nStack buffer overflow vulnerability.",
},
],
};
const buffer = await generateExportDocx(mockExportData, { pageBreaks: true });
expect(buffer).toBeDefined();
expect(buffer.length).toBeGreaterThan(1000);
// Standard ZIP/DOCX magic bytes: 0x50, 0x4B, 0x03, 0x04 ('PK\x03\x04')
expect(buffer[0]).toBe(0x50);
expect(buffer[1]).toBe(0x4b);
expect(buffer[2]).toBe(0x03);
expect(buffer[3]).toBe(0x04);
});
});
+875
View File
@@ -0,0 +1,875 @@
import {
Document as DocxDocument,
Paragraph,
TextRun,
Table,
TableRow,
TableCell,
HeadingLevel,
AlignmentType,
BorderStyle,
WidthType,
ShadingType,
PageBreak,
Header,
Footer,
PageNumber,
Packer,
ExternalHyperlink,
} from "docx";
import type {
Document as CoreDocument,
ExportData,
ExportSummary,
} from "@mcpedia/types";
import {
extractDocCategory,
extractDocPoints,
extractDocDifficulty,
} from "./export.helpers";
// ---------------------------------------------------------------------------
// Markdown to DOCX Inlines & Blocks Parser
// ---------------------------------------------------------------------------
type InlineElement = TextRun | ExternalHyperlink;
/**
* Parse inline markdown formatting (bold, italic, code, links) into docx TextRuns / Hyperlinks.
*/
export function parseInlineMarkdown(text: string): InlineElement[] {
if (!text) return [];
const elements: InlineElement[] = [];
// Regex pattern matching:
// 1. Links: [text](url)
// 2. Inline code: `code`
// 3. Bold + Italic: ***text*** or ___text___
// 4. Bold: **text** or __text__
// 5. Italic: *text* or _text_
const inlineRegex =
/(\[([^\]]+)\]\(([^)]+)\)|`([^`]+)`|\*\*\*([^*]+)\*\*\*|___([^_]+)___|\*\*([^*]+)\*\*|__([^_]+)__|(?<!\*)\*([^*]+)\*(?!\*)|(?<!_)_([^_]+)_(?!_))/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = inlineRegex.exec(text)) !== null) {
// Add any preceding plain text
if (match.index > lastIndex) {
elements.push(
new TextRun({
text: text.slice(lastIndex, match.index),
font: "Inter, Calibri, Arial",
size: 20, // 10pt
color: "1E293B",
}),
);
}
if (match[2] && match[3]) {
// Hyperlink [text](url)
elements.push(
new ExternalHyperlink({
children: [
new TextRun({
text: match[2],
font: "Inter, Calibri, Arial",
size: 20,
color: "2563EB",
underline: {},
}),
],
link: match[3],
}),
);
} else if (match[4]) {
// Inline code `code`
elements.push(
new TextRun({
text: match[4],
font: "Consolas, Courier New",
size: 18, // 9pt
color: "0F172A",
shading: {
type: ShadingType.CLEAR,
fill: "F1F5F9",
color: "auto",
},
}),
);
} else if (match[5] || match[6]) {
// Bold + Italic ***text***
elements.push(
new TextRun({
text: match[5] || match[6],
font: "Inter, Calibri, Arial",
size: 20,
bold: true,
italics: true,
color: "0F172A",
}),
);
} else if (match[7] || match[8]) {
// Bold **text**
elements.push(
new TextRun({
text: match[7] || match[8],
font: "Inter, Calibri, Arial",
size: 20,
bold: true,
color: "0F172A",
}),
);
} else if (match[9] || match[10]) {
// Italic *text*
elements.push(
new TextRun({
text: match[9] || match[10],
font: "Inter, Calibri, Arial",
size: 20,
italics: true,
color: "334155",
}),
);
}
lastIndex = inlineRegex.lastIndex;
}
// Trailing plain text
if (lastIndex < text.length) {
elements.push(
new TextRun({
text: text.slice(lastIndex),
font: "Inter, Calibri, Arial",
size: 20,
color: "1E293B",
}),
);
}
return elements.length > 0
? elements
: [
new TextRun({
text,
font: "Inter, Calibri, Arial",
size: 20,
color: "1E293B",
}),
];
}
/**
* Parse markdown table lines into a Docx Table.
*/
function parseMarkdownTable(tableLines: string[]): Table | null {
if (tableLines.length < 2) return null;
const rows: string[][] = [];
for (const line of tableLines) {
const trimmed = line.trim();
if (!trimmed.startsWith("|") && !trimmed.endsWith("|")) continue;
// Skip separator line (|---|---|)
if (/^\|[\s\-:|]+\|$/.test(trimmed)) continue;
const cells = trimmed
.split("|")
.slice(1, -1)
.map((c) => c.trim());
rows.push(cells);
}
if (rows.length === 0) return null;
const colCount = Math.max(...rows.map((r) => r.length));
return new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
margins: {
top: 100,
bottom: 100,
left: 140,
right: 140,
},
rows: rows.map((rowCells, rowIdx) => {
const isHeader = rowIdx === 0;
return new TableRow({
tableHeader: isHeader,
children: Array.from({ length: colCount }).map((_, colIdx) => {
const cellText = rowCells[colIdx] || "";
return new TableCell({
shading: isHeader
? { type: ShadingType.CLEAR, fill: "F1F5F9", color: "auto" }
: rowIdx % 2 === 1
? { type: ShadingType.CLEAR, fill: "FFFFFF", color: "auto" }
: { type: ShadingType.CLEAR, fill: "F8FAFC", color: "auto" },
borders: {
top: { style: BorderStyle.SINGLE, size: 4, color: "CBD5E1" },
bottom: { style: BorderStyle.SINGLE, size: 4, color: "CBD5E1" },
left: { style: BorderStyle.SINGLE, size: 4, color: "CBD5E1" },
right: { style: BorderStyle.SINGLE, size: 4, color: "CBD5E1" },
},
children: [
new Paragraph({
spacing: { before: 40, after: 40 },
children: isHeader
? [
new TextRun({
text: cellText,
font: "Inter, Calibri, Arial",
size: 18,
bold: true,
color: "0F172A",
}),
]
: parseInlineMarkdown(cellText),
}),
],
});
}),
});
}),
});
}
/**
* Convert a markdown body string into an array of docx Paragraphs and Tables.
*/
export function convertMarkdownToDocx(markdown: string): (Paragraph | Table)[] {
const elements: (Paragraph | Table)[] = [];
const lines = markdown.split(/\r?\n/);
let i = 0;
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trim();
// 1. Blank lines
if (trimmed === "") {
i++;
continue;
}
// 2. Fenced Code Blocks (```lang ... ```)
if (trimmed.startsWith("```")) {
const lang = trimmed.slice(3).trim();
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].trim().startsWith("```")) {
codeLines.push(lines[i]);
i++;
}
i++; // skip closing ```
const codeParagraphs = codeLines.map(
(codeLine) =>
new Paragraph({
spacing: { before: 0, after: 0, line: 240 },
children: [
new TextRun({
text: codeLine.replace(/\t/g, " ") || " ",
font: "Consolas, Courier New",
size: 17, // 8.5pt
color: "0F172A",
}),
],
}),
);
// Wrap in a shaded callout table for code box look
const codeBox = new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
margins: { top: 120, bottom: 120, left: 160, right: 160 },
rows: [
new TableRow({
children: [
new TableCell({
shading: {
type: ShadingType.CLEAR,
fill: "F8FAFC",
color: "auto",
},
borders: {
top: { style: BorderStyle.SINGLE, size: 6, color: "CBD5E1" },
bottom: { style: BorderStyle.SINGLE, size: 6, color: "CBD5E1" },
left: { style: BorderStyle.SINGLE, size: 18, color: "64748B" }, // darker left accent
right: { style: BorderStyle.SINGLE, size: 6, color: "CBD5E1" },
},
children: [
...(lang
? [
new Paragraph({
spacing: { before: 0, after: 60 },
children: [
new TextRun({
text: lang.toUpperCase(),
font: "Consolas, Courier New",
size: 15,
bold: true,
color: "64748B",
}),
],
}),
]
: []),
...codeParagraphs,
],
}),
],
}),
],
});
elements.push(codeBox);
elements.push(new Paragraph({ spacing: { before: 80, after: 0 } }));
continue;
}
// 3. Markdown Tables (| col1 | col2 |)
if (trimmed.startsWith("|") && trimmed.endsWith("|")) {
const tableLines: string[] = [];
while (i < lines.length && lines[i].trim().startsWith("|")) {
tableLines.push(lines[i]);
i++;
}
const table = parseMarkdownTable(tableLines);
if (table) {
elements.push(table);
elements.push(new Paragraph({ spacing: { before: 80, after: 0 } }));
}
continue;
}
// 4. Blockquotes & GitHub Alert Callouts (> [!NOTE], > [!TIP], etc.)
if (trimmed.startsWith(">")) {
const quoteLines: string[] = [];
while (i < lines.length && lines[i].trim().startsWith(">")) {
quoteLines.push(lines[i].trim().replace(/^>\s?/, ""));
i++;
}
const fullQuote = quoteLines.join("\n");
const alertMatch = fullQuote.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/i);
let borderColor = "3B82F6"; // Blue
let fillColor = "EFF6FF";
let title = "NOTE";
if (alertMatch) {
const alertType = alertMatch[1].toUpperCase();
if (alertType === "TIP") {
borderColor = "10B981";
fillColor = "ECFDF5";
title = "TIP";
} else if (alertType === "IMPORTANT") {
borderColor = "8B5CF6";
fillColor = "F5F3FF";
title = "IMPORTANT";
} else if (alertType === "WARNING") {
borderColor = "F59E0B";
fillColor = "FFFBEB";
title = "WARNING";
} else if (alertType === "CAUTION") {
borderColor = "EF4444";
fillColor = "FEF2F2";
title = "CAUTION";
}
}
const contentLines = alertMatch
? fullQuote.replace(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*\n?/i, "").split("\n")
: quoteLines;
const calloutTable = new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
margins: { top: 100, bottom: 100, left: 160, right: 160 },
rows: [
new TableRow({
children: [
new TableCell({
shading: {
type: ShadingType.CLEAR,
fill: fillColor,
color: "auto",
},
borders: {
top: { style: BorderStyle.NONE, size: 0, color: "auto" },
bottom: { style: BorderStyle.NONE, size: 0, color: "auto" },
left: { style: BorderStyle.SINGLE, size: 24, color: borderColor },
right: { style: BorderStyle.NONE, size: 0, color: "auto" },
},
children: [
...(alertMatch
? [
new Paragraph({
spacing: { before: 0, after: 40 },
children: [
new TextRun({
text: `[ ${title} ]`,
font: "Inter, Calibri, Arial",
size: 18,
bold: true,
color: borderColor,
}),
],
}),
]
: []),
...contentLines.map(
(qLine) =>
new Paragraph({
spacing: { before: 20, after: 40 },
children: parseInlineMarkdown(qLine),
}),
),
],
}),
],
}),
],
});
elements.push(calloutTable);
elements.push(new Paragraph({ spacing: { before: 80, after: 0 } }));
continue;
}
// 5. Headings
if (trimmed.startsWith("#### ")) {
elements.push(
new Paragraph({
heading: HeadingLevel.HEADING_4,
spacing: { before: 180, after: 60 },
children: [
new TextRun({
text: trimmed.slice(5),
font: "Inter, Calibri, Arial",
size: 22, // 11pt
bold: true,
color: "1E293B",
}),
],
}),
);
i++;
continue;
}
if (trimmed.startsWith("### ")) {
elements.push(
new Paragraph({
heading: HeadingLevel.HEADING_3,
spacing: { before: 220, after: 80 },
children: [
new TextRun({
text: trimmed.slice(4),
font: "Inter, Calibri, Arial",
size: 24, // 12pt
bold: true,
color: "0F172A",
}),
],
}),
);
i++;
continue;
}
if (trimmed.startsWith("## ")) {
elements.push(
new Paragraph({
heading: HeadingLevel.HEADING_2,
spacing: { before: 280, after: 100 },
border: {
bottom: { style: BorderStyle.SINGLE, size: 6, color: "E2E8F0" },
},
children: [
new TextRun({
text: trimmed.slice(3),
font: "Inter, Calibri, Arial",
size: 28, // 14pt
bold: true,
color: "020617",
}),
],
}),
);
i++;
continue;
}
if (trimmed.startsWith("# ")) {
elements.push(
new Paragraph({
heading: HeadingLevel.HEADING_1,
spacing: { before: 320, after: 120 },
border: {
bottom: { style: BorderStyle.SINGLE, size: 8, color: "CBD5E1" },
},
children: [
new TextRun({
text: trimmed.slice(2),
font: "Inter, Calibri, Arial",
size: 34, // 17pt
bold: true,
color: "020617",
}),
],
}),
);
i++;
continue;
}
// 6. Horizontal Rule (--- or ***)
if (/^(\-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
elements.push(
new Paragraph({
spacing: { before: 120, after: 120 },
border: {
bottom: { style: BorderStyle.SINGLE, size: 6, color: "CBD5E1" },
},
}),
);
i++;
continue;
}
// 7. Bullet Lists (- item or * item)
if (/^[-*+]\s+/.test(trimmed)) {
const itemText = trimmed.replace(/^[-*+]\s+/, "");
elements.push(
new Paragraph({
bullet: { level: 0 },
spacing: { before: 20, after: 40 },
children: parseInlineMarkdown(itemText),
}),
);
i++;
continue;
}
// 8. Numbered Lists (1. item)
if (/^\d+\.\s+/.test(trimmed)) {
const numMatch = trimmed.match(/^(\d+)\.\s+(.*)$/);
const num = numMatch ? numMatch[1] : "1";
const itemText = numMatch ? numMatch[2] : trimmed;
elements.push(
new Paragraph({
spacing: { before: 20, after: 40 },
indent: { left: 360 },
children: [
new TextRun({
text: `${num}. `,
font: "Inter, Calibri, Arial",
size: 20,
bold: true,
color: "475569",
}),
...parseInlineMarkdown(itemText),
],
}),
);
i++;
continue;
}
// 9. Standard Paragraph
elements.push(
new Paragraph({
spacing: { before: 40, after: 100, line: 276 }, // 1.15 line spacing
children: parseInlineMarkdown(line),
}),
);
i++;
}
return elements;
}
// ---------------------------------------------------------------------------
// DOCX Document Builder
// ---------------------------------------------------------------------------
export interface DocxExportOptions {
pageBreaks?: boolean;
}
/**
* Generate a complete, publication-grade Microsoft Word (.docx) document
* from MCPedia ExportData.
*/
export async function generateExportDocx(
exportData: ExportData,
options: DocxExportOptions = {},
): Promise<Buffer> {
const { summary, documents: docs } = exportData;
const pageBreaks = options.pageBreaks ?? true;
const docChildren: (Paragraph | Table)[] = [];
// ==========================================
// 1. Document Cover & Header
// ==========================================
docChildren.push(
new Paragraph({
heading: HeadingLevel.TITLE,
spacing: { before: 0, after: 120 },
children: [
new TextRun({
text: summary.title,
font: "Inter, Calibri, Arial",
size: 44, // 22pt
bold: true,
color: "020617",
}),
],
}),
);
// Metadata subtitle
const metaParts: string[] = [
`${summary.totalDocuments} Challenge${summary.totalDocuments !== 1 ? "s" : ""}`,
];
if (summary.totalPoints > 0) {
metaParts.push(`${summary.totalPoints.toLocaleString()} Total Points`);
}
metaParts.push(
new Date(summary.generatedAt).toLocaleDateString(undefined, {
dateStyle: "long",
}),
);
if (summary.authors && summary.authors.length > 0) {
metaParts.push(`Authors: ${summary.authors.join(", ")}`);
}
docChildren.push(
new Paragraph({
spacing: { before: 0, after: 240 },
border: {
bottom: { style: BorderStyle.SINGLE, size: 8, color: "CBD5E1" },
},
children: [
new TextRun({
text: metaParts.join(" · "),
font: "Consolas, Courier New",
size: 18, // 9pt
color: "64748B",
}),
],
}),
);
// ==========================================
// 2. Table of Contents / Matrix (if > 1 doc)
// ==========================================
if (docs.length > 1) {
docChildren.push(
new Paragraph({
heading: HeadingLevel.HEADING_2,
spacing: { before: 200, after: 120 },
children: [
new TextRun({
text: "Table of Contents & Overview",
font: "Inter, Calibri, Arial",
size: 26, // 13pt
bold: true,
color: "0F172A",
}),
],
}),
);
const tocRows: string[][] = [
["#", "Challenge Title", "Category", "Points", "Difficulty"],
];
docs.forEach((doc, idx) => {
const chNum = String(idx + 1);
const cat = extractDocCategory(doc);
const pts = extractDocPoints(doc);
const diff = extractDocDifficulty(doc) || "-";
tocRows.push([
chNum,
doc.title,
cat,
pts > 0 ? `${pts} pts` : "-",
diff,
]);
});
const tocTable = parseMarkdownTable(
tocRows.map((r) => `| ${r.join(" | ")} |`),
);
if (tocTable) {
docChildren.push(tocTable);
}
docChildren.push(
new Paragraph({
spacing: { before: 200, after: 200 },
border: {
bottom: { style: BorderStyle.SINGLE, size: 6, color: "E2E8F0" },
},
}),
);
}
// ==========================================
// 3. Challenge Writeup Chapters
// ==========================================
docs.forEach((doc, idx) => {
const chNum = idx + 1;
const cat = extractDocCategory(doc);
const pts = extractDocPoints(doc);
const diff = extractDocDifficulty(doc);
// Page break between challenges
if (pageBreaks && idx > 0) {
docChildren.push(
new Paragraph({
children: [new PageBreak()],
}),
);
}
// Chapter Title
docChildren.push(
new Paragraph({
heading: HeadingLevel.HEADING_1,
spacing: { before: pageBreaks ? 0 : 280, after: 80 },
children: [
new TextRun({
text: `${chNum}. ${doc.title}`,
font: "Inter, Calibri, Arial",
size: 32, // 16pt
bold: true,
color: "020617",
}),
],
}),
);
// Metadata Badge Ribbon
const badgeTextParts: string[] = [`CATEGORY: ${cat.toUpperCase()}`];
if (pts > 0) badgeTextParts.push(`POINTS: ${pts} PTS`);
if (diff) badgeTextParts.push(`DIFFICULTY: ${diff.toUpperCase()}`);
if (doc.author) badgeTextParts.push(`AUTHOR: ${doc.author}`);
docChildren.push(
new Paragraph({
spacing: { before: 0, after: 180 },
border: {
bottom: { style: BorderStyle.SINGLE, size: 6, color: "CBD5E1" },
},
children: [
new TextRun({
text: badgeTextParts.join(" | "),
font: "Consolas, Courier New",
size: 16, // 8pt
bold: true,
color: "475569",
}),
],
}),
);
// Markdown Writeup Content
const contentElements = convertMarkdownToDocx(doc.body);
docChildren.push(...contentElements);
// Subtle divider after challenge (when not page breaking)
if (!pageBreaks && idx < docs.length - 1) {
docChildren.push(
new Paragraph({
spacing: { before: 180, after: 180 },
border: {
bottom: { style: BorderStyle.DASHED, size: 6, color: "CBD5E1" },
},
}),
);
}
});
// ==========================================
// 4. Construct Final DOCX Package
// ==========================================
const docx = new DocxDocument({
creator: "MCPedia Knowledge Base",
title: summary.title,
description: `Export of ${summary.title} generated by MCPedia`,
sections: [
{
properties: {
page: {
margin: {
top: 1440, // 1 inch (1440 dxa)
bottom: 1440,
left: 1440,
right: 1440,
},
},
},
headers: {
default: new Header({
children: [
new Paragraph({
alignment: AlignmentType.RIGHT,
spacing: { after: 120 },
children: [
new TextRun({
text: `MCPedia · ${summary.title}`,
font: "Consolas, Courier New",
size: 16,
color: "94A3B8",
}),
],
}),
],
}),
},
footers: {
default: new Footer({
children: [
new Paragraph({
alignment: AlignmentType.RIGHT,
spacing: { before: 120 },
children: [
new TextRun({
text: "Page ",
font: "Inter, Calibri, Arial",
size: 16,
color: "94A3B8",
}),
new TextRun({
children: [PageNumber.CURRENT],
font: "Inter, Calibri, Arial",
size: 16,
color: "94A3B8",
}),
new TextRun({
text: " of ",
font: "Inter, Calibri, Arial",
size: 16,
color: "94A3B8",
}),
new TextRun({
children: [PageNumber.TOTAL_PAGES],
font: "Inter, Calibri, Arial",
size: 16,
color: "94A3B8",
}),
],
}),
],
}),
},
children: docChildren,
},
],
});
return await Packer.toBuffer(docx);
}
+1
View File
@@ -17,6 +17,7 @@ import {
} from "./export.helpers"; } from "./export.helpers";
export * from "./export.helpers"; export * from "./export.helpers";
export * from "./export.docx";
/** /**
* Fetch all documents for export with full bodies directly from PostgreSQL * Fetch all documents for export with full bodies directly from PostgreSQL