DEV Community

Cover image for I built a single-file CLI that compiles a folder into a valid EPUB 3
Yoga Pradana Ismail
Yoga Pradana Ismail

Posted on AI-assisted

I built a single-file CLI that compiles a folder into a valid EPUB 3

Around February last year, that was also when I started my career as a writer. I made a Light Novel and published it on Google Play Books, and at the time I built it directly in .EPUB format using an Android app called ZisE. The app was simple enough for making EPUBs, but the annoying part was the tons of ads and how it forced you to go premium if you tried to bypass them by turning on airplane mode.

Eventually I got fed up with the app, so I asked DeepSeek to build a web app called epubCreator that would make EPUBs with ZIP and metadata using local storage. But that ended up failing because the maintenance was a pain.

Then I thought, why not ask DeepSeek to change how that web app worked into a Node.js script, since I was using Node for the first time. So, because it felt like a waste to have it sitting on my phone unused, I talked it through with DeepSeek until the code was done. And boom! The UX turned out to be really nice and easy to fix. The early versions still used arguments and flags, but now the script has grown a lot.

Note: some features in this article haven't been discussed with DeepSeek yet. They're still as-is based on what I wanted initially, so they might not be fully polished or thoroughly tested.

That script is now epubcreator.js — a single Node file that takes a structured directory and produces an EPUB. Most of the technical decisions I talked through with DeepSeek.

I'm pretty proud of how it turned out: almost every book I've made with this script, when checked on an EPUB validator site, scores an average of 100 — from metadata, code, all the way to accessibility. That's why this script is a good fit for EPUB developers working on standard, simple books, or indie writers like me who want a straightforward build flow without setting up a bunch of tools.

What it looks like

A textbook project:

my-book/
├── config.txt          ← title, author, language, ISBN…
├── ord.txt             ← chapter order (optional)
├── Markdowns/          ← source .md files
│   ├── 01-intro.md
│   └── 02-chapter-one.md
└── EPUB/
    └── images/
        └── cover.png   ← required
Enter fullscreen mode Exit fullscreen mode
node epubcreator.js build
# → builds/my-book.epub
Enter fullscreen mode Exit fullscreen mode

A comic/manga project:

my-comic/
├── config.txt          ← type: comic | manga, reading_direction: ltr | rtl
├── ord.txt             ← *page-spread | alt text, per page
└── EPUB/
    ├── images/
    │   ├── cover.jpg
    │   └── pages/
    │       ├── ch1/0001.jpg
    │       └── ch2/0001.jpg
    └── xhtmls/         ← auto-generated from images
Enter fullscreen mode Exit fullscreen mode

Run it with no arguments and you get a full-screen TUI (raw ANSI escape codes — no Ink, no blessed — arrow keys, inline prompts, a scrolling log panel above the menu). If the terminal is too small or not a TTY, it falls back to a plain numeric menu. On first launch in a fresh project it fetches its own node_modules from a GitHub branch, so git clone && node epubcreator.js genuinely works with zero setup.

The feature list, briefly

  • Textbook mode — reflowable EPUB 3, Markdown → XHTML, standard nested TOC.
  • Comic / Manga mode — fixed-layout EPUB 3. LTR for comics, RTL for manga. Auto-detects double-page spreads.
  • Converters — MD ↔ XHTML, DOCX → MD (with heading detection by Word style or by font size), images → XHTML pages.
  • Import — point it at an existing .epub and it reconstructs the project, extracts images, detects fixed-layout, and rebuilds.
  • Debug — file watcher that re-converts + re-builds on save.
  • Validate — wraps epubcheck.
  • Settings — .epubcreator/settings.txt for watch_delay, auto_build, overwrite_policy, and default_type.

The internals (if you care)

A few decisions I talked through with DeepSeek that I'm happy with, and a few I regret.

No image libraries

To write correct <meta name="viewport"> tags for fixed-layout pages, I needed image dimensions. image-size is a fine package, but adding a dependency for ~80 lines felt wasteful, and I wanted the whole tool to stay self-contained.

getImageSize() reads the buffer directly:

  • PNG: magic number check + two readUInt32BE calls.
  • JPEG: walk markers until SOF, skip the rest.
  • GIF: LE uint16 at offsets 6 and 8.
  • WebP: three sub-formats (VP8, VP8L, VP8X) — the annoying one.
  • SVG: regex on the file, falling back to viewBox.
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47) {
  return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
}
Enter fullscreen mode Exit fullscreen mode

Not exhaustive — no HEIF, no AVIF, and I haven't stress-tested progressive JPEGs — but it's been correct on every file I've thrown at it.

Fixed-layout with page-spread-center

EPUB 3 fixed-layout lets you set rendition:layout: prepaginated in the OPF, then control per-page spread behavior via properties on each <itemref>:

<itemref idref="page_12" properties="page-spread-center" />
Enter fullscreen mode Exit fullscreen mode

For manga (RTL), single-page spreads alternate right/left. The builder tracks which side the previous page landed on and assigns the next one accordingly. Double-page spreads (two-page illustrations) reset the alternation. If auto-detection gets it wrong, you override it in ord.txt:

ch1/0001.xhtml
*ch1/0002.xhtml | big reveal
!ch1/0003.xhtml
Enter fullscreen mode Exit fullscreen mode

* forces center-spread, ! forces single, no prefix means auto-detect (aspect ratio > 1.4 → double). The | part is the TOC title / alt text for that page.

The dependency trick

This is the decision I talked through with DeepSeek and regret the least, even though it's a bit weird.

update module fetches a tarball from the modules branch of the repo and extracts a prebuilt node_modules/. update config does the same for a .epubcreator/ folder. On first launch with no node_modules/, the TUI runs update module automatically before showing the menu.

The point is to make a fresh clone genuinely useful — git clone, node epubcreator.js, done — for people who might not have npm installed, or who are on Android/Termux and don't want to fight the toolchain. Downside: if GitHub is unreachable or the branch is gone, you're back to npm install manually. Fair trade for now.

What it doesn't do (yet)

  • No EPUB 2, no MOBI. You'll still need kindlegen or Calibre for legacy Kindles. EPUB 3 → KDP handles conversion for newer ones.
  • No image processing. No resize, no compression. Pipe through sharp or ImageMagick upstream.
  • No test suite. I know. It's next.
  • Windows needs Git Bash for the auto-download (it shells out to tar).

How I recommend using it

My recommended setup: clone the repo once as a script source, then copy epubcreator.js from the cloned repo into your EPUB project directory. I suggest putting projects under ~/projects/epubs/{project-name}/, so each book gets its own folder and doesn't get mixed up with the source repo.

The reason is simple: almost all operations in this script use process.cwd(), so it works most predictably when run from inside the project directory that contains config.txt, ord.txt, Markdowns/, and EPUB/. By putting a copy of epubcreator.js there, you just cd into the project folder and run node epubcreator.js without having to remember the script path.

Example:

# clone the repo once as a script source
git clone https://github.com/YogabyAllwaysever/epubcreator.js.git ~/tools/epubcreator.js

# set up the EPUB project directory
mkdir -p ~/projects/epubs/nama-proyek
cp ~/tools/epubcreator.js/epubcreator.js ~/projects/epubs/nama-proyek/

# go into the project and run it
cd ~/projects/epubs/nama-proyek
node epubcreator.js createdir
Enter fullscreen mode Exit fullscreen mode

After createdir, fill in the project according to the structure you want (config.txt, ord.txt, Markdowns/, EPUB/images/, etc.), then run node epubcreator.js build to build. On first launch in a new project, the script will fetch its own node_modules from the GitHub branch, so you don't need to set up npm manually.

If epubcreator.js in the source repo gets updated later, just copy the file again into the project directory:

cp ~/tools/epubcreator.js/epubcreator.js ~/projects/epubs/nama-proyek/
Enter fullscreen mode Exit fullscreen mode

This way, the cloned repo stays clean as a script source, while each EPUB project has its own working directory.

Quick try

If you just want to see the script run without setting up a project, clone the repo and run it directly:

git clone https://github.com/YogabyAllwaysever/epubcreator.js.git
cd epubcreator.js
node epubcreator.js
Enter fullscreen mode Exit fullscreen mode

MIT-licensed. Issues and angry bug reports welcome — especially around manga spreads and accessibility metadata, where I know I'm weakest.

If you ship EPUBs, I'd love to know what breaks for you.

Top comments (0)