Build an EPUB from Markdown with Node.js — EPUB 3 Structure, the Uncompressed mimetype Rule, XHTML Conversion, a Navigation Document, and a Cover Image
Generate a valid EPUB 3 from a folder of Markdown chapters using marked and jszip: the required files, why mimetype must be stored uncompressed and first, converting HTML to XHTML, the nav.xhtml table of contents, content.opf metadata, and adding a cover.
An EPUB is a zip file with a strict layout. Once you know the five things it must contain, generating one from Markdown is a short script, and the result opens in Kindle Previewer, Apple Books, Google Play Books and every store uploader we have tried.
What an EPUB 3 must contain
| File | Purpose |
|---|---|
mimetype |
The literal text application/epub+zip, stored uncompressed and as the first entry |
META-INF/container.xml |
Points to the package file |
OEBPS/content.opf |
Metadata, manifest of every file, and the reading order (spine) |
OEBPS/nav.xhtml |
The table of contents, an XHTML document with epub:type="toc" |
OEBPS/*.xhtml |
One XHTML file per chapter, plus optional cover and title pages |
Readers reject a file where mimetype is compressed or not first, so handle that entry explicitly.
Markdown to XHTML
marked produces HTML, but EPUB wants well-formed XHTML. For typical prose output the differences are small: self-close void elements and escape bare ampersands.
const toX = (html) => html
.replace(/<(br|hr|img)([^>]*?)(?<!\/)>/g, '<$1$2/>')
.replace(/&(?!(amp|lt|gt|quot|apos|#\d+);)/g, '&');
Wrap each chapter:
const xhtml = (title, body) => `<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="${lang}">
<head><meta charset="utf-8"/><title>${esc(title)}</title><link rel="stylesheet" href="style.css"/></head>
<body>${body}</body></html>`;
Assemble with jszip
const JSZip = require('jszip');
const zip = new JSZip();
zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' });
zip.file('META-INF/container.xml', `<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
</container>`);
const o = zip.folder('OEBPS');
o.file('style.css', css);
o.file('cover.jpg', coverBuffer);
o.file('cover.xhtml', xhtml('Cover', '<div style="text-align:center"><img src="cover.jpg" alt="Cover"/></div>'));
chapters.forEach((c) => o.file(`${c.id}.xhtml`, xhtml(c.title, toX(`<h1>${esc(c.title)}</h1>${c.html}`))));
Because mimetype is added first with STORE, it satisfies the ordering rule as long as you do not add anything before it.
The navigation document
o.file('nav.xhtml', xhtml('Contents', `<nav epub:type="toc" id="toc"><h1>Contents</h1><ol>
${chapters.map((c) => `<li><a href="${c.id}.xhtml">${esc(c.title)}</a></li>`).join('\n')}
</ol></nav>`));
Keep the label language consistent with the book: an English book with a Thai "สารบัญ" heading is the kind of thing a store previewer shows and a reader notices.
The package file
o.file('content.opf', `<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid" xml:lang="${lang}">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="uid">urn:uuid:${uuid}</dc:identifier>
<dc:title>${esc(title)}</dc:title>
<dc:creator>${esc(author)}</dc:creator>
<dc:language>${lang}</dc:language>
<meta property="dcterms:modified">${new Date().toISOString().replace(/\.\d+Z$/, 'Z')}</meta>
</metadata>
<manifest>
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
<item id="css" href="style.css" media-type="text/css"/>
<item id="cover-image" href="cover.jpg" media-type="image/jpeg" properties="cover-image"/>
<item id="cover" href="cover.xhtml" media-type="application/xhtml+xml"/>
${chapters.map((c) => `<item id="${c.id}" href="${c.id}.xhtml" media-type="application/xhtml+xml"/>`).join('\n ')}
</manifest>
<spine>
<itemref idref="cover"/><itemref idref="nav"/>
${chapters.map((c) => `<itemref idref="${c.id}"/>`).join('')}
</spine>
</package>`);
const buf = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE', mimeType: 'application/epub+zip' });
fs.writeFileSync('book.epub', buf);
dcterms:modified must be a second-precision UTC timestamp without fractions; validators complain otherwise.
Validate
Open the file in a store's previewer or run EPUBCheck. The errors that come up most are unescaped ampersands in text, a missing properties="nav", and mimetype in the wrong place.
Summary
Store mimetype first and uncompressed, convert each chapter to XHTML, write a nav document and a package file with every item in the manifest and the reading order in the spine, and zip it with jszip. The same script can feed a PDF build, covered in PDF from HTML with headless Chrome.