Skip to content

Remove the #tk macro and drop the Lite variants - #11

Open
Jeehut wants to merge 1 commit into
mainfrom
wip/remove-macro
Open

Jeehut wants to merge 1 commit into
mainfrom
wip/remove-macro

Conversation

@Jeehut

@Jeehut Jeehut commented Oct 8, 2026

Copy link
Copy Markdown
Member

TranslateKit 2.0 removes the #tk / #tkm macros and the Lite variants. This PR is meant for the 2.0.0 release.

What changes

  • The TranslateKitMacros target, the macro-backed TranslateKit / TranslateKit<Category> targets and their Exports.swift / Macros.swift files are removed.
  • The former Lite targets take over the regular names (product, target, module and folder): TranslateKitLite becomes TranslateKit, TranslateKit<Category>Lite becomes TranslateKit<Category>. The folders were moved with git mv, so the 27 String Catalogs show up as renames and other open PRs touching them keep a clean diff.
  • Package.swift is rewritten with the same platforms, defaultLocalization and resources. swift-syntax, swift-macro-testing and import CompilerPluginSupport are gone; Package.resolved is removed since there are no dependencies left.
  • The tests only covered the macro implementation. They are replaced by a small Swift Testing suite that loads the core and one category module and resolves strings from their catalogs in English and German.
  • The README loses the macro and Lite sections and gains installation steps. A new MigrationGuide.md documents the upgrade.

Why

  • No swift-syntax build cost and no "Trust & Enable" dialog in Xcode or on CI.
  • Static properties (TK.Action.save and friends) are the main value of the package; the macro was an add-on with a separate trust story.
  • Having both TranslateKit and TranslateKitLite was confusing; with the macro gone there is nothing left to distinguish them.

Breaking changes

  • #tk and #tkm no longer exist.
  • All *Lite products and modules are gone, with no aliases or forwarding targets. Use the same name without Lite.

Migrating from 1.x

  • Lite users: drop Lite from the product name and from the import. Nothing else changes.
  • Macro users: replace each macro call with String(localized:defaultValue:comment:), keeping the key the macro generated. Writing String(localized: "Text") instead would change the key and orphan all existing translations in the String Catalog. The prompt below converts the calls and derives or looks up the keys (it is also part of MigrationGuide.md):
You are migrating a Swift project from the TranslateKit 1.x `#tk` / `#tkm` macros to plain `String(localized:defaultValue:comment:)` calls. TranslateKit 2.0 removed the macros. Replace every `#tk(…)` and `#tkm(…)` call in the project. Do not change anything else (no refactoring, no reformatting, no edits to String Catalogs).

## The one rule that matters

The macro expanded to a `String(localized:)` call whose KEY is generated from the code context. Existing translations in the project's String Catalog (`Localizable.xcstrings`) are stored under exactly these generated keys. Your replacement MUST use the identical key. If you write `String(localized: "Some Text")` without the key, the key changes and every existing translation of that string is orphaned. Never do that.

## Replacement

- `#tk("TEXT")` becomes `String(localized: "KEY", defaultValue: "TEXT")`
- `#tk("TEXT", c: "COMMENT")` becomes `String(localized: "KEY", defaultValue: "TEXT", comment: "COMMENT")`
- `#tkm(…)` is identical, but adds `bundle: .module` after `defaultValue:` (before `comment:`): `String(localized: "KEY", defaultValue: "TEXT", bundle: .module, comment: "COMMENT")`
- Copy TEXT and COMMENT verbatim, including string interpolations such as `\(name)` or `\(ErrorKit.userFriendlyMessage(for: error))`, escape sequences, and the `…` character. Keep the surrounding code (`Text(…)`, `Button(…)`, `.alert(…)`, return statements) untouched – the replacement is a drop-in expression of type `String`.
- Remove `import TranslateKit` only if nothing else in that file uses it (`TK.…` values or other TranslateKit symbols); leave imports of `FlineDevKit` or similar umbrella packages alone.

## Finding KEY – use the catalog first

1. Locate the project's String Catalog(s) (`find . -name Localizable.xcstrings -not -path "*/.build/*" -not -path "*/SourcePackages/*"`). For `#tkm` it is the catalog inside the same Swift package target as the file; for `#tk` it is the app's catalog.
2. The KEY always has the form `<Context>.<textPart>`. Take the catalog key whose last dot-separated component matches the text (see "The text part" below) and whose preceding components match the code context (see "The context part"). If exactly one catalog key fits, use it verbatim. Catalog keys are the ground truth; the rules below are only needed to disambiguate or when the catalog has no entry yet (a macro call that was never extracted into the catalog – then use the derived key; it had no translation before either).
3. Do not guess a key that merely looks similar. If two catalog keys are plausible, work out the derivation below exactly and choose the one it yields.

### The context part

Walk from the OUTERMOST enclosing declaration to the innermost one around the macro call and join the names with `.`:
- `struct`, `class` and `enum` declarations contribute their name; `extension` contributes the extended type (`extension TK.Label` contributes `TK.Label` i.e. two components `TK`, `Label`; `extension Foo.Bar` contributes `Foo`, `Bar`). `actor`, `protocol`, closures, accessors (`get`/`set`/`didSet`), subscripts and enum cases contribute NOTHING.
- A function (`func fetchData(…)`) contributes its base name only (no parameters). An initializer contributes the literal `init`.
- Properties contribute NOTHING, stored or computed, and neither does `var body`: a call inside `var body` or `var displayName` gets only the type names before it (`struct SettingsView { var body … #tk("Save Changes") }` → `SettingsView.saveChanges`, `enum Category { var displayName … #tk("Anime") }` → `Category.anime`). Only functions and initializers add a component.
- Every contributed name is converted to UpperCamelCase: first letter uppercased, other letters unchanged (`fetchData` → `FetchData`, `RESTClient` stays `RESTClient`); non-alphanumeric characters (including apostrophes) are removed and the following letter is uppercased; diacritics are removed.
- Inside the body of a `#Preview { … }` (or any other macro that wraps the code) the context is a long machine-generated name such as `S3App0025BoardFieldViewswift…Ll7PreviewfMf15…`. This cannot be derived by hand – take such a key verbatim from the catalog by matching its last component, and leave the call unchanged if the catalog has no match.
- Nested types appear in order, e.g. `struct Outer { struct Inner { func render() { … } } }` → `Outer.Inner.Render`. Closures and `Text(…)`/`Button(…)` nesting do not add components.

### The text part

Take the string literal EXACTLY as written in source (interpolations stay as the raw source characters `\(expr)`; quotes are stripped from both ends):
- If it ends with `…`: remove the `…`, lowerCamelCase the rest, append `Dots`. Example: `"Add Item…"` → `addItemDots`.
- Else if it has more than 3 characters and is entirely uppercase (`text.uppercased() == text`): use it unchanged (`"OK"` has 2 characters, so it is camel-cased → `ok`; `"DONE"` stays `DONE`).
- Otherwise lowerCamelCase: split the text into words at every character that is not a letter or digit (punctuation and spaces disappear; apostrophes/quotation marks are removed without splitting the word: `it's` → `its`), keep the letters of each word as they are except that each word starts uppercase, lowercase only the very first letter of the first word, then join. Additionally a new word starts at every lower-to-upper letter transition and at every letter-to-digit or digit-to-letter transition, and an acronym followed by a capitalised word splits before the last capital (`TVShow` → `tvShow`). Only the first 10 words are used; if there are more, append `…` to the key. Characters with diacritics are folded to their base letter. Interpolations contribute their characters as words: `"Hello \(name)!"` → `helloName`.
- A leading acronym is lowercased as a whole (`"TV Shows"` → `tvShows`, `"CSV"` → `csv`), and periods between single letters are dropped without splitting (`"e.g. Mom's Birthday"` → `egMomsBirthday`). A lone `&` is dropped (`"Culture & Religion"` → `cultureReligion`).
- Examples: `"Save Changes"` → `saveChanges`; `"The URL is not a valid file URL"` → `theURLIsNotAValidFileURL`; `"TV Show"` → `tvShow`; `"What's your name?"` → `whatsYourName`.

## Prefer pre-localized TK values where they exist

If the project imports TranslateKit (or an umbrella that re-exports it) and the text is a plain, context-free string that is exactly (or near-exactly, same meaning) available as a `TK.Action`, `TK.Label`, `TK.Placeholder` or `TK.Message` property (for example "Save" → `TK.Action.save`, "Cancel" → `TK.Action.cancel`), you may use that property instead – but only when it is a pure UI string without interpolation, and only after checking the property exists (search the TranslateKit sources in the package checkout, e.g. `.build/checkouts/TranslateKit/Sources` or the SwiftPM checkouts folder of Xcode's DerivedData, or its autocompletion documentation comments `/// "Save" - …`). When unsure, keep the `String(localized:)` form with the preserved key; a preserved key is always correct. Note: switching to `TK.*` leaves the old catalog entry unused; mention each such case in your final report so the owner can clean the catalog later.

## Verify

1. `grep -rn '#tkm\?(' --include='*.swift' .` must return nothing.
2. For every `String(localized: "KEY"` you wrote, confirm KEY exists in the relevant catalog: if not, list it in your report as "new key (no existing translation)" and double-check your derivation, since a missing key usually means a derivation mistake.
3. Make sure the project builds.

Final report: the number of replacements, every key that was NOT found in the catalog, and every case where you used a `TK.*` value.

How this was verified

  • swift build and swift test are green (4 tests, Swift Testing).
  • The prompt was given to a fresh AI coding session without any other context and run on two real apps, with this branch used as a local package override. Both apps build after the migration (CrossCraft for iOS Simulator, the TranslateKit app for macOS).
    • CrossCraft: 232 #tk calls in 18 files. 225 keys existed in the catalog; the other 7 had no catalog entry before the migration either (the catalog only had plain-text entries such as Haptics), so no translation is lost.
    • TranslateKit app: 6 #tk calls in one file, all 6 keys exist in the catalog.
  • Pitfalls found in the first prompt draft and fixed in the version above: a wrong rule that properties (var body, var displayName) contribute a component to the key (they do not, only functions and initializers do); keys inside #Preview blocks are machine-generated and must be taken from the catalog; acronym and & handling in the text part. A second run with the fixed prompt produced the same result on both apps.

The #tk and #tkm macros are gone, together with the swift-syntax and
swift-macro-testing dependencies. The Lite targets, which already
contained everything but the macro, take over the regular names:
TranslateKitLite becomes TranslateKit and TranslateKit<Category>Lite
becomes TranslateKit<Category>. There are no aliases.

The old tests only covered the macro implementation and are replaced
by a small Swift Testing suite that loads the core and a category
module and resolves strings from their catalogs in two languages.

The README drops the macro sections and gains installation steps. A
new migration guide explains the move from 1.x, including a prompt
that converts macro calls to String(localized:) while keeping the
generated catalog keys so existing translations are not lost.
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