fuzzyworld

auto-print / files / src / manuscript.ts

manuscript.ts

Blame: the commit that last changed each line · file · history

a6d0aed2 2026-10-24 Pavel Safronov1// Turns manuscript files into the markdown that pandoc converts.
2//
3// Manuscripts hold notes, spelling lists and removed writing next to the story.
4// These functions pick out the story and the extra pages, and put them together.
5// They only read files. Nothing here runs pandoc.
6
7import fs from "node:fs";
8import path from "node:path";
9import { displayPath as labelOf, fail } from "./util.ts";
10
11export interface Heading {
12 line: number;
13 level: number;
14 text: string;
15}
16
17/** Finds ATX headings ("## Like this"), ignoring anything inside fenced code blocks. */
18export function findHeadings(lines: string[]): Heading[] {
19 const headings: Heading[] = [];
20 let fence: string | null = null;
21 lines.forEach((line, index) => {
22 const fenceMatch = /^\s{0,3}(```+|~~~+)/.exec(line);
23 if (fenceMatch) {
24 const marker = fenceMatch[1]![0]!;
25 if (fence === null) fence = marker;
26 else if (fence === marker) fence = null;
27 return;
28 }
29 if (fence !== null) return;
30 const match = /^(#{1,6})\s+(.*?)\s*$/.exec(line);
31 if (match) headings.push({ line: index, level: match[1]!.length, text: match[2]! });
32 });
33 return headings;
34}
35
36export function readLines(filePath: string): string[] {
37 return fs.readFileSync(filePath, "utf8").replace(/\r\n/g, "\n").split("\n");
38}
39
40/**
41 * Finds the section under the heading with the given text, ignoring letter case.
42 * Returns the heading text as written in the file, and the lines under it,
43 * up to the next heading of the same or a higher level.
44 *
45 * If the name is used at several heading levels, the shallowest one wins.
46 * So a "## Characters" section is chosen over "### Characters" notes inside "## Info".
47 */
48export function findSection(lines: string[], name: string, sourceLabel: string): { title: string; lines: string[] } | null {
49 const headings = findHeadings(lines);
50 const wanted = name.trim().toLowerCase();
51 const matches = headings.filter((h) => h.text.toLowerCase() === wanted);
52 if (matches.length === 0) return null;
53 const level = Math.min(...matches.map((h) => h.level));
54 const shallowest = matches.filter((h) => h.level === level);
55 if (shallowest.length > 1) {
56 fail(`${sourceLabel}: the heading "${name}" appears ${shallowest.length} times at the same level. Rename one of them.`);
57 }
58 const start = shallowest[0]!;
59 const end = headings.find((h) => h.line > start.line && h.level <= start.level);
60 return { title: start.text, lines: lines.slice(start.line + 1, end ? end.line : lines.length) };
61}
62
63/** Moves every heading by `by` levels: 1 turns "#" into "##", -1 turns "##" into "#". */
64export function shiftHeadings(lines: string[], by: number): string[] {
65 const shifted = [...lines];
66 for (const heading of findHeadings(lines)) {
67 shifted[heading.line] = by >= 0 ? "#".repeat(by) + shifted[heading.line] : shifted[heading.line]!.slice(-by);
68 }
69 return shifted;
70}
71
72/** Removes HTML comments and moves the headings so that the shallowest one is at `topLevel`. */
73export function tidySection(lines: string[], topLevel: number): string {
74 // Remove HTML comments, such as a blog's <!--more--> excerpt marker.
75 lines = lines.join("\n").replace(/<!--[\s\S]*?-->/g, "").split("\n");
76 const headings = findHeadings(lines);
77 if (headings.length > 0) lines = shiftHeadings(lines, topLevel - Math.min(...headings.map((h) => h.level)));
78 return lines.join("\n").trim();
79}
80
81/**
82 * Pulls the story out of a manuscript.
83 *
84 * The story is the content under one heading (normally "## Writing"). That
85 * content is returned with its headings promoted so the top ones are level 1,
86 * which is what pandoc treats as chapters.
87 */
88export function extractStory(lines: string[], section: string | null, sourceLabel: string): string {
89 if (section === null) {
90 // Whole file. Drop a leading "# Title" line, because the title comes from the config.
91 const first = findHeadings(lines)[0];
92 if (first && first.level === 1) lines = lines.slice(first.line + 1);
93 } else {
94 const found = findSection(lines, section, sourceLabel);
95 if (!found) {
96 const available = findHeadings(lines).filter((h) => h.level <= 2).map((h) => `"${h.text}"`).join(", ");
97 fail(`${sourceLabel}: no heading named "${section}". Top-level headings found: ${available || "none"}.`);
98 }
99 lines = found.lines;
100 }
101
102 const story = tidySection(lines, 1);
103 if (story.length === 0) fail(`${sourceLabel}: the story section is empty.`);
104 return story + "\n";
105}
106
107/**
108 * Moves the text before a story's first heading, such as a content warning,
109 * to just below that heading.
110 *
111 * Used when several stories are joined into one book. Otherwise the opening text
112 * of a story would end up at the end of the previous story's last chapter.
113 */
114export function moveOpeningBelowTitle(story: string): string {
115 const lines = story.split("\n");
116 const first = findHeadings(lines)[0];
117 if (!first || first.line === 0) return story;
118 const opening = lines.slice(0, first.line).join("\n").trim();
119 const rest = lines.slice(first.line + 1).join("\n").trim();
120 return `${lines[first.line]}\n\n${opening}\n\n${rest}\n`;
121}
122
123/** A line that holds only a link to a markdown file, like "[Story one](./story-1.md)". */
124const storyLink = /^\[[^\]]*\]\(<?([^()<>]+\.md)>?\)$/;
125
126/**
127 * Reads the story of a book.
128 *
129 * If the story section holds only links to other markdown files, one per line,
130 * each linked file's own story section is read instead, in the order of the links.
131 * A link inside an HTML comment is ignored, because comments are removed first.
132 * Returns the story and every file that was read, so images can be found next to them.
133 */
134export function readStory(sourcePath: string, section: string | null): { story: string; files: string[] } {
135 const label = labelOf(sourcePath);
136 const story = extractStory(readLines(sourcePath), section, label);
137 const lines = story.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
138 const links = lines.map((line) => storyLink.exec(line));
139 if (!links.some((link) => link !== null)) return { story, files: [sourcePath] };
140 if (!links.every((link) => link !== null)) {
141 fail(`${label}: the story section mixes links to story files with other text. Use only links, one per line, or only story text.`);
142 }
143
144 const files = links.map((link) => path.resolve(path.dirname(sourcePath), decodeURIComponent(link![1]!)));
145 const stories = files.map((file) => {
146 if (!fs.existsSync(file)) fail(`${label}: the linked story file was not found: ${file}`);
147 return moveOpeningBelowTitle(extractStory(readLines(file), section, labelOf(file)));
148 });
149 return { story: stories.join("\n"), files: [sourcePath, ...files] };
150}
151
152/** How a page before or after the story is marked and shown. */
153export interface PageRole {
154 /** The page's role in the epub. pandoc only puts some roles in the front or the back, see pageRoles. */
155 epubType: string;
156 /** false: the page is left out of the table of contents. */
157 listed: boolean;
158 /** false: the heading is not shown. The epub still needs it to start a new page. */
159 showTitle: boolean;
160 /** A class for the page's text, styled in print.css, the epub stylesheet and the docx reference doc. */
161 textClass?: string;
162}
163
164/** A page before or after the story, such as a dedication or a character guide. */
165export interface ExtraPage extends PageRole {
166 title: string;
167 body: string;
168}
169
170/**
171 * Front pages that are treated differently from the others, by heading name.
172 * Other front pages are marked as a preface, and every back page as an appendix.
173 * These are roles that pandoc puts in the front or the back of an epub.
174 */
175export const pageRoles: Record<string, PageRole> = {
176 dedication: { epubType: "dedication", listed: false, showTitle: false, textClass: "dedication" },
177 copyright: { epubType: "copyright-page", listed: false, showTitle: true },
178};
179
180export function readExtraPages(sourcePath: string, names: string[], position: "front" | "back"): ExtraPage[] {
181 const label = labelOf(sourcePath);
182 const lines = readLines(sourcePath);
183 return names.map((name) => {
184 const found = findSection(lines, name, label);
185 if (!found) fail(`${label}: no heading named "${name}", which is listed in "${position}" in the config.`);
186 const body = tidySection(found.lines, 2);
187 if (body.length === 0) fail(`${label}: the "${found.title}" section is empty.`);
188 const role: PageRole = position === "front"
189 ? pageRoles[found.title.toLowerCase()] ?? { epubType: "preface", listed: true, showTitle: true }
190 : { epubType: "appendix", listed: true, showTitle: true };
191 return { title: found.title, body, ...role };
192 });
193}
194
195export type Target = "epub" | "docx" | "html";
196
197/**
198 * A page break, written in the markup that each target passes through untouched.
199 * The epub needs none, because pandoc starts a new file at every level 1 heading.
200 */
201const pageBreaks: Record<Target, string> = {
202 epub: "",
203 docx: '```{=openxml}\n<w:p><w:r><w:br w:type="page"/></w:r></w:p>\n```',
204 html: '<div class="page-break"></div>',
205};
206
207/** The input format for pandoc. assemble() writes markup that needs these extensions. */
208export const pandocFormat = "gfm+smart+attributes+raw_attribute+fenced_divs";
209
210/**
211 * Builds the markdown that pandoc converts, for one target.
212 *
213 * Every extra page starts on a new page. In the epub, an extra page is a level 1
214 * heading, which pandoc puts in a file of its own. When the epub flows continuously
215 * (chapterBreaks false), the story's headings are moved down one level, under one
216 * level 1 heading with the book title, so the whole story stays in one file.
217 */
218export function assemble(story: string, front: ExtraPage[], back: ExtraPage[], target: Target, chapterBreaks: boolean, title: string): string {
219 const parts: string[] = [];
220 const pageBreak = pageBreaks[target];
221 const addPage = (page: ExtraPage) => {
222 if (pageBreak) parts.push(pageBreak);
223 const classes = [!page.listed && ".unlisted", !page.showTitle && ".hidden-title"].filter(Boolean).join(" ");
224 if (target === "epub") parts.push(`# ${page.title} {${classes ? classes + " " : ""}epub:type=${page.epubType}}`);
225 else if (page.showTitle) parts.push(`# ${page.title}${page.listed ? "" : " {.unlisted}"}`);
226 // custom-style names the paragraph style in the docx. The other targets use the class.
227 parts.push(page.textClass ? `::: {.${page.textClass} custom-style="${capitalize(page.textClass)}"}\n${page.body}\n:::` : page.body);
228 };
229
230 front.forEach(addPage);
231 if (front.length > 0 && pageBreak) parts.push(pageBreak);
232 if (target === "epub" && !chapterBreaks) {
233 parts.push(`# ${title} {.unnumbered}`, shiftHeadings(story.trim().split("\n"), 1).join("\n"));
234 } else {
235 parts.push(story.trim());
236 }
237 back.forEach(addPage);
238 return parts.join("\n\n") + "\n";
239}
240
241function capitalize(text: string): string {
242 return text.charAt(0).toUpperCase() + text.slice(1);
243}