Files
Graeme ArthurandClaude Opus 5 498fdd9f77 Add scripts/localize catalog sync
Xcode updates the String Catalogs after an IDE build, but xcodebuild
only emits the compiler's .stringsdata. scripts/localize sync builds
the app and runs `xcrun xcstringstool sync` over that data so the
catalogs can be kept current (and checked in CI) from the command line.
verify keeps catalogs in UI targets and rejects empty translations,
which render blank rather than falling back to English; status
summarises per-language progress.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 22:03:22 +10:00

189 lines
8.0 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# localize — keep the String Catalogs in step with the code using Xcode's own tools.
#
# scripts/localize sync [--check] [--skip-build] [--derived-data DIR]
# scripts/localize verify
# scripts/localize status [--markdown]
#
# `sync` builds the app so the compiler emits .stringsdata, then runs `xcstringstool sync` on
# each catalog exactly as the Xcode IDE does after a build. `--check` reports drift instead of
# writing. `--skip-build` reuses a build already in DIR (default: DerivedData).
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
APP_CATALOG="Homebrew/Localizable.xcstrings"
CATALOGS=("$APP_CATALOG" Sources/*/Resources/Localizable.xcstrings)
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
target_for() {
case "$1" in
"$APP_CATALOG") echo "Homebrew" ;;
Sources/*) echo "$1" | cut -d/ -f2 ;;
*) echo "$1 is not a recognised catalog location" >&2; return 1 ;;
esac
}
languages_in() {
jq -r '.sourceLanguage as $src
| [.strings[].localizations // {} | keys[]] | unique[] | select(. != $src)' "$1"
}
is_ui_layer() {
case "$1" in
"$APP_CATALOG" | Sources/BrewUIComponents/Resources/Localizable.xcstrings | Sources/BrewFeature*/Resources/Localizable.xcstrings) return 0 ;;
*) return 1 ;;
esac
}
build() {
echo "==> Building Brew so the compiler emits string data (DerivedData: $1)"
xcodebuild build -quiet \
-project Homebrew.xcodeproj -scheme Brew -derivedDataPath "$1" -destination "platform=macOS" \
-skipPackagePluginValidation -skipMacroValidation \
CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO
}
sync() {
local check=0 skip_build=0 derived_data="DerivedData" failed=0
while [[ $# -gt 0 ]]; do
case "$1" in
--check) check=1 ;;
--skip-build) skip_build=1 ;;
--derived-data) derived_data="$2"; shift ;;
*) echo "unknown option: $1" >&2; exit 64 ;;
esac
shift
done
[[ $skip_build -eq 1 ]] || build "$derived_data"
for catalog in "${CATALOGS[@]}"; do
local target stringsdata=() output="$catalog"
target="$(target_for "$catalog")"
while IFS= read -r file; do stringsdata+=("$file"); done < <(
find "$derived_data/Build/Intermediates.noindex" -path "*/${target}.build/Objects-normal/*" -name '*.stringsdata' 2>/dev/null | sort
)
if [[ ${#stringsdata[@]} -eq 0 ]]; then
echo "error: no .stringsdata for $target under $derived_data — build first or drop --skip-build" >&2
exit 1
fi
if [[ $check -eq 1 ]]; then
output="$TMP/$catalog"
mkdir -p "$(dirname "$output")"
cp "$catalog" "$output"
fi
# Notices about keys reused with different comments are informational.
xcrun xcstringstool sync "$output" --stringsdata "${stringsdata[@]}" 2>&1 | { grep -v "^notice:" || true; }
if [[ $check -eq 1 ]] && ! diff -u "$catalog" "$output" --label "$catalog" --label "$catalog (synced)"; then
failed=1
fi
done
if [[ $failed -eq 1 ]]; then
echo "" >&2
echo "String catalogs are out of date. Run scripts/localize sync (or build in Xcode) and commit the .xcstrings changes." >&2
exit 1
fi
[[ $check -eq 1 ]] && echo "String catalogs are in sync." || echo "String catalogs updated."
}
# An empty translation compiles to an empty string and renders blank, not as English; plurals
# compile to .stringsdict rather than .strings.
verify() {
local failed=0
command -v jq >/dev/null || { echo "verify needs jq (brew install jq)" >&2; exit 1; }
while IFS= read -r catalog; do
if ! is_ui_layer "$catalog"; then
echo "error: $catalog — copy lives only in BrewUIComponents, BrewFeature* and the Homebrew app" >&2
failed=1
fi
done < <(git ls-files '*.xcstrings')
# BrewUILint requires a comment at every call site, so a key without one bypassed the rule.
for catalog in "${CATALOGS[@]}"; do
while IFS= read -r key; do
echo "error: $catalog — \"$key\" has no translator comment; pass comment: at the call site (#Preview sample copy should not reach the catalog)" >&2
failed=1
done < <(jq -r '.strings | to_entries[] | select((.value.comment // "") == "") | .key' "$catalog")
done
local app_languages
app_languages="$(languages_in "$APP_CATALOG")"
for catalog in "${CATALOGS[@]}"; do
[[ "$catalog" == "$APP_CATALOG" ]] && continue
while IFS= read -r lang; do
if ! grep -qxF "$lang" <<<"$app_languages"; then
echo "error: $catalog — $lang has no translation in $APP_CATALOG, so macOS will not offer it in the language picker and these translations never load; translate at least one string there" >&2
failed=1
fi
done < <(languages_in "$catalog")
done
for catalog in "${CATALOGS[@]}"; do
local out="$TMP/compile/$(target_for "$catalog")"
mkdir -p "$out"
xcrun xcstringstool compile "$catalog" --output-directory "$out" >/dev/null
while IFS= read -r compiled; do
local lang
lang="$(basename "$(dirname "$compiled")" .lproj)"
while IFS= read -r key; do
echo "error: $catalog — \"$key\" has an empty $lang translation; remove the entry to fall back to English" >&2
failed=1
done < <(plutil -convert json -o - "$compiled" | jq -r 'to_entries[] | select([.value | .. | strings] | any(. == "")) | .key')
done < <(find "$out" \( -name '*.strings' -o -name '*.stringsdict' \))
done
[[ $failed -eq 0 ]] && echo "String catalogs verified." || exit 1
}
# A key counts as translated when it has a non-empty value, or any plural form.
status() {
command -v jq >/dev/null || { echo "Skipping localization status: jq is not installed (brew install jq)."; return 0; }
local markdown=0
[[ "${1:-}" == "--markdown" ]] && markdown=1
for catalog in "${CATALOGS[@]}"; do
jq -r --arg app "$([[ "$catalog" == "$APP_CATALOG" ]] && echo 1 || echo 0)" '
.sourceLanguage as $src
| [.strings | to_entries[] | select((.value.extractionState // "") != "stale" and (.value.shouldTranslate // true))] as $keys
| ([$keys[].value.localizations // {} | keys[]] | unique | map(select(. != $src))) as $langs
| "TOTAL\t\($keys | length)",
($langs[] as $lang
| [$keys[] | .value.localizations[$lang] // empty | select((.stringUnit.value // "") != "" or .variations != null)]
| "\($lang)\t\(length)\t\($app)")
' "$catalog"
done | awk -F'\t' -v markdown="$markdown" '
$1 == "TOTAL" { total += $2; next }
{ done[$1] += $2; if ($3 == 1) inApp[$1] = 1 }
END {
# awk iterates an associative array in unspecified order; sort for a stable table.
n = 0
for (lang in done) langs[++n] = lang
for (i = 2; i <= n; i++) {
key = langs[i]
for (j = i - 1; j >= 1 && langs[j] > key; j--) langs[j + 1] = langs[j]
langs[j + 1] = key
}
if (markdown) { print "| Language | Translated | Untranslated |"; print "|---|---|---|" }
for (i = 1; i <= n; i++) {
lang = langs[i]
if (markdown) printf "| %s | %d | %d |\n", lang, done[lang], total - done[lang]
else printf "%-8s %5d translated %5d untranslated\n", lang, done[lang], total - done[lang]
}
for (i = 1; i <= n; i++) if (!(langs[i] in inApp))
printf "\n⚠️ %s has no translation in Homebrew/Localizable.xcstrings, so macOS will not offer it in the language picker.\n", langs[i]
}
'
}
case "${1:-}" in
sync) shift; sync "$@" ;;
verify) verify ;;
status) shift; status "$@" ;;
*) sed -n '3,11p' "$0" | sed 's/^# \{0,1\}//'; exit 64 ;;
esac