UNPKG

@inlang/paraglide-js

Version:

[![Inlang-ecosystem compatibility badge](https://cdn.jsdelivr.net/gh/opral/monorepo@main/inlang/assets/md-badges/inlang.svg)](https://inlang.com)

910 lines (899 loc) 36.7 kB
import { expect, test, describe, vi, beforeEach } from "vitest"; import { AsyncLocalStorage } from "async_hooks"; import { createProject as typescriptProject, ts, } from "@ts-morph/bootstrap"; import { Declaration, insertBundleNested, loadProjectInMemory, newProject, Pattern, VariableReference, } from "@inlang/sdk"; import { compileProject } from "./compile-project.js"; import virtual from "@rollup/plugin-virtual"; import { rolldown } from "rolldown"; beforeEach(() => { // reset the imports to make sure that the runtime is reloaded vi.resetModules(); vi.clearAllMocks(); // mocking DOM globals // @ts-expect-error - global variable definition globalThis.window = undefined; }); test("emitGitignore", async () => { const project = await loadProjectInMemory({ blob: await newProject({ settings: { locales: ["en", "de"], baseLocale: "en", }, }), }); const _default = await compileProject({ project, }); const _true = await compileProject({ project, compilerOptions: { emitGitIgnore: true }, }); const _false = await compileProject({ project, compilerOptions: { emitGitIgnore: false }, }); expect(_default).toHaveProperty(".gitignore"); expect(_true).toHaveProperty(".gitignore"); expect(_false).not.toHaveProperty(".gitignore"); }); test("emitPrettierIgnore", async () => { const project = await loadProjectInMemory({ blob: await newProject({ settings: { locales: ["en", "de"], baseLocale: "en", }, }), }); const _default = await compileProject({ project, }); const _true = await compileProject({ project, compilerOptions: { emitPrettierIgnore: true }, }); const _false = await compileProject({ project, compilerOptions: { emitPrettierIgnore: false }, }); expect(_default).toHaveProperty(".prettierignore"); expect(_true).toHaveProperty(".prettierignore"); expect(_false).not.toHaveProperty(".prettierignore"); }); test("handles message bundles with a : in the id", async () => { const project = await loadProjectInMemory({ blob: await newProject({ settings: { locales: ["en", "de"], baseLocale: "en", }, }), }); await insertBundleNested(project.db, createBundleNested({ id: "hello:world", messages: [ { locale: "en", variants: [{ pattern: [{ type: "text", value: "Hello world!" }] }], }, ], })); const output = await compileProject({ project, }); const code = await bundleCode(output, `export * as m from "./paraglide/messages.js" export * as runtime from "./paraglide/runtime.js"`); const { m } = await importCode(code); expect(m["hello:world"]()).toBe("Hello world!"); }); // https://github.com/opral/inlang-paraglide-js/issues/347 test("can emit message bundles with more than 255 characters", async () => { const project = await loadProjectInMemory({ blob: await newProject({ settings: { baseLocale: "en", locales: ["en", "de"], }, }), }); await insertBundleNested(project.db, createBundleNested({ // 300 characters long id id: "a".repeat(300), messages: [ { locale: "en", variants: [ { pattern: [{ type: "text", value: "Hello" }], }, ], }, ], })); const output = await compileProject({ project, compilerOptions: { urlPatterns: [], }, }); const code = await bundleCode(output, `export * as m from "./paraglide/messages.js" export * as runtime from "./paraglide/runtime.js"`); const { m } = await importCode(code); expect(m["a".repeat(300)]()).toBe("Hello"); }); describe.each([ // useTsImports must be true to test emitTs. Otherwise, rolldown can't resolve the imports { outputStructure: "locale-modules", strategy: ["globalVariable", "baseLocale"], }, { outputStructure: "message-modules", strategy: ["globalVariable", "baseLocale"], }, ])("options", async (compilerOptions) => { const output = await compileProject({ project, compilerOptions }); describe("tree-shaking", () => { test("should tree-shake unused messages", async () => { const code = await bundleCode(output, `import * as m from "./paraglide/messages.js" console.log(m.sad_penguin_bundle())`); const log = vi.spyOn(console, "log").mockImplementation(() => { }); // all required code for the message to be rendered is included like sourceLanguageTag. // but, all other messages except of 'sad_penguin_bundle' are tree-shaken away. for (const { id } of mockBundles) { if (id === "sad_penguin_bundle") { expect(code).toContain(id); } else { expect(code).not.toContain(id); } } eval(code); expect(log).toHaveBeenCalledWith("A simple message."); }); // https://github.com/opral/inlang-paraglide-js/issues/345 test("importing { m } works and tree-shakes unused messages", async () => { const code = await bundleCode(output, `import { m } from "./paraglide/messages.js" console.log(m.sad_penguin_bundle())`); const log = vi.spyOn(console, "log").mockImplementation(() => { }); // all required code for the message to be rendered is included like sourceLanguageTag. // but, all other messages except of 'sad_penguin_bundle' are tree-shaken away. for (const { id } of mockBundles) { if (id === "sad_penguin_bundle") { expect(code).toContain(id); } else { expect(code).not.toContain(id); } } eval(code); expect(log).toHaveBeenCalledWith("A simple message."); }); test("should not treeshake messages that are used", async () => { const code = await bundleCode(output, `import * as m from "./paraglide/messages.js" console.log( m.sad_penguin_bundle(), m.depressed_dog({ name: "Samuel" }), m.insane_cats({ name: "Samuel", count: 5 }) )`); const log = vi.spyOn(console, "log").mockImplementation(() => { }); for (const id of mockBundles.map((m) => m.id)) { if (["sad_penguin_bundle", "depressed_dog", "insane_cats"].includes(id)) { expect(code).toContain(id); } else { expect(code).not.toContain(id); } } eval(code); expect(log).toHaveBeenCalledWith("A simple message.", "Good morning Samuel!", "Hello Samuel! You have 5 messages."); }); }); // https://github.com/opral/inlang-paraglide-js/issues/379 test("plurals work", async () => { const project = await loadProjectInMemory({ blob: await newProject({ settings: { locales: ["en", "de"], baseLocale: "en" }, }), }); await insertBundleNested(project.db, createBundleNested({ id: "plural_test", declarations: [ { type: "input-variable", name: "count" }, { type: "local-variable", name: "countPlural", value: { arg: { type: "variable-reference", name: "count" }, annotation: { type: "function-reference", name: "plural", options: [], }, type: "expression", }, }, ], messages: [ { locale: "en", selectors: [{ type: "variable-reference", name: "countPlural" }], variants: [ { matches: [ { type: "literal-match", value: "one", key: "countPlural" }, ], pattern: [{ type: "text", value: "There is one cat." }], }, { matches: [ { type: "literal-match", value: "other", key: "countPlural", }, ], pattern: [{ type: "text", value: "There are many cats." }], }, ], }, ], })); const { m } = await importCode(await bundleCode(await compileProject({ project, compilerOptions, }), `export * as m from "./paraglide/messages.js"`)); expect(m.plural_test({ count: 1 })).toBe("There is one cat."); expect(m.plural_test({ count: 2 })).toBe("There are many cats."); }); describe("e2e", async () => { // The compiled output needs to be bundled into one file to be dynamically imported. const code = await bundleCode(output, `export * as m from "./paraglide/messages.js" export * as runtime from "./paraglide/runtime.js"`); // test is a direct result of a bug test("locales should include locales with a hyphen", async () => { const { runtime } = await importCode(code); expect(runtime.locales).toContain("en-US"); }); test("should set the baseLocale as default getLocale value", async () => { const { runtime } = await importCode(code); expect(runtime.getLocale()).toBe(runtime.baseLocale); }); test("should return the correct message for the current locale", async () => { const { m, runtime } = await importCode(code); console.log(m); runtime.setLocale("en"); expect(m.sad_penguin_bundle()).toBe("A simple message."); runtime.setLocale("de"); expect(m.sad_penguin_bundle()).toBe("Eine einfache Nachricht."); }); test("overwriteGetLocale() works", async () => { const { m, runtime } = await importCode(code); let locale = "en"; runtime.overwriteGetLocale(() => locale); expect(m.sad_penguin_bundle()).toBe("A simple message."); locale = "de"; expect(m.sad_penguin_bundle()).toBe("Eine einfache Nachricht."); }); test("overwriteSetLocale() works", async () => { const { runtime } = await importCode(code); let locale = "en"; runtime.overwriteSetLocale((newLocale) => { locale = newLocale; }); runtime.setLocale("de"); expect(locale).toBe("de"); }); test.skip("defining onSetLocale should be possible and should be called when the locale changes", async () => { const { runtime } = await importCode(code); const mockOnSetLocale = vi.fn().mockImplementation(() => { }); runtime.onSetLocale((locale) => { mockOnSetLocale(locale); }); runtime.setLocale("de"); expect(mockOnSetLocale).toHaveBeenLastCalledWith("de"); runtime.setLocale("en"); expect(mockOnSetLocale).toHaveBeenLastCalledWith("en"); expect(mockOnSetLocale).toHaveBeenCalledTimes(2); }); test.skip("Calling onSetLocale() multiple times should override the previous callback", async () => { const cb1 = vi.fn().mockImplementation(() => { }); const cb2 = vi.fn().mockImplementation(() => { }); const { runtime } = await importCode(code); runtime.onSetLocale(cb1); runtime.setLocale("en"); expect(cb1).toHaveBeenCalledTimes(1); runtime.onSetLocale(cb2); runtime.setLocale("de"); expect(cb2).toHaveBeenCalledTimes(1); expect(cb1).toHaveBeenCalledTimes(1); }); test("should return the correct message if a locale is set in the message options", async () => { const { m, runtime } = await importCode(code); // set the language tag to de to make sure that the message options override the runtime language tag runtime.setLocale("de"); expect(m.sad_penguin_bundle()).toBe("Eine einfache Nachricht."); expect(m.sad_penguin_bundle(undefined, { locale: "en" })).toBe("A simple message."); }); test("runtime.isLocale should only return `true` if a locale is passed to it", async () => { const { runtime } = await importCode(code); for (const tag of runtime.locales) { expect(runtime.isLocale(tag)).toBe(true); } expect(runtime.isLocale("")).toBe(false); expect(runtime.isLocale("pl")).toBe(false); expect(runtime.isLocale("--")).toBe(false); }); test("falls back to base locale", async () => { const project = await loadProjectInMemory({ blob: await newProject({ settings: { locales: ["en", "de", "en-US"], baseLocale: "en" }, }), }); await insertBundleNested(project.db, createBundleNested({ id: "missingInGerman", messages: [ { locale: "en", variants: [ { pattern: [{ type: "text", value: "A simple message." }] }, ], }, ], })); const output = await compileProject({ project, compilerOptions, }); const code = await bundleCode(output, `export * as m from "./paraglide/messages.js" export * as runtime from "./paraglide/runtime.js"`); const { m, runtime } = await importCode(code); runtime.setLocale("de"); expect(m.missingInGerman()).toBe("A simple message."); runtime.setLocale("en-US"); expect(m.missingInGerman()).toBe("A simple message."); }); test("message tracking works", async () => { const project = await loadProjectInMemory({ blob: await newProject({ settings: { locales: ["en", "de", "fr"], baseLocale: "en" }, }), }); // Add test messages await insertBundleNested(project.db, createBundleNested({ id: "greeting", messages: [ { locale: "en", variants: [{ pattern: [{ type: "text", value: "Hello" }] }], }, { locale: "de", variants: [{ pattern: [{ type: "text", value: "Hallo" }] }], }, { locale: "fr", variants: [{ pattern: [{ type: "text", value: "Bonjour" }] }], }, ], })); await insertBundleNested(project.db, createBundleNested({ id: "farewell", messages: [ { locale: "en", variants: [{ pattern: [{ type: "text", value: "Goodbye" }] }], }, { locale: "de", variants: [ { pattern: [{ type: "text", value: "Auf Wiedersehen" }] }, ], }, { locale: "fr", variants: [{ pattern: [{ type: "text", value: "Au revoir" }] }], }, ], })); // Compile the project const output = await compileProject({ project, compilerOptions, }); const code = await bundleCode(output, `export * as m from "./paraglide/messages.js" export * as runtime from "./paraglide/runtime.js"`); const { m, runtime } = await importCode(code); // Setup AsyncLocalStorage for tracking runtime.overwriteServerAsyncLocalStorage(new AsyncLocalStorage()); // Test tracking in English runtime.setLocale("en"); const messageCalls1 = new Set(); const result1 = await runtime.serverAsyncLocalStorage.run({ messageCalls: messageCalls1 }, () => { const greeting = m.greeting(); const farewell = m.farewell(); expect(greeting).toBe("Hello"); expect(farewell).toBe("Goodbye"); return "english"; }); expect(result1).toBe("english"); expect(messageCalls1).toEqual(new Set(["greeting:en", "farewell:en"])); // Test tracking in German runtime.setLocale("de"); const messageCalls2 = new Set(); const result2 = await runtime.serverAsyncLocalStorage.run({ messageCalls: messageCalls2 }, () => { const greeting = m.greeting(); expect(greeting).toBe("Hallo"); return "german"; }); expect(result2).toBe("german"); expect(messageCalls2).toEqual(new Set(["greeting:de"])); expect(messageCalls2.has("farewell:de")).toBe(false); // Test tracking with explicit locale const messageCalls3 = new Set(); const result3 = await runtime.serverAsyncLocalStorage.run({ messageCalls: messageCalls3 }, () => { const greeting = m.greeting(undefined, { locale: "fr" }); expect(greeting).toBe("Bonjour"); return "explicit"; }); expect(result3).toBe("explicit"); expect(messageCalls3).toEqual(new Set(["greeting:fr"])); // Test nested tracking contexts const messageCalls4 = new Set(); const result4 = await runtime.serverAsyncLocalStorage.run({ messageCalls: messageCalls4 }, () => { // Access a message in the outer context const outerGreeting = m.greeting(); expect(outerGreeting).toBe("Hallo"); // Still in German locale // Create a nested tracking context const nestedMessageCalls = new Set(); const nestedResult = runtime.serverAsyncLocalStorage.run({ messageCalls: nestedMessageCalls }, () => { // Access different messages in the nested context const nestedFarewell = m.farewell(); expect(nestedFarewell).toBe("Auf Wiedersehen"); return "nested"; }); // Verify nested tracking expect(nestedResult).toBe("nested"); expect(nestedMessageCalls).toEqual(new Set(["farewell:de"])); expect(nestedMessageCalls.has("greeting:de")).toBe(false); // Not accessed in nested context return "outer"; }); // Verify outer context only contains its own calls expect(result4).toBe("outer"); expect(messageCalls4).toEqual(new Set(["greeting:de"])); // The farewell message should not be in the outer context expect(messageCalls4.has("farewell:de")).toBe(false); }); test("arbitrary module identifiers work", async () => { const project = await loadProjectInMemory({ blob: await newProject({ settings: { locales: ["en", "de"], baseLocale: "en" }, }), }); await insertBundleNested(project.db, createBundleNested({ id: "$502.23-hello_world", messages: [ { locale: "en", variants: [ { pattern: [{ type: "text", value: "A simple message." }] }, ], }, ], })); const output = await compileProject({ project, compilerOptions, }); const code = await bundleCode(output, `export * as m from "./paraglide/messages.js" export * as runtime from "./paraglide/runtime.js"`); const { m } = await importCode(code); expect(m["$502.23-hello_world"]()).toBe("A simple message."); }); test("falls back to parent locale if message doesn't exist", async () => { const project = await loadProjectInMemory({ blob: await newProject({ settings: { locales: ["en", "en-US"], baseLocale: "en" }, }), }); await insertBundleNested(project.db, createBundleNested({ id: "exists_in_both", messages: [ { locale: "en", variants: [ { pattern: [{ type: "text", value: "A simple message." }] }, ], }, { locale: "en-US", variants: [ { pattern: [ { type: "text", value: "A simple message for Americans.", }, ], }, ], }, ], })); await insertBundleNested(project.db, createBundleNested({ id: "missing_in_en_US", messages: [ { locale: "en", variants: [ { pattern: [{ type: "text", value: "Fallback message." }] }, ], }, ], })); const output = await compileProject({ project, compilerOptions, }); const code = await bundleCode(output, `export * as m from "./paraglide/messages.js" export * as runtime from "./paraglide/runtime.js"`); const { m, runtime } = await importCode(code); runtime.setLocale("en-US"); expect(m.exists_in_both()).toBe("A simple message for Americans."); runtime.setLocale("en-US"); expect(m.missing_in_en_US()).toBe("Fallback message."); }); test("arbitrary module identifiers", async () => { const project = await loadProjectInMemory({ blob: await newProject({ settings: { locales: ["en"], baseLocale: "en" }, }), }); await insertBundleNested(project.db, createBundleNested({ id: "happy🍌", messages: [ { locale: "en", variants: [{ pattern: [{ type: "text", value: "Hello" }] }], }, ], })); const output = await compileProject({ project, compilerOptions, }); const code = await bundleCode(output, `export * as m from "./paraglide/messages.js" export * as runtime from "./paraglide/runtime.js"`); const { m } = await importCode(code); expect(m["happy🍌"]()).toBe("Hello"); }); }); test("case sensitivity handling for bundle IDs", async () => { const project = await loadProjectInMemory({ blob: await newProject({ settings: { locales: ["en"], baseLocale: "en" }, }), }); // Create two bundles with the same name but different case await insertBundleNested(project.db, createBundleNested({ id: "Helloworld", messages: [ { locale: "en", variants: [ { pattern: [ { type: "text", value: "Hello from uppercase bundle" }, ], }, ], }, ], })); await insertBundleNested(project.db, createBundleNested({ id: "helloworld", messages: [ { locale: "en", variants: [ { pattern: [ { type: "text", value: "Hello from lowercase bundle" }, ], }, ], }, ], })); const output = await compileProject({ project, compilerOptions, }); const code = await bundleCode(output, `export * as m from "./paraglide/messages.js" export { helloworld, Helloworld } from "./paraglide/messages.js"`); const imported = await importCode(code); // Both message functions should be available expect(imported.helloworld()).toBe("Hello from lowercase bundle"); expect(imported.Helloworld()).toBe("Hello from uppercase bundle"); // They should also be available through the m namespace expect(imported.m.helloworld()).toBe("Hello from lowercase bundle"); expect(imported.m.Helloworld()).toBe("Hello from uppercase bundle"); }); // whatever the strictest users use, this is the ultimate nothing gets stricter than this // (to avoid developers opening issues "i get a ts warning in my code") const superStrictRuleOutAnyErrorTsSettings = { outDir: "dist", declaration: true, allowJs: true, checkJs: true, noImplicitAny: true, noUnusedLocals: true, noUnusedParameters: true, noImplicitReturns: true, noImplicitThis: true, noUncheckedIndexedAccess: true, noPropertyAccessFromIndexSignature: true, module: ts.ModuleKind.Node16, strict: true, }; test("./messages.js types", async () => { const project = await typescriptProject({ useInMemoryFileSystem: true, compilerOptions: superStrictRuleOutAnyErrorTsSettings, }); for (const [fileName, code] of Object.entries(output)) { if (fileName.endsWith(".js") || fileName.endsWith(".ts")) { project.createSourceFile(fileName, code); } } project.createSourceFile("test.ts", ` import * as m from "./messages.js" // --------- MESSAGES --------- // the return value of a message should be a string m.insane_cats({ name: "John", count: 5 }) satisfies string // @ts-expect-error - missing all params m.insane_cats() // @ts-expect-error - one param missing m.insane_cats({ name: "John" }) // a message without params shouldn't require params m.sad_penguin_bundle() satisfies string // --------- MESSAGE OPTIONS --------- // the locale option should be optional m.sad_penguin_bundle({}, {}) satisfies string // the locale option should be allowed m.sad_penguin_bundle({}, { locale: "en" }) satisfies string // the locale option must be a valid language tag // @ts-expect-error - invalid language tag m.sad_penguin_bundle({}, { locale: "---" }) `); const program = project.createProgram(); const diagnostics = ts.getPreEmitDiagnostics(program).filter((d) => { // async_hooks is a node module that is not available in the browser return !d.messageText .toString() .includes("Cannot find module 'async_hooks'"); }); for (const diagnostic of diagnostics) { console.error(diagnostic.messageText, diagnostic.file?.fileName); } expect(diagnostics.length).toEqual(0); }); }); async function bundleCode(output, file) { output["runtime.js"] = output["runtime.js"].replace('import "@inlang/paraglide-js/urlpattern-polyfill";', "/** @type {any} */const URLPattern = {};").replace('const { AsyncLocalStorage } = await import("async_hooks");', "const AsyncLocalStorage = class {};"); const bundle = await rolldown({ input: ["main.js"], plugins: [ // @ts-expect-error - rollup types are not up to date virtual({ ...Object.fromEntries(Object.entries(output).map(([fileName, code]) => [ "paraglide/" + fileName, code, ])), "main.js": file, }), ], }); const compiled = await bundle.generate({ format: "esm" }); const code = compiled.output[0].code; return code; } async function importCode(code) { // The random comment ensures that each code is a unique module // and state is not shared between imports const randomComment = `// ${Math.random()}`; const codeWithComment = `${randomComment}\n${code}`; return await import(`data:application/javascript;base64,${Buffer.from(codeWithComment, "utf8").toString("base64")}`); } const project = await loadProjectInMemory({ blob: await newProject({ settings: { baseLocale: "en", locales: ["en", "de", "en-US"], }, }), }); const mockBundles = [ createBundleNested({ id: "sad_penguin_bundle", messages: [ { locale: "en", variants: [{ pattern: [{ type: "text", value: "A simple message." }] }], }, { locale: "de", variants: [ { pattern: [{ type: "text", value: "Eine einfache Nachricht." }] }, ], }, ], }), createBundleNested({ id: "Sad_penguin_bundle", messages: [ { locale: "en", variants: [ { pattern: [{ type: "text", value: "Capital Sad penguin" }] }, ], }, { locale: "de", variants: [ { pattern: [ { type: "text", value: "Grossgeschriebenes Sad penguin" }, ], }, ], }, ], }), { id: "depressed_dog", declarations: [ { type: "input-variable", name: "name", }, ], messages: [ { id: "depressed_dog_en", bundleId: "depressed_dog", locale: "en", selectors: [], variants: [ { id: "depressed_dog_en_variant_one", messageId: "depressed_dog_en", matches: [], pattern: [ { type: "text", value: "Good morning " }, { type: "expression", arg: { type: "variable-reference", name: "name" }, }, { type: "text", value: "!" }, ], }, ], }, { id: "depressed_dog_de", bundleId: "depressed_dog", locale: "de", selectors: [], variants: [ { id: "depressed_dog_de_variant_one", messageId: "depressed_dog_de", matches: [], pattern: [ { type: "text", value: "Guten Morgen " }, { type: "expression", arg: { type: "variable-reference", name: "name" }, }, { type: "text", value: "!" }, ], }, ], }, ], }, { id: "insane_cats", declarations: [ { type: "input-variable", name: "name", }, { type: "input-variable", name: "count", }, ], messages: [ { id: "insane_cats_en", bundleId: "insane_cats", locale: "en", selectors: [], variants: [ { id: "insane_cats_en_variant_one", messageId: "insane_cats_en", matches: [], pattern: [ { type: "text", value: "Hello " }, { type: "expression", arg: { type: "variable-reference", name: "name" }, }, { type: "text", value: "! You have " }, { type: "expression", arg: { type: "variable-reference", name: "count" }, }, { type: "text", value: " messages." }, ], }, ], }, { id: "insane_cats_de", bundleId: "insane_cats", locale: "de", selectors: [], variants: [ { id: "insane_cats_de_variant_one", messageId: "insane_cats_de", matches: [], pattern: [ { type: "text", value: "Hallo " }, { type: "expression", arg: { type: "variable-reference", name: "name" }, }, { type: "text", value: "! Du hast " }, { type: "expression", arg: { type: "variable-reference", name: "count" }, }, { type: "text", value: " Nachrichten." }, ], }, ], }, ], }, ]; for (const bundle of mockBundles) { await insertBundleNested(project.db, bundle); } function createBundleNested(args) { return { id: args.id, declarations: args.declarations ?? [], messages: args.messages.map((message) => ({ id: args.id + "_" + message.locale, bundleId: args.id, locale: message.locale, selectors: message.selectors ?? [], variants: message.variants.map((variant) => ({ id: args.id + "_" + message.locale + "_" + Math.random().toString(36).slice(2), messageId: args.id, matches: variant.matches ?? [], pattern: variant.pattern, })), })), }; }