From 137f5ed647ff2b96186a236576d226d67b89c81a Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 27 Jul 2026 19:32:02 -0400 Subject: [PATCH 01/14] fix: Localize patterns count string Improve i18n. --- ios/Sources/GutenbergKit/Sources/EditorLocalization.swift | 2 ++ .../Sources/Views/Patterns/PatternSectionView.swift | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift index 4791bb51e..04e994e22 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift @@ -17,6 +17,7 @@ public enum EditorLocalizableString { case insertPattern case patternsCategoryUncategorized case patternsCategoryAll + case patternsCount(Int) // MARK: - Editor Loading case loadingEditor @@ -51,6 +52,7 @@ public final class EditorLocalization { case .insertPattern: "Insert Pattern" case .patternsCategoryUncategorized: "Uncategorized" case .patternsCategoryAll: "All" + case .patternsCount(let count): count == 1 ? "1 pattern" : "\(count) patterns" case .loadingEditor: "Loading Editor" case .editorError: "Editor Error" case .lockdownModeTitle: "Lockdown Mode Detected" diff --git a/ios/Sources/GutenbergKit/Sources/Views/Patterns/PatternSectionView.swift b/ios/Sources/GutenbergKit/Sources/Views/Patterns/PatternSectionView.swift index cf14b3ead..60ff3f0f7 100644 --- a/ios/Sources/GutenbergKit/Sources/Views/Patterns/PatternSectionView.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/Patterns/PatternSectionView.swift @@ -29,7 +29,7 @@ struct PatternGridSection: View { .fontWeight(.semibold) .foregroundStyle(Color.primary) - Text("\(section.patterns.count) patterns") + Text(EditorLocalization[.patternsCount(section.patterns.count)]) .font(.subheadline) .foregroundStyle(Color.secondary) } From d112327702cc7c718bd85bfcd2f90c44256db879 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 28 Jul 2026 09:20:44 -0400 Subject: [PATCH 02/14] feat(ios): let host apps fall back to the editor's default strings `EditorLocalization.localize` held the default strings inline, so host apps replaced the whole table and switched over `EditorLocalizableString` exhaustively. Every string the editor added then broke the host build, blocking adoption of unrelated changes until someone wrote a translation. Expose the defaults as `defaultLocalize` so hosts can delegate unhandled keys with a `default:` case. New strings render untranslated instead of failing to compile, matching how the editor's own JS translations and Android string resources degrade. Report the fallback at the `debug` level, but only once a host installs its own `localize`. Without an override every string comes from the default table by design, so logging each one would be noise. Co-Authored-By: Claude Opus 5 (1M context) --- .../Sources/EditorLocalization.swift | 48 ++++++- .../EditorLocalizationTests.swift | 117 ++++++++++++++++++ 2 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 ios/Tests/GutenbergKitTests/EditorLocalizationTests.swift diff --git a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift index 04e994e22..9d507424a 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift @@ -40,8 +40,48 @@ public enum EditorLocalizableString { @MainActor public final class EditorLocalization { /// This is designed to be overridden by the host app to provide translations. + /// + /// Host apps are encouraged to delegate unhandled keys to + /// ``defaultLocalize`` rather than switching exhaustively: + /// + /// ```swift + /// EditorLocalization.localize = { key in + /// switch key { + /// case .showMore: NSLocalizedString("editor.blockInserter.showMore", ...) + /// // ...keys the host translates. + /// default: EditorLocalization.defaultLocalize(key) + /// } + /// } + /// ``` + /// + /// An exhaustive switch stops compiling whenever the editor adds a string, + /// which blocks the host from adopting unrelated changes until someone + /// writes a translation. Delegating instead renders the untranslated + /// default for new strings and logs at the `debug` level, so a missing + /// translation degrades the string rather than the build. public static var localize: (EditorLocalizableString) -> String = { key in - switch key { + defaultLocalize(key) + } { + didSet { hasHostTranslations = true } + } + + /// Whether a host app installed its own ``localize``. + /// + /// Falling back is only worth reporting once a host has taken + /// responsibility for translations. Without an override every string comes + /// from the default table by design, and logging each one would be noise. + private static var hasHostTranslations = false + + /// The editor's untranslated strings. + /// + /// Exposed so host apps can fall back to it for keys they do not translate. + /// See ``localize``. + public static let defaultLocalize: (EditorLocalizableString) -> String = { key in + if hasHostTranslations { + log(.debug, "Missing host translation for \(key), using the editor default.") + } + + return switch key { case .showMore: "Show More" case .showLess: "Show Less" case .search: "Search" @@ -67,4 +107,10 @@ public final class EditorLocalization { public static subscript(key: EditorLocalizableString) -> String { localize(key) } + + /// Clears the record of a host override so tests can restore the initial + /// state after assigning ``localize``. + static func resetHostTranslationsForTesting() { + hasHostTranslations = false + } } diff --git a/ios/Tests/GutenbergKitTests/EditorLocalizationTests.swift b/ios/Tests/GutenbergKitTests/EditorLocalizationTests.swift new file mode 100644 index 000000000..962cb2b01 --- /dev/null +++ b/ios/Tests/GutenbergKitTests/EditorLocalizationTests.swift @@ -0,0 +1,117 @@ +import Foundation +import Testing +@testable import GutenbergKit + +/// Captures log messages so the fallback reporting can be inspected. +private final class SpyLogger: EditorLogging, @unchecked Sendable { + private let lock = NSLock() + private var _messages: [(EditorLogLevel, String)] = [] + + var messages: [(EditorLogLevel, String)] { + lock.withLock { _messages } + } + + func log(_ level: EditorLogLevel, _ message: String) { + lock.withLock { _messages.append((level, message)) } + } +} + +@MainActor +struct EditorLocalizationTests { + + /// Restores the global localization and logging state around each test. + private func withLocalization( + _ body: (SpyLogger) throws -> Void + ) rethrows { + let previousLocalize = EditorLocalization.localize + let previousLogger = EditorLogger.shared + let previousLevel = EditorLogger.logLevel + + let spy = SpyLogger() + EditorLogger.shared = spy + EditorLogger.logLevel = .debug + + defer { + EditorLocalization.localize = previousLocalize + EditorLogger.shared = previousLogger + EditorLogger.logLevel = previousLevel + EditorLocalization.resetHostTranslationsForTesting() + } + + try body(spy) + } + + @Test + func defaultLocalizeProvidesEveryString() { + #expect(EditorLocalization.defaultLocalize(.showMore) == "Show More") + #expect(EditorLocalization.defaultLocalize(.patterns) == "Patterns") + #expect(EditorLocalization.defaultLocalize(.patternsCount(1)) == "1 pattern") + #expect(EditorLocalization.defaultLocalize(.patternsCount(3)) == "3 patterns") + } + + @Test + func subscriptUsesTheDefaultsWithoutAHostOverride() { + withLocalization { _ in + #expect(EditorLocalization[.showMore] == "Show More") + } + } + + @Test + func hostTranslationsTakePrecedence() { + withLocalization { _ in + EditorLocalization.localize = { key in + switch key { + case .showMore: "Mostrar más" + default: EditorLocalization.defaultLocalize(key) + } + } + + #expect(EditorLocalization[.showMore] == "Mostrar más") + } + } + + @Test + func unhandledKeysFallBackToTheDefaults() { + withLocalization { _ in + EditorLocalization.localize = { key in + switch key { + case .showMore: "Mostrar más" + default: EditorLocalization.defaultLocalize(key) + } + } + + #expect(EditorLocalization[.search] == "Search") + } + } + + // Without a host override every string comes from the default table by + // design, so reporting each one would be noise. + @Test + func fallbackIsNotReportedWithoutAHostOverride() { + withLocalization { spy in + _ = EditorLocalization[.showMore] + + #expect(spy.messages.isEmpty) + } + } + + @Test + func fallbackIsReportedOnceAHostTranslates() { + withLocalization { spy in + EditorLocalization.localize = { key in + switch key { + case .showMore: "Mostrar más" + default: EditorLocalization.defaultLocalize(key) + } + } + + _ = EditorLocalization[.showMore] + #expect(spy.messages.isEmpty) + + _ = EditorLocalization[.search] + #expect(spy.messages.count == 1) + #expect(spy.messages.first?.0 == .debug) + #expect(spy.messages.first?.1.contains("search") == true) + } + } +} From 1958269609784ca6e218444084f075d89962f804 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 28 Jul 2026 09:42:35 -0400 Subject: [PATCH 03/14] fix(ios): report missing translations where hosts can see them The fallback report went through `EditorLogger`, which only reaches hosts that install a logger and lower the log level from its `error` default. Host apps do neither by default, so the message was never emitted for the integration it was meant to help. Log through `OSLog` instead, which needs no host configuration and matches how the rest of the library reports activity. Use `notice` so the message also persists to the log store and stays readable from Console.app after the fact, rather than only streaming live to an attached debugger. Cover the emission against a real `OSLogStore` with `EditorLogger` left unconfigured, matching how host apps integrate the library. Co-Authored-By: Claude Opus 5 (1M context) --- .../Sources/EditorLocalization.swift | 31 +++++-- .../GutenbergKit/Sources/EditorLogging.swift | 3 + .../EditorLocalizationTests.swift | 92 ++++++++++--------- 3 files changed, 75 insertions(+), 51 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift index 9d507424a..c0c5eacd4 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift @@ -1,4 +1,5 @@ import Foundation +import OSLog /// Enum representing all localizable strings in the editor. public enum EditorLocalizableString { @@ -62,23 +63,35 @@ public final class EditorLocalization { public static var localize: (EditorLocalizableString) -> String = { key in defaultLocalize(key) } { - didSet { hasHostTranslations = true } + didSet { shouldReportFallback = true } } - /// Whether a host app installed its own ``localize``. + /// Whether falling back to a default string is worth reporting. /// - /// Falling back is only worth reporting once a host has taken + /// True once a host app installs its own ``localize`` and thereby takes /// responsibility for translations. Without an override every string comes - /// from the default table by design, and logging each one would be noise. - private static var hasHostTranslations = false + /// from the default table by design, and reporting each one would be noise. + static private(set) var shouldReportFallback = false /// The editor's untranslated strings. /// /// Exposed so host apps can fall back to it for keys they do not translate. /// See ``localize``. public static let defaultLocalize: (EditorLocalizableString) -> String = { key in - if hasHostTranslations { - log(.debug, "Missing host translation for \(key), using the editor default.") + if shouldReportFallback { + // Logged through `OSLog` rather than `EditorLogger`, which reaches + // only hosts that install a logger and raise the log level. This + // message is for whoever integrates the library, and the hosts most + // likely to miss a translation are the ones least likely to have + // configured logging. + // + // Logged at `notice` rather than `debug` so it persists to the log + // store. `debug` is held in an in-memory buffer that requires + // enabling debug logging for the subsystem to read, which defeats + // the point of reporting something the host is unaware of. + Logger.localization.notice( + "Missing host translation for \(String(describing: key), privacy: .public), using the editor default." + ) } return switch key { @@ -110,7 +123,7 @@ public final class EditorLocalization { /// Clears the record of a host override so tests can restore the initial /// state after assigning ``localize``. - static func resetHostTranslationsForTesting() { - hasHostTranslations = false + static func resetFallbackReportingForTesting() { + shouldReportFallback = false } } diff --git a/ios/Sources/GutenbergKit/Sources/EditorLogging.swift b/ios/Sources/GutenbergKit/Sources/EditorLogging.swift index 954efe5ee..e04788906 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorLogging.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorLogging.swift @@ -25,6 +25,9 @@ extension Logger { /// Logs media import activity static let media = Logger(subsystem: "GutenbergKit", category: "media") + + /// Logs editor localization activity + static let localization = Logger(subsystem: "GutenbergKit", category: "localization") } public struct SignpostMonitor: Sendable { diff --git a/ios/Tests/GutenbergKitTests/EditorLocalizationTests.swift b/ios/Tests/GutenbergKitTests/EditorLocalizationTests.swift index 962cb2b01..38adf546e 100644 --- a/ios/Tests/GutenbergKitTests/EditorLocalizationTests.swift +++ b/ios/Tests/GutenbergKitTests/EditorLocalizationTests.swift @@ -1,44 +1,21 @@ import Foundation +import OSLog import Testing @testable import GutenbergKit -/// Captures log messages so the fallback reporting can be inspected. -private final class SpyLogger: EditorLogging, @unchecked Sendable { - private let lock = NSLock() - private var _messages: [(EditorLogLevel, String)] = [] - - var messages: [(EditorLogLevel, String)] { - lock.withLock { _messages } - } - - func log(_ level: EditorLogLevel, _ message: String) { - lock.withLock { _messages.append((level, message)) } - } -} - @MainActor struct EditorLocalizationTests { - /// Restores the global localization and logging state around each test. - private func withLocalization( - _ body: (SpyLogger) throws -> Void - ) rethrows { + /// Restores the global localization state around each test. + private func withLocalization(_ body: () throws -> Void) rethrows { let previousLocalize = EditorLocalization.localize - let previousLogger = EditorLogger.shared - let previousLevel = EditorLogger.logLevel - - let spy = SpyLogger() - EditorLogger.shared = spy - EditorLogger.logLevel = .debug defer { EditorLocalization.localize = previousLocalize - EditorLogger.shared = previousLogger - EditorLogger.logLevel = previousLevel - EditorLocalization.resetHostTranslationsForTesting() + EditorLocalization.resetFallbackReportingForTesting() } - try body(spy) + try body() } @Test @@ -51,14 +28,14 @@ struct EditorLocalizationTests { @Test func subscriptUsesTheDefaultsWithoutAHostOverride() { - withLocalization { _ in + withLocalization { #expect(EditorLocalization[.showMore] == "Show More") } } @Test func hostTranslationsTakePrecedence() { - withLocalization { _ in + withLocalization { EditorLocalization.localize = { key in switch key { case .showMore: "Mostrar más" @@ -72,7 +49,7 @@ struct EditorLocalizationTests { @Test func unhandledKeysFallBackToTheDefaults() { - withLocalization { _ in + withLocalization { EditorLocalization.localize = { key in switch key { case .showMore: "Mostrar más" @@ -88,16 +65,14 @@ struct EditorLocalizationTests { // design, so reporting each one would be noise. @Test func fallbackIsNotReportedWithoutAHostOverride() { - withLocalization { spy in - _ = EditorLocalization[.showMore] - - #expect(spy.messages.isEmpty) + withLocalization { + #expect(EditorLocalization.shouldReportFallback == false) } } @Test func fallbackIsReportedOnceAHostTranslates() { - withLocalization { spy in + withLocalization { EditorLocalization.localize = { key in switch key { case .showMore: "Mostrar más" @@ -105,13 +80,46 @@ struct EditorLocalizationTests { } } - _ = EditorLocalization[.showMore] - #expect(spy.messages.isEmpty) + #expect(EditorLocalization.shouldReportFallback) + } + } + + /// Host apps are not required to configure `EditorLogger`, so the report + /// has to reach the log store on its own. `debug` messages are held in an + /// in-memory buffer and would not. + @Test + func fallbackReachesTheLogStoreWithoutAHostLogger() throws { + let previousShared = EditorLogger.shared + let previousLevel = EditorLogger.logLevel + + // Explicitly leave `EditorLogger` unconfigured. + EditorLogger.shared = nil + EditorLogger.logLevel = .error + + defer { + EditorLogger.shared = previousShared + EditorLogger.logLevel = previousLevel + } + + try withLocalization { + EditorLocalization.localize = { key in + switch key { + case .showMore: "Mostrar más" + default: EditorLocalization.defaultLocalize(key) + } + } + + let started = Date() + _ = EditorLocalization[.patternsCount(3)] + + let store = try OSLogStore(scope: .currentProcessIdentifier) + let entries = try store.getEntries( + at: store.position(date: started), + matching: NSPredicate(format: "subsystem == %@", "GutenbergKit") + ) - _ = EditorLocalization[.search] - #expect(spy.messages.count == 1) - #expect(spy.messages.first?.0 == .debug) - #expect(spy.messages.first?.1.contains("search") == true) + let messages = entries.compactMap { ($0 as? OSLogEntryLog)?.composedMessage } + #expect(messages.contains { $0.contains("Missing host translation") }) } } } From b18641ce63721a095cdc8c62218cfa37f35f5899 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 28 Jul 2026 09:58:26 -0400 Subject: [PATCH 04/14] fix(ios): let host apps call defaultLocalize from a nonisolated context `EditorLocalization` is `@MainActor`, so `defaultLocalize` inherited that isolation. Host apps delegate to it from their own localization functions, which are ordinarily not isolated, so referencing it warned under Swift 5 and would fail to compile under the Swift 6 language mode. Declare it `nonisolated` and make it a function rather than a stored closure. It is a fixed lookup that no one replaces, and `nonisolated` cannot apply to a non-`Sendable` closure type. Guard the reporting flag it reads with a lock instead of leaving it `nonisolated(unsafe)`, so the cross-actor access is free of races rather than asserted to be. Co-Authored-By: Claude Opus 5 (1M context) --- .../Sources/EditorLocalization.swift | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift index c0c5eacd4..32298ad2f 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift @@ -63,7 +63,7 @@ public final class EditorLocalization { public static var localize: (EditorLocalizableString) -> String = { key in defaultLocalize(key) } { - didSet { shouldReportFallback = true } + didSet { setShouldReportFallback(true) } } /// Whether falling back to a default string is worth reporting. @@ -71,13 +71,33 @@ public final class EditorLocalization { /// True once a host app installs its own ``localize`` and thereby takes /// responsibility for translations. Without an override every string comes /// from the default table by design, and reporting each one would be noise. - static private(set) var shouldReportFallback = false + /// + /// ``defaultLocalize`` reads this, and host apps call that from whatever + /// context their own localization runs in, so it cannot be isolated to the + /// main actor. Guarded by a lock rather than declared `nonisolated(unsafe)` + /// so the access is actually free of races instead of merely asserted to be. + nonisolated static var shouldReportFallback: Bool { + fallbackReportingLock.withLock { _shouldReportFallback } + } + + private nonisolated static let fallbackReportingLock = NSLock() + nonisolated(unsafe) private static var _shouldReportFallback = false + + private nonisolated static func setShouldReportFallback(_ newValue: Bool) { + fallbackReportingLock.withLock { _shouldReportFallback = newValue } + } /// The editor's untranslated strings. /// /// Exposed so host apps can fall back to it for keys they do not translate. /// See ``localize``. - public static let defaultLocalize: (EditorLocalizableString) -> String = { key in + /// + /// Deliberately `nonisolated`: host apps delegate to this from their own + /// localization functions, which are ordinarily not main-actor isolated. + /// Isolating it would force that annotation onto every host. + public nonisolated static func defaultLocalize( + _ key: EditorLocalizableString + ) -> String { if shouldReportFallback { // Logged through `OSLog` rather than `EditorLogger`, which reaches // only hosts that install a logger and raise the log level. This @@ -124,6 +144,6 @@ public final class EditorLocalization { /// Clears the record of a host override so tests can restore the initial /// state after assigning ``localize``. static func resetFallbackReportingForTesting() { - shouldReportFallback = false + setShouldReportFallback(false) } } From 14ecd81387994ef35f3ca4b7bb4294924468aa46 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 28 Jul 2026 11:51:56 -0400 Subject: [PATCH 05/14] fix(ios): report each missing translation once, with an opt-out Reporting was gated on a flag that any assignment to `localize` tripped, which meant a host restoring the editor's own strings latched reporting on permanently. It also logged on every string read, and every call site sits in a SwiftUI `body` that re-evaluates per render pass, so scrolling the pattern list wrote a persisted `notice` per row per frame. Replace the latch with `reportsMissingTranslations`, a public opt-out that states the intent directly, and report each key at most once per process. Deduping through the lock also closes the read-then-log race the previous flag only claimed to close. The demo app opts out: it renders the editor's own strings on purpose, so every lookup falls back. Co-Authored-By: Claude Opus 5 (1M context) --- ios/Demo-iOS/Sources/GutenbergApp.swift | 4 + .../Sources/EditorLocalization.swift | 120 ++++++++++++------ .../EditorLocalizationTests.swift | 117 ++++++++++++----- 3 files changed, 173 insertions(+), 68 deletions(-) diff --git a/ios/Demo-iOS/Sources/GutenbergApp.swift b/ios/Demo-iOS/Sources/GutenbergApp.swift index 3a741f03f..ef4af7b2a 100644 --- a/ios/Demo-iOS/Sources/GutenbergApp.swift +++ b/ios/Demo-iOS/Sources/GutenbergApp.swift @@ -43,6 +43,10 @@ struct GutenbergApp: App { // Configure logger for GutenbergKit EditorLogger.shared = OSLogEditorLogger() EditorLogger.logLevel = .debug + + // The demo app renders the editor's own strings on purpose, so every + // lookup falls back and the reports carry no signal here. + EditorLocalization.reportsMissingTranslations = false } var body: some Scene { diff --git a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift index 32298ad2f..12f752422 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift @@ -2,7 +2,7 @@ import Foundation import OSLog /// Enum representing all localizable strings in the editor. -public enum EditorLocalizableString { +public enum EditorLocalizableString: Sendable { // MARK: - Block Inserter case showMore case showLess @@ -32,6 +32,32 @@ public enum EditorLocalizableString { case lockdownModeDismiss } +extension EditorLocalizableString: CaseIterable { + /// Written by hand because the associated value on ``patternsCount`` blocks + /// the compiler's synthesis. Add new cases here so they stay covered by the + /// test asserting every key has a default string. + public static let allCases: [EditorLocalizableString] = [ + .showMore, + .showLess, + .search, + .insertBlock, + .failedToInsertMedia, + .patterns, + .noPatternsFound, + .insertPattern, + .patternsCategoryUncategorized, + .patternsCategoryAll, + .patternsCount(3), + .loadingEditor, + .editorError, + .lockdownModeTitle, + .lockdownModeWarning, + .lockdownModeExcludeHint, + .lockdownModeLearnMore, + .lockdownModeDismiss, + ] +} + /// Provides localized strings for the editor. /// /// Usage: @@ -58,34 +84,31 @@ public final class EditorLocalization { /// An exhaustive switch stops compiling whenever the editor adds a string, /// which blocks the host from adopting unrelated changes until someone /// writes a translation. Delegating instead renders the untranslated - /// default for new strings and logs at the `debug` level, so a missing - /// translation degrades the string rather than the build. + /// default for new strings and reports the gap through ``Logger``, so a + /// missing translation degrades the string rather than the build. See + /// ``reportsMissingTranslations``. public static var localize: (EditorLocalizableString) -> String = { key in defaultLocalize(key) - } { - didSet { setShouldReportFallback(true) } } - /// Whether falling back to a default string is worth reporting. + /// Whether falling back to a default string is reported to the system log. /// - /// True once a host app installs its own ``localize`` and thereby takes - /// responsibility for translations. Without an override every string comes - /// from the default table by design, and reporting each one would be noise. + /// Enabled by default so a host that misses a translation finds out without + /// having to opt in. Set to `false` in apps that render the editor's own + /// strings deliberately — the demo app, say — where every fallback is + /// expected and the reports are noise. /// /// ``defaultLocalize`` reads this, and host apps call that from whatever /// context their own localization runs in, so it cannot be isolated to the - /// main actor. Guarded by a lock rather than declared `nonisolated(unsafe)` - /// so the access is actually free of races instead of merely asserted to be. - nonisolated static var shouldReportFallback: Bool { - fallbackReportingLock.withLock { _shouldReportFallback } + /// main actor. Guarded by the same lock as the reported-key set below. + public nonisolated static var reportsMissingTranslations: Bool { + get { reportingLock.withLock { _reportsMissingTranslations } } + set { reportingLock.withLock { _reportsMissingTranslations = newValue } } } - private nonisolated static let fallbackReportingLock = NSLock() - nonisolated(unsafe) private static var _shouldReportFallback = false - - private nonisolated static func setShouldReportFallback(_ newValue: Bool) { - fallbackReportingLock.withLock { _shouldReportFallback = newValue } - } + private nonisolated static let reportingLock = NSLock() + nonisolated(unsafe) private static var _reportsMissingTranslations = true + nonisolated(unsafe) private static var reportedKeys: Set = [] /// The editor's untranslated strings. /// @@ -98,21 +121,7 @@ public final class EditorLocalization { public nonisolated static func defaultLocalize( _ key: EditorLocalizableString ) -> String { - if shouldReportFallback { - // Logged through `OSLog` rather than `EditorLogger`, which reaches - // only hosts that install a logger and raise the log level. This - // message is for whoever integrates the library, and the hosts most - // likely to miss a translation are the ones least likely to have - // configured logging. - // - // Logged at `notice` rather than `debug` so it persists to the log - // store. `debug` is held in an in-memory buffer that requires - // enabling debug logging for the subsystem to read, which defeats - // the point of reporting something the host is unaware of. - Logger.localization.notice( - "Missing host translation for \(String(describing: key), privacy: .public), using the editor default." - ) - } + reportMissingTranslation(for: key) return switch key { case .showMore: "Show More" @@ -136,14 +145,49 @@ public final class EditorLocalization { } } + /// Reports a missing translation the first time each key falls back. + /// + /// Every call site sits inside a SwiftUI `body`, which re-evaluates on each + /// render pass, so logging unconditionally would write an entry per row per + /// frame while a list scrolls. Reporting once per key tells the integrator + /// the same thing without the volume. + private nonisolated static func reportMissingTranslation( + for key: EditorLocalizableString + ) { + // Associated values distinguish cases that share a translation: + // `patternsCount(3)` and `patternsCount(7)` are one missing string. + let name = String(String(describing: key).prefix { $0 != "(" }) + + let shouldReport = reportingLock.withLock { + _reportsMissingTranslations && reportedKeys.insert(name).inserted + } + guard shouldReport else { return } + + // Logged through `OSLog` rather than `EditorLogger`, which reaches only + // hosts that install a logger and raise the log level. This message is + // for whoever integrates the library, and the hosts most likely to miss + // a translation are the ones least likely to have configured logging. + // + // Logged at `notice` rather than `debug` so it persists to the log + // store. `debug` is held in an in-memory buffer that requires enabling + // debug logging for the subsystem to read, which defeats the point of + // reporting something the host is unaware of. + Logger.localization.notice( + "Missing host translation for \(name, privacy: .public), using the editor default." + ) + } + /// Convenience subscript for accessing localized strings. public static subscript(key: EditorLocalizableString) -> String { localize(key) } - /// Clears the record of a host override so tests can restore the initial - /// state after assigning ``localize``. - static func resetFallbackReportingForTesting() { - setShouldReportFallback(false) + /// Clears the record of which keys have already been reported so tests do + /// not leak state into each other. + static func resetMissingTranslationReportingForTesting() { + reportingLock.withLock { + _reportsMissingTranslations = true + reportedKeys.removeAll() + } } } diff --git a/ios/Tests/GutenbergKitTests/EditorLocalizationTests.swift b/ios/Tests/GutenbergKitTests/EditorLocalizationTests.swift index 38adf546e..c94fca8bd 100644 --- a/ios/Tests/GutenbergKitTests/EditorLocalizationTests.swift +++ b/ios/Tests/GutenbergKitTests/EditorLocalizationTests.swift @@ -3,27 +3,50 @@ import OSLog import Testing @testable import GutenbergKit +/// `EditorLocalization.localize` and its reporting state are process-global, so +/// these tests cannot safely interleave. @MainActor +@Suite(.serialized) struct EditorLocalizationTests { /// Restores the global localization state around each test. - private func withLocalization(_ body: () throws -> Void) rethrows { + /// + /// Reporting is off unless a test asks for it, so that tests incidentally + /// hitting the default table do not write entries the reporting tests would + /// then read back — `OSLogStore.position(date:)` resolves too coarsely to + /// keep those windows apart. + private func withLocalization( + reportsMissingTranslations: Bool = false, + _ body: () throws -> Void + ) rethrows { let previousLocalize = EditorLocalization.localize defer { EditorLocalization.localize = previousLocalize - EditorLocalization.resetFallbackReportingForTesting() + EditorLocalization.resetMissingTranslationReportingForTesting() } + EditorLocalization.reportsMissingTranslations = reportsMissingTranslations + try body() } + /// Every case needs a default; a new one without a string would otherwise + /// surface only when a host delegates to it at runtime. + @Test(arguments: EditorLocalizableString.allCases) + func defaultLocalizeProvidesEveryString(key: EditorLocalizableString) { + withLocalization { + #expect(!EditorLocalization.defaultLocalize(key).isEmpty) + } + } + @Test - func defaultLocalizeProvidesEveryString() { - #expect(EditorLocalization.defaultLocalize(.showMore) == "Show More") - #expect(EditorLocalization.defaultLocalize(.patterns) == "Patterns") - #expect(EditorLocalization.defaultLocalize(.patternsCount(1)) == "1 pattern") - #expect(EditorLocalization.defaultLocalize(.patternsCount(3)) == "3 patterns") + func defaultLocalizePluralizesPatternCounts() { + withLocalization { + #expect(EditorLocalization.defaultLocalize(.showMore) == "Show More") + #expect(EditorLocalization.defaultLocalize(.patternsCount(1)) == "1 pattern") + #expect(EditorLocalization.defaultLocalize(.patternsCount(3)) == "3 patterns") + } } @Test @@ -61,26 +84,42 @@ struct EditorLocalizationTests { } } - // Without a host override every string comes from the default table by - // design, so reporting each one would be noise. + /// Call sites live in SwiftUI `body` methods that re-run on every render + /// pass, so repeat lookups of one key must not each write a log entry. @Test - func fallbackIsNotReportedWithoutAHostOverride() { - withLocalization { - #expect(EditorLocalization.shouldReportFallback == false) + func repeatedFallbacksForOneKeyAreReportedOnce() throws { + try withLocalization(reportsMissingTranslations: true) { + let started = Date() + + for count in 1...5 { + _ = EditorLocalization.defaultLocalize(.patternsCount(count)) + } + + // One report despite five lookups, and despite the differing + // associated values, which must not split one key into many. + let reports = try missingTranslationReports( + forKeyNamed: "patternsCount", + since: started + ) + #expect(reports.count == 1) } } @Test - func fallbackIsReportedOnceAHostTranslates() { - withLocalization { - EditorLocalization.localize = { key in - switch key { - case .showMore: "Mostrar más" - default: EditorLocalization.defaultLocalize(key) - } - } + func reportingCanBeDisabled() throws { + // Enabled by the helper, then turned off here, so the assertion below + // rests on this property rather than on the helper's default. + try withLocalization(reportsMissingTranslations: true) { + EditorLocalization.reportsMissingTranslations = false - #expect(EditorLocalization.shouldReportFallback) + let started = Date() + _ = EditorLocalization.defaultLocalize(.lockdownModeDismiss) + + let reports = try missingTranslationReports( + forKeyNamed: "lockdownModeDismiss", + since: started + ) + #expect(reports.isEmpty) } } @@ -101,7 +140,7 @@ struct EditorLocalizationTests { EditorLogger.logLevel = previousLevel } - try withLocalization { + try withLocalization(reportsMissingTranslations: true) { EditorLocalization.localize = { key in switch key { case .showMore: "Mostrar más" @@ -110,16 +149,34 @@ struct EditorLocalizationTests { } let started = Date() - _ = EditorLocalization[.patternsCount(3)] + _ = EditorLocalization[.lockdownModeLearnMore] - let store = try OSLogStore(scope: .currentProcessIdentifier) - let entries = try store.getEntries( - at: store.position(date: started), - matching: NSPredicate(format: "subsystem == %@", "GutenbergKit") + let reports = try missingTranslationReports( + forKeyNamed: "lockdownModeLearnMore", + since: started ) - - let messages = entries.compactMap { ($0 as? OSLogEntryLog)?.composedMessage } - #expect(messages.contains { $0.contains("Missing host translation") }) + #expect(!reports.isEmpty) } } + + /// Reads the reports for one key back out of the system log store, which is + /// where a host would find them without any configuration on their side. + /// + /// Scoped to a single key rather than a time window because + /// `OSLogStore.position(date:)` resolves coarsely enough that entries from + /// earlier tests fall inside the range. + private func missingTranslationReports( + forKeyNamed name: String, + since start: Date + ) throws -> [String] { + let store = try OSLogStore(scope: .currentProcessIdentifier) + let entries = try store.getEntries( + at: store.position(date: start), + matching: NSPredicate(format: "subsystem == %@", "GutenbergKit") + ) + + return entries + .compactMap { ($0 as? OSLogEntryLog)?.composedMessage } + .filter { $0.contains("Missing host translation for \(name),") } + } } From ea81e3d269e9e888faec88b7bfbe081663829f2f Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 28 Jul 2026 11:56:39 -0400 Subject: [PATCH 06/14] refactor(ios): drop CaseIterable from EditorLocalizableString The hand-written `allCases` existed only to drive a test asserting every key has a default string. The exhaustive switch in `defaultLocalize` already guarantees that at compile time, and because the list was hand-written it could not catch the case it was meant to: a new enum case nobody adds to `allCases` compiles, passes, and goes untested. It was also a public promise of completeness the type could not keep, so a host iterating it to pre-build a translation table would silently miss keys. The remaining test covers `patternsCount`, the one default that is computed rather than a literal. Co-Authored-By: Claude Opus 5 (1M context) --- .../Sources/EditorLocalization.swift | 28 +------------------ .../EditorLocalizationTests.swift | 13 ++------- 2 files changed, 4 insertions(+), 37 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift index 12f752422..9ad9c9569 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift @@ -2,7 +2,7 @@ import Foundation import OSLog /// Enum representing all localizable strings in the editor. -public enum EditorLocalizableString: Sendable { +public enum EditorLocalizableString { // MARK: - Block Inserter case showMore case showLess @@ -32,32 +32,6 @@ public enum EditorLocalizableString: Sendable { case lockdownModeDismiss } -extension EditorLocalizableString: CaseIterable { - /// Written by hand because the associated value on ``patternsCount`` blocks - /// the compiler's synthesis. Add new cases here so they stay covered by the - /// test asserting every key has a default string. - public static let allCases: [EditorLocalizableString] = [ - .showMore, - .showLess, - .search, - .insertBlock, - .failedToInsertMedia, - .patterns, - .noPatternsFound, - .insertPattern, - .patternsCategoryUncategorized, - .patternsCategoryAll, - .patternsCount(3), - .loadingEditor, - .editorError, - .lockdownModeTitle, - .lockdownModeWarning, - .lockdownModeExcludeHint, - .lockdownModeLearnMore, - .lockdownModeDismiss, - ] -} - /// Provides localized strings for the editor. /// /// Usage: diff --git a/ios/Tests/GutenbergKitTests/EditorLocalizationTests.swift b/ios/Tests/GutenbergKitTests/EditorLocalizationTests.swift index c94fca8bd..82414dd28 100644 --- a/ios/Tests/GutenbergKitTests/EditorLocalizationTests.swift +++ b/ios/Tests/GutenbergKitTests/EditorLocalizationTests.swift @@ -31,19 +31,12 @@ struct EditorLocalizationTests { try body() } - /// Every case needs a default; a new one without a string would otherwise - /// surface only when a host delegates to it at runtime. - @Test(arguments: EditorLocalizableString.allCases) - func defaultLocalizeProvidesEveryString(key: EditorLocalizableString) { - withLocalization { - #expect(!EditorLocalization.defaultLocalize(key).isEmpty) - } - } - + /// The only default that is computed rather than a literal. The rest are + /// covered by the exhaustive switch in `defaultLocalize`, which fails to + /// compile if a case has no string. @Test func defaultLocalizePluralizesPatternCounts() { withLocalization { - #expect(EditorLocalization.defaultLocalize(.showMore) == "Show More") #expect(EditorLocalization.defaultLocalize(.patternsCount(1)) == "1 pattern") #expect(EditorLocalization.defaultLocalize(.patternsCount(3)) == "3 patterns") } From 9008006d7a5f399878ec9a266a25b4642fee4915 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 28 Jul 2026 12:41:09 -0400 Subject: [PATCH 07/14] fix(ios): stop reporting strings the host does translate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `defaultLocalize` reported on every call, but it has two callers that mean opposite things: a host delegating an unhandled key, and the editor reading its own default before a host installs `localize`. The second fired falsely. `EditorViewController` reads `loadingEditor` in a stored property initializer, which runs while the view controller is built — before the host's `localize` assignment is guaranteed to have landed. So WordPress-iOS saw "Missing host translation for loadingEditor" for a string it translates. Reports that name correctly-translated strings train integrators to ignore all of them. Split the lookup from the reporting: the library's own default closure calls a private `defaultString(for:)`, and the public `defaultLocalize` — which only hosts call — keeps reporting. Co-Authored-By: Claude Opus 5 (1M context) --- .../Sources/EditorLocalization.swift | 19 ++++++++++++++++-- .../EditorLocalizationTests.swift | 20 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift index 9ad9c9569..1db59555b 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift @@ -61,8 +61,13 @@ public final class EditorLocalization { /// default for new strings and reports the gap through ``Logger``, so a /// missing translation degrades the string rather than the build. See /// ``reportsMissingTranslations``. + /// + /// Until a host installs its own closure this renders the defaults without + /// reporting them. Reporting here would fire for strings the host does + /// translate, because the editor reads some of them — `loadingEditor` among + /// them — while building views, which can run before the host assigns this. public static var localize: (EditorLocalizableString) -> String = { key in - defaultLocalize(key) + defaultString(for: key) } /// Whether falling back to a default string is reported to the system log. @@ -95,9 +100,19 @@ public final class EditorLocalization { public nonisolated static func defaultLocalize( _ key: EditorLocalizableString ) -> String { + // Only the host delegating an unhandled key reaches here, so this is + // where a gap in the host's translations is genuinely observable. The + // editor's own reads go through `defaultString(for:)` instead. reportMissingTranslation(for: key) - return switch key { + return defaultString(for: key) + } + + /// The editor's untranslated strings, without reporting. + private nonisolated static func defaultString( + for key: EditorLocalizableString + ) -> String { + switch key { case .showMore: "Show More" case .showLess: "Show Less" case .search: "Search" diff --git a/ios/Tests/GutenbergKitTests/EditorLocalizationTests.swift b/ios/Tests/GutenbergKitTests/EditorLocalizationTests.swift index 82414dd28..11923dd7c 100644 --- a/ios/Tests/GutenbergKitTests/EditorLocalizationTests.swift +++ b/ios/Tests/GutenbergKitTests/EditorLocalizationTests.swift @@ -49,6 +49,26 @@ struct EditorLocalizationTests { } } + /// The editor reads some strings while building views, which can run before + /// the host assigns `localize`. Reporting those would name keys the host + /// does translate, and reports that cry wolf get ignored. + @Test + func readsBeforeAHostOverrideAreNotReported() throws { + try withLocalization(reportsMissingTranslations: true) { + let started = Date() + + // No host override installed: this is the editor reading its own + // default, not a gap in anyone's translations. + _ = EditorLocalization[.loadingEditor] + + let reports = try missingTranslationReports( + forKeyNamed: "loadingEditor", + since: started + ) + #expect(reports.isEmpty) + } + } + @Test func hostTranslationsTakePrecedence() { withLocalization { From 40f35ecb2c8a88ba8759f911f28cf79571e969a5 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 28 Jul 2026 13:07:19 -0400 Subject: [PATCH 08/14] docs(ios): recommend @unknown default for host localization A plain `default` warns "default will never be executed" in a host that happens to cover every case, which WordPress-iOS does today. `@unknown default` compiles clean there and behaves identically once the editor adds a string. Co-Authored-By: Claude Opus 5 (1M context) --- ios/Sources/GutenbergKit/Sources/EditorLocalization.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift index 1db59555b..c9df09ff9 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift @@ -50,11 +50,15 @@ public final class EditorLocalization { /// switch key { /// case .showMore: NSLocalizedString("editor.blockInserter.showMore", ...) /// // ...keys the host translates. - /// default: EditorLocalization.defaultLocalize(key) + /// @unknown default: EditorLocalization.defaultLocalize(key) /// } /// } /// ``` /// + /// `@unknown default` rather than a plain `default` so a host that happens + /// to cover every case today still compiles without a "default will never + /// be executed" warning. + /// /// An exhaustive switch stops compiling whenever the editor adds a string, /// which blocks the host from adopting unrelated changes until someone /// writes a translation. Delegating instead renders the untranslated From 42b4bb4324aa8d0a2517c9448b0c04f8d9364d6c Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 29 Jul 2026 14:24:39 -0400 Subject: [PATCH 09/14] refactor(ios)!: let hosts decline a key by returning nil MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `EditorLocalization.localize` now returns `String?`. Hosts return `nil` for keys they do not translate instead of calling a public `defaultLocalize`. Delegating by convention could be skipped or reimplemented: a host was free to write `default: ""` and ship blanks past the fallback. `nil` is now the only way to decline a key, so every fallback routes through the editor's defaults and its missing-translation reporting. This also retires the isolation problem that made `defaultLocalize` public. It is now a private `defaultString(for:)`, so nothing forces `nonisolated` onto host code, and reporting moves to the subscript — the one place that observes a declined key. The seven `EditorLocalization.localize(...)` call sites in the editor moved to the subscript, which they should have used already; calling the closure directly bypassed the fallback. Drops the demo app's `reportsMissingTranslations = false`. The demo installs no closure, so it never returns `nil` and never reported. Co-Authored-By: Claude Opus 5 (1M context) --- ios/Demo-iOS/Sources/GutenbergApp.swift | 4 -- .../Sources/EditorLocalization.swift | 66 +++++++++---------- .../Sources/EditorViewController.swift | 4 +- .../Sources/Views/LockdownModeSheet.swift | 10 +-- .../EditorLocalizationTests.swift | 23 ++++--- 5 files changed, 52 insertions(+), 55 deletions(-) diff --git a/ios/Demo-iOS/Sources/GutenbergApp.swift b/ios/Demo-iOS/Sources/GutenbergApp.swift index ef4af7b2a..3a741f03f 100644 --- a/ios/Demo-iOS/Sources/GutenbergApp.swift +++ b/ios/Demo-iOS/Sources/GutenbergApp.swift @@ -43,10 +43,6 @@ struct GutenbergApp: App { // Configure logger for GutenbergKit EditorLogger.shared = OSLogEditorLogger() EditorLogger.logLevel = .debug - - // The demo app renders the editor's own strings on purpose, so every - // lookup falls back and the reports carry no signal here. - EditorLocalization.reportsMissingTranslations = false } var body: some Scene { diff --git a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift index c9df09ff9..d94d595c1 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift @@ -42,35 +42,41 @@ public enum EditorLocalizableString { public final class EditorLocalization { /// This is designed to be overridden by the host app to provide translations. /// - /// Host apps are encouraged to delegate unhandled keys to - /// ``defaultLocalize`` rather than switching exhaustively: + /// Return `nil` for keys the host does not translate; the editor renders its + /// own string for those: /// /// ```swift /// EditorLocalization.localize = { key in /// switch key { /// case .showMore: NSLocalizedString("editor.blockInserter.showMore", ...) /// // ...keys the host translates. - /// @unknown default: EditorLocalization.defaultLocalize(key) + /// default: nil /// } /// } /// ``` /// - /// `@unknown default` rather than a plain `default` so a host that happens - /// to cover every case today still compiles without a "default will never - /// be executed" warning. + /// A plain `default` rather than `@unknown default`, which only suppresses + /// the warning about cases added in future versions and still requires the + /// switch to cover every case that exists today. /// /// An exhaustive switch stops compiling whenever the editor adds a string, /// which blocks the host from adopting unrelated changes until someone - /// writes a translation. Delegating instead renders the untranslated + /// writes a translation. Returning `nil` instead renders the untranslated /// default for new strings and reports the gap through ``Logger``, so a /// missing translation degrades the string rather than the build. See /// ``reportsMissingTranslations``. /// + /// The return type is optional rather than the host calling a public + /// fallback itself so that the fallback cannot be skipped or reimplemented: + /// `nil` is the only way to decline a key, and it routes through the + /// editor's own defaults and reporting. + /// /// Until a host installs its own closure this renders the defaults without - /// reporting them. Reporting here would fire for strings the host does - /// translate, because the editor reads some of them — `loadingEditor` among - /// them — while building views, which can run before the host assigns this. - public static var localize: (EditorLocalizableString) -> String = { key in + /// reporting them, because the closure answers every key. Reporting is tied + /// to `nil` returns, which only a host can produce, so strings the editor + /// reads while building views — `loadingEditor` among them, which can run + /// before the host assigns this — are never reported as missing. + public static var localize: (EditorLocalizableString) -> String? = { key in defaultString(for: key) } @@ -81,9 +87,7 @@ public final class EditorLocalization { /// strings deliberately — the demo app, say — where every fallback is /// expected and the reports are noise. /// - /// ``defaultLocalize`` reads this, and host apps call that from whatever - /// context their own localization runs in, so it cannot be isolated to the - /// main actor. Guarded by the same lock as the reported-key set below. + /// Guarded by the same lock as the reported-key set below. public nonisolated static var reportsMissingTranslations: Bool { get { reportingLock.withLock { _reportsMissingTranslations } } set { reportingLock.withLock { _reportsMissingTranslations = newValue } } @@ -94,25 +98,6 @@ public final class EditorLocalization { nonisolated(unsafe) private static var reportedKeys: Set = [] /// The editor's untranslated strings. - /// - /// Exposed so host apps can fall back to it for keys they do not translate. - /// See ``localize``. - /// - /// Deliberately `nonisolated`: host apps delegate to this from their own - /// localization functions, which are ordinarily not main-actor isolated. - /// Isolating it would force that annotation onto every host. - public nonisolated static func defaultLocalize( - _ key: EditorLocalizableString - ) -> String { - // Only the host delegating an unhandled key reaches here, so this is - // where a gap in the host's translations is genuinely observable. The - // editor's own reads go through `defaultString(for:)` instead. - reportMissingTranslation(for: key) - - return defaultString(for: key) - } - - /// The editor's untranslated strings, without reporting. private nonisolated static func defaultString( for key: EditorLocalizableString ) -> String { @@ -171,8 +156,21 @@ public final class EditorLocalization { } /// Convenience subscript for accessing localized strings. + /// + /// Falls back to the editor's own string when the host declines a key, and + /// reports the gap. Every read goes through here, so a host cannot skip the + /// fallback or supply a placeholder in its place. public static subscript(key: EditorLocalizableString) -> String { - localize(key) + if let translation = localize(key) { + return translation + } + + // Only a host returning `nil` reaches here, so this is where a gap in + // the host's translations is genuinely observable. The default closure + // answers every key, so the editor's own reads never report. + reportMissingTranslation(for: key) + + return defaultString(for: key) } /// Clears the record of which keys have already been reported so tests do diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 808c7034f..f65fd2f75 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -114,7 +114,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro // MARK: - Private Properties (UI) /// Progress bar shown during async dependency fetching ("No Dependencies" flow). - private let progressView = UIEditorProgressView(loadingText: EditorLocalization.localize(.loadingEditor)) + private let progressView = UIEditorProgressView(loadingText: EditorLocalization[.loadingEditor]) /// Spinning indicator shown while WebKit loads and parses the editor JavaScript. private let waitingView = UIActivityIndicatorView(style: .medium) @@ -877,7 +877,7 @@ extension EditorViewController { @MainActor func displayError(_ error: Error) { let view = ContentUnavailableView( - EditorLocalization.localize(.editorError), + EditorLocalization[.editorError], systemImage: "exclamationmark.circle", description: Text(error.localizedDescription) ) diff --git a/ios/Sources/GutenbergKit/Sources/Views/LockdownModeSheet.swift b/ios/Sources/GutenbergKit/Sources/Views/LockdownModeSheet.swift index 023f40de0..b9d2160c8 100644 --- a/ios/Sources/GutenbergKit/Sources/Views/LockdownModeSheet.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/LockdownModeSheet.swift @@ -20,16 +20,16 @@ struct LockdownModeSheet: View { .foregroundColor(.orange) .accessibilityHidden(true) - Text(EditorLocalization.localize(.lockdownModeTitle)) + Text(EditorLocalization[.lockdownModeTitle]) .font(.title2) .fontWeight(.bold) .accessibilityAddTraits(.isHeader) - Text(EditorLocalization.localize(.lockdownModeWarning)) + Text(EditorLocalization[.lockdownModeWarning]) .font(.body) .foregroundColor(.secondary) - Text(EditorLocalization.localize(.lockdownModeExcludeHint)) + Text(EditorLocalization[.lockdownModeExcludeHint]) .font(.body) .foregroundColor(.secondary) } @@ -41,7 +41,7 @@ struct LockdownModeSheet: View { Button { onLearnMore() } label: { - Text(EditorLocalization.localize(.lockdownModeLearnMore)) + Text(EditorLocalization[.lockdownModeLearnMore]) .font(.body) .fontWeight(.semibold) .foregroundStyle(.white) @@ -54,7 +54,7 @@ struct LockdownModeSheet: View { Button { onDismiss() } label: { - Text(EditorLocalization.localize(.lockdownModeDismiss)) + Text(EditorLocalization[.lockdownModeDismiss]) .font(.body) .foregroundStyle(.primary) .frame(maxWidth: .infinity) diff --git a/ios/Tests/GutenbergKitTests/EditorLocalizationTests.swift b/ios/Tests/GutenbergKitTests/EditorLocalizationTests.swift index 11923dd7c..c793ada83 100644 --- a/ios/Tests/GutenbergKitTests/EditorLocalizationTests.swift +++ b/ios/Tests/GutenbergKitTests/EditorLocalizationTests.swift @@ -32,13 +32,13 @@ struct EditorLocalizationTests { } /// The only default that is computed rather than a literal. The rest are - /// covered by the exhaustive switch in `defaultLocalize`, which fails to + /// covered by the exhaustive switch in `defaultString(for:)`, which fails to /// compile if a case has no string. @Test - func defaultLocalizePluralizesPatternCounts() { + func defaultsPluralizePatternCounts() { withLocalization { - #expect(EditorLocalization.defaultLocalize(.patternsCount(1)) == "1 pattern") - #expect(EditorLocalization.defaultLocalize(.patternsCount(3)) == "3 patterns") + #expect(EditorLocalization[.patternsCount(1)] == "1 pattern") + #expect(EditorLocalization[.patternsCount(3)] == "3 patterns") } } @@ -75,7 +75,7 @@ struct EditorLocalizationTests { EditorLocalization.localize = { key in switch key { case .showMore: "Mostrar más" - default: EditorLocalization.defaultLocalize(key) + default: nil } } @@ -84,12 +84,12 @@ struct EditorLocalizationTests { } @Test - func unhandledKeysFallBackToTheDefaults() { + func declinedKeysFallBackToTheDefaults() { withLocalization { EditorLocalization.localize = { key in switch key { case .showMore: "Mostrar más" - default: EditorLocalization.defaultLocalize(key) + default: nil } } @@ -102,10 +102,12 @@ struct EditorLocalizationTests { @Test func repeatedFallbacksForOneKeyAreReportedOnce() throws { try withLocalization(reportsMissingTranslations: true) { + EditorLocalization.localize = { _ in nil } + let started = Date() for count in 1...5 { - _ = EditorLocalization.defaultLocalize(.patternsCount(count)) + _ = EditorLocalization[.patternsCount(count)] } // One report despite five lookups, and despite the differing @@ -123,10 +125,11 @@ struct EditorLocalizationTests { // Enabled by the helper, then turned off here, so the assertion below // rests on this property rather than on the helper's default. try withLocalization(reportsMissingTranslations: true) { + EditorLocalization.localize = { _ in nil } EditorLocalization.reportsMissingTranslations = false let started = Date() - _ = EditorLocalization.defaultLocalize(.lockdownModeDismiss) + _ = EditorLocalization[.lockdownModeDismiss] let reports = try missingTranslationReports( forKeyNamed: "lockdownModeDismiss", @@ -157,7 +160,7 @@ struct EditorLocalizationTests { EditorLocalization.localize = { key in switch key { case .showMore: "Mostrar más" - default: EditorLocalization.defaultLocalize(key) + default: nil } } From 191ae2b51862827fc5c963305595f6c77d3ded9e Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 29 Jul 2026 14:30:55 -0400 Subject: [PATCH 10/14] docs(ios): trim localization docs to the current design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comments argued against alternatives that only ever existed in this branch's history — a public `defaultLocalize` for hosts to call, and `@unknown default` in the host switch. A reader of the merged code has no such context, so the justifications read as warnings about phantom options. Keeps what the code does not say for itself: return `nil` to decline a key, and why `default: nil` beats an exhaustive switch. Co-Authored-By: Claude Opus 5 (1M context) --- .../Sources/EditorLocalization.swift | 41 +++++-------------- 1 file changed, 10 insertions(+), 31 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift index d94d595c1..a6e78e13b 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift @@ -43,7 +43,8 @@ public final class EditorLocalization { /// This is designed to be overridden by the host app to provide translations. /// /// Return `nil` for keys the host does not translate; the editor renders its - /// own string for those: + /// own string for those and reports the gap. See + /// ``reportsMissingTranslations``. /// /// ```swift /// EditorLocalization.localize = { key in @@ -55,27 +56,9 @@ public final class EditorLocalization { /// } /// ``` /// - /// A plain `default` rather than `@unknown default`, which only suppresses - /// the warning about cases added in future versions and still requires the - /// switch to cover every case that exists today. - /// - /// An exhaustive switch stops compiling whenever the editor adds a string, - /// which blocks the host from adopting unrelated changes until someone - /// writes a translation. Returning `nil` instead renders the untranslated - /// default for new strings and reports the gap through ``Logger``, so a - /// missing translation degrades the string rather than the build. See - /// ``reportsMissingTranslations``. - /// - /// The return type is optional rather than the host calling a public - /// fallback itself so that the fallback cannot be skipped or reimplemented: - /// `nil` is the only way to decline a key, and it routes through the - /// editor's own defaults and reporting. - /// - /// Until a host installs its own closure this renders the defaults without - /// reporting them, because the closure answers every key. Reporting is tied - /// to `nil` returns, which only a host can produce, so strings the editor - /// reads while building views — `loadingEditor` among them, which can run - /// before the host assigns this — are never reported as missing. + /// Declining with `default: nil` rather than switching exhaustively keeps + /// the host compiling when the editor adds a string: the new key renders + /// untranslated instead of breaking the build. public static var localize: (EditorLocalizableString) -> String? = { key in defaultString(for: key) } @@ -84,10 +67,8 @@ public final class EditorLocalization { /// /// Enabled by default so a host that misses a translation finds out without /// having to opt in. Set to `false` in apps that render the editor's own - /// strings deliberately — the demo app, say — where every fallback is - /// expected and the reports are noise. - /// - /// Guarded by the same lock as the reported-key set below. + /// strings deliberately, where every fallback is expected and the reports + /// are noise. public nonisolated static var reportsMissingTranslations: Bool { get { reportingLock.withLock { _reportsMissingTranslations } } set { reportingLock.withLock { _reportsMissingTranslations = newValue } } @@ -158,16 +139,14 @@ public final class EditorLocalization { /// Convenience subscript for accessing localized strings. /// /// Falls back to the editor's own string when the host declines a key, and - /// reports the gap. Every read goes through here, so a host cannot skip the - /// fallback or supply a placeholder in its place. + /// reports the gap. public static subscript(key: EditorLocalizableString) -> String { if let translation = localize(key) { return translation } - // Only a host returning `nil` reaches here, so this is where a gap in - // the host's translations is genuinely observable. The default closure - // answers every key, so the editor's own reads never report. + // Only a host returning `nil` reaches here. The default closure answers + // every key, so reads before a host installs one are not reported. reportMissingTranslation(for: key) return defaultString(for: key) From c33a1e6eb51cfcb38b4b446eae4c096deaa6d704 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 29 Jul 2026 14:47:28 -0400 Subject: [PATCH 11/14] docs(ios): restore @unknown default in the localize example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The example recommended a plain `default`, on the mistaken finding that Swift does not warn "default will never be executed" for an exhaustive switch. Xcode does emit that warning; the CLI check that suggested otherwise failed to reproduce the build's configuration. A host covering every case defined today — which any host adopting this version will be — hits that warning with a plain `default`, so `@unknown` is what the example should show. Co-Authored-By: Claude Opus 5 (1M context) --- .../GutenbergKit/Sources/EditorLocalization.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift index a6e78e13b..6ae362cdf 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift @@ -51,14 +51,16 @@ public final class EditorLocalization { /// switch key { /// case .showMore: NSLocalizedString("editor.blockInserter.showMore", ...) /// // ...keys the host translates. - /// default: nil + /// @unknown default: nil /// } /// } /// ``` /// - /// Declining with `default: nil` rather than switching exhaustively keeps - /// the host compiling when the editor adds a string: the new key renders - /// untranslated instead of breaking the build. + /// Declining rather than switching exhaustively keeps the host compiling + /// when the editor adds a string: the new key renders untranslated instead + /// of breaking the build. `@unknown default` rather than a plain `default` + /// so a host that covers every case today still compiles without a + /// "default will never be executed" warning. public static var localize: (EditorLocalizableString) -> String? = { key in defaultString(for: key) } From b08789c18e59c97972f87ba6c02b5d7b5a58f02a Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 29 Jul 2026 14:58:48 -0400 Subject: [PATCH 12/14] refactor(ios): isolate localize and the subscript, not the whole class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `EditorLocalization` was `@MainActor`, but only two of its members need it. Everything else was annotated `nonisolated` to escape that, so the class declaration claimed an isolation the code spent five keywords undoing. The annotation now sits on `localize` and the subscript, which is where it belongs: `localize` holds a non-`Sendable` closure, and the subscript reads it. `reportsMissingTranslations`, `defaultString(for:)` and `reportMissingTranslation(for:)` drop their now-redundant `nonisolated`. The `nonisolated(unsafe)` on the two stored properties stays — that suppresses global-mutable-state checking, unrelated to the class. No source break for hosts. Reading a string off the main actor is still rejected, and `reportsMissingTranslations` is still settable from a non-isolated context; both verified against WordPress-iOS's call pattern with `swiftc -swift-version 6 -strict-concurrency=complete`. Co-Authored-By: Claude Opus 5 (1M context) --- .../GutenbergKit/Sources/EditorLocalization.swift | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift index 6ae362cdf..ffc6dfbad 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift @@ -38,7 +38,6 @@ public enum EditorLocalizableString { /// ```swift /// let text = EditorLocalization[.showMore] /// ``` -@MainActor public final class EditorLocalization { /// This is designed to be overridden by the host app to provide translations. /// @@ -61,6 +60,10 @@ public final class EditorLocalization { /// of breaking the build. `@unknown default` rather than a plain `default` /// so a host that covers every case today still compiles without a /// "default will never be executed" warning. + /// + /// Main-actor isolated because it holds a non-`Sendable` closure. Hosts + /// assign it during editor setup, which already runs on the main actor. + @MainActor public static var localize: (EditorLocalizableString) -> String? = { key in defaultString(for: key) } @@ -71,17 +74,17 @@ public final class EditorLocalization { /// having to opt in. Set to `false` in apps that render the editor's own /// strings deliberately, where every fallback is expected and the reports /// are noise. - public nonisolated static var reportsMissingTranslations: Bool { + public static var reportsMissingTranslations: Bool { get { reportingLock.withLock { _reportsMissingTranslations } } set { reportingLock.withLock { _reportsMissingTranslations = newValue } } } - private nonisolated static let reportingLock = NSLock() + private static let reportingLock = NSLock() nonisolated(unsafe) private static var _reportsMissingTranslations = true nonisolated(unsafe) private static var reportedKeys: Set = [] /// The editor's untranslated strings. - private nonisolated static func defaultString( + private static func defaultString( for key: EditorLocalizableString ) -> String { switch key { @@ -112,7 +115,7 @@ public final class EditorLocalization { /// render pass, so logging unconditionally would write an entry per row per /// frame while a list scrolls. Reporting once per key tells the integrator /// the same thing without the volume. - private nonisolated static func reportMissingTranslation( + private static func reportMissingTranslation( for key: EditorLocalizableString ) { // Associated values distinguish cases that share a translation: @@ -142,6 +145,7 @@ public final class EditorLocalization { /// /// Falls back to the editor's own string when the host declines a key, and /// reports the gap. + @MainActor public static subscript(key: EditorLocalizableString) -> String { if let translation = localize(key) { return translation From b681e106123c9b11b9bdd7923ea18f227dc5a85f Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 29 Jul 2026 15:08:42 -0400 Subject: [PATCH 13/14] refactor(ios): scope the reporting lock to the key set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reporting state was two values sharing one `NSLock`, which forced a `_`-prefixed backing var and a manual accessor pair on a property that is otherwise a plain `Bool`. That bought atomic reads of the flag and the set together — an invariant with no consumer. The flag gates whether to report; the set answers whether a key was seen. Nothing reads them as a pair. `reportsMissingTranslations` is now a stored `nonisolated(unsafe)` var: word-sized, set once during host setup before any editor view reads a string. The key set moves to `OSAllocatedUnfairLock>`, so the type states what is guarded instead of leaving it to convention. Keeps the once-per-key dedup. Its call sites sit in SwiftUI `body` methods — `PatternSectionView` reads `patternsCount` per section — so logging every read would write an entry per row per frame while a list scrolls. Behavior is unchanged; the existing reporting tests pass untouched. Co-Authored-By: Claude Opus 5 (1M context) --- .../Sources/EditorLocalization.swift | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift index ffc6dfbad..46fc7d2cb 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift @@ -74,14 +74,16 @@ public final class EditorLocalization { /// having to opt in. Set to `false` in apps that render the editor's own /// strings deliberately, where every fallback is expected and the reports /// are noise. - public static var reportsMissingTranslations: Bool { - get { reportingLock.withLock { _reportsMissingTranslations } } - set { reportingLock.withLock { _reportsMissingTranslations = newValue } } - } + /// + /// Unsynchronized: hosts set this once during setup, before any editor view + /// reads a string, so the worst a race could cost is a single stray report. + public nonisolated(unsafe) static var reportsMissingTranslations = true - private static let reportingLock = NSLock() - nonisolated(unsafe) private static var _reportsMissingTranslations = true - nonisolated(unsafe) private static var reportedKeys: Set = [] + /// Keys already reported, so each is logged once. Guarded rather than + /// `nonisolated(unsafe)` because `Set` is not safe to mutate concurrently. + private static let reportedKeys = OSAllocatedUnfairLock>( + initialState: [] + ) /// The editor's untranslated strings. private static func defaultString( @@ -118,14 +120,13 @@ public final class EditorLocalization { private static func reportMissingTranslation( for key: EditorLocalizableString ) { + guard reportsMissingTranslations else { return } + // Associated values distinguish cases that share a translation: // `patternsCount(3)` and `patternsCount(7)` are one missing string. let name = String(String(describing: key).prefix { $0 != "(" }) - let shouldReport = reportingLock.withLock { - _reportsMissingTranslations && reportedKeys.insert(name).inserted - } - guard shouldReport else { return } + guard reportedKeys.withLock({ $0.insert(name).inserted }) else { return } // Logged through `OSLog` rather than `EditorLogger`, which reaches only // hosts that install a logger and raise the log level. This message is @@ -161,9 +162,7 @@ public final class EditorLocalization { /// Clears the record of which keys have already been reported so tests do /// not leak state into each other. static func resetMissingTranslationReportingForTesting() { - reportingLock.withLock { - _reportsMissingTranslations = true - reportedKeys.removeAll() - } + reportsMissingTranslations = true + reportedKeys.withLock { $0.removeAll() } } } From 7b48e6db33eeb332b0d501f286cafb92deb92f58 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 29 Jul 2026 15:42:21 -0400 Subject: [PATCH 14/14] docs(ios): state the reporting flag's contract for hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `nonisolated(unsafe)` is invisible at the API boundary — a host can read or write this property from a detached task under Swift 6 strict concurrency with no diagnostic. Documentation is the only channel for the constraint. The comment explained the annotation to maintainers rather than the usage to consumers. It now leads with what a host should do, and keeps the caveat as the reason. Co-Authored-By: Claude Opus 5 (1M context) --- ios/Sources/GutenbergKit/Sources/EditorLocalization.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift index 46fc7d2cb..22e2551a9 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift @@ -75,8 +75,9 @@ public final class EditorLocalization { /// strings deliberately, where every fallback is expected and the reports /// are noise. /// - /// Unsynchronized: hosts set this once during setup, before any editor view - /// reads a string, so the worst a race could cost is a single stray report. + /// Set this once during app setup, before presenting an editor. It is + /// deliberately unsynchronized, so toggling it while an editor is on screen + /// may cost a stray report or drop one. public nonisolated(unsafe) static var reportsMissingTranslations = true /// Keys already reported, so each is logged once. Guarded rather than