#!/usr/bin/env python3 """构建 Clashoo 文档站。 只用 Python 标准库:读取 site.json 与 content/ 下的正文片段,套上统一布局, 顺手做一遍自检(锚点重复、站内死链、导航漏页),最后输出到 .build/docs-site。 python3 docs/site/build.py # 构建 python3 docs/site/build.py --check # 只自检,不写文件 python3 docs/site/build.py --output custom # 指定输出目录 """ from __future__ import annotations import argparse import html import json import re import shutil import sys from pathlib import Path from string import Template from urllib.parse import urlsplit ROOT = Path(__file__).resolve().parent REPO = ROOT.parents[1] HEADING = re.compile(r"]*)>(.*?)", re.S) ID_ATTR = re.compile(r'id="([^"]+)"') LINK_ATTR = re.compile(r'(?:href|src)="([^"]+)"') TAG = re.compile(r"<[^>]+>") LAYOUT = Template( """ $title$title_suffix
$site$subtitle
GitHub
$content
""" ) def read_site() -> dict: return json.loads((ROOT / "site.json").read_text(encoding="utf-8")) def read_version(site: dict) -> str: """版本号从 Makefile 读,文档不会比包版本落后。""" makefile = REPO / site["version_file"] if not makefile.is_file(): return "" text = makefile.read_text(encoding="utf-8") version = re.search(r"^PKG_VERSION:=(.+)$", text, re.M) release = re.search(r"^PKG_RELEASE:=(.+)$", text, re.M) if not version: return "" return version.group(1).strip() + (f"-r{release.group(1).strip()}" if release else "") def pages(site: dict) -> list[dict]: return [item | {"group": group["group"]} for group in site["nav"] for item in group["items"]] def strip_tags(text: str) -> str: return re.sub(r"\s+", " ", html.unescape(TAG.sub(" ", text))).strip() def collect_ids(body: str, page: str) -> set[str]: ids: set[str] = set() for value in ID_ATTR.findall(body): if value in ids: raise SystemExit(f"{page}: 锚点 id 重复 —— {value}") ids.add(value) return ids def check_structure(body: str, page: str) -> list[str]: """Pages 部署在 /openwrt-clashoo/ 子路径下,绝对路径会 404;正文要在第一个 h2 之前出现 h1。""" problems = [] h1 = body.find("") elif h2 >= 0 and h2 < h1: problems.append(f"{page}:

必须出现在第一个

之前") if 'href="/' in body or 'src="/' in body: problems.append(f"{page}: 出现以 / 开头的绝对路径(Pages 部署在子路径下会 404)") return problems def check_links(body: str, page: str, known: set[str], assets: set[str]) -> list[str]: problems = [] for raw in LINK_ATTR.findall(body): if raw.startswith(("http://", "https://", "mailto:", "//")): continue target = urlsplit(raw) path = target.path if not path: continue # 纯锚点,交给浏览器 if path in known or path in assets: continue problems.append(f"{page}: 站内链接指向不存在的文件 —— {raw}") return problems def build(site: dict, output: Path, check_only: bool) -> list[str]: errors: list[str] = [] known = {item["page"] for item in pages(site)} asset_dir = ROOT / "assets" assets = {f"assets/{p.name}" for p in asset_dir.iterdir() if p.is_file()} if asset_dir.is_dir() else set() content_dir = ROOT / "content" on_disk = {p.name for p in content_dir.glob("*.html")} for orphan in sorted(on_disk - known): errors.append(f"content/{orphan}: 没有出现在 site.json 导航里") rendered: dict[str, str] = {} headings_of: dict[str, list[dict]] = {} index: list[dict] = [] for item in pages(site): page = item["page"] source = content_dir / page if not source.is_file(): errors.append(f"content/{page}: 导航里列了但文件不存在") continue body = source.read_text(encoding="utf-8") ids = collect_ids(body, page) errors.extend(check_structure(body, page)) errors.extend(check_links(body, page, known, assets)) headings = [] for level, attrs, text in HEADING.findall(body): match = ID_ATTR.search(attrs) if not match: errors.append(f"{page}: 标题缺少 id —— {strip_tags(text)}") continue headings.append({"level": int(level), "id": match.group(1), "text": strip_tags(text)}) headings_of[page] = headings rendered[page] = body index.append( { "page": page, "title": item["title"], "group": item["group"], "headings": headings, "text": strip_tags(body)[:1200], } ) if errors: return errors if check_only: return [] if output.exists(): shutil.rmtree(output) output.mkdir(parents=True) for item in pages(site): page = item["page"] sidebar = [' ") toc = [' ") topnav = [ f' {html.escape(group["group"])}' for group in site["nav"] ] title = site["title"] if page == "index.html" else f'{item["title"]} · {site["title"]}' document = LAYOUT.substitute( title=html.escape(title), title_suffix="" if page == "index.html" else "", description=html.escape(item.get("summary", site["description"])), canonical=site["base_url"] + page, page=page.removesuffix(".html"), repo=site["repo"], license=site["license"], site=html.escape(site["title"]), subtitle=html.escape(site["subtitle"]), sidebar="\n".join(sidebar), toc="\n".join(toc), topnav="\n".join(topnav), content=rendered[page], version=f'v{html.escape(read_version(site))}' if read_version(site) else "", ) (output / page).write_text(document, encoding="utf-8") if asset_dir.is_dir(): shutil.copytree(asset_dir, output / "assets") (output / "search-index.json").write_text( json.dumps(index, ensure_ascii=False, separators=(",", ":")), encoding="utf-8" ) (output / "404.html").write_text( LAYOUT.substitute( title=f"页面不存在 · {site['title']}", title_suffix="", description="页面不存在", canonical=site["base_url"], page="404", repo=site["repo"], license=site["license"], site=html.escape(site["title"]), subtitle=html.escape(site["subtitle"]), sidebar=' ', toc=' ', topnav=' 概览', content='

页面不存在

\n

链接可能已经调整,回到文档首页。

', version="", ), encoding="utf-8", ) return [] def main() -> int: parser = argparse.ArgumentParser(description="构建 Clashoo 文档站") parser.add_argument("--output", default=str(REPO / ".build/docs-site"), help="输出目录") parser.add_argument("--check", action="store_true", help="只做自检,不写文件") args = parser.parse_args() site = read_site() errors = build(site, Path(args.output), args.check) if errors: for line in errors: print(f"错误: {line}", file=sys.stderr) return 1 if args.check: print("自检通过") return 0 print(f"已生成 {len(pages(site))} 页 → {args.output}") return 0 if __name__ == "__main__": raise SystemExit(main())