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"
+ "\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()