snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,265 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* Deterministic ICU lint for the webapp locale catalogs.
*
* Every check below fails the run, ordered here from breaks-at-runtime to
* renders-but-wrong. There is no way to exempt a key: a translation that
* deviates from its source is either wrong, or the source is encoding English
* grammar the other languages cannot follow, and both are worth fixing rather
* than recording.
*
* - the file is valid JSON and every value is a string
* - every value parses with @formatjs/icu-messageformat-parser, the parser
* react-intl runs in production, so "parses here" implies "parses there"
* - a translation never invents a variable the source does not have. An
* unknown variable throws MISSING_VALUE at format time.
* - a translation never invents a tag the source does not have. An unknown
* tag renders as raw markup.
* - a translation does not drop a variable or tag the source has. Dropping
* one renders fine, it just quietly loses a value or a link.
* - a variable shared with the source is not used in a weaker role than the
* source uses it. Demoting {count, plural, ...} to a bare {count} still
* parses and still renders, it just silently stops pluralizing. The
* opposite direction is allowed: see PROMOTIONS.
* - an unescaped ASCII apostrophe immediately before < or { where the source
* has no such quoting. In ICU that opens a quoted literal which swallows the tag
* or variable and everything after it. The message still parses, so this
* check is the only thing that catches it.
*
* Key parity is a separate question from whether the entries that exist are
* correct. An extra key, one en.json does not have, is always an error: nothing
* will ever read it. A missing key is an error by default and a warning under
* --warn-missing-keys, and is not a runtime defect either way, because
* react-intl falls back to the source message.
*
* Usage:
* node scripts/check_icu.mjs <i18n-dir> [--warn-missing-keys]
*/
import fs from 'fs';
import {createRequire} from 'node:module';
import {pathToFileURL} from 'node:url';
import path from 'path';
// Resolved through react-intl: a bare import gets whichever copy npm hoisted.
const reactIntlRequire = createRequire(import.meta.resolve('react-intl'));
const {parse} = await import(pathToFileURL(reactIntlRequire.resolve('@formatjs/icu-messageformat-parser')).href);
const SOURCE = 'en.json';
const argv = process.argv.slice(2);
const warnMissingKeys = argv.includes('--warn-missing-keys');
const [i18nDir, ...extra] = argv.filter((a) => a !== '--warn-missing-keys');
if (!i18nDir || extra.length) {
console.error('usage: node scripts/check_icu.mjs <i18n-dir> [--warn-missing-keys]');
process.exit(2);
}
const readCatalog = (name) => fs.readFileSync(path.join(i18nDir, name), 'utf8');
// Sorted because readdir order is filesystem dependent, and the report has to
// be stable across runs and diffable between them.
let localeNames;
try {
localeNames = fs.readdirSync(i18nDir, {withFileTypes: true}).
filter((entry) => entry.isFile() && path.extname(entry.name) === '.json' && entry.name !== SOURCE).
map((entry) => entry.name).
sort();
} catch (e) {
console.error(`cannot read the i18n directory ${i18nDir}: ${e.message}`);
process.exit(2);
}
if (localeNames.length === 0) {
console.error(`no locale catalogs beside ${SOURCE} in ${i18nDir}`);
process.exit(2);
}
let en;
try {
en = JSON.parse(readCatalog(SOURCE));
} catch (e) {
console.error(`cannot read ${path.join(i18nDir, SOURCE)}: ${e.message}`);
process.exit(2);
}
// A lone ASCII apostrophe before < or { opens an ICU quoted literal that
// swallows the tag or variable. A doubled one does not: '' is the escape for a
// literal apostrophe, and l''<link> is a documented way to write an elision. So
// only an unpaired apostrophe is a defect.
const UNESCAPED_APOSTROPHE = /(?<!')'(?!')[<{]/;
const errors = [];
const warnings = [];
// Element type ids from @formatjs/icu-messageformat-parser's TYPE enum. They
// are inlined as numbers in the AST, so name them for legible messages.
const TYPES = {
1: 'argument',
2: 'number',
3: 'date',
4: 'time',
5: 'select',
6: 'plural',
8: 'tag',
};
/**
* Maps the role the source uses a variable in to the roles a translation may
* promote it to.
*
* English needs a plural far less often than the languages it is translated
* into: "{count} items were deleted" is one sentence in English and four in
* Russian. Wrapping a bare {count} in {count, plural, ...} is how a translator
* makes that sentence grammatical, and it loses nothing -- so it is allowed,
* even though the source never asked for a plural. Demotion is the reverse and
* stays fatal.
*
* The formatting types are deliberately absent. They are not interchangeable
* with each other or reachable from a bare argument: {x, number} over a string
* renders NaN and {x, date} over a non-date renders "Invalid Date", so a
* translation reaching for one the source did not use is a defect, not a
* grammar fix.
*/
const PROMOTIONS = {
argument: new Set(['plural', 'select']),
number: new Set(['plural']),
};
/**
* Map every variable and tag in an AST to the set of types it is used as. A
* single name is regularly used in more than one role -- "{count, number} new
* {count, plural, one {message} other {messages}}" uses count as both a number
* and a plural -- so this has to be a set per name, not a type per name.
*
* Plural categories are deliberately not compared: which categories a message
* needs is a property of the locale, not of the source string.
*/
function argTypes(ast, out = new Map()) {
for (const el of ast) {
if (TYPES[el.type] && el.value !== undefined) {
if (!out.has(el.value)) {
out.set(el.value, new Set());
}
out.get(el.value).add(TYPES[el.type]);
}
if (el.options) {
for (const option of Object.values(el.options)) {
argTypes(option.value, out);
}
}
if (el.children) {
argTypes(el.children, out);
}
}
return out;
}
const enArgs = new Map();
for (const [key, message] of Object.entries(en)) {
try {
enArgs.set(key, argTypes(parse(message, {ignoreTag: false, requiresOtherClause: true})));
} catch (e) {
errors.push(`${SOURCE}:${key}: source does not parse: ${e.message}`);
}
}
for (const name of localeNames) {
let data;
try {
data = JSON.parse(readCatalog(name));
} catch (e) {
errors.push(`${name}: invalid JSON: ${e.message}`);
continue;
}
for (const key of Object.keys(en)) {
if (!(key in data)) {
(warnMissingKeys ? warnings : errors).push(`${name}:${key}: missing key`);
}
}
for (const [key, message] of Object.entries(data)) {
if (!(key in en)) {
errors.push(`${name}:${key}: extra key not in ${SOURCE}`);
continue;
}
if (typeof message !== 'string') {
errors.push(`${name}:${key}: value is not a string`);
continue;
}
// ignoreTag: false parses <b>...</b> as tags rather than literal text,
// so the tag checks below see them; requiresOtherClause: true rejects a
// plural or select with no "other" arm, which ICU requires as the fallback.
let ast;
try {
ast = parse(message, {ignoreTag: false, requiresOtherClause: true});
} catch (e) {
errors.push(`${name}:${key}: does not parse: ${e.message}`);
continue;
}
// Absent only when en.json's own message failed to parse, which was
// already reported once against en.json. Comparing against a source we
// could not read would just repeat that for every locale.
const sourceArgs = enArgs.get(key);
if (!sourceArgs) {
continue;
}
const targetArgs = argTypes(ast);
for (const arg of targetArgs.keys()) {
if (!sourceArgs.has(arg)) {
errors.push(`${name}:${key}: unknown variable/tag "${arg}" not present in source`);
}
}
for (const arg of sourceArgs.keys()) {
if (!targetArgs.has(arg)) {
errors.push(`${name}:${key}: source variable/tag "${arg}" is missing from the translation`);
}
}
for (const [arg, types] of targetArgs) {
// Not in the source at all, which the unknown-variable check above
// has already reported.
const sourceTypes = sourceArgs.get(arg);
if (!sourceTypes) {
continue;
}
for (const type of types) {
if (sourceTypes.has(type)) {
continue;
}
if ([...sourceTypes].some((sourceType) => PROMOTIONS[sourceType]?.has(type))) {
continue;
}
errors.push(`${name}:${key}: "${arg}" is used as ${type}, but the source only uses it as ${[...sourceTypes].join('/')}`);
}
}
// Only when the source is clean. The same quoting in en.json is the
// author's own, and blaming each translation for it would report the one
// source defect once per locale.
if (UNESCAPED_APOSTROPHE.test(message) && !UNESCAPED_APOSTROPHE.test(en[key])) {
errors.push(`${name}:${key}: ASCII apostrophe before ICU syntax opens a quoted literal and swallows the rest of the message; use ’ or '' instead`);
}
}
}
if (warnings.length) {
console.warn(warnings.join('\n'));
console.warn(`\n${warnings.length} warning(s)`);
}
if (errors.length) {
console.error(errors.join('\n'));
console.error(`\n${errors.length} error(s) across ${localeNames.length} locale file(s)`);
process.exit(1);
}
console.log(`OK: ${localeNames.length} locale files checked against ${path.join(i18nDir, SOURCE)}`);

View File

@@ -0,0 +1,368 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* @jest-environment node
*/
// check_icu.mjs is an ESM CLI whose contract is its exit code, so these drive
// the real script in a subprocess rather than importing pieces of it.
const {spawnSync} = require('child_process');
const fs = require('fs');
const {createRequire} = require('module');
const os = require('os');
const path = require('path');
const SCRIPT = path.join(__dirname, 'check_icu.mjs');
/**
* Write a source catalog and one or more locale catalogs to a temp i18n dir, run
* the checker over that directory, and return its exit code and output. A
* catalog may be an object, or a string when the case needs to be malformed.
*/
function check(en, locales, {warnMissingKeys = false, extraFiles = {}} = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-icu-'));
const serialize = (body) => (typeof body === 'string' ? body : JSON.stringify(body));
fs.writeFileSync(path.join(dir, 'en.json'), serialize(en));
for (const [name, body] of Object.entries({...locales, ...extraFiles})) {
fs.writeFileSync(path.join(dir, name), serialize(body));
}
const args = [SCRIPT, dir, ...(warnMissingKeys ? ['--warn-missing-keys'] : [])];
const result = spawnSync(process.execPath, args, {encoding: 'utf8'});
return {code: result.status, stdout: result.stdout, stderr: result.stderr};
}
describe('check_icu', () => {
describe('parser', () => {
// The script's whole premise is that parsing here implies parsing under
// react-intl, which only holds while the two agree on a version. Three
// copies of this package exist in the tree, so a bare import picks up
// whichever npm hoisted -- for a long time, the older one
// babel-plugin-formatjs pulls. The script sidesteps that by resolving
// the parser from react-intl's own node_modules, which works only while
// react-intl keeps depending on the parser directly.
test('is the copy react-intl loads', () => {
const wantedByReactIntl = require('react-intl/package.json').dependencies['@formatjs/icu-messageformat-parser'];
expect(wantedByReactIntl).toBeDefined();
const fromReactIntl = createRequire(require.resolve('react-intl'));
const resolved = fromReactIntl('@formatjs/icu-messageformat-parser/package.json').version;
expect(resolved).toBe(wantedByReactIntl);
});
});
describe('well-formedness', () => {
test('accepts a clean catalog', () => {
const {code, stdout} = check(
{'a.b': 'Hello {name}'},
{'fr.json': {'a.b': 'Bonjour {name}'}},
);
expect(code).toBe(0);
expect(stdout).toContain('OK:');
});
test('rejects invalid JSON', () => {
const {code, stderr} = check({'a.b': 'Hello'}, {'fr.json': '{"a.b":'});
expect(code).toBe(1);
expect(stderr).toContain('invalid JSON');
});
test.each([
['null', null],
['a number', 5],
['an object', {nested: 'x'}],
])('rejects a value that is %s', (_label, value) => {
const {code, stderr} = check({'a.b': 'Hello'}, {'fr.json': {'a.b': value}});
expect(code).toBe(1);
expect(stderr).toContain('value is not a string');
});
test('rejects a translation that does not parse', () => {
const {code, stderr} = check(
{'a.b': 'Hello {name}'},
{'fr.json': {'a.b': 'Bonjour {name'}},
);
expect(code).toBe(1);
expect(stderr).toContain('does not parse');
});
test('reports an unparseable source once, and skips its key', () => {
const {code, stderr} = check(
{'a.b': 'Hello {name'},
{'fr.json': {'a.b': 'Bonjour {name}'}},
);
expect(code).toBe(1);
expect(stderr).toContain('en.json:a.b: source does not parse');
expect(stderr).not.toContain('fr.json:a.b:');
});
});
describe('key parity', () => {
test('an extra key is always an error, since nothing will read it', () => {
const {code, stderr} = check(
{'a.b': 'Hello'},
{'fr.json': {'a.b': 'Bonjour', 'z.z': 'Orphelin'}},
);
expect(code).toBe(1);
expect(stderr).toContain('fr.json:z.z: extra key not in en.json');
});
test('a missing key is an error by default', () => {
const {code, stderr} = check({'a.b': 'Hello'}, {'fr.json': {}});
expect(code).toBe(1);
expect(stderr).toContain('fr.json:a.b: missing key');
});
test('a missing key is a warning under --warn-missing-keys', () => {
const {code, stderr} = check({'a.b': 'Hello'}, {'fr.json': {}}, {warnMissingKeys: true});
expect(code).toBe(0);
expect(stderr).toContain('fr.json:a.b: missing key');
expect(stderr).toContain('1 warning(s)');
});
});
describe('variables and tags', () => {
test('rejects a variable the source does not have', () => {
const {code, stderr} = check(
{'a.b': 'Hello {name}'},
{'fr.json': {'a.b': 'Bonjour {name}, {extra}'}},
);
expect(code).toBe(1);
expect(stderr).toContain('unknown variable/tag "extra" not present in source');
});
test('rejects a tag the source does not have', () => {
const {code, stderr} = check(
{'a.b': 'Read <link>this</link>'},
{'fr.json': {'a.b': 'Lisez <link>ceci</link> et <b>cela</b>'}},
);
expect(code).toBe(1);
expect(stderr).toContain('unknown variable/tag "b" not present in source');
});
test.each([
['variable', 'Hello {name}', 'Bonjour', 'name'],
['tag', 'Read <link>this</link>', 'Lisez ceci', 'link'],
])('rejects dropping a source %s', (_label, en, fr, dropped) => {
const {code, stderr} = check({'a.b': en}, {'fr.json': {'a.b': fr}});
expect(code).toBe(1);
expect(stderr).toContain(`source variable/tag "${dropped}" is missing from the translation`);
});
test('accepts a name used as both a tag and a variable', () => {
const {code} = check(
{'a.b': '<b>{b}</b>'},
{'fr.json': {'a.b': '<b>{b}</b>'}},
);
expect(code).toBe(0);
});
test('finds variables nested inside plural branches', () => {
const {code, stderr} = check(
{'a.b': '{count, plural, one {{name} item} other {{name} items}}'},
{'fr.json': {'a.b': '{count, plural, one {un article} other {des articles}}'}},
);
expect(code).toBe(1);
expect(stderr).toContain('source variable/tag "name" is missing from the translation');
});
});
describe('argument roles', () => {
// Demotion is the defect the role comparison exists for: it still
// parses and still renders, it just silently stops pluralizing.
test.each([
['plural', '{count, plural, one {# item} other {# items}}', '{count} articles'],
['number', 'You have {count, number} points', 'Vous avez {count} points'],
])('rejects demoting %s to a bare argument', (role, en, fr) => {
const {code, stderr} = check({'a.b': en}, {'fr.json': {'a.b': fr}});
expect(code).toBe(1);
expect(stderr).toContain(`the source only uses it as ${role}`);
});
// English needs a plural far less often than the languages it is
// translated into, so widening a bare argument is correct translation,
// not a defect.
test.each([
['a bare argument', '{count} items were deleted'],
['a number', '{count, number} items were deleted'],
])('accepts promoting %s to a plural', (_label, en) => {
const {code} = check(
{'a.b': en},
{'ru.json': {'a.b': '{count, plural, one {# элемент} few {# элемента} many {# элементов} other {# элемента}}'}},
);
expect(code).toBe(0);
});
test('accepts promoting a bare argument to a select', () => {
const {code} = check(
{'a.b': 'Status: {status}'},
{'fr.json': {'a.b': 'Statut : {status, select, active {actif} other {autre}}'}},
);
expect(code).toBe(0);
});
// Formatting types are not reachable from a bare argument: applying them
// to a value that is not a number or a date renders NaN or Invalid Date.
test.each([
['number', '{count, number}'],
['date', '{count, date}'],
])('rejects promoting a bare argument to %s', (role, fr) => {
const {code, stderr} = check({'a.b': '{count} items'}, {'fr.json': {'a.b': `${fr} articles`}});
expect(code).toBe(1);
expect(stderr).toContain(`"count" is used as ${role}`);
});
test('does not compare plural categories, which belong to the locale', () => {
const {code} = check(
{'a.b': '{count, plural, one {# item} other {# items}}'},
{'ru.json': {'a.b': '{count, plural, one {# элемент} few {# элемента} many {# элементов} other {# элемента}}'}},
);
expect(code).toBe(0);
});
test('does not treat # as a variable', () => {
const {code} = check(
{'a.b': '{count, plural, one {# item} other {# items}}'},
{'fr.json': {'a.b': '{count, plural, one {# article} other {# articles}}'}},
);
expect(code).toBe(0);
});
});
describe('apostrophes', () => {
// A lone apostrophe before ICU syntax opens a quoted literal that
// swallows the rest of the message. It still parses, so nothing else
// catches it.
test.each([
['a tag', 'Read <link>this</link>', "Lisez l'<link>article</link>"],
['a variable', 'Today is {day}', "Aujourd'hui c'est l'{day}"],
])('rejects an unescaped apostrophe before %s', (_label, en, fr) => {
const {code, stderr} = check({'a.b': en}, {'fr.json': {'a.b': fr}});
expect(code).toBe(1);
expect(stderr).toContain('opens a quoted literal');
});
test('accepts a doubled apostrophe, the documented elision escape', () => {
const {code} = check(
{'a.b': 'Read <link>this</link>'},
{'fr.json': {'a.b': "Lisez l''<link>article</link>"}},
);
expect(code).toBe(0);
});
test('accepts a typographic apostrophe', () => {
const {code} = check(
{'a.b': 'Read <link>this</link>'},
{'fr.json': {'a.b': 'Lisez l’<link>article</link>'}},
);
expect(code).toBe(0);
});
test('does not flag quoting the source itself uses', () => {
const {code} = check(
{'a.b': "Use '{braces}' literally"},
{'fr.json': {'a.b': "Utilisez '{braces}' littéralement"}},
);
expect(code).toBe(0);
});
});
describe('cli', () => {
test.each([
['no arguments', []],
['more than one directory', ['a', 'b']],
])('exits 2 on %s', (_label, args) => {
const result = spawnSync(process.execPath, [SCRIPT, ...args], {encoding: 'utf8'});
expect(result.status).toBe(2);
expect(result.stderr).toContain('usage:');
});
test('exits 2 on a directory that does not exist', () => {
const missing = path.join(os.tmpdir(), 'check-icu-does-not-exist');
const result = spawnSync(process.execPath, [SCRIPT, missing], {encoding: 'utf8'});
expect(result.status).toBe(2);
expect(result.stderr).toContain('cannot read the i18n directory');
});
test('exits 2 when en.json is missing or malformed', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-icu-'));
fs.writeFileSync(path.join(dir, 'fr.json'), '{}');
const missing = spawnSync(process.execPath, [SCRIPT, dir], {encoding: 'utf8'});
expect(missing.status).toBe(2);
expect(missing.stderr).toContain('cannot read');
fs.writeFileSync(path.join(dir, 'en.json'), '{not json');
const malformed = spawnSync(process.execPath, [SCRIPT, dir], {encoding: 'utf8'});
expect(malformed.status).toBe(2);
expect(malformed.stderr).toContain('cannot read');
});
test('exits 2 when the directory holds no locale catalogs', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-icu-'));
fs.writeFileSync(path.join(dir, 'en.json'), '{}');
const result = spawnSync(process.execPath, [SCRIPT, dir], {encoding: 'utf8'});
expect(result.status).toBe(2);
expect(result.stderr).toContain('no locale catalogs');
});
// src/i18n holds i18n.ts and a CLAUDE.OPTIONAL.md beside the catalogs.
test('walks only the sibling json catalogs, and never en.json', () => {
const {code, stdout} = check(
{'a.b': 'Hello'},
{'fr.json': {'a.b': 'Bonjour'}},
{extraFiles: {'i18n.ts': 'export default 1;', 'notes.md': 'hi'}},
);
expect(code).toBe(0);
expect(stdout).toContain('OK: 1 locale files checked');
});
test('reports every locale file, not just the first that fails', () => {
const {code, stderr} = check(
{'a.b': 'Hello {name}'},
{
'fr.json': {'a.b': 'Bonjour'},
'de.json': {'a.b': 'Hallo'},
},
);
expect(code).toBe(1);
expect(stderr).toContain('fr.json:a.b:');
expect(stderr).toContain('de.json:a.b:');
expect(stderr).toContain('2 error(s) across 2 locale file(s)');
});
});
});

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* Custom formatjs formatter matching mmjstool's behavior
*
* Based on formatjs simple formatter with case-insensitive sorting
* to match mmjstool's sortJson({ignoreCase: true})
*/
/**
* Format function - extract defaultMessage from message objects
* Same as formatjs simple formatter
*/
module.exports.format = (msgs) => {
return Object.keys(msgs).reduce((all, k) => {
all[k] = msgs[k].defaultMessage;
return all;
}, {});
};
/**
* Compile function - pass through (identity)
* Same as formatjs simple formatter
*/
module.exports.compile = (msgs) => msgs;
/**
* Custom comparator for case-insensitive alphabetical sorting
* with underscore before dot (to match existing en.json ordering)
*/
module.exports.compareMessages = (el1, el2) => {
// Normalize keys: replace _ with a character that sorts before .
// Use \x00 (null char) which sorts before all printable characters
const key1 = el1.key.toLowerCase().replace(/_/g, '\x00');
const key2 = el2.key.toLowerCase().replace(/_/g, '\x00');
if (key1 < key2) {
return -1;
}
if (key1 > key2) {
return 1;
}
return 0;
};