fuzzyworld

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

manuscript.test.ts

7.1 KB · history · blame

1import assert from "node:assert/strict";
2import path from "node:path";
3import { describe, it } from "node:test";
4import { fileURLToPath } from "node:url";
5import {
6 assemble,
7 extractStory,
8 findHeadings,
9 findSection,
10 moveOpeningBelowTitle,
11 readExtraPages,
12 readStory,
13 shiftHeadings,
14} from "../src/manuscript.ts";
15
16const fixtures = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "collection");
17const index = path.join(fixtures, "index.md");
18
19describe("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
29describe("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
45describe("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
52describe("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
71describe("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
81describe("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
101describe("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
122describe("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});