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 <alex@semipublic.co>
This commit is contained in:
Generated
+194
-1
@@ -8,6 +8,8 @@
|
|||||||
"name": "pmc-funder-tool",
|
"name": "pmc-funder-tool",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"docx": "^9.6.1",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
@@ -1952,6 +1954,15 @@
|
|||||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
@@ -2526,6 +2537,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -2654,6 +2671,41 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/dot-prop": {
|
||||||
"version": "9.0.0",
|
"version": "9.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz",
|
||||||
@@ -2882,6 +2934,12 @@
|
|||||||
"reusify": "^1.0.4"
|
"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": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
@@ -3011,6 +3069,16 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||||
@@ -3097,6 +3165,12 @@
|
|||||||
"node": ">=16.x"
|
"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": {
|
"node_modules/imurmurhash": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||||
@@ -3107,6 +3181,12 @@
|
|||||||
"node": ">=0.8.19"
|
"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": {
|
"node_modules/inline-style-parser": {
|
||||||
"version": "0.2.7",
|
"version": "0.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
|
"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"
|
"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": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
@@ -3340,6 +3426,18 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/leven": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||||
@@ -3350,6 +3448,15 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/lilconfig": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||||
@@ -4305,6 +4412,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/minipass": {
|
||||||
"version": "7.1.3",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||||
@@ -4527,6 +4640,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/parse-entities": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
|
||||||
@@ -4809,6 +4928,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/property-information": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
|
||||||
@@ -4912,6 +5037,21 @@
|
|||||||
"pify": "^2.3.0"
|
"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": {
|
"node_modules/readdirp": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
@@ -5113,6 +5253,21 @@
|
|||||||
"queue-microtask": "^1.2.2"
|
"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": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.23.2",
|
"version": "0.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||||
@@ -5132,6 +5287,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"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": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
@@ -5195,6 +5356,15 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"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": {
|
"node_modules/stringify-entities": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
||||||
@@ -5505,6 +5675,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/unicorn-magic": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz",
|
||||||
@@ -5647,7 +5823,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
@@ -5782,6 +5957,24 @@
|
|||||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
"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": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"docx": "^9.6.1",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
|
|||||||
+96
-10
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ export default function App() {
|
|||||||
const [result, setResult] = useState('')
|
const [result, setResult] = useState('')
|
||||||
const [status, setStatus] = useState(null)
|
const [status, setStatus] = useState(null)
|
||||||
const [streamError, setStreamError] = useState(null)
|
const [streamError, setStreamError] = useState(null)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
const resultRef = useRef(null)
|
const resultRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -30,6 +31,33 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [result])
|
}, [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 handleChange = (e) => {
|
||||||
const { name, value } = e.target
|
const { name, value } = e.target
|
||||||
setForm((prev) => ({ ...prev, [name]: value }))
|
setForm((prev) => ({ ...prev, [name]: value }))
|
||||||
@@ -282,15 +310,38 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{(result || isStreaming) && (
|
{(result || isStreaming) && (
|
||||||
<article
|
<div>
|
||||||
ref={resultRef}
|
<article
|
||||||
className="markdown max-h-[60vh] overflow-y-auto rounded-xl border border-stone-300 bg-white p-6 leading-relaxed shadow-sm"
|
ref={resultRef}
|
||||||
>
|
className="markdown max-h-[60vh] overflow-y-auto rounded-xl border border-stone-300 bg-white p-6 leading-relaxed shadow-sm"
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
>
|
||||||
{result}
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
</ReactMarkdown>
|
{result}
|
||||||
{isStreaming && <span className="cursor-pulse" />}
|
</ReactMarkdown>
|
||||||
</article>
|
{isStreaming && <span className="cursor-pulse" />}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{result && !isStreaming && (
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDownloadDocx}
|
||||||
|
className="inline-flex items-center gap-2 rounded-md border border-stone-300 bg-white px-4 py-2 text-sm font-medium text-stone-700 shadow-sm transition hover:bg-stone-50"
|
||||||
|
>
|
||||||
|
<DownloadIcon />
|
||||||
|
Download .docx
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="inline-flex items-center gap-2 rounded-md border border-stone-300 bg-white px-4 py-2 text-sm font-medium text-stone-700 shadow-sm transition hover:bg-stone-50"
|
||||||
|
>
|
||||||
|
<ClipboardIcon />
|
||||||
|
{copied ? 'Copied!' : 'Copy text'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -344,6 +395,41 @@ function StatusBanner({ children }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DownloadIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
||||||
|
<polyline points="7 10 12 15 17 10" />
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClipboardIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||||
|
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function Spinner() {
|
function Spinner() {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import {
|
||||||
|
Document,
|
||||||
|
Paragraph,
|
||||||
|
TextRun,
|
||||||
|
HeadingLevel,
|
||||||
|
AlignmentType,
|
||||||
|
ExternalHyperlink,
|
||||||
|
Table,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
WidthType,
|
||||||
|
BorderStyle,
|
||||||
|
convertInchesToTwip,
|
||||||
|
} from 'docx'
|
||||||
|
|
||||||
|
const HEADING_MAP = {
|
||||||
|
1: HeadingLevel.HEADING_1,
|
||||||
|
2: HeadingLevel.HEADING_2,
|
||||||
|
3: HeadingLevel.HEADING_3,
|
||||||
|
4: HeadingLevel.HEADING_4,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse inline markdown (bold, italic, links, code) into TextRun / Hyperlink children. */
|
||||||
|
function parseInline(text) {
|
||||||
|
const children = []
|
||||||
|
// Match **bold**, *italic*, [link](url), `code`, or plain text.
|
||||||
|
const re =
|
||||||
|
/(\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|\*(.+?)\*|\[([^\]]+)\]\(([^)]+)\)|`([^`]+)`)/g
|
||||||
|
let last = 0
|
||||||
|
let m
|
||||||
|
while ((m = re.exec(text)) !== null) {
|
||||||
|
if (m.index > 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 },
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user