millas 0.2.27 → 0.2.29

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.
Files changed (48) hide show
  1. package/bin/millas.js +12 -2
  2. package/package.json +2 -1
  3. package/src/cli.js +117 -20
  4. package/src/commands/call.js +1 -1
  5. package/src/commands/createsuperuser.js +137 -182
  6. package/src/commands/key.js +61 -83
  7. package/src/commands/lang.js +423 -515
  8. package/src/commands/make.js +88 -62
  9. package/src/commands/migrate.js +200 -279
  10. package/src/commands/new.js +55 -50
  11. package/src/commands/route.js +78 -80
  12. package/src/commands/schedule.js +52 -150
  13. package/src/commands/serve.js +158 -191
  14. package/src/console/AppCommand.js +106 -0
  15. package/src/console/BaseCommand.js +726 -0
  16. package/src/console/CommandContext.js +66 -0
  17. package/src/console/CommandRegistry.js +88 -0
  18. package/src/console/Style.js +123 -0
  19. package/src/console/index.js +12 -3
  20. package/src/container/AppInitializer.js +10 -0
  21. package/src/container/Application.js +2 -0
  22. package/src/facades/DB.js +195 -0
  23. package/src/index.js +2 -1
  24. package/src/scaffold/maker.js +102 -42
  25. package/src/schematics/Collection.js +28 -0
  26. package/src/schematics/SchematicEngine.js +122 -0
  27. package/src/schematics/Template.js +99 -0
  28. package/src/schematics/index.js +7 -0
  29. package/src/templates/command/default.template.js +14 -0
  30. package/src/templates/command/schema.json +19 -0
  31. package/src/templates/controller/default.template.js +10 -0
  32. package/src/templates/controller/resource.template.js +59 -0
  33. package/src/templates/controller/schema.json +30 -0
  34. package/src/templates/job/default.template.js +11 -0
  35. package/src/templates/job/schema.json +19 -0
  36. package/src/templates/middleware/default.template.js +11 -0
  37. package/src/templates/middleware/schema.json +19 -0
  38. package/src/templates/migration/default.template.js +14 -0
  39. package/src/templates/migration/schema.json +19 -0
  40. package/src/templates/model/default.template.js +14 -0
  41. package/src/templates/model/migration.template.js +17 -0
  42. package/src/templates/model/schema.json +30 -0
  43. package/src/templates/service/default.template.js +12 -0
  44. package/src/templates/service/schema.json +19 -0
  45. package/src/templates/shape/default.template.js +11 -0
  46. package/src/templates/shape/schema.json +19 -0
  47. package/src/validation/BaseValidator.js +3 -0
  48. package/src/validation/types.js +3 -3
@@ -1,589 +1,497 @@
1
1
  'use strict';
2
2
 
3
- const chalk = require('chalk');
4
- const path = require('path');
5
- const fs = require('fs');
6
-
7
- const DEFAULT_NS = 'messages'; // bare __('key') with no :: goes here
8
-
9
- module.exports = function (program) {
10
-
11
- // ── lang:publish <locale> [namespace] ────────────────────────────────────
12
- //
13
- // millas lang:publish sw → scan app/, write lang/sw/messages.js
14
- // (and other namespaces found)
15
- // millas lang:publish sw auth → only write lang/sw/auth.js
16
- // millas lang:publish sw -d → include framework defaults too
17
- // millas lang:publish sw --fresh → wipe locale dir and rebuild
18
- // millas lang:publish --all → every locale already in lang/
19
- // millas lang:publish --list → list locales in lang/
20
- //
21
- program
22
- .command('lang:publish [locale] [namespace]')
23
- .description('Extract _() strings from app/ and write to lang/<locale>/<namespace>.js')
24
- .option('-d, --defaults', 'Also include built-in Millas framework strings')
25
- .option('--fresh', 'Clear the namespace file(s) and rebuild from scratch')
26
- .option('--all', 'Publish to every locale already in lang/')
27
- .option('--list', 'List available locales and exit')
28
- .option('--dry-run', 'Preview changes without writing')
29
- .option('--format <fmt>', 'File format: js or json (default: js)')
30
- .option('--src <dir>', 'Extra directory to scan (repeatable). Defaults: app/, resources/, routes/, mail/')
31
- .action(async (locale, namespace, options) => {
32
- try {
33
- const cwd = process.cwd();
34
- const langPath = path.join(cwd, 'lang');
35
- const fmt = options.format || 'js';
36
-
37
- // Resolve scan directories — user config wins, otherwise sensible defaults
38
- const srcDirs = resolveScanDirs(cwd, options.src);
39
-
40
- // ── --list ────────────────────────────────────────────────────────
41
- if (options.list) {
42
- if (!fs.existsSync(langPath)) {
43
- console.log(chalk.yellow('\n lang/ not found — nothing published yet.\n'));
44
- return;
45
- }
46
- const locales = readLocales(langPath);
47
- if (!locales.length) {
48
- console.log(chalk.yellow('\n No locale files in lang/ yet.\n'));
49
- } else {
50
- console.log(chalk.cyan('\n Available locales:\n'));
51
- for (const loc of locales) {
52
- const nsFiles = readNamespaces(langPath, loc);
53
- const nsList = nsFiles.length ? chalk.gray(` (${nsFiles.join(', ')})`) : '';
54
- console.log(` ${chalk.white(loc)}${nsList}`);
55
- }
56
- console.log('');
57
- }
58
- return;
59
- }
3
+ const BaseCommand = require('../console/BaseCommand');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const {string} = require("../core/validation");
7
+
8
+ const DEFAULT_NS = 'messages';
9
+
10
+ class LangCommand extends BaseCommand {
11
+ static description = 'Manage application translations';
12
+
13
+ async onInit(register) {
14
+ register
15
+ .command(this.publish)
16
+ .arg('locale', 'Target locale (e.g., sw, fr)')
17
+ .arg('namespace',v=>v.string(), 'Specific namespace to publish')
18
+ .arg('--defaults', 'Include built-in Millas framework strings')
19
+ .arg('--fresh', 'Clear namespace files and rebuild from scratch')
20
+ .arg('--all', 'Publish to every locale in lang/')
21
+ .arg('--list', 'List available locales and exit')
22
+ .arg('--dry-run', 'Preview changes without writing')
23
+ .arg('--format',v=>v.string(), 'File format: js or json (default: js)')
24
+ .arg('--src',v=>v.string(), 'Extra directory to scan')
25
+ .description('Extract _() strings from app/ and write to lang/<locale>/<namespace>.js');
26
+
27
+ register
28
+ .command(this.missing)
29
+ .arg('locale', 'Target locale to check')
30
+ .description('Show untranslated keys in locale files');
31
+
32
+ register
33
+ .command(this.stats)
34
+ .description('Show translation completion % for all locales');
35
+
36
+ register
37
+ .command(this.keys)
38
+ .arg('--src',v=>v.string(), 'Extra directory to scan')
39
+ .description('List all _() keys found in source files, grouped by namespace');
40
+ }
60
41
 
61
- // ── Determine target locales ──────────────────────────────────────
62
- let targets = [];
63
- if (options.all) {
64
- targets = readLocales(langPath);
65
- if (!targets.length) {
66
- console.log(chalk.yellow('\n No locales in lang/. Run: millas lang:publish <locale>\n'));
67
- return;
68
- }
69
- } else if (locale) {
70
- targets = [locale];
71
- } else {
72
- console.log(chalk.red('\n ✖ Specify a locale: millas lang:publish sw\n'));
73
- console.log(chalk.gray(' millas lang:publish sw'));
74
- console.log(chalk.gray(' millas lang:publish sw auth'));
75
- console.log(chalk.gray(' millas lang:publish sw --defaults'));
76
- console.log(chalk.gray(' millas lang:publish --all\n'));
77
- process.exit(1);
78
- }
42
+ async publish(locale, namespace, defaults, fresh, all, list, dryRun, format, src) {
43
+ const langPath = path.join(this.cwd, 'lang');
44
+ const fmt = format || 'js';
45
+ const srcDirs = this.#resolveScanDirs(src);
79
46
 
80
- // ── Step 1: Extract keys from source ─────────────────────────────
81
- const relDirs = srcDirs.map(d => path.relative(cwd, d)).filter(Boolean);
82
- console.log(chalk.gray(`\n Scanning: ${relDirs.join(', ')}...`));
83
- const appKeys = extractKeysFromDirs(srcDirs, cwd);
84
-
85
- // ── Step 2: Merge framework defaults if -d ────────────────────────
86
- if (options.defaults) {
87
- const defaults = require('../i18n/defaults');
88
- let added = 0;
89
- for (const [key, val] of Object.entries(defaults)) {
90
- if (!appKeys.has(key)) {
91
- const { namespace: ns, bare } = parseKey(key);
92
- appKeys.set(key, {
93
- namespace: ns,
94
- bare,
95
- plural: Array.isArray(val) ? val[1] : null,
96
- locations: ['[millas]'],
97
- });
98
- added++;
99
- }
100
- }
101
- if (added) console.log(chalk.gray(` + ${added} framework string${added !== 1 ? 's' : ''} (-d)`));
102
- }
47
+ if (list) {
48
+ this.#handleList(langPath);
49
+ return;
50
+ }
103
51
 
104
- if (!appKeys.size) {
105
- console.log(chalk.yellow('\n No translatable strings found.\n'));
106
- console.log(chalk.gray(' Use __(), __("ns::key"), _n(), _p(), _f() in your source files.\n'));
107
- return;
108
- }
52
+ const targets = this.#resolveTargets(locale, all, langPath);
53
+ if (!targets) return;
109
54
 
110
- // ── Step 3: Group keys by namespace ──────────────────────────────
111
- // Only publish the requested namespace if one was specified
112
- const grouped = groupByNamespace(appKeys, namespace || null);
113
- const nsNames = [...grouped.keys()].sort();
114
-
115
- console.log(chalk.cyan(
116
- ` ${appKeys.size} string${appKeys.size !== 1 ? 's' : ''} → ` +
117
- `${nsNames.length} namespace${nsNames.length !== 1 ? 's' : ''}: ${nsNames.join(', ')}\n`
118
- ));
119
-
120
- fs.mkdirSync(langPath, { recursive: true });
121
- let totalAdded = 0;
122
-
123
- // ── Step 4: Write each locale × namespace combination ─────────────
124
- for (const loc of targets) {
125
- const locDir = path.join(langPath, loc);
126
- fs.mkdirSync(locDir, { recursive: true });
127
-
128
- let locAdded = 0;
129
- const results = [];
130
-
131
- for (const [ns, nsKeys] of grouped) {
132
- const filePath = path.join(locDir, `${ns}.${fmt}`);
133
- const isNew = !fs.existsSync(filePath);
134
- const existing = (options.fresh || isNew) ? {} : loadCatalogue(filePath);
135
- const added = [];
136
- const kept = [];
137
-
138
- for (const { bare, plural } of nsKeys.values()) {
139
- if (!options.fresh && existing[bare] !== undefined) { kept.push(bare); continue; }
140
- existing[bare] = loc === 'en'
141
- ? (plural ? [bare, plural] : bare)
142
- : (plural ? [null, null] : null);
143
- added.push(bare);
144
- }
145
-
146
- if (added.length > 0 && !options.dryRun) {
147
- writeCatalogue(filePath, existing, fmt);
148
- }
149
-
150
- locAdded += added.length;
151
- totalAdded += added.length;
152
- results.push({ ns, filePath: path.relative(cwd, filePath), added, kept, isNew });
153
- }
55
+ const relDirs = srcDirs.map(d => path.relative(this.cwd, d)).filter(Boolean);
56
+ this.logger.log(this.style.secondary(`\n Scanning: ${relDirs.join(', ')}...`));
57
+ const appKeys = this.#extractKeysFromDirs(srcDirs);
154
58
 
155
- // Print per-locale summary
156
- if (options.dryRun) {
157
- console.log(chalk.cyan(` ${loc} dry run:`));
158
- results.forEach(r => {
159
- const tag = r.isNew ? chalk.cyan('(new)') : chalk.gray(`(${r.kept.length} kept)`);
160
- console.log(` ${r.ns.padEnd(14)} ${tag} +${r.added.length} keys → ${r.filePath}`);
161
- });
162
- } else {
163
- const action = options.fresh ? 'Rebuilt' : (results.every(r => r.isNew) ? 'Created' : 'Updated');
164
- console.log(chalk.green(` ✔ ${loc.padEnd(10)}`) + ` ${action} — ${locAdded} key${locAdded !== 1 ? 's' : ''} added`);
165
- results.filter(r => r.added.length > 0).forEach(r => {
166
- console.log(chalk.gray(` ${r.ns.padEnd(14)} +${r.added.length} ${r.filePath}`));
167
- });
168
- }
59
+ if (defaults) {
60
+ const added = this.#mergeDefaults(appKeys);
61
+ if (added) this.logger.log(this.style.secondary(` + ${added} framework string${added !== 1 ? 's' : ''} (-d)`));
62
+ }
63
+
64
+ if (!appKeys.size) {
65
+ this.warn('No translatable strings found.');
66
+ this.logger.log(this.style.secondary(' Use __(), __("ns::key"), _n(), _p(), _f() in your source files.\n'));
67
+ return;
68
+ }
69
+
70
+ const grouped = this.#groupByNamespace(appKeys, namespace || null);
71
+ const nsNames = [...grouped.keys()].sort();
72
+
73
+ this.logger.log(this.style.info(
74
+ ` ${appKeys.size} string${appKeys.size !== 1 ? 's' : ''} → ` +
75
+ `${nsNames.length} namespace${nsNames.length !== 1 ? 's' : ''}: ${nsNames.join(', ')}\n`
76
+ ));
77
+
78
+ fs.mkdirSync(langPath, { recursive: true });
79
+ let totalAdded = 0;
80
+
81
+ for (const loc of targets) {
82
+ const locDir = path.join(langPath, loc);
83
+ fs.mkdirSync(locDir, { recursive: true });
84
+
85
+ let locAdded = 0;
86
+ const results = [];
87
+
88
+ for (const [ns, nsKeys] of grouped) {
89
+ const filePath = path.join(locDir, `${ns}.${fmt}`);
90
+ const isNew = !fs.existsSync(filePath);
91
+ const existing = (fresh || isNew) ? {} : this.#loadCatalogue(filePath);
92
+ const added = [];
93
+ const kept = [];
94
+
95
+ for (const { bare, plural } of nsKeys.values()) {
96
+ if (!fresh && existing[bare] !== undefined) { kept.push(bare); continue; }
97
+ existing[bare] = loc === 'en'
98
+ ? (plural ? [bare, plural] : bare)
99
+ : (plural ? [null, null] : null);
100
+ added.push(bare);
169
101
  }
170
102
 
171
- if (options.dryRun) {
172
- console.log(chalk.yellow('\n Dry run — nothing written.\n'));
173
- } else if (totalAdded > 0) {
174
- console.log(chalk.green(`\n Done. Fill in null values in lang/ to add translations.`));
175
- console.log(chalk.gray(' Run millas lang:missing to see what needs translation.\n'));
176
- } else {
177
- console.log(chalk.gray('\n All files already up to date.\n'));
103
+ if (added.length > 0 && !dryRun) {
104
+ this.#writeCatalogue(filePath, existing, fmt);
178
105
  }
179
106
 
180
- } catch (err) {
181
- console.error(chalk.red(`\n ✖ ${err.message}\n`));
182
- if (process.env.DEBUG) console.error(err.stack);
183
- process.exit(1);
107
+ locAdded += added.length;
108
+ totalAdded += added.length;
109
+ results.push({ ns, filePath: path.relative(this.cwd, filePath), added, kept, isNew });
184
110
  }
185
- });
186
-
187
- // ── lang:missing [locale] ────────────────────────────────────────────────
188
- program
189
- .command('lang:missing [locale]')
190
- .description('Show untranslated keys in locale files')
191
- .action(async (locale) => {
192
- try {
193
- const langPath = path.join(process.cwd(), 'lang');
194
- if (!fs.existsSync(langPath)) {
195
- console.log(chalk.yellow('\n lang/ not found. Run: millas lang:publish <locale>\n'));
196
- return;
197
- }
198
111
 
199
- const targets = readLocales(langPath)
200
- .filter(l => l !== 'en')
201
- .filter(l => !locale || l === locale);
112
+ if (dryRun) {
113
+ this.logger.log(this.style.info(` ${loc} dry run:`));
114
+ results.forEach(r => {
115
+ const tag = r.isNew ? this.style.info('(new)') : this.style.secondary(`(${r.kept.length} kept)`);
116
+ this.logger.log(` ${r.ns.padEnd(14)} ${tag} +${r.added.length} keys → ${r.filePath}`);
117
+ });
118
+ } else {
119
+ const action = fresh ? 'Rebuilt' : (results.every(r => r.isNew) ? 'Created' : 'Updated');
120
+ this.logger.log(this.style.success(` ✔ ${loc.padEnd(10)}`) + ` ${action} — ${locAdded} key${locAdded !== 1 ? 's' : ''} added`);
121
+ results.filter(r => r.added.length > 0).forEach(r => {
122
+ this.logger.log(this.style.secondary(` ${r.ns.padEnd(14)} +${r.added.length} → ${r.filePath}`));
123
+ });
124
+ }
125
+ }
202
126
 
203
- if (!targets.length) {
204
- console.log(chalk.yellow(locale
205
- ? `\n Locale "${locale}" not found in lang/.\n`
206
- : '\n No non-English locales found.\n'));
207
- return;
208
- }
127
+ if (dryRun) {
128
+ this.warn('Dry run — nothing written.');
129
+ } else if (totalAdded > 0) {
130
+ this.success('Done. Fill in null values in lang/ to add translations.');
131
+ this.logger.log(this.style.secondary(' Run millas lang:missing to see what needs translation.\n'));
132
+ } else {
133
+ this.logger.log(this.style.secondary('\n All files already up to date.\n'));
134
+ }
135
+ }
209
136
 
210
- // Load en as source of truth
211
- const enKeys = loadAllNamespaces(langPath, 'en'); // Map<ns, catalogue>
137
+ async missing(locale) {
138
+ const langPath = path.join(this.cwd, 'lang');
139
+ if (!fs.existsSync(langPath)) {
140
+ this.warn('lang/ not found. Run: millas lang:publish <locale>');
141
+ return;
142
+ }
212
143
 
213
- let totalMissing = 0;
214
- console.log('');
144
+ const targets = this.#readLocales(langPath)
145
+ .filter(l => l !== 'en')
146
+ .filter(l => !locale || l === locale);
215
147
 
216
- for (const loc of targets) {
217
- const locKeys = loadAllNamespaces(langPath, loc);
218
- const missing = [];
148
+ if (!targets.length) {
149
+ this.warn(locale
150
+ ? `Locale "${locale}" not found in lang/.`
151
+ : 'No non-English locales found.');
152
+ return;
153
+ }
219
154
 
220
- for (const [ns, enCat] of enKeys) {
221
- const locCat = locKeys.get(ns) || {};
222
- for (const key of Object.keys(enCat)) {
223
- const val = locCat[key];
224
- if (val === null || val === undefined || (Array.isArray(val) && val.some(v => v === null))) {
225
- missing.push(`${ns}::${key}`);
226
- }
227
- }
228
- }
155
+ const enKeys = this.#loadAllNamespaces(langPath, 'en');
156
+ let totalMissing = 0;
157
+ this.logger.log('');
229
158
 
230
- const total = [...enKeys.values()].reduce((s, c) => s + Object.keys(c).length, 0);
231
- const pct = total ? Math.round(((total - missing.length) / total) * 100) : 100;
232
- const color = pct === 100 ? chalk.green : pct >= 50 ? chalk.yellow : chalk.red;
233
- totalMissing += missing.length;
159
+ for (const loc of targets) {
160
+ const locKeys = this.#loadAllNamespaces(langPath, loc);
161
+ const missing = [];
234
162
 
235
- if (!missing.length) {
236
- console.log(color(` ${loc.padEnd(10)} ${progressBar(pct, 18)} 100% Fully translated`));
237
- } else {
238
- console.log(color(` ${loc.padEnd(10)} ${progressBar(pct, 18)} ${pct}% ${missing.length} missing:`));
239
- missing.forEach(k => console.log(chalk.gray(` - ${JSON.stringify(k)}`)));
163
+ for (const [ns, enCat] of enKeys) {
164
+ const locCat = locKeys.get(ns) || {};
165
+ for (const key of Object.keys(enCat)) {
166
+ const val = locCat[key];
167
+ if (val === null || val === undefined || (Array.isArray(val) && val.some(v => v === null))) {
168
+ missing.push(`${ns}::${key}`);
240
169
  }
241
170
  }
171
+ }
242
172
 
243
- console.log('');
244
- console.log(totalMissing > 0
245
- ? chalk.yellow(` ${totalMissing} key${totalMissing !== 1 ? 's' : ''} need translation.\n`)
246
- : chalk.green(' All locales fully translated.\n'));
173
+ const total = [...enKeys.values()].reduce((s, c) => s + Object.keys(c).length, 0);
174
+ const pct = total ? Math.round(((total - missing.length) / total) * 100) : 100;
175
+ const color = pct === 100 ? this.style.success : pct >= 50 ? this.style.warning : this.style.danger;
176
+ totalMissing += missing.length;
247
177
 
248
- } catch (err) {
249
- console.error(chalk.red(`\n ${err.message}\n`));
250
- process.exit(1);
178
+ if (!missing.length) {
179
+ this.logger.log(color(` ${loc.padEnd(10)} ${this.#progressBar(pct, 18)} 100% Fully translated`));
180
+ } else {
181
+ this.logger.log(color(` ${loc.padEnd(10)} ${this.#progressBar(pct, 18)} ${pct}% ${missing.length} missing:`));
182
+ missing.forEach(k => this.logger.log(this.style.secondary(` - ${JSON.stringify(k)}`)));
251
183
  }
252
- });
253
-
254
- // ── lang:stats ───────────────────────────────────────────────────────────
255
- program
256
- .command('lang:stats')
257
- .description('Show translation completion % for all locales')
258
- .action(async () => {
259
- try {
260
- const langPath = path.join(process.cwd(), 'lang');
261
- if (!fs.existsSync(langPath)) {
262
- console.log(chalk.yellow('\n lang/ not found. Run: millas lang:publish <locale>\n'));
263
- return;
264
- }
184
+ }
265
185
 
266
- const locales = readLocales(langPath);
267
- const enKeys = loadAllNamespaces(langPath, 'en');
268
- const total = [...enKeys.values()].reduce((s, c) => s + Object.keys(c).length, 0);
186
+ this.logger.log('');
187
+ this.logger.log(totalMissing > 0
188
+ ? this.style.warning(` ${totalMissing} key${totalMissing !== 1 ? 's' : ''} need translation.\n`)
189
+ : this.style.success(' All locales fully translated.\n'));
190
+ }
269
191
 
270
- console.log(chalk.cyan(`\n Translation Stats (source: en, ${total} key${total !== 1 ? 's' : ''})\n`));
271
- console.log(chalk.gray(' ' + ''.repeat(55)));
192
+ async stats() {
193
+ const langPath = path.join(this.cwd, 'lang');
194
+ if (!fs.existsSync(langPath)) {
195
+ this.warn('lang/ not found. Run: millas lang:publish <locale>');
196
+ return;
197
+ }
272
198
 
273
- for (const loc of locales.sort()) {
274
- if (loc === 'en') {
275
- console.log(` ${chalk.bold(loc.padEnd(10))} ${chalk.green(progressBar(100, 18))} ${chalk.green('100%')} ${total}/${total} ${chalk.gray('(source)')}`);
276
- continue;
277
- }
278
- const locKeys = loadAllNamespaces(langPath, loc);
279
- let translated = 0;
280
- for (const [ns, enCat] of enKeys) {
281
- const locCat = locKeys.get(ns) || {};
282
- for (const key of Object.keys(enCat)) {
283
- const v = locCat[key];
284
- if (v !== null && v !== undefined && (!Array.isArray(v) || v.every(x => x !== null))) translated++;
285
- }
286
- }
287
- const pct = total > 0 ? Math.round((translated / total) * 100) : 0;
288
- const color = pct === 100 ? chalk.green : pct >= 50 ? chalk.yellow : chalk.red;
289
- console.log(` ${chalk.bold(loc.padEnd(10))} ${color(progressBar(pct, 18))} ${color((pct + '%').padStart(4))} ${translated}/${total}`);
290
- }
291
- console.log('');
199
+ const locales = this.#readLocales(langPath);
200
+ const enKeys = this.#loadAllNamespaces(langPath, 'en');
201
+ const total = [...enKeys.values()].reduce((s, c) => s + Object.keys(c).length, 0);
202
+
203
+ this.logger.log(this.style.info(`\n Translation Stats (source: en, ${total} key${total !== 1 ? 's' : ''})\n`));
204
+ this.logger.log(this.style.secondary(' ' + '─'.repeat(55)));
292
205
 
293
- } catch (err) {
294
- console.error(chalk.red(`\n ✖ ${err.message}\n`));
295
- process.exit(1);
206
+ for (const loc of locales.sort()) {
207
+ if (loc === 'en') {
208
+ this.logger.log(` ${this.style.bold(loc.padEnd(10))} ${this.style.success(this.#progressBar(100, 18))} ${this.style.success('100%')} ${total}/${total} ${this.style.secondary('(source)')}`);
209
+ continue;
296
210
  }
297
- });
298
-
299
- // ── lang:keys ────────────────────────────────────────────────────────────
300
- program
301
- .command('lang:keys')
302
- .description('List all _() keys found in source files, grouped by namespace')
303
- .option('--src <dir>', 'Extra directory to scan (uses defaults if not set)')
304
- .action(async (options) => {
305
- try {
306
- const cwd = process.cwd();
307
- const keys = extractKeysFromDirs(resolveScanDirs(cwd, options.src), cwd);
308
-
309
- if (!keys.size) {
310
- console.log(chalk.yellow(`\n No translatable strings found.\n`));
311
- return;
211
+ const locKeys = this.#loadAllNamespaces(langPath, loc);
212
+ let translated = 0;
213
+ for (const [ns, enCat] of enKeys) {
214
+ const locCat = locKeys.get(ns) || {};
215
+ for (const key of Object.keys(enCat)) {
216
+ const v = locCat[key];
217
+ if (v !== null && v !== undefined && (!Array.isArray(v) || v.every(x => x !== null))) translated++;
312
218
  }
219
+ }
220
+ const pct = total > 0 ? Math.round((translated / total) * 100) : 0;
221
+ const color = pct === 100 ? this.style.success : pct >= 50 ? this.style.warning : this.style.danger;
222
+ this.logger.log(` ${this.style.bold(loc.padEnd(10))} ${color(this.#progressBar(pct, 18))} ${color((pct + '%').padStart(4))} ${translated}/${total}`);
223
+ }
224
+ this.logger.log('');
225
+ }
313
226
 
314
- // Group by namespace for display
315
- const grouped = groupByNamespace(keys, null);
316
- console.log(chalk.cyan(`\n ${keys.size} string${keys.size !== 1 ? 's' : ''} across ${grouped.size} namespace${grouped.size !== 1 ? 's' : ''}:\n`));
317
-
318
- for (const [ns, nsKeys] of [...grouped].sort((a, b) => a[0].localeCompare(b[0]))) {
319
- console.log(chalk.bold(` [${ns}]`));
320
- for (const [key, meta] of [...nsKeys].sort((a, b) => a[0].localeCompare(b[0]))) {
321
- const p = meta.plural ? chalk.gray(` [plural: ${JSON.stringify(meta.plural)}]`) : '';
322
- const locs = meta.locations.slice(0, 2).map(l => chalk.gray(l)).join(', ');
323
- const more = meta.locations.length > 2 ? chalk.gray(` +${meta.locations.length - 2}`) : '';
324
- console.log(` ${chalk.white(JSON.stringify(meta.bare))}${p}`);
325
- console.log(` ${locs}${more}`);
326
- }
327
- console.log('');
328
- }
227
+ async keys(src) {
228
+ const keys = this.#extractKeysFromDirs(this.#resolveScanDirs(src));
329
229
 
330
- } catch (err) {
331
- console.error(chalk.red(`\n ✖ ${err.message}\n`));
332
- process.exit(1);
230
+ if (!keys.size) {
231
+ this.warn('No translatable strings found.');
232
+ return;
233
+ }
234
+
235
+ const grouped = this.#groupByNamespace(keys, null);
236
+ this.logger.log(this.style.info(`\n ${keys.size} string${keys.size !== 1 ? 's' : ''} across ${grouped.size} namespace${grouped.size !== 1 ? 's' : ''}:\n`));
237
+
238
+ for (const [ns, nsKeys] of [...grouped].sort((a, b) => a[0].localeCompare(b[0]))) {
239
+ this.logger.log(this.style.bold(` [${ns}]`));
240
+ for (const [key, meta] of [...nsKeys].sort((a, b) => a[0].localeCompare(b[0]))) {
241
+ const p = meta.plural ? this.style.secondary(` [plural: ${JSON.stringify(meta.plural)}]`) : '';
242
+ const locs = meta.locations.slice(0, 2).map(l => this.style.secondary(l)).join(', ');
243
+ const more = meta.locations.length > 2 ? this.style.secondary(` +${meta.locations.length - 2}`) : '';
244
+ this.logger.log(` ${this.style.light(JSON.stringify(meta.bare))}${p}`);
245
+ this.logger.log(` ${locs}${more}`);
333
246
  }
334
- });
335
- };
336
-
337
- // ─── Directory resolution ────────────────────────────────────────────────────────
338
-
339
- /**
340
- * Resolve the list of directories to scan for translatable strings.
341
- *
342
- * Priority:
343
- * 1. config/i18n.js → scan: ['app', 'resources', 'routes', 'mail']
344
- * 2. --src <dir> CLI flag (adds to defaults, does not replace them)
345
- * 3. Built-in defaults: app/, resources/, routes/, mail/
346
- *
347
- * Only returns directories that actually exist — silently skips missing ones
348
- * so projects that don't have a resources/ directory don't see warnings.
349
- */
350
- function resolveScanDirs(cwd, extraSrc) {
351
- // Try config/i18n.js first
352
- let configured = null;
353
- try {
354
- const i18nConfig = require(path.join(cwd, 'config/i18n'));
355
- if (Array.isArray(i18nConfig.scan) && i18nConfig.scan.length) {
356
- configured = i18nConfig.scan;
247
+ this.logger.log('');
357
248
  }
358
- } catch { /* no config/i18n.js — use defaults */ }
359
-
360
- const rawDirs = configured || [
361
- 'app', // controllers, models, services, middleware, jobs
362
- 'resources', // views (.njk, .html) — resources/views/ is common
363
- 'routes', // route files often contain inline messages
364
- 'mail', // email templates
365
- 'providers', // service providers sometimes have translatable strings
366
- ];
367
-
368
- // Add any --src flag value on top
369
- if (extraSrc) rawDirs.push(extraSrc);
370
-
371
- // Resolve to absolute paths and filter to only existing ones
372
- return [...new Set(rawDirs)]
373
- .map(d => path.isAbsolute(d) ? d : path.join(cwd, d))
374
- .filter(d => fs.existsSync(d));
375
- }
249
+ }
376
250
 
377
- /**
378
- * Extract translatable keys from multiple directories.
379
- * Merges all results a key found in multiple places gets all locations tracked.
380
- */
381
- function extractKeysFromDirs(dirs, cwd) {
382
- const merged = new Map();
383
- for (const dir of dirs) {
384
- const keys = extractKeys(dir, cwd);
385
- for (const [fullKey, meta] of keys) {
386
- if (!merged.has(fullKey)) {
387
- merged.set(fullKey, { ...meta, locations: [...meta.locations] });
388
- } else {
389
- // Merge locations from multiple dirs
390
- const existing = merged.get(fullKey);
391
- for (const loc of meta.locations) {
392
- if (!existing.locations.includes(loc)) existing.locations.push(loc);
393
- }
251
+ #handleList(langPath) {
252
+ if (!fs.existsSync(langPath)) {
253
+ this.warn('lang/ not foundnothing published yet.');
254
+ return;
255
+ }
256
+ const locales = this.#readLocales(langPath);
257
+ if (!locales.length) {
258
+ this.warn('No locale files in lang/ yet.');
259
+ } else {
260
+ this.logger.log(this.style.info('\n Available locales:\n'));
261
+ for (const loc of locales) {
262
+ const nsFiles = this.#readNamespaces(langPath, loc);
263
+ const nsList = nsFiles.length ? this.style.secondary(` (${nsFiles.join(', ')})`) : '';
264
+ this.logger.log(` ${this.style.light(loc)}${nsList}`);
394
265
  }
266
+ this.logger.log('');
395
267
  }
396
268
  }
397
- return merged;
398
- }
399
269
 
400
- // ─── Namespace parsing ────────────────────────────────────────────────────────
270
+ #resolveTargets(locale, all, langPath) {
271
+ if (all) {
272
+ const targets = this.#readLocales(langPath);
273
+ if (!targets.length) {
274
+ this.warn('No locales in lang/. Run: millas lang:publish <locale>');
275
+ return null;
276
+ }
277
+ return targets;
278
+ }
279
+
280
+ if (locale) {
281
+ return [locale];
282
+ }
401
283
 
402
- function parseKey(key) {
403
- const sep = key.indexOf('::');
404
- if (sep === -1) return { namespace: DEFAULT_NS, bare: key };
405
- return { namespace: key.slice(0, sep).trim() || DEFAULT_NS, bare: key.slice(sep + 2) };
406
- }
284
+ this.error('Specify a locale: millas lang:publish sw');
285
+ this.logger.log(this.style.secondary(' millas lang:publish sw'));
286
+ this.logger.log(this.style.secondary(' millas lang:publish sw auth'));
287
+ this.logger.log(this.style.secondary(' millas lang:publish sw --defaults'));
288
+ this.logger.log(this.style.secondary(' millas lang:publish --all\n'));
289
+ throw new Error('Locale required');
290
+ }
407
291
 
408
- // ─── Key extractor ────────────────────────────────────────────────────────────
292
+ #resolveScanDirs(extraSrc) {
293
+ let configured = null;
294
+ try {
295
+ const i18nConfig = require(path.join(this.cwd, 'config/i18n'));
296
+ if (Array.isArray(i18nConfig.scan) && i18nConfig.scan.length) {
297
+ configured = i18nConfig.scan;
298
+ }
299
+ } catch { }
409
300
 
410
- /**
411
- * Scan source files and extract all _() calls.
412
- * Returns Map<fullKey, { namespace, bare, plural, locations[] }>
413
- * fullKey is the original string including namespace prefix if present.
414
- */
415
- function extractKeys(srcPath, cwd) {
416
- const keys = new Map();
417
- if (!fs.existsSync(srcPath)) return keys;
301
+ const rawDirs = configured || ['app', 'resources', 'routes', 'mail', 'providers'];
302
+ if (extraSrc) rawDirs.push(extraSrc);
418
303
 
419
- for (const filePath of walkFiles(srcPath, ['.js', '.njk', '.html'])) {
420
- let source;
421
- try { source = fs.readFileSync(filePath, 'utf8'); } catch { continue; }
422
- const rel = path.relative(cwd, filePath);
304
+ return [...new Set(rawDirs)]
305
+ .map(d => path.isAbsolute(d) ? d : path.join(this.cwd, d))
306
+ .filter(d => fs.existsSync(d));
307
+ }
308
+
309
+ #extractKeysFromDirs(dirs) {
310
+ const merged = new Map();
311
+ for (const dir of dirs) {
312
+ const keys = this.#extractKeys(dir);
313
+ for (const [fullKey, meta] of keys) {
314
+ if (!merged.has(fullKey)) {
315
+ merged.set(fullKey, { ...meta, locations: [...meta.locations] });
316
+ } else {
317
+ const existing = merged.get(fullKey);
318
+ for (const loc of meta.locations) {
319
+ if (!existing.locations.includes(loc)) existing.locations.push(loc);
320
+ }
321
+ }
322
+ }
323
+ }
324
+ return merged;
325
+ }
423
326
 
424
- // __('key') __('ns::key')
425
- scanSingle(source, /\b__\(\s*(['"`])((?:\\.|(?!\1).)*)\1/g, 2, null, rel, keys);
327
+ #extractKeys(srcPath) {
328
+ const keys = new Map();
329
+ if (!fs.existsSync(srcPath)) return keys;
426
330
 
427
- // _f('key', ...)
428
- scanSingle(source, /\b_f\(\s*(['"`])((?:\\.|(?!\1).)*)\1/g, 2, null, rel, keys);
331
+ for (const filePath of this.#walkFiles(srcPath, ['.js', '.njk', '.html'])) {
332
+ let source;
333
+ try { source = fs.readFileSync(filePath, 'utf8'); } catch { continue; }
334
+ const rel = path.relative(this.cwd, filePath);
429
335
 
430
- // _n('singular', 'plural', n)
431
- scanPlural(source, /\b_n\(\s*(['"`])((?:\\.|(?!\1).)*)\1\s*,\s*(['"`])((?:\\.|(?!\3).)*)\3/g, rel, keys);
336
+ this.#scanSingle(source, /\b__\(\s*(['\"`])((?:\\.|(?!\1).)*)\1/g, 2, null, rel, keys);
337
+ this.#scanSingle(source, /\b_f\(\s*(['\"`])((?:\\.|(?!\1).)*)\1/g, 2, null, rel, keys);
338
+ this.#scanPlural(source, /\b_n\(\s*(['\"`])((?:\\.|(?!\1).)*)\1\s*,\s*(['\"`])((?:\\.|(?!\3).)*)\3/g, rel, keys);
339
+ this.#scanPlural(source, /\b_fn\(\s*(['\"`])((?:\\.|(?!\1).)*)\1\s*,\s*(['\"`])((?:\\.|(?!\3).)*)\3/g, rel, keys);
432
340
 
433
- // _fn('singular', 'plural', n)
434
- scanPlural(source, /\b_fn\(\s*(['"`])((?:\\.|(?!\1).)*)\1\s*,\s*(['"`])((?:\\.|(?!\3).)*)\3/g, rel, keys);
341
+ let m;
342
+ const pPat = /\b_p\(\s*(['\"`])((?:\\.|(?!\1).)*)\1\s*,\s*(['\"`])((?:\\.|(?!\3).)*)\3/g;
343
+ while ((m = pPat.exec(source)) !== null) {
344
+ this.#addKey(keys, `${this.#unescape(m[2])}|${this.#unescape(m[4])}`, null, rel);
345
+ }
435
346
 
436
- // _p('context', 'key') — stored as 'messages::context|key' (context sep is |, not ::)
437
- let m;
438
- const pPat = /\b_p\(\s*(['"`])((?:\\.|(?!\1).)*)\1\s*,\s*(['"`])((?:\\.|(?!\3).)*)\3/g;
439
- while ((m = pPat.exec(source)) !== null) {
440
- addKey(keys, `${un(m[2])}|${un(m[4])}`, null, rel);
347
+ this.#scanSingle(source, /\{\{[^}]*(['\"])((?:\\.|(?!\1).)*)\1\s*\|\s*__/g, 2, null, rel, keys);
348
+ this.#scanSingle(source, /\{\{[^}]*(['\"])((?:\\.|(?!\1).)*)\1\s*\|\s*_f/g, 2, null, rel, keys);
441
349
  }
442
350
 
443
- // Nunjucks: {{ 'key' | __ }} {{ 'ns::key' | __ }}
444
- scanSingle(source, /\{\{[^}]*(['"])((?:\\.|(?!\1).)*)\1\s*\|\s*__/g, 2, null, rel, keys);
445
- scanSingle(source, /\{\{[^}]*(['"])((?:\\.|(?!\1).)*)\1\s*\|\s*_f/g, 2, null, rel, keys);
351
+ return keys;
446
352
  }
447
353
 
448
- return keys;
449
- }
354
+ #scanSingle(source, regex, keyGroup, _pluralGroup, location, keys) {
355
+ let m;
356
+ while ((m = regex.exec(source)) !== null) this.#addKey(keys, this.#unescape(m[keyGroup]), null, location);
357
+ }
450
358
 
451
- function scanSingle(source, regex, keyGroup, _pluralGroup, location, keys) {
452
- let m;
453
- while ((m = regex.exec(source)) !== null) addKey(keys, un(m[keyGroup]), null, location);
454
- }
359
+ #scanPlural(source, regex, location, keys) {
360
+ let m;
361
+ while ((m = regex.exec(source)) !== null) this.#addKey(keys, this.#unescape(m[2]), this.#unescape(m[4]), location);
362
+ }
455
363
 
456
- function scanPlural(source, regex, location, keys) {
457
- let m;
458
- while ((m = regex.exec(source)) !== null) addKey(keys, un(m[2]), un(m[4]), location);
459
- }
364
+ #addKey(keys, fullKey, plural, location) {
365
+ if (!fullKey || !fullKey.trim()) return;
366
+ const { namespace, bare } = this.#parseKey(fullKey);
367
+ if (!keys.has(fullKey)) keys.set(fullKey, { namespace, bare, plural: null, locations: [] });
368
+ const e = keys.get(fullKey);
369
+ if (!e.plural && plural) e.plural = plural;
370
+ if (!e.locations.includes(location)) e.locations.push(location);
371
+ }
460
372
 
461
- function addKey(keys, fullKey, plural, location) {
462
- if (!fullKey || !fullKey.trim()) return;
463
- const { namespace, bare } = parseKey(fullKey);
464
- if (!keys.has(fullKey)) keys.set(fullKey, { namespace, bare, plural: null, locations: [] });
465
- const e = keys.get(fullKey);
466
- if (!e.plural && plural) e.plural = plural;
467
- if (!e.locations.includes(location)) e.locations.push(location);
468
- }
373
+ #unescape(s) {
374
+ return String(s)
375
+ .replace(/\\n/g, '\n').replace(/\\t/g, '\t')
376
+ .replace(/\\'/g, "'").replace(/\\"/g, '"').replace(/\\\\/g, '\\');
377
+ }
469
378
 
470
- function un(s) {
471
- return String(s)
472
- .replace(/\\n/g,'\n').replace(/\\t/g,'\t')
473
- .replace(/\\'/g,"'").replace(/\\"/g,'"').replace(/\\\\/g,'\\');
474
- }
379
+ #parseKey(key) {
380
+ const sep = key.indexOf('::');
381
+ if (sep === -1) return { namespace: DEFAULT_NS, bare: key };
382
+ return { namespace: key.slice(0, sep).trim() || DEFAULT_NS, bare: key.slice(sep + 2) };
383
+ }
475
384
 
476
- /**
477
- * Group extracted keys by namespace.
478
- * If nsFilter is set, only return that namespace.
479
- * Returns Map<namespace, Map<fullKey, meta>>
480
- */
481
- function groupByNamespace(keys, nsFilter) {
482
- const groups = new Map();
483
- for (const [fullKey, meta] of keys) {
484
- const ns = meta.namespace;
485
- if (nsFilter && ns !== nsFilter) continue;
486
- if (!groups.has(ns)) groups.set(ns, new Map());
487
- groups.get(ns).set(fullKey, meta);
385
+ #groupByNamespace(keys, nsFilter) {
386
+ const groups = new Map();
387
+ for (const [fullKey, meta] of keys) {
388
+ const ns = meta.namespace;
389
+ if (nsFilter && ns !== nsFilter) continue;
390
+ if (!groups.has(ns)) groups.set(ns, new Map());
391
+ groups.get(ns).set(fullKey, meta);
392
+ }
393
+ return groups;
488
394
  }
489
- return groups;
490
- }
491
395
 
492
- // ─── Catalogue I/O ────────────────────────────────────────────────────────────
396
+ #mergeDefaults(appKeys) {
397
+ const defaults = require('../i18n/defaults');
398
+ let added = 0;
399
+ for (const [key, val] of Object.entries(defaults)) {
400
+ if (!appKeys.has(key)) {
401
+ const { namespace: ns, bare } = this.#parseKey(key);
402
+ appKeys.set(key, {
403
+ namespace: ns,
404
+ bare,
405
+ plural: Array.isArray(val) ? val[1] : null,
406
+ locations: ['[millas]'],
407
+ });
408
+ added++;
409
+ }
410
+ }
411
+ return added;
412
+ }
493
413
 
494
- function loadCatalogue(filePath) {
495
- if (!fs.existsSync(filePath)) return {};
496
- try {
497
- try { delete require.cache[require.resolve(filePath)]; } catch {}
498
- return filePath.endsWith('.json') ? JSON.parse(fs.readFileSync(filePath, 'utf8')) : (require(filePath) || {});
499
- } catch { return {}; }
500
- }
414
+ #loadCatalogue(filePath) {
415
+ if (!fs.existsSync(filePath)) return {};
416
+ try {
417
+ try { delete require.cache[require.resolve(filePath)]; } catch { }
418
+ return filePath.endsWith('.json') ? JSON.parse(fs.readFileSync(filePath, 'utf8')) : (require(filePath) || {});
419
+ } catch { return {}; }
420
+ }
501
421
 
502
- /**
503
- * Load all namespace files for a locale.
504
- * Returns Map<namespace, catalogue>
505
- */
506
- function loadAllNamespaces(langPath, locale) {
507
- const result = new Map();
508
- const locDir = path.join(langPath, locale);
509
-
510
- if (fs.existsSync(locDir) && fs.statSync(locDir).isDirectory()) {
511
- // Subdirectory layout: lang/sw/auth.js, lang/sw/messages.js
512
- for (const file of fs.readdirSync(locDir).filter(f => /\.(js|json)$/.test(f))) {
513
- const ns = file.replace(/\.(js|json)$/, '');
514
- const cat = loadCatalogue(path.join(locDir, file));
515
- result.set(ns, cat);
516
- }
517
- } else {
518
- // Flat layout: lang/sw.js — treat everything as 'messages' namespace
519
- for (const ext of ['.js', '.json']) {
520
- const flat = path.join(langPath, locale + ext);
521
- if (fs.existsSync(flat)) {
522
- result.set(DEFAULT_NS, loadCatalogue(flat));
523
- break;
422
+ #loadAllNamespaces(langPath, locale) {
423
+ const result = new Map();
424
+ const locDir = path.join(langPath, locale);
425
+
426
+ if (fs.existsSync(locDir) && fs.statSync(locDir).isDirectory()) {
427
+ for (const file of fs.readdirSync(locDir).filter(f => /\.(js|json)$/.test(f))) {
428
+ const ns = file.replace(/\.(js|json)$/, '');
429
+ const cat = this.#loadCatalogue(path.join(locDir, file));
430
+ result.set(ns, cat);
431
+ }
432
+ } else {
433
+ for (const ext of ['.js', '.json']) {
434
+ const flat = path.join(langPath, locale + ext);
435
+ if (fs.existsSync(flat)) {
436
+ result.set(DEFAULT_NS, this.#loadCatalogue(flat));
437
+ break;
438
+ }
524
439
  }
525
440
  }
441
+ return result;
526
442
  }
527
- return result;
528
- }
529
443
 
530
- function writeCatalogue(filePath, catalogue, fmt) {
531
- const sorted = Object.keys(catalogue).sort().reduce((a, k) => { a[k] = catalogue[k]; return a; }, {});
532
- let content;
533
- if (fmt === 'json') {
534
- content = JSON.stringify(sorted, null, 2) + '\n';
535
- } else {
536
- const lines = ["'use strict';\n", 'module.exports = {'];
537
- for (const [k, v] of Object.entries(sorted)) {
538
- const key = JSON.stringify(k);
539
- if (v === null) lines.push(` ${key}: null,`);
540
- else if (Array.isArray(v)) lines.push(` ${key}: [${v.map(x => x === null ? 'null' : JSON.stringify(x)).join(', ')}],`);
541
- else lines.push(` ${key}: ${JSON.stringify(v)},`);
444
+ #writeCatalogue(filePath, catalogue, fmt) {
445
+ const sorted = Object.keys(catalogue).sort().reduce((a, k) => { a[k] = catalogue[k]; return a; }, {});
446
+ let content;
447
+ if (fmt === 'json') {
448
+ content = JSON.stringify(sorted, null, 2) + '\n';
449
+ } else {
450
+ const lines = ["'use strict';\n", 'module.exports = {'];
451
+ for (const [k, v] of Object.entries(sorted)) {
452
+ const key = JSON.stringify(k);
453
+ if (v === null) lines.push(` ${key}: null,`);
454
+ else if (Array.isArray(v)) lines.push(` ${key}: [${v.map(x => x === null ? 'null' : JSON.stringify(x)).join(', ')}],`);
455
+ else lines.push(` ${key}: ${JSON.stringify(v)},`);
456
+ }
457
+ lines.push('};\n');
458
+ content = lines.join('\n');
542
459
  }
543
- lines.push('};\n');
544
- content = lines.join('\n');
460
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
461
+ fs.writeFileSync(filePath, content, 'utf8');
545
462
  }
546
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
547
- fs.writeFileSync(filePath, content, 'utf8');
548
- }
549
463
 
550
- // ─── Directory helpers ────────────────────────────────────────────────────────
551
-
552
- /**
553
- * Read all locale names from lang/.
554
- * Handles both flat (sw.js) and subdirectory (sw/) layouts.
555
- */
556
- function readLocales(langPath) {
557
- if (!fs.existsSync(langPath)) return [];
558
- return fs.readdirSync(langPath, { withFileTypes: true })
559
- .filter(e => (e.isFile() && /\.(js|json)$/.test(e.name)) || e.isDirectory())
560
- .map(e => e.name.replace(/\.(js|json)$/, ''));
561
- }
464
+ #readLocales(langPath) {
465
+ if (!fs.existsSync(langPath)) return [];
466
+ return fs.readdirSync(langPath, { withFileTypes: true })
467
+ .filter(e => (e.isFile() && /\.(js|json)$/.test(e.name)) || e.isDirectory())
468
+ .map(e => e.name.replace(/\.(js|json)$/, ''));
469
+ }
562
470
 
563
- /**
564
- * Read namespace file names for a locale subdirectory.
565
- */
566
- function readNamespaces(langPath, locale) {
567
- const locDir = path.join(langPath, locale);
568
- if (!fs.existsSync(locDir) || !fs.statSync(locDir).isDirectory()) return [];
569
- return fs.readdirSync(locDir)
570
- .filter(f => /\.(js|json)$/.test(f))
571
- .map(f => f.replace(/\.(js|json)$/, ''));
572
- }
471
+ #readNamespaces(langPath, locale) {
472
+ const locDir = path.join(langPath, locale);
473
+ if (!fs.existsSync(locDir) || !fs.statSync(locDir).isDirectory()) return [];
474
+ return fs.readdirSync(locDir)
475
+ .filter(f => /\.(js|json)$/.test(f))
476
+ .map(f => f.replace(/\.(js|json)$/, ''));
477
+ }
478
+
479
+ #walkFiles(dir, exts) {
480
+ const results = [];
481
+ if (!fs.existsSync(dir)) return results;
482
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
483
+ if (e.name.startsWith('.') || e.name === 'node_modules') continue;
484
+ const full = path.join(dir, e.name);
485
+ if (e.isDirectory()) results.push(...this.#walkFiles(full, exts));
486
+ else if (exts.some(x => e.name.endsWith(x))) results.push(full);
487
+ }
488
+ return results;
489
+ }
573
490
 
574
- function walkFiles(dir, exts) {
575
- const results = [];
576
- if (!fs.existsSync(dir)) return results;
577
- for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
578
- if (e.name.startsWith('.') || e.name === 'node_modules') continue;
579
- const full = path.join(dir, e.name);
580
- if (e.isDirectory()) results.push(...walkFiles(full, exts));
581
- else if (exts.some(x => e.name.endsWith(x))) results.push(full);
491
+ #progressBar(pct, width) {
492
+ const f = Math.round((pct / 100) * width);
493
+ return '[' + '█'.repeat(f) + '░'.repeat(width - f) + ']';
582
494
  }
583
- return results;
584
495
  }
585
496
 
586
- function progressBar(pct, width) {
587
- const f = Math.round((pct / 100) * width);
588
- return '[' + '█'.repeat(f) + '░'.repeat(width - f) + ']';
589
- }
497
+ module.exports = LangCommand;