統計名詞的中英對照是學生的實際痛點:課本是英文、老師講中文、考試兩種都有。「型一誤差」和「Type I error」要能互相對上。
我需要一頁完整的名詞表。但這裡有兩條路,選錯會後悔:
| 做法 | 問題 |
|---|---|
手寫一份 glossary.js |
兩百多課的名詞要手抄,而且課文改了名詞表不會跟著改——遲早不一致 |
| 從課文自動抽取 | 一次寫好腳本,永遠同步 |
Day 8 定的寫作規格裡,名詞是這樣寫的:
<dl class="terms">
<dt>型一誤差 (Type I error)</dt><dd>H₀ 為真卻拒絕它。</dd>
<dt>檢定力 (power)</dt><dd>H₁ 為真時正確拒絕 H₀ 的機率,等於 1−β。</dd>
</dl>
這個格式當初是為了排版一致,今天它變成可程式化的資產。這是規格化內容的複利。
/* scripts/build-glossary.js */
const fs = require("fs"), path = require("path"), vm = require("vm");
const root = path.resolve(__dirname, "..");
const ctx = { window: {} }; vm.createContext(ctx);
vm.runInContext(fs.readFileSync(path.join(root, "js/models/curriculum.js"), "utf8"), ctx);
for (const f of fs.readdirSync(path.join(root, "js/lessons")).sort())
vm.runInContext(fs.readFileSync(path.join(root, "js/lessons", f), "utf8"), ctx);
const LESSONS = ctx.window.LESSONS;
const COURSES = vm.runInContext("COURSES", ctx);
/* 課文 key → { 課程, 章, 單元 } 的反查表 */
const META = {};
for (const c of COURSES)
for (const m of c.modules)
for (const ch of m.chapters)
for (const l of ch.lessons)
META[`${ch.id}-${l.id}`] = { courseId: c.id, courseTitle: c.title,
chId: ch.id, chTitle: `${ch.num} ${ch.title}`,
lessonId: l.id, lessonTitle: l.title };
const unesc = s => s.replace(/</g, "<").replace(/>/g, ">")
.replace(/&/g, "&").replace(/ /g, " ");
const plain = s => unesc(s.replace(/<[^>]*>/g, "")).replace(/\s+/g, " ").trim();
/* 「中文 (English)」→ 拆成兩半;沒括號的就只有中文 */
const splitTerm = dt => {
const m = dt.match(/^(.*?)[((]\s*([^(())]*[A-Za-z][^(())]*)\s*[))]\s*$/);
return m ? { zh: m[1].trim(), en: m[2].trim() } : { zh: dt.trim(), en: "" };
};
const terms = new Map(); // key: zh|en → { zh, en, defs:[], seen:[] }
for (const [key, html] of Object.entries(LESSONS)) {
/* 逐個 <dl class="terms"> 區塊,抓成對的 dt/dd */
for (const dl of html.matchAll(/<dl class="terms">([\s\S]*?)<\/dl>/g)) {
const pairs = [...dl[1].matchAll(/<dt>([\s\S]*?)<\/dt>\s*<dd>([\s\S]*?)<\/dd>/g)];
for (const [, rawDt, rawDd] of pairs) {
const { zh, en } = splitTerm(plain(rawDt));
if (!zh) continue;
const id = `${zh}|${en.toLowerCase()}`;
if (!terms.has(id)) terms.set(id, { zh, en, defs: [], seen: [] });
const t = terms.get(id);
const def = plain(rawDd);
if (def && !t.defs.includes(def)) t.defs.push(def);
t.seen.push({ key, ...META[key] });
}
}
}
幾個實作決定:
用 regex 而不是 DOM parser。 Node 沒有內建 DOM,裝 jsdom 又違反零依賴原則。而課文的 <dl class="terms"> 是我自己按規格寫的、格式穩定,regex 完全夠用。(如果是解析任意來源的 HTML,regex 就是錯的選擇——但這裡的輸入是我自己控制的。)
同一名詞的多個定義都保留。 「變異數」在敘述統計和變異數分析兩章都會出現,定義角度不同。硬要合併成一個定義會損失資訊;並列呈現反而讓使用者看到概念在不同脈絡下的面貌。
seen 是反向索引。 這是整個頁面最有價值的部分:不只告訴你「型一誤差是什麼」,還告訴你「它在第 9 章第 2 課、第 10 章第 1 課出現過」,一鍵跳過去看上下文。
localeCompare 就算了英文名詞按 A–Z 很簡單。中文要按什麼排?
/* 三種索引:英文首字母、中文注音、中文筆畫 */
const enInitial = t => (t.en ? t.en[0].toUpperCase() : "#");
/* 中文用 Intl.Collator 的 zh-Hant 排序(實際上是筆畫/字典序) */
const collator = new Intl.Collator("zh-Hant", { sensitivity: "base" });
const sorted = [...terms.values()].sort((a, b) => collator.compare(a.zh, b.zh));
Intl.Collator("zh-Hant") 在 Node 與現代瀏覽器都可用(需要完整 ICU,Node 14+ 的官方 build 有)。它給的是繁中的字典序,比 String.prototype.localeCompare 沒指定 locale 時的 code point 排序合理得多。
Code point 排序有多糟:「一」是 U+4E00、「乙」是 U+4E59,但「二」是 U+4E8C——按 code point,「一乙二」剛好對,可是「大」(U+5927) 會排在「三」(U+4E09) 後面很遠的地方,完全不是使用者預期。
不過老實說,中文名詞表最實用的索引是搜尋框,不是字母索引。所以我的頁面設計是:搜尋框放最上面(即時過濾),字母索引當輔助。
const out = {
terms: sorted.map(t => ({
zh: t.zh, en: t.en, defs: t.defs,
seen: t.seen.map(s => ({ k: s.key, ch: s.chId, l: s.lessonId,
c: s.courseTitle, cht: s.chTitle, lt: s.lessonTitle })),
})),
byInitial: {},
};
for (const t of out.terms) {
const k = enInitial(t);
(out.byInitial[k] = out.byInitial[k] || []).push(t.zh);
}
fs.writeFileSync(path.join(root, "js/data/glossary.js"),
`/* 自動產生,勿手改:node scripts/build-glossary.js */\nwindow.GLOSSARY = ${JSON.stringify(out)};`);
console.log(`terms=${out.terms.length} 有英文=${out.terms.filter(t=>t.en).length} ` +
`總出現次數=${out.terms.reduce((s,t)=>s+t.seen.length,0)}`);
老規矩:輸出 window.GLOSSARY = {...} 的 JS 檔而不是 JSON,因為 file:// 不能 fetch(Day 6 起的一貫理由)。
實測輸出:約 400 個名詞、其中約 310 個有英文對照、總出現次數約 700 次。檔案 120 KB,比搜尋索引小很多,可以直接在 glossary 頁載入。
/* js/views/glossary-view.js */
const GlossaryView = {
render(root, { q = "", initial = null } = {}) {
const all = (window.GLOSSARY || { terms: [] }).terms;
const qq = q.trim().toLowerCase();
const list = all.filter(t => {
if (initial && (t.en ? t.en[0].toUpperCase() : "#") !== initial) return false;
if (!qq) return true;
return t.zh.toLowerCase().includes(qq) || t.en.toLowerCase().includes(qq)
|| t.defs.some(d => d.toLowerCase().includes(qq));
});
root.innerHTML = `
<div class="gl-bar">
<input id="gl-q" type="search" placeholder="搜尋名詞(中文或英文)" value="${esc(q)}"
aria-label="搜尋名詞">
<div class="gl-index">
<button class="gl-i ${!initial ? "on" : ""}" data-i="">全部</button>
${"ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("").map(c =>
`<button class="gl-i ${initial === c ? "on" : ""}" data-i="${c}"
${(window.GLOSSARY.byInitial[c] || []).length ? "" : "disabled"}>${c}</button>`).join("")}
</div>
<div class="gl-count">${list.length} / ${all.length} 個名詞</div>
</div>
<dl class="gl-list">
${list.map(t => this.termHTML(t, qq)).join("") || `<p class="gl-empty">找不到相關名詞。</p>`}
</dl>`;
},
termHTML(t, qq) {
return `
<div class="gl-term" id="term-${slug(t.zh)}">
<dt>
<span class="gl-zh">${mark(t.zh, qq)}</span>
${t.en ? `<span class="gl-en">${mark(t.en, qq)}</span>` : ""}
</dt>
<dd>
${t.defs.map(d => `<p class="gl-def">${mark(d, qq)}</p>`).join("")}
<div class="gl-seen">
出現在:
${t.seen.map(s =>
`<a href="chapter.html?ch=${s.ch}&l=${s.l}" title="${esc(s.c)} · ${esc(s.cht)}">${esc(s.lt)}</a>`
).join("、")}
</div>
</dd>
</div>`;
},
};
esc() 與 mark() 沿用 Day 12 的實作——使用者輸入(q)要進 innerHTML,一律跳脫。這是本系列第三次遇到同一件事(Day 9 localStorage、Day 12 搜尋、今天 glossary),已經變成肌肉記憶。
字母索引按鈕在沒有該字首名詞時 disabled,避免點了得到空清單。
有了 glossary 之後,可以反過來讓課文裡的名詞連到解釋。但不要自動全文替換——那是個陷阱:
// ❌ 危險:會把公式、程式碼、其他名詞的一部分也換掉
html.replace(/變異數/g, '<a href="glossary.html#term-變異數">變異數</a>');
問題:「變異數分析」裡的「變異數」會被切開、\text{變異數} 在 LaTeX 裡會被插入 HTML 標籤導致公式壞掉(Day 7 的教訓:課文裡亂動 HTML 會讓公式靜靜地不渲染)。
安全的做法是只處理 <dt> 本身——名詞表裡的詞連到 glossary 的對應條目:
/* 在 LessonView.renderLesson 之後執行,只碰 dt,不碰課文其他部分 */
linkTerms(body) {
body.querySelectorAll("dl.terms > dt").forEach(dt => {
const zh = (dt.textContent.match(/^([^((]+)/) || [])[1];
if (!zh) return;
dt.insertAdjacentHTML("beforeend",
` <a class="gl-link" href="glossary.html#term-${encodeURIComponent(slug(zh.trim()))}"
title="在名詞表中查看">🔗</a>`);
});
}
在 DOM 上操作而不是在 HTML 字串上替換,範圍精確可控。而且這段在 MathJax 排版之後執行,不會干擾公式(dl.terms 裡本來就不放公式)。
splitTerm 的括號有全形也有半形。 我寫課文時中英混用,有時打 型一誤差 (Type I error)(半形括號),有時打 型一誤差(Type I error)(全形)。第一版 regex 只處理半形,漏掉三成名詞。
/^(.*?)[((]\s*([^(())]*[A-Za-z][^(())]*)\s*[))]\s*$/
字元類 [((] 與 [))] 同時接受兩種。另外要求括號內必須含至少一個英文字母([A-Za-z]),否則「信賴區間(雙尾)」的「雙尾」會被誤認成英文對照。
同名不同義的 key 設計。 我用 zh|en 當 key,這意味著「常態分配 (normal distribution)」和「常態分配 (Normal distribution)」會被視為不同名詞(大小寫)。所以 key 裡的 en 要 toLowerCase()。反過來,如果同一個中文詞有兩個不同英文(真的發生過:「檢定力 (power)」與「檢定力 (statistical power)」),我選擇保留為兩筆——那反映了課文的真實用詞不一致,正好可以順手去統一課文。
自動產生的檔案要標記。 檔頭寫 /* 自動產生,勿手改 */,並加進一個檢查:如果 glossary.js 比任何課文檔舊,CI 就警告。
# 簡易做法:CI 裡重跑腳本,然後看 git diff 是否為空
node scripts/build-glossary.js
git diff --exit-code js/data/glossary.js || { echo "glossary 過期,請重跑腳本並 commit"; exit 1; }
這招(重跑產生器 + git diff --exit-code)通用於所有自動產生的檔案,Day 22 的 CI 會把它變成標準步驟。
node scripts/build-glossary.js
# terms=402 有英文=311 總出現次數=713
node scripts/verify.js
python3 -m http.server 8901
# http://localhost:8901/glossary.html
檢查清單:
T → 只顯示英文以 T 開頭的。dl.terms 每個 <dt> 後面有 🔗,點了跳到 glossary 並定位到該條目(#term-... anchor)。script 標籤包 alert(1) 的 XSS 字串 → 顯示為純文字,不執行。今天的核心觀念只有一句:
規格化的內容會變成可程式化的資產。
Day 8 我定 <dl class="terms"><dt>中文 (English)</dt> 只是為了排版一致。七天後,同一個格式免費長出一整頁 glossary 加反向索引。這種複利只有在一開始就把內容寫成結構化資料時才會發生——如果當初我用 <p><b>型一誤差</b>:…</p>,今天就抽不出來。
另外兩個可複用的技巧:自動產生的檔案用「重跑 + git diff --exit-code」防過期,以及要修改課文 DOM 就在 DOM 上做,不要在 HTML 字串上做正則替換。
明天做離線化——把 MathJax 從 CDN 搬到本地、加上 service worker,讓網站斷網也能讀。順便把 Day 10 留下的 CSP 例外收乾淨(script-src 可以只剩 'self')。