#!/usr/bin/env python3 """ 站点 YAML 配置结构校验(2026-11-02 教训驱动) 为什么需要:Python 默认 yaml.safe_load 对重复键是"后者覆盖前者"的静默行为, 收编时用它验收会漏。本包实测 public/admin/config.yml 里 typography 被插进双语 字段的 name: 与 widget: object 之间 → 同一 mapping 出现重复键 widget/fields, 后台双语编辑器退化成字符串、排版选择器字段被劫持。本脚本用自定义构造器在 解析阶段对重复键直接报错,并附带后台 hint 覆盖率体检。 用法:python3 scripts/build-perf/config-check.py [repo根=.] 退出码:0 通过;1 有失败项 """ import sys from pathlib import Path try: import yaml except ImportError: print('[config-check] 缺少 pyyaml:pip install pyyaml') sys.exit(1) FILES = [ 'public/admin/config.yml', 'content/site/home.yaml', 'content/site/about.yaml', 'content/site/opensource.yaml', 'content/site/theme.yaml', ] class StrictLoader(yaml.SafeLoader): """对重复键报错的 SafeLoader(默认行为是静默覆盖)。""" def _construct_mapping(loader, node, deep=False): seen = set() for key_node, _value_node in node.value: key = loader.construct_object(key_node, deep=deep) if key in seen: raise yaml.YAMLError( f'duplicate key {key!r} at line {key_node.start_mark.line + 1}' ) seen.add(key) return yaml.SafeLoader.construct_mapping(loader, node, deep=deep) StrictLoader.add_constructor( yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_mapping ) def main() -> int: root = Path(sys.argv[1] if len(sys.argv) > 1 else '.') failed = 0 loaded = {} for rel in FILES: path = root / rel if not path.exists(): print(f'[config-check] SKIP {rel}(不存在)') continue try: loaded[rel] = yaml.load(path.read_text(encoding='utf-8'), Loader=StrictLoader) print(f'[config-check] OK {rel}') except yaml.YAMLError as exc: failed += 1 first = str(exc).splitlines()[0] print(f'[config-check] FAIL {rel}\n {first}') admin = loaded.get('public/admin/config.yml') if admin: # 结构断言(2026-11-02 教训):每个集合必须二选一——folder 集合或 # files 文件集合;files 项必须齐 file/label/fields,且 file 指向真实文件。 # 把 file 直接写在集合层级会被 Sveltia 判非法(后台整体报错)。 for coll in admin.get('collections', []): name = coll.get('name', '') has_folder, has_files = 'folder' in coll, 'files' in coll if not has_folder and not has_files: failed += 1 print(f'[config-check] FAIL 集合 {name}: 既无 folder 也无 files(Sveltia 会拒绝加载)') if has_files: for entry in coll.get('files') or []: miss = [k for k in ('file', 'label', 'fields') if k not in entry] if miss: failed += 1 print(f'[config-check] FAIL 集合 {name} 的文件项缺 {miss}') continue if not (root / entry['file']).exists(): failed += 1 print(f"[config-check] FAIL 集合 {name}: 文件不存在 {entry['file']}") if admin: fields = hints = 0 def walk(node): nonlocal fields, hints if isinstance(node, dict): if 'name' in node: fields += 1 if 'hint' in node: hints += 1 for value in node.values(): walk(value) elif isinstance(node, list): for value in node: walk(value) walk(admin) print(f'[config-check] 后台字段 {fields} 个,其中带 hint {hints} 个') if hints < 20: failed += 1 print('[config-check] FAIL hint 覆盖不足(<20),后台字段缺少中文说明') if failed: print(f'[config-check] {failed} 项未通过') return 1 print('[config-check] 全部通过') return 0 if __name__ == '__main__': raise SystemExit(main())