跳到正文
DEV Community· ayumu kitabayashi·· 3 小時前AI 評分61

獨立開發者如何讓 minulo 的 iPhone App 和網站支援 13 種語言

How I ship an iPhone app and its website in 13 languages as a solo developer

AI 導讀

minulo 的獨立開發者 ayumu kitabayashi 分享如何讓 iPhone App 和網站支援 13 種語言,App Store 商品頁則有 35 種在地化版本。

正文

I build minulo, an iPhone app written in React Native / Expo. The app UI ships in 13 languages: English, Japanese, Korean, Traditional Chinese, German, French, Spanish, Portuguese, Italian, Indonesian, Thai, Vietnamese and Russian. The website has the same 13, and the App Store listing has 35 localizations.

I don't speak most of those languages, and there's no translation team. What keeps it workable is a set of small rules and scripts that catch the mistakes machine translation and I keep making. This post covers what's actually in the repo: how the dictionaries are set up, the checks that run on every change, how I keep feature names consistent, how the store listing fields line up with the site, and what hreflang and llms.txt look like.

1. One dictionary per language, English is the source of truth

All UI text lives in src/i18n/, one TypeScript file per language plus an index.ts:

src/i18n/
  index.ts      # t(), language detection, plural handling
  en.ts         # the key list (source of truth)
  ja.ts  de.ts  fr.ts  es.ts  pt.ts  it.ts
  ko.ts  id.ts  th.ts  vi.ts  zh-Hant.ts  ru.ts

Each dictionary is a flat object of about 840 keys. The type comes from English, so a missing key in another language is a compile error, not a blank label on a screen:

export type Dict = Record<keyof typeof en, string>;
const DICTS: Record<string, Dict> = { en, ja, de, fr, es, pt, it, ko, id, th, vi, 'zh-Hant': zhHant, ru };

There's no i18n library. t() is a plain function that looks up a key and fills placeholders like {n}. Since a plain function doesn't re-render anything when the language changes, there's a tiny useLang() hook built on useSyncExternalStore that bumps a version number. Any component that calls t() has to call useLang(), otherwise switching language leaves that screen in the old one. I learned this the hard way: switching languages left parts of the app in the previous language until I added useLang() to 26 components. (More on how that's checked below.)

Some decisions that turned out to matter:

  • Traditional Chinese only. Device detection maps zh-Hant, or a TW/HK/MO region, to the Traditional dictionary. Simplified Chinese has no dictionary and falls back to English instead of getting the wrong script.
  • Dates go through Intl with an explicit locale. Thai is th-TH-u-ca-gregory, because the default Thai calendar is Buddhist and the year would show up as 2569.
  • Clock format is per language. Only English uses 12-hour time (10:47 PM). Everything else shows 22:47.
  • Languages other than English and Japanese are labeled BETA in the language picker, because no native speaker has reviewed them. I'd rather say that than pretend.

2. Plurals without a library

English has "1 day / 2 days". Japanese has no plural, so if you write the Japanese first, you will never notice "1 days". When I checked, 17 strings broke at 1 ("1 days", "1 times", "1 items").

The fix is a convention inside the translation itself: write day|days, and t() picks a side based on {n}:

// en.ts:  "cel.title": "{n} day|days of blocking\nin a row"
const single = dict === fr ? Math.abs(n) < 2 : Math.abs(n) === 1;   // French: 0 is singular too
s = s.replace(/([A-Za-zÀ-ÖØ-öø-ÿ]+)\|([A-Za-zÀ-ÖØ-öø-ÿ]+)/g, (_, one, many) => (single ? one : many));
// Russian has three forms: книга|книги|книг (1 / 2–4 / 5+)

Irregular plurals (person|people) are written out by hand. I also avoid \p{L} in the regex on purpose: if Hermes doesn't support it, the whole file fails to load and every screen goes down with it.

3. A check script instead of trusting machine translation

The non-English dictionaries started out as translations of English. Machine output looks fine at a glance and fails in very predictable ways, so scripts/check-i18n.mjs runs as part of npm run check and looks for exactly those:

  1. Same key set and same key order as English in every language. A missing key means an empty label.
  2. Same placeholders. If {n} disappears, the number disappears. If an extra one sneaks in, the user sees a literal {n}.
  3. No Japanese characters in other languages. For Traditional Chinese, where kanji are correct, it only looks for kana.
  4. Plural pipes in the right shape. word|words for Latin scripts, three Cyrillic forms for Russian.
  5. Untranslated sentences. If more than 15 long values are identical to English, the language fails as "probably not translated".
  6. Every component that calls t() also calls useLang(). The script finds component bodies with a regex and checks each one.
  7. English counted nouns have a plural pipe. If {n} is followed by a word ending in "s" and there's no |, it fails and suggests day|days.

When it passes, it prints this (the real output is in Japanese, translated here):

OK  ja/en 840 keys: key set, placeholders, Japanese leakage, untranslated
OK  other languages (de 840, fr 840, ... ru 840): keys, order, placeholders, pipes, Japanese leakage
OK  86 components that render translations call useLang()
OK  English counts don't break at 1

At first the comparison didn't include the five languages I added last (Indonesian, Thai, Vietnamese, Traditional Chinese, Russian). Then a Vietnamese string with a doubled word ("dụng dụng") slipped through, so I extended it. A check that only covers some languages is basically no check for the others.

Punctuation is translated too. At one point the Japanese comma "、", the full stop "。" and the range mark "〜" were hard-coded in a few components, so every language got Japanese punctuation, on screen and in VoiceOver. Now separators are dictionary keys:

// en.ts                     // ja.ts                  // zh-Hant.ts
"ui.c": ", ",                "ui.c": "、",              "ui.c": ",",
"ui.p": ". ",                "ui.p": "。",
"ui.range": "{a}–{b}",       "ui.range": "{a}〜{b}",

4. Keeping feature names and tone consistent

Machine translation will happily give one feature three different names on three screens. What I do:

  • Renames go through every dictionary in one commit. When I renamed a feature, one commit changed every dictionary that used the name, plus the code comments, so no language kept the old one.
  • Retired names are banned by a script. scripts/check-facts.mjs has a list of old feature names, and if one shows up in live copy the check fails. It also compares numbers in the copy (minutes, seconds, steps) against constants read from the code, so changing a constant without changing the text fails the build, and the other way around.
  • Tone is fixed per language. French uses vous, Korean uses 해요체, Indonesian uses kamu. A recent commit went through all 13 dictionaries to make tab names, capitalization and tone consistent, and to catch strings that had been updated in Japanese and English but never in the other 11.
  • Banned words. scripts/check-jp.mjs keeps a list of words I've decided never to use, each with the replacement and the reason. Wording quality is hard to check automatically, but "we decided never to say X" is easy to check. It reads the English file too, because a claim that shouldn't be made is a problem in any language.

5. Store listing fields: languages are not countries

This took me the longest to understand. An App Store localization isn't the same thing as a country. According to Apple's "App Store localizations" reference, a single storefront indexes several localizations at once. A few examples from that page as I recorded them:

Japan          ja + en-US
United Kingdom en-GB
Australia      en-AU + en-GB
France/Germany/Italy/Spain/Mexico/Brazil/Korea/...  native locale + en-GB
United States  en-US + ar-SA, zh-Hans, zh-Hant, fr-FR, ko, pt-BR, ru, es-MX, vi

What that means in practice:

  • The en-US fields didn't help in the UK, Canada or Australia. I added en-GB, en-AU and en-CA. The en-GB keyword field also counts in several European storefronts.
  • Don't repeat words across localizations that share a storefront. Apple doesn't give extra credit for repetition, so I strip any keyword that already appears in the name, subtitle or keywords of another localization the same storefront reads, and use the space for native-language terms. The ten localizations the US storefront reads have no keyword overlap between them.
  • Measure before choosing native-language names. In some newer locales, people were searching for English terms like "reels blocker" and the English fields were already indexed there. The native-language searches were broader screen-time phrases, so those locales got names built on those.
  • Some locales are refused. App Store Connect rejected several Indian-language locale codes as "not a valid locale", so India is covered by Hindi and English fields.
  • Count before you paste. scripts/count-store.mjs checks every name, subtitle (30), keyword field (100), promotional text (170) and description (4,000) against Apple's limits, and flags spaces in keyword fields since each one wastes a character.

The listing is currently 35 localizations. The app UI is 13. Screenshots are localized for 11, and the rest show English ones. Store languages that aren't in the app (Dutch, Polish, Turkish and others) are about being found in search. The app itself opens in English there.

6. The website: 13 folders, hreflang, JSON-LD and llms.txt

The site is static HTML on GitHub Pages. Japanese is at the root, and the other 12 languages each have a folder (/en/, /ko/, /zh-Hant/, /pt/, ...). Most pages are generated by small Python scripts from one JSON file per page type, keyed by language.

Coverage isn't even, and the markup says so. The sitemap has 261 URLs: 40 English, about 30 each for French, German, Spanish and Portuguese, 20 each for Italian and Korean, and only 3 to 5 for Indonesian, Thai, Vietnamese, Traditional Chinese and Russian. The generator only emits hreflang links for languages that actually have that page:

def langs_of(path): return [l for l in ALL if l in G[path]]   # only languages with a translation

alts = '\n'.join(f'<link rel="alternate" hreflang="{l}" href="{SITE}{DATA[l]["dir"]}{path}">' for l in LANGS)
alts += f'\n<link rel="alternate" hreflang="x-default" href="{SITE}en/{path}">'

A few details:

  • Folder name and language tag can differ. The folder is /pt/, but the hreflang, <html lang> and JSON-LD inLanguage are pt-BR, because the copy is Brazilian Portuguese. One mapping table in the generator handles this.
  • JSON-LD in every language. The top pages include MobileApplication, WebSite, WebPage, Organization and Person. Guide pages include Article (with citation URLs for sources) and FAQPage, and each one has the right inLanguage. The latest change added sameAs links (App Store, Product Hunt, AlternativeTo, GitHub) to the top page in all 13 languages.
  • llms.txt lists every language version. It's a short Markdown file at the root for AI crawlers: what the app does, when it fits and when something else fits better, the 13 supported languages, and each page with its language versions inline (ja … · fr … · ko …). There's also an llms-full.txt.

The last point matters more than I expected. In the data report I published, 28% of users found the app through an AI chatbot. If a page exists in Korean but no AI system knows about it, a Korean user asking in Korean won't be pointed to it.

What I'd tell another solo developer

  • Make English (or whatever language you write first) the type, so missing keys fail at compile time.
  • Write checks for the failures machine translation actually produces: dropped placeholders, leaked source-language characters, untranslated sentences, broken plurals. Run them on every language, not just the first few.
  • Store punctuation and separators in the dictionary.
  • When you rename a feature, change every dictionary in one commit, and ban the retired name with a script.
  • Label unreviewed languages honestly.
  • Read Apple's storefront-to-localization mapping before writing keywords, and don't repeat words across localizations that share a storefront.
  • Emit hreflang only for pages that really exist, and keep llms.txt in sync with them.

I build minulo, an iPhone app that hides Instagram Reels and YouTube Shorts while keeping DMs. Hiding them is free.

來源:DEV Community · dev.to