De Markdown à EPUB en OEB/OEBPS avec Node.js: pipeline minimal et reproductible
Crée un EPUB OEBPS (OPF 2.0 + NCX) depuis Markdown avec Node.js. Pipeline minimal, reproductible et sans outils lourds. Prêt pour la publication.
Sommaire
De Markdown à EPUB en OEB/OEBPS avec Node.js: pipeline minimal et reproductible
Passer d’un dossier de fichiers Markdown à un EPUB 2 parfaitement lisible sur des liseuses anciennes comme sur des applis modernes est possible sans outil lourd. Dans ce tutoriel, on met en place un pipeline Node.js reproductible qui génère du XHTML 1.1 propre, un package OPF 2.0, une table des matières NCX, puis un .epub correctement empaqueté et validé par EpubCheck. À la fin, vous aurez un script unique qui transforme votre manuscrit en un livre prêt à publier.
Objectif
L’objectif est de construire un EPUB 2 au format OEB/OEBPS sans recourir à des convertisseurs généralistes. On va:
- convertir chaque chapitre Markdown en XHTML 1.1 strict, avec les feuilles de style et les identifiants d’ancre cohérents;
- générer un fichier package OPF 2.0 contenant les métadonnées, le manifest, le spine et le guide;
- construire une table des matières NCX fiable et limitée en profondeur pour rester compatible;
- empaqueter l’ensemble en .epub avec l’ordre et la méthode de compression exigés par la spécification;
- valider le résultat avec EpubCheck et tester sur plusieurs lecteurs.
Un exemple concret: si vous avez 01-intro.md, 02-setup.md, 03-conclusion.md, vous obtiendrez OEBPS/chapter-01.xhtml, OEBPS/chapter-02.xhtml, OEBPS/chapter-03.xhtml, un content.opf et un toc.ncx, puis un dist/book.epub que vous pourrez ouvrir dans Apple Books, Thorium ou Calibre.
Objectif et périmètre (8–12 min, concret)
On cible EPUB 2 basé sur OEB/OEBPS: OPF 2.0, NCX, et XHTML 1.1. Ce choix maximise la compatibilité (liseuses anciennes, moteurs basés sur Adobe RMSDK, etc.). L’entrée est un ensemble de fichiers .md triés par préfixe numérique; la sortie est un unique book.epub. On reste sur un environnement léger: Node.js et quelques bibliothèques. L’empaquetage doit respecter l’OCF: fichier mimetype en première entrée du ZIP, non compressé, suivi du reste compressé. En suivant ce guide et en copiant les snippets, vous pouvez produire un premier EPUB en moins d’un quart d’heure.
Pré-requis et structure de travail
Installez Node.js 18+ (pour les APIs fs/promises modernes), Java (pour EpubCheck), et un utilitaire zip/unzip si vous préférez la CLI au lieu d’une lib Node pour ZIP.
Organisez le projet comme suit, avec des dossiers clairs pour le manuscrit et les assets:
.
├── manuscript/
│ ├── 01-intro.md
│ ├── 02-setup.md
│ └── 03-conclusion.md
├── assets/
│ ├── css/
│ │ └── styles.css
│ ├── images/
│ │ └── cover.jpg
│ └── fonts/
│ └── SourceSerif-Regular.ttf
├── build/ (généré)
│ └── OEBPS/...
├── dist/ (généré)
│ └── book.epub
├── package.json
└── build.js
La convention de tri repose sur un préfixe numérique (01-, 02-, 03-). Cela nous permet de parcourir les fichiers dans l’ordre, de numéroter les chapitres et de construire la table des matières.
Rappels OEB/OEBPS minimaux à respecter
Un EPUB 2 valide repose sur quelques incontournables:
- Le fichier mimetype doit être la première entrée du ZIP, non compressée, et contenir exactement la chaîne:
application/epub+zip(sans saut de ligne final). - Le dossier META-INF doit contenir un container.xml qui pointe vers OEBPS/content.opf. Exemple minimal de META-INF/container.xml:
<?xml version="1.0" encoding="UTF-8"?>
<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>
- Le dossier OEBPS contient au minimum: content.opf (OPF 2.0), toc.ncx (NCX), les chapitres en XHTML 1.1, la CSS, les images (ex: cover.jpg).
- Le balisage XHTML doit être bien formé (XML), codé en UTF-8 sans BOM. Fermez toutes les balises, utilisez des attributs correctement quotés et préférez des ids stables pour les ancres.
Initialiser le projet Node
Initialisez le projet, créez des scripts npm et installez les dépendances nécessaires à la conversion, à la génération XML et à l’archivage.
mkdir epub-pipeline && cd epub-pipeline
npm init -y
# Basculer en ESM pour des imports modernes
node -e "let p=require('./package.json'); p.type='module'; p.scripts={}; require('fs').writeFileSync('package.json', JSON.stringify(p, null, 2));"
# Dépendances de conversion et support
npm i unified remark-parse remark-rehype rehype-format rehype-stringify sanitize-html
npm i hast-util-to-html mdast-util-to-string github-slugger
# XML et utilitaires
npm i xmlbuilder glob fs-extra yazl uuid mime-types
# Types (optionnel, si vous utilisez TS)
# npm i -D typescript @types/node
# Qualité de vie
npm i -D prettier
Ajoutez des scripts utiles dans package.json:
{
"name": "epub-pipeline",
"version": "1.0.0",
"type": "module",
"scripts": {
"clean": "node -e \"require('fs').rmSync('build',{recursive:true,force:true}); require('fs').rmSync('dist',{recursive:true,force:true});\"",
"build": "node build.js",
"package": "node build.js --package",
"validate": "java -jar tools/epubcheck.jar dist/book.epub || exit 1",
"watch": "node build.js --watch"
}
}
Note: placez epubcheck.jar dans tools/ (par exemple tools/epubcheck-5.2.3/epubcheck.jar) et ajustez le chemin si besoin.
Conversion Markdown -> XHTML 1.1
On parcourt les fichiers de manuscript dans l’ordre, on les transforme en arbre mdast puis hast avec unified, et on sérialise en XHTML strict. On enveloppe chaque chapitre avec un template XHTML 1.1, on nettoie le HTML si besoin (sanitize-html), et on collecte les titres et ids pour construire la NCX.
Créez build.js et implémentez la conversion:
// build.js
import fs from 'fs/promises';
import path from 'path';
import { globby } from 'glob';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import rehypeFormat from 'rehype-format';
import rehypeStringify from 'rehype-stringify';
import sanitizeHtml from 'sanitize-html';
import { v4 as uuidv4 } from 'uuid';
import { create } from 'xmlbuilder';
import yazl from 'yazl';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
import Slugger from 'github-slugger';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT = __dirname;
const MANUSCRIPT_DIR = path.join(ROOT, 'manuscript');
const ASSETS_DIR = path.join(ROOT, 'assets');
const BUILD_DIR = path.join(ROOT, 'build');
const OEBPS_DIR = path.join(BUILD_DIR, 'OEBPS');
const DIST_DIR = path.join(ROOT, 'dist');
const BOOK_ID = uuidv4(); // Stable à persister si besoin
function ensureDirs() {
return Promise.all([
fs.mkdir(BUILD_DIR, { recursive: true }),
fs.mkdir(OEBPS_DIR, { recursive: true }),
fs.mkdir(path.join(ROOT, 'META-INF'), { recursive: true }),
fs.mkdir(DIST_DIR, { recursive: true }),
]);
}
async function listMarkdownFiles() {
const files = await globby(['manuscript/*.md']);
files.sort(); // grâce aux préfixes 01-, 02-, ...
return files;
}
function wrapXhtml({ title, body, cssHref = 'styles.css', lang = 'fr' }) {
// XHTML 1.1 compatible, XML bien formé
return [
'<?xml version="1.0" encoding="utf-8"?>',
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">',
`<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="${lang}">`,
'<head>',
`<meta http-equiv="Content-Type" content="application/xhtml+xml; charset=utf-8" />`,
`<title>${escapeXml(title)}</title>`,
`<link rel="stylesheet" type="text/css" href="${cssHref}" />`,
'</head>',
'<body>',
body,
'</body>',
'</html>',
].join('\n');
}
function escapeXml(str) {
return str.replace(/[<>&'"]/g, c => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' }[c]));
}
function mdToHtmlProcessor() {
return unified().use(remarkParse).use(remarkRehype).use(rehypeFormat).use(rehypeStringify, {
closeSelfClosing: true,
closeEmptyElements: true,
allowParseErrors: false,
omitOptionalTags: false
});
}
function extractTitleFromMarkdown(mdContent, fallback = 'Sans titre') {
// Le premier titre de niveau 1 ou 2 sert d’intitulé de chapitre
const lines = mdContent.split(/\r?\n/);
for (const line of lines) {
const m = line.match(/^(#|##)\s+(.+?)\s*$/);
if (m) return m[2].trim();
}
return fallback;
}
function normalizeIds(html, slugger) {
// Définir un id sur la première ancre niveau 1-3 si absent, et slugger pour cohérence
return html.replace(/<(h[1-3])(\s+[^>]*)?>(.*?)<\/\1>/g, (full, tag, attrs = '', inner) => {
const text = inner.replace(/<[^>]+>/g, '').trim();
let idMatch = attrs && attrs.match(/\sid=["']([^"']+)["']/);
let id = idMatch ? idMatch[1] : slugger.slug(text || 'section');
if (idMatch) {
// garantir l’unicité
id = slugger.slug(id);
attrs = attrs.replace(/\sid=["'][^"']+["']/, ` id="${id}"`);
} else {
attrs = (attrs || '') + ` id="${id}"`;
}
return `<${tag}${attrs}>${inner}</${tag}>`;
});
}
async function convertMarkdownToXhtml() {
const files = await listMarkdownFiles();
const processor = mdToHtmlProcessor();
const chapters = [];
const slugger = new Slugger();
await fs.mkdir(OEBPS_DIR, { recursive: true });
for (let i = 0; i < files.length; i++) {
slugger.reset();
const file = files[i];
const num = String(i + 1).padStart(2, '0');
const basename = path.basename(file, '.md');
const raw = await fs.readFile(file, 'utf8');
const title = extractTitleFromMarkdown(raw, `Chapitre ${num}`);
const vfile = await processor.process(raw);
let html = String(vfile);
html = sanitizeHtml(html, {
allowedTags: false, // autoriser par défaut les éléments de base
allowedAttributes: false,
transformTags: { img: (tagName, attribs) => ({ tagName, attribs: { ...attribs, alt: attribs.alt || '' } }) }
});
html = normalizeIds(html, slugger);
const wrapped = wrapXhtml({
title,
body: html,
cssHref: 'styles.css',
lang: 'fr'
});
const xhtmlName = `chapter-${num}.xhtml`;
await fs.writeFile(path.join(OEBPS_DIR, xhtmlName), wrapped, 'utf8');
// Collecte pour OPF/NCX
// On prend l’id du premier titre (h1..h3) comme point d’ancrage
const anchorMatch = wrapped.match(/<(h[1-3])\s+[^>]*id="([^"]+)"/);
const anchorId = anchorMatch ? anchorMatch[2] : null;
chapters.push({
href: xhtmlName,
title,
anchor: anchorId ? `${xhtmlName}#${anchorId}` : xhtmlName
});
}
return chapters;
}
Cette fonction produit pour chaque Markdown un fichier XHTML 1.1 valide, les range dans build/OEBPS et renvoie une liste de chapitres avec leur ancre (utile pour la NCX). On a volontairement normalisé les ids de titres avec github-slugger pour éviter les doublons.
Générer content.opf (OPF 2.0)
Le OPF contient des métadonnées Dublin Core, le manifest de toutes les ressources, l’ordre de lecture (spine) et un guide optionnel. On le génère à partir des chapitres trouvés, des assets présents et des infos d’édition.
import { lookup as mimeLookup } from 'mime-types';
// ...
function buildOpfXml({ metadata, manifestItems, spineIds, guideRefs }) {
const doc = create('package', { encoding: 'UTF-8' })
.att('version', '2.0')
.att('xmlns', 'http://www.idpf.org/2007/opf')
.att('unique-identifier', 'BookId');
const md = doc.ele('metadata', { 'xmlns:dc': 'http://purl.org/dc/elements/1.1/' });
md.ele('dc:identifier', { id: 'BookId' }, metadata.identifier);
md.ele('dc:title').txt(metadata.title);
md.ele('dc:language').txt(metadata.language || 'fr');
if (metadata.creator) md.ele('dc:creator').txt(metadata.creator);
if (metadata.publisher) md.ele('dc:publisher').txt(metadata.publisher);
if (metadata.date) md.ele('dc:date').txt(metadata.date);
const manifest = doc.ele('manifest');
// NCX
manifest.ele('item', {
id: 'ncx',
href: 'toc.ncx',
'media-type': 'application/x-dtbncx+xml'
});
// CSS
manifest.ele('item', {
id: 'css',
href: 'styles.css',
'media-type': 'text/css'
});
// autres items (chapitres, images, polices, cover.xhtml)
for (const item of manifestItems) {
manifest.ele('item', {
id: item.id,
href: item.href,
'media-type': item.mediaType,
...(item.properties ? { properties: item.properties } : {})
});
}
const spine = doc.ele('spine', { toc: 'ncx' });
for (const idref of spineIds) {
spine.ele('itemref', { idref });
}
if (guideRefs && guideRefs.length) {
const guide = doc.ele('guide');
for (const g of guideRefs) {
guide.ele('reference', { type: g.type, title: g.title, href: g.href });
}
}
return doc.end({ prettyPrint: true });
}
async function collectAssetsForManifest() {
const items = [];
const files = await globby(['assets/**/*.*']);
for (const f of files) {
const rel = path.relative(path.join(ROOT, 'assets'), f).replace(/\\/g, '/');
const outPath = path.join(OEBPS_DIR, rel);
await fs.mkdir(path.dirname(outPath), { recursive: true });
await fs.copyFile(f, outPath);
const mediaType = mimeLookup(rel) || guessMediaType(rel);
const id = rel.replace(/[^\w]+/g, '_');
items.push({ id, href: rel, mediaType });
}
return items;
}
function guessMediaType(rel) {
// fallback pour quelques types connus
if (rel.endsWith('.xhtml')) return 'application/xhtml+xml';
if (rel.endsWith('.ttf')) return 'application/x-font-ttf';
if (rel.endsWith('.otf')) return 'application/vnd.ms-opentype';
if (rel.endsWith('.woff')) return 'font/woff';
if (rel.endsWith('.svg')) return 'image/svg+xml';
return 'application/octet-stream';
}
async function writeOpf(chapters, { meta }) {
const manifestItems = [];
// Ajout des chapitres au manifest
chapters.forEach((ch, idx) => {
manifestItems.push({
id: `chap_${String(idx + 1).padStart(2, '0')}`,
href: ch.href,
mediaType: 'application/xhtml+xml'
});
});
// Copier assets et déclarer dans le manifest
const assetItems = await collectAssetsForManifest();
manifestItems.push(...assetItems);
// Cover XHTML (facultatif mais recommandé)
const hasCover = assetItems.some(a => a.href === 'images/cover.jpg');
if (hasCover) {
const coverXhtml = wrapXhtml({
title: 'Couverture',
body: `<div><img src="images/cover.jpg" alt="Couverture" /></div>`
});
await fs.writeFile(path.join(OEBPS_DIR, 'cover.xhtml'), coverXhtml, 'utf8');
manifestItems.push({
id: 'cover_xhtml',
href: 'cover.xhtml',
mediaType: 'application/xhtml+xml'
});
}
// Spine
const spineIds = [];
if (hasCover) spineIds.push('cover_xhtml');
chapters.forEach((_, idx) => spineIds.push(`chap_${String(idx + 1).padStart(2, '0')}`));
// Guide EPUB 2
const guideRefs = [];
if (hasCover) {
guideRefs.push({ type: 'cover', title: 'Couverture', href: 'cover.xhtml' });
}
const opfXml = buildOpfXml({
metadata: {
identifier: meta.identifier || `urn:uuid:${BOOK_ID}`,
title: meta.title || 'Mon livre',
language: meta.language || 'fr',
creator: meta.creator || 'Auteur',
publisher: meta.publisher || undefined,
date: meta.date || new Date().toISOString().slice(0, 10)
},
manifestItems,
spineIds,
guideRefs
});
await fs.writeFile(path.join(OEBPS_DIR, 'content.opf'), opfXml, 'utf8');
}
Exemple d’éléments manifest pour les images: image/jpeg (et non image/jpg). Pour des polices TrueType, utilisez application/x-font-ttf; pour WOFF, font/woff. Déclarez les CSS sous text/css et les chapitres sous application/xhtml+xml.
Construire toc.ncx (NCX)
La NCX relie les titres aux chapitres. On insère les métadonnées dtb:uid, depth, et la liste ordonnée des navPoint avec playOrder croissant. On se base sur les ancres collectées pendant la conversion.
function buildNcxXml({ uid, title, author, navPoints }) {
const doc = create('ncx', { version: '1.0', encoding: 'UTF-8' })
.att('xmlns', 'http://www.daisy.org/z3986/2005/ncx/')
.att('version', '2005-1');
const head = doc.ele('head');
head.ele('meta', { name: 'dtb:uid', content: uid });
head.ele('meta', { name: 'dtb:depth', content: '2' });
head.ele('meta', { name: 'dtb:totalPageCount', content: '0' });
head.ele('meta', { name: 'dtb:maxPageNumber', content: '0' });
const docTitle = doc.ele('docTitle');
docTitle.ele('text').txt(title);
if (author) {
const docAuthor = doc.ele('docAuthor');
docAuthor.ele('text').txt(author);
}
const navMap = doc.ele('navMap');
let order = 1;
for (const np of navPoints) {
const navPoint = navMap.ele('navPoint', { id: np.id, playOrder: String(order++) });
navPoint.ele('navLabel').ele('text').txt(np.label);
navPoint.ele('content', { src: np.src });
}
return doc.end({ prettyPrint: true });
}
async function writeNcx(chapters, { meta }) {
const navPoints = chapters.map((ch, i) => ({
id: `navPoint-${String(i + 1).padStart(2, '0')}`,
label: ch.title,
src: ch.anchor
}));
const ncx = buildNcxXml({
uid: meta.identifier || `urn:uuid:${BOOK_ID}`,
title: meta.title || 'Mon livre',
author: meta.creator || 'Auteur',
navPoints
});
await fs.writeFile(path.join(OEBPS_DIR, 'toc.ncx'), ncx, 'utf8');
}
Pour limiter la profondeur à 2–3 niveaux, ne listez que les grands titres (h1/h2). Ici, on se contente d’un niveau 1 par chapitre, suffisamment compatible pour EPUB 2.
Ressources: CSS, couverture, polices
Préparez une CSS minimale pour une typographie sobre. Voici un exemple assets/css/styles.css:
html, body { margin: 0; padding: 0; }
body { font-family: "Source Serif", serif; line-height: 1.4; font-size: 1em; widows: 2; orphans: 2; }
h1, h2, h3 { page-break-after: avoid; }
img { max-width: 100%; height: auto; }
p { margin: 0 0 1em 0; }
code, pre { font-family: "Courier New", monospace; }
@page { margin: 5%;}
@font-face {
font-family: "Source Serif";
src: url("../fonts/SourceSerif-Regular.ttf") format("truetype");
font-weight: normal;
font-style: normal;
}
La couverture consiste en une image cover.jpg et une page cover.xhtml simple déjà générée dans writeOpf. Dans le manifest OPF, l’image n’a pas besoin d’une propriété spéciale en EPUB 2, mais le guide inclut une référence type="cover". Vérifiez les licences de vos polices et privilégiez TTF ou OTF (WOFF passe sur de nombreux lecteurs, mais EPUB 2 historiques ne l’ont pas toujours supporté).
Emballage EPUB conforme
Créez le fichier mimetype à la racine, le container.xml dans META-INF, puis zipez dans l’ordre: mimetype sans compression, le reste compressé. Avec yazl, vous contrôlez précisément l’ordre et la méthode.
async function writeContainerXml() {
const metaInfDir = path.join(BUILD_DIR, 'META-INF');
await fs.mkdir(metaInfDir, { recursive: true });
const container = `<?xml version="1.0" encoding="UTF-8"?>
<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>`;
await fs.writeFile(path.join(metaInfDir, 'container.xml'), container, 'utf8');
}
async function packageEpub() {
await fs.mkdir(DIST_DIR, { recursive: true });
const outPath = path.join(DIST_DIR, 'book.epub');
// Nettoyage ancien fichier
try { await fs.unlink(outPath); } catch {}
const zipfile = new yazl.ZipFile();
// 1) mimetype non compressé, en premier
zipfile.addBuffer(Buffer.from('application/epub+zip'), 'mimetype', { compress: false });
// 2) META-INF et OEBPS (compressés)
await addDirToZip(zipfile, path.join(BUILD_DIR, 'META-INF'), 'META-INF');
await addDirToZip(zipfile, OEBPS_DIR, 'OEBPS');
const writeStream = (await import('fs')).createWriteStream(outPath);
zipfile.outputStream.pipe(writeStream);
zipfile.end();
await new Promise((res, rej) => {
writeStream.on('close', res);
writeStream.on('error', rej);
});
console.log(`EPUB écrit: ${outPath}`);
}
async function addDirToZip(zipfile, absDir, zipPrefix) {
const entries = await globby(['**/*'], { cwd: absDir, dot: true });
for (const rel of entries) {
const abs = path.join(absDir, rel);
const stat = await (await import('fs')).promises.stat(abs);
if (stat.isFile()) {
zipfile.addFile(abs, path.join(zipPrefix, rel), { compress: true });
}
}
}
Alternative avec la CLI zip, si installée:
cd build
printf "application/epub+zip" > mimetype
zip -X0 ../dist/book.epub mimetype
zip -Xr9D ../dist/book.epub META-INF OEBPS
La clé est que mimetype soit le premier et non compressé. Un ordre incorrect rendra l’EPUB illisible pour certains moteurs.
Validation et tests
Validez systématiquement avec EpubCheck et essayez plusieurs lecteurs:
java -jar tools/epubcheck.jar dist/book.epub
Corrigez toutes les erreurs: media-type manquants ou erronés, ids dupliqués, href cassés, attribut xml:lang omis, etc. Ouvrez le livre dans Calibre, Apple Books, Thorium pour vérifier la couverture, la table des matières et la progression de lecture. En cas de doute, inspectez le ZIP:
unzip -l dist/book.epub
et vérifiez l’encodage (UTF-8, sans BOM) de vos XHTML:
file -I build/OEBPS/chapter-01.xhtml
Automatiser: scripts npm et watch
Automatisez le cycle complet: nettoyage, build, package, validation. On peut ajouter un mode watch qui reconstruit à l’enregistrement.
Complétez build.js avec un orchestrateur:
async function main() {
const args = process.argv.slice(2);
const watch = args.includes('--watch');
const doPackage = args.includes('--package') || !watch;
await ensureDirs();
// Copie styles.css au bon endroit si besoin
await fs.mkdir(path.join(OEBPS_DIR, 'css'), { recursive: true });
const styleSrc = path.join(ASSETS_DIR, 'css', 'styles.css');
const styleDst = path.join(OEBPS_DIR, 'styles.css');
try { await fs.copyFile(styleSrc, styleDst); } catch {}
const meta = {
identifier: `urn:uuid:${BOOK_ID}`,
title: 'Exemple EPUB',
language: 'fr',
creator: 'Jane Doe',
publisher: 'Maison d’édition'
};
const chapters = await convertMarkdownToXhtml();
await writeNcx(chapters, { meta });
await writeOpf(chapters, { meta });
await writeContainerXml();
if (doPackage) {
await packageEpub();
}
if (watch) {
watchFiles(meta);
}
}
function watchFiles(meta) {
console.log('Watch mode activé. Modifiez vos .md ou assets pour reconstruire.');
const fsn = (await import('fs')).default; // trick ESM top-level
}
main().catch(e => {
console.error(e);
process.exit(1);
});
Pour un watch simple sans dépendance supplémentaire, vous pouvez relancer les étapes clés sur changement avec fs.watch. Exemple minimal à insérer à la place de watchFiles(meta):
import { watch as fsWatch } from 'fs';
function watchFiles(meta) {
const rebuild = async () => {
const chapters = await convertMarkdownToXhtml();
await writeNcx(chapters, { meta });
await writeOpf(chapters, { meta });
await writeContainerXml();
console.log('Rebuild terminé.');
};
fsWatch(MANUSCRIPT_DIR, { recursive: true }, debounce(rebuild, 200));
fsWatch(ASSETS_DIR, { recursive: true }, debounce(rebuild, 200));
}
function debounce(fn, delay) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), delay);
};
}
Une fois en place, lancez:
npm run clean
npm run build
npm run package
npm run validate
npm run watch
Pièges courants et check-list
Le premier piège est l’empaquetage: si mimetype n’est pas premier et stocké sans compression, certains lecteurs refusent d’ouvrir le livre. Vérifiez l’ordre avec unzip -l. Deuxième cause fréquente d’échec: des media-type incorrects (par exemple image/jpg au lieu de image/jpeg). Appuyez-vous sur mime-types et corrigez les exceptions (polices notamment). Les ids dupliqués se glissent facilement lors de la conversion; utilisez un slugger et nettoyez/normalisez les id, surtout ceux des titres utilisés par la NCX. Si vos XHTML ne sont pas bien formés (balises oubliées, attributs non quotés), les moteurs stricts échoueront: confiez la sérialisation à rehype-stringify et gardez sanitize-html en filet de sécurité. Compressez les images lourdes et fixez des dimensions si nécessaire pour éviter les débordements. Enfin, la table des matières doit avoir un playOrder continu et des ancres existantes; testez les liens dans un lecteur, au besoin en ouvrant directement les fichiers .xhtml pour vérifier les ids.
Avant publication, relisez les métadonnées (titre, auteur, langue), testez les commandes de build/validate, ouvrez le .epub dans au moins deux lecteurs, et conservez une trace du BOOK_ID (UUID) si vous générez des révisions.
Optionnel: compat EPUB 3 sans casser OEB
Il est possible d’ajouter une navigation EPUB 3 moderne tout en conservant OPF 2.0 + NCX pour la rétrocompatibilité. Ajoutez un nav.xhtml et référencez-le dans le manifest avec properties="nav". De nombreux lecteurs EPUB 2 l’ignorent sans problème.
Exemple de nav.xhtml minimal:
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="fr">
<head>
<title>Sommaire</title>
<meta http-equiv="Content-Type" content="application/xhtml+xml; charset=utf-8" />
<link rel="stylesheet" type="text/css" href="styles.css" />
</head>
<body>
<nav epub:type="toc">
<h1>Sommaire</h1>
<ol>
<li><a href="chapter-01.xhtml">Chapitre 01</a></li>
<li><a href="chapter-02.xhtml">Chapitre 02</a></li>
</ol>
</nav>
</body>
</html>
Et ajoutez-le au manifest dans writeOpf:
manifest.ele('item', {
id: 'nav',
href: 'nav.xhtml',
'media-type': 'application/xhtml+xml',
properties: 'nav'
});
Conservez la NCX et le spine toc="ncx" inchangés. Testez sur plusieurs moteurs pour vérifier l’absence de régression.
Livrables finaux
Au terme du pipeline, vous obtenez:
- un dist/book.epub validé par EpubCheck, ouvrable sur liseuses et applications courantes;
- un script Node reproductible (build.js) et des scripts npm pour enchaîner clean, build, package, validate;
- un exemple de projet avec trois chapitres Markdown, une couverture, une CSS, et des OPF/NCX générés automatiquement.
Arborescence finale attendue:
build/
├── META-INF/
│ └── container.xml
└── OEBPS/
├── content.opf
├── toc.ncx
├── styles.css
├── cover.xhtml
├── chapter-01.xhtml
├── chapter-02.xhtml
├── chapter-03.xhtml
├── images/
│ └── cover.jpg
└── fonts/
└── SourceSerif-Regular.ttf
dist/
└── book.epub
Checklist
Relisez chaque chapitre dans son rendu XHTML pour repérer les artefacts de conversion (listes, code inline, images). Testez les commandes npm run build, npm run package et npm run validate pour vous assurer qu’elles produisent toujours le même résultat sur une machine propre. Publiez ensuite le .epub validé et archivez le projet avec son package.json et build.js pour pouvoir reconstruire à l’identique plus tard.
Ressources
- Spécification OCF (conteneur EPUB): https://www.idpf.org/epub/20/spec/OPF_2.0.1_draft.htm#Section2
- Spécification OPF 2.0: https://www.idpf.org/epub/20/spec/OPF_2.0.1_draft.htm
- Spécification NCX (DAISY): https://www.niso.org/publications/ansiniso-z3986-2005-r2012
- EpubCheck: https://github.com/w3c/epubcheck
- unified/remark/rehype: https://unifiedjs.com/ et https://github.com/remarkjs/remark et https://github.com/rehypejs/rehype
- sanitize-html: https://github.com/apostrophecms/sanitize-html
- xmlbuilder: https://github.com/oozcitak/xmlbuilder-js
- yazl: https://github.com/thejoshwolfe/yazl
- mime-types: https://github.com/jshttp/mime-types
- GitHub Slugger: https://github.com/Flet/github-slugger
- Guide media types (IANA): https://www.iana.org/assignments/media-types/media-types.xhtml
Conclusion
En s’appuyant sur Node.js et quelques bibliothèques ciblées, on peut produire un EPUB 2 propre et robuste à partir de simples fichiers Markdown, sans chaîne d’outils lourde. Le cœur du pipeline consiste à garantir un XHTML 1.1 bien formé, un OPF 2.0 exact, une NCX cohérente et un empaquetage OCF strict. Une fois ces fondations en place, vous pouvez enrichir votre livre (styles, polices, couverture), automatiser la construction et, si besoin, ajouter une compatibilité EPUB 3 avec un nav.xhtml. Conservez un œil sur EpubCheck et testez plusieurs lecteurs: c’est la meilleure assurance qualité avant publication. Bon build !