@strav/kernel 0.1.0
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 +59 -0
- package/src/cache/cache_manager.ts +60 -0
- package/src/cache/cache_store.ts +31 -0
- package/src/cache/helpers.ts +74 -0
- package/src/cache/index.ts +4 -0
- package/src/cache/memory_store.ts +63 -0
- package/src/config/configuration.ts +105 -0
- package/src/config/index.ts +2 -0
- package/src/config/loaders/base_loader.ts +69 -0
- package/src/config/loaders/env_loader.ts +112 -0
- package/src/config/loaders/typescript_loader.ts +56 -0
- package/src/config/types.ts +8 -0
- package/src/core/application.ts +241 -0
- package/src/core/container.ts +113 -0
- package/src/core/index.ts +4 -0
- package/src/core/inject.ts +39 -0
- package/src/core/service_provider.ts +44 -0
- package/src/encryption/encryption_manager.ts +215 -0
- package/src/encryption/helpers.ts +158 -0
- package/src/encryption/index.ts +3 -0
- package/src/encryption/types.ts +6 -0
- package/src/events/emitter.ts +101 -0
- package/src/events/index.ts +2 -0
- package/src/exceptions/errors.ts +71 -0
- package/src/exceptions/exception_handler.ts +140 -0
- package/src/exceptions/helpers.ts +25 -0
- package/src/exceptions/http_exception.ts +132 -0
- package/src/exceptions/index.ts +23 -0
- package/src/exceptions/strav_error.ts +11 -0
- package/src/helpers/compose.ts +104 -0
- package/src/helpers/crypto.ts +4 -0
- package/src/helpers/env.ts +50 -0
- package/src/helpers/index.ts +6 -0
- package/src/helpers/strings.ts +67 -0
- package/src/helpers/ulid.ts +28 -0
- package/src/i18n/defaults/en/validation.json +20 -0
- package/src/i18n/helpers.ts +76 -0
- package/src/i18n/i18n_manager.ts +157 -0
- package/src/i18n/index.ts +3 -0
- package/src/i18n/translator.ts +96 -0
- package/src/i18n/types.ts +17 -0
- package/src/index.ts +11 -0
- package/src/logger/index.ts +5 -0
- package/src/logger/logger.ts +113 -0
- package/src/logger/sinks/console_sink.ts +24 -0
- package/src/logger/sinks/file_sink.ts +24 -0
- package/src/logger/sinks/sink.ts +36 -0
- package/src/providers/cache_provider.ts +16 -0
- package/src/providers/config_provider.ts +26 -0
- package/src/providers/encryption_provider.ts +16 -0
- package/src/providers/i18n_provider.ts +17 -0
- package/src/providers/index.ts +8 -0
- package/src/providers/logger_provider.ts +16 -0
- package/src/providers/storage_provider.ts +16 -0
- package/src/storage/index.ts +32 -0
- package/src/storage/local_driver.ts +46 -0
- package/src/storage/ostra_client.ts +432 -0
- package/src/storage/ostra_driver.ts +58 -0
- package/src/storage/s3_driver.ts +51 -0
- package/src/storage/storage.ts +43 -0
- package/src/storage/storage_manager.ts +70 -0
- package/src/storage/types.ts +49 -0
- package/src/storage/upload.ts +91 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalizes a constructor to satisfy TypeScript's mixin constraint.
|
|
3
|
+
*
|
|
4
|
+
* TypeScript requires mixin base classes to have a `new (...args: any[]) => any`
|
|
5
|
+
* constructor. This type widens any class constructor while preserving its
|
|
6
|
+
* instance type and static members.
|
|
7
|
+
*
|
|
8
|
+
* @see https://github.com/microsoft/TypeScript/issues/37142
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* function myMixin<T extends NormalizeConstructor<typeof BaseModel>>(superclass: T) {
|
|
12
|
+
* return class extends superclass {
|
|
13
|
+
* greet() { return 'hello' }
|
|
14
|
+
* }
|
|
15
|
+
* }
|
|
16
|
+
*/
|
|
17
|
+
export type NormalizeConstructor<T extends new (...args: any[]) => any> = {
|
|
18
|
+
new (...args: any[]): InstanceType<T>
|
|
19
|
+
} & Omit<T, 'constructor'>
|
|
20
|
+
|
|
21
|
+
interface MixinFunction<In, Out> {
|
|
22
|
+
(superclass: In): Out
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Compose a class by applying mixins left-to-right.
|
|
27
|
+
*
|
|
28
|
+
* Eliminates deeply nested mixin syntax and provides full type safety
|
|
29
|
+
* for up to 8 mixins.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* import { compose } from '@stravigor/kernel/helpers'
|
|
33
|
+
* import { BaseModel } from '@stravigor/database/orm'
|
|
34
|
+
* import { billable } from '@stravigor/stripe'
|
|
35
|
+
*
|
|
36
|
+
* // Without compose (nested):
|
|
37
|
+
* class User extends billable(softDeletes(BaseModel)) { }
|
|
38
|
+
*
|
|
39
|
+
* // With compose (flat):
|
|
40
|
+
* class User extends compose(BaseModel, softDeletes, billable) { }
|
|
41
|
+
*/
|
|
42
|
+
export function compose<T extends new (...args: any[]) => any, A>(
|
|
43
|
+
superclass: T,
|
|
44
|
+
mixinA: MixinFunction<T, A>
|
|
45
|
+
): A
|
|
46
|
+
export function compose<T extends new (...args: any[]) => any, A, B>(
|
|
47
|
+
superclass: T,
|
|
48
|
+
mixinA: MixinFunction<T, A>,
|
|
49
|
+
mixinB: MixinFunction<A, B>
|
|
50
|
+
): B
|
|
51
|
+
export function compose<T extends new (...args: any[]) => any, A, B, C>(
|
|
52
|
+
superclass: T,
|
|
53
|
+
mixinA: MixinFunction<T, A>,
|
|
54
|
+
mixinB: MixinFunction<A, B>,
|
|
55
|
+
mixinC: MixinFunction<B, C>
|
|
56
|
+
): C
|
|
57
|
+
export function compose<T extends new (...args: any[]) => any, A, B, C, D>(
|
|
58
|
+
superclass: T,
|
|
59
|
+
mixinA: MixinFunction<T, A>,
|
|
60
|
+
mixinB: MixinFunction<A, B>,
|
|
61
|
+
mixinC: MixinFunction<B, C>,
|
|
62
|
+
mixinD: MixinFunction<C, D>
|
|
63
|
+
): D
|
|
64
|
+
export function compose<T extends new (...args: any[]) => any, A, B, C, D, E>(
|
|
65
|
+
superclass: T,
|
|
66
|
+
mixinA: MixinFunction<T, A>,
|
|
67
|
+
mixinB: MixinFunction<A, B>,
|
|
68
|
+
mixinC: MixinFunction<B, C>,
|
|
69
|
+
mixinD: MixinFunction<C, D>,
|
|
70
|
+
mixinE: MixinFunction<D, E>
|
|
71
|
+
): E
|
|
72
|
+
export function compose<T extends new (...args: any[]) => any, A, B, C, D, E, F>(
|
|
73
|
+
superclass: T,
|
|
74
|
+
mixinA: MixinFunction<T, A>,
|
|
75
|
+
mixinB: MixinFunction<A, B>,
|
|
76
|
+
mixinC: MixinFunction<B, C>,
|
|
77
|
+
mixinD: MixinFunction<C, D>,
|
|
78
|
+
mixinE: MixinFunction<D, E>,
|
|
79
|
+
mixinF: MixinFunction<E, F>
|
|
80
|
+
): F
|
|
81
|
+
export function compose<T extends new (...args: any[]) => any, A, B, C, D, E, F, G>(
|
|
82
|
+
superclass: T,
|
|
83
|
+
mixinA: MixinFunction<T, A>,
|
|
84
|
+
mixinB: MixinFunction<A, B>,
|
|
85
|
+
mixinC: MixinFunction<B, C>,
|
|
86
|
+
mixinD: MixinFunction<C, D>,
|
|
87
|
+
mixinE: MixinFunction<D, E>,
|
|
88
|
+
mixinF: MixinFunction<E, F>,
|
|
89
|
+
mixinG: MixinFunction<F, G>
|
|
90
|
+
): G
|
|
91
|
+
export function compose<T extends new (...args: any[]) => any, A, B, C, D, E, F, G, H>(
|
|
92
|
+
superclass: T,
|
|
93
|
+
mixinA: MixinFunction<T, A>,
|
|
94
|
+
mixinB: MixinFunction<A, B>,
|
|
95
|
+
mixinC: MixinFunction<B, C>,
|
|
96
|
+
mixinD: MixinFunction<C, D>,
|
|
97
|
+
mixinE: MixinFunction<D, E>,
|
|
98
|
+
mixinF: MixinFunction<E, F>,
|
|
99
|
+
mixinG: MixinFunction<F, G>,
|
|
100
|
+
mixinH: MixinFunction<G, H>
|
|
101
|
+
): H
|
|
102
|
+
export function compose(superclass: any, ...mixins: Function[]) {
|
|
103
|
+
return mixins.reduce((c, mixin) => mixin(c), superclass)
|
|
104
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read an environment variable with an optional default.
|
|
3
|
+
*
|
|
4
|
+
* Bun auto-loads `.env`, so all variables are available via `process.env`.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* env('DB_HOST', '127.0.0.1')
|
|
8
|
+
* env('APP_KEY') // throws if not set and no default
|
|
9
|
+
*/
|
|
10
|
+
function env(key: string, defaultValue: string): string
|
|
11
|
+
function env(key: string, defaultValue: null): string | null
|
|
12
|
+
function env(key: string): string
|
|
13
|
+
function env(key: string, defaultValue?: string | null): string | null {
|
|
14
|
+
const value = process.env[key]
|
|
15
|
+
if (value !== undefined) return value
|
|
16
|
+
if (defaultValue !== undefined) return defaultValue
|
|
17
|
+
throw new Error(`Environment variable "${key}" is not set`)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Read an environment variable as an integer. */
|
|
21
|
+
env.int = (key: string, defaultValue?: number): number => {
|
|
22
|
+
const raw = process.env[key]
|
|
23
|
+
if (raw !== undefined) {
|
|
24
|
+
const parsed = parseInt(raw, 10)
|
|
25
|
+
if (!isNaN(parsed)) return parsed
|
|
26
|
+
}
|
|
27
|
+
if (defaultValue !== undefined) return defaultValue
|
|
28
|
+
throw new Error(`Environment variable "${key}" is not set or not a valid integer`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Read an environment variable as a float. */
|
|
32
|
+
env.float = (key: string, defaultValue?: number): number => {
|
|
33
|
+
const raw = process.env[key]
|
|
34
|
+
if (raw !== undefined) {
|
|
35
|
+
const parsed = parseFloat(raw)
|
|
36
|
+
if (!isNaN(parsed)) return parsed
|
|
37
|
+
}
|
|
38
|
+
if (defaultValue !== undefined) return defaultValue
|
|
39
|
+
throw new Error(`Environment variable "${key}" is not set or not a valid number`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Read an environment variable as a boolean. Truthy: `'true'`, `'1'`, `'yes'`. */
|
|
43
|
+
env.bool = (key: string, defaultValue?: boolean): boolean => {
|
|
44
|
+
const raw = process.env[key]
|
|
45
|
+
if (raw !== undefined) return ['true', '1', 'yes'].includes(raw.toLowerCase())
|
|
46
|
+
if (defaultValue !== undefined) return defaultValue
|
|
47
|
+
throw new Error(`Environment variable "${key}" is not set`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export { env }
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { toSnakeCase, toCamelCase, toPascalCase } from './strings.ts'
|
|
2
|
+
export { env } from './env.ts'
|
|
3
|
+
export { randomHex } from './crypto.ts'
|
|
4
|
+
export { compose } from './compose.ts'
|
|
5
|
+
export type { NormalizeConstructor } from './compose.ts'
|
|
6
|
+
export { ulid, isUlid } from './ulid.ts'
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert a camelCase or PascalCase string to snake_case.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* toSnakeCase('firstName') // 'first_name'
|
|
6
|
+
* toSnakeCase('HTMLParser') // 'html_parser'
|
|
7
|
+
* toSnakeCase('already_snake') // 'already_snake'
|
|
8
|
+
*/
|
|
9
|
+
export function toSnakeCase(str: string): string {
|
|
10
|
+
return str
|
|
11
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
|
|
12
|
+
.replace(/([a-z\d])([A-Z])/g, '$1_$2')
|
|
13
|
+
.toLowerCase()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Convert a snake_case string to camelCase.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* toCamelCase('first_name') // 'firstName'
|
|
21
|
+
* toCamelCase('created_at') // 'createdAt'
|
|
22
|
+
* toCamelCase('reviewed_by_id') // 'reviewedById'
|
|
23
|
+
* toCamelCase('id') // 'id'
|
|
24
|
+
*/
|
|
25
|
+
export function toCamelCase(str: string): string {
|
|
26
|
+
return str.replace(/_([a-z\d])/g, (_, c) => c.toUpperCase())
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Convert a snake_case or plain string to PascalCase.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* toPascalCase('user_role') // 'UserRole'
|
|
34
|
+
* toPascalCase('user') // 'User'
|
|
35
|
+
* toPascalCase('order_item') // 'OrderItem'
|
|
36
|
+
*/
|
|
37
|
+
export function toPascalCase(str: string): string {
|
|
38
|
+
const camel = toCamelCase(str)
|
|
39
|
+
return camel.charAt(0).toUpperCase() + camel.slice(1)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Naively pluralize an English word.
|
|
44
|
+
*
|
|
45
|
+
* Handles common suffixes (s/x/z/ch/sh → es, consonant+y → ies).
|
|
46
|
+
* Not a full inflection library — adequate for code-gen route paths.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* pluralize('user') // 'users'
|
|
50
|
+
* pluralize('category') // 'categories'
|
|
51
|
+
* pluralize('status') // 'statuses'
|
|
52
|
+
*/
|
|
53
|
+
export function pluralize(word: string): string {
|
|
54
|
+
if (
|
|
55
|
+
word.endsWith('s') ||
|
|
56
|
+
word.endsWith('x') ||
|
|
57
|
+
word.endsWith('z') ||
|
|
58
|
+
word.endsWith('ch') ||
|
|
59
|
+
word.endsWith('sh')
|
|
60
|
+
) {
|
|
61
|
+
return word + 'es'
|
|
62
|
+
}
|
|
63
|
+
if (word.endsWith('y') && !/[aeiou]y$/.test(word)) {
|
|
64
|
+
return word.slice(0, -1) + 'ies'
|
|
65
|
+
}
|
|
66
|
+
return word + 's'
|
|
67
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { ulid as generateUlid } from 'ulid'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate a ULID (Universally Unique Lexicographically Sortable Identifier).
|
|
5
|
+
*
|
|
6
|
+
* ULIDs are 26-character strings that are:
|
|
7
|
+
* - Lexicographically sortable
|
|
8
|
+
* - Contain a timestamp component
|
|
9
|
+
* - Cryptographically secure random component
|
|
10
|
+
*
|
|
11
|
+
* @returns A new ULID string
|
|
12
|
+
*/
|
|
13
|
+
export function ulid(): string {
|
|
14
|
+
return generateUlid()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Check if a string is a valid ULID.
|
|
19
|
+
*
|
|
20
|
+
* @param value The string to check
|
|
21
|
+
* @returns true if the string is a valid ULID format
|
|
22
|
+
*/
|
|
23
|
+
export function isUlid(value: string): boolean {
|
|
24
|
+
// ULID is exactly 26 characters long and uses Crockford's base32
|
|
25
|
+
// Crockford's base32 excludes I, L, O, U to avoid confusion
|
|
26
|
+
const ulidRegex = /^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$/
|
|
27
|
+
return ulidRegex.test(value)
|
|
28
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"required": "This field is required",
|
|
3
|
+
"string": "Must be a string",
|
|
4
|
+
"integer": "Must be an integer",
|
|
5
|
+
"number": "Must be a number",
|
|
6
|
+
"boolean": "Must be a boolean",
|
|
7
|
+
"min": {
|
|
8
|
+
"number": "Must be at least :min",
|
|
9
|
+
"string": "Must be at least :min characters"
|
|
10
|
+
},
|
|
11
|
+
"max": {
|
|
12
|
+
"number": "Must be at most :max",
|
|
13
|
+
"string": "Must be at most :max characters"
|
|
14
|
+
},
|
|
15
|
+
"email": "Must be a valid email address",
|
|
16
|
+
"url": "Must be a valid URL",
|
|
17
|
+
"regex": "Invalid format",
|
|
18
|
+
"enum": "Must be one of: :values",
|
|
19
|
+
"array": "Must be an array"
|
|
20
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import I18nManager from './i18n_manager.ts'
|
|
3
|
+
import { translate, translateChoice, interpolate } from './translator.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Request-scoped locale storage.
|
|
7
|
+
* The i18n middleware sets the locale per-request via `localeStorage.run()`.
|
|
8
|
+
*/
|
|
9
|
+
export const localeStorage = new AsyncLocalStorage<string>()
|
|
10
|
+
|
|
11
|
+
// Hardcoded English fallbacks for validation rules when i18n is not configured
|
|
12
|
+
const VALIDATION_DEFAULTS: Record<string, string> = {
|
|
13
|
+
'validation.required': 'This field is required',
|
|
14
|
+
'validation.string': 'Must be a string',
|
|
15
|
+
'validation.integer': 'Must be an integer',
|
|
16
|
+
'validation.number': 'Must be a number',
|
|
17
|
+
'validation.boolean': 'Must be a boolean',
|
|
18
|
+
'validation.min.number': 'Must be at least :min',
|
|
19
|
+
'validation.min.string': 'Must be at least :min characters',
|
|
20
|
+
'validation.max.number': 'Must be at most :max',
|
|
21
|
+
'validation.max.string': 'Must be at most :max characters',
|
|
22
|
+
'validation.email': 'Must be a valid email address',
|
|
23
|
+
'validation.url': 'Must be a valid URL',
|
|
24
|
+
'validation.regex': 'Invalid format',
|
|
25
|
+
'validation.enum': 'Must be one of: :values',
|
|
26
|
+
'validation.array': 'Must be an array',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get the current locale from the request context, or the default locale.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* locale() // 'en'
|
|
34
|
+
*/
|
|
35
|
+
export function locale(): string {
|
|
36
|
+
return localeStorage.getStore() ?? I18nManager.config.default
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Translate a key with optional replacements.
|
|
41
|
+
* Uses the current request locale (from AsyncLocalStorage).
|
|
42
|
+
*
|
|
43
|
+
* If i18n is not configured, returns English fallbacks for validation
|
|
44
|
+
* keys and the raw key for everything else.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* t('auth.failed') // "Invalid credentials"
|
|
48
|
+
* t('messages.welcome', { name: 'Alice' }) // "Welcome, Alice!"
|
|
49
|
+
*/
|
|
50
|
+
export function t(key: string, replacements?: Record<string, string | number>): string {
|
|
51
|
+
if (!I18nManager.isLoaded) {
|
|
52
|
+
// Fallback: return hardcoded English default or the key itself
|
|
53
|
+
const fallback = VALIDATION_DEFAULTS[key]
|
|
54
|
+
if (fallback) return replacements ? interpolate(fallback, replacements) : fallback
|
|
55
|
+
return key
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return translate(locale(), key, replacements)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Pluralized translation based on count.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* choice('messages.apple', 1) // "one apple"
|
|
66
|
+
* choice('messages.apple', 5) // "5 apples"
|
|
67
|
+
*/
|
|
68
|
+
export function choice(
|
|
69
|
+
key: string,
|
|
70
|
+
count: number,
|
|
71
|
+
replacements?: Record<string, string | number>
|
|
72
|
+
): string {
|
|
73
|
+
if (!I18nManager.isLoaded) return key
|
|
74
|
+
|
|
75
|
+
return translateChoice(locale(), key, count, replacements)
|
|
76
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { resolve, join, basename } from 'node:path'
|
|
2
|
+
import { readdirSync } from 'node:fs'
|
|
3
|
+
import { inject } from '../core/inject.ts'
|
|
4
|
+
import Configuration from '../config/configuration.ts'
|
|
5
|
+
import { ConfigurationError } from '../exceptions/errors.ts'
|
|
6
|
+
import { dotGet } from './translator.ts'
|
|
7
|
+
import type { I18nConfig, Messages, LocaleDetection } from './types.ts'
|
|
8
|
+
|
|
9
|
+
// Default English validation messages (loaded as base layer)
|
|
10
|
+
import defaultValidation from './defaults/en/validation.json'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Central i18n configuration hub.
|
|
14
|
+
*
|
|
15
|
+
* Resolved once via the DI container — reads the i18n config,
|
|
16
|
+
* loads translation files from disk, and provides key resolution.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* app.singleton(I18nManager)
|
|
20
|
+
* app.resolve(I18nManager)
|
|
21
|
+
* await I18nManager.load()
|
|
22
|
+
*/
|
|
23
|
+
@inject
|
|
24
|
+
export default class I18nManager {
|
|
25
|
+
private static _config: I18nConfig
|
|
26
|
+
private static _translations = new Map<string, Messages>()
|
|
27
|
+
private static _loaded = false
|
|
28
|
+
|
|
29
|
+
constructor(config: Configuration) {
|
|
30
|
+
I18nManager._config = {
|
|
31
|
+
default: config.get('i18n.default', 'en') as string,
|
|
32
|
+
fallback: config.get('i18n.fallback', 'en') as string,
|
|
33
|
+
supported: config.get('i18n.supported', ['en']) as string[],
|
|
34
|
+
directory: config.get('i18n.directory', 'lang') as string,
|
|
35
|
+
detect: config.get('i18n.detect', ['header']) as LocaleDetection[],
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
static get config(): I18nConfig {
|
|
40
|
+
if (!I18nManager._config) {
|
|
41
|
+
throw new ConfigurationError(
|
|
42
|
+
'I18nManager not configured. Resolve it through the container first.'
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
return I18nManager._config
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Whether translation files have been loaded. */
|
|
49
|
+
static get isLoaded(): boolean {
|
|
50
|
+
return I18nManager._loaded
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Load all translation files from the configured directory.
|
|
55
|
+
* Merges built-in defaults as the base layer.
|
|
56
|
+
*/
|
|
57
|
+
static async load(): Promise<void> {
|
|
58
|
+
// Load built-in defaults first
|
|
59
|
+
I18nManager.mergeMessages('en', { validation: defaultValidation as Messages })
|
|
60
|
+
|
|
61
|
+
// Scan lang/ directory
|
|
62
|
+
const langDir = resolve(I18nManager._config.directory)
|
|
63
|
+
|
|
64
|
+
let locales: string[]
|
|
65
|
+
try {
|
|
66
|
+
locales = readdirSync(langDir, { withFileTypes: true })
|
|
67
|
+
.filter(d => d.isDirectory())
|
|
68
|
+
.map(d => d.name)
|
|
69
|
+
} catch {
|
|
70
|
+
// No lang directory — use defaults only
|
|
71
|
+
I18nManager._loaded = true
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const locale of locales) {
|
|
76
|
+
const localeDir = join(langDir, locale)
|
|
77
|
+
let files: string[]
|
|
78
|
+
try {
|
|
79
|
+
files = readdirSync(localeDir).filter(f => f.endsWith('.json'))
|
|
80
|
+
} catch {
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const file of files) {
|
|
85
|
+
const namespace = basename(file, '.json')
|
|
86
|
+
const filePath = join(localeDir, file)
|
|
87
|
+
const content = await Bun.file(filePath).json()
|
|
88
|
+
I18nManager.mergeMessages(locale, { [namespace]: content as Messages })
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
I18nManager._loaded = true
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Resolve a translation key for a specific locale.
|
|
97
|
+
* Tries the requested locale, then the fallback locale.
|
|
98
|
+
* Returns null if not found in either.
|
|
99
|
+
*/
|
|
100
|
+
static resolve(locale: string, key: string): string | null {
|
|
101
|
+
// Try requested locale
|
|
102
|
+
const messages = I18nManager._translations.get(locale)
|
|
103
|
+
if (messages) {
|
|
104
|
+
const value = dotGet(messages, key)
|
|
105
|
+
if (typeof value === 'string') return value
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Try fallback locale
|
|
109
|
+
const fallback = I18nManager._config?.fallback ?? 'en'
|
|
110
|
+
if (locale !== fallback) {
|
|
111
|
+
const fallbackMessages = I18nManager._translations.get(fallback)
|
|
112
|
+
if (fallbackMessages) {
|
|
113
|
+
const value = dotGet(fallbackMessages, key)
|
|
114
|
+
if (typeof value === 'string') return value
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Register translations at runtime (useful for testing or dynamic loading). */
|
|
122
|
+
static register(locale: string, messages: Messages): void {
|
|
123
|
+
I18nManager.mergeMessages(locale, messages)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Reset all state (for testing). */
|
|
127
|
+
static reset(): void {
|
|
128
|
+
I18nManager._translations.clear()
|
|
129
|
+
I18nManager._loaded = false
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Internal
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
private static mergeMessages(locale: string, messages: Messages): void {
|
|
137
|
+
const existing = I18nManager._translations.get(locale) ?? {}
|
|
138
|
+
I18nManager._translations.set(locale, deepMerge(existing, messages))
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function deepMerge(target: Messages, source: Messages): Messages {
|
|
143
|
+
const result = { ...target }
|
|
144
|
+
for (const [key, value] of Object.entries(source)) {
|
|
145
|
+
if (
|
|
146
|
+
typeof value === 'object' &&
|
|
147
|
+
value !== null &&
|
|
148
|
+
typeof result[key] === 'object' &&
|
|
149
|
+
result[key] !== null
|
|
150
|
+
) {
|
|
151
|
+
result[key] = deepMerge(result[key] as Messages, value as Messages)
|
|
152
|
+
} else {
|
|
153
|
+
result[key] = value
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return result
|
|
157
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import I18nManager from './i18n_manager.ts'
|
|
2
|
+
import type { Messages } from './types.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolve a dot-notated key from a nested messages object.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* dotGet({ auth: { failed: 'Invalid' } }, 'auth.failed') // 'Invalid'
|
|
9
|
+
*/
|
|
10
|
+
export function dotGet(messages: Messages, key: string): string | Messages | undefined {
|
|
11
|
+
const parts = key.split('.')
|
|
12
|
+
let current: string | Messages | undefined = messages
|
|
13
|
+
|
|
14
|
+
for (const part of parts) {
|
|
15
|
+
if (typeof current !== 'object' || current === null) return undefined
|
|
16
|
+
current = (current as Messages)[part]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return current
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Replace `:key` placeholders with values from the replacements object.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* interpolate('Hello, :name!', { name: 'Alice' }) // 'Hello, Alice!'
|
|
27
|
+
*/
|
|
28
|
+
export function interpolate(
|
|
29
|
+
message: string,
|
|
30
|
+
replacements: Record<string, string | number>
|
|
31
|
+
): string {
|
|
32
|
+
let result = message
|
|
33
|
+
for (const [key, value] of Object.entries(replacements)) {
|
|
34
|
+
result = result.replaceAll(`:${key}`, String(value))
|
|
35
|
+
}
|
|
36
|
+
return result
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve a translation key and interpolate replacements.
|
|
41
|
+
* Falls back to the fallback locale, then returns the key itself.
|
|
42
|
+
*/
|
|
43
|
+
export function translate(
|
|
44
|
+
locale: string,
|
|
45
|
+
key: string,
|
|
46
|
+
replacements?: Record<string, string | number>
|
|
47
|
+
): string {
|
|
48
|
+
const raw = I18nManager.resolve(locale, key)
|
|
49
|
+
if (raw === null) return key
|
|
50
|
+
|
|
51
|
+
return replacements ? interpolate(raw, replacements) : raw
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Plural category order used by Intl.PluralRules
|
|
55
|
+
const PLURAL_CATEGORIES = ['zero', 'one', 'two', 'few', 'many', 'other'] as const
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Pluralize a translation based on count.
|
|
59
|
+
*
|
|
60
|
+
* The translation value uses `|` to separate plural forms:
|
|
61
|
+
* - 2 forms: `"one apple|:count apples"` → `Intl.PluralRules` picks one vs other
|
|
62
|
+
* - 6 forms: mapped to `zero|one|two|few|many|other`
|
|
63
|
+
*
|
|
64
|
+
* `:count` is automatically added to replacements.
|
|
65
|
+
*/
|
|
66
|
+
export function translateChoice(
|
|
67
|
+
locale: string,
|
|
68
|
+
key: string,
|
|
69
|
+
count: number,
|
|
70
|
+
replacements?: Record<string, string | number>
|
|
71
|
+
): string {
|
|
72
|
+
const raw = I18nManager.resolve(locale, key)
|
|
73
|
+
if (raw === null) return key
|
|
74
|
+
|
|
75
|
+
const segments = raw.split('|')
|
|
76
|
+
const merged = { count, ...replacements }
|
|
77
|
+
|
|
78
|
+
let chosen: string
|
|
79
|
+
|
|
80
|
+
if (segments.length === 1) {
|
|
81
|
+
chosen = segments[0]!
|
|
82
|
+
} else if (segments.length === 2) {
|
|
83
|
+
// Simple singular/plural
|
|
84
|
+
const rules = new Intl.PluralRules(locale)
|
|
85
|
+
const category = rules.select(count)
|
|
86
|
+
chosen = category === 'one' ? segments[0]! : segments[1]!
|
|
87
|
+
} else {
|
|
88
|
+
// Map to plural categories
|
|
89
|
+
const rules = new Intl.PluralRules(locale)
|
|
90
|
+
const category = rules.select(count)
|
|
91
|
+
const index = PLURAL_CATEGORIES.indexOf(category)
|
|
92
|
+
chosen = segments[Math.min(index, segments.length - 1)]!
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return interpolate(chosen.trim(), merged)
|
|
96
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface I18nConfig {
|
|
2
|
+
/** Default locale, e.g. 'en'. */
|
|
3
|
+
default: string
|
|
4
|
+
/** Fallback locale when a key is missing in the current locale. */
|
|
5
|
+
fallback: string
|
|
6
|
+
/** List of supported locale codes. */
|
|
7
|
+
supported: string[]
|
|
8
|
+
/** Path to the lang/ directory (relative to cwd). */
|
|
9
|
+
directory: string
|
|
10
|
+
/** Locale detection strategies, tried in order. */
|
|
11
|
+
detect: LocaleDetection[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type LocaleDetection = 'header' | 'cookie' | 'query'
|
|
15
|
+
|
|
16
|
+
/** Nested translation messages — leaf values are strings. */
|
|
17
|
+
export type Messages = { [key: string]: string | Messages }
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export * from './core/index.ts'
|
|
2
|
+
export * from './config/index.ts'
|
|
3
|
+
export * from './events/index.ts'
|
|
4
|
+
export * from './exceptions/index.ts'
|
|
5
|
+
export * from './helpers/index.ts'
|
|
6
|
+
export * from './encryption/index.ts'
|
|
7
|
+
export * from './storage/index.ts'
|
|
8
|
+
export * from './cache/index.ts'
|
|
9
|
+
export * from './i18n/index.ts'
|
|
10
|
+
export * from './logger/index.ts'
|
|
11
|
+
export * from './providers/index.ts'
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { default } from './logger.ts'
|
|
2
|
+
export { default as Logger } from './logger.ts'
|
|
3
|
+
export { LogSink, type SinkConfig, type LogLevel } from './sinks/sink.ts'
|
|
4
|
+
export { ConsoleSink, type ConsoleSinkConfig } from './sinks/console_sink.ts'
|
|
5
|
+
export { FileSink, type FileSinkConfig } from './sinks/file_sink.ts'
|