Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 104 additions & 3 deletions ios/Sources/GutenbergKit/Sources/EditorLocalization.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import OSLog

/// Enum representing all localizable strings in the editor.
public enum EditorLocalizableString {
Expand All @@ -17,6 +18,7 @@ public enum EditorLocalizableString {
case insertPattern
case patternsCategoryUncategorized
case patternsCategoryAll
case patternsCount(Int)

// MARK: - Editor Loading
case loadingEditor
Expand All @@ -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
/// }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should EditorLocalization.localize allow returning a nil, which means GBK can call defaultLocalize internally, rather than requiring the app to call it?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This approach makes sense. Thanks for the suggestion. Addressed in 42b4bb4.

/// }
/// ```
///
/// 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<Set<String>>(
initialState: []
)

/// The editor's untranslated strings.
private static func defaultString(
for key: EditorLocalizableString
) -> String {
switch key {
case .showMore: "Show More"
case .showLess: "Show Less"
Expand All @@ -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"
Expand All @@ -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() }
}
}
3 changes: 3 additions & 0 deletions ios/Sources/GutenbergKit/Sources/EditorLogging.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions ios/Sources/GutenbergKit/Sources/EditorViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
)
Expand Down
10 changes: 5 additions & 5 deletions ios/Sources/GutenbergKit/Sources/Views/LockdownModeSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -41,7 +41,7 @@ struct LockdownModeSheet: View {
Button {
onLearnMore()
} label: {
Text(EditorLocalization.localize(.lockdownModeLearnMore))
Text(EditorLocalization[.lockdownModeLearnMore])
.font(.body)
.fontWeight(.semibold)
.foregroundStyle(.white)
Expand All @@ -54,7 +54,7 @@ struct LockdownModeSheet: View {
Button {
onDismiss()
} label: {
Text(EditorLocalization.localize(.lockdownModeDismiss))
Text(EditorLocalization[.lockdownModeDismiss])
.font(.body)
.foregroundStyle(.primary)
.frame(maxWidth: .infinity)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading
Loading