Skip to content

Commit 13f7422

Browse files
dbuezasryan4yin
andauthored
Epub via pandoc (#249)
* epub via pandoc * Update .gitignore Co-authored-by: Ryan Yin <[email protected]>
1 parent 3dc2395 commit 13f7422

File tree

4 files changed

+172
-2
lines changed

4 files changed

+172
-2
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,5 @@ TODOs.md
2020
.eslintcache
2121
.direnv/
2222
.pre-commit-config.yaml
23+
.temp
24+
*.epub

docs/en/nix-store/host-your-own-binary-cache-server.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ automatically removed after a specified number of days.
220220
This is useful for keeping the cache size manageable and ensuring that outdated binaries
221221
are not stored indefinitely.
222222

223-
## References {#references}
223+
## References
224224

225225
- [Blog post by Jeff on Nix binary caches](https://jcollie.github.io/nixos/2022/04/27/nixos-binary-cache-2022.html)
226226
- [Binary cache in the NixOS wiki](https://wiki.nixos.org/wiki/Binary_Cache)

epub-export.ts

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import config from "./docs/.vitepress/config"
2+
import fs from "fs"
3+
import path from "path"
4+
import { exec } from "child_process"
5+
6+
const sidebar: {
7+
text: string
8+
items?: { text: string; link: string }[]
9+
}[] = config.locales!.root.themeConfig!.sidebar as any
10+
11+
// -------- helpers --------
12+
13+
/**
14+
* Reduce fence opener to plain ```lang (strip any {...}/attributes).
15+
* Also map shell/console → bash. If no lang, keep plain ``` only.
16+
*/
17+
function normalizeFenceOpeners(md: string): string {
18+
return md.split(/(```[\s\S]*?```)/g).map(block => {
19+
if (!block.startsWith("```")) return block
20+
21+
const lines = block.split("\n")
22+
const opener = lines[0]
23+
const m = opener.match(/^```([^\n]*)$/)
24+
if (!m) return block
25+
26+
let info = m[1].trim()
27+
28+
// Attribute form: ```{.nix ...} → extract first class as lang
29+
if (info.startsWith("{")) {
30+
const mm = info.match(/\.([a-zA-Z0-9_-]+)/)
31+
const lang = mm ? mm[1] : ""
32+
const mapped = (lang === "shell" || lang === "console") ? "bash" : lang
33+
lines[0] = "```" + (mapped || "")
34+
return lines.join("\n")
35+
}
36+
37+
// Info-string form: ```lang{...} or ```lang
38+
const mm = info.match(/^([a-zA-Z0-9_-]+)(\{[^}]*\})?$/) // ignore tail
39+
if (!mm) {
40+
// unknown → leave as-is
41+
lines[0] = "```" + info
42+
return lines.join("\n")
43+
}
44+
45+
let lang = mm[1]
46+
if (lang === "shell" || lang === "console") lang = "bash"
47+
48+
lines[0] = "```" + (lang || "")
49+
return lines.join("\n")
50+
}).join("")
51+
}
52+
53+
/** Add left-gutter line numbers as literal text (e.g., " 1 | …") inside fenced blocks. */
54+
function addLineNumbersToFences(md: string): string {
55+
return md.split(/(```[\s\S]*?```)/g).map(block => {
56+
if (!block.startsWith("```")) return block
57+
58+
const lines = block.split("\n")
59+
// find closing fence
60+
let closeIdx = lines.length - 1
61+
while (closeIdx > 0 && !lines[closeIdx].startsWith("```")) closeIdx--
62+
63+
const opener = lines[0]
64+
const body = lines.slice(1, closeIdx)
65+
const width = Math.max(1, String(body.length).length)
66+
67+
const numbered = body.map((l, i) => `${String(i + 1).padStart(width, " ")} | ${l}`)
68+
const tail = lines.slice(closeIdx) // includes closing fence
69+
return [opener, ...numbered, ...tail].join("\n")
70+
}).join("")
71+
}
72+
73+
/** Apply XHTML + path fixes only outside fenced code blocks. */
74+
function sanitizeOutsideCode(md: string): string {
75+
return md.split(/(```[\s\S]*?```)/g).map(part => {
76+
if (part.startsWith("```")) return part
77+
return part
78+
.replace(/<br\s*>/g, "<br />")
79+
.replace(/<img([^>]*?)(?<!\/)>/g, "<img$1 />")
80+
.replace(/!\[([^\]]*)\]\(\/([^)]*)\)/g, "![$1]($2)") // MD images /foo → foo
81+
.replace(/src="\/([^"]+)"/g, 'src="$1"') // HTML <img src="/foo"> → "foo"
82+
}).join("")
83+
}
84+
85+
// -------- setup .temp --------
86+
87+
const tempDir = ".temp"
88+
if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true })
89+
fs.mkdirSync(tempDir, { recursive: true })
90+
91+
// --- Generate file list ---
92+
let fileList: string[] = []
93+
94+
for (const category of sidebar) {
95+
if (category.items) {
96+
for (const item of category.items) {
97+
if (item.link && item.link.endsWith(".md")) {
98+
const filePath = path.join("en", item.link).replace(/\\/g, "/")
99+
fileList.push(filePath)
100+
}
101+
}
102+
}
103+
}
104+
105+
console.log("Files to include:", fileList)
106+
107+
// --- Copy and patch Markdown files into .temp ---
108+
for (const relFile of fileList) {
109+
110+
const srcPath = path.join("docs", relFile)
111+
const dstPath = path.join(tempDir, relFile)
112+
113+
fs.mkdirSync(path.dirname(dstPath), { recursive: true })
114+
let content = fs.readFileSync(srcPath, "utf8")
115+
116+
// 1) Strip attributes/ranges: end up with plain ```lang (alias shell→bash)
117+
content = normalizeFenceOpeners(content)
118+
119+
// 2) XHTML + path fixes only outside code
120+
content = sanitizeOutsideCode(content)
121+
122+
// 3) Inline line numbers (start at 1)
123+
content = addLineNumbersToFences(content)
124+
125+
fs.writeFileSync(dstPath, content)
126+
}
127+
128+
// --- Write Kindle CSS fix ---
129+
const css = `
130+
/* Fix Kindle extra spacing in Pandoc-highlighted code blocks */
131+
code.sourceCode > span { display: inline !important; } /* override inline-block */
132+
pre > code.sourceCode > span { display: inline !important; } /* extra safety */
133+
pre { line-height: 1.2 !important; margin: 0 !important; } /* tighten & remove gaps */
134+
pre code { display: block; padding: 0; margin: 0; }
135+
pre, code { font-variant-ligatures: none; } /* avoid odd ligature spacing */
136+
pre > code.sourceCode { white-space: pre; } /* don’t pre-wrap lines */
137+
`;
138+
fs.writeFileSync(path.join(tempDir, "epub-fixes.css"), css);
139+
140+
141+
// --- Run Pandoc ---
142+
const outputFileName = "../nixos-and-flakes-book.epub"
143+
const pandocCommand = `pandoc ${fileList.join(" ")} \
144+
-o ${outputFileName} \
145+
--from=markdown+gfm_auto_identifiers+pipe_tables+raw_html+tex_math_dollars+fenced_code_blocks+fenced_code_attributes \
146+
--to=epub3 \
147+
--standalone \
148+
--toc --toc-depth=2 \
149+
--number-sections \
150+
--embed-resources \
151+
--highlight-style=tango \
152+
--css=epub-fixes.css \
153+
--metadata=title:"NixOS and Flakes Book" \
154+
--metadata=author:"Ryan Yin" \
155+
--resource-path=.:../docs/public:en`
156+
157+
console.log("🚀 Executing pandoc:", pandocCommand)
158+
159+
exec(pandocCommand, { cwd: tempDir }, (error, stdout, stderr) => {
160+
if (error) {
161+
console.error(`❌ Pandoc failed: ${error}`)
162+
return
163+
}
164+
if (stdout) console.log(stdout)
165+
if (stderr) console.error(stderr)
166+
console.log("✅ EPUB generated:", outputFileName)
167+
})

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"docs:dev": "vitepress dev docs",
99
"docs:build": "vitepress build docs",
1010
"docs:preview": "vitepress preview docs",
11-
"export-pdf": "press-export-pdf export ./docs --outFile ./nixos-and-flakes-book.pdf"
11+
"export-pdf": "press-export-pdf export ./docs --outFile ./nixos-and-flakes-book.pdf",
12+
"export-epub": "npx tsx epub-export.ts"
1213
},
1314
"dependencies": {
1415
"@searking/markdown-it-cjk-breaks": "2.0.1-0",

0 commit comments

Comments
 (0)