diff --git a/.gitignore b/.gitignore
index b79279f..46fdfbe 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,5 @@ TODOs.md
.eslintcache
.direnv/
.pre-commit-config.yaml
+.temp
+*.epub
\ No newline at end of file
diff --git a/docs/en/nix-store/host-your-own-binary-cache-server.md b/docs/en/nix-store/host-your-own-binary-cache-server.md
index c6475cb..b65df2c 100644
--- a/docs/en/nix-store/host-your-own-binary-cache-server.md
+++ b/docs/en/nix-store/host-your-own-binary-cache-server.md
@@ -220,7 +220,7 @@ automatically removed after a specified number of days.
This is useful for keeping the cache size manageable and ensuring that outdated binaries
are not stored indefinitely.
-## References {#references}
+## References
- [Blog post by Jeff on Nix binary caches](https://jcollie.github.io/nixos/2022/04/27/nixos-binary-cache-2022.html)
- [Binary cache in the NixOS wiki](https://wiki.nixos.org/wiki/Binary_Cache)
diff --git a/epub-export.ts b/epub-export.ts
new file mode 100644
index 0000000..23b2885
--- /dev/null
+++ b/epub-export.ts
@@ -0,0 +1,167 @@
+import config from "./docs/.vitepress/config"
+import fs from "fs"
+import path from "path"
+import { exec } from "child_process"
+
+const sidebar: {
+ text: string
+ items?: { text: string; link: string }[]
+}[] = config.locales!.root.themeConfig!.sidebar as any
+
+// -------- helpers --------
+
+/**
+ * Reduce fence opener to plain ```lang (strip any {...}/attributes).
+ * Also map shell/console → bash. If no lang, keep plain ``` only.
+ */
+function normalizeFenceOpeners(md: string): string {
+ return md.split(/(```[\s\S]*?```)/g).map(block => {
+ if (!block.startsWith("```")) return block
+
+ const lines = block.split("\n")
+ const opener = lines[0]
+ const m = opener.match(/^```([^\n]*)$/)
+ if (!m) return block
+
+ let info = m[1].trim()
+
+ // Attribute form: ```{.nix ...} → extract first class as lang
+ if (info.startsWith("{")) {
+ const mm = info.match(/\.([a-zA-Z0-9_-]+)/)
+ const lang = mm ? mm[1] : ""
+ const mapped = (lang === "shell" || lang === "console") ? "bash" : lang
+ lines[0] = "```" + (mapped || "")
+ return lines.join("\n")
+ }
+
+ // Info-string form: ```lang{...} or ```lang
+ const mm = info.match(/^([a-zA-Z0-9_-]+)(\{[^}]*\})?$/) // ignore tail
+ if (!mm) {
+ // unknown → leave as-is
+ lines[0] = "```" + info
+ return lines.join("\n")
+ }
+
+ let lang = mm[1]
+ if (lang === "shell" || lang === "console") lang = "bash"
+
+ lines[0] = "```" + (lang || "")
+ return lines.join("\n")
+ }).join("")
+}
+
+/** Add left-gutter line numbers as literal text (e.g., " 1 | …") inside fenced blocks. */
+function addLineNumbersToFences(md: string): string {
+ return md.split(/(```[\s\S]*?```)/g).map(block => {
+ if (!block.startsWith("```")) return block
+
+ const lines = block.split("\n")
+ // find closing fence
+ let closeIdx = lines.length - 1
+ while (closeIdx > 0 && !lines[closeIdx].startsWith("```")) closeIdx--
+
+ const opener = lines[0]
+ const body = lines.slice(1, closeIdx)
+ const width = Math.max(1, String(body.length).length)
+
+ const numbered = body.map((l, i) => `${String(i + 1).padStart(width, " ")} | ${l}`)
+ const tail = lines.slice(closeIdx) // includes closing fence
+ return [opener, ...numbered, ...tail].join("\n")
+ }).join("")
+}
+
+/** Apply XHTML + path fixes only outside fenced code blocks. */
+function sanitizeOutsideCode(md: string): string {
+ return md.split(/(```[\s\S]*?```)/g).map(part => {
+ if (part.startsWith("```")) return part
+ return part
+ .replace(/
/g, "
")
+ .replace(/
]*?)(?/g, "
")
+ .replace(/!\[([^\]]*)\]\(\/([^)]*)\)/g, "") // MD images /foo → foo
+ .replace(/src="\/([^"]+)"/g, 'src="$1"') // HTML
→ "foo"
+ }).join("")
+}
+
+// -------- setup .temp --------
+
+const tempDir = ".temp"
+if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true })
+fs.mkdirSync(tempDir, { recursive: true })
+
+// --- Generate file list ---
+let fileList: string[] = []
+
+for (const category of sidebar) {
+ if (category.items) {
+ for (const item of category.items) {
+ if (item.link && item.link.endsWith(".md")) {
+ const filePath = path.join("en", item.link).replace(/\\/g, "/")
+ fileList.push(filePath)
+ }
+ }
+ }
+}
+
+console.log("Files to include:", fileList)
+
+// --- Copy and patch Markdown files into .temp ---
+for (const relFile of fileList) {
+
+ const srcPath = path.join("docs", relFile)
+ const dstPath = path.join(tempDir, relFile)
+
+ fs.mkdirSync(path.dirname(dstPath), { recursive: true })
+ let content = fs.readFileSync(srcPath, "utf8")
+
+ // 1) Strip attributes/ranges: end up with plain ```lang (alias shell→bash)
+ content = normalizeFenceOpeners(content)
+
+ // 2) XHTML + path fixes only outside code
+ content = sanitizeOutsideCode(content)
+
+ // 3) Inline line numbers (start at 1)
+ content = addLineNumbersToFences(content)
+
+ fs.writeFileSync(dstPath, content)
+}
+
+// --- Write Kindle CSS fix ---
+const css = `
+/* Fix Kindle extra spacing in Pandoc-highlighted code blocks */
+code.sourceCode > span { display: inline !important; } /* override inline-block */
+pre > code.sourceCode > span { display: inline !important; } /* extra safety */
+pre { line-height: 1.2 !important; margin: 0 !important; } /* tighten & remove gaps */
+pre code { display: block; padding: 0; margin: 0; }
+pre, code { font-variant-ligatures: none; } /* avoid odd ligature spacing */
+pre > code.sourceCode { white-space: pre; } /* don’t pre-wrap lines */
+`;
+fs.writeFileSync(path.join(tempDir, "epub-fixes.css"), css);
+
+
+// --- Run Pandoc ---
+const outputFileName = "../nixos-and-flakes-book.epub"
+const pandocCommand = `pandoc ${fileList.join(" ")} \
+ -o ${outputFileName} \
+ --from=markdown+gfm_auto_identifiers+pipe_tables+raw_html+tex_math_dollars+fenced_code_blocks+fenced_code_attributes \
+ --to=epub3 \
+ --standalone \
+ --toc --toc-depth=2 \
+ --number-sections \
+ --embed-resources \
+ --highlight-style=tango \
+ --css=epub-fixes.css \
+ --metadata=title:"NixOS and Flakes Book" \
+ --metadata=author:"Ryan Yin" \
+ --resource-path=.:../docs/public:en`
+
+console.log("🚀 Executing pandoc:", pandocCommand)
+
+exec(pandocCommand, { cwd: tempDir }, (error, stdout, stderr) => {
+ if (error) {
+ console.error(`❌ Pandoc failed: ${error}`)
+ return
+ }
+ if (stdout) console.log(stdout)
+ if (stderr) console.error(stderr)
+ console.log("✅ EPUB generated:", outputFileName)
+})
diff --git a/package.json b/package.json
index f7e0392..84c96ed 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,8 @@
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs",
- "export-pdf": "press-export-pdf export ./docs --outFile ./nixos-and-flakes-book.pdf"
+ "export-pdf": "press-export-pdf export ./docs --outFile ./nixos-and-flakes-book.pdf",
+ "export-epub": "npx tsx epub-export.ts"
},
"dependencies": {
"@searking/markdown-it-cjk-breaks": "2.0.1-0",