From cc77ff77ff46603d0fcc4dd43589ff30b1aaa49f Mon Sep 17 00:00:00 2001 From: Levende Date: Thu, 20 Feb 2025 11:11:41 +0100 Subject: [PATCH 1/3] feat: add unused locales --- .github/renovate.json | 82 +++-------- eslint.config.mjs | 7 + packages/unused-i18n/.eslintrc.json | 5 + packages/unused-i18n/.npmignore | 5 + packages/unused-i18n/README.md | 120 +++++++++++++++ packages/unused-i18n/package.json | 51 +++++++ packages/unused-i18n/src/cli.ts | 32 ++++ packages/unused-i18n/src/index.ts | 121 ++++++++++++++++ packages/unused-i18n/src/lib/analyze.ts | 23 +++ .../src/lib/global/extractGlobalT.ts | 79 ++++++++++ packages/unused-i18n/src/lib/remove.ts | 38 +++++ .../extractNamespaceTranslation.ts | 79 ++++++++++ .../lib/scopedNamespace/extractScopedTs.ts | 137 ++++++++++++++++++ packages/unused-i18n/src/lib/search.ts | 32 ++++ packages/unused-i18n/src/types/index.ts | 58 ++++++++ packages/unused-i18n/src/utils/escapeRegex.ts | 2 + packages/unused-i18n/src/utils/loadConfig.ts | 55 +++++++ .../src/utils/missingTranslations.ts | 28 ++++ .../unused-i18n/src/utils/shouldExclude.ts | 13 ++ packages/unused-i18n/src/utils/summary.ts | 15 ++ packages/unused-i18n/src/vite-env.d.ts | 1 + packages/unused-i18n/tsconfig.build.json | 10 ++ packages/unused-i18n/tsconfig.json | 4 + packages/unused-i18n/vite.config.ts | 25 ++++ packages/unused-i18n/vitest.setup.ts | 11 ++ pnpm-lock.yaml | 15 ++ vite.config.ts | 2 +- 27 files changed, 986 insertions(+), 64 deletions(-) create mode 100644 packages/unused-i18n/.eslintrc.json create mode 100644 packages/unused-i18n/.npmignore create mode 100644 packages/unused-i18n/README.md create mode 100644 packages/unused-i18n/package.json create mode 100755 packages/unused-i18n/src/cli.ts create mode 100644 packages/unused-i18n/src/index.ts create mode 100644 packages/unused-i18n/src/lib/analyze.ts create mode 100644 packages/unused-i18n/src/lib/global/extractGlobalT.ts create mode 100644 packages/unused-i18n/src/lib/remove.ts create mode 100644 packages/unused-i18n/src/lib/scopedNamespace/extractNamespaceTranslation.ts create mode 100644 packages/unused-i18n/src/lib/scopedNamespace/extractScopedTs.ts create mode 100644 packages/unused-i18n/src/lib/search.ts create mode 100644 packages/unused-i18n/src/types/index.ts create mode 100644 packages/unused-i18n/src/utils/escapeRegex.ts create mode 100644 packages/unused-i18n/src/utils/loadConfig.ts create mode 100644 packages/unused-i18n/src/utils/missingTranslations.ts create mode 100644 packages/unused-i18n/src/utils/shouldExclude.ts create mode 100644 packages/unused-i18n/src/utils/summary.ts create mode 100644 packages/unused-i18n/src/vite-env.d.ts create mode 100644 packages/unused-i18n/tsconfig.build.json create mode 100644 packages/unused-i18n/tsconfig.json create mode 100644 packages/unused-i18n/vite.config.ts create mode 100644 packages/unused-i18n/vitest.setup.ts diff --git a/.github/renovate.json b/.github/renovate.json index 78ec0d1f0..298dc9a96 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,107 +1,63 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "assignees": [ - "team:console" - ], + "assignees": ["team:console"], "assigneesSampleSize": 1, "automerge": false, "dependencyDashboard": true, - "enabledManagers": [ - "github-actions", - "npm" - ], - "extends": [ - "config:recommended", - ":combinePatchMinorReleases" - ], - "labels": [ - "dependencies" - ], + "enabledManagers": ["github-actions", "npm"], + "extends": ["config:recommended", ":combinePatchMinorReleases"], + "labels": ["dependencies"], "prConcurrentLimit": 10, "prHourlyLimit": 5, "rangeStrategy": "pin", "rebaseWhen": "auto", - "reviewers": [ - "team:console" - ], + "reviewers": ["team:console"], "reviewersSampleSize": 2, "semanticCommitScope": "deps", "semanticCommitType": "chore", - "ignorePaths": [ - "packages_deprecated/**/package.json" - ], + "ignorePaths": ["packages_deprecated/**/package.json"], "packageRules": [ { - "matchDepTypes": [ - "engines", - "peerDependencies" - ], + "matchDepTypes": ["engines", "peerDependencies"], "rangeStrategy": "widen" }, { - "matchManagers": [ - "github-actions" - ], + "matchManagers": ["github-actions"], "semanticCommitScope": "devDeps", "automerge": true, "autoApprove": true }, { "semanticCommitScope": "devDeps", - "matchDepTypes": [ - "packageManager", - "devDependencies" - ], - "matchUpdateTypes": [ - "major" - ] + "matchDepTypes": ["packageManager", "devDependencies"], + "matchUpdateTypes": ["major"] }, { "semanticCommitScope": "devDeps", - "matchDepTypes": [ - "packageManager", - "devDependencies" - ], - "matchUpdateTypes": [ - "minor", - "patch" - ] + "matchDepTypes": ["packageManager", "devDependencies"], + "matchUpdateTypes": ["minor", "patch"] }, { - "labels": [ - "UPDATE-MAJOR" - ], + "labels": ["UPDATE-MAJOR"], "minimumReleaseAge": "14 days", - "matchUpdateTypes": [ - "major" - ] + "matchUpdateTypes": ["major"] }, { - "labels": [ - "UPDATE-MINOR" - ], + "labels": ["UPDATE-MINOR"], "minimumReleaseAge": "7 days", - "matchUpdateTypes": [ - "minor" - ], + "matchUpdateTypes": ["minor"], "automerge": true, "autoApprove": true }, { - "labels": [ - "UPDATE-PATCH" - ], + "labels": ["UPDATE-PATCH"], "minimumReleaseAge": "3 days", - "matchUpdateTypes": [ - "patch" - ], + "matchUpdateTypes": ["patch"], "automerge": true, "autoApprove": true }, { - "matchDepTypes": [ - "engines" - ], + "matchDepTypes": ["engines"], "rangeStrategy": "widen" } ] diff --git a/eslint.config.mjs b/eslint.config.mjs index 0cc6fd682..9b4839edd 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -108,4 +108,11 @@ export default [ 'import/no-relative-packages': 'off', }, }, + { + files: ['packages/unused-i18n/**/*.ts'], + rules: { + 'no-console': 'off', + 'no-cond-assign': 'off', + }, + }, ] diff --git a/packages/unused-i18n/.eslintrc.json b/packages/unused-i18n/.eslintrc.json new file mode 100644 index 000000000..d5ba8f9d9 --- /dev/null +++ b/packages/unused-i18n/.eslintrc.json @@ -0,0 +1,5 @@ +{ + "rules": { + "no-console": "off" + } +} diff --git a/packages/unused-i18n/.npmignore b/packages/unused-i18n/.npmignore new file mode 100644 index 000000000..5600eef5f --- /dev/null +++ b/packages/unused-i18n/.npmignore @@ -0,0 +1,5 @@ +**/__tests__/** +examples/ +src +.eslintrc.cjs +!.npmignore diff --git a/packages/unused-i18n/README.md b/packages/unused-i18n/README.md new file mode 100644 index 000000000..df162de7d --- /dev/null +++ b/packages/unused-i18n/README.md @@ -0,0 +1,120 @@ +# Unused i18n + +Simplifies managing and cleaning up unused translation keys in localization files + +## Features + +- Analyzes source files to identify used and unused translation keys. +- Supports multiple scoped translation functions. +- Can display or remove unused translation keys. +- Configurable through a JSON, CJS, or JS config file. + +## Installation + +You can install `unused-i18n` via npm: + +```sh +npm install -g unused-i18n + +or + +npm install -D unused-i18n +``` + +## Configuration + +Create a unused-i18n.config.json or unused-i18n.config.js file in the root of your project. Here's an example configuration: + +```cjs +module.exports = { + paths: [ + { + srcPath: ['src/pages/products'], + localPath: 'src/pages/products/locales', + }, + ], + localesExtensions: 'ts', + localesNames: 'en', + scopedNames: ['scopedT', 'scopedTOne'], + ignorePaths: ['src/pages/products/ignoreThisFolder'], + excludeKey: ['someKey'], +} +``` + +| Option | Type | Default | Required | Description | +| ------------------- | ---------------- | ------- | -------- | ---------------------------------------------------------------------------- | +| `paths` | Array of Objects | `[]` | Yes | An array of objects defining the source paths and local paths to be checked. | +| `paths.srcPath` | Array of Strings | `[]` | Yes | Source paths to search for translations. | +| `paths.localPath` | Strings | `""` | Yes | Path to the translation files. | +| `localesExtensions` | String | `js` | No | Extension of the locale files. | +| `localesNames` | String | `en` | No | Name of the locale files without the extension. | +| `scopedNames` | Array of Strings | `[]` | No | Names of the scoped translation functions used in your project. | +| `ignorePaths` | Array of Strings | `[]` | No | Paths to be ignored during the search. | +| `excludeKey` | Array of Strings | `[]` | No | Specific translation keys to be excluded from the removal process. | + +## Usage + +### Using with Config File + +To use unused-i18n with your config file, simply run: + +```sh +npx unused-i18n display +``` + +### Using with Command Line Options + +You can also specify the source and local paths directly in the command line: + +##### Display Unused Translations + +```sh +npx unused-i18n display --srcPath="src/folders/bla" --localPath="src/folders/bla/locales" +``` + +##### Remove Unused Translations + +```sh +npx unused-i18n remove --srcPath="src/folders/bla" --localPath="src/folders/bla/locales" +``` + +## API + +`processTranslations(paths, action)` +Processes translations based on the specified paths and action. + +- paths: Array of objects containing srcPath and localPath. +- action: Action to perform, either 'display' or 'remove'. + +## Development + +### Building the Project + +To build the project, run: + +```sh +npm run build +``` + +#### Running Tests + +To run the tests, use: + +```sh +npm run test +``` + +## License + +This project is licensed under the MIT License - see the [LICENSE](https://github.com/Lawndlwd/unused-i18n/blob/HEAD/LICENSE) file for details. + +## Contributing + +Contributions are welcome! Please open an issue or submit a pull request if you have any improvements or suggestions. + +Acknowledgements + +- [Vite](https://vitejs.dev/) - Next Generation Frontend Tooling. +- [TypeScript](https://www.typescriptlang.org/) - Typed JavaScript used in this project. +- [Vitest](https://vitest.dev/guide/cli) - Testing framework used in this project. +- [Commander](https://github.com/tj/commander.js#readme) - Node.js command-line interfaces. diff --git a/packages/unused-i18n/package.json b/packages/unused-i18n/package.json new file mode 100644 index 000000000..ebda037a4 --- /dev/null +++ b/packages/unused-i18n/package.json @@ -0,0 +1,51 @@ +{ + "name": "unused-i18n", + "version": "0.2.0", + "description": "React provider to handle website end user consent cookie storage based on segment integrations", + "type": "module", + "main": "dist/cli.cjs", + "types": "dist/index.d.ts", + "bin": { + "unused-i18n": "dist/cli.cjs" + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "prebuild": "shx rm -rf dist", + "build": "vite build --config vite.config.ts && pnpm run type:generate", + "type:generate": "tsc --declaration -p tsconfig.build.json", + "typecheck": "tsc --noEmit", + "release": "pnpm build && pnpm changeset publish", + "coverage": "vitest run --coverage", + "lint": "eslint --report-unused-disable-directives --cache --cache-strategy content --ext ts,tsx .", + "test:unit": "vitest --run --config vite.config.ts", + "test:unit:coverage": "pnpm test:unit --coverage" + }, + "repository": { + "type": "git", + "url": "https://github.com/scaleway/scaleway-lib", + "directory": "packages/unused-i18n" + }, + "dependencies": { + "commander": "12.1.0", + "esbuild": "0.24.2" + }, + "keywords": [ + "i18n", + "unused-i18n", + "i18n-unused", + "locales", + "next-international", + "internationalization", + "translate", + "next" + ], + "engines": { + "node": ">=22.x", + "pnpm": ">=9.x" + } +} diff --git a/packages/unused-i18n/src/cli.ts b/packages/unused-i18n/src/cli.ts new file mode 100755 index 000000000..ec5091a2b --- /dev/null +++ b/packages/unused-i18n/src/cli.ts @@ -0,0 +1,32 @@ +import { Command } from 'commander' +import { processTranslations } from './index' + +const program = new Command() + +program + .command('display') + .description('Display missing translations') + .option('--srcPath path', 'Source path to search for translations') + .option('--localPath path', 'Local path to the translation files') + .action(async (options: { srcPath?: string; localPath?: string }) => { + const paths = + options.srcPath && options.localPath + ? [{ srcPath: [options.srcPath], localPath: options.localPath }] + : undefined + await processTranslations({ paths, action: 'display' }) + }) + +program + .command('remove') + .description('Remove unused translations') + .option('--srcPath path', 'Source path to search for translations') + .option('--localPath path', 'Local path to the translation files') + .action(async (options: { srcPath?: string; localPath?: string }) => { + const paths = + options.srcPath && options.localPath + ? [{ srcPath: [options.srcPath], localPath: options.localPath }] + : undefined + await processTranslations({ paths, action: 'remove' }) + }) + +program.parse(process.argv) diff --git a/packages/unused-i18n/src/index.ts b/packages/unused-i18n/src/index.ts new file mode 100644 index 000000000..7268b9291 --- /dev/null +++ b/packages/unused-i18n/src/index.ts @@ -0,0 +1,121 @@ +import * as fs from 'fs' +import { performance } from 'perf_hooks' +import { analyze } from './lib/analyze' +import { removeLocaleKeys } from './lib/remove' +import { searchFilesRecursively } from './lib/search' +import type { ProcessTranslationsArgs } from './types' +import { loadConfig } from './utils/loadConfig' +import { getMissingTranslations } from './utils/missingTranslations' +import { summary } from './utils/summary' + +export const processTranslations = async ({ + paths, + action, +}: ProcessTranslationsArgs) => { + const config = await loadConfig() + const excludePatterns = [/\.test\./, /__mock__/] + const localesExtensions = config.localesExtensions ?? 'js' + const localesNames = config.localesNames ?? 'en' + + let totalUnusedLocales = 0 + const unusedLocalesCountByPath: { + path: string + messages?: string + warning?: string + }[] = [] + + const pathsToProcess = paths || config.paths + + const startTime = performance.now() + pathsToProcess.forEach(({ srcPath, localPath }) => { + let allExtractedTranslations: string[] = [] + let pathUnusedLocalesCount = 0 + + srcPath.forEach(pathEntry => { + const ignorePathExists = config.ignorePaths?.some(ignorePath => + pathEntry.includes(ignorePath), + ) + if (ignorePathExists) return + const files = searchFilesRecursively({ + excludePatterns, + regex: /use-i18n/, + baseDir: pathEntry, + }) + + const extractedTranslations = files + .flatMap(file => + analyze({ + filePath: file, + scopedNames: config.scopedNames, + }), + ) + .sort() + .filter((item, index, array) => array.indexOf(item) === index) + + allExtractedTranslations = [ + ...allExtractedTranslations, + ...extractedTranslations, + ] + }) + + allExtractedTranslations = [...new Set(allExtractedTranslations)].sort() + + const localeFilePath = `${localPath}/${localesNames}.${localesExtensions}` + const ignorePathExists = config.ignorePaths?.some(ignorePath => + localeFilePath.includes(ignorePath), + ) + if (fs.existsSync(localeFilePath) && !ignorePathExists) { + console.log(`${localeFilePath}...`) + + const localLines = fs + .readFileSync(localeFilePath, 'utf-8') + .split('\n') + .map(line => line.trim()) + .filter(line => !line.startsWith('//') && line.match(/'[^']*':/)) + .map(line => line.match(/'([^']+)':/)?.[1] ?? '') + .sort() + + const missingTranslations = getMissingTranslations({ + localLines, + extractedTranslations: allExtractedTranslations, + excludeKey: config.excludeKey, + }) + + pathUnusedLocalesCount = missingTranslations.length + totalUnusedLocales += pathUnusedLocalesCount + + const formattedMissingTranslations = missingTranslations + .map(translation => `\x1b[31m${translation}\x1b[0m`) + .join('\n') + + const message = missingTranslations.length + ? `Unused translations in \x1b[33m${localeFilePath}\x1b[0m : \x1b[31m${pathUnusedLocalesCount} \n${formattedMissingTranslations}\x1b[0m` + : undefined + + if (message) { + unusedLocalesCountByPath.push({ + path: localPath, + messages: message, + }) + } + if (action === 'remove') { + removeLocaleKeys({ + localePath: localeFilePath, + missingTranslations, + }) + } + } + }) + const endTime = performance.now() + + summary({ unusedLocalesCountByPath, totalUnusedLocales }) + console.log( + `\x1b[38;2;128;128;128mDuration : ${(endTime - startTime).toFixed( + 0, + )}ms\x1b[0m`, + ) + + if (totalUnusedLocales > 0) { + process.exit(1) + } +} diff --git a/packages/unused-i18n/src/lib/analyze.ts b/packages/unused-i18n/src/lib/analyze.ts new file mode 100644 index 000000000..f9ffc94b9 --- /dev/null +++ b/packages/unused-i18n/src/lib/analyze.ts @@ -0,0 +1,23 @@ +import * as fs from 'fs' +import type { AnalyzeArgs } from '../types' +import { extractGlobalT } from './global/extractGlobalT' +import { extractNamespaceTranslation } from './scopedNamespace/extractNamespaceTranslation' +import { extractScopedTs } from './scopedNamespace/extractScopedTs' + +export const analyze = ({ filePath, scopedNames }: AnalyzeArgs): string[] => { + const fileContent = fs.readFileSync(filePath, 'utf-8') + const namespaceTranslations = extractNamespaceTranslation({ fileContent }) + + const scopedTs = ( + scopedNames?.map(scopedName => + namespaceTranslations.flatMap(namespaceTranslation => + extractScopedTs({ fileContent, namespaceTranslation, scopedName }), + ), + ) ?? [] + ).flat(2) + + const globalTs = extractGlobalT({ fileContent }) + const translations = [...globalTs, ...scopedTs] + + return [...new Set(translations)].sort() +} diff --git a/packages/unused-i18n/src/lib/global/extractGlobalT.ts b/packages/unused-i18n/src/lib/global/extractGlobalT.ts new file mode 100644 index 000000000..eb5879de0 --- /dev/null +++ b/packages/unused-i18n/src/lib/global/extractGlobalT.ts @@ -0,0 +1,79 @@ +import type { ExtractTranslationArgs } from '../../types' + +export const extractGlobalT = ({ + fileContent, +}: ExtractTranslationArgs): string[] => { + const tPattern = /t\(\s*['"`]([\s\S]*?)['"`]\s*(?:,|$|\))/g + const tPatternWithTernary = + /t\(\s*([\s\S]+?)\s*\?\s*['"`]([^'"`\n]+)['"`]\s*:\s*['"`]([^'"`\n]+)['"`]\s*\)/gm + const tVariablePattern = /t\(\s*([a-zA-Z_$][\w.$]*)\s*\)/g + const tTemplatePattern = /t\(\s*`([\s\S]*?)`\s*\)/g + + const normalTs: Set = new Set() + + let match + + // Handle ternary expressions within t arguments + while ((match = tPatternWithTernary.exec(fileContent)) !== null) { + const [, , trueValue, falseValue] = match + if (trueValue) { + normalTs.add(trueValue.replace(/'/g, '').replace(/,/g, '').trim()) + } + if (falseValue) { + normalTs.add(falseValue.replace(/'/g, '').replace(/,/g, '').trim()) + } + } + + // Handle regular t pattern + while ((match = tPattern.exec(fileContent))) { + const translation = match[1]?.trim() + + if (translation?.includes('${')) { + const ternaryPattern = /\${([^}]*\s\?\s[^}]*:[^}]*)}/ + const ternaryMatch = translation.match(ternaryPattern) + + if (ternaryMatch) { + const [fullMatch, ternary] = ternaryMatch + if (ternary) { + const [ifValue, elseValue] = ternary + .split(':') + .map(val => val.trim().replace(/'/g, '')) + if (ifValue && elseValue) { + const stringIf = `${translation.replace(fullMatch, ifValue)}` + const stringElse = `${translation.replace(fullMatch, elseValue)}` + + normalTs.add(stringIf) + normalTs.add(stringElse) + } + } + } else { + normalTs.add(`${translation.replace(/\${[^}]*}/g, '**')}`) + } + } else if (translation) { + normalTs.add(translation) + } + } + + // Handle variable t pattern + while ((match = tVariablePattern.exec(fileContent))) { + normalTs.add('**') + } + + // Handle template literals within t arguments + while ((match = tTemplatePattern.exec(fileContent))) { + const templateLiteral = match[1] + + const dynamicParts = templateLiteral?.split(/(\${[^}]+})/).map(part => { + if (part.startsWith('${') && part.endsWith('}')) { + return '**' + } + + return part + }) + if (dynamicParts) { + normalTs.add(dynamicParts.join('')) + } + } + + return Array.from(normalTs) +} diff --git a/packages/unused-i18n/src/lib/remove.ts b/packages/unused-i18n/src/lib/remove.ts new file mode 100644 index 000000000..6c7acb49b --- /dev/null +++ b/packages/unused-i18n/src/lib/remove.ts @@ -0,0 +1,38 @@ +import * as fs from 'fs' +import type { RemoveLocaleKeysArgs } from '../types' + +export const removeLocaleKeys = ({ + localePath, + missingTranslations, +}: RemoveLocaleKeysArgs) => { + const localeContent = fs.readFileSync(localePath, 'utf-8').split('\n') + let removeNextLine = -1 + + const updatedLocaleContent = localeContent.reduce( + (acc: string[], line, index) => { + const match = line.match(/^\s*'([^']+)':/) + if (match) { + const key = match[1] + if (key && missingTranslations.includes(key)) { + if (line.trim().endsWith('} as const')) { + acc.push(line) + } + if (line.trim().endsWith(':')) { + removeNextLine = index + 1 + } + } else { + acc.push(line) + } + } else if (removeNextLine === index) { + removeNextLine = -1 + } else { + acc.push(line) + } + + return acc + }, + [], + ) + + fs.writeFileSync(localePath, updatedLocaleContent.join('\n'), 'utf-8') +} diff --git a/packages/unused-i18n/src/lib/scopedNamespace/extractNamespaceTranslation.ts b/packages/unused-i18n/src/lib/scopedNamespace/extractNamespaceTranslation.ts new file mode 100644 index 000000000..be302f865 --- /dev/null +++ b/packages/unused-i18n/src/lib/scopedNamespace/extractNamespaceTranslation.ts @@ -0,0 +1,79 @@ +import type { ExtractTranslationArgs } from '../../types' + +export const extractNamespaceTranslation = ({ + fileContent, +}: ExtractTranslationArgs): string[] => { + // Match regular namespaceTranslation + const namespaceTranslationPattern = + /namespaceTranslation\(\s*['"`]([\s\S]*?)['"`]\s*,?\s*\)/g + + // Match ternary inside namespaceTranslation + const namespaceTranslationPatternTernary = + /namespaceTranslation\(\s*([^?]+?)\s*\?\s*['"`]([^'"\\`\n]+)['"`]\s*:\s*['"`]([^'"\\`\n]+)['"`]\s*,?\s*\)/g + + // Match template literal inside namespaceTranslation + const namespaceTranslationPatternTemplateLiteral = + /namespaceTranslation\(\s*`([\s\S]*?)`\s*\)/g + + const matches: string[] = [] + let match + + // Extract regular namespaceTranslation matches + while ((match = namespaceTranslationPattern.exec(fileContent)) !== null) { + if (match[1]) { + matches.push(match[1].trim()) + } + } + + // Extract ternary matches inside namespaceTranslation + while ( + (match = namespaceTranslationPatternTernary.exec(fileContent)) !== null + ) { + if (match[2] && match[3]) { + const trueValue = match[2].trim() + const falseValue = match[3].trim() + matches.push(trueValue, falseValue) + } + } + + // Extract template literal matches inside namespaceTranslation + while ( + (match = namespaceTranslationPatternTemplateLiteral.exec(fileContent)) !== + null + ) { + if (match[1]) { + const templateLiteral = match[1].trim() + // Find the dynamic parts inside `${}` in the template literal + const dynamicPartsMatch = /\${([^}]+)}/g + let dynamicPart + while ((dynamicPart = dynamicPartsMatch.exec(templateLiteral)) !== null) { + if (dynamicPart[1]) { + const dynamicExpression = dynamicPart[1].trim() + + // Now handle ternary expression inside this dynamic part + const ternaryMatch = + /([^?]+?)\s*\?\s*['"`]([^'"\\`\n]+)['"`]\s*:\s*['"`]([^'"\\`\n]+)['"`]/g + let ternaryInnerMatch + while ( + (ternaryInnerMatch = ternaryMatch.exec(dynamicExpression)) !== null + ) { + if (ternaryInnerMatch[2] && ternaryInnerMatch[3]) { + const trueValue = ternaryInnerMatch[2].trim() + const falseValue = ternaryInnerMatch[3].trim() + matches.push(trueValue, falseValue) + } + } + + // Push the dynamic expression result into the matches + matches.push(dynamicExpression) + } + + // Push the whole template literal into the matches + matches.push(templateLiteral) + } + } + } + + // Remove duplicates and return the sorted result + return [...new Set(matches)].sort() +} diff --git a/packages/unused-i18n/src/lib/scopedNamespace/extractScopedTs.ts b/packages/unused-i18n/src/lib/scopedNamespace/extractScopedTs.ts new file mode 100644 index 000000000..787be0cf4 --- /dev/null +++ b/packages/unused-i18n/src/lib/scopedNamespace/extractScopedTs.ts @@ -0,0 +1,137 @@ +import type { ExtractScopedTsArgs } from '../../types' + +export const extractScopedTs = ({ + scopedName, + namespaceTranslation, + fileContent, +}: ExtractScopedTsArgs): string[] => { + // Patterns with the variable + const scopedTPattern = new RegExp( + `${scopedName}\\(\\s*['"\`]([\\s\\S]*?)['"\`]\\s*(?:,|\\))`, + 'g', + ) + const scopedTPatternWithTernary = new RegExp( + `${scopedName}\\(\\s*([\\s\\S]+?)\\s*\\?\\s*['"\`]([^'"\\\`\n]+)['"\`']\\s*:\\s*['"\`']([^'"\\\`\n]+)['"\`],?\\s*\\)`, + 'gm', + ) + const scopedTPatternWithTernaryAndParams = new RegExp( + `${scopedName}\\(\\s*([^?\n]+)\\s*\\?\\s*['"\`]([^'"\\\`]+)['"\`']\\s*:\\s*['"\`']([^'"\\\`]+)['"\`'],\\s*\\{[\\s\\S]*?\\},?\\s*\\)`, + 'gm', + ) + const scopedTVariablePattern = new RegExp( + `${scopedName}\\(\\s*([a-zA-Z_$][\\w.$]*)\\s*\\)`, + 'g', + ) + const scopedTTemplatePattern = new RegExp( + `${scopedName}\\(\\s*\`([\\s\\S]*?)\`\\s*\\)`, + 'g', + ) + + const scopedTs: Set = new Set() + const namespaceTranslationTrimmed = namespaceTranslation + .replace(/'/g, '') + .replace(/,/g, '') + + let match + + // Handle ternary expressions within scopedT arguments + while ((match = scopedTPatternWithTernary.exec(fileContent))) { + const trueValue = match[2] + const falseValue = match[3] + + if (trueValue) { + scopedTs.add( + `${namespaceTranslationTrimmed}.${trueValue + .replace(/'/g, '') + .replace(/,/g, '') + .trim()}`, + ) + } + if (falseValue) { + scopedTs.add( + `${namespaceTranslationTrimmed}.${falseValue + .replace(/'/g, '') + .replace(/,/g, '') + .trim()}`, + ) + } + } + + // Handle ternary expressions with additional parameters within scopedT arguments + while ((match = scopedTPatternWithTernaryAndParams.exec(fileContent))) { + const trueValue = match[2]?.trim() ?? '' + const falseValue = match[3]?.trim() ?? '' + + scopedTs.add( + `${namespaceTranslationTrimmed}.${trueValue + .replace(/'/g, '') + .replace(/,/g, '')}`, + ) + scopedTs.add( + `${namespaceTranslationTrimmed}.${falseValue + .replace(/'/g, '') + .replace(/,/g, '')}`, + ) + } + + // Handle regular scopedT pattern + while ((match = scopedTPattern.exec(fileContent))) { + const scopedT = match[1]?.trim() + + const scopedTWithNamespace = `${namespaceTranslationTrimmed}.${scopedT}` + + if (scopedT?.includes('${')) { + const ternaryPattern = /\${([^}]*\s\?\s[^}]*:[^}]*)}/ + const ternaryMatch = scopedTWithNamespace.match(ternaryPattern) + + if (ternaryMatch) { + const [fullMatch, ternary] = ternaryMatch + if (ternary) { + const parts = ternary.split(' ? ')[1]?.split(':') + if (parts && parts.length === 2) { + const [ifValue, elseValue] = parts.map(val => + val.trim().replace(/'/g, ''), + ) + if (ifValue && elseValue) { + const stringIf = `${scopedTWithNamespace.replace(fullMatch, ifValue)}` + const stringElse = `${scopedTWithNamespace.replace(fullMatch, elseValue)}` + + scopedTs.add(stringIf) + scopedTs.add(stringElse) + } + } + } + } else { + scopedTs.add(`${scopedTWithNamespace.replace(/\${[^}]*}/g, '**')}`) + } + } else { + scopedTs.add(scopedTWithNamespace) + } + } + + // Handle variable scopedT pattern + while ((match = scopedTVariablePattern.exec(fileContent))) { + const scopedTWithNamespace = `${namespaceTranslationTrimmed}.**` + scopedTs.add(scopedTWithNamespace) + } + + // Handle template literals within scopedT arguments + while ((match = scopedTTemplatePattern.exec(fileContent))) { + const templateLiteral = match[1] + if (templateLiteral) { + const dynamicParts = templateLiteral.split(/(\$\{[^}]+})/).map(part => { + if (part.startsWith('${') && part.endsWith('}')) { + return '**' + } + + return part + }) + const scopedTWithNamespace = `${namespaceTranslationTrimmed}.${dynamicParts.join( + '', + )}` + scopedTs.add(scopedTWithNamespace) + } + } + + return Array.from(scopedTs) +} diff --git a/packages/unused-i18n/src/lib/search.ts b/packages/unused-i18n/src/lib/search.ts new file mode 100644 index 000000000..5ddfe0b2c --- /dev/null +++ b/packages/unused-i18n/src/lib/search.ts @@ -0,0 +1,32 @@ +import * as fs from 'fs' +import * as path from 'path' +import type { SearchFilesRecursivelyArgs } from '../types' + +export const searchFilesRecursively = ({ + baseDir, + regex, + excludePatterns, +}: SearchFilesRecursivelyArgs): string[] => { + const foundFiles: string[] = [] + + function searchRecursively(directory: string): void { + const files = fs.readdirSync(directory) + + for (const file of files) { + const fullPath = path.join(directory, file) + // Skip excluded patterns + if (excludePatterns.some(pattern => pattern.test(fullPath))) { + return + } + if (fs.lstatSync(fullPath).isDirectory()) { + searchRecursively(fullPath) + } else if (regex.test(fs.readFileSync(fullPath, 'utf-8'))) { + foundFiles.push(fullPath) + } + } + } + + searchRecursively(baseDir) + + return foundFiles +} diff --git a/packages/unused-i18n/src/types/index.ts b/packages/unused-i18n/src/types/index.ts new file mode 100644 index 000000000..a27085ca9 --- /dev/null +++ b/packages/unused-i18n/src/types/index.ts @@ -0,0 +1,58 @@ +export type PathConfig = { + srcPath: string[] + localPath: string +} + +export type Config = { + paths: PathConfig[] + localesExtensions?: string + localesNames?: string + ignorePaths?: string[] + excludeKey?: string | string[] + scopedNames: string[] +} + +type UnusedLocalesCountByPath = { + path: string + messages?: string + warning?: string +} +export type ProcessTranslationsArgs = { + paths?: { srcPath: string[]; localPath: string }[] + action: 'remove' | 'display' +} +export type SummaryArgs = { + unusedLocalesCountByPath: UnusedLocalesCountByPath[] + totalUnusedLocales: number +} + +export type AnalyzeArgs = { + filePath: string + scopedNames?: string[] +} +export type RemoveLocaleKeysArgs = { + localePath: string + missingTranslations: string[] +} +export type SearchFilesRecursivelyArgs = { + baseDir: string + regex: RegExp + excludePatterns: RegExp[] +} +export type ExtractTranslationArgs = { + fileContent: string +} +export type ExtractScopedTsArgs = { + fileContent: string + namespaceTranslation: string + scopedName: string +} +export type GetMissingTranslationsArgs = { + localLines: string[] + extractedTranslations: string[] + excludeKey: string | string[] | undefined +} +export type ShouldExcludeArgs = { + line: string + excludeKey?: string | string[] +} diff --git a/packages/unused-i18n/src/utils/escapeRegex.ts b/packages/unused-i18n/src/utils/escapeRegex.ts new file mode 100644 index 000000000..e7e0923f1 --- /dev/null +++ b/packages/unused-i18n/src/utils/escapeRegex.ts @@ -0,0 +1,2 @@ +export const escapeRegex = (value: string): string => + value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') diff --git a/packages/unused-i18n/src/utils/loadConfig.ts b/packages/unused-i18n/src/utils/loadConfig.ts new file mode 100644 index 000000000..a0cb8ce1f --- /dev/null +++ b/packages/unused-i18n/src/utils/loadConfig.ts @@ -0,0 +1,55 @@ +import * as fs from 'fs' +import * as path from 'path' +import { build } from 'esbuild' +import type { Config } from '../types' + +const supportedExtensions = ['.json', '.js', '.cjs', '.ts'] + +export const loadConfig = async (): Promise => { + const cwd = process.cwd() + let configPath = '' + + for (const ext of supportedExtensions) { + const potentialPath = path.resolve(cwd, `unused-i18n.config${ext}`) + if (fs.existsSync(potentialPath)) { + configPath = potentialPath + break + } + } + + if (!configPath) { + throw new Error( + 'Configuration file unused-i18n.config not found. Supported extensions: .json, .js, .cjs, .ts.', + ) + } + + const extension = path.extname(configPath) + + if (extension === '.json') { + const configContent = fs.readFileSync(configPath, 'utf-8') + + return JSON.parse(configContent) as Config + } + if (extension === '.ts') { + const result = await build({ + entryPoints: [configPath], + outfile: 'config.js', + platform: 'node', + format: 'esm', + bundle: true, + write: false, + }) + + const jsCode = result.outputFiles[0]?.text ?? '' + + const module = (await import( + `data:application/javascript,${encodeURIComponent(jsCode)}` + )) as { default: Config } + + return module.default + } + + const module = (await import(configPath)) as { default: Config } + + return module.default +} diff --git a/packages/unused-i18n/src/utils/missingTranslations.ts b/packages/unused-i18n/src/utils/missingTranslations.ts new file mode 100644 index 000000000..2f2f5c8df --- /dev/null +++ b/packages/unused-i18n/src/utils/missingTranslations.ts @@ -0,0 +1,28 @@ +import type { GetMissingTranslationsArgs } from '../types' +import { escapeRegex } from './escapeRegex' +import { shouldExclude } from './shouldExclude' + +export const getMissingTranslations = ({ + localLines, + excludeKey, + extractedTranslations, +}: GetMissingTranslationsArgs): string[] => localLines.filter(line => { + if (shouldExclude({ line, excludeKey })) { + return false + } + + if (extractedTranslations.includes(line)) { + return false + } + + return !extractedTranslations.some(item => { + if (item === '**') { + return /^\w+$/.test(line) + } + const pattern = new RegExp( + `^${escapeRegex(item).replace(/\\\*\\\*/g, '.*?')}$`, + ) + + return pattern.test(line) + }) + }) diff --git a/packages/unused-i18n/src/utils/shouldExclude.ts b/packages/unused-i18n/src/utils/shouldExclude.ts new file mode 100644 index 000000000..58117707e --- /dev/null +++ b/packages/unused-i18n/src/utils/shouldExclude.ts @@ -0,0 +1,13 @@ +import type { ShouldExcludeArgs } from '../types' + +export const shouldExclude = ({ + excludeKey, + line, +}: ShouldExcludeArgs): boolean => { + if (!excludeKey) return false + if (typeof excludeKey === 'string') { + return line.includes(excludeKey) + } + + return excludeKey.some(key => line.includes(key)) +} diff --git a/packages/unused-i18n/src/utils/summary.ts b/packages/unused-i18n/src/utils/summary.ts new file mode 100644 index 000000000..f2d40cfc1 --- /dev/null +++ b/packages/unused-i18n/src/utils/summary.ts @@ -0,0 +1,15 @@ +import type { SummaryArgs } from '../types' + +export const summary = ({ + unusedLocalesCountByPath, + totalUnusedLocales, +}: SummaryArgs) => { + for (const { messages, warning } of unusedLocalesCountByPath) { + console.log(messages ?? '') + if (warning) { + console.log(warning) + } + } + + console.log(`Total unused locales: \x1b[33m${totalUnusedLocales}\x1b[0m`) +} diff --git a/packages/unused-i18n/src/vite-env.d.ts b/packages/unused-i18n/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/packages/unused-i18n/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/unused-i18n/tsconfig.build.json b/packages/unused-i18n/tsconfig.build.json new file mode 100644 index 000000000..e13cd0194 --- /dev/null +++ b/packages/unused-i18n/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "emitDeclarationOnly": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/types"] +} diff --git a/packages/unused-i18n/tsconfig.json b/packages/unused-i18n/tsconfig.json new file mode 100644 index 000000000..7c2b0759a --- /dev/null +++ b/packages/unused-i18n/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts", "src/**/*.tsx", "*.config.ts"] +} diff --git a/packages/unused-i18n/vite.config.ts b/packages/unused-i18n/vite.config.ts new file mode 100644 index 000000000..a221d1df1 --- /dev/null +++ b/packages/unused-i18n/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, mergeConfig } from 'vite' +import { defaultConfig } from '../../vite.config' + +const config = { + ...defineConfig(defaultConfig), +} +export default mergeConfig(config, { + build: { + lib: { + entry: 'src/cli.ts', + formats: ['cjs'], + }, + minify: true, + rollupOptions: { + preserveSymlinks: true, + input: 'src/cli.ts', + output: { + preserveModules: false, + banner: '#!/usr/bin/env node', + interop: 'compat', + manualChunks: {}, + }, + }, + }, +}) diff --git a/packages/unused-i18n/vitest.setup.ts b/packages/unused-i18n/vitest.setup.ts new file mode 100644 index 000000000..b49124c8b --- /dev/null +++ b/packages/unused-i18n/vitest.setup.ts @@ -0,0 +1,11 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ +import * as matchers from '@testing-library/jest-dom/matchers' +import '@testing-library/jest-dom/vitest' +import { cleanup } from '@testing-library/react' +import { afterEach, expect } from 'vitest' + +expect.extend(matchers) + +afterEach(() => { + cleanup() +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d215b0555..4224d15da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -208,6 +208,15 @@ importers: packages/tsconfig: {} + packages/unused-i18n: + dependencies: + commander: + specifier: 12.1.0 + version: 12.1.0 + esbuild: + specifier: 0.24.2 + version: 0.24.2 + packages/use-dataloader: devDependencies: react: @@ -2257,6 +2266,10 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + commander@13.1.0: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} @@ -6944,6 +6957,8 @@ snapshots: delayed-stream: 1.0.0 optional: true + commander@12.1.0: {} + commander@13.1.0: {} commander@2.20.3: diff --git a/vite.config.ts b/vite.config.ts index 4e72133d5..bb52bac30 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -15,7 +15,7 @@ const externalPkgs = [ const external = (id: string) => { const match = (dependency: string) => new RegExp(`^${dependency}`).test(id) - const isExternal = externalPkgs.some(match) + const isExternal = externalPkgs.some(match) || ['perf_hooks'].includes(id) // alias of bundledDependencies package.json field array const isBundled = pkg.bundleDependencies?.some(match) From 3478f6310bf97799275b5ee62876cbac7a0378d3 Mon Sep 17 00:00:00 2001 From: Levende Date: Thu, 20 Feb 2025 15:38:24 +0100 Subject: [PATCH 2/3] feat: add unused locales --- .changeset/khaki-ways-shave.md | 5 + .../src/__tests__/processTranslations.test.ts | 94 +++++++++++++ packages/unused-i18n/src/index.ts | 2 +- .../src/lib/__tests__/analyze.test.ts | 126 ++++++++++++++++++ .../src/lib/__tests__/remove.test.ts | 82 ++++++++++++ .../src/lib/__tests__/search.test.ts | 60 +++++++++ .../global/__tests__/extractGlobalT.test.ts | 35 +++++ .../extractNamespaceTranslation.test.ts | 27 ++++ .../__tests__/extractScopedTs.test.ts | 111 +++++++++++++++ .../__tests__/getMissingTranslations.test.ts | 67 ++++++++++ packages/unused-i18n/vite.config.ts | 2 + 11 files changed, 610 insertions(+), 1 deletion(-) create mode 100644 .changeset/khaki-ways-shave.md create mode 100644 packages/unused-i18n/src/__tests__/processTranslations.test.ts create mode 100644 packages/unused-i18n/src/lib/__tests__/analyze.test.ts create mode 100644 packages/unused-i18n/src/lib/__tests__/remove.test.ts create mode 100644 packages/unused-i18n/src/lib/__tests__/search.test.ts create mode 100644 packages/unused-i18n/src/lib/global/__tests__/extractGlobalT.test.ts create mode 100644 packages/unused-i18n/src/lib/scopedNamespace/__tests__/extractNamespaceTranslation.test.ts create mode 100644 packages/unused-i18n/src/lib/scopedNamespace/__tests__/extractScopedTs.test.ts create mode 100644 packages/unused-i18n/src/utils/__tests__/getMissingTranslations.test.ts diff --git a/.changeset/khaki-ways-shave.md b/.changeset/khaki-ways-shave.md new file mode 100644 index 000000000..45d2b04e9 --- /dev/null +++ b/.changeset/khaki-ways-shave.md @@ -0,0 +1,5 @@ +--- +"unused-i18n": patch +--- + +Add unused locales to scaleway-lib diff --git a/packages/unused-i18n/src/__tests__/processTranslations.test.ts b/packages/unused-i18n/src/__tests__/processTranslations.test.ts new file mode 100644 index 000000000..1f83fb172 --- /dev/null +++ b/packages/unused-i18n/src/__tests__/processTranslations.test.ts @@ -0,0 +1,94 @@ +import * as fs from 'fs' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { processTranslations } from '../index' +import { analyze } from '../lib/analyze' +import { searchFilesRecursively } from '../lib/search' +import { loadConfig } from '../utils/loadConfig' +import { getMissingTranslations } from '../utils/missingTranslations' +import { shouldExclude } from '../utils/shouldExclude' + +// Mock dependencies +vi.mock('fs', () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), +})) + +vi.mock('../utils/loadConfig', () => ({ + loadConfig: vi.fn(), +})) + +vi.mock('../lib/search', () => ({ + searchFilesRecursively: vi.fn(), +})) + +vi.mock('../lib/analyze', () => ({ + analyze: vi.fn(), +})) + +vi.mock('../utils/shouldExclude', () => ({ + shouldExclude: vi.fn(), +})) + +vi.mock('../utils/missingTranslations', () => ({ + getMissingTranslations: vi.fn(), +})) + +describe('processTranslations', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + it('should process translations correctly', async () => { + const config = { + paths: [ + { + srcPath: ['srcPath'], + localPath: 'localPath', + }, + ], + excludeKey: [], + scopedNames: ['scopedT'], + localesExtensions: 'ts', + localesNames: 'en', + ignorePaths: ['folder/file.ts'], + } + + const files = ['file1.ts', 'folder/file.ts'] + const extractedTranslations = ['key1', 'key2'] + const localeContent = ` +export default { + 'key1': 'value1', + 'key2': 'value2', + 'key3': 'value3', + 'key4': 'value4', +} as const + `.trim() + + const expectedWriteContent = ` +export default { + 'key1': 'value1', + 'key2': 'value2', +} as const + `.trim() + + vi.mocked(loadConfig).mockResolvedValue(config) + vi.mocked(searchFilesRecursively).mockReturnValue(files) + vi.mocked(analyze).mockReturnValue(extractedTranslations) + vi.mocked(shouldExclude).mockReturnValue(false) + vi.mocked(getMissingTranslations).mockReturnValue(['key3', 'key4']) + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue(localeContent) + vi.mocked(fs.writeFileSync).mockImplementation(vi.fn()) + + await processTranslations({ action: 'remove' }) + + expect(fs.existsSync).toHaveBeenCalledWith('localPath/en.ts') + expect(fs.readFileSync).toHaveBeenCalledWith('localPath/en.ts', 'utf-8') + expect(fs.writeFileSync).toHaveBeenCalledWith( + 'localPath/en.ts', + expectedWriteContent, + 'utf-8', + ) + }) +}) diff --git a/packages/unused-i18n/src/index.ts b/packages/unused-i18n/src/index.ts index 7268b9291..5634f0fa9 100644 --- a/packages/unused-i18n/src/index.ts +++ b/packages/unused-i18n/src/index.ts @@ -115,7 +115,7 @@ export const processTranslations = async ({ )}ms\x1b[0m`, ) - if (totalUnusedLocales > 0) { + if (totalUnusedLocales > 0 && process.env['NODE_ENV'] !== 'test') { process.exit(1) } } diff --git a/packages/unused-i18n/src/lib/__tests__/analyze.test.ts b/packages/unused-i18n/src/lib/__tests__/analyze.test.ts new file mode 100644 index 000000000..08c26c394 --- /dev/null +++ b/packages/unused-i18n/src/lib/__tests__/analyze.test.ts @@ -0,0 +1,126 @@ +import * as fs from 'fs' +import { describe, expect, it, vi } from 'vitest' +import { analyze } from '../analyze' +import { extractGlobalT } from '../global/extractGlobalT' +import { extractNamespaceTranslation } from '../scopedNamespace/extractNamespaceTranslation' +import { extractScopedTs } from '../scopedNamespace/extractScopedTs' + +const mockFilePath = '/path/to/test/file.js' +const mockScopedNames = ['scopedT', 'scopedTOne'] + +const fileContent = ` + import { useI18n } from '@scaleway/use-i18n' + const scopedT = namespaceTranslation('namespace'); + {keyLabel ?? scopedT('labelKey1')} + {keyLabel ? scopedT('labelKey2') : scopedT('labelKey3')} + {scopedT(keyLabel ? 'labelKey4' : 'labelKey5')} + {scopedT(\`labelKey6.\${variable}\`)} + {scopedT(variable0)} + {scopedT(\`\${variable1}.\${variable2}\`)} + {t(\`\${variable3}.\${variable4}\`)} + {keyLabel ?? t('labelKey8')} + {keyLabel ? t('labelKey9') : t('labelKey10')} + {t(\`labelKey11.\${variable5}\`)} + {t(\`labelKey12.\${variable6}\`)} + toast.success(t('account.user.modal.edit.changeEmail')); + { [FORM_ERROR]: t('form.errors.formErrorNoRetry') }; + {scopedTOne('labelKey13', { + name: scopedT('labelKey14') + })} + ` + +const expectedTranslationResults = [ + 'account.user.modal.edit.changeEmail', + 'form.errors.formErrorNoRetry', + 'labelKey10', + 'labelKey11.**', + 'labelKey12.**', + 'labelKey8', + 'labelKey9', + '**.**', + 'namespace.**', + 'namespace.**.**', + 'namespace.labelKey1', + 'namespace.labelKey13', + 'namespace.labelKey14', + 'namespace.labelKey2', + 'namespace.labelKey3', + 'namespace.labelKey4', + 'namespace.labelKey5', + 'namespace.labelKey6.**', +] + +vi.mock('fs') + +vi.mock('../global/extractGlobalT', () => ({ + extractGlobalT: vi.fn(() => [ + 'labelKey8', + 'labelKey9', + 'labelKey10', + 'labelKey11.**', + 'labelKey12.**', + 'account.user.modal.edit.changeEmail', + 'form.errors.formErrorNoRetry', + ]), +})) + +vi.mock('../scopedNamespace/extractNamespaceTranslation', () => ({ + extractNamespaceTranslation: vi.fn(() => [ + 'namespace.labelKey1', + 'namespace.labelKey2', + 'namespace.labelKey3', + 'namespace.labelKey4', + 'namespace.labelKey5', + 'namespace.labelKey6.**', + 'namespace.**', + 'namespace.**.**', + ]), +})) + +vi.mock('../scopedNamespace/extractScopedTs', () => ({ + extractScopedTs: vi.fn(() => [ + '**.**', + 'namespace.**', + 'namespace.**.**', + 'namespace.labelKey1', + 'namespace.labelKey13', + 'namespace.labelKey14', + 'namespace.labelKey2', + 'namespace.labelKey3', + 'namespace.labelKey4', + 'namespace.labelKey5', + 'namespace.labelKey6.**', + ]), +})) + +describe('analyze', () => { + it('should extract all translations correctly from the file', () => { + vi.mocked(fs.readFileSync).mockReturnValue(fileContent) + + const result = analyze({ + filePath: mockFilePath, + scopedNames: mockScopedNames, + }) + + expect(fs.readFileSync).toHaveBeenCalledWith(mockFilePath, 'utf-8') + expect(extractGlobalT).toHaveBeenCalledWith({ fileContent }) + expect(extractNamespaceTranslation).toHaveBeenCalledWith({ fileContent }) + + expect(extractScopedTs).toHaveBeenCalledWith({ + fileContent, + namespaceTranslation: 'namespace.labelKey1', + scopedName: 'scopedT', + }) + + expect(extractScopedTs).toHaveBeenCalledTimes(16) + + expect(extractScopedTs).toHaveBeenNthCalledWith(5, { + fileContent, + namespaceTranslation: 'namespace.labelKey5', + scopedName: 'scopedT', + }) + + expect(result).toEqual(expect.arrayContaining(expectedTranslationResults)) + expect(expectedTranslationResults).toEqual(expect.arrayContaining(result)) + }) +}) diff --git a/packages/unused-i18n/src/lib/__tests__/remove.test.ts b/packages/unused-i18n/src/lib/__tests__/remove.test.ts new file mode 100644 index 000000000..4a9fa3dbd --- /dev/null +++ b/packages/unused-i18n/src/lib/__tests__/remove.test.ts @@ -0,0 +1,82 @@ +import * as fs from 'fs' +import { describe, expect, it, vi } from 'vitest' +import { removeLocaleKeys } from '../remove' + +vi.mock('fs') + +describe('removeLocaleKeys', () => { + it('should remove specified locale keys from the file', () => { + const localePath = 'path/to/locale/en.js' + const missingTranslations = ['key1', 'key4', 'key2'] + + const fileContent = `export default { + 'key1': 'value1', + 'key2': 'value2', + 'key3': 'value3', + 'key4': + 'value4', + 'key5': 'value5', +} as const` + + const expectedContent = ` +export default { + 'key3': 'value3', + 'key5': 'value5', +} as const` + + const fsMock = { + readFileSync: vi.fn().mockReturnValue(fileContent), + writeFileSync: vi.fn(), + } + + vi.mocked(fs.readFileSync).mockImplementation(fsMock.readFileSync) + vi.mocked(fs.writeFileSync).mockImplementation(fsMock.writeFileSync) + + removeLocaleKeys({ localePath, missingTranslations }) + + expect(fs.readFileSync).toHaveBeenCalledWith(localePath, 'utf-8') + expect(fs.writeFileSync).toHaveBeenCalledWith( + localePath, + expectedContent.trim(), + 'utf-8', + ) + }) + it('should remove specified locale keys from the file on multi line', () => { + const localePath = 'path/to/locale/en.js' + const missingTranslations = ['key1', 'key5'] + + const fileContent = `export default { + 'key1': 'value1', + 'key2': 'value2', + 'key3': 'value3', + 'key4': + 'value4', + 'key5': 'value5', +} as const` + + const expectedContent = ` +export default { + 'key2': 'value2', + 'key3': 'value3', + 'key4': + 'value4', +} as const` + + const fsMock = { + readFileSync: vi.fn().mockReturnValue(fileContent), + writeFileSync: vi.fn(), + } + + vi.mocked(fs.readFileSync).mockImplementation(fsMock.readFileSync) + vi.mocked(fs.writeFileSync).mockImplementation(fsMock.writeFileSync) + + removeLocaleKeys({ localePath, missingTranslations }) + + expect(fs.readFileSync).toHaveBeenCalledWith(localePath, 'utf-8') + expect(fs.writeFileSync).toHaveBeenCalledWith( + localePath, + expectedContent.trim(), + 'utf-8', + ) + }) +}) diff --git a/packages/unused-i18n/src/lib/__tests__/search.test.ts b/packages/unused-i18n/src/lib/__tests__/search.test.ts new file mode 100644 index 000000000..79cf85e77 --- /dev/null +++ b/packages/unused-i18n/src/lib/__tests__/search.test.ts @@ -0,0 +1,60 @@ +import * as fs from 'fs' +import * as path from 'path' +import { describe, expect, it, vi } from 'vitest' +import { searchFilesRecursively } from "../search" + +vi.mock('fs') + +describe('searchFilesRecursively', () => { + it('should find files where content matches the regex pattern', () => { + const baseDir = 'testDir' + const regex = /use-i18n/ + + const fsMock = { + readdirSync: vi.fn(dir => { + if (dir === baseDir) return ['file1.js', 'file2.js', 'subdir'] + if (dir === path.join(baseDir, 'subdir')) return ['file3.js'] + + return [] + }), + lstatSync: vi.fn(filePath => ({ + isDirectory: () => filePath === path.join(baseDir, 'subdir'), + })), + readFileSync: vi.fn(filePath => { + if (filePath === path.join(baseDir, 'file1.js')) { + return ` + import { useI18n } from '@scaleway/use-i18n' + ` +} + if (filePath === path.join(baseDir, 'file2.js')) return 'no match here' + if (filePath === path.join(baseDir, 'subdir', 'file3.js')) { + return ` + import { useI18n } from '@scaleway/use-i18n' + ` +} + + return '' + }), + } + + vi.mocked(fs.readFileSync).mockImplementation(fsMock.readFileSync) + // @ts-expect-error mockImplementation no function + vi.mocked(fs.lstatSync).mockImplementation(fsMock.lstatSync) + // @ts-expect-error mockImplementation no function + vi.mocked(fs.readdirSync).mockImplementation(fsMock.readdirSync) + + const expected = [ + path.join(baseDir, 'file1.js'), + path.join(baseDir, 'subdir', 'file3.js'), + ] + + const result = searchFilesRecursively({ + baseDir, + regex, + excludePatterns: [], + }) + + expect(result).toEqual(expect.arrayContaining(expected)) + expect(expected).toEqual(expect.arrayContaining(result)) + }) +}) diff --git a/packages/unused-i18n/src/lib/global/__tests__/extractGlobalT.test.ts b/packages/unused-i18n/src/lib/global/__tests__/extractGlobalT.test.ts new file mode 100644 index 000000000..4a369531d --- /dev/null +++ b/packages/unused-i18n/src/lib/global/__tests__/extractGlobalT.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { extractGlobalT } from '../extractGlobalT' + +describe('extractGlobalT', () => { + it('should extract scoped translations correctly', () => { + const fileContent = ` + {keyLabel ?? t('namespace.labelKey1')} + {keyLabel ? t('namespace.labelKey2') : t('namespace.labelKey3')} + {t(keyLabel ? 'labelKey4' : 'labelKey5')} + {t(\`labelKey6.\${variable}\`)} + {t(variable0)} + {t(\`\${variable1}.\${variable2}\`)} + {t('labelKey13', { + name: t('labelKey14') + })} + ` + + const expected = [ + 'labelKey4', + 'labelKey5', + 'namespace.labelKey1', + 'namespace.labelKey2', + 'namespace.labelKey3', + 'labelKey6.**', + '**', + '**.**', + 'labelKey13', + 'labelKey14', + ] + const result = extractGlobalT({ fileContent }) + + expect(result).toEqual(expect.arrayContaining(expected)) + expect(expected).toEqual(expect.arrayContaining(result)) + }) +}) diff --git a/packages/unused-i18n/src/lib/scopedNamespace/__tests__/extractNamespaceTranslation.test.ts b/packages/unused-i18n/src/lib/scopedNamespace/__tests__/extractNamespaceTranslation.test.ts new file mode 100644 index 000000000..a1ac3f47b --- /dev/null +++ b/packages/unused-i18n/src/lib/scopedNamespace/__tests__/extractNamespaceTranslation.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { extractNamespaceTranslation } from "../extractNamespaceTranslation" + +describe('extractNamespaceTranslation', () => { + it('should extract namespace translations correctly', () => { + const fileContent = ` + const scopedT = namespaceTranslation('namespace.key'); + const scopedTOne = namespaceTranslation('scopedTOne.key'); + ` + + const expected = ['namespace.key', 'scopedTOne.key'] + const result = extractNamespaceTranslation({ fileContent }) + + expect(result).toEqual(expected) + }) + + it('should handle ternary expressions correctly', () => { + const fileContent = ` + const scopedT = namespaceTranslation(variable ? 'namespace.keyTrue' : 'namespace.keyFalse'); + ` + + const expected = ['namespace.keyTrue', 'namespace.keyFalse'] + const result = extractNamespaceTranslation({ fileContent }) + + expect(result).toEqual(expect.arrayContaining(expected)) + }) +}) diff --git a/packages/unused-i18n/src/lib/scopedNamespace/__tests__/extractScopedTs.test.ts b/packages/unused-i18n/src/lib/scopedNamespace/__tests__/extractScopedTs.test.ts new file mode 100644 index 000000000..95a8c80f5 --- /dev/null +++ b/packages/unused-i18n/src/lib/scopedNamespace/__tests__/extractScopedTs.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import { extractScopedTs } from "../extractScopedTs" + +describe('extractScopedTs', () => { + it('should extract scoped translations correctly', () => { + const fileContent = ` + {keyLabel ?? scopedT('labelKey1')} + {keyLabel ? scopedT('labelKey2') : scopedT('labelKey3')} + {scopedT(keyLabel ? 'labelKey4' : 'labelKey5')} + {scopedT(\`labelKey6.\${variable}\`)} + {scopedT(variable0)} + {scopedT(\`\${variable1}.\${variable2}\`)} + {scopedT('labelKey13', { + name: scopedT('labelKey14') + })} + {scopedT(statusInformation.message)} + {scopedT( + isMFAEnforced + ? 'text.status.enforced' + : 'text.status.not_enforced', + )} + scopedT(activated ? 'deactivate.success' : 'activate.success', { + identifier: domain, + }) + {scopedT( + device.status === 'enabled' + ? 'dropdown.disable' + : 'dropdown.enable', + )} + {scopedT(locked ? 'unlock.description' : 'lock.description', { + identifier: ( + + {domain} + + ), + })} + {scopedT( + activated ? 'deactivate.description' : 'activate.description', + { + identifier: ( + + {domain} + + ), + }, + )} + {scopedT( + \`addForm.name.\${ + organization?.type === 'professional' ? 'corporate' : 'individual' + }\`, + )} + {scopedT( + offer.product?.supportIncluded + ? 'support' + : 'support.notIncluded', + )} + {scopedT( + offer.product?.databasesQuota === -1 + ? 'database' + : 'database.limited', + )} + {scopedT( + isSubscriptionConfirmed({ + subscriptionArn: subscription.SubscriptionArn, + }) + ? 'confirmed' + : 'notConfirmed', + )} + ` + + const namespaceTranslation = 'namespace' + const expected = [ + 'namespace.confirmed', + 'namespace.notConfirmed', + 'namespace.labelKey4', + 'namespace.labelKey5', + 'namespace.text.status.enforced', + 'namespace.text.status.not_enforced', + 'namespace.database', + 'namespace.database.limited', + 'namespace.support', + 'namespace.support.notIncluded', + 'namespace.dropdown.disable', + 'namespace.dropdown.enable', + 'namespace.deactivate.success', + 'namespace.activate.success', + 'namespace.unlock.description', + 'namespace.lock.description', + 'namespace.deactivate.description', + 'namespace.activate.description', + 'namespace.labelKey1', + 'namespace.labelKey2', + 'namespace.labelKey3', + 'namespace.labelKey6.**', + 'namespace.**.**', + 'namespace.labelKey13', + 'namespace.labelKey14', + 'namespace.addForm.name.corporate', + 'namespace.addForm.name.individual', + 'namespace.**', + ] + const result = extractScopedTs({ + fileContent, + namespaceTranslation, + scopedName: 'scopedT', + }) + + expect(result).toEqual(expect.arrayContaining(expected)) + expect(expected).toEqual(expect.arrayContaining(result)) + }) +}) diff --git a/packages/unused-i18n/src/utils/__tests__/getMissingTranslations.test.ts b/packages/unused-i18n/src/utils/__tests__/getMissingTranslations.test.ts new file mode 100644 index 000000000..b92474ba4 --- /dev/null +++ b/packages/unused-i18n/src/utils/__tests__/getMissingTranslations.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest' +import { getMissingTranslations } from "../missingTranslations" +import { shouldExclude } from "../shouldExclude" + +vi.mock('../../utils/shouldExclude', () => ({ + shouldExclude: vi.fn(), +})) + +describe('getMissingTranslations', () => { + it('should return missing translations', () => { + const localLines = [ + 'billing.back.organization', + 'billing.back.organization.dsds', + 'dsds', + 'billing.budget.alert.go', + ] + + const extractedTranslations = [ + 'billing.back.organization', + 'billing.budget.alert.**', + '**', + ] + + const excludeKey: string[] = [] + + vi.mocked(shouldExclude).mockReturnValue(false) + + const result = getMissingTranslations({ + localLines, + extractedTranslations, + excludeKey, + }) + + const expected = ['billing.back.organization.dsds'] + + expect(result).toEqual(expected) + }) + + it('should exclude keys based on shouldExclude', () => { + const localLines = [ + 'billing.back.organization', + 'billing.back.organization.dsds', + 'dsds', + 'billing.budget.alert.go.error', + ] + + const extractedTranslations = [ + 'billing.back.organization', + 'billing.budget.alert.**', + ] + + const excludeKey = ['dsds'] + vi.mocked(shouldExclude).mockImplementation( + ({ line, excludeKey: keys }) => keys?.includes(line) ?? false, + ) + + const result = getMissingTranslations({ + localLines, + extractedTranslations, + excludeKey, + }) + + const expected = ['billing.back.organization.dsds'] + + expect(result).toEqual(expected) + }) +}) diff --git a/packages/unused-i18n/vite.config.ts b/packages/unused-i18n/vite.config.ts index a221d1df1..5a4ea3182 100644 --- a/packages/unused-i18n/vite.config.ts +++ b/packages/unused-i18n/vite.config.ts @@ -1,8 +1,10 @@ import { defineConfig, mergeConfig } from 'vite' import { defaultConfig } from '../../vite.config' +import { defaultConfig as vitestDefaultConfig } from '../../vitest.config' const config = { ...defineConfig(defaultConfig), + ...vitestDefaultConfig, } export default mergeConfig(config, { build: { From 91ca6dadacbb56dbcb0aa9850a7ba7543459a0e0 Mon Sep 17 00:00:00 2001 From: Levende Date: Thu, 20 Feb 2025 17:47:09 +0100 Subject: [PATCH 3/3] feat: add unused locales --- .../src/lib/__tests__/search.test.ts | 14 +-- .../extractNamespaceTranslation.test.ts | 2 +- .../__tests__/extractScopedTs.test.ts | 2 +- .../__tests__/getMissingTranslations.test.ts | 4 +- .../src/utils/__tests__/loadConfig.test.ts | 98 +++++++++++++++++++ packages/unused-i18n/src/utils/loadConfig.ts | 8 +- .../src/utils/missingTranslations.ts | 3 +- 7 files changed, 115 insertions(+), 16 deletions(-) create mode 100644 packages/unused-i18n/src/utils/__tests__/loadConfig.test.ts diff --git a/packages/unused-i18n/src/lib/__tests__/search.test.ts b/packages/unused-i18n/src/lib/__tests__/search.test.ts index 79cf85e77..50c563526 100644 --- a/packages/unused-i18n/src/lib/__tests__/search.test.ts +++ b/packages/unused-i18n/src/lib/__tests__/search.test.ts @@ -1,7 +1,7 @@ import * as fs from 'fs' import * as path from 'path' import { describe, expect, it, vi } from 'vitest' -import { searchFilesRecursively } from "../search" +import { searchFilesRecursively } from '../search' vi.mock('fs') @@ -22,16 +22,16 @@ describe('searchFilesRecursively', () => { })), readFileSync: vi.fn(filePath => { if (filePath === path.join(baseDir, 'file1.js')) { - return ` + return ` import { useI18n } from '@scaleway/use-i18n' - ` -} + ` + } if (filePath === path.join(baseDir, 'file2.js')) return 'no match here' if (filePath === path.join(baseDir, 'subdir', 'file3.js')) { - return ` + return ` import { useI18n } from '@scaleway/use-i18n' - ` -} + ` + } return '' }), diff --git a/packages/unused-i18n/src/lib/scopedNamespace/__tests__/extractNamespaceTranslation.test.ts b/packages/unused-i18n/src/lib/scopedNamespace/__tests__/extractNamespaceTranslation.test.ts index a1ac3f47b..f94ec65b9 100644 --- a/packages/unused-i18n/src/lib/scopedNamespace/__tests__/extractNamespaceTranslation.test.ts +++ b/packages/unused-i18n/src/lib/scopedNamespace/__tests__/extractNamespaceTranslation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { extractNamespaceTranslation } from "../extractNamespaceTranslation" +import { extractNamespaceTranslation } from '../extractNamespaceTranslation' describe('extractNamespaceTranslation', () => { it('should extract namespace translations correctly', () => { diff --git a/packages/unused-i18n/src/lib/scopedNamespace/__tests__/extractScopedTs.test.ts b/packages/unused-i18n/src/lib/scopedNamespace/__tests__/extractScopedTs.test.ts index 95a8c80f5..adb905061 100644 --- a/packages/unused-i18n/src/lib/scopedNamespace/__tests__/extractScopedTs.test.ts +++ b/packages/unused-i18n/src/lib/scopedNamespace/__tests__/extractScopedTs.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { extractScopedTs } from "../extractScopedTs" +import { extractScopedTs } from '../extractScopedTs' describe('extractScopedTs', () => { it('should extract scoped translations correctly', () => { diff --git a/packages/unused-i18n/src/utils/__tests__/getMissingTranslations.test.ts b/packages/unused-i18n/src/utils/__tests__/getMissingTranslations.test.ts index b92474ba4..1cb6a622c 100644 --- a/packages/unused-i18n/src/utils/__tests__/getMissingTranslations.test.ts +++ b/packages/unused-i18n/src/utils/__tests__/getMissingTranslations.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { getMissingTranslations } from "../missingTranslations" -import { shouldExclude } from "../shouldExclude" +import { getMissingTranslations } from '../missingTranslations' +import { shouldExclude } from '../shouldExclude' vi.mock('../../utils/shouldExclude', () => ({ shouldExclude: vi.fn(), diff --git a/packages/unused-i18n/src/utils/__tests__/loadConfig.test.ts b/packages/unused-i18n/src/utils/__tests__/loadConfig.test.ts new file mode 100644 index 000000000..a17108b83 --- /dev/null +++ b/packages/unused-i18n/src/utils/__tests__/loadConfig.test.ts @@ -0,0 +1,98 @@ +import * as fs from 'fs' +import * as path from 'path' +import { type BuildResult, build } from 'esbuild' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { loadConfig } from '../loadConfig' + +vi.mock('fs') +vi.mock('esbuild', () => ({ + build: vi.fn(), +})) +vi.mock('path') + +describe('loadConfig', () => { + beforeEach(() => { + vi.resetAllMocks() + vi.spyOn(process, 'cwd').mockReturnValue('/mocked/cwd') + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should load JSON config file correctly', async () => { + const configPath = '/mocked/cwd/unused-i18n.config.json' + const configContent = '{"test": "value"}' + const existsSyncImpl = (filePath: string) => filePath === configPath + + vi.mocked(() => path.resolve()).mockImplementation((...args: string[]) => + args.join('/'), + ) + vi.mocked((arg: string) => path.extname(arg)).mockReturnValue('.json') + + vi.mocked(fs.existsSync).mockImplementation( + existsSyncImpl as typeof fs.existsSync, + ) + vi.mocked(fs.readFileSync).mockReturnValue(configContent) + + const config = await loadConfig() + + expect(config).toEqual({ test: 'value' }) + expect(fs.existsSync).toHaveBeenCalledWith(configPath) + expect(fs.readFileSync).toHaveBeenCalledWith(configPath, 'utf-8') + }) + it('should load TypeScript config file correctly', async () => { + const configPath = '/mocked/cwd/unused-i18n.config.ts' + const jsCode = 'export default { test: "value" }' + const buildResult = { outputFiles: [{ text: jsCode }] } + const existsSyncImpl = (filePath: string) => filePath === configPath + + vi.mocked(() => path.resolve()).mockImplementation((...args: string[]) => + args.join('/'), + ) + vi.mocked((arg: string) => path.extname(arg)).mockReturnValue('.ts') + + vi.mocked(fs.existsSync).mockImplementation( + existsSyncImpl as typeof fs.existsSync, + ) + vi.mocked(build).mockResolvedValue(buildResult as BuildResult) + + const config = await loadConfig() + expect(config).toEqual({ test: 'value' }) + expect(fs.existsSync).toHaveBeenCalledWith(configPath) + expect(build).toHaveBeenCalledWith({ + entryPoints: [configPath], + outfile: 'config.js', + platform: 'node', + format: 'esm', + bundle: true, + write: false, + }) + }) + + it('should throw an error if no config file is found', async () => { + vi.mocked(() => path.resolve()).mockImplementation((...args: string[]) => + args.join('/'), + ) + + vi.mocked(fs.existsSync).mockReturnValue(false) + + await expect(loadConfig()).rejects.toThrow( + 'Configuration file unused-i18n.config not found. Supported extensions: .json, .js, .cjs, .ts.', + ) + + expect(fs.existsSync).toHaveBeenCalledTimes(4) + expect(fs.existsSync).toHaveBeenCalledWith( + '/mocked/cwd/unused-i18n.config.json', + ) + expect(fs.existsSync).toHaveBeenCalledWith( + '/mocked/cwd/unused-i18n.config.js', + ) + expect(fs.existsSync).toHaveBeenCalledWith( + '/mocked/cwd/unused-i18n.config.cjs', + ) + expect(fs.existsSync).toHaveBeenCalledWith( + '/mocked/cwd/unused-i18n.config.ts', + ) + }) +}) diff --git a/packages/unused-i18n/src/utils/loadConfig.ts b/packages/unused-i18n/src/utils/loadConfig.ts index a0cb8ce1f..4c7c1a773 100644 --- a/packages/unused-i18n/src/utils/loadConfig.ts +++ b/packages/unused-i18n/src/utils/loadConfig.ts @@ -1,5 +1,5 @@ import * as fs from 'fs' -import * as path from 'path' +import { extname, resolve } from 'path' import { build } from 'esbuild' import type { Config } from '../types' @@ -10,7 +10,7 @@ export const loadConfig = async (): Promise => { let configPath = '' for (const ext of supportedExtensions) { - const potentialPath = path.resolve(cwd, `unused-i18n.config${ext}`) + const potentialPath = resolve(cwd, `unused-i18n.config${ext}`) if (fs.existsSync(potentialPath)) { configPath = potentialPath break @@ -23,13 +23,14 @@ export const loadConfig = async (): Promise => { ) } - const extension = path.extname(configPath) + const extension = extname(configPath) if (extension === '.json') { const configContent = fs.readFileSync(configPath, 'utf-8') return JSON.parse(configContent) as Config } + if (extension === '.ts') { const result = await build({ entryPoints: [configPath], @@ -41,7 +42,6 @@ export const loadConfig = async (): Promise => { }) const jsCode = result.outputFiles[0]?.text ?? '' - const module = (await import( `data:application/javascript,${encodeURIComponent(jsCode)}` )) as { default: Config } diff --git a/packages/unused-i18n/src/utils/missingTranslations.ts b/packages/unused-i18n/src/utils/missingTranslations.ts index 2f2f5c8df..c97a69882 100644 --- a/packages/unused-i18n/src/utils/missingTranslations.ts +++ b/packages/unused-i18n/src/utils/missingTranslations.ts @@ -6,7 +6,8 @@ export const getMissingTranslations = ({ localLines, excludeKey, extractedTranslations, -}: GetMissingTranslationsArgs): string[] => localLines.filter(line => { +}: GetMissingTranslationsArgs): string[] => + localLines.filter(line => { if (shouldExclude({ line, excludeKey })) { return false }