From 94d4ba90d367e3b1f5c43da83970de3fe2c0f062 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 13:30:38 +0000 Subject: [PATCH] feat: add Copy Text and Download .docx buttons to response window - Copy Text: copies raw markdown to clipboard with visual feedback - Download .docx: converts markdown to a properly formatted Word document using the docx library (headings, bold/italic, lists, tables, blockquotes, links, horizontal rules) - Both buttons appear below the response article after streaming ends - docx/file-saver are lazy-loaded (dynamic import) so they don't bloat the initial bundle (~308 KB main vs ~407 KB docx chunk) Co-Authored-By: alex --- package-lock.json | 195 ++++++++++++++++++++++++++++++- package.json | 2 + src/App.jsx | 106 +++++++++++++++-- src/markdownToDocx.js | 265 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 557 insertions(+), 11 deletions(-) create mode 100644 src/markdownToDocx.js diff --git a/package-lock.json b/package-lock.json index a4c4ff4..573f8f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "pmc-funder-tool", "version": "0.1.0", "dependencies": { + "docx": "^9.6.1", + "file-saver": "^2.0.5", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^9.0.1", @@ -1952,6 +1954,15 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.8.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", + "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -2526,6 +2537,12 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2654,6 +2671,41 @@ "dev": true, "license": "MIT" }, + "node_modules/docx": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/docx/-/docx-9.6.1.tgz", + "integrity": "sha512-ZJja9/KBUuFC109sCMzovoq2GR2wCG/AuxivjA+OHj/q0TEgJIm3S7yrlUxIy3B+bV8YDj/BiHfWyrRFmyWpDQ==", + "license": "MIT", + "dependencies": { + "@types/node": "^25.2.3", + "hash.js": "^1.1.7", + "jszip": "^3.10.1", + "nanoid": "^5.1.3", + "xml": "^1.0.1", + "xml-js": "^1.6.8" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/docx/node_modules/nanoid": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz", + "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, "node_modules/dot-prop": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", @@ -2882,6 +2934,12 @@ "reusify": "^1.0.4" } }, + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -3011,6 +3069,16 @@ "node": ">=8" } }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/hasown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", @@ -3097,6 +3165,12 @@ "node": ">=16.x" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -3107,6 +3181,12 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -3257,6 +3337,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3340,6 +3426,18 @@ "node": ">=0.10.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -3350,6 +3448,15 @@ "node": ">=6" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -4305,6 +4412,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -4527,6 +4640,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -4809,6 +4928,12 @@ "dev": true, "license": "MIT" }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -4912,6 +5037,21 @@ "pify": "^2.3.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -5113,6 +5253,21 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -5132,6 +5287,12 @@ "semver": "bin/semver.js" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5195,6 +5356,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -5505,6 +5675,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, "node_modules/unicorn-magic": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", @@ -5647,7 +5823,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/uuid": { @@ -5782,6 +5957,24 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "license": "MIT" + }, + "node_modules/xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index cbce231..f4150fe 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "preview": "vite preview" }, "dependencies": { + "docx": "^9.6.1", + "file-saver": "^2.0.5", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^9.0.1", diff --git a/src/App.jsx b/src/App.jsx index 86e38cc..1b29ac6 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from 'react' +import { useState, useRef, useEffect, useCallback } from 'react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' @@ -22,6 +22,7 @@ export default function App() { const [result, setResult] = useState('') const [status, setStatus] = useState(null) const [streamError, setStreamError] = useState(null) + const [copied, setCopied] = useState(false) const resultRef = useRef(null) useEffect(() => { @@ -30,6 +31,33 @@ export default function App() { } }, [result]) + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(result) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { + /* clipboard API unavailable */ + } + }, [result]) + + const handleDownloadDocx = useCallback(async () => { + const [{ Packer }, { saveAs }, { markdownToDocx }] = await Promise.all([ + import('docx'), + import('file-saver'), + import('./markdownToDocx'), + ]) + const title = form.stationName + ? `Funding Brief — ${form.stationName}` + : 'PMC Funding Brief' + const doc = markdownToDocx(result, title) + const blob = await Packer.toBlob(doc) + const filename = form.stationName + ? `${form.stationName.replace(/[^\w\s-]/g, '').replace(/\s+/g, '_')}_Funding_Brief.docx` + : 'PMC_Funding_Brief.docx' + saveAs(blob, filename) + }, [result, form.stationName]) + const handleChange = (e) => { const { name, value } = e.target setForm((prev) => ({ ...prev, [name]: value })) @@ -282,15 +310,38 @@ export default function App() { )} {(result || isStreaming) && ( -
- - {result} - - {isStreaming && } -
+
+
+ + {result} + + {isStreaming && } +
+ + {result && !isStreaming && ( +
+ + +
+ )} +
)} @@ -344,6 +395,41 @@ function StatusBanner({ children }) { ) } +function DownloadIcon() { + return ( + + + + + + ) +} + +function ClipboardIcon() { + return ( + + + + + ) +} + function Spinner() { return ( last) { + children.push(new TextRun(text.slice(last, m.index))) + } + if (m[2]) { + // ***bold italic*** + children.push(new TextRun({ text: m[2], bold: true, italics: true })) + } else if (m[3]) { + // **bold** + children.push(new TextRun({ text: m[3], bold: true })) + } else if (m[4]) { + // *italic* + children.push(new TextRun({ text: m[4], italics: true })) + } else if (m[5] && m[6]) { + // [text](url) + children.push( + new ExternalHyperlink({ + children: [ + new TextRun({ + text: m[5], + style: 'Hyperlink', + }), + ], + link: m[6], + }), + ) + } else if (m[7]) { + // `code` + children.push( + new TextRun({ + text: m[7], + font: 'Courier New', + size: 20, + }), + ) + } + last = m.index + m[0].length + } + if (last < text.length) { + children.push(new TextRun(text.slice(last))) + } + return children.length ? children : [new TextRun(text)] +} + +/** Convert a markdown string to a docx Document. */ +export function markdownToDocx(markdown, title) { + const lines = markdown.split('\n') + const children = [] + let i = 0 + + while (i < lines.length) { + const line = lines[i] + + // Blank line → skip + if (!line.trim()) { + i++ + continue + } + + // Heading + const headingMatch = line.match(/^(#{1,4})\s+(.+)/) + if (headingMatch) { + const level = headingMatch[1].length + children.push( + new Paragraph({ + heading: HEADING_MAP[level], + children: parseInline(headingMatch[2]), + }), + ) + i++ + continue + } + + // Horizontal rule + if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { + children.push( + new Paragraph({ + border: { + bottom: { style: BorderStyle.SINGLE, size: 6, color: 'AAAAAA' }, + }, + children: [], + }), + ) + i++ + continue + } + + // Table — collect rows that start with | + if (line.trimStart().startsWith('|')) { + const tableLines = [] + while (i < lines.length && lines[i].trimStart().startsWith('|')) { + tableLines.push(lines[i]) + i++ + } + const parsed = parseTable(tableLines) + if (parsed) children.push(parsed) + continue + } + + // Unordered list item + const ulMatch = line.match(/^(\s*)[-*+]\s+(.+)/) + if (ulMatch) { + const indent = Math.floor(ulMatch[1].length / 2) + children.push( + new Paragraph({ + bullet: { level: indent }, + children: parseInline(ulMatch[2]), + }), + ) + i++ + continue + } + + // Ordered list item + const olMatch = line.match(/^(\s*)\d+\.\s+(.+)/) + if (olMatch) { + const indent = Math.floor(olMatch[1].length / 3) + children.push( + new Paragraph({ + numbering: { reference: 'default-numbering', level: indent }, + children: parseInline(olMatch[2]), + }), + ) + i++ + continue + } + + // Blockquote + const bqMatch = line.match(/^>\s?(.*)/) + if (bqMatch) { + children.push( + new Paragraph({ + indent: { left: convertInchesToTwip(0.5) }, + children: [ + new TextRun({ text: bqMatch[1], italics: true, color: '666666' }), + ], + }), + ) + i++ + continue + } + + // Regular paragraph — consume consecutive non-blank, non-special lines + const paraLines = [] + while ( + i < lines.length && + lines[i].trim() && + !lines[i].match(/^#{1,4}\s/) && + !lines[i].match(/^[-*+]\s/) && + !lines[i].match(/^\d+\.\s/) && + !lines[i].match(/^>\s?/) && + !lines[i].match(/^(-{3,}|\*{3,}|_{3,})\s*$/) && + !lines[i].trimStart().startsWith('|') + ) { + paraLines.push(lines[i]) + i++ + } + if (paraLines.length) { + children.push( + new Paragraph({ + spacing: { after: 120 }, + children: parseInline(paraLines.join(' ')), + }), + ) + } + } + + return new Document({ + title: title || 'PMC Funding Brief', + numbering: { + config: [ + { + reference: 'default-numbering', + levels: Array.from({ length: 9 }, (_, lvl) => ({ + level: lvl, + format: 'decimal', + text: `%${lvl + 1}.`, + alignment: AlignmentType.START, + style: { paragraph: { indent: { left: convertInchesToTwip(0.5 * (lvl + 1)), hanging: convertInchesToTwip(0.25) } } }, + })), + }, + ], + }, + sections: [{ children }], + }) +} + +/** Parse GFM table lines into a docx Table. */ +function parseTable(tableLines) { + // Filter out the separator row (|---|---|) + const rows = tableLines.filter( + (l) => !l.replace(/[|\-:\s]/g, '').length === false && !/^\|[\s-:|]+\|$/.test(l), + ) + // Re-parse: first non-separator is header, rest are body + const dataRows = tableLines.filter((l) => !/^\|[\s\-:]+\|$/.test(l)) + if (!dataRows.length) return null + + const parseCells = (line) => + line + .split('|') + .slice(1, -1) // drop leading/trailing empty from split + .map((c) => c.trim()) + + const headerCells = parseCells(dataRows[0]) + const bodyRows = dataRows.slice(1).map(parseCells) + + const makeCellParagraph = (text, bold) => + new TableCell({ + children: [ + new Paragraph({ + children: bold + ? [new TextRun({ text, bold: true })] + : parseInline(text), + }), + ], + }) + + const docxRows = [ + new TableRow({ + tableHeader: true, + children: headerCells.map((c) => makeCellParagraph(c, true)), + }), + ...bodyRows.map( + (cells) => + new TableRow({ + children: cells.map((c) => makeCellParagraph(c, false)), + }), + ), + ] + + return new Table({ + rows: docxRows, + width: { size: 100, type: WidthType.PERCENTAGE }, + }) +}