@stacksjs/i18n 0.70.88 → 0.70.90
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/dist/bootstrap.d.ts +14 -0
- package/dist/bootstrap.js +70 -0
- package/dist/formatter.d.ts +105 -0
- package/dist/formatter.js +128 -0
- package/dist/index.d.ts +94 -0
- package/dist/index.js +50 -0
- package/dist/loader.d.ts +52 -0
- package/dist/loader.js +144 -0
- package/dist/locale-switch.d.ts +16 -0
- package/dist/locale-switch.js +45 -0
- package/dist/pluralization.d.ts +42 -0
- package/dist/pluralization.js +190 -0
- package/dist/translator.d.ts +79 -0
- package/dist/translator.js +249 -0
- package/dist/types.d.ts +213 -0
- package/dist/types.js +25 -0
- package/package.json +2 -2
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load translation files from the project `locales/` directory once.
|
|
3
|
+
* Locale subdirectories (`locales/de.yml`, `locales/en.yml`, …) are detected
|
|
4
|
+
* automatically by `loadFromDirectory`.
|
|
5
|
+
*/
|
|
6
|
+
export declare function ensureLocalesLoaded(): Promise<void>;
|
|
7
|
+
/**
|
|
8
|
+
* Resolve the active locale for an HTTP request.
|
|
9
|
+
* Order: STX locale prefix (`/en/...`), `?locale=`, `locale` cookie,
|
|
10
|
+
* Accept-Language, then `config.app.locale`.
|
|
11
|
+
*/
|
|
12
|
+
export declare function resolveRequestLocale(request?: Request): string;
|
|
13
|
+
/** Load `locales/` and activate the global translator for this request. */
|
|
14
|
+
export declare function applyRequestLocale(request?: Request): Promise<string>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { config } from "@stacksjs/config";
|
|
3
|
+
import { projectPath } from "@stacksjs/path";
|
|
4
|
+
import { loadFromDirectory } from "./loader";
|
|
5
|
+
import { configure, getAvailableLocales, setLocale } from "./translator";
|
|
6
|
+
let localesLoaded = !1;
|
|
7
|
+
export async function ensureLocalesLoaded() {
|
|
8
|
+
if (localesLoaded)
|
|
9
|
+
return;
|
|
10
|
+
const directory = projectPath("locales");
|
|
11
|
+
if (existsSync(directory))
|
|
12
|
+
await loadFromDirectory({
|
|
13
|
+
directory,
|
|
14
|
+
extensions: [".yaml", ".yml", ".json"]
|
|
15
|
+
});
|
|
16
|
+
const app = config.app, defaultLocale = app?.locale ?? "en", fallback = app?.fallbackLocale ?? defaultLocale, available = getAvailableLocales();
|
|
17
|
+
configure({
|
|
18
|
+
locale: defaultLocale,
|
|
19
|
+
fallbackLocale: fallback,
|
|
20
|
+
availableLocales: available.length > 0 ? available : [defaultLocale],
|
|
21
|
+
warnMissing: (app?.env ?? process.env.APP_ENV) !== "production"
|
|
22
|
+
});
|
|
23
|
+
localesLoaded = !0;
|
|
24
|
+
}
|
|
25
|
+
export function resolveRequestLocale(request) {
|
|
26
|
+
const defaultLocale = config.app?.locale ?? "en", available = new Set(getAvailableLocales());
|
|
27
|
+
if (!available.size)
|
|
28
|
+
available.add(defaultLocale);
|
|
29
|
+
const pick = (code) => {
|
|
30
|
+
const normalized = code.trim().toLowerCase();
|
|
31
|
+
if (available.has(normalized))
|
|
32
|
+
return normalized;
|
|
33
|
+
const short = normalized.split("-")[0];
|
|
34
|
+
if (short && available.has(short))
|
|
35
|
+
return short;
|
|
36
|
+
return defaultLocale;
|
|
37
|
+
};
|
|
38
|
+
if (!request)
|
|
39
|
+
return defaultLocale;
|
|
40
|
+
const url = new URL(request.url), pathname = url.pathname, fromStx = request.headers.get("x-stx-locale");
|
|
41
|
+
if (fromStx)
|
|
42
|
+
return pick(fromStx);
|
|
43
|
+
for (const code of available) {
|
|
44
|
+
if (code === defaultLocale)
|
|
45
|
+
continue;
|
|
46
|
+
if (pathname === `/${code}` || pathname === `/${code}/`)
|
|
47
|
+
return pick(code);
|
|
48
|
+
if (pathname.startsWith(`/${code}/`))
|
|
49
|
+
return pick(code);
|
|
50
|
+
}
|
|
51
|
+
const fromQuery = url.searchParams.get("locale");
|
|
52
|
+
if (fromQuery)
|
|
53
|
+
return pick(fromQuery);
|
|
54
|
+
const match = (request.headers.get("cookie") ?? "").match(/(?:^|;\s*)locale=([a-z]{2}(?:-[a-z]{2})?)(?:;|$)/i);
|
|
55
|
+
if (match?.[1])
|
|
56
|
+
return pick(match[1]);
|
|
57
|
+
const accept = request.headers.get("accept-language") ?? "";
|
|
58
|
+
for (const part of accept.split(",")) {
|
|
59
|
+
const tag = part.split(";")[0]?.trim();
|
|
60
|
+
if (tag)
|
|
61
|
+
return pick(tag);
|
|
62
|
+
}
|
|
63
|
+
return defaultLocale;
|
|
64
|
+
}
|
|
65
|
+
export async function applyRequestLocale(request) {
|
|
66
|
+
await ensureLocalesLoaded();
|
|
67
|
+
const locale = resolveRequestLocale(request);
|
|
68
|
+
setLocale(locale);
|
|
69
|
+
return locale;
|
|
70
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format a date
|
|
3
|
+
*/
|
|
4
|
+
export declare function formatDate(date: Date | number | string, style?: DateStyle | DateFormatOptions, locale?: string): string;
|
|
5
|
+
/**
|
|
6
|
+
* Format a time
|
|
7
|
+
*/
|
|
8
|
+
export declare function formatTime(date: Date | number | string, style?: TimeStyle | DateFormatOptions, locale?: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* Format a date and time
|
|
11
|
+
*/
|
|
12
|
+
export declare function formatDateTime(date: Date | number | string, dateStyle?: DateStyle, timeStyle?: TimeStyle, locale?: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* Format relative time (e.g., "2 days ago", "in 3 hours")
|
|
15
|
+
*/
|
|
16
|
+
export declare function formatRelativeTime(date: Date | number | string, baseDate?: Date | number | string, style?: 'long' | 'short' | 'narrow', locale?: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Format a number
|
|
19
|
+
*/
|
|
20
|
+
export declare function formatNumber(value: number, options?: NumberFormatOptions, locale?: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* Format a currency value
|
|
23
|
+
*/
|
|
24
|
+
export declare function formatCurrency(value: number, currency?: string, options?: Omit<NumberFormatOptions, 'style' | 'currency'>, locale?: string): string;
|
|
25
|
+
/**
|
|
26
|
+
* Format a percentage
|
|
27
|
+
*/
|
|
28
|
+
export declare function formatPercent(value: number, options?: Omit<NumberFormatOptions, 'style'>, locale?: string): string;
|
|
29
|
+
/**
|
|
30
|
+
* Format a number with units (e.g., "5 kilograms", "10 miles")
|
|
31
|
+
*/
|
|
32
|
+
export declare function formatUnit(value: number, unit: string, unitDisplay?: 'long' | 'short' | 'narrow', locale?: string): string;
|
|
33
|
+
/**
|
|
34
|
+
* Format bytes to human-readable format
|
|
35
|
+
*/
|
|
36
|
+
export declare function formatBytes(bytes: number, decimals?: number, locale?: string): string;
|
|
37
|
+
/**
|
|
38
|
+
* Format a list of items
|
|
39
|
+
*/
|
|
40
|
+
export declare function formatList(items: string[], type?: ListType, style?: ListStyle, locale?: string): string;
|
|
41
|
+
/**
|
|
42
|
+
* Get the plural category for a number
|
|
43
|
+
*/
|
|
44
|
+
export declare function formatPlural(value: number, locale?: string): Intl.LDMLPluralRule;
|
|
45
|
+
/**
|
|
46
|
+
* Select from plural options based on a number
|
|
47
|
+
*/
|
|
48
|
+
export declare function selectPlural<T>(value: number, options: Partial<Record<Intl.LDMLPluralRule, T>> & { other: T }, locale?: string): T;
|
|
49
|
+
/**
|
|
50
|
+
* Get display name for a code
|
|
51
|
+
*/
|
|
52
|
+
export declare function getDisplayName(code: string, type?: DisplayNameType, style?: 'long' | 'short' | 'narrow', locale?: string): string | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* Get language name
|
|
55
|
+
*/
|
|
56
|
+
export declare function getLanguageName(languageCode: string, locale?: string): string | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* Get region/country name
|
|
59
|
+
*/
|
|
60
|
+
export declare function getRegionName(regionCode: string, locale?: string): string | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* Get currency name
|
|
63
|
+
*/
|
|
64
|
+
export declare function getCurrencyName(currencyCode: string, locale?: string): string | undefined;
|
|
65
|
+
/**
|
|
66
|
+
* Compare strings in a locale-aware manner
|
|
67
|
+
*/
|
|
68
|
+
export declare function compareStrings(a: string, b: string, options?: Intl.CollatorOptions, locale?: string): number;
|
|
69
|
+
/**
|
|
70
|
+
* Sort an array of strings in locale-aware order
|
|
71
|
+
*/
|
|
72
|
+
export declare function sortStrings(strings: string[], options?: Intl.CollatorOptions, locale?: string): string[];
|
|
73
|
+
/**
|
|
74
|
+
* Get the text direction for a locale
|
|
75
|
+
*/
|
|
76
|
+
export declare function getTextDirection(locale?: string): 'ltr' | 'rtl';
|
|
77
|
+
/**
|
|
78
|
+
* Check if a locale uses RTL text direction
|
|
79
|
+
*/
|
|
80
|
+
export declare function isRTL(locale?: string): boolean;
|
|
81
|
+
export declare interface DateFormatOptions extends Intl.DateTimeFormatOptions {
|
|
82
|
+
dateStyle?: DateStyle
|
|
83
|
+
timeStyle?: TimeStyle
|
|
84
|
+
}
|
|
85
|
+
// =============================================================================
|
|
86
|
+
// Number Formatting
|
|
87
|
+
// =============================================================================
|
|
88
|
+
export declare interface NumberFormatOptions extends Intl.NumberFormatOptions {
|
|
89
|
+
compact?: boolean
|
|
90
|
+
precision?: number
|
|
91
|
+
}
|
|
92
|
+
// =============================================================================
|
|
93
|
+
// Date Formatting
|
|
94
|
+
// =============================================================================
|
|
95
|
+
export type DateStyle = 'full' | 'long' | 'medium' | 'short';
|
|
96
|
+
export type TimeStyle = 'full' | 'long' | 'medium' | 'short';
|
|
97
|
+
// =============================================================================
|
|
98
|
+
// List Formatting
|
|
99
|
+
// =============================================================================
|
|
100
|
+
export type ListType = 'conjunction' | 'disjunction' | 'unit';
|
|
101
|
+
export type ListStyle = 'long' | 'short' | 'narrow';
|
|
102
|
+
// =============================================================================
|
|
103
|
+
// Display Names
|
|
104
|
+
// =============================================================================
|
|
105
|
+
export type DisplayNameType = 'language' | 'region' | 'script' | 'currency' | 'calendar' | 'dateTimeField';
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { getLocale } from "./translator";
|
|
2
|
+
export function formatDate(date, style = "medium", locale) {
|
|
3
|
+
const targetLocale = locale || getLocale(), dateObj = toDate(date), options = typeof style === "string" ? { dateStyle: style } : style;
|
|
4
|
+
return new Intl.DateTimeFormat(targetLocale, options).format(dateObj);
|
|
5
|
+
}
|
|
6
|
+
export function formatTime(date, style = "short", locale) {
|
|
7
|
+
const targetLocale = locale || getLocale(), dateObj = toDate(date), options = typeof style === "string" ? { timeStyle: style } : style;
|
|
8
|
+
return new Intl.DateTimeFormat(targetLocale, options).format(dateObj);
|
|
9
|
+
}
|
|
10
|
+
export function formatDateTime(date, dateStyle = "medium", timeStyle = "short", locale) {
|
|
11
|
+
const targetLocale = locale || getLocale(), dateObj = toDate(date);
|
|
12
|
+
return new Intl.DateTimeFormat(targetLocale, {
|
|
13
|
+
dateStyle,
|
|
14
|
+
timeStyle
|
|
15
|
+
}).format(dateObj);
|
|
16
|
+
}
|
|
17
|
+
export function formatRelativeTime(date, baseDate = new Date, style = "long", locale) {
|
|
18
|
+
const targetLocale = locale || getLocale(), dateObj = toDate(date), baseDateObj = toDate(baseDate), diffMs = dateObj.getTime() - baseDateObj.getTime(), diffSeconds = Math.round(diffMs / 1000), diffMinutes = Math.round(diffSeconds / 60), diffHours = Math.round(diffMinutes / 60), diffDays = Math.round(diffHours / 24), diffWeeks = Math.round(diffDays / 7), diffMonths = Math.round(diffDays / 30), diffYears = Math.round(diffDays / 365), rtf = new Intl.RelativeTimeFormat(targetLocale, { style });
|
|
19
|
+
if (Math.abs(diffSeconds) < 60)
|
|
20
|
+
return rtf.format(diffSeconds, "second");
|
|
21
|
+
if (Math.abs(diffMinutes) < 60)
|
|
22
|
+
return rtf.format(diffMinutes, "minute");
|
|
23
|
+
if (Math.abs(diffHours) < 24)
|
|
24
|
+
return rtf.format(diffHours, "hour");
|
|
25
|
+
if (Math.abs(diffDays) < 7)
|
|
26
|
+
return rtf.format(diffDays, "day");
|
|
27
|
+
if (Math.abs(diffWeeks) < 4)
|
|
28
|
+
return rtf.format(diffWeeks, "week");
|
|
29
|
+
if (Math.abs(diffMonths) < 12)
|
|
30
|
+
return rtf.format(diffMonths, "month");
|
|
31
|
+
return rtf.format(diffYears, "year");
|
|
32
|
+
}
|
|
33
|
+
export function formatNumber(value, options = {}, locale) {
|
|
34
|
+
const targetLocale = locale || getLocale(), formatOptions = { ...options };
|
|
35
|
+
if (options.compact) {
|
|
36
|
+
formatOptions.notation = "compact";
|
|
37
|
+
formatOptions.compactDisplay = "short";
|
|
38
|
+
}
|
|
39
|
+
if (options.precision !== void 0) {
|
|
40
|
+
formatOptions.minimumFractionDigits = options.precision;
|
|
41
|
+
formatOptions.maximumFractionDigits = options.precision;
|
|
42
|
+
}
|
|
43
|
+
return new Intl.NumberFormat(targetLocale, formatOptions).format(value);
|
|
44
|
+
}
|
|
45
|
+
export function formatCurrency(value, currency = "USD", options = {}, locale) {
|
|
46
|
+
const targetLocale = locale || getLocale();
|
|
47
|
+
return new Intl.NumberFormat(targetLocale, {
|
|
48
|
+
style: "currency",
|
|
49
|
+
currency,
|
|
50
|
+
...options
|
|
51
|
+
}).format(value);
|
|
52
|
+
}
|
|
53
|
+
export function formatPercent(value, options = {}, locale) {
|
|
54
|
+
const targetLocale = locale || getLocale();
|
|
55
|
+
return new Intl.NumberFormat(targetLocale, {
|
|
56
|
+
style: "percent",
|
|
57
|
+
...options
|
|
58
|
+
}).format(value);
|
|
59
|
+
}
|
|
60
|
+
export function formatUnit(value, unit, unitDisplay = "short", locale) {
|
|
61
|
+
const targetLocale = locale || getLocale();
|
|
62
|
+
return new Intl.NumberFormat(targetLocale, {
|
|
63
|
+
style: "unit",
|
|
64
|
+
unit,
|
|
65
|
+
unitDisplay
|
|
66
|
+
}).format(value);
|
|
67
|
+
}
|
|
68
|
+
export function formatBytes(bytes, decimals = 2, locale) {
|
|
69
|
+
const targetLocale = locale || getLocale();
|
|
70
|
+
if (bytes === 0)
|
|
71
|
+
return "0 B";
|
|
72
|
+
const k = 1024, sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"], i = Math.floor(Math.log(bytes) / Math.log(k)), value = bytes / Math.pow(k, i);
|
|
73
|
+
return `${new Intl.NumberFormat(targetLocale, {
|
|
74
|
+
minimumFractionDigits: 0,
|
|
75
|
+
maximumFractionDigits: decimals
|
|
76
|
+
}).format(value)} ${sizes[i]}`;
|
|
77
|
+
}
|
|
78
|
+
export function formatList(items, type = "conjunction", style = "long", locale) {
|
|
79
|
+
const targetLocale = locale || getLocale();
|
|
80
|
+
return new Intl.ListFormat(targetLocale, { type, style }).format(items);
|
|
81
|
+
}
|
|
82
|
+
export function formatPlural(value, locale) {
|
|
83
|
+
const targetLocale = locale || getLocale();
|
|
84
|
+
return new Intl.PluralRules(targetLocale).select(value);
|
|
85
|
+
}
|
|
86
|
+
export function selectPlural(value, options, locale) {
|
|
87
|
+
const category = formatPlural(value, locale);
|
|
88
|
+
return options[category] ?? options.other;
|
|
89
|
+
}
|
|
90
|
+
export function getDisplayName(code, type = "language", style = "long", locale) {
|
|
91
|
+
const targetLocale = locale || getLocale();
|
|
92
|
+
try {
|
|
93
|
+
return new Intl.DisplayNames(targetLocale, { type, style }).of(code);
|
|
94
|
+
} catch {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export function getLanguageName(languageCode, locale) {
|
|
99
|
+
return getDisplayName(languageCode, "language", "long", locale);
|
|
100
|
+
}
|
|
101
|
+
export function getRegionName(regionCode, locale) {
|
|
102
|
+
return getDisplayName(regionCode, "region", "long", locale);
|
|
103
|
+
}
|
|
104
|
+
export function getCurrencyName(currencyCode, locale) {
|
|
105
|
+
return getDisplayName(currencyCode, "currency", "long", locale);
|
|
106
|
+
}
|
|
107
|
+
export function compareStrings(a, b, options = {}, locale) {
|
|
108
|
+
const targetLocale = locale || getLocale();
|
|
109
|
+
return new Intl.Collator(targetLocale, options).compare(a, b);
|
|
110
|
+
}
|
|
111
|
+
export function sortStrings(strings, options = {}, locale) {
|
|
112
|
+
const targetLocale = locale || getLocale(), collator = new Intl.Collator(targetLocale, options);
|
|
113
|
+
return [...strings].sort((a, b) => collator.compare(a, b));
|
|
114
|
+
}
|
|
115
|
+
function toDate(date) {
|
|
116
|
+
if (date instanceof Date)
|
|
117
|
+
return date;
|
|
118
|
+
if (typeof date === "number")
|
|
119
|
+
return new Date(date);
|
|
120
|
+
return new Date(date);
|
|
121
|
+
}
|
|
122
|
+
export function getTextDirection(locale) {
|
|
123
|
+
const targetLocale = locale || getLocale(), lang = (targetLocale.split("-")[0] ?? targetLocale).toLowerCase();
|
|
124
|
+
return ["ar", "he", "fa", "ur", "yi", "ps", "sd", "ug", "ku", "ckb"].includes(lang) ? "rtl" : "ltr";
|
|
125
|
+
}
|
|
126
|
+
export function isRTL(locale) {
|
|
127
|
+
return getTextDirection(locale) === "rtl";
|
|
128
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export type { I18nConfig as TsI18nConfig, TranslationTree, TranslationValue } from '@stacksjs/ts-i18n';
|
|
2
|
+
/**
|
|
3
|
+
* Stacks i18n - Internationalization System
|
|
4
|
+
*
|
|
5
|
+
* A comprehensive internationalization system with support for:
|
|
6
|
+
* - Translation loading from JSON/YAML files
|
|
7
|
+
* - Plural forms
|
|
8
|
+
* - Variable interpolation
|
|
9
|
+
* - Nested translations
|
|
10
|
+
* - Date/time formatting
|
|
11
|
+
* - Number formatting
|
|
12
|
+
* - Currency formatting
|
|
13
|
+
* - Relative time formatting
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* import { t, setLocale, addTranslations } from '@stacksjs/i18n'
|
|
18
|
+
*
|
|
19
|
+
* // Add translations
|
|
20
|
+
* addTranslations('en', {
|
|
21
|
+
* greeting: 'Hello, {name}!',
|
|
22
|
+
* items: '{count} item | {count} items',
|
|
23
|
+
* })
|
|
24
|
+
*
|
|
25
|
+
* // Use translations
|
|
26
|
+
* t('greeting', { name: 'World' }) // "Hello, World!"
|
|
27
|
+
* t('items', { count: 1 }) // "1 item"
|
|
28
|
+
* t('items', { count: 5 }) // "5 items"
|
|
29
|
+
*
|
|
30
|
+
* // Format dates and numbers
|
|
31
|
+
* formatDate(new Date(), 'long') // "January 8, 2026"
|
|
32
|
+
* formatNumber(1234567.89) // "1,234,567.89"
|
|
33
|
+
* formatCurrency(99.99, 'USD') // "$99.99"
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export * from './translator';
|
|
37
|
+
export * from './formatter';
|
|
38
|
+
export * from './pluralization';
|
|
39
|
+
export * from './loader';
|
|
40
|
+
export * from './types';
|
|
41
|
+
// Re-export the file-loading and type-generation surface from
|
|
42
|
+
// `@stacksjs/ts-i18n`. The library handles disk loading (YAML / TS /
|
|
43
|
+
// JSON), namespace resolution, and `.d.ts` codegen for translation
|
|
44
|
+
// keys; the Stacks-side translator/formatter sits on top of that
|
|
45
|
+
// data with locale management, ICU pluralization, and Intl-backed
|
|
46
|
+
// formatters. Importing both names from `@stacksjs/i18n` keeps the
|
|
47
|
+
// app side from caring which symbol comes from which package.
|
|
48
|
+
export {
|
|
49
|
+
loadTranslations as loadTranslationsFromDisk,
|
|
50
|
+
generateTypes as generateI18nTypes,
|
|
51
|
+
generateTypesFromModule as generateI18nTypesFromModule,
|
|
52
|
+
generateSampleConfig as generateI18nSampleConfig,
|
|
53
|
+
writeOutputs as writeI18nOutputs,
|
|
54
|
+
createTranslator as createSimpleTranslator,
|
|
55
|
+
} from '@stacksjs/ts-i18n';
|
|
56
|
+
// Re-export main functions for convenience
|
|
57
|
+
export {
|
|
58
|
+
t,
|
|
59
|
+
trans,
|
|
60
|
+
tc,
|
|
61
|
+
te,
|
|
62
|
+
tm,
|
|
63
|
+
setLocale,
|
|
64
|
+
getLocale,
|
|
65
|
+
addTranslations,
|
|
66
|
+
loadTranslations,
|
|
67
|
+
getAvailableLocales,
|
|
68
|
+
hasTranslation,
|
|
69
|
+
I18n,
|
|
70
|
+
createI18n,
|
|
71
|
+
useI18n,
|
|
72
|
+
} from './translator';
|
|
73
|
+
export {
|
|
74
|
+
ensureLocalesLoaded,
|
|
75
|
+
resolveRequestLocale,
|
|
76
|
+
applyRequestLocale,
|
|
77
|
+
} from './bootstrap';
|
|
78
|
+
export {
|
|
79
|
+
createLocaleSwitchResponse,
|
|
80
|
+
localizePath,
|
|
81
|
+
stripLocalePrefix,
|
|
82
|
+
type LocaleSwitchConfig,
|
|
83
|
+
} from './locale-switch';
|
|
84
|
+
export {
|
|
85
|
+
formatDate,
|
|
86
|
+
formatTime,
|
|
87
|
+
formatDateTime,
|
|
88
|
+
formatNumber,
|
|
89
|
+
formatCurrency,
|
|
90
|
+
formatPercent,
|
|
91
|
+
formatRelativeTime,
|
|
92
|
+
formatList,
|
|
93
|
+
formatPlural,
|
|
94
|
+
} from './formatter';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export * from "./translator";
|
|
2
|
+
export * from "./formatter";
|
|
3
|
+
export * from "./pluralization";
|
|
4
|
+
export * from "./loader";
|
|
5
|
+
export * from "./types";
|
|
6
|
+
export {
|
|
7
|
+
loadTranslations as loadTranslationsFromDisk,
|
|
8
|
+
generateTypes as generateI18nTypes,
|
|
9
|
+
generateTypesFromModule as generateI18nTypesFromModule,
|
|
10
|
+
generateSampleConfig as generateI18nSampleConfig,
|
|
11
|
+
writeOutputs as writeI18nOutputs,
|
|
12
|
+
createTranslator as createSimpleTranslator
|
|
13
|
+
} from "@stacksjs/ts-i18n";
|
|
14
|
+
export {
|
|
15
|
+
t,
|
|
16
|
+
trans,
|
|
17
|
+
tc,
|
|
18
|
+
te,
|
|
19
|
+
tm,
|
|
20
|
+
setLocale,
|
|
21
|
+
getLocale,
|
|
22
|
+
addTranslations,
|
|
23
|
+
loadTranslations,
|
|
24
|
+
getAvailableLocales,
|
|
25
|
+
hasTranslation,
|
|
26
|
+
I18n,
|
|
27
|
+
createI18n,
|
|
28
|
+
useI18n
|
|
29
|
+
} from "./translator";
|
|
30
|
+
export {
|
|
31
|
+
ensureLocalesLoaded,
|
|
32
|
+
resolveRequestLocale,
|
|
33
|
+
applyRequestLocale
|
|
34
|
+
} from "./bootstrap";
|
|
35
|
+
export {
|
|
36
|
+
createLocaleSwitchResponse,
|
|
37
|
+
localizePath,
|
|
38
|
+
stripLocalePrefix
|
|
39
|
+
} from "./locale-switch";
|
|
40
|
+
export {
|
|
41
|
+
formatDate,
|
|
42
|
+
formatTime,
|
|
43
|
+
formatDateTime,
|
|
44
|
+
formatNumber,
|
|
45
|
+
formatCurrency,
|
|
46
|
+
formatPercent,
|
|
47
|
+
formatRelativeTime,
|
|
48
|
+
formatList,
|
|
49
|
+
formatPlural
|
|
50
|
+
} from "./formatter";
|
package/dist/loader.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { TranslationMessages, Translations } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Load translations from a directory
|
|
4
|
+
*/
|
|
5
|
+
export declare function loadFromDirectory(options: LoaderOptions): Promise<Translations>;
|
|
6
|
+
/**
|
|
7
|
+
* Load a single translation file
|
|
8
|
+
*/
|
|
9
|
+
export declare function loadFile(filePath: string): Promise<TranslationMessages>;
|
|
10
|
+
/**
|
|
11
|
+
* Load a single locale from a file or directory
|
|
12
|
+
*/
|
|
13
|
+
export declare function loadLocale(locale: string, path: string): Promise<TranslationMessages>;
|
|
14
|
+
/**
|
|
15
|
+
* Create a translation loader for a specific directory
|
|
16
|
+
*/
|
|
17
|
+
export declare function createLoader(directory: string, options?: Partial<Omit<LoaderOptions, 'directory'>>): {
|
|
18
|
+
load: () => unknown;
|
|
19
|
+
loadLocale: (locale: string, filename: string) => unknown
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Load translations via `@stacksjs/ts-i18n` and register them with the
|
|
23
|
+
* global translator. This is the recommended path for new projects —
|
|
24
|
+
* it gets you:
|
|
25
|
+
*
|
|
26
|
+
* - Per-locale subdirectory namespacing (en/auth.yaml → en.auth.*)
|
|
27
|
+
* - First-class .ts/.json/.yaml support with consistent error
|
|
28
|
+
* messages
|
|
29
|
+
* - Optional `.d.ts` codegen via `generateI18nTypes()` so message
|
|
30
|
+
* keys autocomplete in `t('…')` calls
|
|
31
|
+
*
|
|
32
|
+
* The legacy `loadFromDirectory()` remains for projects that depend
|
|
33
|
+
* on its specific behavior; new code should prefer this entry.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```ts
|
|
37
|
+
* await loadFromTsI18n({
|
|
38
|
+
* translationsDir: path.userI18nPath(),
|
|
39
|
+
* defaultLocale: 'en',
|
|
40
|
+
* fallbackLocale: 'en',
|
|
41
|
+
* sources: ['yaml', 'ts', 'json'],
|
|
42
|
+
* optional: true,
|
|
43
|
+
* })
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export declare function loadFromTsI18n(config: import('@stacksjs/ts-i18n').I18nConfig): Promise<Translations>;
|
|
47
|
+
export declare interface LoaderOptions {
|
|
48
|
+
directory: string
|
|
49
|
+
extensions?: string[]
|
|
50
|
+
recursive?: boolean
|
|
51
|
+
namespaceSeparator?: string
|
|
52
|
+
}
|
package/dist/loader.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { basename, extname, join } from "node:path";
|
|
4
|
+
import { log } from "@stacksjs/logging";
|
|
5
|
+
import { addTranslations, loadTranslations } from "./translator";
|
|
6
|
+
export async function loadFromDirectory(options) {
|
|
7
|
+
const {
|
|
8
|
+
directory,
|
|
9
|
+
extensions = [".json", ".yaml", ".yml", ".ts", ".js"],
|
|
10
|
+
recursive = !1,
|
|
11
|
+
namespaceSeparator = "."
|
|
12
|
+
} = options, translations = {};
|
|
13
|
+
if (!existsSync(directory)) {
|
|
14
|
+
log.warn(`[i18n] Translation directory not found: ${directory}`);
|
|
15
|
+
return translations;
|
|
16
|
+
}
|
|
17
|
+
await loadDirectory(directory, translations, extensions, recursive, namespaceSeparator, "");
|
|
18
|
+
loadTranslations(translations);
|
|
19
|
+
return translations;
|
|
20
|
+
}
|
|
21
|
+
async function loadDirectory(dir, translations, extensions, recursive, namespaceSeparator, namespace) {
|
|
22
|
+
const entries = readdirSync(dir, { withFileTypes: !0 });
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
const fullPath = join(dir, entry.name);
|
|
25
|
+
if (entry.isDirectory()) {
|
|
26
|
+
const locale = entry.name;
|
|
27
|
+
if (isLocaleCode(locale))
|
|
28
|
+
await loadLocaleDirectory(fullPath, locale, translations, extensions);
|
|
29
|
+
else if (recursive) {
|
|
30
|
+
const newNamespace = namespace ? `${namespace}${namespaceSeparator}${entry.name}` : entry.name;
|
|
31
|
+
await loadDirectory(fullPath, translations, extensions, recursive, namespaceSeparator, newNamespace);
|
|
32
|
+
}
|
|
33
|
+
} else if (entry.isFile()) {
|
|
34
|
+
const ext = extname(entry.name);
|
|
35
|
+
if (extensions.includes(ext)) {
|
|
36
|
+
const locale = basename(entry.name, ext);
|
|
37
|
+
if (!translations[locale])
|
|
38
|
+
translations[locale] = {};
|
|
39
|
+
const messages = await loadFile(fullPath);
|
|
40
|
+
if (namespace)
|
|
41
|
+
setNested(translations[locale], namespace, messages, namespaceSeparator);
|
|
42
|
+
else
|
|
43
|
+
translations[locale] = deepMerge(translations[locale], messages);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function loadLocaleDirectory(dir, locale, translations, extensions) {
|
|
49
|
+
if (!translations[locale])
|
|
50
|
+
translations[locale] = {};
|
|
51
|
+
const entries = readdirSync(dir, { withFileTypes: !0 });
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
if (!entry.isFile())
|
|
54
|
+
continue;
|
|
55
|
+
const ext = extname(entry.name);
|
|
56
|
+
if (!extensions.includes(ext))
|
|
57
|
+
continue;
|
|
58
|
+
const fullPath = join(dir, entry.name), namespace = basename(entry.name, ext), messages = await loadFile(fullPath);
|
|
59
|
+
if (namespace === locale)
|
|
60
|
+
translations[locale] = deepMerge(translations[locale], messages);
|
|
61
|
+
else
|
|
62
|
+
translations[locale][namespace] = messages;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export async function loadFile(filePath) {
|
|
66
|
+
const ext = extname(filePath).toLowerCase(), content = await readFile(filePath, "utf8");
|
|
67
|
+
switch (ext) {
|
|
68
|
+
case ".json":
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(content);
|
|
71
|
+
} catch {
|
|
72
|
+
throw Error(`Invalid JSON in translation file: ${filePath}`);
|
|
73
|
+
}
|
|
74
|
+
case ".yaml":
|
|
75
|
+
case ".yml":
|
|
76
|
+
return parseYaml(content);
|
|
77
|
+
case ".ts":
|
|
78
|
+
case ".js": {
|
|
79
|
+
const module = await import(filePath);
|
|
80
|
+
return module.default || module;
|
|
81
|
+
}
|
|
82
|
+
default:
|
|
83
|
+
throw Error(`Unsupported file type: ${ext}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export async function loadLocale(locale, path) {
|
|
87
|
+
const messages = await loadFile(path);
|
|
88
|
+
addTranslations(locale, messages);
|
|
89
|
+
return messages;
|
|
90
|
+
}
|
|
91
|
+
function parseYaml(content) {
|
|
92
|
+
try {
|
|
93
|
+
const parsed = Bun.YAML.parse(content);
|
|
94
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
95
|
+
throw Error("YAML content must be an object at the root level");
|
|
96
|
+
return parsed;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
const message = error instanceof Error ? error.message : "Unknown YAML parse error";
|
|
99
|
+
throw Error(`Invalid YAML translation file: ${message}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function isLocaleCode(str) {
|
|
103
|
+
return /^[a-z]{2}(?:-[A-Z]{2})?(?:-[A-Za-z]+)?$/i.test(str);
|
|
104
|
+
}
|
|
105
|
+
function setNested(obj, path, value, separator) {
|
|
106
|
+
const parts = path.split(separator);
|
|
107
|
+
let current = obj;
|
|
108
|
+
for (let i = 0;i < parts.length - 1; i++) {
|
|
109
|
+
const part = parts[i];
|
|
110
|
+
if (!part)
|
|
111
|
+
continue;
|
|
112
|
+
if (!current[part] || typeof current[part] === "string")
|
|
113
|
+
current[part] = {};
|
|
114
|
+
current = current[part];
|
|
115
|
+
}
|
|
116
|
+
const lastPart = parts[parts.length - 1];
|
|
117
|
+
if (lastPart)
|
|
118
|
+
current[lastPart] = value;
|
|
119
|
+
}
|
|
120
|
+
function deepMerge(target, source) {
|
|
121
|
+
const result = { ...target };
|
|
122
|
+
for (const key of Object.keys(source)) {
|
|
123
|
+
const sourceValue = source[key];
|
|
124
|
+
if (sourceValue === void 0)
|
|
125
|
+
continue;
|
|
126
|
+
const targetValue = result[key];
|
|
127
|
+
if (typeof sourceValue === "object" && sourceValue !== null && typeof targetValue === "object" && targetValue !== null)
|
|
128
|
+
result[key] = deepMerge(targetValue, sourceValue);
|
|
129
|
+
else
|
|
130
|
+
result[key] = sourceValue;
|
|
131
|
+
}
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
export function createLoader(directory, options = {}) {
|
|
135
|
+
return {
|
|
136
|
+
load: () => loadFromDirectory({ directory, ...options }),
|
|
137
|
+
loadLocale: (locale, filename) => loadLocale(locale, join(directory, filename))
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
export async function loadFromTsI18n(config) {
|
|
141
|
+
const { loadTranslations: tsLoad } = await import("@stacksjs/ts-i18n"), localeMap = await tsLoad(config);
|
|
142
|
+
loadTranslations(localeMap);
|
|
143
|
+
return localeMap;
|
|
144
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare function stripLocalePrefix(path: string, locales: string[]): { locale: string | null, path: string };
|
|
2
|
+
export declare function localizePath(path: string, locale: string, defaultLocale: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* Build a redirect Response that sets the `locale` cookie and sends the
|
|
5
|
+
* visitor to the equivalent path in the requested locale (STX-style).
|
|
6
|
+
*/
|
|
7
|
+
export declare function createLocaleSwitchResponse(request: Request, localeCode: string, config: LocaleSwitchConfig): Response;
|
|
8
|
+
/**
|
|
9
|
+
* Locale switch redirect — mirrors STX `buildLangPickerScript` path logic
|
|
10
|
+
* (`/<code>/…` prefixes) so cookie-based fallbacks and `/locale/{code}`
|
|
11
|
+
* redirects stay consistent with stx-serve i18n routing.
|
|
12
|
+
*/
|
|
13
|
+
export declare interface LocaleSwitchConfig {
|
|
14
|
+
locales: string[]
|
|
15
|
+
defaultLocale: string
|
|
16
|
+
}
|