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
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:
@@ -13,15 +13,23 @@ function signCookie(value: string): string {
|
||||
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 {
|
||||
if (!cookieValue) return false;
|
||||
const [value, sig] = cookieValue.split(".");
|
||||
if (!value || !sig) return false;
|
||||
const expected = signCookie(value);
|
||||
return timingSafeEqual(
|
||||
Buffer.from(cookieValue),
|
||||
Buffer.from(expected),
|
||||
);
|
||||
return safeTimingEqual(cookieValue, expected);
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
const ok = timingSafeEqual(
|
||||
Buffer.from(password),
|
||||
Buffer.from(ADMIN_PASSWORD),
|
||||
);
|
||||
const ok = safeTimingEqual(password, ADMIN_PASSWORD);
|
||||
|
||||
if (!ok) {
|
||||
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
@@ -6,8 +6,12 @@ import { timingSafeEqual } from "node:crypto";
|
||||
function isAuthorized(req: NextRequest): boolean {
|
||||
if (!WEBHOOK_SECRET) return false;
|
||||
const headerSecret = req.headers.get("x-webhook-secret") ?? "";
|
||||
if (headerSecret && timingSafeEqual(Buffer.from(headerSecret), Buffer.from(WEBHOOK_SECRET))) {
|
||||
return true;
|
||||
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.");
|
||||
|
||||
@@ -13,8 +13,12 @@ import { timingSafeEqual } from "node:crypto";
|
||||
function isAuthorized(req: NextRequest): boolean {
|
||||
if (!WEBHOOK_SECRET) return false;
|
||||
const headerSecret = req.headers.get("x-webhook-secret") ?? "";
|
||||
if (headerSecret && timingSafeEqual(Buffer.from(headerSecret), Buffer.from(WEBHOOK_SECRET))) {
|
||||
return true;
|
||||
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.");
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
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";
|
||||
|
||||
@@ -11,6 +16,8 @@ export async function GET(req: NextRequest) {
|
||||
const sortBy = (searchParams.get("sort") as ExportSortOption) || "category_points";
|
||||
const slugsParam = searchParams.get("slugs");
|
||||
const slugs = slugsParam ? slugsParam.split(",").map((s) => s.trim()) : undefined;
|
||||
const pageBreaksParam = searchParams.get("pageBreaks");
|
||||
const pageBreaks = pageBreaksParam !== "false" && pageBreaksParam !== "0";
|
||||
|
||||
try {
|
||||
const data = await getExportDocuments({
|
||||
@@ -20,13 +27,25 @@ export async function GET(req: NextRequest) {
|
||||
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") {
|
||||
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",
|
||||
|
||||
@@ -1,12 +1,32 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { restoreRevision, getRevision } from "@mcpedia/core";
|
||||
import { WEBHOOK_SECRET } from "@mcpedia/config";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
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.
|
||||
// Body (form-urlencoded): id=<revision uuid>
|
||||
// After restoring, we rebuild semantic chunks (handled inside restoreRevision)
|
||||
// and revalidate the doc page so the Web UI reflects the restored body.
|
||||
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 id = form?.get("id");
|
||||
if (typeof id !== "string" || id.length === 0) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Check,
|
||||
Share2,
|
||||
FileDown,
|
||||
FileText,
|
||||
Edit3,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -84,6 +85,16 @@ export default function DocActions({ slug, body, canEdit }: DocActionsProps) {
|
||||
<span>PDF</span>
|
||||
</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 */}
|
||||
<Link
|
||||
href={canEdit ? `/${slug}?edit=1` : `/login?redirect=/${slug}`}
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function Header() {
|
||||
}, []);
|
||||
|
||||
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">
|
||||
{/* Brand */}
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -51,16 +51,16 @@ function PreBlock({ children, ...props }: React.HTMLAttributes<HTMLPreElement>)
|
||||
}
|
||||
|
||||
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 */}
|
||||
<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="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-amber-500/80 inline-block" />
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500/80 inline-block" />
|
||||
</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}
|
||||
</span>
|
||||
</div>
|
||||
@@ -85,7 +85,7 @@ function PreBlock({ children, ...props }: React.HTMLAttributes<HTMLPreElement>)
|
||||
</button>
|
||||
</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}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -97,15 +97,15 @@ export default function Markdown({ source }: { source: string }) {
|
||||
<div
|
||||
className="prose max-w-none
|
||||
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-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-h3:text-lg prose-h3:mb-3 prose-h3:mt-6
|
||||
prose-p:text-[var(--text-secondary)] prose-p:leading-relaxed prose-p:mb-4
|
||||
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)] 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 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 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-ul:text-[var(--text-secondary)] prose-ul:my-4 prose-li:my-1.5
|
||||
prose-ol:text-[var(--text-secondary)] prose-ol:my-4
|
||||
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 print:prose-ol:my-1
|
||||
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
|
||||
remarkPlugins={[remarkGfm]}
|
||||
@@ -122,7 +122,7 @@ export default function Markdown({ source }: { source: string }) {
|
||||
}
|
||||
return (
|
||||
<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}
|
||||
>
|
||||
{children}
|
||||
@@ -130,7 +130,7 @@ export default function Markdown({ source }: { source: string }) {
|
||||
);
|
||||
},
|
||||
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} />
|
||||
</div>
|
||||
),
|
||||
@@ -140,35 +140,35 @@ export default function Markdown({ source }: { source: string }) {
|
||||
|
||||
if (alertMatch) {
|
||||
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 title = "Note";
|
||||
|
||||
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;
|
||||
title = "Tip";
|
||||
} 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;
|
||||
title = "Important";
|
||||
} 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;
|
||||
title = "Warning";
|
||||
} 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;
|
||||
title = "Caution";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`my-5 p-4 rounded-xl border-l-4 ${borderClass} shadow-xs`}>
|
||||
<div className="flex items-center gap-2 font-bold text-xs uppercase tracking-wider mb-1.5">
|
||||
<Icon className="w-4 h-4" />
|
||||
<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 print:text-[7.5pt] uppercase tracking-wider mb-1.5 print:mb-0.5">
|
||||
<Icon className="w-4 h-4 print:w-3 print:h-3" />
|
||||
<span>{title}</span>
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
@@ -176,7 +176,7 @@ export default function Markdown({ source }: { source: string }) {
|
||||
}
|
||||
|
||||
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}
|
||||
</blockquote>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import { getSectionMeta } from "@mcpedia/config/sections";
|
||||
import {
|
||||
Printer,
|
||||
FileDown,
|
||||
FileText,
|
||||
Copy,
|
||||
Check,
|
||||
ArrowLeft,
|
||||
@@ -119,6 +120,37 @@ export default function PdfExportView({
|
||||
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 (
|
||||
<div className="pb-16 print:pb-0">
|
||||
{/* Export Control Toolbar (Screen Only — Hidden in Print) */}
|
||||
@@ -183,6 +215,22 @@ export default function PdfExportView({
|
||||
<span>Download .md</span>
|
||||
</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 */}
|
||||
<button
|
||||
onClick={handlePrint}
|
||||
@@ -280,13 +328,13 @@ export default function PdfExportView({
|
||||
</aside>
|
||||
|
||||
{/* 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 */}
|
||||
<header className="mb-8 pb-4 border-b border-slate-300">
|
||||
<h1 className="text-3xl sm:text-4xl font-extrabold text-slate-950 tracking-tight mb-2">
|
||||
<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 print:text-lg font-extrabold text-slate-950 tracking-tight mb-2 print:mb-0.5">
|
||||
{summary.title}
|
||||
</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>
|
||||
{currentTotalPoints > 0 && (
|
||||
<span>· {currentTotalPoints.toLocaleString()} Total Points</span>
|
||||
@@ -297,25 +345,25 @@ export default function PdfExportView({
|
||||
|
||||
{/* Table of Contents */}
|
||||
{showTOC && filteredAndSortedDocs.length > 1 && (
|
||||
<section className="toc-section mb-10 pb-6 border-b border-slate-200">
|
||||
<h2 className="text-xs font-bold uppercase font-mono text-slate-700 mb-3 tracking-wider flex items-center gap-1.5">
|
||||
<BookOpen className="w-3.5 h-3.5" />
|
||||
<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 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 print:w-3 print:h-3" />
|
||||
<span>Table of Contents</span>
|
||||
</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) => {
|
||||
const cat = extractDocCategory(doc);
|
||||
const pts = extractDocPoints(doc);
|
||||
|
||||
return (
|
||||
<li key={doc.slug}>
|
||||
<li key={doc.slug} className="print:leading-tight">
|
||||
<a
|
||||
href={`#ch-${idx + 1}`}
|
||||
className="hover:underline font-medium text-slate-900"
|
||||
>
|
||||
{doc.title}
|
||||
</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` : ""}]
|
||||
</span>
|
||||
</li>
|
||||
@@ -340,14 +388,14 @@ export default function PdfExportView({
|
||||
id={`ch-${chNum}`}
|
||||
className={`challenge-entry ${
|
||||
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 */}
|
||||
<div className="mb-4 pb-2 border-b border-slate-200">
|
||||
<h2 className="text-2xl font-bold text-slate-950 tracking-tight leading-tight mb-1.5">
|
||||
<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 print:text-[11pt] font-bold text-slate-950 tracking-tight leading-tight mb-1.5 print:mb-0.5">
|
||||
{chNum}. {doc.title}
|
||||
</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>
|
||||
{pts > 0 && <span><strong>Points:</strong> {pts} pts</span>}
|
||||
{diff && <span><strong>Difficulty:</strong> {diff}</span>}
|
||||
@@ -387,7 +435,7 @@ export default function PdfExportView({
|
||||
</div>
|
||||
|
||||
{/* 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} />
|
||||
</div>
|
||||
|
||||
|
||||
+175
-24
@@ -437,27 +437,31 @@ body {
|
||||
@media print {
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 18mm 15mm 18mm 15mm;
|
||||
margin: 10mm 12mm 10mm 12mm;
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
html, body {
|
||||
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-size: 10pt !important;
|
||||
line-height: 1.5 !important;
|
||||
font-size: 8.5pt !important;
|
||||
line-height: 1.35 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
header,
|
||||
aside,
|
||||
/* Hide navigation, toolbars, sidebar, and web UI controls */
|
||||
header.site-header,
|
||||
header.glass-nav,
|
||||
aside.site-sidebar,
|
||||
aside:not(.print-include),
|
||||
footer,
|
||||
nav.site-header,
|
||||
.no-print,
|
||||
@@ -467,7 +471,8 @@ body {
|
||||
}
|
||||
|
||||
main,
|
||||
.print-container {
|
||||
.print-container,
|
||||
main > div {
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
margin: 0 !important;
|
||||
@@ -477,53 +482,190 @@ body {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Explicit page break classes */
|
||||
.page-break-before,
|
||||
.chapter-page,
|
||||
.challenge-entry {
|
||||
.chapter-page {
|
||||
break-before: page !important;
|
||||
page-break-before: always !important;
|
||||
}
|
||||
|
||||
/* Prevent awkward mid-element page breaks */
|
||||
.avoid-break-inside,
|
||||
.code-block-wrapper,
|
||||
.code-block-container,
|
||||
.challenge-entry,
|
||||
.callout,
|
||||
blockquote,
|
||||
table,
|
||||
tr,
|
||||
img,
|
||||
pre,
|
||||
blockquote {
|
||||
img {
|
||||
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 {
|
||||
max-width: 100% !important;
|
||||
max-height: 220px !important;
|
||||
height: auto !important;
|
||||
object-fit: contain !important;
|
||||
margin: 2.5pt auto !important;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.code-block-wrapper,
|
||||
.code-block-container {
|
||||
break-inside: avoid !important;
|
||||
page-break-inside: avoid !important;
|
||||
border: 1px solid #d4d4d8 !important;
|
||||
background-color: #f4f4f5 !important;
|
||||
border-radius: 4px !important;
|
||||
margin: 8pt 0 !important;
|
||||
border: 1px solid #cbd5e1 !important;
|
||||
background-color: #f8fafc !important;
|
||||
border-radius: 3px !important;
|
||||
margin: 2.5pt 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.code-block-header {
|
||||
border-bottom: 1px solid #d4d4d8 !important;
|
||||
background-color: #e4e4e7 !important;
|
||||
padding: 3pt 8pt !important;
|
||||
color: #27272a !important;
|
||||
border-bottom: 1px solid #cbd5e1 !important;
|
||||
background-color: #f1f5f9 !important;
|
||||
padding: 1.5pt 5pt !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 {
|
||||
border: none !important;
|
||||
background-color: transparent !important;
|
||||
padding: 6pt 8pt !important;
|
||||
padding: 3pt 5pt !important;
|
||||
margin: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
@@ -535,7 +677,16 @@ body {
|
||||
word-break: break-all !important;
|
||||
overflow-wrap: anywhere !important;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
|
||||
font-size: 8.5pt !important;
|
||||
line-height: 1.4 !important;
|
||||
font-size: 7.5pt !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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,11 +46,11 @@ export default function RootLayout({
|
||||
|
||||
<div className="mx-auto max-w-7xl w-full flex-1 flex flex-col">
|
||||
<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 />
|
||||
</aside>
|
||||
<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}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@mcpedia/parser": "workspace:*",
|
||||
"@mcpedia/search": "workspace:*",
|
||||
"@mcpedia/types": "workspace:*",
|
||||
"docx": "^9.7.1",
|
||||
"drizzle-orm": "^0.38.0"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "./export.helpers";
|
||||
|
||||
export * from "./export.helpers";
|
||||
export * from "./export.docx";
|
||||
|
||||
/**
|
||||
* Fetch all documents for export with full bodies directly from PostgreSQL
|
||||
|
||||
Reference in New Issue
Block a user