From 0ec73a424ff63e0ee9198e8128bd946972a9528e Mon Sep 17 00:00:00 2001 From: Logan Lindquist Land Date: Sat, 25 Jul 2026 10:03:07 -0500 Subject: [PATCH] feat: add deterministic Pandoc conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shell out to Pandoc behind a typed conversion contract so callers can\nproduce reproducible DOCX bytes without embedding CLI behavior.\n\n• Enforce Pandoc 3.x probing and fail-closed validation before conversion\n• Add deterministic golden-fixture coverage around SOURCE_DATE_EPOCH and\n reference-doc handling\n• Document why DOCX conversion omits Markdown-only wrap and heading flags --- README.md | 108 +++- package.json | 8 +- pnpm-lock.yaml | 173 +++++- src/index.ts | 36 +- src/pandoc.ts | 501 +++++++++++++++ tests/fixtures/golden/README.md | 7 + .../publish/basic-note/expected.document.xml | 1 + tests/fixtures/reference/reference.docx | Bin 0 -> 10446 bytes tests/index.test.ts | 583 +++++++++++++++++- 9 files changed, 1360 insertions(+), 57 deletions(-) create mode 100644 src/pandoc.ts create mode 100644 tests/fixtures/golden/publish/basic-note/expected.document.xml create mode 100644 tests/fixtures/reference/reference.docx diff --git a/README.md b/README.md index 41d9a0b..6adcc63 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,110 @@ # polydoc-core -`polydoc-core` is the reusable TypeScript library layer for Polydoc: the -conversion core plus pluggable transports for publishing Markdown-derived -documents. It is intended to be consumed by the standalone `polydoc` tool and by -TeamWiki workflows that need the same conversion behavior without taking on a -CLI. +`polydoc-core` is the reusable TypeScript library layer for Polydoc: deterministic +Markdown-to-DOCX conversion plus hook contracts for consumers that need their own +Markdown transforms. It is intended to be consumed by the standalone `polydoc` +tool and by TeamWiki workflows that need the same conversion behavior without +taking on a CLI. The original Polydoc design work scoped a local-first Markdown-to-Word and Markdown-to-Google Docs workflow. This package keeps only the reusable library boundary from that work. CLI commands, project manifests, watch mode, OAuth user -experience, and sidecar storage stay outside this repository. +experience, transports, and sidecar storage stay outside this repository. -## Current Status +## API -This repository is at its initial package scaffold. The public API is deliberately -small until the dedicated conversion and transport issues define stable contracts. -Today it exports package identity and a descriptor of the current library -boundary. +The package is ESM-only and exports the core Pandoc contract from +`@agentic-tooling/polydoc-core`. + +```ts +import { + SUPPORTED_PANDOC_MAJOR, + convertMarkdownToDocx, + doctor, +} from "@agentic-tooling/polydoc-core"; + +const probe = await doctor(); + +if (!probe.ok) { + throw new Error(probe.message); +} + +const docxBytes = await convertMarkdownToDocx({ + markdown: "# Publish me\n\nTeamWiki can pass no hooks here.", + referenceDocxPath: "./reference.docx", + sourceDateEpoch: 1_704_067_200, + preprocessors: [ + async (markdown) => markdown.replaceAll("[[TeamWiki]]", "TeamWiki"), + ], +}); + +console.log(SUPPORTED_PANDOC_MAJOR); // 3 +``` + +`convertMarkdownToDocx()` returns DOCX bytes as a `Uint8Array`. Callers can then +write those bytes to disk, upload them to a transport, or pass them to another +library. + +## Pandoc Contract + +This package shells out to the system `pandoc` binary through `execa` with an +argument array. It does not bundle Pandoc and does not invoke a shell. + +- Supported Pandoc policy is exported as `SUPPORTED_PANDOC_MAJOR`; the current + supported major is `3`. +- `doctor()` runs `pandoc --version`, parses the installed version and feature + line, and returns a typed success or failure result. +- Every conversion probes Pandoc first and fails closed before creating + conversion files when Pandoc is missing, unparseable, or outside the supported + major. +- Failures throw `PandocError` with a stable `code` and actionable `guidance`. +- A readable `referenceDocxPath` is required. Pandoc's `--reference-doc` option + is the styling contract for generated Word documents. + +Markdown-to-DOCX conversion uses: + +```txt +pandoc --from gfm --to docx --reference-doc --output +``` + +The forward DOCX writer intentionally does not pass `--wrap=none` or +`--markdown-headings=atx`; those Pandoc options affect textual Markdown output, +not DOCX generation. + +## Hooks + +Forward conversion accepts Markdown preprocessors: + +```ts +const docxBytes = await convertMarkdownToDocx({ + markdown, + referenceDocxPath, + preprocessors: [ + (source) => source.replaceAll("[[", "").replaceAll("]]", ""), + ], +}); +``` + +Preprocessors run sequentially before Pandoc receives the Markdown. They receive +a context object with `phase: "preprocess"` and `targetFormat: "docx"`. + +The package also exports `MarkdownPostprocessor` and +`applyMarkdownPostprocessors()` for future reverse or textual Markdown pipelines. +They do not run during Markdown-to-DOCX conversion because that pipeline returns +DOCX bytes, not Markdown text. + +## Determinism + +`convertMarkdownToDocx()` sets `SOURCE_DATE_EPOCH` for Pandoc. By default it uses +`"0"`; callers can pass `sourceDateEpoch` as a non-negative Unix timestamp string +or number. Identical Markdown, options, reference DOCX, source date epoch, and +Pandoc binary/version are expected to produce identical bytes. ## Requirements - Node.js 20 or newer - pnpm 11.9.0 +- Pandoc 3.x for conversion ## Development @@ -46,6 +129,9 @@ pnpm typecheck pnpm format:check ``` +Pandoc integration tests are included in `pnpm test`. They skip cleanly when a +supported Pandoc binary is unavailable. + Format files: ```sh diff --git a/package.json b/package.json index f468e1a..730fee9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@agentic-tooling/polydoc-core", "version": "0.0.0", - "description": "Reusable TypeScript conversion core and pluggable transports for polydoc and TeamWiki.", + "description": "Reusable TypeScript Markdown-to-DOCX conversion core for polydoc and TeamWiki.", "type": "module", "license": "MIT", "author": "Logan Lindquist Land", @@ -50,9 +50,13 @@ }, "devDependencies": { "@biomejs/biome": "^2.5.5", - "@types/node": "^26.1.1", + "@types/node": "^20.19.43", "@vitest/coverage-v8": "^4.1.10", + "fflate": "^0.8.3", "typescript": "^7.0.2", "vitest": "^4.1.10" + }, + "dependencies": { + "execa": "^8.0.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52ca094..d29a99d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,22 +7,29 @@ settings: importers: .: + dependencies: + execa: + specifier: ^8.0.1 + version: 8.0.1 devDependencies: '@biomejs/biome': specifier: ^2.5.5 version: 2.5.5 '@types/node': - specifier: ^26.1.1 - version: 26.1.1 + specifier: ^20.19.43 + version: 20.19.43 '@vitest/coverage-v8': specifier: ^4.1.10 version: 4.1.10(vitest@4.1.10) + fflate: + specifier: ^0.8.3 + version: 0.8.3 typescript: specifier: ^7.0.2 version: 7.0.2 vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@26.1.1)) + version: 4.1.10(@types/node@20.19.43)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@20.19.43)) packages: @@ -245,8 +252,8 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@types/node@26.1.1': - resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} @@ -420,6 +427,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -430,6 +441,10 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -443,11 +458,18 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -455,6 +477,17 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -554,15 +587,38 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + nanoid@3.3.16: resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + obug@2.1.4: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -587,9 +643,21 @@ packages: engines: {node: '>=10'} hasBin: true + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -600,6 +668,10 @@ packages: std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -627,8 +699,8 @@ packages: engines: {node: '>=16.20.0'} hasBin: true - undici-types@8.3.0: - resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} vite@8.1.5: resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} @@ -714,6 +786,11 @@ packages: jsdom: optional: true + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -872,9 +949,9 @@ snapshots: '@types/estree@1.0.9': {} - '@types/node@26.1.1': + '@types/node@20.19.43': dependencies: - undici-types: 8.3.0 + undici-types: 6.21.0 '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -948,7 +1025,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@26.1.1)) + vitest: 4.1.10(@types/node@20.19.43)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@20.19.43)) '@vitest/expect@4.1.10': dependencies: @@ -959,13 +1036,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@26.1.1))': + '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@20.19.43))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.5(@types/node@26.1.1) + vite: 8.1.5(@types/node@20.19.43) '@vitest/pretty-format@4.1.10': dependencies: @@ -1003,6 +1080,12 @@ snapshots: convert-source-map@2.0.0: {} + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + detect-libc@2.1.2: {} es-module-lexer@2.3.1: {} @@ -1011,19 +1094,41 @@ snapshots: dependencies: '@types/estree': 1.0.9 + execa@8.0.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + expect-type@1.4.0: {} fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 + fflate@0.8.3: {} + fsevents@2.3.3: optional: true + get-stream@8.0.1: {} + has-flag@4.0.0: {} html-escaper@2.0.2: {} + human-signals@5.0.0: {} + + is-stream@3.0.0: {} + + isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -1102,10 +1207,26 @@ snapshots: dependencies: semver: 7.8.5 + merge-stream@2.0.0: {} + + mimic-fn@4.0.0: {} + nanoid@3.3.16: {} + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + obug@2.1.4: {} + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + + path-key@3.1.1: {} + + path-key@4.0.0: {} + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -1141,14 +1262,24 @@ snapshots: semver@7.8.5: {} + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + signal-exit@4.1.0: {} + source-map-js@1.2.1: {} stackback@0.0.2: {} std-env@4.2.0: {} + strip-final-newline@3.0.0: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -1190,9 +1321,9 @@ snapshots: '@typescript/typescript-win32-arm64': 7.0.2 '@typescript/typescript-win32-x64': 7.0.2 - undici-types@8.3.0: {} + undici-types@6.21.0: {} - vite@8.1.5(@types/node@26.1.1): + vite@8.1.5(@types/node@20.19.43): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -1200,13 +1331,13 @@ snapshots: rolldown: 1.1.5 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.1.1 + '@types/node': 20.19.43 fsevents: 2.3.3 - vitest@4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@26.1.1)): + vitest@4.1.10(@types/node@20.19.43)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@20.19.43)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@26.1.1)) + '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@20.19.43)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -1223,14 +1354,18 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.5(@types/node@26.1.1) + vite: 8.1.5(@types/node@20.19.43) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 26.1.1 + '@types/node': 20.19.43 '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) transitivePeerDependencies: - msw + which@2.0.2: + dependencies: + isexe: 2.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 diff --git a/src/index.ts b/src/index.ts index 3d43bfb..fb7c736 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,26 @@ -export const POLYDOC_CORE_PACKAGE_NAME = "@agentic-tooling/polydoc-core"; - -export const POLYDOC_CORE_DESCRIPTOR = { - packageName: POLYDOC_CORE_PACKAGE_NAME, - purpose: "conversion-core-and-transports", - includes: ["conversion-core", "pluggable-transports"], - excludes: ["cli", "manifest", "watch-mode", "oauth-ux", "sidecar-management"], -} as const; - -export type PolydocCoreDescriptor = typeof POLYDOC_CORE_DESCRIPTOR; +export type { + ConvertMarkdownToDocxOptions, + MarkdownPostprocessor, + MarkdownPreprocessor, + MarkdownProcessor, + MarkdownProcessorContext, + PandocDoctorFailure, + PandocDoctorFailureCode, + PandocDoctorOptions, + PandocDoctorResult, + PandocDoctorSuccess, + PandocErrorCode, + PandocRunner, + PandocRunnerOptions, + PandocRunnerResult, + PandocVersion, +} from "./pandoc.js"; +export { + applyMarkdownPostprocessors, + applyMarkdownPreprocessors, + convertMarkdownToDocx, + DEFAULT_SOURCE_DATE_EPOCH, + doctor, + PandocError, + SUPPORTED_PANDOC_MAJOR, +} from "./pandoc.js"; diff --git a/src/pandoc.ts b/src/pandoc.ts new file mode 100644 index 0000000..e4eeeee --- /dev/null +++ b/src/pandoc.ts @@ -0,0 +1,501 @@ +import { constants } from "node:fs"; +import { access, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, extname, join } from "node:path"; +import { execa } from "execa"; + +export const SUPPORTED_PANDOC_MAJOR = 3; +export const DEFAULT_SOURCE_DATE_EPOCH = "0"; + +const DEFAULT_PANDOC_BINARY = "pandoc"; + +export type PandocDoctorFailureCode = + | "PANDOC_NOT_FOUND" + | "PANDOC_PROBE_FAILED" + | "PANDOC_VERSION_UNPARSEABLE" + | "PANDOC_UNSUPPORTED_MAJOR"; + +export type PandocErrorCode = + | PandocDoctorFailureCode + | "MARKDOWN_HOOK_FAILED" + | "PANDOC_CONVERSION_FAILED" + | "REFERENCE_DOC_REQUIRED" + | "REFERENCE_DOC_INVALID" + | "SOURCE_DATE_EPOCH_INVALID"; + +export interface PandocVersion { + readonly major: number; + readonly minor: number | undefined; + readonly patch: number | undefined; + readonly raw: string; +} + +export interface PandocDoctorSuccess { + readonly ok: true; + readonly binary: string; + readonly supportedMajor: number; + readonly version: PandocVersion; + readonly features: readonly string[]; + readonly rawOutput: string; +} + +export interface PandocDoctorFailure { + readonly ok: false; + readonly binary: string; + readonly code: PandocDoctorFailureCode; + readonly message: string; + readonly guidance: string; + readonly supportedMajor: number; + readonly detectedVersion?: PandocVersion; + readonly rawOutput?: string; + readonly cause?: unknown; +} + +export type PandocDoctorResult = PandocDoctorSuccess | PandocDoctorFailure; + +export interface PandocRunnerOptions { + readonly env?: Readonly>; + readonly reject?: boolean; +} + +export interface PandocRunnerResult { + readonly stdout: string; + readonly stderr: string; + readonly exitCode: number; +} + +export type PandocRunner = ( + binary: string, + args: readonly string[], + options?: PandocRunnerOptions, +) => Promise; + +export interface PandocDoctorOptions { + readonly pandocPath?: string; + readonly runner?: PandocRunner; +} + +export interface MarkdownProcessorContext { + readonly phase: "preprocess" | "postprocess"; + readonly sourceFormat: "markdown"; + readonly targetFormat: "docx" | "markdown"; + readonly metadata: Readonly>; +} + +export type MarkdownProcessor = ( + markdown: string, + context: MarkdownProcessorContext, +) => string | Promise; + +export type MarkdownPreprocessor = MarkdownProcessor; + +/** + * Typed contract for future reverse/textual Markdown pipelines. + * + * Markdown-to-DOCX conversion does not run postprocessors because the forward + * pipeline returns DOCX bytes, not Markdown text. + */ +export type MarkdownPostprocessor = MarkdownProcessor; + +export interface ConvertMarkdownToDocxOptions extends PandocDoctorOptions { + readonly markdown: string | Uint8Array; + readonly referenceDocxPath: string; + readonly sourceDateEpoch?: number | string; + readonly preprocessors?: readonly MarkdownPreprocessor[]; + readonly metadata?: Readonly>; +} + +export class PandocError extends Error { + readonly code: PandocErrorCode; + readonly guidance: string; + + constructor( + code: PandocErrorCode, + message: string, + guidance: string, + options: { readonly cause?: unknown } = {}, + ) { + const fullMessage = `${message} ${guidance}`; + + if ("cause" in options) { + super(fullMessage, { cause: options.cause }); + } else { + super(fullMessage); + } + + this.name = "PandocError"; + this.code = code; + this.guidance = guidance; + } +} + +export async function doctor(options: PandocDoctorOptions = {}): Promise { + const binary = options.pandocPath ?? DEFAULT_PANDOC_BINARY; + const runner = options.runner ?? defaultPandocRunner; + + try { + const result = await runner(binary, ["--version"], { reject: false }); + + if (result.exitCode !== 0) { + return { + ok: false, + binary, + code: "PANDOC_PROBE_FAILED", + message: "Pandoc was found but `pandoc --version` failed.", + guidance: + "Run `pandoc --version` locally and fix the installation before converting Markdown to DOCX.", + supportedMajor: SUPPORTED_PANDOC_MAJOR, + rawOutput: joinOutput(result.stdout, result.stderr), + }; + } + + const rawOutput = joinOutput(result.stdout, result.stderr); + const version = parsePandocVersion(rawOutput); + + if (version === undefined) { + return { + ok: false, + binary, + code: "PANDOC_VERSION_UNPARSEABLE", + message: "Pandoc responded, but its version could not be parsed.", + guidance: `Install a supported Pandoc ${SUPPORTED_PANDOC_MAJOR}.x release and retry.`, + supportedMajor: SUPPORTED_PANDOC_MAJOR, + rawOutput, + }; + } + + if (version.major !== SUPPORTED_PANDOC_MAJOR) { + return { + ok: false, + binary, + code: "PANDOC_UNSUPPORTED_MAJOR", + message: `Pandoc ${version.raw} is installed, but this package supports Pandoc ${SUPPORTED_PANDOC_MAJOR}.x.`, + guidance: `Install or select Pandoc ${SUPPORTED_PANDOC_MAJOR}.x with the pandocPath option before converting.`, + supportedMajor: SUPPORTED_PANDOC_MAJOR, + detectedVersion: version, + rawOutput, + }; + } + + return { + ok: true, + binary, + supportedMajor: SUPPORTED_PANDOC_MAJOR, + version, + features: parsePandocFeatures(rawOutput), + rawOutput, + }; + } catch (cause) { + return { + ok: false, + binary, + code: isNotFoundError(cause) ? "PANDOC_NOT_FOUND" : "PANDOC_PROBE_FAILED", + message: isNotFoundError(cause) + ? "Pandoc was not found on PATH." + : "Pandoc could not be probed.", + guidance: + "Install Pandoc 3.x from https://pandoc.org/installing.html or pass pandocPath to the installed binary.", + supportedMajor: SUPPORTED_PANDOC_MAJOR, + cause, + }; + } +} + +export async function applyMarkdownPreprocessors( + markdown: string, + preprocessors: readonly MarkdownPreprocessor[] = [], + metadata: Readonly> = {}, +): Promise { + return applyMarkdownProcessors(markdown, preprocessors, { + phase: "preprocess", + sourceFormat: "markdown", + targetFormat: "docx", + metadata, + }); +} + +export async function applyMarkdownPostprocessors( + markdown: string, + postprocessors: readonly MarkdownPostprocessor[] = [], + metadata: Readonly> = {}, +): Promise { + return applyMarkdownProcessors(markdown, postprocessors, { + phase: "postprocess", + sourceFormat: "markdown", + targetFormat: "markdown", + metadata, + }); +} + +export async function convertMarkdownToDocx( + options: ConvertMarkdownToDocxOptions, +): Promise { + const pandoc = await doctor(options); + + if (!pandoc.ok) { + throw pandocFailureToError(pandoc); + } + + const referenceDocxPath = await validateReferenceDocxPath(options.referenceDocxPath); + const sourceDateEpoch = normalizeSourceDateEpoch(options.sourceDateEpoch); + const markdown = await applyMarkdownPreprocessors( + decodeMarkdown(options.markdown), + options.preprocessors, + options.metadata, + ); + const runner = options.runner ?? defaultPandocRunner; + const tempDirectory = await mkdtemp(join(tmpdir(), "polydoc-core-")); + + try { + const inputPath = join(tempDirectory, "input.md"); + const outputPath = join(tempDirectory, "output.docx"); + await writeFile(inputPath, markdown, "utf8"); + + let result: PandocRunnerResult; + + try { + result = await runner( + pandoc.binary, + [ + "--from", + "gfm", + "--to", + "docx", + "--reference-doc", + referenceDocxPath, + "--output", + outputPath, + inputPath, + ], + { + env: { SOURCE_DATE_EPOCH: sourceDateEpoch }, + reject: false, + }, + ); + } catch (cause) { + if (cause instanceof PandocError) { + throw cause; + } + + throw new PandocError( + "PANDOC_CONVERSION_FAILED", + "Pandoc execution failed while converting Markdown to DOCX.", + "Confirm pandocPath points at a working Pandoc 3.x binary and retry the conversion.", + { cause }, + ); + } + + if (result.exitCode !== 0) { + throw new PandocError( + "PANDOC_CONVERSION_FAILED", + "Pandoc failed while converting Markdown to DOCX.", + conversionGuidance(result.stderr), + ); + } + + try { + return await readFile(outputPath); + } catch (cause) { + throw new PandocError( + "PANDOC_CONVERSION_FAILED", + "Pandoc completed but the DOCX output could not be read.", + "Check that the conversion process can write to the system temp directory and that disk space is available.", + { cause }, + ); + } + } finally { + await rm(tempDirectory, { force: true, recursive: true }); + } +} + +async function defaultPandocRunner( + binary: string, + args: readonly string[], + options: PandocRunnerOptions = {}, +): Promise { + const execaOptions = + options.env === undefined + ? { reject: options.reject ?? true } + : { env: { ...options.env }, reject: options.reject ?? true }; + const result = await execa(binary, [...args], execaOptions); + + return { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode ?? 0, + }; +} + +async function applyMarkdownProcessors( + markdown: string, + processors: readonly MarkdownProcessor[], + context: MarkdownProcessorContext, +): Promise { + let current = markdown; + + for (const processor of processors) { + try { + const next = await processor(current, context); + + if (typeof next !== "string") { + throw new TypeError("Markdown processors must return a string."); + } + + current = next; + } catch (cause) { + if (cause instanceof PandocError) { + throw cause; + } + + throw new PandocError( + "MARKDOWN_HOOK_FAILED", + `A Markdown ${context.phase}or failed.`, + "Fix the configured Markdown hook or remove it before retrying the conversion.", + { cause }, + ); + } + } + + return current; +} + +async function validateReferenceDocxPath(referenceDocxPath: string): Promise { + if (referenceDocxPath.trim() === "") { + throw new PandocError( + "REFERENCE_DOC_REQUIRED", + "A reference DOCX path is required for Markdown-to-DOCX conversion.", + "Pass referenceDocxPath pointing at a readable .docx file that defines the Word styling contract.", + ); + } + + if (extname(referenceDocxPath).toLowerCase() !== ".docx") { + throw new PandocError( + "REFERENCE_DOC_INVALID", + `Reference document ${basename(referenceDocxPath)} is not a .docx file.`, + "Pass a readable .docx file through referenceDocxPath.", + ); + } + + try { + const referenceStat = await stat(referenceDocxPath); + + if (!referenceStat.isFile()) { + throw new PandocError( + "REFERENCE_DOC_INVALID", + `Reference document ${basename(referenceDocxPath)} is not a file.`, + "Pass a readable .docx file through referenceDocxPath.", + ); + } + + await access(referenceDocxPath, constants.R_OK); + } catch (cause) { + if (cause instanceof PandocError) { + throw cause; + } + + throw new PandocError( + "REFERENCE_DOC_INVALID", + `Reference document ${basename(referenceDocxPath)} could not be read.`, + "Create the reference DOCX first or pass the correct referenceDocxPath.", + { cause }, + ); + } + + return referenceDocxPath; +} + +function normalizeSourceDateEpoch(sourceDateEpoch: number | string | undefined): string { + const normalized = sourceDateEpoch ?? DEFAULT_SOURCE_DATE_EPOCH; + + if (typeof normalized === "number") { + if (!Number.isSafeInteger(normalized) || normalized < 0) { + throw invalidSourceDateEpochError(); + } + + return String(normalized); + } + + if (!/^(0|[1-9]\d*)$/.test(normalized)) { + throw invalidSourceDateEpochError(); + } + + return normalized; +} + +function invalidSourceDateEpochError(): PandocError { + return new PandocError( + "SOURCE_DATE_EPOCH_INVALID", + "SOURCE_DATE_EPOCH must be a non-negative Unix timestamp.", + "Pass sourceDateEpoch as a non-negative integer string or number.", + ); +} + +function parsePandocVersion(output: string): PandocVersion | undefined { + const match = /^pandoc\s+(\d+)(?:\.(\d+))?(?:\.(\d+))?([^\s]*)?/m.exec(output); + + if (match === null) { + return undefined; + } + + const major = Number.parseInt(match[1] ?? "", 10); + const minor = match[2] === undefined ? undefined : Number.parseInt(match[2], 10); + const patch = match[3] === undefined ? undefined : Number.parseInt(match[3], 10); + + if (!Number.isInteger(major)) { + return undefined; + } + + return { + major, + minor: Number.isNaN(minor) ? undefined : minor, + patch: Number.isNaN(patch) ? undefined : patch, + raw: [match[1], match[2], match[3]].filter((part) => part !== undefined).join("."), + }; +} + +function parsePandocFeatures(output: string): readonly string[] { + const featuresLine = output + .split(/\r?\n/) + .find((line) => line.toLowerCase().startsWith("features:")); + + if (featuresLine === undefined) { + return []; + } + + return featuresLine + .replace(/^features:\s*/i, "") + .split(/\s+/) + .filter((feature) => feature.length > 0); +} + +function pandocFailureToError(failure: PandocDoctorFailure): PandocError { + return new PandocError(failure.code, failure.message, failure.guidance, { cause: failure.cause }); +} + +function decodeMarkdown(markdown: string | Uint8Array): string { + if (typeof markdown === "string") { + return markdown; + } + + return new TextDecoder().decode(markdown); +} + +function joinOutput(stdout: string, stderr: string): string { + return [stdout, stderr].filter((text) => text.length > 0).join("\n"); +} + +function conversionGuidance(stderr: string): string { + const trimmed = stderr.trim(); + const stderrSuffix = trimmed.length > 0 ? ` Pandoc stderr: ${trimmed.slice(0, 800)}` : ""; + + return `Confirm the Markdown input and reference DOCX are valid, then retry.${stderrSuffix}`; +} + +function isNotFoundError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { readonly code?: unknown }).code === "ENOENT" + ); +} diff --git a/tests/fixtures/golden/README.md b/tests/fixtures/golden/README.md index 13714c0..518ed23 100644 --- a/tests/fixtures/golden/README.md +++ b/tests/fixtures/golden/README.md @@ -8,3 +8,10 @@ Current paths: - `publish/basic-note/input.md` - a small Obsidian-flavored source note for the first Markdown-to-publish fixture. +- `publish/basic-note/expected.document.xml` - normalized `word/document.xml` + expected from the DOCX output. Normalization removes volatile Word revision + IDs, bookmark numeric IDs, relationship numeric IDs, and whitespace-only + layout differences while preserving document structure, styles, text, and + links. +- `../reference/reference.docx` - the required Pandoc reference document used by + integration tests for deterministic DOCX generation. diff --git a/tests/fixtures/golden/publish/basic-note/expected.document.xml b/tests/fixtures/golden/publish/basic-note/expected.document.xml new file mode 100644 index 0000000..e106003 --- /dev/null +++ b/tests/fixtures/golden/publish/basic-note/expected.document.xml @@ -0,0 +1 @@ + Basic TeamWiki Note This note links to [[TeamWiki]] and includes an ordinary Markdown link to polydoc. [!note] Publish target The conversion core will eventually preserve this callout through the Markdown-to-docx publish path. diff --git a/tests/fixtures/reference/reference.docx b/tests/fixtures/reference/reference.docx new file mode 100644 index 0000000000000000000000000000000000000000..1b284dea7067d4b9c157fb142c51dbca10ce8969 GIT binary patch literal 10446 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 literal 0 HcmV?d00001 diff --git a/tests/index.test.ts b/tests/index.test.ts index 7cb07fa..fb9f8fe 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,23 +1,576 @@ -import { readFile } from "node:fs/promises"; -import { describe, expect, it } from "vitest"; +import { chmod, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { strFromU8, unzipSync } from "fflate"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { POLYDOC_CORE_DESCRIPTOR, POLYDOC_CORE_PACKAGE_NAME } from "../src/index.js"; +import { + applyMarkdownPostprocessors, + applyMarkdownPreprocessors, + type ConvertMarkdownToDocxOptions, + convertMarkdownToDocx, + DEFAULT_SOURCE_DATE_EPOCH, + doctor, + PandocError, + type PandocRunner, + SUPPORTED_PANDOC_MAJOR, +} from "../src/index.js"; -describe("polydoc-core scaffold", () => { - it("exports the package identity and current library boundary", () => { - expect(POLYDOC_CORE_PACKAGE_NAME).toBe("@agentic-tooling/polydoc-core"); - expect(POLYDOC_CORE_DESCRIPTOR).toEqual({ - packageName: "@agentic-tooling/polydoc-core", - purpose: "conversion-core-and-transports", - includes: ["conversion-core", "pluggable-transports"], - excludes: ["cli", "manifest", "watch-mode", "oauth-ux", "sidecar-management"], +const fixtureInputPath = "tests/fixtures/golden/publish/basic-note/input.md"; +const fixtureExpectedDocumentXmlPath = + "tests/fixtures/golden/publish/basic-note/expected.document.xml"; +const fixtureReferenceDocxPath = "tests/fixtures/reference/reference.docx"; + +const realPandocProbe = await doctor(); +const canRepresentUnreadableFiles = process.platform !== "win32" && process.getuid?.() !== 0; + +const tempDirectories: string[] = []; +const unsupportedMajorOptionRegression: ConvertMarkdownToDocxOptions = { + markdown: "# Type regression", + referenceDocxPath: "reference.docx", + // @ts-expect-error supportedMajor is intentionally not a public policy override. + supportedMajor: 2, +}; +void unsupportedMajorOptionRegression; + +afterEach(async () => { + while (tempDirectories.length > 0) { + const tempDirectory = tempDirectories.pop(); + + if (tempDirectory !== undefined) { + await rm(tempDirectory, { force: true, recursive: true }); + } + } +}); + +describe("pandoc doctor", () => { + it("parses a supported Pandoc 3 version and features", async () => { + const runner: PandocRunner = vi.fn(async () => ({ + stdout: ["pandoc 3.10", "Features: +server +lua", "Scripting engine: Lua 5.4"].join("\n"), + stderr: "", + exitCode: 0, + })); + + const result = await doctor({ runner }); + + expect(result).toMatchObject({ + ok: true, + binary: "pandoc", + supportedMajor: SUPPORTED_PANDOC_MAJOR, + version: { + major: 3, + minor: 10, + patch: undefined, + raw: "3.10", + }, + features: ["+server", "+lua"], + }); + expect(runner).toHaveBeenCalledWith("pandoc", ["--version"], { reject: false }); + }); + + it("reports a missing Pandoc binary without throwing", async () => { + const error = Object.assign(new Error("spawn pandoc ENOENT"), { code: "ENOENT" }); + const runner: PandocRunner = vi.fn(async () => { + throw error; + }); + + const result = await doctor({ runner }); + + expect(result).toMatchObject({ + ok: false, + code: "PANDOC_NOT_FOUND", + message: "Pandoc was not found on PATH.", + supportedMajor: SUPPORTED_PANDOC_MAJOR, + }); + expect(result.ok ? undefined : result.guidance).toContain("Install Pandoc 3.x"); + }); + + it("rejects incompatible Pandoc major versions", async () => { + const runner: PandocRunner = vi.fn(async () => ({ + stdout: "pandoc 2.19.2\nFeatures: +lua", + stderr: "", + exitCode: 0, + })); + + const result = await doctor({ runner }); + + expect(result).toMatchObject({ + ok: false, + code: "PANDOC_UNSUPPORTED_MAJOR", + detectedVersion: { + major: 2, + minor: 19, + patch: 2, + raw: "2.19.2", + }, }); }); - it("tracks golden fixture inputs for future conversion behavior", async () => { - const fixture = await readFile("tests/fixtures/golden/publish/basic-note/input.md", "utf8"); + it("rejects unparseable Pandoc version output", async () => { + const runner: PandocRunner = vi.fn(async () => ({ + stdout: "not pandoc", + stderr: "", + exitCode: 0, + })); - expect(fixture).toContain("[[TeamWiki]]"); - expect(fixture).toContain("> [!note] Publish target"); + const result = await doctor({ runner }); + + expect(result).toMatchObject({ + ok: false, + code: "PANDOC_VERSION_UNPARSEABLE", + }); + }); + + it("does not allow callers to override the supported Pandoc major through conversion", async () => { + const runner: PandocRunner = vi.fn(async () => ({ + stdout: "pandoc 2.19", + stderr: "", + exitCode: 0, + })); + + await expect( + convertMarkdownToDocx({ + markdown: "# Nope", + referenceDocxPath: "ignored-before-reference-validation.docx", + runner, + supportedMajor: 2, + } as unknown as ConvertMarkdownToDocxOptions), + ).rejects.toMatchObject({ + name: "PandocError", + code: "PANDOC_UNSUPPORTED_MAJOR", + }); + expect(runner).toHaveBeenCalledTimes(1); }); }); + +describe("markdown hooks", () => { + it("runs preprocessors in order with forward DOCX context", async () => { + const seenTargets: string[] = []; + const result = await applyMarkdownPreprocessors( + "Hello", + [ + (markdown, context) => { + seenTargets.push(`${context.phase}:${context.targetFormat}`); + return `${markdown}, TeamWiki`; + }, + async (markdown, context) => { + seenTargets.push(`${context.phase}:${String(context.metadata.source)}`); + return `${markdown}!`; + }, + ], + { source: "fixture" }, + ); + + expect(result).toBe("Hello, TeamWiki!"); + expect(seenTargets).toEqual(["preprocess:docx", "preprocess:fixture"]); + }); + + it("exports postprocessors as Markdown-to-Markdown contracts", async () => { + const result = await applyMarkdownPostprocessors("Hello", [ + (markdown, context) => `${markdown} ${context.phase} ${context.targetFormat}`, + ]); + + expect(result).toBe("Hello postprocess markdown"); + }); + + it("wraps hook failures in typed errors", async () => { + await expect( + applyMarkdownPreprocessors("Hello", [ + () => { + throw new Error("boom"); + }, + ]), + ).rejects.toMatchObject({ + name: "PandocError", + code: "MARKDOWN_HOOK_FAILED", + }); + }); +}); + +describe("markdown to DOCX conversion", () => { + it("fails closed when Pandoc is unsupported before touching conversion files", async () => { + const runner: PandocRunner = vi.fn(async () => ({ + stdout: "pandoc 2.19", + stderr: "", + exitCode: 0, + })); + const referenceDocxPath = await createTempDocx("reference.docx"); + + await expect( + convertMarkdownToDocx({ + markdown: "# Nope", + referenceDocxPath, + runner, + }), + ).rejects.toMatchObject({ + name: "PandocError", + code: "PANDOC_UNSUPPORTED_MAJOR", + }); + expect(runner).toHaveBeenCalledTimes(1); + }); + + it("requires a readable reference DOCX after a successful probe", async () => { + const runner = createSuccessfulDoctorRunner(); + + await expect( + convertMarkdownToDocx({ + markdown: "# Missing reference", + referenceDocxPath: "tests/fixtures/reference/missing.docx", + runner, + }), + ).rejects.toMatchObject({ + name: "PandocError", + code: "REFERENCE_DOC_INVALID", + }); + expect(runner).toHaveBeenCalledTimes(1); + }); + + it.skipIf(!canRepresentUnreadableFiles)( + "wraps unreadable reference DOCX access in a typed validation error", + async () => { + const runner = createSuccessfulDoctorRunner(); + const referenceDocxPath = await createTempDocx("reference.docx"); + + try { + await chmod(referenceDocxPath, 0o000); + + await expect( + convertMarkdownToDocx({ + markdown: "# Unreadable reference", + referenceDocxPath, + runner, + }), + ).rejects.toMatchObject({ + name: "PandocError", + code: "REFERENCE_DOC_INVALID", + guidance: expect.stringContaining("referenceDocxPath"), + }); + expect(runner).toHaveBeenCalledTimes(1); + } finally { + await chmod(referenceDocxPath, 0o600); + } + }, + ); + + it("uses argument arrays, source date, reference DOCX, and no reverse-only writer flags", async () => { + const calls: Array<{ + binary: string; + args: readonly string[]; + env: Readonly> | undefined; + }> = []; + const referenceDocxPath = await createTempDocx("reference.docx"); + const runner: PandocRunner = vi.fn(async (binary, args, options) => { + calls.push({ binary, args, env: options?.env }); + + if (args[0] === "--version") { + return { + stdout: "pandoc 3.10\nFeatures: +lua", + stderr: "", + exitCode: 0, + }; + } + + const outputPath = args[args.indexOf("--output") + 1]; + const inputPath = args.at(-1); + + if (outputPath === undefined || inputPath === undefined) { + throw new Error("test runner received an incomplete invocation"); + } + + expect(await readFile(inputPath, "utf8")).toBe("# Title\n\nProcessed twice\n"); + await writeFile(outputPath, "docx bytes"); + + return { + stdout: "", + stderr: "", + exitCode: 0, + }; + }); + + const bytes = await convertMarkdownToDocx({ + markdown: "# Title\n", + referenceDocxPath, + sourceDateEpoch: 123, + preprocessors: [(markdown) => `${markdown}\nProcessed`, (markdown) => `${markdown} twice\n`], + runner, + }); + + expect(Buffer.from(bytes).toString("utf8")).toBe("docx bytes"); + expect(calls).toHaveLength(2); + expect(calls[1]).toEqual({ + binary: "pandoc", + args: [ + "--from", + "gfm", + "--to", + "docx", + "--reference-doc", + referenceDocxPath, + "--output", + expect.stringMatching(/output\.docx$/), + expect.stringMatching(/input\.md$/), + ], + env: { SOURCE_DATE_EPOCH: "123" }, + }); + expect(calls[1]?.args).not.toContain("--wrap=none"); + expect(calls[1]?.args).not.toContain("--markdown-headings=atx"); + }); + + it("wraps unexpected runner failures after a successful probe and cleans up", async () => { + let tempOutputPath: string | undefined; + const referenceDocxPath = await createTempDocx("reference.docx"); + const runner: PandocRunner = vi.fn(async (_binary, args) => { + if (args[0] === "--version") { + return { + stdout: "pandoc 3.10", + stderr: "", + exitCode: 0, + }; + } + + tempOutputPath = args[args.indexOf("--output") + 1]; + throw new Error("process runner exploded"); + }); + + await expect( + convertMarkdownToDocx({ + markdown: "# Broken", + referenceDocxPath, + runner, + }), + ).rejects.toMatchObject({ + name: "PandocError", + code: "PANDOC_CONVERSION_FAILED", + guidance: expect.stringContaining("pandocPath"), + }); + + expect(tempOutputPath).toBeDefined(); + await expect(stat(join(tempOutputPath ?? "", ".."))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("preserves typed conversion errors thrown by the runner and cleans up", async () => { + let tempOutputPath: string | undefined; + const referenceDocxPath = await createTempDocx("reference.docx"); + const typedError = new PandocError( + "PANDOC_CONVERSION_FAILED", + "Injected typed failure.", + "Caller guidance.", + ); + const runner: PandocRunner = vi.fn(async (_binary, args) => { + if (args[0] === "--version") { + return { + stdout: "pandoc 3.10", + stderr: "", + exitCode: 0, + }; + } + + tempOutputPath = args[args.indexOf("--output") + 1]; + throw typedError; + }); + + await expect( + convertMarkdownToDocx({ + markdown: "# Broken", + referenceDocxPath, + runner, + }), + ).rejects.toBe(typedError); + + expect(tempOutputPath).toBeDefined(); + await expect(stat(join(tempOutputPath ?? "", ".."))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("uses a fixed SOURCE_DATE_EPOCH by default", async () => { + const referenceDocxPath = await createTempDocx("reference.docx"); + const runner: PandocRunner = vi.fn(async (_binary, args) => { + if (args[0] === "--version") { + return { + stdout: "pandoc 3.10", + stderr: "", + exitCode: 0, + }; + } + + const outputPath = args[args.indexOf("--output") + 1]; + + if (outputPath === undefined) { + throw new Error("test runner received no output path"); + } + + await writeFile(outputPath, "docx bytes"); + + return { + stdout: "", + stderr: "", + exitCode: 0, + }; + }); + + await convertMarkdownToDocx({ + markdown: "# Title", + referenceDocxPath, + runner, + }); + + expect(runner).toHaveBeenLastCalledWith( + "pandoc", + expect.any(Array), + expect.objectContaining({ + env: { SOURCE_DATE_EPOCH: DEFAULT_SOURCE_DATE_EPOCH }, + reject: false, + }), + ); + }); + + it("cleans up temporary files when Pandoc conversion fails", async () => { + let tempOutputPath: string | undefined; + const referenceDocxPath = await createTempDocx("reference.docx"); + const runner: PandocRunner = vi.fn(async (_binary, args) => { + if (args[0] === "--version") { + return { + stdout: "pandoc 3.10", + stderr: "", + exitCode: 0, + }; + } + + tempOutputPath = args[args.indexOf("--output") + 1]; + + return { + stdout: "", + stderr: "invalid reference doc", + exitCode: 43, + }; + }); + + await expect( + convertMarkdownToDocx({ + markdown: "# Broken", + referenceDocxPath, + runner, + }), + ).rejects.toBeInstanceOf(PandocError); + + expect(tempOutputPath).toBeDefined(); + await expect(stat(join(tempOutputPath ?? "", ".."))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("wraps missing output after exit 0 and cleans up", async () => { + let tempOutputPath: string | undefined; + const referenceDocxPath = await createTempDocx("reference.docx"); + const runner: PandocRunner = vi.fn(async (_binary, args) => { + if (args[0] === "--version") { + return { + stdout: "pandoc 3.10", + stderr: "", + exitCode: 0, + }; + } + + tempOutputPath = args[args.indexOf("--output") + 1]; + + return { + stdout: "", + stderr: "", + exitCode: 0, + }; + }); + + await expect( + convertMarkdownToDocx({ + markdown: "# Missing output", + referenceDocxPath, + runner, + }), + ).rejects.toMatchObject({ + name: "PandocError", + code: "PANDOC_CONVERSION_FAILED", + message: expect.stringContaining("DOCX output could not be read"), + }); + + expect(tempOutputPath).toBeDefined(); + await expect(stat(join(tempOutputPath ?? "", ".."))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("rejects invalid SOURCE_DATE_EPOCH values", async () => { + await expect( + convertMarkdownToDocx({ + markdown: "# Bad time", + referenceDocxPath: await createTempDocx("reference.docx"), + sourceDateEpoch: "now", + runner: createSuccessfulDoctorRunner(), + }), + ).rejects.toMatchObject({ + code: "SOURCE_DATE_EPOCH_INVALID", + }); + }); +}); + +describe.skipIf(!realPandocProbe.ok)("Pandoc integration", () => { + it("converts the golden Markdown fixture to deterministic DOCX bytes", async () => { + const markdown = await readFile(fixtureInputPath, "utf8"); + const expectedDocumentXml = await readFile(fixtureExpectedDocumentXmlPath, "utf8"); + const first = await convertMarkdownToDocx({ + markdown, + referenceDocxPath: fixtureReferenceDocxPath, + sourceDateEpoch: 1_704_067_200, + preprocessors: [stripYamlFrontmatter], + }); + const second = await convertMarkdownToDocx({ + markdown, + referenceDocxPath: fixtureReferenceDocxPath, + sourceDateEpoch: 1_704_067_200, + preprocessors: [stripYamlFrontmatter], + }); + + expect(Buffer.compare(Buffer.from(first), Buffer.from(second))).toBe(0); + + const documentXml = normalizeDocumentXml(first); + expect(`${documentXml}\n`).toBe(expectedDocumentXml); + }); +}); + +describe.skipIf(realPandocProbe.ok)("Pandoc integration skip behavior", () => { + it("skips golden conversion tests when Pandoc is absent or unsupported", () => { + expect(realPandocProbe.ok).toBe(false); + }); +}); + +async function createTempDocx(name: string): Promise { + const tempDirectory = await mkdtemp(join(tmpdir(), "polydoc-core-test-")); + tempDirectories.push(tempDirectory); + const docxPath = join(tempDirectory, name); + await writeFile(docxPath, "fake docx"); + + return docxPath; +} + +function createSuccessfulDoctorRunner(): PandocRunner { + return vi.fn(async () => ({ + stdout: "pandoc 3.10", + stderr: "", + exitCode: 0, + })); +} + +function stripYamlFrontmatter(markdown: string): string { + return markdown.replace(/^---\n[\s\S]*?\n---\n\n?/, ""); +} + +function normalizeDocumentXml(docxBytes: Uint8Array): string { + const files = unzipSync(docxBytes); + const documentXml = files["word/document.xml"]; + + if (documentXml === undefined) { + throw new Error("DOCX did not include word/document.xml"); + } + + return strFromU8(documentXml) + .replace(/ w:rsid\w+="[^"]*"/g, "") + .replace(/w:id="\d+"/g, 'w:id="ID"') + .replace(/r:id="rId\d+"/g, 'r:id="rId"') + .replace(/]*\/>]*\/>/g, "") + .replace(/\s+/g, " ") + .trim(); +}