menuMessages.en.json stays the English source of the menu's words; en.json now carries them too (the i18n test takes them into the source union) and 48 catalogs translate all 51 menu words and the seven new mode words (plural forms per language for the match count). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
232 lines
14 KiB
TypeScript
232 lines
14 KiB
TypeScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import {
|
|
argumentShapes, createI18n, direction, fallbackChain, formatMessage, matchLocale, normalizeLocale,
|
|
parseMessage, placeholders, pluralCategories, relativeParts, type Catalog,
|
|
} from '../src/i18n.ts';
|
|
import { checkCatalog, checkMessageSafety, checkShape, checkSource, coverage, generateKeyTypes, type CatalogIssue, type Meta } from '../src/i18nCheck.ts';
|
|
import { createPluginI18n, pluginNamespace } from '../src/pluginI18n.ts';
|
|
import { KIT_MESSAGES } from '../src/ui.ts';
|
|
import { SHELL_MESSAGES, shellText } from '../../shell/src/messages.ts';
|
|
import { STORE_MESSAGES } from '../../shell/src/storeMessages.ts';
|
|
import { FRAME_MESSAGES } from '../../shell/src/frame/frameMessages.ts';
|
|
import { PANEL_MESSAGES } from '../../shell/src/panel/panelMessages.ts';
|
|
import { INSTALLER_MESSAGES } from '../../shell/src/installerMessages.ts';
|
|
import MENU_MESSAGES from '../../shell/src/frame/menuMessages.en.json' with { type: 'json' };
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
|
|
test('locale tags are normalized, including X and legacy codes', () => {
|
|
assert.equal(normalizeLocale('en_gb'), 'en-GB');
|
|
assert.equal(normalizeLocale('zh-cn'), 'zh-Hans');
|
|
assert.equal(normalizeLocale('zh-TW'), 'zh-Hant');
|
|
assert.equal(normalizeLocale('zh-Hant-HK'), 'zh-Hant');
|
|
assert.equal(normalizeLocale('zh'), 'zh-Hans');
|
|
assert.equal(normalizeLocale('msa'), 'ms');
|
|
assert.equal(normalizeLocale('no'), 'nb');
|
|
assert.equal(normalizeLocale('iw'), 'he');
|
|
assert.equal(normalizeLocale('ar-x-fm'), 'ar-x-fm');
|
|
assert.equal(normalizeLocale('pt-br'), 'pt-BR');
|
|
for (const bad of ['', 'x', 'english!', '<script>', 'a'.repeat(40), null, 42]) assert.equal(normalizeLocale(bad), null, String(bad));
|
|
});
|
|
|
|
test('fallback chains end in the source and never cross scripts', () => {
|
|
assert.deepEqual(fallbackChain('pt-BR'), ['pt-BR', 'pt', 'en']);
|
|
assert.deepEqual(fallbackChain('zh-Hant'), ['zh-Hant', 'en']);
|
|
assert.deepEqual(fallbackChain('ar-x-fm'), ['ar-x-fm', 'ar', 'en']);
|
|
assert.deepEqual(fallbackChain('en-GB', 'en', { 'en-GB': 'en' }), ['en-GB', 'en']);
|
|
assert.equal(matchLocale(['pt-PT', 'en'], ['en', 'pt', 'tr']), 'pt');
|
|
assert.equal(matchLocale(['xx', 'tr-TR'], ['en', 'tr']), 'tr');
|
|
assert.equal(matchLocale(['ja'], ['en', 'tr']), 'en');
|
|
});
|
|
|
|
test('direction follows the script', () => {
|
|
for (const rtl of ['ar', 'ar-x-fm', 'fa', 'he', 'ur']) assert.equal(direction(rtl), 'rtl', rtl);
|
|
for (const ltr of ['en', 'tr', 'ja', 'zh-Hant', 'ha']) assert.equal(direction(ltr), 'ltr', ltr);
|
|
});
|
|
|
|
test('ICU-lite: placeholders, plurals with exact forms, select, quoting', () => {
|
|
assert.equal(formatMessage('Hello, {name}!', { name: '<b>x</b>' }), 'Hello, <b>x</b>!', 'values are text');
|
|
const drafts = '{count, plural, =0 {No drafts} one {# draft} other {# drafts}}';
|
|
assert.equal(formatMessage(drafts, { count: 0 }), 'No drafts');
|
|
assert.equal(formatMessage(drafts, { count: 1 }), '1 draft');
|
|
assert.equal(formatMessage(drafts, { count: 1234 }), '1,234 drafts');
|
|
assert.equal(formatMessage(drafts, { count: 1234 }, 'de'), '1.234 drafts');
|
|
// Arabic has six forms; "few" for 3.
|
|
assert.equal(formatMessage('{n, plural, zero {z} one {o} two {t} few {f} many {m} other {x}}', { n: 3 }, 'ar'), 'f');
|
|
assert.equal(formatMessage('{kind, select, post {Post} reply {Reply} other {Item}}', { kind: 'reply' }), 'Reply');
|
|
assert.equal(formatMessage('{kind, select, post {Post} other {Item}}', { kind: 'nope' }), 'Item');
|
|
assert.equal(formatMessage("Use '{braces}' and it''s fine"), "Use {braces} and it's fine");
|
|
assert.equal(formatMessage('{n, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}', { n: 22 }), '22nd');
|
|
assert.equal(formatMessage('{n, number, percent}', { n: 0.5 }), '50%');
|
|
assert.equal(formatMessage('Missing {x}'), 'Missing {x}');
|
|
assert.equal(formatMessage('Broken {x'), 'Broken {x', 'malformed messages render raw');
|
|
assert.throws(() => parseMessage('{n, plural, one {x}}'), /other/);
|
|
assert.throws(() => parseMessage('{a-b}'), /placeholder/);
|
|
assert.deepEqual(placeholders('{a} {n, plural, one {# {b}} other {{c}}}'), ['a', 'b', 'c', 'n']);
|
|
assert.deepEqual(argumentShapes(drafts), { count: { kind: 'plural', options: ['=0', 'one', 'other'] } });
|
|
assert.deepEqual(pluralCategories('tr').sort(), ['one', 'other']);
|
|
assert.deepEqual(pluralCategories('ja'), ['other']);
|
|
});
|
|
|
|
test('createI18n: lazy catalogs, fallback, missing reports, resolved subsets', async () => {
|
|
const missing: string[] = [];
|
|
const loads: string[] = [];
|
|
const i18n = createI18n({
|
|
source: { 'a.hello': 'Hello, {name}', 'a.only': 'English only', 'x.nav': 'Nav' },
|
|
available: ['en', 'pt', 'pt-BR', 'tr'],
|
|
load: tag => {
|
|
loads.push(tag);
|
|
return ({ pt: { 'a.hello': 'Olá, {name}' }, 'pt-BR': { 'x.nav': 'Navegação' }, tr: { 'a.hello': 'Merhaba {name}' } } as Record<string, Record<string, string>>)[tag];
|
|
},
|
|
onMissing: (key, locale, reason) => missing.push(`${reason}:${locale}:${key}`),
|
|
});
|
|
const seen: string[] = [];
|
|
i18n.onChange(locale => seen.push(locale));
|
|
assert.equal(await i18n.setLocale('pt-BR'), 'pt-BR');
|
|
assert.deepEqual(i18n.chain, ['pt-BR', 'pt', 'en']);
|
|
assert.deepEqual(loads.sort(), ['pt', 'pt-BR']);
|
|
assert.equal(i18n.t('a.hello', { name: 'Ana' }), 'Olá, Ana');
|
|
assert.equal(i18n.t('x.nav'), 'Navegação');
|
|
assert.equal(i18n.t('a.only'), 'English only');
|
|
assert.equal(i18n.t('nope'), 'nope');
|
|
assert.ok(missing.includes('key:pt-BR:a.only') && missing.includes('key:pt-BR:nope'));
|
|
assert.deepEqual(i18n.resolved(['x.']), { 'x.nav': 'Navegação' });
|
|
assert.equal(await i18n.setLocale('fr-FR'), 'en', 'no catalog: the source');
|
|
assert.equal(await i18n.setLocale('tr-TR'), 'tr');
|
|
assert.deepEqual(seen, ['pt-BR', 'en', 'tr']);
|
|
assert.equal(i18n.number(1234.5), '1.234,5');
|
|
assert.equal(i18n.dir, 'ltr');
|
|
i18n.add('tr', { 'a.only': 'Sadece' });
|
|
assert.equal(i18n.t('a.only'), 'Sadece');
|
|
assert.equal(i18n.tx('dynamic.key', {}, 'fallback'), 'fallback');
|
|
assert.equal(typeof i18n.relative(-3, 'minute'), 'string');
|
|
assert.deepEqual(relativeParts(Date.now() - 5 * 60_000), { value: -5, unit: 'minute' });
|
|
});
|
|
|
|
test('catalog checks: placeholders, plurals, markup, extra keys, lengths', () => {
|
|
const source = {
|
|
'a.hello': 'Hello, {name}',
|
|
'a.count': '{n, plural, one {# item} other {# items}}',
|
|
'a.kind': '{k, select, post {Post} other {Other}}',
|
|
'a.short': 'Save',
|
|
};
|
|
const catalog: Catalog = {
|
|
locale: 'ar', name: 'العربية', englishName: 'Arabic', dir: 'rtl', status: 'machine',
|
|
messages: {
|
|
'a.hello': 'مرحبا {nom}',
|
|
'a.count': '{n, plural, one {عنصر} other {# عناصر}}',
|
|
'a.kind': '{k, select, other {آخر}}',
|
|
'a.short': 'x'.repeat(80),
|
|
'a.gone': 'قديم',
|
|
},
|
|
};
|
|
const issues = checkCatalog(catalog, source);
|
|
const text = issues.map(issue => `${issue.level}:${issue.key}:${issue.message}`).join('\n');
|
|
assert.match(text, /error:a\.hello:missing placeholder\(s\) \{name\}/);
|
|
assert.match(text, /error:a\.hello:unknown placeholder\(s\) \{nom\}/);
|
|
assert.match(text, /warning:a\.count:.*no "zero" form \(ar uses it/);
|
|
assert.match(text, /error:a\.kind:.*lacks the "post" option/);
|
|
assert.match(text, /warning:a\.short:translation is 80 characters/);
|
|
assert.match(text, /error:a\.gone:key is not in the source/);
|
|
assert.equal(checkMessageSafety('tr', 'k', 'Merhaba <img src=x onerror=alert(1)>')[0]?.level, 'error');
|
|
assert.equal(checkMessageSafety('tr', 'k', 'ab')[0]?.level, 'error');
|
|
assert.equal(checkMessageSafety('tr', 'k', 'Boyut < 8 MB').length, 0);
|
|
assert.equal(checkShape({ locale: 'tr', name: 'Türkçe', englishName: 'Turkish', status: 'reviewed', messages: {} }).length, 1, 'reviewed needs reviewers');
|
|
assert.equal(checkShape({ locale: 'ar', name: 'x', englishName: 'y', status: 'machine', dir: 'ltr', messages: {} }).length, 1, 'wrong dir');
|
|
assert.equal(checkShape({ locale: 'zh-cn', name: 'x', englishName: 'y', status: 'machine', messages: {} }).length, 1, 'non-canonical tag');
|
|
assert.equal(checkSource({ locale: 'en', name: 'English', englishName: 'English', status: 'source', messages: { a: 'x' } }, { b: 'desc' }).length, 1);
|
|
assert.equal(coverage(catalog, source), 1);
|
|
assert.match(generateKeyTypes(source), /"a\.hello": \{ name: string \| number \| Date \}/);
|
|
});
|
|
|
|
test('apostrophes are literal unless they quote { } # or another apostrophe', () => {
|
|
assert.equal(formatMessage("Don't lose the developer's {name}", { name: 'draft' }), "Don't lose the developer's draft");
|
|
assert.equal(formatMessage("It's {n, plural, one {# post} other {# posts}}", { n: 2 }), "It's 2 posts");
|
|
assert.equal(formatMessage("'#'1 and '{'x'}' and it''s"), '#1 and {x} and it\'s');
|
|
assert.equal(formatMessage("{n, plural, other {'#' is #}}", { n: 3 }), '# is 3');
|
|
assert.deepEqual(placeholders("don't {a} 'n' {b}"), ['a', 'b']);
|
|
assert.deepEqual(checkMessageSafety('en', 'k', "Don't show the developer's notes"), []);
|
|
});
|
|
|
|
test('catalog checks: plural-only arguments may be dropped, other placeholders may not', () => {
|
|
const source = {
|
|
'a.fonts': 'Download {count, plural, one {it} other {them}} now?',
|
|
'a.saved': 'Download {fonts} to see {count, plural, one {it} other {them}}.',
|
|
'a.drafts': '{count, plural, one {# draft} other {# drafts}}',
|
|
'a.kind': 'Open {kind, select, post {the post} other {it}}',
|
|
'a.frames': 'New {ext}{frames, plural, =0 {} =1 {} other { · # frames}}',
|
|
'a.before': 'In',
|
|
};
|
|
const catalog: Catalog = {
|
|
locale: 'ms', name: 'Bahasa Melayu', englishName: 'Malay', status: 'machine',
|
|
messages: {
|
|
'a.fonts': 'Muat turun sekarang?',
|
|
'a.saved': 'Muat turun {fonts} untuk melihatnya.',
|
|
'a.drafts': 'draf',
|
|
'a.kind': 'Buka',
|
|
'a.frames': '{ext} baharu{frames, plural, other { · # bingkai}}',
|
|
'a.before': '',
|
|
},
|
|
};
|
|
const errors = checkCatalog(catalog, source, { 'a.before': { description: 'fragment', allowEmpty: true } }).filter(issue => issue.level === 'error');
|
|
assert.deepEqual(errors.map(issue => `${issue.key}:${issue.message}`), [
|
|
'a.drafts:missing placeholder(s) {count}',
|
|
'a.kind:missing placeholder(s) {kind}',
|
|
]);
|
|
assert.equal(checkCatalog({ ...catalog, messages: { 'a.before': '' } }, source).filter(issue => issue.level === 'error').length, 1, 'empty needs allowEmpty');
|
|
// Only `other` where the language has only `other` (ms, vi, th, yo): no warning either.
|
|
for (const locale of ['ms', 'vi', 'th', 'yo']) {
|
|
const only = checkCatalog({ ...catalog, locale, messages: { 'a.drafts': '{count, plural, other {# x}}' } }, source);
|
|
assert.deepEqual(only.filter(issue => issue.key === 'a.drafts'), [], locale);
|
|
}
|
|
const sk = checkCatalog({ ...catalog, locale: 'sk', messages: { 'a.frames': 'Nový {ext}{frames, plural, =0 {} =1 {} other { · # sn.}}' } }, source);
|
|
assert.ok(sk.every(issue => issue.level === 'warning'), 'missing plural forms are warnings');
|
|
});
|
|
|
|
test('plugin strings: own translations, then the app catalog, then own English', () => {
|
|
let locale = 'tr';
|
|
const listeners = new Set<() => void>();
|
|
const host = { 'plugin.reading-time.label': '{minutes} dk okuma', 'kit.ok': 'Tamam' } as Record<string, string>;
|
|
const source = { locale: () => locale, message: (key: string) => host[key], onChange: (fn: () => void) => { listeners.add(fn); return () => listeners.delete(fn); } };
|
|
const root = createPluginI18n(source);
|
|
const bundled = root.namespace(pluginNamespace('reading-time.plugin.js'));
|
|
bundled.register({ en: { label: '{minutes} min read', other: 'Other' } });
|
|
assert.equal(bundled.t('label', { minutes: 3 }), '3 dk okuma');
|
|
assert.equal(bundled.t('other'), 'Other');
|
|
const third = root.namespace('notes');
|
|
third.register({ en: { saved: 'Saved' }, 'tr-TR': { saved: 'Kaydedildi' } });
|
|
assert.equal(third.t('saved'), 'Saved', 'tr-TR does not serve tr');
|
|
third.register({ tr: { saved: 'Kaydedildi' } });
|
|
assert.equal(third.t('saved'), 'Kaydedildi');
|
|
let heard = '';
|
|
third.onChange(next => { heard = next; });
|
|
locale = 'en';
|
|
for (const fn of listeners) fn();
|
|
assert.equal(heard, 'en');
|
|
assert.equal(third.t('saved'), 'Saved');
|
|
assert.equal(third.t('missing'), 'missing');
|
|
assert.throws(() => root.namespace('../x'), /invalid/);
|
|
});
|
|
|
|
test('framework catalogs: kit words for every locale pass the checks', () => {
|
|
const dir = join(here, '../../waybetter/locales');
|
|
const en = JSON.parse(readFileSync(join(dir, 'en.json'), 'utf8')) as Catalog;
|
|
assert.deepEqual(en.messages, { ...KIT_MESSAGES, ...SHELL_MESSAGES, ...STORE_MESSAGES, ...FRAME_MESSAGES, ...MENU_MESSAGES, ...PANEL_MESSAGES, ...INSTALLER_MESSAGES }, 'en.json is the kit, shell-model, store, shell-frame, app-menu, panel and installer source');
|
|
assert.equal(shellText('wb.account.signedOut', { handle: '@a' }), '@a · signed out');
|
|
assert.equal(shellText('wb.account.signedOut', { handle: '@a' }, () => '{handle} · oturum kapalı'), '@a · oturum kapalı');
|
|
// Translator notes (en.meta.json): where a message appears, and fragments a language may leave empty.
|
|
const meta = JSON.parse(readFileSync(join(dir, 'en.meta.json'), 'utf8')) as Meta & { $schema?: string };
|
|
delete meta.$schema;
|
|
const files = readdirSync(dir).filter(file => file.endsWith('.json') && !file.endsWith('.meta.json'));
|
|
assert.ok(files.length >= 50, `${files.length} locales`);
|
|
for (const file of files) {
|
|
const catalog = JSON.parse(readFileSync(join(dir, file), 'utf8')) as Catalog;
|
|
assert.equal(`${catalog.locale}.json`, file);
|
|
const errors: CatalogIssue[] = [...checkShape(catalog, file), ...(file === 'en.json' ? checkSource(catalog, meta) : checkCatalog(catalog, en.messages, meta))].filter(issue => issue.level === 'error');
|
|
assert.deepEqual(errors, [], file);
|
|
}
|
|
});
|