@inlang/paraglide-js
Version:
[](https://www.npmjs.com/package/@inlang/paraglide-js) [ • 72 kB
JavaScript
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