From ce200fe98efaff54fe08724bdb6281f73304f629 Mon Sep 17 00:00:00 2001 From: Kuingsmile <96409857+Kuingsmile@users.noreply.github.com> Date: Fri, 11 Jul 2025 12:35:51 +0800 Subject: [PATCH] :package: Chore(custom): add i18n check scripts and action --- .github/workflows/i18n-check.yml | 326 ++++++++++++++++++++ package.json | 5 + public/i18n/en.yml | 6 +- scripts/find-unused-i18n.mjs | 499 +++++++++++++++++++++++++++++++ 4 files changed, 833 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/i18n-check.yml create mode 100644 scripts/find-unused-i18n.mjs diff --git a/.github/workflows/i18n-check.yml b/.github/workflows/i18n-check.yml new file mode 100644 index 00000000..238eb84a --- /dev/null +++ b/.github/workflows/i18n-check.yml @@ -0,0 +1,326 @@ +name: 'I18n Check - Find Unused Translation Keys' + +on: + workflow_dispatch: + inputs: + auto_fix: + description: 'Automatically remove unused keys and create PR' + required: false + default: false + type: boolean + pull_request: + branches: [ main, dev ] + paths: + - 'src/**/*.vue' + - 'src/**/*.ts' + - 'src/**/*.js' + - 'public/i18n/**/*.yml' + - 'scripts/find-unused-i18n.mjs' + - '.github/workflows/i18n-check.yml' + push: + branches: [ main, dev ] + paths: + - 'public/i18n/**/*.yml' + +env: + NODE_OPTIONS: '--max-old-space-size=4096' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + i18n-check: + name: Check for Unused I18n Keys + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'yarn' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Run i18n unused keys check + id: i18n-check + run: | + echo "Running i18n unused keys analysis..." + + # Run the script and capture output + OUTPUT=$(node scripts/find-unused-i18n.mjs 2>&1) + EXIT_CODE=$? + + # Save the output to a file for the job summary + echo "$OUTPUT" > i18n-check-output.txt + + # Also output to console + echo "$OUTPUT" + + # Check if there are unused keys by looking for the specific pattern in output + if echo "$OUTPUT" | grep -q "šŸ—‘ļø Unused I18n Keys:"; then + echo "unused_keys_found=true" >> $GITHUB_OUTPUT + echo "āŒ Found unused i18n keys!" + exit 1 + else + echo "unused_keys_found=false" >> $GITHUB_OUTPUT + echo "āœ… No unused i18n keys found!" + exit 0 + fi + + - name: Generate Job Summary + if: always() + run: | + echo "## 🌐 I18n Keys Analysis Report" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if [ -f i18n-check-output.txt ]; then + echo "### šŸ“Š Analysis Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + cat i18n-check-output.txt >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + fi + + if [ "${{ steps.i18n-check.outputs.unused_keys_found }}" = "true" ]; then + echo "### āŒ Action Required" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Unused i18n keys were found. Consider:" >> $GITHUB_STEP_SUMMARY + echo "- Removing unused keys from locale files" >> $GITHUB_STEP_SUMMARY + echo "- Verifying that the keys are actually unused" >> $GITHUB_STEP_SUMMARY + echo "- Adding usage for keys that should be kept" >> $GITHUB_STEP_SUMMARY + else + echo "### āœ… All Clear" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "No unused i18n keys found. Great job maintaining clean translations! šŸŽ‰" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "### šŸ’” Tips" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- Run \`yarn i18n:check\` locally to check for unused keys" >> $GITHUB_STEP_SUMMARY + echo "- Run \`yarn i18n:check:verbose\` for detailed usage information" >> $GITHUB_STEP_SUMMARY + echo "- This check runs automatically on PRs that modify Vue, TS, JS, or i18n files" >> $GITHUB_STEP_SUMMARY + + - name: Upload analysis results + if: always() + uses: actions/upload-artifact@v4 + with: + name: i18n-analysis-results + path: i18n-check-output.txt + retention-days: 7 + + - name: Comment on PR (if unused keys found) + if: github.event_name == 'pull_request' && steps.i18n-check.outputs.unused_keys_found == 'true' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + + let output = ''; + try { + output = fs.readFileSync('i18n-check-output.txt', 'utf8'); + } catch (error) { + output = 'Unable to read analysis output'; + } + + const body = `## 🌐 I18n Analysis Report + + āŒ **Unused i18n keys were found in this PR** + +
+ šŸ“Š Click to view detailed analysis + + \`\`\` + ${output} + \`\`\` +
+ + ### šŸ”§ Action Required + + Please review the unused keys listed above and consider: + - **Removing** keys that are truly unused + - **Verifying** that the detection is correct (some dynamic key usage might not be detected) + - **Adding usage** for keys that should be kept + + ### šŸ’” Local Testing + + You can run this analysis locally using: + \`\`\`bash + yarn i18n:check # Basic check + yarn i18n:check:verbose # Detailed output with usage examples + \`\`\` + + --- + *This comment was automatically generated by the I18n Check workflow.*`; + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: body + }); + + locale-consistency-check: + name: Check Locale File Consistency + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'yarn' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Check locale files consistency + run: | + echo "Checking if all locale files have the same structure..." + + # Run the i18n script in verbose mode to also check for inconsistencies + node scripts/find-unused-i18n.mjs --verbose > locale-check.txt 2>&1 + + # Check if there are inconsistencies reported + if grep -q "āš ļø Locale Inconsistencies:" locale-check.txt; then + echo "āŒ Found locale inconsistencies!" + cat locale-check.txt + exit 1 + else + echo "āœ… All locale files are consistent!" + fi + + - name: Generate Consistency Report + if: always() + run: | + echo "## šŸ”„ Locale Consistency Check" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if grep -q "āš ļø Locale Inconsistencies:" locale-check.txt 2>/dev/null; then + echo "### āŒ Inconsistencies Found" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Some keys exist in one locale but not others:" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + cat locale-check.txt >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + else + echo "### āœ… All Locale Files Consistent" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "All locale files have matching key structures. Perfect! šŸŽ‰" >> $GITHUB_STEP_SUMMARY + fi + + auto-fix: + name: Auto-fix Unused I18n Keys + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' && github.event.inputs.auto_fix == 'true' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'yarn' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Check for unused keys before deletion + id: check-before + run: | + echo "Checking for unused keys..." + + # Check if there are unused keys + if node scripts/find-unused-i18n.mjs | grep -q "šŸ—‘ļø Unused I18n Keys:"; then + echo "unused_keys_found=true" >> $GITHUB_OUTPUT + echo "āœ… Found unused keys to clean up" + else + echo "unused_keys_found=false" >> $GITHUB_OUTPUT + echo "ā„¹ļø No unused keys found" + fi + + - name: Delete unused keys + if: steps.check-before.outputs.unused_keys_found == 'true' + run: | + echo "šŸ—‘ļø Removing unused i18n keys..." + node scripts/find-unused-i18n.mjs --delete + + - name: Regenerate TypeScript types + if: steps.check-before.outputs.unused_keys_found == 'true' + run: | + echo "šŸ”„ Regenerating TypeScript types..." + yarn i18n + + - name: Create Pull Request + if: steps.check-before.outputs.unused_keys_found == 'true' + uses: peter-evans/create-pull-request@v5 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: 'feat(i18n): remove unused translation keys' + title: 'šŸ—‘ļø Remove unused i18n translation keys' + body: | + ## šŸ—‘ļø Automated I18n Cleanup + + This PR automatically removes unused translation keys that were detected by the i18n analysis script. + + ### ✨ Changes Made + + - Removed unused translation keys from all locale files + - Regenerated TypeScript type definitions + + ### šŸ” Analysis Details + + The unused keys were identified by scanning the entire codebase for i18n usage patterns including: + - `$T('KEY')` - Template usage + - `T('KEY')` - Script usage + - `i18nManager.T('KEY')` - Direct manager usage + - Dynamic patterns with template literals + + ### āš ļø Review Required + + Please review the changes to ensure: + - No keys were removed that are used in ways not detected by the script + - All locale files maintain consistency + - The TypeScript types were properly regenerated + + --- + *This PR was automatically created by the I18n Auto-fix workflow.* + branch: feat/cleanup-unused-i18n-keys + delete-branch: true + + - name: Generate Summary + run: | + if [ "${{ steps.check-before.outputs.unused_keys_found }}" = "true" ]; then + echo "## šŸ—‘ļø I18n Auto-fix Completed" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "āœ… Successfully removed unused translation keys and created a pull request." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### šŸ“ Next Steps" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "1. Review the created pull request" >> $GITHUB_STEP_SUMMARY + echo "2. Verify that no important keys were removed" >> $GITHUB_STEP_SUMMARY + echo "3. Test the application to ensure everything works correctly" >> $GITHUB_STEP_SUMMARY + echo "4. Merge the pull request when ready" >> $GITHUB_STEP_SUMMARY + else + echo "## ā„¹ļø No Action Needed" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "No unused i18n keys were found. Your translations are already clean! šŸŽ‰" >> $GITHUB_STEP_SUMMARY + fi diff --git a/package.json b/package.json index 4c1502c3..7af96e27 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,10 @@ "electron:build": "vue-cli-service electron:build", "electron:serve": "vue-cli-service electron:serve", "i18n": "node ./scripts/gen-i18n-types.js", + "i18n:check": "node ./scripts/find-unused-i18n.mjs", + "i18n:check:verbose": "node ./scripts/find-unused-i18n.mjs --verbose", + "i18n:clean": "node ./scripts/find-unused-i18n.mjs --delete", + "i18n:clean:dry-run": "node ./scripts/find-unused-i18n.mjs --delete --dry-run", "link": "node ./scripts/link.js", "lint": "vue-cli-service lint", "lint:dpdm": "dpdm -T --tsconfig ./tsconfig.json --no-tree --no-warning --exit-code circular:1 src/background.ts", @@ -60,6 +64,7 @@ "got": "^12.6.0", "highlight.js": "^11.9.0", "hpagent": "^1.2.0", + "js-yaml": "^4.1.0", "lowdb": "^1.0.0", "marked": "^9.1.5", "mime-types": "^2.1.35", diff --git a/public/i18n/en.yml b/public/i18n/en.yml index 642aace7..e00e412e 100644 --- a/public/i18n/en.yml +++ b/public/i18n/en.yml @@ -245,10 +245,10 @@ UPLOADER_CONFIG_NAME: Configuration Name UPLOADER_CONFIG_PLACEHOLDER: Please Enter Configuration Name SELECTED_SETTING_HINT: Selected SETTINGS_MAIN_WINDOW_SIZE: Default Main Window Size -SETTINGS_MAIN_WINDOW_WIDTH: Default Main Window Width +SETTINGS_MAIN_WINDOW_SIZE_WIDTH: Default Main Window Width SETTINGS_MAIN_WINDOW_WIDTH_HINT: 'Default: 1200' -SETTINGS_MAIN_WINDOW_WIDTH_RULE: Window Height must be greater than 100 -SETTINGS_MAIN_WINDOW_HEIGHT: Default Main Window Height +SETTINGS_MAIN_WINDOW_WIDTH_RULE: Window Width must be greater than 100 +SETTINGS_MAIN_WINDOW_SIZE_HEIGHT: Default Main Window Height SETTINGS_MAIN_WINDOW_HEIGHT_HINT: 'Default: 800' SETTINGS_MAIN_WINDOW_HEIGHT_RULE: Window Height must be greater than 100 SETTINGS_RAW_PICGO_SIZE: Raw PicGo Size diff --git a/scripts/find-unused-i18n.mjs b/scripts/find-unused-i18n.mjs new file mode 100644 index 00000000..e932af95 --- /dev/null +++ b/scripts/find-unused-i18n.mjs @@ -0,0 +1,499 @@ +#!/usr/bin/env node + +import { readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { basename, dirname, extname, join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' +import { load, dump } from 'js-yaml' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +const LOCALE_DIR = join(__dirname, '../public/i18n') +const SRC_DIR = join(__dirname, '../src') + +// Parse command line arguments +const args = process.argv.slice(2) +const isVerbose = args.includes('--verbose') || args.includes('-v') +const shouldDelete = args.includes('--delete') || args.includes('-d') +const isDryRun = args.includes('--dry-run') + +const colors = { + reset: '\x1b[0m', + bright: '\x1b[1m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m' +} + +function colorize(text, color) { + return `${colors[color]}${text}${colors.reset}` +} + +console.log(`\nšŸ” Analyzing i18n keys in ${LOCALE_DIR} and source files in ${SRC_DIR}`) +if (shouldDelete) { + console.log(colorize(`šŸ—‘ļø Delete mode enabled ${isDryRun ? '(DRY RUN)' : ''}`, 'yellow')) +} +console.log('') + +function flattenKeys(obj, prefix = '') { + const keys = [] + + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key + + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + keys.push(...flattenKeys(value, fullKey)) + } else { + keys.push(fullKey) + } + } + + return keys +} + +function readLocaleFile(filePath) { + try { + const content = readFileSync(filePath, 'utf8') + return load(content) || {} + } catch (error) { + console.error(colorize(`Error reading ${filePath}: ${error.message}`, 'red')) + return {} + } +} + +function getAllI18nKeys() { + const localeFiles = readdirSync(LOCALE_DIR).filter(file => file.endsWith('.yml')) + const allKeys = new Set() + const localeData = {} + + console.log(colorize('\nšŸ“ Found locale files:', 'blue')) + + for (const file of localeFiles) { + const filePath = join(LOCALE_DIR, file) + const locale = basename(file, '.yml') + const data = readLocaleFile(filePath) + const keys = flattenKeys(data) + + localeData[locale] = { + file: filePath, + keys, + data + } + + keys.forEach(key => allKeys.add(key)) + + console.log(` ${colorize('āœ“', 'green')} ${file} (${keys.length} keys)`) + } + + return { + allKeys: Array.from(allKeys).sort(), + localeData + } +} + +function findFiles(dir, extensions = ['.vue', '.ts', '.js']) { + const files = [] + + function walk(currentDir) { + try { + const entries = readdirSync(currentDir, { withFileTypes: true }) + + for (const entry of entries) { + const fullPath = join(currentDir, entry.name) + + if (entry.isDirectory()) { + if (!['node_modules', '.git', 'dist', 'build', 'target', 'dist_electron'].includes(entry.name)) { + walk(fullPath) + } + } else if (entry.isFile()) { + const ext = extname(entry.name) + if (extensions.includes(ext)) { + files.push(fullPath) + } + } + } + } catch (error) { + console.warn(colorize(`Warning: Cannot read directory ${currentDir}: ${error.message}`, 'yellow')) + } + } + + walk(dir) + return files +} + +function findKeyUsage(keys) { + const usage = {} + const dynamicPatterns = [] + + keys.forEach(key => { + usage[key] = { + used: false, + files: [], + patterns: [], + dynamicMatch: false + } + }) + + console.log(colorize('\nšŸ” Searching for key usage in source files...', 'blue')) + + const sourceFiles = findFiles(SRC_DIR) + + console.log(` Found ${sourceFiles.length} source files to analyze`) + + const searchPatterns = [ + /\$T\s*\(\s*['"`]([^'"`]+)['"`]/g, + /(?:^|[^a-zA-Z$])T\s*\(\s*['"`]([^'"`]+)['"`]/g, + /\{\{\s*\$T\s*\(\s*['"`]([^'"`]+)['"`]/g, + /i18nManager\.T\s*\(\s*['"`]([^'"`]+)['"`]/g + ] + + const dynamicPattern = /\$T\s*\(\s*`([^`]*\$\{[^}]+\}[^`]*)`/g + + sourceFiles.forEach(filePath => { + try { + const content = readFileSync(filePath, 'utf8') + const relativePath = relative(join(__dirname, '..'), filePath) + + searchPatterns.forEach((pattern, patternIndex) => { + let match + while ((match = pattern.exec(content)) !== null) { + const key = match[1] + if (usage[key]) { + usage[key].used = true + if (!usage[key].files.includes(relativePath)) { + usage[key].files.push(relativePath) + } + if (!usage[key].patterns.includes(patternIndex)) { + usage[key].patterns.push(patternIndex) + } + } + } + }) + + let dynamicMatch + while ((dynamicMatch = dynamicPattern.exec(content)) !== null) { + const templateString = dynamicMatch[1] + + const staticParts = templateString.split(/\$\{[^}]+\}/) + + const patternInfo = { + template: templateString, + file: relativePath, + staticParts + } + + if (!dynamicPatterns.some(p => p.template === templateString && p.file === relativePath)) { + dynamicPatterns.push(patternInfo) + } + + keys.forEach(key => { + if (matchesDynamicPattern(key, staticParts)) { + if (usage[key]) { + usage[key].used = true + usage[key].dynamicMatch = true + if (!usage[key].files.includes(relativePath)) { + usage[key].files.push(relativePath) + } + if (!usage[key].patterns.includes('dynamic')) { + usage[key].patterns.push('dynamic') + } + } + } + }) + } + } catch (error) { + console.error(colorize(`Error reading ${filePath}: ${error.message}`, 'red')) + } + }) + + usage._dynamicPatterns = dynamicPatterns + + return usage +} + +function matchesDynamicPattern(key, staticParts) { + if (staticParts.length === 0) return false + + let keyIndex = 0 + + for (let i = 0; i < staticParts.length; i++) { + const part = staticParts[i] + + if (part === '') { + if (i < staticParts.length - 1) { + const nextPart = staticParts[i + 1] + if (nextPart) { + const nextIndex = key.indexOf(nextPart, keyIndex) + if (nextIndex === -1) return false + keyIndex = nextIndex + } + } + continue + } + + if (i === 0) { + if (!key.startsWith(part)) return false + keyIndex = part.length + } else if (i === staticParts.length - 1) { + if (part && !key.endsWith(part)) return false + } else { + const index = key.indexOf(part, keyIndex) + if (index === -1) return false + keyIndex = index + part.length + } + } + + return true +} + +function findLocaleInconsistencies(localeData) { + const locales = Object.keys(localeData) + const inconsistencies = {} + + if (locales.length < 2) { + return inconsistencies + } + + locales.forEach(locale => { + const currentKeys = new Set(localeData[locale].keys) + inconsistencies[locale] = { + missing: [], + extra: [] + } + + locales.forEach(otherLocale => { + if (locale !== otherLocale) { + localeData[otherLocale].keys.forEach(key => { + if (!currentKeys.has(key) && !inconsistencies[locale].missing.includes(key)) { + inconsistencies[locale].missing.push(key) + } + }) + } + }) + + localeData[locale].keys.forEach(key => { + const existsInOthers = locales.some( + otherLocale => locale !== otherLocale && localeData[otherLocale].keys.includes(key) + ) + if (!existsInOthers) { + inconsistencies[locale].extra.push(key) + } + }) + }) + + return inconsistencies +} + +function deleteUnusedKeys(unusedKeys, localeData, isDryRun = false) { + const results = {} + + Object.entries(localeData).forEach(([locale, data]) => { + results[locale] = { + deleted: [], + errors: [] + } + + try { + const updatedData = { ...data.data } + + unusedKeys.forEach(key => { + if (key in updatedData) { + if (!isDryRun) { + delete updatedData[key] + } + results[locale].deleted.push(key) + } + }) + + if (!isDryRun && results[locale].deleted.length > 0) { + const yamlContent = dump(updatedData, { + indent: 2, + lineWidth: -1, + noRefs: true, + sortKeys: false + }) + writeFileSync(data.file, yamlContent, 'utf8') + } + } catch (error) { + results[locale].errors.push(`Failed to process ${locale}: ${error.message}`) + } + }) + + return results +} + +function main() { + console.log(colorize('🌐 PicList - I18n Usage Analyzer', 'cyan')) + console.log(colorize('==================================', 'cyan')) + + const { allKeys, localeData } = getAllI18nKeys() + + console.log(colorize(`\nšŸ“Š Total unique keys found: ${allKeys.length}`, 'yellow')) + const usage = findKeyUsage(allKeys) + const dynamicPatterns = usage._dynamicPatterns || [] + delete usage._dynamicPatterns + + const usedKeys = allKeys.filter(key => usage[key].used) + const unusedKeys = allKeys.filter(key => !usage[key].used) + const dynamicallyUsedKeys = usedKeys.filter(key => usage[key].dynamicMatch) + const staticUsedKeys = usedKeys.filter(key => !usage[key].dynamicMatch) + + const inconsistencies = findLocaleInconsistencies(localeData) + + console.log(colorize('\nšŸ“ˆ Usage Summary:', 'blue')) + console.log(` ${colorize('āœ“', 'green')} Used keys: ${usedKeys.length}`) + console.log(` ${colorize('→', 'cyan')} Static usage: ${staticUsedKeys.length}`) + console.log(` ${colorize('→', 'magenta')} Dynamic usage: ${dynamicallyUsedKeys.length}`) + console.log(` ${colorize('āœ—', 'red')} Unused keys: ${unusedKeys.length}`) + console.log(` ${colorize('šŸ“Š', 'yellow')} Usage rate: ${((usedKeys.length / allKeys.length) * 100).toFixed(1)}%`) + + if (dynamicPatterns.length > 0) { + console.log(colorize('\nšŸ”® Dynamic I18n Patterns Detected:', 'magenta')) + console.log(colorize('===================================', 'magenta')) + + dynamicPatterns.forEach((pattern, index) => { + console.log(colorize(`\n${index + 1}. Template: \`${pattern.template}\``, 'cyan')) + console.log(` File: ${pattern.file}`) + console.log(` Static parts: [${pattern.staticParts.map(p => `"${p}"`).join(', ')}]`) + + const matchingKeys = allKeys.filter(key => matchesDynamicPattern(key, pattern.staticParts)) + if (matchingKeys.length > 0) { + console.log( + ` ${colorize('Matches', 'green')} (${matchingKeys.length}): ${matchingKeys.slice(0, 5).join(', ')}${ + matchingKeys.length > 5 ? '...' : '' + }` + ) + } + }) + } + + if (unusedKeys.length > 0) { + console.log(colorize('\nšŸ—‘ļø Unused I18n Keys:', 'red')) + console.log(colorize('====================', 'red')) + + const groupedUnused = {} + unusedKeys.forEach(key => { + const namespace = key.includes('.') ? key.split('.')[0] : 'ROOT' + if (!groupedUnused[namespace]) { + groupedUnused[namespace] = [] + } + groupedUnused[namespace].push(key) + }) + + Object.entries(groupedUnused).forEach(([namespace, keys]) => { + console.log(colorize(`\n[${namespace}] - ${keys.length} unused keys:`, 'yellow')) + keys.forEach(key => { + console.log(` ${colorize('āœ—', 'red')} ${key}`) + }) + }) + } else { + console.log(colorize('\nšŸŽ‰ No unused keys found! All i18n keys are being used.', 'green')) + } + + const hasInconsistencies = Object.values(inconsistencies).some(inc => inc.missing.length > 0 || inc.extra.length > 0) + + if (hasInconsistencies) { + console.log(colorize('\nāš ļø Locale Inconsistencies:', 'yellow')) + console.log(colorize('=========================', 'yellow')) + + Object.entries(inconsistencies).forEach(([locale, data]) => { + if (data.missing.length > 0 || data.extra.length > 0) { + console.log(colorize(`\n[${locale}.yml]:`, 'cyan')) + + if (data.missing.length > 0) { + console.log(colorize(` Missing ${data.missing.length} keys:`, 'red')) + data.missing.forEach(key => { + console.log(` ${colorize('āœ—', 'red')} ${key}`) + }) + } + + if (data.extra.length > 0) { + console.log(colorize(` Extra ${data.extra.length} keys:`, 'blue')) + data.extra.forEach(key => { + console.log(` ${colorize('!', 'blue')} ${key}`) + }) + } + } + }) + } + + if (isVerbose) { + console.log(colorize('\nšŸ“‹ Sample Used Keys (first 10):', 'blue')) + console.log(colorize('=================================', 'blue')) + + usedKeys.slice(0, 10).forEach(key => { + const files = usage[key].files.slice(0, 3) + const moreFiles = usage[key].files.length > 3 ? ` (+${usage[key].files.length - 3} more)` : '' + const usageType = usage[key].dynamicMatch ? colorize('(dynamic)', 'magenta') : colorize('(static)', 'cyan') + console.log(` ${colorize('āœ“', 'green')} ${key} ${usageType}`) + console.log(` Used in: ${files.join(', ')}${moreFiles}`) + }) + + if (dynamicallyUsedKeys.length > 0) { + console.log(colorize('\nšŸ”® Dynamic Key Usage Details:', 'magenta')) + console.log(colorize('=============================', 'magenta')) + + dynamicallyUsedKeys.slice(0, 5).forEach(key => { + const files = usage[key].files.slice(0, 2) + console.log(` ${colorize('✨', 'magenta')} ${key}`) + console.log(` Files: ${files.join(', ')}`) + }) + + if (dynamicallyUsedKeys.length > 5) { + console.log(` ... and ${dynamicallyUsedKeys.length - 5} more dynamic keys`) + } + } + } + + if (shouldDelete && unusedKeys.length > 0) { + console.log(colorize('\nšŸ—‘ļø Deleting Unused Keys:', 'yellow')) + console.log(colorize('=========================', 'yellow')) + + if (isDryRun) { + console.log(colorize('šŸ” DRY RUN MODE - No files will be modified', 'cyan')) + } + + const deletionResults = deleteUnusedKeys(unusedKeys, localeData, isDryRun) + + Object.entries(deletionResults).forEach(([locale, result]) => { + console.log(colorize(`\n[${locale}.yml]:`, 'cyan')) + if (result.deleted.length > 0) { + console.log(colorize(` Deleted ${result.deleted.length} keys:`, 'green')) + result.deleted.forEach(key => { + console.log(` ${colorize('āœ“', 'green')} ${key}`) + }) + } + if (result.errors.length > 0) { + console.log(colorize(` Errors ${result.errors.length}:`, 'red')) + result.errors.forEach(error => { + console.log(` ${colorize('āœ—', 'red')} ${error}`) + }) + } + }) + + if (!isDryRun) { + console.log(colorize('\n✨ Deletion complete! Remember to regenerate type definitions.', 'green')) + console.log(colorize('šŸ’” Run `yarn i18n` to update TypeScript types', 'blue')) + } + } + + console.log(colorize('\n✨ Analysis complete!', 'cyan')) + + if (unusedKeys.length > 0 && !shouldDelete) { + console.log(colorize('\nšŸ’” Tips:', 'blue')) + console.log(colorize('- Run with --verbose (-v) flag to see usage details of used keys', 'blue')) + console.log(colorize('- Run with --delete (-d) flag to automatically remove unused keys', 'blue')) + console.log(colorize('- Run with --delete --dry-run to preview what would be deleted', 'blue')) + } else if (unusedKeys.length === 0) { + console.log(colorize('\nšŸ’” Tip: Run with --verbose (-v) flag to see usage details of used keys', 'blue')) + } + + if (unusedKeys.length > 0 && !shouldDelete) { + process.exit(1) + } +} + +main()