@multiplatform.one/i18n 6.1.0 → 6.4.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/README.md +55 -0
- package/package.json +16 -5
- package/src/frappeBackend.spec.ts +108 -0
- package/src/localePersistence.spec.ts +62 -0
- package/types/createI18nConfig.d.ts +38 -0
- package/types/createI18nConfig.d.ts.map +1 -0
- package/types/detectLanguage.d.ts +10 -0
- package/types/detectLanguage.d.ts.map +1 -0
- package/types/formatters.d.ts +23 -0
- package/types/formatters.d.ts.map +1 -0
- package/types/frappeBackend.d.ts +14 -0
- package/types/frappeBackend.d.ts.map +1 -0
- package/types/index.d.ts +9 -0
- package/types/index.d.ts.map +1 -0
- package/types/localePersistence.d.ts +7 -0
- package/types/localePersistence.d.ts.map +1 -0
- package/types/useLanguage.d.ts +2 -0
- package/types/useLanguage.d.ts.map +1 -0
- package/CHANGELOG.md +0 -47
- package/tests/createI18nConfig.test.ts +0 -58
- package/tests/detectLanguage.test.ts +0 -66
- package/tests/formatters.test.ts +0 -50
- package/tests/frappeBackend.test.ts +0 -57
- package/tests/frappeBackendEnabled.test.ts +0 -111
- package/tests/integrationFrappeDisabled.test.ts +0 -182
- package/tests/integrationFrappeEnabled.test.ts +0 -232
- package/tests/integrationLanguagePersistence.test.ts +0 -241
- package/tests/integrationTreeshaking.test.ts +0 -126
- package/tests/localFallbackDictionaries.test.ts +0 -294
- package/tests/localePersistence.test.ts +0 -65
- package/tests/makeMkFlag.test.ts +0 -25
- package/tests/useLanguagePersistence.test.ts +0 -74
- package/tsconfig.json +0 -11
- package/vitest.config.mjs +0 -13
package/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# @multiplatform.one/i18n
|
|
2
|
+
|
|
3
|
+
Internationalization for multiplatform.one: [i18next](https://www.i18next.com)
|
|
4
|
+
config factory, language detection/persistence, a Frappe translation backend,
|
|
5
|
+
and locale-aware formatters.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add @multiplatform.one/i18n
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Peers: `i18next`, `react` (pair with `react-i18next` in the app).
|
|
14
|
+
|
|
15
|
+
## What it owns
|
|
16
|
+
|
|
17
|
+
- **`createI18nConfig(options)`** — builds the i18next config from
|
|
18
|
+
`languages`, `namespaces`, `defaultLanguage`, `defaultNamespace`, bundled
|
|
19
|
+
`resources`, and an optional Frappe backend
|
|
20
|
+
- **`createFrappeBackend(options)`** — loads translations from a Frappe server
|
|
21
|
+
- **Language handling** — `useLanguage()`, `detectLanguage()`,
|
|
22
|
+
`persistLanguage()` / `getPersistedLanguage()` (cookie-based, SSR-safe)
|
|
23
|
+
- **Formatters** — `useFormatDate`, `useFormatNumber`, `useFormatCurrency`
|
|
24
|
+
(+ non-hook `createDateFormatter`, `createNumberFormatter`,
|
|
25
|
+
`createCurrencyFormatter`)
|
|
26
|
+
|
|
27
|
+
## What it must not do
|
|
28
|
+
|
|
29
|
+
- No UI and no translation _content_ — apps own their resources/namespaces
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { createI18nConfig } from "@multiplatform.one/i18n";
|
|
35
|
+
import common from "./locales/en/common.json";
|
|
36
|
+
|
|
37
|
+
export const i18nConfig = createI18nConfig({
|
|
38
|
+
languages: ["en", "te"],
|
|
39
|
+
namespaces: ["common"],
|
|
40
|
+
defaultLanguage: "en",
|
|
41
|
+
defaultNamespace: "common",
|
|
42
|
+
resources: { en: { common } },
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```tsx
|
|
47
|
+
import { useLanguage, useFormatCurrency } from "@multiplatform.one/i18n";
|
|
48
|
+
|
|
49
|
+
const [language, setLanguage] = useLanguage();
|
|
50
|
+
const formatCurrency = useFormatCurrency();
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
Apache-2.0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@multiplatform.one/i18n",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.4.0",
|
|
4
4
|
"description": "Internationalization utilities and hooks for multiplatform.one",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"i18n",
|
|
@@ -10,16 +10,26 @@
|
|
|
10
10
|
],
|
|
11
11
|
"license": "Apache-2.0",
|
|
12
12
|
"author": "BitSpur",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://gitlab.com/bitspur/frappe/multiplatform.one.git",
|
|
16
|
+
"directory": "public/i18n"
|
|
17
|
+
},
|
|
13
18
|
"source": "src/index.ts",
|
|
19
|
+
"files": [
|
|
20
|
+
"src",
|
|
21
|
+
"types",
|
|
22
|
+
"!types/.tsbuildinfo"
|
|
23
|
+
],
|
|
14
24
|
"sideEffects": false,
|
|
15
25
|
"main": "src/index.ts",
|
|
16
26
|
"module": "src/index.ts",
|
|
17
|
-
"types": "
|
|
27
|
+
"types": "types/index.d.ts",
|
|
18
28
|
"exports": {
|
|
19
29
|
"./package.json": "./package.json",
|
|
20
30
|
".": {
|
|
21
31
|
"source": "./src/index.ts",
|
|
22
|
-
"types": "./
|
|
32
|
+
"types": "./types/index.d.ts",
|
|
23
33
|
"default": "./src/index.ts"
|
|
24
34
|
}
|
|
25
35
|
},
|
|
@@ -27,7 +37,7 @@
|
|
|
27
37
|
"access": "public"
|
|
28
38
|
},
|
|
29
39
|
"dependencies": {
|
|
30
|
-
"@multiplatform.one/platform": "6.
|
|
40
|
+
"@multiplatform.one/platform": "6.4.0"
|
|
31
41
|
},
|
|
32
42
|
"devDependencies": {
|
|
33
43
|
"@types/react": "~19.2.14",
|
|
@@ -40,6 +50,7 @@
|
|
|
40
50
|
"react": "^19.1.0"
|
|
41
51
|
},
|
|
42
52
|
"scripts": {
|
|
43
|
-
"typecheck": "tsc --noEmit"
|
|
53
|
+
"typecheck": "tsc --noEmit",
|
|
54
|
+
"build": "rm -rf types 2>/dev/null && tsc -p tsconfig.build.json"
|
|
44
55
|
}
|
|
45
56
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frappe i18next backend contracts — the VITE_FRAPPE_ENABLED tree-shaking
|
|
3
|
+
* guard (backend must be null when Frappe is off), and the backend read
|
|
4
|
+
* path: fetches Frappe's get_dict endpoint with credentials, hands the
|
|
5
|
+
* translation dict to i18next, and surfaces fetch failures as errors.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
import type { Services, InitOptions } from "i18next";
|
|
10
|
+
import { createFrappeBackend, type FrappeBackendOptions } from "./frappeBackend";
|
|
11
|
+
|
|
12
|
+
const savedFlag = process.env.VITE_FRAPPE_ENABLED;
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
if (savedFlag === undefined) delete process.env.VITE_FRAPPE_ENABLED;
|
|
16
|
+
else process.env.VITE_FRAPPE_ENABLED = savedFlag;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function enabledBackend() {
|
|
20
|
+
process.env.VITE_FRAPPE_ENABLED = "true";
|
|
21
|
+
const backend = createFrappeBackend();
|
|
22
|
+
expect(backend).not.toBeNull();
|
|
23
|
+
return backend!;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function initBackend(
|
|
27
|
+
backend: ReturnType<typeof createFrappeBackend>,
|
|
28
|
+
options: FrappeBackendOptions,
|
|
29
|
+
) {
|
|
30
|
+
backend!.init({} as Services, options, {} as InitOptions);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("createFrappeBackend gating", () => {
|
|
34
|
+
it("returns null when VITE_FRAPPE_ENABLED is not 'true' (tree-shake guard)", () => {
|
|
35
|
+
process.env.VITE_FRAPPE_ENABLED = "false";
|
|
36
|
+
expect(createFrappeBackend()).toBeNull();
|
|
37
|
+
delete process.env.VITE_FRAPPE_ENABLED;
|
|
38
|
+
expect(createFrappeBackend()).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns a backend module when enabled", () => {
|
|
42
|
+
const backend = enabledBackend();
|
|
43
|
+
expect(backend.type).toBe("backend");
|
|
44
|
+
expect(typeof backend.read).toBe("function");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("backend read", () => {
|
|
49
|
+
it("fetches the Frappe translation dict and passes it to the callback", async () => {
|
|
50
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
51
|
+
ok: true,
|
|
52
|
+
json: () => Promise.resolve({ message: { Hello: "నమస్కారం" } }),
|
|
53
|
+
});
|
|
54
|
+
const backend = enabledBackend();
|
|
55
|
+
initBackend(backend, { baseUrl: "https://erp.example.com", fetch: fetchMock as never });
|
|
56
|
+
|
|
57
|
+
const result = await new Promise((resolve, reject) => {
|
|
58
|
+
backend.read("te", "common", (err, data) => (err ? reject(err) : resolve(data)));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(result).toEqual({ Hello: "నమస్కారం" });
|
|
62
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
63
|
+
"https://erp.example.com/api/method/frappe.translate.get_dict?language=te",
|
|
64
|
+
{ credentials: "include" },
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("URI-encodes the language in the request", async () => {
|
|
69
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
70
|
+
ok: true,
|
|
71
|
+
json: () => Promise.resolve({ message: {} }),
|
|
72
|
+
});
|
|
73
|
+
const backend = enabledBackend();
|
|
74
|
+
initBackend(backend, { fetch: fetchMock as never });
|
|
75
|
+
|
|
76
|
+
await new Promise((resolve) => {
|
|
77
|
+
backend.read("pt BR", "common", () => resolve(null));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(String(fetchMock.mock.calls[0][0])).toContain("language=pt%20BR");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("falls back to an empty dict when the response has no message", async () => {
|
|
84
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
85
|
+
ok: true,
|
|
86
|
+
json: () => Promise.resolve({}),
|
|
87
|
+
});
|
|
88
|
+
const backend = enabledBackend();
|
|
89
|
+
initBackend(backend, { fetch: fetchMock as never });
|
|
90
|
+
|
|
91
|
+
const result = await new Promise((resolve, reject) => {
|
|
92
|
+
backend.read("en", "common", (err, data) => (err ? reject(err) : resolve(data)));
|
|
93
|
+
});
|
|
94
|
+
expect(result).toEqual({});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("surfaces HTTP failures to the callback as errors", async () => {
|
|
98
|
+
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 503 });
|
|
99
|
+
const backend = enabledBackend();
|
|
100
|
+
initBackend(backend, { fetch: fetchMock as never });
|
|
101
|
+
|
|
102
|
+
const error = await new Promise<Error>((resolve) => {
|
|
103
|
+
backend.read("en", "common", (err) => resolve(err as Error));
|
|
104
|
+
});
|
|
105
|
+
expect(error).toBeInstanceOf(Error);
|
|
106
|
+
expect(error.message).toContain("503");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locale persistence contracts — the `preferred_language` cookie (Frappe's
|
|
3
|
+
* convention) round-trip on the client, and the SSR-side cookie-header
|
|
4
|
+
* parser used to boot i18n with the user's language before hydration.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
8
|
+
import { getPersistedLanguage, parseCookieValue, persistLanguage } from "./localePersistence";
|
|
9
|
+
|
|
10
|
+
function clearPreferredLanguageCookie() {
|
|
11
|
+
document.cookie = "preferred_language=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("persistLanguage / getPersistedLanguage", () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
clearPreferredLanguageCookie();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("returns null when no language has been persisted", () => {
|
|
20
|
+
expect(getPersistedLanguage()).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("round-trips the language through the preferred_language cookie", () => {
|
|
24
|
+
persistLanguage("te");
|
|
25
|
+
expect(document.cookie).toContain("preferred_language=te");
|
|
26
|
+
expect(getPersistedLanguage()).toBe("te");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("overwrites a previously persisted language", () => {
|
|
30
|
+
persistLanguage("en");
|
|
31
|
+
persistLanguage("hi");
|
|
32
|
+
expect(getPersistedLanguage()).toBe("hi");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("parseCookieValue (SSR cookie header)", () => {
|
|
37
|
+
it("extracts the named cookie from a multi-cookie header", () => {
|
|
38
|
+
const header = "sid=abc123; preferred_language=te; system_user=yes";
|
|
39
|
+
expect(parseCookieValue(header, "preferred_language")).toBe("te");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("handles leading whitespace after semicolons", () => {
|
|
43
|
+
expect(parseCookieValue("a=1; preferred_language=en", "preferred_language")).toBe("en");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns null when the cookie is absent", () => {
|
|
47
|
+
expect(parseCookieValue("sid=abc123", "preferred_language")).toBeNull();
|
|
48
|
+
expect(parseCookieValue("", "preferred_language")).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("does not match cookies whose name only ends with the requested name", () => {
|
|
52
|
+
expect(parseCookieValue("not_preferred_language=fr", "preferred_language")).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("decodes URI-encoded values", () => {
|
|
56
|
+
expect(parseCookieValue("preferred_language=pt%2DBR", "preferred_language")).toBe("pt-BR");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("returns null for an empty value", () => {
|
|
60
|
+
expect(parseCookieValue("preferred_language=", "preferred_language")).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { BackendModule, InitOptions } from "i18next";
|
|
2
|
+
/**
|
|
3
|
+
* Options for creating i18n initialization configuration.
|
|
4
|
+
* All language/namespace settings must be provided by the consumer,
|
|
5
|
+
* keeping this utility fully generic and framework-level.
|
|
6
|
+
*/
|
|
7
|
+
export interface I18nConfigOptions {
|
|
8
|
+
/** Supported language codes (e.g. ["en", "te"]) */
|
|
9
|
+
languages: readonly string[];
|
|
10
|
+
/** Translation namespaces (e.g. ["common"]) */
|
|
11
|
+
namespaces: readonly string[];
|
|
12
|
+
/** Default/fallback language code */
|
|
13
|
+
defaultLanguage: string;
|
|
14
|
+
/** Default namespace used when none is specified in t() calls */
|
|
15
|
+
defaultNamespace: string;
|
|
16
|
+
/** Translation resources - can be bundled or loaded dynamically */
|
|
17
|
+
resources?: InitOptions["resources"];
|
|
18
|
+
/** Frappe backend plugin instance (from createFrappeBackend) */
|
|
19
|
+
frappeBackend?: BackendModule | null;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Creates i18n initialization options with consistent settings.
|
|
23
|
+
* Use this to ensure all platforms have the same i18n behavior.
|
|
24
|
+
*
|
|
25
|
+
* When `VITE_FRAPPE_ENABLED` is true and a `frappeBackend` is provided,
|
|
26
|
+
* i18next is configured with `partialBundledLanguages` so the Frappe
|
|
27
|
+
* backend supplements (and overrides) bundled local resources.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* import { createI18nConfig, createFrappeBackend } from "@multiplatform.one/i18n";
|
|
31
|
+
*
|
|
32
|
+
* i18n.use(initReactI18next).init(createI18nConfig({
|
|
33
|
+
* languages, namespaces, defaultLanguage, defaultNamespace, resources,
|
|
34
|
+
* frappeBackend: createFrappeBackend(),
|
|
35
|
+
* }));
|
|
36
|
+
*/
|
|
37
|
+
export declare function createI18nConfig(options: I18nConfigOptions): InitOptions;
|
|
38
|
+
//# sourceMappingURL=createI18nConfig.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"createI18nConfig.d.ts","sourceRoot":"","sources":["../src/createI18nConfig.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAE1D;;;;GAIG;AACH,MAAM,WAAW,iBAAiB;IAChC,mDAAmD;IACnD,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;IAC7B,+CAA+C;IAC/C,UAAU,EAAE,SAAS,MAAM,EAAE,CAAC;IAC9B,qCAAqC;IACrC,eAAe,EAAE,MAAM,CAAC;IACxB,iEAAiE;IACjE,gBAAgB,EAAE,MAAM,CAAC;IACzB,mEAAmE;IACnE,SAAS,CAAC,EAAE,WAAW,CAAC,WAAW,CAAC,CAAC;IACrC,gEAAgE;IAChE,aAAa,CAAC,EAAE,aAAa,GAAG,IAAI,CAAC;CACtC;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,iBAAiB,GAAG,WAAW,CAsCxE"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface DetectLanguageOptions {
|
|
2
|
+
supportedLanguages: string[];
|
|
3
|
+
defaultLanguage: string;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Detect the user's preferred language from a Request.
|
|
7
|
+
* Priority: preferred_language cookie → Accept-Language header → default.
|
|
8
|
+
*/
|
|
9
|
+
export declare function detectLanguage(request: Request, options: DetectLanguageOptions): string;
|
|
10
|
+
//# sourceMappingURL=detectLanguage.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"detectLanguage.d.ts","sourceRoot":"","sources":["../src/detectLanguage.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,qBAAqB;IACpC,kBAAkB,EAAE,MAAM,EAAE,CAAC;IAC7B,eAAe,EAAE,MAAM,CAAC;CACzB;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,qBAAqB,GAAG,MAAM,CAqBvF"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
type DateInput = Date | number | string;
|
|
2
|
+
export declare function createDateFormatter(language: string): {
|
|
3
|
+
format: (date: DateInput, options?: Intl.DateTimeFormatOptions) => string;
|
|
4
|
+
relative: (date: DateInput) => string;
|
|
5
|
+
};
|
|
6
|
+
export declare function createNumberFormatter(language: string): {
|
|
7
|
+
format: (value: number, options?: Intl.NumberFormatOptions) => string;
|
|
8
|
+
};
|
|
9
|
+
export declare function createCurrencyFormatter(language: string): {
|
|
10
|
+
format: (value: number, currency: string, options?: Omit<Intl.NumberFormatOptions, "style" | "currency">) => string;
|
|
11
|
+
};
|
|
12
|
+
export declare function useFormatDate(): {
|
|
13
|
+
format: (date: DateInput, options?: Intl.DateTimeFormatOptions) => string;
|
|
14
|
+
relative: (date: DateInput) => string;
|
|
15
|
+
};
|
|
16
|
+
export declare function useFormatNumber(): {
|
|
17
|
+
format: (value: number, options?: Intl.NumberFormatOptions) => string;
|
|
18
|
+
};
|
|
19
|
+
export declare function useFormatCurrency(): {
|
|
20
|
+
format: (value: number, currency: string, options?: Omit<Intl.NumberFormatOptions, "style" | "currency">) => string;
|
|
21
|
+
};
|
|
22
|
+
export {};
|
|
23
|
+
//# sourceMappingURL=formatters.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"formatters.d.ts","sourceRoot":"","sources":["../src/formatters.ts"],"names":[],"mappings":"AAGA,KAAK,SAAS,GAAG,IAAI,GAAG,MAAM,GAAG,MAAM,CAAC;AAcxC,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM;mBAEjC,SAAS,YAAY,IAAI,CAAC,qBAAqB;qBAE7C,SAAS;EAE7B;AAED,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,MAAM;oBAElC,MAAM,YAAY,IAAI,CAAC,mBAAmB;EAG7D;AAED,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,MAAM;oBAG3C,MAAM,YACH,MAAM,YACN,IAAI,CAAC,IAAI,CAAC,mBAAmB,EAAE,OAAO,GAAG,UAAU,CAAC;EAGnE;AAED,wBAAgB,aAAa;mBAvBV,SAAS,YAAY,IAAI,CAAC,qBAAqB;qBAE7C,SAAS;EAwB7B;AAED,wBAAgB,eAAe;oBApBX,MAAM,YAAY,IAAI,CAAC,mBAAmB;EAuB7D;AAED,wBAAgB,iBAAiB;oBAjBpB,MAAM,YACH,MAAM,YACN,IAAI,CAAC,IAAI,CAAC,mBAAmB,EAAE,OAAO,GAAG,UAAU,CAAC;EAkBnE"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { BackendModule } from "i18next";
|
|
2
|
+
export interface FrappeBackendOptions {
|
|
3
|
+
/** Base URL for Frappe API (default: '') */
|
|
4
|
+
baseUrl?: string;
|
|
5
|
+
/** Custom fetch function (for SSR or testing) */
|
|
6
|
+
fetch?: typeof globalThis.fetch;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* i18next backend plugin that fetches translations from Frappe's API.
|
|
10
|
+
* Guarded by VITE_FRAPPE_ENABLED so it tree-shakes out of non-Frappe builds.
|
|
11
|
+
*/
|
|
12
|
+
declare function createFrappeBackend(): BackendModule<FrappeBackendOptions> | null;
|
|
13
|
+
export { createFrappeBackend };
|
|
14
|
+
//# sourceMappingURL=frappeBackend.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"frappeBackend.d.ts","sourceRoot":"","sources":["../src/frappeBackend.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAuC,MAAM,SAAS,CAAC;AAElF,MAAM,WAAW,oBAAoB;IACnC,4CAA4C;IAC5C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,iDAAiD;IACjD,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;CACjC;AAoBD;;;GAGG;AACH,iBAAS,mBAAmB,IAAI,aAAa,CAAC,oBAAoB,CAAC,GAAG,IAAI,CAgBzE;AAED,OAAO,EAAE,mBAAmB,EAAE,CAAC"}
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { createI18nConfig } from "./createI18nConfig";
|
|
2
|
+
export type { I18nConfigOptions } from "./createI18nConfig";
|
|
3
|
+
export { createFrappeBackend } from "./frappeBackend";
|
|
4
|
+
export type { FrappeBackendOptions } from "./frappeBackend";
|
|
5
|
+
export { useLanguage } from "./useLanguage";
|
|
6
|
+
export { getPersistedLanguage, persistLanguage, parseCookieValue } from "./localePersistence";
|
|
7
|
+
export { detectLanguage } from "./detectLanguage";
|
|
8
|
+
export { useFormatDate, useFormatNumber, useFormatCurrency, createDateFormatter, createNumberFormatter, createCurrencyFormatter, } from "./formatters";
|
|
9
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AACtD,YAAY,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAC5D,OAAO,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AACtD,YAAY,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAC5D,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAC5C,OAAO,EAAE,oBAAoB,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAC9F,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAClD,OAAO,EACL,aAAa,EACb,eAAe,EACf,iBAAiB,EACjB,mBAAmB,EACnB,qBAAqB,EACrB,uBAAuB,GACxB,MAAM,cAAc,CAAC"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Read the `preferred_language` cookie value (client-side). */
|
|
2
|
+
export declare function getPersistedLanguage(): string | null;
|
|
3
|
+
/** Set the `preferred_language` cookie (client-side, 1-year expiry). */
|
|
4
|
+
export declare function persistLanguage(lang: string): void;
|
|
5
|
+
/** Parse a single cookie value from a cookie header string. */
|
|
6
|
+
export declare function parseCookieValue(cookieHeader: string, name: string): string | null;
|
|
7
|
+
//# sourceMappingURL=localePersistence.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"localePersistence.d.ts","sourceRoot":"","sources":["../src/localePersistence.ts"],"names":[],"mappings":"AAKA,gEAAgE;AAChE,wBAAgB,oBAAoB,IAAI,MAAM,GAAG,IAAI,CAEpD;AAED,wEAAwE;AACxE,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAElD;AAED,+DAA+D;AAC/D,wBAAgB,gBAAgB,CAAC,YAAY,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAKlF"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useLanguage.d.ts","sourceRoot":"","sources":["../src/useLanguage.ts"],"names":[],"mappings":"AAIA,wBAAgB,WAAW,IAAI,CAAC,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC,CAsBhE"}
|
package/CHANGELOG.md
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
# @multiplatform.one/i18n
|
|
2
|
-
|
|
3
|
-
## 6.1.0
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
- @multiplatform.one/platform@6.1.0
|
|
8
|
-
|
|
9
|
-
## 6.0.4
|
|
10
|
-
|
|
11
|
-
### Patch Changes
|
|
12
|
-
|
|
13
|
-
- @multiplatform.one/platform@6.0.4
|
|
14
|
-
|
|
15
|
-
## 6.0.3
|
|
16
|
-
|
|
17
|
-
### Patch Changes
|
|
18
|
-
|
|
19
|
-
- @multiplatform.one/platform@6.0.3
|
|
20
|
-
|
|
21
|
-
## 6.0.2
|
|
22
|
-
|
|
23
|
-
### Patch Changes
|
|
24
|
-
|
|
25
|
-
- @multiplatform.one/platform@6.0.2
|
|
26
|
-
|
|
27
|
-
## 6.0.1
|
|
28
|
-
|
|
29
|
-
### Patch Changes
|
|
30
|
-
|
|
31
|
-
- @multiplatform.one/platform@6.0.1
|
|
32
|
-
|
|
33
|
-
## 6.0.0
|
|
34
|
-
|
|
35
|
-
### Major Changes
|
|
36
|
-
|
|
37
|
-
- Unified 6.0.0 release: realign all public `@multiplatform.one/*` packages onto a single, matching version line (changesets `fixed` group). Highlights:
|
|
38
|
-
- **web3 split** into standalone `@multiplatform.one/connectkit` and `@multiplatform.one/gpg` packages; `web3` now depends on them instead of bundling ConnectKit.
|
|
39
|
-
- **Sign-In With GPG** screen added to the ConnectKit modal.
|
|
40
|
-
- **Native workspace web-resolution fix** + platform-split wrappers (rich-text / `TextEditor`, forms `Barcode`) so web-only libraries stay out of the native Hermes bundle.
|
|
41
|
-
|
|
42
|
-
From this release on, these packages are versioned together.
|
|
43
|
-
|
|
44
|
-
### Patch Changes
|
|
45
|
-
|
|
46
|
-
- Updated dependencies
|
|
47
|
-
- @multiplatform.one/platform@6.0.0
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { createI18nConfig } from "../src/createI18nConfig";
|
|
3
|
-
import type { BackendModule } from "i18next";
|
|
4
|
-
|
|
5
|
-
const baseOptions = {
|
|
6
|
-
languages: ["en", "es"] as const,
|
|
7
|
-
namespaces: ["common"] as const,
|
|
8
|
-
defaultLanguage: "en",
|
|
9
|
-
defaultNamespace: "common",
|
|
10
|
-
resources: { en: { common: { hello: "Hello" } } },
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
describe("createI18nConfig", () => {
|
|
14
|
-
// Test 4: When VITE_FRAPPE_ENABLED is false (default in test env),
|
|
15
|
-
// the backend is excluded even if frappeBackend is provided.
|
|
16
|
-
it("excludes backend when VITE_FRAPPE_ENABLED is false", () => {
|
|
17
|
-
const mockBackend: BackendModule = {
|
|
18
|
-
type: "backend",
|
|
19
|
-
init: () => {},
|
|
20
|
-
read: () => {},
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
const config = createI18nConfig({ ...baseOptions, frappeBackend: mockBackend });
|
|
24
|
-
|
|
25
|
-
expect(config.backend).toBeUndefined();
|
|
26
|
-
expect(config.partialBundledLanguages).toBeUndefined();
|
|
27
|
-
// Core config is still correct
|
|
28
|
-
expect(config.fallbackLng).toBe("en");
|
|
29
|
-
expect(config.supportedLngs).toEqual(["en", "es"]);
|
|
30
|
-
expect(config.resources).toEqual(baseOptions.resources);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it("works without frappeBackend option", () => {
|
|
34
|
-
const config = createI18nConfig(baseOptions);
|
|
35
|
-
|
|
36
|
-
expect(config.backend).toBeUndefined();
|
|
37
|
-
expect(config.partialBundledLanguages).toBeUndefined();
|
|
38
|
-
expect(config.defaultNS).toBe("common");
|
|
39
|
-
expect(config.ns).toEqual(["common"]);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it("preserves all standard i18next settings", () => {
|
|
43
|
-
const config = createI18nConfig(baseOptions);
|
|
44
|
-
|
|
45
|
-
expect(config.compatibilityJSON).toBe("v3");
|
|
46
|
-
expect(config.interpolation?.escapeValue).toBe(false);
|
|
47
|
-
expect(config.returnNull).toBe(false);
|
|
48
|
-
expect(config.returnEmptyString).toBe(false);
|
|
49
|
-
expect(config.debug).toBe(false);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("handles null frappeBackend gracefully", () => {
|
|
53
|
-
const config = createI18nConfig({ ...baseOptions, frappeBackend: null });
|
|
54
|
-
|
|
55
|
-
expect(config.backend).toBeUndefined();
|
|
56
|
-
expect(config.partialBundledLanguages).toBeUndefined();
|
|
57
|
-
});
|
|
58
|
-
});
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { detectLanguage } from "../src/detectLanguage";
|
|
3
|
-
|
|
4
|
-
const supported = ["en", "es", "te", "fr", "de"];
|
|
5
|
-
const opts = { supportedLanguages: supported, defaultLanguage: "en" };
|
|
6
|
-
|
|
7
|
-
function makeRequest(headers: Record<string, string> = {}): Request {
|
|
8
|
-
return new Request("http://localhost:8000/", { headers });
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
describe("detectLanguage", () => {
|
|
12
|
-
// Test 2: Prefers cookie over Accept-Language
|
|
13
|
-
it("prefers preferred_language cookie over Accept-Language header", () => {
|
|
14
|
-
const req = makeRequest({
|
|
15
|
-
cookie: "preferred_language=te",
|
|
16
|
-
"accept-language": "es;q=1.0,fr;q=0.9",
|
|
17
|
-
});
|
|
18
|
-
expect(detectLanguage(req, opts)).toBe("te");
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it("falls back to Accept-Language when no cookie is set", () => {
|
|
22
|
-
const req = makeRequest({ "accept-language": "es;q=0.9,fr;q=0.8" });
|
|
23
|
-
expect(detectLanguage(req, opts)).toBe("es");
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it("falls back to default when no cookie or header matches", () => {
|
|
27
|
-
const req = makeRequest({});
|
|
28
|
-
expect(detectLanguage(req, opts)).toBe("en");
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("ignores cookie value not in supported languages", () => {
|
|
32
|
-
const req = makeRequest({
|
|
33
|
-
cookie: "preferred_language=ja",
|
|
34
|
-
"accept-language": "fr",
|
|
35
|
-
});
|
|
36
|
-
expect(detectLanguage(req, opts)).toBe("fr");
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
// Test 3: Parses Accept-Language quality values correctly
|
|
40
|
-
describe("Accept-Language quality parsing", () => {
|
|
41
|
-
it("picks highest quality language", () => {
|
|
42
|
-
const req = makeRequest({ "accept-language": "fr;q=0.7, de;q=0.9, es;q=0.8" });
|
|
43
|
-
expect(detectLanguage(req, opts)).toBe("de");
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("treats missing q as q=1 (highest priority)", () => {
|
|
47
|
-
const req = makeRequest({ "accept-language": "te, es;q=0.9" });
|
|
48
|
-
expect(detectLanguage(req, opts)).toBe("te");
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("matches base code when full code is not supported", () => {
|
|
52
|
-
const req = makeRequest({ "accept-language": "es-MX;q=1.0, fr-FR;q=0.8" });
|
|
53
|
-
expect(detectLanguage(req, opts)).toBe("es");
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it("skips wildcard (*) entries", () => {
|
|
57
|
-
const req = makeRequest({ "accept-language": "*, fr;q=0.5" });
|
|
58
|
-
expect(detectLanguage(req, opts)).toBe("fr");
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("returns default when all Accept-Language entries are unsupported", () => {
|
|
62
|
-
const req = makeRequest({ "accept-language": "ja;q=1.0, zh;q=0.9" });
|
|
63
|
-
expect(detectLanguage(req, opts)).toBe("en");
|
|
64
|
-
});
|
|
65
|
-
});
|
|
66
|
-
});
|
package/tests/formatters.test.ts
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
createDateFormatter,
|
|
4
|
-
createNumberFormatter,
|
|
5
|
-
createCurrencyFormatter,
|
|
6
|
-
} from "../src/formatters";
|
|
7
|
-
|
|
8
|
-
describe("useFormatDate", () => {
|
|
9
|
-
afterEach(() => vi.useRealTimers());
|
|
10
|
-
|
|
11
|
-
it("formats a date differently for en vs te locale", () => {
|
|
12
|
-
const date = new Date("2024-03-15T12:00:00Z");
|
|
13
|
-
const en = createDateFormatter("en");
|
|
14
|
-
const te = createDateFormatter("te");
|
|
15
|
-
const enResult = en.format(date);
|
|
16
|
-
const teResult = te.format(date);
|
|
17
|
-
expect(enResult).toBeTruthy();
|
|
18
|
-
expect(teResult).toBeTruthy();
|
|
19
|
-
expect(enResult).not.toBe(teResult);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it('relative time returns correct unit ("2 hours ago", "yesterday")', () => {
|
|
23
|
-
vi.useFakeTimers();
|
|
24
|
-
vi.setSystemTime(new Date("2024-06-15T12:00:00Z"));
|
|
25
|
-
const fmt = createDateFormatter("en");
|
|
26
|
-
expect(fmt.relative(new Date("2024-06-15T10:00:00Z"))).toBe("2 hours ago");
|
|
27
|
-
expect(fmt.relative(new Date("2024-06-14T12:00:00Z"))).toBe("yesterday");
|
|
28
|
-
});
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
describe("useFormatNumber", () => {
|
|
32
|
-
it("uses locale-appropriate decimal separator", () => {
|
|
33
|
-
const en = createNumberFormatter("en");
|
|
34
|
-
const de = createNumberFormatter("de");
|
|
35
|
-
expect(en.format(1234.56)).toContain(".");
|
|
36
|
-
expect(de.format(1234.56)).toContain(",");
|
|
37
|
-
});
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
describe("useFormatCurrency", () => {
|
|
41
|
-
it("formats with correct currency symbol and position", () => {
|
|
42
|
-
const en = createCurrencyFormatter("en-US");
|
|
43
|
-
const de = createCurrencyFormatter("de-DE");
|
|
44
|
-
const usd = en.format(42.5, "USD");
|
|
45
|
-
const eur = de.format(42.5, "EUR");
|
|
46
|
-
expect(usd).toMatch(/^\$42\.50$/);
|
|
47
|
-
expect(eur).toContain("€");
|
|
48
|
-
expect(eur).toContain("42,50");
|
|
49
|
-
});
|
|
50
|
-
});
|