mirror of
https://github.com/Homebrew/BrewUI.git
synced 2026-10-17 05:09:29 +08:00
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>
189 lines
8.0 KiB
Bash
Executable File
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
|