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.
- package/package.json +3 -2
- package/src/admin/ActivityLog.js +153 -52
- package/src/admin/Admin.js +516 -199
- 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 +318 -0
- package/src/admin/WidgetRegistry.js +406 -0
- package/src/admin/index.js +17 -0
- package/src/admin/resources/AdminResource.js +393 -97
- package/src/admin/static/admin.css +1422 -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 +87 -1046
- package/src/admin/views/pages/detail.njk +56 -21
- package/src/admin/views/pages/error.njk +65 -0
- package/src/admin/views/pages/form.njk +47 -599
- package/src/admin/views/pages/list.njk +270 -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 +480 -0
- package/src/admin/views/partials/form-widget.njk +297 -0
- package/src/admin/views/partials/icons.njk +64 -0
- package/src/admin/views/partials/json-dialog.njk +80 -0
- package/src/admin/views/partials/json-editor.njk +37 -0
- package/src/ai/AIManager.js +954 -0
- package/src/ai/AITokenBudget.js +250 -0
- package/src/ai/PromptGuard.js +216 -0
- package/src/ai/agents.js +218 -0
- package/src/ai/conversation.js +213 -0
- package/src/ai/drivers.js +734 -0
- package/src/ai/files.js +249 -0
- package/src/ai/media.js +303 -0
- package/src/ai/pricing.js +152 -0
- package/src/ai/provider_tools.js +114 -0
- package/src/ai/types.js +356 -0
- package/src/auth/Auth.js +18 -2
- package/src/auth/AuthUser.js +65 -44
- package/src/cli.js +3 -1
- package/src/commands/createsuperuser.js +267 -0
- package/src/commands/lang.js +589 -0
- package/src/commands/migrate.js +154 -81
- package/src/commands/serve.js +3 -4
- package/src/container/AppInitializer.js +101 -20
- package/src/container/Application.js +31 -1
- package/src/container/MillasApp.js +10 -3
- package/src/container/MillasConfig.js +35 -6
- package/src/core/admin.js +5 -0
- package/src/core/db.js +2 -1
- package/src/core/foundation.js +2 -10
- package/src/core/lang.js +1 -0
- package/src/errors/HttpError.js +32 -16
- package/src/facades/AI.js +411 -0
- package/src/facades/Hash.js +67 -0
- package/src/facades/Process.js +144 -0
- package/src/hashing/Hash.js +262 -0
- package/src/http/HtmlEscape.js +162 -0
- package/src/http/MillasRequest.js +63 -7
- package/src/http/MillasResponse.js +70 -4
- package/src/http/ResponseDispatcher.js +21 -27
- package/src/http/SafeFilePath.js +195 -0
- package/src/http/SafeRedirect.js +62 -0
- package/src/http/SecurityBootstrap.js +70 -0
- package/src/http/helpers.js +40 -125
- package/src/http/index.js +10 -1
- package/src/http/middleware/CsrfMiddleware.js +258 -0
- package/src/http/middleware/RateLimiter.js +314 -0
- package/src/http/middleware/SecurityHeaders.js +281 -0
- package/src/i18n/I18nServiceProvider.js +91 -0
- package/src/i18n/Translator.js +643 -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/logger/LogRedactor.js +247 -0
- package/src/logger/Logger.js +1 -1
- package/src/logger/formatters/JsonFormatter.js +11 -4
- package/src/logger/formatters/PrettyFormatter.js +103 -65
- package/src/logger/formatters/SimpleFormatter.js +14 -3
- package/src/middleware/ThrottleMiddleware.js +27 -4
- 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 +143 -74
- 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/process/Process.js +333 -0
- package/src/providers/AdminServiceProvider.js +66 -9
- package/src/providers/AuthServiceProvider.js +40 -5
- package/src/providers/CacheStorageServiceProvider.js +2 -2
- package/src/providers/DatabaseServiceProvider.js +3 -2
- package/src/providers/LogServiceProvider.js +4 -1
- package/src/providers/MailServiceProvider.js +1 -1
- package/src/providers/QueueServiceProvider.js +1 -1
- package/src/router/MiddlewareRegistry.js +27 -2
- package/src/scaffold/templates.js +80 -21
- 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;
|