auto-print / files / src / manuscript.ts
manuscript.ts
| 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 | } |