@stacksjs/i18n 0.70.85 → 0.70.87

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 DELETED
@@ -1,335 +0,0 @@
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 (!part) continue
254
- if (!current[part] || typeof current[part] === 'string') {
255
- current[part] = {}
256
- }
257
- current = current[part] as TranslationMessages
258
- }
259
-
260
- const lastPart = parts[parts.length - 1]
261
- if (lastPart) current[lastPart] = value
262
- }
263
-
264
- /**
265
- * Deep merge two objects
266
- */
267
- function deepMerge(target: TranslationMessages, source: TranslationMessages): TranslationMessages {
268
- const result = { ...target }
269
-
270
- for (const key of Object.keys(source)) {
271
- const sourceValue = source[key]
272
- if (sourceValue === undefined) continue
273
- const targetValue = result[key]
274
-
275
- if (
276
- typeof sourceValue === 'object'
277
- && sourceValue !== null
278
- && typeof targetValue === 'object'
279
- && targetValue !== null
280
- ) {
281
- result[key] = deepMerge(
282
- targetValue as TranslationMessages,
283
- sourceValue as TranslationMessages,
284
- )
285
- }
286
- else {
287
- result[key] = sourceValue
288
- }
289
- }
290
-
291
- return result
292
- }
293
-
294
- /**
295
- * Create a translation loader for a specific directory
296
- */
297
- export function createLoader(directory: string, options: Partial<Omit<LoaderOptions, 'directory'>> = {}) {
298
- return {
299
- load: () => loadFromDirectory({ directory, ...options }),
300
- loadLocale: (locale: string, filename: string) => loadLocale(locale, join(directory, filename)),
301
- }
302
- }
303
-
304
- /**
305
- * Load translations via `@stacksjs/ts-i18n` and register them with the
306
- * global translator. This is the recommended path for new projects —
307
- * it gets you:
308
- *
309
- * - Per-locale subdirectory namespacing (en/auth.yaml → en.auth.*)
310
- * - First-class .ts/.json/.yaml support with consistent error
311
- * messages
312
- * - Optional `.d.ts` codegen via `generateI18nTypes()` so message
313
- * keys autocomplete in `t('…')` calls
314
- *
315
- * The legacy `loadFromDirectory()` remains for projects that depend
316
- * on its specific behavior; new code should prefer this entry.
317
- *
318
- * @example
319
- * ```ts
320
- * await loadFromTsI18n({
321
- * translationsDir: path.userI18nPath(),
322
- * defaultLocale: 'en',
323
- * fallbackLocale: 'en',
324
- * sources: ['yaml', 'ts', 'json'],
325
- * optional: true,
326
- * })
327
- * ```
328
- */
329
- export async function loadFromTsI18n(config: import('@stacksjs/ts-i18n').I18nConfig): Promise<Translations> {
330
- const { loadTranslations: tsLoad } = await import('@stacksjs/ts-i18n')
331
- const localeMap = await tsLoad(config) as Record<string, TranslationMessages>
332
- // Register each locale with the Stacks translator so `t()` picks it up.
333
- loadTranslations(localeMap as unknown as Translations)
334
- return localeMap as unknown as Translations
335
- }
@@ -1,81 +0,0 @@
1
- /**
2
- * Locale switch redirect — mirrors STX `buildLangPickerScript` path logic
3
- * (`/<code>/…` prefixes) so cookie-based fallbacks and `/locale/{code}`
4
- * redirects stay consistent with stx-serve i18n routing.
5
- */
6
-
7
- export interface LocaleSwitchConfig {
8
- locales: string[]
9
- defaultLocale: string
10
- }
11
-
12
- function normalizeLocale(code: string, config: LocaleSwitchConfig): string | null {
13
- const normalized = code.trim().toLowerCase()
14
- if (config.locales.includes(normalized))
15
- return normalized
16
- const short = normalized.split('-')[0]
17
- if (short && config.locales.includes(short))
18
- return short
19
- return null
20
- }
21
-
22
- export function stripLocalePrefix(path: string, locales: string[]): { locale: string | null, path: string } {
23
- for (const loc of locales) {
24
- if (path === `/${loc}` || path === `/${loc}/`)
25
- return { locale: loc, path: '/' }
26
- if (path.startsWith(`/${loc}/`))
27
- return { locale: loc, path: path.slice(loc.length + 1) }
28
- }
29
- return { locale: null, path }
30
- }
31
-
32
- export function localizePath(path: string, locale: string, defaultLocale: string): string {
33
- if (locale === defaultLocale)
34
- return path
35
- if (path === '/')
36
- return `/${locale}/`
37
- if (path === '/404.html')
38
- return `/${locale}/404.html`
39
- return `/${locale}${path}`
40
- }
41
-
42
- /**
43
- * Build a redirect Response that sets the `locale` cookie and sends the
44
- * visitor to the equivalent path in the requested locale (STX-style).
45
- */
46
- export function createLocaleSwitchResponse(
47
- request: Request,
48
- localeCode: string,
49
- config: LocaleSwitchConfig,
50
- ): Response {
51
- const locale = normalizeLocale(localeCode, config)
52
- if (!locale) {
53
- return Response.redirect(new URL('/', request.url).href, 302)
54
- }
55
-
56
- const referer = request.headers.get('referer')
57
- let basePath = '/'
58
-
59
- if (referer) {
60
- try {
61
- const ref = new URL(referer)
62
- if (!ref.pathname.startsWith('/locale/')) {
63
- const stripped = stripLocalePrefix(ref.pathname, config.locales)
64
- basePath = stripped.path || '/'
65
- }
66
- }
67
- catch {
68
- basePath = '/'
69
- }
70
- }
71
-
72
- const targetPath = localizePath(basePath, locale, config.defaultLocale)
73
- const target = new URL(targetPath, request.url)
74
-
75
- const res = Response.redirect(target.href, 302)
76
- res.headers.append(
77
- 'Set-Cookie',
78
- `locale=${locale}; Path=/; Max-Age=31536000; SameSite=Lax`,
79
- )
80
- return res
81
- }
@@ -1,290 +0,0 @@
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() ?? locale.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
- const category = categories[i]
67
- const part = parts[i]
68
- if (category !== undefined && part !== undefined)
69
- forms.set(category, part)
70
- }
71
- }
72
-
73
- return forms
74
- }
75
-
76
- /**
77
- * Select the appropriate plural form for a count
78
- */
79
- export function selectPluralForm(
80
- forms: Map<PluralCategory, string>,
81
- count: number,
82
- locale: string,
83
- ): string {
84
- const category = getPluralCategory(count, locale)
85
-
86
- // Try exact category first
87
- if (forms.has(category)) {
88
- return forms.get(category)!
89
- }
90
-
91
- // Fall back to 'other'
92
- if (forms.has('other')) {
93
- return forms.get('other')!
94
- }
95
-
96
- // Last resort: return first available form
97
- const first = forms.values().next()
98
- return first.done ? '' : first.value
99
- }
100
-
101
- /**
102
- * Plural rules for various languages
103
- * Based on CLDR plural rules
104
- */
105
- const pluralRules: Record<string, PluralRule> = {
106
- // === One/Other languages ===
107
-
108
- // English, German, Dutch, etc.
109
- en: n => (n === 1 ? 'one' : 'other'),
110
- de: n => (n === 1 ? 'one' : 'other'),
111
- nl: n => (n === 1 ? 'one' : 'other'),
112
- es: n => (n === 1 ? 'one' : 'other'),
113
- it: n => (n === 1 ? 'one' : 'other'),
114
- pt: n => (n === 1 ? 'one' : 'other'),
115
- sv: n => (n === 1 ? 'one' : 'other'),
116
- da: n => (n === 1 ? 'one' : 'other'),
117
- no: n => (n === 1 ? 'one' : 'other'),
118
- fi: n => (n === 1 ? 'one' : 'other'),
119
- el: n => (n === 1 ? 'one' : 'other'),
120
- he: n => (n === 1 ? 'one' : 'other'),
121
- hu: n => (n === 1 ? 'one' : 'other'),
122
- tr: n => (n === 1 ? 'one' : 'other'),
123
-
124
- // === No plural (always other) ===
125
-
126
- // Chinese, Japanese, Korean, Vietnamese, Thai
127
- zh: () => 'other',
128
- ja: () => 'other',
129
- ko: () => 'other',
130
- vi: () => 'other',
131
- th: () => 'other',
132
- id: () => 'other',
133
- ms: () => 'other',
134
-
135
- // === French (0-1 is one) ===
136
-
137
- fr: n => (n === 0 || n === 1 ? 'one' : 'other'),
138
-
139
- // === Russian, Ukrainian (complex) ===
140
-
141
- ru: (n) => {
142
- const mod10 = n % 10
143
- const mod100 = n % 100
144
-
145
- if (mod10 === 1 && mod100 !== 11) return 'one'
146
- if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 'few'
147
- if (mod10 === 0 || (mod10 >= 5 && mod10 <= 9) || (mod100 >= 11 && mod100 <= 14)) return 'many'
148
- return 'other'
149
- },
150
-
151
- uk: (n) => {
152
- const mod10 = n % 10
153
- const mod100 = n % 100
154
-
155
- if (mod10 === 1 && mod100 !== 11) return 'one'
156
- if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 'few'
157
- if (mod10 === 0 || (mod10 >= 5 && mod10 <= 9) || (mod100 >= 11 && mod100 <= 14)) return 'many'
158
- return 'other'
159
- },
160
-
161
- // === Polish (complex) ===
162
-
163
- pl: (n) => {
164
- const mod10 = n % 10
165
- const mod100 = n % 100
166
-
167
- if (n === 1) return 'one'
168
- if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 'few'
169
- if (mod10 === 0 || mod10 === 1 || (mod10 >= 5 && mod10 <= 9) || (mod100 >= 12 && mod100 <= 14)) return 'many'
170
- return 'other'
171
- },
172
-
173
- // === Czech, Slovak ===
174
-
175
- cs: (n) => {
176
- if (n === 1) return 'one'
177
- if (n >= 2 && n <= 4) return 'few'
178
- return 'other'
179
- },
180
-
181
- sk: (n) => {
182
- if (n === 1) return 'one'
183
- if (n >= 2 && n <= 4) return 'few'
184
- return 'other'
185
- },
186
-
187
- // === Arabic (complex with dual) ===
188
-
189
- ar: (n) => {
190
- if (n === 0) return 'zero'
191
- if (n === 1) return 'one'
192
- if (n === 2) return 'two'
193
-
194
- const mod100 = n % 100
195
- if (mod100 >= 3 && mod100 <= 10) return 'few'
196
- if (mod100 >= 11 && mod100 <= 99) return 'many'
197
- return 'other'
198
- },
199
-
200
- // === Welsh (with zero, one, two, few, many) ===
201
-
202
- cy: (n) => {
203
- if (n === 0) return 'zero'
204
- if (n === 1) return 'one'
205
- if (n === 2) return 'two'
206
- if (n === 3) return 'few'
207
- if (n === 6) return 'many'
208
- return 'other'
209
- },
210
-
211
- // === Slovenian (with dual) ===
212
-
213
- sl: (n) => {
214
- const mod100 = n % 100
215
-
216
- if (mod100 === 1) return 'one'
217
- if (mod100 === 2) return 'two'
218
- if (mod100 === 3 || mod100 === 4) return 'few'
219
- return 'other'
220
- },
221
-
222
- // === Irish ===
223
-
224
- ga: (n) => {
225
- if (n === 1) return 'one'
226
- if (n === 2) return 'two'
227
- if (n >= 3 && n <= 6) return 'few'
228
- if (n >= 7 && n <= 10) return 'many'
229
- return 'other'
230
- },
231
-
232
- // === Lithuanian ===
233
-
234
- lt: (n) => {
235
- const mod10 = n % 10
236
- const mod100 = n % 100
237
-
238
- if (mod10 === 1 && (mod100 < 11 || mod100 > 19)) return 'one'
239
- if (mod10 >= 2 && mod10 <= 9 && (mod100 < 11 || mod100 > 19)) return 'few'
240
- return 'other'
241
- },
242
-
243
- // === Latvian ===
244
-
245
- lv: (n) => {
246
- const mod10 = n % 10
247
- const mod100 = n % 100
248
-
249
- if (n === 0) return 'zero'
250
- if (mod10 === 1 && mod100 !== 11) return 'one'
251
- return 'other'
252
- },
253
-
254
- // === Romanian ===
255
-
256
- ro: (n) => {
257
- if (n === 1) return 'one'
258
-
259
- const mod100 = n % 100
260
- if (n === 0 || (mod100 >= 1 && mod100 <= 19)) return 'few'
261
- return 'other'
262
- },
263
-
264
- // === Hindi ===
265
-
266
- hi: n => (n === 0 || n === 1 ? 'one' : 'other'),
267
- }
268
-
269
- /**
270
- * Get all supported locales for pluralization
271
- */
272
- export function getSupportedPluralLocales(): string[] {
273
- return Object.keys(pluralRules)
274
- }
275
-
276
- /**
277
- * Check if a locale has custom plural rules
278
- */
279
- export function hasPluralRule(locale: string): boolean {
280
- const lang = locale.split('-')[0]?.toLowerCase() ?? locale.toLowerCase()
281
- return lang in pluralRules
282
- }
283
-
284
- /**
285
- * Add a custom plural rule for a locale
286
- */
287
- export function addPluralRule(locale: string, rule: PluralRule): void {
288
- const lang = locale.split('-')[0]?.toLowerCase() ?? locale.toLowerCase()
289
- pluralRules[lang] = rule
290
- }