diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-16 00:21:07 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-16 00:21:07 +0200 |
| commit | 3b2d03a919e56d14f8097844b97b844ddf526cd6 (patch) | |
| tree | 31b222fedf6d608809eb47c45c5f431d7001d87a /packages/meshbay-hub/src/meshbay_hub/static/i18n.js | |
| parent | 75065cc1ed5d9cd733871d478dc4f915663d795c (diff) | |
| download | meshbay-3b2d03a919e56d14f8097844b97b844ddf526cd6.tar.gz | |
feat(hub): translate the web client into nine more languages
French, Spanish, Brazilian Portuguese, Simplified Chinese, Japanese,
German, Italian, Dutch and Polish, all in the formal register. `hub`,
`node` and `GEK` stay in English: they name the CLI, node.toml and the
docs, and translating them would cut the interface off from everything an
operator reads and types.
Catalogues move out of i18n.js into locales/, one file per language,
fetched with a dynamic import. A visitor downloads their language plus
English as a fallback — about 36 KB rather than the ~180 KB that ten
inlined catalogues would have cost everyone. i18n.js keeps only the
loader, so the first render now waits for initLocale().
Three things the old code got wrong, none of them visible until there was
a second language:
- Resolution trimmed a tag to its base before matching, so a browser
reporting pt-BR looked for a `pt` catalogue that does not exist and fell
back to English. Matching is now exact first, then by base language.
- Counted strings were single strings, so Polish could not express
1 plik / 2 pliki / 5 plików at all. t() selects through
Intl.PluralRules; en.js gains the same treatment, which incidentally
fixes "1 files".
- Interpolation used String.replace, which reads `$&` in the replacement.
A file named rap$&sody.mp3 rendered corrupted in its own delete dialog.
Five strings were still hardcoded in app.js — the group name placeholder
and the four visibility/join-policy descriptions — and are now keyed.
test_locales.py holds the nine translations to the shape of en.js: same
keys, a counted string stays counted everywhere, every plural entry covers
each category Intl actually produces for that language, and the
{placeholders} survive translation. Verified failing first, against a
catalogue with a key removed, a placeholder dropped and the Polish `few`
form deleted.
The language menu also grew from one entry to ten, which overran a short
viewport inside a dropdown that clipped instead of scrolling.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/i18n.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/i18n.js | 509 |
1 files changed, 123 insertions, 386 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js index affa7c8..48408d9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js @@ -1,422 +1,159 @@ /** * MeshBay i18n — lightweight string localization. * + * Catalogues live one per language in `locales/` and are fetched on demand, so + * a visitor downloads the language they read plus English as a fallback, never + * the other eight. + * * Usage: - * import { t, setLocale, getLocale, LOCALES } from './i18n.js'; + * import { t, initLocale, setLocale, getLocale, LOCALES } from './i18n.js'; + * await initLocale(); // once, before the first render * t('nav.logout') // "Logout" * t('status.files', { n: 42 }) // "42 files" + * + * A catalogue value is a string, or an object keyed by Intl.PluralRules + * category when it is rendered with a count: + * 'status.files': { one: '{n} file', other: '{n} files' } + * Polish needs `one`/`few`/`many`/`other` where English needs two, which is why + * the plural category is chosen here rather than baked into the string. */ const LANG_KEY = 'mb_lang'; +const FALLBACK = 'en'; -// ── English strings ───────────────────────────────────────────────────────── - -const en = { - // Nav - 'nav.toggle_menu': 'Toggle menu', - 'nav.light_mode': 'Light mode', - 'nav.dark_mode': 'Dark mode', - 'nav.logout': 'Logout', - 'nav.login': 'Login', - - // Sidebar - 'sidebar.my_groups': 'My Groups', - 'sidebar.no_groups': 'No groups yet', - 'sidebar.discover': 'Discover', - 'sidebar.public_groups': 'Public groups', - - // Login - 'login.title': 'Login', - 'login.username': 'Username', - 'login.password': 'Password', - 'login.submit': 'Login', - 'login.loading': 'Logging in...', - 'login.no_account': 'No account?', - 'login.register_link': 'Register', - - // Register - 'register.title': 'Register', - 'register.username': 'Username', - 'register.email': 'Email', - 'register.password': 'Passphrase (min 12 chars)', - 'register.confirm': 'Confirm password', - 'register.submit': 'Register', - 'register.loading': 'Creating account...', - 'register.has_account': 'Already have an account?', - 'register.login_link': 'Login', - 'register.success_title': 'Account created', - 'register.success_msg': 'You can now log in with your credentials.', - 'register.go_login': 'Go to login', - 'register.err_mismatch': 'Passwords do not match', - 'register.err_min_len': 'Use at least {n} characters', - 'register.err_too_weak': 'Too easy to guess. Your passphrase is what protects ' - + 'your keys where they are stored — a few unrelated words work well.', - 'register.strength': 'Strength: about {bits} bits. This protects the copy of ' - + 'your keys kept on the nodes you join, so it is worth getting right.', - - // Home - 'home.welcome': 'Welcome to MeshBay', - 'home.no_groups': 'You are not a member of any group yet.', - 'home.browse_prefix': 'Browse ', - 'home.browse_link': 'public groups', - 'home.browse_suffix': ' or ask a group admin to invite you.', - 'home.my_groups': 'My Groups', - - // Explore - 'explore.title': 'Public Groups', - 'explore.search': 'Search groups...', - 'explore.loading': 'Loading...', - 'explore.empty': 'No public groups available.', - 'explore.create_group': 'Create group', - 'explore.join': 'Join', - 'explore.member': 'Joined', - - // Group page - 'group.default_name': 'Group', - 'group.tab_files': 'Files', - 'group.tab_chat': 'Chat', - 'group.tab_members': 'Members', - 'group.filter': 'Filter files...', - 'group.col_name': 'Name', - 'group.col_size': 'Size', - 'group.col_type': 'Type', - 'group.col_date': 'Date', - 'group.empty_filter': 'No files match the filter', - 'group.empty_dir': 'This directory is empty', - 'group.download': 'Download', - 'group.play': 'Play', - 'group.offline_title': 'No nodes are currently online for this group.', - 'group.offline_hint': 'Files will appear when a node hosting this group connects.', - 'group.upload': 'Upload', - 'group.mkdir': 'New folder', - 'group.mkdir_prompt': 'Name of the new folder:', - 'group.view': 'View', - 'group.delete': 'Delete', - 'group.delete_confirm': 'Delete {name}?', - 'group.delete_group': 'Delete group', - 'group.delete_group_confirm': 'Permanently delete "{name}" and remove all members? Files on the node are preserved.', - 'group.err_transport': 'Transport module not loaded', - - // Status - 'status.idle': 'Idle', - 'status.discovering': 'Finding nodes...', - 'status.connecting': 'Connecting via WebRTC...', - 'status.fetching': 'Fetching index...', - 'status.files': '{n} files', - 'status.offline': 'No nodes online', - 'status.error': 'Connection failed', - - // Chat - 'chat.empty': 'No messages yet. Start the conversation!', - 'chat.placeholder': 'Type a message...', - 'chat.send': 'Send', - 'chat.attach': 'Attach file', - - // Video player - 'video.loading': 'Loading {name}...', - 'video.buffering': 'Buffering...', - 'video.close': 'Close (Esc)', - 'preview.pdf_fallback': 'This browser will not display the PDF inline. Download it instead — it was decrypted here either way.', - 'group.upload_indexing': 'indexing…', - 'video.err_transport': 'Transport not connected', - 'video.err_mse': 'Codec not supported for streaming: {codec}', - - // Settings - 'settings.title': 'Settings', - 'settings.coming_soon': 'Coming soon.', - 'settings.downloads': 'Downloads', - 'settings.dl_auto': 'Save automatically', - 'settings.dl_auto_hint': 'Files go straight into the folder you choose below, ' - + 'with no dialog. Downloading twenty files puts twenty files there.', - 'settings.dl_ask': 'Ask every time', - 'settings.dl_ask_hint': 'A Save As dialog for each file — one per file, ' - + 'including when you download a selection.', - 'settings.dl_folder': 'Folder: {name}', - 'settings.dl_no_folder': 'No folder chosen — downloads go wherever your browser ' - + 'puts them', - 'settings.dl_choose': 'Choose folder', - 'settings.dl_change': 'Change', - 'settings.dl_forget': 'Forget', - 'settings.dl_path_note': 'A web page cannot be given a path, so there is nothing ' - + 'to type here: your browser grants access to the folder you pick, and MeshBay ' - + 'only ever writes inside it. You may be asked to confirm once per session.', - 'settings.dl_unsupported': 'This browser cannot write into a folder of your ' - + 'choosing (no File System Access API), so downloads go to its own download ' - + 'folder. Chrome and Edge support choosing one.', - 'settings.profile': 'Profile', - 'settings.username': 'Username', - 'settings.node_pins': 'Node identities', - 'settings.node_pins_hint': "Each node's identity key is remembered the first time you connect. If it changes, the connection is refused — that is expected only when an operator reinstalls a node. Verify with them before clearing.", - 'settings.node_pins_count': '{n} pinned', - 'settings.node_pins_clear': 'Clear pinned identities', - 'settings.danger': 'Delete account', - 'settings.delete_hint': 'Removes your account, your group memberships and your ' - + 'notifications from the hub, and frees your username. It cannot reach what ' - + 'lives on nodes: files you uploaded stay where their operator keeps them, ' - + 'and each node keeps the identity it pinned for you until its operator ' - + 'unpins it. Connection logs are kept for a year, as the law requires.', - 'settings.delete_account': 'Delete my account', - 'settings.delete_confirm': 'Enter your passphrase to confirm. This cannot be undone.', - 'settings.delete_confirm_btn': 'Delete for good', - 'settings.cancel': 'Cancel', - 'settings.appearance': 'Appearance', - 'settings.theme': 'Theme', - 'settings.theme_light': 'Light', - 'settings.theme_dark': 'Dark', - 'settings.theme_system': 'System', - 'settings.language': 'Language', - 'settings.about': 'About', - 'settings.version': 'Version', - 'settings.protocol': 'Protocol', - 'settings.role': 'Role', - 'settings.groups': 'Group notifications', - 'settings.notifications': 'Notifications', - 'settings.node_key': 'Link Node', - 'settings.node_key_desc': 'If you operate a MeshBay node, paste its Ed25519 public key here to link it to your account. You can find this key in your node\'s admin dashboard.', - 'settings.node_key_placeholder': 'Paste node Ed25519 public key (base64)', - 'settings.node_key_submit': 'Link Node', - 'settings.node_key_success': 'Node linked successfully', - 'settings.node_key_current': 'Linked node key', - - // Sidebar - 'sidebar.create_group': 'Create group', - 'sidebar.settings': 'Settings', - 'sidebar.admin': 'Admin', - - // User menu - 'usermenu.language': 'Language', - 'usermenu.settings': 'Settings', - 'usermenu.theme_light': 'Light mode', - 'usermenu.theme_dark': 'Dark mode', - 'usermenu.logout': 'Logout', - - // Admin panel - 'admin.title': 'Administration', - 'admin.tab_stats': 'Stats', - 'admin.tab_users': 'Users', - 'admin.tab_groups': 'Groups', - 'admin.tab_nodes': 'Nodes', - 'admin.tab_logs': 'Logs', - 'admin.col_observed_ip': 'Seen from', - 'admin.col_hint': 'Announced hint', - 'admin.col_last_seen': 'Last announce', - 'admin.node_online': 'online', - 'admin.node_offline': 'offline', - 'admin.no_nodes': 'No nodes registered', - 'admin.nodes_hint': '"Seen from" is the address the announcement arrived from, ' - + 'on a request signed with the node key — that is the one to answer a ' - + 'question with. The hint is what the node believes its own address to be, ' - + 'learned from a STUN server and sent to us; it is useful for reaching the ' - + 'node and is not evidence of anything.', - 'admin.tab_blocklist': 'Blocklist', - - // Admin stats - 'admin.stat_users': 'Users', - 'admin.stat_groups': 'Groups', - 'admin.stat_nodes': 'Nodes', - 'admin.stat_online': 'Online Nodes', - - // Admin users - 'admin.users_search': 'Search users...', - 'admin.col_username': 'Username', - 'admin.col_role': 'Role', - 'admin.col_status': 'Status', - 'admin.col_created': 'Created', - 'admin.col_actions': 'Actions', - 'admin.col_email': 'Email', - 'admin.col_groups': 'Groups', - 'admin.no_users': 'No users found', - 'admin.btn_suspend': 'Suspend', - 'admin.btn_unsuspend': 'Unsuspend', - 'admin.btn_details': 'Details', - 'admin.btn_delete': 'Delete', - 'admin.delete_confirm': 'Delete the account "{user}"? This cannot be undone. ' - + 'Files they uploaded stay on the nodes that host them, and each node keeps ' - + 'the identity it pinned until its operator unpins it. Suspending is the ' - + 'reversible option.', - 'admin.user_detail': 'User details', - 'admin.btn_close': 'Close', - 'admin.self_note': '(you)', - - // Admin groups - 'admin.col_name': 'Name', - 'admin.col_visibility': 'Visibility', - 'admin.col_members': 'Members', - 'admin.no_groups': 'No groups found', - - // Admin logs - 'admin.col_time': 'Time', - 'admin.col_event': 'Event', - 'admin.col_user': 'User', - 'admin.col_ip': 'IP', - 'admin.col_detail': 'Detail', - 'admin.filter_all': 'All events', - 'admin.no_logs': 'No logs found', - 'admin.btn_load_more': 'Load more', - - // Admin blocklist - 'admin.col_hash': 'Hash', - 'admin.col_reason': 'Reason', - 'admin.col_date': 'Date', - 'admin.col_added_by': 'Added by', - 'admin.no_blocked': 'No blocked hashes', - 'admin.add_hash': 'Add hash to blocklist', - 'admin.hash_placeholder': 'blake3 hash (64 hex chars)', - 'admin.reason_placeholder': 'Reason', - 'admin.btn_block': 'Block', - 'admin.btn_unblock': 'Unblock', +export const LOCALES = [ + { code: 'en', name: 'English', flag: '\u{1F1EC}\u{1F1E7}' }, + { code: 'fr', name: 'Français', flag: '\u{1F1EB}\u{1F1F7}' }, + { code: 'es', name: 'Español', flag: '\u{1F1EA}\u{1F1F8}' }, + { code: 'pt-BR', name: 'Português (Brasil)', flag: '\u{1F1E7}\u{1F1F7}' }, + { code: 'zh-CN', name: '简体中文', flag: '\u{1F1E8}\u{1F1F3}' }, + { code: 'ja', name: '日本語', flag: '\u{1F1EF}\u{1F1F5}' }, + { code: 'de', name: 'Deutsch', flag: '\u{1F1E9}\u{1F1EA}' }, + { code: 'it', name: 'Italiano', flag: '\u{1F1EE}\u{1F1F9}' }, + { code: 'nl', name: 'Nederlands', flag: '\u{1F1F3}\u{1F1F1}' }, + { code: 'pl', name: 'Polski', flag: '\u{1F1F5}\u{1F1F1}' }, +]; - // Notifications - // Create group - 'create_group.title': 'Create Group', - 'create_group.name': 'Group name', - 'create_group.visibility': 'Visibility', - 'create_group.private': 'Private', - 'create_group.public': 'Public', - 'create_group.join_policy': 'Join policy', - 'create_group.invite': 'Invite only', - 'create_group.open': 'Open (anyone can join)', - 'create_group.description': 'Description', - 'create_group.description_hint': 'What is this group about? (optional)', - 'create_group.submit': 'Create', - 'create_group.hint': 'A group needs a node to host files. You can create the group now and connect a node later.', - 'create_group.creating': 'Creating...', +const _codes = LOCALES.map(l => l.code); +const _strings = {}; +const _plurals = {}; +let _locale = FALLBACK; - // Members - 'members.col_role': 'Role', - 'members.group_role': 'Group role', - 'members.admin': 'Admin', - 'members.group_admin': 'Group admin', - 'members.owner': 'Owner', - 'members.member': 'Member', - 'members.invite_title': 'Invite member', - 'members.username_placeholder': 'Username', - 'members.invite_btn': 'Invite', - 'members.invite_needs_pairing': 'Invitations are signed with the key this node ' - + 'pinned for your browser, so pair it below before inviting anyone.', - 'members.invite_ask_operator': 'Only the operator of the node hosting this group can ' - + 'invite, from a browser paired with it.', - 'members.remove': 'Remove', - 'members.remove_confirm': 'Remove {user} from this group? Their account, their ' - + 'other groups and the files they uploaded are not affected.', - 'members.remove_hint': 'Removing someone takes away their membership and stops ' - + 'the node serving them the group key. It does not delete their account, and ' - + 'it does not take back the key they already hold — rotate it on the node ' - + '(meshbay-node gek-init) if that matters.', - 'members.pair_title': 'Pair this browser with your node', - 'members.pair_hint': 'Your node only accepts operator actions — invites, file ' - + 'deletion — from a browser it has been paired with. Run ' - + '`meshbay-node operator pair` on the node and type the code here. The code ' - + 'never passes through the hub.', - 'members.pair_btn': 'Pair', - 'members.pair_success': 'This browser is now paired with the node.', - 'members.invite_code_ready': 'Invitation code for {user} — send it to them the way ' - + 'you normally talk. It works once, and it never passes through the hub.', - 'members.invite_code_hint': 'They enter it the first time they open this group. ' - + 'You do not need to be online then.', - 'transfers.title': 'Transfers', - 'transfers.clear': 'Clear finished', - 'transfers.cancel': 'Cancel', - 'transfers.open': 'Open', - 'transfers.done': 'Finished', - 'transfers.cancelled': 'Cancelled', - 'transfers.failed': 'Failed', - 'group.select': 'Select', - 'group.select_done': 'Done', - 'group.actions': 'Actions ({n})', - 'group.download_n': 'Download ({n})', - 'group.download_zip_n': 'Download folders as zip ({n})', - 'group.delete_n': 'Delete ({n})', - 'group.delete_n_confirm': 'Delete {n} item(s)? {names}', - 'group.download_zip': 'Download as zip ({n} files)', - 'group.zip_empty': 'That folder has nothing in it to download.', - 'group.zip_no_stream': 'This browser cannot write a download straight to disk, ' - + 'so {name} ({size}) has to be built in memory first. On a large archive ' - + 'that may fail. Continue?', - 'group.rmdir': 'Delete folder', - 'group.rmdir_confirm': 'Delete the folder "{name}"? It must be empty.', - 'group.rmdir_not_empty': 'That folder is not empty. Delete what is in it first — ' - + 'nothing here removes files you cannot see.', - 'group.desc_edit': 'Edit description', - 'group.desc_add': 'Add a description', - 'group.desc_save': 'Save', - 'group.desc_cancel': 'Cancel', - 'group.desc_placeholder': 'What this group is for — members see it on their home page.', - 'group.join_code_title': 'This node needs to recognise you', - 'group.join_code_hint': 'Ask whoever invited you for the one-time code, and enter ' - + 'it here. After that this browser is recognised and you will not be asked again.', - 'group.join_code_btn': 'Join', +// ── Resolution ────────────────────────────────────────────────────────────── - 'notif.purge': 'Clear all', - 'notif.title': 'Notifications', - 'notif.empty': 'No notifications', - 'notif.mark_all_read': 'Mark all read', +/** + * Match a requested tag against the registry, region-tolerantly. + * + * `navigator.language` is `pt-BR` or `zh-CN` on the machines those catalogues + * are for, and `fr-CA` or `de-AT` on machines the base catalogue serves fine. + * Trimming to the base first — which this used to do — sent a Brazilian browser + * looking for a `pt` catalogue that does not exist. + */ +function _match(tag) { + if (!tag) return null; + const want = String(tag).replace('_', '-'); + const exact = _codes.find(c => c.toLowerCase() === want.toLowerCase()); + if (exact) return exact; + const base = want.split('-')[0].toLowerCase(); + return _codes.find(c => c.split('-')[0].toLowerCase() === base) || null; +} - // Sidebar - 'sidebar.search': 'Search files', +/** + * Storage access throws outright in some privacy modes rather than returning + * null. A language preference is not worth failing the boot over, so both + * directions swallow it and the browser's own languages decide instead. + */ +function _stored(value) { + try { + if (value === undefined) return localStorage.getItem(LANG_KEY); + localStorage.setItem(LANG_KEY, value); + } catch { + return null; + } + return value; +} - // Status +function _preferred() { + const stored = _match(_stored()); + if (stored) return stored; + for (const tag of navigator.languages || [navigator.language]) { + const hit = _match(tag); + if (hit) return hit; + } + return FALLBACK; +} - // Search - 'search.title': 'Search Files', - 'search.placeholder': 'Search across all groups...', - 'search.no_results': 'No files match your search.', - 'search.synced': 'synced {ago}', - 'search.cache_note': 'Search reads what each group looked like the last time you ' - + 'opened it. Open a group to bring its files up to date here.', - 'search.col_group': 'Group', - 'search.result_count': '{n} files found', - 'search.hint': 'Search file names across all cached group indexes.', -}; +// ── Loading ───────────────────────────────────────────────────────────────── -// ── Locale registry ───────────────────────────────────────────────────────── +async function _load(code) { + if (_strings[code]) return true; + try { + const mod = await import(`./locales/${code}.js`); + _strings[code] = mod.default; + return true; + } catch (err) { + console.warn(`[MeshBay] locale ${code} failed to load:`, err); + return false; + } +} -const _strings = { en }; -let _locale = 'en'; +/** + * Load the active catalogue and the English fallback, then fix the locale. + * Resolves even when a catalogue is missing — an untranslated interface beats + * a blank page, so a failed load degrades to English rather than throwing. + */ +export async function initLocale() { + const want = _preferred(); + const loads = [_load(want)]; + if (want !== FALLBACK) loads.push(_load(FALLBACK)); + const [ok] = await Promise.all(loads); + _locale = ok ? want : FALLBACK; + if (!_strings[FALLBACK]) await _load(FALLBACK); + document.documentElement.lang = _locale; + return _locale; +} -export const LOCALES = [ - { code: 'en', name: 'English', flag: '\u{1F1EC}\u{1F1E7}' }, -]; +// ── Lookup ────────────────────────────────────────────────────────────────── export function getLocale() { return _locale; } export function setLocale(code) { - if (_strings[code]) { - _locale = code; - localStorage.setItem(LANG_KEY, code); - return true; - } - return false; + const hit = _match(code); + if (!hit) return false; + _stored(hit); + return true; } export function addLocale(code, strings) { _strings[code] = strings; } +function _pluralRules(locale) { + if (!_plurals[locale]) _plurals[locale] = new Intl.PluralRules(locale); + return _plurals[locale]; +} + +/** A catalogue entry is a plain string, or plural categories to choose from. */ +function _select(entry, locale, params) { + if (typeof entry === 'string') return entry; + if (!entry || typeof entry !== 'object') return null; + const n = Number(params && params.n); + const cat = _pluralRules(locale).select(Number.isFinite(n) ? n : 0); + return entry[cat] ?? entry.other ?? null; +} + export function t(key, params) { - let s = (_strings[_locale] && _strings[_locale][key]) - || _strings.en[key] - || key; + let s = _select(_strings[_locale] && _strings[_locale][key], _locale, params); + if (s == null) { + s = _select(_strings[FALLBACK] && _strings[FALLBACK][key], FALLBACK, params); + } + if (s == null) return key; if (params) { for (const [k, v] of Object.entries(params)) { - s = s.replace(`{${k}}`, v); + // split/join rather than replace: a value is often a filename, and + // String.replace reads `$&` and friends in the replacement. + s = s.split(`{${k}}`).join(v); } } return s; } - -// ── Init ──────────────────────────────────────────────────────────────────── - -function _init() { - const stored = localStorage.getItem(LANG_KEY); - if (stored && _strings[stored]) { - _locale = stored; - return; - } - const browserLang = (navigator.language || '').split('-')[0]; - if (_strings[browserLang]) { - _locale = browserLang; - } -} - -_init(); |