Skip to content

LocalizationKit: deferred strings, drift script, WhereUI + WhereCore#40

Open
kyleve wants to merge 18 commits into
mainfrom
localization-drift-script
Open

LocalizationKit: deferred strings, drift script, WhereUI + WhereCore#40
kyleve wants to merge 18 commits into
mainfrom
localization-drift-script

Conversation

@kyleve

@kyleve kyleve commented Jun 26, 2026

Copy link
Copy Markdown
Owner

Summary

Makes Swift source the single source of truth for user-facing strings and adds a build-free ./localize script that reconciles .xcstrings catalogs on every commit. Framework tooling lives in a new LocalizationKit module; WhereUI and WhereCore each keep their own LocalizedStrings.swift catalog plus a thin .module wrapper.

  • LocalizationKit (new): LocalizedString, LocalizationConfig, generic .catalog(_:_:bundle:) factory (+ config-aware closure overload), Text(localized:), and View overloads for navigationTitle / accessibilityLabel / accessibilityHint. Extracted from StuffCore/WhereUI; StuffCore reverts to a version placeholder.
  • LocalizedStrings convention: nested enums whose members return deferred LocalizedString values; parameter-less members are cached static lets (Sendable). WhereUI uses leading-dot syntax at SwiftUI call sites (e.g. Text(localized: .primary.emptyDescription)); plain-String sites keep LocalizedStrings.<Category>.<member>.localized.
  • ./localize: Ruby script (no Tuist/build) that auto-discovers every LocalizedStrings.swift and reconciles the sibling Localizable.xcstrings — add missing keys, prune orphans, update simple English defaults, preserve plurals/translations. Wired into pre-commit (--git-add) and CI (--lint).
  • WhereCore migrated: region names, notification copy, and backup errors now flow through WhereCore/Sources/LocalizedStrings.swift (including plural/parameterized summary strings). Region.localizedName stays String for unchanged call sites.
  • Docs: root AGENTS.md, Where/AGENTS.md, and per-module README/AGENTS for LocalizationKit and StuffCore.

Out of scope (follow-ups)

  • WhereWidgets (WidgetStrings.swift) still uses raw String(localized:).
  • SwiftUI Environment-driven locale override.

Test plan

  • ./localize --lint (WhereUI + WhereCore catalogs)
  • ./swiftformat --lint
  • tuist test LocalizationKitTests
  • tuist test StuffCoreTests
  • tuist test WhereUITests
  • tuist test WhereCoreTests
  • CI green on macos-26

kyleve and others added 15 commits June 26, 2026 16:51
Adds the deferred LocalizedString value type and LocalizationConfig to
StuffCore (Foundation-only shared slot), wiring StuffCore into the WhereUI
target and WhereUITests bundle. Groundwork for the LocalizedStrings pattern;
no behavior change yet.

Closes plan to-do: infra.

Co-authored-by: Cursor <cursoragent@cursor.com>
Static Text.localized(_:_:) factory that resolves a deferred LocalizedString at
the point of display, giving call sites a single resolution seam (and the future
home for an Environment-driven locale override).

Closes plan to-do: text-helper.

Co-authored-by: Cursor <cursoragent@cursor.com>
Rewrites the WhereUI Strings enum as LocalizedStrings: nested enums grouped by
the existing catalog sections, each member returning a deferred LocalizedString
built from a literal String(localized:defaultValue:bundle:.module, locale:) call
so the English source lives in Swift and stays discoverable by both Xcode and
the upcoming ./localize script. Call sites now resolve at the point of display
via Text.localized(_:) or .localized(); tests assert against .localized().

Closes plan to-do: migrate-whereui.

Co-authored-by: Cursor <cursoragent@cursor.com>
Closes plan to-do "script". `./localize` (build-free Ruby, like sync-agents)
extracts each module's LocalizedStrings.swift String(localized:defaultValue:)
calls and reconciles the sibling Localizable.xcstrings: add missing keys,
prune orphans, update simple English defaults, and preserve plural variations,
parameterized keys, and non-English translations untouched. Writes Xcode's
exact pretty-printed JSON (2-space indent, case-insensitively sorted keys, no
trailing newline) so an in-sync catalog round-trips with no diff. Modes:
default (write), --lint (CI), --git-add (pre-commit).

Co-authored-by: Cursor <cursoragent@cursor.com>
Closes plan to-do "precommit". The pre-commit hook runs ./localize --git-add
(after the SwiftFormat re-stage, before sync-agents) so reconciled catalogs are
written and re-staged on every commit. The CI format job runs ./localize --lint
to fail the build on any catalog that has drifted from its Swift source.

Co-authored-by: Cursor <cursoragent@cursor.com>
Closes plan to-do "docs". Root AGENTS.md gains a "Keeping localization in sync"
section (the localize script, its add/prune/update/preserve rules, and the
pre-commit + CI wiring) and lists ./localize among the root dev scripts.
StuffCore README/AGENTS document LocalizedString / LocalizationConfig as the
first real shared API, and Where/AGENTS.md documents the WhereUI
LocalizedStrings convention and Text.localized helper. Regenerated CLAUDE.md
files via ./sync-agents (gitignored).

Co-authored-by: Cursor <cursoragent@cursor.com>
Adds a `var localized` convenience that calls `localized()` with default
arguments, so call sites that don't override the locale can drop the empty
`()`. Migrates the WhereUI call sites and tests to `.localized`, and updates
the StuffCore / WhereUI docs to match. `localized(_:)` stays for locale
overrides.

Co-authored-by: Cursor <cursoragent@cursor.com>
Introduces LocalizedString.module(_:_:) in WhereUI, which bakes in
bundle:.module and the locale plumbing so each simple catalog string is a
one-line `.module("key", "default")`. The key stays a StaticString literal so
the plural-aware String(localized:) overload, Xcode extraction, and ./localize
all keep reading keys statically. Members that compose a nested
.localized(config) or branch on a count keep the explicit closure form.

Teaches ./localize to parse the .module("key", value) form alongside the raw
String(localized:defaultValue:) calls, and updates the root/StuffCore/WhereUI
docs. The catalog is unchanged (the rewrite extracts identical keys/values).

Co-authored-by: Cursor <cursoragent@cursor.com>
Composed LocalizedStrings members built their default from the
resolution config (a nested .localized(config)), so they couldn't use
the value-taking .module(_:_:) and dropped to a raw String(localized:)
closure. Add a .module overload taking a config -> LocalizationValue
builder so every member stays on .module, and teach ./localize to read
the default literal out of that trailing closure.

Co-authored-by: Cursor <cursoragent@cursor.com>
Mark the LocalizedString builder @sendable and conform to Sendable so
strings can be cached and cross isolation boundaries (e.g. widget
timelines). That lets the 125 parameter-less LocalizedStrings members
collapse from computed `static var { .module(...) }` to one-line cached
`static let`s.

Add View overloads of navigationTitle/accessibilityLabel/accessibilityHint
that take a LocalizedString (mirroring Text.localized), and migrate the
call sites so they pass the LocalizedString directly instead of resolving
with a trailing .localized. Also fix the now-stale LocalizedStrings header
doc and refresh the StuffCore/Where module docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add LocalizedString+DotSyntax.swift: one accessor per LocalizedStrings
nested enum (top-level on LocalizedString, nested on their parent), each
returning the enum metatype so `.category.member` chains resolve to the
existing static let / static func members with no duplicated strings.

Replace the Text.localized(_:) static with a Text(localized:) initializer
and migrate the LocalizedString-typed call sites (Text plus the
navigationTitle/accessibilityLabel/accessibilityHint View overloads) to
dot syntax, e.g. Text(localized: .primary.emptyDescription). Plain-String
sites keep LocalizedStrings.<Category>.<member>.localized.

Co-authored-by: Cursor <cursoragent@cursor.com>
Create Shared/LocalizationKit with LocalizedString, LocalizationConfig,
the generic .catalog(_:_:bundle:) factory, Text(localized:), and View
modifier overloads. Move LocalizedStringTests here; wire
LocalizationKitTests in Project.swift. StuffCore reverts to the version
placeholder.

Co-authored-by: Cursor <cursoragent@cursor.com>
Swap WhereUI's StuffCore dependency for LocalizationKit; rewrite the
WhereUI .module factory to delegate to .catalog(..., bundle: .module).
Update imports across WhereUI sources and tests; wire LocalizationKit in
WhereUITests extraPackageProducts.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add WhereCore LocalizedStrings.swift and a thin .module wrapper; convert
Region.localizedName, notification schedulers, DailySummaryReconciler, and
BackupService to resolve via LocalizedString. Add LocalizationKit as a
WhereCore dependency; reconcile the WhereCore catalog via ./localize.

Co-authored-by: Cursor <cursoragent@cursor.com>
Update root AGENTS.md (LocalizationKit in targets table, factory split in
keeping-localization-in-sync, WhereWidgets follow-up) and Where/AGENTS.md
(WhereUI + WhereCore localization sections, LocalizationKit links).

Co-authored-by: Cursor <cursoragent@cursor.com>
@kyleve kyleve changed the title Localization drift script (WhereUI pilot) LocalizationKit: deferred strings, drift script, WhereUI + WhereCore Jun 27, 2026
/// read the locale from. Prefer them over `someString.localized` at call
/// sites.
public func navigationTitle(
_ title: LocalizedString,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Let's change this to match .navigationTitle(localized: ...) like Text(localized: ...).

}

/// Set the accessibility label from a deferred ``LocalizedString``.
public func accessibilityLabel(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Let's change this to match .navigationTitle(localized: ...) like Text(localized: ...).

}

/// Set the accessibility hint from a deferred ``LocalizedString``.
public func accessibilityHint(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Let's change this to match .navigationTitle(localized: ...) like Text(localized: ...).

/// the repo's `./localize` script can statically find every key. Adding a
/// new region case is intentionally a compile error here until you add a
/// matching ``LocalizedStrings/Region`` member and catalog entry.
public var localizedName: String {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Change this to be a func localizedName(with config: LocalizationConfig? = nil) that's passed through to each string.

kyleve and others added 3 commits June 26, 2026 22:54
Add LocalizedStrings.swift and a thin .module wrapper to LifecycleKit
(failure.launch.* in LifecycleFailureView) and WhereWidgets (gallery
picker copy). Replace WidgetStrings with LocalizedStrings; move the
WhereWidgets catalog to Sources/Resources so ./localize can discover it.
Wire LocalizationKit into both targets and update module docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
Abort extraction when the same catalog key appears twice in one
LocalizedStrings.swift, with context for both call sites, instead of
silently keeping the last occurrence.

Co-authored-by: Cursor <cursoragent@cursor.com>
Bring in the Primary tab year calendar (PresenceCalendar, CalendarView,
session calendar plumbing) from main, resolve conflicts against the
LocalizationKit migration, and port new strings to LocalizedStrings.
Includes Xcode-extracted catalog comments and reconciled xcstrings entries.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant