auto-print / files / test / manuscript.test.ts
manuscript.test.ts
a6d0aed2 2026-10-24 Pavel Safronov | 1 | import assert from "node:assert/strict"; |
| 2 | import path from "node:path"; | |
| 3 | import { describe, it } from "node:test"; | |
| 4 | import { fileURLToPath } from "node:url"; | |
| 5 | import { | |
| 6 | assemble, | |
| 7 | extractStory, | |
| 8 | findHeadings, | |
| 9 | findSection, | |
| 10 | moveOpeningBelowTitle, | |
| 11 | readExtraPages, | |
| 12 | readStory, | |
| 13 | shiftHeadings, | |
| 14 | } from "../src/manuscript.ts"; | |
| 15 | ||
| 16 | const fixtures = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "collection"); | |
| 17 | const index = path.join(fixtures, "index.md"); | |
| 18 | ||
| 19 | describe("findHeadings", () => { | |
| 20 | it("finds headings and skips fenced code blocks", () => { | |
| 21 | const lines = ["# One", "text", "```", "# not a heading", "```", "### Three "]; | |
| 22 | assert.deepEqual(findHeadings(lines), [ | |
| 23 | { line: 0, level: 1, text: "One" }, | |
| 24 | { line: 5, level: 3, text: "Three" }, | |
| 25 | ]); | |
| 26 | }); | |
| 27 | }); | |
| 28 | ||
| 29 | describe("findSection", () => { | |
| 30 | const lines = ["## Info", "### Characters", "secret", "## Characters", "public", "## Writing", "story"]; | |
| 31 | ||
| 32 | it("picks the shallowest heading when a name is used at several levels", () => { | |
| 33 | assert.deepEqual(findSection(lines, "characters", "file.md"), { title: "Characters", lines: ["public"] }); | |
| 34 | }); | |
| 35 | ||
| 36 | it("returns null for a missing heading", () => { | |
| 37 | assert.equal(findSection(lines, "Dedication", "file.md"), null); | |
| 38 | }); | |
| 39 | ||
| 40 | it("fails when a name is used twice at the same level", () => { | |
| 41 | assert.throws(() => findSection([...lines, "## Info"], "Info", "file.md"), /appears 2 times at the same level/); | |
| 42 | }); | |
| 43 | }); | |
| 44 | ||
| 45 | describe("shiftHeadings", () => { | |
| 46 | it("moves headings down and up", () => { | |
| 47 | assert.deepEqual(shiftHeadings(["# A", "text", "## B"], 1), ["## A", "text", "### B"]); | |
| 48 | assert.deepEqual(shiftHeadings(["### A", "#### B"], -2), ["# A", "## B"]); | |
| 49 | }); | |
| 50 | }); | |
| 51 | ||
| 52 | describe("extractStory", () => { | |
| 53 | it("takes the Writing section, removes comments and promotes the headings", () => { | |
| 54 | const lines = ["# Title", "## Notes", "note", "## Writing", "<!--more-->", "### Chapter", "Text.", "## Removed", "cut"]; | |
| 55 | assert.equal(extractStory(lines, "Writing", "file.md"), "# Chapter\nText.\n"); | |
| 56 | }); | |
| 57 | ||
| 58 | it("takes the whole file without its title when the section is null", () => { | |
| 59 | assert.equal(extractStory(["# Title", "", "## Chapter", "Text."], null, "file.md"), "# Chapter\nText.\n"); | |
| 60 | }); | |
| 61 | ||
| 62 | it("names the headings it found when the section is missing", () => { | |
| 63 | assert.throws(() => extractStory(["# Title", "## Notes"], "Writing", "file.md"), /no heading named "Writing".*"Title", "Notes"/); | |
| 64 | }); | |
| 65 | ||
| 66 | it("fails on an empty story", () => { | |
| 67 | assert.throws(() => extractStory(["## Writing", "<!-- nothing -->", "## Notes"], "Writing", "file.md"), /empty/); | |
| 68 | }); | |
| 69 | }); | |
| 70 | ||
| 71 | describe("moveOpeningBelowTitle", () => { | |
| 72 | it("moves a content warning below the story title", () => { | |
| 73 | assert.equal(moveOpeningBelowTitle("[Warning.]\n\n# Story\n\n## Scene\n\nText.\n"), "# Story\n\n[Warning.]\n\n## Scene\n\nText.\n"); | |
| 74 | }); | |
| 75 | ||
| 76 | it("leaves a story that starts with its title alone", () => { | |
| 77 | assert.equal(moveOpeningBelowTitle("# Story\n\nText.\n"), "# Story\n\nText.\n"); | |
| 78 | }); | |
| 79 | }); | |
| 80 | ||
| 81 | describe("readStory", () => { | |
| 82 | it("follows the links in the Writing section, in order, and skips commented-out links", () => { | |
| 83 | const { story, files } = readStory(index, "Writing"); | |
| 84 | assert.deepEqual(files.map((file) => path.basename(file)), ["index.md", "story-1.md", "story-2.md"]); | |
| 85 | const headings = findHeadings(story.split("\n")).map((h) => `${h.level} ${h.text}`); | |
| 86 | assert.deepEqual(headings, ["1 Story one", "2 The start", "1 ~Intermission One~", "1 Story two", "2 Another start"]); | |
| 87 | // Each story's content warning sits right under its title. | |
| 88 | assert.match(story, /^# Story one\n\n\[Content Warning: peril\.\]/); | |
| 89 | assert.match(story, /# Story two\n\n\[Content Warning: more peril\.\]/); | |
| 90 | // Notes outside the Writing sections stay out. | |
| 91 | assert.doesNotMatch(story, /This was cut|cspell|Secret/); | |
| 92 | }); | |
| 93 | ||
| 94 | it("returns a story without links as it is", () => { | |
| 95 | const { story, files } = readStory(path.join(fixtures, "story-2.md"), "Writing"); | |
| 96 | assert.equal(files.length, 1); | |
| 97 | assert.match(story, /^\[Content Warning: more peril\.\]\n\n# Story two/); | |
| 98 | }); | |
| 99 | }); | |
| 100 | ||
| 101 | describe("readExtraPages", () => { | |
| 102 | it("reads front pages with their roles", () => { | |
| 103 | const [dedication, warnings] = readExtraPages(index, ["Dedication", "content warnings"], "front"); | |
| 104 | assert.deepEqual(dedication, { | |
| 105 | title: "Dedication", body: "To *everyone*.", epubType: "dedication", listed: false, showTitle: false, textClass: "dedication", | |
| 106 | }); | |
| 107 | assert.equal(warnings?.title, "Content Warnings"); | |
| 108 | assert.equal(warnings?.epubType, "preface"); | |
| 109 | }); | |
| 110 | ||
| 111 | it("reads back pages as appendices, with the public section and not the notes", () => { | |
| 112 | const [characters] = readExtraPages(index, ["Characters"], "back"); | |
| 113 | assert.equal(characters?.epubType, "appendix"); | |
| 114 | assert.equal(characters?.body, "## Hero\n\nThe one the stories are about."); | |
| 115 | }); | |
| 116 | ||
| 117 | it("fails on a name that is not in the file", () => { | |
| 118 | assert.throws(() => readExtraPages(index, ["Map"], "back"), /no heading named "Map", which is listed in "back"/); | |
| 119 | }); | |
| 120 | }); | |
| 121 | ||
| 122 | describe("assemble", () => { | |
| 123 | const story = "# Story\n\n## Scene\n\nText.\n"; | |
| 124 | const dedication = { title: "Dedication", body: "To you.", epubType: "dedication", listed: false, showTitle: false, textClass: "dedication" }; | |
| 125 | const guide = { title: "Guide", body: "People.", epubType: "appendix", listed: true, showTitle: true }; | |
| 126 | ||
| 127 | it("returns the story unchanged when there are no extra pages", () => { | |
| 128 | assert.equal(assemble(story, [], [], "docx", false, "Book"), "# Story\n\n## Scene\n\nText.\n"); | |
| 129 | assert.equal(assemble(story, [], [], "html", false, "Book"), "# Story\n\n## Scene\n\nText.\n"); | |
| 130 | }); | |
| 131 | ||
| 132 | it("puts a flowing epub under one heading with the book title", () => { | |
| 133 | assert.equal(assemble(story, [], [], "epub", false, "Book"), "# Book {.unnumbered}\n\n## Story\n\n### Scene\n\nText.\n"); | |
| 134 | assert.equal(assemble(story, [], [], "epub", true, "Book"), "# Story\n\n## Scene\n\nText.\n"); | |
| 135 | }); | |
| 136 | ||
| 137 | it("gives each extra page its own level 1 heading in the epub", () => { | |
| 138 | const markdown = assemble(story, [dedication], [guide], "epub", false, "Book"); | |
| 139 | assert.equal(markdown, [ | |
| 140 | "# Dedication {.unlisted .hidden-title epub:type=dedication}", | |
| 141 | '::: {.dedication custom-style="Dedication"}\nTo you.\n:::', | |
| 142 | "# Book {.unnumbered}", | |
| 143 | "## Story\n\n### Scene\n\nText.", | |
| 144 | "# Guide {epub:type=appendix}", | |
| 145 | "People.", | |
| 146 | ].join("\n\n") + "\n"); | |
| 147 | }); | |
| 148 | ||
| 149 | it("puts page breaks before the extra pages and the story in the docx, and leaves out a hidden heading", () => { | |
| 150 | const pageBreak = '```{=openxml}\n<w:p><w:r><w:br w:type="page"/></w:r></w:p>\n```'; | |
| 151 | const markdown = assemble(story, [dedication], [guide], "docx", false, "Book"); | |
| 152 | assert.equal(markdown, [ | |
| 153 | pageBreak, | |
| 154 | '::: {.dedication custom-style="Dedication"}\nTo you.\n:::', | |
| 155 | pageBreak, | |
| 156 | "# Story\n\n## Scene\n\nText.", | |
| 157 | pageBreak, | |
| 158 | "# Guide", | |
| 159 | "People.", | |
| 160 | ].join("\n\n") + "\n"); | |
| 161 | }); | |
| 162 | ||
| 163 | it("uses a div as the page break in html", () => { | |
| 164 | const markdown = assemble(story, [], [guide], "html", false, "Book"); | |
| 165 | assert.equal(markdown, '# Story\n\n## Scene\n\nText.\n\n<div class="page-break"></div>\n\n# Guide\n\nPeople.\n'); | |
| 166 | }); | |
| 167 | }); |