waybetter-for-x/docs/TRANSLATING.md
2026-10-01 06:01:13 +07:00

207 lines
13 KiB
Markdown

# 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.<hash>.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
"<Language> 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.<file>.*`) 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/<tag>.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/<tag>.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 (`<b>`, 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 `/<tag>/`: `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/<tag>.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/<tag>.json`
and `waybetter/packages/waybetter/locales/<tag>.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/<tag>.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-<attribute>="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.<file name>.<key>`; 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 `<html lang>`, reported by X tabs),
Follow system (the OS languages, read natively) or one language. The first start follows X, then
the system, then English.