# Translating wayBetter for X wayBetter for X speaks the languages X's own website speaks. Every language starts as a machine draft; people who use the app in their language make it right. This page is for them, and for developers adding text to the app. ## Which languages Exactly the UI languages X's web client offers, so "Follow X" in Settings → Language always finds a match. The source is X's own client: the locale chunk loader in `abs.twimg.com/responsive-web/client-web/main..js` (`loadLanguage.registerChunkLoader([...])`), read on 2026-09-29. It lists 50 languages besides English: Arabic, Arabic (Feminine) `ar-x-fm`, Bangla, Basque, British English, Bulgarian, Catalan, Chinese (Simplified and Traditional), Croatian, Czech, Danish, Dutch, Filipino, Finnish, French, Galician, German, Greek, Gujarati, Hausa, Hebrew, Hindi, Hungarian, Igbo, Indonesian, Irish, Italian, Japanese, Kannada, Korean, Malay, Marathi, Norwegian, Persian, Polish, Portuguese, Romanian, Russian, Serbian, Slovak, Spanish, Swedish, Tamil, Thai, Turkish, Ukrainian, Urdu, Vietnamese and Yoruba. (X's pseudo-locales `en-ss` and `en-xx` are test builds, not languages. X's newer logged-out pages, `abs.twimg.com/x-web/…`, ship message sets for 44 of them so far.) X's own codes map to standard tags: `msa` → `ms`, `no` → `nb`, `zh-cn` → `zh-Hans`, `zh-tw` → `zh-Hant`, `en-gb` → `en-GB`. `apps/desktop/tests/i18n.test.ts` fails if a catalog is added or missing compared with this list; when X adds a language, update the list there and here. ### Tiers: supported and community The app keeps all of these languages, in two tiers (`tier` in the generated `languages.json`): - **Supported** (English, British English, Japanese, Spanish, Arabic, Indonesian, German, Portuguese, Turkish, French, Thai, Korean, Dutch, Vietnamese, Traditional Chinese, Italian, Hindi and Russian). "Follow X" and "Follow system" resolve to these, through the usual fallback chain (`pt-BR` → `pt`, `zh-TW`/`zh-HK` → `zh-Hant`, `en-AU` → `en`). In Settings → Language they are listed first. - **Community**: every other language. These are machine drafts nobody has reviewed. They are listed below the supported ones under "Community translations", with the Help translate link, and are used only when someone picks them explicitly (a pick applies whatever its tier). If X or the system is set to a community language, the app stays on English and Settings → Language says " is available as a community translation" with a button to switch to it. The source of truth is `supported` in `waybetter.i18n.json` (English is always supported; without the list every language is). `node scripts/i18n-check.mjs --write` generates `tier` into `languages.json`, and the check fails if `supported` names a language without a catalog. **Promotion.** A community language becomes supported when a native speaker has reviewed it: the catalog gets `"status": "reviewed"` and the reviewer in `reviewers` (see below), and a maintainer adds its code to `supported` in `waybetter.i18n.json` and re-runs the check with `--write`. ## Where the words live | File | What | | --- | --- | | `apps/desktop/locales/en.json` | The English source: every string of the shell, the panel, the drafts popover, the X runtime (`x.*`), bundled plugins (`plugin..*`) and native pieces (`native.*`). | | `apps/desktop/locales/en.meta.json` | Notes for translators, per key: where it appears, length limits, sentence fragments. | | `apps/desktop/locales/.json` | One file per language, same keys as English. | | `apps/desktop/locales/languages.json` | Generated list of languages with status and coverage (Settings reads it). | | `waybetter/packages/waybetter/locales/.json` | The framework's own words (plugin UI kit buttons, shell models, the Store, the panel, the Windows installer, and the shell frame: tabs, address bar, palette, account switcher, decks, workspaces, `wb.*`), shared by every wayBetter app. | A catalog looks like this: ```json { "$schema": "../../../waybetter/packages/waybetter/i18n.schema.json", "locale": "tr", "name": "Türkçe", "englishName": "Turkish", "status": "reviewed", "reviewers": ["@yourhandle"], "messages": { "shell.tab.new": "Yeni sekme", "chip.dev.errors": "{count, plural, one {# hata} other {# hata}}" } } ``` `status` is `machine` (drafted by a machine, nobody has checked it; Settings shows a "machine translation" badge and a Help translate link), or `reviewed` (people went through it; list yourself in `reviewers`). Arabic, Persian, Hebrew and Urdu get `"dir": "rtl"`. `ar-x-fm` has `"fallback": "ar"`: it only lists the strings that differ from Arabic. `status` is the review state of the file; whether the language is supported or community is `tier`, set in `waybetter.i18n.json`. ## How to help 1. Open your language's file, for example `apps/desktop/locales/de.json`, next to `en.json` and `en.meta.json`. 2. Fix what reads wrong. Keep the key order. A key you delete falls back to English. 3. When you have gone through the whole file, set `"status": "reviewed"` and add yourself to `reviewers`. 4. Run `bun run i18n:check` (or `node scripts/i18n-check.mjs de` for one language; it is the framework's `waybetter i18n check`, which also checks the framework's catalog). It must print `0 error(s)`. Warnings are for review, not failures. 5. See it: `bun run --cwd apps/desktop dev`, then open `http://127.0.0.1:1420/?panel&lang=de` (the panel) or `http://127.0.0.1:1420/?shell&tabs=5&lang=de` (the window chrome). In the app, Settings → Language switches everything at once, without a restart. 6. Send a pull request (or an issue with the changed file) to the repository. ## Message syntax Messages are text with a small part of ICU MessageFormat: - `{name}`: a value. Keep every placeholder English has, spelled the same; you may move it. - `{count, plural, one {# draft} other {# drafts}}`: plural forms. `#` is the number. Use the forms your language has (CLDR categories: `zero`, `one`, `two`, `few`, `many`, `other`); `other` is required. A form you leave out shows `other`, which the check reports as a warning. Languages with a single form (Japanese, Chinese, Korean, Vietnamese, Thai, Malay, Indonesian, Yoruba) write only `other`. `=0 {…}` and `=1 {…}` match exact numbers. - An argument English only uses to choose a word ("Download {count, plural, one {it} other {them}}") may be dropped when your language does not change there. A placeholder that puts text or a number on screen (`{name}`, or a plural with `#`) must stay. - `{kind, select, post {…} reply {…} other {…}}`: keep every option English has. - `{n, number}`, `{n, number, percent}`, `{d, date, medium}`, `{d, time, short}`: formatted for the language automatically. - Apostrophes are plain text: `don't`, `l'app`, `developer's`. Only an apostrophe right before `{`, `}`, `#` or another apostrophe quotes: `'{'` is a literal brace, `''` a literal apostrophe. The check refuses markup (``, entities), control and bidi-override characters, unknown keys and broken syntax. Catalogs are data: values are always inserted as text, never as HTML. Some English sentences are split around a link or a button. `en.meta.json` says so, and when its entry has `"allowEmpty": true` you may leave that piece empty if your word order puts the words elsewhere. ## Native pieces `native.*` keys reach places outside the web views: - `native.notify.*`, `native.dialog.*`: notifications and file dialog titles. The shell hands these strings to native code when the language changes (`i18n_set`), and they are kept for the next start. - `native.app.name`, `native.app.comment`: the app name and description in macOS (`InfoPlist.strings`, `CFBundleLocalizations`) and the Linux `.desktop` file. - The Windows installer (NSIS) is the framework's: its words are `wb.installer.*` in the framework catalogs. Tauri ships installer text for some languages; the others are generated under `waybetter/packages/waybetter/windows/languages/` (`node waybetter/packages/waybetter/scripts/installer-languages.mjs --write`). The macOS and Linux files are generated. After changing a catalog, run `node scripts/i18n-check.mjs --write`; the plain check (part of `bun run test`) fails while they are stale. ## The website waybetter.fyi is translated the same way, with its own catalogs. It is published in English (at the root) and in 16 more languages under `//`: `ja`, `es`, `ar`, `id`, `de`, `pt`, `tr`, `fr`, `th`, `ko`, `nl`, `vi`, `zh-Hant`, `it`, `hi`, `ru` (the list is `LANGUAGES` in `apps/web/site.config.ts`). | File | What | | --- | --- | | `apps/web/locales/en.json` | The English source. The pages' own text (`site.home.*`, `site.x.*`, `site.store.*`, `site.submit.*`, `site.themes.*`, `site.plugins.*`, `site.notFound.*`, `site.common.*`) is generated from the HTML; what the build and the scripts say (`site.meta.*` titles and descriptions, `site.gen.*` Store pages, `site.js.*`, `site.lang.*`) is written by hand. | | `apps/web/locales/en.meta.json` | Notes per key: which page, which element, what each placeholder stands for, length limits. | | `apps/web/locales/.json` | One file per language, same keys, same format and `status` as the app's catalogs. | | `apps/web/locales/languages.json` | Generated list with coverage. | What is different from the app: - **Placeholders for markup.** A sentence with a link or emphasis in it is one message: `{a1}…{a1End}` is a link, `{em1}…{em1End}` and `{b1}…{b1End}` emphasis, `{span1}…{span1End}` a styled part; `{code1}`, `{kbd1}`, `{br1}` stand for a whole piece of code, a key or a line break. Keep each one, in pairs; you may move them to where your sentence needs them. `{version}`, `{host}`, `{themes}`, `{plugins}`, `{items}`, `{date}`, `{apps}` are values the build fills in (the minimum app version is `MIN_APP_VERSION` in `apps/web/site.config.ts`; never write the number). - **Not translated:** wayBetter, wayBetter for X, wayBetter Store, X, plugin and theme names, commands (`npm create waybetter`), keyboard keys, file names and URLs. A Store item's own name, description and tags stay in English on every page and are marked `lang="en"`. - **Same words as the app.** Tab, pane, theme, plugin, Store, deck, draft, install, Settings, Appearance and the names of fixes and tweaks are the words of `apps/desktop/locales/.json` and `waybetter/packages/waybetter/locales/.json`. - **Length.** Page titles stay within 60 characters and descriptions within 50 to 160; the check reports a message that is over its limit. To fix a translation: edit `apps/web/locales/.json`, run `bun run i18n:site` (it must print `0 error(s)`; `node scripts/i18n-site.mjs de` checks one language), then see it with `bun run --cwd apps/web dev` at `http://localhost:5173/de/` (every language page is served in development too). `bun test` in `apps/web` builds the site and checks that no page mixes languages, that every page links only to pages of its own language and that the language menu, `hreflang` and the sitemap agree. For developers: a page's text is marked in its HTML with `data-i18n="key"` (and `data-i18n-="key"` for `aria-label`, `alt`, `title`, `placeholder`); `translate="no"` keeps a part as it is. After changing a page, `node scripts/i18n-site.mjs --write` regenerates the pages' part of `en.json`, the notes and `languages.json`. Text the build or a script produces is `t('site.gen.…')` / `t('site.js.…')` (`apps/web/i18n.ts`, `apps/web/src/i18n.ts`). CSS uses logical properties, so Arabic is laid out right to left without its own stylesheet. ## For developers - Shell, panel, popover: `const t = useT(); t('section.key', { name })` (`src/i18n/index.ts`). Keys are typed from `en.json`. Dates, numbers, relative times: `formatDate`, `formatNumber`, `relativeTime`. - X runtime (`src/inject`): `t('x.…')` from `src/inject/i18n.ts`. Only `x.*`, `kit.*` and `plugin.*` reach X tabs. - Code shared by both (`src/core`): `coreText('x.…')`. - Native: `desktop_i18n::tr(&app, "native.key", "English fallback")` / `trf(…, &[("name", value)])`. The fallback must equal `en.json`. - Add the English string to `en.json` (and a note to `en.meta.json` when the context is not obvious), then run `bun run i18n:extract` to find lookups whose key is missing. Other languages fall back to English until translated; drafting them is fine, marked `machine`. - Plugins: `api.i18n.register({ en: {...}, tr: {...} })` and `api.i18n.t('key', params)`, plus `locale`, `dir`, `onChange`, `number`, `date`, `relative`, `list` (see [PLUGINS.md](PLUGINS.md) and `waybetter/packages/runtime/src/pluginI18n.ts`). Bundled plugins keep their English inline between `// i18n:begin` and `// i18n:end` and are translated in the app catalogs as `plugin..`; the check keeps the two in sync. - Right-to-left: write CSS with logical properties (`margin-inline-start`, `padding-inline`, `inset-inline-end`, `text-align: start`). A physical `left`/`right` margin, padding, border or text-align needs a `/* rtl-ok: why */` comment; a test enforces it. The window frame (titlebar and tab strips) stays left to right by design, like the OS window buttons. - Language choice: Settings → Language offers Follow X (X's ``, reported by X tabs), Follow system (the OS languages, read natively) or one language. The first start follows X, then the system, then English.