@stacksjs/i18n 0.70.23

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/src/loader.ts ADDED
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Translation Loader
3
+ *
4
+ * Load translations from JSON, YAML, or TypeScript/JavaScript files.
5
+ */
6
+
7
+ import { existsSync, readdirSync } from 'node:fs'
8
+ import { readFile } from 'node:fs/promises'
9
+ import { basename, extname, join } from 'node:path'
10
+ import { log } from '@stacksjs/logging'
11
+ import type { TranslationMessages, Translations } from './types'
12
+ import { addTranslations, loadTranslations } from './translator'
13
+
14
+ export interface LoaderOptions {
15
+ /**
16
+ * Directory containing translation files
17
+ */
18
+ directory: string
19
+
20
+ /**
21
+ * File extensions to load
22
+ * @default ['.json', '.yaml', '.yml', '.ts', '.js']
23
+ */
24
+ extensions?: string[]
25
+
26
+ /**
27
+ * Whether to load files recursively
28
+ * @default false
29
+ */
30
+ recursive?: boolean
31
+
32
+ /**
33
+ * Namespace separator for nested directories
34
+ * @default '.'
35
+ */
36
+ namespaceSeparator?: string
37
+ }
38
+
39
+ /**
40
+ * Load translations from a directory
41
+ */
42
+ export async function loadFromDirectory(options: LoaderOptions): Promise<Translations> {
43
+ const {
44
+ directory,
45
+ extensions = ['.json', '.yaml', '.yml', '.ts', '.js'],
46
+ recursive = false,
47
+ namespaceSeparator = '.',
48
+ } = options
49
+
50
+ const translations: Translations = {}
51
+
52
+ if (!existsSync(directory)) {
53
+ log.warn(`[i18n] Translation directory not found: ${directory}`)
54
+ return translations
55
+ }
56
+
57
+ await loadDirectory(directory, translations, extensions, recursive, namespaceSeparator, '')
58
+
59
+ // Also register with global i18n
60
+ loadTranslations(translations)
61
+
62
+ return translations
63
+ }
64
+
65
+ /**
66
+ * Recursively load a directory
67
+ */
68
+ async function loadDirectory(
69
+ dir: string,
70
+ translations: Translations,
71
+ extensions: string[],
72
+ recursive: boolean,
73
+ namespaceSeparator: string,
74
+ namespace: string,
75
+ ): Promise<void> {
76
+ const entries = readdirSync(dir, { withFileTypes: true })
77
+
78
+ for (const entry of entries) {
79
+ const fullPath = join(dir, entry.name)
80
+
81
+ if (entry.isDirectory()) {
82
+ // Check if this is a locale directory (e.g., "en", "fr", "de")
83
+ const locale = entry.name
84
+
85
+ // If it looks like a locale code, load all files in it as that locale
86
+ if (isLocaleCode(locale)) {
87
+ await loadLocaleDirectory(fullPath, locale, translations, extensions)
88
+ }
89
+ else if (recursive) {
90
+ // Otherwise, treat as namespace
91
+ const newNamespace = namespace
92
+ ? `${namespace}${namespaceSeparator}${entry.name}`
93
+ : entry.name
94
+
95
+ await loadDirectory(
96
+ fullPath,
97
+ translations,
98
+ extensions,
99
+ recursive,
100
+ namespaceSeparator,
101
+ newNamespace,
102
+ )
103
+ }
104
+ }
105
+ else if (entry.isFile()) {
106
+ const ext = extname(entry.name)
107
+
108
+ if (extensions.includes(ext)) {
109
+ // File name (without extension) is the locale
110
+ const locale = basename(entry.name, ext)
111
+
112
+ if (!translations[locale]) {
113
+ translations[locale] = {}
114
+ }
115
+
116
+ const messages = await loadFile(fullPath)
117
+
118
+ if (namespace) {
119
+ // Nest under namespace
120
+ setNested(translations[locale], namespace, messages, namespaceSeparator)
121
+ }
122
+ else {
123
+ // Merge at root
124
+ translations[locale] = deepMerge(translations[locale], messages)
125
+ }
126
+ }
127
+ }
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Load all translation files in a locale directory
133
+ */
134
+ async function loadLocaleDirectory(
135
+ dir: string,
136
+ locale: string,
137
+ translations: Translations,
138
+ extensions: string[],
139
+ ): Promise<void> {
140
+ if (!translations[locale]) {
141
+ translations[locale] = {}
142
+ }
143
+
144
+ const entries = readdirSync(dir, { withFileTypes: true })
145
+
146
+ for (const entry of entries) {
147
+ if (!entry.isFile()) continue
148
+
149
+ const ext = extname(entry.name)
150
+ if (!extensions.includes(ext)) continue
151
+
152
+ const fullPath = join(dir, entry.name)
153
+ const namespace = basename(entry.name, ext)
154
+
155
+ const messages = await loadFile(fullPath)
156
+
157
+ // If the file is named after the locale (e.g., "en.json" in "en" folder), merge at root
158
+ if (namespace === locale) {
159
+ translations[locale] = deepMerge(translations[locale], messages)
160
+ }
161
+ else {
162
+ // Otherwise, use filename as namespace
163
+ translations[locale][namespace] = messages
164
+ }
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Load a single translation file
170
+ */
171
+ export async function loadFile(filePath: string): Promise<TranslationMessages> {
172
+ const ext = extname(filePath).toLowerCase()
173
+ const content = await readFile(filePath, 'utf8')
174
+
175
+ switch (ext) {
176
+ case '.json':
177
+ try {
178
+ return JSON.parse(content) as TranslationMessages
179
+ }
180
+ catch {
181
+ throw new Error(`Invalid JSON in translation file: ${filePath}`)
182
+ }
183
+
184
+ case '.yaml':
185
+ case '.yml':
186
+ return parseYaml(content)
187
+
188
+ case '.ts':
189
+ case '.js': {
190
+ // Dynamic import for JS/TS files
191
+ const module = await import(filePath)
192
+ return module.default || module
193
+ }
194
+
195
+ default:
196
+ throw new Error(`Unsupported file type: ${ext}`)
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Load a single locale from a file or directory
202
+ */
203
+ export async function loadLocale(
204
+ locale: string,
205
+ path: string,
206
+ ): Promise<TranslationMessages> {
207
+ const messages = await loadFile(path)
208
+ addTranslations(locale, messages)
209
+ return messages
210
+ }
211
+
212
+ /**
213
+ * Parse YAML translation files.
214
+ */
215
+ function parseYaml(content: string): TranslationMessages {
216
+ try {
217
+ const parsed = Bun.YAML.parse(content)
218
+
219
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
220
+ throw new Error('YAML content must be an object at the root level')
221
+ }
222
+
223
+ return parsed as TranslationMessages
224
+ }
225
+ catch (error) {
226
+ const message = error instanceof Error ? error.message : 'Unknown YAML parse error'
227
+ throw new Error(`Invalid YAML translation file: ${message}`)
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Check if a string looks like a locale code
233
+ */
234
+ function isLocaleCode(str: string): boolean {
235
+ // Match patterns like "en", "en-US", "zh-Hans", etc.
236
+ return /^[a-z]{2}(-[A-Z]{2})?(-[A-Za-z]+)?$/i.test(str)
237
+ }
238
+
239
+ /**
240
+ * Set a nested value in an object
241
+ */
242
+ function setNested(
243
+ obj: TranslationMessages,
244
+ path: string,
245
+ value: TranslationMessages,
246
+ separator: string,
247
+ ): void {
248
+ const parts = path.split(separator)
249
+ let current = obj
250
+
251
+ for (let i = 0; i < parts.length - 1; i++) {
252
+ const part = parts[i]
253
+ if (!current[part] || typeof current[part] === 'string') {
254
+ current[part] = {}
255
+ }
256
+ current = current[part] as TranslationMessages
257
+ }
258
+
259
+ const lastPart = parts[parts.length - 1]
260
+ current[lastPart] = value
261
+ }
262
+
263
+ /**
264
+ * Deep merge two objects
265
+ */
266
+ function deepMerge(target: TranslationMessages, source: TranslationMessages): TranslationMessages {
267
+ const result = { ...target }
268
+
269
+ for (const key of Object.keys(source)) {
270
+ const sourceValue = source[key]
271
+ const targetValue = result[key]
272
+
273
+ if (
274
+ typeof sourceValue === 'object'
275
+ && sourceValue !== null
276
+ && typeof targetValue === 'object'
277
+ && targetValue !== null
278
+ ) {
279
+ result[key] = deepMerge(
280
+ targetValue as TranslationMessages,
281
+ sourceValue as TranslationMessages,
282
+ )
283
+ }
284
+ else {
285
+ result[key] = sourceValue
286
+ }
287
+ }
288
+
289
+ return result
290
+ }
291
+
292
+ /**
293
+ * Create a translation loader for a specific directory
294
+ */
295
+ export function createLoader(directory: string, options: Partial<Omit<LoaderOptions, 'directory'>> = {}) {
296
+ return {
297
+ load: () => loadFromDirectory({ directory, ...options }),
298
+ loadLocale: (locale: string, filename: string) => loadLocale(locale, join(directory, filename)),
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Load translations via `@stacksjs/ts-i18n` and register them with the
304
+ * global translator. This is the recommended path for new projects —
305
+ * it gets you:
306
+ *
307
+ * - Per-locale subdirectory namespacing (en/auth.yaml → en.auth.*)
308
+ * - First-class .ts/.json/.yaml support with consistent error
309
+ * messages
310
+ * - Optional `.d.ts` codegen via `generateI18nTypes()` so message
311
+ * keys autocomplete in `t('…')` calls
312
+ *
313
+ * The legacy `loadFromDirectory()` remains for projects that depend
314
+ * on its specific behavior; new code should prefer this entry.
315
+ *
316
+ * @example
317
+ * ```ts
318
+ * await loadFromTsI18n({
319
+ * translationsDir: path.userI18nPath(),
320
+ * defaultLocale: 'en',
321
+ * fallbackLocale: 'en',
322
+ * sources: ['yaml', 'ts', 'json'],
323
+ * optional: true,
324
+ * })
325
+ * ```
326
+ */
327
+ export async function loadFromTsI18n(config: import('@stacksjs/ts-i18n').I18nConfig): Promise<Translations> {
328
+ const { loadTranslations: tsLoad } = await import('@stacksjs/ts-i18n')
329
+ const localeMap = await tsLoad(config) as Record<string, TranslationMessages>
330
+ // Register each locale with the Stacks translator so `t()` picks it up.
331
+ loadTranslations(localeMap as unknown as Translations)
332
+ return localeMap as unknown as Translations
333
+ }
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Pluralization Rules
3
+ *
4
+ * Implements CLDR plural rules for various languages.
5
+ * @see https://cldr.unicode.org/index/cldr-spec/plural-rules
6
+ */
7
+
8
+ export type PluralCategory = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'
9
+
10
+ export type PluralRule = (_n: number) => PluralCategory
11
+
12
+ /**
13
+ * Get the plural category for a number in a given locale
14
+ */
15
+ export function getPluralCategory(n: number, locale: string): PluralCategory {
16
+ const rule = getPluralRule(locale)
17
+ return rule(n)
18
+ }
19
+
20
+ /**
21
+ * Get the plural rule function for a locale
22
+ */
23
+ export function getPluralRule(locale: string): PluralRule {
24
+ // Get base language code
25
+ const lang = locale.split('-')[0].toLowerCase()
26
+
27
+ const rule = pluralRules[lang]
28
+ if (rule) return rule
29
+
30
+ // Default to 'other' for unknown locales
31
+ return () => 'other'
32
+ }
33
+
34
+ /**
35
+ * Parse a plural string into its forms
36
+ *
37
+ * Supports formats:
38
+ * - "one | other" (simple)
39
+ * - "zero | one | other" (with zero)
40
+ * - "{0} item | {0} items" (with placeholders)
41
+ * - "no items | one item | {n} items" (with special forms)
42
+ */
43
+ export function parsePluralForms(
44
+ str: string,
45
+ separator = '|',
46
+ ): Map<PluralCategory, string> {
47
+ const parts = str.split(separator).map(s => s.trim())
48
+ const forms = new Map<PluralCategory, string>()
49
+
50
+ if (parts.length === 1) {
51
+ forms.set('other', parts[0])
52
+ }
53
+ else if (parts.length === 2) {
54
+ forms.set('one', parts[0])
55
+ forms.set('other', parts[1])
56
+ }
57
+ else if (parts.length === 3) {
58
+ forms.set('zero', parts[0])
59
+ forms.set('one', parts[1])
60
+ forms.set('other', parts[2])
61
+ }
62
+ else if (parts.length >= 4) {
63
+ // Extended format: zero | one | two | few | many | other
64
+ const categories: PluralCategory[] = ['zero', 'one', 'two', 'few', 'many', 'other']
65
+ for (let i = 0; i < parts.length && i < categories.length; i++) {
66
+ forms.set(categories[i], parts[i])
67
+ }
68
+ }
69
+
70
+ return forms
71
+ }
72
+
73
+ /**
74
+ * Select the appropriate plural form for a count
75
+ */
76
+ export function selectPluralForm(
77
+ forms: Map<PluralCategory, string>,
78
+ count: number,
79
+ locale: string,
80
+ ): string {
81
+ const category = getPluralCategory(count, locale)
82
+
83
+ // Try exact category first
84
+ if (forms.has(category)) {
85
+ return forms.get(category)!
86
+ }
87
+
88
+ // Fall back to 'other'
89
+ if (forms.has('other')) {
90
+ return forms.get('other')!
91
+ }
92
+
93
+ // Last resort: return first available form
94
+ const first = forms.values().next()
95
+ return first.done ? '' : first.value
96
+ }
97
+
98
+ /**
99
+ * Plural rules for various languages
100
+ * Based on CLDR plural rules
101
+ */
102
+ const pluralRules: Record<string, PluralRule> = {
103
+ // === One/Other languages ===
104
+
105
+ // English, German, Dutch, etc.
106
+ en: n => (n === 1 ? 'one' : 'other'),
107
+ de: n => (n === 1 ? 'one' : 'other'),
108
+ nl: n => (n === 1 ? 'one' : 'other'),
109
+ es: n => (n === 1 ? 'one' : 'other'),
110
+ it: n => (n === 1 ? 'one' : 'other'),
111
+ pt: n => (n === 1 ? 'one' : 'other'),
112
+ sv: n => (n === 1 ? 'one' : 'other'),
113
+ da: n => (n === 1 ? 'one' : 'other'),
114
+ no: n => (n === 1 ? 'one' : 'other'),
115
+ fi: n => (n === 1 ? 'one' : 'other'),
116
+ el: n => (n === 1 ? 'one' : 'other'),
117
+ he: n => (n === 1 ? 'one' : 'other'),
118
+ hu: n => (n === 1 ? 'one' : 'other'),
119
+ tr: n => (n === 1 ? 'one' : 'other'),
120
+
121
+ // === No plural (always other) ===
122
+
123
+ // Chinese, Japanese, Korean, Vietnamese, Thai
124
+ zh: () => 'other',
125
+ ja: () => 'other',
126
+ ko: () => 'other',
127
+ vi: () => 'other',
128
+ th: () => 'other',
129
+ id: () => 'other',
130
+ ms: () => 'other',
131
+
132
+ // === French (0-1 is one) ===
133
+
134
+ fr: n => (n === 0 || n === 1 ? 'one' : 'other'),
135
+
136
+ // === Russian, Ukrainian (complex) ===
137
+
138
+ ru: (n) => {
139
+ const mod10 = n % 10
140
+ const mod100 = n % 100
141
+
142
+ if (mod10 === 1 && mod100 !== 11) return 'one'
143
+ if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 'few'
144
+ if (mod10 === 0 || (mod10 >= 5 && mod10 <= 9) || (mod100 >= 11 && mod100 <= 14)) return 'many'
145
+ return 'other'
146
+ },
147
+
148
+ uk: (n) => {
149
+ const mod10 = n % 10
150
+ const mod100 = n % 100
151
+
152
+ if (mod10 === 1 && mod100 !== 11) return 'one'
153
+ if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 'few'
154
+ if (mod10 === 0 || (mod10 >= 5 && mod10 <= 9) || (mod100 >= 11 && mod100 <= 14)) return 'many'
155
+ return 'other'
156
+ },
157
+
158
+ // === Polish (complex) ===
159
+
160
+ pl: (n) => {
161
+ const mod10 = n % 10
162
+ const mod100 = n % 100
163
+
164
+ if (n === 1) return 'one'
165
+ if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 'few'
166
+ if (mod10 === 0 || mod10 === 1 || (mod10 >= 5 && mod10 <= 9) || (mod100 >= 12 && mod100 <= 14)) return 'many'
167
+ return 'other'
168
+ },
169
+
170
+ // === Czech, Slovak ===
171
+
172
+ cs: (n) => {
173
+ if (n === 1) return 'one'
174
+ if (n >= 2 && n <= 4) return 'few'
175
+ return 'other'
176
+ },
177
+
178
+ sk: (n) => {
179
+ if (n === 1) return 'one'
180
+ if (n >= 2 && n <= 4) return 'few'
181
+ return 'other'
182
+ },
183
+
184
+ // === Arabic (complex with dual) ===
185
+
186
+ ar: (n) => {
187
+ if (n === 0) return 'zero'
188
+ if (n === 1) return 'one'
189
+ if (n === 2) return 'two'
190
+
191
+ const mod100 = n % 100
192
+ if (mod100 >= 3 && mod100 <= 10) return 'few'
193
+ if (mod100 >= 11 && mod100 <= 99) return 'many'
194
+ return 'other'
195
+ },
196
+
197
+ // === Welsh (with zero, one, two, few, many) ===
198
+
199
+ cy: (n) => {
200
+ if (n === 0) return 'zero'
201
+ if (n === 1) return 'one'
202
+ if (n === 2) return 'two'
203
+ if (n === 3) return 'few'
204
+ if (n === 6) return 'many'
205
+ return 'other'
206
+ },
207
+
208
+ // === Slovenian (with dual) ===
209
+
210
+ sl: (n) => {
211
+ const mod100 = n % 100
212
+
213
+ if (mod100 === 1) return 'one'
214
+ if (mod100 === 2) return 'two'
215
+ if (mod100 === 3 || mod100 === 4) return 'few'
216
+ return 'other'
217
+ },
218
+
219
+ // === Irish ===
220
+
221
+ ga: (n) => {
222
+ if (n === 1) return 'one'
223
+ if (n === 2) return 'two'
224
+ if (n >= 3 && n <= 6) return 'few'
225
+ if (n >= 7 && n <= 10) return 'many'
226
+ return 'other'
227
+ },
228
+
229
+ // === Lithuanian ===
230
+
231
+ lt: (n) => {
232
+ const mod10 = n % 10
233
+ const mod100 = n % 100
234
+
235
+ if (mod10 === 1 && (mod100 < 11 || mod100 > 19)) return 'one'
236
+ if (mod10 >= 2 && mod10 <= 9 && (mod100 < 11 || mod100 > 19)) return 'few'
237
+ return 'other'
238
+ },
239
+
240
+ // === Latvian ===
241
+
242
+ lv: (n) => {
243
+ const mod10 = n % 10
244
+ const mod100 = n % 100
245
+
246
+ if (n === 0) return 'zero'
247
+ if (mod10 === 1 && mod100 !== 11) return 'one'
248
+ return 'other'
249
+ },
250
+
251
+ // === Romanian ===
252
+
253
+ ro: (n) => {
254
+ if (n === 1) return 'one'
255
+
256
+ const mod100 = n % 100
257
+ if (n === 0 || (mod100 >= 1 && mod100 <= 19)) return 'few'
258
+ return 'other'
259
+ },
260
+
261
+ // === Hindi ===
262
+
263
+ hi: n => (n === 0 || n === 1 ? 'one' : 'other'),
264
+ }
265
+
266
+ /**
267
+ * Get all supported locales for pluralization
268
+ */
269
+ export function getSupportedPluralLocales(): string[] {
270
+ return Object.keys(pluralRules)
271
+ }
272
+
273
+ /**
274
+ * Check if a locale has custom plural rules
275
+ */
276
+ export function hasPluralRule(locale: string): boolean {
277
+ const lang = locale.split('-')[0].toLowerCase()
278
+ return lang in pluralRules
279
+ }
280
+
281
+ /**
282
+ * Add a custom plural rule for a locale
283
+ */
284
+ export function addPluralRule(locale: string, rule: PluralRule): void {
285
+ const lang = locale.split('-')[0].toLowerCase()
286
+ pluralRules[lang] = rule
287
+ }