@multiplatform.one/i18n 6.0.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.
@@ -0,0 +1,232 @@
1
+ /**
2
+ * 10.1 Cross-group integration test: Frappe-enabled flow
3
+ *
4
+ * Simulates booting the i18n system with a Frappe backend (mocked),
5
+ * switching to a non-English language, and verifying:
6
+ * - Frappe translations are loaded and override local resources
7
+ * - frappe-ui strings render in translated language
8
+ * - DocType metadata labels come back translated (mocked API)
9
+ */
10
+ import { describe, it, expect } from "vitest";
11
+ import i18next from "i18next";
12
+ import type { BackendModule, ReadCallback } from "i18next";
13
+ import enCommon from "../../../packages/i18n/en/common.json";
14
+ import teCommon from "../../../packages/i18n/te/common.json";
15
+
16
+ // Simulated Frappe Spanish translations (overrides for frappe-ui strings)
17
+ const frappeEsTranslations: Record<string, string> = {
18
+ Save: "Guardar",
19
+ Delete: "Eliminar",
20
+ Cancel: "Cancelar",
21
+ Filters: "Filtros",
22
+ "Loading...": "Cargando...",
23
+ "Saved Filters": "Filtros guardados",
24
+ Name: "Nombre",
25
+ "Add Row": "Agregar fila",
26
+ "Expand All": "Expandir todo",
27
+ "Collapse All": "Contraer todo",
28
+ "No items found": "No se encontraron elementos",
29
+ "Loading {{doctype}}...": "Cargando {{doctype}}...",
30
+ "Loading {{doctype}} schema...": "Cargando esquema de {{doctype}}...",
31
+ Activity: "Actividad",
32
+ Attachments: "Adjuntos",
33
+ Tags: "Etiquetas",
34
+ // DocType metadata labels (what Frappe would return for field labels)
35
+ "Pokemon Name": "Nombre del Pokémon",
36
+ Species: "Especie",
37
+ Height: "Altura",
38
+ Weight: "Peso",
39
+ };
40
+
41
+ /**
42
+ * Build a mock Frappe backend that returns translations synchronously.
43
+ * i18next BackendModule `read` calls the callback synchronously
44
+ * so translations are available immediately after init.
45
+ */
46
+ function createMockFrappeBackend(
47
+ mockTranslations: Record<string, Record<string, string>>,
48
+ ): BackendModule & { fetchCalls: Array<{ language: string; namespace: string }> } {
49
+ const fetchCalls: Array<{ language: string; namespace: string }> = [];
50
+ return {
51
+ type: "backend",
52
+ fetchCalls,
53
+ init() {},
54
+ read(language: string, namespace: string, callback: ReadCallback) {
55
+ fetchCalls.push({ language, namespace });
56
+ const translations = mockTranslations[language];
57
+ callback(null, translations ?? {});
58
+ },
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Helper to init an i18next instance with a mock Frappe backend.
64
+ * Uses `partialBundledLanguages: true` so the backend is queried
65
+ * for languages NOT included in `resources`. English is always bundled
66
+ * as the fallback.
67
+ */
68
+ async function initWithFrappeBackend(
69
+ mockBackend: BackendModule,
70
+ options: {
71
+ lng: string;
72
+ bundledResources?: Record<string, Record<string, unknown>>;
73
+ supportedLngs?: string[];
74
+ },
75
+ ) {
76
+ const instance = i18next.createInstance();
77
+ const { lng, bundledResources, supportedLngs = ["en", "es", "te"] } = options;
78
+
79
+ await new Promise<void>((resolve, reject) => {
80
+ instance.use(mockBackend).init(
81
+ {
82
+ lng,
83
+ defaultNS: "common",
84
+ ns: ["common"],
85
+ supportedLngs,
86
+ fallbackLng: "en",
87
+ // Only bundle English + any explicitly provided resources.
88
+ // Non-bundled languages (like es) will be fetched from backend.
89
+ resources: bundledResources ?? {
90
+ en: { common: enCommon },
91
+ },
92
+ partialBundledLanguages: true,
93
+ interpolation: { escapeValue: false },
94
+ returnNull: false,
95
+ returnEmptyString: false,
96
+ },
97
+ (err) => {
98
+ if (err) reject(err);
99
+ else resolve();
100
+ },
101
+ );
102
+ });
103
+
104
+ return instance;
105
+ }
106
+
107
+ describe("10.1 Frappe-enabled integration flow", () => {
108
+ it("loads Frappe translations that override local resources for Spanish", async () => {
109
+ const mockBackend = createMockFrappeBackend({
110
+ es: frappeEsTranslations,
111
+ });
112
+
113
+ const instance = await initWithFrappeBackend(mockBackend, { lng: "es" });
114
+
115
+ // The backend was called to fetch Spanish translations
116
+ expect(mockBackend.fetchCalls.length).toBeGreaterThanOrEqual(1);
117
+ expect(mockBackend.fetchCalls.some((c) => c.language === "es")).toBe(true);
118
+
119
+ // Frappe translations override local resources
120
+ expect(instance.t("Save")).toBe("Guardar");
121
+ expect(instance.t("Filters")).toBe("Filtros");
122
+ expect(instance.t("Cancel")).toBe("Cancelar");
123
+ expect(instance.t("Loading...")).toBe("Cargando...");
124
+ expect(instance.t("Expand All")).toBe("Expandir todo");
125
+ expect(instance.t("No items found")).toBe("No se encontraron elementos");
126
+ });
127
+
128
+ it("frappe-ui strings with interpolation render in translated language", async () => {
129
+ const mockBackend = createMockFrappeBackend({
130
+ es: frappeEsTranslations,
131
+ });
132
+
133
+ const instance = await initWithFrappeBackend(mockBackend, { lng: "es" });
134
+
135
+ // Interpolated frappe-ui strings
136
+ expect(instance.t("Loading {{doctype}}...", { doctype: "Pokemon" })).toBe(
137
+ "Cargando Pokemon...",
138
+ );
139
+ expect(instance.t("Loading {{doctype}} schema...", { doctype: "Pokemon" })).toBe(
140
+ "Cargando esquema de Pokemon...",
141
+ );
142
+ });
143
+
144
+ it("DocType metadata labels come back translated from Frappe", async () => {
145
+ const mockBackend = createMockFrappeBackend({
146
+ es: frappeEsTranslations,
147
+ });
148
+
149
+ const instance = await initWithFrappeBackend(mockBackend, {
150
+ lng: "es",
151
+ supportedLngs: ["en", "es"],
152
+ });
153
+
154
+ // DocType field labels translated by Frappe
155
+ expect(instance.t("Pokemon Name")).toBe("Nombre del Pokémon");
156
+ expect(instance.t("Species")).toBe("Especie");
157
+ expect(instance.t("Height")).toBe("Altura");
158
+ expect(instance.t("Weight")).toBe("Peso");
159
+ });
160
+
161
+ it("falls back to English local resource when Frappe has no translation for a key", async () => {
162
+ const mockBackend = createMockFrappeBackend({
163
+ es: { Save: "Guardar" }, // Only one key translated by Frappe
164
+ });
165
+
166
+ const instance = await initWithFrappeBackend(mockBackend, {
167
+ lng: "es",
168
+ supportedLngs: ["en", "es"],
169
+ });
170
+
171
+ // Key from Frappe
172
+ expect(instance.t("Save")).toBe("Guardar");
173
+ // Key NOT in Frappe's dict → falls back to English local resource (identity mapping)
174
+ expect(instance.t("Filters")).toBe("Filters");
175
+ expect(instance.t("common.appName")).toBe("multiplatform.one");
176
+ });
177
+
178
+ it("switching language triggers a new backend fetch", async () => {
179
+ const mockBackend = createMockFrappeBackend({
180
+ es: frappeEsTranslations,
181
+ te: { Save: "సేవ్ చేయండి", Filters: "ఫిల్టర్‌లు" },
182
+ });
183
+
184
+ // Start in English (bundled), then switch to Spanish (backend)
185
+ const instance = await initWithFrappeBackend(mockBackend, { lng: "en" });
186
+
187
+ // Initially English from bundled resources
188
+ expect(instance.t("Save")).toBe("Save");
189
+
190
+ // Switch to Spanish — triggers backend fetch
191
+ await instance.changeLanguage("es");
192
+ expect(instance.t("Save")).toBe("Guardar");
193
+ expect(mockBackend.fetchCalls.some((c) => c.language === "es")).toBe(true);
194
+
195
+ // Switch to Telugu — triggers another backend fetch
196
+ await instance.changeLanguage("te");
197
+ expect(instance.t("Save")).toBe("సేవ్ చేయండి");
198
+ expect(instance.t("Filters")).toBe("ఫిల్టర్‌లు");
199
+ expect(mockBackend.fetchCalls.some((c) => c.language === "te")).toBe(true);
200
+ });
201
+
202
+ it("bundled languages use local resources; backend only serves non-bundled languages", async () => {
203
+ // Architecture: en and te are bundled locally. Spanish is not bundled,
204
+ // so Frappe backend provides it. The backend is NOT called for bundled languages.
205
+ const mockBackend = createMockFrappeBackend({
206
+ es: frappeEsTranslations,
207
+ });
208
+
209
+ const instance = await initWithFrappeBackend(mockBackend, {
210
+ lng: "te",
211
+ // Telugu is fully bundled — backend should NOT be called for it
212
+ bundledResources: {
213
+ en: { common: enCommon },
214
+ te: { common: teCommon },
215
+ },
216
+ });
217
+
218
+ // Backend was NOT called for Telugu (it's bundled)
219
+ expect(mockBackend.fetchCalls.some((c) => c.language === "te")).toBe(false);
220
+
221
+ // Telugu translations come from local bundled resources
222
+ expect(instance.t("Save")).toBe("సేవ్ చేయండి");
223
+ expect(instance.t("Filters")).toBe("ఫిల్టర్‌లు");
224
+ expect(instance.t("Delete")).toBe("తొలగించు");
225
+
226
+ // Switch to Spanish (not bundled) — backend provides translations
227
+ await instance.changeLanguage("es");
228
+ expect(mockBackend.fetchCalls.some((c) => c.language === "es")).toBe(true);
229
+ expect(instance.t("Save")).toBe("Guardar");
230
+ expect(instance.t("Filters")).toBe("Filtros");
231
+ });
232
+ });
@@ -0,0 +1,241 @@
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
+ });
@@ -0,0 +1,126 @@
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
+ });