Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
3 changes: 3 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,8 @@ if [ -n "$staged" ]; then
| xargs -0 git add
fi

# Reconcile .xcstrings catalogs from Swift source, then re-stage any changes.
./localize --git-add

# Sync AGENTS.md + .agents/skills → CLAUDE.md + .claude/skills.
./sync-agents --git-add
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ jobs:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v3
- run: ./swiftformat --lint
# Fail if any .xcstrings catalog has drifted from its Swift source of truth.
- run: ./localize --lint

test:
name: Build & Test
Expand Down
45 changes: 39 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
| SwiftFormat | 0.60.1 | `.mise.toml` |
| Swift PM | 6.2 | `Package.swift` (`swift-tools-version`) |

**Libraries** (**StuffCore**, **LifecycleKit**, **LogKit**, **LogViewerUI**, **WhereCore**, **WhereUI**, **WhereTesting**) are defined in the root [`Package.swift`](Package.swift) — local package for libraries, Tuist for apps and test bundles.
**Libraries** (**StuffCore**, **LocalizationKit**, **LifecycleKit**, **LogKit**, **LogViewerUI**, **WhereCore**, **WhereUI**, **WhereTesting**) are defined in the root [`Package.swift`](Package.swift) — local package for libraries, Tuist for apps and test bundles.

Tuist manifests live at the repo root ([`Project.swift`](Project.swift), [`Tuist.swift`](Tuist.swift)). `Project.swift` references `Package.local(path: .relativeToRoot("."))` and declares the **Where** app, **StuffTestHost**, and unit-test targets that depend on package products.

Expand All @@ -20,8 +20,10 @@ project](#generating-the-xcode-project)).
Root dev scripts: `ide`, `swiftformat` (runs SwiftFormat via mise),
`sync-agents` (keeps Claude Code–oriented files in sync with `AGENTS.md`),
`profile` (prints build/test hot spots — slowest build phases, slowest
tests, and slow type-check sites; see `./profile --help`), and `icons`
(adds/removes selectable app icons; see `./icons --help`).
tests, and slow type-check sites; see `./profile --help`), `icons`
(adds/removes selectable app icons; see `./icons --help`), and `localize`
(reconciles `.xcstrings` catalogs from Swift source; see [Keeping
localization in sync](#keeping-localization-in-sync)).

### Managing app icons

Expand All @@ -45,12 +47,43 @@ appiconset is registered automatically — run `./ide --no-open` afterward to
regenerate. The script's file edits work on Linux; compiling the catalogs is
macOS-only. The primary "Classic" icon is reserved.

### Keeping localization in sync

Swift is the single source of truth for user-facing strings. Framework types
live in [`LocalizationKit`](Shared/LocalizationKit/) (`LocalizedString`,
`LocalizationConfig`, `LocalizedString.catalog(_:_:bundle:)`, and the SwiftUI
helpers). Each module that ships a catalog declares keys in its own
`LocalizedStrings.swift` via a thin `.module(_:_:)` wrapper (delegating to
`.catalog(..., bundle: .module)`), and `./localize` (a build-free Ruby script,
like `sync-agents`) reconciles the sibling `Resources/Localizable.xcstrings`
so the two can't drift:

- **add** keys present in Swift but missing from the catalog,
- **prune** catalog keys no longer referenced in Swift,
- **update** the English value of a simple key when its Swift default changed,
- **preserve** plural `variations`, parameterized keys, and every non-English
translation untouched (those stay hand-authored in the catalog / Xcode).

```bash
./localize # reconcile + write all catalogs
./localize --lint # check only; non-zero exit on drift (CI)
./localize --git-add # reconcile, write, then re-stage (pre-commit hook)
```

The pre-commit hook runs `./localize --git-add` and the CI `format` job runs
`./localize --lint`. **WhereUI**, **WhereCore**, **LifecycleKit**, and
**WhereWidgets** each have a `LocalizedStrings.swift`; the script auto-discovers
every such file. No Tuist/Xcode build is involved, so it runs in the hook and
on Linux CI.

## Formatting

- **SwiftFormat** uses [`.swiftformat`](.swiftformat). Run `./swiftformat` to
format the tree, or `./swiftformat --lint` to check only (as in CI).
- The pre-commit hook (enabled by `./ide` via `core.hooksPath`) formats staged
`*.swift` files in place and re-stages them.
`*.swift` files in place and re-stages them, then runs `./localize --git-add`
(see [Keeping localization in sync](#keeping-localization-in-sync)) and
`./sync-agents --git-add`.

## Agent instructions sync

Expand All @@ -68,8 +101,8 @@ by `./sync-agents`.

## Targets

- **Package products** ([`Package.swift`](Package.swift)) — **StuffCore** ([`Shared/StuffCore/Sources/`](Shared/StuffCore/Sources/)), **LifecycleKit** ([`Shared/LifecycleKit/Sources/`](Shared/LifecycleKit/Sources/)), **LogKit** ([`Shared/LogKit/Sources/`](Shared/LogKit/Sources/), the logging facade), **LogViewerUI** ([`Shared/LogViewerUI/Sources/`](Shared/LogViewerUI/Sources/), the generic SwiftUI log viewer), and **SwiftDataInspector** ([`Shared/SwiftDataInspector/Sources/`](Shared/SwiftDataInspector/Sources/), the generic SwiftData browser) under [`Shared/`](Shared/); **WhereCore** / **WhereUI** / **WhereTesting** under [`Where/`](Where/).
- **Tuist targets** ([`Project.swift`](Project.swift)) — **Where** app ([`Where/Where/`](Where/Where/)), **StuffTestHost** ([`Shared/StuffTestHost/`](Shared/StuffTestHost/)), **WhereTests** (app tests, no host), and hosted **\*Tests** bundles (**StuffCoreTests**, **LifecycleKitTests**, **LogKitTests**, **LogViewerUITests**, **SwiftDataInspectorTests**, **WhereCoreTests**, **WhereUITests**) that depend on **StuffTestHost** + **WhereTesting** + the relevant package product.
- **Package products** ([`Package.swift`](Package.swift)) — **StuffCore** ([`Shared/StuffCore/Sources/`](Shared/StuffCore/Sources/), Foundation-only placeholder), **LocalizationKit** ([`Shared/LocalizationKit/Sources/`](Shared/LocalizationKit/Sources/), deferred localization framework + SwiftUI helpers), **LifecycleKit** ([`Shared/LifecycleKit/Sources/`](Shared/LifecycleKit/Sources/)), **LogKit** ([`Shared/LogKit/Sources/`](Shared/LogKit/Sources/), the logging facade), **LogViewerUI** ([`Shared/LogViewerUI/Sources/`](Shared/LogViewerUI/Sources/), the generic SwiftUI log viewer), and **SwiftDataInspector** ([`Shared/SwiftDataInspector/Sources/`](Shared/SwiftDataInspector/Sources/), the generic SwiftData browser) under [`Shared/`](Shared/); **WhereCore** / **WhereUI** / **WhereTesting** under [`Where/`](Where/).
- **Tuist targets** ([`Project.swift`](Project.swift)) — **Where** app ([`Where/Where/`](Where/Where/)), **StuffTestHost** ([`Shared/StuffTestHost/`](Shared/StuffTestHost/)), **WhereTests** (app tests, no host), and hosted **\*Tests** bundles (**StuffCoreTests**, **LocalizationKitTests**, **LifecycleKitTests**, **LogKitTests**, **LogViewerUITests**, **SwiftDataInspectorTests**, **WhereCoreTests**, **WhereUITests**) that depend on **StuffTestHost** + **WhereTesting** + the relevant package product.
- Add SPM library targets in `Package.swift` and wire apps/tests in `Project.swift` (see existing `unitTests` helper). A new module also ships a root `README.md` and `AGENTS.md` — see [Per-module docs](#per-module-docs).

## Deployment
Expand Down
10 changes: 10 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ let package = Package(
],
products: [
.library(name: "StuffCore", targets: ["StuffCore"]),
.library(name: "LocalizationKit", targets: ["LocalizationKit"]),
.library(name: "LifecycleKit", targets: ["LifecycleKit"]),
.library(name: "LogKit", targets: ["LogKit"]),
.library(name: "LogViewerUI", targets: ["LogViewerUI"]),
Expand All @@ -25,8 +26,15 @@ let package = Package(
name: "StuffCore",
path: "Shared/StuffCore/Sources",
),
.target(
name: "LocalizationKit",
path: "Shared/LocalizationKit/Sources",
),
.target(
name: "LifecycleKit",
dependencies: [
.target(name: "LocalizationKit"),
],
path: "Shared/LifecycleKit/Sources",
resources: [
.process("Resources"),
Expand All @@ -50,6 +58,7 @@ let package = Package(
.target(
name: "WhereCore",
dependencies: [
.target(name: "LocalizationKit"),
.target(name: "LogKit"),
.product(name: "ZIPFoundation", package: "ZIPFoundation"),
],
Expand All @@ -61,6 +70,7 @@ let package = Package(
.target(
name: "WhereUI",
dependencies: [
.target(name: "LocalizationKit"),
.target(name: "WhereCore"),
.target(name: "LifecycleKit"),
.target(name: "LogKit"),
Expand Down
17 changes: 15 additions & 2 deletions Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,10 @@ let project = Project(
]),
]),
sources: ["Where/WhereWidgets/Sources/**"],
resources: ["Where/WhereWidgets/Resources/**"],
resources: ["Where/WhereWidgets/Sources/Resources/**"],
entitlements: whereAppGroupEntitlements,
dependencies: [
.package(product: "LocalizationKit"),
.package(product: "LogKit"),
.package(product: "WhereCore"),
.package(product: "WhereUI"),
Expand Down Expand Up @@ -180,6 +181,12 @@ let project = Project(
productDependency: "StuffCore",
sources: ["Shared/StuffCore/Tests/**"],
),
unitTests(
name: "LocalizationKitTests",
bundleIdSuffix: "localizationkit",
productDependency: "LocalizationKit",
sources: ["Shared/LocalizationKit/Tests/**"],
),
unitTests(
name: "LifecycleKitTests",
bundleIdSuffix: "lifecyclekit",
Expand Down Expand Up @@ -215,7 +222,12 @@ let project = Project(
bundleIdSuffix: "whereui",
productDependency: "WhereUI",
sources: ["Where/WhereUI/Tests/**"],
extraPackageProducts: ["LifecycleKit", "LogViewerUI", "SwiftDataInspector"],
extraPackageProducts: [
"LocalizationKit",
"LifecycleKit",
"LogViewerUI",
"SwiftDataInspector",
],
),
],
// Tuist's autogeneration doesn't emit working standalone test actions for
Expand All @@ -225,6 +237,7 @@ let project = Project(
// target a single bundle without building the whole workspace.
schemes: [
testScheme(name: "StuffCoreTests"),
testScheme(name: "LocalizationKitTests"),
testScheme(name: "LifecycleKitTests"),
testScheme(name: "LogKitTests"),
testScheme(name: "LogViewerUITests"),
Expand Down
11 changes: 7 additions & 4 deletions Shared/LifecycleKit/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ system, formatting, and global conventions. Read that first.

## Scope & dependencies

- Pure **SwiftUI + Foundation + Observation**. It must **not** import WhereCore,
UIKit, or any app code — it's a generic library that the Where app (and any
future app) adopts. Keep it that way: app-specific launch logic lives in the
consumer (e.g. `WhereUI/Sources/Launch/`), not here.
- Pure **SwiftUI + Foundation + Observation + LocalizationKit**. It must **not**
import WhereCore, UIKit, or any app code — it's a generic library that the
Where app (and any future app) adopts. Keep it that way: app-specific launch
logic lives in the consumer (e.g. `WhereUI/Sources/Launch/`), not here.
- Library target only ([`Package.swift`](../../Package.swift),
`Shared/LifecycleKit/Sources`); the hosted test bundle `LifecycleKitTests`
is wired in [`Project.swift`](../../Project.swift) via the `unitTests` helper
Expand Down Expand Up @@ -90,6 +90,9 @@ itself.
destination (`content`, e.g. the app's `TabView`) is **not** a step — it's
terminal and shown only at `.ready`.
- [`LifecycleSplash`](Sources/LifecycleSplash.swift) – the default placeholder.
- [`LocalizedStrings`](Sources/LocalizedStrings.swift) – launch failure UI
copy (`failure.launch.*`), reconciled via `./localize` against
[`Resources/Localizable.xcstrings`](Sources/Resources/Localizable.xcstrings).

## Two invariants to preserve

Expand Down
5 changes: 3 additions & 2 deletions Shared/LifecycleKit/Sources/LifecycleFailureView.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import LocalizationKit
import SwiftUI

/// The UI shown when a launch step throws. Describes the failure and offers a
Expand All @@ -14,13 +15,13 @@ public struct LifecycleFailureView: View {
public var body: some View {
ContentUnavailableView {
Label(
String(localized: "failure.launch.title", bundle: .module),
LocalizedStrings.Failure.launchTitle.localized,
systemImage: "exclamationmark.triangle",
)
} description: {
Text(failure.error.localizedDescription)
} actions: {
Button(String(localized: "failure.launch.retry", bundle: .module), action: retry)
Button(LocalizedStrings.Failure.launchRetry.localized, action: retry)
.buttonStyle(.borderedProminent)
}
}
Expand Down
19 changes: 19 additions & 0 deletions Shared/LifecycleKit/Sources/LocalizedString+Module.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import LocalizationKit

extension LocalizedString {
/// A LifecycleKit catalog string — delegates to ``LocalizedString/catalog(_:_:bundle:)``
/// with `bundle: .module`.
static func module(
_ key: StaticString,
_ defaultValue: String.LocalizationValue,
) -> LocalizedString {
.catalog(key, defaultValue, bundle: .module)
}

static func module(
_ key: StaticString,
_ defaultValue: @Sendable @escaping (LocalizationConfig?) -> String.LocalizationValue,
) -> LocalizedString {
.catalog(key, bundle: .module, defaultValue)
}
}
20 changes: 20 additions & 0 deletions Shared/LifecycleKit/Sources/LocalizedStrings.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import LocalizationKit

/// Catalog-backed strings for LifecycleKit.
///
/// Swift is the source of truth for keys and English defaults; the sibling
/// `Resources/Localizable.xcstrings` owns translations. The root `./localize`
/// script reconciles the catalog from this file.
enum LocalizedStrings {
enum Failure {
static let launchTitle: LocalizedString = .module(
"failure.launch.title",
"Couldn't finish launching",
)

static let launchRetry: LocalizedString = .module(
"failure.launch.retry",
"Try Again",
)
}
}
4 changes: 2 additions & 2 deletions Shared/LifecycleKit/Sources/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,5 @@
}
}
},
"version" : "1.0"
}
"version" : "1.1"
}
43 changes: 43 additions & 0 deletions Shared/LocalizationKit/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# LocalizationKit – Module Shape

Framework-level localization tooling — **Foundation + SwiftUI**. Each app module
(WhereUI, WhereCore, …) depends on this for `LocalizedString` and the SwiftUI
helpers, then keeps its own `LocalizedStrings.swift` catalog and a thin
`.module(_:_:)` wrapper binding `Bundle.module`.

Complements root [`AGENTS.md`](../../AGENTS.md). Tests: `LocalizationKitTests`
in `StuffTestHost` (`tuist test LocalizationKitTests`).

## Key types

- [`LocalizedString`](Sources/LocalizedString.swift) — a **deferred** localized
string. Wraps a `@Sendable (LocalizationConfig?) -> String` builder;
`.localized(_:)` runs it. `Sendable`, so a `LocalizedString` can be cached in a
`static let` and cross isolation boundaries.
- [`LocalizationConfig`](Sources/LocalizedString.swift) — `Sendable, Hashable`
value type carrying the `locale` to resolve against. `nil` means "process
default" (`.current`).
- [`LocalizedString.catalog(_:_:bundle:)`](Sources/LocalizedString+Catalog.swift)
— the generic factory. Each consumer module defines a thin `.module(_:_:)`
that passes `bundle: .module` (see WhereUI's `LocalizedString+Module.swift`).
- [`Text(localized:)`](Sources/Text+Localized.swift) and
[`View` overloads](Sources/View+Localized.swift) — resolve at display time.

## Invariants / conventions

- LocalizationKit ships **no catalog** — only the types and helpers. Keys and
English defaults live in each module's `LocalizedStrings.swift`; the root
`./localize` script parses literal `.module("<key>", "<value>")` factory calls
(and the closure overload) — see root
[`AGENTS.md`](../../AGENTS.md#keeping-localization-in-sync).
- Keep the key and default value as **string literals** (the key is a
`StaticString`) — anything dynamic makes the script fail loudly rather than
drift silently.
- Resolution is lazy: referencing a `LocalizedString` does no work; only
`.localized` reads the catalog. Don't cache resolved `String`s where a
locale override might later apply.

## Testing

Tests live in [`Tests/`](Tests) (Swift Testing only). The bundle runs in
`StuffTestHost`. See [`LocalizedStringTests`](Tests/LocalizedStringTests.swift).
50 changes: 50 additions & 0 deletions Shared/LocalizationKit/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# LocalizationKit

SwiftUI-capable SPM library for deferred, catalog-backed localization. It owns
the framework-level tooling; each app module keeps its own `LocalizedStrings`
enum and `Localizable.xcstrings` catalog.

## Key types

- **`LocalizedString`** — a user-facing string that hasn't been localized yet.
It wraps a `@Sendable` builder closure and resolves lazily when you call
`.localized(_:)`. Per-module `LocalizedStrings` enums return these instead of
`String`, deferring the catalog lookup to the point of display so a call site
can override the locale. It's `Sendable`, so producers can cache each string
in a `static let` and pass it across isolation boundaries.
- **`LocalizationConfig`** — a small value type (today just a `Locale`) passed
to `.localized(config)` to render against a locale other than the process
default.
- **`LocalizedString.catalog(_:_:bundle:)`** — the generic factory that performs
a `String(localized:bundle:locale:)` lookup. Each module wraps it in a thin
`.module(_:_:)` that passes `bundle: .module`.
- **`Text(localized:)`** and **`View` overloads** of `navigationTitle`,
`accessibilityLabel`, and `accessibilityHint` — resolve a `LocalizedString`
at the point of display.

## Quick start

```swift
import LocalizationKit

// In each module's LocalizedString+Module.swift:
extension LocalizedString {
static func module(_ key: StaticString, _ value: String.LocalizationValue) -> LocalizedString {
.catalog(key, value, bundle: .module)
}
}

// In LocalizedStrings.swift:
static let greeting: LocalizedString = .module("greeting", "Hello")

// At a SwiftUI call site (with dot-syntax accessors on LocalizedString):
Text(localized: .primary.emptyDescription)
.navigationTitle(.settings.title)

// Where a plain String is needed:
Button(LocalizedStrings.Common.done.localized) { … }
```

Add shared types under [`Sources/`](Sources/) and wire consumers in
[`Package.swift`](../../Package.swift). Run tests with
`tuist test LocalizationKitTests`.
Loading
Loading