Announcing: @smoores/epub
Dec. 13, 2024
Like most software projects, Storyteller stands on the shoulders of many open source giants. It relies on Echogarden, whisper.cpp, and FFmpeg for audio processing and automated audio transcription. It relies on React, Next.js, React Native, and Expo for UI and app development. It relies on a massive ecosystem of open source programming languages, development tools, and libraries. And without them, it simply couldn’t exist. Storyteller’s relatively rapid development and increasingly full feature set is only possible because of these other free, open source software projects, some of which seem to have completely solved the problems of their domain.
But when I started developing Storyteller, there was a hole in this ecosystem that was surprising to me. Storyteller publications are based on the EPUB specification, which is itself an open source project, maintained by the W3C. It's a good spec! And yet, there seemed to be very few open source libraries dedicated to working on EPUB publications. Initially, Storyteller’s backend was primarily written in Python, a language with a lively, wide-ranging open source ecosystem. At the time, the only contender in the space was EbookLib. This is a really neat library, but it wasn’t designed for Storyteller’s use case — modifying existing EPUBs — and it made some assumptions that made it essentially impossible to use. At the time, it was fully in maintenance mode, though it seems like that may be changing.
When I re-wrote Storyteller with Node.js (something that was truly only possible due to the outstanding Echogarden library), I found the situation even more dire. There was a larger quantity of packages, but nearly all of them were abandoned, and none of them provided the flexibility that I needed for Storyteller.
Since working with EPUB 3 files is Storyteller’s primary domain, I decided it was worth the effort to build my own EPUB library within Storyteller. It began as a very low-level library, as Storyteller largely needed to modify the XHTML representation of chapters and add metadata to link chapters to media overlays. Eventually, I added higher level APIs for ergonomically manipulating publication metadata, like titles and authors. I think that this approach will, in the long run, be a recipe for this library’s success — as I mentioned before, many other EPUB libraries attempted to abstract away the underlying EPUB structure, resulting in inflexible APIs that didn’t meet Storyteller’s needs.
Afer working on this library for several months, a colleague reached out to ask for advice on a project they were working on. They wanted to be able to generate EPUB publications programmatically, but they were running into the same issues that I had. The landscape for open source EPUB libraries in Node.js consisted of mostly unmaintained or incomplete projects.
I knew what I had to do. So for the past few weeks, I’ve been pulling Storyteller’s EPUB code out into its own library, adding documentation, and cleaning up the public-facing API. I think it’s ready to share with the world, so here it is: @smoores/epub
What does it do? And what exactly is an EPUB, anyway?
An EPUB file is a ZIP archive with a partially specified directory and file structure. Most of the metadata and content is specified as XML documents, with additional resources referenced from those XML documents. The most important of these documents is the package document.
The package document is an XML document that consists of a set of elements that each encapsulate information about a particular aspect of an EPUB publication. These elements serve to centralize metadata, detail the individual resources, and provide the reading order and other information necessary for its rendering.
@smoores/epub
is primarily concerned with providing access to the metadata, manifest, and spine of the EPUB publication. Metadata refers to information _about_ the publication, such as its title or authors. The manifest refers to the complete set of resources that are used to render the publication, such as XHTML documents and image files. And the spine refers to the ordered list of manifest items that represent the default reading order — the order that readers will encounter the manifest items by simply turning pages one at a time.
Here are some examples on how it can be used:
If you’re like me, that last example may have raised an eyebrow. I’m not the biggest fan of templating XML/XHTML strings, especially if the content may need to be conditional or repetitive. Storyteller, in particular, needs to be able to wrap each sentence in a span
tag, without breaking any existing markup on the text. So in addition to supporting byte arrays and unicode strings as encodings for manifest items, the library also supports the fast-xml-parser XML structures, and provides a basic API for working with them. Here’s how we can re-write the above example with programmatic XHTML generation:
For more details on how to use the library, you can check out the API docs on NPM!
And speaking of docs, I ended up spending quite a lot of time piecing together these docs in a way that I was happy with. I had a few fairly basic requirements:
The documentation should live in the README. I don’t have anything against documentation sites — Storyteller has one! — but this is a dedicated, single-purpose library, and I wanted to keep it simple.
At least part of the documentation should be hand-written. I wanted a narrative section at the beginning of the docs that I could write myself.
The table of contents should be automatically generated. Trying to manage it by hand was a recipe for disaster, I knew from previous experience, and it would inevitably become stale if its upkeep wasn’t automated.
There should be API docs automatically generated from the Typescript type information. I firmly believe that good docs requireboth hand-written narrative documentation and full API documentation. And I had the same worries about keeping the API docs up-to-date as the table of contents.
For the table of contents, I already had a tool that I enjoyed and was familiar with from my work on React ProseMirror — markdown-toc
. For automatically generating API docs from Typescript, the best solution seemed to be TypeDoc, combined with the markdown and remark plugins.
In order to easily author my own narrative documentation, I created a readme-stub.md
file. This is the only file that I actually update manually. Then I configured TypeDoc to generate a Markdown file with only the API docs in gen/README.md
, which is gitignored. Here's the full TypeDoc configuration:
Finally, some package scripts allow us to set up the README compilation pipeline, generating the top-level table of contents, then generating the API docs, and finally merging the stub with the API docs to produce the final README:
And that’s all! I hope you give @smoores/epub
a shot! If you run into any issues, you can open an Issue on the Storyteller GitLab repo.