Skip to content

Commit 61396a5

Browse files
committed
Split dictionary volumes into per-syllable section pages
Use Astro build.format directory so routes match existing index.html URL helpers. Add volume hub and section Astro pages, pre-rendered public/sections snapshots, anchor-integrity checks, and client search card fetches against section pages.
1 parent b44083d commit 61396a5

25 files changed

Lines changed: 948 additions & 377 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,5 @@ docs/
1616
data/koktai-full.jsonl
1717
font/_regen_tmp/
1818
font/_pixel_baseline/
19+
20+
.DS_Store

README-preview.md

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# 本地預覽(Astro + Pug 3)
22

33
- **首頁**`src/pages/index.astro`——書名、對照字例(八)、二十六卷拇指索引、附錄、關於
4-
- **辭典/附錄**`pug/*.pug`**Pug 3**`src/pages/[volume].astro`(靜態路徑如 `/01.html`
4+
- **辭典/附錄**`pug/*.pug`**Pug 3**Astro(**** `/<vol>.html` 音節目錄;**音節** `/<vol>/<n>.html` 條目內文;附錄 `/<name>.html`
55
- `pug/` 是 1990 年代 `.dic` 轉換產生的**資料成品**(三千萬字元,現由純 TS pipeline 重產,見「其他」),不是手寫模板——
66
真正的模板層在 Astro(layout/pages)。檔案為 Pug 3 語法
77
`doctype html`、一般屬性、行內 HTML 文字)。
@@ -39,14 +39,10 @@ bun run preview # 預覽 build 結果
3939

4040
## 卷頁效能(實測與界線)
4141

42-
- **已量到的負載**:例如 `dist/02.html`**5.9 MiB** 原始 HTML、**65**`section.syl`**1500+**`.entry``bun run volume:stats` 可列出 26 卷)。
43-
- **`.entry` 早就有** `content-visibility: auto`(略過視窗外條目的排版/繪製)。
44-
- **`section.syl` 亦設** `content-visibility: auto``contain-intrinsic-size`(略過整段音節區塊的排版;`intrinsicPx` 可選用 `bun run measure:sections` 產生的 `section-sizes.json` 讓捲動估高較準)。
45-
- **CV 做不到的事**:不減少下載量、不減少 HTML 解析與完整 DOM 建樹成本。若卡頓主要在**第一次開卷**(白屏/可互動時間),瓶頸多半是 **5 MiB 級 payload + parse**,需靠**拆頁**(例如每音節一個靜態 URL,仍保留 Ctrl+F/無 JS/kk 卡片抓整卷 HTML)而非 fetch 注入空殼。
46-
- **怎麼驗證**(需本機瀏覽器,headless 無法代表體感):
47-
1. DevTools → **Performance**:錄製重新載入 `02.html`,看 **Scripting / Parse HTML / Layout** 哪段最長。
48-
2. DevTools → **Network**:看 **Transferred****DOMContentLoaded**
49-
3.**Parse HTML** 主導 → 規劃音節拆頁;若 **Layout/Paint** 在長卷捲動時主導 → CV 分層有幫助。
42+
- **已量到的負載**(拆頁後):單音節頁遠小於整卷 monolith(舊例 `dist/02.html`**5.9 MiB**);卷 hub 僅音節索引。`bun run volume:stats` 可列 26 卷。
43+
- **建置**`bun run build` 先跑 **`section:snapshots`**`public/sections/…/index.html` 快照),再產出 `dist/<vol>.html``dist/<vol>/<n>.html`
44+
- **錨點閘門**`check-built-html.ts` 驗證 **`.kk` 跨卷連結**`#w-`/`#c-` 錨點;`anchor-integrity.test.ts` 驗證 corpus 路由。
45+
- **怎麼驗證**:DevTools 對 `01/3.html` 等單音節 URL 錄製重新載入。
5046

5147
## 平行建置
5248

astro.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@ export default defineConfig({
55
base: "/koktai",
66
output: "static",
77
server: { port: 4173 },
8-
build: { format: "file" },
8+
build: { format: "directory" },
99
});

lib/site/anchor-integrity.ts

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { readFileSync, existsSync, readdirSync } from "node:fs";
2+
import { join } from "node:path";
3+
import type { LinkTarget } from "./linkify.ts";
4+
import type { Corpus } from "./corpus.ts";
5+
import { VOLUME_IDS } from "../dic/pipeline.ts";
6+
import {
7+
buildSectionEntryIndex,
8+
entryAnchor,
9+
sectionForTarget,
10+
targetPageHref,
11+
volumeSectionPath,
12+
} from "./volume-paths.ts";
13+
14+
/** Paths under `dist/` (site root, not repo root). */
15+
export interface BuiltPageRef {
16+
rel: string;
17+
vol: string;
18+
/** 1-based syllable section; null = hub or monolith. */
19+
section: number | null;
20+
}
21+
22+
const VOL_RE = /^(0[1-9]|1[0-9]|2[0-6])$/;
23+
24+
export function extractElementIds(html: string): Set<string> {
25+
const ids = new Set<string>();
26+
for (const m of html.matchAll(/\bid="([^"]+)"/g)) ids.add(m[1]!);
27+
return ids;
28+
}
29+
30+
/** Normalize site-internal dictionary URL → page path + optional entry anchor. */
31+
export function parseDictionaryHref(
32+
href: string,
33+
siteBase = "/koktai/",
34+
): { page: string; vol: string; section: number | null; anchor: string | null } | null {
35+
const base = siteBase.endsWith("/") ? siteBase : `${siteBase}/`;
36+
let path = href.trim();
37+
if (path.startsWith("http://") || path.startsWith("https://")) {
38+
try {
39+
path = new URL(path).pathname;
40+
} catch {
41+
return null;
42+
}
43+
}
44+
const hashIdx = path.indexOf("#");
45+
const anchor = hashIdx >= 0 ? path.slice(hashIdx + 1) : null;
46+
path = hashIdx >= 0 ? path.slice(0, hashIdx) : path;
47+
if (!path.startsWith(base)) return null;
48+
const rest = path.slice(base.length);
49+
const m = rest.match(/^((?:0[1-9]|1[0-9]|2[0-6]))(?:\/index\.html|\/(\d+)\/index\.html)?$/);
50+
if (!m) return null;
51+
const vol = m[1]!;
52+
const section = m[2] ? Number.parseInt(m[2], 10) : null;
53+
if (anchor && !/^(w|c)-\d+$/.test(anchor)) {
54+
if (!anchor.startsWith("s-")) return { page: rest, vol, section, anchor: null };
55+
}
56+
return { page: rest + (anchor ? `#${anchor}` : ""), vol, section, anchor };
57+
}
58+
59+
export function listBuiltDictionaryPages(distRoot: string): BuiltPageRef[] {
60+
const out: BuiltPageRef[] = [];
61+
for (const vol of VOLUME_IDS) {
62+
const volDir = join(distRoot, vol);
63+
if (!existsSync(volDir)) continue;
64+
const hubIndex = join(volDir, "index.html");
65+
if (existsSync(hubIndex)) {
66+
out.push({ rel: `${vol}/index.html`, vol, section: null });
67+
}
68+
for (const name of readdirSync(volDir)) {
69+
if (!/^\d+$/.test(name)) continue;
70+
const secPath = join(volDir, name, "index.html");
71+
if (existsSync(secPath)) {
72+
out.push({ rel: `${vol}/${name}/index.html`, vol, section: Number(name) });
73+
}
74+
}
75+
}
76+
return out;
77+
}
78+
79+
/** Every word/sinogram must map to a section; anchors must match line numbers. */
80+
export function verifyCorpusSectionRouting(corpus: Corpus): string[] {
81+
const errors: string[] = [];
82+
for (const vol of VOLUME_IDS) {
83+
const data = corpus.volumes.get(vol);
84+
if (!data) continue;
85+
for (const w of data.words) {
86+
const sec = corpus.sectionOf(vol, w.chapterZhuyin);
87+
if (sec <= 0) {
88+
errors.push(`${vol} word line ${w.line}: no section for chapter ${w.chapterZhuyin}`);
89+
}
90+
}
91+
for (const s of data.sinograms) {
92+
const sec = corpus.sectionOf(vol, s.chapterZhuyin);
93+
if (sec <= 0) {
94+
errors.push(`${vol} sinogram line ${s.line}: no section for chapter ${s.chapterZhuyin}`);
95+
}
96+
}
97+
}
98+
return errors;
99+
}
100+
101+
/** Hrefs emitted by `targetPageHref` must land on the section that owns the anchor. */
102+
export function verifyTargetHrefRouting(
103+
corpus: Corpus,
104+
hrefBase = "/koktai/",
105+
): string[] {
106+
const index = buildSectionEntryIndex(corpus);
107+
const errors: string[] = [];
108+
const check = (target: LinkTarget) => {
109+
const href = targetPageHref(hrefBase, target, corpus);
110+
const parsed = parseDictionaryHref(href, hrefBase);
111+
if (!parsed?.anchor) {
112+
errors.push(`target ${target.k}:${target.v}:${target.l} → href missing anchor: ${href}`);
113+
return;
114+
}
115+
const expectedSec = index.volumes[target.v]?.[parsed.anchor];
116+
if (expectedSec === undefined) {
117+
errors.push(`target ${target.k}:${target.v}:${target.l} → anchor ${parsed.anchor} not in entry index`);
118+
return;
119+
}
120+
if (parsed.section !== expectedSec) {
121+
errors.push(
122+
`target ${target.k}:${target.v}:${target.l} → section ${parsed.section} !== expected ${expectedSec} (${href})`,
123+
);
124+
}
125+
const viaFn = sectionForTarget(corpus, target);
126+
if (viaFn !== expectedSec) {
127+
errors.push(
128+
`sectionForTarget ${target.k}:${target.v}:${target.l}${viaFn} !== index ${expectedSec}`,
129+
);
130+
}
131+
};
132+
for (const vol of VOLUME_IDS) {
133+
const data = corpus.volumes.get(vol);
134+
if (!data) continue;
135+
for (const w of data.words) check({ k: "w", v: vol, l: w.line });
136+
for (const s of data.sinograms) check({ k: "c", v: vol, l: s.line });
137+
}
138+
return errors;
139+
}
140+
141+
export function collectHrefTargetsFromHtml(html: string): { href: string; fromKk: boolean }[] {
142+
const out: { href: string; fromKk: boolean }[] = [];
143+
for (const m of html.matchAll(/<a\s[^>]*\bhref="([^"]+)"[^>]*>/gi)) {
144+
const tag = m[0]!;
145+
out.push({ href: m[1]!, fromKk: /\bclass="[^"]*kk/.test(tag) });
146+
}
147+
return out;
148+
}
149+
150+
/** Scan built HTML: entry anchors must exist on the resolved target page. */
151+
export function verifyBuiltHtmlAnchors(
152+
distRoot: string,
153+
siteBase = "/koktai/",
154+
): string[] {
155+
const pages = listBuiltDictionaryPages(distRoot);
156+
if (pages.length === 0) return ["no dictionary pages under dist/ (run astro build)"];
157+
158+
const idsByPage = new Map<string, Set<string>>();
159+
for (const p of pages) {
160+
const full = join(distRoot, p.rel);
161+
const html = readFileSync(full, "utf8");
162+
idsByPage.set(p.rel, extractElementIds(html));
163+
}
164+
165+
const errors: string[] = [];
166+
const resolveTargetIds = (vol: string, section: number | null): Set<string> | undefined => {
167+
if (section !== null) {
168+
return idsByPage.get(volumeSectionPath(vol, section));
169+
}
170+
return idsByPage.get(`${vol}/index.html`);
171+
};
172+
173+
for (const p of pages) {
174+
const html = readFileSync(join(distRoot, p.rel), "utf8");
175+
for (const { href, fromKk } of collectHrefTargetsFromHtml(html)) {
176+
const parsed = parseDictionaryHref(href, siteBase);
177+
if (!parsed) continue;
178+
if (!VOL_RE.test(parsed.vol)) continue;
179+
if (!fromKk) continue;
180+
if (!parsed.anchor) continue;
181+
182+
const targetIds = resolveTargetIds(parsed.vol, parsed.section);
183+
if (!targetIds) {
184+
errors.push(`${p.rel}: link ${href} → missing target page`);
185+
continue;
186+
}
187+
if (!targetIds.has(parsed.anchor)) {
188+
errors.push(`${p.rel}: link ${href} → id ${parsed.anchor} not on target`);
189+
}
190+
}
191+
}
192+
return errors;
193+
}
194+
195+
export function expectedSectionHtmlPath(vol: string, section: number): string {
196+
return volumeSectionPath(vol, section);
197+
}

lib/site/linkify.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export interface LinkSegment {
1212
export interface RenderCtx {
1313
resolver: LinkResolver;
1414
hrefBase: string;
15+
/** Section-split URLs; required for cross-volume entry links. */
16+
corpus: import("./corpus.ts").Corpus;
1517
self?: { k?: "w" | "c"; v: string; l: number };
1618
}
1719

lib/site/structured-render.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { legacyPlainText, renderLegacyText } from "./legacy-text.ts";
22
export { legacyPlainText, renderLegacyText } from "./legacy-text.ts";
33
import type { LinkTarget, RenderCtx } from "./linkify.ts";
4+
import { targetPageHref } from "./volume-paths.ts";
45
import type {
56
StructuredEntry,
67
StructuredReading,
@@ -34,9 +35,8 @@ function sameTarget(a: LinkTarget, b: RenderCtx["self"]): boolean {
3435
return !!b && a.v === b.v && a.l === b.l && (!b.k || a.k === b.k);
3536
}
3637

37-
function targetHref(target: LinkTarget, hrefBase: string): string {
38-
const anchor = target.k === "w" ? `w-${target.l}` : `c-${target.l}`;
39-
return `${hrefBase}${target.v}.html#${anchor}`;
38+
function targetHref(target: LinkTarget, ctx: RenderCtx): string {
39+
return targetPageHref(ctx.hrefBase, target, ctx.corpus);
4040
}
4141

4242
function renderTargetLink(text: string, target: LinkTarget, ctx: RenderCtx): string {
@@ -46,7 +46,7 @@ function renderTargetLink(text: string, target: LinkTarget, ctx: RenderCtx): str
4646
if (!alt) return renderLegacyText(text);
4747
effective = alt;
4848
}
49-
const href = targetHref(effective, ctx.hrefBase);
49+
const href = targetHref(effective, ctx);
5050
return `<a class="kk" href="${escapeHtml(href)}" data-kk="${effective.k}:${effective.v}:${effective.l}">${renderLegacyText(text)}</a>`;
5151
}
5252

lib/site/volume-astro-shared.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { chineseNumeral } from "../compile-pug.ts";
2+
import { legacyPlainText } from "./structured-render.ts";
3+
import type { RailSection } from "./volume-rail.ts";
4+
5+
export interface DocMeta {
6+
zh: string;
7+
file: string;
8+
kind: "prose" | "file";
9+
desc: string;
10+
}
11+
12+
export const APPENDIX: Record<string, DocMeta> = {
13+
preface1: {
14+
zh: "編輯緣起・凡例",
15+
file: "PREFACE1.DIC",
16+
kind: "prose",
17+
desc: "吳守禮自述編纂始末(代序),附全書凡例。",
18+
},
19+
phsource: {
20+
zh: "華語台語注音符號溯源",
21+
file: "PHSOURCE",
22+
kind: "prose",
23+
desc: "注音符號與臺灣方音符號的來歷考述。",
24+
},
25+
"ph-comp": {
26+
zh: "方音音標對照表",
27+
file: "PH-COMP.TXT",
28+
kind: "file",
29+
desc: "漢注、華臺、TLPA、國際音標、甘典、漢閩諸系統對照。",
30+
},
31+
mytaiin8: {
32+
zh: "綜合閩方言拼音總表",
33+
file: "MYTAIIN8",
34+
kind: "file",
35+
desc: "閩方言拼音系統總表。",
36+
},
37+
"dic-cont": {
38+
zh: "辭典內容說明",
39+
file: "DIC-CONT.TXT",
40+
kind: "file",
41+
desc: "原始檔案內容一覽。",
42+
},
43+
};
44+
45+
export const APPENDIX_IDS = Object.keys(APPENDIX);
46+
47+
export function volumeNav(
48+
volNo: number,
49+
href: (path?: string) => string,
50+
): { prev: string | null; next: string | null; volName: string } {
51+
const pad = (n: number) => String(n).padStart(2, "0");
52+
return {
53+
volName: `卷${chineseNumeral(volNo)}`,
54+
prev: volNo > 1 ? pad(volNo - 1) : null,
55+
next: volNo < 26 ? pad(volNo + 1) : null,
56+
};
57+
}
58+
59+
export function volumeRangeText(railSections: RailSection[]): string {
60+
if (railSections.length === 0) return "";
61+
const range = `${railSections[0]!.syllable}${railSections[railSections.length - 1]!.syllable}`;
62+
return legacyPlainText(range);
63+
}

0 commit comments

Comments
 (0)