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.
- package/package.json +3 -16
- package/src/admin/ActivityLog.js +153 -52
- package/src/admin/Admin.js +400 -167
- package/src/admin/AdminAuth.js +213 -98
- package/src/admin/FormGenerator.js +372 -0
- package/src/admin/HookRegistry.js +256 -0
- package/src/admin/QueryEngine.js +263 -0
- package/src/admin/ViewContext.js +309 -0
- package/src/admin/WidgetRegistry.js +406 -0
- package/src/admin/index.js +17 -0
- package/src/admin/resources/AdminResource.js +383 -97
- package/src/admin/static/admin.css +1341 -0
- package/src/admin/static/date-picker.css +157 -0
- package/src/admin/static/date-picker.js +316 -0
- package/src/admin/static/json-editor.css +649 -0
- package/src/admin/static/json-editor.js +1429 -0
- package/src/admin/static/ui.js +1044 -0
- package/src/admin/views/layouts/base.njk +65 -1013
- package/src/admin/views/pages/detail.njk +40 -16
- package/src/admin/views/pages/form.njk +47 -599
- package/src/admin/views/pages/list.njk +145 -62
- package/src/admin/views/partials/form-field.njk +53 -0
- package/src/admin/views/partials/form-footer.njk +28 -0
- package/src/admin/views/partials/form-readonly.njk +114 -0
- package/src/admin/views/partials/form-scripts.njk +476 -0
- package/src/admin/views/partials/form-widget.njk +296 -0
- package/src/admin/views/partials/json-dialog.njk +80 -0
- package/src/admin/views/partials/json-editor.njk +37 -0
- package/src/admin.zip +0 -0
- package/src/auth/Auth.js +31 -10
- package/src/auth/AuthController.js +3 -1
- package/src/auth/AuthUser.js +119 -0
- package/src/cli.js +4 -2
- package/src/commands/createsuperuser.js +254 -0
- package/src/commands/lang.js +589 -0
- package/src/commands/migrate.js +154 -81
- package/src/commands/serve.js +82 -110
- package/src/container/AppInitializer.js +215 -0
- package/src/container/Application.js +278 -253
- package/src/container/HttpServer.js +156 -0
- package/src/container/MillasApp.js +29 -279
- package/src/container/MillasConfig.js +192 -0
- package/src/core/admin.js +5 -0
- package/src/core/auth.js +9 -0
- package/src/core/db.js +9 -0
- package/src/core/foundation.js +59 -0
- package/src/core/http.js +11 -0
- package/src/core/lang.js +1 -0
- package/src/core/mail.js +6 -0
- package/src/core/queue.js +7 -0
- package/src/core/validation.js +29 -0
- package/src/facades/Admin.js +1 -1
- package/src/facades/Auth.js +22 -39
- package/src/facades/Cache.js +21 -10
- package/src/facades/Database.js +1 -1
- package/src/facades/Events.js +18 -17
- package/src/facades/Facade.js +197 -0
- package/src/facades/Http.js +42 -45
- package/src/facades/Log.js +25 -49
- package/src/facades/Mail.js +27 -32
- package/src/facades/Queue.js +22 -15
- package/src/facades/Storage.js +18 -10
- package/src/facades/Url.js +53 -0
- package/src/http/HttpClient.js +673 -0
- package/src/http/ResponseDispatcher.js +18 -111
- package/src/http/UrlGenerator.js +375 -0
- package/src/http/WelcomePage.js +273 -0
- package/src/http/adapters/ExpressAdapter.js +315 -0
- package/src/http/adapters/HttpAdapter.js +168 -0
- package/src/http/adapters/index.js +9 -0
- package/src/i18n/I18nServiceProvider.js +91 -0
- package/src/i18n/Translator.js +635 -0
- package/src/i18n/defaults.js +122 -0
- package/src/i18n/index.js +164 -0
- package/src/i18n/locales/en.js +55 -0
- package/src/i18n/locales/sw.js +48 -0
- package/src/index.js +5 -144
- package/src/logger/formatters/PrettyFormatter.js +103 -57
- package/src/logger/internal.js +2 -2
- package/src/logger/patchConsole.js +91 -81
- package/src/middleware/MiddlewareRegistry.js +62 -82
- package/src/migrations/system/0001_users.js +21 -0
- package/src/migrations/system/0002_admin_log.js +25 -0
- package/src/migrations/system/0003_sessions.js +23 -0
- package/src/orm/fields/index.js +210 -188
- package/src/orm/migration/DefaultValueParser.js +325 -0
- package/src/orm/migration/InteractiveResolver.js +191 -0
- package/src/orm/migration/Makemigrations.js +312 -0
- package/src/orm/migration/MigrationGraph.js +227 -0
- package/src/orm/migration/MigrationRunner.js +202 -108
- package/src/orm/migration/MigrationWriter.js +463 -0
- package/src/orm/migration/ModelInspector.js +412 -344
- package/src/orm/migration/ModelScanner.js +225 -0
- package/src/orm/migration/ProjectState.js +213 -0
- package/src/orm/migration/RenameDetector.js +175 -0
- package/src/orm/migration/SchemaBuilder.js +8 -81
- package/src/orm/migration/operations/base.js +57 -0
- package/src/orm/migration/operations/column.js +191 -0
- package/src/orm/migration/operations/fields.js +252 -0
- package/src/orm/migration/operations/index.js +55 -0
- package/src/orm/migration/operations/models.js +152 -0
- package/src/orm/migration/operations/registry.js +131 -0
- package/src/orm/migration/operations/special.js +51 -0
- package/src/orm/migration/utils.js +208 -0
- package/src/orm/model/Model.js +81 -13
- package/src/providers/AdminServiceProvider.js +66 -9
- package/src/providers/AuthServiceProvider.js +46 -7
- package/src/providers/CacheStorageServiceProvider.js +5 -3
- package/src/providers/DatabaseServiceProvider.js +3 -2
- package/src/providers/EventServiceProvider.js +2 -1
- package/src/providers/LogServiceProvider.js +7 -3
- package/src/providers/MailServiceProvider.js +4 -3
- package/src/providers/QueueServiceProvider.js +4 -3
- package/src/router/Router.js +119 -152
- package/src/scaffold/templates.js +83 -26
- 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
|
+
}
|