wordpress-mobile / wordpress-mobile/release-toolkit
New action: localization placeholder-compatibility check (catch placeholder-breaking changes to existing keys)
Nobody has claimed this yet.
- Dominant language
- Ruby
- Stars
- 31
- Forks
- 10
- Avg merge
- 5h 45m
- Merged PRs (30d)
- 2
Description
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+ thevalidate_string_placeholderslane. 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'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):
| 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 positionalN$, 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↔%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
- Key each specifier by position: explicit (
%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
| 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
-
common/signature primitive (placeholder_signature,placeholders_compatible?,incompatible_placeholder_changes). - Action that fails/reports on incompatible base↔base changes, with
abort_on_violations/allow_retry. - iOS file-format wrapper; Android wrapper (or a follow-up issue).
- RSpec, CHANGELOG entry,
actions/README.mdentry. - Signature output matches the worked examples exactly.
Open questions
- Naming (
lint_localization_placeholder_changes?check_translations_placeholder_consistency?). - Strictness of type classes (
%d↔%icompatible — yes; revisit unsigned/hex). .stringsdict/ Android plurals / ICU MessageFormat support.- Portable
.stringsparser vsplutil(macOS-only) — required for cross-platform.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reviewing the existing ios_lint_localizations, L10nLinterHelper, an_validate_lib_strings, and StringsFileValidationHelper implementations, then inspect the common/ and ios_/android_ action structure. Add the pure-Ruby signature and diff primitives, platform wrappers, RSpec coverage for the listed examples and edge cases, and the requested documentation and CHANGELOG updates. Done means the acceptance criteria pass and signature output matches the worked examples.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- android, ios, ruby
- Domain
- localization, release, tooling
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100