@kopynator/cli 1.5.1 → 1.6.1

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.
@@ -1,43 +1,151 @@
1
1
  import chalk from 'chalk';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
+ import { detectFramework, getSourceRoot, getTranslationDir } from '../lib/project';
5
+ import {
6
+ findGlobalDuplicates,
7
+ flatten,
8
+ gitShowFile,
9
+ isProtectedByPrefix,
10
+ loadBaseline,
11
+ loadSafelist,
12
+ saveBaseline,
13
+ scanUsedKeys,
14
+ walkSourceFiles,
15
+ } from '../lib/i18n-guardian';
16
+
17
+ export interface CheckOptions {
18
+ baseRef?: string;
19
+ updateBaseline?: boolean;
20
+ all?: boolean;
21
+ }
22
+
23
+ export async function checkCommand(opts: CheckOptions = {}) {
24
+ if (process.env.KOPYNATOR_I18N_SKIP === '1') {
25
+ console.log(chalk.yellow('⚠️ KOPYNATOR_I18N_SKIP=1 — skipping i18n check.'));
26
+ return;
27
+ }
4
28
 
5
- export async function checkCommand() {
6
29
  console.log(chalk.bold.blue('\n🔍 Validating JSON translation files...\n'));
7
30
 
8
- const assetsDir = path.join(process.cwd(), 'src/assets/i18n'); // Default angular structural assumption for demo
31
+ const cwd = process.cwd();
32
+ const framework = detectFramework();
33
+ const assetsDir = getTranslationDir(framework);
9
34
 
10
35
  if (!fs.existsSync(assetsDir)) {
11
36
  console.log(chalk.red(`❌ Could not find directory: ${assetsDir}`));
12
37
  console.log(chalk.yellow('Make sure you are running this from your project root.'));
13
- return;
38
+ process.exit(1);
14
39
  }
15
40
 
16
41
  const files = fs.readdirSync(assetsDir).filter(f => f.endsWith('.json'));
17
42
 
18
43
  if (files.length === 0) {
19
- console.log(chalk.yellow('⚠️ No JSON files found in assets/i18n.'));
44
+ console.log(chalk.yellow('⚠️ No JSON files found in ' + assetsDir + '.'));
20
45
  return;
21
46
  }
22
47
 
23
- let hasErrors = false;
48
+ let hasJsonErrors = false;
49
+ const parsed: Record<string, any> = {};
24
50
 
25
51
  files.forEach(file => {
26
52
  try {
27
53
  const content = fs.readFileSync(path.join(assetsDir, file), 'utf-8');
28
- JSON.parse(content);
54
+ parsed[file] = JSON.parse(content);
29
55
  console.log(chalk.green(`✓ ${file} is valid JSON.`));
30
56
  } catch (e: any) {
31
- hasErrors = true;
57
+ hasJsonErrors = true;
32
58
  console.log(chalk.red(`❌ ${file} has syntax errors:`));
33
59
  console.log(chalk.red(` ${e.message}`));
34
60
  }
35
61
  });
36
62
 
37
- if (hasErrors) {
63
+ if (hasJsonErrors) {
38
64
  console.log(chalk.red('\n💥 Validation failed. Please fix the errors above.'));
39
65
  process.exit(1);
40
- } else {
41
- console.log(chalk.bold.green('\n✨ All files are valid! You are ready to go.'));
42
66
  }
67
+
68
+ console.log(chalk.bold.blue('\n🛡️ Guarding against broken references and duplicate keys...\n'));
69
+
70
+ // Defined keys: union across every locale file (a key only needs to exist somewhere).
71
+ const definedKeys = new Set<string>();
72
+ for (const file of files) {
73
+ Object.keys(flatten(parsed[file])).forEach(k => definedKeys.add(k));
74
+ }
75
+
76
+ // Reference locale for value-based duplicate detection: prefer en.json, else first alphabetically.
77
+ const refFile = files.includes('en.json') ? 'en.json' : files.sort()[0];
78
+ const refValues = flatten(parsed[refFile]);
79
+
80
+ // Scan app source for key usage + inline `kopynator-keys: prefix.*` declarations.
81
+ const sourceRoot = getSourceRoot(framework);
82
+ const sourceFiles = walkSourceFiles(sourceRoot);
83
+ const { used, dynamicPrefixes: magicPrefixes } = scanUsedKeys(sourceFiles);
84
+
85
+ const safelist = loadSafelist(cwd);
86
+ const protectedPrefixes = new Set([...(safelist.dynamicPrefixes || []), ...magicPrefixes]);
87
+
88
+ // --- Broken references: used in code, not defined in any locale file ---
89
+ const missing = [...used.keys()]
90
+ .filter(k => !definedKeys.has(k))
91
+ .filter(k => !isProtectedByPrefix(k, protectedPrefixes));
92
+
93
+ const baseline = loadBaseline(cwd);
94
+ const baselineSet = new Set(baseline.keys);
95
+ const freshMissing = missing.filter(k => !baselineSet.has(k));
96
+ const staleBaseline = baseline.keys.filter(k => !missing.includes(k));
97
+
98
+ if (opts.updateBaseline) {
99
+ saveBaseline(cwd, missing);
100
+ console.log(chalk.green(`✅ Baseline updated: ${missing.length} accepted missing key(s) written to kopynator.i18n-baseline.json`));
101
+ return;
102
+ }
103
+
104
+ if (opts.all) {
105
+ console.log(chalk.bold(`\nAll missing keys (${missing.length}):`));
106
+ missing
107
+ .slice()
108
+ .sort()
109
+ .forEach(k => {
110
+ const tag = baselineSet.has(k) ? chalk.gray('[baseline]') : chalk.red('[NEW]');
111
+ console.log(` ${tag} ${k} ${chalk.gray(`(used ${used.get(k)}x)`)}`);
112
+ });
113
+ }
114
+
115
+ // --- Duplicate-of-global values: only flag keys that are new since the base ref ---
116
+ const baseRef = opts.baseRef || 'master';
117
+ const oldRefContent = gitShowFile(cwd, baseRef, path.relative(cwd, path.join(assetsDir, refFile)));
118
+ const oldRefValues = oldRefContent ? flatten(JSON.parse(oldRefContent)) : {};
119
+ const duplicates = findGlobalDuplicates(refValues, new Set(used.keys())).filter(
120
+ d => oldRefValues[d.key] === undefined || oldRefValues[d.key] !== refValues[d.key]
121
+ );
122
+
123
+ let hasFailures = false;
124
+
125
+ if (freshMissing.length) {
126
+ hasFailures = true;
127
+ console.log(chalk.red(`\n❌ ${freshMissing.length} translation key(s) used in code but missing from ${assetsDir}:`));
128
+ freshMissing.sort().forEach(k => console.log(chalk.red(` - ${k} ${chalk.gray(`(used ${used.get(k)}x)`)}`)));
129
+ }
130
+
131
+ if (duplicates.length) {
132
+ hasFailures = true;
133
+ console.log(chalk.red(`\n❌ ${duplicates.length} key(s) duplicate an existing global value:`));
134
+ duplicates.forEach(d =>
135
+ console.log(chalk.red(` - "${d.key}" duplicates "${d.canonical}"${d.canonicalIsNew ? chalk.gray(' (not created yet — proposed)') : ''} → "${d.value}"`))
136
+ );
137
+ }
138
+
139
+ if (staleBaseline.length) {
140
+ console.log(
141
+ chalk.blue(`\nℹ️ i18n: ${staleBaseline.length} baseline key(s) no longer missing; regenerate: kopynator check --update-baseline`)
142
+ );
143
+ }
144
+
145
+ if (hasFailures) {
146
+ console.log(chalk.red('\n💥 i18n check failed. Fix the issues above, or run with --update-baseline to accept current debt.'));
147
+ process.exit(1);
148
+ }
149
+
150
+ console.log(chalk.bold.green(`\n✨ i18n check passed (${files.length} locale file(s); baseline=${baselineSet.size}; scanned ${sourceFiles.length} source file(s)).`));
43
151
  }
@@ -72,7 +72,7 @@ export async function limitsCommand(): Promise<void> {
72
72
  const url = `${base}/tokens/limits?token=${encodeURIComponent(resolved.apiKey)}`;
73
73
 
74
74
  try {
75
- const res = await fetch(url, { headers: { 'x-kopynator-version': '1.5.0' } });
75
+ const res = await fetch(url, { headers: { 'x-kopynator-version': '1.6.1' } });
76
76
  if (!res.ok) {
77
77
  const body = await res.text().catch(() => '');
78
78
  console.log(chalk.red(`\n✖ Could not fetch limits (HTTP ${res.status}). ${body}\n`));
@@ -3,6 +3,7 @@ import fs from 'fs';
3
3
  import path from 'path';
4
4
  import inquirer from 'inquirer';
5
5
  import ora from 'ora';
6
+ import { detectFramework, getTranslationDir } from '../lib/project';
6
7
 
7
8
  interface KopyConfig {
8
9
  apiKey: string;
@@ -16,54 +17,6 @@ interface SyncConfig {
16
17
  indent: '2' | '4' | 'tab';
17
18
  }
18
19
 
19
- /**
20
- * Detect the framework being used in the current project
21
- */
22
- function detectFramework(): 'Angular' | 'React' | 'Vue' | 'Other' {
23
- const angularJson = path.join(process.cwd(), 'angular.json');
24
- const packageJson = path.join(process.cwd(), 'package.json');
25
-
26
- // Check for Angular
27
- if (fs.existsSync(angularJson)) {
28
- return 'Angular';
29
- }
30
-
31
- // Check package.json for React/Vue
32
- if (fs.existsSync(packageJson)) {
33
- try {
34
- const pkg = JSON.parse(fs.readFileSync(packageJson, 'utf-8'));
35
- const deps = { ...pkg.dependencies, ...pkg.devDependencies };
36
-
37
- if (deps['@angular/core']) return 'Angular';
38
- if (deps['react']) return 'React';
39
- if (deps['vue']) return 'Vue';
40
- } catch (e) {
41
- // Ignore parse errors
42
- }
43
- }
44
-
45
- return 'Other';
46
- }
47
-
48
- /**
49
- * Get the translation directory path based on framework
50
- */
51
- function getTranslationDir(framework: string): string {
52
- switch (framework) {
53
- case 'Angular':
54
- return path.join(process.cwd(), 'src/assets/i18n');
55
- case 'React':
56
- // Common React patterns, can be customized later
57
- return path.join(process.cwd(), 'public/locales');
58
- case 'Vue':
59
- // Common Vue patterns, can be customized later
60
- return path.join(process.cwd(), 'public/locales');
61
- default:
62
- // Default fallback
63
- return path.join(process.cwd(), 'locales');
64
- }
65
- }
66
-
67
20
  /**
68
21
  * API key del proyecto primero (no hace falta en el comando), luego env para CI.
69
22
  * Order: app.config.ts -> app.module.ts -> kopynator.config.json -> KOPYNATOR_API_KEY
@@ -212,7 +165,7 @@ export async function syncCommand() {
212
165
  // Get sync configuration
213
166
  const syncConfig = await getSyncConfig();
214
167
 
215
- const baseUrl = config.baseUrl || 'https://api.kopynator.com/api/tokens';
168
+ const baseUrl = config.baseUrl || 'https://api.kopynator.com/tokens';
216
169
  const token = config.apiKey;
217
170
 
218
171
  const spinner = ora('📡 Connecting to Kopynator Cloud...').start();
@@ -223,7 +176,7 @@ export async function syncCommand() {
223
176
  const languagesUrl = `${baseUrl}/languages?token=${token}`;
224
177
  const languagesResponse = await fetch(languagesUrl, {
225
178
  headers: {
226
- 'x-kopynator-version': '1.4.1'
179
+ 'x-kopynator-version': '1.6.1'
227
180
  }
228
181
  });
229
182
  if (!languagesResponse.ok) {
@@ -249,7 +202,7 @@ export async function syncCommand() {
249
202
  try {
250
203
  const translationResponse = await fetch(fetchUrl, {
251
204
  headers: {
252
- 'x-kopynator-version': '1.4.1'
205
+ 'x-kopynator-version': '1.6.1'
253
206
  }
254
207
  });
255
208
 
@@ -164,7 +164,7 @@ export async function uploadCommand(options: { file?: string; lang?: string }) {
164
164
  headers: {
165
165
  'Content-Type': 'application/json',
166
166
  'x-api-token': token,
167
- 'x-kopynator-version': '1.4.1',
167
+ 'x-kopynator-version': '1.6.1',
168
168
  },
169
169
  body: JSON.stringify({ lang, data: batches[i] }),
170
170
  });
package/src/index.ts CHANGED
@@ -8,7 +8,7 @@ const program = new Command();
8
8
  program
9
9
  .name('kopynator')
10
10
  .description('Kopynator CLI - Manage your i18n workflow')
11
- .version('1.5.1', '-v, --version')
11
+ .version('1.6.1', '-v, --version')
12
12
  .helpOption('-h, --help', 'Display help for command')
13
13
  .addHelpText('beforeAll', chalk.blue('\n👋 Welcome to Kopynator CLI!\n'));
14
14
 
@@ -19,8 +19,11 @@ program
19
19
 
20
20
  program
21
21
  .command('check')
22
- .description('Validate your local JSON translation files')
23
- .action(checkCommand);
22
+ .description('Validate translation files: JSON syntax, broken references and duplicate global keys')
23
+ .option('--base-ref <ref>', 'Git ref to diff against when detecting new duplicate keys', 'master')
24
+ .option('--update-baseline', 'Accept all currently-missing keys as backlog (writes kopynator.i18n-baseline.json)')
25
+ .option('--all', 'List every missing key, including ones already accepted in the baseline')
26
+ .action((opts) => checkCommand({ baseRef: opts.baseRef, updateBaseline: opts.updateBaseline, all: opts.all }));
24
27
 
25
28
  program
26
29
  .command('sync')
@@ -0,0 +1,190 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { execFileSync } from 'child_process';
4
+
5
+ export interface SafelistConfig {
6
+ dynamicPrefixes?: string[];
7
+ }
8
+
9
+ export interface BaselineConfig {
10
+ keys: string[];
11
+ }
12
+
13
+ export interface UsedKey {
14
+ key: string;
15
+ count: number;
16
+ }
17
+
18
+ export interface DuplicateFinding {
19
+ key: string;
20
+ value: string;
21
+ canonical: string;
22
+ canonicalIsNew: boolean;
23
+ }
24
+
25
+ const SAFELIST_FILE = 'kopynator.i18n-safelist.json';
26
+ const BASELINE_FILE = 'kopynator.i18n-baseline.json';
27
+ const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.html', '.vue'];
28
+ const EXCLUDED_DIRS = new Set(['node_modules', 'dist', 'build', '.git', '.angular', 'coverage', '.next', '.nuxt', 'out']);
29
+
30
+ const KEY = `[\\w.:-]+`;
31
+ const USE_PATTERNS: { re: RegExp; group: number }[] = [
32
+ // {{ 'key' | kopy }} — Angular pipe
33
+ { re: new RegExp(`(['"\`])(${KEY})\\1\\s*\\|\\s*kopy`, 'g'), group: 2 },
34
+ // [kopy]="'key'" — Angular directive with a string literal binding
35
+ { re: new RegExp(`\\[kopy\\]\\s*=\\s*"'(${KEY})'"`, 'g'), group: 1 },
36
+ // .translate('key') / .t('key') — @kopynator/core & @kopynator/react API
37
+ { re: new RegExp(`\\.(?:translate|t)\\(\\s*(['"\`])(${KEY})\\1`, 'g'), group: 2 },
38
+ ];
39
+
40
+ const MAGIC_RE = /kopynator-keys\s*:\s*([^\n]+)/g;
41
+
42
+ /** Recursively flattens a nested translation object into dot-notation keys. */
43
+ export function flatten(obj: Record<string, any>, prefix = ''): Record<string, string> {
44
+ const out: Record<string, string> = {};
45
+ for (const [k, v] of Object.entries(obj || {})) {
46
+ const key = prefix ? `${prefix}.${k}` : k;
47
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
48
+ Object.assign(out, flatten(v, key));
49
+ } else {
50
+ out[key] = String(v);
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+
56
+ export function walkSourceFiles(rootDir: string): string[] {
57
+ if (!fs.existsSync(rootDir)) return [];
58
+ const results: string[] = [];
59
+
60
+ function walk(dir: string) {
61
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
62
+ if (entry.isDirectory()) {
63
+ if (EXCLUDED_DIRS.has(entry.name)) continue;
64
+ walk(path.join(dir, entry.name));
65
+ } else if (SOURCE_EXTENSIONS.includes(path.extname(entry.name))) {
66
+ results.push(path.join(dir, entry.name));
67
+ }
68
+ }
69
+ }
70
+
71
+ walk(rootDir);
72
+ return results;
73
+ }
74
+
75
+ /** Scans source files for translation key usage and inline `kopynator-keys:` dynamic-prefix declarations. */
76
+ export function scanUsedKeys(files: string[]): { used: Map<string, number>; dynamicPrefixes: Set<string> } {
77
+ const used = new Map<string, number>();
78
+ const dynamicPrefixes = new Set<string>();
79
+
80
+ for (const file of files) {
81
+ const content = fs.readFileSync(file, 'utf-8');
82
+
83
+ for (const { re, group } of USE_PATTERNS) {
84
+ re.lastIndex = 0;
85
+ let match: RegExpExecArray | null;
86
+ while ((match = re.exec(content))) {
87
+ const key = match[group];
88
+ used.set(key, (used.get(key) || 0) + 1);
89
+ }
90
+ }
91
+
92
+ MAGIC_RE.lastIndex = 0;
93
+ let magicMatch: RegExpExecArray | null;
94
+ while ((magicMatch = MAGIC_RE.exec(content))) {
95
+ for (const raw of magicMatch[1].split(',')) {
96
+ const prefix = raw.trim().replace(/\*$/, '');
97
+ if (prefix) dynamicPrefixes.add(prefix);
98
+ }
99
+ }
100
+ }
101
+
102
+ return { used, dynamicPrefixes };
103
+ }
104
+
105
+ export function loadSafelist(cwd: string): SafelistConfig {
106
+ const p = path.join(cwd, SAFELIST_FILE);
107
+ if (!fs.existsSync(p)) return { dynamicPrefixes: [] };
108
+ try {
109
+ return JSON.parse(fs.readFileSync(p, 'utf-8'));
110
+ } catch {
111
+ return { dynamicPrefixes: [] };
112
+ }
113
+ }
114
+
115
+ export function loadBaseline(cwd: string): BaselineConfig {
116
+ const p = path.join(cwd, BASELINE_FILE);
117
+ if (!fs.existsSync(p)) return { keys: [] };
118
+ try {
119
+ const parsed = JSON.parse(fs.readFileSync(p, 'utf-8'));
120
+ return { keys: Array.isArray(parsed.keys) ? parsed.keys : [] };
121
+ } catch {
122
+ return { keys: [] };
123
+ }
124
+ }
125
+
126
+ export function saveBaseline(cwd: string, keys: string[]): void {
127
+ const p = path.join(cwd, BASELINE_FILE);
128
+ fs.writeFileSync(p, JSON.stringify({ keys: keys.sort() }, null, 2) + '\n');
129
+ }
130
+
131
+ export function isProtectedByPrefix(key: string, prefixes: Set<string> | string[]): boolean {
132
+ for (const prefix of prefixes) {
133
+ if (key.startsWith(prefix)) return true;
134
+ }
135
+ return false;
136
+ }
137
+
138
+ /** Reads a file's content at a given git ref, or null if git/ref/file is unavailable. */
139
+ export function gitShowFile(cwd: string, ref: string, relPath: string): string | null {
140
+ try {
141
+ return execFileSync('git', ['show', `${ref}:${relPath}`], { cwd, stdio: ['pipe', 'pipe', 'ignore'] }).toString('utf-8');
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+
147
+ export function slugify(value: string, maxLen = 40): string {
148
+ return String(value)
149
+ .replace(/<[^>]+>/g, '')
150
+ .replace(/\{\{[^}]+\}\}/g, '')
151
+ .normalize('NFD')
152
+ .replace(/[̀-ͯ]/g, '')
153
+ .toLowerCase()
154
+ .replace(/[^a-z0-9]+/g, '_')
155
+ .replace(/^_+|_+$/g, '')
156
+ .slice(0, maxLen) || 'value';
157
+ }
158
+
159
+ /** Picks the canonical key among a group of keys sharing the same value: prefers a category-segmented `global.*` key, else the shortest `global.*`, else null (no existing canonical). */
160
+ export function pickGlobal(keys: string[]): string | null {
161
+ const globals = keys.filter(k => k.startsWith('global.'));
162
+ if (!globals.length) return null;
163
+ const segmented = globals.filter(k => /^global\.(status|error|action)\./.test(k));
164
+ const pool = segmented.length ? segmented : globals;
165
+ return pool.reduce((shortest, k) => (k.length < shortest.length ? k : shortest), pool[0]);
166
+ }
167
+
168
+ /** Finds keys whose value duplicates an existing (or proposable) `global.*` canonical, restricted to keys actually used in source. */
169
+ export function findGlobalDuplicates(values: Record<string, string>, usedKeys: Set<string>): DuplicateFinding[] {
170
+ const byValue = new Map<string, string[]>();
171
+ for (const [key, value] of Object.entries(values)) {
172
+ const trimmed = value.trim();
173
+ if (!trimmed) continue;
174
+ const group = byValue.get(trimmed) || [];
175
+ group.push(key);
176
+ byValue.set(trimmed, group);
177
+ }
178
+
179
+ const findings: DuplicateFinding[] = [];
180
+ for (const [value, keys] of byValue) {
181
+ if (keys.length < 2) continue;
182
+ const canonical = pickGlobal(keys) || `global.${slugify(value)}`;
183
+ for (const key of keys) {
184
+ if (key === canonical) continue;
185
+ if (!usedKeys.has(key)) continue;
186
+ findings.push({ key, value, canonical, canonicalIsNew: !keys.includes(canonical) });
187
+ }
188
+ }
189
+ return findings;
190
+ }
@@ -0,0 +1,60 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ export type Framework = 'Angular' | 'React' | 'Vue' | 'Other';
5
+
6
+ /**
7
+ * Detect the framework being used in the current project
8
+ */
9
+ export function detectFramework(): Framework {
10
+ const angularJson = path.join(process.cwd(), 'angular.json');
11
+ const packageJson = path.join(process.cwd(), 'package.json');
12
+
13
+ if (fs.existsSync(angularJson)) {
14
+ return 'Angular';
15
+ }
16
+
17
+ if (fs.existsSync(packageJson)) {
18
+ try {
19
+ const pkg = JSON.parse(fs.readFileSync(packageJson, 'utf-8'));
20
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
21
+
22
+ if (deps['@angular/core']) return 'Angular';
23
+ if (deps['react']) return 'React';
24
+ if (deps['vue']) return 'Vue';
25
+ } catch (e) {
26
+ // Ignore parse errors
27
+ }
28
+ }
29
+
30
+ return 'Other';
31
+ }
32
+
33
+ /**
34
+ * Get the translation directory path based on framework
35
+ */
36
+ export function getTranslationDir(framework: Framework): string {
37
+ switch (framework) {
38
+ case 'Angular':
39
+ return path.join(process.cwd(), 'src/assets/i18n');
40
+ case 'React':
41
+ case 'Vue':
42
+ return path.join(process.cwd(), 'public/locales');
43
+ default:
44
+ return path.join(process.cwd(), 'locales');
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Get the app source root to scan for translation key usage.
50
+ */
51
+ export function getSourceRoot(framework: Framework): string {
52
+ switch (framework) {
53
+ case 'Angular':
54
+ case 'React':
55
+ case 'Vue':
56
+ return path.join(process.cwd(), 'src');
57
+ default:
58
+ return process.cwd();
59
+ }
60
+ }