@buenojs/bueno 0.8.3 → 0.8.5
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/README.md +136 -16
- package/dist/cli/{index.js → bin.js} +3036 -1421
- package/dist/container/index.js +250 -0
- package/dist/context/index.js +219 -0
- package/dist/database/index.js +493 -0
- package/dist/frontend/index.js +7697 -0
- package/dist/health/index.js +364 -0
- package/dist/i18n/index.js +345 -0
- package/dist/index.js +11043 -6482
- package/dist/jobs/index.js +819 -0
- package/dist/lock/index.js +367 -0
- package/dist/logger/index.js +281 -0
- package/dist/metrics/index.js +289 -0
- package/dist/middleware/index.js +77 -0
- package/dist/migrations/index.js +571 -0
- package/dist/modules/index.js +3346 -0
- package/dist/notification/index.js +484 -0
- package/dist/observability/index.js +331 -0
- package/dist/openapi/index.js +776 -0
- package/dist/orm/index.js +1356 -0
- package/dist/router/index.js +886 -0
- package/dist/rpc/index.js +691 -0
- package/dist/schema/index.js +400 -0
- package/dist/telemetry/index.js +595 -0
- package/dist/template/index.js +640 -0
- package/dist/templates/index.js +640 -0
- package/dist/testing/index.js +1111 -0
- package/dist/types/index.js +60 -0
- package/package.json +121 -27
- package/src/cache/index.ts +2 -1
- package/src/cli/bin.ts +2 -2
- package/src/cli/commands/build.ts +183 -165
- package/src/cli/commands/dev.ts +96 -89
- package/src/cli/commands/generate.ts +142 -111
- package/src/cli/commands/help.ts +20 -16
- package/src/cli/commands/index.ts +3 -6
- package/src/cli/commands/migration.ts +124 -105
- package/src/cli/commands/new.ts +392 -438
- package/src/cli/commands/start.ts +81 -79
- package/src/cli/core/args.ts +68 -50
- package/src/cli/core/console.ts +89 -95
- package/src/cli/core/index.ts +4 -4
- package/src/cli/core/prompt.ts +65 -62
- package/src/cli/core/spinner.ts +23 -20
- package/src/cli/index.ts +46 -38
- package/src/cli/templates/database/index.ts +61 -0
- package/src/cli/templates/database/mysql.ts +14 -0
- package/src/cli/templates/database/none.ts +16 -0
- package/src/cli/templates/database/postgresql.ts +14 -0
- package/src/cli/templates/database/sqlite.ts +14 -0
- package/src/cli/templates/deploy.ts +29 -26
- package/src/cli/templates/docker.ts +41 -30
- package/src/cli/templates/frontend/index.ts +63 -0
- package/src/cli/templates/frontend/none.ts +17 -0
- package/src/cli/templates/frontend/react.ts +140 -0
- package/src/cli/templates/frontend/solid.ts +134 -0
- package/src/cli/templates/frontend/svelte.ts +131 -0
- package/src/cli/templates/frontend/vue.ts +130 -0
- package/src/cli/templates/generators/index.ts +339 -0
- package/src/cli/templates/generators/types.ts +56 -0
- package/src/cli/templates/index.ts +35 -2
- package/src/cli/templates/project/api.ts +81 -0
- package/src/cli/templates/project/default.ts +140 -0
- package/src/cli/templates/project/fullstack.ts +111 -0
- package/src/cli/templates/project/index.ts +95 -0
- package/src/cli/templates/project/minimal.ts +45 -0
- package/src/cli/templates/project/types.ts +94 -0
- package/src/cli/templates/project/website.ts +263 -0
- package/src/cli/utils/fs.ts +55 -41
- package/src/cli/utils/index.ts +3 -2
- package/src/cli/utils/strings.ts +47 -33
- package/src/cli/utils/version.ts +47 -0
- package/src/config/env-validation.ts +100 -0
- package/src/config/env.ts +169 -41
- package/src/config/index.ts +28 -20
- package/src/config/loader.ts +25 -16
- package/src/config/merge.ts +21 -10
- package/src/config/types.ts +545 -25
- package/src/config/validation.ts +215 -7
- package/src/container/forward-ref.ts +22 -22
- package/src/container/index.ts +34 -12
- package/src/context/index.ts +11 -1
- package/src/database/index.ts +7 -190
- package/src/database/orm/builder.ts +457 -0
- package/src/database/orm/casts/index.ts +130 -0
- package/src/database/orm/casts/types.ts +25 -0
- package/src/database/orm/compiler.ts +304 -0
- package/src/database/orm/hooks/index.ts +114 -0
- package/src/database/orm/index.ts +61 -0
- package/src/database/orm/model-registry.ts +59 -0
- package/src/database/orm/model.ts +821 -0
- package/src/database/orm/relationships/base.ts +146 -0
- package/src/database/orm/relationships/belongs-to-many.ts +179 -0
- package/src/database/orm/relationships/belongs-to.ts +56 -0
- package/src/database/orm/relationships/has-many.ts +45 -0
- package/src/database/orm/relationships/has-one.ts +41 -0
- package/src/database/orm/relationships/index.ts +11 -0
- package/src/database/orm/scopes/index.ts +55 -0
- package/src/events/__tests__/event-system.test.ts +235 -0
- package/src/events/config.ts +238 -0
- package/src/events/example-usage.ts +185 -0
- package/src/events/index.ts +278 -0
- package/src/events/manager.ts +385 -0
- package/src/events/registry.ts +182 -0
- package/src/events/types.ts +124 -0
- package/src/frontend/api-routes.ts +65 -23
- package/src/frontend/bundler.ts +76 -34
- package/src/frontend/console-client.ts +2 -2
- package/src/frontend/console-stream.ts +94 -38
- package/src/frontend/dev-server.ts +94 -46
- package/src/frontend/file-router.ts +61 -19
- package/src/frontend/frameworks/index.ts +37 -10
- package/src/frontend/frameworks/react.ts +10 -8
- package/src/frontend/frameworks/solid.ts +11 -9
- package/src/frontend/frameworks/svelte.ts +15 -9
- package/src/frontend/frameworks/vue.ts +13 -11
- package/src/frontend/hmr-client.ts +12 -10
- package/src/frontend/hmr.ts +146 -103
- package/src/frontend/index.ts +14 -5
- package/src/frontend/islands.ts +41 -22
- package/src/frontend/isr.ts +59 -37
- package/src/frontend/layout.ts +36 -21
- package/src/frontend/ssr/react.ts +74 -27
- package/src/frontend/ssr/solid.ts +54 -20
- package/src/frontend/ssr/svelte.ts +48 -14
- package/src/frontend/ssr/vue.ts +50 -18
- package/src/frontend/ssr.ts +83 -39
- package/src/frontend/types.ts +91 -56
- package/src/health/index.ts +21 -9
- package/src/i18n/engine.ts +305 -0
- package/src/i18n/index.ts +38 -0
- package/src/i18n/loader.ts +218 -0
- package/src/i18n/middleware.ts +164 -0
- package/src/i18n/negotiator.ts +162 -0
- package/src/i18n/types.ts +158 -0
- package/src/index.ts +179 -27
- package/src/jobs/drivers/memory.ts +315 -0
- package/src/jobs/drivers/redis.ts +459 -0
- package/src/jobs/index.ts +30 -0
- package/src/jobs/queue.ts +281 -0
- package/src/jobs/types.ts +295 -0
- package/src/jobs/worker.ts +380 -0
- package/src/logger/index.ts +1 -3
- package/src/logger/transports/index.ts +62 -22
- package/src/metrics/index.ts +25 -16
- package/src/migrations/index.ts +9 -0
- package/src/modules/filters.ts +13 -17
- package/src/modules/guards.ts +49 -26
- package/src/modules/index.ts +409 -298
- package/src/modules/interceptors.ts +58 -20
- package/src/modules/lazy.ts +11 -19
- package/src/modules/lifecycle.ts +15 -7
- package/src/modules/metadata.ts +15 -5
- package/src/modules/pipes.ts +94 -72
- package/src/notification/channels/base.ts +68 -0
- package/src/notification/channels/email.ts +105 -0
- package/src/notification/channels/push.ts +104 -0
- package/src/notification/channels/sms.ts +105 -0
- package/src/notification/channels/whatsapp.ts +104 -0
- package/src/notification/index.ts +48 -0
- package/src/notification/service.ts +354 -0
- package/src/notification/types.ts +344 -0
- package/src/observability/__tests__/observability.test.ts +483 -0
- package/src/observability/breadcrumbs.ts +114 -0
- package/src/observability/index.ts +136 -0
- package/src/observability/interceptor.ts +85 -0
- package/src/observability/service.ts +303 -0
- package/src/observability/trace.ts +37 -0
- package/src/observability/types.ts +196 -0
- package/src/openapi/__tests__/decorators.test.ts +335 -0
- package/src/openapi/__tests__/document-builder.test.ts +285 -0
- package/src/openapi/__tests__/route-scanner.test.ts +334 -0
- package/src/openapi/__tests__/schema-generator.test.ts +275 -0
- package/src/openapi/decorators.ts +328 -0
- package/src/openapi/document-builder.ts +274 -0
- package/src/openapi/index.ts +112 -0
- package/src/openapi/metadata.ts +112 -0
- package/src/openapi/route-scanner.ts +289 -0
- package/src/openapi/schema-generator.ts +256 -0
- package/src/openapi/swagger-module.ts +166 -0
- package/src/openapi/types.ts +398 -0
- package/src/orm/index.ts +10 -0
- package/src/rpc/index.ts +3 -1
- package/src/schema/index.ts +9 -0
- package/src/security/index.ts +15 -6
- package/src/ssg/index.ts +9 -8
- package/src/telemetry/index.ts +76 -22
- package/src/template/index.ts +7 -0
- package/src/templates/engine.ts +224 -0
- package/src/templates/index.ts +9 -0
- package/src/templates/loader.ts +331 -0
- package/src/templates/renderers/markdown.ts +212 -0
- package/src/templates/renderers/simple.ts +269 -0
- package/src/templates/types.ts +154 -0
- package/src/testing/index.ts +100 -27
- package/src/types/optional-deps.d.ts +347 -187
- package/src/validation/index.ts +92 -2
- package/src/validation/schemas.ts +536 -0
- package/tests/integration/fullstack.test.ts +4 -4
- package/tests/unit/database.test.ts +2 -72
- package/tests/unit/env-validation.test.ts +166 -0
- package/tests/unit/events.test.ts +910 -0
- package/tests/unit/i18n.test.ts +455 -0
- package/tests/unit/jobs.test.ts +493 -0
- package/tests/unit/notification.test.ts +988 -0
- package/tests/unit/observability.test.ts +453 -0
- package/tests/unit/orm/builder.test.ts +323 -0
- package/tests/unit/orm/casts.test.ts +179 -0
- package/tests/unit/orm/compiler.test.ts +220 -0
- package/tests/unit/orm/eager-loading.test.ts +285 -0
- package/tests/unit/orm/hooks.test.ts +191 -0
- package/tests/unit/orm/model.test.ts +373 -0
- package/tests/unit/orm/relationships.test.ts +303 -0
- package/tests/unit/orm/scopes.test.ts +74 -0
- package/tests/unit/templates-simple.test.ts +53 -0
- package/tests/unit/templates.test.ts +454 -0
- package/tests/unit/validation.test.ts +18 -24
- package/tsconfig.json +11 -3
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* i18n Engine — Translation Lookup and Interpolation
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates locale loading and exposes the t() translation function.
|
|
5
|
+
* Handles pluralisation, variable interpolation, fallback, and metrics.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { TranslationLoader } from "./loader";
|
|
9
|
+
import { LocaleNegotiator } from "./negotiator";
|
|
10
|
+
import type {
|
|
11
|
+
I18nConfig,
|
|
12
|
+
I18nMetrics,
|
|
13
|
+
PluralKey,
|
|
14
|
+
ResolvedI18nConfig,
|
|
15
|
+
TranslationFunction,
|
|
16
|
+
TranslationParams,
|
|
17
|
+
} from "./types";
|
|
18
|
+
|
|
19
|
+
// ============= Defaults =============
|
|
20
|
+
|
|
21
|
+
const DEFAULT_I18N_CONFIG: ResolvedI18nConfig = {
|
|
22
|
+
defaultLocale: "en",
|
|
23
|
+
supportedLocales: ["en"],
|
|
24
|
+
basePath: "resources/i18n",
|
|
25
|
+
fallbackToDefault: true,
|
|
26
|
+
cookieName: "bueno_locale",
|
|
27
|
+
cookieMaxAge: 31536000,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// ============= Plural Resolution =============
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Select the appropriate plural key based on the count value.
|
|
34
|
+
* Uses simple English-style pluralisation (zero, one, other).
|
|
35
|
+
*
|
|
36
|
+
* Resolution:
|
|
37
|
+
* - count === 0 → look for "{key}_zero", fall back to "{key}_other"
|
|
38
|
+
* - count === 1 → look for "{key}_one", fall back to "{key}_other"
|
|
39
|
+
* - else → look for "{key}_other"
|
|
40
|
+
*
|
|
41
|
+
* If no plural variant is found, returns the bare key
|
|
42
|
+
* (allowing non-plural strings to be used without variants).
|
|
43
|
+
*
|
|
44
|
+
* For full CLDR support (two, few, many forms), users can subclass
|
|
45
|
+
* and override this function.
|
|
46
|
+
*
|
|
47
|
+
* @param count The count parameter from translation params
|
|
48
|
+
* @param availableKeys Set of all available key names (for fast lookup)
|
|
49
|
+
* @param base Base key name (without _zero/_one/_other suffix)
|
|
50
|
+
* @returns The resolved plural key to use
|
|
51
|
+
*/
|
|
52
|
+
function selectPluralKey(
|
|
53
|
+
count: number,
|
|
54
|
+
availableKeys: Set<string>,
|
|
55
|
+
base: string,
|
|
56
|
+
): string {
|
|
57
|
+
const candidates: PluralKey[] =
|
|
58
|
+
count === 0
|
|
59
|
+
? ["zero", "other"]
|
|
60
|
+
: count === 1
|
|
61
|
+
? ["one", "other"]
|
|
62
|
+
: ["other"];
|
|
63
|
+
|
|
64
|
+
for (const form of candidates) {
|
|
65
|
+
const candidate = `${base}_${form}`;
|
|
66
|
+
if (availableKeys.has(candidate)) return candidate;
|
|
67
|
+
}
|
|
68
|
+
return base; // fall back to bare key (caller handles missing)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ============= Interpolation =============
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Replace {{ name }} and {{ name }} placeholders with values from params.
|
|
75
|
+
*
|
|
76
|
+
* Regex: /\{\{\s*(\w+)\s*\}\}/g
|
|
77
|
+
* Matches: {{ key }}, {{key}}, {{ key}} (with optional whitespace)
|
|
78
|
+
*
|
|
79
|
+
* Unresolved placeholders (key not in params) are replaced with empty string.
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* interpolate("Hello, {{name}}!", { name: "Alice" }) → "Hello, Alice!"
|
|
83
|
+
* interpolate("You have {{count}} items", { count: 3 }) → "You have 3 items"
|
|
84
|
+
* interpolate("Hello {{missing}}", {}) → "Hello "
|
|
85
|
+
*
|
|
86
|
+
* @param template Template string with {{ }} placeholders
|
|
87
|
+
* @param params Key-value pairs for interpolation
|
|
88
|
+
* @returns Interpolated string
|
|
89
|
+
*/
|
|
90
|
+
function interpolate(template: string, params: TranslationParams): string {
|
|
91
|
+
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key: string) => {
|
|
92
|
+
const val = params[key];
|
|
93
|
+
return val === undefined || val === null ? "" : String(val);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ============= I18n Engine =============
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Main i18n engine.
|
|
101
|
+
* Handles translation lookup, plural forms, interpolation, and caching.
|
|
102
|
+
*
|
|
103
|
+
* Usage:
|
|
104
|
+
* ```
|
|
105
|
+
* const i18n = new I18n({ defaultLocale: 'en', supportedLocales: ['en', 'fr'] });
|
|
106
|
+
* i18n.preload(); // optional
|
|
107
|
+
* const t = i18n.createTranslator('fr');
|
|
108
|
+
* console.log(t('greeting', { name: 'Alice' })); // from fr.json
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
export class I18n {
|
|
112
|
+
private loader: TranslationLoader;
|
|
113
|
+
private negotiator: LocaleNegotiator;
|
|
114
|
+
readonly config: ResolvedI18nConfig;
|
|
115
|
+
private metrics: I18nMetrics = {
|
|
116
|
+
totalLookups: 0,
|
|
117
|
+
hits: 0,
|
|
118
|
+
fallbacks: 0,
|
|
119
|
+
misses: 0,
|
|
120
|
+
loadedLocales: [],
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Create an i18n engine.
|
|
125
|
+
* @param config Optional configuration (all fields are optional)
|
|
126
|
+
*/
|
|
127
|
+
constructor(config: I18nConfig = {}) {
|
|
128
|
+
this.config = {
|
|
129
|
+
defaultLocale: config.defaultLocale ?? DEFAULT_I18N_CONFIG.defaultLocale,
|
|
130
|
+
supportedLocales:
|
|
131
|
+
config.supportedLocales ?? DEFAULT_I18N_CONFIG.supportedLocales,
|
|
132
|
+
basePath: config.basePath ?? DEFAULT_I18N_CONFIG.basePath,
|
|
133
|
+
fallbackToDefault:
|
|
134
|
+
config.fallbackToDefault ?? DEFAULT_I18N_CONFIG.fallbackToDefault,
|
|
135
|
+
cookieName: config.cookieName ?? DEFAULT_I18N_CONFIG.cookieName,
|
|
136
|
+
cookieMaxAge: config.cookieMaxAge ?? DEFAULT_I18N_CONFIG.cookieMaxAge,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
this.loader = new TranslationLoader(this.config);
|
|
140
|
+
this.negotiator = new LocaleNegotiator(
|
|
141
|
+
this.config.supportedLocales,
|
|
142
|
+
this.config.defaultLocale,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Pre-load all supported locale files at startup.
|
|
148
|
+
* Optional — lazy loading works without calling this.
|
|
149
|
+
* Useful for production to catch missing files early.
|
|
150
|
+
*/
|
|
151
|
+
preload(): void {
|
|
152
|
+
this.loader.preload();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Enable hot-reload file watching for all supported locales.
|
|
157
|
+
* Should only be called in development mode.
|
|
158
|
+
*/
|
|
159
|
+
watchAll(): void {
|
|
160
|
+
for (const locale of this.config.supportedLocales) {
|
|
161
|
+
this.loader.watch(locale);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Stop all file watchers.
|
|
167
|
+
* Call this when the application shuts down.
|
|
168
|
+
*/
|
|
169
|
+
stopWatching(): void {
|
|
170
|
+
this.loader.stopWatching();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Return a bound translation function for the given locale.
|
|
175
|
+
* This is what gets stored on context: ctx.set('t', ...)
|
|
176
|
+
*
|
|
177
|
+
* @param locale Locale to create translator for
|
|
178
|
+
* @returns TranslationFunction bound to that locale
|
|
179
|
+
*/
|
|
180
|
+
createTranslator(locale: string): TranslationFunction {
|
|
181
|
+
return (key: string, params?: TranslationParams): string => {
|
|
182
|
+
return this.t(locale, key, params);
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Primary translation lookup.
|
|
188
|
+
*
|
|
189
|
+
* Resolution order:
|
|
190
|
+
* 1. Check `locale` translations
|
|
191
|
+
* a. If `params.count` is provided, try plural key first ({key}_one, {key}_other, etc.)
|
|
192
|
+
* b. Then try bare key
|
|
193
|
+
* 2. If fallbackToDefault and locale !== defaultLocale, repeat step 1 for defaultLocale
|
|
194
|
+
* 3. Return the key string as last resort
|
|
195
|
+
*
|
|
196
|
+
* Metrics are tracked: hits (found in locale), fallbacks (found in default),
|
|
197
|
+
* misses (returned key string).
|
|
198
|
+
*
|
|
199
|
+
* @param locale Locale to translate in
|
|
200
|
+
* @param key Translation key (supports dot-notation for nested keys)
|
|
201
|
+
* @param params Optional translation parameters (interpolation + plural count)
|
|
202
|
+
* @returns Translated string, or key string if not found
|
|
203
|
+
*/
|
|
204
|
+
t(locale: string, key: string, params?: TranslationParams): string {
|
|
205
|
+
this.metrics.totalLookups++;
|
|
206
|
+
|
|
207
|
+
const hasCount =
|
|
208
|
+
params !== undefined &&
|
|
209
|
+
"count" in params &&
|
|
210
|
+
typeof params.count === "number";
|
|
211
|
+
|
|
212
|
+
// Attempt resolution in given locale
|
|
213
|
+
const result = this._resolve(locale, key, params, hasCount);
|
|
214
|
+
if (result !== null) {
|
|
215
|
+
this.metrics.hits++;
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Fallback to default locale
|
|
220
|
+
if (this.config.fallbackToDefault && locale !== this.config.defaultLocale) {
|
|
221
|
+
const fallbackResult = this._resolve(
|
|
222
|
+
this.config.defaultLocale,
|
|
223
|
+
key,
|
|
224
|
+
params,
|
|
225
|
+
hasCount,
|
|
226
|
+
);
|
|
227
|
+
if (fallbackResult !== null) {
|
|
228
|
+
this.metrics.fallbacks++;
|
|
229
|
+
return fallbackResult;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Complete miss — return the key path
|
|
234
|
+
this.metrics.misses++;
|
|
235
|
+
return key;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Returns the LocaleNegotiator instance for use by middleware.
|
|
240
|
+
* @returns LocaleNegotiator instance
|
|
241
|
+
*/
|
|
242
|
+
getNegotiator(): LocaleNegotiator {
|
|
243
|
+
return this.negotiator;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Get current translation metrics.
|
|
248
|
+
* Useful for debugging and performance monitoring.
|
|
249
|
+
* @returns Current I18nMetrics
|
|
250
|
+
*/
|
|
251
|
+
getMetrics(): I18nMetrics {
|
|
252
|
+
return {
|
|
253
|
+
...this.metrics,
|
|
254
|
+
loadedLocales: this.loader.loadedLocales(),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ============= Private Helpers =============
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Resolve a translation key in a specific locale.
|
|
262
|
+
* Returns null if not found (to distinguish from a successful empty string).
|
|
263
|
+
*
|
|
264
|
+
* @param locale Locale to resolve in
|
|
265
|
+
* @param key Translation key
|
|
266
|
+
* @param params Translation parameters
|
|
267
|
+
* @param hasCount Whether params contains a count field
|
|
268
|
+
* @returns Translated string, or null if not found
|
|
269
|
+
*/
|
|
270
|
+
private _resolve(
|
|
271
|
+
locale: string,
|
|
272
|
+
key: string,
|
|
273
|
+
params: TranslationParams | undefined,
|
|
274
|
+
hasCount: boolean,
|
|
275
|
+
): string | null {
|
|
276
|
+
const bundle = this.loader.load(locale);
|
|
277
|
+
const translations = bundle.translations;
|
|
278
|
+
const availableKeys = new Set(translations.keys());
|
|
279
|
+
|
|
280
|
+
let resolvedKey = key;
|
|
281
|
+
|
|
282
|
+
// Plural selection
|
|
283
|
+
if (hasCount) {
|
|
284
|
+
resolvedKey = selectPluralKey(params!.count!, availableKeys, key);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const raw = translations.get(resolvedKey);
|
|
288
|
+
if (raw === undefined) return null;
|
|
289
|
+
|
|
290
|
+
return params ? interpolate(raw, params) : raw;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ============= Factory =============
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Create an i18n engine.
|
|
298
|
+
* Convenience factory for new I18n(config).
|
|
299
|
+
*
|
|
300
|
+
* @param config Optional configuration
|
|
301
|
+
* @returns New I18n instance
|
|
302
|
+
*/
|
|
303
|
+
export function createI18n(config?: I18nConfig): I18n {
|
|
304
|
+
return new I18n(config);
|
|
305
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* i18n Module — Internationalisation for Bueno Framework
|
|
3
|
+
*
|
|
4
|
+
* Locale detection (cookie → Accept-Language), translation lookup with
|
|
5
|
+
* dot-notation keys, variable interpolation, plural forms, and caching.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Types
|
|
9
|
+
export type {
|
|
10
|
+
PluralKey,
|
|
11
|
+
TranslationMap,
|
|
12
|
+
FlatTranslations,
|
|
13
|
+
TranslationParams,
|
|
14
|
+
TranslationFunction,
|
|
15
|
+
LocaleMatch,
|
|
16
|
+
I18nContext,
|
|
17
|
+
I18nConfig,
|
|
18
|
+
ResolvedI18nConfig,
|
|
19
|
+
LocaleBundle,
|
|
20
|
+
I18nMetrics,
|
|
21
|
+
} from "./types";
|
|
22
|
+
|
|
23
|
+
// Core engine
|
|
24
|
+
export { I18n, createI18n } from "./engine";
|
|
25
|
+
|
|
26
|
+
// Loader (advanced use)
|
|
27
|
+
export { TranslationLoader } from "./loader";
|
|
28
|
+
|
|
29
|
+
// Negotiator (advanced use)
|
|
30
|
+
export {
|
|
31
|
+
LocaleNegotiator,
|
|
32
|
+
parseAcceptLanguage,
|
|
33
|
+
normaliseLocale,
|
|
34
|
+
} from "./negotiator";
|
|
35
|
+
|
|
36
|
+
// Middleware
|
|
37
|
+
export { i18nMiddleware, getLocale, getT } from "./middleware";
|
|
38
|
+
export type { I18nMiddlewareOptions } from "./middleware";
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translation Loader — JSON File Loading and Caching
|
|
3
|
+
*
|
|
4
|
+
* Loads locale JSON files from disk, flattens nested keys to dot-notation,
|
|
5
|
+
* caches in memory, and supports hot-reload via file watching.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, watch } from "fs";
|
|
9
|
+
import { join, resolve } from "path";
|
|
10
|
+
import type {
|
|
11
|
+
FlatTranslations,
|
|
12
|
+
LocaleBundle,
|
|
13
|
+
ResolvedI18nConfig,
|
|
14
|
+
} from "./types";
|
|
15
|
+
|
|
16
|
+
// ============= Flattening =============
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Recursively flatten a nested object into dot-notation keys.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* flattenTranslations({ nav: { home: "Home", about: "About" } })
|
|
23
|
+
* → Map { "nav.home" → "Home", "nav.about" → "About" }
|
|
24
|
+
*
|
|
25
|
+
* Already-flat keys pass through unchanged.
|
|
26
|
+
* Non-string leaf values are converted via String().
|
|
27
|
+
* Arrays are not recursed — they are stringified as-is.
|
|
28
|
+
*
|
|
29
|
+
* @param obj Object to flatten (or nested structure)
|
|
30
|
+
* @param prefix Current dot-notation prefix (used recursively)
|
|
31
|
+
* @param result Accumulator map (used recursively)
|
|
32
|
+
* @returns Flattened Map with all keys in dot-notation form
|
|
33
|
+
*/
|
|
34
|
+
function flattenTranslations(
|
|
35
|
+
obj: Record<string, unknown>,
|
|
36
|
+
prefix = "",
|
|
37
|
+
result: FlatTranslations = new Map(),
|
|
38
|
+
): FlatTranslations {
|
|
39
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
40
|
+
const dotKey = prefix ? `${prefix}.${key}` : key;
|
|
41
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
42
|
+
flattenTranslations(value as Record<string, unknown>, dotKey, result);
|
|
43
|
+
} else {
|
|
44
|
+
result.set(dotKey, String(value ?? ""));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ============= Loader =============
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Loads and caches locale translation bundles from JSON files.
|
|
54
|
+
* Supports file watching for hot-reload in development.
|
|
55
|
+
*/
|
|
56
|
+
export class TranslationLoader {
|
|
57
|
+
private cache: Map<string, LocaleBundle> = new Map();
|
|
58
|
+
private watchers: Map<string, ReturnType<typeof watch>> = new Map();
|
|
59
|
+
private config: ResolvedI18nConfig;
|
|
60
|
+
|
|
61
|
+
constructor(config: ResolvedI18nConfig) {
|
|
62
|
+
this.config = config;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Load a locale bundle. Returns from cache if already loaded.
|
|
67
|
+
*
|
|
68
|
+
* For the default locale:
|
|
69
|
+
* - Throws if the file is not found (cannot proceed without defaults)
|
|
70
|
+
*
|
|
71
|
+
* For non-default locales:
|
|
72
|
+
* - Returns an empty bundle if the file is not found
|
|
73
|
+
* - Fallback in the engine will handle the miss
|
|
74
|
+
*
|
|
75
|
+
* @param locale Locale identifier to load
|
|
76
|
+
* @returns LocaleBundle with flattened translations
|
|
77
|
+
* @throws Error if default locale file is not found
|
|
78
|
+
*/
|
|
79
|
+
load(locale: string): LocaleBundle {
|
|
80
|
+
const cached = this.cache.get(locale);
|
|
81
|
+
if (cached) return cached;
|
|
82
|
+
|
|
83
|
+
return this._loadFromDisk(locale);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Pre-load all supported locales eagerly.
|
|
88
|
+
* Call this once at application startup for best performance.
|
|
89
|
+
*
|
|
90
|
+
* Non-default locales that are missing are silently skipped
|
|
91
|
+
* (returning empty bundles).
|
|
92
|
+
*/
|
|
93
|
+
preload(): void {
|
|
94
|
+
for (const locale of this.config.supportedLocales) {
|
|
95
|
+
try {
|
|
96
|
+
this._loadFromDisk(locale);
|
|
97
|
+
} catch {
|
|
98
|
+
// Non-default locales may legitimately have no file yet
|
|
99
|
+
if (locale !== this.config.defaultLocale) {
|
|
100
|
+
this.cache.set(locale, {
|
|
101
|
+
locale,
|
|
102
|
+
translations: new Map(),
|
|
103
|
+
loadedAt: Date.now(),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Invalidate a locale's cache entry.
|
|
112
|
+
* Forces a reload from disk on the next access.
|
|
113
|
+
*
|
|
114
|
+
* @param locale Locale to invalidate
|
|
115
|
+
*/
|
|
116
|
+
invalidate(locale: string): void {
|
|
117
|
+
this.cache.delete(locale);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Return all currently loaded locale names.
|
|
122
|
+
*
|
|
123
|
+
* @returns Array of loaded locale identifiers
|
|
124
|
+
*/
|
|
125
|
+
loadedLocales(): string[] {
|
|
126
|
+
return Array.from(this.cache.keys());
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Enable file watching for a locale (hot reload in dev mode).
|
|
131
|
+
* Invalidates cache and reloads on file change.
|
|
132
|
+
*
|
|
133
|
+
* @param locale Locale file to watch
|
|
134
|
+
*/
|
|
135
|
+
watch(locale: string): void {
|
|
136
|
+
const filePath = this._resolvePath(locale);
|
|
137
|
+
if (!existsSync(filePath)) return;
|
|
138
|
+
if (this.watchers.has(locale)) return;
|
|
139
|
+
|
|
140
|
+
const watcher = watch(filePath, () => {
|
|
141
|
+
this.invalidate(locale);
|
|
142
|
+
try {
|
|
143
|
+
this._loadFromDisk(locale);
|
|
144
|
+
} catch {
|
|
145
|
+
// Ignore parse errors during hot reload — log in production
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
this.watchers.set(locale, watcher);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Stop all file watchers.
|
|
154
|
+
* Call this when the application shuts down.
|
|
155
|
+
*/
|
|
156
|
+
stopWatching(): void {
|
|
157
|
+
for (const watcher of this.watchers.values()) {
|
|
158
|
+
watcher.close();
|
|
159
|
+
}
|
|
160
|
+
this.watchers.clear();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ============= Private Helpers =============
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Resolve the full file path for a locale.
|
|
167
|
+
* @param locale Locale identifier
|
|
168
|
+
* @returns Full file path to the locale JSON file
|
|
169
|
+
*/
|
|
170
|
+
private _resolvePath(locale: string): string {
|
|
171
|
+
return resolve(join(this.config.basePath, `${locale}.json`));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Load a locale file from disk and cache it.
|
|
176
|
+
* @param locale Locale to load
|
|
177
|
+
* @returns LocaleBundle with flattened translations
|
|
178
|
+
* @throws Error if default locale file is not found or JSON is invalid
|
|
179
|
+
*/
|
|
180
|
+
private _loadFromDisk(locale: string): LocaleBundle {
|
|
181
|
+
const filePath = this._resolvePath(locale);
|
|
182
|
+
|
|
183
|
+
if (!existsSync(filePath)) {
|
|
184
|
+
if (locale === this.config.defaultLocale) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`[i18n] Default locale file not found: ${filePath}. ` +
|
|
187
|
+
`Create ${locale}.json in ${this.config.basePath}`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
// For non-default locales, silently return an empty bundle
|
|
191
|
+
const empty: LocaleBundle = {
|
|
192
|
+
locale,
|
|
193
|
+
translations: new Map(),
|
|
194
|
+
loadedAt: Date.now(),
|
|
195
|
+
};
|
|
196
|
+
this.cache.set(locale, empty);
|
|
197
|
+
return empty;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
201
|
+
let parsed: Record<string, unknown>;
|
|
202
|
+
try {
|
|
203
|
+
parsed = JSON.parse(raw);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
throw new Error(
|
|
206
|
+
`[i18n] Failed to parse locale file ${filePath}: ${String(err)}`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const bundle: LocaleBundle = {
|
|
211
|
+
locale,
|
|
212
|
+
translations: flattenTranslations(parsed),
|
|
213
|
+
loadedAt: Date.now(),
|
|
214
|
+
};
|
|
215
|
+
this.cache.set(locale, bundle);
|
|
216
|
+
return bundle;
|
|
217
|
+
}
|
|
218
|
+
}
|