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
21 changes: 21 additions & 0 deletions docs/BROWSER_LANGUAGES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Browser languages

The shared browser interface supports English, Simplified Chinese, Hindi, Spanish,
Arabic, French, Bengali and Portuguese. Display settings lists the native names.
On first visit, the interface uses the first supported browser language; a saved
choice on this device takes precedence. Unsupported saved choices fall back to
English. Regional language tags such as `es-MX` and `ar-EG` select the base locale.

Navigation, settings, search, wallet and certificate labels and primary post
controls use the shared catalog. Some auxiliary operation/status messages still
fall back to English. User posts, channel descriptions, command output, API names
and protocol documents are not automatically translated. The CLI/TUI locale
support is separate from the browser catalog.

Arabic switches the interface to right-to-left; switching back restores
left-to-right. Code and the MSG brand retain left-to-right reading order. Locale
selection does not change authorization or translate identifiers sent to APIs.

Add a locale to `LANGUAGES` and a complete column in `browser_i18n.py`; catalogs
are embedded in the existing CSP-hashed script without another network request.
Run the browser language, rendering and OAuth integrity tests after changes.
184 changes: 184 additions & 0 deletions src/msg/transports/browser_i18n.py

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/msg/transports/browser_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
const status = document.getElementById('status');
if (!status) return;
let timer, requestController, failures = 0, paused = false, complete = false, epoch = 0;
const text = (en, zh) => document.documentElement.lang.startsWith('zh') ? zh : en;
const text = (en, zh) => globalThis.msgText?.(en, zh) ?? (document.documentElement.lang.startsWith('zh') ? zh : en);
const show = (en, zh) => {
status.removeAttribute('data-i18n');
status.textContent = text(en, zh);
Expand Down
22 changes: 18 additions & 4 deletions src/msg/transports/browser_style.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"""One restrained, dependency-free visual language for browser-only views."""

from html import escape
from importlib.resources import files

from msg.transports.browser_i18n import LANGUAGES
from msg.transports.browser_palette import ACCENTS

_MARK = files('msg.data').joinpath('logo.svg').read_text()
Expand All @@ -20,7 +22,12 @@
PREFERENCES = (
'<details class="preferences"><summary title="Display settings / 显示设置"><svg class="settings-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="3"/><circle cx="15" cy="17" r="3"/></svg><span data-i18n="display">Display settings</span><svg class="settings-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></summary>'
'<div class="preference-fields"><div class="preference-row"><label for="msg-language" data-i18n="language">Language</label>'
'<select id="msg-language"><option value="en">English</option><option value="zh">简体中文</option></select></div>'
'<select id="msg-language">'
+ ''.join(
f'<option value="{code}" lang="{tag}">{escape(label)}</option>'
for code, label, tag, _ in LANGUAGES
)
+ '</select></div>'
'<div class="preference-row"><span id="accent-label" data-i18n="accent">Accent</span>'
'<div id="msg-accent" class="accent-options" role="radiogroup" aria-labelledby="accent-label">'
+ ''.join(
Expand Down Expand Up @@ -158,12 +165,12 @@
.notice::before { content: "[!] "; font-family: var(--mono); }
.scroll-hint { display: none; color: var(--muted); font: 11px/1.6 var(--mono); }
.table-scroll { max-width: 100%; overflow-x: auto; overscroll-behavior-inline: contain; }
table { width: 100%; border-collapse: collapse; text-align: left; font-size: 14px; }
table { width: 100%; border-collapse: collapse; text-align: start; font-size: 14px; }
.table-scroll table { min-width: 600px; }
th, td { padding: 14px 16px; vertical-align: top; }
th { color: var(--muted); font-size: 12px; font-weight: 500; border-bottom: 1px solid var(--line); }
th:first-child, td:first-child { padding-left: 0; }
th:last-child, td:last-child { padding-right: 0; }
th:first-child, td:first-child { padding-inline-start: 0; }
th:last-child, td:last-child { padding-inline-end: 0; }
td a { text-decoration: none; }
tbody tr:hover { background: var(--panel); }
.number { text-align: right; font-variant-numeric: tabular-nums; }
Expand Down Expand Up @@ -372,3 +379,10 @@
.page-document .account-menu summary { max-width: 70px; }
}
"""

# Technical identifiers retain their reading order in right-to-left interfaces.
THEME_CSS += """
code, pre, .brand { direction: ltr; unicode-bidi: isolate; }
[dir=rtl] .settings-note { text-align: start; }
[dir=rtl] .document-toolbar .preferences { margin-right: 0; margin-inline-end: auto; }
"""
2 changes: 1 addition & 1 deletion src/msg/transports/post_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
POST_ACTIONS_SCRIPT = r"""(() => {
const panel = document.getElementById('post-actions');
if (!panel) return;
const text = (en, zh) => document.documentElement.lang.startsWith('zh') ? zh : en;
const text = (en, zh) => globalThis.msgText?.(en, zh) ?? (document.documentElement.lang.startsWith('zh') ? zh : en);
const status = panel.querySelector('[role=status]');
const buttons = [...panel.querySelectorAll('[data-action]')];
let ready = false, writing = false, countsAvailable = false, proofsKnown = false, proofWritten = false;
Expand Down
45 changes: 34 additions & 11 deletions src/msg/transports/webmcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
from base64 import b64encode
from hashlib import sha256

from msg.transports.browser_i18n import ACTION_LABELS, LANGUAGES, TRANSLATIONS
from msg.transports.browser_palette import ACCENTS

WEBMCP_SCRIPT = r"""(() => {
WEBMCP_SCRIPT = (
r"""(() => {
const brand = document.querySelector('a.brand');
let logoRun = {count: 0, first: 0};
brand?.addEventListener('click', event => {
Expand Down Expand Up @@ -83,7 +85,13 @@
search_local: ['Searches this MSG service and only content you can read.','仅搜索当前 MSG 服务中你有权限读取的内容。'],
search_copy_engine: ['Copy search engine URL','复制搜索引擎地址']
});
Object.assign(translations, __ACCENT_LABELS__);
Object.assign(translations, __ACCENT_LABELS__, __ACTION_LABELS__);
const locales = __LOCALES__;
const localized = __LOCALIZED__;
let language = 'en';
const translate = key => localized[language]?.[key] ?? translations[key]?.[language === 'zh' ? 1 : 0];
const sourceKeys = Object.fromEntries(Object.entries(translations).map(([key, pair]) => [pair[0], key]));
globalThis.msgText = (en, zh) => language === 'zh' ? zh : language === 'en' ? en : translate(sourceKeys[en]) ?? en;
const palettes = __ACCENT_PALETTES__;
const load = (key, fallback) => {try {return localStorage.getItem(key) || fallback;} catch {return fallback;}};
const save = (key, value) => {try {localStorage.setItem(key, value);} catch {}};
Expand Down Expand Up @@ -128,14 +136,19 @@
document.addEventListener('scroll', repositionMenus, true);
window.visualViewport?.addEventListener('resize', repositionMenus);
window.visualViewport?.addEventListener('scroll', repositionMenus);
const normalizeLanguage = value => String(value || '').toLowerCase().split(/[-_]/)[0];
const browserLanguage = () => (navigator.languages || [navigator.language || 'en'])
.map(normalizeLanguage).find(value => Object.hasOwn(locales, value)) || 'en';
const applyLanguage = value => {
const language = value === 'zh' ? 'zh' : 'en';
document.documentElement.lang = language === 'zh' ? 'zh-CN' : 'en';
const requested = normalizeLanguage(value);
language = Object.hasOwn(locales, requested) ? requested : 'en';
document.documentElement.lang = locales[language].tag;
document.documentElement.dir = locales[language].direction;
document.querySelectorAll('[data-i18n]').forEach(node => {
const text = translations[node.dataset.i18n]; if (text) node.textContent = text[language === 'zh' ? 1 : 0];
const value = translate(node.dataset.i18n); if (value !== undefined) node.textContent = value;
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(node => {
const text = translations[node.dataset.i18nPlaceholder]; if (text) node.placeholder = text[language === 'zh' ? 1 : 0];
const value = translate(node.dataset.i18nPlaceholder); if (value !== undefined) node.placeholder = value;
});
const selector = document.getElementById('msg-language'); if (selector) selector.value = language;
save('msg.language', language);
Expand Down Expand Up @@ -168,7 +181,7 @@
document.querySelectorAll('input[name=msg-theme]').forEach(input => { input.checked = input.value === theme; });
save('msg.theme', theme);
};
applyLanguage(load('msg.language', 'en')); applyAccent(load('msg.accent', 'blue')); applyTheme(load('msg.theme','system'));
applyLanguage(load('msg.language', browserLanguage())); applyAccent(load('msg.accent', 'blue')); applyTheme(load('msg.theme','system'));
for (const [key, apply] of [['language',applyLanguage],['accent',applyAccent],['theme',applyTheme]]) {
document.getElementById('msg-' + key)?.addEventListener('change', event => apply(event.target.value));
}
Expand Down Expand Up @@ -335,10 +348,20 @@
Promise.all(tools.map(tool => context.registerTool(tool, {signal: controller.signal})))
.catch(() => { controller.abort(); });
} catch { controller.abort(); }
})();""".replace(
'__ACCENT_LABELS__', json.dumps({key: [en, zh] for key, en, zh, _, _ in ACCENTS})
).replace(
'__ACCENT_PALETTES__', json.dumps({key: [light, dark] for key, _, _, light, dark in ACCENTS})
})();"""
.replace('__ACTION_LABELS__', json.dumps(ACTION_LABELS, ensure_ascii=False))
.replace(
'__LOCALES__',
json.dumps({
code: {'tag': tag, 'direction': direction} for code, _, tag, direction in LANGUAGES
}),
)
.replace('__LOCALIZED__', json.dumps(TRANSLATIONS, ensure_ascii=False))
.replace('__ACCENT_LABELS__', json.dumps({key: [en, zh] for key, en, zh, _, _ in ACCENTS}))
.replace(
'__ACCENT_PALETTES__',
json.dumps({key: [light, dark] for key, _, _, light, dark in ACCENTS}),
)
)

WEBMCP_HASH = b64encode(sha256(WEBMCP_SCRIPT.encode()).digest()).decode()
Expand Down
2 changes: 1 addition & 1 deletion src/msg/transports/wiki_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
const panel = document.getElementById('wiki-actions');
if (!panel) return;
const form = panel.querySelector('form'), status = panel.querySelector('[role=status]');
const text = (en, zh) => document.documentElement.lang.startsWith('zh') ? zh : en;
const text = (en, zh) => globalThis.msgText?.(en, zh) ?? (document.documentElement.lang.startsWith('zh') ? zh : en);
panel.querySelector('[data-open]').addEventListener('click', () => {
if (panel.dataset.signedIn !== 'true') { location.assign('/login'); return; }
form.hidden = !form.hidden;
Expand Down
85 changes: 85 additions & 0 deletions tests/test_browser_languages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""Language choice survives navigation and preserves unknown content and RTL code."""

import json
import re
import subprocess

from msg.transports.browser_i18n import ACTION_LABELS, LANGUAGES, TRANSLATIONS
from msg.transports.browser_style import PREFERENCES
from msg.transports.webmcp import WEBMCP_SCRIPT


def test_locale_catalog_and_native_picker_labels():
assert {code for code, *_ in LANGUAGES} == {'en', 'zh', 'hi', 'es', 'ar', 'fr', 'bn', 'pt'}
for code, label, tag, _ in LANGUAGES:
assert f'<option value="{code}" lang="{tag}">{label}</option>' in PREFERENCES
base_keys = set(re.findall(r'\b(\w+):\s*\[', WEBMCP_SCRIPT.split('const locales')[0]))
assert base_keys | set(ACTION_LABELS) <= set(TRANSLATIONS['es'])
assert len({frozenset(messages) for messages in TRANSLATIONS.values()}) == 1


def test_browser_language_switching_fallback_and_persistence():
program = r"""
const vm = require('node:vm'), assert = require('node:assert/strict');
const script = require('node:fs').readFileSync(0, 'utf8');
function page(saved, browserLanguages) {
const storage = new Map(saved ? [['msg.language', saved]] : []);
const home = {dataset: {i18n: 'home'}, textContent: 'Home'};
const unknown = {dataset: {i18n: 'not_a_catalog_key'}, textContent: 'User content'};
const search = {dataset: {i18nPlaceholder: 'search_query'}};
const selector = {addEventListener(name, callback) {this.change = callback;}};
const root = {clientWidth: 1080, dataset: {}, style: {setProperty() {}}};
const document = {
documentElement: root,
querySelector() {return null;},
querySelectorAll(selector) {
if (selector === '[data-i18n]') return [home, unknown];
if (selector === '[data-i18n-placeholder]') return [search];
return [];
},
getElementById(id) {return id === 'msg-language' ? selector : null;},
addEventListener() {},
};
const context = {document, navigator: {languages: browserLanguages},
window: {addEventListener() {}}, addEventListener() {},
requestAnimationFrame() {return 1;},
localStorage: {getItem: key => storage.get(key), setItem: (key, value) => storage.set(key, value)}};
vm.runInNewContext(script, context);
return {root, home, unknown, search, selector, storage, context};
}
const initial = page(undefined, ['xx', 'es-MX']);
assert.equal(initial.home.textContent, 'Inicio');
assert.equal(initial.search.placeholder, 'Buscar en MSG');
assert.equal(initial.unknown.textContent, 'User content');
const expected = {en: 'Home', zh: '首页', hi: 'मुखपृष्ठ', es: 'Inicio', ar: 'الرئيسية', fr: 'Accueil', bn: 'মূল পাতা', pt: 'Início'};
for (const [code, label] of Object.entries(expected)) {
initial.selector.change({target: {value: code}});
assert.equal(initial.home.textContent, label);
assert.equal(initial.root.dir, code === 'ar' ? 'rtl' : 'ltr');
assert.equal(initial.root.lang, code === 'zh' ? 'zh-CN' : code);
assert.equal(initial.storage.get('msg.language'), code);
const next = page(code, ['en']);
assert.equal(next.home.textContent, label);
assert.equal(next.selector.value, code);
}
initial.selector.change({target: {value: 'ar-EG'}});
assert.equal(initial.context.msgText('Comment', '评论'), 'تعليق');
initial.selector.change({target: {value: 'zh'}});
assert.equal(initial.context.msgText('Following', '已关注'), '已关注');
initial.selector.change({target: {value: 'unsupported'}});
assert.equal(initial.root.lang, 'en');
assert.equal(initial.root.dir, 'ltr');
assert.equal(initial.home.textContent, 'Home');
assert.equal(page('bn', ['en']).home.textContent, 'মূল পাতা');
process.stdout.write(JSON.stringify({ok: true}));
"""
result = subprocess.run(
['node', '-e', program],
input=WEBMCP_SCRIPT,
capture_output=True,
text=True,
timeout=10,
check=False,
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {'ok': True}
Loading