@mk-kit/ui 0.56.0 → 0.57.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.
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env node
2
+ // mk-translate — hygiene for the JSON dictionaries behind @mk-kit/ui/translate.
3
+ //
4
+ // mk-translate check [options]
5
+ //
6
+ // --dir <path> directory with <lang>.json files (default: src/assets/i18n)
7
+ // --base <lang> the source-of-truth language (default: the first of --langs, else "pl" if present, else the first file)
8
+ // --langs <a,b,c> languages to check (default: every <lang>.json in --dir)
9
+ // --src <path> source root(s) to scan, repeatable (default: src)
10
+ // --ext <ts,html> file extensions to scan (default: ts,html)
11
+ // --prefix <p> extra dynamic prefix, repeatable (keys starting with it count as used)
12
+ // --list print every unused key
13
+ // --fix delete unused keys from every language file, then report
14
+ // --json machine-readable report on stdout
15
+ //
16
+ // A key counts as USED when it appears as a string literal in the scanned
17
+ // sources (specs excluded), or when it starts with a DYNAMIC PREFIX the code
18
+ // builds at runtime — 'Day' + n, `ns.${key}`, 'checkout.' + status(),
19
+ // `| translatePlural: 'ns.key'` (which resolves ns.key.one|few|many|other).
20
+ // Parity: every used key must exist in every language; no language may carry
21
+ // keys the base lacks. Exit code 1 on any finding.
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { pathToFileURL } from 'node:url';
25
+
26
+ export function flatten(obj, prefix = '', out = {}) {
27
+ for (const [k, v] of Object.entries(obj ?? {})) {
28
+ const key = prefix ? `${prefix}.${k}` : k;
29
+ if (v && typeof v === 'object' && !Array.isArray(v)) flatten(v, key, out);
30
+ else out[key] = v;
31
+ }
32
+ return out;
33
+ }
34
+
35
+ function deleteKey(obj, parts) {
36
+ const [head, ...rest] = parts;
37
+ if (!(head in obj)) return;
38
+ if (rest.length) {
39
+ deleteKey(obj[head], rest);
40
+ if (obj[head] && typeof obj[head] === 'object' && Object.keys(obj[head]).length === 0) delete obj[head];
41
+ } else {
42
+ delete obj[head];
43
+ }
44
+ }
45
+
46
+ function walk(root, exts, files = []) {
47
+ if (!fs.existsSync(root)) return files;
48
+ for (const e of fs.readdirSync(root, { withFileTypes: true })) {
49
+ const p = path.join(root, e.name);
50
+ if (e.isDirectory()) {
51
+ if (!['node_modules', '.angular', 'dist', '.git'].includes(e.name)) walk(p, exts, files);
52
+ } else if (exts.some((x) => p.endsWith(`.${x}`)) && !/\.spec\.(ts|js|mts)$/.test(p)) {
53
+ files.push(p);
54
+ }
55
+ }
56
+ return files;
57
+ }
58
+
59
+ /** Keys and prefixes referenced by the sources. */
60
+ export function scanSources(src) {
61
+ const literals = new Set();
62
+ for (const m of src.matchAll(/['"`]([A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)*)['"`]/g)) literals.add(m[1]);
63
+ const prefixes = new Set();
64
+ // 'checkout.' + status() / 'Day' + n
65
+ for (const m of src.matchAll(/['"]([A-Za-z][A-Za-z0-9_.-]*)['"]\s*\+/g)) {
66
+ if (/[A-Z.]/.test(m[1]) || m[1].length > 3) prefixes.add(m[1]);
67
+ }
68
+ // `ns.${key}`
69
+ for (const m of src.matchAll(/`([A-Za-z][A-Za-z0-9_.-]*)\$\{/g)) {
70
+ if (m[1].includes('.') || /^Day/.test(m[1])) prefixes.add(m[1]);
71
+ }
72
+ // 'ns' + '.' + key
73
+ for (const m of src.matchAll(/['"]([A-Za-z][A-Za-z0-9_.-]*)['"]\s*\+\s*['"]\.['"]\s*\+/g)) prefixes.add(m[1] + '.');
74
+ // plural(keyBase, …) / | translatePlural: 'keyBase' → keyBase.one|few|many|other
75
+ for (const m of src.matchAll(/\|\s*translatePlural\s*:\s*['"]([A-Za-z][A-Za-z0-9_.-]*)['"]/g)) prefixes.add(m[1] + '.');
76
+ for (const m of src.matchAll(/\.plural\(\s*['"]([A-Za-z][A-Za-z0-9_.-]*)['"]/g)) prefixes.add(m[1] + '.');
77
+ return { literals, prefixes };
78
+ }
79
+
80
+ /**
81
+ * Analyse dictionaries against sources. Pure: returns the findings and the
82
+ * parsed trees (so `--fix` can rewrite), touches no file.
83
+ */
84
+ export function analyze({ dir, langs, base, srcRoots, exts, extraPrefixes = [] }) {
85
+ const raw = Object.fromEntries(
86
+ langs.map((l) => [l, JSON.parse(fs.readFileSync(path.join(dir, `${l}.json`), 'utf8'))]),
87
+ );
88
+ const dicts = Object.fromEntries(langs.map((l) => [l, flatten(raw[l])]));
89
+ const keys = new Set(Object.keys(dicts[base]));
90
+ const files = srcRoots.flatMap((r) => walk(r, exts));
91
+ const src = files.map((f) => fs.readFileSync(f, 'utf8')).join('\n');
92
+ const { literals, prefixes } = scanSources(src);
93
+ for (const p of extraPrefixes) prefixes.add(p);
94
+ const prefixList = [...prefixes];
95
+ const used = new Set();
96
+ for (const k of keys) if (literals.has(k) || prefixList.some((p) => k.startsWith(p))) used.add(k);
97
+ const others = langs.filter((l) => l !== base);
98
+ return {
99
+ base,
100
+ langs,
101
+ files: files.length,
102
+ total: keys.size,
103
+ used: used.size,
104
+ literals: literals.size,
105
+ prefixes: prefixList.length,
106
+ unused: [...keys].filter((k) => !used.has(k)).sort(),
107
+ missing: Object.fromEntries(others.map((l) => [l, [...used].filter((k) => !(k in dicts[l])).sort()])),
108
+ extra: Object.fromEntries(others.map((l) => [l, Object.keys(dicts[l]).filter((k) => !keys.has(k)).sort()])),
109
+ raw,
110
+ };
111
+ }
112
+
113
+ /** Delete `keys` from every language file (pretty-printed, 2 spaces, trailing newline). */
114
+ export function removeKeys(dir, langs, raw, keys) {
115
+ for (const l of langs) {
116
+ for (const k of keys) deleteKey(raw[l], k.split('.'));
117
+ fs.writeFileSync(path.join(dir, `${l}.json`), JSON.stringify(raw[l], null, 2) + '\n');
118
+ }
119
+ }
120
+
121
+ function parseArgs(argv) {
122
+ const opts = { dir: 'src/assets/i18n', src: [], ext: 'ts,html', prefix: [], list: false, fix: false, json: false };
123
+ for (let i = 0; i < argv.length; i++) {
124
+ const a = argv[i];
125
+ const next = () => argv[++i];
126
+ if (a === '--dir') opts.dir = next();
127
+ else if (a === '--base') opts.base = next();
128
+ else if (a === '--langs') opts.langs = next().split(',').map((s) => s.trim()).filter(Boolean);
129
+ else if (a === '--src') opts.src.push(next());
130
+ else if (a === '--ext') opts.ext = next();
131
+ else if (a === '--prefix') opts.prefix.push(next());
132
+ else if (a === '--list') opts.list = true;
133
+ else if (a === '--fix') opts.fix = true;
134
+ else if (a === '--json') opts.json = true;
135
+ else if (a === '--help' || a === '-h') opts.help = true;
136
+ else if (a.startsWith('--')) throw new Error(`Unknown option ${a}`);
137
+ }
138
+ if (!opts.src.length) opts.src = ['src'];
139
+ return opts;
140
+ }
141
+
142
+ function usage() {
143
+ const text = fs.readFileSync(new URL(import.meta.url), 'utf8');
144
+ return text.split('\n').filter((l) => l.startsWith('//')).slice(1).map((l) => l.replace(/^\/\/ ?/, '')).join('\n');
145
+ }
146
+
147
+ export function main(argv = process.argv.slice(2)) {
148
+ const [command, ...rest] = argv;
149
+ if (!command || command === '--help' || command === '-h') {
150
+ console.log(usage());
151
+ return 0;
152
+ }
153
+ if (command !== 'check') {
154
+ console.error(`mk-translate: unknown command "${command}" (try: check)`);
155
+ return 2;
156
+ }
157
+ const opts = parseArgs(rest);
158
+ if (opts.help) {
159
+ console.log(usage());
160
+ return 0;
161
+ }
162
+ if (!fs.existsSync(opts.dir)) {
163
+ console.error(`mk-translate: no such directory ${opts.dir}`);
164
+ return 2;
165
+ }
166
+ const available = fs.readdirSync(opts.dir).filter((f) => /^[a-z]{2}(-[A-Z]{2})?\.json$/.test(f)).map((f) => f.replace(/\.json$/, '')).sort();
167
+ const langs = opts.langs ?? available;
168
+ const missingFiles = langs.filter((l) => !available.includes(l));
169
+ if (!langs.length || missingFiles.length) {
170
+ console.error(`mk-translate: no dictionary for ${missingFiles.join(', ') || 'any language'} in ${opts.dir}`);
171
+ return 2;
172
+ }
173
+ const base = opts.base ?? (opts.langs ? langs[0] : langs.includes('pl') ? 'pl' : langs[0]);
174
+ const report = analyze({
175
+ dir: opts.dir,
176
+ langs: [base, ...langs.filter((l) => l !== base)],
177
+ base,
178
+ srcRoots: opts.src,
179
+ exts: opts.ext.split(',').map((s) => s.trim()),
180
+ extraPrefixes: opts.prefix,
181
+ });
182
+
183
+ if (opts.fix && report.unused.length) {
184
+ removeKeys(opts.dir, report.langs, report.raw, report.unused);
185
+ if (!opts.json) console.log(`✓ removed ${report.unused.length} unused key(s) from ${report.langs.join('/')}`);
186
+ report.fixed = report.unused;
187
+ report.unused = [];
188
+ }
189
+
190
+ const others = report.langs.filter((l) => l !== base);
191
+ const bad =
192
+ report.unused.length > 0 || others.some((l) => report.missing[l].length || report.extra[l].length);
193
+
194
+ if (opts.json) {
195
+ const { raw: _raw, ...rest } = report;
196
+ console.log(JSON.stringify({ ...rest, ok: !bad }, null, 2));
197
+ return bad ? 1 : 0;
198
+ }
199
+ console.log(
200
+ `i18n: ${report.total} keys in ${base} · used ${report.used} (${report.literals} literal sites, ${report.prefixes} dynamic prefixes, ${report.files} files)`,
201
+ );
202
+ if (opts.list) for (const k of report.unused) console.log(` unused ${k}`);
203
+ if (report.unused.length) {
204
+ console.error(`✗ ${report.unused.length} unused key(s) — --list to see them, --fix to delete`);
205
+ }
206
+ for (const l of others) {
207
+ const m = report.missing[l];
208
+ const x = report.extra[l];
209
+ if (m.length) console.error(`✗ ${l}.json lacks ${m.length} used key(s): ${m.slice(0, 10).join(', ')}${m.length > 10 ? ' …' : ''}`);
210
+ if (x.length) console.error(`✗ ${l}.json has ${x.length} key(s) ${base} lacks: ${x.slice(0, 10).join(', ')}${x.length > 10 ? ' …' : ''}`);
211
+ }
212
+ if (!bad) console.log(`✓ i18n keys clean (no unused keys, ${others.join('/') || 'nothing else'} in parity with ${base})`);
213
+ return bad ? 1 : 0;
214
+ }
215
+
216
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
217
+ try {
218
+ process.exit(main());
219
+ } catch (err) {
220
+ console.error(`mk-translate: ${err.message}`);
221
+ process.exit(2);
222
+ }
223
+ }