@f5xc-salesdemos/pi-utils 19.18.6 → 19.19.0
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 +2 -2
- package/src/i18n.test.ts +155 -0
- package/src/i18n.ts +111 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5xc-salesdemos/pi-utils",
|
|
4
|
-
"version": "19.
|
|
4
|
+
"version": "19.19.0",
|
|
5
5
|
"description": "Shared utilities for pi packages",
|
|
6
6
|
"homepage": "https://github.com/f5xc-salesdemos/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@types/bun": "^1.3",
|
|
41
|
-
"@f5xc-salesdemos/pi-natives": "19.
|
|
41
|
+
"@f5xc-salesdemos/pi-natives": "19.19.0"
|
|
42
42
|
},
|
|
43
43
|
"engines": {
|
|
44
44
|
"bun": ">=1.3.7"
|
package/src/i18n.test.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { getLocale, initI18n, mapToSupportedLocale, registerLocales, setLocale, t } from "./i18n";
|
|
3
|
+
|
|
4
|
+
const EN = {
|
|
5
|
+
greeting: "Hello",
|
|
6
|
+
"greeting.with.name": "Hello, {name}!",
|
|
7
|
+
"items.count.one": "{count} item",
|
|
8
|
+
"items.count.other": "{count} items",
|
|
9
|
+
"error.notFound": "File not found: {path}",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const JA = {
|
|
13
|
+
greeting: "こんにちは",
|
|
14
|
+
"greeting.with.name": "こんにちは、{name}さん!",
|
|
15
|
+
"items.count.one": "{count}個のアイテム",
|
|
16
|
+
"items.count.other": "{count}個のアイテム",
|
|
17
|
+
"error.notFound": "ファイルが見つかりません: {path}",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
describe("i18n", () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
registerLocales({ en: EN, ja: JA });
|
|
23
|
+
setLocale("en");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("t() returns English string by key", () => {
|
|
27
|
+
expect(t("greeting")).toBe("Hello");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("t() interpolates parameters", () => {
|
|
31
|
+
expect(t("greeting.with.name", { name: "World" })).toBe("Hello, World!");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("t() falls back to key when key is missing", () => {
|
|
35
|
+
expect(t("nonexistent.key")).toBe("nonexistent.key");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("t() handles plural: one", () => {
|
|
39
|
+
expect(t("items.count", { count: 1 })).toBe("1 item");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("t() handles plural: other", () => {
|
|
43
|
+
expect(t("items.count", { count: 5 })).toBe("5 items");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("setLocale() switches to Japanese", () => {
|
|
47
|
+
setLocale("ja");
|
|
48
|
+
expect(t("greeting")).toBe("こんにちは");
|
|
49
|
+
expect(t("greeting.with.name", { name: "太郎" })).toBe("こんにちは、太郎さん!");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("getLocale() returns current locale", () => {
|
|
53
|
+
expect(getLocale()).toBe("en");
|
|
54
|
+
setLocale("ja");
|
|
55
|
+
expect(getLocale()).toBe("ja");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("falls back to base locale (ja-jp -> ja)", () => {
|
|
59
|
+
setLocale("ja-jp");
|
|
60
|
+
expect(t("greeting")).toBe("こんにちは");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("falls back to English for unknown locale", () => {
|
|
64
|
+
setLocale("xx");
|
|
65
|
+
expect(t("greeting")).toBe("Hello");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("normalizes locale with underscores", () => {
|
|
69
|
+
setLocale("ja_JP");
|
|
70
|
+
expect(getLocale()).toBe("ja");
|
|
71
|
+
expect(t("greeting")).toBe("こんにちは");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("handles locale with encoding suffix", () => {
|
|
75
|
+
setLocale("ja_JP.UTF-8");
|
|
76
|
+
expect(getLocale()).toBe("ja");
|
|
77
|
+
expect(t("greeting")).toBe("こんにちは");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("interpolation leaves unmatched placeholders", () => {
|
|
81
|
+
expect(t("error.notFound", { path: "/foo" })).toBe("File not found: /foo");
|
|
82
|
+
expect(t("greeting.with.name", {})).toBe("Hello, {name}!");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("initI18n() defaults to en when no env vars set", () => {
|
|
86
|
+
const oldLang = Bun.env.LANG;
|
|
87
|
+
const oldXcsh = Bun.env.XCSH_LOCALE;
|
|
88
|
+
delete Bun.env.LANG;
|
|
89
|
+
delete Bun.env.XCSH_LOCALE;
|
|
90
|
+
initI18n();
|
|
91
|
+
expect(getLocale()).toBe("en");
|
|
92
|
+
if (oldLang) Bun.env.LANG = oldLang;
|
|
93
|
+
if (oldXcsh) Bun.env.XCSH_LOCALE = oldXcsh;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("initI18n() reads XCSH_LOCALE env var", () => {
|
|
97
|
+
Bun.env.XCSH_LOCALE = "ja";
|
|
98
|
+
initI18n();
|
|
99
|
+
expect(getLocale()).toBe("ja");
|
|
100
|
+
delete Bun.env.XCSH_LOCALE;
|
|
101
|
+
setLocale("en");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("initI18n() prefers explicit locale argument", () => {
|
|
105
|
+
Bun.env.XCSH_LOCALE = "ja";
|
|
106
|
+
initI18n("en");
|
|
107
|
+
expect(getLocale()).toBe("en");
|
|
108
|
+
delete Bun.env.XCSH_LOCALE;
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("mapToSupportedLocale", () => {
|
|
113
|
+
beforeEach(() => {
|
|
114
|
+
registerLocales({ en: EN, ja: JA, "zh-cn": {}, "zh-tw": {}, "pt-br": {}, fr: {}, de: {} });
|
|
115
|
+
setLocale("en");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("exact match", () => {
|
|
119
|
+
expect(mapToSupportedLocale("ja")).toBe("ja");
|
|
120
|
+
expect(mapToSupportedLocale("en")).toBe("en");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("regional variant maps to base", () => {
|
|
124
|
+
expect(mapToSupportedLocale("en-US")).toBe("en");
|
|
125
|
+
expect(mapToSupportedLocale("ja-JP")).toBe("ja");
|
|
126
|
+
expect(mapToSupportedLocale("fr-FR")).toBe("fr");
|
|
127
|
+
expect(mapToSupportedLocale("de-DE")).toBe("de");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("regional variant with exact match", () => {
|
|
131
|
+
expect(mapToSupportedLocale("pt-BR")).toBe("pt-br");
|
|
132
|
+
expect(mapToSupportedLocale("zh-CN")).toBe("zh-cn");
|
|
133
|
+
expect(mapToSupportedLocale("zh-TW")).toBe("zh-tw");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("Apple zh-Hans maps to zh-cn", () => {
|
|
137
|
+
expect(mapToSupportedLocale("zh-Hans")).toBe("zh-cn");
|
|
138
|
+
expect(mapToSupportedLocale("zh-Hans-CN")).toBe("zh-cn");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("Apple zh-Hant maps to zh-tw", () => {
|
|
142
|
+
expect(mapToSupportedLocale("zh-Hant")).toBe("zh-tw");
|
|
143
|
+
expect(mapToSupportedLocale("zh-Hant-TW")).toBe("zh-tw");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("underscore format", () => {
|
|
147
|
+
expect(mapToSupportedLocale("pt_BR")).toBe("pt-br");
|
|
148
|
+
expect(mapToSupportedLocale("ja_JP.UTF-8")).toBe("ja");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("unsupported locale returns undefined", () => {
|
|
152
|
+
expect(mapToSupportedLocale("xx")).toBeUndefined();
|
|
153
|
+
expect(mapToSupportedLocale("sv-SE")).toBeUndefined();
|
|
154
|
+
});
|
|
155
|
+
});
|
package/src/i18n.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { $pickenv } from "./env";
|
|
2
|
+
|
|
3
|
+
type LocaleMap = Record<string, string>;
|
|
4
|
+
type LocaleBundle = Record<string, LocaleMap>;
|
|
5
|
+
|
|
6
|
+
let currentLocale = "en";
|
|
7
|
+
let bundles: LocaleBundle = {};
|
|
8
|
+
let active: LocaleMap = {};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Register all locale bundles. Call once at startup before any t() calls.
|
|
12
|
+
* Keys are locale codes ("en", "ja", "zh-cn"), values are flat key→string maps.
|
|
13
|
+
*/
|
|
14
|
+
export function registerLocales(localeBundle: LocaleBundle): void {
|
|
15
|
+
bundles = localeBundle;
|
|
16
|
+
active = resolveBundle(currentLocale);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Initialize i18n from environment / config.
|
|
21
|
+
* Priority: explicit locale arg > XCSH_LOCALE env > PI_LOCALE env > LANG env > "en"
|
|
22
|
+
*/
|
|
23
|
+
export function initI18n(locale?: string): void {
|
|
24
|
+
const resolved =
|
|
25
|
+
locale || $pickenv("XCSH_LOCALE", "PI_LOCALE") || parseSystemLocale($pickenv("LANG", "LC_MESSAGES")) || "en";
|
|
26
|
+
setLocale(resolved);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function setLocale(locale: string): void {
|
|
30
|
+
currentLocale = mapToSupportedLocale(locale) ?? normalizeLocale(locale);
|
|
31
|
+
active = resolveBundle(currentLocale);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getLocale(): string {
|
|
35
|
+
return currentLocale;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Translate a key with optional parameter interpolation.
|
|
40
|
+
* Falls back to the English string, then to the key itself.
|
|
41
|
+
*
|
|
42
|
+
* Usage:
|
|
43
|
+
* t("cli.error.patternRequired")
|
|
44
|
+
* t("cli.error.fileNotFound", { path: "/foo" })
|
|
45
|
+
* t("items.count", { count: 5 }) // uses ".one" / ".other" suffixes
|
|
46
|
+
*/
|
|
47
|
+
export function t(key: string, params?: Record<string, string | number>): string {
|
|
48
|
+
let template = active[key];
|
|
49
|
+
|
|
50
|
+
if (template === undefined && params && typeof params.count === "number") {
|
|
51
|
+
const pluralSuffix = params.count === 1 ? ".one" : ".other";
|
|
52
|
+
template = active[key + pluralSuffix];
|
|
53
|
+
if (template === undefined) {
|
|
54
|
+
template = bundles.en?.[key + pluralSuffix] ?? bundles.en?.[key];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (template === undefined) {
|
|
59
|
+
template = bundles.en?.[key] ?? key;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!params) return template;
|
|
63
|
+
|
|
64
|
+
return template.replace(/\{(\w+)\}/g, (match, name: string) => {
|
|
65
|
+
const val = params[name];
|
|
66
|
+
return val !== undefined ? String(val) : match;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Map an OS locale code (macOS AppleLanguages, Linux LANG) to a supported xcsh locale.
|
|
72
|
+
* Handles Apple script subtags (zh-Hans, zh-Hant) and regional variants (pt-BR, en-US).
|
|
73
|
+
* Returns undefined if no supported locale matches.
|
|
74
|
+
*/
|
|
75
|
+
export function mapToSupportedLocale(osLocale: string): string | undefined {
|
|
76
|
+
const normalized = normalizeLocale(osLocale);
|
|
77
|
+
|
|
78
|
+
if (bundles[normalized]) return normalized;
|
|
79
|
+
|
|
80
|
+
// Apple uses zh-hans / zh-hant (script subtags) instead of zh-cn / zh-tw
|
|
81
|
+
if (normalized.startsWith("zh-hans")) return bundles["zh-cn"] ? "zh-cn" : undefined;
|
|
82
|
+
if (normalized.startsWith("zh-hant")) return bundles["zh-tw"] ? "zh-tw" : undefined;
|
|
83
|
+
|
|
84
|
+
// Try with region: pt-br, en-us, etc.
|
|
85
|
+
const withRegion = normalized.split("-").slice(0, 2).join("-");
|
|
86
|
+
if (bundles[withRegion]) return withRegion;
|
|
87
|
+
|
|
88
|
+
// Fall back to base language: pt-br → pt, en-us → en
|
|
89
|
+
const base = normalized.split("-")[0];
|
|
90
|
+
if (bundles[base]) return base;
|
|
91
|
+
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normalizeLocale(raw: string): string {
|
|
96
|
+
return raw.toLowerCase().replace(/_/g, "-").split(".")[0];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseSystemLocale(raw: string | undefined): string | undefined {
|
|
100
|
+
if (!raw) return undefined;
|
|
101
|
+
const normalized = normalizeLocale(raw);
|
|
102
|
+
if (normalized === "c" || normalized === "posix") return "en";
|
|
103
|
+
return normalized;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function resolveBundle(locale: string): LocaleMap {
|
|
107
|
+
if (bundles[locale]) return bundles[locale];
|
|
108
|
+
const base = locale.split("-")[0];
|
|
109
|
+
if (bundles[base]) return bundles[base];
|
|
110
|
+
return bundles.en ?? {};
|
|
111
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export * from "./frontmatter";
|
|
|
8
8
|
export * from "./fs-error";
|
|
9
9
|
export * from "./glob";
|
|
10
10
|
export * from "./hook-fetch";
|
|
11
|
+
export { getLocale, initI18n, mapToSupportedLocale, registerLocales, setLocale, t } from "./i18n";
|
|
11
12
|
export * from "./json";
|
|
12
13
|
export * as logger from "./logger";
|
|
13
14
|
export * from "./mermaid-ascii";
|