├── src ├── helpers │ ├── meta.ts │ ├── consoleColor.ts │ ├── parseRegex.ts │ ├── git.ts │ ├── writeJsonFile.ts │ ├── flatKeys.ts │ └── files.ts ├── index.ts ├── core │ ├── action.ts │ ├── initialize.ts │ └── translations.ts ├── actions │ ├── sync.ts │ ├── remove.ts │ ├── mark.ts │ └── display.ts └── types │ └── index.ts ├── .prettierrc ├── .gitignore ├── jest.config.js ├── .editorconfig ├── .eslintrc ├── tests ├── factories │ └── RecursiveStructFactory.ts ├── core │ └── core.spec.ts └── helpers │ ├── files.spec.ts │ └── writeJsonFile.spec.ts ├── tsconfig.json ├── LICENSE ├── package.json ├── bin └── i18n-unused.cjs ├── CHANGELOG.md └── README.md /src/helpers/meta.ts: -------------------------------------------------------------------------------- 1 | export function importMetaUrl(): string { 2 | return import.meta.url; 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "singleQuote": false, 5 | "printWidth": 80 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_STORE 2 | node_modules 3 | dist 4 | .yarn/* 5 | !.yarn/cache 6 | !.yarn/releases 7 | !.yarn/plugins 8 | *.log 9 | .idea 10 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@ts-jest/dist/types').InitialOptionsTsJest} */ 2 | export default { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | }; 6 | -------------------------------------------------------------------------------- /src/helpers/consoleColor.ts: -------------------------------------------------------------------------------- 1 | export const GREEN = "\x1b[32m"; 2 | export const RED = "\x1b[31m"; 3 | export const BLUE = "\x1b[34m"; 4 | export const MAGENTA = "\x1b[35m"; 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint" 6 | ], 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /src/helpers/parseRegex.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Transform the escaped string provided into a valid regex 3 | * @param {string} str 4 | * @return {RegExp} 5 | */ 6 | export const parseRegex = (str: string): RegExp => { 7 | const parts = str.split("/"); 8 | return new RegExp(`${parts[1]}`.replace(/\\\\/g, "\\"), parts[2]); 9 | }; 10 | -------------------------------------------------------------------------------- /tests/factories/RecursiveStructFactory.ts: -------------------------------------------------------------------------------- 1 | import { RecursiveStruct } from '../../src/types' 2 | 3 | export const createRecursiveStructDefault = (): RecursiveStruct => { 4 | return { 5 | one: 'one' 6 | }; 7 | } 8 | export const createRecursiveStructArray = (): RecursiveStruct => { 9 | return { 10 | array: ['one', 'two', 'three'] 11 | }; 12 | } 13 | export const createRecursiveStructNested = (): RecursiveStruct => { 14 | return { 15 | nested: {one: 'one'} 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | displayUnusedTranslations, 3 | displayMissedTranslations, 4 | } from "./actions/display"; 5 | export { removeUnusedTranslations } from "./actions/remove"; 6 | export { markUnusedTranslations } from "./actions/mark"; 7 | export { syncTranslations } from "./actions/sync"; 8 | 9 | export { collectUnusedTranslations } from "./core/translations"; 10 | export { generateFilesPaths } from "./helpers/files"; 11 | export { parseRegex } from "./helpers/parseRegex"; 12 | 13 | export { RunOptions } from "./types"; 14 | -------------------------------------------------------------------------------- /src/helpers/git.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from "child_process"; 2 | 3 | import { GREEN, MAGENTA } from "./consoleColor"; 4 | 5 | export const checkUncommittedChanges = (): boolean => { 6 | const result = execSync("git status --porcelain").toString(); 7 | 8 | if (result) { 9 | console.log( 10 | MAGENTA, 11 | "Working tree is dirty: you might want to commit your changes before running the script", 12 | ); 13 | 14 | return true; 15 | } else { 16 | console.log(GREEN, "Working tree is clean"); 17 | 18 | return false; 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "downlevelIteration": true, 5 | "module": "ESNext", 6 | "esModuleInterop": true, 7 | "allowSyntheticDefaultImports": true, 8 | "target": "ESNext", 9 | "declaration": true, 10 | "emitDecoratorMetadata": true, 11 | "moduleResolution": "node", 12 | "sourceMap": true, 13 | "skipLibCheck": true, 14 | "baseUrl": ".", 15 | "outDir": "dist", 16 | "paths": { 17 | "*": ["node_modules/*"] 18 | } 19 | }, 20 | "include": ["src"] 21 | } 22 | -------------------------------------------------------------------------------- /src/helpers/writeJsonFile.ts: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from "fs"; 2 | import { RecursiveStruct } from "../types"; 3 | 4 | interface writeJsonFileOptions { 5 | localeJsonStringifyIndent?: string | number; 6 | } 7 | 8 | export const writeJsonFile = ( 9 | filePath: string, 10 | data: RecursiveStruct, 11 | config: writeJsonFileOptions, 12 | ): void => { 13 | const jsonString = JSON.stringify( 14 | data, 15 | null, 16 | config.localeJsonStringifyIndent, 17 | ); 18 | const jsonStringWithNewLine = `${jsonString}\n`; 19 | writeFileSync(filePath, jsonStringWithNewLine); 20 | }; 21 | -------------------------------------------------------------------------------- /src/core/action.ts: -------------------------------------------------------------------------------- 1 | import { ApplyFlat, RecursiveStruct } from "../types"; 2 | 3 | type Options = { 4 | flatTranslations: boolean; 5 | separator: string; 6 | }; 7 | 8 | export const applyToFlatKey = ( 9 | source: RecursiveStruct, 10 | key: string, 11 | cb: ApplyFlat, 12 | options: Options, 13 | ): boolean => { 14 | const separatedKey = options.flatTranslations 15 | ? [key] 16 | : key.split(options.separator); 17 | const keyLength = separatedKey.length - 1; 18 | 19 | separatedKey.reduce((acc, _k, i) => { 20 | if (i === keyLength) { 21 | cb(acc, _k); 22 | } else { 23 | acc = acc[_k] as RecursiveStruct; 24 | } 25 | 26 | return acc; 27 | }, source); 28 | 29 | return true; 30 | }; 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Maxim Vishnevsky 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/core/core.spec.ts: -------------------------------------------------------------------------------- 1 | import { applyToFlatKey } from '../../src/core/action'; 2 | import { 3 | createRecursiveStructArray, 4 | createRecursiveStructDefault, 5 | createRecursiveStructNested, 6 | } from '../factories/RecursiveStructFactory'; 7 | 8 | const config = { 9 | flatTranslations: false, 10 | separator: '.', 11 | }; 12 | 13 | describe('core-actions', () => { 14 | describe('applyToFlatKey', () => { 15 | it('flat', () => { 16 | const struct = createRecursiveStructDefault(); 17 | applyToFlatKey( 18 | struct, 19 | 'one', 20 | (source, lastKey) => { 21 | source[lastKey] = 'NEW'; 22 | }, 23 | config, 24 | ); 25 | expect(struct).toEqual({ one: 'NEW' }); 26 | }); 27 | it('array', () => { 28 | const struct = createRecursiveStructArray(); 29 | applyToFlatKey( 30 | struct, 31 | 'array.0', 32 | (source, lastKey) => { 33 | source[lastKey] = 'NEW'; 34 | }, 35 | config, 36 | ); 37 | expect(struct).toEqual({ array: ['NEW', 'two', 'three'] }); 38 | }); 39 | it('nested', () => { 40 | const struct = createRecursiveStructNested(); 41 | applyToFlatKey( 42 | struct, 43 | 'nested.one', 44 | (source, lastKey) => { 45 | source[lastKey] = 'NEW'; 46 | }, 47 | config, 48 | ); 49 | expect(struct).toEqual({ nested: { one: 'NEW' } }); 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "i18n-unused", 3 | "version": "0.19.0", 4 | "description": "The tool for finding, analyzing and removing unused and missed i18n translations in your JavaScript project", 5 | "type": "module", 6 | "source": "src/index.ts", 7 | "bin": "bin/i18n-unused.cjs", 8 | "main": "dist/i18n-unused.cjs", 9 | "module": "dist/i18n-unused.js", 10 | "types": "dist/index.d.ts", 11 | "exports": { 12 | "require": "./dist/i18n-unused.cjs", 13 | "import": "./dist/i18n-unused.js" 14 | }, 15 | "files": [ 16 | "*" 17 | ], 18 | "scripts": { 19 | "test": "jest", 20 | "build": "rm -rf dist && microbundle --target node", 21 | "prepare": "npm run build", 22 | "lint": "eslint ./src --ext .ts", 23 | "lint-fix": "eslint ./src --ext .ts --fix", 24 | "format-code": "prettier --config .prettierrc 'src/**/*.ts' --write" 25 | }, 26 | "publishConfig": { 27 | "registry": "https://registry.npmjs.org" 28 | }, 29 | "repository": { 30 | "type": "git", 31 | "url": "git+https://github.com/mxmvshnvsk/i18n-unused.git" 32 | }, 33 | "keywords": [ 34 | "i18n" 35 | ], 36 | "author": "Maxim Vishnevsky", 37 | "license": "MIT", 38 | "bugs": { 39 | "url": "https://github.com/mxmvshnvsk/i18n-unused/issues" 40 | }, 41 | "homepage": "https://github.com/mxmvshnvsk/i18n-unused#readme", 42 | "devDependencies": { 43 | "@types/jest": "^26.0.24", 44 | "@typescript-eslint/eslint-plugin": "^4.28.4", 45 | "@typescript-eslint/parser": "^4.28.4", 46 | "eslint": "^7.32.0", 47 | "jest": "^27.0.6", 48 | "microbundle": "^0.13.3", 49 | "prettier": "^3.0.0", 50 | "ts-jest": "^27.0.4", 51 | "typescript": "^4.9.5" 52 | }, 53 | "dependencies": { 54 | "commander": "^10.0.1", 55 | "esm": "^3.2.25", 56 | "ts-import": "^2.0.40" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/actions/sync.ts: -------------------------------------------------------------------------------- 1 | import { createRequire } from "module"; 2 | 3 | import { RunOptions, RecursiveStruct } from "../types"; 4 | 5 | import { initialize } from "../core/initialize"; 6 | import { generateFilesPaths } from "../helpers/files"; 7 | import { checkUncommittedChanges } from "../helpers/git"; 8 | import { importMetaUrl } from "../helpers/meta"; 9 | 10 | import { GREEN } from "../helpers/consoleColor"; 11 | import { writeJsonFile } from "../helpers/writeJsonFile"; 12 | 13 | export const mergeLocaleData = ( 14 | source: RecursiveStruct, 15 | target: RecursiveStruct, 16 | ): RecursiveStruct => { 17 | const keys = Object.keys(source); 18 | 19 | keys.forEach((key) => { 20 | if (typeof source[key] === "object") { 21 | target[key] = target[key] || {}; 22 | mergeLocaleData( 23 | source[key] as RecursiveStruct, 24 | target[key] as RecursiveStruct, 25 | ); 26 | } else { 27 | target[key] = target[key] || source[key]; 28 | } 29 | }); 30 | 31 | return target; 32 | }; 33 | 34 | export const syncTranslations = async ( 35 | source: string, 36 | target: string, 37 | options: RunOptions, 38 | ): Promise => { 39 | const config: RunOptions = await initialize(options); 40 | 41 | const [sourcePath] = await generateFilesPaths(config.localesPath, { 42 | fileNameResolver: (n) => n === source, 43 | }); 44 | const [targetPath] = await generateFilesPaths(config.localesPath, { 45 | fileNameResolver: (n) => n === target, 46 | }); 47 | 48 | const r = createRequire(importMetaUrl()); 49 | const sourceLocale = r(sourcePath); 50 | const targetLocale = r(targetPath); 51 | 52 | const mergedLocale = mergeLocaleData(sourceLocale, targetLocale); 53 | 54 | if (config.gitCheck) { 55 | checkUncommittedChanges(); 56 | } 57 | 58 | writeJsonFile(targetPath, mergedLocale, config); 59 | 60 | console.log(GREEN, "Translations are synchronized"); 61 | 62 | return true; 63 | }; 64 | -------------------------------------------------------------------------------- /tests/helpers/files.spec.ts: -------------------------------------------------------------------------------- 1 | import { resolveFile, generateFilesPaths } from '../../src/helpers/files'; 2 | import { jest } from '@jest/globals'; 3 | import { pathToFileURL } from 'url'; 4 | import { promises } from 'fs'; 5 | 6 | 7 | jest.mock('../../src/helpers/meta', () => ({ 8 | importMetaUrl: () => pathToFileURL(__filename).toString(), 9 | })); 10 | 11 | jest.mock("fs", () => ({ 12 | promises: { 13 | readdir: jest.fn() 14 | } 15 | })); 16 | jest.mock("process", () => ({ 17 | cwd: jest.fn().mockReturnValue(() => 'Users/JohnSmith/development/project-name') 18 | })); 19 | 20 | 21 | const loaderMock = jest.fn(() => ({ foo: 'bar' })); 22 | 23 | 24 | 25 | describe('file', () => { 26 | describe('resolveFile', () => { 27 | it('should use custom loader', async () => { 28 | const filePath = '/locales/de/common.yml'; 29 | const result = await resolveFile(filePath, undefined, loaderMock); 30 | expect(loaderMock).toHaveBeenCalledTimes(1); 31 | expect(loaderMock).toHaveBeenCalledWith(filePath); 32 | expect(result).toEqual({ foo: 'bar' }); 33 | }); 34 | }); 35 | 36 | describe('generateFilesPaths', () => { 37 | const originalProcess = process 38 | 39 | beforeEach(() => { 40 | promises.readdir = jest.fn().mockReturnValue([ 41 | { name: 'catFile.jpeg', isDirectory: () => false }, 42 | { name: 'dogFile.png', isDirectory: () => false }, 43 | { name: 'catFile.php', isDirectory: () => false }, 44 | { name: 'Component.vue', isDirectory: () => false }, 45 | { name: 'Welcome.tsx', isDirectory: () => false } 46 | ]) as any; 47 | }); 48 | 49 | it('handle one level directory paths', async () => { 50 | const srcPath = '/development/project-name'; 51 | const result = await generateFilesPaths(srcPath, { }); 52 | 53 | expect(result).toEqual(["/development/project-name/catFile.jpeg", "/development/project-name/dogFile.png", "/development/project-name/catFile.php", "/development/project-name/Component.vue", "/development/project-name/Welcome.tsx"]); 54 | }); 55 | 56 | }); 57 | 58 | }); 59 | -------------------------------------------------------------------------------- /src/helpers/flatKeys.ts: -------------------------------------------------------------------------------- 1 | import { RecursiveStruct } from "../types"; 2 | 3 | interface Options { 4 | parent?: string; 5 | keys?: string[]; 6 | context: boolean; 7 | contextSeparator: string; 8 | contextMatcher: RegExp; 9 | excludeKey?: string | string[]; 10 | } 11 | 12 | export const generateTranslationsFlatKeys = ( 13 | source: RecursiveStruct, 14 | { 15 | parent, 16 | keys = [], 17 | excludeKey, 18 | context, 19 | contextSeparator, 20 | contextMatcher, 21 | }: Options, 22 | ): string[] => { 23 | Object.keys(source).forEach((key) => { 24 | const flatKey = parent ? `${parent}.${key}` : key; 25 | 26 | if (!Array.isArray(source[key]) && typeof source[key] === "object") { 27 | generateTranslationsFlatKeys(source[key] as RecursiveStruct, { 28 | contextSeparator, 29 | parent: flatKey, 30 | excludeKey, 31 | context, 32 | contextMatcher, 33 | keys, 34 | }); 35 | } else { 36 | keys.push( 37 | context 38 | ? getKeyWithoutContext(flatKey, contextSeparator, contextMatcher) 39 | : flatKey, 40 | ); 41 | } 42 | }); 43 | 44 | const resultKeys = excludeKey 45 | ? keys.filter((k: string) => 46 | typeof excludeKey === "string" 47 | ? !k.includes(excludeKey) 48 | : excludeKey.every((ek) => !k.includes(ek)), 49 | ) 50 | : keys; 51 | 52 | // The context removal can cause duplicates, so we need to remove them 53 | return [...new Set(resultKeys)]; 54 | }; 55 | 56 | /** 57 | * Removes context from key. 58 | * 59 | * Makes sure translation keys like `some_key_i_have` is not treated as context. 60 | */ 61 | const getKeyWithoutContext = ( 62 | flatKey: string, 63 | contextSeparator: string, 64 | contextMatcher: RegExp, 65 | ) => { 66 | const splitted = flatKey.split(contextSeparator); 67 | if (splitted.length === 1) return flatKey; 68 | 69 | const lastPart = splitted[splitted.length - 1]; 70 | 71 | // If the last part is a context, remove it 72 | if (lastPart.match(contextMatcher)) { 73 | return splitted.slice(0, splitted.length - 2).join(contextSeparator); 74 | } 75 | // Otherwise, join all parts 76 | return splitted.join(contextSeparator); 77 | }; 78 | -------------------------------------------------------------------------------- /bin/i18n-unused.cjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { program } = require('commander'); 3 | 4 | const { description, version } = require('../package.json'); 5 | 6 | const { 7 | displayUnusedTranslations, 8 | displayMissedTranslations, 9 | removeUnusedTranslations, 10 | markUnusedTranslations, 11 | syncTranslations, 12 | parseRegex 13 | } = require('../dist/i18n-unused.cjs'); 14 | 15 | 16 | 17 | program.description(description); 18 | 19 | program.version(version, '-v --version', 'output version'); 20 | 21 | program 22 | .option('-sExt, --src-extensions [srcExtensions...]', 'files extensions, which includes for searching (ext ext ext; by default: js, ts, jsx, tsx, vue)') 23 | .option('-lExt, --locales-extensions [localesExtensions...]', 'locales files extensions (ext,ext,ext; by default: json)') 24 | .option('-lExt, --translation-key-matcher ', '{string} locales matcher to search for translation keys in files by default: \'/(?:[$ .](_|t|tc|i18nKey))\\(.*?\\)/gi\'', parseRegex) 25 | .option('-sPath, --src-path ', 'path to source of code (path, ex. \'src\')') 26 | .option('-lPath, --locales-path ', 'path to locales (path, ex. \'src/locales\')'); 27 | 28 | program 29 | .command('display-unused') 30 | .description('output table with unused translations') 31 | .action(() => displayUnusedTranslations(program.opts())); 32 | 33 | program 34 | .command('display-missed') 35 | .description('output table with missed translations') 36 | .action(() => displayMissedTranslations(program.opts())); 37 | 38 | program 39 | .command('mark-unused') 40 | .description('mark unused translations via [UNUSED] or your marker from config') 41 | .action(() => markUnusedTranslations(program.opts())); 42 | 43 | program 44 | .command('remove-unused') 45 | .description('remove unused translations') 46 | .action(() => removeUnusedTranslations(program.opts())); 47 | 48 | program 49 | .command('display-missed') 50 | .description('output table with missed translations') 51 | .action(displayUnusedTranslations); 52 | 53 | program 54 | .command('sync source target') 55 | .description('sync translations') 56 | .action((s, t) => syncTranslations(s, t, program.opts())); 57 | 58 | program.parse(process.argv); 59 | -------------------------------------------------------------------------------- /src/core/initialize.ts: -------------------------------------------------------------------------------- 1 | /* eslint no-empty: ["error", { "allowEmptyCatch": true }] */ 2 | 3 | import { resolveFile } from "../helpers/files"; 4 | 5 | import { RunOptions, RecursiveStruct } from "../types"; 6 | import fs from "fs"; 7 | import { parseRegex } from "../helpers/parseRegex"; 8 | 9 | const defaultValues: RunOptions = { 10 | srcPath: "", 11 | context: true, 12 | excludeKey: "", 13 | marker: "[UNUSED]", 14 | ignoreComments: false, 15 | flatTranslations: false, 16 | translationSeparator: ".", 17 | translationContextSeparator: "_", 18 | // Based on https://www.i18next.com/misc/json-format 19 | translationContextMatcher: 20 | /^(zero|one|two|few|many|other|male|female|0|1|2|3|4|5|plural|11|100)$/, 21 | srcExtensions: ["js", "ts", "jsx", "tsx", "vue"], 22 | translationKeyMatcher: /(?:[.$\s]*\{?)?(_|t|tc|i18nKey)\)\(([\n\r\s]|.)*?\)/gi, 23 | localeFileParser: (m: RecursiveStruct): RecursiveStruct => 24 | (m.default || m) as RecursiveStruct, 25 | missedTranslationParser: /\(([^)]+)\)/, 26 | localeJsonStringifyIndent: 2, 27 | }; 28 | 29 | export const initialize = async ( 30 | inlineOptions: RunOptions, 31 | ): Promise => { 32 | let config: RunOptions = { ...inlineOptions }; 33 | 34 | try { 35 | const base = process.cwd(); 36 | 37 | let configFile: Partial = {}; 38 | 39 | for (const ext of ["js", "cjs", "json"]) { 40 | const path = `${base}/i18n-unused.config.${ext}`; 41 | if (fs.existsSync(path)) { 42 | configFile = await resolveFile(path); 43 | // ⛔ There is no safe/reliable way to parse a function 44 | // ✔ When the file is a JSON need to parse the regex 45 | if (ext === "json") { 46 | const potentialRegex = [ 47 | "translationContextMatcher", 48 | "translationKeyMatcher", 49 | "missedTranslationParser", 50 | "localeNameResolver", 51 | ]; 52 | potentialRegex.forEach((value) => { 53 | if (Object.prototype.hasOwnProperty.call(configFile, value)) { 54 | configFile[value] = parseRegex(configFile[value]); 55 | } 56 | }); 57 | } 58 | break; 59 | } 60 | } 61 | 62 | config = { ...configFile, ...inlineOptions }; 63 | } catch (e) {} 64 | 65 | if (!config.localesPath) { 66 | throw new Error("Locales path is required"); 67 | } 68 | 69 | if (!config.localesExtensions && !config.localeNameResolver) { 70 | config.localesExtensions = ["json"]; 71 | } 72 | 73 | return { ...defaultValues, ...config }; 74 | }; 75 | -------------------------------------------------------------------------------- /tests/helpers/writeJsonFile.spec.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals'; 2 | import { writeFileSync } from 'fs'; 3 | import {RunOptions} from "../../src/types"; 4 | import {writeJsonFile} from "../../src/helpers/writeJsonFile"; 5 | 6 | 7 | jest.mock("fs", () => ({ 8 | writeFileSync: jest.fn() 9 | })); 10 | 11 | describe('writeJsonFile', () => { 12 | describe('defaultValue', () => { 13 | it('should indent with 2 spaces', async () => { 14 | const filePath = '/test-file.json'; 15 | const jsonData = { 16 | test: 'value', 17 | }; 18 | const config: RunOptions = { 19 | localeJsonStringifyIndent: 2, 20 | } 21 | 22 | writeJsonFile(filePath, jsonData, config); 23 | 24 | const expectedValue = `{\n "test": "value"\n}\n`; 25 | 26 | expect(writeFileSync).toHaveBeenCalledWith(filePath, expectedValue); 27 | }); 28 | }); 29 | 30 | describe('localeJsonStringifyIndent of 4', () => { 31 | it('should indent with 4 spaces', async () => { 32 | const filePath = '/test-file.json'; 33 | const jsonData = { 34 | test: 'value', 35 | }; 36 | const config: RunOptions = { 37 | localeJsonStringifyIndent: 4, 38 | } 39 | 40 | writeJsonFile(filePath, jsonData, config); 41 | 42 | const expectedValue = `{\n "test": "value"\n}\n`; 43 | 44 | expect(writeFileSync).toHaveBeenCalledWith(filePath, expectedValue); 45 | }); 46 | }); 47 | 48 | describe('localeJsonStringifyIndent of \t', () => { 49 | it('should indent with 1 tab', async () => { 50 | const filePath = '/test-file.json'; 51 | const jsonData = { 52 | test: 'value', 53 | }; 54 | const config: RunOptions = { 55 | localeJsonStringifyIndent: '\t', 56 | } 57 | 58 | writeJsonFile(filePath, jsonData, config); 59 | 60 | const expectedValue = `{\n\t"test": "value"\n}\n`; 61 | 62 | expect(writeFileSync).toHaveBeenCalledWith(filePath, expectedValue); 63 | }); 64 | }); 65 | 66 | 67 | describe('localeJsonStringifyIndent of `indent`', () => { 68 | it('should indent with the string `indent`', async () => { 69 | const filePath = '/test-file.json'; 70 | const jsonData = { 71 | test: 'value', 72 | }; 73 | const config: RunOptions = { 74 | localeJsonStringifyIndent: 'indent', 75 | } 76 | 77 | writeJsonFile(filePath, jsonData, config); 78 | 79 | const expectedValue = `{\nindent"test": "value"\n}\n`; 80 | 81 | expect(writeFileSync).toHaveBeenCalledWith(filePath, expectedValue); 82 | }); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /src/actions/remove.ts: -------------------------------------------------------------------------------- 1 | import { createRequire } from "module"; 2 | 3 | import { RunOptions, UnusedTranslations } from "../types"; 4 | 5 | import { initialize } from "../core/initialize"; 6 | import { collectUnusedTranslations } from "../core/translations"; 7 | import { generateFilesPaths } from "../helpers/files"; 8 | import { applyToFlatKey } from "../core/action"; 9 | import { checkUncommittedChanges } from "../helpers/git"; 10 | import { importMetaUrl } from "../helpers/meta"; 11 | 12 | import { GREEN } from "../helpers/consoleColor"; 13 | import { writeJsonFile } from "../helpers/writeJsonFile"; 14 | 15 | export const removeUnusedTranslations = async ( 16 | options: RunOptions, 17 | ): Promise => { 18 | const config = await initialize(options); 19 | 20 | const localesFilesPaths = await generateFilesPaths(config.localesPath, { 21 | srcExtensions: ["json"], // @TODO implement other types when add other types writes 22 | fileNameResolver: config.localeNameResolver, 23 | }); 24 | 25 | const srcFilesPaths = await generateFilesPaths( 26 | `${process.cwd()}/${config.srcPath}`, 27 | { 28 | srcExtensions: config.srcExtensions, 29 | ignorePaths: config.ignorePaths, 30 | basePath: config.srcPath, 31 | }, 32 | ); 33 | 34 | const unusedTranslations = await collectUnusedTranslations( 35 | localesFilesPaths, 36 | srcFilesPaths, 37 | { 38 | context: config.context, 39 | contextSeparator: config.translationContextSeparator, 40 | contextMatcher: config.translationContextMatcher, 41 | ignoreComments: config.ignoreComments, 42 | localeFileParser: config.localeFileParser, 43 | localeFileLoader: config.localeFileLoader, 44 | customChecker: config.customChecker, 45 | excludeTranslationKey: config.excludeKey, 46 | translationKeyMatcher: config.translationKeyMatcher, 47 | }, 48 | ); 49 | 50 | if (config.gitCheck) { 51 | checkUncommittedChanges(); 52 | } 53 | 54 | unusedTranslations.translations.forEach((translation) => { 55 | const r = createRequire(importMetaUrl()); 56 | const locale = r(translation.localePath); 57 | 58 | translation.keys.forEach((key) => 59 | applyToFlatKey( 60 | locale, 61 | key, 62 | (source, lastKey) => { 63 | delete source[lastKey]; 64 | }, 65 | { 66 | flatTranslations: config.flatTranslations, 67 | separator: config.translationSeparator, 68 | }, 69 | ), 70 | ); 71 | 72 | writeJsonFile(translation.localePath, locale, config); 73 | 74 | console.log(GREEN, `Successfully removed: ${translation.localePath}`); 75 | }); 76 | 77 | return unusedTranslations; 78 | }; 79 | -------------------------------------------------------------------------------- /src/actions/mark.ts: -------------------------------------------------------------------------------- 1 | import { createRequire } from "module"; 2 | 3 | import { RunOptions, UnusedTranslations } from "../types"; 4 | 5 | import { initialize } from "../core/initialize"; 6 | import { collectUnusedTranslations } from "../core/translations"; 7 | import { generateFilesPaths } from "../helpers/files"; 8 | import { applyToFlatKey } from "../core/action"; 9 | import { checkUncommittedChanges } from "../helpers/git"; 10 | import { importMetaUrl } from "../helpers/meta"; 11 | 12 | import { GREEN } from "../helpers/consoleColor"; 13 | import { writeJsonFile } from "../helpers/writeJsonFile"; 14 | 15 | export const markUnusedTranslations = async ( 16 | options: RunOptions, 17 | ): Promise => { 18 | const config = await initialize(options); 19 | 20 | const localesFilesPaths = await generateFilesPaths(config.localesPath, { 21 | srcExtensions: ["json"], // @TODO implement other types when add other types writes 22 | fileNameResolver: config.localeNameResolver, 23 | }); 24 | 25 | const srcFilesPaths = await generateFilesPaths( 26 | `${process.cwd()}/${config.srcPath}`, 27 | { 28 | srcExtensions: config.srcExtensions, 29 | ignorePaths: config.ignorePaths, 30 | basePath: config.srcPath, 31 | }, 32 | ); 33 | 34 | const unusedTranslations = await collectUnusedTranslations( 35 | localesFilesPaths, 36 | srcFilesPaths, 37 | { 38 | context: config.context, 39 | contextSeparator: config.translationContextSeparator, 40 | contextMatcher: config.translationContextMatcher, 41 | ignoreComments: config.ignoreComments, 42 | localeFileParser: config.localeFileParser, 43 | localeFileLoader: config.localeFileLoader, 44 | customChecker: config.customChecker, 45 | excludeTranslationKey: config.excludeKey, 46 | translationKeyMatcher: config.translationKeyMatcher, 47 | }, 48 | ); 49 | 50 | if (config.gitCheck) { 51 | checkUncommittedChanges(); 52 | } 53 | 54 | unusedTranslations.translations.forEach((translation) => { 55 | const r = createRequire(importMetaUrl()); 56 | const locale = r(translation.localePath); 57 | 58 | translation.keys.forEach((key) => 59 | applyToFlatKey( 60 | locale, 61 | key, 62 | (source, lastKey) => { 63 | source[lastKey] = `${config.marker} ${source[lastKey]}`; 64 | }, 65 | { 66 | flatTranslations: config.flatTranslations, 67 | separator: config.translationSeparator, 68 | }, 69 | ), 70 | ); 71 | 72 | writeJsonFile(translation.localePath, locale, config); 73 | 74 | console.log(GREEN, `Successfully marked: ${translation.localePath}`); 75 | }); 76 | 77 | return unusedTranslations; 78 | }; 79 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # i18n-unused changelog 2 | 3 | ## [0.7.2](https://www.npmjs.com/package/i18n-unused/v/0.7.1) - 2021-12-23 4 | ### Fixed 5 | - ignorePaths filter strategy 6 | 7 | ## [0.7.1](https://www.npmjs.com/package/i18n-unused/v/0.7.1) - 2021-12-22 8 | ### Fixed 9 | - ignorePaths logic 10 | 11 | ## [0.7.0](https://www.npmjs.com/package/i18n-unused/v/0.7.0) - 2021-12-21 12 | ### Added 13 | - ignorePaths option 14 | 15 | ## [0.6.0](https://www.npmjs.com/package/i18n-unused/v/0.6.0) - 2021-12-16 16 | ### Added 17 | - Context option 18 | 19 | ## [0.5.2](https://www.npmjs.com/package/i18n-unused/v/0.5.2) - 2021-12-08 20 | ### Changed 21 | - Default i18n keys 22 | 23 | ## [0.5.1](https://www.npmjs.com/package/i18n-unused/v/0.5.1) - 2021-09-03 24 | ### Fixed 25 | - Code comments searching 26 | 27 | ## [0.5.0](https://www.npmjs.com/package/i18n-unused/v/0.5.0) - 2021-08-31 28 | ### Added 29 | - Code comments ignore option 30 | 31 | ## [0.4.2](https://www.npmjs.com/package/i18n-unused/v/0.4.2) - 2021-08-23 32 | ### Fixed 33 | - RegExp performance 34 | 35 | ## [0.4.1](https://www.npmjs.com/package/i18n-unused/v/0.4.1) - 2021-08-12 36 | ### Changed 37 | - Specs 38 | 39 | ## [0.4.0](https://www.npmjs.com/package/i18n-unused/v/0.4.0) - 2021-08-12 40 | ### Added 41 | - Tests 42 | 43 | ## [0.3.4](https://www.npmjs.com/package/i18n-unused/v/0.3.4) - 2021-07-30 44 | ### Changed 45 | - srcPath options as non required 46 | 47 | ## [0.3.3](https://www.npmjs.com/package/i18n-unused/v/0.3.3) - 2021-07-28 48 | ### Fixed 49 | - Translations parsing 50 | 51 | ## [0.3.2](https://www.npmjs.com/package/i18n-unused/v/0.3.2) - 2021-07-23 52 | ### Changed 53 | - Docs 54 | 55 | ## [0.3.1](https://www.npmjs.com/package/i18n-unused/v/0.3.1) - 2021-07-23 56 | ### Changed 57 | - Default i18n keys 58 | 59 | ## [0.3.0](https://www.npmjs.com/package/i18n-unused/v/0.3.0) - 2021-07-22 60 | ### Added 61 | - Missed actions 62 | - Linter 63 | - Prettier 64 | ### Changed 65 | - Docs 66 | - Project structure 67 | ### Fixed 68 | - Circle dependencies 69 | 70 | ## [0.2.0](https://www.npmjs.com/package/i18n-unused/v/0.2.0) - 2021-07-20 71 | ### Fixed 72 | - Common and ES using 73 | 74 | ## [0.1.7](https://www.npmjs.com/package/i18n-unused/v/0.1.7) - 2021-07-20 75 | ### Fixed 76 | - Build bin path 77 | 78 | ## [0.1.6](https://www.npmjs.com/package/i18n-unused/v/0.1.6) - 2021-07-20 79 | ### Fixed 80 | - Build file type 81 | 82 | ## [0.1.4](https://www.npmjs.com/package/i18n-unused/v/0.1.4) - 2021-07-20 83 | ### Fixed 84 | - Build files 85 | 86 | ## [0.1.3](https://www.npmjs.com/package/i18n-unused/v/0.1.3) - 2021-07-20 87 | ### Added 88 | - Package types 89 | 90 | ## [0.1.2](https://www.npmjs.com/package/i18n-unused/v/0.1.2) - 2021-07-20 91 | ### Changed 92 | - Build paths 93 | 94 | ## [0.1.1](https://www.npmjs.com/package/i18n-unused/v/0.1.1) - 2021-07-20 95 | ### Fixed 96 | - Locales files sync action 97 | 98 | ## [0.1.0](https://www.npmjs.com/package/i18n-unused/v/0.1.0) - 2021-07-19 99 | ### Added 100 | - File name resolver 101 | - File resolver 102 | 103 | ## [0.0.8](https://www.npmjs.com/package/i18n-unused/v/0.0.8) - 2021-07-15 104 | ### Added 105 | - File size handler 106 | - Unused collection 107 | ### Fixed 108 | - Inline options 109 | 110 | ## [0.0.7](https://www.npmjs.com/package/i18n-unused/v/0.0.7) - 2021-07-14 111 | ### Changed 112 | - Docs badges 113 | 114 | ## [0.0.6](https://www.npmjs.com/package/i18n-unused/v/0.0.6) - 2021-07-14 115 | ### Changed 116 | - Readme 117 | 118 | ## [0.0.5](https://www.npmjs.com/package/i18n-unused/v/0.0.5) - 2021-07-14 119 | ### Changed 120 | - Readme 121 | 122 | ## [0.0.4](https://www.npmjs.com/package/i18n-unused/v/0.0.4) - 2021-07-14 123 | ### Added 124 | - Helpers 125 | - Mark actions 126 | - Remove actions 127 | - Git changes check option 128 | ### Fixed 129 | - Display actions 130 | 131 | ## [0.0.3](https://www.npmjs.com/package/i18n-unused/v/0.0.3) - 2021-06-09 132 | ### Changed 133 | - Build scripts 134 | 135 | ## [0.0.2](https://www.npmjs.com/package/i18n-unused/v/0.0.2) - 2021-06-09 136 | ### Added 137 | - Project settings 138 | - Types 139 | 140 | ## [0.0.1](https://www.npmjs.com/package/i18n-unused/v/0.0.1) - 2021-06-09 141 | ### Added 142 | - Init project, add base structure -------------------------------------------------------------------------------- /src/helpers/files.ts: -------------------------------------------------------------------------------- 1 | import { tsImport } from "ts-import"; 2 | 3 | import { createRequire } from "module"; 4 | 5 | import { readFileSync, Dirent, promises as FsPromises } from "fs"; 6 | 7 | import path from "path"; 8 | 9 | import { 10 | ModuleResolver, 11 | ModuleNameResolver, 12 | RecursiveStruct, 13 | CustomFileLoader, 14 | } from "../types"; 15 | import { importMetaUrl } from "./meta"; 16 | 17 | export const getFileSizeKb = (str: string): number => 18 | Buffer.byteLength(str, "utf8") / 1000; 19 | 20 | export const isSubstrInFile = (filePath: string, substr: string): boolean => { 21 | const file = readFileSync(filePath).toString(); 22 | 23 | return file.includes(substr); 24 | }; 25 | 26 | const isNodeVersion22OrAbove = () => { 27 | const version = process.version.match(/^v(\d+)/); 28 | return version && parseInt(version[1], 10) >= 22; 29 | }; 30 | 31 | export const resolveFile = async ( 32 | filePath: string, 33 | resolver: ModuleResolver = (m) => m, 34 | loader?: CustomFileLoader, 35 | ): Promise => { 36 | const [, ext] = filePath.match(/\.([0-9a-z]+)(?:[?#]|$)/i) || []; 37 | let m = {}; 38 | 39 | if (loader) { 40 | m = loader(filePath); 41 | } else if (ext === "ts") { 42 | m = await tsImport.compile(filePath); 43 | } else if (["js", "cjs"].includes(ext)) { 44 | let r = createRequire(importMetaUrl()); 45 | if (!isNodeVersion22OrAbove()) { 46 | r = r("esm")(m /*, options*/); 47 | } 48 | m = r(filePath); 49 | } else if (ext === "json") { 50 | const r = createRequire(importMetaUrl()); 51 | m = r(filePath); 52 | } 53 | 54 | return resolver(m); 55 | }; 56 | 57 | const useFileNameResolver = ( 58 | resolver: ModuleNameResolver, 59 | name: string, 60 | ): boolean => { 61 | if (resolver instanceof RegExp) { 62 | return resolver.test(name); 63 | } 64 | if (typeof resolver === "function") { 65 | return resolver(name); 66 | } 67 | 68 | return false; 69 | }; 70 | 71 | interface options { 72 | basePath?: string; 73 | ignorePaths?: string[]; 74 | srcExtensions?: string[]; 75 | fileNameResolver?: ModuleNameResolver; 76 | } 77 | 78 | export const generateFilesPaths = async ( 79 | srcPath: string, 80 | { basePath, ignorePaths, srcExtensions, fileNameResolver }: options, 81 | ): Promise => { 82 | // Dirent: https://nodejs.org/api/fs.html#class-fsdirent 83 | const entries: Dirent[] = await FsPromises.readdir(srcPath, { 84 | withFileTypes: true, 85 | }); 86 | const files = await entries.reduce(async (accPromise, dirent: Dirent) => { 87 | const nextPath: string = path.resolve(srcPath, dirent.name); 88 | const acc = await accPromise; 89 | 90 | if (ignorePaths) { 91 | const fullBasePath = path.resolve(`${process.cwd()}/${basePath}`); 92 | const pathFromBasePath = `${path.relative(fullBasePath, nextPath)}${ 93 | dirent.isDirectory() ? "/" : "" 94 | }`; 95 | if ( 96 | ignorePaths.some((ignorePath) => 97 | pathFromBasePath.startsWith(`${ignorePath}/`), 98 | ) 99 | ) { 100 | return acc; 101 | } 102 | } 103 | 104 | if (dirent.isDirectory()) { 105 | const generatedNextPath = await generateFilesPaths(nextPath, { 106 | basePath, 107 | ignorePaths, 108 | srcExtensions, 109 | fileNameResolver, 110 | }); 111 | 112 | acc.push(...generatedNextPath); 113 | 114 | return acc; 115 | } 116 | 117 | const fileName = path.basename(nextPath); 118 | 119 | if (srcExtensions) { 120 | const [, ext] = fileName.match(/\.([0-9a-z]+)(?:[?#]|$)/i) || []; 121 | const validExtension = srcExtensions.some((_ext: string) => { 122 | if (_ext === ext && fileNameResolver) { 123 | return useFileNameResolver(fileNameResolver, fileName); 124 | } 125 | 126 | return _ext === ext; 127 | }); 128 | 129 | if (!validExtension) { 130 | return acc; 131 | } 132 | } 133 | 134 | if (fileNameResolver && !useFileNameResolver(fileNameResolver, fileName)) { 135 | return acc; 136 | } 137 | 138 | acc.push(nextPath); 139 | 140 | return acc; 141 | }, Promise.resolve([])); 142 | 143 | return files; 144 | }; 145 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export type RecursiveStruct = { 2 | [key: string]: string | string[] | RecursiveStruct; 3 | }; 4 | 5 | export type ApplyFlat = (source: RecursiveStruct, key: string) => void; 6 | 7 | export type UnusedTranslation = { 8 | localePath: string; 9 | keys: string[]; 10 | count: number; 11 | }[]; 12 | 13 | export type MissedTranslation = { 14 | filePath: string; 15 | staticKeys: string[]; 16 | dynamicKeys: string[]; 17 | staticCount: number; 18 | dynamicCount: number; 19 | }[]; 20 | 21 | export type UnusedTranslations = { 22 | translations: UnusedTranslation; 23 | totalCount: number; 24 | }; 25 | 26 | export type MissedTranslations = { 27 | translations: MissedTranslation; 28 | totalStaticCount: number; 29 | totalDynamicCount: number; 30 | }; 31 | 32 | export type ModuleNameResolver = RegExp | ((n: string) => boolean); 33 | 34 | export type TranslationKeyMatcher = RegExp; 35 | 36 | export type MissedTranslationParser = RegExp | ((v: string) => string); 37 | 38 | export type ModuleResolver = (m: RecursiveStruct) => RecursiveStruct; 39 | 40 | export type CustomFileLoader = (filePath: string) => RecursiveStruct; 41 | 42 | export type CustomChecker = ( 43 | matchKeys: Set, 44 | translationsKeys: string[], 45 | ) => void; 46 | 47 | export interface RunOptions { 48 | /** 49 | * Path to the locales folder 50 | */ 51 | localesPath?: string; 52 | /** 53 | * Allowed file extensions for locales 54 | * @default ['json'] if `localeNameResolver` is not set 55 | */ 56 | localesExtensions?: string[]; 57 | /** 58 | * File name resolver for locales 59 | */ 60 | localeNameResolver?: ModuleNameResolver; 61 | /** 62 | * Function to check if a key is used, if so the key should be removed from 63 | * translationsKeys 64 | */ 65 | customChecker?: CustomChecker; 66 | /** 67 | * Resolve locale imports, for example if you use named imports from locales 68 | * files, just wrap it to your own resolver 69 | * @default (m) => m.default 70 | */ 71 | localeFileParser?: ModuleResolver; 72 | /** 73 | * Load the locale file manually (e.g. for using your own parser) 74 | */ 75 | localeFileLoader?: CustomFileLoader; 76 | /** 77 | * Path to search for translations 78 | * @default process.cwd() 79 | */ 80 | srcPath?: string; 81 | /** 82 | * Allowed file extensions for translations 83 | * @default ['js', 'jsx', 'ts', 'tsx', 'vue'] 84 | */ 85 | srcExtensions?: string[]; 86 | /** 87 | * Ignore paths, eg: `['src/ignored-folder']`, should start similarly srcPath 88 | */ 89 | ignorePaths?: string[]; 90 | /** 91 | * Matcher to search for translation keys in files 92 | * @default RegExp, match $_, $t, t, $tc, tc and i18nKey 93 | */ 94 | translationKeyMatcher?: TranslationKeyMatcher; 95 | /** 96 | * Doesn't process translations that include passed key(s), for example if you 97 | * set `excludeKey: '.props.'`, script will ignore `Button.props.value`. 98 | */ 99 | excludeKey?: string | string[]; 100 | /** 101 | * Ignore code comments in src files. 102 | * @default false 103 | */ 104 | ignoreComments?: boolean; 105 | /** 106 | * Special string to mark unused translations, it'll added via mark-unused 107 | * @default '[UNUSED]' 108 | */ 109 | marker?: string; 110 | /** 111 | * Show git state change tree 112 | * @default false 113 | */ 114 | gitCheck?: boolean; 115 | /** 116 | * Use i18n context, (eg: {@link https://www.i18next.com/translation-function/plurals Plurals}) 117 | * @default true 118 | */ 119 | context?: boolean; 120 | /** 121 | * Use flat translations, (eg: {@link https://www.codeandweb.com/babeledit/documentation/file-formats#flat-json Flat JSON}) 122 | */ 123 | flatTranslations?: boolean; 124 | /** 125 | * Separator for translations using in code 126 | * @default '.' 127 | */ 128 | translationSeparator?: string; 129 | /** 130 | * Separator for i18n (see {@link context context option}) 131 | * @default '_' 132 | */ 133 | translationContextSeparator?: string; 134 | /** 135 | * Matcher to search for i18n context in translations 136 | * @default RegExp, match `zero`, `one`, `two`, `few`, `many`, `other`, `male`, `female`, `0`, `1`, `2`, `3`, `4`, `5`, `plural`, `11` and `100` 137 | */ 138 | translationContextMatcher?: RegExp; 139 | /** 140 | * Parser for ejecting value from {@link translationKeyMatcher} matches 141 | * @default RegExp, match value inside rounded brackets 142 | */ 143 | missedTranslationParser?: MissedTranslationParser; 144 | /** 145 | * Json indent value for writing json file, either a number of spaces, or a 146 | * string to indent with. (i.e. `2`, `4`, `\t`) 147 | * @default 2 148 | */ 149 | localeJsonStringifyIndent: string | number; 150 | 151 | /** 152 | * Throw error when found unused translations 153 | * @default false 154 | */ 155 | failOnUnused?: boolean; 156 | 157 | /** 158 | * Throw error when found missed translations 159 | * @default false 160 | */ 161 | failOnMissed?: boolean; 162 | } 163 | -------------------------------------------------------------------------------- /src/actions/display.ts: -------------------------------------------------------------------------------- 1 | import { RunOptions, UnusedTranslations, MissedTranslations } from "../types"; 2 | 3 | import { initialize } from "../core/initialize"; 4 | import { 5 | collectUnusedTranslations, 6 | collectMissedTranslations, 7 | } from "../core/translations"; 8 | import { generateFilesPaths, getFileSizeKb } from "../helpers/files"; 9 | 10 | export const displayUnusedTranslations = async ( 11 | options: RunOptions, 12 | ): Promise => { 13 | const config = await initialize(options); 14 | 15 | const localesFilesPaths = await generateFilesPaths(config.localesPath, { 16 | srcExtensions: config.localesExtensions, 17 | fileNameResolver: config.localeNameResolver, 18 | }); 19 | 20 | const srcFilesPaths = await generateFilesPaths( 21 | `${process.cwd()}/${config.srcPath}`, 22 | { 23 | srcExtensions: config.srcExtensions, 24 | ignorePaths: config.ignorePaths, 25 | basePath: config.srcPath, 26 | }, 27 | ); 28 | 29 | const unusedTranslations = await collectUnusedTranslations( 30 | localesFilesPaths, 31 | srcFilesPaths, 32 | { 33 | context: config.context, 34 | contextSeparator: config.translationContextSeparator, 35 | contextMatcher: config.translationContextMatcher, 36 | ignoreComments: config.ignoreComments, 37 | localeFileParser: config.localeFileParser, 38 | localeFileLoader: config.localeFileLoader, 39 | customChecker: config.customChecker, 40 | excludeTranslationKey: config.excludeKey, 41 | translationKeyMatcher: config.translationKeyMatcher, 42 | }, 43 | ); 44 | 45 | unusedTranslations.translations.forEach((translation) => { 46 | console.log( 47 | "<<<==========================================================>>>", 48 | ); 49 | console.log(`Unused translations in: ${translation.localePath}`); 50 | console.log(`Unused translations count: ${translation.count}`); 51 | console.table( 52 | translation.keys.map((key: string) => ({ Translation: key })), 53 | ); 54 | }); 55 | 56 | console.log( 57 | `Total unused translations count: ${unusedTranslations.totalCount}`, 58 | ); 59 | 60 | console.log( 61 | `Can free up memory: ~${getFileSizeKb( 62 | unusedTranslations.translations.reduce( 63 | (acc, { keys }) => `${acc}, ${keys.join(", ")}`, 64 | "", 65 | ), 66 | )}kb`, 67 | ); 68 | 69 | if (config.failOnUnused && unusedTranslations.totalCount > 0) { 70 | process.exitCode = 1; 71 | } 72 | 73 | return unusedTranslations; 74 | }; 75 | 76 | export const displayMissedTranslations = async ( 77 | options: RunOptions, 78 | ): Promise => { 79 | const config = await initialize(options); 80 | 81 | const localesFilesPaths = await generateFilesPaths(config.localesPath, { 82 | srcExtensions: config.localesExtensions, 83 | fileNameResolver: config.localeNameResolver, 84 | }); 85 | 86 | const srcFilesPaths = await generateFilesPaths( 87 | `${process.cwd()}/${config.srcPath}`, 88 | { 89 | srcExtensions: config.srcExtensions, 90 | ignorePaths: config.ignorePaths, 91 | basePath: config.srcPath, 92 | }, 93 | ); 94 | 95 | const missedTranslations = await collectMissedTranslations( 96 | localesFilesPaths, 97 | srcFilesPaths, 98 | { 99 | context: config.context, 100 | contextSeparator: config.translationContextSeparator, 101 | contextMatcher: config.translationContextMatcher, 102 | ignoreComments: config.ignoreComments, 103 | localeFileParser: config.localeFileParser, 104 | localeFileLoader: config.localeFileLoader, 105 | excludeTranslationKey: config.excludeKey, 106 | translationKeyMatcher: config.translationKeyMatcher, 107 | missedTranslationParser: config.missedTranslationParser, 108 | }, 109 | ); 110 | 111 | missedTranslations.translations.forEach((translation) => { 112 | console.log( 113 | "<<<==========================================================>>>", 114 | ); 115 | 116 | console.log(`Missed translations in: ${translation.filePath}`); 117 | console.log(`Missed static translations count: ${translation.staticCount}`); 118 | console.log( 119 | `Missed dynamic translations count: ${translation.dynamicCount}`, 120 | ); 121 | 122 | if (translation.staticKeys.length) { 123 | console.log("--------------------------------------------"); 124 | console.log("Static keys:"); 125 | console.table( 126 | translation.staticKeys.map((key: string) => ({ Key: key })), 127 | ); 128 | } 129 | if (translation.dynamicKeys.length) { 130 | console.log("--------------------------------------------"); 131 | console.log("Dynamic keys:"); 132 | console.table( 133 | translation.dynamicKeys.map((key: string) => ({ Key: key })), 134 | ); 135 | } 136 | }); 137 | 138 | console.log( 139 | `Total missed static translations count: ${missedTranslations.totalStaticCount}`, 140 | ); 141 | console.log( 142 | `Total missed dynamic translations count: ${missedTranslations.totalDynamicCount}`, 143 | ); 144 | 145 | if ( 146 | config.failOnMissed && 147 | missedTranslations.totalStaticCount + missedTranslations.totalDynamicCount > 148 | 0 149 | ) { 150 | process.exitCode = 1; 151 | } 152 | 153 | return missedTranslations; 154 | }; 155 | -------------------------------------------------------------------------------- /src/core/translations.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "fs"; 2 | 3 | import { 4 | UnusedTranslation, 5 | UnusedTranslations, 6 | MissedTranslation, 7 | MissedTranslations, 8 | ModuleResolver, 9 | TranslationKeyMatcher, 10 | CustomFileLoader, 11 | MissedTranslationParser, 12 | CustomChecker, 13 | } from "../types"; 14 | 15 | import { resolveFile } from "../helpers/files"; 16 | import { generateTranslationsFlatKeys } from "../helpers/flatKeys"; 17 | 18 | const replaceQuotes = (v: string): string => v.replace(/['"`]/gi, ""); 19 | 20 | const isStaticKey = (v: string): boolean => !v.includes("${") && /['"]/.test(v); 21 | 22 | const isDynamicKey = (v: string): boolean => 23 | v.includes("${") || !/['"]/.test(v); 24 | 25 | const isInlineComment = (str: string): boolean => /^(\/\/)/.test(str); 26 | const isHTMLComment = (str: string): boolean => /^(