Skip to content

Localization foundations, with Brazilian Portuguese as the first translation - #154

Open
jacksonfdam wants to merge 23 commits into
Homebrew:mainfrom
jacksonfdam:feat/localization-ptbr
Open

Localization foundations, with Brazilian Portuguese as the first translation#154
jacksonfdam wants to merge 23 commits into
Homebrew:mainfrom
jacksonfdam:feat/localization-ptbr

Conversation

@jacksonfdam

Copy link
Copy Markdown

PR: Localization foundations, with Brazilian Portuguese as the first translation

Summary

BrewUI ships English only today — no .lproj, no String Catalogs, no defaultLocalization. This is a proposal, not a finished feature: it adds localization infrastructure any language can plug into, and translates two modules into Brazilian Portuguese to prove the infrastructure fits real code rather than a toy example.

I opened it as a PR rather than an issue because the mechanism is easier to argue about with the code in front of you. If the project would rather settle policy first, I am happy to close this and move the discussion to an issue — see Questions for maintainers below.

Coverage is deliberately partial, and the PR says so plainly: BrewUIComponents and BrewFeatureDoctor are translated. Installed, Upgrades, Discover, Console, Config and the app shell are not, and error copy is English everywhere. A user selecting Portuguese today gets the Doctor tab in Portuguese and the rest in English.

Changes

Infrastructure

  • defaultLocalization: "en" in Package.swift; each localized target declares Resources/Localizable.xcstrings as a processed resource.
  • Strings that cross a boundary — view model to view, module to module — are LocalizedStringResource rather than String, so they resolve at display time against the bundle that owns them. LastUpdatedLabel(lead:) is the case that forces this: the lead phrase belongs to the calling module's catalogue.
  • Each localized module has an internal LocalizedStringResource.init(<module>:) supplying Bundle.module. LocalizedStringResource(_:) and String(localized:) default to Bundle.main, which in a SwiftPM module is the app — and resolving against the wrong bundle does not throw, it returns the key, so the string silently stays English.
  • knownRegions gains pt-BR; Homebrew/{en,pt-BR}.lproj/InfoPlist.strings make the app bundle itself advertise both languages, which is what macOS reads for the per-app entry in System Settings › General › Language & Region.

Translation

  • BrewUIComponents (13 keys) and BrewFeatureDoctor (24 keys), both en + pt-BR, every entry carrying a translator-facing comment.
  • RelativeTimeText no longer builds "1 minute ago" / "5 minutes ago" with a count == 1 ternary. The count is interpolated so the catalogue key is %lld minutes ago and per-language plural variations decide the form — plural rules differ by language, so a Swift-side ternary can only ever be right for one.

Not translated, on purpose

  • DoctorCopy.warningPreamble — a verbatim echo of brew doctor's own output. NoteCallout gained an explicit verbatim: route so this cannot be mistaken for localizable copy.
  • Copyable command text, FORMULA/CASK badges, SF Symbol names, AXID values. Each carries a comment saying why.

Tests

  • StringCatalogueCompletenessTests reads every .xcstrings as JSON and fails if a catalogue is internally inconsistent — if it declares a language for one key it must declare it for every key. A catalogue that has not been translated at all passes. See Questions for maintainers.
  • Tests/ suites assert catalogue keys and bundle bindings; BrewTests/LocalizationResolutionTests asserts resolved prose. That split is forced by the toolchain — see Why this split.
  • BrewUITests/Screens/DoctorScreen.swift matched the healthy state by its English copy, which breaks the moment the app runs in another language. It now resolves through BrewUIElement and AXID.doctorHealthyState, which CONVENTIONS.md already asked for ("never write a raw identifier string in a view or a test").

Docs — a Localization section in CONVENTIONS.md, a dated entry in .ai/memory.md, and the catalogue location in ARCHITECTURE.md.

Why this split

Two modules, not all seven. BrewFeatureDoctor cannot be localized alone: it renders copy owned by BrewUIComponents (the command block header, Retry, the relative timestamp), so translating only the feature would leave a half-translated screen. Those two are the smallest coherent unit. Every other module is a mechanical repeat of the pattern this PR establishes.

A toolchain fact worth knowing before reviewing the tests. swift build copies Localizable.xcstrings into the module bundle raw — SwiftPM's native builder runs no Apple resource compiler at all, which is also why Media.xcassets arrives uncompiled and why BrewColorTokenContrastTests already parses that JSON from source rather than resolving NSColor(named:). xcodebuild does compile catalogues, into <lang>.lproj/Localizable.strings. CI runs both legs, and Brew-Unit contains only the BrewTests target, so anything under Tests/ runs exclusively under swift test. A resolved-translation assertion placed there passes locally under Xcode and fails in CI. Hence: keys and bundle bindings under Tests/, resolved prose in BrewTests/.

One Xcode quirk. "Re-checking" (a VoiceOver label) and "Re-checking…" (the header subtitle) derive the same identifier in the GenerateStringSymbols phase, which strips punctuation — a hard build failure, not a warning. Both entries carry "generatesSymbol": false, safe because nothing references the generated symbols.

Testing

  • scripts/test — 961 tests. Two failures, both pre-existing on untouched main: DiscoverListRowViewModelTests and DiscoverPackageDetailViewModelTests assert "12,345" and get "12 345", because installs30DayLabel formats with the system locale and my region uses a space as the thousands separator. Unrelated to this PR — I can send a two-line fix separately if useful.
  • xcodebuild test -scheme Brew-Unit — TEST SUCCEEDED, including the four resolution tests that prove pt-BR really comes back from the compiled catalogues.
  • mint run swiftformat --lint . — 0 of 374 files need formatting.
  • mint run swiftlint lint --strict — 0 violations, 349 files.
  • BrewUILint over Homebrew + Sources — clean.
  • Built the app and inspected the bundle: top-level en.lproj / pt-BR.lproj, and compiled Localizable.strings in both embedded module bundles containing the Portuguese values.
  • Ran the app with -AppleLanguages '(pt-BR)' and confirmed the Doctor surface renders in Portuguese, with the brew doctor preamble still in English.
  • scripts/test-ui not run. My shell lacks the Accessibility/Automation/Screen Recording permissions that BrewUITests/TROUBLESHOOTING.md item 1 describes, so the runner never bootstraps — an unrelated, untouched UI test fails identically. The DoctorScreen change compiles and uses the same resolution path as identifiers that pass in CI today, but it has not been exercised at runtime. Please treat CI's ui-test job as the real check on it.

PR checklist

  • Have you followed this repository's contribution and workflow guidance?
  • Have you explained what changed and why this should land now?
  • Have you run relevant local checks for the changed scope?
  • Are changes scoped and free of unrelated modifications?

  • AI was used to generate or assist with generating this PR.
  • If yes, describe exactly how AI was used and what manual verification was performed.

The code, the catalogues and this description were written with Claude Code, working from AGENTS.md and CONVENTIONS.md, and reviewed by me before each commit. Verification was not left to the model: every command in the Testing section above was actually run and its output read, the two pre-existing failures were confirmed against untouched main by checking it out with a clean tree, the Portuguese translations were checked by a native speaker (me), and the Bundle.module and .lproj claims were verified by inspecting the built app bundle rather than inferred. The two limits are stated plainly rather than papered over: the UI test is unverified at runtime, and coverage is two modules.

Questions for maintainers

  1. Does the project want translations at all, and on what terms? There is no prior localization issue here, so this PR is also the question. Who owns a translation once it lands, what happens when one goes stale, and which languages are accepted? I did not want to assume an answer, which is why the completeness test checks consistency within a catalogue rather than demanding Portuguese for every new string — nothing here obliges a maintainer to produce a translation to merge an English string.

  2. Should the app advertise pt-BR before coverage is meaningful? As it stands, System Settings will offer Portuguese while roughly a tenth of the app is translated. The alternative is to drop Homebrew/{en,pt-BR}.lproj/InfoPlist.strings from this PR and add them once coverage justifies it — the translations still ship, they are just not selectable yet. Happy to do that; it is two files.

  3. Is the staging shape right? I would rather send the remaining modules as separate PRs than grow this one.

Follow-ups

  • The remaining modules: BrewFeatureInstalled, BrewFeatureDiscover, BrewFeatureConsole, BrewFeatureConfig, and the app shell.
  • Error copy is still English everywhere. LoadState's failure payload is String, produced by OperationFailure.userFacingMessage in BrewCore and consumed by all five feature modules — migrating it touches all of them at once and belongs in its own PR.
  • 81 existing localized: call sites pass no bundle: (BrewFeatureInstalled 46, BrewFeatureDiscover 27, BrewFeatureConfig 3, BrewServicesTestSupport 2, BrewCore 2, BrewRepositories 1). Harmless while those targets have no catalogue — the call returns its English key, which is today's behaviour — but each is wrong in a way that only becomes visible when its module is translated. A follow-up should fix them together with a BrewUILint rule making the argument mandatory, since the rule fails the tree until every site is fixed.
  • Three component parameters still take String for caller-supplied copy (CommandBlockView.summaryText, PackageDetailSectionHeading.title, ErrorStateView.message); each is fed by a view model in a module this PR does not touch.

Address Task 4 code review findings: every entry now carries a
translator-facing comment (including the generatesSymbol rationale for
the two Re-checking entries), "Run Fix" now sorts before "Run
diagnostics..." to restore alphabetical order, and the file is
reformatted to match BrewUIComponents' Xcode pretty-printer style
(space-before-colon, compact stringUnit lines). No key or value
changed.
Commit 4aad734 re-serialized this file with a JSON dumper and silently
reverted the Xcode-style formatting applied in eef117c. Reformat it to
match Sources/BrewUIComponents/Resources/Localizable.xcstrings exactly
(space before colons, two-space indentation, collapsed simple
stringUnit blocks) so both catalogs stay consistent whenever Xcode
resaves one of them. No key, value, or comment content changed.
The header title, the copy button title and its confirmation lived in computed
properties rather than in a view initialiser, so the module sweep missed them
and the Doctor pane rendered "Terminal command" and "Copy" beside Portuguese.

Carrying them needs BrewActionButton and CommandBlockView's own title to take a
LocalizedStringResource: resolving a resource to a String at the call site would
bake in the wrong language and lose the owning module's bundle.
Caller-supplied copy is a LocalizedStringResource, per CONVENTIONS.md. Both of
NoteCallout's callers render text that must not be translated — brew doctor's
own preamble, and a package's caveats as Homebrew publishes them — so they take
a verbatim: route named after Text(verbatim:), which makes opting out of
localization something a call site has to say out loud.
LocalizedStringResource interpolates another resource directly, producing the
same "Severity: %@" key. The nested String(localized:) collapsed the severity
name against Locale.current while the view body was evaluating, for no reason
beyond the receiving API once taking a String.
FORMULA and CASK are Homebrew's own domain terms, not app copy. CONVENTIONS.md
now requires text left out of a catalogue to carry a comment saying why.
BrewUIElement is documented as the only layer that touches XCUIElement, and it
resolves by descendants(matching: .any) — so the lookup no longer depends on
guessing which element type SwiftUI surfaces the container as, which was the
open question the removed comment recorded. It also self-waits and produces the
project's standard failure diagnostics, the way .doctorScreen already does.
The rule was: every string everywhere must have Portuguese. That makes a
translation a standing obligation on maintainers who never agreed to one — add
an English button to a localized module and the build goes red until you produce
pt-BR for it. Which languages the project accepts is the project's decision.

The rule is now: a catalogue that translates any of its keys into a language
translates all of them. It still catches the half-translated module the test
exists for, while a catalogue nobody has started translating passes.

Also fixes two flaws in the same file. The Sources/ walk ended in `?? []`, so a
renamed directory would have left the suite passing having checked only the
deliberately empty app catalogue; the catalogues that exist are now named and
their absence throws. And the failure label for Homebrew/Localizable.xcstrings
walked two directories up into the checkout folder, whose name is whatever the
person cloning chose.
Both exist only so the Xcode test target can reach a bundle that is otherwise
internal, and the staged plan adds one per localized module. @_spi keeps the
escape hatch open for BrewTests without growing the package's public surface,
which .periphery.yml's retain_public: false would then have to carry.
"pro" is a spoken contraction; product UI reads in the written register.
Resolving the lead and the relative phrase separately is correct — they belong
to different catalogues — but it fixes the order and the separator, which is the
classic trap for a language that wants either one different. Say so, so the
current shape is not read as the general answer.
The completeness test's rule changed, component APIs now take resources, and the
bundle accessors are SPI. Each was written down as something else.

@MikeMcQuaid MikeMcQuaid left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hold off on this for now please. Want to get a stable release out and in homebred-cask before we introduce any localisations yet 🙇🏻

@jacksonmafra-umain

Copy link
Copy Markdown

Ok, no worries, any other feature / issue that could be helped?

@MikeMcQuaid

Copy link
Copy Markdown
Member

@jacksonfdam no, not yet. Next week!

@MikeMcQuaid
MikeMcQuaid self-requested a review September 13, 2026 14:10

@MikeMcQuaid MikeMcQuaid left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

7.0.0 launched.

@MikeMcQuaid
MikeMcQuaid requested a review from graeme September 13, 2026 14:11
@graeme

graeme commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

@jacksonfdam : Thanks for your patience. As this is an architectural / conventions decision, I'm going to take a bit more care with this one. I've got a few more pressing issues to deal with just now, but I'll come back to this soon for sure.

In the meantime, I'd appreciate if you could trim down the comments I bit. They seem possibly a bit LLM generated? I'd appreciate if we could only add comments when they're either documenting public API / shared components or explaining something genuinely strange / unconventional which the code itself doesn't explain super well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants