millas 0.2.12-beta-1 → 0.2.13

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 (120) hide show
  1. package/package.json +3 -2
  2. package/src/admin/ActivityLog.js +153 -52
  3. package/src/admin/Admin.js +516 -199
  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 +318 -0
  9. package/src/admin/WidgetRegistry.js +406 -0
  10. package/src/admin/index.js +17 -0
  11. package/src/admin/resources/AdminResource.js +393 -97
  12. package/src/admin/static/admin.css +1422 -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 +87 -1046
  19. package/src/admin/views/pages/detail.njk +56 -21
  20. package/src/admin/views/pages/error.njk +65 -0
  21. package/src/admin/views/pages/form.njk +47 -599
  22. package/src/admin/views/pages/list.njk +270 -62
  23. package/src/admin/views/partials/form-field.njk +53 -0
  24. package/src/admin/views/partials/form-footer.njk +28 -0
  25. package/src/admin/views/partials/form-readonly.njk +114 -0
  26. package/src/admin/views/partials/form-scripts.njk +480 -0
  27. package/src/admin/views/partials/form-widget.njk +297 -0
  28. package/src/admin/views/partials/icons.njk +64 -0
  29. package/src/admin/views/partials/json-dialog.njk +80 -0
  30. package/src/admin/views/partials/json-editor.njk +37 -0
  31. package/src/ai/AIManager.js +954 -0
  32. package/src/ai/AITokenBudget.js +250 -0
  33. package/src/ai/PromptGuard.js +216 -0
  34. package/src/ai/agents.js +218 -0
  35. package/src/ai/conversation.js +213 -0
  36. package/src/ai/drivers.js +734 -0
  37. package/src/ai/files.js +249 -0
  38. package/src/ai/media.js +303 -0
  39. package/src/ai/pricing.js +152 -0
  40. package/src/ai/provider_tools.js +114 -0
  41. package/src/ai/types.js +356 -0
  42. package/src/auth/Auth.js +18 -2
  43. package/src/auth/AuthUser.js +65 -44
  44. package/src/cli.js +3 -1
  45. package/src/commands/createsuperuser.js +267 -0
  46. package/src/commands/lang.js +589 -0
  47. package/src/commands/migrate.js +154 -81
  48. package/src/commands/serve.js +3 -4
  49. package/src/container/AppInitializer.js +101 -20
  50. package/src/container/Application.js +31 -1
  51. package/src/container/MillasApp.js +10 -3
  52. package/src/container/MillasConfig.js +35 -6
  53. package/src/core/admin.js +5 -0
  54. package/src/core/db.js +2 -1
  55. package/src/core/foundation.js +2 -10
  56. package/src/core/lang.js +1 -0
  57. package/src/errors/HttpError.js +32 -16
  58. package/src/facades/AI.js +411 -0
  59. package/src/facades/Hash.js +67 -0
  60. package/src/facades/Process.js +144 -0
  61. package/src/hashing/Hash.js +262 -0
  62. package/src/http/HtmlEscape.js +162 -0
  63. package/src/http/MillasRequest.js +63 -7
  64. package/src/http/MillasResponse.js +70 -4
  65. package/src/http/ResponseDispatcher.js +21 -27
  66. package/src/http/SafeFilePath.js +195 -0
  67. package/src/http/SafeRedirect.js +62 -0
  68. package/src/http/SecurityBootstrap.js +70 -0
  69. package/src/http/helpers.js +40 -125
  70. package/src/http/index.js +10 -1
  71. package/src/http/middleware/CsrfMiddleware.js +258 -0
  72. package/src/http/middleware/RateLimiter.js +314 -0
  73. package/src/http/middleware/SecurityHeaders.js +281 -0
  74. package/src/i18n/I18nServiceProvider.js +91 -0
  75. package/src/i18n/Translator.js +643 -0
  76. package/src/i18n/defaults.js +122 -0
  77. package/src/i18n/index.js +164 -0
  78. package/src/i18n/locales/en.js +55 -0
  79. package/src/i18n/locales/sw.js +48 -0
  80. package/src/logger/LogRedactor.js +247 -0
  81. package/src/logger/Logger.js +1 -1
  82. package/src/logger/formatters/JsonFormatter.js +11 -4
  83. package/src/logger/formatters/PrettyFormatter.js +103 -65
  84. package/src/logger/formatters/SimpleFormatter.js +14 -3
  85. package/src/middleware/ThrottleMiddleware.js +27 -4
  86. package/src/migrations/system/0001_users.js +21 -0
  87. package/src/migrations/system/0002_admin_log.js +25 -0
  88. package/src/migrations/system/0003_sessions.js +23 -0
  89. package/src/orm/fields/index.js +210 -188
  90. package/src/orm/migration/DefaultValueParser.js +325 -0
  91. package/src/orm/migration/InteractiveResolver.js +191 -0
  92. package/src/orm/migration/Makemigrations.js +312 -0
  93. package/src/orm/migration/MigrationGraph.js +227 -0
  94. package/src/orm/migration/MigrationRunner.js +202 -108
  95. package/src/orm/migration/MigrationWriter.js +463 -0
  96. package/src/orm/migration/ModelInspector.js +143 -74
  97. package/src/orm/migration/ModelScanner.js +225 -0
  98. package/src/orm/migration/ProjectState.js +213 -0
  99. package/src/orm/migration/RenameDetector.js +175 -0
  100. package/src/orm/migration/SchemaBuilder.js +8 -81
  101. package/src/orm/migration/operations/base.js +57 -0
  102. package/src/orm/migration/operations/column.js +191 -0
  103. package/src/orm/migration/operations/fields.js +252 -0
  104. package/src/orm/migration/operations/index.js +55 -0
  105. package/src/orm/migration/operations/models.js +152 -0
  106. package/src/orm/migration/operations/registry.js +131 -0
  107. package/src/orm/migration/operations/special.js +51 -0
  108. package/src/orm/migration/utils.js +208 -0
  109. package/src/orm/model/Model.js +81 -13
  110. package/src/process/Process.js +333 -0
  111. package/src/providers/AdminServiceProvider.js +66 -9
  112. package/src/providers/AuthServiceProvider.js +40 -5
  113. package/src/providers/CacheStorageServiceProvider.js +2 -2
  114. package/src/providers/DatabaseServiceProvider.js +3 -2
  115. package/src/providers/LogServiceProvider.js +4 -1
  116. package/src/providers/MailServiceProvider.js +1 -1
  117. package/src/providers/QueueServiceProvider.js +1 -1
  118. package/src/router/MiddlewareRegistry.js +27 -2
  119. package/src/scaffold/templates.js +80 -21
  120. package/src/validation/Validator.js +348 -607
@@ -0,0 +1,643 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+
6
+ /**
7
+ * Translator
8
+ *
9
+ * The Millas i18n engine. Provides Django-style translation with:
10
+ * - _() gettext — translate a string
11
+ * - _n() ngettext — singular/plural
12
+ * - _p() pgettext — contextual translation (same word, different meaning)
13
+ * - _f() format — translate + interpolate variables
14
+ *
15
+ * ── Translation file format ────────────────────────────────────────────────
16
+ *
17
+ * Translation files live in lang/ at the project root (configurable).
18
+ * Each locale has its own JS or JSON file:
19
+ *
20
+ * lang/
21
+ * en.js ← source language (can be empty / identity)
22
+ * sw.js ← Swahili
23
+ * fr.js ← French
24
+ * ar.js ← Arabic (RTL)
25
+ * es.js ← Spanish
26
+ *
27
+ * File format (JS export or JSON):
28
+ *
29
+ * // lang/sw.js
30
+ * module.exports = {
31
+ * // Simple string
32
+ * 'Hello': 'Habari',
33
+ *
34
+ * // Plural forms — array [singular, plural, ...] for complex pluralisation
35
+ * 'You have %d message': ['Una ujumbe %d', 'Una ujumbe %d'],
36
+ *
37
+ * // Contextual — prefixed with context|
38
+ * 'menu|File': 'Faili',
39
+ * 'menu|Edit': 'Hariri',
40
+ *
41
+ * // Interpolation uses %s (string), %d (number), {name} (named)
42
+ * 'Welcome, %s!': 'Karibu, %s!',
43
+ * 'Welcome, {name}!': 'Karibu, {name}!',
44
+ * };
45
+ *
46
+ * ── Usage ──────────────────────────────────────────────────────────────────
47
+ *
48
+ * const { __, _n, _p, _f } = require('millas/src/i18n');
49
+ *
50
+ * // Basic
51
+ * __('Hello') // 'Habari' (if locale=sw)
52
+ *
53
+ * // Plural
54
+ * _n('You have %d message', 'You have %d messages', count)
55
+ *
56
+ * // Contextual
57
+ * _p('menu', 'File') // 'Faili'
58
+ *
59
+ * // Interpolation with named vars
60
+ * _f('Welcome, {name}!', { name: 'Alice' })
61
+ *
62
+ * // Interpolation with positional
63
+ * _f('Hello, %s! You have %d items.', 'Alice', 3)
64
+ *
65
+ * ── Locale detection ───────────────────────────────────────────────────────
66
+ *
67
+ * Locale is resolved in this order:
68
+ * 1. Per-request override — Trans.setLocale('sw') in middleware
69
+ * 2. Accept-Language header — parsed from req automatically
70
+ * 3. config/app.js locale — static default ('en')
71
+ *
72
+ * ── Lazy loading ───────────────────────────────────────────────────────────
73
+ *
74
+ * Translation files are loaded on first use and cached in memory.
75
+ * Reloading is possible in development with Trans.reload().
76
+ *
77
+ * ── Fallback chain ─────────────────────────────────────────────────────────
78
+ *
79
+ * sw-KE → sw → en → original string (never throws, always returns something)
80
+ */
81
+ class Translator {
82
+ constructor() {
83
+ /** Active locale — used when no per-call override is given */
84
+ this._locale = 'en';
85
+
86
+ /** Default/fallback locale */
87
+ this._fallback = 'en';
88
+
89
+ /** Directory where lang files live */
90
+ this._langPath = null;
91
+
92
+ /** In-memory catalogue cache: Map<locale, catalogue> */
93
+ this._catalogues = new Map();
94
+
95
+ /** Whether to warn when a translation key is missing */
96
+ this._warnMissing = false;
97
+
98
+ /** Set of missing keys collected during this session */
99
+ this._missing = new Set();
100
+ }
101
+
102
+ // ─── Configuration ────────────────────────────────────────────────────────
103
+
104
+ /**
105
+ * Configure the translator.
106
+ *
107
+ * @param {object} options
108
+ * @param {string} [options.locale='en'] — active locale
109
+ * @param {string} [options.fallback='en'] — fallback locale
110
+ * @param {string} [options.langPath] — abs path to lang/ dir
111
+ * @param {boolean} [options.warnMissing=false] — log missing keys
112
+ */
113
+ configure({ locale, fallback, langPath, warnMissing } = {}) {
114
+ if (locale !== undefined) this._locale = locale;
115
+ if (fallback !== undefined) this._fallback = fallback;
116
+ if (langPath !== undefined) this._langPath = langPath;
117
+ if (warnMissing !== undefined) this._warnMissing = warnMissing;
118
+ return this;
119
+ }
120
+
121
+ /**
122
+ * Set the active locale.
123
+ * In a web context, call this per-request in middleware.
124
+ */
125
+ setLocale(locale) {
126
+ this._locale = this._normaliseLocale(locale);
127
+ return this;
128
+ }
129
+
130
+ /**
131
+ * Get the active locale.
132
+ */
133
+ getLocale() {
134
+ return this._locale;
135
+ }
136
+
137
+ /**
138
+ * Get a list of all available locales (files found in lang/).
139
+ */
140
+ availableLocales() {
141
+ if (!this._langPath || !fs.existsSync(this._langPath)) return ['en'];
142
+ return fs.readdirSync(this._langPath, { withFileTypes: true })
143
+ .filter(e => (e.isFile() && /\.(js|json)$/.test(e.name)) || e.isDirectory())
144
+ .map(e => e.name.replace(/\.(js|json)$/, ''));
145
+ }
146
+
147
+ /**
148
+ * Reload all cached catalogues (useful in development).
149
+ */
150
+ reload() {
151
+ this._catalogues.clear();
152
+ return this;
153
+ }
154
+
155
+ // ─── Core translation functions ───────────────────────────────────────────
156
+
157
+ /**
158
+ * Translate a string (gettext).
159
+ *
160
+ * __('Hello')
161
+ * __('Hello', 'sw') // override locale for this call
162
+ *
163
+ * @param {string} key
164
+ * @param {string} [locale]
165
+ * @returns {string}
166
+ */
167
+ translate(key, locale) {
168
+ const loc = this._normaliseLocale(locale || this._locale);
169
+
170
+ // ── Namespace parsing: 'auth::Invalid 2FA code' ───────────────────────
171
+ // Splits into { namespace: 'auth', bare: 'Invalid 2FA code' }
172
+ // Bare keys (no ::) use namespace 'messages' (the default file).
173
+ const { namespace, bare } = this._parseKey(key);
174
+ const catalogue = this._getCatalogue(loc, namespace);
175
+
176
+ // Direct lookup using the bare key (without namespace prefix)
177
+ // null means 'needs translation' — treat as missing, fall through
178
+ if (catalogue[bare] !== undefined && catalogue[bare] !== null) {
179
+ const val = catalogue[bare];
180
+ if (Array.isArray(val) && val[0] === null) { /* plural not translated yet — fall through */ }
181
+ else return Array.isArray(val) ? val[0] : String(val);
182
+ }
183
+
184
+ // Fallback locale
185
+ if (loc !== this._fallback) {
186
+ const fallCatalogue = this._getCatalogue(this._fallback, namespace);
187
+ if (fallCatalogue[bare] !== undefined) {
188
+ const val = fallCatalogue[bare];
189
+ return Array.isArray(val) ? val[0] : String(val);
190
+ }
191
+ }
192
+
193
+ this._recordMissing(key, loc);
194
+ return bare; // return the bare key, not the full 'namespace::key'
195
+ }
196
+
197
+ /**
198
+ * Translate with plural forms (ngettext).
199
+ *
200
+ * _n('You have %d message', 'You have %d messages', count)
201
+ * _n('One item', '%d items', count, 'sw')
202
+ *
203
+ * The plural form index is chosen by getPluralForm() which handles
204
+ * language-specific rules (Arabic has 6 forms, Russian has 3, etc.).
205
+ *
206
+ * @param {string} singular
207
+ * @param {string} plural
208
+ * @param {number} count
209
+ * @param {string} [locale]
210
+ * @returns {string}
211
+ */
212
+ ngettext(singular, plural, count, locale) {
213
+ const loc = this._normaliseLocale(locale || this._locale);
214
+
215
+ // Parse namespace from the singular key: 'auth::Invalid 2FA code'
216
+ const { namespace, bare } = this._parseKey(singular);
217
+ const catalogue = this._getCatalogue(loc, namespace);
218
+
219
+ let forms;
220
+ if (catalogue[bare] !== undefined && catalogue[bare] !== null) {
221
+ const val = catalogue[bare];
222
+ const arr = Array.isArray(val) ? val : [String(val), String(val)];
223
+ if (!arr.some(v => v === null)) forms = arr; // only use if fully translated
224
+ }
225
+ if (!forms && loc !== this._fallback) {
226
+ const fallCatalogue = this._getCatalogue(this._fallback, namespace);
227
+ if (fallCatalogue[bare] !== undefined && fallCatalogue[bare] !== null) {
228
+ const val = fallCatalogue[bare];
229
+ forms = Array.isArray(val) ? val : [String(val), String(val)];
230
+ }
231
+ }
232
+
233
+ if (!forms) {
234
+ this._recordMissing(singular, loc);
235
+ forms = [bare, plural ? this._parseKey(plural).bare : bare];
236
+ }
237
+
238
+ const idx = this._pluralIndex(count, loc);
239
+ return forms[Math.min(idx, forms.length - 1)];
240
+ }
241
+
242
+ /**
243
+ * Contextual translation (pgettext).
244
+ * Use when the same word has different meanings in different contexts.
245
+ *
246
+ * _p('menu', 'File') // 'Faili' — the File menu
247
+ * _p('action', 'File') // 'Faili' — the verb "to file"
248
+ *
249
+ * In the catalogue, context keys are stored as 'context|key'.
250
+ *
251
+ * @param {string} context
252
+ * @param {string} key
253
+ * @param {string} [locale]
254
+ * @returns {string}
255
+ */
256
+ pgettext(context, key, locale) {
257
+ return this.translate(`${context}|${key}`, locale) || key;
258
+ }
259
+
260
+ /**
261
+ * Translate and interpolate variables (format).
262
+ *
263
+ * Supports two interpolation styles:
264
+ *
265
+ * Named: _f('Welcome, {name}!', { name: 'Alice' })
266
+ * Positional: _f('Hello, %s! You have %d items.', 'Alice', 3)
267
+ *
268
+ * @param {string} key
269
+ * @param {...*} args — either a single plain object (named) or positional values
270
+ * @returns {string}
271
+ */
272
+ format(key, ...args) {
273
+ const translated = this.translate(key);
274
+ return this._interpolate(translated, args);
275
+ }
276
+
277
+ /**
278
+ * Translate plural + interpolate.
279
+ *
280
+ * _fn('You have %d message', 'You have %d messages', count)
281
+ * // → 'Una ujumbe 3' (if locale=sw, count=3)
282
+ *
283
+ * @param {string} singular
284
+ * @param {string} plural
285
+ * @param {number} count
286
+ * @param {...*} args — interpolation values (count is always the first positional)
287
+ * @returns {string}
288
+ */
289
+ nformat(singular, plural, count, ...args) {
290
+ const translated = this.ngettext(singular, plural, count);
291
+ // Prepend count so %d in the translated string gets the count value
292
+ return this._interpolate(translated, [count, ...args]);
293
+ }
294
+
295
+ // ─── Middleware ───────────────────────────────────────────────────────────
296
+
297
+ /**
298
+ * Express middleware that detects the locale from:
299
+ * 1. ?lang= query param
300
+ * 2. X-Language header
301
+ * 3. Accept-Language header
302
+ * 4. Cookie (millas_lang)
303
+ * 5. config default
304
+ *
305
+ * Sets req.locale and Trans.setLocale() for the duration of the request.
306
+ *
307
+ * app.use(Trans.middleware())
308
+ *
309
+ * @param {object} [options]
310
+ * @param {boolean} [options.cookie=true] — read/write millas_lang cookie
311
+ * @param {boolean} [options.query=true] — read ?lang= query param
312
+ * @param {string} [options.cookieName='millas_lang']
313
+ */
314
+ middleware({ cookie = true, query = true, cookieName = 'millas_lang' } = {}) {
315
+ const self = this;
316
+ return function millaI18nMiddleware(req, res, next) {
317
+ let locale = null;
318
+
319
+ // 1. Query param: ?lang=sw
320
+ if (query && req.query && req.query.lang) {
321
+ locale = req.query.lang;
322
+ // Persist to cookie so subsequent requests remember
323
+ if (cookie) {
324
+ // Locale is a non-sensitive preference value, not a security credential.
325
+ // httpOnly: false is intentional here so client-side JS can read/switch
326
+ // the locale without a round-trip. All other secure defaults still apply.
327
+ res.cookie(cookieName, locale, {
328
+ maxAge: 60 * 60 * 24 * 365,
329
+ httpOnly: false,
330
+ sameSite: 'Lax',
331
+ secure: process.env.NODE_ENV === 'production',
332
+ });
333
+ }
334
+ }
335
+
336
+ // 2. X-Language header (API clients)
337
+ if (!locale && req.headers['x-language']) {
338
+ locale = req.headers['x-language'];
339
+ }
340
+
341
+ // 3. Cookie
342
+ if (!locale && cookie && req.cookies && req.cookies[cookieName]) {
343
+ locale = req.cookies[cookieName];
344
+ }
345
+
346
+ // 4. Accept-Language header — take the first preferred language
347
+ if (!locale && req.headers['accept-language']) {
348
+ locale = self._parseAcceptLanguage(req.headers['accept-language']);
349
+ }
350
+
351
+ // 5. Fall back to configured default
352
+ if (!locale) locale = self._locale;
353
+
354
+ const normalised = self._normaliseLocale(locale);
355
+ req.locale = normalised;
356
+
357
+ // Make helper functions available on req so controllers can use them
358
+ req.__ = (key, ...a) => a.length ? self.format(key, ...a) : self.translate(key, normalised);
359
+ req._n = (s, p, c, ...a) => self.nformat(s, p, c, ...a);
360
+ req._p = (ctx, key) => self.pgettext(ctx, key, normalised);
361
+
362
+ // Temporarily override locale for this request
363
+ // (uses AsyncLocalStorage in a future version — for now simple override)
364
+ const prev = self._locale;
365
+ self.setLocale(normalised);
366
+ res.on('finish', () => self.setLocale(prev));
367
+
368
+ next();
369
+ };
370
+ }
371
+
372
+ // ─── Missing key reporting ────────────────────────────────────────────────
373
+
374
+ /**
375
+ * Return all keys that had no translation during this session.
376
+ * Useful for finding gaps in translation catalogues.
377
+ */
378
+ getMissingKeys() {
379
+ return [...this._missing];
380
+ }
381
+
382
+ /**
383
+ * Clear the missing keys log.
384
+ */
385
+ clearMissingKeys() {
386
+ this._missing.clear();
387
+ return this;
388
+ }
389
+
390
+ // ─── Internal ─────────────────────────────────────────────────────────────
391
+
392
+ /**
393
+ * Load and cache a translation catalogue for a locale.
394
+ * Returns an empty object for unknown locales (graceful degradation).
395
+ */
396
+ /**
397
+ * Load and cache a translation catalogue for a locale + namespace.
398
+ *
399
+ * Supports two directory layouts — auto-detected:
400
+ *
401
+ * Flat (single file per locale):
402
+ * lang/sw.js ← all keys in one file, no namespacing
403
+ *
404
+ * Namespaced (subdirectory per locale):
405
+ * lang/sw/auth.js ← __('auth::...')
406
+ * lang/sw/messages.js ← __('...') bare keys
407
+ * lang/sw/admin.js ← __('admin::...')
408
+ *
409
+ * When a subdirectory exists it takes priority over the flat file.
410
+ * Within the subdirectory, the namespace maps directly to the filename:
411
+ * 'auth' → lang/sw/auth.js
412
+ * 'messages' → lang/sw/messages.js (also the default for bare keys)
413
+ *
414
+ * Falls back through: sw-KE → sw → fallback locale → empty object.
415
+ * Never throws — always returns something.
416
+ */
417
+ _getCatalogue(locale, namespace = 'messages') {
418
+ const cacheKey = `${locale}::${namespace}`;
419
+ if (this._catalogues.has(cacheKey)) return this._catalogues.get(cacheKey);
420
+
421
+ if (!this._langPath) {
422
+ this._catalogues.set(cacheKey, {});
423
+ return {};
424
+ }
425
+
426
+ // Resolve locale variants: sw-KE → try sw-KE first, then sw
427
+ const variants = [locale];
428
+ if (locale.includes('-')) variants.push(locale.split('-')[0]);
429
+
430
+ for (const variant of variants) {
431
+ // ── Try subdirectory layout first: lang/sw/auth.js ─────────────────
432
+ const subDir = path.join(this._langPath, variant);
433
+ if (fs.existsSync(subDir) && fs.statSync(subDir).isDirectory()) {
434
+ // Try exact namespace name, then singular/plural variants so that
435
+ // 'message.js' and 'messages.js' both work for the 'messages' namespace.
436
+ const nsVariants = [namespace];
437
+ if (namespace.endsWith('s')) nsVariants.push(namespace.slice(0, -1)); // messages → message
438
+ else nsVariants.push(namespace + 's'); // message → messages
439
+
440
+ let nsFile = null;
441
+ for (const nsv of nsVariants) {
442
+ nsFile = this._loadFile(path.join(subDir, `${nsv}.js`))
443
+ ?? this._loadFile(path.join(subDir, `${nsv}.json`));
444
+ if (nsFile !== null) break;
445
+ }
446
+
447
+ if (nsFile !== null) {
448
+ this._catalogues.set(cacheKey, nsFile);
449
+ return nsFile;
450
+ }
451
+ // Namespace file not found in subdir — return empty (don't fall to flat)
452
+ this._catalogues.set(cacheKey, {});
453
+ return {};
454
+ }
455
+
456
+ // ── Flat layout: lang/sw.js — all keys in one file ─────────────────
457
+ // For flat files, ignore namespace — everything lives in one catalogue.
458
+ // Cache under all namespace variants so repeated lookups are fast.
459
+ const flatFile = this._loadFile(path.join(this._langPath, `${variant}.js`))
460
+ ?? this._loadFile(path.join(this._langPath, `${variant}.json`));
461
+ if (flatFile !== null) {
462
+ // Cache this catalogue for every possible namespace key
463
+ this._catalogues.set(`${locale}::${namespace}`, flatFile);
464
+ this._catalogues.set(`${variant}::${namespace}`, flatFile);
465
+ return flatFile;
466
+ }
467
+ }
468
+
469
+ this._catalogues.set(cacheKey, {});
470
+ return {};
471
+ }
472
+
473
+ /**
474
+ * Load a single file safely. Returns null if not found or parse error.
475
+ */
476
+ _loadFile(filePath) {
477
+ if (!fs.existsSync(filePath)) return null;
478
+ try {
479
+ delete require.cache[require.resolve(filePath)];
480
+ return require(filePath) || {};
481
+ } catch (err) {
482
+ process.stderr.write(`[i18n] Failed to load ${filePath}: ${err.message}\n`);
483
+ return null;
484
+ }
485
+ }
486
+
487
+ /**
488
+ * Parse a key that may contain a namespace prefix.
489
+ *
490
+ * 'auth::Invalid 2FA code' → { namespace: 'auth', bare: 'Invalid 2FA code' }
491
+ * 'Invalid 2FA code' → { namespace: 'messages', bare: 'Invalid 2FA code' }
492
+ * 'menu|File' → { namespace: 'messages', bare: 'menu|File' }
493
+ *
494
+ * The '::' separator is chosen to avoid collision with '|' (pgettext context)
495
+ * and '.' (common in English sentences).
496
+ */
497
+ _parseKey(key) {
498
+ const sep = key.indexOf('::');
499
+ if (sep === -1) return { namespace: 'messages', bare: key };
500
+ return {
501
+ namespace: key.slice(0, sep).trim() || 'messages',
502
+ bare: key.slice(sep + 2),
503
+ };
504
+ }
505
+
506
+ /**
507
+ * Normalise a locale string: 'en_US' → 'en-US', lowercase base.
508
+ */
509
+ _normaliseLocale(locale) {
510
+ if (!locale) return this._fallback;
511
+ return String(locale).replace(/_/g, '-').trim();
512
+ }
513
+
514
+ /**
515
+ * Parse the first preferred language from an Accept-Language header.
516
+ * 'sw-KE,sw;q=0.9,en;q=0.8' → 'sw-KE'
517
+ */
518
+ _parseAcceptLanguage(header) {
519
+ if (!header) return null;
520
+ const parts = header.split(',').map(p => {
521
+ const [lang, q] = p.trim().split(';q=');
522
+ return { lang: lang.trim(), q: q ? parseFloat(q) : 1 };
523
+ });
524
+ parts.sort((a, b) => b.q - a.q);
525
+ return parts[0]?.lang || null;
526
+ }
527
+
528
+ /**
529
+ * Choose the correct plural form index for a count in a given locale.
530
+ *
531
+ * Covers the most common plural rules. For full CLDR coverage, an
532
+ * external library (intl-pluralrules) can be plugged in via configure().
533
+ */
534
+ _pluralIndex(count, locale) {
535
+ const base = locale.split('-')[0].toLowerCase();
536
+
537
+ switch (base) {
538
+ // 1 form — no plurals (Chinese, Japanese, Korean, Thai, Vietnamese, Indonesian)
539
+ case 'zh': case 'ja': case 'ko': case 'th': case 'vi': case 'id':
540
+ case 'ms': case 'tr': case 'ka': case 'az':
541
+ return 0;
542
+
543
+ // 2 forms — standard (n != 1): English, German, French, Spanish, Portuguese, etc.
544
+ case 'en': case 'de': case 'nl': case 'sv': case 'no': case 'da':
545
+ case 'fi': case 'hu': case 'el': case 'he': case 'it': case 'es':
546
+ case 'pt': case 'af': case 'bg': case 'ca': case 'et': case 'eu':
547
+ case 'hi': case 'sw': case 'ur': case 'bn':
548
+ return count !== 1 ? 1 : 0;
549
+
550
+ // French: 0 and 1 are singular
551
+ case 'fr':
552
+ return count > 1 ? 1 : 0;
553
+
554
+ // Russian, Ukrainian, Serbian, Croatian — 3 plural forms
555
+ case 'ru': case 'uk': case 'sr': case 'hr': case 'bs': {
556
+ const mod10 = count % 10, mod100 = count % 100;
557
+ if (mod10 === 1 && mod100 !== 11) return 0;
558
+ if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return 1;
559
+ return 2;
560
+ }
561
+
562
+ // Polish — 3 plural forms
563
+ case 'pl': {
564
+ const mod10 = count % 10, mod100 = count % 100;
565
+ if (count === 1) return 0;
566
+ if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return 1;
567
+ return 2;
568
+ }
569
+
570
+ // Czech, Slovak
571
+ case 'cs': case 'sk':
572
+ if (count === 1) return 0;
573
+ if (count >= 2 && count <= 4) return 1;
574
+ return 2;
575
+
576
+ // Arabic — 6 plural forms
577
+ case 'ar': {
578
+ if (count === 0) return 0;
579
+ if (count === 1) return 1;
580
+ if (count === 2) return 2;
581
+ const mod100 = count % 100;
582
+ if (mod100 >= 3 && mod100 <= 10) return 3;
583
+ if (mod100 >= 11 && mod100 <= 99) return 4;
584
+ return 5;
585
+ }
586
+
587
+ // Latvian
588
+ case 'lv':
589
+ if (count === 0) return 0;
590
+ if (count % 10 === 1 && count % 100 !== 11) return 1;
591
+ return 2;
592
+
593
+ default:
594
+ return count !== 1 ? 1 : 0; // safe English default
595
+ }
596
+ }
597
+
598
+ /**
599
+ * Interpolate variables into a translated string.
600
+ *
601
+ * Supports:
602
+ * {name} named placeholder — args[0] must be a plain object
603
+ * %s positional string
604
+ * %d positional number
605
+ * %f positional float
606
+ * %i positional integer
607
+ */
608
+ _interpolate(str, args) {
609
+ if (!args || args.length === 0) return str;
610
+
611
+ // Named placeholders: _f('Hello, {name}!', { name: 'Alice' })
612
+ if (args.length === 1 && args[0] !== null && typeof args[0] === 'object' && !Array.isArray(args[0])) {
613
+ const vars = args[0];
614
+ return str.replace(/\{(\w+)\}/g, (match, key) =>
615
+ vars[key] !== undefined ? String(vars[key]) : match
616
+ );
617
+ }
618
+
619
+ // Positional: _f('Hello %s, you have %d items', 'Alice', 3)
620
+ let idx = 0;
621
+ return str.replace(/%([sdfi])/g, (match, type) => {
622
+ if (idx >= args.length) return match;
623
+ const val = args[idx++];
624
+ switch (type) {
625
+ case 'd': case 'i': return String(Math.trunc(Number(val)));
626
+ case 'f': return String(parseFloat(val));
627
+ case 's': default: return String(val);
628
+ }
629
+ });
630
+ }
631
+
632
+ _recordMissing(key, locale) {
633
+ const entry = `[${locale}] ${key}`;
634
+ if (!this._missing.has(entry)) {
635
+ this._missing.add(entry);
636
+ if (this._warnMissing) {
637
+ process.stderr.write(`[i18n] Missing translation: ${entry}\n`);
638
+ }
639
+ }
640
+ }
641
+ }
642
+
643
+ module.exports = Translator;