From e394b2612d5a593e48989be4c8400fbe5abe72b6 Mon Sep 17 00:00:00 2001 From: panxiao81 Date: Wed, 16 Sep 2026 18:50:57 +0000 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=96=87=E6=A1=A3=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=A3=80=E6=9F=A5=E3=80=81Actions=20=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=B5=81=E4=B8=8E=20PR=20=E7=BB=B4=E6=8A=A4=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/PULL_REQUEST_TEMPLATE.md | 20 +++ .gitea/workflows/docs.yml | 26 ++++ .gitignore | 3 + AGENTS.md | 1 + CONTRIBUTING.md | 50 ++++++++ documentation-backlog.md | 6 + requirements-docs.txt | 3 + scripts/check_docs.py | 221 ++++++++++++++++++++++++++++++++ tests/test_check_docs.py | 69 ++++++++++ 9 files changed, 399 insertions(+) create mode 100644 .gitea/PULL_REQUEST_TEMPLATE.md create mode 100644 .gitea/workflows/docs.yml create mode 100644 requirements-docs.txt create mode 100644 scripts/check_docs.py create mode 100644 tests/test_check_docs.py diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..0264edd --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,20 @@ +## 改了什么 + +说明具体问题、修改后的使用方法或事实;纯文字修正可简写。 + +## 依据 + +关联源码 commit/PR、ticket 或带日期的维护者说明。若来自未提交工作区,明确标注。 + +## 验证与范围 + +填写本地检查/CI 结果。现场是否查询、验证了什么、哪些示例未执行? +没有现场验证时,不因编辑文字而刷新 last_verified。 + +## 文档同步 + +- [ ] 涉及的服务页已更新;新增/退役服务已同步服务总览。 +- [ ] 入口或设计变化已更新相关任务导航/架构约束(不适用可注明)。 +- [ ] 已注明来源与尚未完成的部分,未包含实际凭据。 + +原仓库 README 同步目前暂缓,不是本 PR 的必做项。 diff --git a/.gitea/workflows/docs.yml b/.gitea/workflows/docs.yml new file mode 100644 index 0000000..5351263 --- /dev/null +++ b/.gitea/workflows/docs.yml @@ -0,0 +1,26 @@ +name: docs + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + check: + runs-on: [self-hosted, pod] + container: + image: python:3.12-bookworm + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Install checker dependencies + run: python3 -m pip install --disable-pip-version-check -r requirements-docs.txt + - name: Test checker + run: python3 -m unittest discover -s tests -v + - name: Check documentation + run: python3 scripts/check_docs.py + - name: Check whitespace + run: git show --format= --check HEAD diff --git a/.gitignore b/.gitignore index fae2dfa..8338af3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ node_modules/ .env .env.* !.env.example +.venv/ +__pycache__/ +*.pyc diff --git a/AGENTS.md b/AGENTS.md index 6733720..b4660c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,4 +19,5 @@ - 每项当前事实注明来源;last_verified 只在完成所述现场验证后更新,不随文字编辑刷新。 - 凭据只记录取得方式和受管位置,不复制实际密码、token、私钥、state 或含敏感值的输出。 - 不复制 apps/tailscale/helm.sh 的内容。迁移旧文档前先审查敏感内容,不能整库直接发布。 +- 提交前运行 `python3 scripts/check_docs.py`;修改检查器时运行 `python3 -m unittest discover -s tests -v`。依赖与本地环境见 CONTRIBUTING.md。 - 遵守 CONTRIBUTING.md;不把临时检查日志和个人 agent memory 当成正式文档。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5d1e036..8276289 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,3 +52,53 @@ accepted 不代表部署完成,implemented 必须附实现和验收依据。 使用普通 Markdown 链接、相对附件路径和文字说明;关键事实直接写入正文。 可选 Obsidian 编辑,个人布局、缓存、插件和同步配置不提交。对外分享前检查敏感内容。 提交前检查相对链接、服务目录覆盖,以及新增内容是否混淆计划和运行事实。 + + +## 一次服务变更应更新哪里 + +| 变化 | 必须查看的文档 | +|---|---| +| 使用入口、认证、权限、客户端参数 | 对应 `services/` 页面;入口变化同时更新服务总览 | +| 新增或退役组件 | 服务页、`services/index.md`;任务入口变化再改 `guides/task-index.md` | +| 跨服务设计或边界 | `architecture/constraints.md` 及受影响指南 | +| 只有开发进度变化 | 原项目 ticket;wiki 仅在阶段摘要需要变化时更新并注明日期 | +| 取得新的验证结果 | 服务页说明日期与验证范围,据实更新 `last_verified` | +| 工作区来源已合并 | 核对实际内容后更新 `sources.md` 的固定链接及差异标记 | + +先修改最接近事实的页面,再同步导航,避免把同一套操作复制到多份文档。 +无需每次修改都更新首页、所有服务页或整个来源索引。 +原仓库 README 同步按维护者要求暂缓,不阻塞 wiki 的维护。 + +PR 使用 [.gitea/PULL_REQUEST_TEMPLATE.md](.gitea/PULL_REQUEST_TEMPLATE.md),简述问题、最终变化、依据与验证。 +直接提交也遵循相同的检查与证据规则,不为纯文案修改制造额外审批。 + +## 本地与 CI 检查 + +需要 Python 3.10 或更新版本;首次在 wiki 根目录准备环境: + +```bash +python3 -m venv .venv +.venv/bin/python -m pip install -r requirements-docs.txt +.venv/bin/python -m unittest discover -s tests -v +.venv/bin/python scripts/check_docs.py +git diff --check +``` + +依赖版本固定在 [requirements-docs.txt](requirements-docs.txt)。安装依赖需要网络,检查器本身离线运行。 +Gitea 工作流 [.gitea/workflows/docs.yml](.gitea/workflows/docs.yml) 在 main push、PR 和手动触发时运行, +使用 `[self-hosted, pod]` 与 Python 容器;无需业务秘密或集群权限。 +CI 拉取容器、checkout action 和依赖仍需要对应网络可用。 + +检查范围: + +- Markdown 的相对文件链接、图片、引用式链接及本地标题锚点;不探测远端 URL。 +- frontmatter 的类型、重复键、状态枚举、日期与 `live-verified` 必须有验证日期的约束。 +- `services/` 下的服务页必须有完整状态字段,并由服务总览链接;总览及外部消费者范围页除外。 +- 已有 frontmatter 的其他页面校验 title 和审阅日期;`templates/` 允许日期占位为 null。 + +标题锚点按常见 Gitea/GitHub 规则处理中文、字母、数字、连字符和重复标题。 +需要特殊字符锚点时可声明 HTML `id`,不要依赖 Obsidian 插件或非标准 heading 属性。 +代码块和行内代码里的路径是示例或说明,不当链接执行或检查;源码路径的存在性由 `sources.md` 的明确核对维护。 +检查错误带文件与行号,但不打印原始 frontmatter 内容。 + +检查器不证明命令正确、外链可达、事实最新或服务健康,也不自动获取凭据或执行文档中的示例。 diff --git a/documentation-backlog.md b/documentation-backlog.md index 43e2ea6..cf06e24 100644 --- a/documentation-backlog.md +++ b/documentation-backlog.md @@ -38,6 +38,12 @@ LiteLLM、Tailscale、ps3netsrv、vlmcsd 与 k3s 的 wiki 指南已补;源码 - 已核对 62 个主要来源文件,提供[固定版本与工作区差异](sources.md)。 - 仍有差异或未收录的来源,等内容合并后再补对应版本;不自动提交原仓库工作区。 +## 持续维护 + +- 已提供离线文档检查器、Gitea Actions 工作流与检查器测试。 +- 已补 PR 模板及服务变更到文档的对应规则,见 [CONTRIBUTING](CONTRIBUTING.md)。 +- Backstage 展示与 OpenViking 自动索引仍是独立后续工作,本轮未接入。 + ## 原仓库文档同步(按维护者要求暂缓) - 源码仓库中 Authelia OIDC、LAN DNS、SPIRE 的旧说明后续与知识库结论同步。 diff --git a/requirements-docs.txt b/requirements-docs.txt new file mode 100644 index 0000000..81b9d95 --- /dev/null +++ b/requirements-docs.txt @@ -0,0 +1,3 @@ +markdown-it-py==3.0.0 +mdurl==0.1.2 +PyYAML==6.0.1 diff --git a/scripts/check_docs.py b/scripts/check_docs.py new file mode 100644 index 0000000..78e5be6 --- /dev/null +++ b/scripts/check_docs.py @@ -0,0 +1,221 @@ +#!/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()) diff --git a/tests/test_check_docs.py b/tests/test_check_docs.py new file mode 100644 index 0000000..cb85ea9 --- /dev/null +++ b/tests/test_check_docs.py @@ -0,0 +1,69 @@ +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +from check_docs import check, metadata_errors, split_frontmatter + + +class DocumentChecks(unittest.TestCase): + def run_check(self, documents): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for name, text in documents.items(): + file = root / name + file.parent.mkdir(parents=True, exist_ok=True) + file.write_text(text) + return check(root, list(documents)) + + def test_commonmark_links_and_code_examples(self): + errors = self.run_check({ + "README.md": "[引用][page]\n\n[page]: guide.md#中文-标题\n\n" + "![附件](asset.svg)\n\n`[假链接](missing.md)`\n\n" + "```md\n[示例](missing.md)\n```\n", + "guide.md": "# 中文 标题\n", + "asset.svg": "", + }) + self.assertEqual([], errors) + + def test_broken_reference_and_heading(self): + errors = self.run_check({"README.md": "[x][r]\n\n[r]: missing.md\n\n[x](#absent)\n"}) + self.assertTrue(any("目标不存在" in e for e in errors)) + self.assertTrue(any("标题锚点不存在" in e for e in errors)) + + def test_duplicate_headings_and_html_anchor(self): + self.assertEqual([], self.run_check({"README.md": + '# Same\n# Same\n\n[x](#same-1) [y](#custom)\n'})) + + def test_escaped_paths_tables_and_external_links(self): + self.assertEqual([], self.run_check({ + "README.md": '| a | b |\n|---|---|\n| [x](a%20b.md) | [web](https://example.invalid) |\n', + "a b.md": '# Target\n', + })) + + def test_outside_repository_rejected(self): + errors = self.run_check({"README.md": "[x](../outside.md)\n"}) + self.assertTrue(any("越出仓库" in e for e in errors)) + + def test_frontmatter_dates_and_evidence(self): + meta, _ = split_frontmatter('---\ntitle: Example\nlifecycle: active\nevidence: live-verified\n' + 'last_reviewed: 2026-09-16\nlast_verified: null\n---\n# Title\n') + self.assertTrue(any("live-verified" in e for e in metadata_errors(meta, required=True))) + meta['last_verified'] = '2026-09-17' + self.assertTrue(any("不能晚于" in e for e in metadata_errors(meta, required=True))) + meta['last_verified'] = '2026-02-30' + self.assertTrue(any("YYYY-MM-DD" in e for e in metadata_errors(meta, required=True))) + + def test_duplicate_yaml_keys_rejected(self): + errors = self.run_check({"README.md": '---\ntitle: A\ntitle: B\n---\n'}) + self.assertTrue(errors) + + def test_new_service_requires_metadata_and_index(self): + errors = self.run_check({"services/new.md": "# New\n", "services/index.md": "# Services\n"}) + self.assertTrue(any("缺少 frontmatter" in e for e in errors)) + self.assertTrue(any("未被 services/index.md" in e for e in errors)) + + +if __name__ == '__main__': + unittest.main()