#!/usr/bin/env python3 """离线检查 wiki 的元数据、链接、标题锚点与服务索引。""" from __future__ import annotations import argparse from datetime import date from html.parser import HTMLParser from pathlib import Path import re import subprocess import sys import unicodedata from urllib.parse import unquote, urlsplit from markdown_it import MarkdownIt import yaml LIFECYCLES = {"planned", "experimental", "active", "retired", "unknown"} EVIDENCE = {"configuration", "documented", "live-verified"} # These two pages are navigation/scope descriptions, not individual services. SERVICE_INDEXES = {"services/index.md", "services/external-consumers.md"} class UniqueLoader(yaml.SafeLoader): pass def unique_mapping(loader, node): result = {} for key_node, value_node in node.value: key = loader.construct_object(key_node) if not isinstance(key, str): raise ValueError("frontmatter 键必须是字符串") if key in result: raise ValueError(f"重复 frontmatter 键:{key}") result[key] = loader.construct_object(value_node) return result UniqueLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, unique_mapping) def split_frontmatter(text): lines = text.splitlines(keepends=True) if not lines or lines[0].strip() != "---": return None, text for i in range(1, len(lines)): if lines[i].strip() == "---": metadata = yaml.load("".join(lines[1:i]), Loader=UniqueLoader) if not isinstance(metadata, dict): raise ValueError("frontmatter 必须是映射") # Preserve line positions for diagnostics. return metadata, "\n" * (i + 1) + "".join(lines[i + 1:]) raise ValueError("frontmatter 缺少结束分隔符") def as_date(value): if type(value) is date: return value if isinstance(value, str) and re.fullmatch(r"\d{4}-\d{2}-\d{2}", value): return date.fromisoformat(value) raise ValueError("必须是 YYYY-MM-DD 日期") def metadata_errors(meta, required=False, template=False): if meta is None: return ["服务页缺少 frontmatter"] if required else [] errors = [] keys = {"title", "last_reviewed"} if required: keys |= {"lifecycle", "evidence", "last_verified"} for key in sorted(keys - meta.keys()): errors.append(f"缺少字段 {key}") if not isinstance(meta.get("title"), str) or not meta["title"].strip(): errors.append("title 必须是非空字符串") for key, choices in [("lifecycle", LIFECYCLES), ("evidence", EVIDENCE)]: if key in meta and (not isinstance(meta[key], str) or meta[key] not in choices): errors.append(f"{key} 不在允许值中") dates = {} for key in ["last_reviewed", "last_verified"]: if key not in meta: continue if meta[key] is None: if key == "last_reviewed" and not template: errors.append("last_reviewed 不得为 null") continue try: dates[key] = as_date(meta[key]) except (ValueError, TypeError): errors.append(f"{key} 必须是 YYYY-MM-DD 日期或允许的 null") if meta.get("evidence") == "live-verified" and "last_verified" not in dates: errors.append("live-verified 必须提供 last_verified 日期") if len(dates) == 2 and dates["last_verified"] > dates["last_reviewed"]: errors.append("last_verified 不能晚于 last_reviewed") if "sources" in meta and (not isinstance(meta["sources"], list) or any(not isinstance(x, str) or not x.strip() for x in meta["sources"])): errors.append("sources 必须是非空字符串组成的列表(可为空列表)") return errors def slug(text): # Common Gitea/GitHub heading form; keep CJK, words, spaces and hyphens. return "".join(c for c in text.lower() if c in " -_" or unicodedata.category(c)[0] in "LN").replace(" ", "-") class HTMLLinks(HTMLParser): def __init__(self): super().__init__() self.links = [] self.anchors = set() def handle_starttag(self, tag, attrs): attrs = dict(attrs) for key in ["href", "src"]: if attrs.get(key): self.links.append(attrs[key]) if attrs.get("id"): self.anchors.add(attrs["id"]) if tag == "a" and attrs.get("name"): self.anchors.add(attrs["name"]) def parse_body(body): tokens = MarkdownIt("commonmark").enable("table").parse(body) anchors, links = set(), [] for i, token in enumerate(tokens): if token.type == "heading_open": inline = tokens[i + 1] text = "".join(t.content for t in inline.children or [] if t.type in {"text", "code_inline", "image"}) base = slug(text) candidate, suffix = base, 0 while candidate in anchors: suffix += 1 candidate = f"{base}-{suffix}" anchors.add(candidate) def visit(t, line): line = t.map[0] + 1 if t.map else line if t.type in {"link_open", "image"}: url = t.attrGet("href" if t.type == "link_open" else "src") if url is not None: links.append((line, url)) if t.type in {"html_inline", "html_block"}: html = HTMLLinks() html.feed(t.content) anchors.update(html.anchors) links.extend((line, u) for u in html.links) for child in t.children or []: visit(child, line) visit(token, 1) return anchors, links def check(root, files): root = root.resolve() errors, documents = [], {} for relative in files: path = root / relative try: if not path.resolve().is_relative_to(root): raise ValueError("文件指向仓库外部") meta, body = split_frontmatter(path.read_text(encoding="utf-8")) required = relative.startswith("services/") and relative not in SERVICE_INDEXES errors.extend(f"{relative}:1: {e}" for e in metadata_errors( meta, required=required, template=relative.startswith("templates/"))) documents[relative] = parse_body(body) except (ValueError, OSError, yaml.YAMLError) as exc: # Do not print YAML source lines: malformed frontmatter may contain secrets. errors.append(f"{relative}:1: 无法解析文件或 frontmatter({type(exc).__name__})") indexed = set() for relative, (_, links) in documents.items(): for line, url in links: prefix = f"{relative}:{line}: " try: parsed = urlsplit(url) except ValueError: errors.append(prefix + "URL 格式无效") continue if parsed.scheme in {"http", "https", "mailto", "tel", "data"} or parsed.netloc: continue if parsed.scheme or parsed.path.startswith("/"): errors.append(prefix + "链接必须使用仓库内相对路径或网页 URL") continue target = ((root / relative).parent / unquote(parsed.path)).resolve() if parsed.path else root / relative if not target.is_relative_to(root): errors.append(prefix + "链接越出仓库") continue if not target.exists(): errors.append(prefix + f"目标不存在:{unquote(parsed.path)}") continue dest = target.relative_to(root).as_posix() if relative == "services/index.md": indexed.add(dest) anchor = unquote(parsed.fragment) if anchor and dest in documents and anchor not in documents[dest][0]: errors.append(prefix + f"标题锚点不存在:{dest}#{anchor}") for relative in documents: if relative.startswith("services/") and relative not in SERVICE_INDEXES and relative not in indexed: errors.append(f"{relative}:1: 服务页未被 services/index.md 链接") return errors def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) args = parser.parse_args() output = subprocess.check_output( ["git", "-C", str(args.root), "ls-files", "--cached", "--others", "--exclude-standard", "-z"]) files = sorted({p for p in output.decode().split("\0") if p.endswith(".md")}) errors = check(args.root, files) if errors: print("\n".join(errors), file=sys.stderr) return 1 print(f"文档检查通过:{len(files)} 个 Markdown 文件;未联网或执行文档示例。") return 0 if __name__ == "__main__": raise SystemExit(main())