@lingual/i18n-check 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,345 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.checkUndefinedKeys = exports.checkUnusedKeys = exports.checkTranslations = exports.checkMissingTranslations = exports.checkInvalidTranslations = void 0;
7
+ const findMissingKeys_1 = require("./utils/findMissingKeys");
8
+ const findInvalidTranslations_1 = require("./utils/findInvalidTranslations");
9
+ const findInvalidI18NextTranslations_1 = require("./utils/findInvalidI18NextTranslations");
10
+ const cli_lib_1 = require("@formatjs/cli-lib");
11
+ const i18NextSrcParser_1 = require("./utils/i18NextSrcParser");
12
+ const nextIntlSrcParser_1 = require("./utils/nextIntlSrcParser");
13
+ const fs_1 = __importDefault(require("fs"));
14
+ const path_1 = __importDefault(require("path"));
15
+ const constants_1 = require("./utils/constants");
16
+ const ParseFormats = ['react-intl', 'i18next', 'next-intl'];
17
+ const checkInvalidTranslations = (source, targets, options = { format: 'icu' }) => {
18
+ return options.format === 'i18next'
19
+ ? (0, findInvalidI18NextTranslations_1.findInvalidI18NextTranslations)(source, targets)
20
+ : (0, findInvalidTranslations_1.findInvalidTranslations)(source, targets);
21
+ };
22
+ exports.checkInvalidTranslations = checkInvalidTranslations;
23
+ const checkMissingTranslations = (source, targets, options) => {
24
+ return (0, findMissingKeys_1.findMissingKeys)(source, targets, options);
25
+ };
26
+ exports.checkMissingTranslations = checkMissingTranslations;
27
+ const checkTranslations = (source, targets, options = { format: 'icu', checks: ['invalidKeys', 'missingKeys'] }) => {
28
+ const { checks = ['invalidKeys', 'missingKeys'] } = options;
29
+ const missingKeys = {};
30
+ const invalidKeys = {};
31
+ const hasMissingKeysCheck = checks.includes('missingKeys');
32
+ const hasInvalidKeysCheck = checks.includes('invalidKeys');
33
+ source.forEach(({ name, content }) => {
34
+ const files = Object.fromEntries(targets
35
+ .filter(({ reference }) => reference === name)
36
+ .map(({ name, content }) => [name, content]));
37
+ const filteredContent = filterKeys(content, options.ignore ?? []);
38
+ if (hasMissingKeysCheck) {
39
+ merge(missingKeys, (0, exports.checkMissingTranslations)(filteredContent, files, options));
40
+ }
41
+ if (hasInvalidKeysCheck) {
42
+ merge(invalidKeys, (0, exports.checkInvalidTranslations)(filteredContent, files, options));
43
+ }
44
+ });
45
+ return {
46
+ missingKeys: hasMissingKeysCheck ? missingKeys : undefined,
47
+ invalidKeys: hasInvalidKeysCheck ? invalidKeys : undefined,
48
+ };
49
+ };
50
+ exports.checkTranslations = checkTranslations;
51
+ function merge(left, right) {
52
+ for (const [k, v] of Object.entries(right)) {
53
+ left[k] = (left?.[k] ?? []).concat(v);
54
+ }
55
+ }
56
+ const checkUnusedKeys = async (translationFiles, filesToParse, options = {
57
+ format: 'react-intl',
58
+ checks: [],
59
+ }, componentFunctions = []) => {
60
+ if (!options.format || !ParseFormats.includes(options.format)) {
61
+ return undefined;
62
+ }
63
+ if (!options.checks || !options.checks.includes('unused')) {
64
+ return undefined;
65
+ }
66
+ const filteredTranslationFiles = translationFiles.map(({ content, ...rest }) => ({
67
+ ...rest,
68
+ content: filterKeys(content, options.ignore),
69
+ }));
70
+ if (options.format === 'react-intl') {
71
+ return findUnusedReactIntlTranslations(filteredTranslationFiles, filesToParse);
72
+ }
73
+ else if (options.format === 'i18next') {
74
+ return findUnusedI18NextTranslations(filteredTranslationFiles, filesToParse, componentFunctions);
75
+ }
76
+ else if (options.format === 'next-intl') {
77
+ return findUnusedNextIntlTranslations(filteredTranslationFiles, filesToParse);
78
+ }
79
+ };
80
+ exports.checkUnusedKeys = checkUnusedKeys;
81
+ const findUnusedReactIntlTranslations = async (translationFiles, filesToParse) => {
82
+ const unusedKeys = {};
83
+ const extracted = await (0, cli_lib_1.extract)(filesToParse, {});
84
+ const extractedResultSet = new Set(Object.keys(JSON.parse(extracted)));
85
+ translationFiles.forEach(({ name, content }) => {
86
+ const keysInSource = Object.keys(content);
87
+ const found = [];
88
+ for (const keyInSource of keysInSource) {
89
+ if (!extractedResultSet.has(keyInSource)) {
90
+ found.push(keyInSource);
91
+ }
92
+ }
93
+ unusedKeys[name] = found;
94
+ });
95
+ return unusedKeys;
96
+ };
97
+ const findUnusedI18NextTranslations = async (source, filesToParse, componentFunctions = []) => {
98
+ const unusedKeys = {};
99
+ const { extractedResult, skippableKeys } = await getI18NextKeysInCode(filesToParse, componentFunctions);
100
+ const extractedResultSet = new Set(extractedResult.map(({ key, namespace }) => namespace ? `${namespace}.${key}` : key));
101
+ source.forEach(({ name, content }) => {
102
+ const keysInSource = Object.keys(content)
103
+ // Ensure that any plural definitiions like key_one, key_other etc.
104
+ // are flatted into a single key
105
+ .map((key) => {
106
+ const pluralSuffix = constants_1.I18NEXT_PLURAL_SUFFIX.find((suffix) => {
107
+ return key.endsWith(suffix);
108
+ });
109
+ return pluralSuffix ? key.replace(pluralSuffix, '') : key;
110
+ });
111
+ const found = [];
112
+ for (const keyInSource of keysInSource) {
113
+ const isSkippable = skippableKeys.find((skippableKey) => {
114
+ return keyInSource.includes(skippableKey);
115
+ });
116
+ if (isSkippable !== undefined) {
117
+ continue;
118
+ }
119
+ // find the file name
120
+ const [fileName] = (name.split(path_1.default.sep).pop() ?? '').split('.');
121
+ if (!extractedResultSet.has(`${fileName}.${keyInSource}`) &&
122
+ !extractedResultSet.has(keyInSource)) {
123
+ found.push(keyInSource);
124
+ }
125
+ }
126
+ unusedKeys[name] = found;
127
+ });
128
+ return unusedKeys;
129
+ };
130
+ const findUnusedNextIntlTranslations = async (translationFiles, filesToParse) => {
131
+ const unusedKeys = {};
132
+ const extracted = (0, nextIntlSrcParser_1.extract)(filesToParse);
133
+ const dynamicNamespaces = extracted.flatMap((namespace) => {
134
+ if (namespace.meta.dynamic) {
135
+ return [namespace.key];
136
+ }
137
+ return [];
138
+ });
139
+ const extractedResultSet = new Set(extracted.flatMap((namespace) => {
140
+ if (!namespace.meta.dynamic) {
141
+ return [namespace.key];
142
+ }
143
+ return [];
144
+ }));
145
+ translationFiles.forEach(({ name, content }) => {
146
+ const keysInSource = Object.keys(content);
147
+ const found = [];
148
+ for (const keyInSource of keysInSource) {
149
+ // Check if key is part of a dynamic namespace
150
+ // Skip the key if it is part of the dynamic namespace
151
+ const isDynamicNamespace = dynamicNamespaces.find((dynamicNamespace) => {
152
+ const keyInSourceNamespaces = keyInSource.split('.');
153
+ return dynamicNamespace.split('.').every((namePart, index) => {
154
+ return namePart === keyInSourceNamespaces[index];
155
+ });
156
+ });
157
+ if (isDynamicNamespace) {
158
+ continue;
159
+ }
160
+ if (!extractedResultSet.has(keyInSource)) {
161
+ found.push(keyInSource);
162
+ }
163
+ }
164
+ unusedKeys[name] = found;
165
+ });
166
+ return unusedKeys;
167
+ };
168
+ const checkUndefinedKeys = async (source, filesToParse, options = {
169
+ format: 'react-intl',
170
+ checks: [],
171
+ }, componentFunctions = []) => {
172
+ if (!options.format || !ParseFormats.includes(options.format)) {
173
+ return undefined;
174
+ }
175
+ if (!options.checks || !options.checks.includes('undefined')) {
176
+ return undefined;
177
+ }
178
+ if (options.format === 'react-intl') {
179
+ return findUndefinedReactIntlKeys(source, filesToParse, options);
180
+ }
181
+ else if (options.format === 'i18next') {
182
+ return findUndefinedI18NextKeys(source, filesToParse, options, componentFunctions);
183
+ }
184
+ else if (options.format === 'next-intl') {
185
+ return findUndefinedNextIntlKeys(source, filesToParse, options);
186
+ }
187
+ };
188
+ exports.checkUndefinedKeys = checkUndefinedKeys;
189
+ const findUndefinedReactIntlKeys = async (translationFiles, filesToParse, options = {
190
+ ignore: [],
191
+ }) => {
192
+ const sourceKeys = new Set(translationFiles.flatMap(({ content }) => {
193
+ return Object.keys(content);
194
+ }));
195
+ const extractedResult = await (0, cli_lib_1.extract)(filesToParse, {
196
+ extractSourceLocation: true,
197
+ });
198
+ const undefinedKeys = {};
199
+ Object.entries(JSON.parse(extractedResult)).forEach(([key, meta]) => {
200
+ if (!sourceKeys.has(key) && !isIgnoredKey(options.ignore ?? [], key)) {
201
+ const data = meta;
202
+ if (!('file' in data) || typeof data.file !== 'string') {
203
+ return;
204
+ }
205
+ const file = path_1.default.normalize(data.file);
206
+ if (!undefinedKeys[file]) {
207
+ undefinedKeys[file] = [];
208
+ }
209
+ undefinedKeys[file].push(key);
210
+ }
211
+ });
212
+ return undefinedKeys;
213
+ };
214
+ const findUndefinedI18NextKeys = async (source, filesToParse, options = {
215
+ ignore: [],
216
+ }, componentFunctions = []) => {
217
+ const { extractedResult, skippableKeys } = await getI18NextKeysInCode(filesToParse, componentFunctions);
218
+ const sourceKeys = new Set(source
219
+ .flatMap(({ content }) => {
220
+ return Object.keys(content);
221
+ })
222
+ // Ensure that any plural definitiions like key_one, key_other etc.
223
+ // are flatted into a single key
224
+ .map((key) => {
225
+ const pluralSuffix = constants_1.I18NEXT_PLURAL_SUFFIX.find((suffix) => {
226
+ return key.endsWith(suffix);
227
+ });
228
+ return pluralSuffix ? key.replace(pluralSuffix, '') : key;
229
+ }));
230
+ const undefinedKeys = {};
231
+ extractedResult.forEach(({ file, key }) => {
232
+ const isSkippable = skippableKeys.find((skippableKey) => {
233
+ return key.includes(skippableKey);
234
+ });
235
+ if (isSkippable === undefined &&
236
+ !sourceKeys.has(key) &&
237
+ !isIgnoredKey(options.ignore ?? [], key)) {
238
+ if (!undefinedKeys[file]) {
239
+ undefinedKeys[file] = [];
240
+ }
241
+ undefinedKeys[file].push(key);
242
+ }
243
+ });
244
+ return undefinedKeys;
245
+ };
246
+ const findUndefinedNextIntlKeys = async (translationFiles, filesToParse, options = {
247
+ ignore: [],
248
+ }) => {
249
+ const sourceKeys = new Set(translationFiles.flatMap(({ content }) => {
250
+ return Object.keys(content);
251
+ }));
252
+ const extractedResult = (0, nextIntlSrcParser_1.extract)(filesToParse);
253
+ const undefinedKeys = {};
254
+ extractedResult.forEach(({ key, meta }) => {
255
+ if (!meta.dynamic &&
256
+ !sourceKeys.has(key) &&
257
+ !isIgnoredKey(options.ignore ?? [], key)) {
258
+ const file = meta.file;
259
+ if (!undefinedKeys[file]) {
260
+ undefinedKeys[file] = [];
261
+ }
262
+ undefinedKeys[file].push(key);
263
+ }
264
+ });
265
+ return undefinedKeys;
266
+ };
267
+ const isRecord = (data) => {
268
+ return (typeof data === 'object' &&
269
+ !Array.isArray(data) &&
270
+ data !== null &&
271
+ data !== undefined);
272
+ };
273
+ const getI18NextKeysInCode = async (filesToParse, componentFunctions = []) => {
274
+ // Skip any parsed keys that have the `returnObjects` property set to true
275
+ // As these are used dynamically, they will be skipped to prevent
276
+ // these keys from being marked as unused.
277
+ const extractedResult = [];
278
+ const skippableKeys = [];
279
+ filesToParse.forEach((file) => {
280
+ const rawContent = fs_1.default.readFileSync(file, 'utf-8');
281
+ const entries = (0, i18NextSrcParser_1.getKeys)(file, {
282
+ componentFunctions: componentFunctions.concat(['Trans']),
283
+ }, rawContent);
284
+ // Intermediate solution to retrieve all keys from the parser.
285
+ // This will be built out to also include the namespace and check
286
+ // the key against the namespace corresponding file.
287
+ // The current implementation considers the key as used no matter the namespace.
288
+ for (const entry of entries) {
289
+ // check for namespace, i.e. `namespace:some.key`
290
+ const [namespace, ...keyParts] = entry.key.split(':');
291
+ // If there is a namespace make sure to assign the namespace
292
+ // and update the key name
293
+ // Ensure that the assumed key is not the default value
294
+ if (keyParts.length > 0 && entry.key !== entry.defaultValue) {
295
+ entry.namespace = namespace;
296
+ // rebuild the key without the namespace
297
+ entry.key = keyParts.join(':');
298
+ }
299
+ if (entry.returnObjects) {
300
+ skippableKeys.push(entry.key);
301
+ }
302
+ else {
303
+ extractedResult.push({
304
+ file,
305
+ key: entry.key,
306
+ namespace: entry.namespace,
307
+ });
308
+ }
309
+ }
310
+ });
311
+ return { extractedResult, skippableKeys };
312
+ };
313
+ const filterKeys = (content, keysToIgnore = []) => {
314
+ if (keysToIgnore.length > 0) {
315
+ return Object.entries(content).reduce((acc, [key, value]) => {
316
+ if (isIgnoredKey(keysToIgnore, key)) {
317
+ return acc;
318
+ }
319
+ acc[key] = value;
320
+ return acc;
321
+ }, {});
322
+ }
323
+ return content;
324
+ };
325
+ const isIgnoredKey = (keysToIgnore, key) => {
326
+ return (keysToIgnore.find((ignoreKey) => {
327
+ if (ignoreKey.endsWith('*')) {
328
+ return key.includes(ignoreKey.slice(0, ignoreKey.length - 1));
329
+ }
330
+ return ignoreKey === key;
331
+ }) !== undefined);
332
+ };
333
+ function _flatten(object, prefix = null, result = {}) {
334
+ for (const key in object) {
335
+ const propName = prefix ? `${prefix}.${key}` : key;
336
+ const data = object[key];
337
+ if (isRecord(data)) {
338
+ _flatten(data, propName, result);
339
+ }
340
+ else {
341
+ result[propName] = data;
342
+ }
343
+ }
344
+ return result;
345
+ }
@@ -0,0 +1,23 @@
1
+ import { Context } from './errorReporters';
2
+ export type Translation = Record<string, unknown>;
3
+ export type CheckResult = Record<string, string[]>;
4
+ export type InvalidTranslationEntry = {
5
+ key: string;
6
+ msg: string;
7
+ };
8
+ export type InvalidTranslationsResult = Record<string, InvalidTranslationEntry[]>;
9
+ export type TranslationFile = {
10
+ reference: string | null;
11
+ name: string;
12
+ content: Translation;
13
+ };
14
+ export type FileInfo = {
15
+ file: string;
16
+ name: string;
17
+ path: string[];
18
+ };
19
+ export type Options = {
20
+ format?: 'icu' | 'i18next' | 'react-intl' | 'next-intl';
21
+ checks?: Context[];
22
+ ignore?: string[];
23
+ };
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1 @@
1
+ export declare const I18NEXT_PLURAL_SUFFIX: string[];
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.I18NEXT_PLURAL_SUFFIX = void 0;
4
+ exports.I18NEXT_PLURAL_SUFFIX = [
5
+ '_zero',
6
+ '_one',
7
+ '_two',
8
+ '_few',
9
+ '_many',
10
+ '_other',
11
+ '_interval',
12
+ ];
@@ -0,0 +1,11 @@
1
+ /**
2
+ *
3
+ * i18next specific invalid translations check
4
+ *
5
+ *
6
+ */
7
+ import { MessageFormatElement } from './i18NextParser';
8
+ import { InvalidTranslationEntry, InvalidTranslationsResult, Translation } from '../types';
9
+ export declare const findInvalidI18NextTranslations: (source: Translation, targets: Record<string, Translation>) => InvalidTranslationsResult;
10
+ export declare const compareTranslationFiles: (a: Translation, b: Translation) => InvalidTranslationEntry[];
11
+ export declare const hasDiff: (a: MessageFormatElement[], b: MessageFormatElement[]) => boolean;
@@ -0,0 +1,192 @@
1
+ "use strict";
2
+ /**
3
+ *
4
+ * i18next specific invalid translations check
5
+ *
6
+ *
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.hasDiff = exports.compareTranslationFiles = exports.findInvalidI18NextTranslations = void 0;
10
+ const i18NextParser_1 = require("./i18NextParser");
11
+ const findInvalidI18NextTranslations = (source, targets) => {
12
+ const differences = {};
13
+ if (Object.keys(targets).length === 0) {
14
+ return differences;
15
+ }
16
+ for (const [lang, file] of Object.entries(targets)) {
17
+ const result = (0, exports.compareTranslationFiles)(source, file);
18
+ if (result.length > 0) {
19
+ differences[lang] = result;
20
+ }
21
+ }
22
+ return differences;
23
+ };
24
+ exports.findInvalidI18NextTranslations = findInvalidI18NextTranslations;
25
+ const compareTranslationFiles = (a, b) => {
26
+ const diffs = [];
27
+ for (const key in a) {
28
+ if (b[key] === undefined) {
29
+ continue;
30
+ }
31
+ const parsedTranslationA = (0, i18NextParser_1.parse)(String(a[key]));
32
+ const parsedTranslationB = (0, i18NextParser_1.parse)(String(b[key]));
33
+ if ((0, exports.hasDiff)(parsedTranslationA, parsedTranslationB)) {
34
+ const msg = getErrorMessage(parsedTranslationA, parsedTranslationB);
35
+ diffs.push({ key, msg });
36
+ }
37
+ }
38
+ return diffs;
39
+ };
40
+ exports.compareTranslationFiles = compareTranslationFiles;
41
+ const lookUp = {
42
+ text: 0,
43
+ interpolation: 1,
44
+ interpolation_unescaped: 2,
45
+ nesting: 3,
46
+ plural: 4,
47
+ tag: 5,
48
+ };
49
+ const sortParsedKeys = (a, b) => {
50
+ if (a.type === b.type && a.type !== 'tag' && b.type !== 'tag') {
51
+ return a.content < b.content ? -1 : 1;
52
+ }
53
+ if (a.type === 'tag' && b.type === 'tag') {
54
+ return a.raw < b.raw ? -1 : 1;
55
+ }
56
+ return lookUp[a.type] - lookUp[b.type];
57
+ };
58
+ const hasDiff = (a, b) => {
59
+ const compA = a
60
+ .filter((element) => element.type !== 'text')
61
+ .sort(sortParsedKeys);
62
+ const compB = b
63
+ .filter((element) => element.type !== 'text')
64
+ .sort(sortParsedKeys);
65
+ if (compA.length !== compB.length) {
66
+ return true;
67
+ }
68
+ const hasErrors = compA.some((formatElementA, index) => {
69
+ const formatElementB = compB[index];
70
+ if (formatElementA.type !== formatElementB.type) {
71
+ return true;
72
+ }
73
+ if (formatElementA.type === 'tag' && formatElementB.type === 'tag') {
74
+ return (formatElementA.raw !== formatElementB.raw ||
75
+ formatElementA.voidElement !== formatElementB.voidElement);
76
+ }
77
+ if ((formatElementA.type === 'interpolation' &&
78
+ formatElementB.type === 'interpolation') ||
79
+ (formatElementA.type === 'interpolation_unescaped' &&
80
+ formatElementB.type === 'interpolation_unescaped') ||
81
+ (formatElementA.type === 'nesting' &&
82
+ formatElementB.type === 'nesting') ||
83
+ (formatElementA.type === 'plural' && formatElementB.type === 'plural')) {
84
+ const optionsA = formatElementA.variable
85
+ .split(',')
86
+ .map((value) => value.trim())
87
+ .sort()
88
+ .join('-')
89
+ .trim();
90
+ const optionsB = formatElementB.variable
91
+ .split(',')
92
+ .map((value) => value.trim())
93
+ .sort()
94
+ .join('-')
95
+ .trim();
96
+ if (optionsA !== optionsB) {
97
+ return true;
98
+ }
99
+ if (formatElementA.prefix !== formatElementA.prefix) {
100
+ return true;
101
+ }
102
+ if (formatElementA.suffix !== formatElementA.suffix) {
103
+ return true;
104
+ }
105
+ }
106
+ return false;
107
+ });
108
+ return hasErrors;
109
+ };
110
+ exports.hasDiff = hasDiff;
111
+ const getErrorMessage = (a, b) => {
112
+ const compA = a
113
+ .filter((element) => element.type !== 'text')
114
+ .sort(sortParsedKeys);
115
+ const compB = b
116
+ .filter((element) => element.type !== 'text')
117
+ .sort(sortParsedKeys);
118
+ const errors = compA.reduce((acc, formatElementA, index) => {
119
+ const formatElementB = compB[index];
120
+ if (!formatElementB) {
121
+ acc.push(`Missing element ${formatElementA.type}`);
122
+ return acc;
123
+ }
124
+ if (formatElementA.type !== formatElementB.type) {
125
+ acc.push(`Expected element of type "${formatElementA.type}" but received "${formatElementB.type}"`);
126
+ return acc;
127
+ }
128
+ if (formatElementA.type === 'tag' && formatElementB.type === 'tag') {
129
+ if (formatElementA.raw !== formatElementB.raw) {
130
+ acc.push(`Expected tag "${formatElementA.raw}" but received "${formatElementB.raw}"`);
131
+ }
132
+ else if (formatElementA.voidElement !== formatElementB.voidElement &&
133
+ formatElementA.voidElement === true) {
134
+ acc.push(`Expected a self-closing "${formatElementB.raw}" tag`);
135
+ return acc;
136
+ }
137
+ else if (formatElementA.voidElement !== formatElementB.voidElement &&
138
+ formatElementA.voidElement === false) {
139
+ acc.push(`Non expected self-closing "${formatElementB.raw}" tag`);
140
+ return acc;
141
+ }
142
+ }
143
+ if ((formatElementA.type === 'interpolation' &&
144
+ formatElementB.type === 'interpolation') ||
145
+ (formatElementA.type === 'interpolation_unescaped' &&
146
+ formatElementB.type === 'interpolation_unescaped') ||
147
+ (formatElementA.type === 'nesting' &&
148
+ formatElementB.type === 'nesting') ||
149
+ (formatElementA.type === 'plural' && formatElementB.type === 'plural')) {
150
+ if (formatElementA.prefix !== formatElementA.prefix) {
151
+ acc.push(`Error in ${formatElementA.type}: Expected prefix "${formatElementA.prefix}" but received "${formatElementB.prefix}"`);
152
+ return acc;
153
+ }
154
+ if (formatElementA.suffix !== formatElementA.suffix) {
155
+ acc.push(`Error in ${formatElementA.type}: Expected suffix "${formatElementA.suffix}" but received "${formatElementB.suffix}"`);
156
+ return acc;
157
+ }
158
+ const optionsA = formatElementA.variable
159
+ .split(',')
160
+ .map((value) => value.trim())
161
+ .sort();
162
+ const optionsB = formatElementB.variable
163
+ .split(',')
164
+ .map((value) => value.trim())
165
+ .sort();
166
+ const elementErrors = [];
167
+ optionsA.forEach((key, index) => {
168
+ if (key !== optionsB[index]) {
169
+ elementErrors.push(`Expected ${key} but received ${optionsB[index]}`);
170
+ }
171
+ });
172
+ if (elementErrors.length > 0) {
173
+ acc.push(`Error in ${formatElementA.type}: ${elementErrors
174
+ .flatMap((elementError) => elementError)
175
+ .join(', ')}`);
176
+ }
177
+ return acc;
178
+ }
179
+ return acc;
180
+ }, []);
181
+ if (compA.length < compB.length) {
182
+ const unexpectedElements = compB
183
+ .slice(compA.length)
184
+ .reduce((acc, formatElementB) => {
185
+ acc.push(`Unexpected ${formatElementB.type} element`);
186
+ return acc;
187
+ }, [])
188
+ .join(', ');
189
+ return [...errors, unexpectedElements].join(', ');
190
+ }
191
+ return errors.join(', ');
192
+ };
@@ -0,0 +1,16 @@
1
+ import { MessageFormatElement } from '@formatjs/icu-messageformat-parser';
2
+ import { InvalidTranslationEntry, InvalidTranslationsResult, Translation } from '../types';
3
+ type Location = {
4
+ start: {
5
+ line: number;
6
+ column: number;
7
+ };
8
+ };
9
+ export declare class CheckError extends Error {
10
+ location?: Location;
11
+ originalMessage?: string;
12
+ }
13
+ export declare const findInvalidTranslations: (source: Translation, files: Record<string, Translation>) => InvalidTranslationsResult;
14
+ export declare const compareTranslationFiles: (a: Translation, b: Translation) => InvalidTranslationEntry[];
15
+ export declare const hasDiff: (a: MessageFormatElement[], b: MessageFormatElement[]) => boolean;
16
+ export {};