@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.
@@ -0,0 +1,239 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.hasDiff = exports.compareTranslationFiles = exports.findInvalidTranslations = exports.CheckError = void 0;
4
+ const icu_messageformat_parser_1 = require("@formatjs/icu-messageformat-parser");
5
+ class CheckError extends Error {
6
+ }
7
+ exports.CheckError = CheckError;
8
+ function isLocation(value) {
9
+ return (typeof value === 'object' &&
10
+ value != null &&
11
+ 'start' in value &&
12
+ typeof value.start === 'object' &&
13
+ value.start != null &&
14
+ 'line' in value.start &&
15
+ typeof value.start.line === 'number' &&
16
+ 'column' in value.start &&
17
+ typeof value.start.column === 'number');
18
+ }
19
+ const findInvalidTranslations = (source, files) => {
20
+ const differences = {};
21
+ if (Object.keys(files).length === 0) {
22
+ return differences;
23
+ }
24
+ for (const [lang, file] of Object.entries(files)) {
25
+ try {
26
+ const result = (0, exports.compareTranslationFiles)(source, file);
27
+ if (result.length > 0) {
28
+ differences[lang] = result;
29
+ }
30
+ }
31
+ catch (error) {
32
+ // Re-throw with file context
33
+ const enhancedError = new CheckError(`Error in translation file "${lang}": ${error instanceof Error ? error.message : String(error)}`);
34
+ if (error instanceof Error) {
35
+ if ('location' in error && isLocation(error.location)) {
36
+ enhancedError.location = error.location;
37
+ }
38
+ if ('originalMessage' in error &&
39
+ typeof error.originalMessage === 'string') {
40
+ enhancedError.originalMessage = error.originalMessage;
41
+ }
42
+ }
43
+ throw enhancedError;
44
+ }
45
+ }
46
+ return differences;
47
+ };
48
+ exports.findInvalidTranslations = findInvalidTranslations;
49
+ const sortParsedKeys = (a, b) => {
50
+ if (a.type === b.type) {
51
+ return !(0, icu_messageformat_parser_1.isPoundElement)(a) && !(0, icu_messageformat_parser_1.isPoundElement)(b)
52
+ ? a.value < b.value
53
+ ? -1
54
+ : 1
55
+ : -1;
56
+ }
57
+ return a.type - b.type;
58
+ };
59
+ const compareTranslationFiles = (a, b) => {
60
+ const diffs = [];
61
+ for (const key in a) {
62
+ if (b[key] === undefined) {
63
+ continue;
64
+ }
65
+ try {
66
+ const parsedTranslationA = (0, icu_messageformat_parser_1.parse)(String(a[key]));
67
+ const parsedTranslationB = (0, icu_messageformat_parser_1.parse)(String(b[key]));
68
+ if ((0, exports.hasDiff)(parsedTranslationA, parsedTranslationB)) {
69
+ const msg = getErrorMessage(parsedTranslationA, parsedTranslationB);
70
+ diffs.push({ key, msg });
71
+ }
72
+ }
73
+ catch (error) {
74
+ // Re-throw with key context and preserve location/originalMessage
75
+ const errorMessage = error instanceof Error ? error.message : String(error);
76
+ const enhancedError = new CheckError(`Failed to parse translation key "${key}": ${errorMessage === 'INVALID_TAG' ? 'Invalid ICU message format tags found in translation content' : errorMessage}`);
77
+ if (error instanceof Error) {
78
+ if ('location' in error && isLocation(error.location)) {
79
+ enhancedError.location = error.location;
80
+ }
81
+ if ('originalMessage' in error &&
82
+ typeof error.originalMessage === 'string') {
83
+ enhancedError.originalMessage = error.originalMessage;
84
+ }
85
+ }
86
+ throw enhancedError;
87
+ }
88
+ }
89
+ return diffs;
90
+ };
91
+ exports.compareTranslationFiles = compareTranslationFiles;
92
+ const hasDiff = (a, b) => {
93
+ const compA = a
94
+ .filter((element) => !(0, icu_messageformat_parser_1.isLiteralElement)(element))
95
+ .sort(sortParsedKeys);
96
+ const compB = b
97
+ .filter((element) => !(0, icu_messageformat_parser_1.isLiteralElement)(element))
98
+ .sort(sortParsedKeys);
99
+ if (compA.length !== compB.length) {
100
+ return true;
101
+ }
102
+ const hasErrors = compA.some((formatElementA, index) => {
103
+ const formatElementB = compB[index];
104
+ if ((0, icu_messageformat_parser_1.isPluralElement)(formatElementA) || (0, icu_messageformat_parser_1.isPluralElement)(formatElementB)) {
105
+ const optionsA = (0, icu_messageformat_parser_1.isPluralElement)(formatElementA)
106
+ ? formatElementA.options
107
+ : {};
108
+ const optionsB = (0, icu_messageformat_parser_1.isPluralElement)(formatElementB)
109
+ ? formatElementB.options
110
+ : {};
111
+ return Object.keys(optionsA)
112
+ .sort()
113
+ .some((key) => {
114
+ // We can only compare translations that have the same plural keys.
115
+ // In English, we might have "one", "other", but in German, we might have "one", "few", "other".
116
+ // Or, in Arabic it might just be "other".
117
+ // So, we'll have to skip over the ones that don't have a one-to-one match.
118
+ if (!optionsB[key]) {
119
+ return false;
120
+ }
121
+ return (0, exports.hasDiff)(optionsA[key].value, optionsB[key].value);
122
+ });
123
+ }
124
+ if (formatElementA.type !== formatElementB.type ||
125
+ formatElementA.location !== formatElementB.location) {
126
+ return true;
127
+ }
128
+ if (((0, icu_messageformat_parser_1.isLiteralElement)(formatElementA) && (0, icu_messageformat_parser_1.isLiteralElement)(formatElementB)) ||
129
+ ((0, icu_messageformat_parser_1.isPoundElement)(formatElementA) && (0, icu_messageformat_parser_1.isPoundElement)(formatElementB))) {
130
+ return false;
131
+ }
132
+ if (!(0, icu_messageformat_parser_1.isPoundElement)(formatElementA) &&
133
+ !(0, icu_messageformat_parser_1.isPoundElement)(formatElementB) &&
134
+ formatElementA.value !== formatElementB.value) {
135
+ return true;
136
+ }
137
+ if ((0, icu_messageformat_parser_1.isTagElement)(formatElementA) && (0, icu_messageformat_parser_1.isTagElement)(formatElementB)) {
138
+ return (0, exports.hasDiff)(formatElementA.children, formatElementB.children);
139
+ }
140
+ if ((0, icu_messageformat_parser_1.isSelectElement)(formatElementA) && (0, icu_messageformat_parser_1.isSelectElement)(formatElementB)) {
141
+ const optionsA = Object.keys(formatElementA.options).sort();
142
+ const optionsB = Object.keys(formatElementB.options).sort();
143
+ if (optionsA.join('-') !== optionsB.join('-')) {
144
+ return true;
145
+ }
146
+ return optionsA.some((key) => {
147
+ return (0, exports.hasDiff)(formatElementA.options[key].value, formatElementB.options[key].value);
148
+ });
149
+ }
150
+ return false;
151
+ });
152
+ return hasErrors;
153
+ };
154
+ exports.hasDiff = hasDiff;
155
+ const getErrorMessage = (a, b) => {
156
+ const compA = a
157
+ .filter((element) => !(0, icu_messageformat_parser_1.isLiteralElement)(element))
158
+ .sort(sortParsedKeys);
159
+ const compB = b
160
+ .filter((element) => !(0, icu_messageformat_parser_1.isLiteralElement)(element))
161
+ .sort(sortParsedKeys);
162
+ const errors = compA.reduce((acc, formatElementA, index) => {
163
+ const formatElementB = compB[index];
164
+ if (!formatElementB) {
165
+ acc.push(`Missing element ${typeLookup[formatElementA.type]}`);
166
+ return acc;
167
+ }
168
+ if (formatElementA.type !== formatElementB.type) {
169
+ acc.push(`Expected element of type "${typeLookup[formatElementA.type]}" but received "${typeLookup[formatElementB.type]}"`);
170
+ return acc;
171
+ }
172
+ if (formatElementA.location !== formatElementB.location) {
173
+ acc.push(`Expected location to be ${formatElementA.location?.start?.line}:${formatElementA.location?.start?.column}`);
174
+ return acc;
175
+ }
176
+ if ((0, icu_messageformat_parser_1.isPoundElement)(formatElementA) && (0, icu_messageformat_parser_1.isPoundElement)(formatElementB)) {
177
+ return acc;
178
+ }
179
+ if (!(0, icu_messageformat_parser_1.isPoundElement)(formatElementA) &&
180
+ !(0, icu_messageformat_parser_1.isPoundElement)(formatElementB) &&
181
+ formatElementA.value !== formatElementB.value) {
182
+ acc.push(`Expected ${typeLookup[formatElementA.type]} to contain "${formatElementA.value}" but received "${formatElementB.value}"`);
183
+ return acc;
184
+ }
185
+ if ((0, icu_messageformat_parser_1.isTagElement)(formatElementA) && (0, icu_messageformat_parser_1.isTagElement)(formatElementB)) {
186
+ acc.push(`Error in pound element: ${getErrorMessage(formatElementA.children, formatElementB.children)}`);
187
+ return acc;
188
+ }
189
+ if ((0, icu_messageformat_parser_1.isSelectElement)(formatElementA) && (0, icu_messageformat_parser_1.isSelectElement)(formatElementB)) {
190
+ const optionsA = Object.keys(formatElementA.options).sort();
191
+ const elementErrors = [];
192
+ optionsA.forEach((key) => {
193
+ if (formatElementB.options[key]) {
194
+ elementErrors.push(getErrorMessage(formatElementA.options[key].value, formatElementB.options[key].value));
195
+ }
196
+ });
197
+ acc.push(`Error in select: ${elementErrors
198
+ .flatMap((elementError) => elementError)
199
+ .join(', ')}`);
200
+ return acc;
201
+ }
202
+ if ((0, icu_messageformat_parser_1.isPluralElement)(formatElementA) && (0, icu_messageformat_parser_1.isPluralElement)(formatElementB)) {
203
+ const optionsA = Object.keys(formatElementA.options).sort();
204
+ const elementErrors = [];
205
+ optionsA.forEach((key) => {
206
+ if (formatElementB.options[key]) {
207
+ elementErrors.push(getErrorMessage(formatElementA.options[key].value, formatElementB.options[key].value));
208
+ }
209
+ });
210
+ acc.push(`Error in plural: ${elementErrors
211
+ .flatMap((elementError) => elementError)
212
+ .join(', ')}`);
213
+ return acc;
214
+ }
215
+ return acc;
216
+ }, []);
217
+ if (compA.length < compB.length) {
218
+ const unexpectedElements = compB
219
+ .slice(compA.length)
220
+ .reduce((acc, formatElementB) => {
221
+ acc.push(`Unexpected ${typeLookup[formatElementB.type]} element`);
222
+ return acc;
223
+ }, [])
224
+ .join(', ');
225
+ return [...errors, unexpectedElements].join(', ');
226
+ }
227
+ return errors.join(', ');
228
+ };
229
+ const typeLookup = {
230
+ 0: 'literal',
231
+ 1: 'argument',
232
+ 2: 'number',
233
+ 3: 'date',
234
+ 4: 'time',
235
+ 5: 'select',
236
+ 6: 'plural',
237
+ 7: 'pound',
238
+ 8: 'tag',
239
+ };
@@ -0,0 +1,4 @@
1
+ import { Options, Translation } from '../types';
2
+ export declare const findMissingKeys: (source: Translation, targets: Record<string, Translation>, options?: Options) => Record<string, string[]>;
3
+ export declare const compareTranslationFiles: (src: Translation, target: Translation) => string[];
4
+ export declare const compareI18nextTranslationFiles: (src: Translation, target: Translation) => string[];
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.compareI18nextTranslationFiles = exports.compareTranslationFiles = exports.findMissingKeys = void 0;
4
+ const constants_1 = require("./constants");
5
+ const findMissingKeys = (source, targets, options = {}) => {
6
+ const differences = {};
7
+ for (const [lang, file] of Object.entries(targets)) {
8
+ const result = options.format === 'i18next'
9
+ ? (0, exports.compareI18nextTranslationFiles)(source, file)
10
+ : (0, exports.compareTranslationFiles)(source, file);
11
+ if (result.length > 0) {
12
+ differences[lang] = result;
13
+ }
14
+ }
15
+ return differences;
16
+ };
17
+ exports.findMissingKeys = findMissingKeys;
18
+ const compareTranslationFiles = (src, target) => {
19
+ const diffs = [];
20
+ for (const key in src) {
21
+ const counterKey = target[key];
22
+ if (!counterKey) {
23
+ diffs.push(key);
24
+ }
25
+ }
26
+ return diffs;
27
+ };
28
+ exports.compareTranslationFiles = compareTranslationFiles;
29
+ const compareI18nextTranslationFiles = (src, target) => {
30
+ const diffs = [];
31
+ const flattedSrc = Object.entries(src).reduce((acc, [k, v]) => {
32
+ const pluralSuffix = constants_1.I18NEXT_PLURAL_SUFFIX.find((suffix) => {
33
+ return k.endsWith(suffix);
34
+ });
35
+ const key = pluralSuffix ? k.replace(pluralSuffix, '') : k;
36
+ acc[key] = v;
37
+ return acc;
38
+ }, {});
39
+ const flattedTarget = Object.entries(target).reduce((acc, [k, v]) => {
40
+ const pluralSuffix = constants_1.I18NEXT_PLURAL_SUFFIX.find((suffix) => {
41
+ return k.endsWith(suffix);
42
+ });
43
+ const key = pluralSuffix ? k.replace(pluralSuffix, '') : k;
44
+ acc[key] = v;
45
+ return acc;
46
+ }, {});
47
+ for (const key in flattedSrc) {
48
+ const counterKey = flattedTarget[key];
49
+ if (!counterKey) {
50
+ diffs.push(key);
51
+ }
52
+ }
53
+ return diffs;
54
+ };
55
+ exports.compareI18nextTranslationFiles = compareI18nextTranslationFiles;
@@ -0,0 +1,3 @@
1
+ import { Translation } from '../types';
2
+ export declare const flattenTranslations: (translations: Translation) => Translation;
3
+ export declare const flattenEntry: (entry: Translation, keys?: string[]) => Translation;
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.flattenEntry = exports.flattenTranslations = void 0;
4
+ const flattenTranslations = (translations) => {
5
+ if (!hasNestedDefinitions(translations)) {
6
+ return translations;
7
+ }
8
+ return (0, exports.flattenEntry)(translations);
9
+ };
10
+ exports.flattenTranslations = flattenTranslations;
11
+ /**
12
+ * Top level search for any objects
13
+ */
14
+ const hasNestedDefinitions = (translations) => {
15
+ return Object.values(translations).find((translation) => typeof translation === 'object');
16
+ };
17
+ const isTranslationObject = (entry) => {
18
+ return typeof entry === 'object';
19
+ };
20
+ const flattenEntry = (entry, keys = []) => {
21
+ const result = {};
22
+ if (!entry) {
23
+ return result;
24
+ }
25
+ const entries = Object.entries(entry);
26
+ for (const [k, v] of entries) {
27
+ Object.assign(result, isTranslationObject(v)
28
+ ? (0, exports.flattenEntry)(v, [...keys, String(k)])
29
+ : { [[...keys, String(k)].join('.')]: v });
30
+ }
31
+ return result;
32
+ };
33
+ exports.flattenEntry = flattenEntry;
@@ -0,0 +1,37 @@
1
+ export type MessageFormatElement = {
2
+ type: 'text';
3
+ content: string;
4
+ } | {
5
+ type: 'interpolation';
6
+ raw: string;
7
+ prefix: string;
8
+ suffix: string;
9
+ content: string;
10
+ variable: string;
11
+ } | {
12
+ type: 'interpolation_unescaped';
13
+ raw: string;
14
+ prefix: string;
15
+ suffix: string;
16
+ content: string;
17
+ variable: string;
18
+ } | {
19
+ type: 'nesting';
20
+ raw: string;
21
+ prefix: string;
22
+ suffix: string;
23
+ content: string;
24
+ variable: string;
25
+ } | {
26
+ type: 'plural';
27
+ raw: string;
28
+ prefix: string;
29
+ suffix: string;
30
+ content: string;
31
+ variable: string;
32
+ } | {
33
+ type: 'tag';
34
+ raw: string;
35
+ voidElement: boolean;
36
+ };
37
+ export declare const parse: (input: string) => MessageFormatElement[];
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+ // Based on https://github.com/i18next/i18next-translation-parser/blob/v1.0.0/src/parse.js
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.parse = void 0;
5
+ const REGEXP = new RegExp('({{[^}]+}}|\\$t{[^}]+}|\\$t\\([^\\)]+\\)|\\([0-9\\-inf]+\\)(?=\\[)|<[^>]+>)', 'g');
6
+ const DOUBLE_BRACE = '{{';
7
+ const $_T_BRACE = '$t{';
8
+ const $_T_PARENTHESIS = '$t(';
9
+ const OPEN_PARENTHESIS = '(';
10
+ const OPEN_TAG = '<';
11
+ const CLOSE_TAG = '</';
12
+ const parse = (input) => {
13
+ let ast = [];
14
+ ast = parseInput([input]);
15
+ return ast;
16
+ };
17
+ exports.parse = parse;
18
+ const parseInput = (input) => {
19
+ if (input.length === 0) {
20
+ return [];
21
+ }
22
+ let ast = [];
23
+ input.forEach((element) => {
24
+ const elements = element.split(REGEXP).filter((element) => element !== '');
25
+ const result = elements.reduce((acc, match) => {
26
+ if (match.indexOf('{{-') === 0) {
27
+ const content = match.substring(3, match.length - 2);
28
+ acc.push({
29
+ type: 'interpolation_unescaped',
30
+ raw: match,
31
+ prefix: '{{-',
32
+ suffix: '}}',
33
+ content,
34
+ variable: content.trim(),
35
+ });
36
+ }
37
+ else if (match.indexOf(DOUBLE_BRACE) === 0) {
38
+ const content = match.substring(2, match.length - 2);
39
+ acc.push({
40
+ type: 'interpolation',
41
+ raw: match,
42
+ prefix: '{{',
43
+ suffix: '}}',
44
+ content,
45
+ variable: content.trim(),
46
+ });
47
+ }
48
+ else if (match.indexOf($_T_BRACE) === 0) {
49
+ const content = match.substring(3, match.length - 1);
50
+ acc.push({
51
+ type: 'nesting',
52
+ raw: match,
53
+ prefix: '$t{',
54
+ suffix: '}',
55
+ content,
56
+ variable: content.trim(),
57
+ });
58
+ }
59
+ else if (match.indexOf($_T_PARENTHESIS) === 0) {
60
+ const content = match.substring(3, match.length - 1);
61
+ acc.push({
62
+ type: 'nesting',
63
+ raw: match,
64
+ prefix: '$t(',
65
+ suffix: ')',
66
+ content,
67
+ variable: content.trim(),
68
+ });
69
+ }
70
+ else if (match.indexOf(OPEN_PARENTHESIS) === 0 &&
71
+ /\([0-9\-inf]+\)/.test(match)) {
72
+ const content = match.substring(1, match.length - 1);
73
+ acc.push({
74
+ type: 'plural',
75
+ raw: match,
76
+ prefix: '(',
77
+ suffix: ')',
78
+ content,
79
+ variable: content.trim(),
80
+ });
81
+ }
82
+ else if (match.indexOf(CLOSE_TAG) === 0) {
83
+ acc.push({
84
+ type: 'tag',
85
+ raw: match,
86
+ voidElement: match.substring(match.length - 2) === '/>',
87
+ });
88
+ }
89
+ else if (match.indexOf(OPEN_TAG) === 0 && /<[^\s]+/.test(match)) {
90
+ acc.push({
91
+ type: 'tag',
92
+ raw: match,
93
+ voidElement: match.substring(match.length - 2) === '/>',
94
+ });
95
+ }
96
+ else {
97
+ acc.push({ type: 'text', content: match });
98
+ }
99
+ return acc;
100
+ }, []);
101
+ ast = ast.concat(result);
102
+ });
103
+ return ast;
104
+ };
@@ -0,0 +1,35 @@
1
+ /**
2
+ *
3
+ * Based on the original [i18next-parser](https://github.com/i18next/i18next-parser)
4
+ *
5
+ */
6
+ type Options = {
7
+ attr: string;
8
+ componentFunctions: string[];
9
+ functions: string[];
10
+ namespaceFunctions: string[];
11
+ parseGenerics: boolean;
12
+ transSupportBasicHtmlNodes: boolean;
13
+ transIdentityFunctionsToIgnore: string[];
14
+ typeMap: Record<string, unknown>;
15
+ translationFunctionsWithArgs: Record<string, {
16
+ pos: number;
17
+ storeGlobally: boolean;
18
+ keyPrefix?: string;
19
+ ns?: string;
20
+ }>;
21
+ keyPrefix?: string;
22
+ defaultNamespace?: string;
23
+ transKeepBasicHtmlNodesFor: string[];
24
+ omitAttributes: string[];
25
+ };
26
+ type FoundKey = {
27
+ key: string;
28
+ ns?: string;
29
+ namespace?: string;
30
+ functionName?: string;
31
+ defaultValue?: string;
32
+ [key: string]: unknown;
33
+ };
34
+ export declare const getKeys: (path: string, options: Partial<Options>, content: string) => FoundKey[];
35
+ export {};