@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.
Files changed (218) hide show
  1. package/README.md +136 -16
  2. package/dist/cli/{index.js → bin.js} +3036 -1421
  3. package/dist/container/index.js +250 -0
  4. package/dist/context/index.js +219 -0
  5. package/dist/database/index.js +493 -0
  6. package/dist/frontend/index.js +7697 -0
  7. package/dist/health/index.js +364 -0
  8. package/dist/i18n/index.js +345 -0
  9. package/dist/index.js +11043 -6482
  10. package/dist/jobs/index.js +819 -0
  11. package/dist/lock/index.js +367 -0
  12. package/dist/logger/index.js +281 -0
  13. package/dist/metrics/index.js +289 -0
  14. package/dist/middleware/index.js +77 -0
  15. package/dist/migrations/index.js +571 -0
  16. package/dist/modules/index.js +3346 -0
  17. package/dist/notification/index.js +484 -0
  18. package/dist/observability/index.js +331 -0
  19. package/dist/openapi/index.js +776 -0
  20. package/dist/orm/index.js +1356 -0
  21. package/dist/router/index.js +886 -0
  22. package/dist/rpc/index.js +691 -0
  23. package/dist/schema/index.js +400 -0
  24. package/dist/telemetry/index.js +595 -0
  25. package/dist/template/index.js +640 -0
  26. package/dist/templates/index.js +640 -0
  27. package/dist/testing/index.js +1111 -0
  28. package/dist/types/index.js +60 -0
  29. package/package.json +121 -27
  30. package/src/cache/index.ts +2 -1
  31. package/src/cli/bin.ts +2 -2
  32. package/src/cli/commands/build.ts +183 -165
  33. package/src/cli/commands/dev.ts +96 -89
  34. package/src/cli/commands/generate.ts +142 -111
  35. package/src/cli/commands/help.ts +20 -16
  36. package/src/cli/commands/index.ts +3 -6
  37. package/src/cli/commands/migration.ts +124 -105
  38. package/src/cli/commands/new.ts +392 -438
  39. package/src/cli/commands/start.ts +81 -79
  40. package/src/cli/core/args.ts +68 -50
  41. package/src/cli/core/console.ts +89 -95
  42. package/src/cli/core/index.ts +4 -4
  43. package/src/cli/core/prompt.ts +65 -62
  44. package/src/cli/core/spinner.ts +23 -20
  45. package/src/cli/index.ts +46 -38
  46. package/src/cli/templates/database/index.ts +61 -0
  47. package/src/cli/templates/database/mysql.ts +14 -0
  48. package/src/cli/templates/database/none.ts +16 -0
  49. package/src/cli/templates/database/postgresql.ts +14 -0
  50. package/src/cli/templates/database/sqlite.ts +14 -0
  51. package/src/cli/templates/deploy.ts +29 -26
  52. package/src/cli/templates/docker.ts +41 -30
  53. package/src/cli/templates/frontend/index.ts +63 -0
  54. package/src/cli/templates/frontend/none.ts +17 -0
  55. package/src/cli/templates/frontend/react.ts +140 -0
  56. package/src/cli/templates/frontend/solid.ts +134 -0
  57. package/src/cli/templates/frontend/svelte.ts +131 -0
  58. package/src/cli/templates/frontend/vue.ts +130 -0
  59. package/src/cli/templates/generators/index.ts +339 -0
  60. package/src/cli/templates/generators/types.ts +56 -0
  61. package/src/cli/templates/index.ts +35 -2
  62. package/src/cli/templates/project/api.ts +81 -0
  63. package/src/cli/templates/project/default.ts +140 -0
  64. package/src/cli/templates/project/fullstack.ts +111 -0
  65. package/src/cli/templates/project/index.ts +95 -0
  66. package/src/cli/templates/project/minimal.ts +45 -0
  67. package/src/cli/templates/project/types.ts +94 -0
  68. package/src/cli/templates/project/website.ts +263 -0
  69. package/src/cli/utils/fs.ts +55 -41
  70. package/src/cli/utils/index.ts +3 -2
  71. package/src/cli/utils/strings.ts +47 -33
  72. package/src/cli/utils/version.ts +47 -0
  73. package/src/config/env-validation.ts +100 -0
  74. package/src/config/env.ts +169 -41
  75. package/src/config/index.ts +28 -20
  76. package/src/config/loader.ts +25 -16
  77. package/src/config/merge.ts +21 -10
  78. package/src/config/types.ts +545 -25
  79. package/src/config/validation.ts +215 -7
  80. package/src/container/forward-ref.ts +22 -22
  81. package/src/container/index.ts +34 -12
  82. package/src/context/index.ts +11 -1
  83. package/src/database/index.ts +7 -190
  84. package/src/database/orm/builder.ts +457 -0
  85. package/src/database/orm/casts/index.ts +130 -0
  86. package/src/database/orm/casts/types.ts +25 -0
  87. package/src/database/orm/compiler.ts +304 -0
  88. package/src/database/orm/hooks/index.ts +114 -0
  89. package/src/database/orm/index.ts +61 -0
  90. package/src/database/orm/model-registry.ts +59 -0
  91. package/src/database/orm/model.ts +821 -0
  92. package/src/database/orm/relationships/base.ts +146 -0
  93. package/src/database/orm/relationships/belongs-to-many.ts +179 -0
  94. package/src/database/orm/relationships/belongs-to.ts +56 -0
  95. package/src/database/orm/relationships/has-many.ts +45 -0
  96. package/src/database/orm/relationships/has-one.ts +41 -0
  97. package/src/database/orm/relationships/index.ts +11 -0
  98. package/src/database/orm/scopes/index.ts +55 -0
  99. package/src/events/__tests__/event-system.test.ts +235 -0
  100. package/src/events/config.ts +238 -0
  101. package/src/events/example-usage.ts +185 -0
  102. package/src/events/index.ts +278 -0
  103. package/src/events/manager.ts +385 -0
  104. package/src/events/registry.ts +182 -0
  105. package/src/events/types.ts +124 -0
  106. package/src/frontend/api-routes.ts +65 -23
  107. package/src/frontend/bundler.ts +76 -34
  108. package/src/frontend/console-client.ts +2 -2
  109. package/src/frontend/console-stream.ts +94 -38
  110. package/src/frontend/dev-server.ts +94 -46
  111. package/src/frontend/file-router.ts +61 -19
  112. package/src/frontend/frameworks/index.ts +37 -10
  113. package/src/frontend/frameworks/react.ts +10 -8
  114. package/src/frontend/frameworks/solid.ts +11 -9
  115. package/src/frontend/frameworks/svelte.ts +15 -9
  116. package/src/frontend/frameworks/vue.ts +13 -11
  117. package/src/frontend/hmr-client.ts +12 -10
  118. package/src/frontend/hmr.ts +146 -103
  119. package/src/frontend/index.ts +14 -5
  120. package/src/frontend/islands.ts +41 -22
  121. package/src/frontend/isr.ts +59 -37
  122. package/src/frontend/layout.ts +36 -21
  123. package/src/frontend/ssr/react.ts +74 -27
  124. package/src/frontend/ssr/solid.ts +54 -20
  125. package/src/frontend/ssr/svelte.ts +48 -14
  126. package/src/frontend/ssr/vue.ts +50 -18
  127. package/src/frontend/ssr.ts +83 -39
  128. package/src/frontend/types.ts +91 -56
  129. package/src/health/index.ts +21 -9
  130. package/src/i18n/engine.ts +305 -0
  131. package/src/i18n/index.ts +38 -0
  132. package/src/i18n/loader.ts +218 -0
  133. package/src/i18n/middleware.ts +164 -0
  134. package/src/i18n/negotiator.ts +162 -0
  135. package/src/i18n/types.ts +158 -0
  136. package/src/index.ts +179 -27
  137. package/src/jobs/drivers/memory.ts +315 -0
  138. package/src/jobs/drivers/redis.ts +459 -0
  139. package/src/jobs/index.ts +30 -0
  140. package/src/jobs/queue.ts +281 -0
  141. package/src/jobs/types.ts +295 -0
  142. package/src/jobs/worker.ts +380 -0
  143. package/src/logger/index.ts +1 -3
  144. package/src/logger/transports/index.ts +62 -22
  145. package/src/metrics/index.ts +25 -16
  146. package/src/migrations/index.ts +9 -0
  147. package/src/modules/filters.ts +13 -17
  148. package/src/modules/guards.ts +49 -26
  149. package/src/modules/index.ts +409 -298
  150. package/src/modules/interceptors.ts +58 -20
  151. package/src/modules/lazy.ts +11 -19
  152. package/src/modules/lifecycle.ts +15 -7
  153. package/src/modules/metadata.ts +15 -5
  154. package/src/modules/pipes.ts +94 -72
  155. package/src/notification/channels/base.ts +68 -0
  156. package/src/notification/channels/email.ts +105 -0
  157. package/src/notification/channels/push.ts +104 -0
  158. package/src/notification/channels/sms.ts +105 -0
  159. package/src/notification/channels/whatsapp.ts +104 -0
  160. package/src/notification/index.ts +48 -0
  161. package/src/notification/service.ts +354 -0
  162. package/src/notification/types.ts +344 -0
  163. package/src/observability/__tests__/observability.test.ts +483 -0
  164. package/src/observability/breadcrumbs.ts +114 -0
  165. package/src/observability/index.ts +136 -0
  166. package/src/observability/interceptor.ts +85 -0
  167. package/src/observability/service.ts +303 -0
  168. package/src/observability/trace.ts +37 -0
  169. package/src/observability/types.ts +196 -0
  170. package/src/openapi/__tests__/decorators.test.ts +335 -0
  171. package/src/openapi/__tests__/document-builder.test.ts +285 -0
  172. package/src/openapi/__tests__/route-scanner.test.ts +334 -0
  173. package/src/openapi/__tests__/schema-generator.test.ts +275 -0
  174. package/src/openapi/decorators.ts +328 -0
  175. package/src/openapi/document-builder.ts +274 -0
  176. package/src/openapi/index.ts +112 -0
  177. package/src/openapi/metadata.ts +112 -0
  178. package/src/openapi/route-scanner.ts +289 -0
  179. package/src/openapi/schema-generator.ts +256 -0
  180. package/src/openapi/swagger-module.ts +166 -0
  181. package/src/openapi/types.ts +398 -0
  182. package/src/orm/index.ts +10 -0
  183. package/src/rpc/index.ts +3 -1
  184. package/src/schema/index.ts +9 -0
  185. package/src/security/index.ts +15 -6
  186. package/src/ssg/index.ts +9 -8
  187. package/src/telemetry/index.ts +76 -22
  188. package/src/template/index.ts +7 -0
  189. package/src/templates/engine.ts +224 -0
  190. package/src/templates/index.ts +9 -0
  191. package/src/templates/loader.ts +331 -0
  192. package/src/templates/renderers/markdown.ts +212 -0
  193. package/src/templates/renderers/simple.ts +269 -0
  194. package/src/templates/types.ts +154 -0
  195. package/src/testing/index.ts +100 -27
  196. package/src/types/optional-deps.d.ts +347 -187
  197. package/src/validation/index.ts +92 -2
  198. package/src/validation/schemas.ts +536 -0
  199. package/tests/integration/fullstack.test.ts +4 -4
  200. package/tests/unit/database.test.ts +2 -72
  201. package/tests/unit/env-validation.test.ts +166 -0
  202. package/tests/unit/events.test.ts +910 -0
  203. package/tests/unit/i18n.test.ts +455 -0
  204. package/tests/unit/jobs.test.ts +493 -0
  205. package/tests/unit/notification.test.ts +988 -0
  206. package/tests/unit/observability.test.ts +453 -0
  207. package/tests/unit/orm/builder.test.ts +323 -0
  208. package/tests/unit/orm/casts.test.ts +179 -0
  209. package/tests/unit/orm/compiler.test.ts +220 -0
  210. package/tests/unit/orm/eager-loading.test.ts +285 -0
  211. package/tests/unit/orm/hooks.test.ts +191 -0
  212. package/tests/unit/orm/model.test.ts +373 -0
  213. package/tests/unit/orm/relationships.test.ts +303 -0
  214. package/tests/unit/orm/scopes.test.ts +74 -0
  215. package/tests/unit/templates-simple.test.ts +53 -0
  216. package/tests/unit/templates.test.ts +454 -0
  217. package/tests/unit/validation.test.ts +18 -24
  218. 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
+ }