millas 0.2.12-beta → 0.2.12-beta-2

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 (116) hide show
  1. package/package.json +3 -16
  2. package/src/admin/ActivityLog.js +153 -52
  3. package/src/admin/Admin.js +400 -167
  4. package/src/admin/AdminAuth.js +213 -98
  5. package/src/admin/FormGenerator.js +372 -0
  6. package/src/admin/HookRegistry.js +256 -0
  7. package/src/admin/QueryEngine.js +263 -0
  8. package/src/admin/ViewContext.js +309 -0
  9. package/src/admin/WidgetRegistry.js +406 -0
  10. package/src/admin/index.js +17 -0
  11. package/src/admin/resources/AdminResource.js +383 -97
  12. package/src/admin/static/admin.css +1341 -0
  13. package/src/admin/static/date-picker.css +157 -0
  14. package/src/admin/static/date-picker.js +316 -0
  15. package/src/admin/static/json-editor.css +649 -0
  16. package/src/admin/static/json-editor.js +1429 -0
  17. package/src/admin/static/ui.js +1044 -0
  18. package/src/admin/views/layouts/base.njk +65 -1013
  19. package/src/admin/views/pages/detail.njk +40 -16
  20. package/src/admin/views/pages/form.njk +47 -599
  21. package/src/admin/views/pages/list.njk +145 -62
  22. package/src/admin/views/partials/form-field.njk +53 -0
  23. package/src/admin/views/partials/form-footer.njk +28 -0
  24. package/src/admin/views/partials/form-readonly.njk +114 -0
  25. package/src/admin/views/partials/form-scripts.njk +476 -0
  26. package/src/admin/views/partials/form-widget.njk +296 -0
  27. package/src/admin/views/partials/json-dialog.njk +80 -0
  28. package/src/admin/views/partials/json-editor.njk +37 -0
  29. package/src/admin.zip +0 -0
  30. package/src/auth/Auth.js +31 -10
  31. package/src/auth/AuthController.js +3 -1
  32. package/src/auth/AuthUser.js +119 -0
  33. package/src/cli.js +4 -2
  34. package/src/commands/createsuperuser.js +254 -0
  35. package/src/commands/lang.js +589 -0
  36. package/src/commands/migrate.js +154 -81
  37. package/src/commands/serve.js +82 -110
  38. package/src/container/AppInitializer.js +215 -0
  39. package/src/container/Application.js +278 -253
  40. package/src/container/HttpServer.js +156 -0
  41. package/src/container/MillasApp.js +29 -279
  42. package/src/container/MillasConfig.js +192 -0
  43. package/src/core/admin.js +5 -0
  44. package/src/core/auth.js +9 -0
  45. package/src/core/db.js +9 -0
  46. package/src/core/foundation.js +59 -0
  47. package/src/core/http.js +11 -0
  48. package/src/core/lang.js +1 -0
  49. package/src/core/mail.js +6 -0
  50. package/src/core/queue.js +7 -0
  51. package/src/core/validation.js +29 -0
  52. package/src/facades/Admin.js +1 -1
  53. package/src/facades/Auth.js +22 -39
  54. package/src/facades/Cache.js +21 -10
  55. package/src/facades/Database.js +1 -1
  56. package/src/facades/Events.js +18 -17
  57. package/src/facades/Facade.js +197 -0
  58. package/src/facades/Http.js +42 -45
  59. package/src/facades/Log.js +25 -49
  60. package/src/facades/Mail.js +27 -32
  61. package/src/facades/Queue.js +22 -15
  62. package/src/facades/Storage.js +18 -10
  63. package/src/facades/Url.js +53 -0
  64. package/src/http/HttpClient.js +673 -0
  65. package/src/http/ResponseDispatcher.js +18 -111
  66. package/src/http/UrlGenerator.js +375 -0
  67. package/src/http/WelcomePage.js +273 -0
  68. package/src/http/adapters/ExpressAdapter.js +315 -0
  69. package/src/http/adapters/HttpAdapter.js +168 -0
  70. package/src/http/adapters/index.js +9 -0
  71. package/src/i18n/I18nServiceProvider.js +91 -0
  72. package/src/i18n/Translator.js +635 -0
  73. package/src/i18n/defaults.js +122 -0
  74. package/src/i18n/index.js +164 -0
  75. package/src/i18n/locales/en.js +55 -0
  76. package/src/i18n/locales/sw.js +48 -0
  77. package/src/index.js +5 -144
  78. package/src/logger/formatters/PrettyFormatter.js +103 -57
  79. package/src/logger/internal.js +2 -2
  80. package/src/logger/patchConsole.js +91 -81
  81. package/src/middleware/MiddlewareRegistry.js +62 -82
  82. package/src/migrations/system/0001_users.js +21 -0
  83. package/src/migrations/system/0002_admin_log.js +25 -0
  84. package/src/migrations/system/0003_sessions.js +23 -0
  85. package/src/orm/fields/index.js +210 -188
  86. package/src/orm/migration/DefaultValueParser.js +325 -0
  87. package/src/orm/migration/InteractiveResolver.js +191 -0
  88. package/src/orm/migration/Makemigrations.js +312 -0
  89. package/src/orm/migration/MigrationGraph.js +227 -0
  90. package/src/orm/migration/MigrationRunner.js +202 -108
  91. package/src/orm/migration/MigrationWriter.js +463 -0
  92. package/src/orm/migration/ModelInspector.js +412 -344
  93. package/src/orm/migration/ModelScanner.js +225 -0
  94. package/src/orm/migration/ProjectState.js +213 -0
  95. package/src/orm/migration/RenameDetector.js +175 -0
  96. package/src/orm/migration/SchemaBuilder.js +8 -81
  97. package/src/orm/migration/operations/base.js +57 -0
  98. package/src/orm/migration/operations/column.js +191 -0
  99. package/src/orm/migration/operations/fields.js +252 -0
  100. package/src/orm/migration/operations/index.js +55 -0
  101. package/src/orm/migration/operations/models.js +152 -0
  102. package/src/orm/migration/operations/registry.js +131 -0
  103. package/src/orm/migration/operations/special.js +51 -0
  104. package/src/orm/migration/utils.js +208 -0
  105. package/src/orm/model/Model.js +81 -13
  106. package/src/providers/AdminServiceProvider.js +66 -9
  107. package/src/providers/AuthServiceProvider.js +46 -7
  108. package/src/providers/CacheStorageServiceProvider.js +5 -3
  109. package/src/providers/DatabaseServiceProvider.js +3 -2
  110. package/src/providers/EventServiceProvider.js +2 -1
  111. package/src/providers/LogServiceProvider.js +7 -3
  112. package/src/providers/MailServiceProvider.js +4 -3
  113. package/src/providers/QueueServiceProvider.js +4 -3
  114. package/src/router/Router.js +119 -152
  115. package/src/scaffold/templates.js +83 -26
  116. package/src/facades/Validation.js +0 -69
@@ -0,0 +1,589 @@
1
+ 'use strict';
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
+ }
60
+
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
+ }
79
+
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
+ }
103
+
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
+ }
109
+
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
+ }
154
+
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
+ }
169
+ }
170
+
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'));
178
+ }
179
+
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);
184
+ }
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
+
199
+ const targets = readLocales(langPath)
200
+ .filter(l => l !== 'en')
201
+ .filter(l => !locale || l === locale);
202
+
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
+ }
209
+
210
+ // Load en as source of truth
211
+ const enKeys = loadAllNamespaces(langPath, 'en'); // Map<ns, catalogue>
212
+
213
+ let totalMissing = 0;
214
+ console.log('');
215
+
216
+ for (const loc of targets) {
217
+ const locKeys = loadAllNamespaces(langPath, loc);
218
+ const missing = [];
219
+
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
+ }
229
+
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;
234
+
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)}`)));
240
+ }
241
+ }
242
+
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'));
247
+
248
+ } catch (err) {
249
+ console.error(chalk.red(`\n ✖ ${err.message}\n`));
250
+ process.exit(1);
251
+ }
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
+ }
265
+
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);
269
+
270
+ console.log(chalk.cyan(`\n Translation Stats (source: en, ${total} key${total !== 1 ? 's' : ''})\n`));
271
+ console.log(chalk.gray(' ' + '─'.repeat(55)));
272
+
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('');
292
+
293
+ } catch (err) {
294
+ console.error(chalk.red(`\n ✖ ${err.message}\n`));
295
+ process.exit(1);
296
+ }
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;
312
+ }
313
+
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
+ }
329
+
330
+ } catch (err) {
331
+ console.error(chalk.red(`\n ✖ ${err.message}\n`));
332
+ process.exit(1);
333
+ }
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;
357
+ }
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
+ }
376
+
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
+ }
394
+ }
395
+ }
396
+ }
397
+ return merged;
398
+ }
399
+
400
+ // ─── Namespace parsing ────────────────────────────────────────────────────────
401
+
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
+ }
407
+
408
+ // ─── Key extractor ────────────────────────────────────────────────────────────
409
+
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;
418
+
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);
423
+
424
+ // __('key') __('ns::key')
425
+ scanSingle(source, /\b__\(\s*(['"`])((?:\\.|(?!\1).)*)\1/g, 2, null, rel, keys);
426
+
427
+ // _f('key', ...)
428
+ scanSingle(source, /\b_f\(\s*(['"`])((?:\\.|(?!\1).)*)\1/g, 2, null, rel, keys);
429
+
430
+ // _n('singular', 'plural', n)
431
+ scanPlural(source, /\b_n\(\s*(['"`])((?:\\.|(?!\1).)*)\1\s*,\s*(['"`])((?:\\.|(?!\3).)*)\3/g, rel, keys);
432
+
433
+ // _fn('singular', 'plural', n)
434
+ scanPlural(source, /\b_fn\(\s*(['"`])((?:\\.|(?!\1).)*)\1\s*,\s*(['"`])((?:\\.|(?!\3).)*)\3/g, rel, keys);
435
+
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);
441
+ }
442
+
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);
446
+ }
447
+
448
+ return keys;
449
+ }
450
+
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
+ }
455
+
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
+ }
460
+
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
+ }
469
+
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
+ }
475
+
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);
488
+ }
489
+ return groups;
490
+ }
491
+
492
+ // ─── Catalogue I/O ────────────────────────────────────────────────────────────
493
+
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
+ }
501
+
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;
524
+ }
525
+ }
526
+ }
527
+ return result;
528
+ }
529
+
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)},`);
542
+ }
543
+ lines.push('};\n');
544
+ content = lines.join('\n');
545
+ }
546
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
547
+ fs.writeFileSync(filePath, content, 'utf8');
548
+ }
549
+
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
+ }
562
+
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
+ }
573
+
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);
582
+ }
583
+ return results;
584
+ }
585
+
586
+ function progressBar(pct, width) {
587
+ const f = Math.round((pct / 100) * width);
588
+ return '[' + '█'.repeat(f) + '░'.repeat(width - f) + ']';
589
+ }