@ubean/i18n 0.1.1 → 0.1.3
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/index.d.ts +96 -0
- package/dist/index.js +447 -0
- package/dist/routing.d.ts +27 -0
- package/dist/routing.js +129 -0
- package/package.json +2 -2
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
//#region src/index.d.ts
|
|
2
|
+
type I18nRoutingStrategy = 'prefix' | 'prefix_except_default' | 'prefix_and_default' | 'no_prefix';
|
|
3
|
+
type DateTimeFormatStyle = 'short' | 'medium' | 'long' | 'full';
|
|
4
|
+
type NumberFormatStyle = 'decimal' | 'percent' | 'currency';
|
|
5
|
+
type ListFormatStyle = 'conjunction' | 'disjunction' | 'unit';
|
|
6
|
+
type RelativeTimeUnit = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year';
|
|
7
|
+
interface I18nConfig {
|
|
8
|
+
defaultLocale: string;
|
|
9
|
+
strategy: I18nRoutingStrategy;
|
|
10
|
+
locales: string[];
|
|
11
|
+
}
|
|
12
|
+
interface LocaleMessages {
|
|
13
|
+
[key: string]: string | LocaleMessages;
|
|
14
|
+
}
|
|
15
|
+
interface LocaleDefinition {
|
|
16
|
+
code: string;
|
|
17
|
+
messages: LocaleMessages;
|
|
18
|
+
name?: string;
|
|
19
|
+
dir?: 'ltr' | 'rtl';
|
|
20
|
+
isDefault?: boolean;
|
|
21
|
+
}
|
|
22
|
+
type LocaleChangeCallback = (locale: string) => void;
|
|
23
|
+
type MissingKeyHandler = (locale: string, key: string) => void;
|
|
24
|
+
interface NumberFormatOptions extends Intl.NumberFormatOptions {
|
|
25
|
+
style?: NumberFormatStyle;
|
|
26
|
+
}
|
|
27
|
+
interface I18nInstance {
|
|
28
|
+
locale: string;
|
|
29
|
+
fallbackLocale: string;
|
|
30
|
+
availableLocales: string[];
|
|
31
|
+
t(key: string, params?: Record<string, string | number>): string;
|
|
32
|
+
d(value: Date | number, style?: DateTimeFormatStyle, options?: Intl.DateTimeFormatOptions): string;
|
|
33
|
+
n(value: number, style?: NumberFormatStyle, options?: NumberFormatOptions): string;
|
|
34
|
+
c(value: number, currency: string, options?: Intl.NumberFormatOptions): string;
|
|
35
|
+
relativeTime(value: number, unit: RelativeTimeUnit, options?: Intl.RelativeTimeFormatOptions): string;
|
|
36
|
+
list(items: string[], style?: ListFormatStyle, options?: Intl.ListFormatOptions): string;
|
|
37
|
+
setLocale(locale: string): void;
|
|
38
|
+
getLocale(): string;
|
|
39
|
+
addLocale(code: string, messages: LocaleMessages, options?: {
|
|
40
|
+
name?: string;
|
|
41
|
+
dir?: 'ltr' | 'rtl';
|
|
42
|
+
}): void;
|
|
43
|
+
mergeLocale(code: string, messages: LocaleMessages): void;
|
|
44
|
+
detectLocale(acceptLanguage?: string): string;
|
|
45
|
+
onLocaleChange(callback: LocaleChangeCallback): () => void;
|
|
46
|
+
getLocaleDir(locale?: string): 'ltr' | 'rtl';
|
|
47
|
+
getLocaleName(locale?: string): string | undefined;
|
|
48
|
+
onMissingKey(handler: MissingKeyHandler): () => void;
|
|
49
|
+
}
|
|
50
|
+
declare function defineLocale(definition: LocaleDefinition): LocaleDefinition;
|
|
51
|
+
declare function setI18nConfig(config: Partial<I18nConfig>): void;
|
|
52
|
+
declare function getI18nConfig(): I18nConfig;
|
|
53
|
+
declare function getDefaultLocale(): string;
|
|
54
|
+
declare function localizePath(path: string, locale?: string): string;
|
|
55
|
+
declare function switchLocalePath(newLocale: string, currentPath?: string): string;
|
|
56
|
+
declare function extractLocaleFromPath(path: string): {
|
|
57
|
+
locale: string | null;
|
|
58
|
+
pathWithoutLocale: string;
|
|
59
|
+
};
|
|
60
|
+
declare function useI18n(): I18nInstance;
|
|
61
|
+
declare function t(key: string, params?: Record<string, string | number>): string;
|
|
62
|
+
declare function setLocale(locale: string): void;
|
|
63
|
+
declare function getLocale(): string;
|
|
64
|
+
declare function getRegisteredLocales(): string[];
|
|
65
|
+
interface LocaleMeta {
|
|
66
|
+
code: string;
|
|
67
|
+
name?: string;
|
|
68
|
+
dir: 'ltr' | 'rtl';
|
|
69
|
+
isDefault?: boolean;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Returns metadata for all registered locales (without messages).
|
|
73
|
+
* Used by SSR to serialize the full locale list so the client can
|
|
74
|
+
* register all available locales during hydration — preventing
|
|
75
|
+
* `availableLocales` hydration mismatches.
|
|
76
|
+
*/
|
|
77
|
+
declare function getRegisteredLocalesMeta(): LocaleMeta[];
|
|
78
|
+
declare function getLocaleMessages(locale?: string): LocaleMessages | undefined;
|
|
79
|
+
declare function clearLocales(): void;
|
|
80
|
+
declare function onLocaleChange(callback: LocaleChangeCallback): () => void;
|
|
81
|
+
declare function getLocaleDir(locale?: string): 'ltr' | 'rtl';
|
|
82
|
+
declare function getLocaleName(locale?: string): string | undefined;
|
|
83
|
+
declare function detectLocale(acceptLanguage?: string): string;
|
|
84
|
+
declare function addLocale(code: string, messages: LocaleMessages, options?: {
|
|
85
|
+
name?: string;
|
|
86
|
+
dir?: 'ltr' | 'rtl';
|
|
87
|
+
}): void;
|
|
88
|
+
declare function mergeLocale(code: string, messages: LocaleMessages): void;
|
|
89
|
+
declare function detectBrowserLocale(): string;
|
|
90
|
+
declare function formatDate(value: Date | number, style?: DateTimeFormatStyle, options?: Intl.DateTimeFormatOptions): string;
|
|
91
|
+
declare function formatNumber(value: number, style?: NumberFormatStyle, options?: NumberFormatOptions): string;
|
|
92
|
+
declare function formatCurrency(value: number, currency: string, options?: Intl.NumberFormatOptions): string;
|
|
93
|
+
declare function formatRelativeTime(value: number, unit: RelativeTimeUnit, options?: Intl.RelativeTimeFormatOptions): string;
|
|
94
|
+
declare function formatList(items: string[], style?: ListFormatStyle, options?: Intl.ListFormatOptions): string;
|
|
95
|
+
//#endregion
|
|
96
|
+
export { DateTimeFormatStyle, I18nConfig, I18nInstance, I18nRoutingStrategy, ListFormatStyle, LocaleChangeCallback, LocaleDefinition, LocaleMessages, LocaleMeta, MissingKeyHandler, NumberFormatOptions, NumberFormatStyle, RelativeTimeUnit, addLocale, clearLocales, defineLocale, detectBrowserLocale, detectLocale, extractLocaleFromPath, formatCurrency, formatDate, formatList, formatNumber, formatRelativeTime, getDefaultLocale, getI18nConfig, getLocale, getLocaleDir, getLocaleMessages, getLocaleName, getRegisteredLocales, getRegisteredLocalesMeta, localizePath, mergeLocale, onLocaleChange, setI18nConfig, setLocale, switchLocalePath, t, useI18n };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
//#region src/index.ts
|
|
2
|
+
const I18N_STATE_KEY = "__ubean_i18n_state__";
|
|
3
|
+
function getI18nState() {
|
|
4
|
+
const g = globalThis;
|
|
5
|
+
if (!g[I18N_STATE_KEY]) g[I18N_STATE_KEY] = {
|
|
6
|
+
registeredLocales: /* @__PURE__ */ new Map(),
|
|
7
|
+
messageCache: /* @__PURE__ */ new Map(),
|
|
8
|
+
currentLocale: "en",
|
|
9
|
+
fallbackLocale: "en",
|
|
10
|
+
localeListeners: /* @__PURE__ */ new Set(),
|
|
11
|
+
missingKeyHandlers: /* @__PURE__ */ new Set(),
|
|
12
|
+
missingKeyWarned: /* @__PURE__ */ new Set(),
|
|
13
|
+
i18nConfig: {
|
|
14
|
+
defaultLocale: "en",
|
|
15
|
+
strategy: "prefix_except_default",
|
|
16
|
+
locales: []
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
return g[I18N_STATE_KEY];
|
|
20
|
+
}
|
|
21
|
+
function notifyLocaleChange(locale) {
|
|
22
|
+
for (const fn of getI18nState().localeListeners) fn(locale);
|
|
23
|
+
}
|
|
24
|
+
function addLocaleListener(callback) {
|
|
25
|
+
getI18nState().localeListeners.add(callback);
|
|
26
|
+
return () => getI18nState().localeListeners.delete(callback);
|
|
27
|
+
}
|
|
28
|
+
function addMissingKeyHandler(handler) {
|
|
29
|
+
getI18nState().missingKeyHandlers.add(handler);
|
|
30
|
+
return () => getI18nState().missingKeyHandlers.delete(handler);
|
|
31
|
+
}
|
|
32
|
+
function notifyMissingKey(locale, key) {
|
|
33
|
+
const state = getI18nState();
|
|
34
|
+
const cacheKey = `${locale}:${key}`;
|
|
35
|
+
if (!state.missingKeyWarned.has(cacheKey)) {
|
|
36
|
+
state.missingKeyWarned.add(cacheKey);
|
|
37
|
+
for (const fn of state.missingKeyHandlers) fn(locale, key);
|
|
38
|
+
if (typeof process !== "undefined" && process.env && process.env.NODE_ENV === "development") console.warn(`[i18n] Missing key "${key}" for locale "${locale}"`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function invalidateCache(locale) {
|
|
42
|
+
getI18nState().messageCache.delete(locale);
|
|
43
|
+
}
|
|
44
|
+
function deepMerge(target, source) {
|
|
45
|
+
const result = { ...target };
|
|
46
|
+
for (const key of Object.keys(source)) {
|
|
47
|
+
const sourceVal = source[key];
|
|
48
|
+
const targetVal = result[key];
|
|
49
|
+
if (sourceVal && typeof sourceVal === "object" && !Array.isArray(sourceVal) && targetVal && typeof targetVal === "object" && !Array.isArray(targetVal)) result[key] = deepMerge(targetVal, sourceVal);
|
|
50
|
+
else result[key] = sourceVal;
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
function flattenMessages(messages, prefix = "") {
|
|
55
|
+
const result = {};
|
|
56
|
+
for (const [key, value] of Object.entries(messages)) {
|
|
57
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
58
|
+
if (typeof value === "string") result[fullKey] = value;
|
|
59
|
+
else if (value && typeof value === "object") Object.assign(result, flattenMessages(value, fullKey));
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
function getFlatMessages(locale) {
|
|
64
|
+
const state = getI18nState();
|
|
65
|
+
const cached = state.messageCache.get(locale);
|
|
66
|
+
if (cached) return cached;
|
|
67
|
+
const localeData = state.registeredLocales.get(locale);
|
|
68
|
+
if (!localeData) return {};
|
|
69
|
+
const flat = flattenMessages(localeData.messages);
|
|
70
|
+
state.messageCache.set(locale, flat);
|
|
71
|
+
return flat;
|
|
72
|
+
}
|
|
73
|
+
function getPluralCategory(count, locale) {
|
|
74
|
+
try {
|
|
75
|
+
return new Intl.PluralRules(locale).select(count);
|
|
76
|
+
} catch {
|
|
77
|
+
return count === 0 ? "zero" : count === 1 ? "one" : "other";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function selectPlural(template, count, locale) {
|
|
81
|
+
const parts = template.split("|").map((p) => p.trim());
|
|
82
|
+
if (parts.length === 1) return template;
|
|
83
|
+
for (const part of parts) {
|
|
84
|
+
const eqMatch = part.match(/^=(\d+)\s*/);
|
|
85
|
+
if (eqMatch) {
|
|
86
|
+
if (parseInt(eqMatch[1], 10) === count) return part.replace(/^=\d+\s*/, "");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const explicitCategories = [];
|
|
90
|
+
const plainParts = [];
|
|
91
|
+
for (const part of parts) {
|
|
92
|
+
if (/^=\d+\s*/.test(part)) continue;
|
|
93
|
+
const catMatch = part.match(/^(\w+):\s*/);
|
|
94
|
+
if (catMatch) explicitCategories.push({
|
|
95
|
+
cat: catMatch[1],
|
|
96
|
+
text: part.replace(/^\w+:\s*/, "")
|
|
97
|
+
});
|
|
98
|
+
else plainParts.push(part);
|
|
99
|
+
}
|
|
100
|
+
for (const { cat, text } of explicitCategories) {
|
|
101
|
+
if (cat === "zero" && count === 0) return text;
|
|
102
|
+
if (cat === "one" && count === 1) return text;
|
|
103
|
+
if (cat === getPluralCategory(count, locale)) return text;
|
|
104
|
+
}
|
|
105
|
+
if (plainParts.length === 2) return count === 1 ? plainParts[0] : plainParts[1];
|
|
106
|
+
if (plainParts.length >= 3) {
|
|
107
|
+
if (count === 0) return plainParts[0];
|
|
108
|
+
if (count === 1) return plainParts[1];
|
|
109
|
+
return plainParts[plainParts.length - 1];
|
|
110
|
+
}
|
|
111
|
+
if (plainParts.length === 1) return plainParts[0];
|
|
112
|
+
const category = getPluralCategory(count, locale);
|
|
113
|
+
const categoryIndex = [
|
|
114
|
+
"zero",
|
|
115
|
+
"one",
|
|
116
|
+
"two",
|
|
117
|
+
"few",
|
|
118
|
+
"many",
|
|
119
|
+
"other"
|
|
120
|
+
].indexOf(category);
|
|
121
|
+
if (categoryIndex >= 0 && categoryIndex < parts.length) return parts[categoryIndex];
|
|
122
|
+
return parts[parts.length - 1];
|
|
123
|
+
}
|
|
124
|
+
function resolveLinkedMessages(template, flat, visited = /* @__PURE__ */ new Set()) {
|
|
125
|
+
return template.replace(/@(?::([\w.]+)|{([\w.]+)})/g, (_match, colonKey, braceKey) => {
|
|
126
|
+
const key = colonKey || braceKey;
|
|
127
|
+
if (!key || visited.has(key)) return _match;
|
|
128
|
+
visited.add(key);
|
|
129
|
+
const linked = flat[key];
|
|
130
|
+
if (linked === void 0) return _match;
|
|
131
|
+
const resolved = resolveLinkedMessages(linked, flat, visited);
|
|
132
|
+
visited.delete(key);
|
|
133
|
+
return resolved;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
function interpolate(template, params, locale, flat) {
|
|
137
|
+
let result = template;
|
|
138
|
+
if (flat) result = resolveLinkedMessages(result, flat);
|
|
139
|
+
if (params && typeof params.count === "number") result = selectPlural(result, params.count, locale || "en");
|
|
140
|
+
if (params) result = result.replace(/\{(\w+)\}/g, (_, key) => {
|
|
141
|
+
const val = params[key];
|
|
142
|
+
return val !== void 0 ? String(val) : `{${key}}`;
|
|
143
|
+
});
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
function getMessage(locale, key, params) {
|
|
147
|
+
const flat = getFlatMessages(locale);
|
|
148
|
+
const msg = flat[key];
|
|
149
|
+
if (msg === void 0) return void 0;
|
|
150
|
+
return interpolate(msg, params, locale, flat);
|
|
151
|
+
}
|
|
152
|
+
function defineLocale(definition) {
|
|
153
|
+
const state = getI18nState();
|
|
154
|
+
const locale = {
|
|
155
|
+
code: definition.code,
|
|
156
|
+
messages: definition.messages,
|
|
157
|
+
name: definition.name,
|
|
158
|
+
dir: definition.dir || "ltr",
|
|
159
|
+
isDefault: definition.isDefault
|
|
160
|
+
};
|
|
161
|
+
state.registeredLocales.set(definition.code, locale);
|
|
162
|
+
invalidateCache(definition.code);
|
|
163
|
+
if (definition.isDefault || state.registeredLocales.size === 1) {
|
|
164
|
+
state.fallbackLocale = definition.code;
|
|
165
|
+
if (!state.currentLocale || state.currentLocale === "en") state.currentLocale = definition.code;
|
|
166
|
+
}
|
|
167
|
+
return definition;
|
|
168
|
+
}
|
|
169
|
+
function setI18nConfig(config) {
|
|
170
|
+
const state = getI18nState();
|
|
171
|
+
state.i18nConfig = {
|
|
172
|
+
...state.i18nConfig,
|
|
173
|
+
...config
|
|
174
|
+
};
|
|
175
|
+
if (config.defaultLocale) state.fallbackLocale = config.defaultLocale;
|
|
176
|
+
}
|
|
177
|
+
function getI18nConfig() {
|
|
178
|
+
const state = getI18nState();
|
|
179
|
+
return {
|
|
180
|
+
...state.i18nConfig,
|
|
181
|
+
locales: Array.from(state.registeredLocales.keys())
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
function getDefaultLocale() {
|
|
185
|
+
const state = getI18nState();
|
|
186
|
+
for (const [code, loc] of state.registeredLocales) if (loc.isDefault) return code;
|
|
187
|
+
return state.i18nConfig.defaultLocale;
|
|
188
|
+
}
|
|
189
|
+
function localizePath(path, locale) {
|
|
190
|
+
const state = getI18nState();
|
|
191
|
+
const targetLocale = locale || state.currentLocale;
|
|
192
|
+
const defaultLocale = getDefaultLocale();
|
|
193
|
+
const strategy = state.i18nConfig.strategy;
|
|
194
|
+
const cleanPath = path.replace(/^\/+/, "/").replace(/\/+$/, "") || "/";
|
|
195
|
+
if (strategy === "no_prefix") return cleanPath;
|
|
196
|
+
const pathParts = cleanPath.split("/").filter(Boolean);
|
|
197
|
+
const firstSegment = pathParts[0] || "";
|
|
198
|
+
const isLocalePrefix = state.registeredLocales.has(firstSegment);
|
|
199
|
+
let pathWithoutPrefix = cleanPath;
|
|
200
|
+
if (isLocalePrefix) {
|
|
201
|
+
const rest = pathParts.slice(1).join("/");
|
|
202
|
+
pathWithoutPrefix = rest ? `/${rest}` : "/";
|
|
203
|
+
}
|
|
204
|
+
if ((strategy === "prefix_except_default" || strategy === "prefix_and_default") && targetLocale === defaultLocale) return pathWithoutPrefix;
|
|
205
|
+
return `/${targetLocale}${pathWithoutPrefix === "/" ? "" : pathWithoutPrefix}`;
|
|
206
|
+
}
|
|
207
|
+
function switchLocalePath(newLocale, currentPath) {
|
|
208
|
+
const _global = globalThis;
|
|
209
|
+
return localizePath(currentPath || (typeof _global.window !== "undefined" ? _global.window.location.pathname : "/"), newLocale);
|
|
210
|
+
}
|
|
211
|
+
function extractLocaleFromPath(path) {
|
|
212
|
+
const state = getI18nState();
|
|
213
|
+
const pathParts = path.split("/").filter(Boolean);
|
|
214
|
+
const firstSegment = pathParts[0] || "";
|
|
215
|
+
if (state.registeredLocales.has(firstSegment)) {
|
|
216
|
+
const rest = pathParts.slice(1).join("/");
|
|
217
|
+
return {
|
|
218
|
+
locale: firstSegment,
|
|
219
|
+
pathWithoutLocale: rest ? `/${rest}` : "/"
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
locale: null,
|
|
224
|
+
pathWithoutLocale: path
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
function useI18n() {
|
|
228
|
+
return {
|
|
229
|
+
get locale() {
|
|
230
|
+
return getI18nState().currentLocale;
|
|
231
|
+
},
|
|
232
|
+
get fallbackLocale() {
|
|
233
|
+
return getI18nState().fallbackLocale;
|
|
234
|
+
},
|
|
235
|
+
get availableLocales() {
|
|
236
|
+
return Array.from(getI18nState().registeredLocales.keys());
|
|
237
|
+
},
|
|
238
|
+
t(key, params) {
|
|
239
|
+
const state = getI18nState();
|
|
240
|
+
let message = getMessage(state.currentLocale, key, params);
|
|
241
|
+
if (message === void 0 && state.currentLocale !== state.fallbackLocale) message = getMessage(state.fallbackLocale, key, params);
|
|
242
|
+
if (message === void 0) {
|
|
243
|
+
notifyMissingKey(state.currentLocale, key);
|
|
244
|
+
return key;
|
|
245
|
+
}
|
|
246
|
+
return message;
|
|
247
|
+
},
|
|
248
|
+
d(value, style = "short", options) {
|
|
249
|
+
try {
|
|
250
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
251
|
+
if (isNaN(date.getTime())) throw new Error("Invalid date");
|
|
252
|
+
return new Intl.DateTimeFormat(getI18nState().currentLocale, {
|
|
253
|
+
dateStyle: style,
|
|
254
|
+
...options
|
|
255
|
+
}).format(date);
|
|
256
|
+
} catch {
|
|
257
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
258
|
+
if (isNaN(date.getTime())) return String(value);
|
|
259
|
+
return date.toISOString().split("T")[0];
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
n(value, style = "decimal", options) {
|
|
263
|
+
try {
|
|
264
|
+
return new Intl.NumberFormat(getI18nState().currentLocale, {
|
|
265
|
+
style,
|
|
266
|
+
...options
|
|
267
|
+
}).format(value);
|
|
268
|
+
} catch {
|
|
269
|
+
return String(value);
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
c(value, currency, options) {
|
|
273
|
+
try {
|
|
274
|
+
return new Intl.NumberFormat(getI18nState().currentLocale, {
|
|
275
|
+
style: "currency",
|
|
276
|
+
currency,
|
|
277
|
+
...options
|
|
278
|
+
}).format(value);
|
|
279
|
+
} catch {
|
|
280
|
+
return `${currency} ${value}`;
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
relativeTime(value, unit, options) {
|
|
284
|
+
try {
|
|
285
|
+
return new Intl.RelativeTimeFormat(getI18nState().currentLocale, options).format(value, unit);
|
|
286
|
+
} catch {
|
|
287
|
+
return `${value >= 0 ? "in " : ""}${Math.abs(value)} ${unit}${value === 1 ? "" : "s"}${value < 0 ? " ago" : ""}`;
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
list(items, style = "conjunction", options) {
|
|
291
|
+
try {
|
|
292
|
+
return new Intl.ListFormat(getI18nState().currentLocale, {
|
|
293
|
+
type: style,
|
|
294
|
+
...options
|
|
295
|
+
}).format(items);
|
|
296
|
+
} catch {
|
|
297
|
+
return items.join(", ");
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
setLocale(locale) {
|
|
301
|
+
const state = getI18nState();
|
|
302
|
+
if (state.registeredLocales.has(locale) && locale !== state.currentLocale) {
|
|
303
|
+
state.currentLocale = locale;
|
|
304
|
+
notifyLocaleChange(locale);
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
getLocale() {
|
|
308
|
+
return getI18nState().currentLocale;
|
|
309
|
+
},
|
|
310
|
+
addLocale(code, messages, options) {
|
|
311
|
+
const state = getI18nState();
|
|
312
|
+
const existing = state.registeredLocales.get(code);
|
|
313
|
+
if (existing) {
|
|
314
|
+
existing.messages = deepMerge(existing.messages, messages);
|
|
315
|
+
if (options?.name) existing.name = options.name;
|
|
316
|
+
if (options?.dir) existing.dir = options.dir;
|
|
317
|
+
} else state.registeredLocales.set(code, {
|
|
318
|
+
code,
|
|
319
|
+
messages,
|
|
320
|
+
name: options?.name,
|
|
321
|
+
dir: options?.dir || "ltr"
|
|
322
|
+
});
|
|
323
|
+
invalidateCache(code);
|
|
324
|
+
state.missingKeyWarned = /* @__PURE__ */ new Set();
|
|
325
|
+
},
|
|
326
|
+
mergeLocale(code, messages) {
|
|
327
|
+
const state = getI18nState();
|
|
328
|
+
const existing = state.registeredLocales.get(code);
|
|
329
|
+
if (existing) existing.messages = deepMerge(existing.messages, messages);
|
|
330
|
+
else state.registeredLocales.set(code, {
|
|
331
|
+
code,
|
|
332
|
+
messages,
|
|
333
|
+
dir: "ltr"
|
|
334
|
+
});
|
|
335
|
+
invalidateCache(code);
|
|
336
|
+
state.missingKeyWarned = /* @__PURE__ */ new Set();
|
|
337
|
+
},
|
|
338
|
+
detectLocale(acceptLanguage) {
|
|
339
|
+
const state = getI18nState();
|
|
340
|
+
if (!acceptLanguage) return state.fallbackLocale;
|
|
341
|
+
const requested = acceptLanguage.split(",").map((lang) => {
|
|
342
|
+
const [code, q = "q=1.0"] = lang.trim().split(";");
|
|
343
|
+
const quality = parseFloat(q.replace("q=", "")) || 0;
|
|
344
|
+
return {
|
|
345
|
+
code: code.trim().toLowerCase(),
|
|
346
|
+
quality
|
|
347
|
+
};
|
|
348
|
+
}).sort((a, b) => b.quality - a.quality);
|
|
349
|
+
for (const { code } of requested) for (const registered of state.registeredLocales.keys()) if (code === registered.toLowerCase() || code.startsWith(`${registered.toLowerCase()}-`)) return registered;
|
|
350
|
+
return state.fallbackLocale;
|
|
351
|
+
},
|
|
352
|
+
onLocaleChange: addLocaleListener,
|
|
353
|
+
onMissingKey: addMissingKeyHandler,
|
|
354
|
+
getLocaleDir(locale) {
|
|
355
|
+
const state = getI18nState();
|
|
356
|
+
const code = locale || state.currentLocale;
|
|
357
|
+
return state.registeredLocales.get(code)?.dir || "ltr";
|
|
358
|
+
},
|
|
359
|
+
getLocaleName(locale) {
|
|
360
|
+
const state = getI18nState();
|
|
361
|
+
const code = locale || state.currentLocale;
|
|
362
|
+
return state.registeredLocales.get(code)?.name;
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
function t(key, params) {
|
|
367
|
+
return useI18n().t(key, params);
|
|
368
|
+
}
|
|
369
|
+
function setLocale(locale) {
|
|
370
|
+
useI18n().setLocale(locale);
|
|
371
|
+
}
|
|
372
|
+
function getLocale() {
|
|
373
|
+
return useI18n().getLocale();
|
|
374
|
+
}
|
|
375
|
+
function getRegisteredLocales() {
|
|
376
|
+
return Array.from(getI18nState().registeredLocales.keys());
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Returns metadata for all registered locales (without messages).
|
|
380
|
+
* Used by SSR to serialize the full locale list so the client can
|
|
381
|
+
* register all available locales during hydration — preventing
|
|
382
|
+
* `availableLocales` hydration mismatches.
|
|
383
|
+
*/
|
|
384
|
+
function getRegisteredLocalesMeta() {
|
|
385
|
+
const state = getI18nState();
|
|
386
|
+
return Array.from(state.registeredLocales.values()).map((loc) => ({
|
|
387
|
+
code: loc.code,
|
|
388
|
+
name: loc.name,
|
|
389
|
+
dir: loc.dir,
|
|
390
|
+
isDefault: loc.isDefault
|
|
391
|
+
}));
|
|
392
|
+
}
|
|
393
|
+
function getLocaleMessages(locale) {
|
|
394
|
+
const state = getI18nState();
|
|
395
|
+
const code = locale || state.currentLocale;
|
|
396
|
+
return state.registeredLocales.get(code)?.messages;
|
|
397
|
+
}
|
|
398
|
+
function clearLocales() {
|
|
399
|
+
const state = getI18nState();
|
|
400
|
+
state.registeredLocales.clear();
|
|
401
|
+
state.localeListeners.clear();
|
|
402
|
+
state.missingKeyHandlers.clear();
|
|
403
|
+
state.messageCache.clear();
|
|
404
|
+
state.missingKeyWarned = /* @__PURE__ */ new Set();
|
|
405
|
+
state.currentLocale = "en";
|
|
406
|
+
state.fallbackLocale = "en";
|
|
407
|
+
}
|
|
408
|
+
function onLocaleChange(callback) {
|
|
409
|
+
return useI18n().onLocaleChange(callback);
|
|
410
|
+
}
|
|
411
|
+
function getLocaleDir(locale) {
|
|
412
|
+
return useI18n().getLocaleDir(locale);
|
|
413
|
+
}
|
|
414
|
+
function getLocaleName(locale) {
|
|
415
|
+
return useI18n().getLocaleName(locale);
|
|
416
|
+
}
|
|
417
|
+
function detectLocale(acceptLanguage) {
|
|
418
|
+
return useI18n().detectLocale(acceptLanguage);
|
|
419
|
+
}
|
|
420
|
+
function addLocale(code, messages, options) {
|
|
421
|
+
useI18n().addLocale(code, messages, options);
|
|
422
|
+
}
|
|
423
|
+
function mergeLocale(code, messages) {
|
|
424
|
+
useI18n().mergeLocale(code, messages);
|
|
425
|
+
}
|
|
426
|
+
function detectBrowserLocale() {
|
|
427
|
+
const i18n = useI18n();
|
|
428
|
+
if (typeof navigator !== "undefined" && navigator.language) return i18n.detectLocale(navigator.language);
|
|
429
|
+
return getI18nState().fallbackLocale;
|
|
430
|
+
}
|
|
431
|
+
function formatDate(value, style, options) {
|
|
432
|
+
return useI18n().d(value, style, options);
|
|
433
|
+
}
|
|
434
|
+
function formatNumber(value, style, options) {
|
|
435
|
+
return useI18n().n(value, style, options);
|
|
436
|
+
}
|
|
437
|
+
function formatCurrency(value, currency, options) {
|
|
438
|
+
return useI18n().c(value, currency, options);
|
|
439
|
+
}
|
|
440
|
+
function formatRelativeTime(value, unit, options) {
|
|
441
|
+
return useI18n().relativeTime(value, unit, options);
|
|
442
|
+
}
|
|
443
|
+
function formatList(items, style, options) {
|
|
444
|
+
return useI18n().list(items, style, options);
|
|
445
|
+
}
|
|
446
|
+
//#endregion
|
|
447
|
+
export { addLocale, clearLocales, defineLocale, detectBrowserLocale, detectLocale, extractLocaleFromPath, formatCurrency, formatDate, formatList, formatNumber, formatRelativeTime, getDefaultLocale, getI18nConfig, getLocale, getLocaleDir, getLocaleMessages, getLocaleName, getRegisteredLocales, getRegisteredLocalesMeta, localizePath, mergeLocale, onLocaleChange, setI18nConfig, setLocale, switchLocalePath, t, useI18n };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { I18nRoutingStrategy } from "./index.js";
|
|
2
|
+
import { Context, MiddlewareHandler } from "hono";
|
|
3
|
+
import { UbeanEnv } from "@ubean/types";
|
|
4
|
+
//#region src/routing.d.ts
|
|
5
|
+
interface I18nRoutingOptions {
|
|
6
|
+
strategy?: I18nRoutingStrategy;
|
|
7
|
+
defaultLocale?: string;
|
|
8
|
+
locales?: string[];
|
|
9
|
+
detectFromHeader?: boolean;
|
|
10
|
+
detectFromCookie?: boolean | string;
|
|
11
|
+
redirectOnLocaleMismatch?: boolean;
|
|
12
|
+
cookieName?: string;
|
|
13
|
+
}
|
|
14
|
+
declare function createI18nMiddleware(options?: I18nRoutingOptions): MiddlewareHandler<UbeanEnv>;
|
|
15
|
+
declare function switchLocalePath(c: Context, locale: string, strategy?: I18nRoutingStrategy, defaultLocale?: string): string;
|
|
16
|
+
declare function getLocalePath(c: Context): string;
|
|
17
|
+
declare function getPathWithoutLocale(c: Context): string;
|
|
18
|
+
declare function localeRoutes(locales: string[], defaultLocale: string, strategy?: I18nRoutingStrategy): {
|
|
19
|
+
localizePath: (path: string, locale?: string) => string;
|
|
20
|
+
getLocaleFromUrl: (url: string) => string | null;
|
|
21
|
+
getLocalizedPaths: (path: string) => Array<{
|
|
22
|
+
locale: string;
|
|
23
|
+
path: string;
|
|
24
|
+
}>;
|
|
25
|
+
};
|
|
26
|
+
//#endregion
|
|
27
|
+
export { I18nRoutingOptions, type I18nRoutingStrategy, createI18nMiddleware, getLocalePath, getPathWithoutLocale, localeRoutes, switchLocalePath };
|
package/dist/routing.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { useI18n } from "./index.js";
|
|
2
|
+
//#region src/routing.ts
|
|
3
|
+
function getLocaleFromPath(path, locales) {
|
|
4
|
+
const segments = path.split("/");
|
|
5
|
+
if (segments.length >= 2 && locales.includes(segments[1])) {
|
|
6
|
+
const remainingPath = segments.slice(2).join("/");
|
|
7
|
+
return {
|
|
8
|
+
locale: segments[1],
|
|
9
|
+
pathWithoutLocale: remainingPath ? `/${remainingPath}` : "/"
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
return {
|
|
13
|
+
locale: null,
|
|
14
|
+
pathWithoutLocale: path
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function createI18nMiddleware(options = {}) {
|
|
18
|
+
const { strategy = "prefix_except_default", defaultLocale: explicitDefault, locales: explicitLocales, detectFromHeader = true, detectFromCookie = true, redirectOnLocaleMismatch = true, cookieName = "ubean_locale" } = options;
|
|
19
|
+
return async function i18nMiddleware(c, next) {
|
|
20
|
+
const i18n = useI18n();
|
|
21
|
+
const resolvedDefaultLocale = explicitDefault || i18n.fallbackLocale;
|
|
22
|
+
const resolvedLocales = explicitLocales || i18n.availableLocales;
|
|
23
|
+
const path = new URL(c.req.url).pathname;
|
|
24
|
+
let detectedLocale = resolvedDefaultLocale;
|
|
25
|
+
if (strategy === "prefix" || strategy === "prefix_except_default" || strategy === "prefix_and_default") {
|
|
26
|
+
const { locale: pathLocale, pathWithoutLocale } = getLocaleFromPath(path, resolvedLocales);
|
|
27
|
+
if (pathLocale) {
|
|
28
|
+
detectedLocale = pathLocale;
|
|
29
|
+
c.set("locale", pathLocale);
|
|
30
|
+
c.set("pathWithoutLocale", pathWithoutLocale);
|
|
31
|
+
} else if (strategy === "prefix_and_default") {
|
|
32
|
+
detectedLocale = resolvedDefaultLocale;
|
|
33
|
+
c.set("pathWithoutLocale", path);
|
|
34
|
+
} else if (strategy === "prefix") {
|
|
35
|
+
const preferredLocale = resolvePreferredLocale();
|
|
36
|
+
if (redirectOnLocaleMismatch && preferredLocale !== resolvedDefaultLocale) {
|
|
37
|
+
const redirectUrl = `/${preferredLocale}${path === "/" ? "" : path}`;
|
|
38
|
+
return c.redirect(redirectUrl, 302);
|
|
39
|
+
}
|
|
40
|
+
if (redirectOnLocaleMismatch) return c.redirect(`/${resolvedDefaultLocale}${path === "/" ? "" : path}`, 302);
|
|
41
|
+
} else if (strategy === "prefix_except_default") {
|
|
42
|
+
const preferredLocale = resolvePreferredLocale();
|
|
43
|
+
if (preferredLocale !== resolvedDefaultLocale && redirectOnLocaleMismatch) return c.redirect(`/${preferredLocale}${path === "/" ? "" : path}`, 302);
|
|
44
|
+
detectedLocale = resolvedDefaultLocale;
|
|
45
|
+
}
|
|
46
|
+
} else if (strategy === "no_prefix") detectedLocale = resolvePreferredLocale();
|
|
47
|
+
function resolvePreferredLocale() {
|
|
48
|
+
if (detectFromCookie) {
|
|
49
|
+
const cookieLocale = c.req.header("cookie");
|
|
50
|
+
if (cookieLocale) {
|
|
51
|
+
const match = cookieLocale.match(new RegExp(`${cookieName}=([^;]+)`));
|
|
52
|
+
if (match && resolvedLocales.includes(match[1])) return match[1];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (detectFromHeader) {
|
|
56
|
+
const acceptLang = c.req.header("accept-language");
|
|
57
|
+
if (acceptLang) {
|
|
58
|
+
const detected = i18n.detectLocale(acceptLang);
|
|
59
|
+
if (resolvedLocales.includes(detected)) return detected;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return resolvedDefaultLocale;
|
|
63
|
+
}
|
|
64
|
+
if (resolvedLocales.includes(detectedLocale)) i18n.setLocale(detectedLocale);
|
|
65
|
+
c.set("locale", detectedLocale);
|
|
66
|
+
c.header("Content-Language", detectedLocale);
|
|
67
|
+
await next();
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function switchLocalePath(c, locale, strategy = "prefix_except_default", defaultLocale) {
|
|
71
|
+
const path = new URL(c.req.url).pathname;
|
|
72
|
+
const i18n = useI18n();
|
|
73
|
+
const resolvedDefault = defaultLocale || i18n.fallbackLocale;
|
|
74
|
+
const resolvedLocales = i18n.availableLocales;
|
|
75
|
+
const { pathWithoutLocale } = getLocaleFromPath(path, resolvedLocales);
|
|
76
|
+
const cleanPath = pathWithoutLocale === "/" ? "" : pathWithoutLocale;
|
|
77
|
+
switch (strategy) {
|
|
78
|
+
case "no_prefix": return path;
|
|
79
|
+
case "prefix": return `/${locale}${cleanPath}`;
|
|
80
|
+
case "prefix_except_default":
|
|
81
|
+
case "prefix_and_default":
|
|
82
|
+
if (locale === resolvedDefault) return cleanPath || "/";
|
|
83
|
+
return `/${locale}${cleanPath}`;
|
|
84
|
+
default: return path;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function getLocalePath(c) {
|
|
88
|
+
return c.get("locale") || "en";
|
|
89
|
+
}
|
|
90
|
+
function getPathWithoutLocale(c) {
|
|
91
|
+
return c.get("pathWithoutLocale") || new URL(c.req.url).pathname;
|
|
92
|
+
}
|
|
93
|
+
function localeRoutes(locales, defaultLocale, strategy = "prefix_except_default") {
|
|
94
|
+
function localizePath(path, locale) {
|
|
95
|
+
const targetLocale = locale || defaultLocale;
|
|
96
|
+
const cleanPath = path.startsWith("/") ? path : `/${path}`;
|
|
97
|
+
switch (strategy) {
|
|
98
|
+
case "no_prefix": return cleanPath;
|
|
99
|
+
case "prefix": return `/${targetLocale}${cleanPath === "/" ? "" : cleanPath}`;
|
|
100
|
+
case "prefix_except_default":
|
|
101
|
+
case "prefix_and_default":
|
|
102
|
+
if (targetLocale === defaultLocale) return cleanPath;
|
|
103
|
+
return `/${targetLocale}${cleanPath === "/" ? "" : cleanPath}`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function getLocaleFromUrl(url) {
|
|
107
|
+
try {
|
|
108
|
+
const pathname = new URL(url).pathname;
|
|
109
|
+
const { locale } = getLocaleFromPath(pathname, locales);
|
|
110
|
+
return locale;
|
|
111
|
+
} catch {
|
|
112
|
+
const { locale } = getLocaleFromPath(url, locales);
|
|
113
|
+
return locale;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function getLocalizedPaths(path) {
|
|
117
|
+
return locales.map((locale) => ({
|
|
118
|
+
locale,
|
|
119
|
+
path: localizePath(path, locale)
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
localizePath,
|
|
124
|
+
getLocaleFromUrl,
|
|
125
|
+
getLocalizedPaths
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
//#endregion
|
|
129
|
+
export { createI18nMiddleware, getLocalePath, getPathWithoutLocale, localeRoutes, switchLocalePath };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ubean/i18n",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Zero-dependency i18n for ubean (defineLocale, t, formatDate, routing)",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist"
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"hono": "4.12.32",
|
|
24
|
-
"@ubean/types": "0.1.
|
|
24
|
+
"@ubean/types": "0.1.3"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/node": "^26.1.1",
|