feat: add Word (.docx) export, enhance print density, and fix auth security guards
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s

This commit is contained in:
asepharyana
2026-08-22 17:59:32 +07:00
parent 067115ba13
commit 5ccba958d2
16 changed files with 1374 additions and 85 deletions
+1
View File
@@ -15,6 +15,7 @@
"@mcpedia/parser": "workspace:*",
"@mcpedia/search": "workspace:*",
"@mcpedia/types": "workspace:*",
"docx": "^9.7.1",
"drizzle-orm": "^0.38.0"
},
"scripts": {
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from "bun:test";
import {
parseInlineMarkdown,
convertMarkdownToDocx,
generateExportDocx,
} from "./export.docx";
import type { ExportData } from "@mcpedia/types";
describe("DOCX Export Generator", () => {
it("parseInlineMarkdown: formats plain, bold, italic, inline code, and links", () => {
const text = "Hello **world** and *italic* with `const x = 1;` and [Link](https://example.com)";
const runs = parseInlineMarkdown(text);
expect(runs.length).toBeGreaterThan(1);
});
it("convertMarkdownToDocx: handles headings, tables, callouts, and code blocks", () => {
const markdown = `
# Title
Some paragraph text with **bold** words.
## Subsection
> [!NOTE]
> This is a crucial note for testing.
\`\`\`python
def solve():
return "flag{test}"
\`\`\`
| Challenge | Points |
| --- | --- |
| Chall 1 | 100 |
| Chall 2 | 200 |
- Item 1
- Item 2
1. First
2. Second
`;
const elements = convertMarkdownToDocx(markdown);
expect(elements.length).toBeGreaterThan(5);
});
it("generateExportDocx: builds a valid Word document buffer with OpenXML signature", async () => {
const mockExportData: ExportData = {
summary: {
title: "CTF 2026 Writeups",
scope: "ctf/2026",
section: "ctf",
totalDocuments: 2,
totalPoints: 600,
categories: [
{ name: "Web", count: 1, points: 200 },
{ name: "Pwn", count: 1, points: 400 },
],
authors: ["asepharyana"],
generatedAt: new Date().toISOString(),
},
documents: [
{
id: "ctf/2026/web/baby-web",
slug: "ctf/2026/web/baby-web",
title: "Baby Web",
section: "ctf",
type: "writeup",
status: "published",
author: "asepharyana",
tags: ["web", "sqli"],
path: "ctf/2026/web/baby-web.md",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
extraFields: { category: "Web", points: 200, difficulty: "Easy" },
body: "# Baby Web\n\nSolution explanation here.\n\n```sql\nSELECT * FROM users;\n```",
},
{
id: "ctf/2026/pwn/ret2win",
slug: "ctf/2026/pwn/ret2win",
title: "Ret2Win Challenge",
section: "ctf",
type: "writeup",
status: "published",
author: "asepharyana",
tags: ["pwn", "rop"],
path: "ctf/2026/pwn/ret2win.md",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
extraFields: { category: "Pwn", points: 400, difficulty: "Medium" },
body: "## Challenge Overview\n\nStack buffer overflow vulnerability.",
},
],
};
const buffer = await generateExportDocx(mockExportData, { pageBreaks: true });
expect(buffer).toBeDefined();
expect(buffer.length).toBeGreaterThan(1000);
// Standard ZIP/DOCX magic bytes: 0x50, 0x4B, 0x03, 0x04 ('PK\x03\x04')
expect(buffer[0]).toBe(0x50);
expect(buffer[1]).toBe(0x4b);
expect(buffer[2]).toBe(0x03);
expect(buffer[3]).toBe(0x04);
});
});
+875
View File
@@ -0,0 +1,875 @@
import {
Document as DocxDocument,
Paragraph,
TextRun,
Table,
TableRow,
TableCell,
HeadingLevel,
AlignmentType,
BorderStyle,
WidthType,
ShadingType,
PageBreak,
Header,
Footer,
PageNumber,
Packer,
ExternalHyperlink,
} from "docx";
import type {
Document as CoreDocument,
ExportData,
ExportSummary,
} from "@mcpedia/types";
import {
extractDocCategory,
extractDocPoints,
extractDocDifficulty,
} from "./export.helpers";
// ---------------------------------------------------------------------------
// Markdown to DOCX Inlines & Blocks Parser
// ---------------------------------------------------------------------------
type InlineElement = TextRun | ExternalHyperlink;
/**
* Parse inline markdown formatting (bold, italic, code, links) into docx TextRuns / Hyperlinks.
*/
export function parseInlineMarkdown(text: string): InlineElement[] {
if (!text) return [];
const elements: InlineElement[] = [];
// Regex pattern matching:
// 1. Links: [text](url)
// 2. Inline code: `code`
// 3. Bold + Italic: ***text*** or ___text___
// 4. Bold: **text** or __text__
// 5. Italic: *text* or _text_
const inlineRegex =
/(\[([^\]]+)\]\(([^)]+)\)|`([^`]+)`|\*\*\*([^*]+)\*\*\*|___([^_]+)___|\*\*([^*]+)\*\*|__([^_]+)__|(?<!\*)\*([^*]+)\*(?!\*)|(?<!_)_([^_]+)_(?!_))/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = inlineRegex.exec(text)) !== null) {
// Add any preceding plain text
if (match.index > lastIndex) {
elements.push(
new TextRun({
text: text.slice(lastIndex, match.index),
font: "Inter, Calibri, Arial",
size: 20, // 10pt
color: "1E293B",
}),
);
}
if (match[2] && match[3]) {
// Hyperlink [text](url)
elements.push(
new ExternalHyperlink({
children: [
new TextRun({
text: match[2],
font: "Inter, Calibri, Arial",
size: 20,
color: "2563EB",
underline: {},
}),
],
link: match[3],
}),
);
} else if (match[4]) {
// Inline code `code`
elements.push(
new TextRun({
text: match[4],
font: "Consolas, Courier New",
size: 18, // 9pt
color: "0F172A",
shading: {
type: ShadingType.CLEAR,
fill: "F1F5F9",
color: "auto",
},
}),
);
} else if (match[5] || match[6]) {
// Bold + Italic ***text***
elements.push(
new TextRun({
text: match[5] || match[6],
font: "Inter, Calibri, Arial",
size: 20,
bold: true,
italics: true,
color: "0F172A",
}),
);
} else if (match[7] || match[8]) {
// Bold **text**
elements.push(
new TextRun({
text: match[7] || match[8],
font: "Inter, Calibri, Arial",
size: 20,
bold: true,
color: "0F172A",
}),
);
} else if (match[9] || match[10]) {
// Italic *text*
elements.push(
new TextRun({
text: match[9] || match[10],
font: "Inter, Calibri, Arial",
size: 20,
italics: true,
color: "334155",
}),
);
}
lastIndex = inlineRegex.lastIndex;
}
// Trailing plain text
if (lastIndex < text.length) {
elements.push(
new TextRun({
text: text.slice(lastIndex),
font: "Inter, Calibri, Arial",
size: 20,
color: "1E293B",
}),
);
}
return elements.length > 0
? elements
: [
new TextRun({
text,
font: "Inter, Calibri, Arial",
size: 20,
color: "1E293B",
}),
];
}
/**
* Parse markdown table lines into a Docx Table.
*/
function parseMarkdownTable(tableLines: string[]): Table | null {
if (tableLines.length < 2) return null;
const rows: string[][] = [];
for (const line of tableLines) {
const trimmed = line.trim();
if (!trimmed.startsWith("|") && !trimmed.endsWith("|")) continue;
// Skip separator line (|---|---|)
if (/^\|[\s\-:|]+\|$/.test(trimmed)) continue;
const cells = trimmed
.split("|")
.slice(1, -1)
.map((c) => c.trim());
rows.push(cells);
}
if (rows.length === 0) return null;
const colCount = Math.max(...rows.map((r) => r.length));
return new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
margins: {
top: 100,
bottom: 100,
left: 140,
right: 140,
},
rows: rows.map((rowCells, rowIdx) => {
const isHeader = rowIdx === 0;
return new TableRow({
tableHeader: isHeader,
children: Array.from({ length: colCount }).map((_, colIdx) => {
const cellText = rowCells[colIdx] || "";
return new TableCell({
shading: isHeader
? { type: ShadingType.CLEAR, fill: "F1F5F9", color: "auto" }
: rowIdx % 2 === 1
? { type: ShadingType.CLEAR, fill: "FFFFFF", color: "auto" }
: { type: ShadingType.CLEAR, fill: "F8FAFC", color: "auto" },
borders: {
top: { style: BorderStyle.SINGLE, size: 4, color: "CBD5E1" },
bottom: { style: BorderStyle.SINGLE, size: 4, color: "CBD5E1" },
left: { style: BorderStyle.SINGLE, size: 4, color: "CBD5E1" },
right: { style: BorderStyle.SINGLE, size: 4, color: "CBD5E1" },
},
children: [
new Paragraph({
spacing: { before: 40, after: 40 },
children: isHeader
? [
new TextRun({
text: cellText,
font: "Inter, Calibri, Arial",
size: 18,
bold: true,
color: "0F172A",
}),
]
: parseInlineMarkdown(cellText),
}),
],
});
}),
});
}),
});
}
/**
* Convert a markdown body string into an array of docx Paragraphs and Tables.
*/
export function convertMarkdownToDocx(markdown: string): (Paragraph | Table)[] {
const elements: (Paragraph | Table)[] = [];
const lines = markdown.split(/\r?\n/);
let i = 0;
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trim();
// 1. Blank lines
if (trimmed === "") {
i++;
continue;
}
// 2. Fenced Code Blocks (```lang ... ```)
if (trimmed.startsWith("```")) {
const lang = trimmed.slice(3).trim();
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].trim().startsWith("```")) {
codeLines.push(lines[i]);
i++;
}
i++; // skip closing ```
const codeParagraphs = codeLines.map(
(codeLine) =>
new Paragraph({
spacing: { before: 0, after: 0, line: 240 },
children: [
new TextRun({
text: codeLine.replace(/\t/g, " ") || " ",
font: "Consolas, Courier New",
size: 17, // 8.5pt
color: "0F172A",
}),
],
}),
);
// Wrap in a shaded callout table for code box look
const codeBox = new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
margins: { top: 120, bottom: 120, left: 160, right: 160 },
rows: [
new TableRow({
children: [
new TableCell({
shading: {
type: ShadingType.CLEAR,
fill: "F8FAFC",
color: "auto",
},
borders: {
top: { style: BorderStyle.SINGLE, size: 6, color: "CBD5E1" },
bottom: { style: BorderStyle.SINGLE, size: 6, color: "CBD5E1" },
left: { style: BorderStyle.SINGLE, size: 18, color: "64748B" }, // darker left accent
right: { style: BorderStyle.SINGLE, size: 6, color: "CBD5E1" },
},
children: [
...(lang
? [
new Paragraph({
spacing: { before: 0, after: 60 },
children: [
new TextRun({
text: lang.toUpperCase(),
font: "Consolas, Courier New",
size: 15,
bold: true,
color: "64748B",
}),
],
}),
]
: []),
...codeParagraphs,
],
}),
],
}),
],
});
elements.push(codeBox);
elements.push(new Paragraph({ spacing: { before: 80, after: 0 } }));
continue;
}
// 3. Markdown Tables (| col1 | col2 |)
if (trimmed.startsWith("|") && trimmed.endsWith("|")) {
const tableLines: string[] = [];
while (i < lines.length && lines[i].trim().startsWith("|")) {
tableLines.push(lines[i]);
i++;
}
const table = parseMarkdownTable(tableLines);
if (table) {
elements.push(table);
elements.push(new Paragraph({ spacing: { before: 80, after: 0 } }));
}
continue;
}
// 4. Blockquotes & GitHub Alert Callouts (> [!NOTE], > [!TIP], etc.)
if (trimmed.startsWith(">")) {
const quoteLines: string[] = [];
while (i < lines.length && lines[i].trim().startsWith(">")) {
quoteLines.push(lines[i].trim().replace(/^>\s?/, ""));
i++;
}
const fullQuote = quoteLines.join("\n");
const alertMatch = fullQuote.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/i);
let borderColor = "3B82F6"; // Blue
let fillColor = "EFF6FF";
let title = "NOTE";
if (alertMatch) {
const alertType = alertMatch[1].toUpperCase();
if (alertType === "TIP") {
borderColor = "10B981";
fillColor = "ECFDF5";
title = "TIP";
} else if (alertType === "IMPORTANT") {
borderColor = "8B5CF6";
fillColor = "F5F3FF";
title = "IMPORTANT";
} else if (alertType === "WARNING") {
borderColor = "F59E0B";
fillColor = "FFFBEB";
title = "WARNING";
} else if (alertType === "CAUTION") {
borderColor = "EF4444";
fillColor = "FEF2F2";
title = "CAUTION";
}
}
const contentLines = alertMatch
? fullQuote.replace(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*\n?/i, "").split("\n")
: quoteLines;
const calloutTable = new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
margins: { top: 100, bottom: 100, left: 160, right: 160 },
rows: [
new TableRow({
children: [
new TableCell({
shading: {
type: ShadingType.CLEAR,
fill: fillColor,
color: "auto",
},
borders: {
top: { style: BorderStyle.NONE, size: 0, color: "auto" },
bottom: { style: BorderStyle.NONE, size: 0, color: "auto" },
left: { style: BorderStyle.SINGLE, size: 24, color: borderColor },
right: { style: BorderStyle.NONE, size: 0, color: "auto" },
},
children: [
...(alertMatch
? [
new Paragraph({
spacing: { before: 0, after: 40 },
children: [
new TextRun({
text: `[ ${title} ]`,
font: "Inter, Calibri, Arial",
size: 18,
bold: true,
color: borderColor,
}),
],
}),
]
: []),
...contentLines.map(
(qLine) =>
new Paragraph({
spacing: { before: 20, after: 40 },
children: parseInlineMarkdown(qLine),
}),
),
],
}),
],
}),
],
});
elements.push(calloutTable);
elements.push(new Paragraph({ spacing: { before: 80, after: 0 } }));
continue;
}
// 5. Headings
if (trimmed.startsWith("#### ")) {
elements.push(
new Paragraph({
heading: HeadingLevel.HEADING_4,
spacing: { before: 180, after: 60 },
children: [
new TextRun({
text: trimmed.slice(5),
font: "Inter, Calibri, Arial",
size: 22, // 11pt
bold: true,
color: "1E293B",
}),
],
}),
);
i++;
continue;
}
if (trimmed.startsWith("### ")) {
elements.push(
new Paragraph({
heading: HeadingLevel.HEADING_3,
spacing: { before: 220, after: 80 },
children: [
new TextRun({
text: trimmed.slice(4),
font: "Inter, Calibri, Arial",
size: 24, // 12pt
bold: true,
color: "0F172A",
}),
],
}),
);
i++;
continue;
}
if (trimmed.startsWith("## ")) {
elements.push(
new Paragraph({
heading: HeadingLevel.HEADING_2,
spacing: { before: 280, after: 100 },
border: {
bottom: { style: BorderStyle.SINGLE, size: 6, color: "E2E8F0" },
},
children: [
new TextRun({
text: trimmed.slice(3),
font: "Inter, Calibri, Arial",
size: 28, // 14pt
bold: true,
color: "020617",
}),
],
}),
);
i++;
continue;
}
if (trimmed.startsWith("# ")) {
elements.push(
new Paragraph({
heading: HeadingLevel.HEADING_1,
spacing: { before: 320, after: 120 },
border: {
bottom: { style: BorderStyle.SINGLE, size: 8, color: "CBD5E1" },
},
children: [
new TextRun({
text: trimmed.slice(2),
font: "Inter, Calibri, Arial",
size: 34, // 17pt
bold: true,
color: "020617",
}),
],
}),
);
i++;
continue;
}
// 6. Horizontal Rule (--- or ***)
if (/^(\-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
elements.push(
new Paragraph({
spacing: { before: 120, after: 120 },
border: {
bottom: { style: BorderStyle.SINGLE, size: 6, color: "CBD5E1" },
},
}),
);
i++;
continue;
}
// 7. Bullet Lists (- item or * item)
if (/^[-*+]\s+/.test(trimmed)) {
const itemText = trimmed.replace(/^[-*+]\s+/, "");
elements.push(
new Paragraph({
bullet: { level: 0 },
spacing: { before: 20, after: 40 },
children: parseInlineMarkdown(itemText),
}),
);
i++;
continue;
}
// 8. Numbered Lists (1. item)
if (/^\d+\.\s+/.test(trimmed)) {
const numMatch = trimmed.match(/^(\d+)\.\s+(.*)$/);
const num = numMatch ? numMatch[1] : "1";
const itemText = numMatch ? numMatch[2] : trimmed;
elements.push(
new Paragraph({
spacing: { before: 20, after: 40 },
indent: { left: 360 },
children: [
new TextRun({
text: `${num}. `,
font: "Inter, Calibri, Arial",
size: 20,
bold: true,
color: "475569",
}),
...parseInlineMarkdown(itemText),
],
}),
);
i++;
continue;
}
// 9. Standard Paragraph
elements.push(
new Paragraph({
spacing: { before: 40, after: 100, line: 276 }, // 1.15 line spacing
children: parseInlineMarkdown(line),
}),
);
i++;
}
return elements;
}
// ---------------------------------------------------------------------------
// DOCX Document Builder
// ---------------------------------------------------------------------------
export interface DocxExportOptions {
pageBreaks?: boolean;
}
/**
* Generate a complete, publication-grade Microsoft Word (.docx) document
* from MCPedia ExportData.
*/
export async function generateExportDocx(
exportData: ExportData,
options: DocxExportOptions = {},
): Promise<Buffer> {
const { summary, documents: docs } = exportData;
const pageBreaks = options.pageBreaks ?? true;
const docChildren: (Paragraph | Table)[] = [];
// ==========================================
// 1. Document Cover & Header
// ==========================================
docChildren.push(
new Paragraph({
heading: HeadingLevel.TITLE,
spacing: { before: 0, after: 120 },
children: [
new TextRun({
text: summary.title,
font: "Inter, Calibri, Arial",
size: 44, // 22pt
bold: true,
color: "020617",
}),
],
}),
);
// Metadata subtitle
const metaParts: string[] = [
`${summary.totalDocuments} Challenge${summary.totalDocuments !== 1 ? "s" : ""}`,
];
if (summary.totalPoints > 0) {
metaParts.push(`${summary.totalPoints.toLocaleString()} Total Points`);
}
metaParts.push(
new Date(summary.generatedAt).toLocaleDateString(undefined, {
dateStyle: "long",
}),
);
if (summary.authors && summary.authors.length > 0) {
metaParts.push(`Authors: ${summary.authors.join(", ")}`);
}
docChildren.push(
new Paragraph({
spacing: { before: 0, after: 240 },
border: {
bottom: { style: BorderStyle.SINGLE, size: 8, color: "CBD5E1" },
},
children: [
new TextRun({
text: metaParts.join(" · "),
font: "Consolas, Courier New",
size: 18, // 9pt
color: "64748B",
}),
],
}),
);
// ==========================================
// 2. Table of Contents / Matrix (if > 1 doc)
// ==========================================
if (docs.length > 1) {
docChildren.push(
new Paragraph({
heading: HeadingLevel.HEADING_2,
spacing: { before: 200, after: 120 },
children: [
new TextRun({
text: "Table of Contents & Overview",
font: "Inter, Calibri, Arial",
size: 26, // 13pt
bold: true,
color: "0F172A",
}),
],
}),
);
const tocRows: string[][] = [
["#", "Challenge Title", "Category", "Points", "Difficulty"],
];
docs.forEach((doc, idx) => {
const chNum = String(idx + 1);
const cat = extractDocCategory(doc);
const pts = extractDocPoints(doc);
const diff = extractDocDifficulty(doc) || "-";
tocRows.push([
chNum,
doc.title,
cat,
pts > 0 ? `${pts} pts` : "-",
diff,
]);
});
const tocTable = parseMarkdownTable(
tocRows.map((r) => `| ${r.join(" | ")} |`),
);
if (tocTable) {
docChildren.push(tocTable);
}
docChildren.push(
new Paragraph({
spacing: { before: 200, after: 200 },
border: {
bottom: { style: BorderStyle.SINGLE, size: 6, color: "E2E8F0" },
},
}),
);
}
// ==========================================
// 3. Challenge Writeup Chapters
// ==========================================
docs.forEach((doc, idx) => {
const chNum = idx + 1;
const cat = extractDocCategory(doc);
const pts = extractDocPoints(doc);
const diff = extractDocDifficulty(doc);
// Page break between challenges
if (pageBreaks && idx > 0) {
docChildren.push(
new Paragraph({
children: [new PageBreak()],
}),
);
}
// Chapter Title
docChildren.push(
new Paragraph({
heading: HeadingLevel.HEADING_1,
spacing: { before: pageBreaks ? 0 : 280, after: 80 },
children: [
new TextRun({
text: `${chNum}. ${doc.title}`,
font: "Inter, Calibri, Arial",
size: 32, // 16pt
bold: true,
color: "020617",
}),
],
}),
);
// Metadata Badge Ribbon
const badgeTextParts: string[] = [`CATEGORY: ${cat.toUpperCase()}`];
if (pts > 0) badgeTextParts.push(`POINTS: ${pts} PTS`);
if (diff) badgeTextParts.push(`DIFFICULTY: ${diff.toUpperCase()}`);
if (doc.author) badgeTextParts.push(`AUTHOR: ${doc.author}`);
docChildren.push(
new Paragraph({
spacing: { before: 0, after: 180 },
border: {
bottom: { style: BorderStyle.SINGLE, size: 6, color: "CBD5E1" },
},
children: [
new TextRun({
text: badgeTextParts.join(" | "),
font: "Consolas, Courier New",
size: 16, // 8pt
bold: true,
color: "475569",
}),
],
}),
);
// Markdown Writeup Content
const contentElements = convertMarkdownToDocx(doc.body);
docChildren.push(...contentElements);
// Subtle divider after challenge (when not page breaking)
if (!pageBreaks && idx < docs.length - 1) {
docChildren.push(
new Paragraph({
spacing: { before: 180, after: 180 },
border: {
bottom: { style: BorderStyle.DASHED, size: 6, color: "CBD5E1" },
},
}),
);
}
});
// ==========================================
// 4. Construct Final DOCX Package
// ==========================================
const docx = new DocxDocument({
creator: "MCPedia Knowledge Base",
title: summary.title,
description: `Export of ${summary.title} generated by MCPedia`,
sections: [
{
properties: {
page: {
margin: {
top: 1440, // 1 inch (1440 dxa)
bottom: 1440,
left: 1440,
right: 1440,
},
},
},
headers: {
default: new Header({
children: [
new Paragraph({
alignment: AlignmentType.RIGHT,
spacing: { after: 120 },
children: [
new TextRun({
text: `MCPedia · ${summary.title}`,
font: "Consolas, Courier New",
size: 16,
color: "94A3B8",
}),
],
}),
],
}),
},
footers: {
default: new Footer({
children: [
new Paragraph({
alignment: AlignmentType.RIGHT,
spacing: { before: 120 },
children: [
new TextRun({
text: "Page ",
font: "Inter, Calibri, Arial",
size: 16,
color: "94A3B8",
}),
new TextRun({
children: [PageNumber.CURRENT],
font: "Inter, Calibri, Arial",
size: 16,
color: "94A3B8",
}),
new TextRun({
text: " of ",
font: "Inter, Calibri, Arial",
size: 16,
color: "94A3B8",
}),
new TextRun({
children: [PageNumber.TOTAL_PAGES],
font: "Inter, Calibri, Arial",
size: 16,
color: "94A3B8",
}),
],
}),
],
}),
},
children: docChildren,
},
],
});
return await Packer.toBuffer(docx);
}
+1
View File
@@ -17,6 +17,7 @@ import {
} from "./export.helpers";
export * from "./export.helpers";
export * from "./export.docx";
/**
* Fetch all documents for export with full bodies directly from PostgreSQL