fuzzyworld

auto-print / files / test / pandoc.test.ts

pandoc.test.ts

4.6 KB · history · blame

1// Checks what pandoc makes of the markdown that assemble() writes.
2// These tests are skipped when pandoc is not installed.
3
4import assert from "node:assert/strict";
5import { spawnSync } from "node:child_process";
6import path from "node:path";
7import { describe, it } from "node:test";
8import { fileURLToPath } from "node:url";
9import { assemble, pandocFormat, readExtraPages, readStory, type Target } from "../src/manuscript.ts";
10
11const hasPandoc = spawnSync("pandoc", ["--version"]).status === 0;
12const index = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "collection", "index.md");
13
14interface Node {
15 t: string;
16 c?: unknown;
17}
18
19function 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. */
26function 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. */
42function 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
49function 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
56describe("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});