Publish deterministic bilingual EPUB editions

This commit is contained in:
hanxiankai
2026-09-02 00:15:33 +08:00
parent e9cb40c3c1
commit 22be1defe7
21 changed files with 910 additions and 5 deletions
+1
View File
@@ -0,0 +1 @@
*.epub binary
+1
View File
@@ -101,6 +101,7 @@ All notable project-level changes are documented here. Content pages retain thei
#### Added
- Added deterministic Chinese and English EPUB 3.3 editions containing the main manuscript, glossary, and complete toolkit, with offline images, internal navigation, 1600×2560 portrait covers, integrity metadata, and homepage download links.
- Added the bilingual Recovery, Decision-Making, Relationships, and Attention chapters.
- Added the bilingual Life Practice Toolkit with reusable worksheets for decisions, attention, relationships, and recovery.
- Added the bilingual Listening Resource Audit card for choosing, testing, and retiring volatile listening materials.
+2
View File
@@ -14,6 +14,8 @@ updated: 2026-09-01
<div class="book-meta" aria-label="书稿信息">
<span>持续更新书稿</span>
<a href="./docs/public/downloads/life-level-up-guide-zh.epub" download>下载中文 EPUB</a>
<a href="./docs/public/downloads/life-level-up-guide-en.epub" download>Download English EPUB</a>
<a href="https://github.com/byoungd/up">源码与勘误</a>
<a href="https://creativecommons.org/licenses/by-nc/4.0/">正文 CC BY-NC 4.0</a>
</div>
+2
View File
@@ -14,6 +14,8 @@ updated: 2026-09-01
<div class="book-meta" aria-label="书稿信息">
<span>持续更新书稿</span>
<a href="./downloads/life-level-up-guide-zh.epub" download>下载中文 EPUB</a>
<a href="./downloads/life-level-up-guide-en.epub" download>Download English EPUB</a>
<a href="https://github.com/byoungd/up">源码与勘误</a>
<a href="https://creativecommons.org/licenses/by-nc/4.0/">正文 CC BY-NC 4.0</a>
</div>
+53
View File
@@ -0,0 +1,53 @@
<svg width="1600" height="2560" viewBox="0 0 1600 2560" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title description">
<title id="title">Life Level-up Guide English ebook cover</title>
<desc id="description">By Han Xiankai. Five rising stages represent input, life, tools, practice, and action.</desc>
<rect width="1600" height="2560" fill="#f5f7f3"/>
<rect width="30" height="2560" fill="#176f5b"/>
<path d="M110 210H1490M110 2290H1490" stroke="#cfd7d1" stroke-width="2"/>
<g font-family="system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">
<text x="120" y="170" fill="#176f5b" font-size="38" font-weight="700">A LIFELONG LEARNING GUIDE FOR THE AI ERA</text>
<text x="112" y="500" fill="#1d2926" font-size="158" font-weight="800">LIFE LEVEL-UP</text>
<text x="112" y="675" fill="#1d2926" font-size="158" font-weight="800">GUIDE</text>
<text x="120" y="800" fill="#485652" font-size="46">Begin with language. Move through life, AI,</text>
<text x="120" y="870" fill="#485652" font-size="46">practice, and the work of changing over time.</text>
</g>
<g fill="none" stroke="#dfe5e0" stroke-width="2">
<path d="M110 960H1490"/>
<path d="M110 2040H1490"/>
</g>
<g font-family="system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">
<rect x="120" y="1740" width="220" height="220" rx="8" fill="#176f5b"/>
<rect x="395" y="1570" width="220" height="390" rx="8" fill="#c0523a"/>
<rect x="670" y="1390" width="220" height="570" rx="8" fill="#b8862b"/>
<rect x="945" y="1200" width="220" height="760" rx="8" fill="#3e6e8d"/>
<rect x="1220" y="1000" width="220" height="960" rx="8" fill="#263431"/>
<path d="M230 1680L505 1510L780 1330L1055 1140L1330 940" fill="none" stroke="#1d2926" stroke-width="8"/>
<g fill="#f5f7f3" stroke="#1d2926" stroke-width="7">
<circle cx="230" cy="1680" r="20"/>
<circle cx="505" cy="1510" r="20"/>
<circle cx="780" cy="1330" r="20"/>
<circle cx="1055" cy="1140" r="20"/>
<circle cx="1330" cy="940" r="20"/>
</g>
<g fill="#f5f7f3" font-size="29" font-weight="700" text-anchor="middle">
<text x="230" y="1880">INPUT</text>
<text x="505" y="1880">LIFE</text>
<text x="780" y="1880">TOOLS</text>
<text x="1055" y="1880">PRACTICE</text>
<text x="1330" y="1880">ACTION</text>
</g>
<g fill="#6b7773" font-size="26" text-anchor="middle">
<text x="230" y="2015">01</text>
<text x="505" y="2015">02</text>
<text x="780" y="2015">03</text>
<text x="1055" y="2015">04</text>
<text x="1330" y="2015">05</text>
</g>
<text x="120" y="2210" fill="#1d2926" font-size="37" font-weight="700">QUESTION → LEARN → DELIVER → PRESERVE EVIDENCE → TRANSFER</text>
<text x="120" y="2390" fill="#1d2926" font-size="50" font-weight="700">HAN XIANKAI</text>
<text x="120" y="2500" fill="#6b7773" font-size="30">byoungd.github.io/up/en</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

+53
View File
@@ -0,0 +1,53 @@
<svg width="1600" height="2560" viewBox="0 0 1600 2560" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title description">
<title id="title">《人生进阶指南》中文电子书封面</title>
<desc id="description">韩先凯著。五级上升结构代表输入、生活、工具、实践与行动。</desc>
<rect width="1600" height="2560" fill="#f5f7f3"/>
<rect width="30" height="2560" fill="#176f5b"/>
<path d="M110 210H1490M110 2290H1490" stroke="#cfd7d1" stroke-width="2"/>
<g font-family="system-ui,-apple-system,BlinkMacSystemFont,'PingFang SC','Microsoft YaHei',sans-serif">
<text x="120" y="170" fill="#176f5b" font-size="44" font-weight="700">AI 时代终身学习指南</text>
<text x="112" y="560" fill="#1d2926" font-size="190" font-weight="800">人生进阶指南</text>
<text x="120" y="690" fill="#485652" font-size="54">从英语这扇门出发</text>
<text x="120" y="770" fill="#485652" font-size="54">走向学习、生活、AI 与长期行动</text>
</g>
<g fill="none" stroke="#dfe5e0" stroke-width="2">
<path d="M110 960H1490"/>
<path d="M110 2040H1490"/>
</g>
<g font-family="system-ui,-apple-system,BlinkMacSystemFont,'PingFang SC','Microsoft YaHei',sans-serif">
<rect x="120" y="1740" width="220" height="220" rx="8" fill="#176f5b"/>
<rect x="395" y="1570" width="220" height="390" rx="8" fill="#c0523a"/>
<rect x="670" y="1390" width="220" height="570" rx="8" fill="#b8862b"/>
<rect x="945" y="1200" width="220" height="760" rx="8" fill="#3e6e8d"/>
<rect x="1220" y="1000" width="220" height="960" rx="8" fill="#263431"/>
<path d="M230 1680L505 1510L780 1330L1055 1140L1330 940" fill="none" stroke="#1d2926" stroke-width="8"/>
<g fill="#f5f7f3" stroke="#1d2926" stroke-width="7">
<circle cx="230" cy="1680" r="20"/>
<circle cx="505" cy="1510" r="20"/>
<circle cx="780" cy="1330" r="20"/>
<circle cx="1055" cy="1140" r="20"/>
<circle cx="1330" cy="940" r="20"/>
</g>
<g fill="#f5f7f3" font-size="38" font-weight="700" text-anchor="middle">
<text x="230" y="1880">输入</text>
<text x="505" y="1880">生活</text>
<text x="780" y="1880">工具</text>
<text x="1055" y="1880">实践</text>
<text x="1330" y="1880">行动</text>
</g>
<g fill="#6b7773" font-size="26" text-anchor="middle">
<text x="230" y="2015">01</text>
<text x="505" y="2015">02</text>
<text x="780" y="2015">03</text>
<text x="1055" y="2015">04</text>
<text x="1330" y="2015">05</text>
</g>
<text x="120" y="2210" fill="#1d2926" font-size="42" font-weight="700">发现问题 → 主动学习 → 完成真实任务 → 保存证据 → 复盘迁移</text>
<text x="120" y="2390" fill="#1d2926" font-size="50" font-weight="700">韩先凯 著</text>
<text x="405" y="2390" fill="#6b7773" font-size="36">笔名:离谱</text>
<text x="120" y="2500" fill="#6b7773" font-size="30">byoungd.github.io/up</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+2
View File
@@ -14,6 +14,8 @@ Subtitle: **Lifelong Learning Guide for the AI Era**. This living manuscript beg
<div class="book-meta" aria-label="Book information">
<span>Living manuscript</span>
<a href="../downloads/life-level-up-guide-en.epub" download>Download English EPUB</a>
<a href="../downloads/life-level-up-guide-zh.epub" download>下载中文 EPUB</a>
<a href="https://github.com/byoungd/up">Source and corrections</a>
<a href="https://creativecommons.org/licenses/by-nc/4.0/">Text CC BY-NC 4.0</a>
</div>
+15 -1
View File
@@ -3,7 +3,9 @@
"sources": {
"logo.svg": "f3f2c83b865090ebabee7e4ed794e3acc9d78ef675eaace5416b8ae91ea6c70e",
"feature.svg": "714354ecf7069282f1c9f9642661d2f1b618f43f5381b8be5531d256d018bf91",
"feature-en.svg": "0b187ee711c46ea179d5653a47565e20fa80274267ecba27645915f321adb439"
"feature-en.svg": "0b187ee711c46ea179d5653a47565e20fa80274267ecba27645915f321adb439",
"cover-portrait.svg": "a2c324a411993bc691e7bcd1ab3cb288b49326a3126d86ecedba3fc0cc3d3556",
"cover-portrait-en.svg": "6ea672368e7efca8e207008b473844db05e416c26f0e89bccca2e7ef4870d9c2"
},
"outputs": {
"feature.png": {
@@ -17,6 +19,18 @@
"width": 1200,
"height": 630,
"sha256": "ccf28486d64d44519cf93beb5e070a27438134415a8bf411cd9ad0d2689d802f"
},
"cover-portrait.png": {
"source": "cover-portrait.svg",
"width": 1600,
"height": 2560,
"sha256": "abf59f79f38a57c940eb4cc38685b858c84dc4697bc4ffc92d237ef8e13d8b8e"
},
"cover-portrait-en.png": {
"source": "cover-portrait-en.svg",
"width": 1600,
"height": 2560,
"sha256": "c00cce19ae41003bc2c2ef5f784b0dd377be230e2cce2fadcd3e7e6f43e4f864"
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

+53
View File
@@ -0,0 +1,53 @@
<svg width="1600" height="2560" viewBox="0 0 1600 2560" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title description">
<title id="title">Life Level-up Guide English ebook cover</title>
<desc id="description">By Han Xiankai. Five rising stages represent input, life, tools, practice, and action.</desc>
<rect width="1600" height="2560" fill="#f5f7f3"/>
<rect width="30" height="2560" fill="#176f5b"/>
<path d="M110 210H1490M110 2290H1490" stroke="#cfd7d1" stroke-width="2"/>
<g font-family="system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">
<text x="120" y="170" fill="#176f5b" font-size="38" font-weight="700">A LIFELONG LEARNING GUIDE FOR THE AI ERA</text>
<text x="112" y="500" fill="#1d2926" font-size="158" font-weight="800">LIFE LEVEL-UP</text>
<text x="112" y="675" fill="#1d2926" font-size="158" font-weight="800">GUIDE</text>
<text x="120" y="800" fill="#485652" font-size="46">Begin with language. Move through life, AI,</text>
<text x="120" y="870" fill="#485652" font-size="46">practice, and the work of changing over time.</text>
</g>
<g fill="none" stroke="#dfe5e0" stroke-width="2">
<path d="M110 960H1490"/>
<path d="M110 2040H1490"/>
</g>
<g font-family="system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">
<rect x="120" y="1740" width="220" height="220" rx="8" fill="#176f5b"/>
<rect x="395" y="1570" width="220" height="390" rx="8" fill="#c0523a"/>
<rect x="670" y="1390" width="220" height="570" rx="8" fill="#b8862b"/>
<rect x="945" y="1200" width="220" height="760" rx="8" fill="#3e6e8d"/>
<rect x="1220" y="1000" width="220" height="960" rx="8" fill="#263431"/>
<path d="M230 1680L505 1510L780 1330L1055 1140L1330 940" fill="none" stroke="#1d2926" stroke-width="8"/>
<g fill="#f5f7f3" stroke="#1d2926" stroke-width="7">
<circle cx="230" cy="1680" r="20"/>
<circle cx="505" cy="1510" r="20"/>
<circle cx="780" cy="1330" r="20"/>
<circle cx="1055" cy="1140" r="20"/>
<circle cx="1330" cy="940" r="20"/>
</g>
<g fill="#f5f7f3" font-size="29" font-weight="700" text-anchor="middle">
<text x="230" y="1880">INPUT</text>
<text x="505" y="1880">LIFE</text>
<text x="780" y="1880">TOOLS</text>
<text x="1055" y="1880">PRACTICE</text>
<text x="1330" y="1880">ACTION</text>
</g>
<g fill="#6b7773" font-size="26" text-anchor="middle">
<text x="230" y="2015">01</text>
<text x="505" y="2015">02</text>
<text x="780" y="2015">03</text>
<text x="1055" y="2015">04</text>
<text x="1330" y="2015">05</text>
</g>
<text x="120" y="2210" fill="#1d2926" font-size="37" font-weight="700">QUESTION → LEARN → DELIVER → PRESERVE EVIDENCE → TRANSFER</text>
<text x="120" y="2390" fill="#1d2926" font-size="50" font-weight="700">HAN XIANKAI</text>
<text x="120" y="2500" fill="#6b7773" font-size="30">byoungd.github.io/up/en</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

+53
View File
@@ -0,0 +1,53 @@
<svg width="1600" height="2560" viewBox="0 0 1600 2560" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title description">
<title id="title">《人生进阶指南》中文电子书封面</title>
<desc id="description">韩先凯著。五级上升结构代表输入、生活、工具、实践与行动。</desc>
<rect width="1600" height="2560" fill="#f5f7f3"/>
<rect width="30" height="2560" fill="#176f5b"/>
<path d="M110 210H1490M110 2290H1490" stroke="#cfd7d1" stroke-width="2"/>
<g font-family="system-ui,-apple-system,BlinkMacSystemFont,'PingFang SC','Microsoft YaHei',sans-serif">
<text x="120" y="170" fill="#176f5b" font-size="44" font-weight="700">AI 时代终身学习指南</text>
<text x="112" y="560" fill="#1d2926" font-size="190" font-weight="800">人生进阶指南</text>
<text x="120" y="690" fill="#485652" font-size="54">从英语这扇门出发</text>
<text x="120" y="770" fill="#485652" font-size="54">走向学习、生活、AI 与长期行动</text>
</g>
<g fill="none" stroke="#dfe5e0" stroke-width="2">
<path d="M110 960H1490"/>
<path d="M110 2040H1490"/>
</g>
<g font-family="system-ui,-apple-system,BlinkMacSystemFont,'PingFang SC','Microsoft YaHei',sans-serif">
<rect x="120" y="1740" width="220" height="220" rx="8" fill="#176f5b"/>
<rect x="395" y="1570" width="220" height="390" rx="8" fill="#c0523a"/>
<rect x="670" y="1390" width="220" height="570" rx="8" fill="#b8862b"/>
<rect x="945" y="1200" width="220" height="760" rx="8" fill="#3e6e8d"/>
<rect x="1220" y="1000" width="220" height="960" rx="8" fill="#263431"/>
<path d="M230 1680L505 1510L780 1330L1055 1140L1330 940" fill="none" stroke="#1d2926" stroke-width="8"/>
<g fill="#f5f7f3" stroke="#1d2926" stroke-width="7">
<circle cx="230" cy="1680" r="20"/>
<circle cx="505" cy="1510" r="20"/>
<circle cx="780" cy="1330" r="20"/>
<circle cx="1055" cy="1140" r="20"/>
<circle cx="1330" cy="940" r="20"/>
</g>
<g fill="#f5f7f3" font-size="38" font-weight="700" text-anchor="middle">
<text x="230" y="1880">输入</text>
<text x="505" y="1880">生活</text>
<text x="780" y="1880">工具</text>
<text x="1055" y="1880">实践</text>
<text x="1330" y="1880">行动</text>
</g>
<g fill="#6b7773" font-size="26" text-anchor="middle">
<text x="230" y="2015">01</text>
<text x="505" y="2015">02</text>
<text x="780" y="2015">03</text>
<text x="1055" y="2015">04</text>
<text x="1330" y="2015">05</text>
</g>
<text x="120" y="2210" fill="#1d2926" font-size="42" font-weight="700">发现问题 → 主动学习 → 完成真实任务 → 保存证据 → 复盘迁移</text>
<text x="120" y="2390" fill="#1d2926" font-size="50" font-weight="700">韩先凯 著</text>
<text x="405" y="2390" fill="#6b7773" font-size="36">笔名:离谱</text>
<text x="120" y="2500" fill="#6b7773" font-size="30">byoungd.github.io/up</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+25
View File
@@ -0,0 +1,25 @@
{
"version": 1,
"standard": "EPUB 3.3",
"scope": "Main manuscript, glossary, and toolkit; archive and word lists remain online-only.",
"outputs": {
"zh-CN": {
"file": "life-level-up-guide-zh.epub",
"language": "zh-CN",
"chapters": 53,
"images": 20,
"bytes": 2576693,
"sha256": "70585f9c34c9ac1c2f632ab4d130162c6e1900c1c248e0333a29af2d68c4275e",
"sourceSha256": "d5a78238f803012227d8f2c48f8168428735f984ba261cbe3d598a149e1183a1"
},
"en-US": {
"file": "life-level-up-guide-en.epub",
"language": "en-US",
"chapters": 53,
"images": 20,
"bytes": 2546644,
"sha256": "dc4a13dd1ee44e5e563d42d51904ceea99d0a712a589262b49502d0c15eb28ef",
"sourceSha256": "8fe512dbeddc69b5f27086d40dd4d26c5106cb5d1b15ae34be05748bb72bb180"
}
}
}
Binary file not shown.
Binary file not shown.
+4 -2
View File
@@ -16,8 +16,10 @@
"check:readme": "node scripts/sync-readme.mjs --check",
"check:format": "markdownlint-cli2 \"**/*.md\" \"#node_modules\" \"#docs/.vitepress/dist\"",
"check:bundle": "node scripts/check-build-size.mjs",
"check": "npm run check:navigation && npm run check:readme && npm run check:content && npm run check:format",
"sync": "node scripts/sync-navigation.mjs && node scripts/sync-word-lists.mjs && node scripts/sync-public-assets.mjs && node scripts/sync-readme.mjs",
"book:build": "node scripts/build-epub.mjs",
"book:check": "node scripts/build-epub.mjs --check",
"check": "npm run check:navigation && npm run check:readme && npm run check:content && npm run check:format && npm run book:check",
"sync": "node scripts/sync-navigation.mjs && node scripts/sync-word-lists.mjs && node scripts/sync-public-assets.mjs && node scripts/sync-readme.mjs && npm run book:build",
"test:smoke": "playwright test",
"assets:sanitize": "node scripts/sanitize-assets.mjs"
},
+585
View File
@@ -0,0 +1,585 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import {
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
utimesSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, extname, join, normalize, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { createMarkdownRenderer } from "vitepress";
import { enNavigation, zhNavigation } from "../docs/.vitepress/navigation.mjs";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const DOCS = join(ROOT, "docs");
const PUBLIC = join(DOCS, "public");
const OUTPUT_DIR = join(PUBLIC, "downloads");
const ONLINE_ROOT = "https://byoungd.github.io/up/";
const checkOnly = process.argv.includes("--check");
const fixedTime = new Date("1980-01-01T00:00:00Z");
const zipEnv = { ...process.env, TZ: "UTC" };
const editions = [
{
key: "zh",
file: "life-level-up-guide-zh.epub",
navigation: zhNavigation,
lang: "zh-CN",
title: "人生进阶指南",
subtitle: "AI 时代终身学习指南",
creator: "韩先凯",
frontMatter: "开卷",
appendices: "附录与工具箱",
contents: "目录",
coverAlt: "《人生进阶指南》封面",
description: "从英语、AI、真实项目与人生低谷出发,建立能够复测、迁移、恢复并承担责任的终身学习系统。",
coverSource: join(PUBLIC, "assets/cover-portrait.png"),
},
{
key: "en",
file: "life-level-up-guide-en.epub",
navigation: enNavigation,
lang: "en-US",
title: "Life Level-up Guide",
subtitle: "Lifelong Learning Guide for the AI Era",
creator: "Han Xiankai",
frontMatter: "Front Matter",
appendices: "Appendices and Toolkit",
contents: "Contents",
coverAlt: "Life Level-up Guide cover",
description: "A lifelong-learning system for English, AI, real projects, difficult seasons, evidence, transfer, recovery, and responsibility.",
coverSource: join(PUBLIC, "assets/cover-portrait-en.png"),
},
];
const allNavigationItems = [...zhNavigation, ...enNavigation].flatMap(({ items }) => items);
const sourceToRoute = new Map(allNavigationItems.map(({ source, link }) => [source, link]));
const routeToSource = new Map(
allNavigationItems.map(({ source, link }) => [link.replace(/^\/+|\/+$/g, ""), source]),
);
function sha256(value) {
return createHash("sha256").update(value).digest("hex");
}
function escapeXml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}
function parseFrontmatter(file) {
const source = readFileSync(file, "utf8");
const block = source.match(/^---\n([\s\S]*?)\n---\n/);
const values = {};
if (block) {
for (const line of block[1].split("\n")) {
const match = line.match(/^([a-zA-Z][\w-]*):\s*(.+)$/);
if (match) values[match[1]] = match[2].trim().replace(/^["']|["']$/g, "");
}
}
return {
body: block ? source.slice(block[0].length) : source,
values,
};
}
function publicationSections(edition) {
const [start, ...rest] = edition.navigation;
return [
{ text: edition.frontMatter, items: start.items.slice(1, 3) },
...rest.slice(0, 6),
{
text: edition.appendices,
items: [...start.items.slice(3), ...edition.navigation[7].items],
},
];
}
function normalizeSource(path) {
return normalize(path).split(sep).join("/").replace(/^\.\//, "");
}
function sourceForRoute(route) {
const clean = route.replace(/^\/+|\/+$/g, "");
return routeToSource.get(clean);
}
function resolveMarkdownTarget(source, rawPath) {
let decoded;
try {
decoded = decodeURIComponent(rawPath);
} catch {
decoded = rawPath;
}
if (decoded.startsWith("/")) return sourceForRoute(decoded);
const candidate = normalizeSource(join(dirname(source), decoded));
const choices = extname(candidate)
? [candidate]
: [`${candidate}.md`, join(candidate, "README.md")];
return choices.find((choice) => existsSync(join(DOCS, choice)));
}
function onlineUrlForSource(source, hash = "", query = "") {
const configured = sourceToRoute.get(source);
let route = configured;
if (!route) {
if (source === "README.md") route = "/";
else if (source === "en/README.md") route = "/en/";
else if (source.endsWith("/README.md")) route = `/${source.slice(0, -"README.md".length)}`;
else route = `/${source.replace(/\.md$/, "")}`;
}
return `${ONLINE_ROOT.replace(/\/$/, "")}${route}${query}${hash}`;
}
function onlineUrlForUnresolvedPath(source, rawPath, hash = "", query = "") {
const normalized = rawPath.startsWith("/")
? rawPath.replace(/^\/+/, "")
: normalizeSource(join(dirname(source), rawPath));
return `${ONLINE_ROOT}${normalized}${query}${hash}`;
}
function mediaType(file) {
const extension = extname(file).toLowerCase();
return {
".css": "text/css",
".gif": "image/gif",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
".xhtml": "application/xhtml+xml",
}[extension];
}
function walk(directory, output = []) {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const path = join(directory, entry.name);
if (entry.isDirectory()) walk(path, output);
else output.push(path);
}
return output;
}
function rewriteHtmlAttributes(content, source, rewriteLink, rewriteImage) {
return content
.replace(/\bhref=(['"])(.*?)\1/gi, (_match, quote, href) => `href=${quote}${rewriteLink(href, source)}${quote}`)
.replace(/\bsrc=(['"])(.*?)\1/gi, (_match, quote, src) => `src=${quote}${rewriteImage(src, source)}${quote}`)
.replace(/\s+(?:target|loading|decoding|fetchpriority)=(['"])[^'"]*\1/gi, "");
}
function makeXhtml({ lang, title, body, epubType = "chapter" }) {
const safeBody = body
.replace(/<a\b[^>]*class="header-anchor"[^>]*>[\s\S]*?<\/a>/gi, "")
.replace(/\s+tabindex=(['"])-?\d+\1/gi, "")
.replace(/&nbsp;/g, "&#160;")
.replace(/&ZeroWidthSpace;/g, "&#8203;")
.replace(/&(?!(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-f]+);)/gi, "&amp;")
.replace(/<(img|br|hr)(\b[^>]*?)(?<!\/)\s*>/gi, "<$1$2 />");
return `<?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" lang="${lang}" xml:lang="${lang}">
<head>
<meta charset="utf-8" />
<title>${escapeXml(title)}</title>
<link rel="stylesheet" type="text/css" href="../styles/book.css" />
</head>
<body epub:type="${epubType}">
<main>
${safeBody}
</main>
</body>
</html>
`;
}
function unescapeXml(value) {
return value
.replaceAll("&quot;", '"')
.replaceAll("&apos;", "'")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&amp;", "&");
}
function validateEpubRoot(epubRoot, edition, chapterRecords) {
const oebps = join(epubRoot, "OEBPS");
const opf = readFileSync(join(oebps, "package.opf"), "utf8");
const files = new Set(walk(oebps).map((file) => normalizeSource(relative(oebps, file))));
const manifestItems = [...opf.matchAll(/<item\s+([^>]+)\/>/g)].map((match) => {
const attributes = Object.fromEntries(
[...match[1].matchAll(/([\w-]+)="([^"]*)"/g)].map((attribute) => [attribute[1], unescapeXml(attribute[2])]),
);
return attributes;
});
const manifestById = new Map(manifestItems.map((item) => [item.id, item]));
const manifestFiles = new Set(manifestItems.map(({ href }) => normalizeSource(href)));
for (const file of files) {
if (file === "package.opf") continue;
if (!manifestFiles.has(file)) throw new Error(`${edition.file}: manifest 未收录 ${file}`);
}
for (const file of manifestFiles) {
if (!files.has(file)) throw new Error(`${edition.file}: manifest 目标不存在 ${file}`);
}
const spineIds = [...opf.matchAll(/<itemref\s+idref="([^"]+)"\s*\/>/g)].map((match) => match[1]);
if (spineIds.length !== chapterRecords.length + 2) {
throw new Error(`${edition.file}: spine 数量错误: ${spineIds.length}`);
}
for (const id of spineIds) {
if (!manifestById.has(id)) throw new Error(`${edition.file}: spine 引用未声明资源 ${id}`);
}
const xhtmlFiles = [...files].filter((file) => file.endsWith(".xhtml"));
for (const file of xhtmlFiles) {
const content = readFileSync(join(oebps, file), "utf8");
for (const match of content.matchAll(/\b(?:href|src)="([^"]+)"/g)) {
const href = unescapeXml(match[1]);
if (/^(?:https?:|mailto:|tel:|data:|\/\/)/i.test(href)) continue;
const [pathAndQuery, rawHash = ""] = href.split("#", 2);
const cleanPath = pathAndQuery.split("?", 1)[0];
const targetFile = normalizeSource(cleanPath ? join(dirname(file), cleanPath) : file);
if (!files.has(targetFile)) throw new Error(`${edition.file}: ${file} 链接到不存在的 ${href}`);
if (rawHash) {
let hash;
try {
hash = decodeURIComponent(rawHash);
} catch {
hash = rawHash;
}
const targetContent = readFileSync(join(oebps, targetFile), "utf8");
const escapedId = escapeXml(hash);
if (!targetContent.includes(`id="${escapedId}"`)) {
throw new Error(`${edition.file}: ${file} 链接到不存在的锚点 ${href}`);
}
}
}
}
const nav = readFileSync(join(oebps, "nav.xhtml"), "utf8");
const navLinks = [...nav.matchAll(/<a href="([^"]+)"/g)].map((match) => match[1]);
if (navLinks.length !== chapterRecords.length + 1) {
throw new Error(`${edition.file}: 导航条目数量错误: ${navLinks.length}`);
}
}
function bookCss(lang) {
const isChinese = lang.startsWith("zh");
return `:root { color-scheme: light dark; }
body {
font-family: ${isChinese ? '"Noto Serif CJK SC", "Songti SC", SimSun, serif' : 'Georgia, "Times New Roman", serif'};
line-height: 1.72;
margin: 5%;
orphans: 2;
widows: 2;
}
main { max-width: 42rem; margin: 0 auto; }
h1, h2, h3, h4 { line-height: 1.3; page-break-after: avoid; }
h1 { font-size: 1.8em; margin: 0 0 1.2em; }
h2 { font-size: 1.35em; margin-top: 2.2em; }
h3 { font-size: 1.12em; margin-top: 1.8em; }
p, li { text-align: ${isChinese ? "justify" : "left"}; }
a { color: inherit; text-decoration: underline; text-decoration-thickness: 0.06em; }
blockquote { border-left: 0.18em solid #737373; margin: 1.4em 0; padding-left: 1em; color: #555; }
img { display: block; height: auto; margin: 1.6em auto; max-width: 100%; }
table { border-collapse: collapse; font-size: 0.86em; margin: 1.5em 0; width: 100%; }
th, td { border: 1px solid #999; padding: 0.45em; vertical-align: top; }
pre { background: #f3f3f3; border: 1px solid #ddd; overflow-wrap: anywhere; padding: 0.8em; white-space: pre-wrap; }
code { font-family: "SFMono-Regular", Consolas, monospace; font-size: 0.9em; }
.book-meta, .guide-paths { margin: 1.3em 0; }
.guide-path { display: block; margin: 0.7em 0; text-decoration: none; }
.guide-path strong { display: block; }
.cover { margin: 0; padding: 0; text-align: center; }
.cover img { height: auto; margin: 0 auto; max-height: 90vh; max-width: 100%; }
@media (prefers-color-scheme: dark) {
blockquote { color: #ccc; }
pre { background: #222; border-color: #555; }
}
`;
}
function containerXml() {
return `<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/package.opf" media-type="application/oebps-package+xml" />
</rootfiles>
</container>
`;
}
async function buildEdition(edition, markdown, tempBase) {
const sections = publicationSections(edition);
const items = sections.flatMap(({ items }) => items);
const sourceToChapter = new Map(
items.map(({ source }, index) => [source, `chapter-${String(index + 1).padStart(3, "0")}.xhtml`]),
);
const epubRoot = join(tempBase, `${edition.key}-root`);
const textDir = join(epubRoot, "OEBPS/text");
const stylesDir = join(epubRoot, "OEBPS/styles");
const assetsDir = join(epubRoot, "OEBPS/assets");
const metaDir = join(epubRoot, "META-INF");
for (const directory of [textDir, stylesDir, assetsDir, metaDir]) mkdirSync(directory, { recursive: true });
writeFileSync(join(epubRoot, "mimetype"), "application/epub+zip");
writeFileSync(join(metaDir, "container.xml"), containerXml());
writeFileSync(join(stylesDir, "book.css"), bookCss(edition.lang));
copyFileSync(edition.coverSource, join(assetsDir, "cover.png"));
const imageTargets = new Map();
const rewriteImage = (href, source) => {
if (/^(?:https?:|data:|\/\/)/i.test(href)) return href;
const clean = href.split(/[?#]/, 1)[0];
let absolute;
if (clean.startsWith("/assets/")) absolute = join(PUBLIC, clean.replace(/^\/+/, ""));
else absolute = resolve(dirname(join(DOCS, source)), clean);
if (!existsSync(absolute)) throw new Error(`${source}: EPUB 图片不存在: ${href}`);
let target;
if (absolute.startsWith(`${join(DOCS, "assets")}${sep}`)) {
target = `assets/${normalizeSource(relative(join(DOCS, "assets"), absolute))}`;
} else if (absolute.startsWith(`${PUBLIC}${sep}`)) {
target = `assets/public/${normalizeSource(relative(PUBLIC, absolute))}`;
} else {
throw new Error(`${source}: EPUB 图片不在公开资源目录: ${href}`);
}
imageTargets.set(absolute, target);
return `../${target}`;
};
const rewriteLink = (href, source) => {
if (/^(?:https?:|mailto:|tel:|data:|\/\/)/i.test(href) || href.startsWith("#")) return href;
const hashIndex = href.indexOf("#");
const queryIndex = href.indexOf("?");
const pathEnd = [hashIndex, queryIndex].filter((index) => index >= 0).sort((a, b) => a - b)[0] ?? href.length;
const rawPath = href.slice(0, pathEnd);
const query = queryIndex >= 0 ? href.slice(queryIndex, hashIndex >= 0 ? hashIndex : href.length) : "";
const hash = hashIndex >= 0 ? href.slice(hashIndex) : "";
if (!rawPath) return `${query}${hash}`;
const targetSource = resolveMarkdownTarget(source, rawPath);
if (!targetSource) return onlineUrlForUnresolvedPath(source, rawPath, hash, query);
const chapter = sourceToChapter.get(targetSource);
return chapter ? `${chapter}${hash}` : onlineUrlForSource(targetSource, hash, query);
};
const chapterRecords = [];
let latestUpdated = "1970-01-01";
for (const [index, item] of items.entries()) {
const sourceFile = join(DOCS, item.source);
const { body, values } = parseFrontmatter(sourceFile);
latestUpdated = [latestUpdated, values.updated || "1970-01-01"].sort().at(-1);
const env = { path: item.source, relativePath: item.source };
const tokens = markdown.parse(body, env);
const visit = (token) => {
if (token.type === "link_open") token.attrSet("href", rewriteLink(token.attrGet("href"), item.source));
if (token.type === "image") token.attrSet("src", rewriteImage(token.attrGet("src"), item.source));
if (token.type === "html_block" || token.type === "html_inline") {
token.content = rewriteHtmlAttributes(token.content, item.source, rewriteLink, rewriteImage);
}
for (const child of token.children || []) visit(child);
};
for (const token of tokens) visit(token);
const rendered = markdown.renderer.render(tokens, markdown.options, env);
const filename = sourceToChapter.get(item.source);
const title = values.title || item.text;
writeFileSync(
join(textDir, filename),
makeXhtml({ lang: edition.lang, title, body: rendered, epubType: item.source.endsWith("README.md") ? "preface" : "chapter" }),
);
chapterRecords.push({
id: `chapter-${String(index + 1).padStart(3, "0")}`,
filename,
source: item.source,
title,
});
}
for (const [absolute, target] of [...imageTargets.entries()].sort((a, b) => a[1].localeCompare(b[1]))) {
const destination = join(epubRoot, "OEBPS", target);
mkdirSync(dirname(destination), { recursive: true });
copyFileSync(absolute, destination);
}
const coverBody = `<section class="cover" epub:type="cover"><img src="../assets/cover.png" alt="${escapeXml(edition.coverAlt)}" /></section>`;
writeFileSync(
join(textDir, "cover.xhtml"),
makeXhtml({ lang: edition.lang, title: edition.title, body: coverBody, epubType: "cover" }),
);
const titleBody = `<section class="title-page" epub:type="titlepage">
<h1>${escapeXml(edition.title)}</h1>
<p class="subtitle">${escapeXml(edition.subtitle)}</p>
<p class="author">${escapeXml(edition.creator)}</p>
<p>${escapeXml(edition.description)}</p>
<p><a href="${ONLINE_ROOT}">${ONLINE_ROOT}</a></p>
<p>CC BY-NC 4.0 · ${latestUpdated}</p>
</section>`;
writeFileSync(
join(textDir, "title.xhtml"),
makeXhtml({ lang: edition.lang, title: edition.title, body: titleBody, epubType: "titlepage" }),
);
const navSections = sections
.map(({ text, items }) => {
const links = items
.map(({ source, text: label }) => ` <li><a href="text/${sourceToChapter.get(source)}">${escapeXml(label)}</a></li>`)
.join("\n");
return ` <li><span>${escapeXml(text)}</span>\n <ol>\n${links}\n </ol>\n </li>`;
})
.join("\n");
const navXhtml = `<?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" lang="${edition.lang}" xml:lang="${edition.lang}">
<head><meta charset="utf-8" /><title>${escapeXml(edition.contents)}</title><link rel="stylesheet" type="text/css" href="styles/book.css" /></head>
<body><nav epub:type="toc" id="toc"><h1>${escapeXml(edition.contents)}</h1><ol>
<li><a href="text/title.xhtml">${escapeXml(edition.title)}</a></li>
${navSections}
</ol></nav></body>
</html>
`;
writeFileSync(join(epubRoot, "OEBPS/nav.xhtml"), navXhtml);
const manifestImages = [...imageTargets.values()].sort().map((target, index) => {
const type = mediaType(target);
if (!type) throw new Error(`EPUB 不支持图片类型: ${target}`);
return ` <item id="image-${String(index + 1).padStart(3, "0")}" href="${escapeXml(target)}" media-type="${type}" />`;
});
const manifestChapters = chapterRecords.map(
({ id, filename }) => ` <item id="${id}" href="text/${filename}" media-type="application/xhtml+xml" />`,
);
const spine = chapterRecords.map(({ id }) => ` <itemref idref="${id}" />`);
const identifier = `${ONLINE_ROOT}downloads/${edition.file}`;
const opf = `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id" xml:lang="${edition.lang}">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="pub-id">${escapeXml(identifier)}</dc:identifier>
<dc:title>${escapeXml(edition.title)}</dc:title>
<dc:language>${edition.lang}</dc:language>
<dc:creator>${escapeXml(edition.creator)}</dc:creator>
<dc:description>${escapeXml(edition.description)}</dc:description>
<dc:rights>CC BY-NC 4.0</dc:rights>
<dc:source>${ONLINE_ROOT}</dc:source>
<meta property="dcterms:modified">${latestUpdated}T00:00:00Z</meta>
</metadata>
<manifest>
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav" />
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml" />
<item id="css" href="styles/book.css" media-type="text/css" />
<item id="cover-image" href="assets/cover.png" media-type="image/png" properties="cover-image" />
<item id="cover" href="text/cover.xhtml" media-type="application/xhtml+xml" />
<item id="title-page" href="text/title.xhtml" media-type="application/xhtml+xml" />
${manifestChapters.join("\n")}
${manifestImages.join("\n")}
</manifest>
<spine toc="ncx">
<itemref idref="cover" />
<itemref idref="title-page" />
${spine.join("\n")}
</spine>
</package>
`;
writeFileSync(join(epubRoot, "OEBPS/package.opf"), opf);
const navPoints = [
{ id: "cover", title: edition.title, href: "text/cover.xhtml" },
{ id: "title-page", title: edition.title, href: "text/title.xhtml" },
...chapterRecords.map(({ id, title, filename }) => ({ id, title, href: `text/${filename}` })),
];
const ncx = `<?xml version="1.0" encoding="UTF-8"?>
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1" xml:lang="${edition.lang}">
<head><meta name="dtb:uid" content="${escapeXml(identifier)}" /></head>
<docTitle><text>${escapeXml(edition.title)}</text></docTitle>
<navMap>
${navPoints.map(({ id, title, href }, index) => ` <navPoint id="${id}" playOrder="${index + 1}"><navLabel><text>${escapeXml(title)}</text></navLabel><content src="${href}" /></navPoint>`).join("\n")}
</navMap>
</ncx>
`;
writeFileSync(join(epubRoot, "OEBPS/toc.ncx"), ncx);
validateEpubRoot(epubRoot, edition, chapterRecords);
for (const file of walk(epubRoot)) utimesSync(file, fixedTime, fixedTime);
const output = join(tempBase, edition.file);
execFileSync("zip", ["-X0", output, "mimetype"], { cwd: epubRoot, env: zipEnv, stdio: "ignore" });
const entries = walk(epubRoot)
.map((file) => normalizeSource(relative(epubRoot, file)))
.filter((file) => file !== "mimetype")
.sort();
execFileSync("zip", ["-X9", output, ...entries], { cwd: epubRoot, env: zipEnv, stdio: "ignore" });
execFileSync("unzip", ["-tqq", output], { stdio: "ignore" });
const listed = execFileSync("unzip", ["-Z1", output], { encoding: "utf8" }).trim().split("\n");
if (listed[0] !== "mimetype") throw new Error(`${edition.file}: mimetype 必须是首个 ZIP 条目`);
const mimetype = execFileSync("unzip", ["-p", output, "mimetype"], { encoding: "utf8" });
if (mimetype !== "application/epub+zip") throw new Error(`${edition.file}: mimetype 内容错误`);
const sourceDigest = sha256(
items
.map(({ source }) => `${source}\0${sha256(readFileSync(join(DOCS, source)))}`)
.join("\n"),
);
const buffer = readFileSync(output);
if (buffer.length > 8_000_000) throw new Error(`${edition.file}: EPUB 超过 8MB 预算`);
return {
output,
metadata: {
file: edition.file,
language: edition.lang,
chapters: chapterRecords.length,
images: imageTargets.size + 1,
bytes: buffer.length,
sha256: sha256(buffer),
sourceSha256: sourceDigest,
},
};
}
const tempBase = mkdtempSync(join(tmpdir(), "life-level-up-epub-"));
try {
const markdown = await createMarkdownRenderer(DOCS);
const built = [];
for (const edition of editions) built.push(await buildEdition(edition, markdown, tempBase));
const manifest = `${JSON.stringify({
version: 1,
standard: "EPUB 3.3",
scope: "Main manuscript, glossary, and toolkit; archive and word lists remain online-only.",
outputs: Object.fromEntries(built.map(({ metadata }) => [metadata.language, metadata])),
}, null, 2)}\n`;
if (checkOnly) {
for (const { output, metadata } of built) {
const committed = join(OUTPUT_DIR, metadata.file);
if (!existsSync(committed) || !readFileSync(committed).equals(readFileSync(output))) {
throw new Error(`${relative(ROOT, committed)} 未与书稿同步;运行 npm run book:build`);
}
}
const manifestFile = join(OUTPUT_DIR, "epub-manifest.json");
if (!existsSync(manifestFile) || readFileSync(manifestFile, "utf8") !== manifest) {
throw new Error(`${relative(ROOT, manifestFile)} 未与 EPUB 产物同步;运行 npm run book:build`);
}
console.log("EPUB editions are in sync");
} else {
mkdirSync(OUTPUT_DIR, { recursive: true });
for (const { output, metadata } of built) copyFileSync(output, join(OUTPUT_DIR, metadata.file));
writeFileSync(join(OUTPUT_DIR, "epub-manifest.json"), manifest);
for (const { metadata } of built) {
console.log(`updated docs/public/downloads/${metadata.file} (${metadata.chapters} chapters, ${metadata.bytes} bytes)`);
}
}
} finally {
rmSync(tempBase, { recursive: true, force: true });
}
+5
View File
@@ -76,6 +76,10 @@ function resolveTarget(file, rawTarget) {
function localTargetExists(path) {
if (existsSync(path)) return true;
if (path.startsWith(`${DOCS}${sep}`)) {
const publicPath = join(DOCS, "public", relative(DOCS, path));
if (existsSync(publicPath)) return true;
}
if (!extname(path) && existsSync(`${path}.md`)) return true;
if (!extname(path) && existsSync(join(path, "README.md"))) return true;
return false;
@@ -382,6 +386,7 @@ function repositoryReadmeFromDocs(source) {
.replace("中文 | [English](en/)", "中文 | [English](docs/en/README.md)")
.replace(/\]\((assets|threads|templates|reference)\//g, "](docs/$1/")
.replace(/src="\.\/assets\//g, 'src="./docs/assets/')
.replace(/href="\.\/downloads\//g, 'href="./docs/public/downloads/')
.replace(/\]\(projects\.md\)/g, "](docs/projects.md)")
.replace(/href="\.\/(threads\/[^\"]+|templates\/[^\"]+|projects)"/g, (_match, path) =>
`href="./docs/${path}.md"`,
+2
View File
@@ -13,6 +13,8 @@ mkdirSync(publicDir, { recursive: true });
const coverSpecs = [
{ source: "feature.svg", target: "feature.png", width: 1200, height: 630 },
{ source: "feature-en.svg", target: "feature-en.png", width: 1200, height: 630 },
{ source: "cover-portrait.svg", target: "cover-portrait.png", width: 1600, height: 2560 },
{ source: "cover-portrait-en.svg", target: "cover-portrait-en.png", width: 1600, height: 2560 },
];
let changed = false;
+1
View File
@@ -14,6 +14,7 @@ function toRepositoryReadme(source) {
.replace("中文 | [English](en/)", "中文 | [English](docs/en/README.md)")
.replace(/\]\((assets|threads|templates|reference)\//g, "](docs/$1/")
.replace(/src="\.\/assets\//g, 'src="./docs/assets/')
.replace(/href="\.\/downloads\//g, 'href="./docs/public/downloads/')
.replace(/\]\(projects\.md\)/g, "](docs/projects.md)")
.replace(/href="\.\/(threads\/[^\"]+|templates\/[^\"]+|projects)"/g, (_match, path) =>
`href="./docs/${path}.md"`,
+53 -2
View File
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { expect, test } from "@playwright/test";
@@ -26,6 +27,7 @@ const routesFromNavigation = (groups) =>
const routes = [...routesFromNavigation(zhNavigation), ...routesFromNavigation(enNavigation)];
const expectedBuildRevision = process.env.GITHUB_SHA || process.env.BUILD_REVISION || "local";
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
test("start here leads from the home page into the reader guide and prologue", () => {
const zhStart = zhNavigation.find(({ text }) => text === "开始");
@@ -304,10 +306,59 @@ test("home metadata follows the lifelong-learning positioning", async ({ page })
});
});
test("home pages expose deterministic bilingual EPUB editions", async ({ page, request }) => {
const manifestResponse = await request.get("downloads/epub-manifest.json");
expect(manifestResponse.status()).toBe(200);
expect(manifestResponse.headers()["content-type"]).toContain("application/json");
const manifest = await manifestResponse.json();
expect(manifest).toMatchObject({ version: 1, standard: "EPUB 3.3" });
const editions = [
{ language: "zh-CN", label: "下载中文 EPUB", href: "./downloads/life-level-up-guide-zh.epub" },
{ language: "en-US", label: "Download English EPUB", href: "./downloads/life-level-up-guide-en.epub" },
];
await page.goto("./");
for (const edition of editions) {
const link = page.getByRole("link", { name: edition.label, exact: true });
await expect(link).toHaveAttribute("href", edition.href);
await expect(link).toHaveAttribute("download", "");
const output = manifest.outputs[edition.language];
expect(output.chapters).toBe(53);
expect(output.images).toBeGreaterThan(1);
expect(output.bytes).toBeGreaterThan(1_000_000);
expect(output.bytes).toBeLessThan(8_000_000);
const response = await request.get(`downloads/${output.file}`);
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("application/epub+zip");
const body = await response.body();
expect(body.subarray(0, 2).toString("ascii")).toBe("PK");
expect(body.length).toBe(output.bytes);
expect(sha256(body)).toBe(output.sha256);
}
await page.goto("./en/");
await expect(page.getByRole("link", { name: "Download English EPUB", exact: true })).toHaveAttribute(
"href",
"../downloads/life-level-up-guide-en.epub",
);
await expect(page.getByRole("link", { name: "下载中文 EPUB", exact: true })).toHaveAttribute(
"href",
"../downloads/life-level-up-guide-zh.epub",
);
});
test("brand and social assets load at their declared dimensions", async ({ page, request }) => {
await page.goto("./");
await expect(page.locator('link[rel="icon"]')).toHaveAttribute("href", "/up/assets/logo.svg");
for (const path of ["assets/feature.png", "assets/feature-en.png"]) {
const rasterAssets = [
{ path: "assets/feature.png", dimensions: { width: 1200, height: 630 } },
{ path: "assets/feature-en.png", dimensions: { width: 1200, height: 630 } },
{ path: "assets/cover-portrait.png", dimensions: { width: 1600, height: 2560 } },
{ path: "assets/cover-portrait-en.png", dimensions: { width: 1600, height: 2560 } },
];
for (const { path, dimensions: expectedDimensions } of rasterAssets) {
const response = await request.get(path);
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/png");
@@ -321,7 +372,7 @@ test("brand and social assets load at their declared dimensions", async ({ page,
}),
`./${path}`,
);
expect(dimensions).toEqual({ width: 1200, height: 630 });
expect(dimensions).toEqual(expectedDimensions);
}
const logo = await page.evaluate(