mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
Check translation contracts offline, add Swedish to the installer and enable the website languages
- Offline checks for terminal and website catalogs reject translations that change commands, paths, identifiers or placeholders - Terminal and documentation texts that broke those contracts are corrected in every language - Swedish can be chosen in the stable and beta installers - The website routes, language switcher and exported pages cover the eight languages, with the shared website texts in each of them - Website documentation translations are generated only when requested - From PR #475 by @jcastro
This commit is contained in:
@@ -73,6 +73,24 @@ TECHNICAL_TERMS = (
|
||||
"DKMS",
|
||||
"SSH",
|
||||
"API",
|
||||
"Flask",
|
||||
"SQLite",
|
||||
"Prometheus",
|
||||
"Grafana",
|
||||
"Uptime Kuma",
|
||||
"PWA",
|
||||
"Fail2Ban",
|
||||
"Tailscale",
|
||||
"JavaScript",
|
||||
"TypeScript",
|
||||
"Next.js",
|
||||
"Coral",
|
||||
"rclone",
|
||||
"journald",
|
||||
"gasket",
|
||||
"apex",
|
||||
"Alpine",
|
||||
"OpenVINO",
|
||||
)
|
||||
|
||||
PROTECTED_PATTERNS = (
|
||||
@@ -88,9 +106,13 @@ PROTECTED_PATTERNS = (
|
||||
re.compile(r"(?<![A-Za-z0-9<])/(?:[A-Za-z0-9._~:@%+=-]+/)*[A-Za-z0-9._~:@%+=-]+"),
|
||||
re.compile(r"\b[A-Za-z0-9_.-]+\.(?:json|ya?ml|toml|conf|service|socket|sh|py|tsx?|jsx?|md)\b"),
|
||||
re.compile(r"(?<![\w.@])@[A-Za-z_][A-Za-z0-9_]*"),
|
||||
# Identifiers and encoded delimiters remain literal even outside <code>.
|
||||
re.compile(r"(?<![A-Za-z0-9_])_?[A-Za-z][A-Za-z0-9]*(?:_[A-Za-z0-9]+)+(?![A-Za-z0-9_])"),
|
||||
re.compile(r"\b[A-Za-z0-9]*(?:[a-z][A-Z])[A-Za-z0-9]*\b"),
|
||||
re.compile(r"&(?:[A-Za-z]+|#[0-9]+|#x[0-9A-Fa-f]+);"),
|
||||
)
|
||||
LITERAL_TAG_RE = re.compile(
|
||||
r"<(code|kbd|pre)\b[^>]*>.*?</\1>",
|
||||
r"<(code|codeNw|codeXs|kbd|pre)\b[^>]*>.*?</\1>",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
@@ -109,16 +131,23 @@ ANY_TAG_RE = re.compile(r"</?[A-Za-z][^<>]*>")
|
||||
BARE_SENTINELS = False
|
||||
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_.-]*\}")
|
||||
NON_TRANSLATABLE_KEYS = {
|
||||
"accent",
|
||||
"categoryslug",
|
||||
"command",
|
||||
"code",
|
||||
"href",
|
||||
"icon",
|
||||
"iconbg",
|
||||
"id",
|
||||
"menucmd",
|
||||
"path",
|
||||
"route",
|
||||
"slug",
|
||||
"src",
|
||||
"url",
|
||||
"color",
|
||||
"tone",
|
||||
"variant",
|
||||
}
|
||||
SOURCE_STATE_VERSION = 1
|
||||
|
||||
@@ -186,12 +215,34 @@ def should_copy(source: str, path: tuple[str | int, ...]) -> bool:
|
||||
if not source.strip() or not re.search(r"[A-Za-z]", source):
|
||||
return True
|
||||
last = str(path[-1]).lower() if path else ""
|
||||
# These fields describe the surrounding snippet; they are prose, even
|
||||
# though their names end in "Code".
|
||||
if last in {"aftercode", "beforecode"}:
|
||||
return False
|
||||
# This UI breadcrumb is prose, rather than a filesystem path.
|
||||
if last == "uipath" and "<strong>" in source:
|
||||
return False
|
||||
if last == "flow" and source in {"loader", "dedicated"}:
|
||||
return True
|
||||
# These exact values drive rendering/navigation; similarly named visible
|
||||
# labels remain translatable.
|
||||
if last == "branch" and source in {"main", "develop", "feature/*"}:
|
||||
return True
|
||||
if last == "kind" and source in {"internal", "external"}:
|
||||
return True
|
||||
if last == "key" and source in {"linux", "nas", "windows"}:
|
||||
return True
|
||||
if last in NON_TRANSLATABLE_KEYS or any(
|
||||
last.endswith(suffix)
|
||||
for suffix in ("url", "href", "path", "command", "code", "icon")
|
||||
):
|
||||
return True
|
||||
if re.fullmatch(r"https?://\S+", source) or re.fullmatch(r"/[A-Za-z0-9_./:@%+=-]+", source):
|
||||
if re.fullmatch(r"https?://\S+", source) or re.fullmatch(r"/\S+", source):
|
||||
return True
|
||||
# Standalone literal blocks contain examples, not prose. Separators between
|
||||
# several blocks do not make their command/configuration contents translatable.
|
||||
without_literals = LITERAL_TAG_RE.sub("", source)
|
||||
if without_literals != source and not re.search(r"[A-Za-z]", ANY_TAG_RE.sub("", without_literals)):
|
||||
return True
|
||||
if re.fullmatch(r"[A-Z0-9_.:/+-]{2,}", source):
|
||||
return True
|
||||
@@ -202,6 +253,15 @@ def leaf_token(path: tuple[str | int, ...]) -> str:
|
||||
return json.dumps(path, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def literal_values_match(source: Any, target: Any) -> bool:
|
||||
"""Literal commands and rendering metadata must follow the source too."""
|
||||
return all(
|
||||
not should_copy(leaf.source, leaf.path)
|
||||
or get_at_path(target, leaf.path) == leaf.source
|
||||
for leaf in iter_leaves(source)
|
||||
)
|
||||
|
||||
|
||||
def source_fingerprints(source: Any) -> dict[str, str]:
|
||||
return {
|
||||
leaf_token(leaf.path): hashlib.sha256(leaf.source.encode("utf-8")).hexdigest()[:20]
|
||||
@@ -240,7 +300,7 @@ def protect_rich_tags(text: str) -> tuple[str, dict[str, str]]:
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
original = match.group(1)
|
||||
if original.lower() in {"code", "kbd", "pre"}:
|
||||
if original.lower() in {"code", "codenw", "codexs", "kbd", "pre"}:
|
||||
return match.group(0)
|
||||
key = original.lower()
|
||||
internal = names.get(key)
|
||||
@@ -329,11 +389,14 @@ def protect_text(text: str) -> tuple[str, dict[str, str]]:
|
||||
|
||||
|
||||
def restore_text(text: str, mapping: dict[str, str]) -> str:
|
||||
missing = [token for token in mapping if token not in text]
|
||||
# A local model may append a digit to a bare sentinel. A substring match
|
||||
# would silently restore the prefix and leak the extra digit into prose.
|
||||
patterns = {token: re.compile(re.escape(token) + r"(?![0-9])") for token in mapping}
|
||||
missing = [token for token, pattern in patterns.items() if not pattern.search(text)]
|
||||
if missing:
|
||||
raise ValueError(f"translation provider changed protected token {missing[0]}")
|
||||
for token, original in mapping.items():
|
||||
text = text.replace(token, original)
|
||||
text = patterns[token].sub(lambda match: original, text)
|
||||
return text
|
||||
|
||||
|
||||
@@ -347,6 +410,11 @@ def validate_contract(source: str, target: str) -> None:
|
||||
raise ValueError("literal code changed")
|
||||
if sorted(PLACEHOLDER_RE.findall(source)) != sorted(PLACEHOLDER_RE.findall(target)):
|
||||
raise ValueError("placeholder contract changed")
|
||||
# Bare numeric protection markers once leaked a trailing zero just
|
||||
# outside an otherwise intact code span. Literal/ICU checks cannot see it.
|
||||
residue = re.compile(r'(?:</(?:code|codeNw|codeXs|pre|kbd)>|<strong>)(0+)(?=\s|[;:/<().,-]|$)')
|
||||
if len(residue.findall(target)) > len(residue.findall(source)):
|
||||
raise ValueError("unexpected numeric residue next to literal markup")
|
||||
|
||||
|
||||
def provider_function(args: argparse.Namespace) -> Callable[[str, str], str]:
|
||||
@@ -822,7 +890,10 @@ def main() -> int:
|
||||
leaves = pending_leaves(source, target, queued_tokens)
|
||||
total_strings += len(iter_leaves(source))
|
||||
missing_strings += len(leaves)
|
||||
schema_changed = not schema_matches(source, target)
|
||||
schema_changed = (
|
||||
not schema_matches(source, target)
|
||||
or not literal_values_match(source, target)
|
||||
)
|
||||
if leaves or schema_changed:
|
||||
pending.append(
|
||||
(
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check active terminal translations offline, without generating any text.
|
||||
|
||||
Validates formatting fields and literal commands/identifiers. Missing values
|
||||
can retain English fallback; --require-complete makes them errors. This does
|
||||
not establish the accuracy or fluency of the translated prose.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from build_translation_cache import (
|
||||
DEFAULT_LANGUAGES, STRUCTURAL_LITERAL_RE, extract_catalog_texts,
|
||||
extract_python_texts, extract_translate_texts,
|
||||
)
|
||||
from build_web_docs_i18n import read_json, validate_contract
|
||||
|
||||
|
||||
def active_sources(root: Path) -> list[str]:
|
||||
# Match build-translation-cache.yml, including launchers outside scripts/.
|
||||
extras = [root / name for name in ('menu', 'install_proxmenux.sh', 'install_proxmenux_beta.sh')]
|
||||
texts = extract_translate_texts(root / 'scripts', extras)
|
||||
texts += extract_translate_texts(root / 'oci/remote')
|
||||
texts += extract_python_texts([root / 'scripts', root / 'oci/src', root / 'oci/remote'])
|
||||
texts += extract_catalog_texts([root / 'oci/catalog'])
|
||||
return sorted({text for text in texts if '$' not in text and '`' not in text})
|
||||
|
||||
|
||||
def validate_message(source: str, target: str, *, title_separator: bool = False) -> None:
|
||||
validate_contract(source, target)
|
||||
for match in STRUCTURAL_LITERAL_RE.finditer(source):
|
||||
literal = match.group()
|
||||
# A page title may replace its decorative separator with a dash.
|
||||
# In command examples, a pipe remains a shell operator.
|
||||
if title_separator and literal == '|':
|
||||
continue
|
||||
# DNS hostnames are case-insensitive. Paths, field names and command
|
||||
# names are not, so do not case-fold those to hide a broken example.
|
||||
is_hostname = re.fullmatch(r'[A-Za-z0-9.-]+\.(?:com|org|net|io)', literal)
|
||||
if literal not in target and not (is_hostname and literal.casefold() in target.casefold()):
|
||||
raise ValueError(f'literal command or identifier changed: {literal}')
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--root', type=Path, default=Path('.'))
|
||||
parser.add_argument('--languages', default=','.join(DEFAULT_LANGUAGES))
|
||||
parser.add_argument('--require-complete', action='store_true')
|
||||
args = parser.parse_args()
|
||||
root = args.root.resolve()
|
||||
texts = active_sources(root)
|
||||
errors = 0
|
||||
for language in args.languages.split(','):
|
||||
if language not in DEFAULT_LANGUAGES:
|
||||
parser.error(f'Unsupported locale: {language}')
|
||||
catalog = read_json(root / 'lang' / f'{language}.json') or {}
|
||||
missing = 0
|
||||
for source in texts:
|
||||
target = catalog.get(source)
|
||||
if not isinstance(target, str) or not target.strip():
|
||||
missing += 1
|
||||
continue
|
||||
try:
|
||||
validate_message(source, target)
|
||||
except ValueError as error:
|
||||
print(f'[{language}] {source!r}: {error}')
|
||||
errors += 1
|
||||
print(f'[{language}] active={len(texts)} missing={missing}')
|
||||
if args.require_complete and missing:
|
||||
errors += 1
|
||||
return 1 if errors else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check documentation translation structure and literal contracts offline.
|
||||
|
||||
Missing locales/leaves are allowed by default because the website falls back
|
||||
to English. Use --require-complete when validating a catalog completion PR.
|
||||
This checks data integrity, not translation fluency or linguistic accuracy.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from build_web_docs_i18n import (
|
||||
DEFAULT_LANGUAGES,
|
||||
get_at_path,
|
||||
iter_leaves,
|
||||
literal_values_match,
|
||||
read_json,
|
||||
schema_matches,
|
||||
source_files,
|
||||
)
|
||||
from check_terminal_catalogs import validate_message
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--messages-dir", type=Path, default=Path("web/messages"))
|
||||
parser.add_argument("--languages", default=",".join(DEFAULT_LANGUAGES))
|
||||
parser.add_argument("--require-complete", action="store_true")
|
||||
args = parser.parse_args()
|
||||
root = args.messages_dir.resolve()
|
||||
files = source_files(root / "en", ".")
|
||||
errors = 0
|
||||
for language in args.languages.split(","):
|
||||
language = language.strip()
|
||||
if language not in DEFAULT_LANGUAGES:
|
||||
parser.error(f"Unsupported locale: {language}")
|
||||
missing = checked = present_files = 0
|
||||
for file in files:
|
||||
relative = file.relative_to(root / "en")
|
||||
source = read_json(file)
|
||||
target = read_json(root / language / relative)
|
||||
if target is not None:
|
||||
present_files += 1
|
||||
if args.require_complete and not schema_matches(source, target):
|
||||
print(f"[{language}] {relative}: source/target schema differs")
|
||||
errors += 1
|
||||
for leaf in iter_leaves(source):
|
||||
localized = get_at_path(target, leaf.path)
|
||||
if not isinstance(localized, str) or (leaf.source.strip() and not localized.strip()):
|
||||
missing += 1
|
||||
continue
|
||||
try:
|
||||
validate_message(
|
||||
leaf.source, localized,
|
||||
title_separator=bool(
|
||||
leaf.path and leaf.path[0] == "meta"
|
||||
and leaf.path[-1] in ("title", "ogTitle", "twitterTitle")
|
||||
),
|
||||
)
|
||||
except ValueError as exc:
|
||||
print(f"[{language}] {relative}:{'.'.join(map(str, leaf.path))}: {exc}")
|
||||
errors += 1
|
||||
checked += 1
|
||||
if args.require_complete and not literal_values_match(source, target):
|
||||
print(f"[{language}] {relative}: literal commands or rendering metadata differ")
|
||||
errors += 1
|
||||
print(f"[{language}] files={present_files}/{len(files)} strings={checked} missing={missing}")
|
||||
if args.require_complete and missing:
|
||||
errors += 1
|
||||
return 1 if errors else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -60,6 +60,124 @@ class WebDocsI18nTests(unittest.TestCase):
|
||||
target = {"title": "Actualizaciones", "url": "https://example.com"}
|
||||
self.assertEqual(MODULE.pending_leaves(source, target), [])
|
||||
|
||||
def test_rendering_metadata_is_copied_but_visible_labels_are_translated(self):
|
||||
for key, value in (("tone", "amber"), ("variant", "source"),
|
||||
("accent", "border-blue-300 bg-blue-50"),
|
||||
("iconBg", "bg-blue-100 text-blue-700"),
|
||||
("color", "green")):
|
||||
with self.subTest(key=key):
|
||||
self.assertTrue(MODULE.should_copy(value, ("steps", 0, key)))
|
||||
self.assertFalse(MODULE.should_copy(value, ("label",)))
|
||||
self.assertEqual(MODULE.merge_tree({key: value}, {key: "translated"}, {}), {key: value})
|
||||
self.assertFalse(MODULE.should_copy("Translation", ("type",)))
|
||||
self.assertFalse(MODULE.should_copy("English", ("lang",)))
|
||||
for value in ("loader", "dedicated"):
|
||||
self.assertTrue(MODULE.should_copy(value, ("cards", 0, "flow")))
|
||||
self.assertFalse(MODULE.should_copy(value, ("label",)))
|
||||
|
||||
def test_literal_only_examples_and_api_routes_are_not_translation_gaps(self):
|
||||
for source in ("<code>apt update</code>",
|
||||
"<code>net0</code> / <code>net1</code>.",
|
||||
"/api/task-log/<upid>", "/api/vms/<vmid>/config"):
|
||||
with self.subTest(source=source):
|
||||
self.assertTrue(MODULE.should_copy(source, ("cell",)))
|
||||
self.assertFalse(MODULE.should_copy("Run <code>apt update</code> first.", ("body",)))
|
||||
self.assertFalse(MODULE.should_copy("After running the command, check its output.", ("afterCode",)))
|
||||
self.assertFalse(MODULE.should_copy("Before running the command, read this.", ("beforeCode",)))
|
||||
self.assertTrue(MODULE.should_copy("curl http://localhost:8008/api/health", ("curlCode",)))
|
||||
|
||||
def test_inline_identifiers_and_encoded_delimiters_round_trip(self):
|
||||
source = "Set RS_SKIP_PATHS, call _rs_apply_hot and keep localStorage at <host>."
|
||||
protected, mapping = MODULE.protect_text(source)
|
||||
for literal in ("RS_SKIP_PATHS", "_rs_apply_hot", "localStorage", "<", ">"):
|
||||
self.assertIn(literal, mapping.values())
|
||||
self.assertNotIn(literal, protected)
|
||||
self.assertEqual(MODULE.restore_text(protected, mapping), source)
|
||||
|
||||
def test_custom_code_spans_keep_commands_literal(self):
|
||||
for tag in ("codeNw", "codeXs"):
|
||||
source = f"Run <{tag}>pct config 100</{tag}>."
|
||||
with self.subTest(tag=tag):
|
||||
protected, mapping = MODULE.protect_rich_tags(source)
|
||||
self.assertEqual(protected, source)
|
||||
self.assertEqual(mapping, {})
|
||||
with self.assertRaisesRegex(ValueError, "literal code changed"):
|
||||
MODULE.validate_contract(source, source.replace("config", "configuration"))
|
||||
with self.assertRaisesRegex(ValueError, "numeric residue"):
|
||||
MODULE.validate_contract(source, source.replace(f"</{tag}>", f"</{tag}>0"))
|
||||
self.assertTrue(MODULE.should_copy(f"<{tag}>pct config 100</{tag}>", ("example",)))
|
||||
|
||||
def test_navigation_identifiers_are_distinct_from_visible_labels(self):
|
||||
values = {
|
||||
"categorySlug": ["system", "network"],
|
||||
"menuCmd": ["menu"],
|
||||
"branch": ["main", "develop", "feature/*"],
|
||||
"kind": ["internal", "external"],
|
||||
"key": ["nas", "linux", "windows"],
|
||||
}
|
||||
for key, items in values.items():
|
||||
for value in items:
|
||||
with self.subTest(key=key, value=value):
|
||||
self.assertTrue(MODULE.should_copy(value, ("cards", 0, key)))
|
||||
self.assertFalse(MODULE.should_copy(value, ("label",)))
|
||||
self.assertFalse(MODULE.should_copy("Translation", ("kind",)))
|
||||
self.assertFalse(MODULE.should_copy("Storage", ("branch",)))
|
||||
|
||||
def test_ui_breadcrumb_is_prose_while_paths_remain_literal(self):
|
||||
source = "In the UI: <strong>Datacenter → Backup → Retention</strong>:"
|
||||
self.assertFalse(MODULE.should_copy(source, ("retention", "uiPath")))
|
||||
self.assertTrue(MODULE.should_copy("/etc/pve/storage.cfg", ("uiPath",)))
|
||||
self.assertTrue(MODULE.should_copy("/etc/pve/storage.cfg", ("configPath",)))
|
||||
|
||||
def test_bare_sentinel_with_added_digit_is_rejected(self):
|
||||
with mock.patch.object(MODULE, "BARE_SENTINELS", True):
|
||||
protected, mapping = MODULE.protect_text("ProxMenux collects metrics.")
|
||||
token = next(iter(mapping))
|
||||
with self.assertRaisesRegex(ValueError, "changed protected token"):
|
||||
MODULE.restore_text(protected.replace(token, token + "0"), mapping)
|
||||
self.assertEqual(MODULE.restore_text(protected, mapping), "ProxMenux collects metrics.")
|
||||
|
||||
def test_numeric_residue_outside_intact_literal_is_rejected(self):
|
||||
examples = [
|
||||
('Run <code>pct config</code> first.', 'Spustite <code>pct config</code>0 najprv.'),
|
||||
('<strong><code>Restart=on-failure</code></strong> restarts the service.',
|
||||
'<strong>0<code>Restart=on-failure</code></strong> reštartuje službu.'),
|
||||
]
|
||||
for source, target in examples:
|
||||
with self.subTest(source=source):
|
||||
with self.assertRaisesRegex(ValueError, 'numeric residue'):
|
||||
MODULE.validate_contract(source, target)
|
||||
MODULE.validate_contract('The result is <code>echo</code>0.',
|
||||
'Výsledok je <code>echo</code>0.')
|
||||
|
||||
def test_product_and_kernel_module_names_are_protected(self):
|
||||
source = "Flask uses SQLite and journald; rclone works with Coral, gasket, apex and Alpine."
|
||||
protected, mapping = MODULE.protect_text(source)
|
||||
for name in ("Flask", "SQLite", "journald", "rclone", "Coral", "gasket", "apex", "Alpine"):
|
||||
self.assertIn(name, mapping.values())
|
||||
self.assertNotIn(name, protected)
|
||||
self.assertEqual(MODULE.restore_text(protected, mapping), source)
|
||||
|
||||
def test_literal_only_changes_sync_without_retranslating_human_prose(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
source = root / "messages/en/common.json"
|
||||
target = root / "messages/de/common.json"
|
||||
source.parent.mkdir(parents=True)
|
||||
target.parent.mkdir(parents=True)
|
||||
source.write_text(json.dumps({"title": "Updates", "code": "tool --version=2", "tone": "blue"}))
|
||||
target.write_text(json.dumps({"title": "Aktualisierungen", "code": "tool --version=1", "tone": "blau"}))
|
||||
provider = mock.Mock(side_effect=AssertionError("Existing prose must not be retranslated"))
|
||||
self.assertEqual(self.run_generator(root, provider), 0)
|
||||
self.assertEqual(json.loads(target.read_text()), {
|
||||
"title": "Aktualisierungen", "code": "tool --version=2", "tone": "blue",
|
||||
})
|
||||
provider.assert_not_called()
|
||||
before = target.stat().st_mtime_ns
|
||||
self.assertEqual(self.run_generator(root, provider), 0)
|
||||
self.assertEqual(target.stat().st_mtime_ns, before)
|
||||
provider.assert_not_called()
|
||||
|
||||
def test_changed_source_text_is_pending_even_with_existing_translation(self):
|
||||
source = {"title": "Updated installation guidance"}
|
||||
target = {"title": "Vorherige Installationsanleitung"}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Keep machine generation explicit without losing automatic validation."""
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class DocumentationReviewWorkflowTests(unittest.TestCase):
|
||||
def workflow(self, name):
|
||||
return yaml.load(
|
||||
(ROOT / '.github/workflows' / name).read_text(encoding='utf-8'),
|
||||
Loader=yaml.BaseLoader,
|
||||
)
|
||||
|
||||
def test_generation_requires_explicit_dispatch(self):
|
||||
triggers = self.workflow('build-web-docs-i18n.yml')['on']
|
||||
self.assertEqual(set(triggers), {'workflow_dispatch'})
|
||||
|
||||
def test_website_changes_keep_automatic_offline_validation(self):
|
||||
triggers = self.workflow('test-i18n.yml')['on']
|
||||
for event in ('pull_request', 'push'):
|
||||
with self.subTest(event=event):
|
||||
self.assertIn('web/**', triggers[event]['paths'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Every shipped terminal language must be selectable during installation."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||
ROOT = SCRIPTS.parents[1]
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
from build_translation_cache import DEFAULT_LANGUAGES
|
||||
|
||||
|
||||
class InstallerLanguageTests(unittest.TestCase):
|
||||
def test_both_installers_offer_every_shipped_language(self):
|
||||
expected = {'en', *DEFAULT_LANGUAGES}
|
||||
for name in ('install_proxmenux.sh', 'install_proxmenux_beta.sh'):
|
||||
with self.subTest(installer=name):
|
||||
source = (ROOT / name).read_text(encoding='utf-8')
|
||||
function = re.search(r'^select_language\(\) \{(.*?)^\}', source, re.M | re.S)
|
||||
self.assertIsNotNone(function, 'Missing installer language selector')
|
||||
offered = re.findall(r'^\s*"([a-z]{2})"\s+"[^"]+"', function.group(1), re.M)
|
||||
self.assertEqual(set(offered), expected)
|
||||
self.assertEqual(len(offered), len(expected), 'Duplicate language option')
|
||||
# English is the source text in the scripts, not a JSON catalog.
|
||||
for language in set(offered) - {'en'}:
|
||||
self.assertTrue((ROOT / 'lang' / f'{language}.json').is_file(), language)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Offline regressions for copyable commands and runtime format fields."""
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||
ROOT = SCRIPTS.parents[1]
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
SPEC = importlib.util.spec_from_file_location('check_terminal_catalogs', SCRIPTS / 'check_terminal_catalogs.py')
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC and SPEC.loader
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class TerminalCatalogsTests(unittest.TestCase):
|
||||
def test_extractor_includes_launchers_remote_scripts_and_catalog_text(self):
|
||||
fixtures = {
|
||||
'menu': 'translate "Main launcher"',
|
||||
'install_proxmenux.sh': 'translate "Stable installer"',
|
||||
'install_proxmenux_beta.sh': 'translate "Beta installer"',
|
||||
'scripts/check.sh': 'translate "Host shell script"',
|
||||
'scripts/check.py': 'translate("Host Python script")',
|
||||
'oci/src/check.py': 'N_("OCI Python script")',
|
||||
'oci/remote/check.py': 'translate("Remote Python script")',
|
||||
'oci/remote/check.sh': 'translate "Remote shell script"',
|
||||
'oci/catalog/app.json': json.dumps({'catalog_ui': {'tagline': {'en_US': 'Application tagline'}}}),
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
for name, content in fixtures.items():
|
||||
file = root / name
|
||||
file.parent.mkdir(parents=True, exist_ok=True)
|
||||
file.write_text(content, encoding='utf-8')
|
||||
self.assertEqual(set(MODULE.active_sources(root)), {
|
||||
'Main launcher', 'Stable installer', 'Beta installer',
|
||||
'Host shell script', 'Host Python script', 'OCI Python script',
|
||||
'Remote Python script', 'Remote shell script', 'Application tagline',
|
||||
})
|
||||
|
||||
def test_runtime_format_field_cannot_be_translated(self):
|
||||
source = 'Kimai has no default account. Enter the container with: pct enter {main_vmid}'
|
||||
with self.assertRaisesRegex(ValueError, 'placeholder'):
|
||||
MODULE.validate_message(source, 'Kimai nemá účet: pct enter {main vmid}')
|
||||
MODULE.validate_message(source, 'Kimai nemá účet: pct enter {main_vmid}')
|
||||
|
||||
def test_copyable_command_and_environment_variable_are_literal(self):
|
||||
examples = [
|
||||
('Example: pct config 110 | grep unprivileged', 'Ejemplo: configuración pct 110 | grep sin privilegios'),
|
||||
("Use 'pct restore' to recover the disks.", "Verwenden Sie 'pct restart', um die Datenträger wiederherzustellen."),
|
||||
('Invalid ALLOWED_HOSTS value', 'Valeur HÔTES INDÉSIRABLES'),
|
||||
]
|
||||
for source, target in examples:
|
||||
with self.subTest(source=source):
|
||||
with self.assertRaisesRegex(ValueError, 'literal command or identifier'):
|
||||
MODULE.validate_message(source, target)
|
||||
MODULE.validate_message('Example: pct config 110 | grep unprivileged',
|
||||
'Ejemplo: pct config 110 | grep unprivileged')
|
||||
|
||||
def test_dns_capitalization_does_not_relax_file_path_case(self):
|
||||
MODULE.validate_message('Cannot reach download.proxmox.com.',
|
||||
'Download.proxmox.com ist nicht erreichbar.')
|
||||
with self.assertRaisesRegex(ValueError, 'literal command or identifier'):
|
||||
MODULE.validate_message('Check /etc/pve/storage.cfg', 'Prüfen Sie /etc/PVE/storage.cfg')
|
||||
|
||||
def test_shipped_active_catalogs_keep_literal_contracts(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'),
|
||||
'--root', str(ROOT), '--require-complete'],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
for locale in MODULE.DEFAULT_LANGUAGES:
|
||||
self.assertIn(f'[{locale}] active=', result.stdout)
|
||||
self.assertNotIn('missing=1', result.stdout)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Validate complete catalogs without rejecting intentional English fallback."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "check_web_docs_catalogs.py"
|
||||
|
||||
|
||||
class WebDocsCatalogTests(unittest.TestCase):
|
||||
def run_check(self, root, *options):
|
||||
return subprocess.run(
|
||||
[sys.executable, "-B", str(SCRIPT), "--messages-dir", str(root),
|
||||
"--languages", "de", *options],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
|
||||
def write_catalog(self, root, locale, content):
|
||||
file = root / locale / "common.json"
|
||||
file.parent.mkdir(parents=True, exist_ok=True)
|
||||
file.write_text(json.dumps(content), encoding="utf-8")
|
||||
|
||||
def test_partial_catalog_keeps_fallback_but_complete_mode_reports_gap(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
self.write_catalog(root, "en", {"title": "Updates", "body": "Check the host."})
|
||||
self.write_catalog(root, "de", {"title": "Aktualisierungen"})
|
||||
partial = self.run_check(root)
|
||||
self.assertEqual(partial.returncode, 0, partial.stdout + partial.stderr)
|
||||
self.assertIn("missing=1", partial.stdout)
|
||||
complete = self.run_check(root, "--require-complete")
|
||||
self.assertEqual(complete.returncode, 1, complete.stdout + complete.stderr)
|
||||
self.assertIn("schema differs", complete.stdout)
|
||||
|
||||
def test_translated_code_is_rejected_in_both_modes(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
self.write_catalog(root, "en", {"body": "Run <code>apt update</code>."})
|
||||
self.write_catalog(root, "de", {"body": "Führe <code>apt aktualisieren</code> aus."})
|
||||
for options in ((), ("--require-complete",)):
|
||||
with self.subTest(options=options):
|
||||
result = self.run_check(root, *options)
|
||||
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
||||
|
||||
def test_bare_commands_and_operators_are_not_prose(self):
|
||||
examples = [
|
||||
("Restore with pct restore.", "Mit pct restart wiederherstellen."),
|
||||
("pveversion | grep pve-manager", "pveversion grep pve-manager"),
|
||||
("cd /opt/my-app && ./update.sh", "cd /opt/meine-app && ./update.sh"),
|
||||
]
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
for source, target in examples:
|
||||
with self.subTest(source=source):
|
||||
self.write_catalog(root, "en", {"body": source})
|
||||
self.write_catalog(root, "de", {"body": target})
|
||||
result = self.run_check(root)
|
||||
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
||||
self.assertIn("literal command or identifier", result.stdout)
|
||||
|
||||
def test_title_separator_can_change_without_relaxing_body_pipes(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
self.write_catalog(root, "en", {"meta": {"title": "Storage | ProxMenux"}})
|
||||
self.write_catalog(root, "de", {"meta": {"title": "Speicher — ProxMenux"}})
|
||||
result = self.run_check(root, "--require-complete")
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.write_catalog(root, "en", {"meta": {"description": "pveversion | grep pve-manager"}})
|
||||
self.write_catalog(root, "de", {"meta": {"description": "pveversion grep pve-manager"}})
|
||||
result = self.run_check(root)
|
||||
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
||||
|
||||
def test_shipped_web_catalogs_keep_literal_contracts(self):
|
||||
messages = SCRIPT.parents[2] / "web" / "messages"
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-B", str(SCRIPT), "--messages-dir", str(messages)],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_literal_metadata_is_required_but_labels_can_be_localized(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
self.write_catalog(root, "en", {"label": "Updates", "tone": "blue"})
|
||||
self.write_catalog(root, "de", {"label": "Aktualisierungen", "tone": "blau"})
|
||||
invalid = self.run_check(root, "--require-complete")
|
||||
self.assertEqual(invalid.returncode, 1, invalid.stdout + invalid.stderr)
|
||||
self.assertIn("rendering metadata differ", invalid.stdout)
|
||||
self.write_catalog(root, "de", {"label": "Aktualisierungen", "tone": "blue"})
|
||||
valid = self.run_check(root, "--require-complete")
|
||||
self.assertEqual(valid.returncode, 0, valid.stdout + valid.stderr)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Exercise the static-export language repair on real HTML files."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from test_command_descriptions import require_node
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
SCRIPT = ROOT / "web/scripts/localize-export.mjs"
|
||||
|
||||
|
||||
class WebExportLanguageTests(unittest.TestCase):
|
||||
def test_locales_nested_pages_preserved_content_and_idempotence(self):
|
||||
node = require_node()
|
||||
html = '<!doctype html><html lang="en" class="dark"><body><h1>Text</h1><script>const example=\'<html lang="en">\';</script></body></html>'
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
locales = ["en", "es", "de", "fr", "it", "pt", "sk", "sv"]
|
||||
for locale in locales:
|
||||
file = root / "out" / locale / "docs/monitor/index.html"
|
||||
file.parent.mkdir(parents=True)
|
||||
file.write_text(html)
|
||||
for relative in ["out/index.html", "out/assets/index.html"]:
|
||||
file = root / relative
|
||||
file.parent.mkdir(parents=True, exist_ok=True)
|
||||
file.write_text(html)
|
||||
result = subprocess.run([node, str(SCRIPT)], cwd=root, capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("8 localized pages", result.stdout)
|
||||
for locale in locales:
|
||||
actual = (root / "out" / locale / "docs/monitor/index.html").read_text()
|
||||
self.assertEqual(actual, html.replace('<html lang="en"', f'<html lang="{locale}"', 1))
|
||||
for relative in ["out/index.html", "out/assets/index.html"]:
|
||||
self.assertEqual((root / relative).read_text(), html)
|
||||
tracked = root / "out/de/docs/monitor/index.html"
|
||||
before = tracked.stat().st_mtime_ns
|
||||
second = subprocess.run([node, str(SCRIPT)], cwd=root, capture_output=True, text=True)
|
||||
self.assertEqual(second.returncode, 0, second.stderr)
|
||||
self.assertEqual(tracked.stat().st_mtime_ns, before)
|
||||
@@ -1,14 +1,9 @@
|
||||
name: Build web documentation translations
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
paths:
|
||||
- 'web/messages/en/*.json'
|
||||
- 'web/messages/en/**/*.json'
|
||||
- '.github/scripts/build_web_docs_i18n.py'
|
||||
- '.github/scripts/build_translation_cache.py'
|
||||
- '.github/workflows/build-web-docs-i18n.yml'
|
||||
# Source and locale corrections are reviewed together. An automatic run
|
||||
# would treat the changed English fingerprints as stale translations and
|
||||
# overwrite those reviewed locale strings with provider output.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
languages:
|
||||
|
||||
@@ -9,15 +9,20 @@ on:
|
||||
- 'AppImage/messages/**'
|
||||
- 'AppImage/lib/i18n/**'
|
||||
- 'AppImage/components/terminal-panel.tsx'
|
||||
- 'web/**'
|
||||
- 'CONTRIBUTING.md'
|
||||
# Scheduler consumers and the offline extractor's .sh/.func inputs.
|
||||
- 'scripts/**/*.sh'
|
||||
- 'scripts/**/*.func'
|
||||
- 'scripts/**/*.py'
|
||||
- 'lang/*.json'
|
||||
- 'menu'
|
||||
- 'install_proxmenux.sh'
|
||||
- 'install_proxmenux_beta.sh'
|
||||
- 'oci/src/**/*.py'
|
||||
- 'oci/catalog/**/*.json'
|
||||
- 'oci/remote/**/*.sh'
|
||||
- 'oci/remote/**/*.py'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
@@ -27,15 +32,20 @@ on:
|
||||
- 'AppImage/messages/**'
|
||||
- 'AppImage/lib/i18n/**'
|
||||
- 'AppImage/components/terminal-panel.tsx'
|
||||
- 'web/**'
|
||||
- 'CONTRIBUTING.md'
|
||||
# Scheduler consumers and the offline extractor's .sh/.func inputs.
|
||||
- 'scripts/**/*.sh'
|
||||
- 'scripts/**/*.func'
|
||||
- 'scripts/**/*.py'
|
||||
- 'lang/*.json'
|
||||
- 'menu'
|
||||
- 'install_proxmenux.sh'
|
||||
- 'install_proxmenux_beta.sh'
|
||||
- 'oci/src/**/*.py'
|
||||
- 'oci/catalog/**/*.json'
|
||||
- 'oci/remote/**/*.sh'
|
||||
- 'oci/remote/**/*.py'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
Reference in New Issue
Block a user