用户实测后台报错:「主题设置 集合: The file option content/site/theme.yaml is
invalid. It must be the path to a JSON file」——根因是 site_theme 把 `file:`
直接写在集合层级,而 Decap/Sveltia 的文件集合必须是
`files: [{ name, label, file, format, fields }]` 结构;缺 files 包装时 Sveltia
按单文件变体解析并期待 JSON 路径,故报该错。
- site_theme 改为标准 files 集合(format: yaml 显式声明)
- site_pages 三单例补显式 format: yaml(扩展名推断可用但显式更稳)
- config-check.py 增加结构断言:每个集合必须 folder 或 files 二选一、files
项必须齐 file/label/fields、file 必须指向真实文件——这类"后台整体打不开"
的结构错从此在构建期拦截
验证: config-check 五文件全 OK(207 字段/64 hint)+ 结构断言通过;
pnpm astro build exit 0
Spec: none (后台配置结构修复,构建校验加固)
Co-authored-by: GLM-5.3
124 lines
4.3 KiB
Python
Executable File
124 lines
4.3 KiB
Python
Executable File
#!/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', '<unnamed>')
|
||
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())
|