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}`;
|
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,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.");
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}`}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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";
|
} 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
|
||||||
|
|||||||
Reference in New Issue
Block a user