auto-print / commit
Initial version of auto-print as a package
.gitignore | 3 + .gitlab-ci.yml | 16 ++ CHANGELOG.md | 18 ++ LICENSE | 21 ++ README.md | 257 ++++++++++++++++++++++ assets/epub.css | 13 ++ assets/print.css | 113 ++++++++++ assets/reference.docx | Bin 0 -> 11014 bytes decisions.md | 50 +++++ package-lock.json | 415 ++++++++++++++++++++++++++++++++++++ package.json | 47 ++++ scripts/make-reference-docx.sh | 31 +++ src/build.ts | 267 +++++++++++++++++++++++ src/cli.ts | 75 +++++++ src/config.ts | 132 ++++++++++++ src/manuscript.ts | 243 +++++++++++++++++++++ src/util.ts | 32 +++ test/config.test.ts | 62 ++++++ test/fixtures/collection/index.md | 32 +++ test/fixtures/collection/story-1.md | 24 +++ test/fixtures/collection/story-2.md | 15 ++ test/manuscript.test.ts | 167 +++++++++++++++ test/pandoc.test.ts | 100 +++++++++ tsconfig.build.json | 12 ++ tsconfig.json | 22 ++ 25 files changed, 2167 insertions(+) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3bdd52e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.DS_Store diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..536fe15 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,16 @@ +image: node:22 + +stages: [test] + +test: + stage: test + before_script: + # The same pandoc version as the tests were written against. Debian's own package is too old (2.x). + - curl -fsSL -o /tmp/pandoc.deb https://github.com/jgm/pandoc/releases/download/3.6.1/pandoc-3.6.1-1-amd64.deb + - dpkg -i /tmp/pandoc.deb + - pandoc --version | head -1 + script: + - npm ci + - npm run typecheck + - npm test + - npm run build diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..81761e4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +All notable changes to auto-print are written down here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [0.1.0] - 2026-10-24 + +The first version as a package. Before this, auto-print was a single script inside the author's blog folder. + +### Added + +- Builds an epub, a docx, a pdf and a Kindle kpf for each book in a config file. +- Publishes only the story section of a manuscript (`## Writing` by default), and leaves notes out. +- Collections of stories: a book's story section can be a list of links to story files. +- Extra pages from named sections, before or after the story (`front` and `back`), each on its own page. +- A dedication page with the typical look: centered, in italics, lower on the page, with no visible heading. +- `chapterBreaks` setting for the epub and kpf. +- `--config` option. All paths in the config file are relative to its folder. `outDir` sets the output folder. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..88e06c2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Pavel Safronov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6a78f98 --- /dev/null +++ b/README.md @@ -0,0 +1,257 @@ +# auto-print + +Turns markdown manuscripts into books: an epub, a docx, a pdf and a Kindle kpf for each one. + +It is made for writers who keep their drafts in markdown, next to their notes. +A manuscript file can hold a spelling list, character notes and cut scenes. auto-print publishes only the story, +plus the extra pages you name, such as a dedication or a character guide. + +- The source files are never changed. +- One config file lists the books. One command builds all of them. +- Under the hood, [pandoc](https://pandoc.org) does the conversion. A Chromium-based browser prints the pdf, and Amazon's Kindle Previewer makes the kpf. + +## Requirements + +- **Node 22.18 or newer.** +- **pandoc 3.** It converts the markdown to epub, docx and html. Tested with pandoc 3.6.1. macOS: `brew install pandoc`. +- **Google Chrome**, or Chromium, Edge or Brave, for the pdf and for generated covers. + Set the `CHROME_PATH` environment variable to use a browser in a non-standard location. +- **Kindle Previewer 4**, only for the kpf. macOS: `brew install --cask kindle-previewer`. + Set the `KINDLE_PREVIEWER_PATH` environment variable to use a copy in a non-standard location. + +If a tool is missing, only the formats that need it fail. The other files are still written. + +## Install + +Install the `auto-print` command from GitLab: + +```sh +npm install --global git+https://gitlab.com/PavelSafronov/auto-print.git +``` + +Or run it from a clone, with nothing installed. Node runs the TypeScript source directly: + +```sh +git clone https://gitlab.com/PavelSafronov/auto-print.git +node auto-print/src/cli.ts --config path/to/books.json +``` + +## Usage + +```sh +auto-print # build every book in ./books.json +auto-print my-novel # build only the named book(s) +auto-print --config books/books.json +auto-print --list # list the books, build nothing +``` + +The epub, docx and pdf take a few seconds in total. The kpf takes about 20 seconds per book. +Each run overwrites the previous output files. + +## The config file + +```json +{ + "outDir": "out", + "defaults": { + "author": "Your Name", + "formats": ["epub", "docx", "pdf", "kpf"] + }, + "books": [ + { + "slug": "my-novel", + "title": "My Novel", + "source": "drafts/my-novel.md", + "cover": "covers/my-novel.jpg" + } + ] +} +``` + +Every path in the config file is relative to the folder of the config file. +The files of each book are written to `<outDir>/<slug>/`, for example `out/my-novel/my-novel.epub`. + +| Top-level field | Required | Meaning | +| --------------- | -------- | -------------------------------------------------------------------- | +| `books` | yes | The list of books. | +| `defaults` | no | Settings for every book. A book's own setting wins over the default. | +| `outDir` | no | Output folder. Default `"auto-print-out"`. | + +| Book field | Required | Meaning | +| --------------- | -------- | ----------------------------------------------------------------------------------------- | +| `slug` | yes | Name of the output folder and files. Use lowercase words joined by dashes. | +| `title` | yes | Book title, used on the cover, the title page and in the file metadata. | +| `source` | yes | The markdown file. Its story section can link to other files, see "Collections of stories". | +| `author` | yes | In the book or in `defaults`. | +| `cover` | no | Cover image for the epub and kpf. Without it, a text cover is generated: title, "by", author. | +| `section` | no | Heading that holds the story. Default `"Writing"`. Use `null` to take the whole file. | +| `language` | no | Default `"en-US"`. | +| `formats` | no | Default `["epub", "docx", "pdf", "kpf"]`. List fewer to skip some. | +| `chapterBreaks` | no | epub and kpf only. Default `false`. See "Chapter breaks in the epub". | +| `front` | no | Headings whose sections become pages before the story. See "Extra pages". | +| `back` | no | Headings whose sections become pages after the story. See "Extra pages". | + +## What happens to a manuscript + +1. auto-print finds the heading named `Writing` (or the `section` you set). Any heading level, any letter case. + If the name is used at several levels, the shallowest heading wins. +2. It takes everything under that heading, up to the next heading of the same or a higher level. + Everything else in the file, such as notes or removed scenes, is left out. +3. It removes HTML comments, such as a blog's `<!--more-->` marker. +4. It promotes the headings, so the story's top headings become chapters. If the story's chapters are `###` headings, they become level 1. +5. Text before the first chapter heading, such as a content warning, is kept as an opening section. +6. Images are found relative to the file they are written in, so `` works. + +A manuscript might look like this: + +```markdown +# My Novel + +## Notes + +Things only the author should see. + +## Writing + +[Content warning: peril.] + +### Chapter One + +#### The morning + +The hero woke up. + +## Removed writing + +A scene that was cut. +``` + +## Collections of stories + +A book can be a collection of stories that each live in their own file. +The book's `source` is then an index file, and its `Writing` section holds only links to the story files, one per line: + +```markdown +## Writing + +[The first story](./story-1.md) + +[The second story](./story-2.md) + +<!-- [The third story](./story-3.md) --> +``` + +- auto-print reads the `Writing` section of each linked file, in the order of the links. +- A link inside an HTML comment (`<!-- -->`) is left out. Use this for a story that is not ready. +- A `Writing` section that mixes links with other text stops the build with an error. + +Each story file is handled with the rules in "What happens to a manuscript", with one difference: +the text before a story's first heading, such as its content warning, is moved to just below that heading. +Without this move, a story's content warning would appear at the end of the previous story. + +## Extra pages + +Pages such as a dedication, a content warnings page, a character guide or a map are sections in the book's source file. +The `front` and `back` lists name the headings of those sections, in the order they appear in the book. + +```json +{ + "slug": "my-collection", + "title": "My Collection", + "source": "my-collection.md", + "front": ["Dedication", "Content Warnings"], + "back": ["Characters", "Map"] +} +``` + +```markdown +## Dedication + +To my city. + +## Characters + +### The hero + +The one the stories are about. + +## Map + + +``` + +Only the listed sections are published. Other sections, such as notes, stay private. + +- Headings match ignoring letter case. If a name is used at several heading levels, the shallowest heading wins. + So a `## Characters` page is published, and `### Characters` notes inside a `## Notes` section are not. +- Two headings with the same name at the same level stop the build with an error. So does a listed name that is not in the file. +- The section heading becomes the page title. Headings inside the section become sub-headings of the page. +- Every extra page starts on a new page, in every format. +- A section named `Dedication` has the typical look of a dedication: centered, in italics, about a third of the way down the page. + Its heading is not shown, and it is left out of the table of contents. +- A section named `Copyright` is left out of the table of contents. +- In the epub and the kpf, front pages are marked as front matter, and back pages as back matter (appendices). + E-readers use these marks to know where the story starts. + +## Chapter breaks in the epub + +The `chapterBreaks` setting controls how the chapters appear in the epub and the kpf. + +- `false` (the default): the story flows continuously. A chapter is only a heading in the text. +- `true`: every chapter starts on a new page. + +In both cases the chapters are listed in the table of contents. +The docx and the pdf always flow continuously. + +## Sentences on separate lines + +A manuscript can put each sentence on its own line, or write each paragraph as one long line. +Both styles produce the same book. + +- A single line break joins the two lines with a space, inside the same paragraph. +- A new paragraph starts only at an empty line. A line that holds only spaces also counts as empty. + +This works because auto-print reads the files as GitHub-flavored markdown (`gfm`), where a single line break is not a real line break. + +A line can be read as markdown formatting by mistake. Avoid these at the start or end of a line: + +| In the manuscript | What happens | +| ---------------------------------------------------------- | ----------------------------------------------- | +| A line that starts with `- `, `* `, `+ ` or `1. ` | It starts a list and ends the paragraph. Other numbers, such as `2. `, are safe. | +| A line of only `---` or `===`, directly after a text line | The line above it becomes a heading. | +| A line that starts with `#` and a space | It becomes a heading. | +| A line that starts with `>` | It becomes a block quote. | +| A line that starts with `:::` | It starts or ends a styled block. | +| Two spaces, or a `\`, at the end of a line | A real line break is kept inside the paragraph. | +| `{.word}`, `{#word}` or `{key=value}` at the end of a heading, or right after an image or link | It is read as a setting and not shown. Other braces, like `{quietly}`, are shown as written. | + +A dash such as `—` at the start of a line is safe. + +## How each format is made + +- **epub**: pandoc, with a table of contents built from the headings. + The stylesheet is pandoc's default one, plus [assets/epub.css](assets/epub.css). +- **docx**: pandoc, with [assets/reference.docx](assets/reference.docx) as the Word template. + It is pandoc's default template plus a `Dedication` paragraph style. Chapters use the `Heading 1` style. +- **pdf**: pandoc makes a standalone html page with [assets/print.css](assets/print.css), then a headless browser prints it. + The browser does not always exit by itself after printing, so auto-print waits for the pdf and then stops the browser. +- **kpf**: Kindle Previewer's command line tool converts an epub into a kpf. + auto-print builds a fresh epub for this, so the kpf works even if `epub` is not in `formats`. + +## Development + +```sh +npm install # TypeScript and Node's type definitions, for the checks below +npm test # unit tests, plus pandoc tests that are skipped when pandoc is missing +npm run typecheck +npm run build # compiles src/ to dist/, which the installed command runs +``` + +The code has no runtime dependencies. [decisions.md](decisions.md) records why it works the way it does. + +`assets/reference.docx` is made by [scripts/make-reference-docx.sh](scripts/make-reference-docx.sh). +Run it again to change the Word styles. + +## License + +[MIT](LICENSE) diff --git a/assets/epub.css b/assets/epub.css new file mode 100644 index 0000000..af26927 --- /dev/null +++ b/assets/epub.css @@ -0,0 +1,13 @@ +/* auto-print's additions to pandoc's default epub stylesheet. */ + +/* Pages whose heading is only there to start a new page, such as the dedication. */ +section.hidden-title > h1 { + display: none; +} + +/* The dedication: centered, in italics, about a third of the way down the page. */ +div.dedication { + text-align: center; + font-style: italic; + margin-top: 33%; +} diff --git a/assets/print.css b/assets/print.css new file mode 100644 index 0000000..569d615 --- /dev/null +++ b/assets/print.css @@ -0,0 +1,113 @@ +/* Styles for the PDF output. Chrome prints the pandoc HTML with these rules. */ + +@page { + size: Letter; + margin: 1in; + @bottom-center { + content: counter(page); + font-family: Georgia, "Times New Roman", serif; + font-size: 10pt; + color: #555; + } +} + +/* No page number on the title page. */ +@page :first { + @bottom-center { + content: none; + } +} + +html, +body { + max-width: none; + margin: 0; + padding: 0; + background: white; + color: black; +} + +body { + font-family: Georgia, "Times New Roman", serif; + font-size: 12pt; + line-height: 1.5; + hyphens: manual; +} + +/* Title page: title and author, alone on the first page. */ +#title-block-header { + text-align: center; + padding-top: 3in; + break-after: page; +} + +#title-block-header h1.title { + font-size: 32pt; + margin: 0 0 0.5in 0; +} + +#title-block-header p.author { + font-size: 16pt; +} + +#title-block-header p.date { + display: none; +} + +/* Pages before and after the story, such as a dedication, start on a new page. */ +.page-break { + break-before: page; +} + +/* The dedication: centered, in italics, about a third of the way down the page. */ +.dedication { + text-align: center; + font-style: italic; + padding-top: 2.5in; +} + +/* Chapter headings. Chapters flow continuously, they do not start new pages. */ +h1, +h2, +h3 { + font-family: Helvetica, Arial, sans-serif; + line-height: 1.25; + break-after: avoid; +} + +h1 { + font-size: 15pt; + margin: 2.2em 0 0.8em 0; +} + +h2 { + font-size: 13pt; + margin: 1.8em 0 0.6em 0; +} + +p { + margin: 0 0 0.8em 0; + orphans: 2; + widows: 2; +} + +img { + max-width: 100%; + height: auto; +} + +hr { + border: none; + text-align: center; + margin: 1.5em 0; +} + +hr::after { + content: "* * *"; +} + +/* Inline code, used in the stories for machine or ship speech. */ +code { + font-family: Menlo, Consolas, monospace; + font-size: 0.85em; +} diff --git a/assets/reference.docx b/assets/reference.docx new file mode 100644 index 0000000..17ebdcd Binary files /dev/null and b/assets/reference.docx differ diff --git a/decisions.md b/decisions.md new file mode 100644 index 0000000..7652325 --- /dev/null +++ b/decisions.md @@ -0,0 +1,50 @@ +# Decisions + +Each design or technical decision, why it was made, and when. Newest last. + +## From the blog script (October 2026) + +auto-print started as one script in the author's Dropbox-synced blog folder. These decisions were made there. + +1. **Read manuscripts as `gfm`.** It matches how GitHub, GitLab and the VS Code preview show the files. + A single line break is a soft break, so a manuscript with one sentence per line gives the same book as one with one line + per paragraph. This was checked on three full books with pandoc 3.6.1: the epub, html and docx text was identical. +2. **Publish only one section of a manuscript** (`## Writing` by default). Manuscripts keep notes, spelling lists and cut + scenes next to the story, and these must never reach a reader. +3. **The source files are never changed.** The build reads them and writes only to the output folder. +4. **Collections are an index file with links**, not a list of files in the config. The index file is where the author + already keeps the story order, so the order lives in one place. A link in an HTML comment is skipped, which is how a story + that is not ready is kept out. +5. **A story's opening text moves below its title in a collection.** Otherwise each story's content warning would land at the + end of the previous story. +6. **Extra pages are sections of the source file, named in the config.** The author did not want a separate file per page. + Naming them in the config, instead of recognising fixed names, means a section is never published by accident. +7. **The shallowest heading wins when a name is used at several levels.** A real case: an `## Info` section held + `### Characters` notes with spoilers, next to a reader-facing `## Characters` page. Two headings with the same name at the + same level stop the build, because there is no safe way to choose. +8. **The epub's flowing layout is built in the markdown, not with pandoc's `--shift-heading-level-by`.** Extra pages need + their own epub files (level 1 headings) while the story stays in one file. Doing the shift in the markdown, under a level 1 + heading with the book title, gave byte-identical epub files to pandoc's own shift for books without extra pages. +9. **Only pandoc's known epub roles are used.** pandoc puts `dedication`, `copyright-page` and `preface` in the front matter + and `appendix` in the back matter. An unknown role is treated as story text, so front pages default to `preface` and back + pages to `appendix`. +10. **pandoc extensions `attributes`, `raw_attribute` and `fenced_divs` are on.** The assembled markdown uses them to mark + the extra pages, the page breaks and the dedication. They were turned on only after checking that none of the existing + manuscripts contained `{` or a line starting with `:::`, and that the books came out identical. +11. **Page breaks are written per format:** a raw Word page break in the docx, a `page-break` div in the html for the pdf, + and nothing in the epub, where a new file is a new page. + +## As a package (2026-10-24) + +12. **No runtime dependencies.** Only Node's built-in modules. The external tools (pandoc, a browser, Kindle Previewer) are + run as commands. Fewer dependencies means less upkeep. +13. **TypeScript that Node can run directly** (`erasableSyntaxOnly`, `.ts` imports). The author's blog runs the source from a + clone with `node src/cli.ts`, so there is no build step to forget. Installed packages live in `node_modules`, where Node + does not run `.ts` files, so the published package runs `dist/`, compiled by `tsc` (`rewriteRelativeImportExtensions`). +14. **Every path in the config file is relative to the config file's folder.** One rule for `source`, `cover` and `outDir`. +15. **`author` has no built-in default.** The blog script defaulted to its own author, which would be wrong for anyone else. +16. **The docx dedication style comes from a reference doc** (`assets/reference.docx`). A Word paragraph style is the only way + pandoc can center a paragraph in a docx. The file is pandoc's default template plus one style, made by + `scripts/make-reference-docx.sh`. For a book without a dedication, the only change in the docx is one unused style. +17. **Tests use Node's built-in test runner**, again to avoid dependencies. The pandoc tests check pandoc's syntax tree of the + assembled markdown, and skip themselves when pandoc is not installed. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f6534fc --- /dev/null +++ b/package-lock.json @@ -0,0 +1,415 @@ +{ + "name": "auto-print", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "auto-print", + "version": "0.1.0", + "license": "MIT", + "bin": { + "auto-print": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "22.20.5", + "typescript": "7.0.2" + }, + "engines": { + "node": ">=22.18" + } + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..bd9823e --- /dev/null +++ b/package.json @@ -0,0 +1,47 @@ +{ + "name": "auto-print", + "version": "0.1.0", + "description": "Turns markdown manuscripts into epub, docx, pdf and Kindle kpf books, with pandoc.", + "type": "module", + "bin": { + "auto-print": "dist/cli.js" + }, + "files": [ + "dist", + "assets" + ], + "engines": { + "node": ">=22.18" + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc", + "test": "node --test \"test/**/*.test.ts\"", + "prepare": "npm run build" + }, + "keywords": [ + "markdown", + "epub", + "kindle", + "kpf", + "docx", + "pdf", + "pandoc", + "book", + "writing" + ], + "author": "Pavel Safronov", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://gitlab.com/PavelSafronov/auto-print.git" + }, + "homepage": "https://gitlab.com/PavelSafronov/auto-print", + "bugs": { + "url": "https://gitlab.com/PavelSafronov/auto-print/-/issues" + }, + "devDependencies": { + "@types/node": "22.20.5", + "typescript": "7.0.2" + } +} diff --git a/scripts/make-reference-docx.sh b/scripts/make-reference-docx.sh new file mode 100755 index 0000000..91cce69 --- /dev/null +++ b/scripts/make-reference-docx.sh @@ -0,0 +1,31 @@ +#!/bin/sh +# Makes assets/reference.docx: pandoc's default Word template, plus a "Dedication" paragraph style. +# Needs pandoc, unzip, zip and python3. Run from anywhere: scripts/make-reference-docx.sh +set -eu + +root=$(cd "$(dirname "$0")/.." && pwd) +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +pandoc --print-default-data-file reference.docx > "$work/default.docx" +mkdir "$work/files" +(cd "$work/files" && unzip -q ../default.docx) + +python3 - "$work/files/word/styles.xml" <<'EOF' +import sys +path = sys.argv[1] +styles = open(path, encoding="utf-8").read() +# Centered, in italics, with 2 inches (2880 twentieths of a point) of space above. +dedication = ( + '<w:style w:type="paragraph" w:customStyle="1" w:styleId="Dedication"><w:name w:val="Dedication" />' + '<w:basedOn w:val="BodyText" /><w:qFormat />' + '<w:pPr><w:spacing w:before="2880" /><w:jc w:val="center" /></w:pPr><w:rPr><w:i /><w:iCs /></w:rPr></w:style>' +) +assert styles.count("</w:styles>") == 1 and 'w:styleId="Dedication"' not in styles +open(path, "w", encoding="utf-8").write(styles.replace("</w:styles>", dedication + "</w:styles>")) +EOF + +# [Content_Types].xml goes first, as Word expects. -D leaves out folder entries. +rm -f "$root/assets/reference.docx" +(cd "$work/files" && zip -X -D -q "$root/assets/reference.docx" '[Content_Types].xml' && zip -X -D -q -r "$root/assets/reference.docx" . -x '[Content_Types].xml') +echo "Wrote $root/assets/reference.docx" diff --git a/src/build.ts b/src/build.ts new file mode 100644 index 0000000..6104ace --- /dev/null +++ b/src/build.ts @@ -0,0 +1,267 @@ +// Builds the output files of one book, by running pandoc, a browser and Kindle Previewer. + +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import type { Book } from "./config.ts"; +import { assemble, pandocFormat, readExtraPages, readStory, type Target } from "./manuscript.ts"; +import { displayPath, fail, run, sleep } from "./util.ts"; + +/** Stylesheets and the docx reference doc. The folder sits next to src/ and dist/. */ +export const assetsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "assets"); + +const browserCandidates = [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", + "/usr/bin/google-chrome", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", +]; + +const kindlePreviewerCandidates = [ + "/Applications/Kindle Previewer 4.app/Contents/MacOS/KindlePreviewer4CLI", +]; + +function findBrowser(): string { + const fromEnv = process.env.CHROME_PATH; + if (fromEnv) return fromEnv; + const found = browserCandidates.find((candidate) => fs.existsSync(candidate)); + if (!found) { + fail("No Chromium-based browser found (looked for Chrome, Chromium, Edge, Brave). Install one, or set CHROME_PATH."); + } + return found; +} + +/** + * Runs the browser headless against a local HTML file, with a throwaway profile. + * + * Headless Chrome often keeps running after it has written its output file. + * So this does not wait for the browser to exit. It waits until the output + * file exists and has stopped growing, and then stops the browser itself. + */ +async function runBrowser(tempDir: string, htmlPath: string, browserArgs: string[], expectedOutput: string): Promise<void> { + const browser = findBrowser(); + const child = spawn(browser, [ + "--headless", + "--disable-gpu", + "--no-first-run", + "--no-default-browser-check", + "--disable-breakpad", + "--disable-background-networking", + "--disable-component-update", + `--user-data-dir=${path.join(tempDir, "browser-profile")}`, + ...browserArgs, + pathToFileURL(htmlPath).href, + ], { stdio: "ignore", detached: true }); + + let exited = false; + let spawnError: Error | null = null; + child.on("exit", () => { exited = true; }); + child.on("error", (error) => { spawnError = error; exited = true; }); + + const deadline = Date.now() + 180_000; + let lastSize = -1; + let stableChecks = 0; + while (!exited && Date.now() < deadline) { + await sleep(250); + const size = fs.existsSync(expectedOutput) ? fs.statSync(expectedOutput).size : 0; + stableChecks = size > 0 && size === lastSize ? stableChecks + 1 : 0; + lastSize = size; + if (stableChecks >= 4) break; + } + if (!exited) { + // The browser runs in its own process group, so this also stops its helper processes. + try { + process.kill(-child.pid!, "SIGKILL"); + } catch { + child.kill("SIGKILL"); + } + while (!exited) await sleep(50); + // Give helper processes a moment to disappear, so the temp folder can be removed cleanly. + await sleep(300); + } + + if (spawnError) fail(`${path.basename(browser)} could not be started: ${(spawnError as Error).message}`); + if (!fs.existsSync(expectedOutput) || fs.statSync(expectedOutput).size === 0) { + fail(`${path.basename(browser)} did not produce ${path.basename(expectedOutput)}.`); + } +} + +function escapeHtml(text: string): string { + return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); +} + +/** Makes a plain text cover: "Title / by / Author", in large bold letters on white. */ +async function generateCover(tempDir: string, title: string, author: string): Promise<string> { + const width = 1200; + const height = 1700; + const htmlPath = path.join(tempDir, "cover.html"); + const pngPath = path.join(tempDir, "cover.png"); + const html = `<!doctype html> +<html><head><meta charset="utf-8"><style> + html, body { margin: 0; width: ${width}px; height: ${height}px; background: white; overflow: hidden; } + body { display: flex; align-items: center; justify-content: center; } + #text { font-family: Helvetica, Arial, sans-serif; font-weight: bold; color: #1a1a1a; + text-align: center; line-height: 1.15; font-size: 240px; } +</style></head><body> +<div id="text">${escapeHtml(title)}<br>by<br>${escapeHtml(author)}</div> +<script> + // Shrink the text until it fits inside the cover with a margin. + const text = document.getElementById("text"); + let size = 240; + while (size > 40 && (text.scrollWidth > ${width - 160} || text.scrollHeight > ${height - 200})) { + size -= 4; + text.style.fontSize = size + "px"; + } +</script> +</body></html>`; + fs.writeFileSync(htmlPath, html); + await runBrowser(tempDir, htmlPath, [`--screenshot=${pngPath}`, `--window-size=${width},${height}`, "--hide-scrollbars"], pngPath); + return pngPath; +} + +function findKindlePreviewer(): string { + const fromEnv = process.env.KINDLE_PREVIEWER_PATH; + if (fromEnv) return fromEnv; + const found = kindlePreviewerCandidates.find((candidate) => fs.existsSync(candidate)); + if (!found) { + fail("Kindle Previewer 4 is not installed. On macOS, install it with: brew install --cask kindle-previewer"); + } + return found; +} + +/** + * Converts an epub into a kpf, the file format of Amazon's Kindle tools. + * + * Kindle Previewer names the output after the input file and puts it in a + * "KPF" subfolder, next to its log files. It also exits with code 0 when the + * conversion fails. So this runs it in the temp folder, looks for the kpf + * file, and copies only that file to its final place. + */ +function convertEpubToKpf(tempDir: string, epubPath: string, kpfPath: string): void { + const previewer = findKindlePreviewer(); + const workDir = path.join(tempDir, "kindle"); + fs.mkdirSync(workDir, { recursive: true }); + const output = run(previewer, [epubPath, "--convert", "--output", workDir], undefined, 600_000); + const kpfDir = path.join(workDir, "KPF"); + const produced = fs.existsSync(kpfDir) ? fs.readdirSync(kpfDir).filter((name) => name.endsWith(".kpf")) : []; + if (produced.length === 0) { + const lastLines = output.trim().split("\n").slice(-4).join("\n"); + fail(`Kindle Previewer did not produce a kpf file. Its last messages were:\n${lastLines}`); + } + fs.copyFileSync(path.join(kpfDir, produced[0]!), kpfPath); +} + +/** pandoc's own epub stylesheet, followed by auto-print's additions from assets/epub.css. */ +function writeEpubStylesheet(tempDir: string): string { + const cssPath = path.join(tempDir, "epub.css"); + const defaults = run("pandoc", ["--print-default-data-file", "epub.css"]); + fs.writeFileSync(cssPath, `${defaults}\n${fs.readFileSync(path.join(assetsDir, "epub.css"), "utf8")}`); + return cssPath; +} + +/** + * Builds every format of one book into `outDir/<slug>/`. + * A format that fails is reported, and the other formats are still built. + * Returns the number of formats that failed. + */ +export async function buildBook(book: Book, outDir: string): Promise<number> { + if (!fs.existsSync(book.source)) fail(`Source file not found: ${book.source}`); + const { story, files } = readStory(book.source, book.section); + const front = readExtraPages(book.source, book.front, "front"); + const back = readExtraPages(book.source, book.back, "back"); + const markdownFor = (target: Target) => assemble(story, front, back, target, book.chapterBreaks, book.title); + + const bookDir = path.join(outDir, book.slug); + fs.mkdirSync(bookDir, { recursive: true }); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `auto-print-${book.slug}-`)); + let failures = 0; + + // gfm matches how GitHub, GitLab and the VS Code preview show markdown files. + // "smart" turns straight quotes and dashes into typographic ones. + // The other extensions let assemble() mark the extra pages and their page breaks. + const commonArgs = [ + `--from=${pandocFormat}`, + `--metadata=title:${book.title}`, + `--metadata=author:${book.author}`, + `--metadata=lang:${book.language}`, + // Lets images in the story be found relative to the files they are written in. + `--resource-path=${[...new Set(files.map((file) => path.dirname(file)))].join(path.delimiter)}`, + ]; + + const buildEpub = async (epubPath: string): Promise<void> => { + let coverPath: string; + if (book.cover) { + coverPath = book.cover; + if (!fs.existsSync(coverPath)) fail(`Cover image not found: ${coverPath}`); + } else { + coverPath = await generateCover(tempDir, book.title, book.author); + } + run("pandoc", [ + ...commonArgs, + "--to=epub3", + // pandoc starts a new file, and so a new page, at every level 1 heading. + // assemble() decides which headings are at level 1. See chapterBreaks. + "--split-level=1", + `--css=${writeEpubStylesheet(tempDir)}`, + `--metadata=identifier:${book.slug}`, + `--metadata=date:${new Date().toISOString().slice(0, 10)}`, + `--epub-cover-image=${coverPath}`, + `--output=${epubPath}`, + ], markdownFor("epub")); + }; + + // The kpf is made from an epub. This epub lives in the temp folder, so the + // kpf can be built even when "epub" itself is not in the list of formats. + let epubForKindle: string | null = null; + + try { + for (const format of book.formats) { + const outPath = path.join(bookDir, `${book.slug}.${format}`); + try { + if (format === "epub") { + await buildEpub(outPath); + } else if (format === "docx") { + run("pandoc", [ + ...commonArgs, + "--to=docx", + // pandoc's default Word styles, plus a style for the dedication. + `--reference-doc=${path.join(assetsDir, "reference.docx")}`, + `--output=${outPath}`, + ], markdownFor("docx")); + } else if (format === "pdf") { + const htmlPath = path.join(tempDir, `${book.slug}.html`); + run("pandoc", [ + ...commonArgs, + "--to=html5", + "--standalone", + "--embed-resources", + `--css=${path.join(assetsDir, "print.css")}`, + `--output=${htmlPath}`, + ], markdownFor("html")); + fs.rmSync(outPath, { force: true }); + await runBrowser(tempDir, htmlPath, ["--no-pdf-header-footer", `--print-to-pdf=${outPath}`], outPath); + } else { + findKindlePreviewer(); // Fail early, before spending time on the epub. + if (epubForKindle === null) { + epubForKindle = path.join(tempDir, `${book.slug}.epub`); + await buildEpub(epubForKindle); + } + convertEpubToKpf(tempDir, epubForKindle, outPath); + } + const kilobytes = Math.round(fs.statSync(outPath).size / 1024); + console.log(` wrote ${displayPath(outPath)} (${kilobytes} KB)`); + } catch (error) { + failures += 1; + console.error(` FAILED ${format}: ${(error as Error).message}`); + } + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); + } + return failures; +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..db2409a --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,75 @@ +#!/usr/bin/env node +// auto-print: turns markdown manuscripts into epub, docx, pdf and Kindle kpf files. +// +// auto-print build every book in ./books.json +// auto-print wick-canyon build only the named book(s) +// auto-print --config path/books.json +// auto-print --list list the configured books + +import { buildBook } from "./build.ts"; +import { loadConfig } from "./config.ts"; +import { displayPath, fail } from "./util.ts"; + +const usage = `Usage: auto-print [--config <file>] [--list] [slug ...] + + --config <file> The config file. Default: books.json in the current folder. + --list List the books in the config file, and build nothing. + slug ... Build only these books. Without a slug, every book is built.`; + +export async function main(args: string[]): Promise<number> { + let configPath = "books.json"; + let list = false; + const slugs: string[] = []; + for (let index = 0; index < args.length; index++) { + const arg = args[index]!; + if (arg === "--help" || arg === "-h") { + console.log(usage); + return 0; + } else if (arg === "--list") { + list = true; + } else if (arg === "--config") { + configPath = args[++index] ?? fail("--config needs a file name."); + } else if (arg.startsWith("-")) { + fail(`Unknown option: ${arg}\n\n${usage}`); + } else { + slugs.push(arg); + } + } + + const config = loadConfig(configPath); + if (list) { + for (const book of config.books) console.log(`${book.slug}\t${book.title}\t${displayPath(book.source)}`); + return 0; + } + + const unknown = slugs.filter((slug) => !config.books.some((book) => book.slug === slug)); + if (unknown.length > 0) { + fail(`Unknown book: ${unknown.join(", ")}. Known books: ${config.books.map((b) => b.slug).join(", ")}.`); + } + const selected = slugs.length > 0 ? config.books.filter((book) => slugs.includes(book.slug)) : config.books; + + let failures = 0; + for (const book of selected) { + console.log(`\n${book.title} (${displayPath(book.source)})`); + try { + if ((await buildBook(book, config.outDir)) > 0) failures += 1; + } catch (error) { + failures += 1; + console.error(` FAILED: ${(error as Error).message}`); + } + } + if (failures > 0) { + console.error(`\n${failures} of ${selected.length} book(s) had failures.`); + return 1; + } + console.log(`\nDone. ${selected.length} book(s) built into ${config.outDir}`); + return 0; +} + +main(process.argv.slice(2)).then( + (code) => { process.exitCode = code; }, + (error) => { + console.error(`Error: ${(error as Error).message}`); + process.exitCode = 1; + }, +); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..0f33b83 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,132 @@ +// The config file (books.json): the list of books, and the settings they share. + +import fs from "node:fs"; +import path from "node:path"; +import { fail } from "./util.ts"; + +export type Format = "epub" | "docx" | "pdf" | "kpf"; + +export const allFormats: Format[] = ["epub", "docx", "pdf", "kpf"]; + +/** Settings that a book can have, and that the "defaults" block can set for every book. */ +export interface Settings { + author: string; + language: string; + /** Heading whose content is the story. null means "use the whole file". */ + section: string | null; + formats: Format[]; + /** + * epub only. true: every chapter starts on a new page, as its own file. + * false: the chapters flow continuously, with only a heading between them. + */ + chapterBreaks: boolean; +} + +export interface BookEntry extends Partial<Settings> { + /** Used for the output folder and file names. */ + slug: string; + title: string; + /** Markdown file, relative to the config file. */ + source: string; + /** Cover image, relative to the config file. If missing, a text cover is generated. */ + cover?: string; + /** Headings in the source file whose sections become pages before the story, in this order. */ + front?: string[]; + /** Headings in the source file whose sections become pages after the story, in this order. */ + back?: string[]; +} + +/** A book with every setting filled in, and every path made absolute. */ +export interface Book extends Settings { + slug: string; + title: string; + source: string; + cover: string | null; + front: string[]; + back: string[]; +} + +export interface Config { + /** Absolute path of the config file. */ + path: string; + /** Absolute path of the folder that gets one subfolder of output files per book. */ + outDir: string; + books: Book[]; +} + +const builtInDefaults: Omit<Settings, "author"> = { + language: "en-US", + section: "Writing", + formats: allFormats, + chapterBreaks: false, +}; + +const defaultOutDir = "auto-print-out"; + +function isStringList(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === "string" && item.trim().length > 0); +} + +/** Reads and checks a config file. Every problem is reported with the file name and the book it is in. */ +export function loadConfig(configPath: string): Config { + const absolutePath = path.resolve(configPath); + const name = path.basename(absolutePath); + if (!fs.existsSync(absolutePath)) fail(`Config file not found: ${absolutePath}`); + + let raw: { outDir?: unknown; defaults?: Partial<Settings>; books?: unknown }; + try { + raw = JSON.parse(fs.readFileSync(absolutePath, "utf8")); + } catch (error) { + fail(`${name} is not valid JSON: ${(error as Error).message}`); + } + if (!Array.isArray(raw.books)) fail(`${name}: needs a "books" list.`); + if (raw.outDir !== undefined && (typeof raw.outDir !== "string" || !raw.outDir)) { + fail(`${name}: "outDir" must be a folder name.`); + } + + const baseDir = path.dirname(absolutePath); + const defaults = { ...builtInDefaults, ...raw.defaults }; + const seen = new Set<string>(); + const books = (raw.books as BookEntry[]).map((entry): Book => { + const label = `${name}: book "${entry.slug ?? entry.title ?? JSON.stringify(entry)}"`; + if (!entry.slug || !entry.title || typeof entry.source !== "string" || !entry.source) { + fail(`${name}: every book needs "slug", "title" and "source" (one file). Problem entry: ${JSON.stringify(entry)}`); + } + if (seen.has(entry.slug)) fail(`${name}: the slug "${entry.slug}" is used twice.`); + seen.add(entry.slug); + + const author = entry.author ?? defaults.author; + if (typeof author !== "string" || !author.trim()) { + fail(`${label} needs an "author", in the book or in the "defaults" block.`); + } + for (const position of ["front", "back"] as const) { + if (entry[position] !== undefined && !isStringList(entry[position])) { + fail(`${label}: "${position}" must be a list of heading names, like ["Dedication"].`); + } + } + const formats = entry.formats ?? defaults.formats; + for (const format of formats) { + if (!allFormats.includes(format)) fail(`${label}: unknown format "${format}". Allowed: ${allFormats.join(", ")}.`); + } + + return { + slug: entry.slug, + title: entry.title, + source: path.resolve(baseDir, entry.source), + cover: entry.cover ? path.resolve(baseDir, entry.cover) : null, + front: entry.front ?? [], + back: entry.back ?? [], + author, + language: entry.language ?? defaults.language, + section: entry.section !== undefined ? entry.section : defaults.section, + formats, + chapterBreaks: entry.chapterBreaks ?? defaults.chapterBreaks, + }; + }); + + return { + path: absolutePath, + outDir: path.resolve(baseDir, (raw.outDir as string | undefined) ?? defaultOutDir), + books, + }; +} diff --git a/src/manuscript.ts b/src/manuscript.ts new file mode 100644 index 0000000..e9544ca --- /dev/null +++ b/src/manuscript.ts @@ -0,0 +1,243 @@ +// Turns manuscript files into the markdown that pandoc converts. +// +// Manuscripts hold notes, spelling lists and removed writing next to the story. +// These functions pick out the story and the extra pages, and put them together. +// They only read files. Nothing here runs pandoc. + +import fs from "node:fs"; +import path from "node:path"; +import { displayPath as labelOf, fail } from "./util.ts"; + +export interface Heading { + line: number; + level: number; + text: string; +} + +/** Finds ATX headings ("## Like this"), ignoring anything inside fenced code blocks. */ +export function findHeadings(lines: string[]): Heading[] { + const headings: Heading[] = []; + let fence: string | null = null; + lines.forEach((line, index) => { + const fenceMatch = /^\s{0,3}(```+|~~~+)/.exec(line); + if (fenceMatch) { + const marker = fenceMatch[1]![0]!; + if (fence === null) fence = marker; + else if (fence === marker) fence = null; + return; + } + if (fence !== null) return; + const match = /^(#{1,6})\s+(.*?)\s*$/.exec(line); + if (match) headings.push({ line: index, level: match[1]!.length, text: match[2]! }); + }); + return headings; +} + +export function readLines(filePath: string): string[] { + return fs.readFileSync(filePath, "utf8").replace(/\r\n/g, "\n").split("\n"); +} + +/** + * Finds the section under the heading with the given text, ignoring letter case. + * Returns the heading text as written in the file, and the lines under it, + * up to the next heading of the same or a higher level. + * + * If the name is used at several heading levels, the shallowest one wins. + * So a "## Characters" section is chosen over "### Characters" notes inside "## Info". + */ +export function findSection(lines: string[], name: string, sourceLabel: string): { title: string; lines: string[] } | null { + const headings = findHeadings(lines); + const wanted = name.trim().toLowerCase(); + const matches = headings.filter((h) => h.text.toLowerCase() === wanted); + if (matches.length === 0) return null; + const level = Math.min(...matches.map((h) => h.level)); + const shallowest = matches.filter((h) => h.level === level); + if (shallowest.length > 1) { + fail(`${sourceLabel}: the heading "${name}" appears ${shallowest.length} times at the same level. Rename one of them.`); + } + const start = shallowest[0]!; + const end = headings.find((h) => h.line > start.line && h.level <= start.level); + return { title: start.text, lines: lines.slice(start.line + 1, end ? end.line : lines.length) }; +} + +/** Moves every heading by `by` levels: 1 turns "#" into "##", -1 turns "##" into "#". */ +export function shiftHeadings(lines: string[], by: number): string[] { + const shifted = [...lines]; + for (const heading of findHeadings(lines)) { + shifted[heading.line] = by >= 0 ? "#".repeat(by) + shifted[heading.line] : shifted[heading.line]!.slice(-by); + } + return shifted; +} + +/** Removes HTML comments and moves the headings so that the shallowest one is at `topLevel`. */ +export function tidySection(lines: string[], topLevel: number): string { + // Remove HTML comments, such as a blog's <!--more--> excerpt marker. + lines = lines.join("\n").replace(/<!--[\s\S]*?-->/g, "").split("\n"); + const headings = findHeadings(lines); + if (headings.length > 0) lines = shiftHeadings(lines, topLevel - Math.min(...headings.map((h) => h.level))); + return lines.join("\n").trim(); +} + +/** + * Pulls the story out of a manuscript. + * + * The story is the content under one heading (normally "## Writing"). That + * content is returned with its headings promoted so the top ones are level 1, + * which is what pandoc treats as chapters. + */ +export function extractStory(lines: string[], section: string | null, sourceLabel: string): string { + if (section === null) { + // Whole file. Drop a leading "# Title" line, because the title comes from the config. + const first = findHeadings(lines)[0]; + if (first && first.level === 1) lines = lines.slice(first.line + 1); + } else { + const found = findSection(lines, section, sourceLabel); + if (!found) { + const available = findHeadings(lines).filter((h) => h.level <= 2).map((h) => `"${h.text}"`).join(", "); + fail(`${sourceLabel}: no heading named "${section}". Top-level headings found: ${available || "none"}.`); + } + lines = found.lines; + } + + const story = tidySection(lines, 1); + if (story.length === 0) fail(`${sourceLabel}: the story section is empty.`); + return story + "\n"; +} + +/** + * Moves the text before a story's first heading, such as a content warning, + * to just below that heading. + * + * Used when several stories are joined into one book. Otherwise the opening text + * of a story would end up at the end of the previous story's last chapter. + */ +export function moveOpeningBelowTitle(story: string): string { + const lines = story.split("\n"); + const first = findHeadings(lines)[0]; + if (!first || first.line === 0) return story; + const opening = lines.slice(0, first.line).join("\n").trim(); + const rest = lines.slice(first.line + 1).join("\n").trim(); + return `${lines[first.line]}\n\n${opening}\n\n${rest}\n`; +} + +/** A line that holds only a link to a markdown file, like "[Story one](./story-1.md)". */ +const storyLink = /^\[[^\]]*\]\(<?([^()<>]+\.md)>?\)$/; + +/** + * Reads the story of a book. + * + * If the story section holds only links to other markdown files, one per line, + * each linked file's own story section is read instead, in the order of the links. + * A link inside an HTML comment is ignored, because comments are removed first. + * Returns the story and every file that was read, so images can be found next to them. + */ +export function readStory(sourcePath: string, section: string | null): { story: string; files: string[] } { + const label = labelOf(sourcePath); + const story = extractStory(readLines(sourcePath), section, label); + const lines = story.split("\n").map((line) => line.trim()).filter((line) => line.length > 0); + const links = lines.map((line) => storyLink.exec(line)); + if (!links.some((link) => link !== null)) return { story, files: [sourcePath] }; + if (!links.every((link) => link !== null)) { + fail(`${label}: the story section mixes links to story files with other text. Use only links, one per line, or only story text.`); + } + + const files = links.map((link) => path.resolve(path.dirname(sourcePath), decodeURIComponent(link![1]!))); + const stories = files.map((file) => { + if (!fs.existsSync(file)) fail(`${label}: the linked story file was not found: ${file}`); + return moveOpeningBelowTitle(extractStory(readLines(file), section, labelOf(file))); + }); + return { story: stories.join("\n"), files: [sourcePath, ...files] }; +} + +/** How a page before or after the story is marked and shown. */ +export interface PageRole { + /** The page's role in the epub. pandoc only puts some roles in the front or the back, see pageRoles. */ + epubType: string; + /** false: the page is left out of the table of contents. */ + listed: boolean; + /** false: the heading is not shown. The epub still needs it to start a new page. */ + showTitle: boolean; + /** A class for the page's text, styled in print.css, the epub stylesheet and the docx reference doc. */ + textClass?: string; +} + +/** A page before or after the story, such as a dedication or a character guide. */ +export interface ExtraPage extends PageRole { + title: string; + body: string; +} + +/** + * Front pages that are treated differently from the others, by heading name. + * Other front pages are marked as a preface, and every back page as an appendix. + * These are roles that pandoc puts in the front or the back of an epub. + */ +export const pageRoles: Record<string, PageRole> = { + dedication: { epubType: "dedication", listed: false, showTitle: false, textClass: "dedication" }, + copyright: { epubType: "copyright-page", listed: false, showTitle: true }, +}; + +export function readExtraPages(sourcePath: string, names: string[], position: "front" | "back"): ExtraPage[] { + const label = labelOf(sourcePath); + const lines = readLines(sourcePath); + return names.map((name) => { + const found = findSection(lines, name, label); + if (!found) fail(`${label}: no heading named "${name}", which is listed in "${position}" in the config.`); + const body = tidySection(found.lines, 2); + if (body.length === 0) fail(`${label}: the "${found.title}" section is empty.`); + const role: PageRole = position === "front" + ? pageRoles[found.title.toLowerCase()] ?? { epubType: "preface", listed: true, showTitle: true } + : { epubType: "appendix", listed: true, showTitle: true }; + return { title: found.title, body, ...role }; + }); +} + +export type Target = "epub" | "docx" | "html"; + +/** + * A page break, written in the markup that each target passes through untouched. + * The epub needs none, because pandoc starts a new file at every level 1 heading. + */ +const pageBreaks: Record<Target, string> = { + epub: "", + docx: '```{=openxml}\n<w:p><w:r><w:br w:type="page"/></w:r></w:p>\n```', + html: '<div class="page-break"></div>', +}; + +/** The input format for pandoc. assemble() writes markup that needs these extensions. */ +export const pandocFormat = "gfm+smart+attributes+raw_attribute+fenced_divs"; + +/** + * Builds the markdown that pandoc converts, for one target. + * + * Every extra page starts on a new page. In the epub, an extra page is a level 1 + * heading, which pandoc puts in a file of its own. When the epub flows continuously + * (chapterBreaks false), the story's headings are moved down one level, under one + * level 1 heading with the book title, so the whole story stays in one file. + */ +export function assemble(story: string, front: ExtraPage[], back: ExtraPage[], target: Target, chapterBreaks: boolean, title: string): string { + const parts: string[] = []; + const pageBreak = pageBreaks[target]; + const addPage = (page: ExtraPage) => { + if (pageBreak) parts.push(pageBreak); + const classes = [!page.listed && ".unlisted", !page.showTitle && ".hidden-title"].filter(Boolean).join(" "); + if (target === "epub") parts.push(`# ${page.title} {${classes ? classes + " " : ""}epub:type=${page.epubType}}`); + else if (page.showTitle) parts.push(`# ${page.title}${page.listed ? "" : " {.unlisted}"}`); + // custom-style names the paragraph style in the docx. The other targets use the class. + parts.push(page.textClass ? `::: {.${page.textClass} custom-style="${capitalize(page.textClass)}"}\n${page.body}\n:::` : page.body); + }; + + front.forEach(addPage); + if (front.length > 0 && pageBreak) parts.push(pageBreak); + if (target === "epub" && !chapterBreaks) { + parts.push(`# ${title} {.unnumbered}`, shiftHeadings(story.trim().split("\n"), 1).join("\n")); + } else { + parts.push(story.trim()); + } + back.forEach(addPage); + return parts.join("\n\n") + "\n"; +} + +function capitalize(text: string): string { + return text.charAt(0).toUpperCase() + text.slice(1); +} diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..9b648c0 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,32 @@ +// Small helpers shared by the other modules. + +import { spawnSync } from "node:child_process"; +import path from "node:path"; + +export function fail(message: string): never { + throw new Error(message); +} + +/** Runs a command and returns its output. Fails with the command's error output if it does not succeed. */ +export function run(command: string, args: string[], input?: string, timeoutMs = 180_000): string { + const result = spawnSync(command, args, { input, encoding: "utf8", timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 }); + if (result.error) { + const code = (result.error as NodeJS.ErrnoException).code; + if (code === "ENOENT") fail(`"${command}" is not installed or not on the PATH.`); + fail(`${path.basename(command)} failed: ${result.error.message}`); + } + if (result.status !== 0) { + fail(`${path.basename(command)} exited with code ${result.status}:\n${result.stderr.trim()}`); + } + return result.stdout; +} + +/** How a file is named in messages: relative to the current folder when it is inside it, otherwise in full. */ +export function displayPath(filePath: string): string { + const relative = path.relative(process.cwd(), filePath); + return relative && !relative.startsWith("..") && !path.isAbsolute(relative) ? relative : filePath; +} + +export function sleep(milliseconds: number): Promise<void> { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} diff --git a/test/config.test.ts b/test/config.test.ts new file mode 100644 index 0000000..979c376 --- /dev/null +++ b/test/config.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, it } from "node:test"; +import { loadConfig } from "../src/config.ts"; + +function writeConfig(content: unknown): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "auto-print-test-")); + const file = path.join(dir, "books.json"); + fs.writeFileSync(file, JSON.stringify(content)); + return file; +} + +describe("loadConfig", () => { + it("resolves paths against the config file's folder and fills in the defaults", () => { + const file = writeConfig({ + outDir: "../out", + defaults: { author: "A. Writer" }, + books: [{ slug: "one", title: "One", source: "../one.md", cover: "covers/one.jpg", front: ["Dedication"] }], + }); + const dir = path.dirname(file); + const config = loadConfig(file); + assert.equal(config.outDir, path.resolve(dir, "../out")); + assert.deepEqual(config.books[0], { + slug: "one", + title: "One", + source: path.resolve(dir, "../one.md"), + cover: path.join(dir, "covers/one.jpg"), + front: ["Dedication"], + back: [], + author: "A. Writer", + language: "en-US", + section: "Writing", + formats: ["epub", "docx", "pdf", "kpf"], + chapterBreaks: false, + }); + }); + + it("puts the output next to the config file by default, and lets a book override the defaults", () => { + const file = writeConfig({ + defaults: { author: "A. Writer", formats: ["epub"] }, + books: [{ slug: "one", title: "One", source: "one.md", author: "B. Writer", section: null, chapterBreaks: true }], + }); + const config = loadConfig(file); + assert.equal(config.outDir, path.join(path.dirname(file), "auto-print-out")); + assert.equal(config.books[0]?.author, "B. Writer"); + assert.equal(config.books[0]?.section, null); + assert.equal(config.books[0]?.chapterBreaks, true); + assert.deepEqual(config.books[0]?.formats, ["epub"]); + }); + + it("explains what is wrong", () => { + const book = { slug: "one", title: "One", source: "one.md", author: "A" }; + assert.throws(() => loadConfig("/no/such/books.json"), /Config file not found/); + assert.throws(() => loadConfig(writeConfig({ books: [{ ...book, author: undefined }] })), /needs an "author"/); + assert.throws(() => loadConfig(writeConfig({ books: [book, book] })), /slug "one" is used twice/); + assert.throws(() => loadConfig(writeConfig({ books: [{ ...book, source: ["a.md"] }] })), /needs "slug", "title" and "source"/); + assert.throws(() => loadConfig(writeConfig({ books: [{ ...book, formats: ["mobi"] }] })), /unknown format "mobi"/); + assert.throws(() => loadConfig(writeConfig({ books: [{ ...book, front: "Dedication" }] })), /"front" must be a list/); + }); +}); diff --git a/test/fixtures/collection/index.md b/test/fixtures/collection/index.md new file mode 100644 index 0000000..b09046b --- /dev/null +++ b/test/fixtures/collection/index.md @@ -0,0 +1,32 @@ +# A Collection + +## Info + +### Characters + +1. Hero + 1. Secret: dies in story two + +## Dedication + +To *everyone*. + +## Content Warnings + +This book contains: + +- peril + +## Characters + +### Hero + +The one the stories are about. + +## Writing + +[Story one](./story-1.md) + +[Story two](./story-2.md) + +<!-- [Story three](./story-3.md) --> diff --git a/test/fixtures/collection/story-1.md b/test/fixtures/collection/story-1.md new file mode 100644 index 0000000..40f3b38 --- /dev/null +++ b/test/fixtures/collection/story-1.md @@ -0,0 +1,24 @@ +# A Collection: Story one + +## Spelling + +cspell:words Hero + +## Writing + +<!--more--> + +[Content Warning: peril.] + +### Story one + +#### The start + +The hero woke up. +The sun was up too. + +The end of the beginning. + +### ~Intermission One~ + +A pause. diff --git a/test/fixtures/collection/story-2.md b/test/fixtures/collection/story-2.md new file mode 100644 index 0000000..74780a5 --- /dev/null +++ b/test/fixtures/collection/story-2.md @@ -0,0 +1,15 @@ +# A Collection: Story two + +## Writing + +[Content Warning: more peril.] + +### Story two + +#### Another start + +The hero slept. + +## Removed writing + +This was cut. diff --git a/test/manuscript.test.ts b/test/manuscript.test.ts new file mode 100644 index 0000000..fa58055 --- /dev/null +++ b/test/manuscript.test.ts @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import { + assemble, + extractStory, + findHeadings, + findSection, + moveOpeningBelowTitle, + readExtraPages, + readStory, + shiftHeadings, +} from "../src/manuscript.ts"; + +const fixtures = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "collection"); +const index = path.join(fixtures, "index.md"); + +describe("findHeadings", () => { + it("finds headings and skips fenced code blocks", () => { + const lines = ["# One", "text", "```", "# not a heading", "```", "### Three "]; + assert.deepEqual(findHeadings(lines), [ + { line: 0, level: 1, text: "One" }, + { line: 5, level: 3, text: "Three" }, + ]); + }); +}); + +describe("findSection", () => { + const lines = ["## Info", "### Characters", "secret", "## Characters", "public", "## Writing", "story"]; + + it("picks the shallowest heading when a name is used at several levels", () => { + assert.deepEqual(findSection(lines, "characters", "file.md"), { title: "Characters", lines: ["public"] }); + }); + + it("returns null for a missing heading", () => { + assert.equal(findSection(lines, "Dedication", "file.md"), null); + }); + + it("fails when a name is used twice at the same level", () => { + assert.throws(() => findSection([...lines, "## Info"], "Info", "file.md"), /appears 2 times at the same level/); + }); +}); + +describe("shiftHeadings", () => { + it("moves headings down and up", () => { + assert.deepEqual(shiftHeadings(["# A", "text", "## B"], 1), ["## A", "text", "### B"]); + assert.deepEqual(shiftHeadings(["### A", "#### B"], -2), ["# A", "## B"]); + }); +}); + +describe("extractStory", () => { + it("takes the Writing section, removes comments and promotes the headings", () => { + const lines = ["# Title", "## Notes", "note", "## Writing", "<!--more-->", "### Chapter", "Text.", "## Removed", "cut"]; + assert.equal(extractStory(lines, "Writing", "file.md"), "# Chapter\nText.\n"); + }); + + it("takes the whole file without its title when the section is null", () => { + assert.equal(extractStory(["# Title", "", "## Chapter", "Text."], null, "file.md"), "# Chapter\nText.\n"); + }); + + it("names the headings it found when the section is missing", () => { + assert.throws(() => extractStory(["# Title", "## Notes"], "Writing", "file.md"), /no heading named "Writing".*"Title", "Notes"/); + }); + + it("fails on an empty story", () => { + assert.throws(() => extractStory(["## Writing", "<!-- nothing -->", "## Notes"], "Writing", "file.md"), /empty/); + }); +}); + +describe("moveOpeningBelowTitle", () => { + it("moves a content warning below the story title", () => { + assert.equal(moveOpeningBelowTitle("[Warning.]\n\n# Story\n\n## Scene\n\nText.\n"), "# Story\n\n[Warning.]\n\n## Scene\n\nText.\n"); + }); + + it("leaves a story that starts with its title alone", () => { + assert.equal(moveOpeningBelowTitle("# Story\n\nText.\n"), "# Story\n\nText.\n"); + }); +}); + +describe("readStory", () => { + it("follows the links in the Writing section, in order, and skips commented-out links", () => { + const { story, files } = readStory(index, "Writing"); + assert.deepEqual(files.map((file) => path.basename(file)), ["index.md", "story-1.md", "story-2.md"]); + const headings = findHeadings(story.split("\n")).map((h) => `${h.level} ${h.text}`); + assert.deepEqual(headings, ["1 Story one", "2 The start", "1 ~Intermission One~", "1 Story two", "2 Another start"]); + // Each story's content warning sits right under its title. + assert.match(story, /^# Story one\n\n\[Content Warning: peril\.\]/); + assert.match(story, /# Story two\n\n\[Content Warning: more peril\.\]/); + // Notes outside the Writing sections stay out. + assert.doesNotMatch(story, /This was cut|cspell|Secret/); + }); + + it("returns a story without links as it is", () => { + const { story, files } = readStory(path.join(fixtures, "story-2.md"), "Writing"); + assert.equal(files.length, 1); + assert.match(story, /^\[Content Warning: more peril\.\]\n\n# Story two/); + }); +}); + +describe("readExtraPages", () => { + it("reads front pages with their roles", () => { + const [dedication, warnings] = readExtraPages(index, ["Dedication", "content warnings"], "front"); + assert.deepEqual(dedication, { + title: "Dedication", body: "To *everyone*.", epubType: "dedication", listed: false, showTitle: false, textClass: "dedication", + }); + assert.equal(warnings?.title, "Content Warnings"); + assert.equal(warnings?.epubType, "preface"); + }); + + it("reads back pages as appendices, with the public section and not the notes", () => { + const [characters] = readExtraPages(index, ["Characters"], "back"); + assert.equal(characters?.epubType, "appendix"); + assert.equal(characters?.body, "## Hero\n\nThe one the stories are about."); + }); + + it("fails on a name that is not in the file", () => { + assert.throws(() => readExtraPages(index, ["Map"], "back"), /no heading named "Map", which is listed in "back"/); + }); +}); + +describe("assemble", () => { + const story = "# Story\n\n## Scene\n\nText.\n"; + const dedication = { title: "Dedication", body: "To you.", epubType: "dedication", listed: false, showTitle: false, textClass: "dedication" }; + const guide = { title: "Guide", body: "People.", epubType: "appendix", listed: true, showTitle: true }; + + it("returns the story unchanged when there are no extra pages", () => { + assert.equal(assemble(story, [], [], "docx", false, "Book"), "# Story\n\n## Scene\n\nText.\n"); + assert.equal(assemble(story, [], [], "html", false, "Book"), "# Story\n\n## Scene\n\nText.\n"); + }); + + it("puts a flowing epub under one heading with the book title", () => { + assert.equal(assemble(story, [], [], "epub", false, "Book"), "# Book {.unnumbered}\n\n## Story\n\n### Scene\n\nText.\n"); + assert.equal(assemble(story, [], [], "epub", true, "Book"), "# Story\n\n## Scene\n\nText.\n"); + }); + + it("gives each extra page its own level 1 heading in the epub", () => { + const markdown = assemble(story, [dedication], [guide], "epub", false, "Book"); + assert.equal(markdown, [ + "# Dedication {.unlisted .hidden-title epub:type=dedication}", + '::: {.dedication custom-style="Dedication"}\nTo you.\n:::', + "# Book {.unnumbered}", + "## Story\n\n### Scene\n\nText.", + "# Guide {epub:type=appendix}", + "People.", + ].join("\n\n") + "\n"); + }); + + it("puts page breaks before the extra pages and the story in the docx, and leaves out a hidden heading", () => { + const pageBreak = '```{=openxml}\n<w:p><w:r><w:br w:type="page"/></w:r></w:p>\n```'; + const markdown = assemble(story, [dedication], [guide], "docx", false, "Book"); + assert.equal(markdown, [ + pageBreak, + '::: {.dedication custom-style="Dedication"}\nTo you.\n:::', + pageBreak, + "# Story\n\n## Scene\n\nText.", + pageBreak, + "# Guide", + "People.", + ].join("\n\n") + "\n"); + }); + + it("uses a div as the page break in html", () => { + const markdown = assemble(story, [], [guide], "html", false, "Book"); + assert.equal(markdown, '# Story\n\n## Scene\n\nText.\n\n<div class="page-break"></div>\n\n# Guide\n\nPeople.\n'); + }); +}); diff --git a/test/pandoc.test.ts b/test/pandoc.test.ts new file mode 100644 index 0000000..1065d76 --- /dev/null +++ b/test/pandoc.test.ts @@ -0,0 +1,100 @@ +// Checks what pandoc makes of the markdown that assemble() writes. +// These tests are skipped when pandoc is not installed. + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import { assemble, pandocFormat, readExtraPages, readStory, type Target } from "../src/manuscript.ts"; + +const hasPandoc = spawnSync("pandoc", ["--version"]).status === 0; +const index = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "collection", "index.md"); + +interface Node { + t: string; + c?: unknown; +} + +function parse(markdown: string): Node[] { + const result = spawnSync("pandoc", [`--from=${pandocFormat}`, "--to=json"], { input: markdown, encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + return JSON.parse(result.stdout).blocks; +} + +/** The plain words of a block list, with soft line breaks counted as spaces. */ +function words(blocks: unknown): string { + const out: string[] = []; + const walk = (value: unknown): void => { + if (Array.isArray(value)) value.forEach(walk); + else if (value && typeof value === "object") { + const node = value as Node; + if (node.t === "Str") out.push(node.c as string); + else if (node.t === "Space" || node.t === "SoftBreak") out.push(" "); + else walk(node.c); + } + }; + walk(blocks); + return out.join("").replace(/ +/g, " ").trim(); +} + +/** [level, attributes, text] of each heading. */ +function headings(blocks: Node[]): [number, string[], Record<string, string>, string][] { + return blocks.filter((b) => b.t === "Header").map((b) => { + const [level, [, classes, pairs], inlines] = b.c as [number, [string, string[], [string, string][]], unknown]; + return [level, classes, Object.fromEntries(pairs), words(inlines)]; + }); +} + +function book(target: Target, chapterBreaks = false): Node[] { + const { story } = readStory(index, "Writing"); + const front = readExtraPages(index, ["Dedication", "Content Warnings"], "front"); + const back = readExtraPages(index, ["Characters"], "back"); + return parse(assemble(story, front, back, target, chapterBreaks, "A Collection")); +} + +describe("pandoc", { skip: hasPandoc ? false : "pandoc is not installed" }, () => { + it("joins sentences on separate lines into one paragraph, like a paragraph on one line", () => { + const lines = parse("One sentence.\nTwo sentence.\n\nNew paragraph.\n"); + const joined = parse("One sentence. Two sentence.\n\nNew paragraph.\n"); + assert.equal(lines.length, 2); + assert.deepEqual(lines.map(words), joined.map(words)); + assert.equal(JSON.stringify(lines).includes("LineBreak"), false); + }); + + it("reads braces in prose as text", () => { + assert.equal(words(parse("She said {quietly} that it was over.\n")), "She said {quietly} that it was over."); + }); + + it("marks the extra pages of the epub as level 1 headings with their roles", () => { + assert.deepEqual(headings(book("epub")).filter(([level]) => level === 1), [ + [1, ["unlisted", "hidden-title"], { "epub:type": "dedication" }, "Dedication"], + [1, [], { "epub:type": "preface" }, "Content Warnings"], + [1, ["unnumbered"], {}, "A Collection"], + [1, [], { "epub:type": "appendix" }, "Characters"], + ]); + }); + + it("keeps the stories as level 2 headings in a flowing epub, and level 1 with chapter breaks", () => { + const titles = (blocks: Node[], level: number) => headings(blocks).filter(([l]) => l === level).map(([, , , text]) => text); + assert.deepEqual(titles(book("epub"), 2), ["Story one", "~Intermission One~", "Story two", "Hero"]); + assert.deepEqual(titles(book("epub", true), 1), ["Dedication", "Content Warnings", "Story one", "~Intermission One~", "Story two", "Characters"]); + }); + + it("wraps the dedication in a div with the Dedication style", () => { + const div = book("docx").find((b) => b.t === "Div"); + const [[, classes, pairs], content] = div!.c as [[string, string[], [string, string][]], unknown]; + assert.deepEqual(classes, ["dedication"]); + assert.deepEqual(pairs, [["custom-style", "Dedication"]]); + assert.equal(words(content), "To everyone."); + }); + + it("puts a page break before each extra page and before the story in the docx", () => { + const blocks = book("docx"); + const breaks = blocks.map((b, i) => (b.t === "RawBlock" ? i : -1)).filter((i) => i >= 0); + assert.equal(breaks.length, 4); + for (const i of breaks) assert.deepEqual((blocks[i]!.c as [string, string])[0], "openxml"); + // No heading is shown for the dedication. + assert.equal(headings(blocks).some(([, , , text]) => text === "Dedication"), false); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..e9ce7ef --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + // Compiles src/ to dist/ for the published package (npm run build). + // Installed packages live in node_modules, where Node does not run .ts files. + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "allowImportingTsExtensions": false, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f90a36e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + // Type checking only (npm run typecheck). tsconfig.build.json writes dist/. + // Node runs the .ts files directly, so the code only uses syntax that Node can strip. + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2023"], + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src", "test"] +}