diff --git a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift index 4791bb51e..22e2551a9 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 { @@ -17,6 +18,7 @@ public enum EditorLocalizableString { case insertPattern case patternsCategoryUncategorized case patternsCategoryAll + case patternsCount(Int) // MARK: - Editor Loading case loadingEditor @@ -36,10 +38,58 @@ 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. - public static var localize: (EditorLocalizableString) -> String = { key in + /// + /// Return `nil` for keys the host does not translate; the editor renders its + /// own string for those and reports the gap. See + /// ``reportsMissingTranslations``. + /// + /// ```swift + /// EditorLocalization.localize = { key in + /// switch key { + /// case .showMore: NSLocalizedString("editor.blockInserter.showMore", ...) + /// // ...keys the host translates. + /// @unknown default: nil + /// } + /// } + /// ``` + /// + /// 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. + /// + /// 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) + } + + /// Whether falling back to a default string is reported to the system log. + /// + /// 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, where every fallback is expected and the reports + /// are noise. + /// + /// 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 + /// `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( + for key: EditorLocalizableString + ) -> String { switch key { case .showMore: "Show More" case .showLess: "Show Less" @@ -51,6 +101,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" @@ -61,8 +112,58 @@ 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 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 != "(" }) + + 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 + // 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. + /// + /// 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 { - localize(key) + if let translation = localize(key) { + return translation + } + + // 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) + } + + /// Clears the record of which keys have already been reported so tests do + /// not leak state into each other. + static func resetMissingTranslationReportingForTesting() { + reportsMissingTranslations = true + reportedKeys.withLock { $0.removeAll() } } } 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/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/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) } diff --git a/ios/Tests/GutenbergKitTests/EditorLocalizationTests.swift b/ios/Tests/GutenbergKitTests/EditorLocalizationTests.swift new file mode 100644 index 000000000..c793ada83 --- /dev/null +++ b/ios/Tests/GutenbergKitTests/EditorLocalizationTests.swift @@ -0,0 +1,198 @@ +import Foundation +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. + /// + /// 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.resetMissingTranslationReportingForTesting() + } + + EditorLocalization.reportsMissingTranslations = reportsMissingTranslations + + try body() + } + + /// The only default that is computed rather than a literal. The rest are + /// covered by the exhaustive switch in `defaultString(for:)`, which fails to + /// compile if a case has no string. + @Test + func defaultsPluralizePatternCounts() { + withLocalization { + #expect(EditorLocalization[.patternsCount(1)] == "1 pattern") + #expect(EditorLocalization[.patternsCount(3)] == "3 patterns") + } + } + + @Test + func subscriptUsesTheDefaultsWithoutAHostOverride() { + withLocalization { + #expect(EditorLocalization[.showMore] == "Show More") + } + } + + /// 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 { + EditorLocalization.localize = { key in + switch key { + case .showMore: "Mostrar más" + default: nil + } + } + + #expect(EditorLocalization[.showMore] == "Mostrar más") + } + } + + @Test + func declinedKeysFallBackToTheDefaults() { + withLocalization { + EditorLocalization.localize = { key in + switch key { + case .showMore: "Mostrar más" + default: nil + } + } + + #expect(EditorLocalization[.search] == "Search") + } + } + + /// 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 repeatedFallbacksForOneKeyAreReportedOnce() throws { + try withLocalization(reportsMissingTranslations: true) { + EditorLocalization.localize = { _ in nil } + + let started = Date() + + for count in 1...5 { + _ = EditorLocalization[.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 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.localize = { _ in nil } + EditorLocalization.reportsMissingTranslations = false + + let started = Date() + _ = EditorLocalization[.lockdownModeDismiss] + + let reports = try missingTranslationReports( + forKeyNamed: "lockdownModeDismiss", + since: started + ) + #expect(reports.isEmpty) + } + } + + /// 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(reportsMissingTranslations: true) { + EditorLocalization.localize = { key in + switch key { + case .showMore: "Mostrar más" + default: nil + } + } + + let started = Date() + _ = EditorLocalization[.lockdownModeLearnMore] + + let reports = try missingTranslationReports( + forKeyNamed: "lockdownModeLearnMore", + since: started + ) + #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),") } + } +}