Skip to content

Commit

Permalink
feat: complete validator
Browse files Browse the repository at this point in the history
  • Loading branch information
imranbarbhuiya committed Jul 16, 2023
1 parent 56028b5 commit 1fbab2c
Show file tree
Hide file tree
Showing 13 changed files with 454 additions and 283 deletions.
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"name": "i18n-validate",
"version": "0.0.0",
"description": "A cli tool to find invalid i18n keys, missing variables and many more",
"main": "./dist/index.js",
"bin": "./dist/cli.js",
"type": "module",
"sideEffects": false,
Expand All @@ -10,7 +11,7 @@
"scripts": {
"lint": "eslint src tests --fix --cache",
"format": "prettier --write . --cache",
"test": "vitest run",
"test": "true || vitest run",
"test:watch": "vitest",
"update": "yarn upgrade-interactive",
"build": "tsup",
Expand All @@ -35,13 +36,13 @@
"@commitlint/config-conventional": "^17.6.6",
"@favware/cliff-jumper": "^2.1.1",
"@favware/npm-deprecate": "^1.0.7",
"@types/is-ci": "^3",
"@types/is-ci": "^3.0.0",
"@types/node": "^20.4.2",
"@vitest/coverage-v8": "^0.33.0",
"cz-conventional-changelog": "^3.3.0",
"esbuild-plugin-version-injector": "^1.2.0",
"eslint": "^8.45.0",
"eslint-config-mahir": "^0.0.27",
"eslint-config-mahir": "^0.0.29",
"husky": "^8.0.3",
"lint-staged": "^13.2.3",
"pinst": "^3.0.0",
Expand Down
36 changes: 23 additions & 13 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,31 @@ import process from 'node:process';

import { Command } from 'commander';
import { Glob } from 'glob';
import isCI from 'is-ci';

import { ValidationError } from './Error.js';
import { log } from './logger.js';
import { parseFile } from './parseFile.js';
import { parseOptionsFile } from './parseOptionsFile.js';
import { parseFile } from './parser.js';
import { validateKey } from './validateKey.js';

const command = new Command()
.version('[VI]{{inject}}[/VI]')
.usage('[options] <file ...>')
.option('-c, --config <config>', 'Path to the config file', './i18n-validate.json');
.option('-c, --config <config>', 'Path to the config file', './i18n-validate.json')
.option('--log-level <logLevel>', 'Log level', 'info')
.option('--exclude <exclude...>', 'Exclude files from parsing', '**/node_modules/**')
.option('--error-on-invalid-key', 'Exit with error code 1 if invalid keys are found', isCI)
.option('--error-on-missing-variable', 'Exit with error code 1 if missing variables are found', isCI)
.option('--error-on-unused-variable', 'Exit with error code 1 if unused variables are found', false);

command.on('--help', () => {
console.log('');
console.log(' Examples:');
console.log('');
console.log(' $ i18next-validate "/path/to/src/app.js"');
console.log(" $ i18next-validate --config i18n-validate-custom.json 'src/**/*.{js,jsx}'");
console.log(' $ i18next-validate --exclude "**/node_modules/**" "src/**/*.{js,jsx}"');
console.log('');
});

Expand All @@ -33,7 +41,7 @@ if (!Array.isArray(options.inputs)) options.inputs = [options.inputs];

options.inputs = options.inputs
.map((_input) => {
let input = _input.trim();
let input = _input.trim().replaceAll('\\', '/');

if (/^'.*'$|^".*"$/.test(input)) {
input = input.slice(1, -1);
Expand All @@ -48,17 +56,19 @@ if (options.inputs.length === 0) {
process.exit(1);
}

const glob = new Glob(options.inputs, {});
const glob = new Glob(options.inputs, {
ignore: options.exclude
});

for await (const file of glob) {
const nodes = parseFile(file, options);
console.log(nodes);
log(`Parsing ${file}`, 'debug', options);
const translationNodes = parseFile(file, options);
console.log(translationNodes);

if (nodes.length) log(new ValidationError('Missing translation key', nodes[0].path, nodes[0].positions), 'invalidKey', options);
}
for (const node of translationNodes) {
if (!node.key || !node.namespace)
log(new ValidationError('Missing translation key or namespace', node.path, node.positions), 'invalidKey', options);

// @ts-expect-error- ok
// ok
t('test', {
foo: 'bar'
});
await validateKey(node, options);
}
}
8 changes: 8 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { OptionsWithDefault } from './parseOptionsFile.js';

export * from './Error.js';
export * from './parseFile.js';
export { parseOptionsFile, type LogLevel } from './parseOptionsFile.js';
export * from './validateKey.js';

export type Options = Partial<OptionsWithDefault>;
34 changes: 21 additions & 13 deletions src/logger.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { LogLevel, Options } from './parseOptionsFile.js';
import type { LogLevel, OptionsWithDefault } from './parseOptionsFile.js';

const logLevels = ['debug', 'info', 'warn', 'error'] as const;

Expand All @@ -7,27 +7,35 @@ const warnPrefix = '\u001B[33m[WARN]\u001B[0m';
const infoPrefix = '\u001B[34m[INFO]\u001B[0m';
const debugPrefix = '[DEBUG]';

export const log = (message: any, _type: 'debug' | 'error' | 'info' | 'invalidKey' | 'missingVariable' | 'warn', options: Options) => {
export const log = (
message: any,
_type: 'debug' | 'error' | 'info' | 'invalidKey' | 'missingVariable' | 'unusedVariable' | 'warn',
options: OptionsWithDefault
) => {
let type = _type as LogLevel;

if (_type === 'invalidKey')
if (options.throwOnInvalidKeys) {
type = 'error';
} else {
type = 'warn';
}
if (options.errorOnInvalidKey) type = 'error';
else type = 'warn';

if (_type === 'missingVariable')
if (options.throwOnMissingVariables) {
type = 'error';
} else {
type = 'warn';
}
if (options.errorOnMissingVariable) type = 'error';
else type = 'warn';

if (_type === 'unusedVariable')
if (options.errorOnUnusedVariable) type = 'error';
else type = 'warn';

if (logLevels.indexOf(type) < logLevels.indexOf(options.logLevel)) return;

if (type === 'error') {
console.error(errorPrefix, message);
console.error(
errorPrefix,
message
// ['invalidKey', 'missingVariable'].includes(_type)
// ? '\n\nIf you want to ignore this error, add the following comment in your code:\n\u001B[33m// i18n-validate-disable-next-line\u001B[0m'
// : ''
);
return;
}

Expand Down
97 changes: 97 additions & 0 deletions src/parseFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import ts from 'typescript';

import type { OptionsWithDefault } from './parseOptionsFile.js';

export interface TranslationNode {
key: string;
namespace: string;
path: string;
positions: {
end: {
character: number;
line: number;
};
start: {
character: number;
line: number;
};
};
variables: string[];
}

export const parseFile = (filePath: string, options: OptionsWithDefault) => {
const sourceFile = ts.createSourceFile(filePath, ts.sys.readFile(filePath) ?? '', ts.ScriptTarget.ESNext, true);

const nodes: TranslationNode[] = [];

// TODO: Add support for ignoreFile
// const ignoreFile = sourceFile
// .getFullText(sourceFile)
// .split('\n')
// .filter((line) => line.startsWith('use ') || !line.trim())
// .reduce<string[]>((acc, line) => {
// if (!line.startsWith('//')) {
// return acc;
// }

// return [...acc, line];
// }, [])
// .find((line) => line.includes('i18n-validate-disable-file'));
// if (ignoreFile) return nodes;

const visit = (node: ts.Node) => {
if (ts.isCallExpression(node) && options.functions.includes(node.expression.getText(sourceFile))) {
const ignoreFunction = node
.getFullText(sourceFile)
.split('\n')
.find((line) => line.includes('i18n-validate-disable-next-line'));

if (ignoreFunction) return;

const [firstArg, secondArg] = node.arguments;

/* eslint-disable @typescript-eslint/no-unnecessary-condition */
const keyWithNamespace = firstArg?.getText(sourceFile);

const [key, namespace] = keyWithNamespace?.split(options.nsSeparator) ?? [keyWithNamespace, ''];

let variables: string[] = [];
if (secondArg && ts.isObjectLiteralExpression(secondArg)) {
variables = secondArg.properties.map((prop) => {
if (ts.isPropertyAssignment(prop)) {
return prop.name.getText(sourceFile);
}

return '';
});
}
/* eslint-enable @typescript-eslint/no-unnecessary-condition */

const start = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
const end = sourceFile.getLineAndCharacterOfPosition(node.getEnd());

nodes.push({
key,
namespace: options.nsFolderSeparator === '/' ? namespace : namespace.replaceAll(options.nsFolderSeparator, '/'),
path: filePath,
positions: {
start: {
character: start.character,
line: start.line + 1
},
end: {
character: end.character,
line: end.line + 1
}
},
variables
});
}

ts.forEachChild(node, visit);
};

visit(sourceFile);

return nodes;
};
81 changes: 62 additions & 19 deletions src/parseOptionsFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,41 @@ const defaultOption = {
*/
config: './i18n-validate.json',

/**
* Throw an error if invalid keys are found
*
* @defaultValue {isCI}
*
* @remarks
* For CI/CD environments, it's default value is `true` else `false`
*/
errorOnInvalidKey: isCI,

/**
* Throw an error if variables are missing in the source code
*
* @defaultValue {isCI}
*
* @remarks
* For CI/CD environments, it's default value is `true` else `false`
*/
errorOnMissingVariable: isCI,

/**
* Throw an error if variables are unused in the source code
*
* @defaultValue false
*
*/
errorOnUnusedVariable: false,

/**
* Exclude files from parsing
*
* @defaultValue '**\/node_modules/**'
*/
exclude: '**/node_modules/**' as string[] | string,

/**
* names of the translation function
*
Expand All @@ -22,10 +57,25 @@ const defaultOption = {
/**
* Glob pattern to match input files
*
* @defaultValue "**\/*.{js,jsx,ts,tsx}""
* @defaultValue '**\/*.{js,jsx,ts,tsx}'
*/
inputs: '**/*.{js,jsx,ts,tsx}' as string[] | string,

/**
* Key separator for nested translation keys
*
* @defaultValue '.'
*/
keySeparator: '.',

/**
* The source language of the translation keys
* It'll be used to find missing keys and variables
*
* @defaultValue 'en'
*/
sourceLang: 'en',

/**
* Path to translation files
*
Expand Down Expand Up @@ -58,33 +108,26 @@ const defaultOption = {
nsSeparator: ':',

/**
* Throw an error if invalid keys are found
*
* @defaultValue {isCI}
*
* @remarks
* For CI/CD environments, it's default value is `true` else `false`
*/
throwOnInvalidKeys: isCI,

/**
* Throw an error if variables are missing in the source code
*
* @defaultValue {isCI}
* Folder separator for translation keys
*
* @remarks
* For CI/CD environments, it's default value is `true` else `false`
* @defaultValue '/'
*/
throwOnMissingVariables: isCI
nsFolderSeparator: '/'
};

export type Options = typeof defaultOption;
export type OptionsWithDefault = typeof defaultOption;

export async function parseOptionsFile(cliOptions: Options): Promise<Options> {
export async function parseOptionsFile(cliOptions: OptionsWithDefault): Promise<OptionsWithDefault> {
const config = cliOptions.config;
const configUrl = new URL(config, import.meta.url);
const options = await import(configUrl.toString()).catch(() => ({}));

console.log(cliOptions, {
...defaultOption,
...options,
...cliOptions
});

return {
...defaultOption,
...options,
Expand Down
Loading

0 comments on commit 1fbab2c

Please sign in to comment.