mirror of
https://github.com/mattermost/mattermost.git
synced 2026-11-03 12:48:37 -06:00
* Report a catalog that cannot be read or is not an object, rather than crash A locale catalog whose top level was null, a number or a string made check_icu.mjs throw, and an array was checked as if it were an empty catalog; a catalog it could not read threw too. Either way the run ended at that file, so the catalogs after it went unchecked. Each is now an error against its file, and the rest are checked. en.json is held to the same shape: an array or a number left nothing to check, and every catalog passed. mmgotool i18n verify already reports each of these and carries on. Co-authored-by: Jesse Hallam <lieut-data@users.noreply.github.com> * Keep a copy of go-i18n's server plural forms, pinned by a Go test The translation rules check a server plural map in Node, before the catalog is written, so they need go-i18n's plural categories for each locale. i18n/scripts/lib/server_plurals.json holds them, and a Go test fails if it disagrees with go-i18n for any server catalog, or lists a locale with none. The server translation job runs the test, so a PR that adds a server locale has to add it to the table. Co-authored-by: Jesse Hallam <lieut-data@users.noreply.github.com> * Hold a new translation to the structural rules, and test them in CI i18n/scripts/lib/rules.mjs checks one translation, before it is written to a catalog. Webapp messages go through icu_rules.mjs, the code check_icu.mjs runs, so the two cannot disagree; what CI tolerates as a warning in the catalogs as they stand is an error for new work, except an arrow in a right-to-left locale, which is soft. Server messages get the per-string checks mmgotool i18n verify makes, with plural forms held to go-i18n's table rather than CLDR. icu_rules.mjs gains plural-unused-category, a plural arm the locale never selects. check_icu.mjs does not report it: it is dead text, not something a user sees, so it is left to the pipeline. A new job runs the pipeline's tests, and the workflow now runs for a change under i18n/. Co-authored-by: Jesse Hallam <lieut-data@users.noreply.github.com> * Flag a translation that leaves out a name the glossary keeps in English A doNotTranslate term (Mattermost, GitLab, Markdown) dropped from a translation is a soft finding: usually a mistake, sometimes a sentence that is better leaving the brand implicit, so it lands only with a stated reason. Channels, Playbooks, Boards and Calls are product names that are also ordinary English words. The English reads the same whether a string names the product or starts a sentence about channels, so they are marked commonWord and not flagged; the translator decides from the description. Co-authored-by: Jesse Hallam <lieut-data@users.noreply.github.com> * Catch English left untranslated A translation identical to its English is an error once the English has three ordinary lowercase words, a sentence nobody translated, and soft below that, where it may be a name or a word the language shares with English. In a language not written in Latin script, lowercase English words kept from the source are soft too. Only words a translator would translate count: ICU arguments and tags, template actions, URLs, email addresses, glossary names, quoted values and anything shaped like an identifier are left out. en-* locales are not checked. icu_rules.mjs exports its parser and options, so the rules read a message's literal text the way check_icu.mjs parses it. Co-authored-by: Jesse Hallam <lieut-data@users.noreply.github.com> * Flag the wrong form of address i18n/style.json records, for each locale where the choice matters, how its catalogs address the user, formally or informally, and the pronouns that would mean the other. A translation that uses one is soft: a pronoun can be quoted text or mean someone else, as German "Sie" can at the start of a sentence, so a stated reason lets it land. Co-authored-by: Jesse Hallam <lieut-data@users.noreply.github.com> * Check the glossary and style files in CI The translation rules read i18n/glossary/ and i18n/style.json, so a malformed entry would quietly change what a translation is held to. check_glossary.mjs holds them to their shape: canonical JSON, the known fields only, one owner per term or alias, a glossary file for exactly the locales with catalogs, terms in en.json order, a doNotTranslate term's target the term itself, and no pronoun both used and avoided. Co-authored-by: Jesse Hallam <lieut-data@users.noreply.github.com> * Run every translation check through make i18n-verify-all CI ran the i18n checks as three jobs of inline commands: the webapp checker, make i18n-verify plus the plural-table test in the build container, and the pipeline's tests and glossary check. Nobody could run that set locally without copying it out of the workflow. make i18n-verify-all in server/ now runs all of them, and CI runs only that, in one job on the runner. Go comes from setup-go at mmgotool's go.mod version, as Tools CI does, so the build container, and with it the Docker Hub secrets and id-token permission it needed, are gone. The workflow also runs when server/Makefile changes. Co-authored-by: Jesse Hallam <lieut-data@users.noreply.github.com> * Run make i18n-verify-all in the build environment again The previous commit swapped the build environment for setup-go, for no reason that holds up: the image has the Node .nvmrc pins as well as the Go mmgotool needs, it reuses the Go caches Server CI warms, and it mounts the checkout, so it sees the node_modules webapp-setup installs. Server CI builds and checks in it too. Co-authored-by: Jesse Hallam <lieut-data@users.noreply.github.com> * Drop comments that only restate the code Each said what the line under it already says: a path list, a setup step, a target its help text describes, a function its name and body describe, and how to run tests the Makefile now runs. Comments that give a reason the code cannot show stay. Co-authored-by: Jesse Hallam <lieut-data@users.noreply.github.com> * Fail on a glossary or style file that is not a JSON object readGlossary and readStyle read an array or a number as an empty glossary or no style, which turned the do-not-translate and form-of- address rules off without a word, and threw an unrelated TypeError on null. check_glossary.mjs rejects these shapes in CI, but the rules read the files on their own. Each is now an error naming the file; a file that does not exist still means none. Co-authored-by: Jesse Hallam <lieut-data@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Jesse Hallam <lieut-data@users.noreply.github.com>
137 lines
8.7 KiB
JavaScript
137 lines
8.7 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import {describe, test} from 'node:test';
|
||
import {fileURLToPath} from 'node:url';
|
||
|
||
import {readGlossary, readStyle} from './lib/glossary.mjs';
|
||
import {checkTranslation} from './lib/rules.mjs';
|
||
|
||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||
const files = (contents) => ({read: (rel) => (rel in contents ? JSON.stringify(contents[rel]) : null)});
|
||
const repoTree = {read: (rel) => (fs.existsSync(path.join(root, rel)) ? fs.readFileSync(path.join(root, rel), 'utf8') : null)};
|
||
|
||
const fixtureGlossary = (locale) => readGlossary(files({
|
||
'i18n/glossary/en.json': {
|
||
Mattermost: {definition: 'The product.', partOfSpeech: 'noun', doNotTranslate: true, aliases: ['MM']},
|
||
Boards: {definition: 'A product.', partOfSpeech: 'noun', doNotTranslate: true, commonWord: true},
|
||
channel: {definition: 'A place to talk.', partOfSpeech: 'noun', doNotTranslate: false},
|
||
},
|
||
}), locale);
|
||
|
||
const check = (locale, english, value, surface = 'webapp', glossary = fixtureGlossary(locale)) => checkTranslation({
|
||
item: {english, description: ''},
|
||
value,
|
||
locale,
|
||
surface,
|
||
glossary,
|
||
style: readStyle(repoTree, locale),
|
||
}).map((f) => `${f.severity} ${f.rule}`);
|
||
|
||
describe('rules', () => {
|
||
test('a translation is a non-empty string, or on the server a map of plural forms', () => {
|
||
assert.deepEqual(check('de', 'Save', 5), ['error type']);
|
||
assert.deepEqual(check('de', 'Save', {one: 'x'}), ['error type']);
|
||
assert.deepEqual(check('de', 'Save', {one: 'x', other: 1}, 'server'), ['error type']);
|
||
assert.deepEqual(check('de', 'Save', ' '), ['error empty']);
|
||
assert.deepEqual(check('de', {one: '{{.Count}} item', other: '{{.Count}} items'}, {one: '{{.Count}} Element', other: ''}, 'server'), ['error empty']);
|
||
assert.deepEqual(check('de', 'Save', 'Speichern'), []);
|
||
});
|
||
|
||
test('broken encoding is an error', () => {
|
||
assert.deepEqual(check('de', 'Settings', 'Einstellun\uFFFDen'), ['error encoding']);
|
||
assert.deepEqual(check('de', 'Settings', 'Einstellungen\u0007', 'server'), ['error encoding']);
|
||
});
|
||
|
||
test('webapp messages get the ICU rules, with what CI tolerates as a warning an error', () => {
|
||
assert.deepEqual(check('de', 'Hello {name}', 'Hallo'), ['error missing-argument']);
|
||
assert.deepEqual(check('ru', '{count} items', '{count, plural, one {# элемент} other {# элемента}}'), ['error plural-category']);
|
||
assert.deepEqual(check('ja', '{count, plural, one {# item} other {# items}}', '{count, plural, one {# 件} other {# 件}}'), ['error plural-unused-category']);
|
||
assert.deepEqual(check('ja', '{count, plural, one {# item} other {# items}}', '{count, plural, other {# 件}}'), []);
|
||
});
|
||
|
||
test('an arrow in a right-to-left locale is soft, since it can be the clearest way to write a sequence', () => {
|
||
assert.deepEqual(check('fa', 'Next →', 'بعدی →'), ['soft rtl-arrow']);
|
||
assert.deepEqual(check('fa', 'Next → {{.Name}}', 'بعدی → {{.Name}}', 'server'), ['soft rtl-arrow']);
|
||
});
|
||
|
||
test('an English message that does not parse is reported against en.json', () => {
|
||
assert.deepEqual(check('de', 'Hello {name', 'Hallo {name}'), ['error source']);
|
||
});
|
||
|
||
test('server template tokens and actions survive translation', () => {
|
||
assert.deepEqual(check('de', 'Hello {{.Name}}', 'Hallo {{.Nmae}}', 'server'), ['error unknown-token', 'error missing-token']);
|
||
assert.deepEqual(check('de', 'Hello {{.Name}}', 'Hallo {{.Name}', 'server'), ['error template-syntax', 'error missing-token']);
|
||
assert.deepEqual(check('de', '{{if .On}}On{{else}}Off{{end}}', '{{if .On}}An{{end}}', 'server'), ['error template-action']);
|
||
assert.deepEqual(check('de', '{{if .On}}On{{else}}Off{{end}}', '{{if .On}}An{{else}}Aus{{end}}', 'server'), []);
|
||
});
|
||
|
||
test('server plural forms follow go-i18n, not CLDR', () => {
|
||
const english = {one: '{{.Count}} item', other: '{{.Count}} items'};
|
||
assert.deepEqual(check('es', english, {one: '{{.Count}} elemento', other: '{{.Count}} elementos'}, 'server'), []);
|
||
assert.deepEqual(check('es', english, {one: '{{.Count}} elemento', many: '{{.Count}} elementos', other: '{{.Count}} elementos'}, 'server'), ['error plural-category']);
|
||
assert.deepEqual(check('xx', english, {one: '{{.Count}} x', other: '{{.Count}} x'}, 'server'), ['error plural-category']);
|
||
assert.deepEqual(check('ru', english, '{{.Count}} элементов', 'server'), ['error plural-demoted']);
|
||
});
|
||
|
||
test('English left as it is', () => {
|
||
assert.deepEqual(check('ru', 'With CEL you can define conditions', 'With CEL you can define conditions'), ['error identical-to-english']);
|
||
assert.deepEqual(check('pl', 'Confirm sending notifications to groups', 'Confirm sending notifications to groups'), ['error identical-to-english']);
|
||
assert.deepEqual(check('ru', 'Azure Blob Storage', 'Azure Blob Storage'), ['soft identical-to-english']);
|
||
assert.deepEqual(check('de', 'Save changes', 'Save changes'), ['soft identical-to-english']);
|
||
assert.deepEqual(check('de', 'Status', 'Status'), []);
|
||
assert.deepEqual(check('fr', '[on|off|status] [@username]', '[on|off|status] [@username]', 'server'), []);
|
||
assert.deepEqual(check('en-AU', 'Save your changes now', 'Save your changes now'), []);
|
||
});
|
||
|
||
test('a glossary name is not English left untranslated', () => {
|
||
assert.deepEqual(check('ru', 'Mattermost', 'Mattermost'), []);
|
||
assert.deepEqual(check('de', 'Mattermost Boards', 'Mattermost Boards'), []);
|
||
assert.deepEqual(check('ja', 'Open Mattermost', 'Open Mattermost'), ['soft identical-to-english']);
|
||
});
|
||
|
||
test('English words left in a non-Latin script', () => {
|
||
assert.deepEqual(check('ru', '{count, plural, one {team} other {teams}}', '{count, plural, one {team} few {teams} many {teams} other {teams}}'), ['soft english-words']);
|
||
assert.deepEqual(check('ru', 'Open the {name} webhook settings', 'Откройте настройки вебхука {name}'), []);
|
||
assert.deepEqual(check('ru', 'Upload a .png file from https://example.com', 'Загрузите файл .png с https://example.com'), []);
|
||
assert.deepEqual(check('ru', 'Type /header to set it', 'Введите /header, чтобы задать его'), []);
|
||
assert.deepEqual(check('ru', 'Type "yes" to confirm', 'Введите "yes" для подтверждения'), []);
|
||
});
|
||
|
||
test('form of address', () => {
|
||
assert.deepEqual(check('de', 'Enter a number', 'Geben Sie eine Zahl ein'), ['soft address']);
|
||
assert.deepEqual(check('de', 'They agreed', 'Sie stimmten zu'), []);
|
||
assert.deepEqual(check('de', 'Enter a number', 'Gib eine Zahl ein'), []);
|
||
assert.deepEqual(check('ru', 'Your license', 'Твоя лицензия', 'server'), ['soft address']);
|
||
assert.deepEqual(check('fr', 'You can do it', 'Tu peux le faire'), ['soft address']);
|
||
assert.deepEqual(check('fr', 'Type "tu" to continue', 'Saisissez "tu" pour continuer'), []);
|
||
assert.deepEqual(check('ja', 'You can do it', 'できます'), []);
|
||
});
|
||
|
||
test('a glossary or style file that is not a JSON object is an error, not an empty one', () => {
|
||
for (const text of ['[]', '5', 'null', '{"Mattermost":']) {
|
||
const tree = {read: () => text};
|
||
assert.throws(() => readGlossary(tree, 'de'), /^Error: i18n\/glossary\/en\.json: /);
|
||
assert.throws(() => readStyle(tree, 'de'), /^Error: i18n\/style\.json: /);
|
||
}
|
||
assert.deepEqual(readGlossary(files({}), 'de'), []);
|
||
assert.equal(readStyle(files({}), 'de'), null);
|
||
});
|
||
|
||
test('leaving out a doNotTranslate name is soft', () => {
|
||
assert.deepEqual(check('de', 'Open Mattermost', 'Öffne die App'), ['soft do-not-translate']);
|
||
assert.deepEqual(check('de', 'Open MM', 'Öffne die App', 'server'), ['soft do-not-translate']);
|
||
assert.deepEqual(check('de', 'Open Mattermost', 'Mattermost öffnen'), []);
|
||
assert.deepEqual(check('de', 'Visit mattermost.com', 'Besuche unsere Website'), []);
|
||
assert.deepEqual(check('de', 'Open the channel', 'Öffne den Kanal'), []);
|
||
});
|
||
|
||
test('a doNotTranslate name that is also a common word may be left out', () => {
|
||
assert.deepEqual(check('de', 'Boards are shared', 'Tafeln werden geteilt'), []);
|
||
|
||
const real = readGlossary(repoTree, 'de');
|
||
assert.deepEqual(check('de', 'Channels are listed here', 'Kanäle werden hier aufgelistet', 'webapp', real), []);
|
||
assert.deepEqual(check('de', 'Ask Mattermost support', 'Frag den Support', 'webapp', real), ['soft do-not-translate']);
|
||
});
|
||
});
|