add epub generator (#1831)

* add epub generator

* improve parser, keep images

* check epub after generate

* fix render math content error in block

* render \dots as ...

* render \lfoor and \rfloor

* use monospaced font to render code block

* render code block with syntax highlight

* adjust title render

* fix render LaTeX

* fix '!!! abstract' render

* render code block in flow

* include whole class when not specifiy function

* command line to build other language

* update README

* fix process python code example

* support build en, ja and zh-hant

* add '--all' option to build all version

* use branch docs to build epub

* fix title and toc render

* build epub file with name like 'hello-algo_{zh}_{cpp}.epub

* fix render LaTeX

* optimize style

* use math font

* fix extract code block

* add border for code block

* fix python code style

* fix page break

* try git pull first when build epub

* ajust title level of chapter section

* Update epub styles

* Update epub styles

* Update convers and fonts.

* Convert code comments and README into English.

* Update the output dir.

* Add code reviewers on the cover.

* Support multi language for the reviewer names.

* Update .gitignore

---------

Co-authored-by: krahets <krahets@163.com>
This commit is contained in:
O
2025-12-30 08:23:31 +08:00
committed by GitHub
parent 2db8f741a8
commit 091afd38b4
18 changed files with 4781 additions and 0 deletions

5
epub/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
dist/
node_modules/
*.epub
test_*.js
validation-report.json

58
epub/README.md Normal file
View File

@@ -0,0 +1,58 @@
# Hello Algorithm EPUB Converter
Convert [Hello Algorithm](https://github.com/krahets/hello-algo) Markdown documentation to EPUB e-books.
## Installation
```bash
npm install
```
## Usage
### Basic Usage
```bash
# Build Chinese version with C++ code (default)
npm run build
# Build with specific programming language
npm run build -- -l python
# Build Traditional Chinese version
npm run build -- -d zh-hant -l python -o hello-algo-zh-hant-python.epub
# Build English version
npm run build -- -d en -l python -o hello-algo-en-python.epub
# Build Japanese version
npm run build -- -d ja -l cpp -o hello-algo-ja-cpp.epub
# Build all combinations
npm run build -- --all --release-version 1.2.0
```
### Command Line Parameters
| Parameter | Short | Description | Default |
|-----------|-------|-------------|---------|
| `--doc-language` | `-d` | Document language (zh, zh-hant, en, ja) | `zh` |
| `--output` | `-o` | Output EPUB file path | `./hello-algo.epub` |
| `--language` | `-l` | Programming language | `cpp` |
| `--all` | `-a` | Build all combinations | - |
| `--release-version` | - | Version number for output filename | `1.0.0` |
| `--validate` | - | Enable content integrity validation | `false` |
| `--help` | `-h` | Show help | - |
| `--version` | `-V` | Show version | - |
### Supported Languages
**Document Languages:**
- `zh` - Simplified Chinese (all programming languages)
- `zh-hant` - Traditional Chinese (all programming languages)
- `en` - English (cpp, java, python only)
- `ja` - Japanese (cpp, java, python only)
**Programming Languages:**
- Supported by all document languages: `cpp`, `python`, `java`
- Supported by Chinese versions only: `csharp`, `go`, `swift`, `javascript`, `typescript`, `dart`, `rust`, `c`, `kotlin`, `ruby`, `zig`

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

Binary file not shown.

1735
epub/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
epub/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "hello-algo-epub",
"version": "1.0.0",
"description": "Convert Hello Algorithm docs to EPUB",
"main": "dist/index.js",
"scripts": {
"build": "ts-node src/index.ts"
},
"keywords": [
"epub",
"markdown",
"ebook"
],
"author": "",
"license": "MIT",
"dependencies": {
"@types/commander": "^2.12.0",
"adm-zip": "^0.5.10",
"commander": "^14.0.2",
"epub-gen": "^0.1.0",
"fs-extra": "^11.2.0",
"highlight.js": "^11.11.1",
"js-yaml": "^4.1.0",
"marked": "^11.1.1",
"path": "^0.12.7"
},
"devDependencies": {
"@types/fs-extra": "^11.0.4",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.11.0",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
}
}

371
epub/src/epub.ts Normal file
View File

@@ -0,0 +1,371 @@
import * as fs from 'fs-extra';
import * as path from 'path';
import { Chapter, ImageInfo, EpubGenOptions } from './types';
import { markdownToHtml, extractImagePaths, readImage, getCustomCSS } from './markdown';
const Epub = require('epub-gen');
export interface EpubOptions {
title: string;
author: string;
publisher?: string;
description?: string;
language?: string;
cover?: string;
codeLanguage?: string; // Programming language for code examples
docLanguage?: string; // Document language (zh, zh-hant, en, ja)
version?: string; // Version number
}
/**
* Get table of contents title based on document language
*/
function getTocTitle(docLanguage?: string): string {
const tocTitles: { [key: string]: string } = {
'zh': '目录',
'zh-hant': '目錄',
'en': 'Table of Contents',
'ja': '目次',
};
return tocTitles[docLanguage || 'zh'] || '目录';
}
/**
* Generate homepage HTML
*/
function generateTitlePage(title: string, author: string, docLanguage?: string, version?: string, codeLanguage?: string): string {
const lang = docLanguage || 'zh';
// Code reviewer mapping by language and document language
const codeReviewers: { [docLang: string]: { [codeLang: string]: string } } = {
'zh': {
'python': '靳宇栋(@krahets',
'cpp': '宫兰景(@Gonglja',
'java': '靳宇栋(@krahets',
'csharp': '@hpstory',
'go': '刘代富(@Reanon',
'swift': '@nuomi1',
'javascript': '谢发 (@justin-tse)',
'typescript': '谢发 (@justin-tse)',
'dart': '刘玉新(@gvenusleo',
'rust': '伍志豪(@night-cruise、荣怡@rongyi',
'c': '宫兰景(@Gonglja',
'ruby': '阮春科秀(@khoaxuantu',
'kotlin': '陈东辉(@curtishd',
},
'zh-hant': {
'python': '靳宇棟(@krahets',
'cpp': '宮蘭景(@Gonglja',
'java': '靳宇棟(@krahets',
'csharp': '@hpstory',
'go': '劉代富(@Reanon',
'swift': '@nuomi1',
'javascript': '謝發 (@justin-tse)',
'typescript': '謝發 (@justin-tse)',
'dart': '劉玉新(@gvenusleo',
'rust': '伍志豪(@night-cruise、榮怡@rongyi',
'c': '宮蘭景(@Gonglja',
'ruby': '阮春科秀(@khoaxuantu',
'kotlin': '陳東輝(@curtishd',
},
'en': {
'python': 'Yudong Jin (@krahets)',
'cpp': 'Lanjing Gong (@Gonglja)',
'java': 'Yudong Jin (@krahets)',
'csharp': '@hpstory',
'go': 'Daifu Liu (@Reanon)',
'swift': '@nuomi1',
'javascript': 'Fa Xie (@justin-tse)',
'typescript': 'Fa Xie (@justin-tse)',
'dart': 'Yuxin Liu (@gvenusleo)',
'rust': 'Zhihao Wu (@night-cruise), Yi Rong (@rongyi)',
'c': 'Lanjing Gong (@Gonglja)',
'ruby': 'Chunke Xiu Ruan (@khoaxuantu)',
'kotlin': 'Donghui Chen (@curtishd)',
},
'ja': {
'python': '靳宇棟(@krahets',
'cpp': '宮蘭景(@Gonglja',
'java': '靳宇棟(@krahets',
'csharp': '@hpstory',
'go': '劉代富(@Reanon',
'swift': '@nuomi1',
'javascript': '謝発 (@justin-tse)',
'typescript': '謝発 (@justin-tse)',
'dart': '劉玉新(@gvenusleo',
'rust': '伍志豪(@night-cruise、栄怡@rongyi',
'c': '宮蘭景(@Gonglja',
'ruby': '阮春科秀(@khoaxuantu',
'kotlin': '陳東輝(@curtishd',
},
};
// Multilingual text configuration
const i18n: { [key: string]: { subtitle: string; authorPrefix: string; authorName: string; codeReviewPrefix: string; readOnline: string; codeRepo: string; versionPrefix: string } } = {
'zh': {
subtitle: '动画图解、一键运行的数据结构与算法教程',
authorPrefix: '作者:',
authorName: '靳宇栋 (@krahets)',
codeReviewPrefix: '代码审阅:',
readOnline: '在线阅读',
codeRepo: '代码仓库',
versionPrefix: '版本',
},
'zh-hant': {
subtitle: '動畫圖解、一鍵運行的資料結構與演算法教程',
authorPrefix: '作者:',
authorName: '靳宇棟 (@krahets)',
codeReviewPrefix: '程式碼審閱:',
readOnline: '線上閱讀',
codeRepo: '程式碼倉庫',
versionPrefix: '版本',
},
'en': {
subtitle: 'Data structures and algorithms crash course with animated illustrations and off-the-shelf code',
authorPrefix: 'Author: ',
authorName: 'Yudong Jin (@krahets)',
codeReviewPrefix: 'Code Review: ',
readOnline: 'Read Online',
codeRepo: 'Code Repository',
versionPrefix: 'Version',
},
'ja': {
subtitle: 'アニメーション図解、ワンクリック実行のデータ構造とアルゴリズム教程',
authorPrefix: '著者:',
authorName: '靳宇棟 (@krahets)',
codeReviewPrefix: 'コードレビュー:',
readOnline: 'オンライン閲覧',
codeRepo: 'コードリポジトリ',
versionPrefix: 'バージョン',
},
};
const text = i18n[lang] || i18n['zh'];
const versionText = version ? `${text.versionPrefix} ${version}` : '';
const codeReviewer = codeLanguage && codeReviewers[lang] && codeReviewers[lang][codeLanguage]
? codeReviewers[lang][codeLanguage]
: '';
return `
<div style="text-align: center; padding: 40px 20px 30px 20px;">
<h1 style="font-size: 2.2em; margin-bottom: 0.2em; color: #24292e; font-weight: 700; text-align: center;">Hello 算法</h1>
<p style="font-size: 0.9em; margin: 0.3em 0 1.5em 0; color: #666;">${text.subtitle}</p>
<p style="font-size: 1em; margin: 1.2em 0 0.3em 0; color: #555;">${text.authorPrefix}${text.authorName}</p>
${codeReviewer ? `<p style="font-size: 1em; margin: 0.3em 0 0.8em 0; color: #555;">${text.codeReviewPrefix}${codeReviewer}</p>` : ''}
${versionText ? `<p style="font-size: 0.85em; margin: 0.5em 0; color: #888;">${versionText}</p>` : ''}
<div style="margin: 0.8em auto; padding: 0.6em 1.2em; background-color: #f8f9fa; border-radius: 8px; display: inline-block; max-width: 70%;">
<p style="font-size: 0.9em; margin: 0.6em 0; color: #333;">
<strong>${text.readOnline}</strong><br/>
<a href="https://www.hello-algo.com" style="color: #1581CB; text-decoration: none;">www.hello-algo.com</a>
</p>
<p style="font-size: 0.9em; margin: 0.6em 0; color: #333;">
<strong>${text.codeRepo}</strong><br/>
<a href="https://github.com/krahets/hello-algo" style="color: #1581CB; text-decoration: none;">github.com/krahets/hello-algo</a>
</p>
</div>
</div>
`;
}
/**
* Generate EPUB e-book
*/
export async function generateEpub(
chapters: Chapter[],
docsDir: string,
outputPath: string,
options: EpubOptions
): Promise<void> {
console.log('Starting EPUB generation...');
// Create temporary directory for images
const tempDir = path.join(path.dirname(outputPath), 'temp_images');
fs.ensureDirSync(tempDir);
// Prepare content
const content: any[] = [];
const imageMap: { [original: string]: string } = {};
let imageIndex = 0;
// Add homepage (before table of contents)
const titlePageTitles: { [key: string]: string } = {
'zh': '首页',
'zh-hant': '首頁',
'en': 'Home',
'ja': 'ホーム',
};
const titlePageTitle = titlePageTitles[options.docLanguage || 'zh'] || '首页';
content.push({
title: titlePageTitle,
data: generateTitlePage(options.title, options.author, options.docLanguage, options.version, options.codeLanguage),
beforeToc: true, // Before table of contents
excludeFromToc: false, // Include in table of contents
level: 0,
number: '',
});
for (const chapter of chapters) {
if (!chapter.content) {
continue;
}
console.log(`Processing chapter: ${chapter.title}`);
// Read chapter content
const markdown = chapter.content;
const chapterDir = path.dirname(chapter.path);
// Extract images and copy to temporary directory
const imagePaths = extractImagePaths(markdown, chapter.path);
for (const { original, fullPath } of imagePaths) {
if (!imageMap[original]) {
// Copy image to temporary directory
const ext = path.extname(fullPath);
const newFileName = `img_${imageIndex}${ext}`;
const tempImagePath = path.join(tempDir, newFileName);
fs.copyFileSync(fullPath, tempImagePath);
imageMap[original] = tempImagePath;
imageIndex++;
}
// Replace image paths in markdown with absolute paths (using file:// protocol)
const tempImagePath = imageMap[original];
chapter.content = chapter.content.replace(
new RegExp(`!\\[[^\\]]*\\]\\(${escapeRegex(original)}\\)`, 'g'),
`![${path.basename(fullPath)}](file://${tempImagePath})`
);
}
// Use chapter title directly (MD documents already have numbering)
const displayTitle = chapter.title.trim();
// Convert to HTML
const codeLanguage = options.codeLanguage || 'cpp';
const html = markdownToHtml(chapter.content, chapterDir, codeLanguage, options.docLanguage, chapter.path);
content.push({
title: displayTitle,
data: html,
// Do not use beforeToc, let all chapters arrange naturally in order
// Chapters are already arranged according to mkdocs.yml order, parent chapters first, child chapters follow
beforeToc: false,
excludeFromToc: false,
// Add level information for generating nested table of contents
level: chapter.level,
parentTitle: chapter.parentTitle,
number: chapter.number,
});
}
// Prepare font file paths
const fontsDir = path.join(__dirname, '..', 'fonts');
const fonts: string[] = [];
// Add math fonts (shared by all versions)
const mathJaxMathPath = path.join(fontsDir, 'MathJax_Math-Regular.otf');
const mathJaxMainPath = path.join(fontsDir, 'MathJax_Main-Regular.otf');
// Add code font (shared by all versions)
const jetbrainsMonoPath = path.join(fontsDir, 'JetBrainsMonoNerdFont-Regular.ttf');
// Select body font based on document language
const docLang = options.docLanguage || 'zh';
let serifFontPath: string | null = null;
let serifItalicFontPath: string | null = null;
if (docLang === 'zh' || docLang === 'zh-hant') {
// Chinese version uses Noto Serif SC Regular
serifFontPath = path.join(fontsDir, 'NotoSerifSC-Regular.ttf');
} else if (docLang === 'en') {
// English version uses Roboto Serif
serifFontPath = path.join(fontsDir, 'RobotoSerif-VariableFont_GRAD,opsz,wdth,wght.ttf');
serifItalicFontPath = path.join(fontsDir, 'RobotoSerif-Italic-VariableFont_GRAD,opsz,wdth,wght.ttf');
} else if (docLang === 'ja') {
// Japanese version uses Noto Serif JP
serifFontPath = path.join(fontsDir, 'NotoSerifJP-VariableFont_wght.ttf');
}
// Add math fonts
if (fs.existsSync(mathJaxMathPath)) {
fonts.push(mathJaxMathPath);
} else {
console.warn(`Warning: Font file does not exist: ${mathJaxMathPath}`);
}
if (fs.existsSync(mathJaxMainPath)) {
fonts.push(mathJaxMainPath);
} else {
console.warn(`Warning: Font file does not exist: ${mathJaxMainPath}`);
}
// Add body font
if (serifFontPath && fs.existsSync(serifFontPath)) {
fonts.push(serifFontPath);
} else if (serifFontPath) {
console.warn(`Warning: Font file does not exist: ${serifFontPath}`);
}
// Add italic font (if available)
if (serifItalicFontPath && fs.existsSync(serifItalicFontPath)) {
fonts.push(serifItalicFontPath);
} else if (serifItalicFontPath) {
console.warn(`Warning: Font file does not exist: ${serifItalicFontPath}`);
}
// Add code font
if (fs.existsSync(jetbrainsMonoPath)) {
fonts.push(jetbrainsMonoPath);
} else {
console.warn(`Warning: Font file does not exist: ${jetbrainsMonoPath}`);
}
// Prepare EPUB options
const epubOptions: EpubGenOptions = {
title: options.title,
author: options.author,
publisher: options.publisher || 'Hello Algorithm',
description: options.description || '动画图解、一键运行的数据结构与算法教程',
language: options.language || 'zh-CN',
content: content,
verbose: true,
// Disable automatic chapter title addition (we already handle this in Markdown)
appendChapterTitles: false,
// Use custom templates to generate nested table of contents
customNcxTocTemplatePath: path.join(__dirname, '..', 'templates', 'toc.ncx.ejs'),
customHtmlTocTemplatePath: path.join(__dirname, '..', 'templates', 'toc.xhtml.ejs'),
// Inject custom CSS (based on document language)
css: getCustomCSS(options.docLanguage),
// Set cover
cover: options.cover,
// Add font files
fonts: fonts,
// Set table of contents title based on document language
tocTitle: getTocTitle(options.docLanguage),
};
// Generate EPUB
try {
epubOptions.output = outputPath;
const epub = new Epub(epubOptions);
await epub.promise;
console.log(`EPUB generated successfully: ${outputPath}`);
// Clean up temporary directory
fs.removeSync(tempDir);
} catch (error) {
console.error('Error generating EPUB:', error);
// Clean up temporary directory
fs.removeSync(tempDir);
throw error;
}
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

535
epub/src/index.ts Normal file
View File

@@ -0,0 +1,535 @@
import * as fs from 'fs-extra';
import * as path from 'path';
import { execSync } from 'child_process';
import { Command } from 'commander';
import { parseMkdocsConfig, flattenNav, readChapterContent, parseVersion } from './parser';
import { generateEpub } from './epub';
import { Chapter, HeadingInfo } from './types';
import { extractHeadings } from './markdown';
import { validateEpubHeadings } from './validator';
// Supported programming languages list
const SUPPORTED_LANGUAGES = [
'cpp', 'python', 'java', 'csharp', 'go', 'swift',
'javascript', 'typescript', 'dart', 'rust', 'c',
'kotlin', 'ruby', 'zig'
];
// Document language configuration
interface DocLanguageConfig {
mkdocsPath: string;
docsDir: string;
title: string;
description: string;
language: string;
supportedCodeLanguages?: string[]; // Supported programming languages list
}
/**
* Generate output filename
*/
function generateOutputFilename(docLanguage: string, codeLanguage: string, workDir: string, projectRoot: string): string {
// Get document language configuration
const docConfig = DOC_LANGUAGE_CONFIG[docLanguage];
const mkdocsPath = path.resolve(workDir, docConfig.mkdocsPath);
// Parse version from mkdocs.yml
const version = parseVersion(mkdocsPath);
const filename = `hello-algo_${version}_${docLanguage}_${codeLanguage}.epub`;
const outputDir = path.join(projectRoot, 'build', 'epub', 'outputs');
fs.ensureDirSync(outputDir);
return path.join(outputDir, filename);
}
/**
* Get git repository remote URL
*/
function getGitRemoteUrl(): string {
try {
const url = execSync('git remote get-url origin', { encoding: 'utf-8', cwd: process.cwd() }).trim();
return url;
} catch (error) {
console.error('Error: Unable to get git remote URL');
console.error('Please ensure the current directory is a git repository or manually specify the repository URL');
throw error;
}
}
/**
* Recursively copy directory, skipping existing files
*/
function copyDirectoryRecursive(src: string, dest: string): void {
if (!fs.existsSync(src)) {
return;
}
const stat = fs.statSync(src);
if (stat.isDirectory()) {
// Ensure target directory exists
fs.ensureDirSync(dest);
// Read all contents of source directory
const entries = fs.readdirSync(src);
for (const entry of entries) {
const srcPath = path.join(src, entry);
const destPath = path.join(dest, entry);
const entryStat = fs.statSync(srcPath);
if (entryStat.isDirectory()) {
// Recursively handle subdirectories
copyDirectoryRecursive(srcPath, destPath);
} else {
// Copy file if target doesn't exist
if (!fs.existsSync(destPath)) {
fs.copyFileSync(srcPath, destPath);
}
}
}
} else {
// If it's a file, copy directly (if target doesn't exist)
if (!fs.existsSync(dest)) {
fs.copyFileSync(src, dest);
}
}
}
/**
* Prepare docs branch working directory
* 1. Clone docs branch to build/epub/hello-algo directory
* 2. Copy necessary files and directories (skip existing files)
*/
async function prepareDocsBranch(): Promise<string> {
// Get project root directory (assuming running from epub directory, or current directory is project root)
const currentDir = process.cwd();
let projectRoot: string;
// If current directory is epub directory, go up to find project root
if (path.basename(currentDir) === 'epub') {
projectRoot = path.dirname(currentDir);
} else {
// Otherwise assume current directory is project root
projectRoot = currentDir;
}
const buildEpubDir = path.join(projectRoot, 'build', 'epub', 'hello-algo');
console.log('Preparing docs branch working directory...');
console.log(`Project root directory: ${projectRoot}`);
console.log(`Target directory: ${buildEpubDir}`);
// Create build/epub directory (if it doesn't exist)
const buildDir = path.join(projectRoot, 'build', 'epub');
fs.ensureDirSync(buildDir);
// Get git repository URL
const repoUrl = getGitRemoteUrl();
// Check if build/epub/hello-algo directory already exists
if (fs.existsSync(buildEpubDir)) {
const gitDir = path.join(buildEpubDir, '.git');
// Check if it's a git repository
if (fs.existsSync(gitDir)) {
console.log('Detected existing git repository, using git pull to update...');
try {
// First switch to docs branch (if not on docs branch)
execSync('git checkout docs', {
stdio: 'inherit',
cwd: buildEpubDir
});
// Execute git pull to update
execSync('git pull', {
stdio: 'inherit',
cwd: buildEpubDir
});
console.log('✓ docs branch update completed');
} catch (error) {
console.error('Error: git pull update failed, trying to re-clone...');
// If pull fails, delete directory and re-clone
fs.removeSync(buildEpubDir);
// Continue with clone logic below
}
} else {
// If directory exists but is not a git repository, delete and re-clone
console.log('Detected existing directory but not a git repository, deleting and re-cloning...');
fs.removeSync(buildEpubDir);
}
}
// If directory doesn't exist or has been deleted, execute clone
if (!fs.existsSync(buildEpubDir)) {
try {
console.log(`Cloning docs branch: ${repoUrl}`);
console.log(`Target: ${buildEpubDir}`);
// Execute git clone, using shallow clone for speed
execSync(`git clone --branch docs --depth 1 "${repoUrl}" "${buildEpubDir}"`, {
stdio: 'inherit',
cwd: projectRoot
});
console.log('✓ docs branch clone completed');
} catch (error) {
console.error('Error: Failed to clone docs branch');
throw error;
}
}
// Files and directories to copy
const itemsToCopy = ['mkdocs.yml', 'docs', 'en', 'ja', 'overrides', 'zh-hant'];
console.log('\nStarting file copy...');
for (const item of itemsToCopy) {
const sourcePath = path.join(projectRoot, item);
const targetPath = path.join(buildEpubDir, item);
// Check if source file/directory exists
if (!fs.existsSync(sourcePath)) {
console.log(`⚠️ Skipping ${item} (source file does not exist)`);
continue;
}
try {
const stat = fs.statSync(sourcePath);
if (stat.isDirectory()) {
// For directories, recursively copy, skipping existing files
copyDirectoryRecursive(sourcePath, targetPath);
console.log(`✓ Copied directory: ${item}`);
} else {
// For files, copy if target doesn't exist
if (!fs.existsSync(targetPath)) {
fs.copyFileSync(sourcePath, targetPath);
console.log(`✓ Copied file: ${item}`);
} else {
console.log(`⊘ Skipping ${item} (target file already exists)`);
}
}
} catch (error) {
console.error(`⚠️ Error copying ${item}:`, error instanceof Error ? error.message : String(error));
// Continue processing other files
}
}
console.log('\n✓ File copy completed');
console.log(`Working directory: ${buildEpubDir}\n`);
return buildEpubDir;
}
const DOC_LANGUAGE_CONFIG: { [key: string]: DocLanguageConfig } = {
'zh': {
mkdocsPath: 'mkdocs.yml',
docsDir: 'docs', // Relative to project root directory
title: 'Hello 算法',
description: '动画图解、一键运行的数据结构与算法教程',
language: 'zh-CN',
// Chinese version supports all languages
},
'zh-hant': {
mkdocsPath: 'zh-hant/mkdocs.yml',
docsDir: 'zh-hant/docs', // Relative to project root directory
title: 'Hello 演算法',
description: '動畫圖解、一鍵執行的資料結構與演算法教程',
language: 'zh-Hant',
// Traditional Chinese version supports all languages
},
'en': {
mkdocsPath: 'en/mkdocs.yml',
docsDir: 'en/docs', // Relative to project root directory
title: 'Hello Algo',
description: 'Data Structures and Algorithms Crash Course with Animated Illustrations and Off-the-Shelf Code',
language: 'en',
supportedCodeLanguages: ['cpp', 'java', 'python'],
},
'ja': {
mkdocsPath: 'ja/mkdocs.yml',
docsDir: 'ja/docs', // Relative to project root directory
title: 'Hello アルゴリズム',
description: 'アニメーションで図解、ワンクリック実行のデータ構造とアルゴリズムチュートリアル',
language: 'ja',
supportedCodeLanguages: ['cpp', 'java', 'python'],
}
};
/**
* Build a single EPUB file
*/
async function buildEpub(
docLanguage: string,
codeLanguage: string,
outputPath: string,
workDir: string,
validate: boolean = false
): Promise<{ success: boolean; error?: string }> {
try {
// Get document language configuration
const docConfig = DOC_LANGUAGE_CONFIG[docLanguage];
if (!docConfig) {
return { success: false, error: `Unsupported document language: ${docLanguage}` };
}
// Verify if programming language is supported by this document language
if (docConfig.supportedCodeLanguages && !docConfig.supportedCodeLanguages.includes(codeLanguage)) {
return { success: false, error: `Document language "${docLanguage}" does not support programming language "${codeLanguage}"` };
}
// Config file path (workDir is now project root directory)
const mkdocsPath = path.resolve(workDir, docConfig.mkdocsPath);
// Calculate project root directory (workDir is project root directory)
const repoDir = workDir;
// Document directory (absolute path)
const docsDir = path.join(repoDir, docConfig.docsDir);
// Check if directory exists
if (!fs.existsSync(docsDir)) {
return { success: false, error: `Document directory does not exist: ${docsDir}` };
}
if (!fs.existsSync(mkdocsPath)) {
return { success: false, error: `Config file does not exist: ${mkdocsPath}` };
}
// Parse configuration
const nav = parseMkdocsConfig(mkdocsPath);
// Parse version from mkdocs.yml
const version = parseVersion(mkdocsPath);
// Flatten navigation structure
const chapters = flattenNav(nav, docsDir);
// Read chapter content and extract headings
const allHeadings: HeadingInfo[] = [];
for (const chapter of chapters) {
chapter.content = readChapterContent(chapter);
// Extract all headings from this chapter
if (chapter.content.trim().length > 0) {
const headings = extractHeadings(chapter.content, chapter.path);
allHeadings.push(...headings);
}
}
// Filter out empty content
const validChapters = chapters.filter(ch => ch.content.trim().length > 0);
// Cover image path (select based on document language)
const epubDir = path.join(__dirname, '..');
const coverMap: { [key: string]: string } = {
'zh': path.join(epubDir, 'covers', 'hello-algo-cover-zh.jpg'),
'zh-hant': path.join(epubDir, 'covers', 'hello-algo-cover-zh-hant.jpg'),
'en': path.join(epubDir, 'covers', 'hello-algo-cover-en.jpg'),
'ja': path.join(epubDir, 'covers', 'hello-algo-cover-en.jpg'), // Temporarily use English cover
};
const coverPath = coverMap[docLanguage] || coverMap['zh'];
// Generate EPUB
await generateEpub(validChapters, docsDir, outputPath, {
title: docConfig.title,
author: 'krahets',
publisher: 'Hello Algorithm',
description: docConfig.description,
language: docConfig.language,
cover: coverPath,
codeLanguage: codeLanguage,
docLanguage: docLanguage,
version: version,
});
// Validate EPUB content integrity (only execute when enabled)
if (validate) {
const validation = await validateEpubHeadings(outputPath, allHeadings);
if (!validation.success) {
return { success: true, error: `Validation warning: Missing ${validation.missingHeadings.length} headings` };
}
}
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
async function main() {
// Create command line program
const program = new Command();
program
.name('hello-algo-epub')
.description('Convert Hello Algorithm documentation to EPUB e-book')
.version('1.0.0')
.option('-d, --doc-language <lang>', 'Document language (zh, zh-hant, en, ja)', 'zh')
.option('-o, --output <path>', 'Output EPUB file path')
.option('-l, --language <lang>', `Programming language (${SUPPORTED_LANGUAGES.join(', ')})`, 'cpp')
.option('-a, --all', 'Build all combinations of document languages and programming languages')
.option('--validate', 'Enable EPUB content integrity validation', false)
.parse(process.argv);
const options = program.opts();
// Get project root directory
const currentDir = process.cwd();
let projectRoot: string;
if (path.basename(currentDir) === 'epub') {
projectRoot = path.dirname(currentDir);
} else {
projectRoot = currentDir;
}
// Prepare docs branch working directory
let workDir: string;
try {
workDir = await prepareDocsBranch();
} catch (error) {
console.error('Failed to prepare docs branch working directory:', error instanceof Error ? error.message : String(error));
process.exit(1);
}
// If using --all, execute batch build
if (options.all) {
// Validation: Cannot use --all with other options
// Check if these options were explicitly provided in command line arguments
const args = process.argv;
const hasDocLanguage = args.includes('-d') || args.includes('--doc-language');
const hasLanguage = args.includes('-l') || args.includes('--language');
const hasOutput = args.includes('-o') || args.includes('--output');
if (hasDocLanguage || hasLanguage || hasOutput) {
console.error('Error: Cannot specify --doc-language, --language, or --output when using --all');
process.exit(1);
}
// Generate all combinations
const builds: Array<{ docLanguage: string; codeLanguage: string }> = [];
for (const docLang of Object.keys(DOC_LANGUAGE_CONFIG)) {
const docConfig = DOC_LANGUAGE_CONFIG[docLang];
const codeLangs = docConfig.supportedCodeLanguages || SUPPORTED_LANGUAGES;
for (const codeLang of codeLangs) {
builds.push({ docLanguage: docLang, codeLanguage: codeLang });
}
}
console.log(`Starting batch build of ${builds.length} EPUB files...\n`);
const results: Array<{ docLanguage: string; codeLanguage: string; success: boolean; error?: string; outputPath: string }> = [];
for (let i = 0; i < builds.length; i++) {
const { docLanguage, codeLanguage } = builds[i];
const outputPath = generateOutputFilename(docLanguage, codeLanguage, workDir, projectRoot);
console.log(`[${i + 1}/${builds.length}] Building: ${docLanguage}-${codeLanguage}`);
const result = await buildEpub(docLanguage, codeLanguage, outputPath, workDir, options.validate);
if (result.success) {
console.log(` ✅ Success: ${path.basename(outputPath)}`);
if (result.error) {
console.log(` ⚠️ ${result.error}`);
}
} else {
console.log(` ❌ Failed: ${result.error}`);
}
console.log();
results.push({
docLanguage,
codeLanguage,
success: result.success,
error: result.error,
outputPath
});
}
// Display statistics
const successCount = results.filter(r => r.success).length;
const failCount = results.length - successCount;
console.log('='.repeat(60));
console.log('Build completed!');
console.log(`Success: ${successCount}/${results.length}`);
console.log(`Failed: ${failCount}/${results.length}`);
console.log('='.repeat(60));
if (failCount > 0) {
console.log('\nFailed builds:');
results.filter(r => !r.success).forEach(r => {
console.log(` - ${r.docLanguage}-${r.codeLanguage}: ${r.error}`);
});
process.exit(1);
}
return;
}
// Single build logic
// Validate document language parameter
const supportedDocLanguages = Object.keys(DOC_LANGUAGE_CONFIG);
if (!supportedDocLanguages.includes(options.docLanguage)) {
console.error(`Error: Unsupported document language "${options.docLanguage}"`);
console.error(`Supported document languages: ${supportedDocLanguages.join(', ')}`);
process.exit(1);
}
// Validate programming language parameter
if (!SUPPORTED_LANGUAGES.includes(options.language)) {
console.error(`Error: Unsupported programming language "${options.language}"`);
console.error(`Supported programming languages: ${SUPPORTED_LANGUAGES.join(', ')}`);
process.exit(1);
}
// Get document language configuration
const docConfig = DOC_LANGUAGE_CONFIG[options.docLanguage];
// Validate combination of document language and programming language
if (docConfig.supportedCodeLanguages && !docConfig.supportedCodeLanguages.includes(options.language)) {
console.error(`Error: Document language "${options.docLanguage}" does not support programming language "${options.language}"`);
console.error(`${options.docLanguage} supported programming languages: ${docConfig.supportedCodeLanguages.join(', ')}`);
process.exit(1);
}
// If output path not provided, auto-generate filename
const outputDir = path.join(projectRoot, 'build', 'epub', 'outputs');
fs.ensureDirSync(outputDir);
const outputPath = options.output
? path.join(outputDir, path.basename(options.output))
: path.join(outputDir, `hello-algo_${options.docLanguage}_${options.language}.epub`);
console.log('Starting document processing...');
console.log(`Document language: ${options.docLanguage}`);
console.log(`Programming language: ${options.language}`);
console.log(`Output file: ${outputPath}`);
console.log();
const result = await buildEpub(options.docLanguage, options.language, outputPath, workDir, options.validate);
if (!result.success) {
console.error(`Build failed: ${result.error}`);
process.exit(1);
}
if (result.error) {
console.warn(`⚠️ ${result.error}`);
}
console.log('Completed!');
}
main().catch((error) => {
console.error('An error occurred:', error);
process.exit(1);
});

1457
epub/src/markdown.ts Normal file

File diff suppressed because it is too large Load Diff

168
epub/src/parser.ts Normal file
View File

@@ -0,0 +1,168 @@
import * as fs from 'fs-extra';
import * as path from 'path';
import * as yaml from 'js-yaml';
import { NavItem, Chapter, MkDocsNavItem } from './types';
/**
* Parse mkdocs.yml file to extract version number
*/
export function parseVersion(configPath: string): string {
const content = fs.readFileSync(configPath, 'utf-8');
const config = yaml.load(content) as any;
return config.version || '1.0.0';
}
/**
* Parse mkdocs.yml file to extract navigation structure
*/
export function parseMkdocsConfig(configPath: string): MkDocsNavItem[] {
const content = fs.readFileSync(configPath, 'utf-8');
const config = yaml.load(content) as any;
return config.nav || [];
}
/**
* Flatten navigation structure, extract all chapter paths while preserving hierarchy
* Based on mkdocs.yml structure:
* - Parent titles (e.g., "Chapter 0 Preface") contain arrays of child items
* - Child items can be string paths (index.md) or objects (sub-chapters)
*/
export function flattenNav(nav: MkDocsNavItem[], basePath: string = ''): Chapter[] {
const chapters: Chapter[] = [];
let order = 0;
function traverse(items: MkDocsNavItem[], parentTitle: string = '', parentNumber: string = '', level: number = 0) {
for (const item of items) {
if (typeof item === 'string') {
// Simple path string (usually index.md, as chapter homepage)
const filePath = path.join(basePath, item);
if (fs.existsSync(filePath)) {
// Try to extract title from file content
const content = fs.readFileSync(filePath, 'utf-8');
const titleMatch = content.match(/^#\s+(.+)$/m);
const title = titleMatch ? titleMatch[1].trim() : path.basename(item, '.md');
// index.md uses parent chapter's title and number
chapters.push({
title: parentTitle || title, // If parent title exists, use it; otherwise use file title
path: filePath,
content: '',
order: order++,
level: level,
number: parentNumber || undefined,
parentTitle: undefined // index.md is top-level chapter
});
}
} else if (typeof item === 'object' && item !== null) {
// Handle object format: { "title": "path" } or { "title": [...] }
if (Array.isArray(item)) {
traverse(item, parentTitle, parentNumber, level);
} else {
const keys = Object.keys(item);
if (keys.length === 1) {
const key = keys[0];
const value = item[key];
if (typeof value === 'string') {
// { "title": "path" } format - this is a sub-chapter (e.g., "0.1 &nbsp; About this book": "path")
const filePath = path.join(basePath, value);
if (fs.existsSync(filePath)) {
const fullTitle = key.replace(/&nbsp;/g, ' ').trim();
// Extract chapter number, e.g., "0.1 &nbsp; About this book" -> "0.1"
const numberMatch = fullTitle.match(/^(\d+(?:\.\d+)?)\s+/);
let chapterNumber = numberMatch ? numberMatch[1] : undefined;
// Extract title (remove number part)
const title = fullTitle.replace(/^\d+(?:\.\d+)?\s+/, '').trim();
chapters.push({
title: title,
path: filePath,
content: '',
order: order++,
level: level + 1, // Sub-chapter level +1
number: chapterNumber,
parentTitle: parentTitle || undefined
});
}
} else if (Array.isArray(value)) {
// { "title": [...] } format - this is parent chapter title and child items (e.g., "Chapter 0 Preface": [...])
const chapterTitle = key.replace(/&nbsp;/g, ' ').trim();
// Extract chapter number from title, e.g., "Chapter 0 &nbsp; Preface" -> "0"
let chapterNumber = '';
const numberMatch = chapterTitle.match(/第\s*(\d+)\s*章/);
if (numberMatch) {
chapterNumber = numberMatch[1];
} else {
// If no number found, try to infer from first sub-chapter (e.g., infer "0" from "0.1")
for (const subItem of value) {
if (typeof subItem === 'string') {
continue;
} else if (typeof subItem === 'object' && subItem !== null && !Array.isArray(subItem)) {
const subObj = subItem as { [key: string]: string | MkDocsNavItem[] };
const subKeys = Object.keys(subObj);
if (subKeys.length === 1) {
const subKey = subKeys[0];
const subValue = subObj[subKey];
if (typeof subValue === 'string') {
const subTitle = subKey.replace(/&nbsp;/g, ' ').trim();
const subNumberMatch = subTitle.match(/^(\d+)\.\d+/);
if (subNumberMatch) {
chapterNumber = subNumberMatch[1];
break;
}
}
}
}
}
}
// Recursively process child items, maintain hierarchy
traverse(value, chapterTitle, chapterNumber, level);
}
} else if ('path' in item && typeof item.path === 'string') {
// Object with path property
const filePath = path.join(basePath, item.path);
if (fs.existsSync(filePath)) {
chapters.push({
title: (typeof item.title === 'string' ? item.title : undefined) || path.basename(item.path, '.md'),
path: filePath,
content: '',
order: order++,
level: level,
number: parentNumber || undefined,
parentTitle: parentTitle || undefined
});
}
} else if ('title' in item && 'children' in item && Array.isArray(item.children) && typeof item.title === 'string') {
// Object with title and children properties
traverse(item.children, item.title, parentNumber, level);
}
}
}
}
}
traverse(nav);
// Filter out chapters related to chapter_paperbook
return chapters.filter(chapter => {
// Check if path contains chapter_paperbook
return !chapter.path.includes('chapter_paperbook');
});
}
/**
* Read chapter content
*/
export function readChapterContent(chapter: Chapter): string {
try {
return fs.readFileSync(chapter.path, 'utf-8');
} catch (error) {
console.warn(`Unable to read file: ${chapter.path}`, error);
return '';
}
}

52
epub/src/types.ts Normal file
View File

@@ -0,0 +1,52 @@
export interface NavItem {
title?: string;
path?: string;
children?: NavItem[];
}
// Navigation item type in MkDocs config file
export type MkDocsNavItem = string | { [key: string]: string | MkDocsNavItem[] } | MkDocsNavItem[];
export interface Chapter {
title: string;
path: string;
content: string;
order: number;
level: number; // Hierarchy level: 0=top-level chapter, 1=sub-chapter
number?: string; // Chapter number, e.g., "0", "0.1", "1", "1.1"
parentTitle?: string; // Parent chapter title
}
export interface ImageInfo {
src: string;
data: Buffer;
mimeType: string;
}
// Heading information
export interface HeadingInfo {
level: number; // Heading level (1-6)
text: string; // Heading text
chapterPath: string; // Path to the chapter
lineNumber?: number; // Line number (if available)
}
// Option types for epub-gen library
export interface EpubGenOptions {
title: string;
author: string;
publisher?: string;
description?: string;
language?: string;
content: Chapter[];
verbose?: boolean;
appendChapterTitles?: boolean;
customNcxTocTemplatePath?: string;
customHtmlTocTemplatePath?: string;
css?: string;
cover?: string;
output?: string;
fonts?: string[];
tocTitle?: string; // Table of contents title
}

137
epub/src/validator.ts Normal file
View File

@@ -0,0 +1,137 @@
import * as fs from 'fs-extra';
import * as path from 'path';
import { HeadingInfo } from './types';
const AdmZip = require('adm-zip');
/**
* Validate if EPUB contains all headings
* @param epubPath EPUB file path
* @param expectedHeadings Expected heading list
* @returns Validation result
*/
export async function validateEpubHeadings(
epubPath: string,
expectedHeadings: HeadingInfo[]
): Promise<{ success: boolean; missingHeadings: HeadingInfo[]; summary: string }> {
console.log(`\nStarting EPUB content validation...`);
console.log(`Expected heading count: ${expectedHeadings.length}`);
try {
// Extract EPUB
const zip = new AdmZip(epubPath);
const zipEntries = zip.getEntries();
// Extract all XHTML content
let allHtmlContent = '';
for (const entry of zipEntries) {
if (entry.entryName.endsWith('.xhtml') || entry.entryName.endsWith('.html')) {
const content = entry.getData().toString('utf8');
allHtmlContent += content + '\n';
}
}
// Check if each heading exists
const missingHeadings: HeadingInfo[] = [];
const foundHeadings: HeadingInfo[] = [];
for (const heading of expectedHeadings) {
const found = checkHeadingInHtml(allHtmlContent, heading);
if (found) {
foundHeadings.push(heading);
} else {
missingHeadings.push(heading);
}
}
// Generate report
const successRate = ((foundHeadings.length / expectedHeadings.length) * 100).toFixed(2);
let summary = `\n${'='.repeat(60)}\n`;
summary += `EPUB Content Validation Report\n`;
summary += `${'='.repeat(60)}\n`;
summary += `Total headings: ${expectedHeadings.length}\n`;
summary += `Found: ${foundHeadings.length}\n`;
summary += `Missing: ${missingHeadings.length}\n`;
summary += `Completeness rate: ${successRate}%\n`;
summary += `${'='.repeat(60)}\n`;
if (missingHeadings.length > 0) {
summary += `\n⚠ Missing headings:\n`;
summary += `${'='.repeat(60)}\n`;
// Group by chapter
const byChapter = new Map<string, HeadingInfo[]>();
for (const heading of missingHeadings) {
if (!byChapter.has(heading.chapterPath)) {
byChapter.set(heading.chapterPath, []);
}
byChapter.get(heading.chapterPath)!.push(heading);
}
for (const [chapterPath, headings] of byChapter) {
summary += `\n📄 ${chapterPath}\n`;
for (const heading of headings) {
const indent = ' '.repeat(heading.level - 1);
summary += `${indent}${'#'.repeat(heading.level)} ${heading.text}`;
if (heading.lineNumber) {
summary += ` (line ${heading.lineNumber})`;
}
summary += `\n`;
}
}
summary += `\n`;
} else {
summary += `\n✅ All headings are included in the EPUB!\n\n`;
}
console.log(summary);
return {
success: missingHeadings.length === 0,
missingHeadings,
summary
};
} catch (error) {
console.error('Error during validation:', error);
return {
success: false,
missingHeadings: expectedHeadings,
summary: `Validation failed: ${error}`
};
}
}
/**
* Check if heading exists in HTML
*/
function checkHeadingInHtml(html: string, heading: HeadingInfo): boolean {
// Remove HTML tags and entities, keep only text
const cleanHtml = html
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '') // Remove scripts
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '') // Remove styles
.replace(/<[^>]+>/g, ' ') // Remove all HTML tags
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
// Decode numeric entities
.replace(/&#x([0-9A-Fa-f]+);/g, (match, hex) => {
return String.fromCharCode(parseInt(hex, 16));
})
.replace(/&#(\d+);/g, (match, dec) => {
return String.fromCharCode(parseInt(dec, 10));
});
// Normalize text: remove extra spaces
const normalizedHtml = cleanHtml.replace(/\s+/g, ' ').toLowerCase();
const normalizedHeading = heading.text.replace(/\s+/g, ' ').toLowerCase();
// Check if heading text is included
return normalizedHtml.includes(normalizedHeading);
}

View File

@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="UTF-8"?>
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
<head>
<meta name="dtb:uid" content="<%= id %>" />
<meta name="dtb:generator" content="epub-gen"/>
<meta name="dtb:depth" content="2"/>
<meta name="dtb:totalPageCount" content="0"/>
<meta name="dtb:maxPageNumber" content="0"/>
</head>
<docTitle>
<text><%= title %></text>
</docTitle>
<docAuthor>
<text><%= author %></text>
</docAuthor>
<navMap>
<%
var playOrder = 1;
// 构建层级结构
var hierarchicalContent = [];
var currentParent = null;
content.forEach(function(item, index) {
if (!item.excludeFromToc && !item.beforeToc) {
// 检查是否是顶级章节level 0 或没有 parentTitle
if (item.level === 0 || !item.parentTitle) {
// 开始新的父章节
currentParent = {
title: item.title,
href: item.href,
level: 0,
number: item.number,
children: []
};
hierarchicalContent.push(currentParent);
} else if (item.level === 1 && currentParent) {
// 添加到当前父章节的子项
currentParent.children.push({
title: item.title,
href: item.href,
level: 1,
number: item.number
});
} else {
// 其他情况,作为独立章节
hierarchicalContent.push({
title: item.title,
href: item.href,
level: item.level || 0,
number: item.number,
children: []
});
}
}
});
// 渲染嵌套的导航点
function renderNavPoint(chapter, depth) {
var id = "navpoint_" + playOrder;
var href = chapter.href;
var title = chapter.title || 'Untitled';
var children = chapter.children || [];
var number = chapter.number;
var level = chapter.level || 0;
var currentOrder = playOrder++;
// 对于顶级章节level 0标题已经包含"第X章",不需要再加编号
// 只对子章节level 1添加编号
var displayTitle = (level === 1 && number ? number + ' ' : '') + title;
var result = ' <navPoint id="' + id + '" playOrder="' + currentOrder + '" class="chapter">\n';
result += ' <navLabel>\n';
result += ' <text>' + displayTitle.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</text>\n';
result += ' </navLabel>\n';
result += ' <content src="' + href + '"/>\n';
// 递归渲染子章节
if (children.length > 0) {
children.forEach(function(child) {
result += renderNavPoint(child, depth + 1);
});
}
result += ' </navPoint>\n';
return result;
}
// 渲染所有导航点
hierarchicalContent.forEach(function(chapter) {
%><%- renderNavPoint(chapter, 0) %><%
});
%>
</navMap>
</ncx>

View File

@@ -0,0 +1,115 @@
<?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" xml:lang="<%- lang %>" lang="<%- lang %>">
<head>
<title><%= title %></title>
<meta charset="UTF-8" />
<link rel="stylesheet" type="text/css" href="style.css" />
<style>
/* 隐藏目录中的列表编号 */
nav#toc ol {
list-style: none;
padding-left: 0;
}
nav#toc ol ol {
padding-left: 20px;
}
.toc-level-0 {
margin-left: 0;
font-weight: bold;
}
.toc-level-1 {
margin-left: 20px;
font-weight: normal;
}
</style>
</head>
<body>
<h1 class="h1"><%= tocTitle %></h1>
<nav id="toc" epub:type="toc">
<ol>
<%
// 构建层级结构
var hierarchicalContent = [];
var currentParent = null;
content.forEach(function(item, index) {
if (!item.excludeFromToc && !item.beforeToc) {
// 检查是否是顶级章节level 0 或没有 parentTitle
if (item.level === 0 || !item.parentTitle) {
// 开始新的父章节
currentParent = {
title: item.title,
href: item.href,
level: 0,
number: item.number,
children: []
};
hierarchicalContent.push(currentParent);
} else if (item.level === 1 && currentParent) {
// 添加到当前父章节的子项
currentParent.children.push({
title: item.title,
href: item.href,
level: 1,
number: item.number
});
} else {
// 其他情况,作为独立章节
hierarchicalContent.push({
title: item.title,
href: item.href,
level: item.level || 0,
number: item.number,
children: []
});
}
}
});
// 渲染嵌套的列表项
function renderTocItem(chapter) {
var title = chapter.title || 'Untitled';
var href = chapter.href;
var children = chapter.children || [];
var level = chapter.level || 0;
var number = chapter.number;
// 对于顶级章节level 0标题已经包含"第X章",不需要再加编号
// 只对子章节level 1添加编号
var displayTitle = (level === 1 && number ? number + ' ' : '') + title;
var result = ' <li class="table-of-content toc-level-' + level + '">\n';
result += ' <a href="' + href + '">' + displayTitle;
if (chapter.author && chapter.author.length) {
result += ' - <small class="toc-author">' + chapter.author.join(",") + '</small>';
}
if (chapter.url) {
result += '<span class="toc-link">' + chapter.url + '</span>';
}
result += '</a>\n';
// 如果有子章节,创建嵌套列表
if (children.length > 0) {
result += ' <ol>\n';
children.forEach(function(child) {
result += renderTocItem(child);
});
result += ' </ol>\n';
}
result += ' </li>\n';
return result;
}
// 渲染所有目录项
hierarchicalContent.forEach(function(chapter) {
%><%- renderTocItem(chapter) %><%
});
%>
</ol>
</nav>
</body>
</html>

18
epub/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}