|
1 | 1 | #!/usr/bin/env python3 |
2 | 2 | """Post-process mdBook HTML for GitHub Pages project sites. |
3 | 3 |
|
4 | | -1. Replace root index.html with redirect to the real first chapter (avoids ``../`` |
5 | | - resolving outside ``/deepseek-tech-notes/``). |
6 | | -2. Percent-encode non-ASCII path segments in relative href/src (GitHub Pages 400). |
| 4 | +GitHub Pages serves this book at ``/deepseek-tech-notes/``. mdBook emits |
| 5 | +relative ``../`` links that escape the project prefix on root/index pages, and |
| 6 | +unencoded CJK path segments return 400. Rewrite internal links to absolute, |
| 7 | +percent-encoded URLs under site-url and fix search index paths to match. |
7 | 8 | """ |
8 | 9 | from __future__ import annotations |
9 | 10 |
|
| 11 | +import json |
10 | 12 | import re |
11 | | -from pathlib import Path |
12 | | -from urllib.parse import unquote, quote |
| 13 | +from pathlib import Path, PurePosixPath |
| 14 | +from urllib.parse import quote, unquote |
13 | 15 |
|
14 | 16 | REPO = Path(__file__).resolve().parents[1] |
15 | 17 | OUT = REPO / "mdbook-out" |
16 | | -FIRST_CHAPTER = "00-%E5%89%8D%E8%A8%80/02-%E4%B8%AD%E6%96%87%E5%AF%BC%E8%AF%B2.html" |
| 18 | +BOOK_TOML = REPO / "book.toml" |
17 | 19 |
|
18 | | -SKIP_PREFIXES = ("http://", "https://", "//", "#", "mailto:", "javascript:", "data:") |
| 20 | +SKIP_PREFIXES = ("http://", "https://", "//", "mailto:", "javascript:", "data:") |
19 | 21 | ATTR_RE = re.compile(r'(href|src)="([^"]+)"') |
| 22 | +PATH_TO_ROOT_RE = re.compile(r'var path_to_root = "[^"]*";') |
20 | 23 |
|
21 | 24 |
|
22 | | -def encode_path(path: str) -> str: |
23 | | - if not path or path.startswith(SKIP_PREFIXES): |
24 | | - return path |
| 25 | +def read_site_url() -> str: |
| 26 | + text = BOOK_TOML.read_text(encoding="utf-8") |
| 27 | + m = re.search(r'^site-url\s*=\s*"([^"]+)"', text, re.MULTILINE) |
| 28 | + if not m: |
| 29 | + raise SystemExit("book.toml missing output.html site-url") |
| 30 | + url = m.group(1) |
| 31 | + return url if url.endswith("/") else url + "/" |
| 32 | + |
| 33 | + |
| 34 | +SITE_URL = read_site_url() |
| 35 | + |
| 36 | + |
| 37 | +def encode_segment(part: str) -> str: |
| 38 | + if part in (".", "..", ""): |
| 39 | + return part |
| 40 | + return quote(unquote(part), safe="") |
| 41 | + |
| 42 | + |
| 43 | +def encode_book_rel(rel: str) -> str: |
25 | 44 | frag = "" |
26 | | - if "#" in path: |
27 | | - path, frag = path.split("#", 1) |
| 45 | + if "#" in rel: |
| 46 | + rel, frag = rel.split("#", 1) |
28 | 47 | frag = "#" + frag |
29 | | - parts = path.split("/") |
30 | | - encoded = "/".join( |
31 | | - quote(unquote(part), safe="") if part not in (".", "..", "") else part |
32 | | - for part in parts |
33 | | - ) |
| 48 | + encoded = "/".join(encode_segment(p) for p in rel.split("/") if p) |
34 | 49 | return encoded + frag |
35 | 50 |
|
36 | 51 |
|
| 52 | +def site_href(rel: str) -> str: |
| 53 | + return SITE_URL + encode_book_rel(rel) |
| 54 | + |
| 55 | + |
| 56 | +def resolve_relative(href: str, page_dir: PurePosixPath) -> str | None: |
| 57 | + if not href or href.startswith(SKIP_PREFIXES): |
| 58 | + return None |
| 59 | + if href.startswith(SITE_URL): |
| 60 | + return href |
| 61 | + frag = "" |
| 62 | + if "#" in href: |
| 63 | + href, frag = href.split("#", 1) |
| 64 | + frag = "#" + frag |
| 65 | + if href.startswith("/"): |
| 66 | + rel = href.lstrip("/") |
| 67 | + else: |
| 68 | + rel = PurePosixPath(page_dir, href).as_posix() |
| 69 | + parts: list[str] = [] |
| 70 | + for part in rel.split("/"): |
| 71 | + if part == "..": |
| 72 | + if parts: |
| 73 | + parts.pop() |
| 74 | + elif part not in (".", ""): |
| 75 | + parts.append(part) |
| 76 | + if not parts: |
| 77 | + return None |
| 78 | + return site_href("/".join(parts)) + frag |
| 79 | + |
| 80 | + |
| 81 | +def patch_html(path: Path) -> None: |
| 82 | + page_dir = PurePosixPath(path.relative_to(OUT).parent.as_posix()) |
| 83 | + if page_dir.parts == (".",): |
| 84 | + page_dir = PurePosixPath() |
| 85 | + |
| 86 | + def repl(m: re.Match[str]) -> str: |
| 87 | + attr, val = m.group(1), m.group(2) |
| 88 | + new = resolve_relative(val, page_dir) |
| 89 | + return m.group(0) if new is None else f'{attr}="{new}"' |
| 90 | + |
| 91 | + text = path.read_text(encoding="utf-8") |
| 92 | + text = ATTR_RE.sub(repl, text) |
| 93 | + text = PATH_TO_ROOT_RE.sub(f'var path_to_root = "{SITE_URL}";', text) |
| 94 | + path.write_text(text, encoding="utf-8") |
| 95 | + |
| 96 | + |
37 | 97 | def write_redirect_index() -> None: |
| 98 | + target = site_href("00-前言/02-中文导读.html") |
38 | 99 | html = f"""<!DOCTYPE html> |
39 | 100 | <html lang="zh-Hans"> |
40 | 101 | <head> |
41 | 102 | <meta charset="utf-8"> |
42 | 103 | <title>Redirecting…</title> |
43 | | - <link rel="canonical" href="{FIRST_CHAPTER}"> |
44 | | - <meta http-equiv="refresh" content="0; url={FIRST_CHAPTER}"> |
45 | | - <script>location.replace("{FIRST_CHAPTER}");</script> |
| 104 | + <link rel="canonical" href="{target}"> |
| 105 | + <meta http-equiv="refresh" content="0; url={target}"> |
| 106 | + <script>location.replace("{target}");</script> |
46 | 107 | </head> |
47 | 108 | <body> |
48 | | - <p><a href="{FIRST_CHAPTER}">中文导读</a></p> |
| 109 | + <p><a href="{target}">中文导读</a></p> |
49 | 110 | </body> |
50 | 111 | </html> |
51 | 112 | """ |
52 | 113 | (OUT / "index.html").write_text(html, encoding="utf-8") |
53 | 114 |
|
54 | 115 |
|
55 | | -def patch_html(path: Path) -> int: |
56 | | - text = path.read_text(encoding="utf-8") |
57 | | - |
58 | | - def repl(m: re.Match[str]) -> str: |
59 | | - attr, val = m.group(1), m.group(2) |
60 | | - new = encode_path(val) |
61 | | - return m.group(0) if new == val else f'{attr}="{new}"' |
62 | | - |
63 | | - patched = ATTR_RE.sub(repl, text) |
64 | | - if patched != text: |
65 | | - path.write_text(patched, encoding="utf-8") |
66 | | - return text.count('href="') + text.count('src="') |
| 116 | +def patch_searchindex() -> None: |
| 117 | + path = OUT / "searchindex.json" |
| 118 | + if not path.is_file(): |
| 119 | + return |
| 120 | + data = json.loads(path.read_text(encoding="utf-8")) |
| 121 | + data["doc_urls"] = [encode_book_rel(u) for u in data["doc_urls"]] |
| 122 | + path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") |
| 123 | + js = OUT / "searchindex.js" |
| 124 | + if js.is_file(): |
| 125 | + js.write_text( |
| 126 | + "Object.assign(window.search, " + json.dumps(data, ensure_ascii=False) + ");", |
| 127 | + encoding="utf-8", |
| 128 | + ) |
67 | 129 |
|
68 | 130 |
|
69 | 131 | def main() -> None: |
70 | 132 | if not OUT.is_dir(): |
71 | 133 | raise SystemExit(f"missing {OUT.relative_to(REPO)} — run mdbook build first") |
72 | | - write_redirect_index() |
73 | 134 | n = 0 |
74 | 135 | for html in OUT.rglob("*.html"): |
75 | 136 | if html.parent == OUT and html.name == "index.html": |
76 | 137 | continue |
77 | 138 | patch_html(html) |
78 | 139 | n += 1 |
79 | | - print(f"OK fix_mdbook_paths: redirect index + encoded paths in {n} html files") |
| 140 | + write_redirect_index() |
| 141 | + patch_searchindex() |
| 142 | + print(f"OK fix_mdbook_paths: absolute URLs under {SITE_URL} ({n} chapter html files)") |
80 | 143 |
|
81 | 144 |
|
82 | 145 | if __name__ == "__main__": |
|
0 commit comments