0 strings missing, still English on screen: taking a SwiftUI app to 12 languages
I make Alcove, a to-do list, planner and notes app for Mac, iPhone and iPad, written in SwiftUI. It is my own app, so read this as a build story, not a review. Version 1.4, out now on Mac, iPhone and iPad, added 11 langu
I make Alcove, a to-do list, planner and notes app for Mac, iPhone and iPad, written in SwiftUI. It is my own app, so read this as a build story, not a review. Version 1.4, out now on Mac, iPhone and iPad, added 11 languages to English: German, French, Spanish, Italian, Portuguese (Brazil), Dutch, Japanese, Korean, Simplified and Traditional Chinese, and Hindi.
Two things up front. The translations were made by AI: Claude, called from a Python script, then reviewed and polished by more Claude passes. No human native speaker has checked them. And the code was written with Claude as a coding partner (the commits carry its Co-Authored-By line). This post was written by an AI agent too, drafted from those commits.
At one point the status command printed 0 missing in all 11 languages. At the same time, the screen of finished tasks was titled "OK" in French, buttons were still in English, and "Aujourd'hui" broke into "Aujo urd' hui". This post is about the gap between those two facts.
The pipeline in brief
There are eight String Catalogs: a Localizable.xcstrings for the Mac app, the iOS app, the widgets, the share extension and the TaskKit package, two InfoPlist.xcstrings for permission prompts, and AppShortcuts.xcstrings for Siri. Tools/i18n/sync-strings.sh builds both apps with SWIFT_EMIT_LOC_STRINGS=YES and merges what the compiler collected with xcstringstool sync. Today the catalogs hold 2,288 distinct keys between them.
translate.py sends only the strings that lack a translation, in chunks of 60 by default, through the Message Batches API. Every request carries a glossary of 83 terms per language, which the model generated once and which every prompt then marks "use exactly". A few rows from glossary.json:
{
"de": { "Quick add": "Schnell hinzufรผgen", "Inbox": "Eingang", "Pin (verb)": "Fixieren" },
"fr": { "Quick add": "Ajout rapide", "Done": "OK" },
"ja": { "Quick add": "ใฏใคใใฏ่ฟฝๅ ", "Due (as in \"Due today\")": "ๆ้" }
}
Every reply is checked before it is written back: the same format specifiers as the English (%@, %lld), ${applicationName} as many times, no em-dashes. A string with one integer count must come back with one form per CLDR plural category:
# CLDR also has "many" for fr/es/it/pt; it is left out on purpose,
# Foundation falls back to "other", and generated "many" forms read oddly.
PLURALS = {'de': ['one', 'other'], 'fr': ['one', 'other'], 'ja': ['other'], 'hi': ['one', 'other'], ...}
A failed row is retried once, then reported. Then a review pass made 297 corrections and a "native-speaker polish" pass made 2,784 rewrites. Both are Claude again; the polish prompt asks it to read as "a native speaker who uses an iPhone and Mac every day". A second review made 62 more corrections, and 148 long labels were shortened. Last, a blind audit: Claude, prompted as a strict native-speaker grader and not told the strings were machine translated, graded 50 random strings per language as 93.5% native, 6.4% acceptable and 0.2% wrong (one string, fixed). That is a model grading a model. My own notes say a native speaker's read is still worth having before calling any language perfect.
translate.py status now said 0 missing everywhere. Here is what that number could not see.
A word is not a meaning
The screen of finished tasks is called "Done". So is the button that closes a sheet. Both used the same key:
case .done: localized("Done")
The glossary, rightly for a button, says "Done" is "OK" in French. So the finished-tasks screen in French and Spanish was titled "OK". The screen of tasks still to do, "Open", was German "รffnen", the verb. An AI assistant's "Read" permission was "Vorlesen", which means reading aloud. Both AI review passes ran before this was fixed and kept all three, because a reviewer that sees only "Done" has no way to know which one it is.
The fix is a key per meaning, with a note:
case .open: String(localized: "scope.open", defaultValue: "Open", bundle: .taskKit,
comment: "Title of the screen of tasks still to do (open, not finished). An adjective, not the verb.")
translate.py hands that comment to the model as context. Now "Open" is "Offen" in German and "ร faire" in French, and "Done" is "Erledigt" and "Terminรฉes". The same pattern came back later: "View" on an attachment needed its own key, because the noun "Darstellung" is the Mac's View menu. And it runs the other way too: Dutch "Attachโฆ" had come out as "Voeg toeโฆ", the same words as "Addโฆ". It is "Bijvoegenโฆ" now.
Text the compiler never extracted
Text("Done") is collected by the compiler. Alcove's shared components are not so lucky:
public struct PillButton: View {
private let title: String
public init(_ title: String, style: PillStyle = .muted, ...)
A literal passed to a plain String is shown as written, in English, in every language. The first finder, find-unlocalized.py, asked whether a literal's text was in some catalog. That had already led to about a thousand literals being wrapped. But "Tomorrow" was translated elsewhere, so this line passed:
UpcomingRow(task: TaskRowModel(id: UUID(), title: String(localized: "Send the invoice"), priority: .high), day: "Tomorrow"),
Screenshots of the phone in all 11 languages showed what was left. A UI test, LanguageShotsUITests, run once per language with xcodebuild ... -testLanguage de -testRegion DE, walks the main screens and attaches a picture of each.
The new finder, find-unextracted.py, asks the compiler instead. Every literal that reached a localizing API is in a .stringsdata file with its file, line and column, so a user-facing literal at any other position is shown in English. About 500 more were wrapped.
Then the Mac was photographed in all 12 languages and showed English that the phone pass and the finder had both missed: "+ New task", "On", the Settings title. The finder only looked at literals that start like a label, with a capital letter, so it learned about labels led by a symbol, "iCloud" or an emoji, and two-letter words. The App Store screenshots found four more, the Overview widget's lowercase captions ("today", "pending", "upcoming", "total"). Each pass found what the one before could not.
Sentences built from pieces, and hand-made plurals
This is how the delete warning for a list or folder was put together:
let taskWords = tasks.count == 1 ? "task" : "\(tasks.count) tasks"
case (false, false): subject = "Its \(taskWords) \(tasks.count == 1 ? "is" : "are")"
// ...
return "\(subject) deleted with it. \(undo)"
No language can translate "is" without the rest of the sentence. Now each case is a whole sentence:
gone = t == 1 ? String(localized: "Its task is deleted with it.")
: String(localized: "Its \(t) tasks are deleted with it.")
The selection bar did "Delete (count) (noun)s?". It is now a SelectionNoun enum with a whole sentence for each kind ("Delete (count) tasks?", "Delete (count) notes?"), because, as its doc comment says, other languages change more than an ending. The catalog holds the plurals (trimmed):
"%lld tasks selected" : {
"de" : { "variations" : { "plural" : {
"one" : { "stringUnit" : { "value" : "%lld Aufgabe ausgewรคhlt" } },
"other" : { "stringUnit" : { "value" : "%lld Aufgaben ausgewรคhlt" } } } } },
"ja" : { "stringUnit" : { "value" : "%lldไปถใฎใฟในใฏใ้ธๆไธญ" } }
}
English needs a "one" form too, or "(count) tasks selected" reads "1 tasks selected": translate.py asks the model for the singular of every count string, and 92 English strings have one now. merge-literals.py joins literals added across lines ("first half " + "second half"), so a sentence is one key. The day in Siri's confirmation, "on " + f.string(from: date), became one string with a comment: "A due day in a spoken sentence".
Layouts that broke in French and Dutch
Longer words found every fixed width. In the Mac menu bar card, French and Dutch broke "Today" inside the word ("Aujo urd' hui"), and German broke the add button ("NEUE AUFGAB E"). The fix is to decide what gives way:
// The weekday gives way first, then the title shrinks a little.
ViewThatFits(in: .horizontal) {
HStack(alignment: .center, spacing: Space.md) {
Text("Today").textStyle(Typo.titleS, Theme.textPrimary).fixedSize()
Eyebrow(environment.todayWeekday).fixedSize().padding(.top, Space.xs)
}
Text("Today").textStyle(Typo.titleS, Theme.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.7)
}
In the footer, the links fold to symbols so the add button keeps its words. From the phone pass: the counter captions on Today ("รCHรANCE AUJOURD'HUI") wrap at one shared height, priority chips shrink for "Dringend" or "เค
เคคเฅเคฏเคพเคตเคถเฅเคฏเค", and the meeting notes card stacks its two buttons with ViewThatFits when they do not fit side by side. The Overview widget's captions ran past their columns ("DEMNรCHโฆ"), and there the fix was shorter words: "bald", "du jour", "abiertas", "gepland", "abertas".
Siri phrases and widgets
Siri answers only the phrases in AppShortcuts.xcstrings, while Settings shows its own list of phrases from TaskKit. Translated separately, they would disagree. shortcut-phrases.py builds Siri's sets from the Settings list's translations, so a person in Germany is shown exactly the phrases Siri listens for. Each translation must name "Alcove" exactly once, and gets Siri's ${applicationName} token back.
Two English phrases can come out as one. The code comment's example: "New task in" and "Create a task in" were both "Neue Aufgabe in". Siri needs each phrase once, so sets drop repeats, and English's ten ways to add a task are nine in German and eight in French. bundle-check.py, which reads the compiled tables inside the built apps and extensions, then had to hold a phrase set to being there, not to English's count.
Widgets had their own English: the sample tasks in the widget gallery, "Tomorrow" in Upcoming, and the Overview captions, which got keys of their own because "today" and "total" are also words in other sentences.
What I would do first next time
- Give every word that can mean two things its own key and a comment from day one. "Done", "Open", "Read", "View" and "Block" each cost a fix after translation.
- Write whole sentences with the count in them, never
n == 1 ? "" : "s"or+. - Make shared components' callers pass
String(localized:), and run the position-based finder before believing any "0 missing". - Take screenshots of every screen in every language before the review passes, not after. Each round of pictures found English or broken layouts that the numbers did not.
- Get a native speaker to read it. AI graders gave these translations good marks, but that is not the same thing.
One known limit stays: quick add reads English date words ("tomorrow", "every monday", !high) in every language.
Alcove on the App Store: https://apps.apple.com/app/id6817797913?pt=128735707&ct=devto&mt=8
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes โ full credit and traffic to the original publisher.