mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-20 07:21:27 +02:00
add description to translations
This commit is contained in:
@@ -110,6 +110,8 @@ The main window is **hidden** on close (the `WindowClosing` hook calls `e.Cancel
|
||||
|
||||
The locale tree under `client/ui/i18n/locales/` is the single source of truth for both Go (tray, OS notifications) and React (every user-facing string). It sits next to the Go `i18n` package (the tray's consumer) so a single JSON tree drives both surfaces. Layout: `_index.json` lists shipped languages (`code` / `displayName` / `englishName`); `<code>/common.json` per language. `en/common.json` must exist (the `Bundle` loader hard-fails without it); languages listed in `_index.json` without a bundle are skipped with a warning. Placeholders are single-braced (`"Install version {version}"`) — Go substitutes via `Bundle.Translate(lang, key, "name", value, ...)`; React uses i18next with `interpolation: { prefix: "{", suffix: "}" }`.
|
||||
|
||||
**Bundle shape is Chrome-extension JSON**: each key maps to `{ "message": "...", "description": "..." }`, not a bare string. `description` is **translator context for Crowdin** (which reads it natively from the source file) and is ignored at runtime — only `en/common.json` needs descriptions; target bundles carry just `message`. Both loaders strip back to a flat `key→message` map: Go's `loadBundle` (`bundle.go`) unmarshals into `map[string]bundleEntry` and flattens (so `BundleFor`/`Translate` signatures are unchanged); `frontend/src/lib/i18n.ts` maps each entry's `message` into the i18next `resources`. When editing a string, edit `message`; when a key's purpose isn't obvious from its name, add/update its `description` so translators (and screenshots auto-tagging) have context.
|
||||
|
||||
Adding a language: drop a `<code>/common.json` under `client/ui/i18n/locales/`, append a row to `_index.json`, rebuild. Go reads the tree via `//go:embed all:i18n/locales` in `client/ui/main.go`; Vite reads it via the `../../../i18n/locales/*/common.json` glob in `frontend/src/lib/i18n.ts`, with `server.fs.allow` in `vite.config.ts` whitelisting the parent dir so the dev server can serve files outside `frontend/`.
|
||||
|
||||
Package layout:
|
||||
|
||||
@@ -151,7 +151,7 @@ if (result !== confirmLabel) return;
|
||||
|
||||
Compare against the variable, never against an English literal.
|
||||
|
||||
**Bundle files.** Keys live in `client/ui/i18n/locales/<code>/common.json` as a flat key→string map (`"settings.tabs.general": "General"`). Placeholders use single braces: `"Install version {version}"`. Adding a key: add to `en/common.json` first (the fallback), then every other locale. Missing keys fall back to English; if even that misses, i18next returns the key itself so the gap is visible in the UI rather than blank.
|
||||
**Bundle files.** Keys live in `client/ui/i18n/locales/<code>/common.json` in Chrome-extension JSON shape: each key maps to `{ "message": "General", "description": "..." }` (not a bare string). `description` is translator context for Crowdin (read natively from the source file, ignored at runtime); only `en/common.json` carries descriptions, target bundles carry just `message`. `lib/i18n.ts` strips each entry down to its `message` when building the i18next `resources`, so `t()` lookups are unchanged. Placeholders use single braces: `"Install version {version}"`. Adding a key: add `{ "message": "…", "description": "…" }` to `en/common.json` first (the fallback), then `{ "message": "…" }` to every other locale. Missing keys fall back to English; if even that misses, i18next returns the key itself so the gap is visible in the UI rather than blank.
|
||||
|
||||
**Adding a language.** Drop `client/ui/i18n/locales/<code>/common.json` and append the row to `client/ui/i18n/locales/_index.json`. Also drop the matching `<code>.svg` into `src/assets/flags/1x1/` — source those from the NetBird dashboard repo's same-name folder so the icon set stays consistent: https://github.com/netbirdio/dashboard/tree/main/public/assets/flags/1x1 . **Only check in flags for languages we actually ship** — `LanguagePicker.tsx` eager-globs that directory at build time, so every SVG in it gets bundled into the Wails app whether referenced or not. `src/lib/i18n.ts` discovers bundles via `import.meta.glob('../../../i18n/locales/*/common.json', { eager: true })` (the locales tree lives outside `frontend/`, so `vite.config.ts` whitelists the parent dir under `server.fs.allow`), so no code change is needed to wire the new locale in. Vite still inlines each bundle at build time, same chunk shape as static imports. The Go side reads the same tree (embedded via `client/ui/main.go`'s `embed.FS`), so the tray menu localises automatically off the same files.
|
||||
|
||||
|
||||
@@ -15,7 +15,13 @@ import { LanguageCode } from "@bindings/i18n/models.js";
|
||||
// empty match in some Vite dev-mode setups. `server.fs.allow` in
|
||||
// `vite.config.ts` whitelists the parent directory so the dev server
|
||||
// serves the JSON.
|
||||
const bundleModules = import.meta.glob<Record<string, string>>(
|
||||
//
|
||||
// Each bundle is Chrome-extension JSON: every key maps to
|
||||
// `{ message, description? }`. `description` exists only so Crowdin can
|
||||
// show translator context — it's stripped here and i18next sees a flat
|
||||
// key->message map exactly as before.
|
||||
type BundleEntry = { message: string; description?: string };
|
||||
const bundleModules = import.meta.glob<Record<string, BundleEntry>>(
|
||||
"../../../i18n/locales/*/common.json",
|
||||
{ eager: true, import: "default" },
|
||||
);
|
||||
@@ -24,7 +30,12 @@ const resources: Record<string, { common: Record<string, string> }> = {};
|
||||
for (const path in bundleModules) {
|
||||
const match = path.match(/locales\/([^/]+)\/common\.json$/);
|
||||
if (match) {
|
||||
resources[match[1]] = { common: bundleModules[path] };
|
||||
const entries = bundleModules[path];
|
||||
const messages: Record<string, string> = {};
|
||||
for (const key in entries) {
|
||||
messages[key] = entries[key].message;
|
||||
}
|
||||
resources[match[1]] = { common: messages };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,12 @@ const (
|
||||
// commonBundleFile is the per-language translation bundle. Single
|
||||
// namespace for now ("common") — split later if the key set grows
|
||||
// enough to warrant per-screen bundles.
|
||||
//
|
||||
// Shape is Chrome-extension JSON (each key maps to an object with a
|
||||
// "message" and an optional "description") so Crowdin reads the
|
||||
// description as translator context straight from the source file.
|
||||
// Only the source bundle (en) needs descriptions; target bundles carry
|
||||
// just "message". loadBundle flattens both back to key->message.
|
||||
commonBundleFile = "common.json"
|
||||
)
|
||||
|
||||
@@ -195,15 +201,27 @@ func loadLocaleIndex(localesFS fs.FS) (*localeIndex, error) {
|
||||
return &idx, nil
|
||||
}
|
||||
|
||||
// bundleEntry is the on-disk shape of one translation key: a Chrome-JSON
|
||||
// object carrying the translatable "message" plus an optional translator
|
||||
// "description" (consumed by Crowdin, ignored at runtime).
|
||||
type bundleEntry struct {
|
||||
Message string `json:"message"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
func loadBundle(localesFS fs.FS, code LanguageCode) (map[string]string, error) {
|
||||
p := path.Join(string(code), commonBundleFile)
|
||||
data, err := fs.ReadFile(localesFS, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var bundle map[string]string
|
||||
if err := json.Unmarshal(data, &bundle); err != nil {
|
||||
var entries map[string]bundleEntry
|
||||
if err := json.Unmarshal(data, &entries); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", p, err)
|
||||
}
|
||||
bundle := make(map[string]string, len(entries))
|
||||
for k, e := range entries {
|
||||
bundle[k] = e.Message
|
||||
}
|
||||
return bundle, nil
|
||||
}
|
||||
|
||||
@@ -23,13 +23,13 @@ func fakeLocales() fstest.MapFS {
|
||||
]
|
||||
}`)},
|
||||
"en/common.json": {Data: []byte(`{
|
||||
"tray.menu.connect": "Connect",
|
||||
"tray.menu.installVersion": "Install version {version}",
|
||||
"notify.update.body": "NetBird {version} is available."
|
||||
"tray.menu.connect": {"message": "Connect", "description": "Tray menu item"},
|
||||
"tray.menu.installVersion": {"message": "Install version {version}"},
|
||||
"notify.update.body": {"message": "NetBird {version} is available."}
|
||||
}`)},
|
||||
"hu/common.json": {Data: []byte(`{
|
||||
"tray.menu.connect": "Csatlakozás",
|
||||
"tray.menu.installVersion": "{version} telepítése"
|
||||
"tray.menu.connect": {"message": "Csatlakozás"},
|
||||
"tray.menu.installVersion": {"message": "{version} telepítése"}
|
||||
}`)},
|
||||
}
|
||||
}
|
||||
@@ -118,7 +118,7 @@ func TestBundle_MissingDefaultBundleFails(t *testing.T) {
|
||||
// English locale.
|
||||
fs := fstest.MapFS{
|
||||
"_index.json": {Data: []byte(`{"languages":[{"code":"hu","displayName":"Magyar","englishName":"Hungarian"}]}`)},
|
||||
"hu/common.json": {Data: []byte(`{"k":"v"}`)},
|
||||
"hu/common.json": {Data: []byte(`{"k":{"message":"v"}}`)},
|
||||
}
|
||||
_, err := NewBundle(fs)
|
||||
require.Error(t, err)
|
||||
@@ -134,7 +134,7 @@ func TestBundle_MissingBundleSkipsLanguage(t *testing.T) {
|
||||
{"code":"en","displayName":"English","englishName":"English"},
|
||||
{"code":"de","displayName":"Deutsch","englishName":"German"}
|
||||
]}`)},
|
||||
"en/common.json": {Data: []byte(`{"k":"v"}`)},
|
||||
"en/common.json": {Data: []byte(`{"k":{"message":"v"}}`)},
|
||||
}
|
||||
b, err := NewBundle(fs)
|
||||
require.NoError(t, err)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user