@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,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* i18n Middleware — Locale Detection and Translation Binding
|
|
3
|
+
*
|
|
4
|
+
* Detects locale from cookie (priority 1) then Accept-Language header (priority 2),
|
|
5
|
+
* binds t() and locale to context, persists locale choice in a cookie.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Context } from "../context";
|
|
9
|
+
import type { Middleware } from "../middleware";
|
|
10
|
+
import { type I18n, createI18n } from "./engine";
|
|
11
|
+
import type { I18nConfig, TranslationFunction } from "./types";
|
|
12
|
+
|
|
13
|
+
// ============= Typed Context Helpers =============
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get the current locale from context.
|
|
17
|
+
* Typed helper for accessing the locale set by i18nMiddleware.
|
|
18
|
+
*
|
|
19
|
+
* Returns the default locale ("en") if middleware has not run.
|
|
20
|
+
* Useful in route handlers:
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* router.get('/locale', (ctx) => {
|
|
24
|
+
* const locale = getLocale(ctx); // 'fr', 'en', etc.
|
|
25
|
+
* return ctx.json({ locale });
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* @param ctx Bueno Context instance
|
|
29
|
+
* @returns Current locale, or "en" if middleware hasn't run
|
|
30
|
+
*/
|
|
31
|
+
export function getLocale(ctx: Context): string {
|
|
32
|
+
return (ctx.get("locale") as string | undefined) ?? "en";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get the bound translation function from context.
|
|
37
|
+
* Typed helper for accessing the t() set by i18nMiddleware.
|
|
38
|
+
*
|
|
39
|
+
* Returns an identity function (returns key as-is) if middleware has not run.
|
|
40
|
+
* Useful in route handlers:
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* router.get('/greeting', (ctx) => {
|
|
44
|
+
* const t = getT(ctx);
|
|
45
|
+
* const message = t('greeting', { name: 'Alice' });
|
|
46
|
+
* return ctx.text(message);
|
|
47
|
+
* });
|
|
48
|
+
*
|
|
49
|
+
* @param ctx Bueno Context instance
|
|
50
|
+
* @returns Translation function, or identity function if middleware hasn't run
|
|
51
|
+
*/
|
|
52
|
+
export function getT(ctx: Context): TranslationFunction {
|
|
53
|
+
return (ctx.get("t") as TranslationFunction | undefined) ?? ((key) => key);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ============= Middleware Options =============
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Options for i18nMiddleware.
|
|
60
|
+
* Extends I18nConfig and adds an optional pre-constructed I18n instance.
|
|
61
|
+
*/
|
|
62
|
+
export interface I18nMiddlewareOptions extends I18nConfig {
|
|
63
|
+
/**
|
|
64
|
+
* Pre-constructed I18n instance.
|
|
65
|
+
* If not provided, one is created from the other options.
|
|
66
|
+
*
|
|
67
|
+
* Pass this when you want a single shared instance across multiple
|
|
68
|
+
* middleware chains or route groups (prevents double-loading locale files).
|
|
69
|
+
*/
|
|
70
|
+
i18n?: I18n;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ============= Middleware Factory =============
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Create i18n middleware.
|
|
77
|
+
*
|
|
78
|
+
* Locale detection priority:
|
|
79
|
+
* 1. Cookie (bueno_locale or custom cookieName)
|
|
80
|
+
* 2. Accept-Language header
|
|
81
|
+
* 3. Default locale (config.defaultLocale)
|
|
82
|
+
*
|
|
83
|
+
* Sets on context (available via getLocale/getT):
|
|
84
|
+
* - ctx.set('locale', detectedLocale)
|
|
85
|
+
* - ctx.set('t', translationFunction)
|
|
86
|
+
*
|
|
87
|
+
* Sets on response:
|
|
88
|
+
* - Set-Cookie header to persist locale choice
|
|
89
|
+
* - Vary: Accept-Language header (for correct CDN caching)
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```typescript
|
|
93
|
+
* const router = new Router();
|
|
94
|
+
* router.use(i18nMiddleware({
|
|
95
|
+
* defaultLocale: 'en',
|
|
96
|
+
* supportedLocales: ['en', 'fr', 'de'],
|
|
97
|
+
* basePath: 'resources/i18n'
|
|
98
|
+
* }));
|
|
99
|
+
*
|
|
100
|
+
* router.get('/hello', (ctx) => {
|
|
101
|
+
* const locale = getLocale(ctx);
|
|
102
|
+
* const t = getT(ctx);
|
|
103
|
+
* return ctx.json({ message: t('greeting') });
|
|
104
|
+
* });
|
|
105
|
+
* ```
|
|
106
|
+
*
|
|
107
|
+
* @param options Configuration options
|
|
108
|
+
* @returns Koa-style middleware function
|
|
109
|
+
*/
|
|
110
|
+
export function i18nMiddleware(
|
|
111
|
+
options: I18nMiddlewareOptions = {},
|
|
112
|
+
): Middleware {
|
|
113
|
+
const engine = options.i18n ?? createI18n(options);
|
|
114
|
+
const negotiator = engine.getNegotiator();
|
|
115
|
+
const cookieName = engine.config.cookieName;
|
|
116
|
+
const cookieMaxAge = engine.config.cookieMaxAge;
|
|
117
|
+
|
|
118
|
+
return async (
|
|
119
|
+
ctx: Context,
|
|
120
|
+
next: () => Promise<Response>,
|
|
121
|
+
): Promise<Response> => {
|
|
122
|
+
// --- Step 1: Detect locale ---
|
|
123
|
+
let locale: string;
|
|
124
|
+
|
|
125
|
+
// Priority 1: Cookie
|
|
126
|
+
const cookieLocale = ctx.getCookie(cookieName);
|
|
127
|
+
if (cookieLocale && negotiator.isSupported(cookieLocale)) {
|
|
128
|
+
locale = cookieLocale;
|
|
129
|
+
} else {
|
|
130
|
+
// Priority 2: Accept-Language header
|
|
131
|
+
const acceptLanguage = ctx.getHeader("accept-language") ?? "";
|
|
132
|
+
if (acceptLanguage) {
|
|
133
|
+
const match = negotiator.negotiate(acceptLanguage);
|
|
134
|
+
locale = match.locale;
|
|
135
|
+
} else {
|
|
136
|
+
locale = engine.config.defaultLocale;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --- Step 2: Store on context ---
|
|
141
|
+
// Use 'as never' cast for now (same pattern as requestId middleware)
|
|
142
|
+
// Typed access via getLocale(ctx) and getT(ctx) helpers
|
|
143
|
+
ctx.set("locale" as never, locale);
|
|
144
|
+
ctx.set("t" as never, engine.createTranslator(locale));
|
|
145
|
+
|
|
146
|
+
// --- Step 3: Call next ---
|
|
147
|
+
const response = await next();
|
|
148
|
+
|
|
149
|
+
// --- Step 4: Set cookie on response ---
|
|
150
|
+
// Always refresh the cookie so it stays alive during user session
|
|
151
|
+
const cookieValue = `${cookieName}=${locale}; Max-Age=${cookieMaxAge}; Path=/; SameSite=Lax`;
|
|
152
|
+
response.headers.append("Set-Cookie", cookieValue);
|
|
153
|
+
|
|
154
|
+
// --- Step 5: Set Vary header ---
|
|
155
|
+
// Instruct caches to vary on Accept-Language so different locales are cached separately
|
|
156
|
+
const existing = response.headers.get("Vary");
|
|
157
|
+
response.headers.set(
|
|
158
|
+
"Vary",
|
|
159
|
+
existing ? `${existing}, Accept-Language` : "Accept-Language",
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
return response;
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locale Negotiation — Accept-Language Header Parsing
|
|
3
|
+
*
|
|
4
|
+
* Parses RFC 7231 Accept-Language headers and matches them against
|
|
5
|
+
* supported locales using exact and language-prefix matching.
|
|
6
|
+
*
|
|
7
|
+
* @see https://tools.ietf.org/html/rfc7231#section-5.3.5
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { LocaleMatch } from "./types";
|
|
11
|
+
|
|
12
|
+
// ============= Types =============
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parsed entry from an Accept-Language header.
|
|
16
|
+
*/
|
|
17
|
+
interface AcceptEntry {
|
|
18
|
+
locale: string; // e.g. "en-US"
|
|
19
|
+
quality: number; // 0.0–1.0, default 1.0
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ============= Parsing =============
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parse RFC 7231 Accept-Language header value into quality-sorted entries.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* Input: "en-US,en;q=0.9,fr;q=0.8"
|
|
29
|
+
* Output: [
|
|
30
|
+
* { locale: "en-US", quality: 1.0 },
|
|
31
|
+
* { locale: "en", quality: 0.9 },
|
|
32
|
+
* { locale: "fr", quality: 0.8 }
|
|
33
|
+
* ]
|
|
34
|
+
*
|
|
35
|
+
* @param header Raw Accept-Language header value
|
|
36
|
+
* @returns Array of entries sorted by quality (highest first)
|
|
37
|
+
*/
|
|
38
|
+
export function parseAcceptLanguage(header: string): AcceptEntry[] {
|
|
39
|
+
if (!header.trim()) return [];
|
|
40
|
+
|
|
41
|
+
return header
|
|
42
|
+
.split(",")
|
|
43
|
+
.map((part) => {
|
|
44
|
+
const [localeRaw, qRaw] = part.trim().split(";");
|
|
45
|
+
const locale = localeRaw?.trim() ?? "";
|
|
46
|
+
const quality = qRaw
|
|
47
|
+
? Number.parseFloat(qRaw.trim().replace("q=", ""))
|
|
48
|
+
: 1.0;
|
|
49
|
+
return { locale, quality: isNaN(quality) ? 1.0 : quality };
|
|
50
|
+
})
|
|
51
|
+
.filter((e) => e.locale.length > 0)
|
|
52
|
+
.sort((a, b) => b.quality - a.quality);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Normalise a locale tag:
|
|
57
|
+
* - Converts underscore to hyphen ("en_US" → "en-US")
|
|
58
|
+
* - Lowercases language subtag ("EN" → "en")
|
|
59
|
+
* - Uppercases region subtag ("us" → "US")
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* normaliseLocale("en_US") → "en-US"
|
|
63
|
+
* normaliseLocale("EN-us") → "en-US"
|
|
64
|
+
* normaliseLocale("fr") → "fr"
|
|
65
|
+
*
|
|
66
|
+
* @param locale Raw locale string
|
|
67
|
+
* @returns Normalised locale string
|
|
68
|
+
*/
|
|
69
|
+
export function normaliseLocale(locale: string): string {
|
|
70
|
+
const parts = locale.replace("_", "-").split("-");
|
|
71
|
+
if (parts.length === 0) return locale;
|
|
72
|
+
const lang = parts[0]!.toLowerCase();
|
|
73
|
+
if (parts.length === 1) return lang;
|
|
74
|
+
const region = parts[1]!.toUpperCase();
|
|
75
|
+
return `${lang}-${region}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Extract the language subtag from a locale.
|
|
80
|
+
* @example languageSubtag("en-US") → "en"
|
|
81
|
+
* @param locale Locale identifier
|
|
82
|
+
* @returns Language subtag (first part before hyphen)
|
|
83
|
+
*/
|
|
84
|
+
function languageSubtag(locale: string): string {
|
|
85
|
+
return locale.split("-")[0]!.toLowerCase();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ============= Negotiator =============
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Negotiates the best locale match given an Accept-Language header
|
|
92
|
+
* and a list of supported locales.
|
|
93
|
+
*
|
|
94
|
+
* Matching strategy (two-pass):
|
|
95
|
+
* 1. Exact match: find a supported locale that exactly matches (case-normalised) any entry
|
|
96
|
+
* 2. Language prefix match: find a supported locale whose language subtag matches any entry
|
|
97
|
+
* 3. Default: return the configured default locale
|
|
98
|
+
*
|
|
99
|
+
* This two-pass approach ensures correct behavior even when entries
|
|
100
|
+
* are out of quality order, e.g. "fr-CA,de;q=0.9" against ["de","fr"]
|
|
101
|
+
* should return "fr" (via prefix match), not "de".
|
|
102
|
+
*/
|
|
103
|
+
export class LocaleNegotiator {
|
|
104
|
+
private supported: string[];
|
|
105
|
+
private defaultLocale: string;
|
|
106
|
+
|
|
107
|
+
constructor(supportedLocales: string[], defaultLocale: string) {
|
|
108
|
+
this.supported = supportedLocales;
|
|
109
|
+
this.defaultLocale = defaultLocale;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Find the best matching locale given a raw Accept-Language header string.
|
|
114
|
+
*
|
|
115
|
+
* @param acceptLanguageHeader Raw Accept-Language header value
|
|
116
|
+
* @returns LocaleMatch with matched locale and strategy used
|
|
117
|
+
*/
|
|
118
|
+
negotiate(acceptLanguageHeader: string): LocaleMatch {
|
|
119
|
+
const entries = parseAcceptLanguage(acceptLanguageHeader);
|
|
120
|
+
|
|
121
|
+
// Pass 1: exact match (case-normalised)
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
const norm = normaliseLocale(entry.locale);
|
|
124
|
+
|
|
125
|
+
const exact = this.supported.find((s) => normaliseLocale(s) === norm);
|
|
126
|
+
if (exact) {
|
|
127
|
+
return { locale: exact, strategy: "exact", source: entry.locale };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Pass 2: language prefix match ("en-US" matches supported "en")
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
const lang = languageSubtag(entry.locale);
|
|
134
|
+
|
|
135
|
+
const prefix = this.supported.find((s) => languageSubtag(s) === lang);
|
|
136
|
+
if (prefix) {
|
|
137
|
+
return { locale: prefix, strategy: "prefix", source: entry.locale };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Default fallback
|
|
142
|
+
return {
|
|
143
|
+
locale: this.defaultLocale,
|
|
144
|
+
strategy: "default",
|
|
145
|
+
source: "",
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Validate whether a given locale string is in the supported list.
|
|
151
|
+
* Used to validate cookie values before trusting them.
|
|
152
|
+
*
|
|
153
|
+
* Performs strict membership check — does not normalise or approximate.
|
|
154
|
+
* If a cookie contains "fr-CA" but only "fr" is supported, this returns false.
|
|
155
|
+
*
|
|
156
|
+
* @param locale Locale to validate
|
|
157
|
+
* @returns true if locale is in supportedLocales
|
|
158
|
+
*/
|
|
159
|
+
isSupported(locale: string): boolean {
|
|
160
|
+
return this.supported.includes(locale);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* i18n Types and Interfaces
|
|
3
|
+
*
|
|
4
|
+
* Core types for the internationalisation system:
|
|
5
|
+
* locale negotiation, translation lookup, plural forms, and configuration.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ============= CLDR Plural Keys =============
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* CLDR-standard plural form categories.
|
|
12
|
+
* Used to select the appropriate plural variant of a translation.
|
|
13
|
+
*
|
|
14
|
+
* @see https://cldr.unicode.org/index/cldr-spec/plural-rules
|
|
15
|
+
*/
|
|
16
|
+
export type PluralKey = "zero" | "one" | "two" | "few" | "many" | "other";
|
|
17
|
+
|
|
18
|
+
// ============= Translation Types =============
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Raw translation file shape — can be flat or nested JSON.
|
|
22
|
+
* @example
|
|
23
|
+
* { "nav.home": "Home" } or { "nav": { "home": "Home" } }
|
|
24
|
+
*/
|
|
25
|
+
export type TranslationMap = Record<string, unknown>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Flat map of dot-notation keys → string values.
|
|
29
|
+
* The loader always normalises nested keys to this format before caching.
|
|
30
|
+
* @example
|
|
31
|
+
* Map { "nav.home" → "Home", "nav.about" → "About" }
|
|
32
|
+
*/
|
|
33
|
+
export type FlatTranslations = Map<string, string>;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parameters passed to the translation function.
|
|
37
|
+
* `count` is special — it drives plural form selection.
|
|
38
|
+
* Other keys are used for variable interpolation ({{ key }}).
|
|
39
|
+
*/
|
|
40
|
+
export interface TranslationParams {
|
|
41
|
+
count?: number;
|
|
42
|
+
[key: string]: unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The t() function signature stored on context and returned by the engine.
|
|
47
|
+
* @example
|
|
48
|
+
* t('greeting', { name: 'Alice' }) → "Hello, Alice!"
|
|
49
|
+
*/
|
|
50
|
+
export type TranslationFunction = (
|
|
51
|
+
key: string,
|
|
52
|
+
params?: TranslationParams,
|
|
53
|
+
) => string;
|
|
54
|
+
|
|
55
|
+
// ============= Locale Negotiation =============
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Result of a locale negotiation attempt against supported locales.
|
|
59
|
+
* Used to determine which locale to load based on client preferences.
|
|
60
|
+
*/
|
|
61
|
+
export interface LocaleMatch {
|
|
62
|
+
/** The matched locale from supportedLocales (e.g. "fr") */
|
|
63
|
+
locale: string;
|
|
64
|
+
/** How the match was found: exact string match, language prefix match, or default */
|
|
65
|
+
strategy: "exact" | "prefix" | "default";
|
|
66
|
+
/** The original value that was negotiated (e.g. "fr-CA" from Accept-Language) */
|
|
67
|
+
source: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ============= I18n Context (what is stored on ctx) =============
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* What the middleware stores on the Bueno Context instance.
|
|
74
|
+
* Accessed via ctx.get('locale') and ctx.get('t').
|
|
75
|
+
* Typed helpers getLocale(ctx) and getT(ctx) are recommended.
|
|
76
|
+
*/
|
|
77
|
+
export interface I18nContext {
|
|
78
|
+
locale: string;
|
|
79
|
+
t: TranslationFunction;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ============= I18n Configuration =============
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* User-facing i18n configuration.
|
|
86
|
+
* All fields are optional; defaults are applied by the engine.
|
|
87
|
+
* Environment variable pattern: BUENO_I18N_*
|
|
88
|
+
*/
|
|
89
|
+
export interface I18nConfig {
|
|
90
|
+
/** Enable/disable the i18n system (default: false) */
|
|
91
|
+
enabled?: boolean;
|
|
92
|
+
/** Default locale — used as fallback when requested locale has missing keys (default: "en") */
|
|
93
|
+
defaultLocale?: string;
|
|
94
|
+
/** List of all supported locale identifiers (default: ["en"]) */
|
|
95
|
+
supportedLocales?: string[];
|
|
96
|
+
/** Base directory for locale JSON files (default: "resources/i18n") */
|
|
97
|
+
basePath?: string;
|
|
98
|
+
/**
|
|
99
|
+
* When a key is missing in the requested locale,
|
|
100
|
+
* fall back to the defaultLocale before returning the key string (default: true)
|
|
101
|
+
*/
|
|
102
|
+
fallbackToDefault?: boolean;
|
|
103
|
+
/** Cookie name used to persist locale choice (default: "bueno_locale") */
|
|
104
|
+
cookieName?: string;
|
|
105
|
+
/** Cookie max-age in seconds (default: 31536000 = 1 year) */
|
|
106
|
+
cookieMaxAge?: number;
|
|
107
|
+
/** Enable file watching for hot reload in development (default: false) */
|
|
108
|
+
watch?: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ============= Resolved I18n Config (internal) =============
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Fully-resolved i18n configuration with all defaults applied.
|
|
115
|
+
* All fields are required (non-optional) — used internally by the engine.
|
|
116
|
+
*/
|
|
117
|
+
export interface ResolvedI18nConfig {
|
|
118
|
+
defaultLocale: string;
|
|
119
|
+
supportedLocales: string[];
|
|
120
|
+
basePath: string;
|
|
121
|
+
fallbackToDefault: boolean;
|
|
122
|
+
cookieName: string;
|
|
123
|
+
cookieMaxAge: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ============= Loader Types =============
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A loaded and cached locale bundle.
|
|
130
|
+
* Contains flattened translations and metadata.
|
|
131
|
+
*/
|
|
132
|
+
export interface LocaleBundle {
|
|
133
|
+
/** Locale identifier e.g. "en", "fr-CA" */
|
|
134
|
+
locale: string;
|
|
135
|
+
/** Flat dot-notation key → string value map */
|
|
136
|
+
translations: FlatTranslations;
|
|
137
|
+
/** When this bundle was loaded (ms epoch) */
|
|
138
|
+
loadedAt: number;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ============= Metrics =============
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Metrics collected by the translation engine.
|
|
145
|
+
* Useful for debugging and performance monitoring.
|
|
146
|
+
*/
|
|
147
|
+
export interface I18nMetrics {
|
|
148
|
+
/** Total number of t() calls */
|
|
149
|
+
totalLookups: number;
|
|
150
|
+
/** Lookups that found a key in the requested locale */
|
|
151
|
+
hits: number;
|
|
152
|
+
/** Lookups that fell back to the default locale */
|
|
153
|
+
fallbacks: number;
|
|
154
|
+
/** Lookups that returned the key string (complete miss) */
|
|
155
|
+
misses: number;
|
|
156
|
+
/** Locales currently loaded in memory */
|
|
157
|
+
loadedLocales: string[];
|
|
158
|
+
}
|