mirror of
https://github.com/krahets/hello-algo.git
synced 2026-03-12 17:51:33 +08:00
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:
5
epub/.gitignore
vendored
Normal file
5
epub/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
dist/
|
||||
node_modules/
|
||||
*.epub
|
||||
test_*.js
|
||||
validation-report.json
|
||||
58
epub/README.md
Normal file
58
epub/README.md
Normal 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`
|
||||
BIN
epub/covers/hello-algo-cover-en.jpg
Normal file
BIN
epub/covers/hello-algo-cover-en.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 448 KiB |
BIN
epub/covers/hello-algo-cover-zh-hant.jpg
Normal file
BIN
epub/covers/hello-algo-cover-zh-hant.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 383 KiB |
BIN
epub/covers/hello-algo-cover-zh.jpg
Normal file
BIN
epub/covers/hello-algo-cover-zh.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 380 KiB |
BIN
epub/fonts/MathJax_Main-Regular.otf
Normal file
BIN
epub/fonts/MathJax_Main-Regular.otf
Normal file
Binary file not shown.
BIN
epub/fonts/MathJax_Math-Regular.otf
Normal file
BIN
epub/fonts/MathJax_Math-Regular.otf
Normal file
Binary file not shown.
1735
epub/package-lock.json
generated
Normal file
1735
epub/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
epub/package.json
Normal file
34
epub/package.json
Normal 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
371
epub/src/epub.ts
Normal 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'),
|
||||
``
|
||||
);
|
||||
}
|
||||
|
||||
// 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
535
epub/src/index.ts
Normal 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
1457
epub/src/markdown.ts
Normal file
File diff suppressed because it is too large
Load Diff
168
epub/src/parser.ts
Normal file
168
epub/src/parser.ts
Normal 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 About this book": "path")
|
||||
const filePath = path.join(basePath, value);
|
||||
if (fs.existsSync(filePath)) {
|
||||
const fullTitle = key.replace(/ /g, ' ').trim();
|
||||
|
||||
// Extract chapter number, e.g., "0.1 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(/ /g, ' ').trim();
|
||||
|
||||
// Extract chapter number from title, e.g., "Chapter 0 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(/ /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
52
epub/src/types.ts
Normal 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
137
epub/src/validator.ts
Normal 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(/ /g, ' ')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
96
epub/templates/toc.ncx.ejs
Normal file
96
epub/templates/toc.ncx.ejs
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>') + '</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>
|
||||
|
||||
115
epub/templates/toc.xhtml.ejs
Normal file
115
epub/templates/toc.xhtml.ejs
Normal 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
18
epub/tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user