auto-print / files / src / manuscript.ts
manuscript.ts
a6d0aed2 2026-10-24 Pavel Safronov | 1 | // 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 | ||
| 7 | import fs from "node:fs"; | |
| 8 | import path from "node:path"; | |
| 9 | import { displayPath as labelOf, fail } from "./util.ts"; | |
| 10 | ||
| 11 | export 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. */ | |
| 18 | export 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 | ||
| 36 | export 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 | */ | |
| 48 | export 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 "#". */ | |
| 64 | export 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`. */ | |
| 73 | export 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 | */ | |
| 88 | export 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 | */ | |
| 114 | export 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)". */ | |
| 124 | const 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 | */ | |
| 134 | export 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. */ | |
| 153 | export 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. */ | |
| 165 | export 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 | */ | |
| 175 | export 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 | ||
| 180 | export 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 | ||
| 195 | export 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 | */ | |
| 201 | const 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. */ | |
| 208 | export 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 | */ | |
| 218 | export 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 | ||
| 241 | function capitalize(text: string): string { | |
| 242 | return text.charAt(0).toUpperCase() + text.slice(1); | |
| 243 | } |