@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/package.json +13 -4
- package/src/bootstrap.ts +0 -107
- package/src/formatter.ts +0 -399
- package/src/index.ts +0 -100
- package/src/loader.ts +0 -335
- package/src/locale-switch.ts +0 -81
- package/src/pluralization.ts +0 -290
- package/src/translator.ts +0 -502
- package/src/types.ts +0 -238
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
|
-
}
|
package/src/locale-switch.ts
DELETED
|
@@ -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
|
-
}
|
package/src/pluralization.ts
DELETED
|
@@ -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
|
-
}
|