ci(validate): warn on segmented keys in the json, ex: aaaa.bbbb.cccc
This commit is contained in:
@@ -148,6 +148,17 @@ class TranslationKeyTests(unittest.TestCase):
|
|||||||
with self.subTest(key=key):
|
with self.subTest(key=key):
|
||||||
self.assertFalse(validate_plugins.is_valid_translation_key(key))
|
self.assertFalse(validate_plugins.is_valid_translation_key(key))
|
||||||
|
|
||||||
|
def test_rejects_dotted_json_object_keys(self) -> None:
|
||||||
|
# A flat dotted key is what the i18n platform expands into nested objects; the JSON
|
||||||
|
# source must nest instead, so an object key with a dot is invalid.
|
||||||
|
self.assertFalse(validate_plugins.is_valid_key_segment("settings.label"))
|
||||||
|
self.assertTrue(validate_plugins.is_valid_key_segment("eyecare-active-duration"))
|
||||||
|
translations = {"settings.eyecare-active-duration.label": "Active Duration"}
|
||||||
|
self.assertEqual(
|
||||||
|
validate_plugins.invalid_translation_keys(translations),
|
||||||
|
["settings.eyecare-active-duration.label"],
|
||||||
|
)
|
||||||
|
|
||||||
def test_walks_nested_keys_and_reports_full_paths(self) -> None:
|
def test_walks_nested_keys_and_reports_full_paths(self) -> None:
|
||||||
translations = {
|
translations = {
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|||||||
@@ -68,12 +68,20 @@ ID_SEGMENT_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
|
|||||||
RESERVED_NAMES = {"license", "readme", "index", "api", "admin", "static", "assets"}
|
RESERVED_NAMES = {"license", "readme", "index", "api", "admin", "static", "assets"}
|
||||||
|
|
||||||
# The store flattens every plugin's translations under its id and rejects keys that are not
|
# The store flattens every plugin's translations under its id and rejects keys that are not
|
||||||
# lowercase. A dotted key is a path of segments; each segment allows a-z, 0-9, dashes, and
|
# lowercase. A dotted label_key is a path of segments; each segment allows a-z, 0-9, dashes,
|
||||||
# underscores, but an underscore may not lead a segment. Uppercase (e.g. "zh-Hans") is out.
|
# and underscores, but an underscore may not lead a segment. Uppercase (e.g. "zh-Hans") is out.
|
||||||
TRANSLATION_KEY_SEGMENT_RE = re.compile(r"[a-z0-9-][a-z0-9_-]*")
|
TRANSLATION_KEY_SEGMENT_RE = re.compile(r"[a-z0-9-][a-z0-9_-]*")
|
||||||
|
# Rule for a dotted path, as written in a manifest label_key/description_key.
|
||||||
TRANSLATION_KEY_RULE = (
|
TRANSLATION_KEY_RULE = (
|
||||||
"keys must be lowercase and contain only a-z, 0-9, dots, dashes, and non-leading underscores"
|
"keys must be lowercase and contain only a-z, 0-9, dots, dashes, and non-leading underscores"
|
||||||
)
|
)
|
||||||
|
# Rule for a single object key inside translations/en.json. The dot is a path separator, so it
|
||||||
|
# cannot appear inside a key: the i18n platform expands "a.b" into nested objects on the next
|
||||||
|
# sync, and a flat dotted key would silently churn. Nest with objects instead.
|
||||||
|
TRANSLATION_SEGMENT_RULE = (
|
||||||
|
"each translations/en.json key must be a single lowercase segment (a-z, 0-9, dashes, "
|
||||||
|
"non-leading underscores) with no dots; express nesting with objects, not dotted keys"
|
||||||
|
)
|
||||||
|
|
||||||
# Files every published plugin ships: the site renders the README as the plugin page and
|
# Files every published plugin ships: the site renders the README as the plugin page and
|
||||||
# the thumbnail as its card, and the English catalog backs every label_key.
|
# the thumbnail as its card, and the English catalog backs every label_key.
|
||||||
@@ -362,20 +370,28 @@ def webp_dimensions(header: bytes) -> tuple[int, int] | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_key_segment(segment: str) -> bool:
|
||||||
|
"""True if a single translation key segment is lowercase and dot-free."""
|
||||||
|
return TRANSLATION_KEY_SEGMENT_RE.fullmatch(segment) is not None
|
||||||
|
|
||||||
|
|
||||||
def is_valid_translation_key(key: str) -> bool:
|
def is_valid_translation_key(key: str) -> bool:
|
||||||
"""True if a dotted translation key obeys the store's lowercase key rule."""
|
"""True if a dotted label_key path obeys the store's lowercase key rule."""
|
||||||
parts = key.split(".")
|
return all(is_valid_key_segment(part) for part in key.split("."))
|
||||||
return all(TRANSLATION_KEY_SEGMENT_RE.fullmatch(part) for part in parts)
|
|
||||||
|
|
||||||
|
|
||||||
def invalid_translation_keys(node: Any, prefix: str = "") -> list[str]:
|
def invalid_translation_keys(node: Any, prefix: str = "") -> list[str]:
|
||||||
"""Return dotted paths in a translations tree whose own segment breaks the key rule."""
|
"""Return dotted paths in a translations tree whose own object key breaks the segment rule.
|
||||||
|
|
||||||
|
Each object key must be one segment: a dot inside a key is rejected because the i18n
|
||||||
|
platform expands it into nested objects, so a flat "a.b" key is normalized away on sync.
|
||||||
|
"""
|
||||||
invalid: list[str] = []
|
invalid: list[str] = []
|
||||||
if not isinstance(node, dict):
|
if not isinstance(node, dict):
|
||||||
return invalid
|
return invalid
|
||||||
for key, value in node.items():
|
for key, value in node.items():
|
||||||
path = f"{prefix}.{key}" if prefix else key
|
path = f"{prefix}.{key}" if prefix else key
|
||||||
if not isinstance(key, str) or not is_valid_translation_key(key):
|
if not isinstance(key, str) or not is_valid_key_segment(key):
|
||||||
invalid.append(path)
|
invalid.append(path)
|
||||||
invalid.extend(invalid_translation_keys(value, path))
|
invalid.extend(invalid_translation_keys(value, path))
|
||||||
return invalid
|
return invalid
|
||||||
@@ -996,7 +1012,7 @@ class Validator:
|
|||||||
path = plugin_dir / "translations" / "en.json"
|
path = plugin_dir / "translations" / "en.json"
|
||||||
self.add_error(
|
self.add_error(
|
||||||
path,
|
path,
|
||||||
f"invalid translation key format: {', '.join(invalid)}; {TRANSLATION_KEY_RULE}",
|
f"invalid translation key format: {', '.join(invalid)}; {TRANSLATION_SEGMENT_RULE}",
|
||||||
)
|
)
|
||||||
|
|
||||||
def validate_required_files(self, manifest_path: Path, plugin_dir: Path) -> None:
|
def validate_required_files(self, manifest_path: Path, plugin_dir: Path) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user