mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
@@ -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())
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user