auto-print / files / test / pandoc.test.ts
pandoc.test.ts
a6d0aed2 2026-10-24 Pavel Safronov | 1 | // Checks what pandoc makes of the markdown that assemble() writes. |
| 2 | // These tests are skipped when pandoc is not installed. | |
| 3 | ||
| 4 | import assert from "node:assert/strict"; | |
| 5 | import { spawnSync } from "node:child_process"; | |
| 6 | import path from "node:path"; | |
| 7 | import { describe, it } from "node:test"; | |
| 8 | import { fileURLToPath } from "node:url"; | |
| 9 | import { assemble, pandocFormat, readExtraPages, readStory, type Target } from "../src/manuscript.ts"; | |
| 10 | ||
| 11 | const hasPandoc = spawnSync("pandoc", ["--version"]).status === 0; | |
| 12 | const index = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "collection", "index.md"); | |
| 13 | ||
| 14 | interface Node { | |
| 15 | t: string; | |
| 16 | c?: unknown; | |
| 17 | } | |
| 18 | ||
| 19 | function parse(markdown: string): Node[] { | |
| 20 | const result = spawnSync("pandoc", [`--from=${pandocFormat}`, "--to=json"], { input: markdown, encoding: "utf8" }); | |
| 21 | assert.equal(result.status, 0, result.stderr); | |
| 22 | return JSON.parse(result.stdout).blocks; | |
| 23 | } | |
| 24 | ||
| 25 | /** The plain words of a block list, with soft line breaks counted as spaces. */ | |
| 26 | function words(blocks: unknown): string { | |
| 27 | const out: string[] = []; | |
| 28 | const walk = (value: unknown): void => { | |
| 29 | if (Array.isArray(value)) value.forEach(walk); | |
| 30 | else if (value && typeof value === "object") { | |
| 31 | const node = value as Node; | |
| 32 | if (node.t === "Str") out.push(node.c as string); | |
| 33 | else if (node.t === "Space" || node.t === "SoftBreak") out.push(" "); | |
| 34 | else walk(node.c); | |
| 35 | } | |
| 36 | }; | |
| 37 | walk(blocks); | |
| 38 | return out.join("").replace(/ +/g, " ").trim(); | |
| 39 | } | |
| 40 | ||
| 41 | /** [level, attributes, text] of each heading. */ | |
| 42 | function headings(blocks: Node[]): [number, string[], Record<string, string>, string][] { | |
| 43 | return blocks.filter((b) => b.t === "Header").map((b) => { | |
| 44 | const [level, [, classes, pairs], inlines] = b.c as [number, [string, string[], [string, string][]], unknown]; | |
| 45 | return [level, classes, Object.fromEntries(pairs), words(inlines)]; | |
| 46 | }); | |
| 47 | } | |
| 48 | ||
| 49 | function book(target: Target, chapterBreaks = false): Node[] { | |
| 50 | const { story } = readStory(index, "Writing"); | |
| 51 | const front = readExtraPages(index, ["Dedication", "Content Warnings"], "front"); | |
| 52 | const back = readExtraPages(index, ["Characters"], "back"); | |
| 53 | return parse(assemble(story, front, back, target, chapterBreaks, "A Collection")); | |
| 54 | } | |
| 55 | ||
| 56 | describe("pandoc", { skip: hasPandoc ? false : "pandoc is not installed" }, () => { | |
| 57 | it("joins sentences on separate lines into one paragraph, like a paragraph on one line", () => { | |
| 58 | const lines = parse("One sentence.\nTwo sentence.\n\nNew paragraph.\n"); | |
| 59 | const joined = parse("One sentence. Two sentence.\n\nNew paragraph.\n"); | |
| 60 | assert.equal(lines.length, 2); | |
| 61 | assert.deepEqual(lines.map(words), joined.map(words)); | |
| 62 | assert.equal(JSON.stringify(lines).includes("LineBreak"), false); | |
| 63 | }); | |
| 64 | ||
| 65 | it("reads braces in prose as text", () => { | |
| 66 | assert.equal(words(parse("She said {quietly} that it was over.\n")), "She said {quietly} that it was over."); | |
| 67 | }); | |
| 68 | ||
| 69 | it("marks the extra pages of the epub as level 1 headings with their roles", () => { | |
| 70 | assert.deepEqual(headings(book("epub")).filter(([level]) => level === 1), [ | |
| 71 | [1, ["unlisted", "hidden-title"], { "epub:type": "dedication" }, "Dedication"], | |
| 72 | [1, [], { "epub:type": "preface" }, "Content Warnings"], | |
| 73 | [1, ["unnumbered"], {}, "A Collection"], | |
| 74 | [1, [], { "epub:type": "appendix" }, "Characters"], | |
| 75 | ]); | |
| 76 | }); | |
| 77 | ||
| 78 | it("keeps the stories as level 2 headings in a flowing epub, and level 1 with chapter breaks", () => { | |
| 79 | const titles = (blocks: Node[], level: number) => headings(blocks).filter(([l]) => l === level).map(([, , , text]) => text); | |
| 80 | assert.deepEqual(titles(book("epub"), 2), ["Story one", "~Intermission One~", "Story two", "Hero"]); | |
| 81 | assert.deepEqual(titles(book("epub", true), 1), ["Dedication", "Content Warnings", "Story one", "~Intermission One~", "Story two", "Characters"]); | |
| 82 | }); | |
| 83 | ||
| 84 | it("wraps the dedication in a div with the Dedication style", () => { | |
| 85 | const div = book("docx").find((b) => b.t === "Div"); | |
| 86 | const [[, classes, pairs], content] = div!.c as [[string, string[], [string, string][]], unknown]; | |
| 87 | assert.deepEqual(classes, ["dedication"]); | |
| 88 | assert.deepEqual(pairs, [["custom-style", "Dedication"]]); | |
| 89 | assert.equal(words(content), "To everyone."); | |
| 90 | }); | |
| 91 | ||
| 92 | it("puts a page break before each extra page and before the story in the docx", () => { | |
| 93 | const blocks = book("docx"); | |
| 94 | const breaks = blocks.map((b, i) => (b.t === "RawBlock" ? i : -1)).filter((i) => i >= 0); | |
| 95 | assert.equal(breaks.length, 4); | |
| 96 | for (const i of breaks) assert.deepEqual((blocks[i]!.c as [string, string])[0], "openxml"); | |
| 97 | // No heading is shown for the dedication. | |
| 98 | assert.equal(headings(blocks).some(([, , , text]) => text === "Dedication"), false); | |
| 99 | }); | |
| 100 | }); |