From cfad225ac3384f272edd807733224aa4b561a384 Mon Sep 17 00:00:00 2001 From: Mohammed Elnaggar Date: Thu, 30 Apr 2026 23:41:02 +0300 Subject: [PATCH 1/3] feat: add Arabic localization parity checker --- README.md | 6 + apple-arabic-localization/SKILL.md | 13 +- .../apple-localization-checklist.md | 10 + .../references/parity-checker-examples.md | 83 +++++ .../scripts/check-localization-parity.py | 295 ++++++++++++++++++ 5 files changed, 403 insertions(+), 4 deletions(-) create mode 100644 apple-arabic-localization/references/parity-checker-examples.md create mode 100755 apple-arabic-localization/scripts/check-localization-parity.py diff --git a/README.md b/README.md index e86b2e1..2cdbfe8 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Included references: - `references/rtl-review.md` — Arabic/LTR visual audit and remediation flow - `references/apple-rtl-principles.md` — Apple-authored RTL rules and mirroring guidance - `references/copy-guidelines.md` — Arabic/English product copy and store copy rules +- `references/parity-checker-examples.md` — examples for localization parity findings ## Install @@ -59,9 +60,13 @@ cp -R apple-arabic-localization .agents/skills/ iOS-dev-skills/ apple-arabic-localization/ SKILL.md # Skill definition (frontmatter + instructions) + scripts/ + audit-localization.sh # Static localization audit helper + check-localization-parity.py # English/Arabic resource parity checker references/ apple-localization-checklist.md # Implementation checklist rtl-review.md # RTL visual review guide + parity-checker-examples.md # Example parity findings copy-guidelines.md # Arabic/English copy rules ``` @@ -70,6 +75,7 @@ iOS-dev-skills/ - "Localize this SwiftUI app to Arabic and English." - "Review all RTL issues in this iOS app." - "Run an English and Arabic RTL audit on this SwiftUI app." +- "Check Arabic and English localization files for missing keys." - "Add in-app language switching for this Xcode project." - "Move all visible app strings into localization files." - "Prepare Arabic App Store copy for this app." diff --git a/apple-arabic-localization/SKILL.md b/apple-arabic-localization/SKILL.md index d58b69d..2ddd262 100644 --- a/apple-arabic-localization/SKILL.md +++ b/apple-arabic-localization/SKILL.md @@ -14,10 +14,12 @@ This skill is for Xcode-based Apple apps only: SwiftUI, UIKit, app extensions, a ## Available Resources - `scripts/audit-localization.sh ` — run first to find hard-coded locales, raw strings, notification copy, custom formatters, and RTL/LTR direction hotspots. Produces a structured report. +- `scripts/check-localization-parity.py ` — run after resource changes to compare English and Arabic `.strings`, `.stringsdict`, and `.xcstrings` keys, placeholders, empty values, untranslated values, and bidi risks. - `references/apple-localization-checklist.md` — end-to-end implementation checklist - `references/rtl-review.md` — Arabic/LTR visual audit, prioritization, and verification flow - `references/apple-rtl-principles.md` — Apple-authored RTL rules and what should or should not mirror - `references/copy-guidelines.md` — Arabic/English product copy and store copy rules +- `references/parity-checker-examples.md` — examples of parity checker findings and how to interpret them ## When To Use @@ -44,13 +46,16 @@ Do not use this skill for Android-only or web-only localization work. 4. **Extract visible strings.** Move production-facing strings into the existing localization system (usually `Localizable.strings` or `.xcstrings`). Include views, reducers, notifications, seeded copy, errors, settings rows, and paywall/store messaging. -5. **Wire the app root correctly.** +5. **Check localization resource parity.** + Run `scripts/check-localization-parity.py ` after changing localization resources. Fix missing keys and placeholder mismatches before visual review. Review warnings for untranslated Arabic, Arabic text in English resources, and Arabic strings that start with placeholders. + +6. **Wire the app root correctly.** Inject the active `Locale`, `Calendar`, and `layoutDirection` from the chosen language source. The root must react immediately to in-app language switches without requiring relaunch. -6. **Refresh derived localized data on language change.** +7. **Refresh derived localized data on language change.** Anything computed before the switch may need reload. Common misses: chart labels, seeded schedules, reminder text, cached summaries, tab labels created before the language changed. -7. **Classify findings before fixing.** +8. **Classify findings before fixing.** Split issues into: - shared primitives and design-system components - navigation and row affordances @@ -59,7 +64,7 @@ Do not use this skill for Android-only or web-only localization work. - test coverage gaps Fix shared primitives first. Do not patch the same alignment bug independently across five screens if one shared row or field component is responsible. -8. **Verify with focused tests and visual checks.** +9. **Verify with focused tests and visual checks.** Add tests for formatting helpers, persistence, reducer actions, and reload behavior. Expand snapshot or visual coverage for both locales on the highest-traffic screens. Do final passes in both locales for navigation, sheets, forms, lists, tabs, progress bars, swipe actions, and dates/numbers. ## Gotchas diff --git a/apple-arabic-localization/references/apple-localization-checklist.md b/apple-arabic-localization/references/apple-localization-checklist.md index b2df853..4b758fb 100644 --- a/apple-arabic-localization/references/apple-localization-checklist.md +++ b/apple-arabic-localization/references/apple-localization-checklist.md @@ -16,6 +16,16 @@ Run `scripts/audit-localization.sh ` first. It searches for: Review the audit output before proceeding. Then manually check for anything the script misses: reducer-generated text, cached summaries, and tab labels built at startup. +Run `scripts/check-localization-parity.py ` after editing localization resources. It checks English/Arabic resource parity for: + +- missing keys between English and Arabic +- placeholder mismatches like `%@`, `%d`, `%f`, and `{name}` +- empty Arabic values +- Arabic values that still match English +- English-looking Arabic values +- Arabic text accidentally present in English resources +- Arabic strings that start with placeholders and may need bidi isolation marks + ## 2. Source Of Truth Create or reuse one language model that can answer: diff --git a/apple-arabic-localization/references/parity-checker-examples.md b/apple-arabic-localization/references/parity-checker-examples.md new file mode 100644 index 0000000..495e2b1 --- /dev/null +++ b/apple-arabic-localization/references/parity-checker-examples.md @@ -0,0 +1,83 @@ +# Localization Parity Checker Examples + +Use `scripts/check-localization-parity.py ` after adding or changing Arabic and English localization resources. + +## Missing Arabic Key + +English: + +```text +"settings.title" = "Settings"; +``` + +Arabic: + +```text +// key is missing +``` + +Expected finding: + +```text +[error] en.lproj/Localizable.strings :: settings.title + Missing ar key +``` + +## Placeholder Mismatch + +English: + +```text +"welcome.user" = "Welcome, %@"; +``` + +Arabic: + +```text +"welcome.user" = "مرحباً"; +``` + +Expected finding: + +```text +[error] en.lproj/Localizable.strings :: welcome.user + Placeholder mismatch: ['%@'] vs [] +``` + +## Untranslated Arabic Value + +English: + +```text +"paywall.restore" = "Restore Purchases"; +``` + +Arabic: + +```text +"paywall.restore" = "Restore Purchases"; +``` + +Expected finding: + +```text +[warning] en.lproj/Localizable.strings :: paywall.restore + ar value matches en +``` + +## Bidirectional Text Risk + +Arabic strings that start with placeholders can inherit the placeholder direction. + +Arabic: + +```text +"activity.like" = "%@ أعجب بمنشورك"; +``` + +Expected finding: + +```text +[warning] en.lproj/Localizable.strings :: activity.like + Arabic value starts with a placeholder; review bidi isolation marks +``` diff --git a/apple-arabic-localization/scripts/check-localization-parity.py b/apple-arabic-localization/scripts/check-localization-parity.py new file mode 100755 index 0000000..052d91f --- /dev/null +++ b/apple-arabic-localization/scripts/check-localization-parity.py @@ -0,0 +1,295 @@ +#!/usr/bin/env python3 +"""Check Arabic/English localization resources for structural parity.""" + +from __future__ import annotations + +import argparse +import json +import plistlib +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + + +ARABIC_RE = re.compile(r"[\u0600-\u06ff]") +LATIN_RE = re.compile(r"[A-Za-z]") +PLACEHOLDER_RE = re.compile( + r"%(?:\d+\$)?(?:[-+#0 ]*)?(?:\d+|\*)?(?:\.(?:\d+|\*))?[hlLzjtq]?[A-Za-z@]" + r"|\{[A-Za-z_][A-Za-z0-9_]*\}" + r"|%\([A-Za-z_][A-Za-z0-9_]*\)[A-Za-z@]" +) + + +@dataclass(frozen=True) +class Entry: + source: str + key: str + value: str + + +@dataclass(frozen=True) +class Finding: + severity: str + source: str + key: str + message: str + + +def decode_quoted(value: str) -> str: + if "\\" not in value: + return value + try: + return bytes(value, "utf-8").decode("unicode_escape") + except UnicodeDecodeError: + return value + + +def parse_strings_file(path: Path) -> dict[str, Entry]: + text = path.read_text(encoding="utf-8-sig", errors="replace") + pattern = re.compile( + r'"(?P(?:\\.|[^"\\])*)"\s*=\s*"(?P(?:\\.|[^"\\])*)"\s*;', + re.MULTILINE, + ) + entries: dict[str, Entry] = {} + for match in pattern.finditer(text): + key = decode_quoted(match.group("key")) + value = decode_quoted(match.group("value")) + entries[key] = Entry(str(path), key, value) + return entries + + +def parse_stringsdict_file(path: Path) -> dict[str, Entry]: + try: + data = plistlib.loads(path.read_bytes()) + except Exception as error: # Keep script dependency-free and report parse failures. + return { + "__parse_error__": Entry( + str(path), + "__parse_error__", + f"Could not parse stringsdict: {error}", + ) + } + + entries: dict[str, Entry] = {} + if isinstance(data, dict): + for key, value in data.items(): + entries[str(key)] = Entry(str(path), str(key), json.dumps(value, sort_keys=True)) + return entries + + +def parse_xcstrings_file(path: Path, locale: str) -> dict[str, Entry]: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as error: + return { + "__parse_error__": Entry( + str(path), + "__parse_error__", + f"Could not parse xcstrings: {error}", + ) + } + + strings = data.get("strings", {}) + entries: dict[str, Entry] = {} + if not isinstance(strings, dict): + return entries + + for key, payload in strings.items(): + if not isinstance(payload, dict): + continue + localizations = payload.get("localizations", {}) + if not isinstance(localizations, dict) or locale not in localizations: + continue + localized = localizations.get(locale, {}) + value = "" + if isinstance(localized, dict): + unit = localized.get("stringUnit", {}) + if isinstance(unit, dict): + value = str(unit.get("value", "")) + entries[str(key)] = Entry(f"{path}#{locale}", str(key), value) + return entries + + +def collect_lproj_resources(root: Path, locale: str) -> dict[str, dict[str, Entry]]: + resources: dict[str, dict[str, Entry]] = {} + for lproj in root.rglob(f"{locale}.lproj"): + if not lproj.is_dir() or ".git" in lproj.parts: + continue + parent = lproj.parent + relative_parent = parent.relative_to(root) + for path in lproj.iterdir(): + if str(relative_parent) == ".": + resource_name = path.name + else: + resource_name = str(relative_parent / path.name) + if path.suffix == ".strings": + resources[resource_name] = parse_strings_file(path) + elif path.suffix == ".stringsdict": + resources[resource_name] = parse_stringsdict_file(path) + return resources + + +def collect_xcstrings_resources(root: Path, locale: str) -> dict[str, dict[str, Entry]]: + resources: dict[str, dict[str, Entry]] = {} + for path in root.rglob("*.xcstrings"): + if ".git" in path.parts: + continue + resources[str(path.relative_to(root))] = parse_xcstrings_file(path, locale) + return resources + + +def placeholders(value: str) -> list[str]: + return sorted(PLACEHOLDER_RE.findall(value)) + + +def starts_with_placeholder(value: str) -> bool: + stripped = value.strip() + return bool(stripped and PLACEHOLDER_RE.match(stripped)) + + +def comparable_value(value: str) -> str: + return re.sub(r"\s+", " ", value.strip()).casefold() + + +def compare_entries( + source: str, + base_entries: dict[str, Entry], + target_entries: dict[str, Entry], + base_locale: str, + target_locale: str, +) -> list[Finding]: + findings: list[Finding] = [] + + for key in sorted(set(base_entries) - set(target_entries)): + findings.append(Finding("error", source, key, f"Missing {target_locale} key")) + + for key in sorted(set(target_entries) - set(base_entries)): + findings.append(Finding("warning", source, key, f"Extra {target_locale} key")) + + for key in sorted(set(base_entries) & set(target_entries)): + base_value = base_entries[key].value + target_value = target_entries[key].value + + if key == "__parse_error__": + findings.append(Finding("error", source, key, target_value or base_value)) + continue + + if not target_value.strip(): + findings.append(Finding("error", source, key, f"Empty {target_locale} value")) + + if target_value.strip() and comparable_value(base_value) == comparable_value(target_value): + findings.append( + Finding("warning", source, key, f"{target_locale} value matches {base_locale}") + ) + + base_placeholders = placeholders(base_value) + target_placeholders = placeholders(target_value) + if base_placeholders != target_placeholders: + findings.append( + Finding( + "error", + source, + key, + f"Placeholder mismatch: {base_placeholders} vs {target_placeholders}", + ) + ) + + if target_value.strip() and LATIN_RE.search(target_value) and not ARABIC_RE.search(target_value): + findings.append( + Finding("warning", source, key, f"{target_locale} value appears to be English") + ) + + if ARABIC_RE.search(base_value): + findings.append( + Finding("warning", source, key, f"{base_locale} value contains Arabic text") + ) + + if ARABIC_RE.search(target_value) and starts_with_placeholder(target_value): + findings.append( + Finding( + "warning", + source, + key, + "Arabic value starts with a placeholder; review bidi isolation marks", + ) + ) + + return findings + + +def compare_resources( + base_resources: dict[str, dict[str, Entry]], + target_resources: dict[str, dict[str, Entry]], + base_locale: str, + target_locale: str, +) -> list[Finding]: + findings: list[Finding] = [] + for source in sorted(set(base_resources) - set(target_resources)): + findings.append(Finding("error", source, "*", f"Missing {target_locale} resource file")) + + for source in sorted(set(base_resources) & set(target_resources)): + findings.extend( + compare_entries( + source, + base_resources[source], + target_resources[source], + base_locale, + target_locale, + ) + ) + + return findings + + +def print_findings(findings: Iterable[Finding]) -> int: + count = 0 + for finding in findings: + count += 1 + print(f"[{finding.severity}] {finding.source} :: {finding.key}") + print(f" {finding.message}") + return count + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Check Arabic/English .strings, .stringsdict, and .xcstrings parity." + ) + parser.add_argument("repo", nargs="?", default=".", help="Path to the Xcode project repo") + parser.add_argument("--base", default="en", help="Base locale, usually en") + parser.add_argument("--target", default="ar", help="Target locale, usually ar") + args = parser.parse_args() + + root = Path(args.repo).resolve() + if not root.exists(): + print(f"error: path does not exist: {root}", file=sys.stderr) + return 2 + + base_resources = collect_lproj_resources(root, args.base) + target_resources = collect_lproj_resources(root, args.target) + base_resources.update(collect_xcstrings_resources(root, args.base)) + target_resources.update(collect_xcstrings_resources(root, args.target)) + + findings = compare_resources(base_resources, target_resources, args.base, args.target) + + print(f"=== Localization Parity: {root} ({args.base} -> {args.target}) ===") + print(f"Resources checked: {len(base_resources)} base, {len(target_resources)} target") + print("") + + if not base_resources and not target_resources: + print("No localization resources found.") + return 0 + + if not findings: + print("No parity issues found.") + return 0 + + issue_count = print_findings(findings) + print("") + print(f"=== {issue_count} issue(s) found ===") + return 1 if any(finding.severity == "error" for finding in findings) else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 38c4766fbfb59256c8655ba090a4ecdcbca3b1ea Mon Sep 17 00:00:00 2001 From: Mohammed Elnaggar Date: Mon, 15 Jun 2026 13:26:40 +0300 Subject: [PATCH 2/3] fix: handle xcstrings variations and parse errors --- .../scripts/check-localization-parity.py | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/apple-arabic-localization/scripts/check-localization-parity.py b/apple-arabic-localization/scripts/check-localization-parity.py index 052d91f..df09fe8 100755 --- a/apple-arabic-localization/scripts/check-localization-parity.py +++ b/apple-arabic-localization/scripts/check-localization-parity.py @@ -79,6 +79,21 @@ def parse_stringsdict_file(path: Path) -> dict[str, Entry]: return entries +def xcstrings_values(payload: object) -> list[str]: + values: list[str] = [] + if isinstance(payload, dict): + unit = payload.get("stringUnit") + if isinstance(unit, dict) and "value" in unit: + values.append(str(unit["value"])) + for key, value in payload.items(): + if key != "stringUnit": + values.extend(xcstrings_values(value)) + elif isinstance(payload, list): + for value in payload: + values.extend(xcstrings_values(value)) + return values + + def parse_xcstrings_file(path: Path, locale: str) -> dict[str, Entry]: try: data = json.loads(path.read_text(encoding="utf-8")) @@ -103,11 +118,7 @@ def parse_xcstrings_file(path: Path, locale: str) -> dict[str, Entry]: if not isinstance(localizations, dict) or locale not in localizations: continue localized = localizations.get(locale, {}) - value = "" - if isinstance(localized, dict): - unit = localized.get("stringUnit", {}) - if isinstance(unit, dict): - value = str(unit.get("value", "")) + value = "\n".join(xcstrings_values(localized)) entries[str(key)] = Entry(f"{path}#{locale}", str(key), value) return entries @@ -162,6 +173,19 @@ def compare_entries( ) -> list[Finding]: findings: list[Finding] = [] + parse_errors = [ + entry + for entry in ( + base_entries.get("__parse_error__"), + target_entries.get("__parse_error__"), + ) + if entry is not None + ] + if parse_errors: + for entry in parse_errors: + findings.append(Finding("error", source, entry.key, entry.value)) + return findings + for key in sorted(set(base_entries) - set(target_entries)): findings.append(Finding("error", source, key, f"Missing {target_locale} key")) @@ -172,10 +196,6 @@ def compare_entries( base_value = base_entries[key].value target_value = target_entries[key].value - if key == "__parse_error__": - findings.append(Finding("error", source, key, target_value or base_value)) - continue - if not target_value.strip(): findings.append(Finding("error", source, key, f"Empty {target_locale} value")) From 85c8c29a14f3dde35a7a018188d4f26af19f13f7 Mon Sep 17 00:00:00 2001 From: Mohammed Elnaggar Date: Mon, 15 Jun 2026 16:15:00 +0300 Subject: [PATCH 3/3] fix: handle localization source and plural contracts --- .../scripts/check-localization-parity.py | 65 +++++++++++++++---- 1 file changed, 52 insertions(+), 13 deletions(-) diff --git a/apple-arabic-localization/scripts/check-localization-parity.py b/apple-arabic-localization/scripts/check-localization-parity.py index df09fe8..13ae05e 100755 --- a/apple-arabic-localization/scripts/check-localization-parity.py +++ b/apple-arabic-localization/scripts/check-localization-parity.py @@ -16,10 +16,12 @@ ARABIC_RE = re.compile(r"[\u0600-\u06ff]") LATIN_RE = re.compile(r"[A-Za-z]") PLACEHOLDER_RE = re.compile( - r"%(?:\d+\$)?(?:[-+#0 ]*)?(?:\d+|\*)?(?:\.(?:\d+|\*))?[hlLzjtq]?[A-Za-z@]" + r"%#@[A-Za-z_][A-Za-z0-9_]*@" + r"|%(?:\d+\$)?(?:[-+#0 ]*)?(?:\d+|\*)?(?:\.(?:\d+|\*))?[hlLzjtq]?[A-Za-z@]" r"|\{[A-Za-z_][A-Za-z0-9_]*\}" r"|%\([A-Za-z_][A-Za-z0-9_]*\)[A-Za-z@]" ) +ESCAPE_RE = re.compile(r'\\(?:U(?:[0-9A-Fa-f]{8}|[0-9A-Fa-f]{4})|u[0-9A-Fa-f]{4}|["\\/bfnrt])') @dataclass(frozen=True) @@ -27,6 +29,7 @@ class Entry: source: str key: str value: str + is_text: bool = True @dataclass(frozen=True) @@ -40,10 +43,25 @@ class Finding: def decode_quoted(value: str) -> str: if "\\" not in value: return value - try: - return bytes(value, "utf-8").decode("unicode_escape") - except UnicodeDecodeError: - return value + + escapes = { + '"': '"', + "\\": "\\", + "/": "/", + "b": "\b", + "f": "\f", + "n": "\n", + "r": "\r", + "t": "\t", + } + + def decode_match(match: re.Match[str]) -> str: + escape = match.group(0)[1:] + if escape[0] in {"U", "u"}: + return chr(int(escape[1:], 16)) + return escapes[escape] + + return ESCAPE_RE.sub(decode_match, value) def parse_strings_file(path: Path) -> dict[str, Entry]: @@ -75,7 +93,15 @@ def parse_stringsdict_file(path: Path) -> dict[str, Entry]: entries: dict[str, Entry] = {} if isinstance(data, dict): for key, value in data.items(): - entries[str(key)] = Entry(str(path), str(key), json.dumps(value, sort_keys=True)) + localized_format = ( + value.get("NSStringLocalizedFormatKey", "") if isinstance(value, dict) else "" + ) + contract = ( + localized_format + if isinstance(localized_format, str) + else json.dumps(localized_format, sort_keys=True) + ) + entries[str(key)] = Entry(str(path), str(key), contract, is_text=False) return entries @@ -107,6 +133,7 @@ def parse_xcstrings_file(path: Path, locale: str) -> dict[str, Entry]: } strings = data.get("strings", {}) + source_language = data.get("sourceLanguage") entries: dict[str, Entry] = {} if not isinstance(strings, dict): return entries @@ -115,10 +142,12 @@ def parse_xcstrings_file(path: Path, locale: str) -> dict[str, Entry]: if not isinstance(payload, dict): continue localizations = payload.get("localizations", {}) - if not isinstance(localizations, dict) or locale not in localizations: + if isinstance(localizations, dict) and locale in localizations: + value = "\n".join(xcstrings_values(localizations[locale])) + elif locale == source_language: + value = str(key) + else: continue - localized = localizations.get(locale, {}) - value = "\n".join(xcstrings_values(localized)) entries[str(key)] = Entry(f"{path}#{locale}", str(key), value) return entries @@ -195,11 +224,16 @@ def compare_entries( for key in sorted(set(base_entries) & set(target_entries)): base_value = base_entries[key].value target_value = target_entries[key].value + is_text = base_entries[key].is_text and target_entries[key].is_text if not target_value.strip(): findings.append(Finding("error", source, key, f"Empty {target_locale} value")) - if target_value.strip() and comparable_value(base_value) == comparable_value(target_value): + if ( + is_text + and target_value.strip() + and comparable_value(base_value) == comparable_value(target_value) + ): findings.append( Finding("warning", source, key, f"{target_locale} value matches {base_locale}") ) @@ -216,17 +250,22 @@ def compare_entries( ) ) - if target_value.strip() and LATIN_RE.search(target_value) and not ARABIC_RE.search(target_value): + if ( + is_text + and target_value.strip() + and LATIN_RE.search(target_value) + and not ARABIC_RE.search(target_value) + ): findings.append( Finding("warning", source, key, f"{target_locale} value appears to be English") ) - if ARABIC_RE.search(base_value): + if is_text and ARABIC_RE.search(base_value): findings.append( Finding("warning", source, key, f"{base_locale} value contains Arabic text") ) - if ARABIC_RE.search(target_value) and starts_with_placeholder(target_value): + if is_text and ARABIC_RE.search(target_value) and starts_with_placeholder(target_value): findings.append( Finding( "warning",