Epub via pandoc (#249)

* epub via pandoc
* Update .gitignore

Co-authored-by: Ryan Yin <xiaoyin_c@qq.com>
This commit is contained in:
David Buezas
2025-09-02 13:09:15 +02:00
committed by GitHub
parent 3dc2395649
commit 13f742283b
4 changed files with 172 additions and 2 deletions

2
.gitignore vendored
View File

@@ -20,3 +20,5 @@ TODOs.md
.eslintcache .eslintcache
.direnv/ .direnv/
.pre-commit-config.yaml .pre-commit-config.yaml
.temp
*.epub

View File

@@ -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 This is useful for keeping the cache size manageable and ensuring that outdated binaries
are not stored indefinitely. 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) - [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) - [Binary cache in the NixOS wiki](https://wiki.nixos.org/wiki/Binary_Cache)

167
epub-export.ts Normal file
View File

@@ -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(/<br\s*>/g, "<br />")
.replace(/<img([^>]*?)(?<!\/)>/g, "<img$1 />")
.replace(/!\[([^\]]*)\]\(\/([^)]*)\)/g, "![$1]($2)") // MD images /foo → foo
.replace(/src="\/([^"]+)"/g, 'src="$1"') // HTML <img src="/foo"> → "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; } /* dont 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)
})

View File

@@ -8,7 +8,8 @@
"docs:dev": "vitepress dev docs", "docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs", "docs:build": "vitepress build docs",
"docs:preview": "vitepress preview 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": { "dependencies": {
"@searking/markdown-it-cjk-breaks": "2.0.1-0", "@searking/markdown-it-cjk-breaks": "2.0.1-0",