diff --git a/.changeset/config.json b/.changeset/config.json index b64eb0e8c7..9fc95b456b 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -9,6 +9,8 @@ "@inlang/cli", "@inlang/fink", "@inlang/paraglide-js", + "@inlang/paraglide-next", + "@inlang/paraglide-astro", "@inlang/paraglide-sveltekit", "@inlang/paraglide-js-example-cli", "@inlang/paraglide-js-example-vite", diff --git a/inlang/packages/paraglide/paraglide-astro/example/package.json b/inlang/packages/paraglide/paraglide-astro/example/package.json index 9f20c213af..72fb71c0fa 100644 --- a/inlang/packages/paraglide/paraglide-astro/example/package.json +++ b/inlang/packages/paraglide/paraglide-astro/example/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "astro dev", "start": "astro dev", - "build": "paraglide-js compile --project ./project.inlang && astro check && astro build", + "build": "astro check && astro build", "preview": "astro preview", "astro": "astro" }, diff --git a/inlang/packages/paraglide/paraglide-astro/example/src/components/BaseHead.astro b/inlang/packages/paraglide/paraglide-astro/example/src/components/BaseHead.astro index fe1ec6ef1b..ccb8620bc1 100644 --- a/inlang/packages/paraglide/paraglide-astro/example/src/components/BaseHead.astro +++ b/inlang/packages/paraglide/paraglide-astro/example/src/components/BaseHead.astro @@ -8,7 +8,7 @@ interface Props { image?: string; } -const { title, description, image = "/blog-placeholder-1.jpg" } = Astro.props; +const { title, description } = Astro.props; const pathWithoutLocale = deLocalizePath(Astro.url.pathname); --- diff --git a/inlang/packages/paraglide/paraglide-astro/package.json b/inlang/packages/paraglide/paraglide-astro/package.json index 38843f0ba3..269730e1ef 100644 --- a/inlang/packages/paraglide/paraglide-astro/package.json +++ b/inlang/packages/paraglide/paraglide-astro/package.json @@ -1,6 +1,6 @@ { "name": "@inlang/paraglide-astro", - "version": "1.0.0-beta.3", + "version": "1.0.0-beta.4", "author": "inlang (https://inlang.com/)", "description": "A fully type-safe i18n library specifically designed for partial hydration patterns like Astro's islands.", "homepage": "https://inlang.com/m/iljlwzfs/paraglide-astro-i18n", @@ -22,8 +22,10 @@ "format": "prettier ./src --write", "clean": "rm -rf ./dist ./node_modules" }, + "dependencies": { + "@inlang/paraglide-js": "workspace:*" + }, "peerDependencies": { - "@inlang/paraglide-js": "^2", "astro": "^4 || ^5" }, "devDependencies": { diff --git a/inlang/packages/paraglide/paraglide-js/src/bundler-plugins/unplugin.ts b/inlang/packages/paraglide/paraglide-js/src/bundler-plugins/unplugin.ts index 96df62d0c2..69ec608a1c 100644 --- a/inlang/packages/paraglide/paraglide-js/src/bundler-plugins/unplugin.ts +++ b/inlang/packages/paraglide/paraglide-js/src/bundler-plugins/unplugin.ts @@ -3,17 +3,24 @@ import { compile, type CompilerOptions } from "../compiler/compile.js"; import fs from "node:fs"; import { resolve } from "node:path"; import { nodeNormalizePath } from "../utilities/node-normalize-path.js"; +import { Logger } from "../cli/index.js"; const PLUGIN_NAME = "unplugin-paraglide-js"; +const logger = new Logger(); + +let compilationResult: Awaited> | undefined; + export const unpluginFactory: UnpluginFactory = (args) => ({ name: PLUGIN_NAME, enforce: "pre", async buildStart() { - await compile({ + logger.info("Compiling inlang project..."); + compilationResult = await compile({ fs: wrappedFs, ...args, }); + logger.success("Compilation complete"); for (const path of Array.from(readFiles)) { this.addWatchFile(path); @@ -23,10 +30,15 @@ export const unpluginFactory: UnpluginFactory = (args) => ({ const shouldCompile = readFiles.has(path) && !path.includes("cache"); if (shouldCompile) { readFiles.clear(); - await compile({ - fs: wrappedFs, - ...args, - }); + logger.info("Re-compiling inlang project..."); + compilationResult = await compile( + { + fs: wrappedFs, + ...args, + }, + compilationResult?.outputHashes + ); + logger.success("Compilation complete"); } }, webpack(compiler) { diff --git a/inlang/packages/paraglide/paraglide-js/src/cli/steps/run-compiler.ts b/inlang/packages/paraglide/paraglide-js/src/cli/steps/run-compiler.ts index 2abb515c38..9ac54ee069 100644 --- a/inlang/packages/paraglide/paraglide-js/src/cli/steps/run-compiler.ts +++ b/inlang/packages/paraglide/paraglide-js/src/cli/steps/run-compiler.ts @@ -19,6 +19,6 @@ export const runCompiler: CliStep< project: ctx.project, }); - await writeOutput(absoluteOutdir, output, ctx.fs); + await writeOutput({ directory: absoluteOutdir, output, fs: ctx.fs }); return { ...ctx }; }; diff --git a/inlang/packages/paraglide/paraglide-js/src/compiler/compile-bundle.test.ts b/inlang/packages/paraglide/paraglide-js/src/compiler/compile-bundle.test.ts index 8cf07a90d3..56f6f9aeb1 100644 --- a/inlang/packages/paraglide/paraglide-js/src/compiler/compile-bundle.test.ts +++ b/inlang/packages/paraglide/paraglide-js/src/compiler/compile-bundle.test.ts @@ -63,3 +63,17 @@ const mockBundle: BundleNested = { }, ], }; + +test("throws if a JS keyword is used as an identifier", async () => { + expect(() => + compileBundle({ + fallbackMap: {}, + registry: {}, + bundle: { + id: "then", + declarations: [], + messages: [], + }, + }) + ).toThrow(); +}); diff --git a/inlang/packages/paraglide/paraglide-js/src/compiler/compile-bundle.ts b/inlang/packages/paraglide/paraglide-js/src/compiler/compile-bundle.ts index f7a42e9fc6..fd0a29d2e5 100644 --- a/inlang/packages/paraglide/paraglide-js/src/compiler/compile-bundle.ts +++ b/inlang/packages/paraglide/paraglide-js/src/compiler/compile-bundle.ts @@ -5,10 +5,8 @@ import { jsIdentifier } from "../services/codegen/identifier.js"; import { isValidJSIdentifier } from "../services/valid-js-identifier/index.js"; import { escapeForDoubleQuoteString } from "../services/codegen/escape.js"; import type { Compiled } from "./types.js"; -import { - jsDocBundleFunctionTypes, - jsDocMessageFunctionTypes, -} from "./jsdoc-types.js"; +import { jsDocBundleFunctionTypes } from "./jsdoc-types.js"; +import { KEYWORDS } from "../services/valid-js-identifier/reserved-words.js"; export type CompiledBundleWithMessages = { /** The compilation result for the bundle index */ @@ -29,6 +27,16 @@ export const compileBundle = (args: { }): CompiledBundleWithMessages => { const compiledMessages: Record> = {}; + if (KEYWORDS.includes(args.bundle.id) || args.bundle.id === "then") { + throw new Error( + [ + `You are using a reserved JS keyword as id "${args.bundle.id}".`, + "Rename the message bundle id to something else.", + "See https://github.com/opral/inlang-paraglide-js/issues/331", + ].join("\n") + ); + } + for (const message of args.bundle.messages) { if (compiledMessages[message.locale]) { throw new Error(`Duplicate locale: ${message.locale}`); @@ -40,12 +48,6 @@ export const compileBundle = (args: { message.variants, args.registry ); - // add types to the compiled message function - const inputs = args.bundle.declarations.filter( - (decl) => decl.type === "input-variable" - ); - - compiledMessage.code = `${jsDocMessageFunctionTypes({ inputs })}\n${compiledMessage.code}`; // set the pattern for the language tag compiledMessages[message.locale] = compiledMessage; diff --git a/inlang/packages/paraglide/paraglide-js/src/compiler/compile-message.test.ts b/inlang/packages/paraglide/paraglide-js/src/compiler/compile-message.test.ts index 66c3e58ab9..a65c8f4f46 100644 --- a/inlang/packages/paraglide/paraglide-js/src/compiler/compile-message.test.ts +++ b/inlang/packages/paraglide/paraglide-js/src/compiler/compile-message.test.ts @@ -1,9 +1,9 @@ -import { it, expect } from "vitest"; +import { test, expect } from "vitest"; import { compileMessage } from "./compile-message.js"; import type { Declaration, Message, Variant } from "@inlang/sdk"; import { DEFAULT_REGISTRY } from "./registry.js"; -it("compiles a message with a single variant", async () => { +test("compiles a message with a single variant", async () => { const declarations: Declaration[] = []; const message: Message = { locale: "en", @@ -34,7 +34,7 @@ it("compiles a message with a single variant", async () => { expect(some_message()).toBe("Hello"); }); -it("compiles a message with variants", async () => { +test("compiles a message with variants", async () => { const declarations: Declaration[] = [ { type: "input-variable", name: "fistInput" }, { type: "input-variable", name: "secondInput" }, @@ -99,3 +99,35 @@ it("compiles a message with variants", async () => { expect(some_message({ fistInput: 3, secondInput: 4 })).toBe("Catch all"); expect(some_message({ fistInput: 1, secondInput: 5 })).toBe("Catch all"); }); + +test("only emits input arguments when inputs exist", async () => { + const declarations: Declaration[] = []; + const message: Message = { + locale: "en", + bundleId: "some_message", + id: "message-id", + selectors: [], + }; + const variants: Variant[] = [ + { + id: "1", + messageId: "message-id", + matches: [], + pattern: [{ type: "text", value: "Hello" }], + }, + ]; + + const compiled = compileMessage( + declarations, + message, + variants, + DEFAULT_REGISTRY + ); + + expect(compiled.code, "single variant").toBe( + [ + "/** @type {(inputs: {}) => string} */", + "export const some_message = () => `Hello`;", + ].join("\n") + ); +}); diff --git a/inlang/packages/paraglide/paraglide-js/src/compiler/compile-message.ts b/inlang/packages/paraglide/paraglide-js/src/compiler/compile-message.ts index 53dd07f717..3c83325eb5 100644 --- a/inlang/packages/paraglide/paraglide-js/src/compiler/compile-message.ts +++ b/inlang/packages/paraglide/paraglide-js/src/compiler/compile-message.ts @@ -3,13 +3,11 @@ import type { Registry } from "./registry.js"; import { compilePattern } from "./compile-pattern.js"; import type { Compiled } from "./types.js"; import { doubleQuote } from "../services/codegen/quotes.js"; +import { inputsType } from "./jsdoc-types.js"; /** * Returns the compiled message as a string * - * @example - * @param message The message to compile - * @returns (inputs) => string */ export const compileMessage = ( declarations: Declaration[], @@ -21,6 +19,7 @@ export const compileMessage = ( if (variants.length == 0) { throw new Error("Message must have at least one variant"); } + const hasMultipleVariants = variants.length > 1; return hasMultipleVariants ? compileMessageWithMultipleVariants( @@ -29,10 +28,11 @@ export const compileMessage = ( variants, registry ) - : compileMessageWithOneVariant(message, variants, registry); + : compileMessageWithOneVariant(declarations, message, variants, registry); }; function compileMessageWithOneVariant( + declarations: Declaration[], message: Message, variants: Variant[], registry: Registry @@ -41,12 +41,15 @@ function compileMessageWithOneVariant( if (!variant || variants.length !== 1) { throw new Error("Message must have exactly one variant"); } + const inputs = declarations.filter((decl) => decl.type === "input-variable"); + const hasInputs = inputs.length > 0; const compiledPattern = compilePattern( message.locale, variant.pattern, registry ); - const code = `export const ${message.bundleId} = (i) => ${compiledPattern.code}`; + const code = `/** @type {(inputs: ${inputsType(inputs)}) => string} */ +export const ${message.bundleId} = (${hasInputs ? "i" : ""}) => ${compiledPattern.code};`; return { code, node: message }; } @@ -56,8 +59,12 @@ function compileMessageWithMultipleVariants( variants: Variant[], registry: Registry ): Compiled { - if (variants.length <= 1) + if (variants.length <= 1) { throw new Error("Message must have more than one variant"); + } + + const inputs = declarations.filter((decl) => decl.type === "input-variable"); + const hasInputs = inputs.length > 0; // TODO make sure that matchers use keys instead of indexes const compiledVariants = []; @@ -101,9 +108,10 @@ function compileMessageWithMultipleVariants( ); } - const code = `export const ${message.bundleId} = (i) => { + const code = `/** @type {(inputs: ${inputsType(inputs)}) => string} */ +export const ${message.bundleId} = (${hasInputs ? "i" : ""}) => { ${compiledVariants.join("\n\t")} -}`; +};`; return { code, node: message }; } diff --git a/inlang/packages/paraglide/paraglide-js/src/compiler/compile-project.test.ts b/inlang/packages/paraglide/paraglide-js/src/compiler/compile-project.test.ts index 5552f110ec..cd865f7e9e 100644 --- a/inlang/packages/paraglide/paraglide-js/src/compiler/compile-project.test.ts +++ b/inlang/packages/paraglide/paraglide-js/src/compiler/compile-project.test.ts @@ -1,5 +1,9 @@ import { expect, test, describe, vi, beforeEach } from "vitest"; -import { createProject as typescriptProject, ts } from "@ts-morph/bootstrap"; +import { + createProject as typescriptProject, + ts, + type ProjectOptions, +} from "@ts-morph/bootstrap"; import { type BundleNested, Declaration, @@ -381,22 +385,32 @@ describe.each([ }); }); + // 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: ProjectOptions["compilerOptions"] = + { + 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, + }; + // 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, - }, + compilerOptions: superStrictRuleOutAnyErrorTsSettings, }); - console.log(output["runtime.js"]); - for (const [fileName, code] of Object.entries(output)) { if (fileName.endsWith(".js") || fileName.endsWith(".ts")) { project.createSourceFile(fileName, code); @@ -424,12 +438,18 @@ describe.each([ // isLocale should narrow the type of it's argument const thing = 5; + + let a: "de" | "en" | "en-US"; + if(runtime.isLocale(thing)) { - const a : "de" | "en" | "en-US" = thing + a = thing } else { // @ts-expect-error - thing is not a language tag - const a : "de" | "en" | "en-US" = thing + a = thing } + + // to make ts not complain about unused variables + console.log(a) ` ); @@ -444,14 +464,7 @@ describe.each([ 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, - }, + compilerOptions: superStrictRuleOutAnyErrorTsSettings, }); for (const [fileName, code] of Object.entries(output)) { diff --git a/inlang/packages/paraglide/paraglide-js/src/compiler/compile-project.ts b/inlang/packages/paraglide/paraglide-js/src/compiler/compile-project.ts index db6a155464..dd692c49a4 100644 --- a/inlang/packages/paraglide/paraglide-js/src/compiler/compile-project.ts +++ b/inlang/packages/paraglide/paraglide-js/src/compiler/compile-project.ts @@ -76,9 +76,6 @@ export const compileProject = async (args: { } for (const [filename, content] of Object.entries(output)) { - if (filename.endsWith(".js") || filename.endsWith(".ts")) { - output[filename] = `// @ts-nocheck\n${output[filename]}`; - } if (optionsWithDefaults.includeEslintDisableComment) { if (filename.endsWith(".js")) { output[filename] = `// eslint-disable\n${content}`; diff --git a/inlang/packages/paraglide/paraglide-js/src/compiler/compile.test.ts b/inlang/packages/paraglide/paraglide-js/src/compiler/compile.test.ts index 18f2511337..56a0b7bd6b 100644 --- a/inlang/packages/paraglide/paraglide-js/src/compiler/compile.test.ts +++ b/inlang/packages/paraglide/paraglide-js/src/compiler/compile.test.ts @@ -7,6 +7,7 @@ import { memfs } from "memfs"; import { test, expect, vi } from "vitest"; import { compile, defaultCompilerOptions } from "./compile.js"; import { getAccountFilePath } from "../services/account/index.js"; +import type { Runtime } from "./runtime/type.js"; test("loads a project and compiles it", async () => { const project = await loadProjectInMemory({ @@ -102,7 +103,7 @@ test("saves the local account to app data if not exists", async () => { expect(account).toHaveProperty("name"); }); -test("cleans the output directory", async () => { +test.skip("cleans the output directory", async () => { const fs = memfs().fs as unknown as typeof import("node:fs"); const project = await loadProjectInMemory({ @@ -137,7 +138,12 @@ test("multiple compile calls do not interfere with each other", async () => { const fs = memfs().fs as unknown as typeof import("node:fs"); const project = await loadProjectInMemory({ - blob: await newProject({}), + blob: await newProject({ + settings: { + baseLocale: "en", + locales: ["en", "de", "fr"], + }, + }), }); await saveProjectToDirectory({ @@ -146,14 +152,27 @@ test("multiple compile calls do not interfere with each other", async () => { fs: fs.promises, }); + // different project settings to test compile output + await project.settings.set({ + ...(await project.settings.get()), + baseLocale: "de", + locales: ["de"], + }); + + await saveProjectToDirectory({ + project, + path: "/project2.inlang", + fs: fs.promises, + }); + const compilations = [ compile({ project: "/project.inlang", - outdir: "/output/subdir", + outdir: "/output", fs: fs, }), compile({ - project: "/project.inlang", + project: "/project2.inlang", outdir: "/output", fs: fs, }), @@ -161,9 +180,16 @@ test("multiple compile calls do not interfere with each other", async () => { await Promise.all(compilations); - const outputDir = await fs.promises.readdir("/output"); + const runtimeFile = await fs.promises.readFile("/output/runtime.js", "utf8"); + + const runtime = (await import( + "data:text/javascript;base64," + + Buffer.from(runtimeFile, "utf-8").toString("base64") + )) as Runtime; - expect(outputDir).not.toContain("subdir"); + // expecting the second compile step to overwrite the first + expect(runtime.baseLocale).toBe("de"); + expect(runtime.locales).toEqual(["de"]); }); test("emits additional files", async () => { diff --git a/inlang/packages/paraglide/paraglide-js/src/compiler/compile.ts b/inlang/packages/paraglide/paraglide-js/src/compiler/compile.ts index 602805fdb6..bfcdaafd33 100644 --- a/inlang/packages/paraglide/paraglide-js/src/compiler/compile.ts +++ b/inlang/packages/paraglide/paraglide-js/src/compiler/compile.ts @@ -98,7 +98,9 @@ export type CompilerOptions = { // This is a workaround to prevent multiple compilations from running at the same time. // https://github.com/opral/inlang-paraglide-js/issues/320#issuecomment-2596951222 -let compilationInProgress: Promise | null = null; +let compilationInProgress: Promise<{ + outputHashes: Record | undefined; +}> | null = null; /** * Loads, compiles, and writes the output to disk. @@ -113,7 +115,10 @@ let compilationInProgress: Promise | null = null; * outdir: 'path/to/output', * }) */ -export async function compile(options: CompilerOptions): Promise { +export async function compile( + options: CompilerOptions, + previousOutputHashes?: Record +): Promise<{ outputHashes: Record | undefined }> { const withDefaultOptions = { ...defaultCompilerOptions, ...options, @@ -144,7 +149,12 @@ export async function compile(options: CompilerOptions): Promise { project, }); - await writeOutput(absoluteOutdir, output, fs.promises); + const outputHashes = await writeOutput({ + directory: absoluteOutdir, + output, + fs: fs.promises, + previousOutputHashes, + }); if (!localAccount) { const activeAccount = await project.lix.db @@ -156,8 +166,12 @@ export async function compile(options: CompilerOptions): Promise { } await project.close(); + + return { outputHashes }; })(); - await compilationInProgress; + const result = structuredClone(await compilationInProgress); compilationInProgress = null; + + return result; } diff --git a/inlang/packages/paraglide/paraglide-js/src/compiler/jsdoc-types.ts b/inlang/packages/paraglide/paraglide-js/src/compiler/jsdoc-types.ts index 5797a91c14..d4ff0494a3 100644 --- a/inlang/packages/paraglide/paraglide-js/src/compiler/jsdoc-types.ts +++ b/inlang/packages/paraglide/paraglide-js/src/compiler/jsdoc-types.ts @@ -4,30 +4,30 @@ export function jsDocBundleFunctionTypes(args: { inputs: InputVariable[]; locales: string[]; }): string { - const inputParams = args.inputs - .map((input) => { - return `${input.name}: NonNullable`; - }) - .join(", "); - const localesUnion = args.locales.map((locale) => `"${locale}"`).join(" | "); return ` -* @param {{ ${inputParams} }} inputs +* @param {${inputsType(args.inputs)}} inputs * @param {{ locale?: ${localesUnion} }} options * @returns {string}`; } -export function jsDocMessageFunctionTypes(args: { - inputs: InputVariable[]; -}): string { - const inputParams = args.inputs +/** + * Returns the types for the input variables. + * + * @example + * const inputs = [{ name: "age" }] + * inputsType(inputs) + * >> "{ age: NonNullable }" + */ +export function inputsType(inputs: InputVariable[]): string { + if (inputs.length === 0) { + return "{}"; + } + const inputParams = inputs .map((input) => { return `${input.name}: NonNullable`; }) .join(", "); - - return `/** -* @param {{ ${inputParams} }} i -*/`; + return `{ ${inputParams} }`; } diff --git a/inlang/packages/paraglide/paraglide-js/src/compiler/output-structure/locale-modules.ts b/inlang/packages/paraglide/paraglide-js/src/compiler/output-structure/locale-modules.ts index 671613eab9..99f1860b33 100644 --- a/inlang/packages/paraglide/paraglide-js/src/compiler/output-structure/locale-modules.ts +++ b/inlang/packages/paraglide/paraglide-js/src/compiler/output-structure/locale-modules.ts @@ -41,14 +41,7 @@ export function generateLocaleModules( // generate message files for (const locale of settings.locales) { const filename = `messages/${locale}.${fileExt}`; - let file = ` -/** - * 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.${importExt}'`; + let file = ""; for (const compiledBundle of compiledBundles) { const compiledMessage = compiledBundle.messages[locale]; @@ -68,6 +61,11 @@ import * as registry from '../registry.${importExt}'`; file += `\n\n${compiledMessage.code}`; } + // add import if used + if (file.includes("registry.")) { + file = `import * as registry from "./registry.js"\n` + file; + } + output[filename] = file; } return output; diff --git a/inlang/packages/paraglide/paraglide-js/src/services/file-handling/write-output.test.ts b/inlang/packages/paraglide/paraglide-js/src/services/file-handling/write-output.test.ts index ea3bef72c7..f94ce7d484 100644 --- a/inlang/packages/paraglide/paraglide-js/src/services/file-handling/write-output.test.ts +++ b/inlang/packages/paraglide/paraglide-js/src/services/file-handling/write-output.test.ts @@ -10,20 +10,28 @@ it("should write the output to a non-existing directory", async () => { const { writeOutput } = await import("./write-output.js"); const fs = mockFs({}); - await writeOutput("/output", { "test.txt": "test" }, fs); + await writeOutput({ + directory: "/output", + output: { "test.txt": "test" }, + fs, + }); expect(await fs.readFile("/output/test.txt", { encoding: "utf-8" })).toBe( "test" ); }); -it("should clear & overwrite output that's already there", async () => { +it.skip("should clear & overwrite output that's already there", async () => { const { writeOutput } = await import("./write-output.js"); const fs = mockFs({ "/output/test.txt": "old", "/output/other.txt": "other", }); - await writeOutput("/output", { "test.txt": "new" }, fs); + await writeOutput({ + directory: "/output", + output: { "test.txt": "new" }, + fs, + }); expect(await fs.readFile("/output/test.txt", { encoding: "utf-8" })).toBe( "new" @@ -37,14 +45,14 @@ it("should create any missing directories", async () => { const { writeOutput } = await import("./write-output.js"); const fs = mockFs({}); - await writeOutput( - "/output/messages", - { + await writeOutput({ + directory: "/output/messages", + output: { "de/test.txt": "de", "en/test.txt": "en", }, - fs - ); + fs, + }); expect( await fs.readFile("/output/messages/de/test.txt", { encoding: "utf-8" }) ).toBe("de"); @@ -60,8 +68,17 @@ it("should only write once if the output hasn't changed", async () => { // @ts-expect-error - spy fs.writeFile = vi.spyOn(fs, "writeFile"); - await writeOutput("/output", { "test.txt": "test" }, fs); - await writeOutput("/output", { "test.txt": "test" }, fs); + const hashes = await writeOutput({ + directory: "/output", + output: { "test.txt": "test" }, + fs, + }); + await writeOutput({ + directory: "/output", + output: { "test.txt": "test" }, + fs, + previousOutputHashes: hashes, + }); expect(await fs.readFile("/output/test.txt", { encoding: "utf-8" })).toBe( "test" ); @@ -75,14 +92,46 @@ it("should write again if the output has changed", async () => { // @ts-expect-error - spy fs.writeFile = vi.spyOn(fs, "writeFile"); - await writeOutput("/output", { "test.txt": "test" }, fs); - await writeOutput("/output", { "test.txt": "test2" }, fs); + const hashes = await writeOutput({ + directory: "/output", + output: { "test.txt": "test" }, + fs, + }); + await writeOutput({ + directory: "/output", + output: { "test.txt": "test2" }, + fs, + previousOutputHashes: hashes, + }); expect(await fs.readFile("/output/test.txt", { encoding: "utf-8" })).toBe( "test2" ); expect(fs.writeFile).toHaveBeenCalledTimes(2); }); +it("should write files if output has partially changed", async () => { + const { writeOutput } = await import("./write-output.js"); + const fs = mockFs({}); + + // @ts-expect-error - spy + fs.writeFile = vi.spyOn(fs, "writeFile"); + + const hashes = await writeOutput({ + directory: "/output", + output: { "file1.txt": "test", "file2.txt": "test" }, + fs, + }); + + await writeOutput({ + directory: "/output", + output: { "file1.txt": "test", "file2.txt": "test2" }, + fs, + previousOutputHashes: hashes, + }); + expect(fs.writeFile).toHaveBeenCalledWith("/output/file2.txt", "test2"); + expect(fs.writeFile).toHaveBeenCalledTimes(3); +}); + const mockFs = (files: memfs.DirectoryJSON) => { const _memfs = memfs.createFsFromVolume(memfs.Volume.fromJSON(files)); return _memfs.promises as unknown as typeof fs; diff --git a/inlang/packages/paraglide/paraglide-js/src/services/file-handling/write-output.ts b/inlang/packages/paraglide/paraglide-js/src/services/file-handling/write-output.ts index 613a450fe3..0d18916723 100644 --- a/inlang/packages/paraglide/paraglide-js/src/services/file-handling/write-output.ts +++ b/inlang/packages/paraglide/paraglide-js/src/services/file-handling/write-output.ts @@ -2,48 +2,67 @@ import path from "node:path"; import crypto from "node:crypto"; import type nodeFs from "node:fs/promises"; -let previousOutputHash: string | undefined; +export async function writeOutput(args: { + directory: string; + output: Record; + fs: typeof nodeFs; + previousOutputHashes?: Record; +}) { + const currentOutputHashes = hashOutput(args.output, args.directory); -export async function writeOutput( - outputDirectory: string, - output: Record, - fs: typeof nodeFs -) { // if the output hasn't changed, don't write it - const currentOutputHash = hashOutput(output, outputDirectory); - if (currentOutputHash === previousOutputHash) return; + const changedFiles = new Set(); + + for (const [filePath, hash] of Object.entries(currentOutputHashes)) { + if (args.previousOutputHashes?.[filePath] !== hash) { + changedFiles.add(filePath); + } + } + + if (changedFiles.size === 0) { + return; + } + + // disabled because of https://github.com/opral/inlang-paraglide-js/issues/350 + // // clear the output directory + // await args.fs.rm(args.outputDirectory, { recursive: true, force: true }); - // clear the output directory - await fs.rm(outputDirectory, { recursive: true, force: true }); - await fs.mkdir(outputDirectory, { recursive: true }); + await args.fs.mkdir(args.directory, { recursive: true }); //Create missing directories inside the output directory await Promise.allSettled( - Object.keys(output).map(async (filePath) => { - const fullPath = path.resolve(outputDirectory, filePath); + Object.keys(args.output).map(async (filePath) => { + const fullPath = path.resolve(args.directory, filePath); const directory = path.dirname(fullPath); - await fs.mkdir(directory, { recursive: true }); + await args.fs.mkdir(directory, { recursive: true }); }) ); //Write files await Promise.allSettled( - Object.entries(output).map(async ([filePath, fileContent]) => { - const fullPath = path.resolve(outputDirectory, filePath); - await fs.writeFile(fullPath, fileContent); + Object.entries(args.output).map(async ([filePath, fileContent]) => { + if (!changedFiles.has(filePath)) { + return; + } + const fullPath = path.resolve(args.directory, filePath); + await args.fs.writeFile(fullPath, fileContent); }) ); - //Only update the previousOutputHash if the write was successful - previousOutputHash = currentOutputHash; + //Only update the previousOutputHashes if the write was successful + return currentOutputHashes; } function hashOutput( output: Record, outputDirectory: string -): string { - const hash = crypto.createHash("sha256"); - hash.update(JSON.stringify(output)); - hash.update(outputDirectory); - return hash.digest("hex"); +): Record { + const hashes: Record = {}; + for (const [filePath, fileContent] of Object.entries(output)) { + const hash = crypto.createHash("sha256"); + hash.update(fileContent); + hash.update(path.resolve(outputDirectory, filePath)); + hashes[filePath] = hash.digest("hex"); + } + return hashes; } diff --git a/inlang/packages/paraglide/paraglide-next/example/next.config.mjs b/inlang/packages/paraglide/paraglide-next/example/next.config.mjs index d441152636..8de761c446 100644 --- a/inlang/packages/paraglide/paraglide-next/example/next.config.mjs +++ b/inlang/packages/paraglide/paraglide-next/example/next.config.mjs @@ -6,9 +6,4 @@ export default withParaglideNext({ project: "./project.inlang", strategy: ["pathname", "baseLocale"], }, - eslint: { - // Warning: This allows production builds to successfully complete even if - // your project has ESLint errors. - ignoreDuringBuilds: true, - }, }); diff --git a/inlang/packages/paraglide/paraglide-next/package.json b/inlang/packages/paraglide/paraglide-next/package.json index 3f7e0134fa..46152ae65c 100644 --- a/inlang/packages/paraglide/paraglide-next/package.json +++ b/inlang/packages/paraglide/paraglide-next/package.json @@ -1,7 +1,7 @@ { "name": "@inlang/paraglide-next", "type": "module", - "version": "1.0.0-beta.3", + "version": "1.0.0-beta.4", "license": "MIT", "publishConfig": { "access": "public", @@ -27,9 +27,10 @@ "format": "prettier ./src --write", "clean": "rm -rf ./dist ./node_modules" }, - "dependencies": {}, + "dependencies": { + "@inlang/paraglide-js": "workspace:*" + }, "peerDependencies": { - "@inlang/paraglide-js": "^2", "next": "^15" }, "devDependencies": { diff --git a/inlang/packages/paraglide/paraglide-sveltekit/package.json b/inlang/packages/paraglide/paraglide-sveltekit/package.json index b26048d9a4..1ca3dae639 100644 --- a/inlang/packages/paraglide/paraglide-sveltekit/package.json +++ b/inlang/packages/paraglide/paraglide-sveltekit/package.json @@ -1,7 +1,7 @@ { "name": "@inlang/paraglide-sveltekit", "type": "module", - "version": "1.0.0-beta.4", + "version": "1.0.0-beta.5", "license": "MIT", "publishConfig": { "access": "public", @@ -34,14 +34,14 @@ "format": "prettier ./src --write", "clean": "rm -rf ./dist ./node_modules" }, - "dependencies": {}, + "dependencies": { + "@inlang/paraglide-js": "workspace:*" + }, "peerDependencies": { - "@inlang/paraglide-js": "^2", "svelte": "^5.0.0", "@sveltejs/kit": "^2.4.3" }, "devDependencies": { - "@inlang/paraglide-js": "workspace:*", "@sveltejs/package": "^2.2.3", "@eslint/js": "^9.18.0", "@opral/tsconfig": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00f1dd7689..cbeb626a41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -227,13 +227,13 @@ importers: inlang/packages/paraglide/paraglide-astro: dependencies: + '@inlang/paraglide-js': + specifier: workspace:* + version: link:../paraglide-js astro: specifier: ^4 || ^5 version: 5.1.10(@azure/identity@4.5.0)(@types/node@22.10.6)(jiti@2.3.3)(lightningcss@1.27.0)(rollup@4.24.0)(terser@5.36.0)(tsx@4.19.2)(typescript@5.7.3)(yaml@2.6.0) devDependencies: - '@inlang/paraglide-js': - specifier: workspace:* - version: link:../paraglide-js '@opral/tsconfig': specifier: workspace:* version: link:../../../../packages/tsconfig @@ -404,10 +404,11 @@ importers: version: 6.0.7(@types/node@22.10.6)(jiti@2.3.3)(lightningcss@1.27.0)(terser@5.36.0)(tsx@4.19.2)(yaml@2.6.0) inlang/packages/paraglide/paraglide-next: - devDependencies: + dependencies: '@inlang/paraglide-js': specifier: workspace:* version: link:../paraglide-js + devDependencies: '@opral/tsconfig': specifier: workspace:* version: link:../../../../packages/tsconfig @@ -463,6 +464,9 @@ importers: inlang/packages/paraglide/paraglide-sveltekit: dependencies: + '@inlang/paraglide-js': + specifier: workspace:* + version: link:../paraglide-js '@sveltejs/kit': specifier: ^2.4.3 version: 2.16.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.19.5)(vite@6.0.11(@types/node@22.10.6)(jiti@2.3.3)(lightningcss@1.27.0)(terser@5.36.0)(tsx@4.19.2)(yaml@2.6.0)))(svelte@5.19.5)(vite@6.0.11(@types/node@22.10.6)(jiti@2.3.3)(lightningcss@1.27.0)(terser@5.36.0)(tsx@4.19.2)(yaml@2.6.0)) @@ -473,9 +477,6 @@ importers: '@eslint/js': specifier: ^9.18.0 version: 9.18.0 - '@inlang/paraglide-js': - specifier: workspace:* - version: link:../paraglide-js '@opral/tsconfig': specifier: workspace:* version: link:../../../../packages/tsconfig @@ -18762,21 +18763,6 @@ snapshots: - typescript - verdaccio - '@nrwl/js@18.3.5(@babel/traverse@7.26.4)(@types/node@22.10.6)(nx@18.3.5)(typescript@5.7.2)': - dependencies: - '@nx/js': 18.3.5(@babel/traverse@7.26.4)(@types/node@22.10.6)(nx@18.3.5)(typescript@5.7.2) - transitivePeerDependencies: - - '@babel/traverse' - - '@swc-node/register' - - '@swc/core' - - '@swc/wasm' - - '@types/node' - - debug - - nx - - supports-color - - typescript - - verdaccio - '@nrwl/nx-cloud@16.5.2': dependencies: nx-cloud: 16.5.2 @@ -18974,7 +18960,7 @@ snapshots: '@babel/preset-env': 7.26.0(@babel/core@7.26.0) '@babel/preset-typescript': 7.26.0(@babel/core@7.26.0) '@babel/runtime': 7.25.7 - '@nrwl/js': 18.3.5(@babel/traverse@7.26.4)(@types/node@22.10.6)(nx@18.3.5)(typescript@5.7.2) + '@nrwl/js': 18.3.5(@babel/traverse@7.26.4)(@types/node@22.10.6)(nx@18.3.5)(typescript@5.4.5) '@nx/devkit': 18.3.5(nx@18.3.5) '@nx/workspace': 18.3.5 '@phenomnomnominal/tsquery': 5.0.1(typescript@5.7.2)