diff options
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/docs_translation_parity.py | 145 | ||||
| -rw-r--r-- | scripts/test_docs_translation_parity.py | 78 |
2 files changed, 223 insertions, 0 deletions
diff --git a/scripts/docs_translation_parity.py b/scripts/docs_translation_parity.py new file mode 100644 index 00000000..6b15441b --- /dev/null +++ b/scripts/docs_translation_parity.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + + +DEFAULT_LOCALES = ("ja", "zh-cn") + + +def heading_outline(path: Path) -> list[int]: + outline: list[int] = [] + in_fence = False + + for line in path.read_text(encoding="utf-8").splitlines(): + stripped = line.lstrip() + if stripped.startswith("```") or stripped.startswith("~~~"): + in_fence = not in_fence + continue + if in_fence or not stripped.startswith("#"): + continue + + level = 0 + for char in stripped: + if char != "#": + break + level += 1 + + if level == 0 or level > 6: + continue + if len(stripped) > level and stripped[level] not in (" ", "\t"): + continue + + outline.append(level) + + return outline + + +def english_docs(docs_root: Path) -> list[Path]: + return sorted( + path + for path in docs_root.glob("*.mdx") + if path.is_file() + ) + + +def locale_docs(docs_root: Path, locale: str) -> list[Path]: + locale_root = docs_root / locale + if not locale_root.exists(): + return [] + return sorted(path for path in locale_root.glob("*.mdx") if path.is_file()) + + +def check_docs_translation_parity(docs_root: Path, locales: tuple[str, ...] = DEFAULT_LOCALES) -> list[str]: + errors: list[str] = [] + english = english_docs(docs_root) + english_names = {path.name for path in english} + + for locale in locales: + translated_names = {path.name for path in locale_docs(docs_root, locale)} + + for missing in sorted(english_names - translated_names): + errors.append(f"{docs_root / locale / missing}: missing translation file") + + for stale in sorted(translated_names - english_names): + errors.append(f"{docs_root / locale / stale}: no matching English doc") + + for source in english: + source_outline = heading_outline(source) + + for locale in locales: + translated = docs_root / locale / source.name + if not translated.exists(): + continue + + translated_outline = heading_outline(translated) + if translated_outline == source_outline: + continue + + errors.append( + format_outline_error(source, translated, source_outline, translated_outline) + ) + + return errors + + +def format_outline_error( + source: Path, + translated: Path, + source_outline: list[int], + translated_outline: list[int], +) -> str: + return ( + f"{translated}: heading outline differs from {source} " + f"(English {format_counts(source_outline)}, translated {format_counts(translated_outline)})" + ) + + +def format_counts(levels: list[int]) -> str: + if not levels: + return "0 headings" + + parts = [] + for level in range(1, 7): + count = levels.count(level) + if count: + parts.append(f"h{level}={count}") + return ", ".join(parts) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Check localized docs have the same heading outline as English docs." + ) + parser.add_argument( + "--docs-root", + default="website/src/content/docs", + type=Path, + help="Docs content root containing English .mdx files and locale subdirectories.", + ) + parser.add_argument( + "--locale", + action="append", + dest="locales", + help="Locale subdirectory to check. Can be passed more than once.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(sys.argv[1:] if argv is None else argv) + locales = tuple(args.locales or DEFAULT_LOCALES) + errors = check_docs_translation_parity(args.docs_root, locales) + + if errors: + print("error: localized docs heading outlines differ from English docs", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_docs_translation_parity.py b/scripts/test_docs_translation_parity.py new file mode 100644 index 00000000..9a8aa378 --- /dev/null +++ b/scripts/test_docs_translation_parity.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from scripts.docs_translation_parity import check_docs_translation_parity, heading_outline + + +class DocsTranslationParityTests(unittest.TestCase): + def test_heading_outline_ignores_fenced_code_blocks(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "doc.mdx" + path.write_text( + "# Title\n\n```md\n## Not a heading\n```\n\n## Real section\n", + encoding="utf-8", + ) + + self.assertEqual(heading_outline(path), [1, 2]) + + def test_parity_accepts_translated_heading_text_with_same_shape(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "ja").mkdir() + (root / "zh-cn").mkdir() + (root / "guide.mdx").write_text("# Guide\n\n## Install\n\n### Verify\n", encoding="utf-8") + (root / "ja" / "guide.mdx").write_text( + "# ガイド\n\n## インストール\n\n### 確認\n", + encoding="utf-8", + ) + (root / "zh-cn" / "guide.mdx").write_text( + "# 指南\n\n## 安装\n\n### 验证\n", + encoding="utf-8", + ) + + self.assertEqual(check_docs_translation_parity(root), []) + + def test_parity_reports_missing_heading_sections(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "ja").mkdir() + (root / "zh-cn").mkdir() + (root / "cli-reference.mdx").write_text( + "# CLI reference\n\n## Launch\n\n## Shell completions\n", + encoding="utf-8", + ) + (root / "ja" / "cli-reference.mdx").write_text( + "# CLI リファレンス\n\n## 起動\n", + encoding="utf-8", + ) + (root / "zh-cn" / "cli-reference.mdx").write_text( + "# CLI 参考\n\n## 启动\n\n## Shell 补全\n", + encoding="utf-8", + ) + + errors = check_docs_translation_parity(root) + + self.assertEqual(len(errors), 1) + self.assertIn("ja/cli-reference.mdx", errors[0]) + self.assertIn("heading outline differs", errors[0]) + + def test_parity_reports_missing_and_stale_files(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "ja").mkdir() + (root / "zh-cn").mkdir() + (root / "guide.mdx").write_text("# Guide\n", encoding="utf-8") + (root / "ja" / "old.mdx").write_text("# Old\n", encoding="utf-8") + (root / "zh-cn" / "guide.mdx").write_text("# 指南\n", encoding="utf-8") + + errors = check_docs_translation_parity(root) + + self.assertIn(f"{root / 'ja' / 'guide.mdx'}: missing translation file", errors) + self.assertIn(f"{root / 'ja' / 'old.mdx'}: no matching English doc", errors) + + +if __name__ == "__main__": + unittest.main() |
