Skip to content

Commit 3b6c851

Browse files
committed
feat(epub): 保留 sidebar 层级并生成嵌套目录
- 线性目录会丢失分组关系,导致阅读器内导航语义不清晰 - 复用 sidebar 树同时生成 toc.xhtml 与 nav.xhtml,避免目录来源分叉 - 调整目录页样式以突出顶级层级,并弱化不可点击的分组标题
1 parent 9757392 commit 3b6c851

4 files changed

Lines changed: 81 additions & 31 deletions

File tree

course/.vitepress/epub/build.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { readFileSync, writeFileSync } from "node:fs";
1313
import path from "node:path";
1414
import { fileURLToPath } from "node:url";
1515
import { config } from "./config.ts";
16-
import { resolveChapters } from "./sidebar.ts";
16+
import { resolveChapters, buildNavTree } from "./sidebar.ts";
1717
import { preprocess } from "./preprocess.ts";
1818
import { rewriteLinksAndImages } from "./links.ts";
1919
import { ImageCollector, toPng } from "./images.ts";
@@ -26,8 +26,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
2626
async function main() {
2727
console.log(`[epub] 开始构建《${config.title}》`);
2828

29-
// 1. 解析章节
29+
// 1. 解析章节 + 构建层级目录树
3030
const { chapters, routeToFile } = resolveChapters(config);
31+
const navTree = buildNavTree(routeToFile);
3132
console.log(`[epub] 共 ${chapters.length} 个章节`);
3233

3334
// 2. 准备渲染器与图片收集器
@@ -83,6 +84,7 @@ async function main() {
8384
const epub = await packageEpub({
8485
config,
8586
chapters,
87+
navTree,
8688
renderedChapters: rendered,
8789
images: imageMap,
8890
fonts,

course/.vitepress/epub/package.ts

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import JSZip from "jszip";
22
import type { EpubConfig } from "./config.ts";
3-
import type { Chapter } from "./sidebar.ts";
3+
import type { Chapter, NavNode } from "./sidebar.ts";
44
import { escapeXml, wrapXhtml } from "./render.ts";
55

66
export interface RenderedChapter {
@@ -12,6 +12,8 @@ export interface RenderedChapter {
1212
export interface PackageInput {
1313
config: EpubConfig;
1414
chapters: Chapter[];
15+
/** 层级目录树(保留 sidebar 父/子结构) */
16+
navTree: NavNode[];
1517
renderedChapters: RenderedChapter[];
1618
/** epubPath(去掉 ../) -> bytes,统一 PNG */
1719
images: Map<string, Uint8Array>;
@@ -31,9 +33,34 @@ function mimeOf(p: string): string {
3133
return "application/octet-stream";
3234
}
3335

36+
/**
37+
* 递归渲染层级目录为嵌套列表。
38+
* - 有 href 的节点 -> <a>;分组节点 -> <span>(EPUB nav 允许,用于不可点击的层级标题);
39+
* - 子节点嵌套在父 <li> 内的子列表中。
40+
* @param tag nav.xhtml 必须用 ol;可见目录页用 ul 便于样式化
41+
*/
42+
function renderNavTree(
43+
nodes: NavNode[],
44+
hrefPrefix: string,
45+
tag: "ol" | "ul",
46+
): string {
47+
const items = nodes
48+
.map((n) => {
49+
const head = n.href
50+
? `<a href="${hrefPrefix}${n.href}">${escapeXml(n.text)}</a>`
51+
: `<span>${escapeXml(n.text)}</span>`;
52+
const sub = n.children.length
53+
? `\n${renderNavTree(n.children, hrefPrefix, tag)}\n`
54+
: "";
55+
return `<li>${head}${sub}</li>`;
56+
})
57+
.join("\n");
58+
return `<${tag}>\n${items}\n</${tag}>`;
59+
}
60+
3461
/** 组装并生成 EPUB3 二进制 */
3562
export async function packageEpub(input: PackageInput): Promise<Uint8Array> {
36-
const { config, chapters, renderedChapters, images, fonts, css, cover } =
63+
const { config, navTree, renderedChapters, images, fonts, css, cover } =
3764
input;
3865
const lang = config.language;
3966
const bookId = "urn:uuid:" + crypto.randomUUID();
@@ -64,15 +91,10 @@ export async function packageEpub(input: PackageInput): Promise<Uint8Array> {
6491
`<div class="cover"><img src="../images/cover.png" alt="封面"/></div>`,
6592
lang,
6693
);
67-
const tocItems = chapters
68-
.map(
69-
(ch) =>
70-
`<li class="toc-l${Math.min(ch.item.level, 2)}"><a href="${ch.fileName}">${escapeXml(ch.item.text)}</a></li>`,
71-
)
72-
.join("\n");
94+
// 目录页:保留 sidebar 的父/子层级(嵌套 ul)。toc.xhtml 与章节同在 text/ 目录,故无路径前缀
7395
const tocXhtml = wrapXhtml(
7496
"目录",
75-
`<div class="toc-page"><h1>目录</h1><ul class="toc-list">\n${tocItems}\n</ul></div>`,
97+
`<div class="toc-page"><h1>目录</h1>\n${renderNavTree(navTree, "", "ul")}\n</div>`,
7698
lang,
7799
);
78100

@@ -142,13 +164,7 @@ export async function packageEpub(input: PackageInput): Promise<Uint8Array> {
142164
</package>`;
143165
oebps.file("content.opf", opf);
144166

145-
// ---- nav.xhtml ----
146-
const navList = chapters
147-
.map(
148-
(ch) =>
149-
`<li><a href="text/${ch.fileName}">${escapeXml(ch.item.text)}</a></li>`,
150-
)
151-
.join("\n ");
167+
// ---- nav.xhtml ----(nav.xhtml 在 OEBPS/ 下,章节在 text/,故前缀 text/)
152168
oebps.file(
153169
"nav.xhtml",
154170
`<?xml version="1.0" encoding="UTF-8"?>
@@ -158,9 +174,7 @@ export async function packageEpub(input: PackageInput): Promise<Uint8Array> {
158174
<body>
159175
<nav epub:type="toc" id="toc">
160176
<h1>目录</h1>
161-
<ol>
162-
${navList}
163-
</ol>
177+
${renderNavTree(navTree, "text/", "ol")}
164178
</nav>
165179
</body>
166180
</html>`,

course/.vitepress/epub/sidebar.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,35 @@ export function resolveChapters(config: EpubConfig): {
8484

8585
return { chapters, routeToFile };
8686
}
87+
88+
/** 层级目录节点:保留 sidebar 的父/子结构用于生成嵌套 TOC */
89+
export interface NavNode {
90+
text: string;
91+
/** 对应章节的 xhtml 文件名;分组节点(无可解析链接)则为空 */
92+
href?: string;
93+
children: NavNode[];
94+
}
95+
96+
/**
97+
* 按 sidebar 原始树构建层级目录。
98+
* - 有 link 且能解析到章节的节点 -> 带 href 的可点击项;
99+
* - 分组节点(仅有 items、无 link)-> 无 href 的标题项,其下嵌套子节点;
100+
* - 既无法链接又无子节点的空节点跳过。
101+
*/
102+
export function buildNavTree(routeToFile: Record<string, string>): NavNode[] {
103+
const conv = (nodes: SidebarNode[]): NavNode[] => {
104+
const out: NavNode[] = [];
105+
for (const node of nodes) {
106+
const children = node.items ? conv(node.items) : [];
107+
let href: string | undefined;
108+
if (node.link) {
109+
const route = normalizeRouteKey(node.link);
110+
href = routeToFile[route] || routeToFile[route + "/"];
111+
}
112+
if (!href && children.length === 0) continue;
113+
out.push({ text: node.text, href, children });
114+
}
115+
return out;
116+
};
117+
return conv(sidebar as unknown as SidebarNode[]);
118+
}

course/.vitepress/epub/style.css

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -216,25 +216,27 @@ img {
216216
max-width: 100%;
217217
}
218218

219-
/* 目录页 */
219+
/* 目录页(嵌套层级) */
220220
.toc-page h1 {
221221
text-align: center;
222222
}
223-
.toc-list {
223+
.toc-page ul {
224224
list-style: none;
225+
padding-left: 1.2em;
226+
}
227+
.toc-page > ul {
225228
padding-left: 0;
226229
}
227-
.toc-list li {
230+
.toc-page li {
228231
margin: 0.35em 0;
229232
}
230-
.toc-list .toc-l0 {
233+
/* 顶级条目(分组/一级章节)加粗 */
234+
.toc-page > ul > li > a,
235+
.toc-page > ul > li > span {
231236
font-weight: 700;
232237
font-size: 1.05em;
233238
}
234-
.toc-list .toc-l1 {
235-
padding-left: 1.2em;
236-
}
237-
.toc-list .toc-l2 {
238-
padding-left: 2.4em;
239-
font-size: 0.95em;
239+
/* 分组标题(无链接)用 span,弱化为非链接色 */
240+
.toc-page span {
241+
color: #555;
240242
}

0 commit comments

Comments
 (0)