@lingual/i18n-check 0.9.2 → 0.9.4

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/README.md CHANGED
@@ -259,6 +259,34 @@ yarn i18n:check --locales translations/i18NextMessageExamples -s en-US -f i18nex
259
259
  -u src --parser-component-functions WrappedTransComponent AnotherWrappedTransComponent
260
260
  ```
261
261
 
262
+ ### --next-intl-translation-fn-type-alias
263
+
264
+ To find any indirect function calls inside a `next-intl` codebase, the parser will try to find the type `ReturnType<typeof useTranslations>` in the function call arguments.
265
+
266
+ ```ts
267
+ const indirectFnCall = (
268
+ t: ReturnType<typeof useTranslations<'SomeNameSpace'>>
269
+ ) => {
270
+ const indirectTranslation = t('someKey');
271
+ // Do something with the indirectTranslation
272
+ };
273
+ ```
274
+
275
+ When a type alias is used instead of `ReturnType<typeof useTranslations>`, the `--next-intl-translation-fn-type-alias` option can be used to tell the parser the name of the type or types.
276
+
277
+ ```ts
278
+ import { NextIntlTranslateFnAlias } from './types';
279
+
280
+ const indirectFnCall = (t: NextIntlTranslateFnAlias<'SomeNameSpace'>) => {
281
+ const indirectTranslation = t('someKey');
282
+ // Do something with the indirectTranslation
283
+ };
284
+ ```
285
+
286
+ ```bash
287
+ yarn i18n:check --source en --locales translations/codeExamples/next-intl/locales/ -f next-intl -u translations/codeExamples/next-intl/src --next-intl-translation-fn-type-alias NextIntlTranslateFnAlias
288
+ ```
289
+
262
290
  ## Examples
263
291
 
264
292
  i18n-check is able to load and validate against different locale folder structures. Depending on how the locale files are organized, there are different configuration options.
package/dist/bin/index.js CHANGED
@@ -9,7 +9,7 @@ const node_process_1 = require("node:process");
9
9
  const chalk_1 = __importDefault(require("chalk"));
10
10
  const commander_1 = require("commander");
11
11
  const glob_1 = require("glob");
12
- const js_yaml_1 = __importDefault(require("js-yaml"));
12
+ const yaml_1 = require("yaml");
13
13
  const __1 = require("..");
14
14
  const errorReporters_1 = require("../errorReporters");
15
15
  const flattenTranslations_1 = require("../utils/flattenTranslations");
@@ -28,6 +28,7 @@ commander_1.program
28
28
  .option('-i, --ignore <ignore...>', 'define the key(s) or group of keys (i.e. `some.namespace.*`) that should be excluded from the check')
29
29
  .option('-u, --unused <paths...>', 'define the source path(s) to find all unused and undefined keys')
30
30
  .option('--parser-component-functions <components...>', 'a list of component names to parse when using the --unused option')
31
+ .option('--next-intl-translation-fn-type-alias <nextIntlTranslationFnTypeAliases...>', 'next-intl translation function type aliases')
31
32
  .parse();
32
33
  const getCheckOptions = () => {
33
34
  const checkOption = commander_1.program.getOptionValue('only') || commander_1.program.getOptionValue('check');
@@ -52,6 +53,7 @@ const main = async () => {
52
53
  const ignore = commander_1.program.getOptionValue('ignore');
53
54
  const unusedSrcPath = commander_1.program.getOptionValue('unused');
54
55
  const componentFunctions = commander_1.program.getOptionValue('parserComponentFunctions');
56
+ const nextIntlTranslationFnTypeAlias = commander_1.program.getOptionValue('nextIntlTranslationFnTypeAlias');
55
57
  if (!srcPath) {
56
58
  console.log(chalk_1.default.red('Source not found. Please provide a valid source locale, i.e. -s en-US'));
57
59
  (0, node_process_1.exit)(1);
@@ -81,6 +83,7 @@ const main = async () => {
81
83
  checks: getCheckOptions(),
82
84
  format: format ?? undefined,
83
85
  ignore,
86
+ nextIntlTranslationFnTypeAlias,
84
87
  };
85
88
  const fileInfos = [];
86
89
  files.sort().forEach((file) => {
@@ -97,7 +100,7 @@ const main = async () => {
97
100
  fileInfos.forEach(({ extension, file, name, path }) => {
98
101
  let rawContent;
99
102
  if (extension === 'yaml') {
100
- rawContent = js_yaml_1.default.load(node_fs_1.default.readFileSync(file, 'utf-8'));
103
+ rawContent = (0, yaml_1.parse)(node_fs_1.default.readFileSync(file, 'utf-8'));
101
104
  }
102
105
  else {
103
106
  rawContent = JSON.parse(node_fs_1.default.readFileSync(file, 'utf-8'));
package/dist/index.js CHANGED
@@ -7,13 +7,13 @@ exports.checkUndefinedKeys = exports.checkUnusedKeys = exports.checkTranslations
7
7
  const findMissingKeys_1 = require("./utils/findMissingKeys");
8
8
  const findInvalidTranslations_1 = require("./utils/findInvalidTranslations");
9
9
  const findInvalidI18NextTranslations_1 = require("./utils/findInvalidI18NextTranslations");
10
- const cli_lib_1 = require("@formatjs/cli-lib");
11
10
  const i18NextSrcParser_1 = require("./utils/i18NextSrcParser");
12
11
  const nextIntlSrcParser_1 = require("./utils/nextIntlSrcParser");
13
12
  const fs_1 = __importDefault(require("fs"));
14
13
  const path_1 = __importDefault(require("path"));
15
14
  const constants_1 = require("./utils/constants");
16
15
  const ParseFormats = ['react-intl', 'i18next', 'next-intl'];
16
+ const CONTEXT_SEPARATOR = '_';
17
17
  const checkInvalidTranslations = (source, targets, options = { format: 'icu' }) => {
18
18
  return options.format === 'i18next'
19
19
  ? (0, findInvalidI18NextTranslations_1.findInvalidI18NextTranslations)(source, targets)
@@ -74,25 +74,28 @@ const checkUnusedKeys = async (translationFiles, filesToParse, options = {
74
74
  return findUnusedI18NextTranslations(filteredTranslationFiles, filesToParse, componentFunctions);
75
75
  }
76
76
  else if (options.format === 'next-intl') {
77
- return findUnusedNextIntlTranslations(filteredTranslationFiles, filesToParse);
77
+ return findUnusedNextIntlTranslations(filteredTranslationFiles, filesToParse, options);
78
78
  }
79
79
  };
80
80
  exports.checkUnusedKeys = checkUnusedKeys;
81
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);
82
+ return (async () => {
83
+ const { extract } = await import('@formatjs/cli-lib');
84
+ const unusedKeys = {};
85
+ const extracted = await extract(filesToParse, {});
86
+ const extractedResultSet = new Set(Object.keys(JSON.parse(extracted)));
87
+ translationFiles.forEach(({ name, content }) => {
88
+ const keysInSource = Object.keys(content);
89
+ const found = [];
90
+ for (const keyInSource of keysInSource) {
91
+ if (!extractedResultSet.has(keyInSource)) {
92
+ found.push(keyInSource);
93
+ }
91
94
  }
92
- }
93
- unusedKeys[name] = found;
94
- });
95
- return unusedKeys;
95
+ unusedKeys[name] = found;
96
+ });
97
+ return unusedKeys;
98
+ })();
96
99
  };
97
100
  const findUnusedI18NextTranslations = async (source, filesToParse, componentFunctions = []) => {
98
101
  const unusedKeys = {};
@@ -127,9 +130,9 @@ const findUnusedI18NextTranslations = async (source, filesToParse, componentFunc
127
130
  });
128
131
  return unusedKeys;
129
132
  };
130
- const findUnusedNextIntlTranslations = async (translationFiles, filesToParse) => {
133
+ const findUnusedNextIntlTranslations = async (translationFiles, filesToParse, options) => {
131
134
  const unusedKeys = {};
132
- const extracted = (0, nextIntlSrcParser_1.extract)(filesToParse);
135
+ const extracted = (0, nextIntlSrcParser_1.extract)(filesToParse, options);
133
136
  const dynamicNamespaces = extracted.flatMap((namespace) => {
134
137
  if (namespace.meta.dynamic) {
135
138
  return [namespace.key];
@@ -189,27 +192,30 @@ exports.checkUndefinedKeys = checkUndefinedKeys;
189
192
  const findUndefinedReactIntlKeys = async (translationFiles, filesToParse, options = {
190
193
  ignore: [],
191
194
  }) => {
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] = [];
195
+ return (async () => {
196
+ const { extract } = await import('@formatjs/cli-lib');
197
+ const sourceKeys = new Set(translationFiles.flatMap(({ content }) => {
198
+ return Object.keys(content);
199
+ }));
200
+ const undefinedKeys = {};
201
+ const extractedResult = await extract(filesToParse, {
202
+ extractSourceLocation: true,
203
+ });
204
+ Object.entries(JSON.parse(extractedResult)).forEach(([key, meta]) => {
205
+ if (!sourceKeys.has(key) && !isIgnoredKey(options.ignore ?? [], key)) {
206
+ const data = meta;
207
+ if (!('file' in data) || typeof data.file !== 'string') {
208
+ return;
209
+ }
210
+ const file = path_1.default.normalize(data.file);
211
+ if (!undefinedKeys[file]) {
212
+ undefinedKeys[file] = [];
213
+ }
214
+ undefinedKeys[file].push(key);
208
215
  }
209
- undefinedKeys[file].push(key);
210
- }
211
- });
212
- return undefinedKeys;
216
+ });
217
+ return undefinedKeys;
218
+ })();
213
219
  };
214
220
  const findUndefinedI18NextKeys = async (source, filesToParse, options = {
215
221
  ignore: [],
@@ -219,7 +225,7 @@ const findUndefinedI18NextKeys = async (source, filesToParse, options = {
219
225
  .flatMap(({ content }) => {
220
226
  return Object.keys(content);
221
227
  })
222
- // Ensure that any plural definitiions like key_one, key_other etc.
228
+ // Ensure that any plural definitions like key_one, key_other etc.
223
229
  // are flatted into a single key
224
230
  .map((key) => {
225
231
  const pluralSuffix = constants_1.I18NEXT_PLURAL_SUFFIX.find((suffix) => {
@@ -249,7 +255,7 @@ const findUndefinedNextIntlKeys = async (translationFiles, filesToParse, options
249
255
  const sourceKeys = new Set(translationFiles.flatMap(({ content }) => {
250
256
  return Object.keys(content);
251
257
  }));
252
- const extractedResult = (0, nextIntlSrcParser_1.extract)(filesToParse);
258
+ const extractedResult = (0, nextIntlSrcParser_1.extract)(filesToParse, options);
253
259
  const undefinedKeys = {};
254
260
  extractedResult.forEach(({ key, meta }) => {
255
261
  if (!meta.dynamic &&
@@ -302,7 +308,9 @@ const getI18NextKeysInCode = async (filesToParse, componentFunctions = []) => {
302
308
  else {
303
309
  extractedResult.push({
304
310
  file,
305
- key: entry.key,
311
+ key: entry.context
312
+ ? `${entry.key}${CONTEXT_SEPARATOR}${entry.context}`
313
+ : entry.key,
306
314
  namespace: entry.namespace,
307
315
  });
308
316
  }
package/dist/types.d.ts CHANGED
@@ -20,4 +20,5 @@ export type Options = {
20
20
  format?: 'icu' | 'i18next' | 'react-intl' | 'next-intl';
21
21
  checks?: Context[];
22
22
  ignore?: string[];
23
+ nextIntlTranslationFnTypeAlias?: string[];
23
24
  };
@@ -10,9 +10,7 @@ const OPEN_PARENTHESIS = '(';
10
10
  const OPEN_TAG = '<';
11
11
  const CLOSE_TAG = '</';
12
12
  const parse = (input) => {
13
- let ast = [];
14
- ast = parseInput([input]);
15
- return ast;
13
+ return parseInput([input]);
16
14
  };
17
15
  exports.parse = parse;
18
16
  const parseInput = (input) => {
@@ -347,6 +347,49 @@ const extractFromExpression = (node, options) => {
347
347
  }
348
348
  entries[0].key = concatenatedString;
349
349
  }
350
+ else if (ts.isFunctionExpression(keyArgument) ||
351
+ ts.isFunctionDeclaration(keyArgument) ||
352
+ ts.isArrowFunction(keyArgument)) {
353
+ // Try to find selector api definitions: ($) => $.a.b.c
354
+ if (!keyArgument.body) {
355
+ return null;
356
+ }
357
+ // Check if the function contains a return statement
358
+ if (ts.isBlock(keyArgument.body)) {
359
+ const returnStatement = keyArgument.body.statements.find((statement) => ts.isReturnStatement(statement));
360
+ if (returnStatement &&
361
+ returnStatement.expression &&
362
+ ts.isPropertyAccessExpression(returnStatement.expression)) {
363
+ const [_, ...keys] = returnStatement.expression
364
+ .getFullText()
365
+ .split('.');
366
+ entries[0].key = keys.join('.');
367
+ }
368
+ // check if the access is defined as an array.
369
+ // $["a.b.c"]
370
+ else if (returnStatement &&
371
+ returnStatement.expression &&
372
+ ts.isElementAccessExpression(returnStatement.expression)) {
373
+ entries[0].key = returnStatement.expression.argumentExpression
374
+ .getFullText()
375
+ .replace(/['"]/g, '');
376
+ }
377
+ else {
378
+ return null;
379
+ }
380
+ // check if the access is defined as an array.
381
+ // $["a.b.c"]
382
+ }
383
+ else if (ts.isElementAccessExpression(keyArgument.body)) {
384
+ entries[0].key = keyArgument.body.argumentExpression
385
+ .getFullText()
386
+ .replace(/['"]/g, '');
387
+ }
388
+ else {
389
+ const [_, ...keys] = keyArgument.body.getFullText().split('.');
390
+ entries[0].key = keys.join('.');
391
+ }
392
+ }
350
393
  else {
351
394
  return null;
352
395
  }
@@ -1,4 +1,5 @@
1
- export declare const extract: (filesPaths: string[]) => {
1
+ import { Options } from '../types';
2
+ export declare const extract: (filesPaths: string[], options?: Options) => {
2
3
  key: string;
3
4
  meta: {
4
5
  file: string;
@@ -42,13 +42,15 @@ const ts = __importStar(require("typescript"));
42
42
  const USE_TRANSLATIONS = 'useTranslations';
43
43
  const GET_TRANSLATIONS = 'getTranslations';
44
44
  const COMMENT_CONTAINS_STATIC_KEY_REGEX = /i18n-check t\((["'])(.*?[^\\])(["'])\)/;
45
- const extract = (filesPaths) => {
46
- return filesPaths.flatMap(getKeys).sort((a, b) => {
45
+ const extract = (filesPaths, options = {}) => {
46
+ return filesPaths
47
+ .flatMap((path) => getKeys(path, options))
48
+ .sort((a, b) => {
47
49
  return a.key > b.key ? 1 : -1;
48
50
  });
49
51
  };
50
52
  exports.extract = extract;
51
- const getKeys = (path) => {
53
+ const getKeys = (path, options) => {
52
54
  const content = node_fs_1.default.readFileSync(path, 'utf-8');
53
55
  const sourceFile = ts.createSourceFile(path, content, ts.ScriptTarget.Latest, true);
54
56
  const foundKeys = [];
@@ -328,6 +330,83 @@ const getKeys = (path) => {
328
330
  }
329
331
  }
330
332
  }
333
+ // Third scenaro is if t function the type is
334
+ // an alias for ReturnType<typeof useTranslations>
335
+ const tFunctionAliasParam = node.parameters &&
336
+ node.parameters.find((param) => param.type &&
337
+ ts.isTypeReferenceNode(param.type) &&
338
+ param.type.typeName &&
339
+ ts.isIdentifier(param.type.typeName) &&
340
+ options &&
341
+ options.nextIntlTranslationFnTypeAlias !== undefined &&
342
+ options?.nextIntlTranslationFnTypeAlias.includes(param.type.typeName.text));
343
+ if (tFunctionAliasParam !== undefined &&
344
+ tFunctionAliasParam.type &&
345
+ ts.isTypeReferenceNode(tFunctionAliasParam.type)) {
346
+ const [namespaceArgument] = tFunctionAliasParam.type.typeArguments &&
347
+ tFunctionAliasParam.type.typeArguments.length > 0
348
+ ? tFunctionAliasParam.type.typeArguments
349
+ : [];
350
+ if (ts.isIdentifier(tFunctionAliasParam.name)) {
351
+ const name = namespaceArgument &&
352
+ ts.isLiteralTypeNode(namespaceArgument) &&
353
+ ts.isStringLiteral(namespaceArgument.literal)
354
+ ? namespaceArgument.literal.text
355
+ : '';
356
+ pushNamespace({
357
+ name,
358
+ variable: tFunctionAliasParam.name.text,
359
+ });
360
+ }
361
+ }
362
+ // Fourth scenario is the t function is defined as an object property and uses an alias:
363
+ // someFn({t}: {t: AliasForTheOriginalNextIntlType}>
364
+ const tFunctionParamAliasAsProperty = node.parameters &&
365
+ node.parameters.find((param) => param.type &&
366
+ ts.isTypeLiteralNode(param.type) &&
367
+ param.type.members.find((member) => {
368
+ return (ts.isPropertySignature(member) &&
369
+ member.type &&
370
+ ts.isTypeReferenceNode(member.type) &&
371
+ member.type.typeName &&
372
+ ts.isIdentifier(member.type.typeName) &&
373
+ options &&
374
+ options.nextIntlTranslationFnTypeAlias !== undefined &&
375
+ options?.nextIntlTranslationFnTypeAlias.includes(member.type.typeName.text));
376
+ }));
377
+ if (tFunctionParamAliasAsProperty !== undefined &&
378
+ tFunctionParamAliasAsProperty.type &&
379
+ ts.isTypeLiteralNode(tFunctionParamAliasAsProperty.type)) {
380
+ const fnType = tFunctionParamAliasAsProperty.type.members.find((member) => {
381
+ return (ts.isPropertySignature(member) &&
382
+ member.type &&
383
+ ts.isTypeReferenceNode(member.type) &&
384
+ member.type.typeName &&
385
+ ts.isIdentifier(member.type.typeName) &&
386
+ options &&
387
+ options.nextIntlTranslationFnTypeAlias !== undefined &&
388
+ options?.nextIntlTranslationFnTypeAlias.includes(member.type.typeName.text));
389
+ });
390
+ if (fnType &&
391
+ ts.isPropertySignature(fnType) &&
392
+ fnType.type &&
393
+ ts.isTypeReferenceNode(fnType.type)) {
394
+ const [namespaceArgument] = fnType.type.typeArguments && fnType.type.typeArguments.length > 0
395
+ ? fnType.type.typeArguments
396
+ : [];
397
+ if (fnType.name && ts.isIdentifier(fnType.name)) {
398
+ const name = namespaceArgument &&
399
+ ts.isLiteralTypeNode(namespaceArgument) &&
400
+ ts.isStringLiteral(namespaceArgument.literal)
401
+ ? namespaceArgument.literal.text
402
+ : '';
403
+ pushNamespace({
404
+ name,
405
+ variable: fnType.name.text,
406
+ });
407
+ }
408
+ }
409
+ }
331
410
  }
332
411
  // Search for `t()` calls
333
412
  if (getCurrentNamespaces() !== null &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lingual/i18n-check",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "description": "i18n translation messages check",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -24,24 +24,23 @@
24
24
  "!dist/**/*.test.*"
25
25
  ],
26
26
  "dependencies": {
27
- "@formatjs/cli-lib": "^8.2.3",
27
+ "@formatjs/cli-lib": "^8.5.1",
28
28
  "@formatjs/icu-messageformat-parser": "^2.11.4",
29
29
  "chalk": "^4.1.2",
30
30
  "commander": "^12.1.0",
31
- "glob": "12.0.0",
32
- "js-yaml": "^4.1.1",
33
- "typescript": "^5.9.3"
31
+ "glob": "^13.0.6",
32
+ "typescript": "^5.9.3",
33
+ "yaml": "^2.8.3"
34
34
  },
35
35
  "devDependencies": {
36
- "@eslint/js": "^9.39.1",
37
- "@types/js-yaml": "^4.0.9",
38
- "@types/node": "^22.19.1",
36
+ "@eslint/js": "^10.0.1",
37
+ "@types/node": "^22.19.17",
39
38
  "braces": "^3.0.3",
40
- "eslint": "^9.39.1",
41
- "globals": "^16.5.0",
42
- "prettier": "^3.6.2",
43
- "typescript-eslint": "^8.47.0",
44
- "vitest": "^4.0.10"
39
+ "eslint": "^10.2.1",
40
+ "globals": "^17.5.0",
41
+ "prettier": "^3.8.3",
42
+ "typescript-eslint": "^8.59.0",
43
+ "vitest": "^4.1.5"
45
44
  },
46
45
  "repository": {
47
46
  "type": "git",
@@ -50,5 +49,5 @@
50
49
  "engines": {
51
50
  "node": ">=20"
52
51
  },
53
- "packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
52
+ "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264"
54
53
  }