mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-10-03 17:14:33 -04:00
200 lines
7.4 KiB
Python
Executable File
200 lines
7.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Restore the structural front matter of translated docs pages from English.
|
|
|
|
Crowdin translates every front matter value in docs/<locale>/, but some keys are
|
|
structure the site reads rather than prose:
|
|
|
|
layout names a file in _layouts/ or the theme; a translated name renders
|
|
the page without the theme.
|
|
nav_order a number the theme sorts by.
|
|
|
|
Each of these keys in docs/<locale>/<path>.md is set to what docs/en/<path>.md
|
|
has, verbatim, including multi-line values and absence. Every other line is left
|
|
as Crowdin wrote it.
|
|
|
|
parent is not restored: just-the-docs lists every page whose parent matches a
|
|
page's title in that page's table of contents, nav_exclude or not, so an English
|
|
parent would put the locale page into the English guide's contents.
|
|
|
|
A locale page with no front matter while its English page has one is reported
|
|
but not rewritten: Crowdin rebuilds each translation from the English source's
|
|
structure on the next download.
|
|
|
|
Usage: sync-locale-front-matter.py [--check] [<docs-dir>]
|
|
|
|
With --check nothing is written and the exit status is non-zero when any locale
|
|
page differs from English on these keys. Under GitHub Actions each finding is
|
|
also an annotation on the file.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
STRUCTURAL_KEYS = ("layout", "nav_order")
|
|
LOCALE_DIR = re.compile(r"^[a-z]{2,3}(-r[A-Za-z]+)?$")
|
|
TOP_LEVEL_KEY = re.compile(r"^([A-Za-z_][A-Za-z0-9_-]*)\s*:(\s|$)")
|
|
IN_GITHUB_ACTIONS = os.environ.get("GITHUB_ACTIONS") == "true"
|
|
|
|
Entry = tuple[str | None, list[str]]
|
|
|
|
|
|
def split_lines(text: str) -> list[str]:
|
|
# str.splitlines would also split on U+2028 and friends, which are content here.
|
|
parts = text.split("\n")
|
|
lines = [part + "\n" for part in parts[:-1]]
|
|
return lines + [parts[-1]] if parts[-1] else lines
|
|
|
|
|
|
def content(lines: list[str]) -> list[str]:
|
|
end = len(lines)
|
|
while end > 1 and not lines[end - 1].strip():
|
|
end -= 1
|
|
return lines[:end]
|
|
|
|
|
|
class FrontMatter:
|
|
"""A page split into its front matter entries, one per top-level key."""
|
|
|
|
def __init__(self, head: str, entries: list[Entry], tail: list[str]) -> None:
|
|
self.head = head
|
|
# Lines before the first key, if any, form an entry whose key is None.
|
|
self.entries = entries
|
|
self.tail = tail
|
|
|
|
@classmethod
|
|
def parse(cls, text: str) -> FrontMatter | None:
|
|
lines = split_lines(text)
|
|
if not lines or lines[0].rstrip() != "---":
|
|
return None
|
|
close = next((i for i in range(1, len(lines)) if lines[i].rstrip() in ("---", "...")), None)
|
|
if close is None:
|
|
return None
|
|
entries: list[Entry] = []
|
|
for line in lines[1:close]:
|
|
match = TOP_LEVEL_KEY.match(line)
|
|
if match:
|
|
entries.append((match.group(1), [line]))
|
|
elif entries:
|
|
entries[-1][1].append(line)
|
|
else:
|
|
entries.append((None, [line]))
|
|
return cls(lines[0], entries, lines[close:])
|
|
|
|
def get(self, key: str) -> list[str] | None:
|
|
return next((lines for k, lines in self.entries if k == key), None)
|
|
|
|
def line_of(self, key: str) -> int:
|
|
line = 2
|
|
for k, lines in self.entries:
|
|
if k == key:
|
|
return line
|
|
line += len(lines)
|
|
return 1
|
|
|
|
def render(self) -> str:
|
|
return self.head + "".join("".join(lines) for _, lines in self.entries) + "".join(self.tail)
|
|
|
|
|
|
def shown_value(lines: list[str] | None) -> str:
|
|
if lines is None:
|
|
return "(absent)"
|
|
first = lines[0].split(":", 1)[1].strip()
|
|
return first + (" ..." if len(content(lines)) > 1 else "")
|
|
|
|
|
|
def sync(locale: FrontMatter, english: FrontMatter) -> list[tuple[str, str, str]]:
|
|
"""Rewrites locale in place and returns (key, locale value, English value) per change."""
|
|
changes = []
|
|
newline = "\r\n" if locale.head.endswith("\r\n") else "\n"
|
|
for key in STRUCTURAL_KEYS:
|
|
want = english.get(key)
|
|
have = locale.get(key)
|
|
if want is not None:
|
|
want = [line.rstrip("\r\n") + newline for line in content(want)]
|
|
if (content(have) if have is not None else None) == want:
|
|
continue
|
|
changes.append((key, shown_value(have), shown_value(want)))
|
|
if want is None:
|
|
locale.entries = [(k, lines) for k, lines in locale.entries if k != key]
|
|
elif have is None:
|
|
locale.entries.append((key, want))
|
|
else:
|
|
# Keep the blank lines Crowdin left after the entry.
|
|
replacement = want + have[len(content(have)):]
|
|
locale.entries = [(k, replacement if k == key else lines) for k, lines in locale.entries]
|
|
return changes
|
|
|
|
|
|
def annotate(level: str, path: Path, line: int, message: str) -> None:
|
|
if IN_GITHUB_ACTIONS:
|
|
print(f"::{level} file={path},line={line}::{message}")
|
|
else:
|
|
print(f"{path}:{line}: {level}: {message}")
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
parser.add_argument("--check", action="store_true", help="report drift without writing")
|
|
parser.add_argument("docs_dir", nargs="?", type=Path, default=REPO_ROOT / "docs")
|
|
args = parser.parse_args()
|
|
|
|
docs: Path = args.docs_dir
|
|
english_root = docs / "en"
|
|
if not english_root.is_dir():
|
|
print(f"{english_root} does not exist", file=sys.stderr)
|
|
return 2
|
|
|
|
locale_roots = sorted(
|
|
p for p in docs.iterdir() if p.is_dir() and p.name != "en" and LOCALE_DIR.match(p.name)
|
|
)
|
|
drifted = 0
|
|
for locale_root in locale_roots:
|
|
for page in sorted(locale_root.rglob("*.md")):
|
|
source = english_root / page.relative_to(locale_root)
|
|
if not source.is_file():
|
|
continue
|
|
english = FrontMatter.parse(source.read_bytes().decode("utf-8"))
|
|
if english is None:
|
|
continue
|
|
shown = page.relative_to(REPO_ROOT) if page.is_relative_to(REPO_ROOT) else page
|
|
locale = FrontMatter.parse(page.read_bytes().decode("utf-8"))
|
|
if locale is None:
|
|
missing = [key for key in STRUCTURAL_KEYS if english.get(key) is not None]
|
|
if args.check and missing:
|
|
drifted += 1
|
|
annotate("error", shown, 1, f"no front matter, so no {', '.join(missing)}; docs/en has them")
|
|
else:
|
|
annotate("warning", shown, 1, "no front matter, while its docs/en page has one")
|
|
continue
|
|
lines = {key: locale.line_of(key) for key in STRUCTURAL_KEYS}
|
|
changes = sync(locale, english)
|
|
if not changes:
|
|
continue
|
|
drifted += 1
|
|
for key, have, want in changes:
|
|
if args.check:
|
|
annotate("error", shown, lines[key], f"{key} is '{have}', docs/en has '{want}'")
|
|
else:
|
|
print(f"{shown}: {key} '{have}' -> '{want}'")
|
|
if not args.check:
|
|
page.write_bytes(locale.render().encode("utf-8"))
|
|
|
|
if args.check and drifted:
|
|
print(
|
|
f"{drifted} locale page(s) differ from docs/en on {', '.join(STRUCTURAL_KEYS)}. "
|
|
"Fix with: python3 scripts/docs/sync-locale-front-matter.py",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|