@inlang/paraglide-js 2.24.0 → 2.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/bundler-plugins/esbuild.d.ts +1 -1
  2. package/dist/bundler-plugins/per-locale-build/index.d.ts +3 -3
  3. package/dist/bundler-plugins/per-locale-build/index.js +59 -15
  4. package/dist/bundler-plugins/per-locale-build/index.js.map +1 -1
  5. package/dist/bundler-plugins/rolldown.d.ts +1 -1
  6. package/dist/bundler-plugins/rollup.d.ts +1 -1
  7. package/dist/bundler-plugins/rspack.d.ts +1 -1
  8. package/dist/bundler-plugins/unplugin.d.ts +35 -1
  9. package/dist/bundler-plugins/unplugin.js +404 -123
  10. package/dist/bundler-plugins/unplugin.js.map +1 -1
  11. package/dist/bundler-plugins/unplugin.test.js +357 -202
  12. package/dist/bundler-plugins/unplugin.test.js.map +1 -1
  13. package/dist/bundler-plugins/vite.d.ts +2 -2
  14. package/dist/bundler-plugins/vite.js.map +1 -1
  15. package/dist/bundler-plugins/vite.test.js +2 -1
  16. package/dist/bundler-plugins/vite.test.js.map +1 -1
  17. package/dist/bundler-plugins/webpack.d.ts +1 -1
  18. package/dist/cli/commands/compile/command.js +156 -36
  19. package/dist/cli/commands/compile/command.js.map +1 -1
  20. package/dist/cli/commands/compile/command.test.js +311 -7
  21. package/dist/cli/commands/compile/command.test.js.map +1 -1
  22. package/dist/cli/defaults.d.ts +0 -2
  23. package/dist/cli/defaults.js +0 -2
  24. package/dist/cli/defaults.js.map +1 -1
  25. package/dist/cli/steps/initialize-inlang-project.js +2 -1
  26. package/dist/cli/steps/initialize-inlang-project.js.map +1 -1
  27. package/dist/cli/steps/prompt-for-outdir.js +1 -1
  28. package/dist/cli/steps/prompt-for-outdir.js.map +1 -1
  29. package/dist/compiler/compile-annotation.d.ts +2 -2
  30. package/dist/compiler/compile-annotation.js +9 -9
  31. package/dist/compiler/compile-annotation.js.map +1 -1
  32. package/dist/compiler/compile-local-variable.d.ts +2 -1
  33. package/dist/compiler/compile-local-variable.js +4 -4
  34. package/dist/compiler/compile-local-variable.js.map +1 -1
  35. package/dist/compiler/compile-local-variable.test.js +82 -0
  36. package/dist/compiler/compile-local-variable.test.js.map +1 -1
  37. package/dist/compiler/compile-message.js +10 -2
  38. package/dist/compiler/compile-message.js.map +1 -1
  39. package/dist/compiler/compile-message.test.js +132 -2
  40. package/dist/compiler/compile-message.test.js.map +1 -1
  41. package/dist/compiler/compile-pattern.js +4 -2
  42. package/dist/compiler/compile-pattern.js.map +1 -1
  43. package/dist/compiler/compile-pattern.test.js +31 -0
  44. package/dist/compiler/compile-pattern.test.js.map +1 -1
  45. package/dist/compiler/compiler-options.d.ts +1 -1
  46. package/dist/compiler/compiler-options.js.map +1 -1
  47. package/dist/compiler/server/create-server-file.js +18 -4
  48. package/dist/compiler/server/create-server-file.js.map +1 -1
  49. package/dist/compiler/server/create-server-file.test.js +76 -0
  50. package/dist/compiler/server/create-server-file.test.js.map +1 -1
  51. package/dist/compiler/variable-access.d.ts +2 -0
  52. package/dist/compiler/variable-access.js +6 -0
  53. package/dist/compiler/variable-access.js.map +1 -1
  54. package/dist/index.d.ts +1 -0
  55. package/dist/index.js +1 -0
  56. package/dist/index.js.map +1 -1
  57. package/dist/services/config/config-schema.d.ts +69 -0
  58. package/dist/services/config/config-schema.js +78 -0
  59. package/dist/services/config/config-schema.js.map +1 -0
  60. package/dist/services/config/defaults.d.ts +11 -0
  61. package/dist/services/config/defaults.js +12 -0
  62. package/dist/services/config/defaults.js.map +1 -0
  63. package/dist/services/config/discover-config-file.d.ts +19 -0
  64. package/dist/services/config/discover-config-file.js +39 -0
  65. package/dist/services/config/discover-config-file.js.map +1 -0
  66. package/dist/services/config/index.d.ts +24 -0
  67. package/dist/services/config/index.js +25 -0
  68. package/dist/services/config/index.js.map +1 -0
  69. package/dist/services/config/load-paraglide-config.d.ts +44 -0
  70. package/dist/services/config/load-paraglide-config.js +109 -0
  71. package/dist/services/config/load-paraglide-config.js.map +1 -0
  72. package/dist/services/config/load-paraglide-config.test.d.ts +1 -0
  73. package/dist/services/config/load-paraglide-config.test.js +201 -0
  74. package/dist/services/config/load-paraglide-config.test.js.map +1 -0
  75. package/dist/services/config/resolve-compiler-options.d.ts +24 -0
  76. package/dist/services/config/resolve-compiler-options.js +51 -0
  77. package/dist/services/config/resolve-compiler-options.js.map +1 -0
  78. package/dist/services/config/resolve-compiler-options.test.d.ts +1 -0
  79. package/dist/services/config/resolve-compiler-options.test.js +60 -0
  80. package/dist/services/config/resolve-compiler-options.test.js.map +1 -0
  81. package/dist/services/env-variables/index.js +1 -1
  82. package/dist/services/env-variables/index.js.map +1 -1
  83. package/dist/services/logger/index.js +2 -0
  84. package/dist/services/logger/index.js.map +1 -1
  85. package/package.json +5 -3
@@ -1,8 +1,11 @@
1
1
  import { test, expect, beforeEach, afterEach, vi } from "vitest";
2
2
  import consola from "consola";
3
3
  import { memfs } from "memfs";
4
+ import path from "node:path";
5
+ import realFs from "node:fs";
4
6
  import { loadProjectInMemory, newProject, saveProjectToDirectory, } from "@inlang/sdk";
5
7
  let originalNodeEnv;
8
+ let testDirectories = [];
6
9
  beforeEach(() => {
7
10
  originalNodeEnv = process.env.NODE_ENV;
8
11
  // Reset module state between tests so the module-scoped
@@ -11,72 +14,102 @@ beforeEach(() => {
11
14
  // Mock logging methods to suppress error messages in tests
12
15
  consola.mockTypes(() => vi.fn());
13
16
  });
14
- afterEach(() => {
17
+ afterEach(async () => {
15
18
  if (originalNodeEnv !== undefined) {
16
19
  process.env.NODE_ENV = originalNodeEnv;
17
20
  }
18
21
  else {
19
22
  delete process.env.NODE_ENV;
20
23
  }
24
+ vi.restoreAllMocks();
25
+ for (const directory of testDirectories.splice(0)) {
26
+ await realFs.promises.rm(directory, { recursive: true, force: true });
27
+ }
21
28
  });
22
- test("vite plugin does not throw when compilation is successful", async () => {
23
- // Dynamic import so beforeEach's vi.resetModules() yields a fresh module
24
- // (a static import would share module state across tests).
25
- const { paraglideVitePlugin } = await import("../bundler-plugins/vite.js");
26
- // Create and save a viable project to the virtual file system
29
+ /**
30
+ * Creates a real on-disk fixture: `<workspace>/project.inlang` with a
31
+ * saved inlang project. The paraglide config lives inside the project
32
+ * directory and is loaded through jiti, so it must exist on the real
33
+ * filesystem.
34
+ */
35
+ async function createProjectFixture(options = {}) {
36
+ const { mkdtemp, mkdir, writeFile } = await import("node:fs/promises");
37
+ const { tmpdir } = await import("node:os");
38
+ const workspace = await mkdtemp(path.join(tmpdir(), "paraglide-plugin-"));
39
+ testDirectories.push(workspace);
40
+ const projectDir = path.join(workspace, "project.inlang");
41
+ await mkdir(projectDir, { recursive: true });
27
42
  const project = await loadProjectInMemory({
28
43
  blob: await newProject({
29
44
  settings: {
30
45
  baseLocale: "en",
31
- locales: ["en", "de", "fr"],
46
+ locales: options.locales ?? ["en", "de"],
32
47
  },
33
48
  }),
34
49
  });
35
- const fs = memfs().fs;
36
50
  await saveProjectToDirectory({
37
51
  project,
38
- path: "/project.inlang",
39
- fs: fs.promises,
52
+ path: projectDir,
53
+ fs: realFs.promises,
40
54
  });
41
- const plugin = paraglideVitePlugin({
42
- project: "/project.inlang",
43
- outdir: "/test-output",
44
- fs: fs,
55
+ const outdir = path.join(workspace, options.outdirName ?? "test-output");
56
+ if (options.config !== false) {
57
+ const content = options.config?.content ??
58
+ ["export default {", ` outdir: ${JSON.stringify(outdir)},`, "}"].join("\n");
59
+ await writeFile(path.join(projectDir, options.config?.fileName ?? "paraglide.config.js"), content);
60
+ }
61
+ return { workspace, projectDir, outdir };
62
+ }
63
+ /** Fires Vite's configResolved so the plugin learns its root. */
64
+ function setViteRoot(plugin, root) {
65
+ const configResolved = plugin.vite?.configResolved ??
66
+ plugin.configResolved;
67
+ configResolved?.handler?.({ root });
68
+ }
69
+ const mockContext = { addWatchFile: () => { } };
70
+ test("watch mode follows config precedence flips", async () => {
71
+ process.env.NODE_ENV = "development";
72
+ const { writeFile, rm } = await import("node:fs/promises");
73
+ const { workspace, projectDir, outdir } = await createProjectFixture({
74
+ config: { fileName: "paraglide.config.ts" },
45
75
  });
46
- const mockContext = {
47
- addWatchFile: () => { },
48
- };
49
- await expect(plugin.buildStart?.call(mockContext)).resolves.toBeUndefined();
76
+ const jsOutdir = path.join(workspace, "js-output");
77
+ const { paraglideVitePlugin: vitePlugin } = await import("../bundler-plugins/vite.js");
78
+ const plugin = vitePlugin({ project: projectDir });
79
+ setViteRoot(plugin, workspace);
80
+ await plugin.buildStart?.call(mockContext);
81
+ expect(await realFs.promises.readdir(outdir)).not.toHaveLength(0);
82
+ // Delete the winning .ts config and create a .js one.
83
+ await rm(path.join(projectDir, "paraglide.config.ts"));
84
+ await writeFile(path.join(projectDir, "paraglide.config.js"), ["export default {", ` outdir: ${JSON.stringify(jsOutdir)},`, "}"].join("\n"));
85
+ await expect(plugin.watchChange?.call(mockContext, path.join(projectDir, "paraglide.config.js"))).resolves.toBeUndefined();
86
+ expect(await realFs.promises.readdir(jsOutdir)).not.toHaveLength(0);
50
87
  });
51
88
  test("vite plugin does not throw on compilation errors in development", async () => {
52
89
  const { paraglideVitePlugin } = await import("../bundler-plugins/vite.js");
53
90
  process.env.NODE_ENV = "development";
54
- // Use memfs with no project (simulates missing project)
55
- const fs = memfs().fs;
91
+ const { mkdtemp } = await import("node:fs/promises");
92
+ const { tmpdir } = await import("node:os");
93
+ const workspace = await mkdtemp(path.join(tmpdir(), "paraglide-plugin-"));
94
+ testDirectories.push(workspace);
56
95
  const plugin = paraglideVitePlugin({
57
- project: "/non-existent-project.inlang",
58
- outdir: "/test-output",
59
- fs: fs,
96
+ project: path.join(workspace, "does-not-exist.inlang"),
97
+ outdir: path.join(workspace, "out"),
60
98
  });
61
- const mockContext = {
62
- addWatchFile: () => { },
63
- };
64
99
  // In development mode - should catch errors and NOT throw
65
100
  await expect(plugin.buildStart?.call(mockContext)).resolves.toBeUndefined();
66
101
  });
67
102
  test("vite plugin throws on compilation errors at build time", async () => {
68
103
  const { paraglideVitePlugin } = await import("../bundler-plugins/vite.js");
69
104
  process.env.NODE_ENV = "production";
70
- // Use memfs with no project (simulates missing project)
71
- const fs = memfs().fs;
105
+ const { mkdtemp } = await import("node:fs/promises");
106
+ const { tmpdir } = await import("node:os");
107
+ const workspace = await mkdtemp(path.join(tmpdir(), "paraglide-plugin-"));
108
+ testDirectories.push(workspace);
72
109
  const plugin = paraglideVitePlugin({
73
- project: "/non-existent-project.inlang",
74
- outdir: "/test-output",
75
- fs: fs,
110
+ project: path.join(workspace, "does-not-exist.inlang"),
111
+ outdir: path.join(workspace, "out"),
76
112
  });
77
- const mockContext = {
78
- addWatchFile: () => { },
79
- };
80
113
  // In production mode - should throw the error
81
114
  await expect(plugin.buildStart?.call(mockContext)).rejects.toThrow();
82
115
  });
@@ -86,78 +119,42 @@ test("vite plugin throws on compilation errors at build time", async () => {
86
119
  // config-watcher reload) and producing ENOENT/MISSING_EXPORT/ERR_LOAD_URL.
87
120
  test("vite plugin does not wipe outdir during first buildStart (#659)", async () => {
88
121
  const { paraglideVitePlugin: vitePlugin } = await import("../bundler-plugins/vite.js");
89
- const project = await loadProjectInMemory({
90
- blob: await newProject({
91
- settings: {
92
- baseLocale: "en",
93
- locales: ["en", "de", "fr"],
94
- },
95
- }),
96
- });
97
- const fs = memfs().fs;
98
- await saveProjectToDirectory({
99
- project,
100
- path: "/project.inlang",
101
- fs: fs.promises,
102
- });
122
+ const { projectDir, outdir } = await createProjectFixture();
103
123
  // Pre-seed outdir with a sentinel file mimicking a prior compilation
104
124
  // still on disk from an earlier process.
105
- await fs.promises.mkdir("/test-output", { recursive: true });
106
- await fs.promises.writeFile("/test-output/__sentinel__.txt", "kept");
107
- const rmSpy = vi.spyOn(fs.promises, "rm");
125
+ await realFs.promises.mkdir(outdir, { recursive: true });
126
+ await realFs.promises.writeFile(path.join(outdir, "__sentinel__.txt"), "kept");
127
+ const rmSpy = vi.spyOn(realFs.promises, "rm");
108
128
  const plugin = vitePlugin({
109
- project: "/project.inlang",
110
- outdir: "/test-output",
129
+ project: projectDir,
130
+ outdir,
111
131
  cleanOutdir: true,
112
- fs: fs,
113
132
  });
114
- const mockContext = { addWatchFile: () => { } };
115
133
  await plugin.buildStart?.call(mockContext);
116
134
  // The plugin must not recursively remove the outdir during the first
117
135
  // compile, even though previousCompilation is undefined. Concurrent
118
136
  // readers may be holding file handles into outdir.
119
137
  for (const call of rmSpy.mock.calls) {
120
- expect(call[0]).not.toBe("/test-output");
138
+ expect(String(call[0])).not.toBe(outdir);
121
139
  }
122
140
  });
123
141
  test("vite plugin warm-restart writes nothing when inputs unchanged (#659)", async () => {
124
- const project = await loadProjectInMemory({
125
- blob: await newProject({
126
- settings: {
127
- baseLocale: "en",
128
- locales: ["en", "de", "fr"],
129
- },
130
- }),
131
- });
132
- const fs = memfs().fs;
133
- await saveProjectToDirectory({
134
- project,
135
- path: "/project.inlang",
136
- fs: fs.promises,
137
- });
138
- const mockContext = { addWatchFile: () => { } };
142
+ const { projectDir, outdir } = await createProjectFixture();
143
+ const mockCtx = { addWatchFile: () => { } };
139
144
  // First process: compile and persist files to outdir.
140
145
  {
141
146
  const { paraglideVitePlugin: vitePlugin } = await import("../bundler-plugins/vite.js");
142
- const plugin = vitePlugin({
143
- project: "/project.inlang",
144
- outdir: "/test-output",
145
- fs: fs,
146
- });
147
- await plugin.buildStart?.call(mockContext);
147
+ const plugin = vitePlugin({ project: projectDir, outdir });
148
+ await plugin.buildStart?.call(mockCtx);
148
149
  }
149
150
  // Second process: simulate a fresh node process by resetting modules so
150
151
  // the module-scoped `previousCompilation` is undefined again. The plugin
151
152
  // should seed from on-disk hashes and write zero files (no race window).
152
153
  vi.resetModules();
153
154
  const { paraglideVitePlugin: vitePluginFresh } = await import("../bundler-plugins/vite.js");
154
- const plugin2 = vitePluginFresh({
155
- project: "/project.inlang",
156
- outdir: "/test-output",
157
- fs: fs,
158
- });
159
- const writeFileSpy = vi.spyOn(fs.promises, "writeFile");
160
- await plugin2.buildStart?.call(mockContext);
155
+ const plugin2 = vitePluginFresh({ project: projectDir, outdir });
156
+ const writeFileSpy = vi.spyOn(realFs.promises, "writeFile");
157
+ await plugin2.buildStart?.call(mockCtx);
161
158
  expect(writeFileSpy).not.toHaveBeenCalled();
162
159
  });
163
160
  // Regression test for https://github.com/opral/paraglide-js/issues/741:
@@ -171,28 +168,21 @@ test("vite plugin skips compile on an unchanged fresh process (#741)", async ()
171
168
  compile: compileSpy,
172
169
  }));
173
170
  try {
174
- const project = await loadProjectInMemory({
175
- blob: await newProject({
176
- settings: { baseLocale: "en", locales: ["en", "de"] },
177
- }),
171
+ let emitReadme = true;
172
+ const optionsGetter = () => ({
173
+ emitReadme,
178
174
  });
179
- const fs = memfs().fs;
180
- await saveProjectToDirectory({
181
- project,
182
- path: "/project.inlang",
183
- fs: fs.promises,
175
+ const { projectDir, outdir } = await createProjectFixture({
176
+ locales: ["en", "de"],
184
177
  });
185
- const options = {
186
- project: "/project.inlang",
187
- outdir: "/test-output",
188
- emitReadme: true,
189
- fs,
190
- };
191
- const mockContext = { addWatchFile: () => { } };
192
178
  const startFreshProcess = async () => {
193
179
  vi.resetModules();
194
180
  const { paraglideVitePlugin } = await import("../bundler-plugins/vite.js");
195
- const plugin = paraglideVitePlugin(options);
181
+ const plugin = paraglideVitePlugin({
182
+ project: projectDir,
183
+ outdir,
184
+ ...optionsGetter(),
185
+ });
196
186
  await plugin.buildStart?.call(mockContext);
197
187
  };
198
188
  await startFreshProcess();
@@ -201,34 +191,34 @@ test("vite plugin skips compile on an unchanged fresh process (#741)", async ()
201
191
  await startFreshProcess();
202
192
  expect(compileSpy).toHaveBeenCalledTimes(1);
203
193
  // A file added beside a tracked project input invalidates the cache.
204
- await fs.promises.writeFile("/project.inlang/new-project-input.txt", "new");
194
+ await realFs.promises.writeFile(path.join(projectDir, "new-project-input.txt"), "new");
205
195
  await startFreshProcess();
206
196
  expect(compileSpy).toHaveBeenCalledTimes(2);
207
197
  // Compiler options are part of the cache key.
208
- options.emitReadme = false;
198
+ emitReadme = false;
209
199
  await startFreshProcess();
210
200
  expect(compileSpy).toHaveBeenCalledTimes(3);
211
201
  // Generated output is validated too, so edited output self-repairs.
212
- await fs.promises.writeFile("/test-output/runtime.js", "tampered");
202
+ await realFs.promises.writeFile(path.join(outdir, "runtime.js"), "tampered");
213
203
  await startFreshProcess();
214
204
  expect(compileSpy).toHaveBeenCalledTimes(4);
215
- expect(await fs.promises.readFile("/test-output/runtime.js", "utf8")).not.toBe("tampered");
205
+ expect(await realFs.promises.readFile(path.join(outdir, "runtime.js"), "utf8")).not.toBe("tampered");
216
206
  // A compiler version change invalidates the persisted cache.
217
- const cacheDirectory = "/project.inlang/cache/paraglide-js";
218
- for (const cacheFile of await fs.promises.readdir(cacheDirectory)) {
219
- const cachePath = `${cacheDirectory}/${cacheFile}`;
220
- const cache = JSON.parse(await fs.promises.readFile(cachePath, "utf8"));
207
+ const cacheDirectory = path.join(projectDir, "cache", "paraglide-js");
208
+ for (const cacheFile of await realFs.promises.readdir(cacheDirectory)) {
209
+ const cachePath = path.join(cacheDirectory, cacheFile);
210
+ const cache = JSON.parse(await realFs.promises.readFile(cachePath, "utf8"));
221
211
  cache.compilerVersion = "0.0.0-stale";
222
- await fs.promises.writeFile(cachePath, JSON.stringify(cache));
212
+ await realFs.promises.writeFile(cachePath, JSON.stringify(cache));
223
213
  }
224
214
  await startFreshProcess();
225
215
  expect(compileSpy).toHaveBeenCalledTimes(5);
226
216
  // A syntactically valid but malformed cache is treated as a miss.
227
- for (const cacheFile of await fs.promises.readdir(cacheDirectory)) {
228
- const cachePath = `${cacheDirectory}/${cacheFile}`;
229
- const cache = JSON.parse(await fs.promises.readFile(cachePath, "utf8"));
217
+ for (const cacheFile of await realFs.promises.readdir(cacheDirectory)) {
218
+ const cachePath = path.join(cacheDirectory, cacheFile);
219
+ const cache = JSON.parse(await realFs.promises.readFile(cachePath, "utf8"));
230
220
  cache.outputHashes = null;
231
- await fs.promises.writeFile(cachePath, JSON.stringify(cache));
221
+ await realFs.promises.writeFile(cachePath, JSON.stringify(cache));
232
222
  }
233
223
  await startFreshProcess();
234
224
  expect(compileSpy).toHaveBeenCalledTimes(6);
@@ -239,8 +229,7 @@ test("vite plugin skips compile on an unchanged fresh process (#741)", async ()
239
229
  });
240
230
  // Regression test for https://github.com/opral/paraglide-js/issues/693:
241
231
  // `vite build` fires buildStart once per environment (client, ssr) and each
242
- // run used to do a full compile() — project loading + message compilation —
243
- // even though the inputs hadn't changed.
232
+ // run used to do a full compile() even though the inputs hadn't changed.
244
233
  test("vite plugin skips compile() when buildStart fires again with unchanged inputs (#693)", async () => {
245
234
  const actualCompileModule = await vi.importActual("../compiler/compile.js");
246
235
  const compileSpy = vi.fn(actualCompileModule.compile);
@@ -250,26 +239,10 @@ test("vite plugin skips compile() when buildStart fires again with unchanged inp
250
239
  }));
251
240
  try {
252
241
  const { paraglideVitePlugin: vitePlugin } = await import("../bundler-plugins/vite.js");
253
- const project = await loadProjectInMemory({
254
- blob: await newProject({
255
- settings: {
256
- baseLocale: "en",
257
- locales: ["en", "de", "fr"],
258
- },
259
- }),
260
- });
261
- const fs = memfs().fs;
262
- await saveProjectToDirectory({
263
- project,
264
- path: "/project.inlang",
265
- fs: fs.promises,
242
+ const { projectDir, outdir } = await createProjectFixture({
243
+ locales: ["en", "de", "fr"],
266
244
  });
267
- const plugin = vitePlugin({
268
- project: "/project.inlang",
269
- outdir: "/test-output",
270
- fs: fs,
271
- });
272
- const mockContext = { addWatchFile: () => { } };
245
+ const plugin = vitePlugin({ project: projectDir, outdir });
273
246
  // First environment (e.g. client) compiles.
274
247
  await plugin.buildStart?.call(mockContext);
275
248
  expect(compileSpy).toHaveBeenCalledTimes(1);
@@ -277,10 +250,10 @@ test("vite plugin skips compile() when buildStart fires again with unchanged inp
277
250
  await plugin.buildStart?.call(mockContext);
278
251
  expect(compileSpy).toHaveBeenCalledTimes(1);
279
252
  // Changing an input file invalidates the digest and recompiles.
280
- const settingsPath = "/project.inlang/settings.json";
281
- const settings = JSON.parse(await fs.promises.readFile(settingsPath, "utf-8"));
253
+ const settingsPath = path.join(projectDir, "settings.json");
254
+ const settings = JSON.parse(await realFs.promises.readFile(settingsPath, "utf-8"));
282
255
  settings.locales = ["en", "de", "fr", "es"];
283
- await fs.promises.writeFile(settingsPath, JSON.stringify(settings));
256
+ await realFs.promises.writeFile(settingsPath, JSON.stringify(settings));
284
257
  await plugin.buildStart?.call(mockContext);
285
258
  expect(compileSpy).toHaveBeenCalledTimes(2);
286
259
  }
@@ -299,28 +272,13 @@ test("vite plugin recompiles when a file is added to a tracked directory (#693)"
299
272
  }));
300
273
  try {
301
274
  const { paraglideVitePlugin: vitePlugin } = await import("../bundler-plugins/vite.js");
302
- const project = await loadProjectInMemory({
303
- blob: await newProject({
304
- settings: { baseLocale: "en", locales: ["en", "de"] },
305
- }),
306
- });
307
- const fs = memfs().fs;
308
- await saveProjectToDirectory({
309
- project,
310
- path: "/project.inlang",
311
- fs: fs.promises,
312
- });
313
- const plugin = vitePlugin({
314
- project: "/project.inlang",
315
- outdir: "/test-output",
316
- fs: fs,
317
- });
318
- const mockContext = { addWatchFile: () => { } };
275
+ const { projectDir, outdir } = await createProjectFixture();
276
+ const plugin = vitePlugin({ project: projectDir, outdir });
319
277
  await plugin.buildStart?.call(mockContext);
320
278
  await plugin.buildStart?.call(mockContext);
321
279
  expect(compileSpy).toHaveBeenCalledTimes(1);
322
280
  // No tracked file changed, but the listing of a tracked directory did.
323
- await fs.promises.writeFile("/project.inlang/added-later.txt", "new");
281
+ await realFs.promises.writeFile(path.join(projectDir, "added-later.txt"), "new");
324
282
  await plugin.buildStart?.call(mockContext);
325
283
  expect(compileSpy).toHaveBeenCalledTimes(2);
326
284
  }
@@ -329,8 +287,7 @@ test("vite plugin recompiles when a file is added to a tracked directory (#693)"
329
287
  }
330
288
  });
331
289
  // After a failed compile the stored digest must not be reused: restoring an
332
- // input to its previous content has to trigger a real recompile, because the
333
- // failed attempt may have left no (or partial) output.
290
+ // input to its previous content has to trigger a real recompile.
334
291
  test("vite plugin does not reuse a digest from before a failed compile (#693)", async () => {
335
292
  process.env.NODE_ENV = "development";
336
293
  const actualCompileModule = await vi.importActual("../compiler/compile.js");
@@ -341,36 +298,20 @@ test("vite plugin does not reuse a digest from before a failed compile (#693)",
341
298
  }));
342
299
  try {
343
300
  const { paraglideVitePlugin: vitePlugin } = await import("../bundler-plugins/vite.js");
344
- const project = await loadProjectInMemory({
345
- blob: await newProject({
346
- settings: { baseLocale: "en", locales: ["en", "de"] },
347
- }),
348
- });
349
- const fs = memfs().fs;
350
- await saveProjectToDirectory({
351
- project,
352
- path: "/project.inlang",
353
- fs: fs.promises,
354
- });
355
- const plugin = vitePlugin({
356
- project: "/project.inlang",
357
- outdir: "/test-output",
358
- fs: fs,
359
- });
360
- const mockContext = { addWatchFile: () => { } };
301
+ const { projectDir, outdir } = await createProjectFixture();
302
+ const plugin = vitePlugin({ project: projectDir, outdir });
361
303
  await plugin.buildStart?.call(mockContext);
362
304
  expect(compileSpy).toHaveBeenCalledTimes(1);
363
- const settingsPath = "/project.inlang/settings.json";
364
- const originalSettings = await fs.promises.readFile(settingsPath, "utf-8");
305
+ const settingsPath = path.join(projectDir, "settings.json");
306
+ const originalSettings = await realFs.promises.readFile(settingsPath, "utf-8");
365
307
  // Break the settings file — the compile attempt fails (dev mode
366
308
  // swallows the error) and must clear the stored digest.
367
- await fs.promises.writeFile(settingsPath, "{ not json");
309
+ await realFs.promises.writeFile(settingsPath, "{ not json");
368
310
  await plugin.buildStart?.call(mockContext);
369
311
  expect(compileSpy).toHaveBeenCalledTimes(2);
370
- // Restore the exact original content. The digest now matches the one
371
- // from before the failure — it must have been cleared, so this
372
- // buildStart has to compile again instead of skipping.
373
- await fs.promises.writeFile(settingsPath, originalSettings);
312
+ // Restore the exact original content. It must compile again instead
313
+ // of skipping.
314
+ await realFs.promises.writeFile(settingsPath, originalSettings);
374
315
  await plugin.buildStart?.call(mockContext);
375
316
  expect(compileSpy).toHaveBeenCalledTimes(3);
376
317
  }
@@ -380,40 +321,254 @@ test("vite plugin does not reuse a digest from before a failed compile (#693)",
380
321
  });
381
322
  // Two plugin instances with different filesystems must not share the tracked
382
323
  // fs wrapper or cached compilation state — each compiles into its own fs.
324
+ // The project and config live on the real filesystem (the config loader
325
+ // always uses the native fs); only compiler I/O is virtualized here.
383
326
  test("vite plugin instances with different fs compile against their own fs (#693)", async () => {
384
327
  const { paraglideVitePlugin: vitePlugin } = await import("../bundler-plugins/vite.js");
385
- const makeProjectFs = async () => {
386
- const project = await loadProjectInMemory({
387
- blob: await newProject({
388
- settings: { baseLocale: "en", locales: ["en"] },
389
- }),
390
- });
391
- const fs = memfs().fs;
392
- await saveProjectToDirectory({
393
- project,
394
- path: "/project.inlang",
395
- fs: fs.promises,
396
- });
397
- return fs;
398
- };
399
- const fsA = await makeProjectFs();
400
- const fsB = await makeProjectFs();
401
- const mockContext = { addWatchFile: () => { } };
328
+ const { projectDir, outdir } = await createProjectFixture({
329
+ locales: ["en"],
330
+ });
331
+ const fsA = memfs().fs;
332
+ const fsB = memfs().fs;
333
+ // The compiler reads the project through its own fs, so seed a copy of
334
+ // the real project into each instance's filesystem at /project.inlang.
335
+ async function seedIntoFs(target) {
336
+ async function walk(dir, virtualDir) {
337
+ for (const entry of await realFs.promises.readdir(dir, {
338
+ withFileTypes: true,
339
+ })) {
340
+ const source = path.join(dir, entry.name);
341
+ const destination = path.join(virtualDir, entry.name);
342
+ if (entry.isDirectory()) {
343
+ await target.promises.mkdir(destination, { recursive: true });
344
+ await walk(source, destination);
345
+ }
346
+ else {
347
+ await target.promises.writeFile(destination, await realFs.promises.readFile(source));
348
+ }
349
+ }
350
+ }
351
+ await target.promises.mkdir("/project.inlang", { recursive: true });
352
+ await walk(projectDir, "/project.inlang");
353
+ }
354
+ await seedIntoFs(fsA);
355
+ await seedIntoFs(fsB);
402
356
  const pluginA = vitePlugin({
403
357
  project: "/project.inlang",
404
- outdir: "/test-output",
358
+ outdir,
405
359
  fs: fsA,
406
360
  });
407
361
  await pluginA.buildStart?.call(mockContext);
408
362
  const pluginB = vitePlugin({
409
363
  project: "/project.inlang",
410
- outdir: "/test-output",
364
+ outdir,
411
365
  fs: fsB,
412
366
  });
413
367
  await pluginB.buildStart?.call(mockContext);
414
368
  // B's output must exist in B's filesystem — with a shared tracked fs it
415
369
  // would have been read from and written into A's filesystem instead.
416
- const outputB = await fsB.promises.readdir("/test-output");
370
+ const outputB = await fsB.promises.readdir(outdir);
417
371
  expect(outputB.length).toBeGreaterThan(0);
418
372
  });
373
+ // The plugins must pick up options from a paraglide.config file inside the
374
+ // project directory when they are not passed explicitly, and explicit
375
+ // options must win over the file.
376
+ test("vite plugin reads options from a paraglide config file", async () => {
377
+ const { writeFile } = await import("node:fs/promises");
378
+ const { workspace, projectDir, outdir } = await createProjectFixture({
379
+ outdirName: "from-config",
380
+ config: false,
381
+ });
382
+ const flagOutdir = path.join(workspace, "from-flag");
383
+ // No config yet: the plugin falls back to the default outdir.
384
+ const { paraglideVitePlugin: vitePlugin } = await import("../bundler-plugins/vite.js");
385
+ const pluginWithoutConfig = vitePlugin({
386
+ project: projectDir,
387
+ outdir,
388
+ });
389
+ setViteRoot(pluginWithoutConfig, workspace);
390
+ await pluginWithoutConfig.buildStart?.call(mockContext);
391
+ expect(await realFs.promises.readdir(outdir)).not.toHaveLength(0);
392
+ // A config inside the project directory wins over the defaults...
393
+ await writeFile(path.join(projectDir, "paraglide.config.js"), [
394
+ "export default {",
395
+ ` outdir: ${JSON.stringify(path.join(workspace, "from-config-file"))},`,
396
+ "}",
397
+ ].join("\n"));
398
+ const pluginWithConfig = vitePlugin({ project: projectDir });
399
+ setViteRoot(pluginWithConfig, workspace);
400
+ await pluginWithConfig.buildStart?.call(mockContext);
401
+ expect(await realFs.promises.readdir(path.join(workspace, "from-config-file"))).not.toHaveLength(0);
402
+ // ...and an explicit option wins over the config file.
403
+ const pluginWithOverride = vitePlugin({
404
+ project: projectDir,
405
+ outdir: flagOutdir,
406
+ });
407
+ setViteRoot(pluginWithOverride, workspace);
408
+ await pluginWithOverride.buildStart?.call(mockContext);
409
+ expect(await realFs.promises.readdir(flagOutdir)).not.toHaveLength(0);
410
+ });
411
+ // A relative `project` option is resolved against the tool root.
412
+ test("resolves a relative project option against the vite root", async () => {
413
+ const { workspace, projectDir, outdir } = await createProjectFixture();
414
+ const { paraglideVitePlugin: vitePlugin } = await import("../bundler-plugins/vite.js");
415
+ const plugin = vitePlugin({
416
+ project: "./project.inlang", // relative — resolved against the root
417
+ outdir,
418
+ });
419
+ setViteRoot(plugin, workspace);
420
+ expect(projectDir).toBe(path.join(workspace, "project.inlang"));
421
+ await plugin.buildStart?.call(mockContext);
422
+ expect(await realFs.promises.readdir(outdir)).not.toHaveLength(0);
423
+ });
424
+ test("watch mode hot-reloads when the config file changes", async () => {
425
+ const { writeFile } = await import("node:fs/promises");
426
+ const { workspace, projectDir, outdir } = await createProjectFixture({
427
+ outdirName: "cold-output",
428
+ });
429
+ const hotOutput = path.join(workspace, "hot-output");
430
+ const { paraglideVitePlugin: vitePlugin } = await import("../bundler-plugins/vite.js");
431
+ const plugin = vitePlugin({ project: projectDir });
432
+ setViteRoot(plugin, workspace);
433
+ const capturingContext = {
434
+ addWatchFile: (p) => {
435
+ addedWatchFiles.push(p);
436
+ },
437
+ };
438
+ const addedWatchFiles = [];
439
+ await plugin.buildStart?.call(capturingContext);
440
+ expect(await realFs.promises.readdir(outdir)).not.toHaveLength(0);
441
+ // Changes to unrelated files do not recompile.
442
+ const outputBefore = await realFs.promises.readdir(outdir);
443
+ await plugin.watchChange?.call(undefined, "/unrelated.ts");
444
+ expect(await realFs.promises.readdir(outdir)).toEqual(outputBefore);
445
+ // A config change recompiles with the new options.
446
+ await writeFile(path.join(projectDir, "paraglide.config.js"), ["export default {", ` outdir: ${JSON.stringify(hotOutput)},`, "}"].join("\n"));
447
+ await plugin.watchChange?.call(undefined, path.join(projectDir, "paraglide.config.js"));
448
+ expect(await realFs.promises.readdir(hotOutput)).not.toHaveLength(0);
449
+ });
450
+ // An invalid edited config must not crash the dev server — the previously
451
+ // compiled output keeps being served.
452
+ test("watchChange keeps the previous output when the edited config is invalid", async () => {
453
+ const { writeFile } = await import("node:fs/promises");
454
+ const { projectDir, outdir } = await createProjectFixture();
455
+ const { paraglideVitePlugin: vitePlugin } = await import("../bundler-plugins/vite.js");
456
+ const plugin = vitePlugin({ project: projectDir, outdir });
457
+ setViteRoot(plugin, path.dirname(projectDir));
458
+ await plugin.buildStart?.call(mockContext);
459
+ expect(await realFs.promises.readdir(outdir)).not.toHaveLength(0);
460
+ // Break the config, then trigger a change on it.
461
+ await writeFile(path.join(projectDir, "paraglide.config.js"), "export default { outdir: 42 }");
462
+ await expect(plugin.watchChange?.call(undefined, path.join(projectDir, "paraglide.config.js"))).resolves.toBeUndefined();
463
+ // The previous compilation is still in place.
464
+ expect(await realFs.promises.readdir(outdir)).not.toHaveLength(0);
465
+ });
466
+ // An invalid config at startup follows the same policy as compilation
467
+ // errors: swallowed in development, thrown in production.
468
+ test("buildStart does not throw when the config file is invalid in development", async () => {
469
+ process.env.NODE_ENV = "development";
470
+ const { writeFile } = await import("node:fs/promises");
471
+ const { projectDir } = await createProjectFixture({ config: false });
472
+ await writeFile(path.join(projectDir, "paraglide.config.ts"), 'export default { outdir: "./o", cookieMaxAge: "forever" }');
473
+ const { paraglideVitePlugin: vitePlugin } = await import("../bundler-plugins/vite.js");
474
+ const plugin = vitePlugin({ project: projectDir });
475
+ setViteRoot(plugin, path.dirname(projectDir));
476
+ await expect(plugin.buildStart?.call(mockContext)).resolves.toBeUndefined();
477
+ });
478
+ // Regression: with no valid cached config (dev server started on an
479
+ // invalid config), an unrelated file change must not reject watchChange —
480
+ // that would crash the bundler's watcher.
481
+ test("watchChange does not throw for unrelated files while the config is invalid", async () => {
482
+ process.env.NODE_ENV = "development";
483
+ const { writeFile } = await import("node:fs/promises");
484
+ const { projectDir } = await createProjectFixture({ config: false });
485
+ await writeFile(path.join(projectDir, "paraglide.config.ts"), 'export default { outdir: "./o", cookieMaxAge: "forever" }');
486
+ const { paraglideVitePlugin: vitePlugin } = await import("../bundler-plugins/vite.js");
487
+ const plugin = vitePlugin({ project: projectDir });
488
+ setViteRoot(plugin, path.dirname(projectDir));
489
+ // Swallowed by buildStart; nothing valid ends up in the cache.
490
+ await plugin.buildStart?.call(mockContext);
491
+ await expect(plugin.watchChange?.call(undefined, "/some/unrelated.ts")).resolves.toBeUndefined();
492
+ });
493
+ // Regression (review): webpack fires one watchChange per modified file and
494
+ // a config event must never be starved by later input events.
495
+ test("queued config events are applied even when input events follow", async () => {
496
+ const { writeFile } = await import("node:fs/promises");
497
+ const { workspace, projectDir } = await createProjectFixture({
498
+ outdirName: "cold-output",
499
+ });
500
+ const hotOutput = path.join(workspace, "hot-output");
501
+ const { paraglideVitePlugin: vitePlugin } = await import("../bundler-plugins/vite.js");
502
+ const plugin = vitePlugin({ project: projectDir });
503
+ setViteRoot(plugin, workspace);
504
+ await plugin.buildStart?.call(mockContext);
505
+ // Volley: input, config, input — fired while the first rebuild is still
506
+ // in flight (no awaiting between calls).
507
+ const first = plugin.watchChange?.call(undefined, path.join(projectDir, "messages", "en.json"));
508
+ await writeFile(path.join(projectDir, "paraglide.config.js"), ["export default {", ` outdir: ${JSON.stringify(hotOutput)},`, "}"].join("\n"));
509
+ const second = plugin.watchChange?.call(undefined, path.join(projectDir, "paraglide.config.js"));
510
+ const third = plugin.watchChange?.call(undefined, path.join(projectDir, "messages", "de.json"));
511
+ await Promise.all([first, second, third]);
512
+ expect(await realFs.promises.readdir(hotOutput)).not.toHaveLength(0);
513
+ });
514
+ // Regression (review): an invalid config in production must fail the build
515
+ // loudly instead of silently compiling with default options.
516
+ test("buildStart throws when the config is invalid in production", async () => {
517
+ process.env.NODE_ENV = "production";
518
+ const { writeFile } = await import("node:fs/promises");
519
+ const { projectDir } = await createProjectFixture();
520
+ await writeFile(path.join(projectDir, "paraglide.config.js"), "export default { outdir: 42 }");
521
+ const { paraglideVitePlugin: vitePlugin } = await import("../bundler-plugins/vite.js");
522
+ const plugin = vitePlugin({ project: projectDir });
523
+ setViteRoot(plugin, path.dirname(projectDir));
524
+ await expect(plugin.buildStart?.call(mockContext)).rejects.toThrow(/paraglide config/i);
525
+ });
526
+ // Regression (review): pinning outputStructure in the shared defaults
527
+ // template killed the development default (#486). Without an explicit
528
+ // structure, plugins must emit locale-modules outside production.
529
+ test("development default output structure is locale-modules", async () => {
530
+ const { writeFile } = await import("node:fs/promises");
531
+ const { projectDir, outdir } = await createProjectFixture();
532
+ // Ensure at least one message exists so the two structures differ.
533
+ await realFs.promises.mkdir(path.join(projectDir, "messages"), {
534
+ recursive: true,
535
+ });
536
+ await writeFile(path.join(projectDir, "messages", "en.json"), JSON.stringify({ hello: "Hello" }));
537
+ await writeFile(path.join(projectDir, "messages", "de.json"), JSON.stringify({ hello: "Hallo" }));
538
+ const { paraglideVitePlugin: vitePlugin } = await import("../bundler-plugins/vite.js");
539
+ const plugin = vitePlugin({ project: projectDir, outdir });
540
+ setViteRoot(plugin, path.dirname(projectDir));
541
+ delete process.env.NODE_ENV;
542
+ await plugin.buildStart?.call(mockContext);
543
+ const messages = await realFs.promises.readdir(path.join(outdir, "messages"));
544
+ expect(messages).toContain("en.js");
545
+ expect(messages).toContain("de.js");
546
+ expect(messages).not.toContain("hello.js");
547
+ });
548
+ // All four candidate names are registered inside the project directory so
549
+ // renames/format switches are reported even by backends whose watchers only
550
+ // report registered files (webpack).
551
+ test("registers all config candidates inside the project directory", async () => {
552
+ process.env.NODE_ENV = "development";
553
+ const { workspace, projectDir } = await createProjectFixture({
554
+ config: false,
555
+ });
556
+ const { paraglideVitePlugin: vitePlugin } = await import("../bundler-plugins/vite.js");
557
+ const plugin = vitePlugin({ project: projectDir });
558
+ setViteRoot(plugin, workspace);
559
+ const addedWatchFiles = [];
560
+ await plugin.buildStart?.call({
561
+ addWatchFile: (p) => {
562
+ addedWatchFiles.push(p);
563
+ },
564
+ });
565
+ for (const name of [
566
+ "paraglide.config.js",
567
+ "paraglide.config.mjs",
568
+ "paraglide.config.ts",
569
+ "paraglide.config.cjs",
570
+ ]) {
571
+ expect(addedWatchFiles).toContain(path.join(projectDir, name));
572
+ }
573
+ });
419
574
  //# sourceMappingURL=unplugin.test.js.map