@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.
Files changed (64) hide show
  1. package/package.json +59 -0
  2. package/src/cache/cache_manager.ts +60 -0
  3. package/src/cache/cache_store.ts +31 -0
  4. package/src/cache/helpers.ts +74 -0
  5. package/src/cache/index.ts +4 -0
  6. package/src/cache/memory_store.ts +63 -0
  7. package/src/config/configuration.ts +105 -0
  8. package/src/config/index.ts +2 -0
  9. package/src/config/loaders/base_loader.ts +69 -0
  10. package/src/config/loaders/env_loader.ts +112 -0
  11. package/src/config/loaders/typescript_loader.ts +56 -0
  12. package/src/config/types.ts +8 -0
  13. package/src/core/application.ts +241 -0
  14. package/src/core/container.ts +113 -0
  15. package/src/core/index.ts +4 -0
  16. package/src/core/inject.ts +39 -0
  17. package/src/core/service_provider.ts +44 -0
  18. package/src/encryption/encryption_manager.ts +215 -0
  19. package/src/encryption/helpers.ts +158 -0
  20. package/src/encryption/index.ts +3 -0
  21. package/src/encryption/types.ts +6 -0
  22. package/src/events/emitter.ts +101 -0
  23. package/src/events/index.ts +2 -0
  24. package/src/exceptions/errors.ts +71 -0
  25. package/src/exceptions/exception_handler.ts +140 -0
  26. package/src/exceptions/helpers.ts +25 -0
  27. package/src/exceptions/http_exception.ts +132 -0
  28. package/src/exceptions/index.ts +23 -0
  29. package/src/exceptions/strav_error.ts +11 -0
  30. package/src/helpers/compose.ts +104 -0
  31. package/src/helpers/crypto.ts +4 -0
  32. package/src/helpers/env.ts +50 -0
  33. package/src/helpers/index.ts +6 -0
  34. package/src/helpers/strings.ts +67 -0
  35. package/src/helpers/ulid.ts +28 -0
  36. package/src/i18n/defaults/en/validation.json +20 -0
  37. package/src/i18n/helpers.ts +76 -0
  38. package/src/i18n/i18n_manager.ts +157 -0
  39. package/src/i18n/index.ts +3 -0
  40. package/src/i18n/translator.ts +96 -0
  41. package/src/i18n/types.ts +17 -0
  42. package/src/index.ts +11 -0
  43. package/src/logger/index.ts +5 -0
  44. package/src/logger/logger.ts +113 -0
  45. package/src/logger/sinks/console_sink.ts +24 -0
  46. package/src/logger/sinks/file_sink.ts +24 -0
  47. package/src/logger/sinks/sink.ts +36 -0
  48. package/src/providers/cache_provider.ts +16 -0
  49. package/src/providers/config_provider.ts +26 -0
  50. package/src/providers/encryption_provider.ts +16 -0
  51. package/src/providers/i18n_provider.ts +17 -0
  52. package/src/providers/index.ts +8 -0
  53. package/src/providers/logger_provider.ts +16 -0
  54. package/src/providers/storage_provider.ts +16 -0
  55. package/src/storage/index.ts +32 -0
  56. package/src/storage/local_driver.ts +46 -0
  57. package/src/storage/ostra_client.ts +432 -0
  58. package/src/storage/ostra_driver.ts +58 -0
  59. package/src/storage/s3_driver.ts +51 -0
  60. package/src/storage/storage.ts +43 -0
  61. package/src/storage/storage_manager.ts +70 -0
  62. package/src/storage/types.ts +49 -0
  63. package/src/storage/upload.ts +91 -0
  64. 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,4 @@
1
+ /** Generate a random hex string of the given byte length. */
2
+ export function randomHex(bytes: number): string {
3
+ return Buffer.from(crypto.getRandomValues(new Uint8Array(bytes))).toString('hex')
4
+ }
@@ -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,3 @@
1
+ export { default as I18nManager } from './i18n_manager.ts'
2
+ export { t, choice, locale, localeStorage } from './helpers.ts'
3
+ export type { I18nConfig, Messages, LocaleDetection } from './types.ts'
@@ -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'