Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions CONTRIBUTING-i18n.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# Contributing translations

Thanks for helping localize the **Autonomi** browser extension (`ant-webex`).
This guide covers adding a new locale and polishing existing translations.

## TL;DR

- UI strings live in [`src/i18n/locales/<lang>.json`](./src/i18n/locales/) and are
loaded at runtime by [`src/i18n/index.ts`](./src/i18n/index.ts).
- English (`en.json`) is the source of truth — every other locale mirrors its
structure.
- Most non-English locales ship as **machine-translated baselines** (flagged
with `_translator_notes`). Native speakers are very welcome to polish them
via PR.
- Currently shipped: `en, ja, ko, nl, fr, bg, es, ar, he, ru, uk, zh-CN,
zh-TW, pt-BR, tr, vi, id, de` (18 locales).

## How i18n is wired (two string stores)

The extension is dependency-free — there is no i18n framework. Strings live in
two places, both copied into `dist/` and `dist-firefox/` by `build.mjs`:

1. **Runtime dictionary — `src/i18n/locales/<lang>.json`.** Powers every string
in the popup and onboarding page. Fetched at page load from the packaged
assets and applied by `t()` / `applyStaticTranslations()`. This is where
~95% of the copy lives and where almost all translation happens.

2. **Native store listing — `src/_locales/<lang>/messages.json`.** A single key,
`extDescription`, referenced from the manifest as `__MSG_extDescription__`.
This is the *only* thing a runtime dictionary can't localize: the extension
description shown in the browser and on the Chrome Web Store / Firefox AMO
listing (read from the manifest before any JS runs). The product **name**
("Autonomi") is intentionally left untranslated.

> Note the folder-name convention: Chinese uses **underscore** directory
> names here — `_locales/zh_CN/` and `_locales/zh_TW/` — even though the
> runtime dictionary uses hyphens (`zh-CN.json`, `zh-TW.json`). That's the
> `chrome.i18n` requirement, not a typo.

### Content-script strings

A small subset (the in-page Download/Open link labels and the "Failed to load"
overlay) is bundled into the content script from the `content` section of each
locale file — see `src/i18n/content-locales.json`, which `build.mjs`
**regenerates on every build**; don't hand-edit it. Edit the `content.*` keys in
the per-locale files instead.

The content script follows the **browser UI language** (`navigator.language`);
the in-popup language override governs the extension's own pages (popup +
onboarding) but not content-script labels injected into arbitrary web pages.

## Conventions

Each locale file is one JSON object grouped by area (`popup.*`, `onboarding.*`,
`guide.*`, `install.*`, `downloads.*`, `settings.*`, `content.*`, `common.*`).
Keys are dotted paths used as `t('popup.no_downloads')`.

- **Placeholders** are written `{name}` and must be preserved verbatim:
`{min} {version} {url} {os} {instr} {file} {pct} {received} {total} {asset}
{platform}`.
- **Plurals** (should any be added) use suffixed keys `*_one` / `*_many`, chosen
by the caller via `t(key, { count })` — not a `|` plural syntax.
- **Don't translate identifiers.** Keep verbatim: `Autonomi`, `antd`,
`autonomi://`, `--cors`, `GitHub`, `PowerShell`, `Terminal`, `PATH`,
`macOS`/`Windows`/`Linux`, key names (`Win`, `Enter`, `Cmd+Space`,
`Ctrl+Alt+T`), the literal shell message `"command not found"`, the 🎉 emoji,
and version/number tokens.
- **Shared atoms** (`common.download`, `common.save`, `common.connected`,
`common.checking`, `common.downloading`, `common.settings`,
`common.downloads`) are copied verbatim from the sibling `ant-ui` desktop app
so the two products read identically. Please keep them in sync rather than
re-translating.

## Right-to-left (RTL) locales

Arabic (`ar`) and Hebrew (`he`) ship as RTL baselines. Direction is wired
through two pieces:

- **`src/i18n/index.ts`** — the `RTL_LOCALES` set, applied as `<html dir="rtl">`
in `initI18n()`. Add new RTL locale codes here.
- **Logical CSS** — layout uses flex/gap (mirrors automatically) plus logical
properties (`margin-inline-start/end`) rather than physical `margin-left/
right`, so it flips with `dir`. When adding CSS, prefer logical properties.

Known minor follow-up (functional, not blocking): the indeterminate
download-progress shimmer keyframe in `popup/style.css` animates a physical
`margin-left` and doesn't mirror in RTL. It's a decorative sweep; migrate it to
a direction-aware pair opportunistically.

## Backend error passthrough (carve-out)

Technical error detail produced by the daemon/browser (e.g. the text after
"Failed:" in the downloads list, or after "Failed to load from Autonomi:") stays
in English — the source emits it as a pre-formatted string. Only the leading
label is localized. Please leave the appended detail alone; a later phase can
switch the source to structured error tokens.

## Adding a new locale

1. **Copy `en.json` to `src/i18n/locales/<lang>.json`.** Use the ISO 639-1 code
(`fr`, `de`, …) or an IETF tag where the region matters (`pt-BR`, `zh-TW`).
2. **Add `_translator_notes` as the first key** if the baseline is
machine-translated:
```json
{ "_translator_notes": "Machine-translated baseline. Native-speaker polish via PR welcome.", "common": { … } }
```
Keys starting with `_` are documentation-only — never consumed at runtime.
3. **Translate the values**, leaving every key path intact and in order (diffing
against `en.json` is the fastest way to find gaps).
4. **Register the code** in `SUPPORTED_LOCALES` (and `NATIVE_LOCALE_NAMES`, and
`RTL_LOCALES` if applicable) in `src/i18n/index.ts`. The Settings → Language
picker is populated from `SUPPORTED_LOCALES` automatically.
5. **Add the store-listing description** at
`src/_locales/<lang>/messages.json` (underscore directory names for Chinese —
`zh_CN`, `zh_TW`).
6. **Rebuild and verify** (below), then open a PR noting whether the source is
machine-translated or human-authored.

## Testing locally

```
npm run build:all # builds dist/ (Chrome) and dist-firefox/ (Firefox)
npm run typecheck
```

Load `dist/` as an unpacked extension (chrome://extensions → Load unpacked).
To exercise a specific locale, set your browser's UI language, or open the popup
and pick a language from **Settings → Language** (this persists an override in
`chrome.storage.local`, independent of the browser language). For RTL, pick
Arabic or Hebrew and confirm the layout mirrors.

## Review

Structural changes (new keys, plurals, renames) are reviewed on the English
side. Translation-only PRs get a lighter review — if you self-identify as a
native or fluent speaker, that's enough. Partial polish is welcome; you don't
have to review the whole file.

Thanks again for the help.
48 changes: 47 additions & 1 deletion build.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import * as esbuild from 'esbuild';
import { copyFileSync, mkdirSync, cpSync, existsSync, readFileSync, writeFileSync } from 'fs';
import {
copyFileSync,
mkdirSync,
cpSync,
existsSync,
readFileSync,
writeFileSync,
readdirSync,
} from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';

Expand Down Expand Up @@ -45,7 +53,44 @@ function buildManifest() {
writeFileSync(dist('manifest.json'), JSON.stringify(merged, null, 2));
}

/**
* Regenerate src/i18n/content-locales.json from the `content` section of every
* locale file, so the content script can bundle its (small) string subset
* without fetching packaged assets. The per-locale files stay the single
* source of truth; this artifact is committed only so `tsc` resolves the
* import — it's rewritten on every build.
*/
function generateContentLocales() {
const localesDir = src('i18n/locales');
const out = {};
// Sorted so the generated JSON's key order is deterministic regardless of the
// filesystem's readdir order — keeps the build reproducible across machines
// (readdirSync order is unspecified and differs between macOS and Linux/CI).
for (const file of readdirSync(localesDir).sort()) {
if (!file.endsWith('.json')) continue;
const lang = file.slice(0, -'.json'.length);
const catalog = JSON.parse(readFileSync(resolve(localesDir, file), 'utf-8'));
if (catalog.content) out[lang] = catalog.content;
}
writeFileSync(
src('i18n/content-locales.json'),
JSON.stringify(out, null, 2) + '\n',
);
}

/** Copy both locale stores into a dist: native _locales (manifest __MSG__
* fields) and the runtime dictionary fetched by popup/onboarding. */
function copyLocaleAssets() {
if (existsSync(src('_locales'))) {
cpSync(src('_locales'), dist('_locales'), { recursive: true });
}
cpSync(src('i18n/locales'), dist('i18n/locales'), { recursive: true });
}

async function build() {
// Must run before esbuild bundles content/index.ts, which imports the
// generated content-locales.json.
generateContentLocales();
await Promise.all([
esbuild.build({
...commonOptions,
Expand Down Expand Up @@ -74,6 +119,7 @@ async function build() {
]);

buildManifest();
copyLocaleAssets();
copyFileSync(src('popup/index.html'), dist('popup/index.html'));
copyFileSync(src('popup/style.css'), dist('popup/style.css'));
copyFileSync(src('onboarding/index.html'), dist('onboarding/index.html'));
Expand Down
6 changes: 6 additions & 0 deletions src/_locales/ar/messages.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extDescription": {
"message": "نزّل وشاهد المحتوى من شبكة Autonomi اللامركزية مباشرةً في متصفحك",
"description": "Extension description shown in the browser and on the store listing. 'Autonomi' stays untranslated."
}
}
6 changes: 6 additions & 0 deletions src/_locales/bg/messages.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extDescription": {
"message": "Изтегляйте и преглеждайте съдържание от децентрализираната мрежа Autonomi директно във вашия браузър",
"description": "Extension description shown in the browser and on the store listing. 'Autonomi' stays untranslated."
}
}
6 changes: 6 additions & 0 deletions src/_locales/de/messages.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extDescription": {
"message": "Laden Sie Inhalte aus dem dezentralen Autonomi-Netzwerk herunter und zeigen Sie sie direkt in Ihrem Browser an",
"description": "Extension description shown in the browser and on the store listing. 'Autonomi' stays untranslated."
}
}
6 changes: 6 additions & 0 deletions src/_locales/en/messages.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extDescription": {
"message": "Download and view content from the Autonomi decentralized network directly in your browser",
"description": "Extension description shown in the browser and on the Chrome Web Store / Firefox AMO listing. The product name 'Autonomi' stays untranslated."
}
}
6 changes: 6 additions & 0 deletions src/_locales/es/messages.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extDescription": {
"message": "Descarga y visualiza contenido de la red descentralizada Autonomi directamente en tu navegador",
"description": "Extension description shown in the browser and on the store listing. 'Autonomi' stays untranslated."
}
}
6 changes: 6 additions & 0 deletions src/_locales/fr/messages.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extDescription": {
"message": "Téléchargez et consultez le contenu du réseau décentralisé Autonomi directement dans votre navigateur",
"description": "Extension description shown in the browser and on the store listing. 'Autonomi' stays untranslated."
}
}
6 changes: 6 additions & 0 deletions src/_locales/he/messages.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extDescription": {
"message": "הורד וצפה בתוכן מרשת Autonomi המבוזרת ישירות בדפדפן שלך",
"description": "Extension description shown in the browser and on the store listing. 'Autonomi' stays untranslated."
}
}
6 changes: 6 additions & 0 deletions src/_locales/id/messages.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extDescription": {
"message": "Unduh dan lihat konten dari jaringan terdesentralisasi Autonomi langsung di browser Anda",
"description": "Extension description shown in the browser and on the store listing. 'Autonomi' stays untranslated."
}
}
6 changes: 6 additions & 0 deletions src/_locales/ja/messages.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extDescription": {
"message": "Autonomi 分散型ネットワークのコンテンツを、ブラウザで直接ダウンロードして表示します",
"description": "Extension description shown in the browser and on the store listing. 'Autonomi' stays untranslated."
}
}
6 changes: 6 additions & 0 deletions src/_locales/ko/messages.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extDescription": {
"message": "Autonomi 분산 네트워크의 콘텐츠를 브라우저에서 바로 다운로드하고 볼 수 있습니다",
"description": "Extension description shown in the browser and on the store listing. 'Autonomi' stays untranslated."
}
}
6 changes: 6 additions & 0 deletions src/_locales/nl/messages.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extDescription": {
"message": "Download en bekijk inhoud van het gedecentraliseerde Autonomi-netwerk rechtstreeks in uw browser",
"description": "Extension description shown in the browser and on the store listing. 'Autonomi' stays untranslated."
}
}
6 changes: 6 additions & 0 deletions src/_locales/pt-BR/messages.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extDescription": {
"message": "Baixe e visualize conteúdo da rede descentralizada Autonomi diretamente no seu navegador",
"description": "Extension description shown in the browser and on the store listing. 'Autonomi' stays untranslated."
}
}
6 changes: 6 additions & 0 deletions src/_locales/ru/messages.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extDescription": {
"message": "Скачивайте и просматривайте контент из децентрализованной сети Autonomi прямо в браузере",
"description": "Extension description shown in the browser and on the store listing. 'Autonomi' stays untranslated."
}
}
6 changes: 6 additions & 0 deletions src/_locales/tr/messages.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extDescription": {
"message": "Autonomi merkeziyetsiz ağından içeriği doğrudan tarayıcınızda indirin ve görüntüleyin",
"description": "Extension description shown in the browser and on the store listing. 'Autonomi' stays untranslated."
}
}
6 changes: 6 additions & 0 deletions src/_locales/uk/messages.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extDescription": {
"message": "Завантажуйте та переглядайте контент із децентралізованої мережі Autonomi безпосередньо у вашому браузері",
"description": "Extension description shown in the browser and on the store listing. 'Autonomi' stays untranslated."
}
}
6 changes: 6 additions & 0 deletions src/_locales/vi/messages.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extDescription": {
"message": "Tải xuống và xem nội dung từ mạng phi tập trung Autonomi trực tiếp trong trình duyệt của bạn",
"description": "Extension description shown in the browser and on the store listing. 'Autonomi' stays untranslated."
}
}
6 changes: 6 additions & 0 deletions src/_locales/zh_CN/messages.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extDescription": {
"message": "直接在浏览器中从 Autonomi 去中心化网络下载和查看内容",
"description": "Extension description shown in the browser and on the store listing. 'Autonomi' stays untranslated."
}
}
6 changes: 6 additions & 0 deletions src/_locales/zh_TW/messages.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extDescription": {
"message": "直接在瀏覽器中從 Autonomi 去中心化網路下載並檢視內容",
"description": "Extension description shown in the browser and on the store listing. 'Autonomi' stays untranslated."
}
}
26 changes: 26 additions & 0 deletions src/content/i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Content-script i18n. The content script injects into arbitrary pages and
* can't fetch packaged locale JSON without widening web_accessible_resources
* (a store-review surface), so its small string subset is bundled at build
* time. content-locales.json is regenerated from the `content` section of each
* locale file by build.mjs — the per-locale files stay the single source of
* truth.
*
* The locale here follows the browser UI language (navigator.language). The
* in-popup language override governs the extension's own pages; content-script
* link labels follow the browser — see CONTRIBUTING-i18n.md.
*/
import { normalizeLocale } from '../i18n';
import CONTENT_LOCALES from '../i18n/content-locales.json';

type ContentDict = Record<string, string>;
const all = CONTENT_LOCALES as Record<string, ContentDict>;
const en: ContentDict = all.en ?? {};
const dict: ContentDict = all[normalizeLocale(navigator.language)] ?? en;

/** Translate a content-script key, with {name} interpolation and en fallback. */
export function tc(key: string, params?: Record<string, string | number>): string {
const raw = dict[key] ?? en[key] ?? key;
if (!params) return raw;
return raw.replace(/\{(\w+)\}/g, (m, k) => (k in params ? String(params[k]) : m));
}
Loading
Loading