diff --git a/.changeset/soft-chairs-boil.md b/.changeset/soft-chairs-boil.md new file mode 100644 index 0000000000..c2d90f56c9 --- /dev/null +++ b/.changeset/soft-chairs-boil.md @@ -0,0 +1,18 @@ +--- +"@inlang/paraglide-js": minor +--- + +feat: expose compiler as library + +closes https://github.com/opral/inlang-paraglide-js/issues/206 + +The Paraglide compiler is now exposed as a library. This allows you to use and extend the compiler however you need. + +```ts +import { compile } from '@inlang/paraglide-js/compiler'; + +await compile({ + path: "/path/to/project.inlang", + outdir: "/path/to/output", +}); +``` \ No newline at end of file diff --git a/.changeset/stupid-tips-burn.md b/.changeset/stupid-tips-burn.md new file mode 100644 index 0000000000..09eff44e6c --- /dev/null +++ b/.changeset/stupid-tips-burn.md @@ -0,0 +1,7 @@ +--- +"@lix-js/server-api-schema": patch +--- + +fix: emit .js file for node js + +Otherwise, node js will throw "UNKNOWN FILE EXTENSION .ts". diff --git a/packages/inlang-paraglide-js/example/package.json b/packages/inlang-paraglide-js/example/package.json deleted file mode 100644 index 75d17e29d4..0000000000 --- a/packages/inlang-paraglide-js/example/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "@inlang/paraglide-js-example-typescript", - "type": "module", - "version": "0.0.0", - "private": true, - "scripts": { - "_dev": "paraglide-js compile --project ./project.inlang --watch", - "build": "paraglide-js compile --project ./project.inlang", - "pretest": "paraglide-js compile --project ./project.inlang", - "test": "tsc --noEmit && vitest run" - }, - "devDependencies": { - "@inlang/paraglide-js": "workspace:*", - "@inlang/plugin-message-format": "workspace:*", - "typescript": "^5.5.2", - "vitest": "^2.0.5" - } -} diff --git a/packages/inlang-paraglide-js/example/project.inlang/settings.json b/packages/inlang-paraglide-js/example/project.inlang/settings.json deleted file mode 100644 index 64e9ffa13f..0000000000 --- a/packages/inlang-paraglide-js/example/project.inlang/settings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "$schema": "https://inlang.com/schema/project-settings", - "baseLocale": "en", - "locales": ["en", "de", "en-US"], - "modules": ["../../../plugins/inlang-message-format/dist/index.js"], - "plugin.inlang.messageFormat": { - "pathPattern": "./messages/{locale}.json" - } -} diff --git a/packages/inlang-paraglide-js/example/.vscode/extensions.json b/packages/inlang-paraglide-js/examples/cli/.vscode/extensions.json similarity index 100% rename from packages/inlang-paraglide-js/example/.vscode/extensions.json rename to packages/inlang-paraglide-js/examples/cli/.vscode/extensions.json diff --git a/packages/inlang-paraglide-js/example/CHANGELOG.md b/packages/inlang-paraglide-js/examples/cli/CHANGELOG.md similarity index 100% rename from packages/inlang-paraglide-js/example/CHANGELOG.md rename to packages/inlang-paraglide-js/examples/cli/CHANGELOG.md diff --git a/packages/inlang-paraglide-js/example/messages/de.json b/packages/inlang-paraglide-js/examples/cli/messages/de.json similarity index 100% rename from packages/inlang-paraglide-js/example/messages/de.json rename to packages/inlang-paraglide-js/examples/cli/messages/de.json diff --git a/packages/inlang-paraglide-js/example/messages/en-US.json b/packages/inlang-paraglide-js/examples/cli/messages/en-US.json similarity index 100% rename from packages/inlang-paraglide-js/example/messages/en-US.json rename to packages/inlang-paraglide-js/examples/cli/messages/en-US.json diff --git a/packages/inlang-paraglide-js/example/messages/en.json b/packages/inlang-paraglide-js/examples/cli/messages/en.json similarity index 100% rename from packages/inlang-paraglide-js/example/messages/en.json rename to packages/inlang-paraglide-js/examples/cli/messages/en.json diff --git a/packages/inlang-paraglide-js/examples/cli/package.json b/packages/inlang-paraglide-js/examples/cli/package.json new file mode 100644 index 0000000000..b150e809d7 --- /dev/null +++ b/packages/inlang-paraglide-js/examples/cli/package.json @@ -0,0 +1,16 @@ +{ + "name": "@inlang/paraglide-js-example-cli", + "type": "module", + "version": "0.0.0", + "private": true, + "scripts": { + "_dev": "paraglide-js compile --project ./project.inlang --watch", + "build": "paraglide-js compile --project ./project.inlang", + "pretest": "paraglide-js compile --project ./project.inlang" + }, + "devDependencies": { + "@inlang/paraglide-js": "workspace:*", + "typescript": "^5.5.2", + "vitest": "^2.0.5" + } +} diff --git a/packages/inlang-paraglide-js/example/project.inlang/.gitignore b/packages/inlang-paraglide-js/examples/cli/project.inlang/.gitignore similarity index 100% rename from packages/inlang-paraglide-js/example/project.inlang/.gitignore rename to packages/inlang-paraglide-js/examples/cli/project.inlang/.gitignore diff --git a/packages/inlang-paraglide-js/example/project.inlang/project_id b/packages/inlang-paraglide-js/examples/cli/project.inlang/project_id similarity index 100% rename from packages/inlang-paraglide-js/example/project.inlang/project_id rename to packages/inlang-paraglide-js/examples/cli/project.inlang/project_id diff --git a/packages/inlang-paraglide-js/examples/cli/project.inlang/settings.json b/packages/inlang-paraglide-js/examples/cli/project.inlang/settings.json new file mode 100644 index 0000000000..dcd6ef69dc --- /dev/null +++ b/packages/inlang-paraglide-js/examples/cli/project.inlang/settings.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://inlang.com/schema/project-settings", + "baseLocale": "en", + "locales": ["en", "de", "en-US"], + "modules": [ + "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@latest/dist/index.js" + ], + "plugin.inlang.messageFormat": { + "pathPattern": "./messages/{locale}.json" + } +} diff --git a/packages/inlang-paraglide-js/example/src/main.ts b/packages/inlang-paraglide-js/examples/cli/src/main.ts similarity index 100% rename from packages/inlang-paraglide-js/example/src/main.ts rename to packages/inlang-paraglide-js/examples/cli/src/main.ts diff --git a/packages/inlang-paraglide-js/example/tsconfig.json b/packages/inlang-paraglide-js/examples/cli/tsconfig.json similarity index 100% rename from packages/inlang-paraglide-js/example/tsconfig.json rename to packages/inlang-paraglide-js/examples/cli/tsconfig.json diff --git a/packages/inlang-paraglide-js/package.json b/packages/inlang-paraglide-js/package.json index 73b92c3ddf..83570612b9 100644 --- a/packages/inlang-paraglide-js/package.json +++ b/packages/inlang-paraglide-js/package.json @@ -12,26 +12,6 @@ "type": "git", "url": "https://github.com/opral/inlang-paraglide-js" }, - "keywords": [ - "paraglide", - "javascript i18n", - "i18n", - "l10n", - "translation", - "internationalization", - "svelte", - "localization", - "lint", - "react", - "vue", - "angular", - "nextjs", - "next i18n", - "astro", - "astro i18n", - "solid", - "solidstart" - ], "bin": { "paraglide-js": "./bin/run.js" }, @@ -77,6 +57,8 @@ "vitest": "2.0.5" }, "exports": { + "./cli": "./dist/cli/index.js", + "./compiler": "./dist/compiler/index.js", ".": { "import": "./default/index.js", "types": "./default/index.d.ts" @@ -93,5 +75,25 @@ "import": "./dist/adapter-utils/index.js", "types": "./dist/adapter-utils/index.d.ts" } - } + }, + "keywords": [ + "paraglide", + "javascript i18n", + "i18n", + "l10n", + "translation", + "internationalization", + "svelte", + "localization", + "lint", + "react", + "vue", + "angular", + "nextjs", + "next i18n", + "astro", + "astro i18n", + "solid", + "solidstart" + ] } \ No newline at end of file diff --git a/packages/inlang-paraglide-js/src/cli/steps/run-compiler.ts b/packages/inlang-paraglide-js/src/cli/steps/run-compiler.ts index 96d895b687..ba1bc0451b 100644 --- a/packages/inlang-paraglide-js/src/cli/steps/run-compiler.ts +++ b/packages/inlang-paraglide-js/src/cli/steps/run-compiler.ts @@ -1,9 +1,9 @@ -import { selectBundleNested, type InlangProject } from "@inlang/sdk"; +import { type InlangProject } from "@inlang/sdk"; import type { CliStep } from "../utils.js"; import path from "node:path"; -import { compile } from "../../compiler/compile.js"; import { writeOutput } from "../../services/file-handling/write-output.js"; import fs from "node:fs"; +import { compileProject } from "../../compiler/compileProject.js"; export const runCompiler: CliStep< { @@ -14,14 +14,11 @@ export const runCompiler: CliStep< unknown > = async (ctx) => { const absoluteOutdir = path.resolve(process.cwd(), ctx.outdir); - const bundles = await selectBundleNested(ctx.project.db).execute(); - const settings = await ctx.project.settings.get(); - const output = await compile({ - bundles, - settings, + const output = await compileProject({ + project: ctx.project, }); await writeOutput(absoluteOutdir, output, ctx.fs); - return { ...ctx, bundles }; + return { ...ctx }; }; diff --git a/packages/inlang-paraglide-js/src/compiler/compile.test.ts b/packages/inlang-paraglide-js/src/compiler/compile.test.ts index 2b6b85b287..ca9f0e7ccd 100644 --- a/packages/inlang-paraglide-js/src/compiler/compile.test.ts +++ b/packages/inlang-paraglide-js/src/compiler/compile.test.ts @@ -1,692 +1,39 @@ -import { expect, test, describe, vi, beforeEach } from "vitest"; -import { createProject as typescriptProject, ts } from "@ts-morph/bootstrap"; import { - type BundleNested, - Declaration, - type Match, - Pattern, - ProjectSettings, - VariableReference, + loadProjectInMemory, + newProject, + saveProjectToDirectory, } from "@inlang/sdk"; +import { memfs } from "memfs"; +import { test, expect } from "vitest"; import { compile } from "./compile.js"; -import { rollup as _rollup } from "rollup"; -import virtual from "@rollup/plugin-virtual"; -beforeEach(() => { - // reset the imports to make sure that the runtime is reloaded - vi.resetModules(); - vi.restoreAllMocks(); -}); -describe("output-formalities", async () => { - const output = await compile({ - bundles: [], - settings: { locales: ["en", "de"], baseLocale: "en" }, - }); - // the compiled should be ignored to avoid merge conflicts - test("the files should include a gitignore file", async () => { - expect(output).toHaveProperty(".gitignore"); - expect(output[".gitignore"]).toContain("*"); - }); - // ignore all formatting stuff - test("the files should include a prettierignore file", async () => { - expect(output).toHaveProperty(".prettierignore"); - expect(output[".prettierignore"]).toContain("*"); - }); - - test("the files should include files for each language, even if there are no messages", async () => { - const output = await compile({ - bundles: [], - settings: { locales: ["en", "de"], baseLocale: "en" }, - }); - expect(output["messages/en.js"]).toBeDefined(); - expect(output["messages/de.js"]).toBeDefined(); - }); -}); - -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."); - }); - - 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." - ); - }); -}); - -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" - export * as en from "./paraglide/messages/en.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("it should be possible to directly import a message function via a resource file", async () => { - const { en } = await importCode(code); - - expect(en).toBeDefined(); - expect(en.sad_penguin_bundle()).toBe("A simple message."); - }); - - 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); - - 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("setting the locale as a getter function should be possible", async () => { - const { m, runtime } = await importCode(code); - - 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("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: any) => { - mockOnSetLocale(locale); - }); - - runtime.setLocale("de"); - expect(mockOnSetLocale).toHaveBeenLastCalledWith("de"); - - runtime.setLocale("en"); - expect(mockOnSetLocale).toHaveBeenLastCalledWith("en"); - - expect(mockOnSetLocale).toHaveBeenCalledTimes(2); - }); - - test("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 languageTag 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.setLanguageTag("de"); - expect(m.sad_penguin_bundle()).toBe("Eine einfache Nachricht."); - expect(m.sad_penguin_bundle(undefined, { languageTag: "en" })).toBe( - "A simple message." - ); - }); - - test("runtime.isAvailableLocale should only return `true` if a locale is passed to it", async () => { - const { runtime } = await importCode(code); - - for (const tag of runtime.availableLanguageTags) { - expect(runtime.isAvailableLocale(tag)).toBe(true); - } - - expect(runtime.isAvailableLocale("")).toBe(false); - expect(runtime.isAvailableLocale("pl")).toBe(false); - expect(runtime.isAvailableLocale("--")).toBe(false); - }); - - test("falls back to base locale", async () => { - const output = await compile({ - bundles: [ - createBundleNested({ - id: "missingInGerman", - messages: [ - { - locale: "en", - variants: [ - { pattern: [{ type: "text", value: "A simple message." }] }, - ], - }, - ], - }), - ], - settings: { locales: ["en", "de", "en-US"], baseLocale: "en" }, - }); - 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("falls back to parent locale if message doesn't exist", async () => { - const output = await compile({ - bundles: [ - 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." }, - ], - }, - ], - }, - ], - }), - createBundleNested({ - id: "missing_in_en_US", - messages: [ - { - locale: "en", - variants: [ - { pattern: [{ type: "text", value: "Fallback message." }] }, - ], - }, - ], - }), - ], - settings: { locales: ["en", "en-US"], baseLocale: "en" }, - }); - 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("loads a project and compiles it", async () => { + const project = await loadProjectInMemory({ + blob: await newProject({ + settings: { + baseLocale: "en", + locales: ["en", "de", "fr"], + }, + }), }); - test("throws an error if getLocale() returns an unavailable locale", async () => { - const { runtime } = await importCode(code); + const fs = memfs().fs as unknown as typeof import("node:fs"); - expect(() => { - runtime.setLocale(() => "dsklfgj"); - runtime.getLocale(); - }).toThrow(); + // save project to directory to test loading + await saveProjectToDirectory({ + project, + path: "/project.inlang", + fs: fs.promises, }); -}); -// remove with v3 of paraglide js -test("./runtime.js types", async () => { - const project = await typescriptProject({ - useInMemoryFileSystem: true, - compilerOptions: { - outDir: "dist", - declaration: true, - allowJs: true, - checkJs: true, - module: ts.ModuleKind.Node16, - strict: true, - }, + await compile({ + path: "/project.inlang", + outdir: "/output", + fs: fs, }); - for (const [fileName, code] of Object.entries(output)) { - if (fileName.endsWith(".js")) { - project.createSourceFile(fileName, code); - } - } - project.createSourceFile( - "test.ts", - ` - import * as runtime from "./runtime.js" - - // --------- RUNTIME --------- - - // getLocale() should return type should be a union of language tags, not a generic string - runtime.getLocale() satisfies "de" | "en" | "en-US" + const files = await fs.promises.readdir("/output"); - // availableLocales should have a narrow type, not a generic string - runtime.locales satisfies Readonly> - - // setLocale() should fail if the given language tag is not included in availableLocales - // @ts-expect-error - runtime.setLocale("fr") - - // setLocale() should not fail if the given language tag is included in availableLocales - runtime.setLocale("de") - - // setting the locale as a getter function should be possible - runtime.setLocale(() => "en") - - // isAvailableLocale should narrow the type of it's argument - const thing = 5; - if(runtime.isAvailableLocale(thing)) { - const a : "de" | "en" | "en-US" = thing - } else { - // @ts-expect-error - thing is not a language tag - const a : "de" | "en" | "en-US" = thing - } - ` - ); - - const program = project.createProgram(); - const diagnostics = ts.getPreEmitDiagnostics(program); - for (const diagnostic of diagnostics) { - console.error(diagnostic.messageText, diagnostic.file?.fileName); - } - expect(diagnostics.length).toEqual(0); + //runtime.js and messages.js are always compiled with the default options + expect(files).toEqual(expect.arrayContaining(["runtime.js", "messages.js"])); }); - -// remove with v3 of paraglide js -test("./runtime.js (legacy) types", async () => { - const project = await typescriptProject({ - useInMemoryFileSystem: true, - compilerOptions: { - outDir: "dist", - declaration: true, - allowJs: true, - checkJs: true, - module: ts.ModuleKind.Node16, - strict: true, - }, - }); - - for (const [fileName, code] of Object.entries(output)) { - if (fileName.endsWith(".js")) { - project.createSourceFile(fileName, code); - } - } - project.createSourceFile( - "test.ts", - ` - import * as runtime from "./runtime.js" - - // --------- RUNTIME --------- - - // sourceLanguageTag should have a narrow type, not a generic string - - runtime.sourceLanguageTag satisfies "en" - - // availableLanguageTags should have a narrow type, not a generic string - runtime.availableLanguageTags satisfies Readonly> - - // setLanguageTag() should fail if the given language tag is not included in availableLanguageTags - // @ts-expect-error - runtime.setLanguageTag("fr") - - // setLanguageTag() should not fail if the given language tag is included in availableLanguageTags - runtime.setLanguageTag("de") - - // languageTag should return type should be a union of language tags, not a generic string - runtime.languageTag() satisfies "de" | "en" | "en-US" - - // setting the language tag as a getter function should be possible - runtime.setLanguageTag(() => "en") - - // isAvailableLocale should narrow the type of it's argument - const thing = 5; - if(runtime.isAvailableLocale(thing)) { - const a : "de" | "en" | "en-US" = thing - } else { - // @ts-expect-error - thing is not a language tag - const a : "de" | "en" | "en-US" = thing - } - ` - ); - - const program = project.createProgram(); - const diagnostics = ts.getPreEmitDiagnostics(program); - for (const diagnostic of diagnostics) { - console.error(diagnostic.messageText, diagnostic.file?.fileName); - } - expect(diagnostics.length).toEqual(0); -}); - -test("./messages.js types", async () => { - const project = await typescriptProject({ - useInMemoryFileSystem: true, - compilerOptions: { - outDir: "dist", - declaration: true, - allowJs: true, - checkJs: true, - module: ts.ModuleKind.Node16, - strict: true, - }, - }); - - for (const [fileName, code] of Object.entries(output)) { - if (fileName.endsWith(".js")) { - 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 languageTag option should be optional - m.sad_penguin_bundle({}, {}) satisfies string - - // the languageTag option should be allowed - m.sad_penguin_bundle({}, { languageTag: "en" }) satisfies string - - // the languageTag option must be a valid language tag - // @ts-expect-error - invalid language tag - m.sad_penguin_bundle({}, { languageTag: "---" }) - ` - ); - - const program = project.createProgram(); - const diagnostics = ts.getPreEmitDiagnostics(program); - for (const diagnostic of diagnostics) { - console.error(diagnostic.messageText, diagnostic.file?.fileName); - } - expect(diagnostics.length).toEqual(0); -}); - -async function bundleCode(output: Record, file: string) { - const bundle = await _rollup({ - input: "main.js", - output: { - minifyInternalExports: false, - }, - 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: string) { - return await import( - `data:application/javascript;base64,${Buffer.from(code, "utf8").toString("base64")}` - ); -} - -const mockBundles: BundleNested[] = [ - 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." }] }, - ], - }, - ], - }), - { - 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." }, - ], - }, - ], - }, - ], - }, -]; - -const mockSettings: ProjectSettings = { - baseLocale: "en", - locales: ["en", "de", "en-US"], -}; - -const output = await compile({ - bundles: mockBundles, - settings: mockSettings, -}); - -function createBundleNested(args: { - id: string; - declarations?: Declaration[]; - messages: { - selectors?: VariableReference[]; - locale: string; - variants: { - matches?: Match[]; - pattern: Pattern; - }[]; - }[]; -}): BundleNested { - return { - id: args.id, - declarations: args.declarations ?? [], - messages: args.messages.map((message) => ({ - id: args.id, - bundleId: args.id, - locale: message.locale, - selectors: message.selectors ?? [], - variants: message.variants.map((variant) => ({ - id: args.id, - messageId: args.id, - matches: variant.matches ?? [], - pattern: variant.pattern, - })), - })), - }; -} diff --git a/packages/inlang-paraglide-js/src/compiler/compile.ts b/packages/inlang-paraglide-js/src/compiler/compile.ts index 6e1ffa29b0..95d368db5b 100644 --- a/packages/inlang-paraglide-js/src/compiler/compile.ts +++ b/packages/inlang-paraglide-js/src/compiler/compile.ts @@ -1,272 +1,38 @@ -import { compileBundle, type Resource } from "./compileBundle.js"; -import { jsIdentifier } from "../services/codegen/identifier.js"; -import { createRuntime } from "./runtime.js"; -import { createRegistry, DEFAULT_REGISTRY } from "./registry.js"; -import { type BundleNested, type ProjectSettings } from "@inlang/sdk"; -import * as prettier from "prettier"; -import { escapeForSingleQuoteString } from "../services/codegen/escape.js"; -import { lookup } from "../services/lookup.js"; - -const ignoreDirectory = `# ignore everything because the directory is auto-generated by inlang paraglide-js -# for more info visit https://inlang.com/m/gerre34r/paraglide-js -* -`; - -export type CompileOptions = { - bundles: Readonly; - settings: Pick; - /** - * The file-structure of the compiled output. - * - * @default "regular" - */ - outputStructure?: "regular" | "message-modules"; -}; - -const defaultCompileOptions = { - outputStructure: "regular", -} satisfies Partial; +import { loadProjectFromDirectory } from "@inlang/sdk"; +import path from "node:path"; +import { ENV_VARIABLES } from "../services/env-variables/index.js"; +import { compileProject } from "./compileProject.js"; +import { writeOutput } from "../services/file-handling/write-output.js"; /** - * A compile function takes a list of messages and project settings and returns - * a map of file names to file contents. + * Loads, compiles, and writes the output to disk. + * + * This is the main function to use when you want to compile a project. + * If you want to adjust inlang project loading, or the output, use + * `compileProject()` instead. * * @example - * const output = compile({ messages, settings }) - * console.log(output) - * >> { "messages.js": "...", "runtime.js": "..." } - */ -export const compile = async ( - args: CompileOptions -): Promise> => { - const opts = { - ...defaultCompileOptions, - ...args, - }; - - //Maps each language to it's fallback - //If there is no fallback, it will be undefined - const fallbackMap = getFallbackMap( - opts.settings.locales, - opts.settings.baseLocale - ); - const resources = opts.bundles.map((bundle) => - compileBundle({ - bundle, - fallbackMap, - registry: DEFAULT_REGISTRY, - }) - ); - - const output = - opts.outputStructure === "regular" - ? generateRegularOutput(resources, opts.settings, fallbackMap) - : generateModuleOutput(resources, opts.settings, fallbackMap); - - // // telemetry - // const pkgJson = await getPackageJson(fs, process.cwd()) - // const stack = getStackInfo(pkgJson) - // telemetry.capture( - // { - // event: "PARAGLIDE-JS compile executed", - // properties: { stack }, - // }, - // opts.projectId - // ) - - // telemetry.shutdown() - return await formatFiles(output); -}; - -function generateRegularOutput( - resources: Resource[], - settings: Pick, - fallbackMap: Record -): Record { - const indexFile = [ - "/* eslint-disable */", - 'import { getLocale } from "./runtime.js"', - settings.locales - .map( - (locale) => - `import * as ${jsIdentifier(locale)} from "./messages/${locale}.js"` - ) - .join("\n"), - resources.map(({ bundle }) => bundle.code).join("\n"), - ].join("\n"); - - const output: Record = { - ".prettierignore": ignoreDirectory, - ".gitignore": ignoreDirectory, - "runtime.js": createRuntime(settings), - "registry.js": createRegistry(), - "messages.js": indexFile, - }; - - // generate message files - for (const locale of settings.locales) { - const filename = `messages/${locale}.js`; - let file = ` -/* eslint-disable */ -/** - * This file contains language specific functions for tree-shaking. - * - *! WARNING: Only import from this file if you want to manually - *! optimize your bundle. Else, import from the \`messages.js\` file. + * await compile({ + * path: 'path/to/project', + * outdir: 'path/to/output', + * }) */ -import * as registry from '../registry.js'`; - - for (const resource of resources) { - const compiledMessage = resource.messages[locale]; - const id = jsIdentifier(resource.bundle.node.id); - if (!compiledMessage) { - const fallbackLocale = fallbackMap[locale]; - if (fallbackLocale) { - // use the fall back locale e.g. render the message in English if the German message is missing - file += `\nexport { ${id} } from "./${fallbackLocale}.js"`; - } else { - // no fallback exists, render the bundleId - file += `\nexport const ${id} = () => '${id}'`; - } - continue; - } - - file += `\n\n${compiledMessage.code}`; - } - - output[filename] = file; - } - return output; -} - -function generateModuleOutput( - resources: Resource[], - settings: Pick, - fallbackMap: Record -): Record { - const output: Record = { - ".prettierignore": ignoreDirectory, - ".gitignore": ignoreDirectory, - "runtime.js": createRuntime(settings), - "registry.js": createRegistry(), - }; - - // index messages - output["messages.js"] = [ - "/* eslint-disable */", - ...resources.map( - ({ bundle }) => `export * from './messages/index/${bundle.node.id}.js'` - ), - ].join("\n"); - - for (const resource of resources) { - const filename = `messages/index/${resource.bundle.node.id}.js`; - const code = [ - "/* eslint-disable */", - "import * as registry from '../../registry.js'", - settings.locales - .map( - (locale) => - `import * as ${jsIdentifier(locale)} from "../${locale}.js"` - ) - .join("\n"), - "import { languageTag } from '../../runtime.js'", - "", - resource.bundle.code, - ].join("\n"); - output[filename] = code; - } - - // generate locales - for (const locale of settings.locales) { - const messageIndexFile = [ - "/* eslint-disable */", - ...resources.map( - ({ bundle }) => `export * from './${locale}/${bundle.node.id}.js'` - ), - ].join("\n"); - output[`messages/${locale}.js`] = messageIndexFile; - - // generate individual message files - for (const resource of resources) { - let file = [ - "/* eslint-disable */", - "import * as registry from '../../registry.js' ", - ].join("\n"); - - const compiledMessage = resource.messages[locale]; - const id = jsIdentifier(resource.bundle.node.id); - if (!compiledMessage) { - // add fallback - const fallbackLocale = fallbackMap[locale]; - if (fallbackLocale) { - file += `\nexport { ${id} } from "../${fallbackLocale}.js"`; - } else { - file += `\nexport const ${id} = () => '${escapeForSingleQuoteString( - resource.bundle.node.id - )}'`; - } - } else { - file += `\n${compiledMessage.code}`; - } - - output[`messages/${locale}/${resource.bundle.node.id}.js`] = file; - } - } - return output; -} - -async function formatFiles( - files: Record -): Promise> { - const output: Record = {}; - const promises: Promise[] = []; - - for (const [key, value] of Object.entries(files)) { - if (!key.endsWith(".js")) { - output[key] = value; - continue; - } - - promises.push( - new Promise((resolve, reject) => { - fmt(value) - .then((formatted) => { - output[key] = formatted; - resolve(); - }) - .catch(reject); - }) - ); - } - - await Promise.all(promises); - return output; -} - -async function fmt(js: string): Promise { - return await prettier.format(js, { - arrowParens: "always", - singleQuote: true, - printWidth: 100, - parser: "babel", - plugins: ["prettier-plugin-jsdoc"], +export async function compile(args: { + path: string; + outdir: string; + fs: typeof import("node:fs"); +}): Promise { + const absoluteOutdir = path.resolve(process.cwd(), args.outdir); + + const project = await loadProjectFromDirectory({ + path: args.path, + fs: args.fs, + appId: ENV_VARIABLES.PARJS_APP_ID, }); -} -export function getFallbackMap( - locales: T[], - baseLocale: NoInfer -): Record { - return Object.fromEntries( - locales.map((lang) => { - const fallbackLanguage = lookup(lang, { - locales: locales.filter((l) => l !== lang), - baseLocale, - }); + const output = await compileProject({ + project, + }); - if (lang === fallbackLanguage) return [lang, undefined]; - else return [lang, fallbackLanguage]; - }) - ) as Record; + await writeOutput(absoluteOutdir, output, args.fs.promises); } diff --git a/packages/inlang-paraglide-js/src/compiler/compileProject.test.ts b/packages/inlang-paraglide-js/src/compiler/compileProject.test.ts new file mode 100644 index 0000000000..5cb759230d --- /dev/null +++ b/packages/inlang-paraglide-js/src/compiler/compileProject.test.ts @@ -0,0 +1,728 @@ +import { expect, test, describe, vi, beforeEach } from "vitest"; +import { createProject as typescriptProject, ts } from "@ts-morph/bootstrap"; +import { + type BundleNested, + Declaration, + insertBundleNested, + loadProjectInMemory, + type Match, + newProject, + Pattern, + VariableReference, +} from "@inlang/sdk"; +import { compileProject } from "./compileProject.js"; +import { rollup as _rollup } from "rollup"; +import virtual from "@rollup/plugin-virtual"; + +beforeEach(() => { + // reset the imports to make sure that the runtime is reloaded + vi.resetModules(); + vi.restoreAllMocks(); +}); +describe("output-formalities", async () => { + const project = await loadProjectInMemory({ + blob: await newProject({ + settings: { + locales: ["en", "de"], + baseLocale: "en", + }, + }), + }); + + const output = await compileProject({ project }); + // the compiled should be ignored to avoid merge conflicts + test("the files should include a gitignore file", async () => { + expect(output).toHaveProperty(".gitignore"); + expect(output[".gitignore"]).toContain("*"); + }); + // ignore all formatting stuff + test("the files should include a prettierignore file", async () => { + expect(output).toHaveProperty(".prettierignore"); + expect(output[".prettierignore"]).toContain("*"); + }); + + test("the files should include files for each language, even if there are no messages", async () => { + const output = await compileProject({ project }); + expect(output["messages/en.js"]).toBeDefined(); + expect(output["messages/de.js"]).toBeDefined(); + }); +}); + +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."); + }); + + 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." + ); + }); +}); + +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" + export * as en from "./paraglide/messages/en.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("it should be possible to directly import a message function via a resource file", async () => { + const { en } = await importCode(code); + + expect(en).toBeDefined(); + expect(en.sad_penguin_bundle()).toBe("A simple message."); + }); + + 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); + + 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("setting the locale as a getter function should be possible", async () => { + const { m, runtime } = await importCode(code); + + 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("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: any) => { + mockOnSetLocale(locale); + }); + + runtime.setLocale("de"); + expect(mockOnSetLocale).toHaveBeenLastCalledWith("de"); + + runtime.setLocale("en"); + expect(mockOnSetLocale).toHaveBeenLastCalledWith("en"); + + expect(mockOnSetLocale).toHaveBeenCalledTimes(2); + }); + + test("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 languageTag 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.setLanguageTag("de"); + expect(m.sad_penguin_bundle()).toBe("Eine einfache Nachricht."); + expect(m.sad_penguin_bundle(undefined, { languageTag: "en" })).toBe( + "A simple message." + ); + }); + + test("runtime.isAvailableLocale should only return `true` if a locale is passed to it", async () => { + const { runtime } = await importCode(code); + + for (const tag of runtime.availableLanguageTags) { + expect(runtime.isAvailableLocale(tag)).toBe(true); + } + + expect(runtime.isAvailableLocale("")).toBe(false); + expect(runtime.isAvailableLocale("pl")).toBe(false); + expect(runtime.isAvailableLocale("--")).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, + }); + 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("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, + }); + + 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("throws an error if getLocale() returns an unavailable locale", async () => { + const { runtime } = await importCode(code); + + expect(() => { + runtime.setLocale(() => "dsklfgj"); + runtime.getLocale(); + }).toThrow(); + }); +}); + +// remove with v3 of paraglide js +test("./runtime.js types", async () => { + const project = await typescriptProject({ + useInMemoryFileSystem: true, + compilerOptions: { + outDir: "dist", + declaration: true, + allowJs: true, + checkJs: true, + module: ts.ModuleKind.Node16, + strict: true, + }, + }); + + for (const [fileName, code] of Object.entries(output)) { + if (fileName.endsWith(".js")) { + project.createSourceFile(fileName, code); + } + } + project.createSourceFile( + "test.ts", + ` + import * as runtime from "./runtime.js" + + // --------- RUNTIME --------- + + // getLocale() should return type should be a union of language tags, not a generic string + runtime.getLocale() satisfies "de" | "en" | "en-US" + + // availableLocales should have a narrow type, not a generic string + runtime.locales satisfies Readonly> + + // setLocale() should fail if the given language tag is not included in availableLocales + // @ts-expect-error + runtime.setLocale("fr") + + // setLocale() should not fail if the given language tag is included in availableLocales + runtime.setLocale("de") + + // setting the locale as a getter function should be possible + runtime.setLocale(() => "en") + + // isAvailableLocale should narrow the type of it's argument + const thing = 5; + if(runtime.isAvailableLocale(thing)) { + const a : "de" | "en" | "en-US" = thing + } else { + // @ts-expect-error - thing is not a language tag + const a : "de" | "en" | "en-US" = thing + } + ` + ); + + const program = project.createProgram(); + const diagnostics = ts.getPreEmitDiagnostics(program); + for (const diagnostic of diagnostics) { + console.error(diagnostic.messageText, diagnostic.file?.fileName); + } + expect(diagnostics.length).toEqual(0); +}); + +// remove with v3 of paraglide js +test("./runtime.js (legacy) types", async () => { + const project = await typescriptProject({ + useInMemoryFileSystem: true, + compilerOptions: { + outDir: "dist", + declaration: true, + allowJs: true, + checkJs: true, + module: ts.ModuleKind.Node16, + strict: true, + }, + }); + + for (const [fileName, code] of Object.entries(output)) { + if (fileName.endsWith(".js")) { + project.createSourceFile(fileName, code); + } + } + project.createSourceFile( + "test.ts", + ` + import * as runtime from "./runtime.js" + + // --------- RUNTIME --------- + + // sourceLanguageTag should have a narrow type, not a generic string + + runtime.sourceLanguageTag satisfies "en" + + // availableLanguageTags should have a narrow type, not a generic string + runtime.availableLanguageTags satisfies Readonly> + + // setLanguageTag() should fail if the given language tag is not included in availableLanguageTags + // @ts-expect-error + runtime.setLanguageTag("fr") + + // setLanguageTag() should not fail if the given language tag is included in availableLanguageTags + runtime.setLanguageTag("de") + + // languageTag should return type should be a union of language tags, not a generic string + runtime.languageTag() satisfies "de" | "en" | "en-US" + + // setting the language tag as a getter function should be possible + runtime.setLanguageTag(() => "en") + + // isAvailableLocale should narrow the type of it's argument + const thing = 5; + if(runtime.isAvailableLocale(thing)) { + const a : "de" | "en" | "en-US" = thing + } else { + // @ts-expect-error - thing is not a language tag + const a : "de" | "en" | "en-US" = thing + } + ` + ); + + const program = project.createProgram(); + const diagnostics = ts.getPreEmitDiagnostics(program); + for (const diagnostic of diagnostics) { + console.error(diagnostic.messageText, diagnostic.file?.fileName); + } + expect(diagnostics.length).toEqual(0); +}); + +test("./messages.js types", async () => { + const project = await typescriptProject({ + useInMemoryFileSystem: true, + compilerOptions: { + outDir: "dist", + declaration: true, + allowJs: true, + checkJs: true, + module: ts.ModuleKind.Node16, + strict: true, + }, + }); + + for (const [fileName, code] of Object.entries(output)) { + if (fileName.endsWith(".js")) { + 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 languageTag option should be optional + m.sad_penguin_bundle({}, {}) satisfies string + + // the languageTag option should be allowed + m.sad_penguin_bundle({}, { languageTag: "en" }) satisfies string + + // the languageTag option must be a valid language tag + // @ts-expect-error - invalid language tag + m.sad_penguin_bundle({}, { languageTag: "---" }) + ` + ); + + const program = project.createProgram(); + const diagnostics = ts.getPreEmitDiagnostics(program); + for (const diagnostic of diagnostics) { + console.error(diagnostic.messageText, diagnostic.file?.fileName); + } + expect(diagnostics.length).toEqual(0); +}); + +async function bundleCode(output: Record, file: string) { + const bundle = await _rollup({ + input: "main.js", + output: { + minifyInternalExports: false, + }, + 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: string) { + return await import( + `data:application/javascript;base64,${Buffer.from(code, "utf8").toString("base64")}` + ); +} + +const project = await loadProjectInMemory({ + blob: await newProject({ + settings: { + baseLocale: "en", + locales: ["en", "de", "en-US"], + }, + }), +}); + +const mockBundles: BundleNested[] = [ + 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." }] }, + ], + }, + ], + }), + { + 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); +} + +const output = await compileProject({ project }); + +function createBundleNested(args: { + id: string; + declarations?: Declaration[]; + messages: { + selectors?: VariableReference[]; + locale: string; + variants: { + matches?: Match[]; + pattern: Pattern; + }[]; + }[]; +}): BundleNested { + 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 + + "_" + + variant.pattern.map((p) => p.type).join(""), + messageId: args.id, + matches: variant.matches ?? [], + pattern: variant.pattern, + })), + })), + }; +} diff --git a/packages/inlang-paraglide-js/src/compiler/compileProject.ts b/packages/inlang-paraglide-js/src/compiler/compileProject.ts new file mode 100644 index 0000000000..cef8ccf79f --- /dev/null +++ b/packages/inlang-paraglide-js/src/compiler/compileProject.ts @@ -0,0 +1,256 @@ +import { compileBundle, type Resource } from "./compileBundle.js"; +import { jsIdentifier } from "../services/codegen/identifier.js"; +import { createRuntime } from "./runtime.js"; +import { createRegistry, DEFAULT_REGISTRY } from "./registry.js"; +import { + selectBundleNested, + type InlangProject, + type ProjectSettings, +} from "@inlang/sdk"; +import * as prettier from "prettier"; +import { escapeForSingleQuoteString } from "../services/codegen/escape.js"; +import { lookup } from "../services/lookup.js"; + +const ignoreDirectory = `# ignore everything because the directory is auto-generated by inlang paraglide-js +# for more info visit https://inlang.com/m/gerre34r/paraglide-js +* +`; + +/** + * Takes an inlang project and compiles it into a set of files. + * + * Use this function for more programmatic control than `compile()`. + * You can adjust the output structure and get the compiled files as a return value. + * + * @example + * const output = await compileProject({ project }); + * await writeOutput('path', output, fs.promises); + */ +export const compileProject = async (_args: { + project: InlangProject; + /** + * The file-structure of the compiled output. + * + * @default "regular" + */ + outputStructure?: "regular" | "message-modules"; +}): Promise> => { + const args = { + outputStructure: "regular", + ..._args, + }; + + const settings = await args.project.settings.get(); + const bundles = await selectBundleNested(args.project.db).execute(); + + //Maps each language to it's fallback + //If there is no fallback, it will be undefined + const fallbackMap = getFallbackMap(settings.locales, settings.baseLocale); + const resources = bundles.map((bundle) => + compileBundle({ + bundle, + fallbackMap, + registry: DEFAULT_REGISTRY, + }) + ); + + const output = + args.outputStructure === "regular" + ? generateRegularOutput(resources, settings, fallbackMap) + : generateModuleOutput(resources, settings, fallbackMap); + + return await formatFiles(output); +}; + +function generateRegularOutput( + resources: Resource[], + settings: Pick, + fallbackMap: Record +): Record { + const indexFile = [ + "/* eslint-disable */", + 'import { getLocale } from "./runtime.js"', + settings.locales + .map( + (locale) => + `import * as ${jsIdentifier(locale)} from "./messages/${locale}.js"` + ) + .join("\n"), + resources.map(({ bundle }) => bundle.code).join("\n"), + ].join("\n"); + + const output: Record = { + ".prettierignore": ignoreDirectory, + ".gitignore": ignoreDirectory, + "runtime.js": createRuntime(settings), + "registry.js": createRegistry(), + "messages.js": indexFile, + }; + + // generate message files + for (const locale of settings.locales) { + const filename = `messages/${locale}.js`; + let file = ` +/* eslint-disable */ +/** + * This file contains language specific functions for tree-shaking. + * + *! WARNING: Only import from this file if you want to manually + *! optimize your bundle. Else, import from the \`messages.js\` file. + */ +import * as registry from '../registry.js'`; + + for (const resource of resources) { + const compiledMessage = resource.messages[locale]; + const id = jsIdentifier(resource.bundle.node.id); + if (!compiledMessage) { + const fallbackLocale = fallbackMap[locale]; + if (fallbackLocale) { + // use the fall back locale e.g. render the message in English if the German message is missing + file += `\nexport { ${id} } from "./${fallbackLocale}.js"`; + } else { + // no fallback exists, render the bundleId + file += `\nexport const ${id} = () => '${id}'`; + } + continue; + } + + file += `\n\n${compiledMessage.code}`; + } + + output[filename] = file; + } + return output; +} + +function generateModuleOutput( + resources: Resource[], + settings: Pick, + fallbackMap: Record +): Record { + const output: Record = { + ".prettierignore": ignoreDirectory, + ".gitignore": ignoreDirectory, + "runtime.js": createRuntime(settings), + "registry.js": createRegistry(), + }; + + // index messages + output["messages.js"] = [ + "/* eslint-disable */", + ...resources.map( + ({ bundle }) => `export * from './messages/index/${bundle.node.id}.js'` + ), + ].join("\n"); + + for (const resource of resources) { + const filename = `messages/index/${resource.bundle.node.id}.js`; + const code = [ + "/* eslint-disable */", + "import * as registry from '../../registry.js'", + settings.locales + .map( + (locale) => + `import * as ${jsIdentifier(locale)} from "../${locale}.js"` + ) + .join("\n"), + "import { languageTag } from '../../runtime.js'", + "", + resource.bundle.code, + ].join("\n"); + output[filename] = code; + } + + // generate locales + for (const locale of settings.locales) { + const messageIndexFile = [ + "/* eslint-disable */", + ...resources.map( + ({ bundle }) => `export * from './${locale}/${bundle.node.id}.js'` + ), + ].join("\n"); + output[`messages/${locale}.js`] = messageIndexFile; + + // generate individual message files + for (const resource of resources) { + let file = [ + "/* eslint-disable */", + "import * as registry from '../../registry.js' ", + ].join("\n"); + + const compiledMessage = resource.messages[locale]; + const id = jsIdentifier(resource.bundle.node.id); + if (!compiledMessage) { + // add fallback + const fallbackLocale = fallbackMap[locale]; + if (fallbackLocale) { + file += `\nexport { ${id} } from "../${fallbackLocale}.js"`; + } else { + file += `\nexport const ${id} = () => '${escapeForSingleQuoteString( + resource.bundle.node.id + )}'`; + } + } else { + file += `\n${compiledMessage.code}`; + } + + output[`messages/${locale}/${resource.bundle.node.id}.js`] = file; + } + } + return output; +} + +async function formatFiles( + files: Record +): Promise> { + const output: Record = {}; + const promises: Promise[] = []; + + for (const [key, value] of Object.entries(files)) { + if (!key.endsWith(".js")) { + output[key] = value; + continue; + } + + promises.push( + new Promise((resolve, reject) => { + fmt(value) + .then((formatted) => { + output[key] = formatted; + resolve(); + }) + .catch(reject); + }) + ); + } + + await Promise.all(promises); + return output; +} + +async function fmt(js: string): Promise { + return await prettier.format(js, { + arrowParens: "always", + singleQuote: true, + printWidth: 100, + parser: "babel", + plugins: ["prettier-plugin-jsdoc"], + }); +} + +export function getFallbackMap( + locales: T[], + baseLocale: NoInfer +): Record { + return Object.fromEntries( + locales.map((lang) => { + const fallbackLanguage = lookup(lang, { + locales: locales.filter((l) => l !== lang), + baseLocale, + }); + + if (lang === fallbackLanguage) return [lang, undefined]; + else return [lang, fallbackLanguage]; + }) + ) as Record; +} diff --git a/packages/inlang-paraglide-js/src/compiler/index.ts b/packages/inlang-paraglide-js/src/compiler/index.ts new file mode 100644 index 0000000000..5dd0498714 --- /dev/null +++ b/packages/inlang-paraglide-js/src/compiler/index.ts @@ -0,0 +1,6 @@ +export { compile } from "./compile.js"; +export { compileProject } from "./compileProject.js"; +export { compileBundle } from "./compileBundle.js"; +export { compileMessage } from "./compileMessage.js"; +export { compilePattern } from "./compilePattern.js"; +export { writeOutput } from "../services/file-handling/write-output.js"; diff --git a/packages/inlang-paraglide-js/src/index.ts b/packages/inlang-paraglide-js/src/index.ts index 7f5485679f..0e13a65a94 100644 --- a/packages/inlang-paraglide-js/src/index.ts +++ b/packages/inlang-paraglide-js/src/index.ts @@ -1,4 +1,4 @@ -export { compile } from "./compiler/compile.js"; +export { compileProject as compile } from "./compiler/compileProject.js"; export { writeOutput } from "./services/file-handling/write-output.js"; export { Logger, type LoggerOptions } from "./services/logger/index.js"; export { classifyProjectErrors } from "./services/error-handling.js"; diff --git a/packages/lix-server-api-schema/.gitignore b/packages/lix-server-api-schema/.gitignore new file mode 100644 index 0000000000..dda85b56ab --- /dev/null +++ b/packages/lix-server-api-schema/.gitignore @@ -0,0 +1,2 @@ +!/dist +/dist/schema.d.ts \ No newline at end of file diff --git a/packages/lix-server-api-schema/dist/schema.js b/packages/lix-server-api-schema/dist/schema.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/lix-server-api-schema/package.json b/packages/lix-server-api-schema/package.json index 46796c87e0..1a8832fe43 100644 --- a/packages/lix-server-api-schema/package.json +++ b/packages/lix-server-api-schema/package.json @@ -5,7 +5,7 @@ "version": "0.1.0", "license": "Apache-2.0", "exports": { - ".": "./dist/schema.d.ts" + ".": "./dist/schema.js" }, "scripts": { "build": "npx openapi-typescript ./src/schema.yaml -o ./dist/schema.d.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b76328d8a0..866b12a2be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,6 +179,18 @@ importers: specifier: 2.0.5 version: 2.0.5(@types/node@20.16.13)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.36.0) + packages/inlang-paraglide-js/examples/cli: + devDependencies: + '@inlang/paraglide-js': + specifier: workspace:* + version: link:../.. + typescript: + specifier: ^5.5.2 + version: 5.7.2 + vitest: + specifier: ^2.0.5 + version: 2.1.8(@types/node@22.10.1)(jsdom@25.0.1)(lightningcss@1.27.0)(msw@2.4.11(typescript@5.7.2))(terser@5.36.0) + packages/inlang-sdk: dependencies: '@lix-js/sdk': @@ -540,7 +552,7 @@ importers: version: 22.10.1 prettier: specifier: ^3.3.3 - version: 3.3.3 + version: 3.4.2 typescript: specifier: ^5.7.2 version: 5.7.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 21979c5ac2..6dc7af42f1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,5 @@ packages: - 'packages/*' - - 'packages/lix-sdk/examples/*' + - 'packages/*/example' + - 'packages/*/examples/*' \ No newline at end of file