UNPKG

@inlang/paraglide-js

Version:

[![NPM Downloads](https://img.shields.io/npm/dw/%40inlang%2Fparaglide-js?logo=npm&logoColor=red&label=npm%20downloads)](https://www.npmjs.com/package/@inlang/paraglide-js) [![GitHub Issues](https://img.shields.io/github/issues-closed/opral/paraglide-js?lo

1,234 lines (1,208 loc) 72 kB
import { expect, test, describe, vi, beforeEach } from "vitest"; import { createProject as typescriptProject, ts, } from "@ts-morph/bootstrap"; import { Declaration, insertBundleNested, loadProjectInMemory, newProject, Pattern, VariableReference, } from "@inlang/sdk"; import { compileProject, getFallbackMap } 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; }); function hasFallbackCycle(fallbackMap, start) { const visited = new Set(); let current = start; while (current) { if (visited.has(current)) return true; visited.add(current); current = fallbackMap[current]; } return false; } 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("emits messages/package.json with sideEffects:false for message-modules", async () => { const project = await loadProjectInMemory({ blob: await newProject({ settings: { locales: ["en", "de"], baseLocale: "en", }, }), }); const messageModules = await compileProject({ project, }); const localeModules = await compileProject({ project, compilerOptions: { outputStructure: "locale-modules" }, }); // message-modules: the message modules are side-effect-free, so bundlers can // drop unused re-exports from the `m` barrel per entry instead of emitting one // shared chunk with every message. `type: "module"` keeps the generated ESM // files in the new `messages/` package scope from defaulting to CommonJS. expect(messageModules).toHaveProperty("messages/package.json"); expect(JSON.parse(messageModules["messages/package.json"])).toEqual({ type: "module", sideEffects: false, }); // scoped to message-modules (the structure with the re-export barrel) expect(localeModules).not.toHaveProperty("messages/package.json"); }); 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("emitReadme", async () => { const project = await loadProjectInMemory({ blob: await newProject({ settings: { locales: ["en", "de"], baseLocale: "en", }, }), }); const projectPath = "./project.inlang"; const _default = await compileProject({ project, projectPath, }); const _true = await compileProject({ project, projectPath, compilerOptions: { emitReadme: true }, }); const _false = await compileProject({ project, projectPath, compilerOptions: { emitReadme: false }, }); const _noProjectPath = await compileProject({ project, }); expect(_default).toHaveProperty("README.md"); expect(_true).toHaveProperty("README.md"); expect(_false).not.toHaveProperty("README.md"); // README should still be emitted even without projectPath expect(_noProjectPath).toHaveProperty("README.md"); // but should not contain the "Compiled from:" line expect(_noProjectPath["README.md"]).not.toContain("Compiled from:"); }); test("emitReadme includes project path", async () => { const project = await loadProjectInMemory({ blob: await newProject({ settings: { locales: ["en", "de"], baseLocale: "en", }, }), }); const projectPath = "./my-custom-project.inlang"; const output = await compileProject({ project, projectPath, }); expect(output["README.md"]).toContain(projectPath); expect(output["README.md"]).toContain("Compiled from:"); expect(output["README.md"]).toContain("Paraglide JS"); }); test("throws during compile for invalid routeStrategies match patterns", async () => { const project = await loadProjectInMemory({ blob: await newProject({ settings: { locales: ["en", "de"], baseLocale: "en", }, }), }); await expect(compileProject({ project, compilerOptions: { routeStrategies: [{ match: "/api/:path(.*", exclude: true }], }, })).rejects.toThrow(`Invalid routeStrategies[0].match "/api/:path(.*"`); }); // https://github.com/opral/paraglide-js/issues/539 test("omits async_hooks import when disableAsyncLocalStorage is true", async () => { const project = await loadProjectInMemory({ blob: await newProject({ settings: { locales: ["en", "de"], baseLocale: "en", }, }), }); const output = await compileProject({ project, compilerOptions: { disableAsyncLocalStorage: true }, }); expect(output["server.js"]).not.toContain('const { AsyncLocalStorage } = await import("async_hooks");'); }); // https://github.com/opral/paraglide-js/issues/544 test("fallback map should not create cycles for language-only + regional baseLocale", () => { const fallbackMap = getFallbackMap(["it", "it-IT", "fr", "fr-FR"], "it-IT"); expect(hasFallbackCycle(fallbackMap, "it")).toBe(false); expect(hasFallbackCycle(fallbackMap, "it-IT")).toBe(false); expect(fallbackMap["it"]).toBe("it-IT"); expect(fallbackMap["it-IT"]).toBeUndefined(); }); test("emitTsDeclarations generates declaration files", async () => { const output = await compileProject({ project, compilerOptions: { emitTsDeclarations: true, outputStructure: "locale-modules", }, }); expect(output).toHaveProperty("messages/_index.d.ts"); expect(output).toHaveProperty("messages.d.ts"); expect(output).toHaveProperty("registry.d.ts"); expect(output["messages/_index.d.ts"]).toContain("sad_penguin_bundle"); expect(output["messages/_index.d.ts"]).toContain("relative_time_dynamic"); expect(output["registry.d.ts"]).toContain("RelativeTimeFormatUnit"); expect(output["registry.d.ts"]).toContain("relativetime"); const tsProject = await typescriptProject({ useInMemoryFileSystem: true, compilerOptions: { module: ts.ModuleKind.Node16, moduleResolution: ts.ModuleResolutionKind.Node16, strict: true, }, }); for (const [fileName, code] of Object.entries(output)) { if (fileName.endsWith(".d.ts")) { tsProject.createSourceFile(fileName, code); } } tsProject.createSourceFile("test.ts", ` import { relative_time_dynamic } from "./messages.js"; import { relativetime } from "./registry.js"; relative_time_dynamic({ duration: -3, unit: "hour" }) satisfies string; relativetime("en", -3, { unit: "hour", style: "short" }) satisfies string; `); const program = tsProject.createProgram(); const diagnostics = ts.getPreEmitDiagnostics(program); for (const diagnostic of diagnostics) { console.error(diagnostic.messageText, diagnostic.file?.fileName); } expect(diagnostics.length).toEqual(0); }); test("emitTsDeclarations quotes unsafe export aliases", async () => { const project = await loadProjectInMemory({ blob: await newProject({ settings: { locales: ["en"], baseLocale: "en", }, }), }); await insertBundleNested(project.db, createBundleNested({ id: "greeting.hello", messages: [ { locale: "en", variants: [{ pattern: [{ type: "text", value: "Hello" }] }], }, ], })); for (const outputStructure of [ "message-modules", "locale-modules", ]) { const output = await compileProject({ project, compilerOptions: { emitTsDeclarations: true, outputStructure, }, }); const declarationFile = outputStructure === "message-modules" ? "messages/greeting_hello.d.ts" : "messages/_index.d.ts"; expect(output[declarationFile]).toContain(`export { greeting_hello as "greeting.hello" };`); const tsProject = await typescriptProject({ useInMemoryFileSystem: true, compilerOptions: { module: ts.ModuleKind.Node16, moduleResolution: ts.ModuleResolutionKind.Node16, strict: true, }, }); for (const [fileName, code] of Object.entries(output)) { if (fileName.endsWith(".d.ts")) { tsProject.createSourceFile(fileName, code); } } tsProject.createSourceFile("test.ts", ` import { "greeting.hello" as greetingHello } from "./messages.js"; greetingHello() satisfies string; `); const program = tsProject.createProgram(); const diagnostics = ts.getPreEmitDiagnostics(program); for (const diagnostic of diagnostics) { console.error(diagnostic.messageText, diagnostic.file?.fileName); } expect(diagnostics.length).toEqual(0); } }); 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!"); }); test("emits .parts() on bundle functions for markup messages", async () => { const project = await loadProjectInMemory({ blob: await newProject({ settings: { locales: ["en", "de"], baseLocale: "en", }, }), }); await insertBundleNested(project.db, createBundleNested({ id: "balance", declarations: [ { type: "input-variable", name: "amount", }, ], messages: [ { locale: "en", variants: [ { pattern: [ { type: "text", value: "You have " }, { type: "markup-start", name: "strong", options: [], attributes: [], }, { type: "expression", arg: { type: "variable-reference", name: "amount" }, }, { type: "text", value: " coins" }, { type: "markup-end", name: "strong", options: [], attributes: [], }, { type: "text", value: "." }, ], }, ], }, ], })); 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.balance({ amount: 7 }, { locale: "en" })).toBe("You have 7 coins."); expect(m.balance.parts({ amount: 7 }, { locale: "en" })).toEqual([ { type: "text", value: "You have " }, { type: "markup-start", name: "strong", options: {}, attributes: {} }, { type: "text", value: "7" }, { type: "text", value: " coins" }, { type: "markup-end", name: "strong", options: {}, attributes: {} }, { type: "text", value: "." }, ]); }); // 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("relativetime formatter works with literal units and locale overrides", async () => { const { m, runtime } = await importCode(code); runtime.setLocale("de"); expect(m.relative_time_literal({ duration: -1 })).toBe("Updated gestern."); expect(m.relative_time_literal({ duration: -1 }, { locale: "en" })).toBe("Updated yesterday."); }); test("relativetime formatter works with dynamic units", async () => { const { m, runtime } = await importCode(code); runtime.setLocale("en"); expect(m.relative_time_dynamic({ duration: -3, unit: "hour" })).toBe("Updated 3 hr. ago."); runtime.setLocale("de"); expect(m.relative_time_dynamic({ duration: 2, unit: "weeks" })).toBe("Updated in 2 Wochen."); }); 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("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("relativetime dynamic unit casts generated options without narrowing public inputs", () => { const relativeTimeDynamicFile = compilerOptions.outputStructure === "locale-modules" ? "messages/en.js" : "messages/relative_time_dynamic.js"; expect(output).toHaveProperty(relativeTimeDynamicFile); const relativeTimeDynamicModule = output[relativeTimeDynamicFile]; expect(relativeTimeDynamicModule).toContain('unit: /** @type {import("../registry.js").RelativeTimeFormatUnit} */ (i?.unit)'); expect(relativeTimeDynamicModule).toContain("{ duration: NonNullable<unknown>, unit: NonNullable<unknown> }"); }); 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 // dynamic relativetime unit inputs stay consistent with other formatter inputs m.relative_time_literal({ duration: -1 }) satisfies string m.relative_time_dynamic({ duration: -3, unit: "hour" }) satisfies string m.relative_time_dynamic({ duration: -3, unit: "not-a-relative-time-unit" }) satisfies string // --------- MATCH TYPE INFERENCE --------- // known match values should be accepted m.auth_password_error({ type: "invalid" }) satisfies string m.auth_password_error({ type: "empty" }) satisfies string m.auth_password_error({ type: "min_length" }) satisfies string m.auth_password_error({ type: "secure" }) satisfies string // @ts-expect-error - unknown match value m.auth_password_error({ type: "typo" }) // quoted keys should still get literal unions m.error_with_dash({ "error-type": "network" }) satisfies string m.error_with_dash({ "error-type": "timeout" }) satisfies string // @ts-expect-error - unknown match value for quoted key m.error_with_dash({ "error-type": "offline" }) // catchall should widen match value typing m.auth_password_error_catchall({ type: "invalid" }) satisfies string m.auth_password_error_catchall({ type: "typo" }) satisfies string // multiple match keys should each get literal unions m.multi_key_status_type({ type: "invalid", status: "ready" }) satisfies string m.multi_key_status_type({ type: "empty", status: "done" }) satisfies string m.multi_key_status_type({ type: "secure", status: "failed" }) satisfies string // @ts-expect-error - invalid type literal m.multi_key_status_type({ type: "typo", status: "ready" }) // @ts-expect-error - invalid status literal m.multi_key_status_type({ type: "invalid", status: "pending" }) // numeric matches should accept both number and string forms m.numeric_input_match({ input: 1 }) satisfies string m.numeric_input_match({ input: 2 }) satisfies string m.numeric_input_match({ input: "1" }) satisfies string m.numeric_input_match({ input: "2" }) satisfies string // @ts-expect-error - invalid numeric literal m.numeric_input_match({ input: 3 }) // @ts-expect-error - invalid string literal m.numeric_input_match({ input: "3" }) // @ts-expect-error - no loose boolean coercion m.numeric_input_match({ input: true }) // empty matches should widen to NonNullable<unknown> m.empty_matches_catchall({ type: "invalid" }) satisfies string m.empty_matches_catchall({ type: "typo" }) 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); }); test("./messages.js types (single locale should not emit TS6133)", async () => { const singleLocaleProject = await loadProjectInMemory({ blob: await newProject({ settings: { locales: ["en"], baseLocale: "en" }, }), }); await insertBundleNested(singleLocaleProject.db, createBundleNested({ id: "single_locale_message", messages: [ { locale: "en", variants: [{ pattern: [{ type: "text", value: "Hello" }] }], }, ], })); const singleLocaleOutput = await compileProject({ project: singleLocaleProject, compilerOptions, }); const project = await typescriptProject({ useInMemoryFileSystem: true, compilerOptions: superStrictRuleOutAnyErrorTsSettings, }); for (const [fileName, code] of Object.entries(singleLocaleOutput)) { if (fileName.endsWith(".js") || fileName.endsWith(".ts")) { project.createSourceFile(fileName, code); } } project.createSourceFile("test.ts", `import { single_locale_message } from "./messages.js"; single_locale_message();`); const program = project.createProgram(); const diagnostics = ts.getPreEmitDiagnostics(program).filter((d) => { return !d.messageText .toString() .includes("Cannot find module 'async_hooks'"); }); const ts6133LocaleDiagnostics = diagnostics.filter((d) => { return (d.code === 6133 && d.messageText .toString() .includes("'locale' is declared but its value is never read")); }); expect(ts6133LocaleDiagnostics.length).toEqual(0); }); test("#625: number formatter digit options should pass checkJs", async () => { const projectWithNumberOptions = await loadProjectInMemory({ blob: await newProject({ settings: { baseLocale: "en", locales: ["en"], }, }), }); await insertBundleNested(projectWithNumberOptions.db, createBundleNested({ id: "formatted_percentage", declarations: [ { type: "input-variable", name: "value", }, { type: "local-variable", name: "formattedValue", value: { type: "expression", arg: { type: "variable-reference", name: "value" }, annotation: { type: "function-reference", name: "number", options: [ { name: "minimumFractionDigits", value: { type: "literal", value: "1" }, }, { name: "maximumFractionDigits", value: { type: "literal", value: "1" }, }, ], }, }, }, ], messages: [ { locale: "en", variants: [ { pattern: [ { type: "expression", arg: { type: "variable-reference", name: "formattedValue", }, }, { type: "text", value: "%" }, ], }, ], }, ], })); const output = await compileProject({ project: projectWithNumberOptions, compilerOptions, }); const tsProject = await typescriptProject({ useInMemoryFileSystem: true, compilerOptions: superStrictRuleOutAnyErrorTsSettings, }); for (const [fileName, code] of Object.entries(output)) { if (fileName.endsWith(".js") || fileName.endsWith(".ts")) { tsProject.createSourceFile(fileName, code); } } const program = tsProject.createProgram(); const diagnostics = ts.getPreEmitDiagnostics(program).filter((d) => { 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); }); test("#694: pattern-level annotations compile to registry calls and pass checkJs", async () => { const projectWithPatternAnnotation = await loadProjectInMemory({ blob: await newProject({ settings: { baseLocale: "en", locales: ["en"], }, }), }); await insertBundleNested(projectWithPatternAnnotation.db, createBundleNested({ id: "views", declarations: [ { type: "input-variable", name: "count", }, ], messages: [ { locale: "en", variants: [ { pattern: [ { type: "expression", arg: { type: "variable-reference", name: "count" }, annotation: { type: "function-reference", name: "number", options: [ { name: "maximumFractionDigits", value: { type: "literal", value: "1" }, }, ], }, }, { type: "text", value: " views" }, ], }, ], }, ], })); const output = await compileProject({ project: projectWithPatternAnnotation, compilerOptions, }); expect(Object.values(output).join("\n")).toContain('registry.number("en", i?.count, { maximumFractionDigits: 1 })'); const tsProject = await typescriptProject({ useInMemoryFileSystem: true, compilerOptions: superStrictRuleOutAnyErrorTsSettings, }); for (const [fileName, code] of Object.entries(output)) { if (fileName.endsWith(".js") || fileName.endsWith(".ts")) { tsProject.createSourceFile(fileName, code); } } const program = tsProject.createProgram(); const diagnostics = ts.getPreEmitDiagnostics(program).filter((d) => { 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); }); test("relativetime formatter options should pass checkJs", async () => { const projectWithRelativeTimeOptions = await loadProjectInMemory({ blob: await newProject({ settings: { baseLocale: "en", locales: ["en"], }, }), }); await insertBundleNested(projectWithRelativeTimeOptions.db, createBundleNested({ id: "formatted_relative_time", declarations: [ { type: "input-variable", name: "duration", }, { type: "input-variable", name: "unit", }, { type: "local-variable", name: "formattedDuration", value: { type: "expression", arg: { type: "variable-reference", name: "duration" }, annotation: { type: "function-reference", name: "relativetime", options: [ { name: "unit", value: { type: "variable-reference", name: "unit" }, }, { name: "style", value: { type: "literal", value: "short" }, }, ], }, }, }, ], messages: [ { locale: "en", variants: [ { pattern: [ { type: "expression", arg: { type: "variable-reference", name: "formattedDuration", }, }, ], }, ], }, ], })); const output = await compileProject({ project: projectWithRelativeTimeOptions, compilerOptions, }); const tsProject = await typescriptProject({ useInMemoryFileSystem: true, compilerOptions: superStrictRuleOutAnyErrorTsSettings, }); for (const [fileName, code] of Object.entries(output)) { if (fileName.endsWith(".js") || fileName.endsWith(".ts")) { tsProject.createSourceFile(fileName, code); } } const program = tsProject.createProgram(); const diagnostics = ts.getPreEmitDiagnostics(program).filter((d) => { return !d.messageText .toString() .includes("Cannot find module 'async_hooks'"); }); for (const diagnostic of diagnostics) { console.error(diagnostic.messageText, diagnostic.file?.fileName); } expect(d