From 94edb58bc478ed5facc992f91c753bd945ce4558 Mon Sep 17 00:00:00 2001 From: "Jackson F. de A. Mafra" <885385+jacksonfdam@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:01:02 +0200 Subject: [PATCH 01/22] Add String Catalog localization foundations to BrewUIComponents --- BrewTests/LocalizationResolutionTests.swift | 28 ++++++++++++++++++ Package.swift | 2 ++ .../LocalizedStringResource+Module.swift | 26 +++++++++++++++++ .../Resources/Localizable.xcstrings | 24 +++++++++++++++ .../Views/AsyncContentView.swift | 2 +- .../LocalizationBundleTests.swift | 29 +++++++++++++++++++ 6 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 BrewTests/LocalizationResolutionTests.swift create mode 100644 Sources/BrewUIComponents/Localization/LocalizedStringResource+Module.swift create mode 100644 Sources/BrewUIComponents/Resources/Localizable.xcstrings create mode 100644 Tests/BrewUIComponentsTests/LocalizationBundleTests.swift diff --git a/BrewTests/LocalizationResolutionTests.swift b/BrewTests/LocalizationResolutionTests.swift new file mode 100644 index 00000000..2bac6716 --- /dev/null +++ b/BrewTests/LocalizationResolutionTests.swift @@ -0,0 +1,28 @@ +// +// LocalizationResolutionTests.swift +// BrewTests +// + +import BrewUIComponents +import Foundation +import Testing + +/// Asserts a translation actually comes back, which only `xcodebuild` can demonstrate: the SwiftPM +/// CLI copies `Localizable.xcstrings` into the module bundle raw, while `xcodebuild` compiles it to +/// `.lproj/Localizable.strings`. That is why this suite lives in the Xcode target — CI runs it +/// via test plan `Brew-Unit` — and not beside the rest of `BrewUIComponents`' tests under `Tests/`. +struct LocalizationResolutionTests { + private static func localized(_ resource: LocalizedStringResource, in identifier: String) -> String { + var resolved = resource + resolved.locale = Locale(identifier: identifier) + return String(localized: resolved) + } + + @Test func `a module string resolves in both languages`() { + let retry = LocalizedStringResource("Retry", bundle: .atURL(Bundle.brewUIComponents.bundleURL)) + #expect( + [Self.localized(retry, in: "en"), Self.localized(retry, in: "pt-BR")] + == ["Retry", "Tentar novamente"], + ) + } +} diff --git a/Package.swift b/Package.swift index a7714e39..96d317ac 100644 --- a/Package.swift +++ b/Package.swift @@ -3,6 +3,7 @@ import PackageDescription let package = Package( name: "BrewKit", + defaultLocalization: "en", platforms: [ .macOS("26.0"), ], @@ -71,6 +72,7 @@ let package = Package( dependencies: ["BrewAccessibilityID", "BrewCore"], resources: [ .process("Resources/Media.xcassets"), + .process("Resources/Localizable.xcstrings"), ], swiftSettings: [ .defaultIsolation(MainActor.self), diff --git a/Sources/BrewUIComponents/Localization/LocalizedStringResource+Module.swift b/Sources/BrewUIComponents/Localization/LocalizedStringResource+Module.swift new file mode 100644 index 00000000..26ae819e --- /dev/null +++ b/Sources/BrewUIComponents/Localization/LocalizedStringResource+Module.swift @@ -0,0 +1,26 @@ +// +// LocalizedStringResource+Module.swift +// BrewUIComponents +// + +import Foundation + +extension LocalizedStringResource { + /// Builds a resource bound to this module's catalogue. + /// + /// `LocalizedStringResource` defaults to `Bundle.main`, which in a SwiftPM module is the app — + /// not where `Localizable.xcstrings` was processed to. Resolution against the wrong bundle does + /// not throw; it returns the key, so the string silently stays English. Every user-facing string + /// in `BrewUIComponents` goes through here. + init(uiComponents key: String.LocalizationValue) { + self.init(key, bundle: .atURL(Bundle.module.bundleURL)) + } +} + +public extension Bundle { + /// `Bundle.module` is internal to its own module. The Xcode test target needs the same bundle + /// to assert that a translation resolves, so it is exposed here — the only reason this is public. + static var brewUIComponents: Bundle { + .module + } +} diff --git a/Sources/BrewUIComponents/Resources/Localizable.xcstrings b/Sources/BrewUIComponents/Resources/Localizable.xcstrings new file mode 100644 index 00000000..aacf4b99 --- /dev/null +++ b/Sources/BrewUIComponents/Resources/Localizable.xcstrings @@ -0,0 +1,24 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "Retry" : { + "comment" : "Button that re-runs a failed load", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retry" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tentar novamente" + } + } + } + } + }, + "version" : "1.0" +} diff --git a/Sources/BrewUIComponents/Views/AsyncContentView.swift b/Sources/BrewUIComponents/Views/AsyncContentView.swift index a47dace2..a692ff79 100644 --- a/Sources/BrewUIComponents/Views/AsyncContentView.swift +++ b/Sources/BrewUIComponents/Views/AsyncContentView.swift @@ -68,7 +68,7 @@ struct ErrorStateView: View { .foregroundStyle(Color.brewStatusError) .multilineTextAlignment(.center) if let onRetry { - Button("Retry", action: onRetry) + Button(LocalizedStringResource(uiComponents: "Retry"), action: onRetry) .axid(.errorRetryButton) } } diff --git a/Tests/BrewUIComponentsTests/LocalizationBundleTests.swift b/Tests/BrewUIComponentsTests/LocalizationBundleTests.swift new file mode 100644 index 00000000..ed0098c9 --- /dev/null +++ b/Tests/BrewUIComponentsTests/LocalizationBundleTests.swift @@ -0,0 +1,29 @@ +// +// LocalizationBundleTests.swift +// BrewUIComponentsTests +// + +@testable import BrewUIComponents +import Foundation +import Testing + +/// Pins the wiring, not the wording. A `LocalizedStringResource` built without an explicit bundle +/// resolves against `Bundle.main` — the app, not the module — and the failure is silent: the lookup +/// returns the key, so the string just stays English. These tests name that failure mode directly. +/// +/// Resolved translations are asserted in `BrewTests/LocalizationResolutionTests.swift` instead: +/// `swift test` never compiles the catalogue, so resolution cannot be observed from here. +struct LocalizationBundleTests { + @Test func `a module string keeps its key`() { + #expect(LocalizedStringResource(uiComponents: "Retry").key == "Retry") + } + + @Test func `a module string points at the module bundle, not main`() { + // `BundleDescription` is not Equatable, so the case is matched rather than compared. + guard case let .atURL(url) = LocalizedStringResource(uiComponents: "Retry").bundle else { + Issue.record("Expected .atURL — a .main bundle means the module catalogue is unreachable") + return + } + #expect(url.lastPathComponent == "BrewKit_BrewUIComponents.bundle") + } +} From 8445c1d75fa5132160cf14ac200e60e6dd98cef2 Mon Sep 17 00:00:00 2001 From: "Jackson F. de A. Mafra" <885385+jacksonfdam@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:01:09 +0200 Subject: [PATCH 02/22] Localize BrewUIComponents copy and move relative time to catalogue plurals --- BrewTests/LocalizationResolutionTests.swift | 39 +++++++ .../BrewFeatureDoctor/Views/DoctorView.swift | 5 +- .../Commands/RefreshCommands.swift | 2 +- .../Commands/SearchCommands.swift | 2 +- .../Resources/Localizable.xcstrings | 104 ++++++++++++++++-- .../Views/LastUpdatedLabel.swift | 35 +++--- .../RelativeTimeTextTests.swift | 61 +++++++--- 7 files changed, 208 insertions(+), 40 deletions(-) diff --git a/BrewTests/LocalizationResolutionTests.swift b/BrewTests/LocalizationResolutionTests.swift index 2bac6716..a5d63dc4 100644 --- a/BrewTests/LocalizationResolutionTests.swift +++ b/BrewTests/LocalizationResolutionTests.swift @@ -25,4 +25,43 @@ struct LocalizationResolutionTests { == ["Retry", "Tentar novamente"], ) } + + /// One representative count per unit. This is where plural variations are actually exercised: + /// the count comes from a `%lld` argument, and the catalogue picks `one` or `other` per language. + /// + /// `@MainActor`: `BrewUIComponents` defaults every declaration to main-actor isolation + /// (`Package.swift`'s `.defaultIsolation(MainActor.self)`), so `RelativeTimeText.resource` is + /// main-actor-isolated too. `BrewTests` carries no such default, so the call needs an isolated + /// caller — unlike `Tests/BrewUIComponentsTests`, whose own target shares that same default. + @MainActor + @Test func `relative time resolves in English`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let phrases = [0, 60, 5 * 60, 3600, 24 * 3600].map { secondsAgo in + Self.localized( + RelativeTimeText.resource( + for: now.addingTimeInterval(-TimeInterval(secondsAgo)), + relativeTo: now, + ), + in: "en", + ) + } + #expect(phrases == ["just now", "1 minute ago", "5 minutes ago", "1 hour ago", "1 day ago"]) + } + + /// Portuguese takes the singular below two, as English does here — but through catalogue plural + /// variations rather than a hand-written ternary, so a language with different rules stays right. + @MainActor + @Test func `relative time resolves in Portuguese`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let phrases = [0, 60, 5 * 60, 3600, 24 * 3600].map { secondsAgo in + Self.localized( + RelativeTimeText.resource( + for: now.addingTimeInterval(-TimeInterval(secondsAgo)), + relativeTo: now, + ), + in: "pt-BR", + ) + } + #expect(phrases == ["agora mesmo", "há 1 minuto", "há 5 minutos", "há 1 hora", "há 1 dia"]) + } } diff --git a/Sources/BrewFeatureDoctor/Views/DoctorView.swift b/Sources/BrewFeatureDoctor/Views/DoctorView.swift index 457cf5f0..6d0a3cb8 100644 --- a/Sources/BrewFeatureDoctor/Views/DoctorView.swift +++ b/Sources/BrewFeatureDoctor/Views/DoctorView.swift @@ -35,7 +35,10 @@ struct DoctorView: View { .font(.brewSubheadline) .foregroundStyle(Color.brewTextSecondary) if let lastCheckedAt = viewModel.lastCheckedAt { - LastUpdatedLabel(lead: "Last checked", date: lastCheckedAt) + LastUpdatedLabel( + lead: LocalizedStringResource("Last checked", bundle: .atURL(Bundle.main.bundleURL)), + date: lastCheckedAt, + ) } } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Sources/BrewUIComponents/Commands/RefreshCommands.swift b/Sources/BrewUIComponents/Commands/RefreshCommands.swift index 8d702cef..13c370ed 100644 --- a/Sources/BrewUIComponents/Commands/RefreshCommands.swift +++ b/Sources/BrewUIComponents/Commands/RefreshCommands.swift @@ -13,7 +13,7 @@ public struct RefreshCommands: Commands { public var body: some Commands { CommandGroup(after: .sidebar) { - Button("Refresh") { refreshAll?() } + Button(LocalizedStringResource(uiComponents: "Refresh")) { refreshAll?() } .keyboardShortcut("r", modifiers: .command) .disabled(refreshAll == nil) } diff --git a/Sources/BrewUIComponents/Commands/SearchCommands.swift b/Sources/BrewUIComponents/Commands/SearchCommands.swift index d04f72de..cd38804d 100644 --- a/Sources/BrewUIComponents/Commands/SearchCommands.swift +++ b/Sources/BrewUIComponents/Commands/SearchCommands.swift @@ -13,7 +13,7 @@ public struct SearchCommands: Commands { public var body: some Commands { CommandGroup(after: .textEditing) { - Button("Find") { focusSearchField?() } + Button(LocalizedStringResource(uiComponents: "Find")) { focusSearchField?() } .keyboardShortcut("f") // ⌘F .disabled(focusSearchField == nil) } diff --git a/Sources/BrewUIComponents/Resources/Localizable.xcstrings b/Sources/BrewUIComponents/Resources/Localizable.xcstrings index aacf4b99..a7f55d19 100644 --- a/Sources/BrewUIComponents/Resources/Localizable.xcstrings +++ b/Sources/BrewUIComponents/Resources/Localizable.xcstrings @@ -1,23 +1,111 @@ { "sourceLanguage" : "en", "strings" : { - "Retry" : { - "comment" : "Button that re-runs a failed load", + "%lld days ago" : { + "comment" : "Relative time, whole days", "extractionState" : "manual", "localizations" : { "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Retry" + "variations" : { + "plural" : { + "one" : { "stringUnit" : { "state" : "translated", "value" : "%lld day ago" } }, + "other" : { "stringUnit" : { "state" : "translated", "value" : "%lld days ago" } } + } } }, "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tentar novamente" + "variations" : { + "plural" : { + "one" : { "stringUnit" : { "state" : "translated", "value" : "há %lld dia" } }, + "other" : { "stringUnit" : { "state" : "translated", "value" : "há %lld dias" } } + } } } } + }, + "%lld hours ago" : { + "comment" : "Relative time, whole hours", + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { "stringUnit" : { "state" : "translated", "value" : "%lld hour ago" } }, + "other" : { "stringUnit" : { "state" : "translated", "value" : "%lld hours ago" } } + } + } + }, + "pt-BR" : { + "variations" : { + "plural" : { + "one" : { "stringUnit" : { "state" : "translated", "value" : "há %lld hora" } }, + "other" : { "stringUnit" : { "state" : "translated", "value" : "há %lld horas" } } + } + } + } + } + }, + "%lld minutes ago" : { + "comment" : "Relative time, whole minutes", + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { "stringUnit" : { "state" : "translated", "value" : "%lld minute ago" } }, + "other" : { "stringUnit" : { "state" : "translated", "value" : "%lld minutes ago" } } + } + } + }, + "pt-BR" : { + "variations" : { + "plural" : { + "one" : { "stringUnit" : { "state" : "translated", "value" : "há %lld minuto" } }, + "other" : { "stringUnit" : { "state" : "translated", "value" : "há %lld minutos" } } + } + } + } + } + }, + "Find" : { + "comment" : "Edit menu item that focuses the search field (⌘F)", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Find" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Buscar" } } + } + }, + "just now" : { + "comment" : "Relative time, under one minute", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "just now" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "agora mesmo" } } + } + }, + "Last checked" : { + "comment" : "Preview-only lead phrase; production callers pass their own", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Last checked" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Verificado" } } + } + }, + "Refresh" : { + "comment" : "View menu item that reloads the current surface (⌘R)", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Refresh" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Atualizar" } } + } + }, + "Retry" : { + "comment" : "Button that re-runs a failed load", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Retry" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Tentar novamente" } } + } } }, "version" : "1.0" diff --git a/Sources/BrewUIComponents/Views/LastUpdatedLabel.swift b/Sources/BrewUIComponents/Views/LastUpdatedLabel.swift index 39cd13d0..a308f2c5 100644 --- a/Sources/BrewUIComponents/Views/LastUpdatedLabel.swift +++ b/Sources/BrewUIComponents/Views/LastUpdatedLabel.swift @@ -6,41 +6,47 @@ import Foundation import SwiftUI -/// Spelled out rather than left to `RelativeDateTimeFormatter`, which follows the system locale and would -/// put a translated phrase after an English lead-in. +/// Spelled out rather than left to `RelativeDateTimeFormatter` so the phrase is a catalogue entry a +/// translator can see and reword. Interpolating the count produces a `%lld` key, which the catalogue +/// answers with per-language plural variations — pluralisation rules differ by language, so a +/// hand-written singular/plural ternary is only ever right for one of them. public enum RelativeTimeText { - public static func string(for date: Date, relativeTo now: Date) -> String { + public static func resource(for date: Date, relativeTo now: Date) -> LocalizedStringResource { let seconds = now.timeIntervalSince(date) guard seconds >= 60 else { // Also covers a future date: a clock that moved backwards should read as "now", not a countdown. - return "just now" + return LocalizedStringResource(uiComponents: "just now") } let minutes = Int(seconds / 60) if minutes < 60 { - return "\(minutes) \(minutes == 1 ? "minute" : "minutes") ago" + return LocalizedStringResource(uiComponents: "\(minutes) minutes ago") } let hours = minutes / 60 if hours < 24 { - return "\(hours) \(hours == 1 ? "hour" : "hours") ago" + return LocalizedStringResource(uiComponents: "\(hours) hours ago") } let days = hours / 24 - return "\(days) \(days == 1 ? "day" : "days") ago" + return LocalizedStringResource(uiComponents: "\(days) days ago") } } public struct LastUpdatedLabel: View { - private let lead: String + private let lead: LocalizedStringResource private let date: Date - /// `lead` is the phrase the relative time is appended to, e.g. `"Last checked"`. - public init(lead: String, date: Date) { + /// `lead` is the phrase the relative time is appended to, e.g. `"Last checked"`. It arrives as a + /// resource rather than a `String` so it resolves against the calling module's catalogue, not + /// this one's. + public init(lead: LocalizedStringResource, date: Date) { self.lead = lead self.date = date } public var body: some View { TimelineView(.periodic(from: date, by: 60)) { context in - Text("\(lead) \(RelativeTimeText.string(for: date, relativeTo: context.date))") + // Two `Text` values concatenated rather than one interpolated string: the lead and the + // relative phrase live in different catalogues and must resolve separately. + (Text(lead) + Text(verbatim: " ") + Text(RelativeTimeText.resource(for: date, relativeTo: context.date))) .font(.brewCaption) .foregroundStyle(Color.brewTextTertiary) } @@ -50,8 +56,11 @@ public struct LastUpdatedLabel: View { #if DEBUG #Preview("Last updated") { VStack(alignment: .leading, spacing: BrewSpacing.xs) { - LastUpdatedLabel(lead: "Last checked", date: .now) - LastUpdatedLabel(lead: "Last checked", date: .now.addingTimeInterval(-3600)) + LastUpdatedLabel(lead: LocalizedStringResource(uiComponents: "Last checked"), date: .now) + LastUpdatedLabel( + lead: LocalizedStringResource(uiComponents: "Last checked"), + date: .now.addingTimeInterval(-3600), + ) } .padding() } diff --git a/Tests/BrewUIComponentsTests/RelativeTimeTextTests.swift b/Tests/BrewUIComponentsTests/RelativeTimeTextTests.swift index 612ca52e..53e64176 100644 --- a/Tests/BrewUIComponentsTests/RelativeTimeTextTests.swift +++ b/Tests/BrewUIComponentsTests/RelativeTimeTextTests.swift @@ -7,36 +7,65 @@ import Foundation import Testing +/// Asserts unit selection — which catalogue key the elapsed time maps to — not the wording. The key +/// for a counted unit is its format string (`%lld minutes ago`), so the count itself is not visible +/// here; the resolved phrases for representative counts are asserted in +/// `BrewTests/LocalizationResolutionTests.swift`, where the catalogue is compiled. struct RelativeTimeTextTests { private static let now = Date(timeIntervalSince1970: 1_800_000_000) - private static func text(secondsAgo: TimeInterval) -> String { - RelativeTimeText.string(for: now.addingTimeInterval(-secondsAgo), relativeTo: now) + private static func key(secondsAgo: TimeInterval) -> String { + RelativeTimeText.resource(for: now.addingTimeInterval(-secondsAgo), relativeTo: now).key } @Test func `anything under a minute reads as just now`() { - #expect(Self.text(secondsAgo: 0) == "just now") - #expect(Self.text(secondsAgo: 59) == "just now") + #expect([Self.key(secondsAgo: 0), Self.key(secondsAgo: 59)] == ["just now", "just now"]) } @Test func `minutes, hours and days each get their own unit`() { - #expect(Self.text(secondsAgo: 60) == "1 minute ago") - #expect(Self.text(secondsAgo: 59 * 60) == "59 minutes ago") - #expect(Self.text(secondsAgo: 60 * 60) == "1 hour ago") - #expect(Self.text(secondsAgo: 23 * 3600) == "23 hours ago") - #expect(Self.text(secondsAgo: 24 * 3600) == "1 day ago") - #expect(Self.text(secondsAgo: 10 * 24 * 3600) == "10 days ago") + #expect([ + Self.key(secondsAgo: 60), + Self.key(secondsAgo: 59 * 60), + Self.key(secondsAgo: 60 * 60), + Self.key(secondsAgo: 23 * 3600), + Self.key(secondsAgo: 24 * 3600), + Self.key(secondsAgo: 10 * 24 * 3600), + ] == [ + "%lld minutes ago", + "%lld minutes ago", + "%lld hours ago", + "%lld hours ago", + "%lld days ago", + "%lld days ago", + ]) } - /// Each unit truncates rather than rounds, so the phrase never claims more time has passed than has. + /// Each unit truncates rather than rounds, so the phrase never claims more time has passed than + /// has: 119 seconds is still the minutes unit, and 7199 is still hours. @Test func `a part-elapsed unit does not round up`() { - #expect(Self.text(secondsAgo: 119) == "1 minute ago") - #expect(Self.text(secondsAgo: 7199) == "1 hour ago") + #expect([Self.key(secondsAgo: 119), Self.key(secondsAgo: 7199)] + == ["%lld minutes ago", "%lld hours ago"]) } - /// A clock that has moved backwards leaves the timestamp in the future; a countdown would be nonsense. + /// A clock that has moved backwards leaves the timestamp in the future; a countdown would be + /// nonsense. @Test func `a future timestamp reads as just now`() { - #expect(RelativeTimeText.string(for: Self.now.addingTimeInterval(600), relativeTo: Self.now) - == "just now") + #expect(RelativeTimeText.resource(for: Self.now.addingTimeInterval(600), relativeTo: Self.now) + .key == "just now") + } + + /// Every string this type can produce is bound to the module bundle, not `Bundle.main`. + @Test func `every relative phrase points at the module bundle`() { + let bundles = [0, 60, 3600, 24 * 3600].map { secondsAgo -> String? in + let resource = RelativeTimeText.resource( + for: Self.now.addingTimeInterval(-TimeInterval(secondsAgo)), + relativeTo: Self.now, + ) + guard case let .atURL(url) = resource.bundle else { + return nil + } + return url.lastPathComponent + } + #expect(bundles == Array(repeating: "BrewKit_BrewUIComponents.bundle", count: 4)) } } From accfb57caf340497a8875a01c0e2fb38f08d582d Mon Sep 17 00:00:00 2001 From: "Jackson F. de A. Mafra" <885385+jacksonfdam@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:01:12 +0200 Subject: [PATCH 03/22] Localize the Doctor surface into Brazilian Portuguese --- BrewTests/LocalizationResolutionTests.swift | 17 + Package.swift | 3 + .../LocalizedStringResource+Module.swift | 22 + .../Resources/Localizable.xcstrings | 416 ++++++++++++++++++ .../ViewModels/DoctorViewModel.swift | 18 +- .../BrewFeatureDoctor/Views/DoctorCopy.swift | 3 +- .../Views/DoctorIssueDetailView.swift | 27 +- .../Views/DoctorIssueRowView.swift | 2 +- .../Views/DoctorSeverityStyle.swift | 8 +- .../BrewFeatureDoctor/Views/DoctorView.swift | 18 +- .../DoctorSeverityStyleTests.swift | 6 +- .../DoctorViewModelTests.swift | 25 +- 12 files changed, 525 insertions(+), 40 deletions(-) create mode 100644 Sources/BrewFeatureDoctor/Localization/LocalizedStringResource+Module.swift create mode 100644 Sources/BrewFeatureDoctor/Resources/Localizable.xcstrings diff --git a/BrewTests/LocalizationResolutionTests.swift b/BrewTests/LocalizationResolutionTests.swift index a5d63dc4..a673c36f 100644 --- a/BrewTests/LocalizationResolutionTests.swift +++ b/BrewTests/LocalizationResolutionTests.swift @@ -3,6 +3,7 @@ // BrewTests // +import BrewFeatureDoctor import BrewUIComponents import Foundation import Testing @@ -64,4 +65,20 @@ struct LocalizationResolutionTests { } #expect(phrases == ["agora mesmo", "há 1 minuto", "há 5 minutos", "há 1 hora", "há 1 dia"]) } + + /// A representative Doctor string per surface: the header subtitle and a severity label. The + /// completeness test guarantees every other key has a `pt-BR` entry; this one proves the Doctor + /// module's bundle wiring reaches it. + @Test func `doctor copy resolves in Portuguese`() { + let subtitle = LocalizedStringResource( + "Running brew doctor…", + bundle: .atURL(Bundle.brewFeatureDoctor.bundleURL), + ) + let severity = LocalizedStringResource( + "Unsupported", + bundle: .atURL(Bundle.brewFeatureDoctor.bundleURL), + ) + #expect([Self.localized(subtitle, in: "pt-BR"), Self.localized(severity, in: "pt-BR")] + == ["Executando brew doctor…", "Sem suporte"]) + } } diff --git a/Package.swift b/Package.swift index 96d317ac..5ad78677 100644 --- a/Package.swift +++ b/Package.swift @@ -196,6 +196,9 @@ let package = Package( "BrewRepositoryInterfaces", "BrewAppEnvironment", ], + resources: [ + .process("Resources/Localizable.xcstrings"), + ], swiftSettings: [ .defaultIsolation(MainActor.self), .swiftLanguageMode(.v6), diff --git a/Sources/BrewFeatureDoctor/Localization/LocalizedStringResource+Module.swift b/Sources/BrewFeatureDoctor/Localization/LocalizedStringResource+Module.swift new file mode 100644 index 00000000..f48d8873 --- /dev/null +++ b/Sources/BrewFeatureDoctor/Localization/LocalizedStringResource+Module.swift @@ -0,0 +1,22 @@ +// +// LocalizedStringResource+Module.swift +// BrewFeatureDoctor +// + +import Foundation + +extension LocalizedStringResource { + /// Builds a resource bound to this module's catalogue. See the equivalent in `BrewUIComponents` + /// for why the default `Bundle.main` is wrong here and fails silently. + init(doctor key: String.LocalizationValue) { + self.init(key, bundle: .atURL(Bundle.module.bundleURL)) + } +} + +public extension Bundle { + /// `Bundle.module` is internal to its own module. The Xcode test target needs the same bundle to + /// assert that a translation resolves, so it is exposed here — the only reason this is public. + static var brewFeatureDoctor: Bundle { + .module + } +} diff --git a/Sources/BrewFeatureDoctor/Resources/Localizable.xcstrings b/Sources/BrewFeatureDoctor/Resources/Localizable.xcstrings new file mode 100644 index 00000000..0287ff09 --- /dev/null +++ b/Sources/BrewFeatureDoctor/Resources/Localizable.xcstrings @@ -0,0 +1,416 @@ +{ + "sourceLanguage": "en", + "strings": { + "Copy brew doctor output": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy brew doctor output" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Copiar saída do brew doctor" + } + } + } + }, + "Copy output": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy output" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Copiar saída" + } + } + } + }, + "Danger": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Danger" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Perigo" + } + } + } + }, + "Doctor": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Doctor" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Doctor" + } + } + } + }, + "Doctor issues": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Doctor issues" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Problemas do Doctor" + } + } + } + }, + "Fix available": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Fix available" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Correção disponível" + } + } + } + }, + "Last checked": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Last checked" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Verificado" + } + } + } + }, + "Needs admin · runs in Terminal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Needs admin · runs in Terminal" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Requer administrador · executa no Terminal" + } + } + } + }, + "No problems found": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No problems found" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nenhum problema encontrado" + } + } + } + }, + "No selection": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No selection" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nada selecionado" + } + } + } + }, + "Raw output": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Raw output" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Saída bruta" + } + } + } + }, + "Re-checking": { + "extractionState": "manual", + "generatesSymbol": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Re-checking" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Verificando novamente" + } + } + } + }, + "Re-checking…": { + "extractionState": "manual", + "generatesSymbol": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Re-checking…" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Verificando novamente…" + } + } + } + }, + "Run Again": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Run Again" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Executar novamente" + } + } + } + }, + "Run diagnostics, then choose an issue to see details and fixes.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Run diagnostics, then choose an issue to see details and fixes." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Execute o diagnóstico e escolha um problema para ver detalhes e correções." + } + } + } + }, + "Run Fix": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Run Fix" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Aplicar correção" + } + } + } + }, + "Running brew doctor…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Running brew doctor…" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Executando brew doctor…" + } + } + } + }, + "Severity: %@": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Severity: %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Severidade: %@" + } + } + } + }, + "The check could not be completed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The check could not be completed" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível concluir a verificação" + } + } + } + }, + "Unsupported": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unsupported" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Sem suporte" + } + } + } + }, + "Warning": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Warning" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Aviso" + } + } + } + }, + "Warnings found": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Warnings found" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Avisos encontrados" + } + } + } + }, + "Your system is ready to brew": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your system is ready to brew" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Seu sistema está pronto pro brew" + } + } + } + }, + "brew doctor found no problems.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "brew doctor found no problems." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O brew doctor não encontrou problemas." + } + } + } + } + }, + "version": "1.0" +} diff --git a/Sources/BrewFeatureDoctor/ViewModels/DoctorViewModel.swift b/Sources/BrewFeatureDoctor/ViewModels/DoctorViewModel.swift index 0be33a3b..db078afe 100644 --- a/Sources/BrewFeatureDoctor/ViewModels/DoctorViewModel.swift +++ b/Sources/BrewFeatureDoctor/ViewModels/DoctorViewModel.swift @@ -102,16 +102,24 @@ final class DoctorViewModel { /// Header subtitle copy. Mirrors ``presentation``; while a re-check runs on top of a prior report it /// switches to "Re-checking…" so the user knows the visible content is being refreshed. - var subtitle: String { + var subtitle: LocalizedStringResource { switch presentation { case .loading: - "Running brew doctor…" + LocalizedStringResource(doctor: "Running brew doctor…") case .healthy: - isRefreshing ? "Re-checking…" : "No problems found" + if isRefreshing { + LocalizedStringResource(doctor: "Re-checking…") + } else { + LocalizedStringResource(doctor: "No problems found") + } case .issues: - isRefreshing ? "Re-checking…" : "Warnings found" + if isRefreshing { + LocalizedStringResource(doctor: "Re-checking…") + } else { + LocalizedStringResource(doctor: "Warnings found") + } case .failed: - "The check could not be completed" + LocalizedStringResource(doctor: "The check could not be completed") } } diff --git a/Sources/BrewFeatureDoctor/Views/DoctorCopy.swift b/Sources/BrewFeatureDoctor/Views/DoctorCopy.swift index 27290244..7a6ed2ce 100644 --- a/Sources/BrewFeatureDoctor/Views/DoctorCopy.swift +++ b/Sources/BrewFeatureDoctor/Views/DoctorCopy.swift @@ -3,7 +3,8 @@ // BrewFeatureDoctor // -/// Doctor copy that must match `brew doctor` word for word. +/// Doctor copy that must match `brew doctor` word for word. Deliberately not localized: it is a +/// verbatim echo of the CLI, and translating it would contradict the output shown beside it. enum DoctorCopy { static let warningPreamble = """ Please note that these warnings are just used to help the Homebrew maintainers with debugging \ diff --git a/Sources/BrewFeatureDoctor/Views/DoctorIssueDetailView.swift b/Sources/BrewFeatureDoctor/Views/DoctorIssueDetailView.swift index a2ed84d8..4abd7471 100644 --- a/Sources/BrewFeatureDoctor/Views/DoctorIssueDetailView.swift +++ b/Sources/BrewFeatureDoctor/Views/DoctorIssueDetailView.swift @@ -102,9 +102,12 @@ struct DoctorIssueDetailView: View { VStack(alignment: .leading, spacing: BrewSpacing.sm) { captionText(block.caption) if steps.contains(where: \.needsAdmin) { - Label("Needs admin · runs in Terminal", systemImage: "lock.fill") - .font(.brewCaption) - .foregroundStyle(Color.brewTextTertiary) + Label( + LocalizedStringResource(doctor: "Needs admin · runs in Terminal"), + systemImage: "lock.fill", + ) + .font(.brewCaption) + .foregroundStyle(Color.brewTextTertiary) } CommandBlockView(commands: steps.map(\.displayCommand)) if isPrimaryRunnable(block) { @@ -127,12 +130,12 @@ struct DoctorIssueDetailView: View { .controlSize(.small) .frame(minWidth: 120) } else { - Text("Run Fix") + Text(LocalizedStringResource(doctor: "Run Fix")) } } .buttonStyle(.borderedProminent) .disabled(viewModel.isFixRunning(item)) - .accessibilityLabel("Run Fix") + .accessibilityLabel(LocalizedStringResource(doctor: "Run Fix")) if let fixError = viewModel.fixError(item) { Text(fixError) @@ -176,7 +179,11 @@ struct DoctorIssueDetailView: View { // MARK: - Raw output private var rawOutputSection: some View { - CommandBlockView(command: item.rawText, title: "Raw output", collapsible: true) + CommandBlockView( + command: item.rawText, + title: String(localized: LocalizedStringResource(doctor: "Raw output")), + collapsible: true, + ) } } @@ -193,7 +200,9 @@ private struct DoctorSeverityBadge: View { .padding(.horizontal, BrewSpacing.sm) .padding(.vertical, BrewSpacing.xxs) .background(DoctorSeverityStyle.background(severity), in: Capsule()) - .accessibilityLabel("Severity: \(DoctorSeverityStyle.displayName(severity))") + .accessibilityLabel(LocalizedStringResource( + doctor: "Severity: \(String(localized: DoctorSeverityStyle.displayName(severity)))", + )) } } @@ -220,10 +229,10 @@ private struct DoctorLinkRow: View { struct DoctorDetailPlaceholder: View { var body: some View { VStack(alignment: .leading, spacing: BrewSpacing.sm) { - Text("No selection") + Text(LocalizedStringResource(doctor: "No selection")) .font(.brewTitle2) .foregroundStyle(Color.brewTextPrimary) - Text("Run diagnostics, then choose an issue to see details and fixes.") + Text(LocalizedStringResource(doctor: "Run diagnostics, then choose an issue to see details and fixes.")) .font(.brewCallout) .foregroundStyle(Color.brewTextSecondary) Spacer() diff --git a/Sources/BrewFeatureDoctor/Views/DoctorIssueRowView.swift b/Sources/BrewFeatureDoctor/Views/DoctorIssueRowView.swift index c37e646b..1863c211 100644 --- a/Sources/BrewFeatureDoctor/Views/DoctorIssueRowView.swift +++ b/Sources/BrewFeatureDoctor/Views/DoctorIssueRowView.swift @@ -21,7 +21,7 @@ struct DoctorIssueRowView: View { .foregroundStyle(Color.brewTextPrimary) .lineLimit(2) if item.hasRunnableFix { - Label("Fix available", systemImage: "wrench.and.screwdriver") + Label(LocalizedStringResource(doctor: "Fix available"), systemImage: "wrench.and.screwdriver") .font(.brewCaption) .foregroundStyle(Color.brewTextBrand) } diff --git a/Sources/BrewFeatureDoctor/Views/DoctorSeverityStyle.swift b/Sources/BrewFeatureDoctor/Views/DoctorSeverityStyle.swift index ee16b858..d27bbaaa 100644 --- a/Sources/BrewFeatureDoctor/Views/DoctorSeverityStyle.swift +++ b/Sources/BrewFeatureDoctor/Views/DoctorSeverityStyle.swift @@ -12,11 +12,11 @@ import SwiftUI /// distinct icon so a reader can tell severities apart at a glance; Danger and Unsupported share the /// error colour token because they share the same severity register in the BrewUI palette. enum DoctorSeverityStyle { - static func displayName(_ severity: DoctorSeverity) -> String { + static func displayName(_ severity: DoctorSeverity) -> LocalizedStringResource { switch severity { - case .caution: "Warning" - case .danger: "Danger" - case .unsupported: "Unsupported" + case .caution: LocalizedStringResource(doctor: "Warning") + case .danger: LocalizedStringResource(doctor: "Danger") + case .unsupported: LocalizedStringResource(doctor: "Unsupported") } } diff --git a/Sources/BrewFeatureDoctor/Views/DoctorView.swift b/Sources/BrewFeatureDoctor/Views/DoctorView.swift index 6d0a3cb8..94bde4cd 100644 --- a/Sources/BrewFeatureDoctor/Views/DoctorView.swift +++ b/Sources/BrewFeatureDoctor/Views/DoctorView.swift @@ -28,7 +28,7 @@ struct DoctorView: View { private var header: some View { HStack(alignment: .firstTextBaseline, spacing: BrewSpacing.sm) { VStack(alignment: .leading, spacing: BrewSpacing.xs) { - Text("Doctor") + Text(LocalizedStringResource(doctor: "Doctor")) .font(.brewTitle2) .foregroundStyle(Color.brewTextPrimary) Text(viewModel.subtitle) @@ -36,7 +36,7 @@ struct DoctorView: View { .foregroundStyle(Color.brewTextSecondary) if let lastCheckedAt = viewModel.lastCheckedAt { LastUpdatedLabel( - lead: LocalizedStringResource("Last checked", bundle: .atURL(Bundle.main.bundleURL)), + lead: LocalizedStringResource(doctor: "Last checked"), date: lastCheckedAt, ) } @@ -57,16 +57,16 @@ struct DoctorView: View { if viewModel.isRefreshing { ProgressView() .controlSize(.small) - .accessibilityLabel("Re-checking") + .accessibilityLabel(LocalizedStringResource(doctor: "Re-checking")) } if viewModel.rawDoctorOutput != nil { - Button("Copy output") { + Button(LocalizedStringResource(doctor: "Copy output")) { viewModel.copyDoctorOutput() } .controlSize(.small) - .accessibilityLabel("Copy brew doctor output") + .accessibilityLabel(LocalizedStringResource(doctor: "Copy brew doctor output")) } - Button("Run Again") { + Button(LocalizedStringResource(doctor: "Run Again")) { Task { await viewModel.load(forceRefresh: true) } } .controlSize(.small) @@ -95,10 +95,10 @@ struct DoctorView: View { Image(systemName: "checkmark.seal.fill") .font(.system(size: 44)) .foregroundStyle(Color.brewStatusSuccess) - Text("Your system is ready to brew") + Text(LocalizedStringResource(doctor: "Your system is ready to brew")) .font(.brewTitle3) .foregroundStyle(Color.brewTextPrimary) - Text("brew doctor found no problems.") + Text(LocalizedStringResource(doctor: "brew doctor found no problems.")) .font(.brewCallout) .foregroundStyle(Color.brewTextSecondary) } @@ -141,7 +141,7 @@ struct DoctorView: View { } .focused($isFocused) .listStyle(.inset) - .accessibilityLabel("Doctor issues") + .accessibilityLabel(LocalizedStringResource(doctor: "Doctor issues")) .onKeyPress(.upArrow) { viewModel.selectPrevious() return .handled diff --git a/Tests/BrewFeatureDoctorTests/DoctorSeverityStyleTests.swift b/Tests/BrewFeatureDoctorTests/DoctorSeverityStyleTests.swift index b092cd68..0fe49100 100644 --- a/Tests/BrewFeatureDoctorTests/DoctorSeverityStyleTests.swift +++ b/Tests/BrewFeatureDoctorTests/DoctorSeverityStyleTests.swift @@ -12,9 +12,9 @@ import Testing @MainActor struct DoctorSeverityStyleTests { @Test func `displayName names each severity`() { - #expect(DoctorSeverityStyle.displayName(.caution) == "Warning") - #expect(DoctorSeverityStyle.displayName(.danger) == "Danger") - #expect(DoctorSeverityStyle.displayName(.unsupported) == "Unsupported") + #expect([DoctorSeverity.caution, .danger, .unsupported] + .map { DoctorSeverityStyle.displayName($0).key } + == ["Warning", "Danger", "Unsupported"]) } @Test func `icon is a distinct glyph per severity`() { diff --git a/Tests/BrewFeatureDoctorTests/DoctorViewModelTests.swift b/Tests/BrewFeatureDoctorTests/DoctorViewModelTests.swift index 88191acc..bdc8fd4e 100644 --- a/Tests/BrewFeatureDoctorTests/DoctorViewModelTests.swift +++ b/Tests/BrewFeatureDoctorTests/DoctorViewModelTests.swift @@ -404,34 +404,43 @@ struct DoctorViewModelTests { @Test func `subtitle while loading describes the running check`() { let viewModel = Self.viewModel(repository: LoadingDoctorRepository()) - #expect(viewModel.subtitle == "Running brew doctor…") + #expect(viewModel.subtitle.key == "Running brew doctor…") } @Test func `subtitle on healthy reflects refresh state`() { let repository = MutableDoctorRepository(report: DoctorReport(issues: [])) let viewModel = Self.viewModel(repository: repository) - - #expect(viewModel.subtitle == "No problems found") + let resting = viewModel.subtitle.key repository.setRefreshing(true) - #expect(viewModel.subtitle == "Re-checking…") + #expect([resting, viewModel.subtitle.key] == ["No problems found", "Re-checking…"]) } @Test func `subtitle on issues shows "Warnings found" when not refreshing`() { let repository = MutableDoctorRepository(report: Self.issuesReport()) let viewModel = Self.viewModel(repository: repository) - - #expect(viewModel.subtitle == "Warnings found") + let resting = viewModel.subtitle.key repository.setRefreshing(true) - #expect(viewModel.subtitle == "Re-checking…") + #expect([resting, viewModel.subtitle.key] == ["Warnings found", "Re-checking…"]) } @Test func `subtitle on failure shows a generic could-not-complete message`() { let viewModel = Self.viewModel( repository: StubDoctorRepository(error: BrewLookupError.executableNotFound), ) - #expect(viewModel.subtitle == "The check could not be completed") + #expect(viewModel.subtitle.key == "The check could not be completed") + } + + /// The whole point of the module initialiser: a subtitle bound to `Bundle.main` would silently + /// stay English no matter what the catalogue says. + @Test func `subtitle points at the module bundle, not main`() { + let viewModel = Self.viewModel(repository: LoadingDoctorRepository()) + guard case let .atURL(url) = viewModel.subtitle.bundle else { + Issue.record("Expected .atURL — .main means the Doctor catalogue is unreachable") + return + } + #expect(url.lastPathComponent == "BrewKit_BrewFeatureDoctor.bundle") } // MARK: - shouldFocusList From 8963e566076bae76fb99677841274632ac916e81 Mon Sep 17 00:00:00 2001 From: "Jackson F. de A. Mafra" <885385+jacksonfdam@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:01:16 +0200 Subject: [PATCH 04/22] Add comments and fix ordering/style in Doctor Localizable.xcstrings 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. --- .../Resources/Localizable.xcstrings | 562 ++++++------------ 1 file changed, 173 insertions(+), 389 deletions(-) diff --git a/Sources/BrewFeatureDoctor/Resources/Localizable.xcstrings b/Sources/BrewFeatureDoctor/Resources/Localizable.xcstrings index 0287ff09..f6c60c3b 100644 --- a/Sources/BrewFeatureDoctor/Resources/Localizable.xcstrings +++ b/Sources/BrewFeatureDoctor/Resources/Localizable.xcstrings @@ -1,416 +1,200 @@ { - "sourceLanguage": "en", - "strings": { - "Copy brew doctor output": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Copy brew doctor output" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Copiar saída do brew doctor" - } - } + "sourceLanguage" : "en", + "strings" : { + "Copy brew doctor output" : { + "comment" : "VoiceOver label for the header's copy button; describes the action fully since VoiceOver users don't see the adjacent brew doctor context that the shorter visible title relies on.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Copy brew doctor output" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Copiar saída do brew doctor" } } + } + }, + "Copy output" : { + "comment" : "Visible title of the header button that copies the raw brew doctor output to the clipboard.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Copy output" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Copiar saída" } } + } + }, + "Danger" : { + "comment" : "Display name for the .danger case of DoctorSeverity, shown as a severity badge/label on issue rows and details.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Danger" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Perigo" } } + } + }, + "Doctor" : { + "comment" : "Both the Doctor tab/pane title and the name of the brew doctor CLI command; identical in Portuguese because it names the command itself, not a description of it.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Doctor" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Doctor" } } + } + }, + "Doctor issues" : { + "comment" : "VoiceOver label for the issues list in the Doctor pane.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Doctor issues" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Problemas do Doctor" } } + } + }, + "Fix available" : { + "comment" : "Hint label shown on an issue row when brew doctor supplied a runnable fix for that issue.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Fix available" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Correção disponível" } } + } + }, + "Last checked" : { + "comment" : "Lead phrase before the relative timestamp of the most recent brew doctor run, passed to LastUpdatedLabel.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Last checked" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Verificado" } } + } + }, + "Needs admin · runs in Terminal" : { + "comment" : "Label on a fix's command block warning that it needs administrator privileges and opens Terminal to run; the middle dot (·, U+00B7) is a visual separator between the two clauses, not a translatable word.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Needs admin · runs in Terminal" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Requer administrador · executa no Terminal" } } + } + }, + "No problems found" : { + "comment" : "Header subtitle shown at rest when the most recent brew doctor run reported no issues.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "No problems found" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Nenhum problema encontrado" } } + } + }, + "No selection" : { + "comment" : "Placeholder title shown in the detail pane before any issue is selected in the list.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "No selection" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Nada selecionado" } } + } + }, + "Raw output" : { + "comment" : "Title of the collapsible block that shows the verbatim brew doctor console output for the selected issue.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Raw output" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Saída bruta" } } + } + }, + "Re-checking" : { + "comment" : "VoiceOver label on the small spinner shown in the header while a background re-check runs. generatesSymbol is false because Xcode's GenerateStringSymbols build phase strips punctuation from keys, so this and the Re-checking… entry below would otherwise collide on the same generated identifier.", + "extractionState" : "manual", + "generatesSymbol" : false, + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Re-checking" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Verificando novamente" } } + } + }, + "Re-checking…" : { + "comment" : "Header subtitle shown while a background re-check runs on top of a previously loaded report. generatesSymbol is false for the same reason as the Re-checking entry above: stripping punctuation makes the two keys generate an identical identifier.", + "extractionState" : "manual", + "generatesSymbol" : false, + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Re-checking…" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Verificando novamente…" } } } }, - "Copy output": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Copy output" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Copiar saída" - } - } + "Run Again" : { + "comment" : "Header button title that re-runs brew doctor from scratch.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Run Again" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Executar novamente" } } } }, - "Danger": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Danger" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Perigo" - } - } + "Run Fix" : { + "comment" : "Title and VoiceOver label of the button that applies an issue's suggested automated fix.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Run Fix" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Aplicar correção" } } } }, - "Doctor": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Doctor" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Doctor" - } - } + "Run diagnostics, then choose an issue to see details and fixes." : { + "comment" : "Placeholder body text in the detail pane, shown under the No selection title before any issue is chosen.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Run diagnostics, then choose an issue to see details and fixes." } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Execute o diagnóstico e escolha um problema para ver detalhes e correções." } } } }, - "Doctor issues": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Doctor issues" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Problemas do Doctor" - } - } + "Running brew doctor…" : { + "comment" : "Header subtitle shown while the initial brew doctor run is still in progress.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Running brew doctor…" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Executando brew doctor…" } } } }, - "Fix available": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Fix available" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Correção disponível" - } - } + "Severity: %@" : { + "comment" : "VoiceOver label prefixing an issue's severity name, e.g. Severity: Warning; %@ is filled with the localized value produced by DoctorSeverityStyle.displayName(_:).", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Severity: %@" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Severidade: %@" } } } }, - "Last checked": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Last checked" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Verificado" - } - } + "The check could not be completed" : { + "comment" : "Header subtitle shown when the brew doctor run fails.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "The check could not be completed" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Não foi possível concluir a verificação" } } } }, - "Needs admin · runs in Terminal": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Needs admin · runs in Terminal" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Requer administrador · executa no Terminal" - } - } + "Unsupported" : { + "comment" : "Display name for the .unsupported case of DoctorSeverity, shown as a severity badge/label on issue rows and details.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Unsupported" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Sem suporte" } } } }, - "No problems found": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "No problems found" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Nenhum problema encontrado" - } - } + "Warning" : { + "comment" : "Display name for the .caution case of DoctorSeverity; brew itself calls this severity a warning, so the label reads Warning rather than Caution.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Warning" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Aviso" } } } }, - "No selection": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "No selection" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Nada selecionado" - } - } + "Warnings found" : { + "comment" : "Header subtitle shown at rest when the most recent brew doctor run reported outstanding issues.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Warnings found" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Avisos encontrados" } } } }, - "Raw output": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Raw output" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Saída bruta" - } - } + "Your system is ready to brew" : { + "comment" : "Headline shown in the healthy empty state, above the brew doctor found no problems. body text.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Your system is ready to brew" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Seu sistema está pronto pro brew" } } } }, - "Re-checking": { - "extractionState": "manual", - "generatesSymbol": false, - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Re-checking" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Verificando novamente" - } - } - } - }, - "Re-checking…": { - "extractionState": "manual", - "generatesSymbol": false, - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Re-checking…" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Verificando novamente…" - } - } - } - }, - "Run Again": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Run Again" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Executar novamente" - } - } - } - }, - "Run diagnostics, then choose an issue to see details and fixes.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Run diagnostics, then choose an issue to see details and fixes." - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Execute o diagnóstico e escolha um problema para ver detalhes e correções." - } - } - } - }, - "Run Fix": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Run Fix" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Aplicar correção" - } - } - } - }, - "Running brew doctor…": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Running brew doctor…" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Executando brew doctor…" - } - } - } - }, - "Severity: %@": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Severity: %@" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Severidade: %@" - } - } - } - }, - "The check could not be completed": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "The check could not be completed" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Não foi possível concluir a verificação" - } - } - } - }, - "Unsupported": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Unsupported" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Sem suporte" - } - } - } - }, - "Warning": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Warning" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Aviso" - } - } - } - }, - "Warnings found": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Warnings found" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Avisos encontrados" - } - } - } - }, - "Your system is ready to brew": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Your system is ready to brew" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Seu sistema está pronto pro brew" - } - } - } - }, - "brew doctor found no problems.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "brew doctor found no problems." - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "O brew doctor não encontrou problemas." - } - } + "brew doctor found no problems." : { + "comment" : "Body text in the healthy empty state, directly under Your system is ready to brew; distinct from the untranslated DoctorCopy.warningPreamble, which must match brew's own CLI wording verbatim.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "brew doctor found no problems." } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "O brew doctor não encontrou problemas." } } } } }, - "version": "1.0" + "version" : "1.0" } From b2837d001846313a6c09d5f4a19de1fb16e691ff Mon Sep 17 00:00:00 2001 From: "Jackson F. de A. Mafra" <885385+jacksonfdam@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:01:19 +0200 Subject: [PATCH 05/22] Reorder Doctor string catalogue to case-insensitive alphabetical order --- .../Resources/Localizable.xcstrings | 586 ++++++++++++------ 1 file changed, 413 insertions(+), 173 deletions(-) diff --git a/Sources/BrewFeatureDoctor/Resources/Localizable.xcstrings b/Sources/BrewFeatureDoctor/Resources/Localizable.xcstrings index f6c60c3b..2f8e8ad8 100644 --- a/Sources/BrewFeatureDoctor/Resources/Localizable.xcstrings +++ b/Sources/BrewFeatureDoctor/Resources/Localizable.xcstrings @@ -1,200 +1,440 @@ { - "sourceLanguage" : "en", - "strings" : { - "Copy brew doctor output" : { - "comment" : "VoiceOver label for the header's copy button; describes the action fully since VoiceOver users don't see the adjacent brew doctor context that the shorter visible title relies on.", - "extractionState" : "manual", - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Copy brew doctor output" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Copiar saída do brew doctor" } } - } - }, - "Copy output" : { - "comment" : "Visible title of the header button that copies the raw brew doctor output to the clipboard.", - "extractionState" : "manual", - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Copy output" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Copiar saída" } } - } - }, - "Danger" : { - "comment" : "Display name for the .danger case of DoctorSeverity, shown as a severity badge/label on issue rows and details.", - "extractionState" : "manual", - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Danger" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Perigo" } } - } - }, - "Doctor" : { - "comment" : "Both the Doctor tab/pane title and the name of the brew doctor CLI command; identical in Portuguese because it names the command itself, not a description of it.", - "extractionState" : "manual", - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Doctor" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Doctor" } } - } - }, - "Doctor issues" : { - "comment" : "VoiceOver label for the issues list in the Doctor pane.", - "extractionState" : "manual", - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Doctor issues" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Problemas do Doctor" } } - } - }, - "Fix available" : { - "comment" : "Hint label shown on an issue row when brew doctor supplied a runnable fix for that issue.", - "extractionState" : "manual", - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Fix available" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Correção disponível" } } - } - }, - "Last checked" : { - "comment" : "Lead phrase before the relative timestamp of the most recent brew doctor run, passed to LastUpdatedLabel.", - "extractionState" : "manual", - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Last checked" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Verificado" } } - } - }, - "Needs admin · runs in Terminal" : { - "comment" : "Label on a fix's command block warning that it needs administrator privileges and opens Terminal to run; the middle dot (·, U+00B7) is a visual separator between the two clauses, not a translatable word.", - "extractionState" : "manual", - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Needs admin · runs in Terminal" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Requer administrador · executa no Terminal" } } - } - }, - "No problems found" : { - "comment" : "Header subtitle shown at rest when the most recent brew doctor run reported no issues.", - "extractionState" : "manual", - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "No problems found" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Nenhum problema encontrado" } } - } - }, - "No selection" : { - "comment" : "Placeholder title shown in the detail pane before any issue is selected in the list.", - "extractionState" : "manual", - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "No selection" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Nada selecionado" } } - } - }, - "Raw output" : { - "comment" : "Title of the collapsible block that shows the verbatim brew doctor console output for the selected issue.", - "extractionState" : "manual", - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Raw output" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Saída bruta" } } - } - }, - "Re-checking" : { - "comment" : "VoiceOver label on the small spinner shown in the header while a background re-check runs. generatesSymbol is false because Xcode's GenerateStringSymbols build phase strips punctuation from keys, so this and the Re-checking… entry below would otherwise collide on the same generated identifier.", - "extractionState" : "manual", - "generatesSymbol" : false, - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Re-checking" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Verificando novamente" } } - } - }, - "Re-checking…" : { - "comment" : "Header subtitle shown while a background re-check runs on top of a previously loaded report. generatesSymbol is false for the same reason as the Re-checking entry above: stripping punctuation makes the two keys generate an identical identifier.", - "extractionState" : "manual", - "generatesSymbol" : false, - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Re-checking…" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Verificando novamente…" } } + "sourceLanguage": "en", + "strings": { + "brew doctor found no problems.": { + "comment": "Body text in the healthy empty state, directly under Your system is ready to brew; distinct from the untranslated DoctorCopy.warningPreamble, which must match brew's own CLI wording verbatim.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "brew doctor found no problems." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O brew doctor não encontrou problemas." + } + } } }, - "Run Again" : { - "comment" : "Header button title that re-runs brew doctor from scratch.", - "extractionState" : "manual", - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Run Again" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Executar novamente" } } + "Copy brew doctor output": { + "comment": "VoiceOver label for the header's copy button; describes the action fully since VoiceOver users don't see the adjacent brew doctor context that the shorter visible title relies on.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy brew doctor output" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Copiar saída do brew doctor" + } + } } }, - "Run Fix" : { - "comment" : "Title and VoiceOver label of the button that applies an issue's suggested automated fix.", - "extractionState" : "manual", - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Run Fix" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Aplicar correção" } } + "Copy output": { + "comment": "Visible title of the header button that copies the raw brew doctor output to the clipboard.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy output" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Copiar saída" + } + } } }, - "Run diagnostics, then choose an issue to see details and fixes." : { - "comment" : "Placeholder body text in the detail pane, shown under the No selection title before any issue is chosen.", - "extractionState" : "manual", - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Run diagnostics, then choose an issue to see details and fixes." } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Execute o diagnóstico e escolha um problema para ver detalhes e correções." } } + "Danger": { + "comment": "Display name for the .danger case of DoctorSeverity, shown as a severity badge/label on issue rows and details.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Danger" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Perigo" + } + } } }, - "Running brew doctor…" : { - "comment" : "Header subtitle shown while the initial brew doctor run is still in progress.", - "extractionState" : "manual", - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Running brew doctor…" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Executando brew doctor…" } } + "Doctor": { + "comment": "Both the Doctor tab/pane title and the name of the brew doctor CLI command; identical in Portuguese because it names the command itself, not a description of it.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Doctor" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Doctor" + } + } } }, - "Severity: %@" : { - "comment" : "VoiceOver label prefixing an issue's severity name, e.g. Severity: Warning; %@ is filled with the localized value produced by DoctorSeverityStyle.displayName(_:).", - "extractionState" : "manual", - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Severity: %@" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Severidade: %@" } } + "Doctor issues": { + "comment": "VoiceOver label for the issues list in the Doctor pane.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Doctor issues" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Problemas do Doctor" + } + } } }, - "The check could not be completed" : { - "comment" : "Header subtitle shown when the brew doctor run fails.", - "extractionState" : "manual", - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "The check could not be completed" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Não foi possível concluir a verificação" } } + "Fix available": { + "comment": "Hint label shown on an issue row when brew doctor supplied a runnable fix for that issue.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Fix available" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Correção disponível" + } + } } }, - "Unsupported" : { - "comment" : "Display name for the .unsupported case of DoctorSeverity, shown as a severity badge/label on issue rows and details.", - "extractionState" : "manual", - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Unsupported" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Sem suporte" } } + "Last checked": { + "comment": "Lead phrase before the relative timestamp of the most recent brew doctor run, passed to LastUpdatedLabel.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Last checked" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Verificado" + } + } } }, - "Warning" : { - "comment" : "Display name for the .caution case of DoctorSeverity; brew itself calls this severity a warning, so the label reads Warning rather than Caution.", - "extractionState" : "manual", - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Warning" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Aviso" } } + "Needs admin · runs in Terminal": { + "comment": "Label on a fix's command block warning that it needs administrator privileges and opens Terminal to run; the middle dot (·, U+00B7) is a visual separator between the two clauses, not a translatable word.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Needs admin · runs in Terminal" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Requer administrador · executa no Terminal" + } + } } }, - "Warnings found" : { - "comment" : "Header subtitle shown at rest when the most recent brew doctor run reported outstanding issues.", - "extractionState" : "manual", - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Warnings found" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Avisos encontrados" } } + "No problems found": { + "comment": "Header subtitle shown at rest when the most recent brew doctor run reported no issues.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No problems found" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nenhum problema encontrado" + } + } } }, - "Your system is ready to brew" : { - "comment" : "Headline shown in the healthy empty state, above the brew doctor found no problems. body text.", - "extractionState" : "manual", - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Your system is ready to brew" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Seu sistema está pronto pro brew" } } + "No selection": { + "comment": "Placeholder title shown in the detail pane before any issue is selected in the list.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No selection" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nada selecionado" + } + } } }, - "brew doctor found no problems." : { - "comment" : "Body text in the healthy empty state, directly under Your system is ready to brew; distinct from the untranslated DoctorCopy.warningPreamble, which must match brew's own CLI wording verbatim.", - "extractionState" : "manual", - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "brew doctor found no problems." } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "O brew doctor não encontrou problemas." } } + "Raw output": { + "comment": "Title of the collapsible block that shows the verbatim brew doctor console output for the selected issue.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Raw output" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Saída bruta" + } + } + } + }, + "Re-checking": { + "comment": "VoiceOver label on the small spinner shown in the header while a background re-check runs. generatesSymbol is false because Xcode's GenerateStringSymbols build phase strips punctuation from keys, so this and the Re-checking… entry below would otherwise collide on the same generated identifier.", + "extractionState": "manual", + "generatesSymbol": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Re-checking" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Verificando novamente" + } + } + } + }, + "Re-checking…": { + "comment": "Header subtitle shown while a background re-check runs on top of a previously loaded report. generatesSymbol is false for the same reason as the Re-checking entry above: stripping punctuation makes the two keys generate an identical identifier.", + "extractionState": "manual", + "generatesSymbol": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Re-checking…" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Verificando novamente…" + } + } + } + }, + "Run Again": { + "comment": "Header button title that re-runs brew doctor from scratch.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Run Again" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Executar novamente" + } + } + } + }, + "Run diagnostics, then choose an issue to see details and fixes.": { + "comment": "Placeholder body text in the detail pane, shown under the No selection title before any issue is chosen.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Run diagnostics, then choose an issue to see details and fixes." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Execute o diagnóstico e escolha um problema para ver detalhes e correções." + } + } + } + }, + "Run Fix": { + "comment": "Title and VoiceOver label of the button that applies an issue's suggested automated fix.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Run Fix" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Aplicar correção" + } + } + } + }, + "Running brew doctor…": { + "comment": "Header subtitle shown while the initial brew doctor run is still in progress.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Running brew doctor…" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Executando brew doctor…" + } + } + } + }, + "Severity: %@": { + "comment": "VoiceOver label prefixing an issue's severity name, e.g. Severity: Warning; %@ is filled with the localized value produced by DoctorSeverityStyle.displayName(_:).", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Severity: %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Severidade: %@" + } + } + } + }, + "The check could not be completed": { + "comment": "Header subtitle shown when the brew doctor run fails.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The check could not be completed" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível concluir a verificação" + } + } + } + }, + "Unsupported": { + "comment": "Display name for the .unsupported case of DoctorSeverity, shown as a severity badge/label on issue rows and details.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unsupported" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Sem suporte" + } + } + } + }, + "Warning": { + "comment": "Display name for the .caution case of DoctorSeverity; brew itself calls this severity a warning, so the label reads Warning rather than Caution.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Warning" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Aviso" + } + } + } + }, + "Warnings found": { + "comment": "Header subtitle shown at rest when the most recent brew doctor run reported outstanding issues.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Warnings found" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Avisos encontrados" + } + } + } + }, + "Your system is ready to brew": { + "comment": "Headline shown in the healthy empty state, above the brew doctor found no problems. body text.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your system is ready to brew" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Seu sistema está pronto pro brew" + } + } } } }, - "version" : "1.0" + "version": "1.0" } From e65b5ee6ceaab9463833d7e9fea65b20fbdb772e Mon Sep 17 00:00:00 2001 From: "Jackson F. de A. Mafra" <885385+jacksonfdam@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:01:22 +0200 Subject: [PATCH 06/22] Reformat Doctor string catalog to match Xcode pretty-print style 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. --- .../Resources/Localizable.xcstrings | 586 ++++++------------ 1 file changed, 173 insertions(+), 413 deletions(-) diff --git a/Sources/BrewFeatureDoctor/Resources/Localizable.xcstrings b/Sources/BrewFeatureDoctor/Resources/Localizable.xcstrings index 2f8e8ad8..b08c0102 100644 --- a/Sources/BrewFeatureDoctor/Resources/Localizable.xcstrings +++ b/Sources/BrewFeatureDoctor/Resources/Localizable.xcstrings @@ -1,440 +1,200 @@ { - "sourceLanguage": "en", - "strings": { - "brew doctor found no problems.": { - "comment": "Body text in the healthy empty state, directly under Your system is ready to brew; distinct from the untranslated DoctorCopy.warningPreamble, which must match brew's own CLI wording verbatim.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "brew doctor found no problems." - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "O brew doctor não encontrou problemas." - } - } + "sourceLanguage" : "en", + "strings" : { + "brew doctor found no problems." : { + "comment" : "Body text in the healthy empty state, directly under Your system is ready to brew; distinct from the untranslated DoctorCopy.warningPreamble, which must match brew's own CLI wording verbatim.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "brew doctor found no problems." } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "O brew doctor não encontrou problemas." } } + } + }, + "Copy brew doctor output" : { + "comment" : "VoiceOver label for the header's copy button; describes the action fully since VoiceOver users don't see the adjacent brew doctor context that the shorter visible title relies on.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Copy brew doctor output" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Copiar saída do brew doctor" } } + } + }, + "Copy output" : { + "comment" : "Visible title of the header button that copies the raw brew doctor output to the clipboard.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Copy output" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Copiar saída" } } + } + }, + "Danger" : { + "comment" : "Display name for the .danger case of DoctorSeverity, shown as a severity badge/label on issue rows and details.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Danger" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Perigo" } } + } + }, + "Doctor" : { + "comment" : "Both the Doctor tab/pane title and the name of the brew doctor CLI command; identical in Portuguese because it names the command itself, not a description of it.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Doctor" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Doctor" } } + } + }, + "Doctor issues" : { + "comment" : "VoiceOver label for the issues list in the Doctor pane.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Doctor issues" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Problemas do Doctor" } } + } + }, + "Fix available" : { + "comment" : "Hint label shown on an issue row when brew doctor supplied a runnable fix for that issue.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Fix available" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Correção disponível" } } + } + }, + "Last checked" : { + "comment" : "Lead phrase before the relative timestamp of the most recent brew doctor run, passed to LastUpdatedLabel.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Last checked" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Verificado" } } + } + }, + "Needs admin · runs in Terminal" : { + "comment" : "Label on a fix's command block warning that it needs administrator privileges and opens Terminal to run; the middle dot (·, U+00B7) is a visual separator between the two clauses, not a translatable word.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Needs admin · runs in Terminal" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Requer administrador · executa no Terminal" } } + } + }, + "No problems found" : { + "comment" : "Header subtitle shown at rest when the most recent brew doctor run reported no issues.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "No problems found" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Nenhum problema encontrado" } } + } + }, + "No selection" : { + "comment" : "Placeholder title shown in the detail pane before any issue is selected in the list.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "No selection" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Nada selecionado" } } + } + }, + "Raw output" : { + "comment" : "Title of the collapsible block that shows the verbatim brew doctor console output for the selected issue.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Raw output" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Saída bruta" } } + } + }, + "Re-checking" : { + "comment" : "VoiceOver label on the small spinner shown in the header while a background re-check runs. generatesSymbol is false because Xcode's GenerateStringSymbols build phase strips punctuation from keys, so this and the Re-checking… entry below would otherwise collide on the same generated identifier.", + "extractionState" : "manual", + "generatesSymbol" : false, + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Re-checking" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Verificando novamente" } } } }, - "Copy brew doctor output": { - "comment": "VoiceOver label for the header's copy button; describes the action fully since VoiceOver users don't see the adjacent brew doctor context that the shorter visible title relies on.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Copy brew doctor output" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Copiar saída do brew doctor" - } - } + "Re-checking…" : { + "comment" : "Header subtitle shown while a background re-check runs on top of a previously loaded report. generatesSymbol is false for the same reason as the Re-checking entry above: stripping punctuation makes the two keys generate an identical identifier.", + "extractionState" : "manual", + "generatesSymbol" : false, + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Re-checking…" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Verificando novamente…" } } } }, - "Copy output": { - "comment": "Visible title of the header button that copies the raw brew doctor output to the clipboard.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Copy output" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Copiar saída" - } - } + "Run Again" : { + "comment" : "Header button title that re-runs brew doctor from scratch.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Run Again" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Executar novamente" } } } }, - "Danger": { - "comment": "Display name for the .danger case of DoctorSeverity, shown as a severity badge/label on issue rows and details.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Danger" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Perigo" - } - } + "Run diagnostics, then choose an issue to see details and fixes." : { + "comment" : "Placeholder body text in the detail pane, shown under the No selection title before any issue is chosen.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Run diagnostics, then choose an issue to see details and fixes." } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Execute o diagnóstico e escolha um problema para ver detalhes e correções." } } } }, - "Doctor": { - "comment": "Both the Doctor tab/pane title and the name of the brew doctor CLI command; identical in Portuguese because it names the command itself, not a description of it.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Doctor" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Doctor" - } - } + "Run Fix" : { + "comment" : "Title and VoiceOver label of the button that applies an issue's suggested automated fix.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Run Fix" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Aplicar correção" } } } }, - "Doctor issues": { - "comment": "VoiceOver label for the issues list in the Doctor pane.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Doctor issues" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Problemas do Doctor" - } - } + "Running brew doctor…" : { + "comment" : "Header subtitle shown while the initial brew doctor run is still in progress.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Running brew doctor…" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Executando brew doctor…" } } } }, - "Fix available": { - "comment": "Hint label shown on an issue row when brew doctor supplied a runnable fix for that issue.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Fix available" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Correção disponível" - } - } + "Severity: %@" : { + "comment" : "VoiceOver label prefixing an issue's severity name, e.g. Severity: Warning; %@ is filled with the localized value produced by DoctorSeverityStyle.displayName(_:).", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Severity: %@" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Severidade: %@" } } } }, - "Last checked": { - "comment": "Lead phrase before the relative timestamp of the most recent brew doctor run, passed to LastUpdatedLabel.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Last checked" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Verificado" - } - } + "The check could not be completed" : { + "comment" : "Header subtitle shown when the brew doctor run fails.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "The check could not be completed" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Não foi possível concluir a verificação" } } } }, - "Needs admin · runs in Terminal": { - "comment": "Label on a fix's command block warning that it needs administrator privileges and opens Terminal to run; the middle dot (·, U+00B7) is a visual separator between the two clauses, not a translatable word.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Needs admin · runs in Terminal" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Requer administrador · executa no Terminal" - } - } + "Unsupported" : { + "comment" : "Display name for the .unsupported case of DoctorSeverity, shown as a severity badge/label on issue rows and details.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Unsupported" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Sem suporte" } } } }, - "No problems found": { - "comment": "Header subtitle shown at rest when the most recent brew doctor run reported no issues.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "No problems found" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Nenhum problema encontrado" - } - } + "Warning" : { + "comment" : "Display name for the .caution case of DoctorSeverity; brew itself calls this severity a warning, so the label reads Warning rather than Caution.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Warning" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Aviso" } } } }, - "No selection": { - "comment": "Placeholder title shown in the detail pane before any issue is selected in the list.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "No selection" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Nada selecionado" - } - } + "Warnings found" : { + "comment" : "Header subtitle shown at rest when the most recent brew doctor run reported outstanding issues.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Warnings found" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Avisos encontrados" } } } }, - "Raw output": { - "comment": "Title of the collapsible block that shows the verbatim brew doctor console output for the selected issue.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Raw output" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Saída bruta" - } - } - } - }, - "Re-checking": { - "comment": "VoiceOver label on the small spinner shown in the header while a background re-check runs. generatesSymbol is false because Xcode's GenerateStringSymbols build phase strips punctuation from keys, so this and the Re-checking… entry below would otherwise collide on the same generated identifier.", - "extractionState": "manual", - "generatesSymbol": false, - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Re-checking" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Verificando novamente" - } - } - } - }, - "Re-checking…": { - "comment": "Header subtitle shown while a background re-check runs on top of a previously loaded report. generatesSymbol is false for the same reason as the Re-checking entry above: stripping punctuation makes the two keys generate an identical identifier.", - "extractionState": "manual", - "generatesSymbol": false, - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Re-checking…" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Verificando novamente…" - } - } - } - }, - "Run Again": { - "comment": "Header button title that re-runs brew doctor from scratch.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Run Again" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Executar novamente" - } - } - } - }, - "Run diagnostics, then choose an issue to see details and fixes.": { - "comment": "Placeholder body text in the detail pane, shown under the No selection title before any issue is chosen.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Run diagnostics, then choose an issue to see details and fixes." - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Execute o diagnóstico e escolha um problema para ver detalhes e correções." - } - } - } - }, - "Run Fix": { - "comment": "Title and VoiceOver label of the button that applies an issue's suggested automated fix.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Run Fix" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Aplicar correção" - } - } - } - }, - "Running brew doctor…": { - "comment": "Header subtitle shown while the initial brew doctor run is still in progress.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Running brew doctor…" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Executando brew doctor…" - } - } - } - }, - "Severity: %@": { - "comment": "VoiceOver label prefixing an issue's severity name, e.g. Severity: Warning; %@ is filled with the localized value produced by DoctorSeverityStyle.displayName(_:).", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Severity: %@" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Severidade: %@" - } - } - } - }, - "The check could not be completed": { - "comment": "Header subtitle shown when the brew doctor run fails.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "The check could not be completed" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Não foi possível concluir a verificação" - } - } - } - }, - "Unsupported": { - "comment": "Display name for the .unsupported case of DoctorSeverity, shown as a severity badge/label on issue rows and details.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Unsupported" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Sem suporte" - } - } - } - }, - "Warning": { - "comment": "Display name for the .caution case of DoctorSeverity; brew itself calls this severity a warning, so the label reads Warning rather than Caution.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Warning" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Aviso" - } - } - } - }, - "Warnings found": { - "comment": "Header subtitle shown at rest when the most recent brew doctor run reported outstanding issues.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Warnings found" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Avisos encontrados" - } - } - } - }, - "Your system is ready to brew": { - "comment": "Headline shown in the healthy empty state, above the brew doctor found no problems. body text.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Your system is ready to brew" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Seu sistema está pronto pro brew" - } - } + "Your system is ready to brew" : { + "comment" : "Headline shown in the healthy empty state, above the brew doctor found no problems. body text.", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Your system is ready to brew" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Seu sistema está pronto pro brew" } } } } }, - "version": "1.0" + "version" : "1.0" } From d364aff079a24344f73f38e22bedba7c6ebc565e Mon Sep 17 00:00:00 2001 From: "Jackson F. de A. Mafra" <885385+jacksonfdam@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:01:26 +0200 Subject: [PATCH 07/22] Fail the build on an untranslated string catalogue entry --- Homebrew/Localizable.xcstrings | 6 + .../StringCatalogueCompletenessTests.swift | 124 ++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 Homebrew/Localizable.xcstrings create mode 100644 Tests/BrewUIComponentsTests/StringCatalogueCompletenessTests.swift diff --git a/Homebrew/Localizable.xcstrings b/Homebrew/Localizable.xcstrings new file mode 100644 index 00000000..00ebfd34 --- /dev/null +++ b/Homebrew/Localizable.xcstrings @@ -0,0 +1,6 @@ +{ + "sourceLanguage" : "en", + "strings" : { + }, + "version" : "1.0" +} diff --git a/Tests/BrewUIComponentsTests/StringCatalogueCompletenessTests.swift b/Tests/BrewUIComponentsTests/StringCatalogueCompletenessTests.swift new file mode 100644 index 00000000..560a4284 --- /dev/null +++ b/Tests/BrewUIComponentsTests/StringCatalogueCompletenessTests.swift @@ -0,0 +1,124 @@ +// +// StringCatalogueCompletenessTests.swift +// BrewUIComponentsTests +// + +import Foundation +import Testing + +/// Every catalogue in the package must carry a translated `pt-BR` entry for every source string. +/// A missing translation is invisible at runtime — the key is returned, so the view renders English +/// inside an otherwise Portuguese screen. This is the only thing that makes it a build failure. +/// +/// Reads the catalogue sources from the repository rather than a bundle: `.xcstrings` is compiled +/// into `.lproj` directories, so the authored JSON never reaches the test bundle. +struct StringCatalogueCompletenessTests { + private static let requiredLanguage = "pt-BR" + + private static var packageRoot: URL { + // .../Tests/BrewUIComponentsTests/ThisFile.swift → package root + URL(filePath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + } + + /// Flattened as siblings rather than nested inside `Catalogue`/`Localization`: swiftlint's + /// nesting rule caps types at one level deep, and this file already spends that level on + /// `Catalogue` itself. + private struct Catalogue: Decodable { + let sourceLanguage: String + let strings: [String: CatalogueEntry] + } + + private struct CatalogueEntry: Decodable { + let localizations: [String: CatalogueLocalization]? + } + + private struct CatalogueLocalization: Decodable { + let stringUnit: CatalogueStringUnit? + let variations: CatalogueVariations? + + /// A plural entry has no top-level `stringUnit`; every one of its cases must be translated. + var isTranslated: Bool { + if let stringUnit { + return stringUnit.state == "translated" + } + guard let cases = variations?.plural, !cases.isEmpty else { + return false + } + return cases.values.allSatisfy { $0.stringUnit.state == "translated" } + } + } + + private struct CatalogueStringUnit: Decodable { + let state: String + } + + private struct CatalogueVariations: Decodable { + let plural: [String: CataloguePluralCase]? + } + + private struct CataloguePluralCase: Decodable { + let stringUnit: CatalogueStringUnit + } + + /// The app target's catalogue sits outside `Sources/`, so it is named rather than walked to. + /// Widening the walk to the repository root would also sweep `Tests/` and any future fixture + /// catalogue — a bigger blast radius than one known path deserves. + private static let appTargetCatalogue = "Homebrew/Localizable.xcstrings" + + private static func catalogueURLs() throws -> [URL] { + let sources = packageRoot.appending(path: "Sources") + let enumerator = FileManager.default.enumerator( + at: sources, + includingPropertiesForKeys: nil, + ) + var found = enumerator? + .compactMap { $0 as? URL } + .filter { $0.lastPathComponent == "Localizable.xcstrings" } ?? [] + + // Named, not discovered — so a moved or deleted app catalogue fails here instead of + // quietly dropping out of the checked set. + let appCatalogue = packageRoot.appending(path: appTargetCatalogue) + guard FileManager.default.fileExists(atPath: appCatalogue.path) else { + throw CatalogueNotFound(path: appTargetCatalogue) + } + found.append(appCatalogue) + + return found.sorted { $0.path < $1.path } + } + + private struct CatalogueNotFound: Error, CustomStringConvertible { + let path: String + var description: String { + "Expected a String Catalog at \(path). If it moved, update appTargetCatalogue." + } + } + + @Test func `every catalogue string has a translated pt-BR entry`() throws { + var untranslated: [String] = [] + + for url in try Self.catalogueURLs() { + let catalogue = try JSONDecoder().decode(Catalogue.self, from: Data(contentsOf: url)) + let module = url.deletingLastPathComponent().deletingLastPathComponent().lastPathComponent + + for (key, entry) in catalogue.strings { + let localization = entry.localizations?[Self.requiredLanguage] + if localization?.isTranslated != true { + untranslated.append("\(module): \(key)") + } + } + } + + #expect(untranslated.sorted() == []) + } + + /// A catalogue that declares a different source language would silently change what the keys mean. + @Test func `every catalogue declares English as its source language`() throws { + let languages = try Self.catalogueURLs().map { url in + try JSONDecoder().decode(Catalogue.self, from: Data(contentsOf: url)).sourceLanguage + } + #expect(Set(languages).subtracting(["en"]) == []) + } +} From 87cb9849f3edc16551f31cb6467cb0fa736a26bc Mon Sep 17 00:00:00 2001 From: "Jackson F. de A. Mafra" <885385+jacksonfdam@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:01:29 +0200 Subject: [PATCH 08/22] Match the Doctor healthy state by identifier instead of copy --- BrewUITests/Screens/DoctorScreen.swift | 5 +- Sources/BrewAccessibilityID/AXID.swift | 2 + .../BrewFeatureDoctor/Views/DoctorView.swift | 2 + .../BrewAccessibilityIDTests/AXIDTests.swift | 79 +++++++------------ 4 files changed, 36 insertions(+), 52 deletions(-) diff --git a/BrewUITests/Screens/DoctorScreen.swift b/BrewUITests/Screens/DoctorScreen.swift index 2ce8de32..e028e79b 100644 --- a/BrewUITests/Screens/DoctorScreen.swift +++ b/BrewUITests/Screens/DoctorScreen.swift @@ -43,14 +43,15 @@ struct DoctorScreen: Screen { return self } - /// `DoctorReport.placeholder` is not healthy, so this text exists only once `brew doctor` has run. + /// `DoctorReport.placeholder` is not healthy, so this element exists only once `brew doctor` has + /// run. Matched by identifier rather than copy so the assertion holds in every localization. @discardableResult func assertIsHealthy( timeout: TimeInterval = BrewUITestTimeout.command, file: StaticString = #filePath, line: UInt = #line, ) -> Self { - let healthy = root.element.staticTexts["Your system is ready to brew"] + let healthy = root.element.otherElements[AXID.doctorHealthyState.rawValue] guard healthy.waitForExistence(timeout: timeout) else { XCTFail( """ diff --git a/Sources/BrewAccessibilityID/AXID.swift b/Sources/BrewAccessibilityID/AXID.swift index d201003e..ddd2933b 100644 --- a/Sources/BrewAccessibilityID/AXID.swift +++ b/Sources/BrewAccessibilityID/AXID.swift @@ -35,6 +35,7 @@ public enum AXID: Hashable, Sendable { // Config / Doctor case configScreen case doctorScreen + case doctorHealthyState case brewNotFoundState // Detail / Console @@ -76,6 +77,7 @@ public enum AXID: Hashable, Sendable { case let .discoverRow(token): "discover.row.\(token)" case .configScreen: "config.screen" case .doctorScreen: "doctor.screen" + case .doctorHealthyState: "doctor.healthy" case .brewNotFoundState: "brew.not.found" case .packageDetail: "package.detail" case .installButton: "detail.install" diff --git a/Sources/BrewFeatureDoctor/Views/DoctorView.swift b/Sources/BrewFeatureDoctor/Views/DoctorView.swift index 94bde4cd..bfb60769 100644 --- a/Sources/BrewFeatureDoctor/Views/DoctorView.swift +++ b/Sources/BrewFeatureDoctor/Views/DoctorView.swift @@ -104,6 +104,8 @@ struct DoctorView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) .padding(BrewSpacing.xl) + .accessibilityElement(children: .contain) + .axid(.doctorHealthyState) } private func issuesList(groups: [DoctorIssueGroup]) -> some View { diff --git a/Tests/BrewAccessibilityIDTests/AXIDTests.swift b/Tests/BrewAccessibilityIDTests/AXIDTests.swift index 51577daf..1286fd0f 100644 --- a/Tests/BrewAccessibilityIDTests/AXIDTests.swift +++ b/Tests/BrewAccessibilityIDTests/AXIDTests.swift @@ -10,58 +10,37 @@ import Testing /// an expensive, flaky-looking failure; a diff here is a cheap, obvious one — so identifier drift /// should break this suite first. struct AXIDTests { + /// Case and expected rawValue live on one line, rather than in two parallel arrays kept in sync by + /// position, so this stays under swiftlint's function_body_length even as the identifier list grows. @Test func `static identifiers keep their wire format`() { - let identifiers = [ - AXID.sidebar, - .installedScreen, - .installedList, - .installedSearchField, - .upgradesScreen, - .upgradesList, - .upgradesRefreshButton, - .discoverScreen, - .discoverSearchField, - .discoverList, - .configScreen, - .doctorScreen, - .brewNotFoundState, - .packageDetail, - .installButton, - .uninstallButton, - .upgradeButton, - .console, - .consoleStatus, - .consoleToggle, - .consoleOutput, - .errorState, - .errorRetryButton, - ].map(\.rawValue) + let pairs: [(AXID, String)] = [ + (.sidebar, "sidebar"), + (.installedScreen, "installed.screen"), + (.installedList, "installed.list"), + (.installedSearchField, "installed.search"), + (.upgradesScreen, "upgrades.screen"), + (.upgradesList, "upgrades.list"), + (.upgradesRefreshButton, "upgrades.refresh"), + (.discoverScreen, "discover.screen"), + (.discoverSearchField, "discover.search"), + (.discoverList, "discover.list"), + (.configScreen, "config.screen"), + (.doctorScreen, "doctor.screen"), + (.doctorHealthyState, "doctor.healthy"), + (.brewNotFoundState, "brew.not.found"), + (.packageDetail, "package.detail"), + (.installButton, "detail.install"), + (.uninstallButton, "detail.uninstall"), + (.upgradeButton, "detail.upgrade"), + (.console, "console"), + (.consoleStatus, "console.status"), + (.consoleToggle, "console.toggle"), + (.consoleOutput, "console.output"), + (.errorState, "error.state"), + (.errorRetryButton, "error.retry"), + ] - #expect(identifiers == [ - "sidebar", - "installed.screen", - "installed.list", - "installed.search", - "upgrades.screen", - "upgrades.list", - "upgrades.refresh", - "discover.screen", - "discover.search", - "discover.list", - "config.screen", - "doctor.screen", - "brew.not.found", - "package.detail", - "detail.install", - "detail.uninstall", - "detail.upgrade", - "console", - "console.status", - "console.toggle", - "console.output", - "error.state", - "error.retry", - ]) + #expect(pairs.map(\.0.rawValue) == pairs.map(\.1)) } @Test func `every sidebar destination maps to a distinct namespaced identifier`() { From f46a79cbd205dec05214a0b344955e88a7e57afa Mon Sep 17 00:00:00 2001 From: "Jackson F. de A. Mafra" <885385+jacksonfdam@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:01:32 +0200 Subject: [PATCH 09/22] Declare pt-BR as a known region for the app target --- Homebrew.xcodeproj/project.pbxproj | 1 + 1 file changed, 1 insertion(+) diff --git a/Homebrew.xcodeproj/project.pbxproj b/Homebrew.xcodeproj/project.pbxproj index 6ce85c14..00996cb5 100644 --- a/Homebrew.xcodeproj/project.pbxproj +++ b/Homebrew.xcodeproj/project.pbxproj @@ -270,6 +270,7 @@ knownRegions = ( en, Base, + "pt-BR", ); mainGroup = EEC39EB62F5AB4B900269514; minimizedProjectReferenceProxies = 1; From 0c3940bf0ee482be468f0198d757919d8f0c5899 Mon Sep 17 00:00:00 2001 From: "Jackson F. de A. Mafra" <885385+jacksonfdam@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:01:36 +0200 Subject: [PATCH 10/22] Note unverified accessibility lookup in DoctorScreen --- BrewUITests/Screens/DoctorScreen.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/BrewUITests/Screens/DoctorScreen.swift b/BrewUITests/Screens/DoctorScreen.swift index e028e79b..815fa5c0 100644 --- a/BrewUITests/Screens/DoctorScreen.swift +++ b/BrewUITests/Screens/DoctorScreen.swift @@ -51,6 +51,8 @@ struct DoctorScreen: Screen { file: StaticString = #filePath, line: UInt = #line, ) -> Self { + // Element type unconfirmed: the suite couldn't be run locally to verify this matches. + // If it doesn't, try `staticTexts` first, then `descendants(matching: .any)`. let healthy = root.element.otherElements[AXID.doctorHealthyState.rawValue] guard healthy.waitForExistence(timeout: timeout) else { XCTFail( From 4959724001af3b71bc5bfcc330b883034710e8cf Mon Sep 17 00:00:00 2001 From: "Jackson F. de A. Mafra" <885385+jacksonfdam@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:01:39 +0200 Subject: [PATCH 11/22] Declare app-level en/pt-BR localizations via InfoPlist.strings --- Homebrew/en.lproj/InfoPlist.strings | 4 ++++ Homebrew/pt-BR.lproj/InfoPlist.strings | 4 ++++ 2 files changed, 8 insertions(+) create mode 100644 Homebrew/en.lproj/InfoPlist.strings create mode 100644 Homebrew/pt-BR.lproj/InfoPlist.strings diff --git a/Homebrew/en.lproj/InfoPlist.strings b/Homebrew/en.lproj/InfoPlist.strings new file mode 100644 index 00000000..f508ae75 --- /dev/null +++ b/Homebrew/en.lproj/InfoPlist.strings @@ -0,0 +1,4 @@ +/* The app's name is a proper noun and is identical in every language. This file exists so the + app bundle declares that it ships this localization — macOS reads the bundle's .lproj folders + to decide which languages to offer in System Settings > General > Language & Region. */ +"CFBundleDisplayName" = "Homebrew"; diff --git a/Homebrew/pt-BR.lproj/InfoPlist.strings b/Homebrew/pt-BR.lproj/InfoPlist.strings new file mode 100644 index 00000000..f508ae75 --- /dev/null +++ b/Homebrew/pt-BR.lproj/InfoPlist.strings @@ -0,0 +1,4 @@ +/* The app's name is a proper noun and is identical in every language. This file exists so the + app bundle declares that it ships this localization — macOS reads the bundle's .lproj folders + to decide which languages to offer in System Settings > General > Language & Region. */ +"CFBundleDisplayName" = "Homebrew"; From 41188766e5299964a254780905b900777bf7357f Mon Sep 17 00:00:00 2001 From: "Jackson F. de A. Mafra" <885385+jacksonfdam@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:01:42 +0200 Subject: [PATCH 12/22] Document the localization pattern and record the decisions --- .ai/memory.md | 79 +++++++++++++++++++++++++++++++++++++++++++++++++ ARCHITECTURE.md | 5 ++++ CONVENTIONS.md | 49 ++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+) diff --git a/.ai/memory.md b/.ai/memory.md index 5fef5bc6..c04314e8 100644 --- a/.ai/memory.md +++ b/.ai/memory.md @@ -607,3 +607,82 @@ - **The app is unsandboxed, so `~/Library` is shared ground.** There is no container to namespace writes, and a folder named `Brew` collides with anything else of that name and reads as the `brew` CLI's. Every store resolves its own path under `sh.brew.app`. - **Which root follows from whether the contents can be rebuilt.** The catalogue and Discover analytics caches hold ETag-validated HTTP responses, so they sit in `Caches`: purgeable, out of Time Machine, one refetch to replace. Crash reports sit in `Application Support` because a pending report cannot be regenerated. Do not merge the two back into one root. + +## 2026-09-09 — Localization (String Catalogs, pt-BR first) + +- **Mechanism:** one `Resources/Localizable.xcstrings` per target that owns user-facing copy, with + `defaultLocalization: "en"` in `Package.swift`. Not a central localization module: Xcode's key + extraction is per-target, and a shared catalogue would degrade into a hand-maintained key enum. +- **Boundary type:** strings crossing a boundary (ViewModel → View, module → module) are + `LocalizedStringResource`, not `String`. It carries key *and* bundle and resolves at display time. + `LastUpdatedLabel(lead:)` is the reason this matters — the lead phrase belongs to the *calling* + module's catalogue. +- **The bundle trap:** `String(localized:)` and `LocalizedStringResource(_:)` default to + `Bundle.main`, which in a SwiftPM module is the app, not where the catalogue was processed. + Resolution against the wrong bundle does not throw — it returns the key, so the string silently + stays English. Each localized module has an internal `init(:)` that supplies the bundle; + every user-facing string in that module goes through it. +- **The two build systems disagree, and that dictates where a test can live.** `xcrun swift build` + / `swift test` copies `Localizable.xcstrings` into the module bundle raw — SwiftPM's native + builder runs no Apple resource compiler at all (`Media.xcassets` is copied raw too, same + reasoning as the 2026-08-30 contrast audit). So under `swift test` a catalogue lookup always + returns its key, and an assertion on *resolved* Portuguese prose can never pass there. `xcodebuild` + is the one that actually compiles the catalogue, into `.lproj/Localizable.strings`. CI runs + both, and `Brew-Unit.xctestplan` contains only the `BrewTests` Xcode target — everything under + `Tests/` runs exclusively under `swift test`. Hence the split: suites under `Tests/` assert + catalogue keys and bundle bindings (e.g. `Tests/BrewUIComponentsTests/LocalizationBundleTests.swift`, + `StringCatalogueCompletenessTests`, which reads the `.xcstrings` JSON directly and so is + build-system-agnostic); `BrewTests/LocalizationResolutionTests.swift` is the only place a resolved + Portuguese string is asserted. A Portuguese assertion added under `Tests/` will pass locally under + `xcodebuild` and fail in CI's `swift test` leg. +- **The app bundle has to advertise its own languages.** Adding `pt-BR` to `knownRegions` in + `Package.swift`/the Xcode project only puts `pt-BR.lproj` inside the nested `BrewKit_*.bundle`s. + macOS decides which languages an app offers in System Settings → General → Language & Region → + Applications by reading the *app bundle's own* top-level `.lproj` folders and + `CFBundleLocalizations` — and this app has no in-app language picker by choice, so without this + the translation would ship built but unreachable. Fixed with + `Homebrew/en.lproj/InfoPlist.strings` and `Homebrew/pt-BR.lproj/InfoPlist.strings`. + `INFOPLIST_KEY_CFBundleLocalizations` does **not** work — Xcode's build setting synthesis maps + only an allowlist of `INFOPLIST_KEY_*` names into `Info.plist`, and that key isn't on it. Don't + retry that route; ship the `.lproj` files. +- **Two strings differing only in punctuation collide.** Xcode's `GenerateStringSymbols` build + phase strips punctuation when deriving an identifier, so `"Re-checking"` (a VoiceOver label) and + `"Re-checking…"` (the header subtitle) produced the same generated symbol and broke `xcodebuild` + outright — not a warning, a build failure. Fixed with `"generatesSymbol": false` on both entries, + safe because nothing references the generated symbols. Any new string that's an existing one plus + trailing punctuation needs the same flag. +- **Catalogue house style, so the two files stop fighting each other:** keys are the English source + text; entries are ordered case-insensitively; the JSON follows Xcode's own pretty-printer (a space + before every colon, a simple `stringUnit` object collapsed onto one line) because Xcode rewrites + the file in that style on save and a hand-formatted diff would just get re-diffed into noise on + the next edit made through the editor. Every entry carries a `comment` — a translator working in + Xcode's String Catalog editor sees only the string and its comment, never the surrounding view. +- **81 existing call sites are still bundle-less** — `localized:` appears 81 times across `Sources` + in `BrewFeatureInstalled` (46), `BrewFeatureDiscover` (27), `BrewFeatureConfig` (3), + `BrewServicesTestSupport` (2), `BrewCore` (2), `BrewRepositories` (1), none passing `bundle:`. + Harmless while those targets have no catalogue — the call returns its English key, which is + today's behaviour. Fixing them, plus the BrewUILint rule that would enforce the argument, is + specified in `.ai/plans/2026-09-10-localization-bundle-followup.md` — a **local working document + only**, since `.ai/plans/` is gitignored and nobody else can open it from the repository; treat + its existence as this note, not as that file. Deliberately deferred: it touches six targets, and + this pull request is scoped to two. +- **Never localized:** copy that echoes `brew` output verbatim (`DoctorCopy.warningPreamble`), + copyable command text (`CONVENTIONS.md` — Command transparency), SF Symbol names, `AXID` values. +- **No in-app language picker.** macOS already offers per-app language selection; a second source + of truth would have to be persisted and defended against the system's. +- **Plurals** go through catalogue plural variations (`%lld minutes ago`), never a + singular/plural ternary in Swift — plural rules are per-language. +- **Completeness is enforced,** not reviewed: `StringCatalogueCompletenessTests` reads every + `.xcstrings` in `Sources/` and fails on any string lacking a translated `pt-BR` entry. +- **`BrewTests` needs `@MainActor` on any test that reaches into a UI module.** + `BrewUIComponents` and `BrewFeatureDoctor` set `.defaultIsolation(MainActor.self)` in + `Package.swift`; the Xcode `BrewTests` target sets no default isolation, so a nonisolated test + calling into either module is a hard Swift 6 compile error. Annotate the test — never loosen the + module's isolation default to work around it. +- **Status:** `BrewUIComponents` and `BrewFeatureDoctor` are translated. The `Homebrew/` app target + has an empty catalogue plus the two `InfoPlist.strings` files, so it already sits inside the + completeness guard. Every other target awaits its own pull request. +- **Known gap:** `LoadState`'s failure payload is still `String`, produced by + `OperationFailure.userFacingMessage` in `BrewCore` and consumed by every feature module. Error + copy is therefore still English everywhere. Migrating it touches all five feature modules at + once and belongs in its own PR. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 169fff20..775781ca 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -50,6 +50,11 @@ Guiding patterns: - **Command center:** `BrewCommandCenter` (actor protocol; app default `SerialBrewCommandCenter`) — **serializes** mutating `brew` work, tracks **in-flight / failed** **operation** state (`BrewOperationID` + `BrewOperationPhase`) for UI across surfaces, and runs **small `BrewMutatingCommand` types** that call `BrewCommandRunning` + the brew locator. It does **not** own **read/parsing** of `brew list` / `brew info` output — that stays in **repositories**. Feature-scoped executors (e.g. upgrade helpers) should stay **thin** and be invoked **from** commands the center schedules, not as a second parallel pipeline. - **Models:** Domain-only value types and relationships shared across layers. Keep UI/presentation helpers, transport decoding models, and infrastructure-state containers out of domain models. +## File organisation + +- `Sources//Resources/Localizable.xcstrings` — that target's String Catalog. Source + language English; `pt-BR` supported. See [`CONVENTIONS.md`](CONVENTIONS.md) — **Localization**. + ## Command execution Run Homebrew commands **asynchronously** via subprocess; support **cancellation**; **stream or preserve** stdout/stderr for transparency and logs. Always make the **exact command** visible to the user; treat **CLI text output as unstable** (tolerant parsing, fallbacks). diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 13c34b06..7def2b24 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -37,6 +37,55 @@ Follow [Swift API Design Guidelines](https://www.swift.org/documentation/api-des UI in `Brew/` uses **semantic tokens** under [`Brew/Theme/`](Brew/Theme/) (`BrewColors`, `BrewSpacing` / `BrewLayout` / `BrewRadius`, `BrewFonts`). Do not hard-code colours, spacing, or typography in feature views — **add or extend tokens** in Theme when new semantics appear. Cursor agents: see [`.cursor/rules/design-system.mdc`](.cursor/rules/design-system.mdc). +## Localization + +Every target with user-facing copy owns `Resources/Localizable.xcstrings`, declared as a +`.process` resource in `Package.swift`. Source language is English; the key is the English text. + +**Adding a string:** + +1. Build the value through the module's initialiser: `LocalizedStringResource(doctor: "Run Again")`. + Never `String(localized:)` or `LocalizedStringResource(_:)` without a `bundle:` — those default + to `Bundle.main`, which is not where a SwiftPM module's catalogue lives, and the failure is + silent: you get the English key back, which reads as a missing translation. If a module has no + initialiser yet, add one rather than passing the bundle inline at each call. +2. Add the key to the module's catalogue with an `en` and a `pt-BR` entry, both `"translated"`. + Order entries case-insensitively and give every entry a `comment` — a translator working in + Xcode's String Catalog editor sees only the string and its comment, never the surrounding view. + Match Xcode's own pretty-printer style (space before every colon, a `stringUnit` object collapsed + onto one line): Xcode rewrites the file in that style the moment anyone saves it in the editor, + so a hand-formatted file just produces a reformat-only diff on the next edit. +3. Interpolate counts rather than branching on them — `"\(count) minutes ago"` produces a `%lld` + key that the catalogue answers with per-language plural variations. +4. If the new string is an existing one plus trailing punctuation (`"Loading"` / + `"Loading…"`), set `"generatesSymbol": false` on both entries. Xcode's `GenerateStringSymbols` + phase strips punctuation when deriving an identifier, so the two would otherwise generate the + same symbol and fail the `xcodebuild` build outright. + +**`LocalizedStringResource` or `String`:** a string a person reads is a `LocalizedStringResource`, +including accessibility labels. It stays a `LocalizedStringResource` across every boundary — a +ViewModel returns one, and a component that renders caller-supplied copy accepts one, so it +resolves against the catalogue of the module that owns it. + +**Never localized:** text that echoes `brew` output word for word, copyable command text, SF Symbol +names, and `AXID` values. Mark each with a comment saying why. + +**Tests** resolve against a named locale rather than the machine's — and only `BrewTests` (the +Xcode target, run by `xcodebuild`) can do that, because `swift test` copies a catalogue into the +module bundle uncompiled and a lookup under it always returns its key: + +```swift +var resource = viewModel.subtitle +resource.locale = Locale(identifier: "pt-BR") +#expect(String(localized: resource) == "Executando brew doctor…") +``` + +A suite under `Tests/` (which only ever runs via `swift test`) must stop at asserting catalogue +keys and bundle bindings — never a resolved translation, which would pass locally under `xcodebuild` +and fail in CI's `swift test` leg. A `BrewTests` case that reaches into `BrewUIComponents` or +`BrewFeatureDoctor` needs `@MainActor`: both set `.defaultIsolation(MainActor.self)` in +`Package.swift`, and `BrewTests` itself carries no default isolation. + ## Implementation notes **Errors:** Prefer typed `Error` enums with associated values where useful. Separate **user-facing** copy from **technical** detail; log or preserve detail; do not swallow errors silently. From 0f9f49f5077779f13594a272dd7486eb0bd9872b Mon Sep 17 00:00:00 2001 From: "Jackson F. de A. Mafra" <885385+jacksonfdam@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:01:46 +0200 Subject: [PATCH 13/22] Localize the command block's header and copy button 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. --- .../Views/DoctorIssueDetailView.swift | 2 +- .../Resources/Localizable.xcstrings | 40 +++++++++++++++++++ .../Views/BrewActionButton.swift | 21 ++++++---- .../Views/CommandBlockView.swift | 34 ++++++++++++---- 4 files changed, 80 insertions(+), 17 deletions(-) diff --git a/Sources/BrewFeatureDoctor/Views/DoctorIssueDetailView.swift b/Sources/BrewFeatureDoctor/Views/DoctorIssueDetailView.swift index 4abd7471..fae47cec 100644 --- a/Sources/BrewFeatureDoctor/Views/DoctorIssueDetailView.swift +++ b/Sources/BrewFeatureDoctor/Views/DoctorIssueDetailView.swift @@ -181,7 +181,7 @@ struct DoctorIssueDetailView: View { private var rawOutputSection: some View { CommandBlockView( command: item.rawText, - title: String(localized: LocalizedStringResource(doctor: "Raw output")), + title: LocalizedStringResource(doctor: "Raw output"), collapsible: true, ) } diff --git a/Sources/BrewUIComponents/Resources/Localizable.xcstrings b/Sources/BrewUIComponents/Resources/Localizable.xcstrings index a7f55d19..aff9e7cc 100644 --- a/Sources/BrewUIComponents/Resources/Localizable.xcstrings +++ b/Sources/BrewUIComponents/Resources/Localizable.xcstrings @@ -67,6 +67,30 @@ } } }, + "Copied" : { + "comment" : "Confirmation shown on the copy button after the commands reach the pasteboard", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Copied" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Copiado" } } + } + }, + "Copy" : { + "comment" : "Button that copies the single command in a command block to the pasteboard", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Copy" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Copiar" } } + } + }, + "Copy all" : { + "comment" : "Button that copies every command in a command block to the pasteboard at once", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Copy all" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Copiar tudo" } } + } + }, "Find" : { "comment" : "Edit menu item that focuses the search field (⌘F)", "extractionState" : "manual", @@ -106,6 +130,22 @@ "en" : { "stringUnit" : { "state" : "translated", "value" : "Retry" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Tentar novamente" } } } + }, + "Terminal command" : { + "comment" : "Header of a command block holding a single shell command", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Terminal command" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Comando do Terminal" } } + } + }, + "Terminal commands" : { + "comment" : "Header of a command block holding several shell commands", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Terminal commands" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Comandos do Terminal" } } + } } }, "version" : "1.0" diff --git a/Sources/BrewUIComponents/Views/BrewActionButton.swift b/Sources/BrewUIComponents/Views/BrewActionButton.swift index 069bd741..0da1de8e 100644 --- a/Sources/BrewUIComponents/Views/BrewActionButton.swift +++ b/Sources/BrewUIComponents/Views/BrewActionButton.swift @@ -10,10 +10,10 @@ import SwiftUI /// An action that leaves no visible trace (copying to the pasteboard, clearing a list) passes a /// `confirmationTitle`: the button swaps to a tick and that title for a few seconds. public struct BrewActionButton: View { - private let title: String + private let title: LocalizedStringResource private let systemImage: String - private let confirmationTitle: String? - private let help: String? + private let confirmationTitle: LocalizedStringResource? + private let help: LocalizedStringResource? private let action: () -> Void @State private var isHovered = false @@ -23,10 +23,10 @@ public struct BrewActionButton: View { private static let confirmationDuration: Duration = .seconds(5) public init( - _ title: String, + _ title: LocalizedStringResource, systemImage: String, - confirmationTitle: String? = nil, - help: String? = nil, + confirmationTitle: LocalizedStringResource? = nil, + help: LocalizedStringResource? = nil, action: @escaping () -> Void, ) { self.title = title @@ -74,10 +74,15 @@ public struct BrewActionButton: View { /// What a ``BrewActionButton`` shows right now. struct BrewActionButtonAppearance: Equatable { - let title: String + let title: LocalizedStringResource let systemImage: String - init(title: String, systemImage: String, confirmationTitle: String?, isConfirming: Bool) { + init( + title: LocalizedStringResource, + systemImage: String, + confirmationTitle: LocalizedStringResource?, + isConfirming: Bool, + ) { if isConfirming, let confirmationTitle { self.title = confirmationTitle self.systemImage = "checkmark" diff --git a/Sources/BrewUIComponents/Views/CommandBlockView.swift b/Sources/BrewUIComponents/Views/CommandBlockView.swift index 2917bb09..542d0e54 100644 --- a/Sources/BrewUIComponents/Views/CommandBlockView.swift +++ b/Sources/BrewUIComponents/Views/CommandBlockView.swift @@ -12,10 +12,15 @@ import SwiftUI public struct CommandBlockView: View { let commands: [String] let summaryText: String? - let title: String? + let title: LocalizedStringResource? let collapsible: Bool - public init(command: String, summaryText: String? = nil, title: String? = nil, collapsible: Bool = false) { + public init( + command: String, + summaryText: String? = nil, + title: LocalizedStringResource? = nil, + collapsible: Bool = false, + ) { commands = [command] self.summaryText = summaryText self.title = title @@ -23,7 +28,12 @@ public struct CommandBlockView: View { _isExpanded = State(initialValue: !collapsible) } - public init(commands: [String], summaryText: String? = nil, title: String? = nil, collapsible: Bool = false) { + public init( + commands: [String], + summaryText: String? = nil, + title: LocalizedStringResource? = nil, + collapsible: Bool = false, + ) { self.commands = commands self.summaryText = summaryText self.title = title @@ -69,7 +79,11 @@ public struct CommandBlockView: View { .foregroundStyle(Color.brewTextSecondary) } Spacer() - BrewActionButton(copyTitle, systemImage: "doc.on.doc", confirmationTitle: "Copied") { + BrewActionButton( + copyTitle, + systemImage: "doc.on.doc", + confirmationTitle: LocalizedStringResource(uiComponents: "Copied"), + ) { NSPasteboard.general.clearContents() NSPasteboard.general.setString(commands.joined(separator: "\n"), forType: .string) } @@ -79,12 +93,16 @@ public struct CommandBlockView: View { .background(Color.brewSurfaceRecessed) } - private var headerTitle: String { - commands.count > 1 ? "Terminal commands" : "Terminal command" + private var headerTitle: LocalizedStringResource { + commands.count > 1 + ? LocalizedStringResource(uiComponents: "Terminal commands") + : LocalizedStringResource(uiComponents: "Terminal command") } - private var copyTitle: String { - commands.count > 1 ? "Copy all" : "Copy" + private var copyTitle: LocalizedStringResource { + commands.count > 1 + ? LocalizedStringResource(uiComponents: "Copy all") + : LocalizedStringResource(uiComponents: "Copy") } /// Deliberately not on ``Color/brewTerminal``: light-on-dark text under the system selection From 9410e7da4f3d44be4fbd6ac46644eccc0239ae99 Mon Sep 17 00:00:00 2001 From: "Jackson F. de A. Mafra" <885385+jacksonfdam@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:01:49 +0200 Subject: [PATCH 14/22] Give NoteCallout a resource route and a verbatim one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../BrewFeatureDoctor/Views/DoctorView.swift | 4 +++- ...InstalledPackageDetailSubviewSections.swift | 3 ++- .../BrewUIComponents/Views/NoteCallout.swift | 18 +++++++++++++----- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/Sources/BrewFeatureDoctor/Views/DoctorView.swift b/Sources/BrewFeatureDoctor/Views/DoctorView.swift index bfb60769..04b3df48 100644 --- a/Sources/BrewFeatureDoctor/Views/DoctorView.swift +++ b/Sources/BrewFeatureDoctor/Views/DoctorView.swift @@ -168,7 +168,9 @@ private struct DoctorSeveritySectionHeader: View { private struct DoctorReassuranceNote: View { var body: some View { - NoteCallout(DoctorCopy.warningPreamble, tone: .info) + // `verbatim:`, not a resource: this is `brew doctor`'s own wording, echoed word for word + // beside the output it explains. See ``DoctorCopy``. + NoteCallout(verbatim: DoctorCopy.warningPreamble, tone: .info) .padding(.horizontal, BrewSpacing.lg) .padding(.bottom, BrewSpacing.sm) } diff --git a/Sources/BrewFeatureInstalled/Views/InstalledPackageDetailSubviewSections.swift b/Sources/BrewFeatureInstalled/Views/InstalledPackageDetailSubviewSections.swift index efd800b6..d63c2789 100644 --- a/Sources/BrewFeatureInstalled/Views/InstalledPackageDetailSubviewSections.swift +++ b/Sources/BrewFeatureInstalled/Views/InstalledPackageDetailSubviewSections.swift @@ -182,7 +182,8 @@ struct InstalledPackageDetailMetadataSection: View { } private func caveatsCallout(text: String) -> some View { - NoteCallout(text) + // `verbatim:`: caveats are the package's own text as Homebrew publishes it, not app copy. + NoteCallout(verbatim: text) .padding(.top, BrewSpacing.lg) } } diff --git a/Sources/BrewUIComponents/Views/NoteCallout.swift b/Sources/BrewUIComponents/Views/NoteCallout.swift index 6b647c61..a861f0a6 100644 --- a/Sources/BrewUIComponents/Views/NoteCallout.swift +++ b/Sources/BrewUIComponents/Views/NoteCallout.swift @@ -30,11 +30,19 @@ public enum NoteCalloutTone: Sendable { } public struct NoteCallout: View { - private let text: String + private let text: Text private let tone: NoteCalloutTone - public init(_ text: String, tone: NoteCalloutTone = .brand) { - self.text = text + public init(_ text: LocalizedStringResource, tone: NoteCalloutTone = .brand) { + self.text = Text(text) + self.tone = tone + } + + /// For copy that must reach the screen exactly as given and therefore owns no catalogue key — + /// `brew`'s own output, or a package's caveats. Named after `Text(verbatim:)`, which it wraps, so + /// a call site cannot silently opt out of localization without saying so. + public init(verbatim text: String, tone: NoteCalloutTone = .brand) { + self.text = Text(verbatim: text) self.tone = tone } @@ -43,7 +51,7 @@ public struct NoteCallout: View { Image(systemName: "info.circle.fill") .font(.brewSubheadline) .foregroundStyle(tone.iconColor) - Text(text) + text .font(.brewCallout) .foregroundStyle(Color.brewTextPrimary) .fixedSize(horizontal: false, vertical: true) @@ -62,7 +70,7 @@ public struct NoteCallout: View { VStack(alignment: .leading, spacing: BrewSpacing.md) { NoteCallout("Casks and formulae are installed to different prefixes.") NoteCallout( - "Please note that these warnings are just used to help the Homebrew maintainers.", + verbatim: "Please note that these warnings are just used to help the Homebrew maintainers.", tone: .info, ) } From a051a984be51bd138b6512f1f595dfc90d6af9e8 Mon Sep 17 00:00:00 2001 From: "Jackson F. de A. Mafra" <885385+jacksonfdam@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:01:53 +0200 Subject: [PATCH 15/22] Interpolate the severity resource instead of resolving it first 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. --- Sources/BrewFeatureDoctor/Views/DoctorIssueDetailView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/BrewFeatureDoctor/Views/DoctorIssueDetailView.swift b/Sources/BrewFeatureDoctor/Views/DoctorIssueDetailView.swift index fae47cec..82f6670a 100644 --- a/Sources/BrewFeatureDoctor/Views/DoctorIssueDetailView.swift +++ b/Sources/BrewFeatureDoctor/Views/DoctorIssueDetailView.swift @@ -201,7 +201,7 @@ private struct DoctorSeverityBadge: View { .padding(.vertical, BrewSpacing.xxs) .background(DoctorSeverityStyle.background(severity), in: Capsule()) .accessibilityLabel(LocalizedStringResource( - doctor: "Severity: \(String(localized: DoctorSeverityStyle.displayName(severity)))", + doctor: "Severity: \(DoctorSeverityStyle.displayName(severity))", )) } } From 7f225fe8bb1e930424a41cb7c16107de2c285473 Mon Sep 17 00:00:00 2001 From: "Jackson F. de A. Mafra" <885385+jacksonfdam@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:01:56 +0200 Subject: [PATCH 16/22] Say why the package kind badges stay English 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. --- Sources/BrewUIComponents/Chrome/PackageKindChrome.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/BrewUIComponents/Chrome/PackageKindChrome.swift b/Sources/BrewUIComponents/Chrome/PackageKindChrome.swift index 90cd8c32..1a2bc9bf 100644 --- a/Sources/BrewUIComponents/Chrome/PackageKindChrome.swift +++ b/Sources/BrewUIComponents/Chrome/PackageKindChrome.swift @@ -29,6 +29,9 @@ public struct PackageKindChrome: Equatable { } public extension HomebrewPackageKind { + /// `badgeLabel` stays English in every localization: "formula" and "cask" are Homebrew's own + /// names for the two kinds of package, the same words `brew` prints and the docs use, so a + /// translated badge would name something the rest of the ecosystem does not. var chrome: PackageKindChrome { switch self { case .formula: From 89954f50a1575d5b016a01586fe5abd7b9565fd3 Mon Sep 17 00:00:00 2001 From: "Jackson F. de A. Mafra" <885385+jacksonfdam@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:01:59 +0200 Subject: [PATCH 17/22] Resolve the Doctor healthy state through BrewUIElement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- BrewUITests/Screens/DoctorScreen.swift | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/BrewUITests/Screens/DoctorScreen.swift b/BrewUITests/Screens/DoctorScreen.swift index 815fa5c0..abbf6881 100644 --- a/BrewUITests/Screens/DoctorScreen.swift +++ b/BrewUITests/Screens/DoctorScreen.swift @@ -51,20 +51,13 @@ struct DoctorScreen: Screen { file: StaticString = #filePath, line: UInt = #line, ) -> Self { - // Element type unconfirmed: the suite couldn't be run locally to verify this matches. - // If it doesn't, try `staticTexts` first, then `descendants(matching: .any)`. - let healthy = root.element.otherElements[AXID.doctorHealthyState.rawValue] - guard healthy.waitForExistence(timeout: timeout) else { - XCTFail( - """ - Expected Doctor to show the healthy state within \(timeout)s. - \(BrewUITestDiagnostics.report(for: app)) - """, - file: file, - line: line, - ) - return self - } + healthyState.waitToExist(timeout: timeout, file: file, line: line) return self } + + /// Scoped to this screen's root, and resolved by ``BrewUIElement`` rather than a typed query, so + /// it holds whichever element type SwiftUI surfaces the healthy state's container as. + private var healthyState: BrewUIElement { + BrewUIElement(app, .doctorHealthyState, in: root.element) + } } From 82efaa03b399b3c8e932e730680fdcc2ce67bc20 Mon Sep 17 00:00:00 2001 From: "Jackson F. de A. Mafra" <885385+jacksonfdam@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:02:03 +0200 Subject: [PATCH 18/22] Check catalogue consistency instead of imposing a language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../StringCatalogueCompletenessTests.swift | 69 +++++++++++++------ 1 file changed, 47 insertions(+), 22 deletions(-) diff --git a/Tests/BrewUIComponentsTests/StringCatalogueCompletenessTests.swift b/Tests/BrewUIComponentsTests/StringCatalogueCompletenessTests.swift index 560a4284..5f2dd871 100644 --- a/Tests/BrewUIComponentsTests/StringCatalogueCompletenessTests.swift +++ b/Tests/BrewUIComponentsTests/StringCatalogueCompletenessTests.swift @@ -6,15 +6,19 @@ import Foundation import Testing -/// Every catalogue in the package must carry a translated `pt-BR` entry for every source string. -/// A missing translation is invisible at runtime — the key is returned, so the view renders English -/// inside an otherwise Portuguese screen. This is the only thing that makes it a build failure. +/// A catalogue that has begun translating into a language must finish: every key it holds needs a +/// translated entry in every language that any of its keys declares. +/// +/// This enforces consistency, not language policy. Which languages the project accepts, and whether +/// a new string must arrive with a translation, is the project's call — a module nobody has started +/// translating declares no second language and passes untouched. What the rule does catch is the +/// half-translated module, and that distinction matters because the failure is invisible at runtime: +/// a missing entry returns the key, so an otherwise Portuguese screen renders one English button and +/// nothing else notices. /// /// Reads the catalogue sources from the repository rather than a bundle: `.xcstrings` is compiled /// into `.lproj` directories, so the authored JSON never reaches the test bundle. struct StringCatalogueCompletenessTests { - private static let requiredLanguage = "pt-BR" - private static var packageRoot: URL { // .../Tests/BrewUIComponentsTests/ThisFile.swift → package root URL(filePath: #filePath) @@ -68,45 +72,66 @@ struct StringCatalogueCompletenessTests { /// catalogue — a bigger blast radius than one known path deserves. private static let appTargetCatalogue = "Homebrew/Localizable.xcstrings" + /// Walking two directories up from the app catalogue reaches the checkout folder, whose name is + /// whatever the person cloning chose. Every other catalogue sits at `/Resources/`. + private static let appTargetLabel = "Homebrew" + + /// Every catalogue that exists today. The `Sources/` walk still finds any others, so a new + /// module's catalogue is checked the day it lands — but a walk that returns nothing, because a + /// directory was renamed or the layout restructured, fails here instead of passing a suite that + /// silently checked only the deliberately empty app catalogue. + private static let requiredCatalogues = [ + "Sources/BrewFeatureDoctor/Resources/Localizable.xcstrings", + "Sources/BrewUIComponents/Resources/Localizable.xcstrings", + appTargetCatalogue, + ] + private static func catalogueURLs() throws -> [URL] { let sources = packageRoot.appending(path: "Sources") let enumerator = FileManager.default.enumerator( at: sources, includingPropertiesForKeys: nil, ) - var found = enumerator? - .compactMap { $0 as? URL } - .filter { $0.lastPathComponent == "Localizable.xcstrings" } ?? [] - - // Named, not discovered — so a moved or deleted app catalogue fails here instead of - // quietly dropping out of the checked set. - let appCatalogue = packageRoot.appending(path: appTargetCatalogue) - guard FileManager.default.fileExists(atPath: appCatalogue.path) else { - throw CatalogueNotFound(path: appTargetCatalogue) + let walked = (enumerator?.compactMap { $0 as? URL } ?? []) + .filter { $0.lastPathComponent == "Localizable.xcstrings" } + + var found = Set(walked.map(\.standardizedFileURL)) + for relativePath in requiredCatalogues { + let url = packageRoot.appending(path: relativePath).standardizedFileURL + guard FileManager.default.fileExists(atPath: url.path) else { + throw CatalogueNotFound(path: relativePath) + } + found.insert(url) } - found.append(appCatalogue) return found.sorted { $0.path < $1.path } } + private static func moduleLabel(for url: URL) -> String { + guard url != packageRoot.appending(path: appTargetCatalogue).standardizedFileURL else { + return appTargetLabel + } + return url.deletingLastPathComponent().deletingLastPathComponent().lastPathComponent + } + private struct CatalogueNotFound: Error, CustomStringConvertible { let path: String var description: String { - "Expected a String Catalog at \(path). If it moved, update appTargetCatalogue." + "Expected a String Catalog at \(path). If it moved, update requiredCatalogues." } } - @Test func `every catalogue string has a translated pt-BR entry`() throws { + @Test func `a catalogue that translates a string into a language translates all of them`() throws { var untranslated: [String] = [] for url in try Self.catalogueURLs() { let catalogue = try JSONDecoder().decode(Catalogue.self, from: Data(contentsOf: url)) - let module = url.deletingLastPathComponent().deletingLastPathComponent().lastPathComponent + let module = Self.moduleLabel(for: url) + let languages = Set(catalogue.strings.values.compactMap(\.localizations).flatMap(\.keys)) - for (key, entry) in catalogue.strings { - let localization = entry.localizations?[Self.requiredLanguage] - if localization?.isTranslated != true { - untranslated.append("\(module): \(key)") + for language in languages { + for (key, entry) in catalogue.strings where entry.localizations?[language]?.isTranslated != true { + untranslated.append("\(module) [\(language)]: \(key)") } } } From b2fefd5a92dd1bc569bb66d2c79f13b81ceb84d2 Mon Sep 17 00:00:00 2001 From: "Jackson F. de A. Mafra" <885385+jacksonfdam@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:02:06 +0200 Subject: [PATCH 19/22] Ship the module bundle accessors as SPI, not API 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. --- BrewTests/LocalizationResolutionTests.swift | 4 ++-- .../Localization/LocalizedStringResource+Module.swift | 5 +++-- .../Localization/LocalizedStringResource+Module.swift | 7 ++++--- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/BrewTests/LocalizationResolutionTests.swift b/BrewTests/LocalizationResolutionTests.swift index a673c36f..ba9e8b46 100644 --- a/BrewTests/LocalizationResolutionTests.swift +++ b/BrewTests/LocalizationResolutionTests.swift @@ -3,8 +3,8 @@ // BrewTests // -import BrewFeatureDoctor -import BrewUIComponents +@_spi(BrewUITesting) import BrewFeatureDoctor +@_spi(BrewUITesting) import BrewUIComponents import Foundation import Testing diff --git a/Sources/BrewFeatureDoctor/Localization/LocalizedStringResource+Module.swift b/Sources/BrewFeatureDoctor/Localization/LocalizedStringResource+Module.swift index f48d8873..b1003ae3 100644 --- a/Sources/BrewFeatureDoctor/Localization/LocalizedStringResource+Module.swift +++ b/Sources/BrewFeatureDoctor/Localization/LocalizedStringResource+Module.swift @@ -15,8 +15,9 @@ extension LocalizedStringResource { public extension Bundle { /// `Bundle.module` is internal to its own module. The Xcode test target needs the same bundle to - /// assert that a translation resolves, so it is exposed here — the only reason this is public. - static var brewFeatureDoctor: Bundle { + /// assert that a translation resolves, which is the only reason this exists — so it ships as SPI + /// rather than API. See the equivalent in `BrewUIComponents`. + @_spi(BrewUITesting) static var brewFeatureDoctor: Bundle { .module } } diff --git a/Sources/BrewUIComponents/Localization/LocalizedStringResource+Module.swift b/Sources/BrewUIComponents/Localization/LocalizedStringResource+Module.swift index 26ae819e..44fef184 100644 --- a/Sources/BrewUIComponents/Localization/LocalizedStringResource+Module.swift +++ b/Sources/BrewUIComponents/Localization/LocalizedStringResource+Module.swift @@ -18,9 +18,10 @@ extension LocalizedStringResource { } public extension Bundle { - /// `Bundle.module` is internal to its own module. The Xcode test target needs the same bundle - /// to assert that a translation resolves, so it is exposed here — the only reason this is public. - static var brewUIComponents: Bundle { + /// `Bundle.module` is internal to its own module. The Xcode test target needs the same bundle to + /// assert that a translation resolves, which is the only reason this exists — so it ships as SPI + /// rather than API, and `.periphery.yml`'s `retain_public: false` keeps counting it. + @_spi(BrewUITesting) static var brewUIComponents: Bundle { .module } } From 824b83a196134e90635004a1f80f6a9b7d8962a4 Mon Sep 17 00:00:00 2001 From: "Jackson F. de A. Mafra" <885385+jacksonfdam@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:02:09 +0200 Subject: [PATCH 20/22] Use the written form of "para o" in the healthy state "pro" is a spoken contraction; product UI reads in the written register. --- Sources/BrewFeatureDoctor/Resources/Localizable.xcstrings | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/BrewFeatureDoctor/Resources/Localizable.xcstrings b/Sources/BrewFeatureDoctor/Resources/Localizable.xcstrings index b08c0102..54f7be7c 100644 --- a/Sources/BrewFeatureDoctor/Resources/Localizable.xcstrings +++ b/Sources/BrewFeatureDoctor/Resources/Localizable.xcstrings @@ -192,7 +192,7 @@ "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Your system is ready to brew" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Seu sistema está pronto pro brew" } } + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Seu sistema está pronto para o brew" } } } } }, From 10206b651538fe4f9d247aa76cc059a120f01457 Mon Sep 17 00:00:00 2001 From: "Jackson F. de A. Mafra" <885385+jacksonfdam@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:02:14 +0200 Subject: [PATCH 21/22] Name the word-order limit in LastUpdatedLabel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Sources/BrewUIComponents/Views/LastUpdatedLabel.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/BrewUIComponents/Views/LastUpdatedLabel.swift b/Sources/BrewUIComponents/Views/LastUpdatedLabel.swift index a308f2c5..cf0fe453 100644 --- a/Sources/BrewUIComponents/Views/LastUpdatedLabel.swift +++ b/Sources/BrewUIComponents/Views/LastUpdatedLabel.swift @@ -37,6 +37,13 @@ public struct LastUpdatedLabel: View { /// `lead` is the phrase the relative time is appended to, e.g. `"Last checked"`. It arrives as a /// resource rather than a `String` so it resolves against the calling module's catalogue, not /// this one's. + /// + /// The two halves are joined lead-then-time with a space, which is a limit of this shape rather + /// than a general answer: a language that puts the time first, or joins with something other than + /// a space, cannot express that here. Resolving the halves separately is what forces it — they + /// belong to different catalogues, so neither can hold a format string with the other's slot in + /// it. A language that needs a different order wants one catalogue entry that takes the relative + /// phrase as an argument, which means moving the lead into the component's own catalogue. public init(lead: LocalizedStringResource, date: Date) { self.lead = lead self.date = date From 011aa40c4ce011fc18f01a36f116d5ae6fac09a2 Mon Sep 17 00:00:00 2001 From: "Jackson F. de A. Mafra" <885385+jacksonfdam@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:02:17 +0200 Subject: [PATCH 22/22] Record the rulings the final review settled The completeness test's rule changed, component APIs now take resources, and the bundle accessors are SPI. Each was written down as something else. --- .ai/memory.md | 19 ++++++++++++++++++- CONVENTIONS.md | 10 +++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/.ai/memory.md b/.ai/memory.md index c04314e8..f888f6f5 100644 --- a/.ai/memory.md +++ b/.ai/memory.md @@ -673,7 +673,24 @@ - **Plurals** go through catalogue plural variations (`%lld minutes ago`), never a singular/plural ternary in Swift — plural rules are per-language. - **Completeness is enforced,** not reviewed: `StringCatalogueCompletenessTests` reads every - `.xcstrings` in `Sources/` and fails on any string lacking a translated `pt-BR` entry. + `.xcstrings` in `Sources/` plus the app target's, and fails when a catalogue translates *some* of + its keys into a language but not all of them. Deliberately not "every string must have `pt-BR`": + that would put a standing translation obligation on maintainers who never agreed to one, which is + project policy and not this repository's to decide. A catalogue nobody has begun translating + passes; a half-translated one — the failure that is invisible at runtime — does not. +- **A component that renders caller-supplied copy takes a `LocalizedStringResource`,** and offers a + `verbatim:` initialiser for text that must not be translated (`NoteCallout`, for `brew`'s own + preamble and for a package's caveats). Migrated in this branch rather than in PRs 2..n, so later + modules plug in without changing public signatures again: `CommandBlockView.title`, + `BrewActionButton`'s title/confirmation/help, `NoteCallout`. Still `String` and deliberately + deferred: `CommandBlockView.summaryText`, `PackageDetailSectionHeading.title` and + `LoadState`'s failure payload — every one of those has a call site fed by a view-model-computed + `String`, so they move with their own module's PR. +- **The module bundle accessors are `@_spi(BrewUITesting) public`,** not `public`. They exist only so + the Xcode `BrewTests` target can reach a bundle that is otherwise internal, `.periphery.yml` sets + `retain_public: false`, and the staged plan adds one per localized module. `BrewTests` imports them + with `@_spi(BrewUITesting) import`; dropping that attribute is a hard compile error, which is the + point. - **`BrewTests` needs `@MainActor` on any test that reaches into a UI module.** `BrewUIComponents` and `BrewFeatureDoctor` set `.defaultIsolation(MainActor.self)` in `Package.swift`; the Xcode `BrewTests` target sets no default isolation, so a nonisolated test diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 7def2b24..b0c74182 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -68,7 +68,15 @@ ViewModel returns one, and a component that renders caller-supplied copy accepts resolves against the catalogue of the module that owns it. **Never localized:** text that echoes `brew` output word for word, copyable command text, SF Symbol -names, and `AXID` values. Mark each with a comment saying why. +names, and `AXID` values. Mark each with a comment saying why. A component that also has to render +such text offers a separate `verbatim:` initialiser alongside its resource one (see `NoteCallout`), +so a call site cannot opt out of localization without saying so. + +**`StringCatalogueCompletenessTests` enforces consistency, not language policy:** a catalogue that +translates any of its keys into a language must translate all of them into it. A catalogue with no +translations yet passes — which languages the project accepts is the project's decision, not the +test's. A module bundle exposed for `BrewTests` to resolve against is `@_spi(BrewUITesting) public`, +not plain `public`: it is a test hatch, not API. **Tests** resolve against a named locale rather than the machine's — and only `BrewTests` (the Xcode target, run by `xcodebuild`) can do that, because `swift test` copies a catalogue into the