@kopynator/cli 1.5.0 → 1.6.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 +187 -13
- package/dist/index.js +392 -91
- package/package.json +1 -2
- package/src/commands/check.ts +118 -10
- package/src/commands/index.ts +1 -0
- package/src/commands/limits.ts +110 -0
- package/src/commands/sync.ts +1 -48
- package/src/index.ts +12 -4
- package/src/lib/i18n-guardian.ts +190 -0
- package/src/lib/project.ts +60 -0
package/src/commands/check.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
44
|
+
console.log(chalk.yellow('⚠️ No JSON files found in ' + assetsDir + '.'));
|
|
20
45
|
return;
|
|
21
46
|
}
|
|
22
47
|
|
|
23
|
-
let
|
|
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
|
-
|
|
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 (
|
|
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
|
}
|
package/src/commands/index.ts
CHANGED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
interface ResolvedKey {
|
|
6
|
+
apiKey: string;
|
|
7
|
+
baseUrl?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolve the API key the same way `sync`/`upload` do: config JSON first, then env.
|
|
12
|
+
*/
|
|
13
|
+
function resolveApiKey(): ResolvedKey | null {
|
|
14
|
+
const cwd = process.cwd();
|
|
15
|
+
|
|
16
|
+
const fromJson = (p: string): ResolvedKey | null => {
|
|
17
|
+
try {
|
|
18
|
+
if (fs.existsSync(p)) {
|
|
19
|
+
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
20
|
+
if (cfg && cfg.apiKey) return { apiKey: cfg.apiKey, baseUrl: cfg.baseUrl };
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
/* ignore malformed config */
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const fromAppFile = (p: string): ResolvedKey | null => {
|
|
29
|
+
try {
|
|
30
|
+
if (fs.existsSync(p)) {
|
|
31
|
+
const content = fs.readFileSync(p, 'utf-8');
|
|
32
|
+
const match = content.match(/apiKey:\s*['"]([^'"]+)['"]/);
|
|
33
|
+
if (match) return { apiKey: match[1] };
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
/* ignore */
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
fromJson(path.join(cwd, 'kopynator.config.json')) ||
|
|
43
|
+
fromJson(path.join(cwd, 'src/kopynator.config.json')) ||
|
|
44
|
+
fromAppFile(path.join(cwd, 'src/app/app.config.ts')) ||
|
|
45
|
+
fromAppFile(path.join(cwd, 'src/app/app.module.ts')) ||
|
|
46
|
+
(process.env.KOPYNATOR_API_KEY
|
|
47
|
+
? { apiKey: process.env.KOPYNATOR_API_KEY.trim(), baseUrl: process.env.KOPYNATOR_BASE_URL?.trim() }
|
|
48
|
+
: null)
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function formatLimit(value: number): string {
|
|
53
|
+
return value === -1 ? 'Unlimited' : String(value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `kopynator limits` — show the current plan, its per-organization limits and usage.
|
|
58
|
+
*/
|
|
59
|
+
export async function limitsCommand(): Promise<void> {
|
|
60
|
+
const resolved = resolveApiKey();
|
|
61
|
+
if (!resolved) {
|
|
62
|
+
console.log(chalk.red('\n✖ No API key found.'));
|
|
63
|
+
console.log(chalk.gray(' Add it to kopynator.config.json or set KOPYNATOR_API_KEY.\n'));
|
|
64
|
+
process.exit(1);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Endpoint lives outside the /api prefix (like /tokens/fetch), so normalise a trailing /api away.
|
|
69
|
+
const base = (resolved.baseUrl || process.env.KOPYNATOR_BASE_URL || 'https://api.kopynator.com')
|
|
70
|
+
.replace(/\/+$/, '')
|
|
71
|
+
.replace(/\/api$/, '');
|
|
72
|
+
const url = `${base}/tokens/limits?token=${encodeURIComponent(resolved.apiKey)}`;
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const res = await fetch(url, { headers: { 'x-kopynator-version': '1.5.0' } });
|
|
76
|
+
if (!res.ok) {
|
|
77
|
+
const body = await res.text().catch(() => '');
|
|
78
|
+
console.log(chalk.red(`\n✖ Could not fetch limits (HTTP ${res.status}). ${body}\n`));
|
|
79
|
+
process.exit(1);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const data: any = await res.json();
|
|
84
|
+
const limits = data.limits || {};
|
|
85
|
+
const usage = data.usage || {};
|
|
86
|
+
|
|
87
|
+
const row = (label: string, used: number | undefined, limit: number): string => {
|
|
88
|
+
const usedPart = used === undefined ? '' : `${used} / `;
|
|
89
|
+
const text = ` ${label.padEnd(9)} ${usedPart}${formatLimit(limit)}`;
|
|
90
|
+
const reached = limit !== -1 && used !== undefined && used >= limit;
|
|
91
|
+
return reached ? chalk.red(`${text} (limit reached — upgrade your plan)`) : chalk.green(text);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
console.log('');
|
|
95
|
+
console.log(chalk.bold('📊 Kopynator — plan limits (per organization)'));
|
|
96
|
+
const planLabel = chalk.cyan((data.plan || 'free').toUpperCase());
|
|
97
|
+
const statusLabel = data.active
|
|
98
|
+
? chalk.green('active')
|
|
99
|
+
: chalk.yellow('inactive/expired → free limits apply');
|
|
100
|
+
console.log(` Plan: ${planLabel} (${statusLabel})`);
|
|
101
|
+
console.log('');
|
|
102
|
+
console.log(row('Projects', usage.projects, limits.projects));
|
|
103
|
+
console.log(row('Keys', usage.keys, limits.keys));
|
|
104
|
+
console.log(row('Members', undefined, limits.members));
|
|
105
|
+
console.log('');
|
|
106
|
+
} catch (error: any) {
|
|
107
|
+
console.log(chalk.red(`\n✖ Request failed: ${error?.message || error}\n`));
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
}
|
package/src/commands/sync.ts
CHANGED
|
@@ -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
|
package/src/index.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
|
-
import { initCommand, checkCommand, syncCommand, uploadCommand } from './commands';
|
|
4
|
+
import { initCommand, checkCommand, syncCommand, uploadCommand, limitsCommand } from './commands';
|
|
5
5
|
|
|
6
6
|
const program = new Command();
|
|
7
7
|
|
|
8
8
|
program
|
|
9
9
|
.name('kopynator')
|
|
10
10
|
.description('Kopynator CLI - Manage your i18n workflow')
|
|
11
|
-
.version('1.
|
|
11
|
+
.version('1.5.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
|
|
23
|
-
.
|
|
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')
|
|
@@ -34,6 +37,11 @@ program
|
|
|
34
37
|
.option('-l, --lang <code>', 'Language code (default: inferred from filename)')
|
|
35
38
|
.action((opts) => uploadCommand({ file: opts.file, lang: opts.lang }));
|
|
36
39
|
|
|
40
|
+
program
|
|
41
|
+
.command('limits')
|
|
42
|
+
.description('Show your plan limits and current usage (projects, keys, members)')
|
|
43
|
+
.action(limitsCommand);
|
|
44
|
+
|
|
37
45
|
program
|
|
38
46
|
.command('help')
|
|
39
47
|
.description('Show help for all commands')
|
|
@@ -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
|
+
}
|