Summary
Add a reusable placeholder-shape primitive plus an action that fails when an existing localization key's source-language value changes its format placeholders — count, position, or argument type — between two versions of the source strings. Such a change silently breaks every existing translation for that key.
This is a different axis from ios_lint_localizations, which compares each translation against the base language at a single point in time. This compares base ↔ base across versions (temporal).
Filed for the WordPress-iOS continuous-localization work ("Faster Releases" RFC). Reference implementation: wordpress-mobile/WordPress-iOS#25675 — fastlane/helpers/string_placeholders.rb + the validate_string_placeholders lane. WPiOS will adopt the toolkit version once it lands and delete the project-side copy.
Motivation
The continuous-localization model uploads regenerated source strings on every trunk merge and downloads whatever human translations exist daily — so whatever translations exist ship as-is. That is only safe if a translation key is never reused for placeholder-incompatible copy:
- GlotPress (and most TMS) key translations by the source string's key, not its current English text.
- If a key's English changes from
"%1$@ liked your post" to "%1$d people liked your post" (object → int), existing translations stay under the same key, and the app formats the new call site's Int argument with a translation that still expects an object → wrong output or a crash.
Today this invariant is convention-only. We need an automated guardrail in CI.
The invariant
For every key present in both the previous and the newly-generated source-language strings, the set/sequence of format specifiers — accounting for positional args and argument type — must be identical. New and removed keys are ignored: copy that needs a fresh translation is expected to land under a new key (which shows up as remove-old + add-new).
Proposed API
1. Reusable primitive (the important part — also consumed by the companion AI-translation backfill issue)
A platform-agnostic helper operating on raw strings / { key => value } hashes:
placeholder_signature(value) -> String — canonical signature of the placeholders in a string ('' if none).
placeholders_compatible?(a, b) -> Boolean — signature(a) == signature(b).
incompatible_placeholder_changes(old_hash, new_hash) -> Array<Hash> — keys present in both whose signature differs, each { key:, old:, new:, old_signature:, new_signature: }.
2. Action
A Fastlane action that fails (or reports) on incompatible changes between two versions of a base strings file. Suggested options (mirroring ios_lint_localizations):
| Option |
Description |
old_file / new_file |
Paths to the previous and newly-generated base strings. |
abort_on_violations |
Abort the lane on any violation (default true). |
allow_retry |
Interactive retry prompt (parity with ios_lint_localizations). |
The signature algorithm (please implement exactly)
The signature must be invariant to benign copy edits and to reordering equivalent positional args, but sensitive to count/position/type changes.
- Extract format specifiers with a printf/NSString regex:
%, optional positional N$, flags [-+ 0#], width, .precision, length modifier (hh|h|ll|l|q|L|z|t|j), conversion char [@diouxXeEfgGaAcCsSpn%].
- Skip
%% (literal percent — not a placeholder).
- Map the conversion char to an argument-type CLASS (so
%d↔%i are compatible but %d↔%@ are not):
@ → object
d i u o x X → int
e E f g G a A → float
c C → char
s S → cstring
p → pointer
- Key each specifier by position: explicit (
%1$@ → 1) or, for non-positional specifiers, by appearance order (1, 2, 3…). Sort by position, render each as position:class, join with ,. Empty string if there are no specifiers.
Worked examples
| Value |
Signature |
Just text |
(empty) |
Hello %@ |
1:object |
%1$d items in %2$@ |
1:int,2:object |
%2$@ told by %1$@ |
1:object,2:object |
100%% sure about %@ |
1:object |
%d likes |
1:int |
Compatibility (same signature ⇒ compatible):
| Old |
New |
Compatible? |
Why |
Hello %@ |
Hi %@ |
✅ |
text-only change |
%1$@ said %2$@ |
%2$@ told by %1$@ |
✅ |
positional reorder, same types |
%d likes |
%@ likes |
❌ |
int → object |
%1$d in %2$@ |
%1$d only |
❌ |
dropped a placeholder |
Parsing the strings files
iOS .strings are old-style property lists. The WPiOS reference parses them with plutil -convert json (most robust — handles escapes, comments, encodings). Important for a cross-platform toolkit: plutil is macOS-only, but the toolkit's Android actions run on Linux. Keep the signature primitive pure-Ruby (portable); gate the .strings parser behind macOS, and provide a pure-Ruby .strings parser (or reuse Fastlane::Helper::Ios::L10nHelper) for portability. Android (strings.xml) needs its own parser.
Cross-platform considerations
printf placeholders are shared by iOS (%@, %1$d) and Android (%1$s, %d), so the signature primitive is platform-agnostic. Recommend the toolkit's usual split:
common/ — the signature primitive + the diff.
ios_* / android_* wrappers — parse the platform's file format into { key => value } and call the common diff.
Relationship to existing toolkit actions
ios_lint_localizations / L10nLinterHelper — cross-locale (base ↔ translations), single point in time, SwiftGen-based. Complementary; this is the temporal axis. (The lightweight primitive here could later also back a SwiftGen-free re-implementation of that linter — out of scope.)
an_validate_lib_strings — related Android validation.
Edge cases to cover
- Positional reorder of same-typed args → compatible.
%% literal → not a placeholder.
- Mixed positional + non-positional in one string (rare/invalid) → be conservative/deterministic.
- New / removed keys → ignored.
- Duplicate keys → defer to the existing duplicate-key check (
StringsFileValidationHelper).
- Empty / whitespace-only values; non-ASCII; escaped quotes/backslashes.
- Width / precision / length modifiers → ignored for type-classing.
.stringsdict / ICU plurals → open question (different structure; likely v2).
Obtaining the previous version
The action compares old vs new base strings. WPiOS does: regenerate strings (generate_strings_file_for_glotpress skip_commit:true), snapshot the committed version via git show HEAD:<path> to a temp file, then diff. Recommend the action take two paths (pure), with an optional helper for the git-ref case.
Testing
- RSpec covering every example/edge case above (port the WPiOS fixtures).
- Pure-Ruby signature tests run anywhere; gate file-parsing tests by platform / use a pure-Ruby parser so Android/Linux CI passes.
Acceptance criteria
Open questions
- Naming (
lint_localization_placeholder_changes? check_translations_placeholder_consistency?).
- Strictness of type classes (
%d↔%i compatible — yes; revisit unsigned/hex).
.stringsdict / Android plurals / ICU MessageFormat support.
- Portable
.strings parser vs plutil (macOS-only) — required for cross-platform.
Summary
Add a reusable placeholder-shape primitive plus an action that fails when an existing localization key's source-language value changes its format placeholders — count, position, or argument type — between two versions of the source strings. Such a change silently breaks every existing translation for that key.
This is a different axis from
ios_lint_localizations, which compares each translation against the base language at a single point in time. This compares base ↔ base across versions (temporal).Motivation
The continuous-localization model uploads regenerated source strings on every trunk merge and downloads whatever human translations exist daily — so whatever translations exist ship as-is. That is only safe if a translation key is never reused for placeholder-incompatible copy:
"%1$@ liked your post"to"%1$d people liked your post"(object → int), existing translations stay under the same key, and the app formats the new call site'sIntargument with a translation that still expects an object → wrong output or a crash.Today this invariant is convention-only. We need an automated guardrail in CI.
The invariant
For every key present in both the previous and the newly-generated source-language strings, the set/sequence of format specifiers — accounting for positional args and argument type — must be identical. New and removed keys are ignored: copy that needs a fresh translation is expected to land under a new key (which shows up as remove-old + add-new).
Proposed API
1. Reusable primitive (the important part — also consumed by the companion AI-translation backfill issue)
A platform-agnostic helper operating on raw strings /
{ key => value }hashes:placeholder_signature(value) -> String— canonical signature of the placeholders in a string (''if none).placeholders_compatible?(a, b) -> Boolean—signature(a) == signature(b).incompatible_placeholder_changes(old_hash, new_hash) -> Array<Hash>— keys present in both whose signature differs, each{ key:, old:, new:, old_signature:, new_signature: }.2. Action
A Fastlane action that fails (or reports) on incompatible changes between two versions of a base strings file. Suggested options (mirroring
ios_lint_localizations):old_file/new_fileabort_on_violationstrue).allow_retryios_lint_localizations).The signature algorithm (please implement exactly)
The signature must be invariant to benign copy edits and to reordering equivalent positional args, but sensitive to count/position/type changes.
%, optional positionalN$, flags[-+ 0#], width,.precision, length modifier (hh|h|ll|l|q|L|z|t|j), conversion char[@diouxXeEfgGaAcCsSpn%].%%(literal percent — not a placeholder).%d↔%iare compatible but%d↔%@are not):@→objectd i u o x X→inte E f g G a A→floatc C→chars S→cstringp→pointer%1$@→ 1) or, for non-positional specifiers, by appearance order (1, 2, 3…). Sort by position, render each asposition:class, join with,. Empty string if there are no specifiers.Worked examples
Just textHello %@1:object%1$d items in %2$@1:int,2:object%2$@ told by %1$@1:object,2:object100%% sure about %@1:object%d likes1:intCompatibility (same signature ⇒ compatible):
Hello %@Hi %@%1$@ said %2$@%2$@ told by %1$@%d likes%@ likes%1$d in %2$@%1$d onlyParsing the strings files
iOS
.stringsare old-style property lists. The WPiOS reference parses them withplutil -convert json(most robust — handles escapes, comments, encodings). Important for a cross-platform toolkit:plutilis macOS-only, but the toolkit's Android actions run on Linux. Keep the signature primitive pure-Ruby (portable); gate the.stringsparser behind macOS, and provide a pure-Ruby.stringsparser (or reuseFastlane::Helper::Ios::L10nHelper) for portability. Android (strings.xml) needs its own parser.Cross-platform considerations
printf placeholders are shared by iOS (
%@,%1$d) and Android (%1$s,%d), so the signature primitive is platform-agnostic. Recommend the toolkit's usual split:common/— the signature primitive + the diff.ios_*/android_*wrappers — parse the platform's file format into{ key => value }and call the common diff.Relationship to existing toolkit actions
ios_lint_localizations/L10nLinterHelper— cross-locale (base ↔ translations), single point in time, SwiftGen-based. Complementary; this is the temporal axis. (The lightweight primitive here could later also back a SwiftGen-free re-implementation of that linter — out of scope.)an_validate_lib_strings— related Android validation.Edge cases to cover
%%literal → not a placeholder.StringsFileValidationHelper)..stringsdict/ ICU plurals → open question (different structure; likely v2).Obtaining the previous version
The action compares old vs new base strings. WPiOS does: regenerate strings (
generate_strings_file_for_glotpress skip_commit:true), snapshot the committed version viagit show HEAD:<path>to a temp file, then diff. Recommend the action take two paths (pure), with an optional helper for the git-ref case.Testing
Acceptance criteria
common/signature primitive (placeholder_signature,placeholders_compatible?,incompatible_placeholder_changes).abort_on_violations/allow_retry.actions/README.mdentry.Open questions
lint_localization_placeholder_changes?check_translations_placeholder_consistency?).%d↔%icompatible — yes; revisit unsigned/hex)..stringsdict/ Android plurals / ICU MessageFormat support..stringsparser vsplutil(macOS-only) — required for cross-platform.