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:
@@ -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