mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-21 22:29:08 +02:00
[client] UI refactor (#6069)
Refactor UI --------- Co-authored-by: Eduard Gert <kontakt@eduardgert.de> Co-authored-by: braginini <bangvalo@gmail.com> Co-authored-by: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: riccardom <riccardomanfrin@gmail.com>
This commit is contained in:
co-authored by
Eduard Gert
braginini
Pascal Fischer
Claude
riccardom
parent
679c7182a4
commit
8b7ce337d8
@@ -0,0 +1,133 @@
|
||||
# Translating the NetBird UI
|
||||
|
||||
A short brief for translating the desktop UI — for any translator, human or AI agent (*"you"* = whoever's translating).
|
||||
|
||||
**Drive an agent with:** *"Read `i18n/TRANSLATING.md` and translate the UI to Russian"* — or *"…and review the existing German translation."*
|
||||
|
||||
> 💡 **The one habit that matters most:** read each key's `description` before translating it. Labels are terse and ambiguous on their own; the `description` tells you what the string is, where it shows up, what to keep verbatim, and what it actually means.
|
||||
|
||||
---
|
||||
|
||||
## What NetBird is
|
||||
|
||||
A **business zero-trust VPN** — an encrypted **overlay mesh** between a company's devices, built on **WireGuard®**, connecting peers directly with a **relay** fallback. This is the **desktop client** (tray app + windows) someone runs to connect, switch profiles, browse peers, and pick an exit node — *not* the admin dashboard.
|
||||
|
||||
**Audience:** IT-literate professionals. **Tone:** clear and professional, never consumer-cute.
|
||||
|
||||
**The vocabulary you'll meet:**
|
||||
|
||||
| Term | What it means here |
|
||||
|---|---|
|
||||
| **Peer** | A device on the network (laptop, server, phone) |
|
||||
| **Resource / Network** | A routed network or service reachable through NetBird (UI calls these "Resources") |
|
||||
| **Exit Node** | A peer that routes *all* internet traffic, like a full-tunnel gateway |
|
||||
| **Profile** | A saved connection identity you can switch between |
|
||||
| **Daemon** | The background service the UI talks to |
|
||||
| **Management server** | The control plane — *Cloud* (hosted) or *self-hosted* (customer-run) |
|
||||
| **Relay** | Forwards traffic when two peers can't connect directly |
|
||||
| **Rosenpass** | Post-quantum security layered over WireGuard® |
|
||||
| **Handshake** | The periodic WireGuard® key sync between peers |
|
||||
|
||||
---
|
||||
|
||||
## The files
|
||||
|
||||
```
|
||||
i18n/locales/_index.json shipped-language list
|
||||
i18n/locales/en/common.json source of truth — message + description
|
||||
i18n/locales/<code>/common.json a target — message only
|
||||
```
|
||||
|
||||
Chrome-extension JSON, each key → `{ "message", "description" }`. You translate the **`message`**.
|
||||
|
||||
| ✅ Do | ❌ Don't |
|
||||
|---|---|
|
||||
| Keep **every key** from `en`, in the same order | Translate, rename, reorder, drop, or add keys (they're identifiers; the set grows over time) |
|
||||
| Put **only `message`** in target bundles | Copy `description` into a target bundle |
|
||||
| Give every key a non-empty `message` | Leave keys missing or empty |
|
||||
| Save valid UTF-8 JSON, no BOM | Add trailing commas or break the JSON |
|
||||
|
||||
---
|
||||
|
||||
## Hard rules — get these exactly right
|
||||
|
||||
These are the usual ways a translation *breaks the app*, not just reads oddly.
|
||||
|
||||
| ✅ Do | ❌ Don't |
|
||||
|---|---|
|
||||
| Copy `{placeholders}` verbatim — `{version}`, `{count}`, `{name}`… | Translate the word inside the braces (`{verbleibend}` breaks it) |
|
||||
| Reposition a placeholder so the sentence flows | Drop or duplicate a placeholder |
|
||||
| Preserve every `\n`, leading/trailing space, and trailing `...` | Trim "invisible" spaces or the `...` (they're load-bearing) |
|
||||
| Keep `®` in WireGuard® and quotes around `{name}` | Strip punctuation the description flags |
|
||||
|
||||
**Plurals:** the app has only a *one / other* split — the singular key fires only when `count == 1`; the `{count}` key covers everything else (0, 2, 5, 100…). Languages with more than two forms (ru, pl, uk) can't be fully correct here — use the form that fits the widest range (Russian genitive plural: `минут` / `часов` / `дней`). Don't invent extra keys or cram multiple forms into one string. When no single form fits every value — a unit label after a number field, say — reach for a number-agnostic form (an abbreviation, or wording that reads the same for 1 and 100) instead of forcing a plural the *one / other* split can't supply.
|
||||
|
||||
**Agreement:** a `{placeholder}` drops a value into a fixed frame, so the words around it must fit *every* value the app can supply. In inflected languages, write the frame in the case the surrounding preposition demands — German's duration fragments are **dative** because they land inside "…in {remaining}" (`in {count} Tagen`, `weniger als einer Minute`), not nominative `Tage`. Check the key that *consumes* the fragment (here `tray.session.expiresIn`) before choosing the form.
|
||||
|
||||
---
|
||||
|
||||
## Glossary
|
||||
|
||||
**Tier A — never translate (brands):** `NetBird` · `WireGuard®` · `Rosenpass` · `GitHub` · `ICE` · company/product names · sample URLs · version numbers.
|
||||
|
||||
When a brand sits beside a common noun, keep its exact spelling but join them the way your language builds such phrases — a hyphen, a connector word, an inflected noun — rather than copying English's bare noun-stack.
|
||||
|
||||
**Tier B — keep as-is (acronyms):** `SSO` · `MFA` · `DNS` · `IP`/`IPv6` · `ACL` · `SSH` · `GUI` · `P2P` · `URL` · `TCP`/`UDP`.
|
||||
|
||||
**Tier C — judgment.** One rule decides every term:
|
||||
|
||||
> **Use the word that language's IT users actually say.** Translate when a natural, common term exists; keep the English term *only* when the literal translation would be awkward or no one in that field really uses it.
|
||||
|
||||
Apply each term **consistently** — same English term → same translation everywhere — and keep a term once you've settled it. Whether a term stays English or takes a native word is **language-dependent**: a technical loanword (e.g. *Daemon*, *Handshake*) often stays, an everyday word (e.g. *Latency*, *Public key*) usually localizes, and some (*Exit Node*, *Peer*) go either way depending on the language. Decide per term with the rule above — a foreign origin alone is no reason to keep English. **Your main reference is the existing bundles:** match how a term was already rendered for your language rather than re-deciding it.
|
||||
|
||||
Two checks before you commit a term:
|
||||
|
||||
- **Prefer established localized wording.** If a widely used tool in this space (for example WireGuard) ships your language, its wording for a shared term such as *handshake* is what users already expect — look at the translated app, not just English docs. For generic UI verbs and formal address, follow your OS vendor's style guide (Microsoft / Apple / Google).
|
||||
- **Watch for false friends.** A literal translation can collide with a *different* established term in your field — confirm your word doesn't already mean something else in this domain before using it.
|
||||
|
||||
---
|
||||
|
||||
## Style
|
||||
|
||||
| ✅ Do | ❌ Don't |
|
||||
|---|---|
|
||||
| Use the **formal "you"** (de *Sie*, fr *vous*, ru *вы*, it *Lei*, zh 您) | Use casual/informal address |
|
||||
| Keep **buttons, menu, and tray** items short, in your language's action form (de "Speichern", fr "Enregistrer") | Let a label run much longer than the English — space is tight |
|
||||
| Follow **locale punctuation** (fr NBSP + « », de „…", zh full-width), including around a quoted UI label | Carry over English Title Case (use sentence case; German nouns excepted) |
|
||||
| Translate a term the **same way everywhere** | Vary wording for the same concept across screens |
|
||||
|
||||
Where it reads naturally, aim to keep each string **roughly the same length** as the English — the UI is tight and over-long strings can wrap or truncate. It's a soft preference, not a rule: if your language simply needs more words, use them.
|
||||
|
||||
A few habits that keep a bundle reading like one product rather than a word-for-word port:
|
||||
|
||||
- **Translate meaning, not words.** Render what a string *does*. An idiom or an awkward source phrase should become natural in your language, not a literal calque.
|
||||
- **Keep one voice within a family.** Sibling strings — the connection states, every settings *help* caption, every "… Failed" title — should share a grammatical form. If one member sounds wrong in that form, re-voice the whole family rather than leave one odd sibling.
|
||||
- **Mirror opposites.** A status should read as the natural counterpart of its pair: translate *Disconnected* as the opposite of however you rendered *Connected*, not as an unrelated word. Same for Active/Inactive, Selected/Not selected.
|
||||
- **Give a standalone label its subject.** A bare button or title can lose the context the surrounding English UI implied — add the noun back if it would otherwise read ambiguously.
|
||||
|
||||
---
|
||||
|
||||
## Procedure
|
||||
|
||||
**New language** — read `en/common.json` *with* descriptions → settle your Tier C terms → write `i18n/locales/<code>/common.json` (same keys and order as `en`, `message` only, placeholders & brands preserved) → add a row to `_index.json` (`{"code","displayName"` = native name`,"englishName"}`) → run the QA list. Use the locale-code style the existing entries use (e.g. `fr`, `pt`, `zh-CN`).
|
||||
|
||||
**Review (de / hu / …)** — read source and target side by side; for each key check glossary conformance (e.g. de `Exit-Node` → `Exit Node`, hu `Kilépő csomópont` → `Exit Node`), placeholder/`\n` integrity, consistency, tone, and that the meaning matches the English `description`. Fix in place, then report what you changed (especially term standardizations) so a native speaker can sanity-check.
|
||||
|
||||
---
|
||||
|
||||
## QA before you finish
|
||||
|
||||
- [ ] Valid JSON · **every `en` key** present, same order · **no `description`** fields
|
||||
- [ ] Every `{placeholder}`, `\n`, and intentional space preserved · `...` / `… Failed` / `{name}` quotes kept
|
||||
- [ ] Tier A/B left intact · Tier C applied consistently (and matching the existing bundle for your language)
|
||||
- [ ] Buttons & tray short · locale punctuation and capitalization applied
|
||||
- [ ] New language added to `_index.json`
|
||||
- [ ] **Tested in the running app** ↓
|
||||
|
||||
---
|
||||
|
||||
## Test it in the app
|
||||
|
||||
A bundle can pass every check above and still read wrong on screen. **Run the app, switch to your language, and click through the real surfaces** — tray menu, main window, every Settings tab, the dialogs. Watch for text overflow or truncation, labels that are technically right but wrong *for what the control does*, leaked placeholders, and terms that drift between screens.
|
||||
|
||||
How to run the app and switch language: see the project README. Can't run it (e.g. a headless agent)? Say so in your summary — don't silently skip this step.
|
||||
@@ -0,0 +1,196 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
// Package i18n loads and serves translation strings for both the tray (Go)
|
||||
// and the React UI (via the services.I18n facade).
|
||||
//
|
||||
// The locale tree is passed in as an fs.FS so the embed directive can live in
|
||||
// the main binary.
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
localeIndexFile = "_index.json"
|
||||
|
||||
// commonBundleFile shape is Chrome-extension JSON (key -> "message" plus
|
||||
// optional Crowdin "description"); loadBundle flattens to key->message.
|
||||
commonBundleFile = "common.json"
|
||||
)
|
||||
|
||||
// LanguageCode is a BCP-47-ish locale identifier ("en", "hu", ...).
|
||||
type LanguageCode string
|
||||
|
||||
// DefaultLanguage is the fallback bundle for missing keys and the default
|
||||
// when no preference is on disk.
|
||||
const DefaultLanguage LanguageCode = "en"
|
||||
|
||||
var ErrUnsupportedLanguage = errors.New("unsupported language")
|
||||
|
||||
// Language describes one shipped UI locale. DisplayName is in the locale's
|
||||
// own script (a Hungarian entry reads "Magyar" regardless of UI language).
|
||||
type Language struct {
|
||||
Code LanguageCode `json:"code"`
|
||||
DisplayName string `json:"displayName"`
|
||||
EnglishName string `json:"englishName"`
|
||||
}
|
||||
|
||||
type localeIndex struct {
|
||||
Languages []Language `json:"languages"`
|
||||
}
|
||||
|
||||
// Bundle holds the parsed translation bundles. Loaded once at construction
|
||||
// and never mutated.
|
||||
type Bundle struct {
|
||||
mu sync.RWMutex
|
||||
languages []Language
|
||||
bundles map[LanguageCode]map[string]string
|
||||
}
|
||||
|
||||
// NewBundle parses _index.json plus every <code>/common.json in the locale
|
||||
// tree. Hard-fails only when the default language is missing; other locales
|
||||
// without a bundle are dropped with a warning.
|
||||
func NewBundle(localesFS fs.FS) (*Bundle, error) {
|
||||
idx, err := loadLocaleIndex(localesFS)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load locale index: %w", err)
|
||||
}
|
||||
|
||||
bundles := make(map[LanguageCode]map[string]string, len(idx.Languages))
|
||||
available := make([]Language, 0, len(idx.Languages))
|
||||
for _, l := range idx.Languages {
|
||||
b, err := loadBundle(localesFS, l.Code)
|
||||
if err != nil {
|
||||
log.Warnf("skip language %q: %v", l.Code, err)
|
||||
continue
|
||||
}
|
||||
bundles[l.Code] = b
|
||||
available = append(available, l)
|
||||
}
|
||||
|
||||
if _, ok := bundles[DefaultLanguage]; !ok {
|
||||
return nil, fmt.Errorf("default language %q bundle missing", DefaultLanguage)
|
||||
}
|
||||
|
||||
sort.Slice(available, func(i, j int) bool { return available[i].Code < available[j].Code })
|
||||
|
||||
return &Bundle{
|
||||
languages: available,
|
||||
bundles: bundles,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Languages returns a copy of the available locales.
|
||||
func (b *Bundle) Languages() []Language {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
out := make([]Language, len(b.languages))
|
||||
copy(out, b.languages)
|
||||
return out
|
||||
}
|
||||
|
||||
func (b *Bundle) HasLanguage(code LanguageCode) bool {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
_, ok := b.bundles[code]
|
||||
return ok
|
||||
}
|
||||
|
||||
// BundleFor returns a copy of the full key->text map for one language.
|
||||
func (b *Bundle) BundleFor(code LanguageCode) (map[string]string, error) {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
|
||||
bundle, ok := b.bundles[code]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %q", ErrUnsupportedLanguage, code)
|
||||
}
|
||||
out := make(map[string]string, len(bundle))
|
||||
for k, v := range bundle {
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Translate resolves key for lang, substituting args given as name/value
|
||||
// pairs ("version", "1.2.3" replaces "{version}"). Unknown keys fall back to
|
||||
// the default language, then to the key itself so a miss is visible in the UI.
|
||||
func (b *Bundle) Translate(lang LanguageCode, key string, args ...string) string {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
|
||||
if v, ok := b.bundles[lang][key]; ok {
|
||||
return applyPlaceholders(v, args)
|
||||
}
|
||||
if lang != DefaultLanguage {
|
||||
if v, ok := b.bundles[DefaultLanguage][key]; ok {
|
||||
return applyPlaceholders(v, args)
|
||||
}
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// applyPlaceholders substitutes {name} in s using args as flat name/value
|
||||
// pairs. An odd-length args drops the trailing item.
|
||||
func applyPlaceholders(s string, args []string) string {
|
||||
if len(args) == 0 {
|
||||
return s
|
||||
}
|
||||
if len(args)%2 != 0 {
|
||||
log.Debugf("i18n placeholder args not paired: %d items, last dropped", len(args))
|
||||
args = args[:len(args)-1]
|
||||
}
|
||||
for j := 0; j < len(args); j += 2 {
|
||||
s = strings.ReplaceAll(s, "{"+args[j]+"}", args[j+1])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func loadLocaleIndex(localesFS fs.FS) (*localeIndex, error) {
|
||||
data, err := fs.ReadFile(localesFS, localeIndexFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var idx localeIndex
|
||||
if err := json.Unmarshal(data, &idx); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", localeIndexFile, err)
|
||||
}
|
||||
if len(idx.Languages) == 0 {
|
||||
return nil, errors.New("no languages declared")
|
||||
}
|
||||
return &idx, nil
|
||||
}
|
||||
|
||||
// bundleEntry is one translation key on disk; Description is Crowdin context,
|
||||
// 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 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
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakeLocales returns an in-memory FS that mirrors the real
|
||||
// client/ui/i18n/locales layout (root-level _index.json plus
|
||||
// <code>/common.json bundles). Used by every Bundle test so we don't
|
||||
// depend on the embedded production bundles staying stable.
|
||||
func fakeLocales() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"_index.json": {Data: []byte(`{
|
||||
"languages": [
|
||||
{"code": "en", "displayName": "English", "englishName": "English"},
|
||||
{"code": "hu", "displayName": "Magyar", "englishName": "Hungarian"}
|
||||
]
|
||||
}`)},
|
||||
"en/common.json": {Data: []byte(`{
|
||||
"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": {"message": "Csatlakozás"},
|
||||
"tray.menu.installVersion": {"message": "{version} telepítése"}
|
||||
}`)},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundle_LoadsAllLanguages(t *testing.T) {
|
||||
b, err := NewBundle(fakeLocales())
|
||||
require.NoError(t, err)
|
||||
|
||||
langs := b.Languages()
|
||||
require.Len(t, langs, 2)
|
||||
codes := []LanguageCode{langs[0].Code, langs[1].Code}
|
||||
assert.ElementsMatch(t, []LanguageCode{"en", "hu"}, codes, "Languages should list every bundle that loaded")
|
||||
}
|
||||
|
||||
func TestBundle_TranslateLooksUpKey(t *testing.T) {
|
||||
b, err := NewBundle(fakeLocales())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "Csatlakozás", b.Translate("hu", "tray.menu.connect"))
|
||||
assert.Equal(t, "Connect", b.Translate("en", "tray.menu.connect"))
|
||||
}
|
||||
|
||||
func TestBundle_TranslateSubstitutesPlaceholders(t *testing.T) {
|
||||
b, err := NewBundle(fakeLocales())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "Install version 1.2.3",
|
||||
b.Translate("en", "tray.menu.installVersion", "version", "1.2.3"),
|
||||
"placeholders should substitute by name")
|
||||
assert.Equal(t, "1.2.3 telepítése",
|
||||
b.Translate("hu", "tray.menu.installVersion", "version", "1.2.3"))
|
||||
}
|
||||
|
||||
func TestBundle_TranslateFallsBackToEnglish(t *testing.T) {
|
||||
b, err := NewBundle(fakeLocales())
|
||||
require.NoError(t, err)
|
||||
|
||||
// notify.update.body is missing from the hu bundle; English fallback
|
||||
// applies so the user always sees a populated label rather than the
|
||||
// raw key.
|
||||
got := b.Translate("hu", "notify.update.body", "version", "9.9.9")
|
||||
assert.Equal(t, "NetBird 9.9.9 is available.", got, "missing hu key should fall back to en bundle")
|
||||
}
|
||||
|
||||
func TestBundle_TranslateUnknownKeyReturnsKey(t *testing.T) {
|
||||
b, err := NewBundle(fakeLocales())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "tray.missing", b.Translate("en", "tray.missing"),
|
||||
"unknown key should return the key itself for debugability")
|
||||
}
|
||||
|
||||
func TestBundle_BundleForReturnsCopy(t *testing.T) {
|
||||
b, err := NewBundle(fakeLocales())
|
||||
require.NoError(t, err)
|
||||
|
||||
m, err := b.BundleFor("en")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, m, "BundleFor should return populated map for known language")
|
||||
|
||||
m["tray.menu.connect"] = "Mutated"
|
||||
assert.Equal(t, "Connect", b.Translate("en", "tray.menu.connect"),
|
||||
"BundleFor must return a copy, not the live map")
|
||||
}
|
||||
|
||||
func TestBundle_BundleForUnknownLanguage(t *testing.T) {
|
||||
b, err := NewBundle(fakeLocales())
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = b.BundleFor("xx")
|
||||
assert.ErrorIs(t, err, ErrUnsupportedLanguage)
|
||||
}
|
||||
|
||||
func TestBundle_HasLanguage(t *testing.T) {
|
||||
b, err := NewBundle(fakeLocales())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, b.HasLanguage("en"))
|
||||
assert.True(t, b.HasLanguage("hu"))
|
||||
assert.False(t, b.HasLanguage("de"))
|
||||
}
|
||||
|
||||
func TestBundle_MissingDefaultBundleFails(t *testing.T) {
|
||||
// Without an en bundle we have nothing to fall back to, so construction
|
||||
// must hard-fail. Catches packaging accidents where someone drops the
|
||||
// English locale.
|
||||
fs := fstest.MapFS{
|
||||
"_index.json": {Data: []byte(`{"languages":[{"code":"hu","displayName":"Magyar","englishName":"Hungarian"}]}`)},
|
||||
"hu/common.json": {Data: []byte(`{"k":{"message":"v"}}`)},
|
||||
}
|
||||
_, err := NewBundle(fs)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "default language")
|
||||
}
|
||||
|
||||
func TestBundle_MissingBundleSkipsLanguage(t *testing.T) {
|
||||
// A language declared in the index but missing its bundle file is
|
||||
// dropped from Languages with a warning — adding a new language must
|
||||
// be a two-step process (declare + ship), not declare-only.
|
||||
fs := fstest.MapFS{
|
||||
"_index.json": {Data: []byte(`{"languages":[
|
||||
{"code":"en","displayName":"English","englishName":"English"},
|
||||
{"code":"de","displayName":"Deutsch","englishName":"German"}
|
||||
]}`)},
|
||||
"en/common.json": {Data: []byte(`{"k":{"message":"v"}}`)},
|
||||
}
|
||||
b, err := NewBundle(fs)
|
||||
require.NoError(t, err)
|
||||
|
||||
langs := b.Languages()
|
||||
require.Len(t, langs, 1)
|
||||
assert.Equal(t, LanguageCode("en"), langs[0].Code, "language without a bundle file must be dropped")
|
||||
assert.False(t, b.HasLanguage("de"))
|
||||
}
|
||||
|
||||
func TestBundle_OddPlaceholderArgsDoNotPanic(t *testing.T) {
|
||||
b, err := NewBundle(fakeLocales())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Trailing dangling arg should be dropped, not panic — preserves UI
|
||||
// stability when a caller passes an unpaired placeholder by mistake.
|
||||
got := b.Translate("en", "tray.menu.installVersion", "version", "1.2.3", "extra")
|
||||
assert.Equal(t, "Install version 1.2.3", got)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"languages": [
|
||||
{"code": "en", "displayName": "English (US)", "englishName": "English (US)"},
|
||||
{"code": "de", "displayName": "Deutsch", "englishName": "German"},
|
||||
{"code": "hu", "displayName": "Magyar", "englishName": "Hungarian"},
|
||||
{"code": "ru", "displayName": "Русский", "englishName": "Russian"},
|
||||
{"code": "es", "displayName": "Español", "englishName": "Spanish"},
|
||||
{"code": "fr", "displayName": "Français", "englishName": "French"},
|
||||
{"code": "it", "displayName": "Italiano", "englishName": "Italian"},
|
||||
{"code": "pt", "displayName": "Português", "englishName": "Portuguese"},
|
||||
{"code": "zh-CN", "displayName": "简体中文", "englishName": "Simplified Chinese"}
|
||||
]
|
||||
}
|
||||
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
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
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