Files
mattermost/i18n/scripts/rules.test.mjs
T
58b104cac3 The rules a new translation is held to (#39299)
* 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>
2026-10-22 16:20:27 -03:00

137 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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']);
});
});