langcleaner 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,153 @@
1
+ # 🌍 LangCleaner
2
+
3
+ **The intelligent i18n maintenance toolkit for nested or flat JSON.**
4
+
5
+ LangCleaner is a CLI tool designed to keep your internationalization files clean, synchronized, and perfectly matched to your source code. It doesn't just find problems—it fixes them.
6
+
7
+ ---
8
+
9
+ ## 🚀 The Maintenance Pipeline
10
+
11
+ The most powerful way to use this tool is the **maintenance command**. It runs a 5-step "Self-Healing" cycle where each step builds upon the last:
12
+
13
+ 1. **Dedupe**
14
+ Finds multiple keys with the same translation (e.g., `Save` and `Apply` both being `"Submit"`) and merges them.
15
+
16
+ 2. **Apply Code Updates**
17
+ Automatically renames those keys in your React/Vue/JS source code to match the new merged keys.
18
+
19
+ 3. **Find Unused Keys**
20
+ Scans your code, finds and deletes (if you add --fix) any keys from your JSON that are no longer used.
21
+
22
+ 4. **Find Missing Translations**
23
+ Finds keys you wrote in your code (like `t("nav.home")`) that don't exist in your JSON yet and adds (if you add --fix) them as empty stubs.
24
+
25
+ 5. **Sync Lang / Sync All**
26
+ Takes your now-perfect English (or other master) file and forces all other languages (`tr.json`, `de.json`, etc.) to match its structure perfectly.
27
+
28
+ ### Maintenance Pipeline Diagram
29
+
30
+ ```plaintext
31
+ +---------+
32
+ | Start |
33
+ +----+----+
34
+ |
35
+ v
36
+ +-----+-----+
37
+ | Dedupe |
38
+ +-----+-----+
39
+ |
40
+ v
41
+ +----------+-----------+
42
+ | Apply Updates |
43
+ +----------+-----------+
44
+ |
45
+ v
46
+ +-------+-------+
47
+ | Clean Unused |
48
+ +-------+-------+
49
+ |
50
+ v
51
+ +-------+-------+
52
+ | Add Missing |
53
+ +-------+-------+
54
+ |
55
+ v
56
+ +-----+-----+
57
+ | Sync All |
58
+ +-----+-----+
59
+ |
60
+ v
61
+ +----+----+
62
+ | Finish |
63
+ +---------+
64
+ ```
65
+
66
+ ---
67
+
68
+ ## 📦 Installation
69
+
70
+ ```bash
71
+ npm install -g langcleaner
72
+ ```
73
+
74
+ ---
75
+
76
+ ## 🛠 Commands & Usage
77
+
78
+ ### ⚡ Full Maintenance (Recommended)
79
+
80
+ Run the entire pipeline in one go:
81
+
82
+ ```bash
83
+ langcleaner maintenance ./locales/en.json ./src --fix
84
+ ```
85
+
86
+ **Options:**
87
+
88
+ * `--fix`: Required to actually update your files. Without it, the tool only generates reports.
89
+
90
+ ### 🔍 Individual Commands
91
+
92
+ | Command | Purpose | Sample Usage |
93
+ | --------------------------- | ------------------------------------ | ------------------------------------------------------------- |
94
+ | `dedupe` | Merges duplicate values in JSON | `langcleaner dedupe ./locales/en.json --fix` |
95
+ | `apply-code-updates` | Updates code based on dedupe mapping | `langcleaner apply-code-updates ./output/update-mapping.json ./src` |
96
+ | `find-unused-keys` | Removes dead keys from JSON | `langcleaner find-unused-keys ./locales/en.json ./src --fix` |
97
+ | `find-missing-translations` | Stubs out missing keys in JSON | `langcleaner find-missing-translations ./locales/en.json ./src --fix` |
98
+ | `sync-lang` | Syncs a language to match master | `langcleaner sync-lang ./locales/en.json ./locales/de.json` |
99
+ | `sync-all` | Syncs all languages to match master | `langcleaner sync-all ./locales/en.json ./locales` |
100
+
101
+ ---
102
+
103
+ ## 📂 Understanding the `output/` folder
104
+
105
+ LangCleaner follows a **Safety-First** approach. It never overwrites your primary master file. Instead, it creates an `output/` directory:
106
+
107
+ ```plaintext
108
+ output/
109
+ └── en/
110
+ ├── cleaned-en.json <-- Your "Gold Standard" file
111
+ ├── update-mapping.json <-- Logic used to update code
112
+ ├── unused-keys-report.json <-- Audit of what was deleted
113
+ └── missing-keys-report.json <-- Audit of what needs translation
114
+ ```
115
+
116
+ ### Example JSON Snippets
117
+
118
+ **Before `dedupe`:**
119
+
120
+ ```json
121
+ {
122
+ "save": "Submit",
123
+ "apply": "Submit",
124
+ "cancel": "Cancel"
125
+ }
126
+ ```
127
+
128
+ **After `dedupe`:**
129
+
130
+ ```json
131
+ {
132
+ "save": "Submit",
133
+ "cancel": "Cancel"
134
+ }
135
+ ```
136
+
137
+ ### Professional Audit Reports
138
+
139
+ Every report is standardized for your team's review:
140
+
141
+ * **Config:** Shows absolute paths and files scanned.
142
+ * **Summary:** High-level stats (e.g., "37% of keys were unused").
143
+ * **Details:** Alphabetical lists of exactly which keys were changed.
144
+
145
+ ---
146
+
147
+ ## 💡 Pro-Tips
148
+
149
+ * **Always use a Master:** Choose one language (usually English) as your master path.
150
+ * **Dry Run:** Run commands without `--fix` first to see the JSON reports and verify changes.
151
+ * **Commit First:** Always commit your code to Git before running with `--fix` so you can easily revert if needed.
152
+
153
+ ---
package/bin/index.js ADDED
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env node
2
+ import { program } from "commander";
3
+ import path from "path";
4
+ import fs from "fs";
5
+
6
+ // Command Imports
7
+ import dedupe from "../src/commands/dedupe.js";
8
+ import applyCodeUpdates from "../src/commands/applyCodeUpdates.js";
9
+ import findUnusedKeys from "../src/commands/findUnusedKeys.js";
10
+ import findMissingTranslations from "../src/commands/findMissingTranslations.js";
11
+ import syncLang from "../src/commands/syncLang.js";
12
+ import syncAll from "../src/commands/syncAll.js";
13
+
14
+ program
15
+ .name("langcleaner")
16
+ .description("I18n nested JSON cleaner & synchronization CLI tool")
17
+ .version("1.0.0");
18
+
19
+ /* --- THE SNOWBALL EFFECT ---
20
+ Each command follows a prioritized path logic:
21
+ 1. Check for a "previously cleaned" version in ./output/{lang}/cleaned-{file}.json.
22
+ 2. If not found, fallback to the original source path.
23
+ This allows the maintenance pipeline to build upon the results of previous steps.
24
+ */
25
+
26
+ /**
27
+ * 1. DEDUPE
28
+ * Merges keys sharing identical values.
29
+ * Sample: langcleaner dedupe ./locales/en.json --fix --sort
30
+ */
31
+ program
32
+ .command("dedupe <masterPath>")
33
+ .description("Find repetitive values and generate an update-mapping.json")
34
+ .option("--fix", "Generate a cleaned JSON file in the output folder")
35
+ .option("--sort", "Sort keys alphabetically in the output file")
36
+ .action(dedupe);
37
+
38
+ /**
39
+ * 2. APPLY CODE UPDATES
40
+ * Renames keys in source files based on dedupe mapping.
41
+ * Sample: langcleaner apply-code-updates ./output/en/update-mapping.json ./src
42
+ */
43
+ program
44
+ .command("apply-code-updates <mappingPath> <projectDir>")
45
+ .description("Update source code references based on the dedupe mapping file")
46
+ .action(applyCodeUpdates);
47
+
48
+ /**
49
+ * 3. FIND UNUSED KEYS
50
+ * Removes keys from JSON that aren't found in the code.
51
+ * Sample: langcleaner find-unused-keys ./locales/en.json ./src --fix
52
+ */
53
+ program
54
+ .command("find-unused-keys <masterPath> <projectDir>")
55
+ .description("Find and optionally remove i18n keys not referenced in code")
56
+ .option("--fix", "Generate a cleaned version (removes unused keys)")
57
+ .action(async (masterPath, projectDir, options) => {
58
+ await findUnusedKeys(masterPath, projectDir, options.fix);
59
+ });
60
+
61
+ /**
62
+ * 4. FIND MISSING TRANSLATIONS
63
+ * Detects keys in code missing from JSON.
64
+ * Sample: langcleaner find-missing-translations ./locales/en.json ./src --fix
65
+ */
66
+ program
67
+ .command("find-missing-translations <masterPath> <projectDir>")
68
+ .description("Find keys present in code but missing from the master JSON file")
69
+ .option("--fix", "Automatically add missing keys to the cleaned file as stubs")
70
+ .action(async (masterPath, projectDir, options) => {
71
+ await findMissingTranslations(masterPath, projectDir, options.fix);
72
+ });
73
+
74
+ /**
75
+ * 5. SYNC SINGLE LANGUAGE
76
+ * Syncs one specific target to match the master structure.
77
+ * Sample: langcleaner sync-lang ./locales/en.json ./locales/tr.json
78
+ */
79
+ program
80
+ .command("sync-lang <masterPath> <targetPath>")
81
+ .description("Sync a single target file structure to match the master file")
82
+ .action(syncLang);
83
+
84
+ /**
85
+ * 6. SYNC ALL LANGUAGES
86
+ * Bulk syncs all files in a folder against the master.
87
+ * Sample: langcleaner sync-all ./locales/en.json ./locales
88
+ */
89
+ program
90
+ .command("sync-all <masterPath> <localesDir>")
91
+ .description("Sync all JSON files in a directory to match the master structure")
92
+ .action(syncAll);
93
+
94
+ /**
95
+ * 7. FULL MAINTENANCE (THE AUTOMATED PIPELINE)
96
+ * Runs the full sequence: Dedupe -> Apply -> Unused -> Missing -> Sync.
97
+ * Sample: langcleaner maintenance ./locales/en.json ./src --fix
98
+ */
99
+ program
100
+ .command("maintenance <masterPath> <projectDir>")
101
+ .description("Run full cycle: Dedupe -> Update Code -> Clean Unused -> Find Missing -> Sync All")
102
+ .option("--fix", "Automatically apply changes to JSON and source code")
103
+ .action(async (masterPath, projectDir, options) => {
104
+ const langName = path.basename(masterPath, ".json");
105
+ const langFileName = path.basename(masterPath);
106
+ const outDir = path.join(process.cwd(), "output", langName);
107
+ const mappingFile = path.join(outDir, "update-mapping.json");
108
+ const cleanedFile = path.join(outDir, `cleaned-${langFileName}`);
109
+
110
+ console.log("\n🚀 Starting Full Project Maintenance...");
111
+ console.log("──────────────────────────────────────────────────");
112
+
113
+ // Step 1: Dedupe (Initial Clean)
114
+ console.log("\n[1/5] Deduping values...");
115
+ await dedupe(masterPath, { fix: options.fix, sort: true });
116
+
117
+ // Step 2: Update Code (Requires mapping from Step 1)
118
+ if (options.fix) {
119
+ console.log("\n[2/5] Updating source code references...");
120
+ await applyCodeUpdates(mappingFile, projectDir);
121
+ }
122
+
123
+ // Step 3: Remove Unused (Refines Clean)
124
+ console.log("\n[3/5] Cleaning unused keys...");
125
+ await findUnusedKeys(masterPath, projectDir, options.fix);
126
+
127
+ // Step 4: Audit/Add Missing (Finalizes Clean)
128
+ console.log("\n[4/5] Checking and adding missing translations...");
129
+ await findMissingTranslations(masterPath, projectDir, options.fix);
130
+
131
+ // Step 5: Global Synchronization
132
+ // Priority: Use the fully processed 'cleaned' file if it exists, otherwise use original.
133
+ const syncSource = (options.fix && fs.existsSync(cleanedFile)) ? cleanedFile : masterPath;
134
+
135
+ console.log(`\n[5/5] Syncing all languages using: ${path.basename(syncSource)}...`);
136
+ await syncAll(syncSource, path.dirname(masterPath));
137
+
138
+ console.log("\n──────────────────────────────────────────────────");
139
+ console.log("✅ Maintenance completed! Review results in the 'output' folder.");
140
+ });
141
+
142
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "langcleaner",
3
+ "version": "1.0.0",
4
+ "description": "Smart i18n maintenance tool: dedupe keys, prune unused translations, and sync nested JSON structures.",
5
+ "main": "bin/index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "langcleaner": "./bin/index.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node bin/index.js",
12
+ "test": "echo \"Error: no test specified\" && exit 1"
13
+ },
14
+ "keywords": [
15
+ "i18n",
16
+ "internationalization",
17
+ "json-cleaner",
18
+ "dedupe",
19
+ "unused-keys",
20
+ "localization",
21
+ "translate",
22
+ "refactor",
23
+ "cli"
24
+ ],
25
+ "author": "Recep Yalcin",
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/recyalcin/langcleaner.git"
30
+ },
31
+ "bugs": {
32
+ "url": "https://github.com/recyalcin/langcleaner/issues"
33
+ },
34
+ "homepage": "https://github.com/recyalcin/langcleaner#readme",
35
+ "dependencies": {
36
+ "commander": "^11.1.0"
37
+ },
38
+ "engines": {
39
+ "node": ">=16.0.0"
40
+ }
41
+ }
@@ -0,0 +1,60 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { getAllFiles, loadJson } from "../utils/fileUtils.js";
4
+
5
+
6
+ export default async function applyCodeUpdates(mappingFile, projectPath) {
7
+ if (!fs.existsSync(mappingFile)) {
8
+ console.error(`❌ Error: Mapping file not found at ${mappingFile}`);
9
+ return;
10
+ }
11
+
12
+ // updateMapping: { "auth.login.old": "auth.login.new" }
13
+ const { updateMapping } = loadJson(mappingFile);
14
+ const files = getAllFiles(projectPath, [".js", ".jsx", ".ts", ".tsx", ".vue", ".html", ".svelte"]);
15
+
16
+ console.log(`\n🛠 Updating code references...`);
17
+ console.log(`──────────────────────────────────────────────────`);
18
+
19
+ let fileCount = 0;
20
+ let replacementCount = 0;
21
+
22
+ files.forEach(file => {
23
+ let content = fs.readFileSync(file, "utf8");
24
+ let isModified = false;
25
+
26
+ for (const [oldKey, newKey] of Object.entries(updateMapping)) {
27
+ /**
28
+ * REGEX STRATEGY:
29
+ * 1. (['"`]) : Opening quote (single, double, or backtick)
30
+ * 2. ${escapedOldKey} : The exact i18n path to be replaced
31
+ * 3. (['"`]) : Closing quote
32
+ * * This structure catches:
33
+ * - t("auth.button")
34
+ * - translation['auth.button']
35
+ * - [ "auth.button" ]
36
+ * - i18nKey="auth.button"
37
+ */
38
+ const escapedOldKey = oldKey.replace(/\./g, "\\.");
39
+ const regex = new RegExp(`(['"\`])${escapedOldKey}(['"\`])`, "g");
40
+
41
+ if (regex.test(content)) {
42
+ // $1 and $2 put the captured quotes back, replacing only the key inside
43
+ content = content.replace(regex, `$1${newKey}$2`);
44
+ isModified = true;
45
+ replacementCount++;
46
+ }
47
+ }
48
+
49
+ if (isModified) {
50
+ fs.writeFileSync(file, content, "utf8");
51
+ console.log(` ✔ Updated: ${path.relative(projectPath, file)}`);
52
+ fileCount++;
53
+ }
54
+ });
55
+
56
+ console.log(`──────────────────────────────────────────────────`);
57
+ console.log(`✅ Update Complete!`);
58
+ console.log(` • ${replacementCount} key references replaced.`);
59
+ console.log(` • ${fileCount} files updated.`);
60
+ }
@@ -0,0 +1,85 @@
1
+ import path from "path";
2
+ import { loadJson, writeJson, ensureDir } from "../utils/fileUtils.js";
3
+ import { dedupeValues } from "../utils/dedupeUtils.js";
4
+ import { sortJsonObject } from "../utils/sortUtils.js";
5
+
6
+ /**
7
+ * Finds repetitive values in the language file, reports them, and optionally cleans them.
8
+ * @param {string} file - Path to the language file (e.g., ./locales/en.json)
9
+ * @param {object} options - CLI options (--fix, --sort)
10
+ */
11
+ export default async function dedupe(file, options) {
12
+ if (!file) {
13
+ console.error("❌ Error: Please provide a valid language file path.");
14
+ return;
15
+ }
16
+
17
+ // 1. Prepare File and Language Info
18
+ const json = loadJson(file);
19
+ const fileName = path.basename(file);
20
+ const langName = path.basename(file, ".json");
21
+ const absoluteSourcePath = path.resolve(file); // Get full path for the report
22
+
23
+ // 2. Set Output Directory
24
+ const outDir = path.join(process.cwd(), "output", langName);
25
+ ensureDir(outDir);
26
+
27
+ console.log(`\n🔍 Analyzing [${fileName}]...`);
28
+
29
+ // 3. Deduplication Logic
30
+ const { cleaned, removedValues, updateMapping } = dedupeValues(json);
31
+ const duplicateCount = Object.keys(removedValues).length;
32
+
33
+ // 4. Enhanced Report Data (Matching the format of other commands)
34
+ const reportPath = path.join(outDir, `repetitive-keys-report.json`);
35
+ const mappingPath = path.join(outDir, `update-mapping.json`);
36
+
37
+ const reportData = {
38
+ timestamp: new Date().toISOString(),
39
+ config: {
40
+ sourceFile: fileName,
41
+ absoluteSourcePath: absoluteSourcePath,
42
+ isCleaned: !!options.fix,
43
+ isSorted: !!options.sort
44
+ },
45
+ summary: {
46
+ totalDuplicatesFound: duplicateCount
47
+ },
48
+ details: {
49
+ duplicates: removedValues,
50
+ mapping: updateMapping
51
+ }
52
+ };
53
+
54
+ // Write reports
55
+ writeJson(reportPath, reportData);
56
+
57
+ if (duplicateCount > 0) {
58
+ // We save a clean version of the mapping file for the code-updater to read easily
59
+ writeJson(mappingPath, { updateMapping });
60
+ }
61
+
62
+ // 5. FIX & SORT Operations
63
+ if (options.fix && duplicateCount > 0) {
64
+ let finalJson = cleaned;
65
+
66
+ if (options.sort) {
67
+ console.log(" Sorting keys alphabetically (recursive)...");
68
+ finalJson = sortJsonObject(cleaned);
69
+ }
70
+
71
+ const cleanedFile = path.join(outDir, `cleaned-${fileName}`);
72
+ writeJson(cleanedFile, finalJson);
73
+
74
+ console.log(` ✅ Success: Cleaned file created at ${cleanedFile}`);
75
+ } else if (duplicateCount > 0) {
76
+ console.log(` ℹ️ ${duplicateCount} duplicates found. Use --fix to clean them.`);
77
+ } else {
78
+ console.log(` ✅ Great! No repetitive values found.`);
79
+ }
80
+
81
+ console.log(` 📄 Report: ${reportPath}`);
82
+ if (duplicateCount > 0) {
83
+ console.log(` 🔗 Mapping: ${mappingPath} (Ready for apply-code-updates)`);
84
+ }
85
+ }
@@ -0,0 +1,115 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { getAllFiles, loadJson, writeJson, ensureDir } from "../utils/fileUtils.js";
4
+
5
+ /* ------------------ HELPERS ------------------ */
6
+
7
+ function extractKeysFromContent(content) {
8
+ const keys = new Set();
9
+ const patterns = [
10
+ /\b\$?t\s*\(\s*["'`]([^"'`$]+?)["'`]/g,
11
+ /\b(?:i18n|i18next)\.t\s*\(\s*["'`]([^"'`$]+?)["'`]/g,
12
+ /(?:id|i18nKey)=["'`]([^"'`]+?)["'`]/g,
13
+ /\[\s*["'`]([^"'`$]+?)["'`]\s*\]/g
14
+ ];
15
+ for (const pattern of patterns) {
16
+ let match;
17
+ while ((match = pattern.exec(content)) !== null) {
18
+ if (match[1] && match[1].trim().length > 0) keys.add(match[1]);
19
+ }
20
+ }
21
+ return keys;
22
+ }
23
+
24
+ function flattenKeys(obj, prefix = "", res = {}) {
25
+ for (const key in obj) {
26
+ const value = obj[key];
27
+ const newKey = prefix ? `${prefix}.${key}` : key;
28
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
29
+ flattenKeys(value, newKey, res);
30
+ } else {
31
+ res[newKey] = value;
32
+ }
33
+ }
34
+ return res;
35
+ }
36
+
37
+ function setDeep(obj, path, value) {
38
+ const parts = path.split(".");
39
+ let current = obj;
40
+ for (let i = 0; i < parts.length - 1; i++) {
41
+ const part = parts[i];
42
+ if (!current[part] || typeof current[part] !== "object") {
43
+ current[part] = {};
44
+ }
45
+ current = current[part];
46
+ }
47
+ const lastPart = parts[parts.length - 1];
48
+ if (!(lastPart in current)) {
49
+ current[lastPart] = value;
50
+ }
51
+ }
52
+
53
+ /* ------------------ MAIN COMMAND ------------------ */
54
+
55
+ export default async function findMissingTranslations(masterFile, projectDir, fix = false) {
56
+ if (!fs.existsSync(masterFile)) {
57
+ console.error(`❌ Error: Master language file not found at ${masterFile}`);
58
+ return;
59
+ }
60
+
61
+ const langFileName = path.basename(masterFile);
62
+ const langName = path.basename(masterFile, ".json");
63
+ const outDir = path.join(process.cwd(), "output", langName);
64
+
65
+ const previouslyCleaned = path.join(outDir, `cleaned-${langFileName}`);
66
+ const sourcePath = fs.existsSync(previouslyCleaned) ? previouslyCleaned : masterFile;
67
+
68
+ ensureDir(outDir);
69
+
70
+ const masterData = loadJson(sourcePath);
71
+ const masterKeys = new Set(Object.keys(flattenKeys(masterData)));
72
+ const files = getAllFiles(projectDir, [".js", ".jsx", ".ts", ".tsx", ".vue", ".html", ".svelte"]);
73
+ const usedInCode = new Set();
74
+
75
+ for (const file of files) {
76
+ const content = fs.readFileSync(file, "utf-8");
77
+ extractKeysFromContent(content).forEach(k => usedInCode.add(k));
78
+ }
79
+
80
+ const missing = Array.from(usedInCode).filter(key => !masterKeys.has(key));
81
+
82
+ if (fix && missing.length > 0) {
83
+ missing.forEach(key => setDeep(masterData, key, ""));
84
+ writeJson(path.join(outDir, `cleaned-${langFileName}`), masterData);
85
+ console.log(`✨ Success: Added ${missing.length} stub keys to cleaned file.`);
86
+ }
87
+
88
+ // --- UPDATED REPORT STRUCTURE ---
89
+ const reportPath = path.join(outDir, "missing-keys-report.json");
90
+ const reportData = {
91
+ timestamp: new Date().toISOString(),
92
+ config: {
93
+ comparedAgainst: sourcePath,
94
+ projectDirectory: path.resolve(projectDir), // Shows the full absolute path of the scanned code
95
+ totalFilesScanned: files.length
96
+ },
97
+ summary: {
98
+ uniqueKeysInCode: usedInCode.size,
99
+ missingKeysCount: missing.length
100
+ },
101
+ missingKeys: missing.sort()
102
+ };
103
+
104
+ writeJson(reportPath, reportData);
105
+
106
+ console.log(`──────────────────────────────────────────`);
107
+ if (missing.length === 0) {
108
+ console.log(`✅ Success: All keys found in code are defined.`);
109
+ } else {
110
+ console.log(`❌ Found ${missing.length} keys missing from ${path.basename(sourcePath)}:`);
111
+ missing.slice(0, 10).forEach(k => console.log(` • ${k}`));
112
+ if (missing.length > 10) console.log(` • ...and ${missing.length - 10} more.`);
113
+ console.log(`\n📂 Report saved to: ${reportPath}`);
114
+ }
115
+ }
@@ -0,0 +1,114 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { getAllFiles, ensureDir, writeJson, loadJson } from "../utils/fileUtils.js";
4
+
5
+ /* ------------------ HELPERS ------------------ */
6
+
7
+ function flattenKeys(obj, prefix = "", res = {}) {
8
+ for (const key in obj) {
9
+ const value = obj[key];
10
+ const newKey = prefix ? `${prefix}.${key}` : key;
11
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
12
+ flattenKeys(value, newKey, res);
13
+ } else {
14
+ res[newKey] = value;
15
+ }
16
+ }
17
+ return res;
18
+ }
19
+
20
+ function removeEmptyObjects(obj) {
21
+ for (const key in obj) {
22
+ if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {
23
+ removeEmptyObjects(obj[key]);
24
+ if (Object.keys(obj[key]).length === 0) {
25
+ delete obj[key];
26
+ }
27
+ }
28
+ }
29
+ }
30
+
31
+ function deepDelete(obj, keyPath) {
32
+ const parts = keyPath.split('.');
33
+ const last = parts.pop();
34
+ const target = parts.reduce((acc, part) => acc && acc[part], obj);
35
+ if (target && typeof target === 'object' && last in target) {
36
+ delete target[last];
37
+ }
38
+ }
39
+
40
+ /* ------------------ MAIN COMMAND ------------------ */
41
+
42
+ export default async function findUnusedKeys(langFilePath, projectPath, fix = false) {
43
+ if (!fs.existsSync(langFilePath)) {
44
+ console.error(`❌ Error: Language file not found at ${langFilePath}`);
45
+ return;
46
+ }
47
+
48
+ const langFileName = path.basename(langFilePath);
49
+ const langName = path.basename(langFilePath, ".json");
50
+ const outDir = path.join(process.cwd(), "output", langName);
51
+
52
+ const previouslyCleaned = path.join(outDir, `cleaned-${langFileName}`);
53
+ const sourcePath = fs.existsSync(previouslyCleaned) ? previouslyCleaned : langFilePath;
54
+
55
+ if (sourcePath === previouslyCleaned) {
56
+ console.log(`💡 Note: Using previously cleaned file as source: ${previouslyCleaned}`);
57
+ }
58
+
59
+ ensureDir(outDir);
60
+ console.log(`\n🔍 Scanning project for unused keys in [${langName}]...`);
61
+
62
+ const langData = loadJson(sourcePath);
63
+ const flatLangKeys = Object.keys(flattenKeys(langData));
64
+ const projectFiles = getAllFiles(projectPath, [".js", ".jsx", ".ts", ".tsx", ".vue", ".html", ".svelte"]);
65
+
66
+ let allProjectContent = "";
67
+ for (const file of projectFiles) {
68
+ allProjectContent += fs.readFileSync(file, "utf-8") + "\n";
69
+ }
70
+
71
+ const unused = flatLangKeys.filter(key => {
72
+ const escapedKey = key.replace(/\./g, "\\.");
73
+ const regex = new RegExp(`(['"\`])${escapedKey}(['"\`])`, "g");
74
+ return !regex.test(allProjectContent);
75
+ });
76
+
77
+ const totalKeys = flatLangKeys.length;
78
+ const unusedCount = unused.length;
79
+
80
+ if (fix && unusedCount > 0) {
81
+ unused.forEach(key => deepDelete(langData, key));
82
+ removeEmptyObjects(langData);
83
+ const cleanFilePath = path.join(outDir, `cleaned-${langFileName}`);
84
+ writeJson(cleanFilePath, langData);
85
+ console.log(`✨ Success: Updated cleaned file created at ${cleanFilePath}`);
86
+ }
87
+
88
+ // --- STANDARDIZED REPORT STRUCTURE ---
89
+ const reportPath = path.join(outDir, "unused-keys-report.json");
90
+ const reportData = {
91
+ timestamp: new Date().toISOString(),
92
+ config: {
93
+ sourceUsed: path.resolve(sourcePath),
94
+ projectDirectory: path.resolve(projectPath),
95
+ totalFilesScanned: projectFiles.length,
96
+ isCleaned: !!fix
97
+ },
98
+ summary: {
99
+ totalKeysInJSON: totalKeys,
100
+ unusedCount: unusedCount,
101
+ usedCount: totalKeys - unusedCount,
102
+ percentageUnused: totalKeys > 0 ? ((unusedCount / totalKeys) * 100).toFixed(2) + "%" : "0%"
103
+ },
104
+ details: {
105
+ unusedKeys: unused.sort()
106
+ }
107
+ };
108
+
109
+ writeJson(reportPath, reportData);
110
+
111
+ console.log(`──────────────────────────────────────────`);
112
+ console.log(`✅ Used: ${totalKeys - unusedCount} | ❌ Unused: ${unusedCount}`);
113
+ console.log(`📂 Report saved to: ${reportPath}`);
114
+ }
@@ -0,0 +1,48 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import syncLang from "./syncLang.js";
4
+
5
+ /**
6
+ * Syncs all JSON files in a directory with the master language file.
7
+ * Automatically excludes the master file from being treated as a target.
8
+ */
9
+ export default async function syncAll(masterFile, dirPath) {
10
+ if (!fs.existsSync(dirPath)) {
11
+ console.error(`❌ Error: Locales directory not found at ${dirPath}`);
12
+ return;
13
+ }
14
+
15
+ const masterAbsPath = path.resolve(masterFile);
16
+ const masterName = path.basename(masterFile);
17
+
18
+ // Find other .json files in the folder (excluding the master file)
19
+ const files = fs.readdirSync(dirPath).filter(f => {
20
+ const fullPath = path.resolve(path.join(dirPath, f));
21
+ return f.endsWith(".json") && fullPath !== masterAbsPath;
22
+ });
23
+
24
+ console.log(`\n🌍 Bulk Sync Started`);
25
+ console.log(`──────────────────────────────────`);
26
+ console.log(`Master Source: ${masterName}`);
27
+ console.log(`Target Count: ${files.length} files\n`);
28
+
29
+ const results = [];
30
+
31
+ for (const file of files) {
32
+ const fullPath = path.join(dirPath, file);
33
+ try {
34
+ await syncLang(masterFile, fullPath);
35
+ results.push({ lang: file, status: "✔ Success" });
36
+ } catch (error) {
37
+ results.push({ lang: file, status: "✖ Failed", error: error.message });
38
+ }
39
+ }
40
+
41
+ console.log(`──────────────────────────────────`);
42
+ console.log(`🏁 Sync All Completed!`);
43
+ results.forEach(res => {
44
+ const statusMsg = res.status.padEnd(12);
45
+ const errorDetail = res.error ? `(Error: ${res.error})` : "";
46
+ console.log(`${statusMsg} ${res.lang} ${errorDetail}`);
47
+ });
48
+ }
@@ -0,0 +1,74 @@
1
+ import path from "path";
2
+ import fs from "fs";
3
+ import { ensureDir, writeJson } from "../utils/fileUtils.js";
4
+ import { syncSingleLang } from "./syncLangCore.js";
5
+
6
+ /**
7
+ * Syncs a specific target translation file with the master file.
8
+ */
9
+ export default async function syncLang(masterFile, targetFile) {
10
+ if (!fs.existsSync(masterFile) || !fs.existsSync(targetFile)) {
11
+ console.error(`❌ Error: One or both translation files are missing.`);
12
+ return;
13
+ }
14
+
15
+ const masterName = path.basename(masterFile);
16
+ const targetName = path.basename(targetFile);
17
+ const langCode = path.basename(targetFile, '.json');
18
+
19
+ console.log(`🔄 Syncing: ${targetName} ← ${masterName}`);
20
+
21
+ // 1. Perform recursive synchronization
22
+ const { result, missing, extra } = syncSingleLang(masterFile, targetFile);
23
+
24
+ // 2. Set output directory (e.g., output/tr for tr.json)
25
+ const outDir = path.join(process.cwd(), "output", langCode);
26
+ ensureDir(outDir);
27
+
28
+ // 3. Write updated translation file
29
+ const outFile = path.join(outDir, targetName);
30
+ writeJson(outFile, result);
31
+
32
+ // Helper to count total leaf nodes (actual translation strings)
33
+ function countKeys(obj) {
34
+ let count = 0;
35
+ for (const key in obj) {
36
+ if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {
37
+ count += countKeys(obj[key]);
38
+ } else {
39
+ count++;
40
+ }
41
+ }
42
+ return count;
43
+ }
44
+
45
+ // 4. STANDARDIZED REPORT GENERATION
46
+ const reportData = {
47
+ timestamp: new Date().toISOString(),
48
+ config: {
49
+ masterSource: path.resolve(masterFile),
50
+ targetSource: path.resolve(targetFile),
51
+ masterFileName: masterName,
52
+ targetFileName: targetName
53
+ },
54
+ summary: {
55
+ missingKeysAdded: missing.length,
56
+ extraKeysRemoved: extra.length,
57
+ totalKeysInTarget: countKeys(result)
58
+ },
59
+ details: {
60
+ addedKeys: missing.sort(), // Alphabetical sort for readability
61
+ removedKeys: extra.sort() // Alphabetical sort for readability
62
+ }
63
+ };
64
+
65
+ const reportFile = path.join(outDir, `sync-report.json`);
66
+ writeJson(reportFile, reportData);
67
+
68
+ console.log(` ✔ File saved: ${outFile}`);
69
+ console.log(` ✔ Report: ${reportFile}`);
70
+ console.log(` • Added: ${missing.length} missing keys | Removed: ${extra.length} obsolete keys\n`);
71
+
72
+ // Return data for syncAll if needed
73
+ return { success: true, target: targetName, report: reportData };
74
+ }
@@ -0,0 +1,64 @@
1
+ import { loadJson } from "../utils/fileUtils.js";
2
+
3
+ /**
4
+ * Recursive function to synchronize nested objects.
5
+ * Deeply identifies missing keys and extra keys based on a master object.
6
+ */
7
+ function recursiveSync(master, target = {}, currentPath = "") {
8
+ const result = {};
9
+ const missing = [];
10
+ const extra = [];
11
+
12
+ // 1. Iterate through Master (Build structure and find missing keys)
13
+ for (const key in master) {
14
+ const fullPath = currentPath ? `${currentPath}.${key}` : key;
15
+ const masterValue = master[key];
16
+ const targetValue = target && typeof target === 'object' ? target[key] : undefined;
17
+
18
+ if (typeof masterValue === 'object' && masterValue !== null && !Array.isArray(masterValue)) {
19
+ // If it's an object, dive deeper
20
+ const sub = recursiveSync(masterValue, targetValue || {}, fullPath);
21
+ result[key] = sub.result;
22
+ missing.push(...sub.missing);
23
+ extra.push(...sub.extra);
24
+ } else {
25
+ // If it's a value: keep target value if it exists, otherwise add empty string
26
+ if (target && key in target) {
27
+ result[key] = target[key];
28
+ } else {
29
+ result[key] = ""; // New key added as empty
30
+ missing.push(fullPath);
31
+ }
32
+ }
33
+ }
34
+
35
+ // 2. Identify keys in Target that do not exist in Master (Extra/Obsolete keys)
36
+ if (target && typeof target === 'object') {
37
+ for (const key in target) {
38
+ const fullPath = currentPath ? `${currentPath}.${key}` : key;
39
+ if (!(key in master)) {
40
+ extra.push(fullPath);
41
+ // We don't add these to 'result', so they are effectively removed (Cleanup)
42
+ }
43
+ }
44
+ }
45
+
46
+ return { result, missing, extra };
47
+ }
48
+
49
+ /**
50
+ * Synchronizes a single target language file with the master file.
51
+ */
52
+ export function syncSingleLang(masterFile, targetFile) {
53
+ const master = loadJson(masterFile);
54
+ const target = loadJson(targetFile);
55
+
56
+ // Start the recursive synchronization process
57
+ const { result, missing, extra } = recursiveSync(master, target);
58
+
59
+ return {
60
+ result,
61
+ missing,
62
+ extra
63
+ };
64
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * 🔵 1. Duplicate KEY temizleme (JSON.parse sonrası)
3
+ * Not: Standart JSON.parse zaten son anahtarı alır.
4
+ * Bu fonksiyon manuel inşa edilen objeler için recursive çalışır.
5
+ */
6
+ export function dedupeKeys(obj, strategy = "first") {
7
+ const cleaned = {};
8
+ const removedKeys = {};
9
+
10
+ for (const key in obj) {
11
+ const value = obj[key];
12
+
13
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
14
+ const sub = dedupeKeys(value, strategy);
15
+ cleaned[key] = sub.cleaned;
16
+ if (Object.keys(sub.removedKeys).length > 0) {
17
+ removedKeys[key] = sub.removedKeys;
18
+ }
19
+ } else {
20
+ cleaned[key] = value;
21
+ }
22
+ }
23
+
24
+ return { cleaned, removedKeys };
25
+ }
26
+
27
+ /**
28
+ * 🔵 2. Duplicate VALUE temizleme (JSON Object Mode - Recursive)
29
+ * Tüm hiyerarşiyi tarar ve aynı değere sahip anahtarları bulur.
30
+ */
31
+ export function dedupeValues(obj) {
32
+ const removedValues = {};
33
+ const valueMap = {}; // value -> firstKeyPath
34
+ const updateMapping = {}; // duplicateKeyPath -> firstKeyPath
35
+
36
+ function walk(currentObj, path = "") {
37
+ const result = {};
38
+
39
+ for (const key in currentObj) {
40
+ const fullPath = path ? `${path}.${key}` : key;
41
+ const value = currentObj[key];
42
+
43
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
44
+ result[key] = walk(value, fullPath);
45
+ // Eğer iç obje boş kaldıysa temizle (isteğe bağlı)
46
+ if (Object.keys(result[key]).length === 0) delete result[key];
47
+ } else {
48
+ // Değer kontrolü (Normalizasyon: Boşlukları temizle)
49
+ const normalized = String(value).trim();
50
+
51
+ if (!Object.prototype.hasOwnProperty.call(valueMap, normalized)) {
52
+ valueMap[normalized] = fullPath;
53
+ result[key] = value;
54
+ } else {
55
+ removedValues[fullPath] = value;
56
+ updateMapping[fullPath] = valueMap[normalized];
57
+ // Duplicate olduğu için result'a eklemiyoruz
58
+ }
59
+ }
60
+ }
61
+ return result;
62
+ }
63
+
64
+ const cleaned = walk(obj);
65
+ return { cleaned, removedValues, updateMapping };
66
+ }
67
+
68
+ /**
69
+ * 🔵 3. Duplicate VALUE temizleme (RAW MODE)
70
+ * i18n dosyalarında yorumları korumak kritikse kullanılır.
71
+ * (Genelde düz yapılar için daha sağlıklıdır)
72
+ */
73
+ export function dedupeValuesRaw(rawText) {
74
+ const lines = rawText.split("\n");
75
+ const valueMap = {};
76
+ const removedValues = {};
77
+ const updateMapping = {};
78
+ const outputLines = [];
79
+
80
+ const keyValueRegex = /^\s*"([^"]+)"\s*:\s*"([\s\S]*?)"\s*(,)?\s*$/;
81
+
82
+ for (const line of lines) {
83
+ const match = line.match(keyValueRegex);
84
+ if (!match) {
85
+ outputLines.push(line);
86
+ continue;
87
+ }
88
+
89
+ const key = match[1];
90
+ const value = match[2];
91
+ const normalized = value.replace(/\s+/g, " ").trim();
92
+
93
+ if (!Object.prototype.hasOwnProperty.call(valueMap, normalized)) {
94
+ valueMap[normalized] = key;
95
+ outputLines.push(line);
96
+ } else {
97
+ removedValues[key] = value;
98
+ updateMapping[key] = valueMap[normalized];
99
+ }
100
+ }
101
+
102
+ return { cleanedText: outputLines.join("\n"), removedValues, updateMapping };
103
+ }
@@ -0,0 +1,59 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+
4
+ /**
5
+ * Loads and parses a JSON file.
6
+ */
7
+ export function loadJson(file) {
8
+ try {
9
+ return JSON.parse(fs.readFileSync(file, "utf-8"));
10
+ } catch (error) {
11
+ console.error(`❌ Error parsing JSON at ${file}:`, error.message);
12
+ return {};
13
+ }
14
+ }
15
+
16
+ /**
17
+ * Writes an object to a JSON file with 2-space indentation.
18
+ */
19
+ export function writeJson(file, data) {
20
+ fs.writeFileSync(file, JSON.stringify(data, null, 2), "utf-8");
21
+ }
22
+
23
+ /**
24
+ * Ensures a directory exists, creating it recursively if needed.
25
+ */
26
+ export function ensureDir(dir) {
27
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
28
+ }
29
+
30
+ /**
31
+ * Generates a standardized output path: output/[langName]
32
+ */
33
+ export function getOutputDir(file) {
34
+ const fileName = path.basename(file, path.extname(file));
35
+ const outputDir = path.join("output", fileName);
36
+ ensureDir(outputDir);
37
+ return outputDir;
38
+ }
39
+
40
+ /**
41
+ * Recursively scans a directory for files with specific extensions.
42
+ * Centralized here to keep commands dry (Don't Repeat Yourself).
43
+ */
44
+ export function getAllFiles(dir, exts, files = []) {
45
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
46
+ for (const entry of entries) {
47
+ const fullPath = path.join(dir, entry.name);
48
+
49
+ if (entry.isDirectory()) {
50
+ // Ignore unnecessary directories to speed up scanning
51
+ if (!["node_modules", ".git", "dist", "build", ".next", "out"].includes(entry.name)) {
52
+ getAllFiles(fullPath, exts, files);
53
+ }
54
+ } else if (exts.includes(path.extname(entry.name))) {
55
+ files.push(fullPath);
56
+ }
57
+ }
58
+ return files;
59
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * JSON objesini derinlemesine (recursive) alfabetik olarak sıralar.
3
+ */
4
+ export function sortJsonObject(obj) {
5
+ // Eğer gelen veri bir obje değilse veya null ise direkt döndür (recursion sonu)
6
+ if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
7
+ return obj;
8
+ }
9
+
10
+ // 1. Mevcut objenin anahtarlarını al ve sırala
11
+ const sortedKeys = Object.keys(obj).sort((a, b) => a.localeCompare(b));
12
+
13
+ const result = {};
14
+
15
+ // 2. Her bir anahtar için derinleş
16
+ for (const key of sortedKeys) {
17
+ const value = obj[key];
18
+
19
+ // Eğer değer bir objeyse, onun içine de girip sırala
20
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
21
+ result[key] = sortJsonObject(value);
22
+ } else {
23
+ result[key] = value;
24
+ }
25
+ }
26
+
27
+ return result;
28
+ }