@multiplatform.one/i18n 6.1.0 → 6.3.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.
@@ -1,241 +0,0 @@
1
- /**
2
- * 10.3 Cross-group integration test: Language persistence roundtrip
3
- *
4
- * Verifies end-to-end language persistence:
5
- * - Change language → cookie set → simulate reload → same language restored
6
- * - Change language → Frappe API receives new language context
7
- * - SSR detection → correct language from Accept-Language
8
- */
9
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
10
- import i18next from "i18next";
11
- import { persistLanguage, getPersistedLanguage, parseCookieValue } from "../src/localePersistence";
12
- import { detectLanguage } from "../src/detectLanguage";
13
- import enCommon from "../../../packages/i18n/en/common.json";
14
- import teCommon from "../../../packages/i18n/te/common.json";
15
-
16
- describe("10.3 Language persistence roundtrip", () => {
17
- let cookieStore: string;
18
-
19
- beforeEach(() => {
20
- cookieStore = "";
21
- Object.defineProperty(globalThis, "document", {
22
- value: {
23
- get cookie() {
24
- return cookieStore;
25
- },
26
- set cookie(val: string) {
27
- const name = val.split("=")[0];
28
- const parts = cookieStore.split("; ").filter((c) => c && !c.startsWith(`${name}=`));
29
- parts.push(val.split(";")[0]);
30
- cookieStore = parts.filter(Boolean).join("; ");
31
- },
32
- },
33
- writable: true,
34
- configurable: true,
35
- });
36
- });
37
-
38
- afterEach(() => {
39
- Object.defineProperty(globalThis, "document", {
40
- value: undefined,
41
- writable: true,
42
- configurable: true,
43
- });
44
- });
45
-
46
- it("change language → cookie set → simulate reload → same language restored", async () => {
47
- // Step 1: Initialize i18next with English
48
- const instance1 = i18next.createInstance();
49
- await instance1.init({
50
- lng: "en",
51
- defaultNS: "common",
52
- ns: ["common"],
53
- supportedLngs: ["en", "te"],
54
- fallbackLng: "en",
55
- resources: {
56
- en: { common: enCommon },
57
- te: { common: teCommon },
58
- },
59
- interpolation: { escapeValue: false },
60
- returnNull: false,
61
- });
62
-
63
- expect(instance1.language).toBe("en");
64
- expect(instance1.t("Save")).toBe("Save");
65
-
66
- // Step 2: Change language to Telugu and persist (simulates useLanguage.changeLanguage)
67
- persistLanguage("te");
68
- await instance1.changeLanguage("te");
69
-
70
- expect(instance1.language).toBe("te");
71
- expect(instance1.t("Save")).toBe("సేవ్ చేయండి");
72
-
73
- // Verify cookie is set
74
- expect(getPersistedLanguage()).toBe("te");
75
-
76
- // Step 3: Simulate "reload" — create a new i18next instance and read persisted language
77
- const persistedLang = getPersistedLanguage();
78
- expect(persistedLang).toBe("te");
79
-
80
- const instance2 = i18next.createInstance();
81
- await instance2.init({
82
- lng: persistedLang!, // Use the persisted language
83
- defaultNS: "common",
84
- ns: ["common"],
85
- supportedLngs: ["en", "te"],
86
- fallbackLng: "en",
87
- resources: {
88
- en: { common: enCommon },
89
- te: { common: teCommon },
90
- },
91
- interpolation: { escapeValue: false },
92
- returnNull: false,
93
- });
94
-
95
- // Language is restored from cookie
96
- expect(instance2.language).toBe("te");
97
- expect(instance2.t("Save")).toBe("సేవ్ చేయండి");
98
- expect(instance2.t("Filters")).toBe("ఫిల్టర్‌లు");
99
- expect(instance2.t("Delete")).toBe("తొలగించు");
100
- });
101
-
102
- it("language change updates cookie that Frappe API can read from request", async () => {
103
- // Simulate language change
104
- persistLanguage("es");
105
- expect(getPersistedLanguage()).toBe("es");
106
-
107
- // The preferred_language cookie is included in HTTP requests (via credentials: "include")
108
- // Frappe reads this cookie to determine the user's language preference.
109
- // Verify the cookie value is parseable from a simulated request cookie header.
110
- const simulatedCookieHeader = `session_id=abc123; ${cookieStore}`;
111
- const parsedLang = parseCookieValue(simulatedCookieHeader, "preferred_language");
112
- expect(parsedLang).toBe("es");
113
-
114
- // Change language again
115
- persistLanguage("te");
116
- const updatedCookieHeader = `session_id=abc123; ${cookieStore}`;
117
- const updatedLang = parseCookieValue(updatedCookieHeader, "preferred_language");
118
- expect(updatedLang).toBe("te");
119
- });
120
-
121
- it("SSR detects language from cookie in Request headers", () => {
122
- const supported = ["en", "es", "te"];
123
- const opts = { supportedLanguages: supported, defaultLanguage: "en" };
124
-
125
- // User previously set Telugu
126
- const req = new Request("http://localhost:8000/", {
127
- headers: {
128
- cookie: "preferred_language=te; session_id=xyz",
129
- "accept-language": "en-US,en;q=0.9",
130
- },
131
- });
132
-
133
- const detected = detectLanguage(req, opts);
134
- // Cookie takes precedence over Accept-Language
135
- expect(detected).toBe("te");
136
- });
137
-
138
- it("SSR falls back to Accept-Language when no cookie is set", () => {
139
- const supported = ["en", "es", "te"];
140
- const opts = { supportedLanguages: supported, defaultLanguage: "en" };
141
-
142
- const req = new Request("http://localhost:8000/", {
143
- headers: {
144
- "accept-language": "es-MX;q=1.0, en;q=0.5",
145
- },
146
- });
147
-
148
- const detected = detectLanguage(req, opts);
149
- expect(detected).toBe("es");
150
- });
151
-
152
- it("full roundtrip: persist → SSR detect → initialize with correct language", async () => {
153
- // Step 1: User changes language on the client
154
- persistLanguage("te");
155
-
156
- // Step 2: On next request, SSR reads cookie from the request
157
- const simulatedRequest = new Request("http://localhost:8000/", {
158
- headers: {
159
- cookie: cookieStore,
160
- "accept-language": "en-US,en;q=0.9",
161
- },
162
- });
163
-
164
- const detected = detectLanguage(simulatedRequest, {
165
- supportedLanguages: ["en", "te", "es"],
166
- defaultLanguage: "en",
167
- });
168
-
169
- expect(detected).toBe("te");
170
-
171
- // Step 3: SSR initializes i18next with detected language
172
- const instance = i18next.createInstance();
173
- await instance.init({
174
- lng: detected,
175
- defaultNS: "common",
176
- ns: ["common"],
177
- supportedLngs: ["en", "te"],
178
- fallbackLng: "en",
179
- resources: {
180
- en: { common: enCommon },
181
- te: { common: teCommon },
182
- },
183
- interpolation: { escapeValue: false },
184
- returnNull: false,
185
- });
186
-
187
- // SSR renders in the user's chosen language
188
- expect(instance.language).toBe("te");
189
- expect(instance.t("Save")).toBe("సేవ్ చేయండి");
190
- expect(instance.t("Filters")).toBe("ఫిల్టర్‌లు");
191
- expect(instance.t("common.appName")).toBe("multiplatform.one");
192
- });
193
-
194
- it("language change triggers Frappe API to receive new language context via cookie", async () => {
195
- // Simulate the sequence:
196
- // 1. User is in English
197
- // 2. User switches to Telugu
198
- // 3. Next HTTP request includes preferred_language=te cookie
199
- persistLanguage("en");
200
- expect(getPersistedLanguage()).toBe("en");
201
-
202
- // Simulate: user clicks "Telugu" in language switcher
203
- persistLanguage("te");
204
- expect(getPersistedLanguage()).toBe("te");
205
-
206
- // Simulate: next API call includes cookies
207
- // The HttpClient sends requests with credentials: "include",
208
- // so the preferred_language cookie is automatically included.
209
- const mockFetch = vi.fn().mockResolvedValue({
210
- ok: true,
211
- json: () =>
212
- Promise.resolve({
213
- data: [
214
- { name: "Pikachu", species: "పికాచు" }, // Telugu metadata from Frappe
215
- ],
216
- }),
217
- });
218
-
219
- // Simulate an API call that would be made after language change
220
- await mockFetch("/api/resource/Pokemon", {
221
- credentials: "include",
222
- headers: {
223
- "Accept-Language": "te",
224
- Cookie: cookieStore,
225
- },
226
- });
227
-
228
- // Verify the API call was made with language context
229
- expect(mockFetch).toHaveBeenCalledWith("/api/resource/Pokemon", {
230
- credentials: "include",
231
- headers: expect.objectContaining({
232
- "Accept-Language": "te",
233
- }),
234
- });
235
-
236
- // The cookie header contains preferred_language=te
237
- const callArgs = mockFetch.mock.calls[0][1];
238
- const cookieVal = parseCookieValue(callArgs.headers.Cookie, "preferred_language");
239
- expect(cookieVal).toBe("te");
240
- });
241
- });
@@ -1,126 +0,0 @@
1
- /**
2
- * 10.5 Bundle size / tree-shaking verification
3
- *
4
- * Verifies that the Frappe backend plugin code is structured to tree-shake
5
- * out of non-Frappe builds:
6
- * - createFrappeBackend is guarded by process.env.VITE_FRAPPE_ENABLED
7
- * - When VITE_FRAPPE_ENABLED=false, createFrappeBackend returns null
8
- * - createI18nConfig does not attach backend when VITE_FRAPPE_ENABLED=false
9
- * - The Frappe API URL telltale string is inside the guarded code path
10
- * - The module source structure enables dead-code elimination
11
- */
12
- import { describe, it, expect } from "vitest";
13
- import { readFileSync } from "node:fs";
14
- import { resolve } from "node:path";
15
- import { createFrappeBackend } from "../src/frappeBackend";
16
- import { createI18nConfig } from "../src/createI18nConfig";
17
- import type { BackendModule } from "i18next";
18
-
19
- describe("10.5 Tree-shaking & bundle verification", () => {
20
- describe("runtime guards", () => {
21
- it("createFrappeBackend returns null when VITE_FRAPPE_ENABLED is false", () => {
22
- // In the test environment, VITE_FRAPPE_ENABLED defaults to "false"
23
- const backend = createFrappeBackend();
24
- expect(backend).toBeNull();
25
- });
26
-
27
- it("createI18nConfig does not attach backend when disabled", () => {
28
- const config = createI18nConfig({
29
- languages: ["en"],
30
- namespaces: ["common"],
31
- defaultLanguage: "en",
32
- defaultNamespace: "common",
33
- resources: { en: { common: {} } },
34
- frappeBackend: null,
35
- });
36
-
37
- expect(config.backend).toBeUndefined();
38
- expect(config.partialBundledLanguages).toBeUndefined();
39
- });
40
-
41
- it("createI18nConfig omits backend even when a non-null backend is passed but flag is false", () => {
42
- const mockBackend: BackendModule = {
43
- type: "backend",
44
- init() {},
45
- read() {},
46
- };
47
-
48
- const config = createI18nConfig({
49
- languages: ["en"],
50
- namespaces: ["common"],
51
- defaultLanguage: "en",
52
- defaultNamespace: "common",
53
- resources: { en: { common: {} } },
54
- frappeBackend: mockBackend,
55
- });
56
-
57
- // Even though a backend was provided, it's not attached because VITE_FRAPPE_ENABLED=false
58
- expect(config.backend).toBeUndefined();
59
- });
60
- });
61
-
62
- describe("source code structure enables tree-shaking", () => {
63
- it("frappeBackend.ts guards the entire backend creation with VITE_FRAPPE_ENABLED check", () => {
64
- const source = readFileSync(resolve(__dirname, "../src/frappeBackend.ts"), "utf-8");
65
-
66
- // The guard must be present — this is what allows bundlers to eliminate the code
67
- expect(source).toContain("process.env.VITE_FRAPPE_ENABLED");
68
- expect(source).toContain("return null");
69
-
70
- // The Frappe API URL (telltale string) is INSIDE the guarded code path
71
- expect(source).toContain("frappe.translate.get_dict");
72
-
73
- // The guard pattern: returns null early when flag is not "true"
74
- // This means when the bundler replaces VITE_FRAPPE_ENABLED with "false",
75
- // the entire backend object literal becomes dead code.
76
- expect(source).toMatch(/VITE_FRAPPE_ENABLED.*!==.*"true".*return null/s);
77
- });
78
-
79
- it("createI18nConfig.ts guards backend attachment with VITE_FRAPPE_ENABLED check", () => {
80
- const source = readFileSync(resolve(__dirname, "../src/createI18nConfig.ts"), "utf-8");
81
-
82
- // The config conditionally includes backend only when enabled
83
- expect(source).toContain("process.env.VITE_FRAPPE_ENABLED");
84
- expect(source).toContain("partialBundledLanguages");
85
-
86
- // The guard pattern: if block only executes when VITE_FRAPPE_ENABLED === "true"
87
- expect(source).toMatch(/VITE_FRAPPE_ENABLED.*===.*"true".*frappeBackend/s);
88
- });
89
-
90
- it("frappeBackend.ts does not have side effects at module level", () => {
91
- const source = readFileSync(resolve(__dirname, "../src/frappeBackend.ts"), "utf-8");
92
-
93
- // The fetch function is defined but not called at module level.
94
- // Only createFrappeBackend() is exported, and it returns null when disabled.
95
- // This ensures Vite/Rollup can tree-shake the entire module.
96
- expect(source).not.toMatch(/^fetch\(/m);
97
- expect(source).not.toMatch(/^await /m);
98
- // The package.json has "sideEffects": false, confirming tree-shaking
99
- });
100
-
101
- it("package.json has sideEffects: false for tree-shaking", () => {
102
- const pkg = JSON.parse(readFileSync(resolve(__dirname, "../package.json"), "utf-8"));
103
- expect(pkg.sideEffects).toBe(false);
104
- });
105
- });
106
-
107
- describe("export structure", () => {
108
- it("index.ts exports createFrappeBackend (tree-shakeable named export)", () => {
109
- const source = readFileSync(resolve(__dirname, "../src/index.ts"), "utf-8");
110
-
111
- // Named exports allow tree-shaking — consumers that don't import
112
- // createFrappeBackend won't include it in their bundle
113
- expect(source).toContain("createFrappeBackend");
114
-
115
- // It's a named re-export, not a default export or namespace import
116
- expect(source).toMatch(/export\s*\{.*createFrappeBackend.*\}/s);
117
- });
118
-
119
- it("all i18n exports are named (not default) for optimal tree-shaking", () => {
120
- const source = readFileSync(resolve(__dirname, "../src/index.ts"), "utf-8");
121
-
122
- // No default export — all named exports are individually tree-shakeable
123
- expect(source).not.toContain("export default");
124
- });
125
- });
126
- });
@@ -1,294 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import i18next from "i18next";
3
- import enCommon from "../../../packages/i18n/en/common.json";
4
- import teCommon from "../../../packages/i18n/te/common.json";
5
-
6
- /**
7
- * Complete inventory of flat English string keys used in frappe-ui components
8
- * (Groups 5-7). Each string is used as its own translation key following
9
- * Frappe's __() convention.
10
- */
11
- const frappeUiStrings = [
12
- // FrappeForm
13
- "Validation error occurred",
14
- "Failed to save document",
15
- "Delete this {{doctype}}?",
16
- "Failed to delete",
17
- "Loading {{doctype}}...",
18
- "Loading form...",
19
- "Not Saved",
20
- "Delete",
21
- "Cancel",
22
- "Saving...",
23
- "Save",
24
- "Activity",
25
-
26
- // FrappeListView
27
- "Name",
28
- "Loading {{doctype}} schema...",
29
- "List view",
30
- "Table view",
31
- "Refresh",
32
- "Add {{doctype}}",
33
- "Filters",
34
- "Saved Filters",
35
-
36
- // FrappeReportBuilder
37
- "Count",
38
- "Sum",
39
- "Average",
40
- "Min",
41
- "Max",
42
- "Bar chart",
43
- "Toggle Columns",
44
- "Group By:",
45
- "None",
46
- "Clear group by",
47
- "Load",
48
- "Saved Reports",
49
- "Delete {{name}}",
50
- "Columns",
51
- "Chart",
52
- "Export",
53
- "Save Report",
54
- "Report name",
55
- "Loading data...",
56
- "No data to display",
57
- "(empty)",
58
- "record",
59
- "records",
60
- "Yes",
61
- "No",
62
-
63
- // FrappeTreeView
64
- "This DocType does not support tree view",
65
- "Loading {{doctype}} tree...",
66
- "Updating...",
67
- "Expand All",
68
- "Collapse All",
69
- "No items found",
70
-
71
- // FrappeImageView
72
- "No image field configured for {{doctype}}",
73
-
74
- // FrappeCalendar
75
- "Today",
76
- "Month",
77
- "Week",
78
- "Day",
79
- "Events",
80
- "+{{n}} more",
81
-
82
- // FrappeKanban
83
- "Drop here",
84
- "No items",
85
-
86
- // FrappeDashboard
87
- "vs {{previousValue}}",
88
- "Loading dashboard...",
89
- "Last updated: {{time}}",
90
-
91
- // FrappeList
92
- "No {{doctype}} records found",
93
- "{{n}} selected",
94
- "Select all",
95
- "Showing {{n}} items with virtual scrolling",
96
-
97
- // FrappeTable
98
- "Error loading {{doctype}}",
99
-
100
- // ViewSwitcher
101
- "List",
102
- "Table",
103
- "Report",
104
- "Image",
105
- "Tree",
106
- "Kanban",
107
- "Calendar",
108
-
109
- // FormSidebar
110
- "Write",
111
- "Read",
112
- "Submit",
113
- "Share",
114
- "Assigned To",
115
- "Add assignment",
116
- "No assignments",
117
- "Attachments",
118
- "Tags",
119
- "Shared With",
120
- "Add share",
121
- "Not shared",
122
- "Info",
123
- "Created by {{owner}} on {{date}}",
124
- "Last edited by {{modified_by}} on {{date}}",
125
-
126
- // DeskShell / DeskFormPage / DeskSidebar / DeskListPage
127
- "Desk",
128
- "No DocType specified",
129
- "New",
130
- "Modules",
131
- "Recent",
132
- "Favorites",
133
-
134
- // Awesomebar
135
- "Search or type a command (Ctrl+K)",
136
- "Result:",
137
- "Commands",
138
- "Navigation",
139
- "Documents",
140
- "Searching documents...",
141
-
142
- // FilterManager
143
- "Filter name is required",
144
- "Failed to save filter",
145
- "Save Filter",
146
- "Save the current filter configuration for quick access later.",
147
- "Filter Name",
148
- "e.g., Big Pokemon",
149
- "Make this filter global (visible to all users)",
150
- "Current filters: {{n}} active",
151
-
152
- // SavedFilterDropdown
153
- "(Global)",
154
-
155
- // Attachments
156
- "B",
157
- "KB",
158
- "MB",
159
- "GB",
160
- "File size exceeds {{limit}} limit",
161
- "Failed to read file",
162
- "Upload failed",
163
- "Delete failed",
164
- "No attachments",
165
- "Uploading...",
166
- "Upload",
167
- "Download",
168
- "Close",
169
-
170
- // Tags
171
- "Add tag...",
172
- 'Tag "{{name}}" is already applied',
173
- "Failed to add tag",
174
- "Failed to remove tag",
175
- "No tags",
176
- 'Create "{{value}}"',
177
- "Suggestions",
178
- "No available tags",
179
- "Add Tag",
180
- "Click + to add tags",
181
-
182
- // Timeline
183
- "just now",
184
- "{{count}} minute ago",
185
- "{{count}} minutes ago",
186
- "{{count}} hour ago",
187
- "{{count}} hours ago",
188
- "{{count}} day ago",
189
- "{{count}} days ago",
190
- "{{count}} week ago",
191
- "{{count}} weeks ago",
192
- "{{count}} month ago",
193
- "{{count}} months ago",
194
- "No activity yet",
195
- "+{{count}} more changes",
196
- "made changes",
197
- "added an attachment",
198
- "assigned",
199
- "Add a comment...",
200
- "Add Comment",
201
- "Sending...",
202
- "Comment",
203
- "Failed to add comment",
204
- "{{count}} entry",
205
- "{{count}} entries",
206
-
207
- // ChildTable
208
- "#",
209
- "Add Row",
210
-
211
- // SchemaForm
212
- "Unsupported field type: {{fieldtype}}",
213
-
214
- // Toolbar
215
- "Loading...",
216
- ] as const;
217
-
218
- describe("Group 8: Local Fallback Dictionaries", () => {
219
- it("Test 1: All frappe-ui strings exist as keys in en/common.json", () => {
220
- const enKeys = Object.keys(enCommon);
221
- const missing: string[] = [];
222
-
223
- for (const str of frappeUiStrings) {
224
- if (!enKeys.includes(str)) {
225
- missing.push(str);
226
- }
227
- }
228
-
229
- expect(missing, `Missing keys in en/common.json:\n ${missing.join("\n ")}`).toEqual([]);
230
-
231
- // Also verify identity mapping: for English, key === value
232
- for (const str of frappeUiStrings) {
233
- expect((enCommon as Record<string, unknown>)[str]).toBe(str);
234
- }
235
- });
236
-
237
- it("Test 2: te/common.json has the same key set as en/common.json", () => {
238
- const enKeys = Object.keys(enCommon).sort();
239
- const teKeys = Object.keys(teCommon).sort();
240
-
241
- // Every key in en must also appear in te
242
- const missingInTe = enKeys.filter((k) => !teKeys.includes(k));
243
- expect(
244
- missingInTe,
245
- `Keys present in en/common.json but missing in te/common.json:\n ${missingInTe.join("\n ")}`,
246
- ).toEqual([]);
247
-
248
- // Every key in te must also appear in en (no stale keys)
249
- const extraInTe = teKeys.filter((k) => !enKeys.includes(k));
250
- expect(
251
- extraInTe,
252
- `Keys present in te/common.json but missing in en/common.json:\n ${extraInTe.join("\n ")}`,
253
- ).toEqual([]);
254
- });
255
-
256
- it('Test 3: t("Filters") resolves correctly in English locale', async () => {
257
- const instance = i18next.createInstance();
258
- await instance.init({
259
- lng: "en",
260
- defaultNS: "common",
261
- ns: ["common"],
262
- resources: {
263
- en: { common: enCommon },
264
- te: { common: teCommon },
265
- },
266
- interpolation: { escapeValue: false },
267
- returnNull: false,
268
- });
269
-
270
- // Flat English string keys resolve via identity mapping
271
- expect(instance.t("Filters")).toBe("Filters");
272
- expect(instance.t("Save")).toBe("Save");
273
- expect(instance.t("Loading...")).toBe("Loading...");
274
-
275
- // Interpolated keys resolve with variable substitution
276
- expect(instance.t("Loading {{doctype}}...", { doctype: "Pokemon" })).toBe("Loading Pokemon...");
277
- expect(instance.t("{{n}} selected", { n: 5 })).toBe("5 selected");
278
-
279
- // Namespaced keys still work
280
- expect(instance.t("common.appName")).toBe("multiplatform.one");
281
- expect(instance.t("screens.pokemon.title")).toBe("Pokemon");
282
-
283
- // Switch to Telugu and verify translations differ from English
284
- await instance.changeLanguage("te");
285
- const teFilters = (teCommon as Record<string, unknown>)["Filters"];
286
- const teSave = (teCommon as Record<string, unknown>)["Save"];
287
- expect(instance.t("Filters")).toBe(teFilters);
288
- expect(instance.t("Save")).toBe(teSave);
289
- expect(instance.t("Filters")).not.toBe("Filters");
290
- expect(instance.t("Save")).not.toBe("Save");
291
- // appName stays the same across locales
292
- expect(instance.t("common.appName")).toBe("multiplatform.one");
293
- });
294
- });