@inlang/sdk 2.9.2 → 2.10.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.
@@ -12,7 +12,9 @@ This is an [unpacked (git-friendly)](https://inlang.com/docs/unpacked-project) i
12
12
  ## At a glance
13
13
 
14
14
  Purpose:
15
- - This folder stores inlang project configuration and plugin cache data.
15
+ - This folder is the Git-friendly representation of an \`.inlang\` project.
16
+ - The canonical \`.inlang\` format is a single binary file; this directory is the unpacked version for Git.
17
+ - This folder stores project configuration and plugin cache data.
16
18
  - Translation files live outside this folder and are referenced from \`settings.json\`.
17
19
 
18
20
  Safe to edit:
@@ -23,26 +25,44 @@ Do not edit:
23
25
  - \`.gitignore\`
24
26
 
25
27
  Key files:
26
- - \`settings.json\` — locales, plugins, file patterns (source of truth)
28
+ - \`settings.json\` — locales, plugins, file patterns
27
29
  - \`cache/\` — plugin caches (safe to delete)
28
30
  - \`.gitignore\` — generated
31
+ - \`README.md\` — generated, explains this folder
32
+ - \`.meta.json\` — generated SDK metadata
29
33
 
30
34
  \`\`\`
31
35
  *.inlang/
32
- ├── settings.json # Locales, plugins, and file patterns (source of truth)
33
- ├── cache/ # Plugin caches (gitignored)
34
- └── .gitignore # Ignores everything except settings.json
36
+ ├── settings.json # Locales, plugins, and file patterns; kept in Git
37
+ ├── .gitignore # Ignores everything except settings.json
38
+ ├── README.md # Generated, explains this folder
39
+ ├── .meta.json # Generated SDK metadata
40
+ └── cache/ # Plugin caches, usually cache/plugins/
35
41
  \`\`\`
36
42
 
37
43
  Translation files (like \`messages/en.json\`) live **outside** this folder and are referenced via plugins in \`settings.json\`.
38
44
 
39
45
  ## What is inlang?
40
46
 
41
- [Inlang](https://inlang.com) is an open file format for building custom localization (i18n) tooling. It provides:
47
+ [Inlang](https://inlang.com) is an open project file format for localization. An \`.inlang\` project is canonically a single binary file: a SQLite database with version control via [lix](https://lix.dev). Like \`.sqlite\` for relational data, \`.inlang\` packages localization data into one file that tools can share.
48
+
49
+ For Git repositories, that binary file can be unpacked into a directory of plain files. The packed file is the canonical format; this directory is the Git-friendly representation.
50
+
51
+ Use inlang when multiple tools, teams, automations, or agents need to use the same localization data. The \`@inlang/sdk\` is the reference implementation for reading and writing \`.inlang\` projects.
52
+
53
+ \`.inlang\` is the canonical project format. Plugins import and export external translation files for compatibility with existing runtimes and workflows. Messages, variants, and locale data live in the \`.inlang\` database; translation files such as \`messages/en.json\` live outside this folder and are connected through plugins. Version control via lix adds file-level history, merging, and change proposals to \`.inlang\` projects.
54
+
55
+ It provides:
42
56
 
43
57
  - **CRUD API** — Read and write translations programmatically via SQL
44
- - **Plugin system** — Import/export any format (JSON, XLIFF, etc.)
45
- - **Version control** — Built-in version control via [lix](https://lix.dev)
58
+ - **Plugin system** — Import/export external translation files (JSON, XLIFF, etc.)
59
+ - **Version control** — Version control via [lix](https://lix.dev)
60
+
61
+ Core data model:
62
+
63
+ - **Bundle** — one translatable unit across locales
64
+ - **Message** — locale-specific translation for a bundle
65
+ - **Variant** — text pattern plus selector matches
46
66
 
47
67
  \`\`\`
48
68
  ┌──────────┐ ┌───────────┐ ┌────────────┐
@@ -65,15 +85,45 @@ npm install @inlang/sdk
65
85
 
66
86
  \`\`\`ts
67
87
  import { loadProjectFromDirectory, saveProjectToDirectory } from "@inlang/sdk";
88
+ import fs from "node:fs";
68
89
 
69
- const project = await loadProjectFromDirectory({ path: "./project.inlang" });
70
- // Query messages with SQLite + [Kysely](https://kysely.dev/) under the hood.
90
+ const project = await loadProjectFromDirectory({
91
+ path: "./project.inlang",
92
+ fs,
93
+ });
94
+ // Query messages with the SDK.
71
95
  const messages = await project.db.selectFrom("message").selectAll().execute();
72
96
 
73
97
  // Use project.db to update messages.
74
- await saveProjectToDirectory({ path: "./project.inlang", project });
98
+ await saveProjectToDirectory({
99
+ path: "./project.inlang",
100
+ fs,
101
+ project,
102
+ });
103
+
104
+ await project.close();
75
105
  \`\`\`
76
106
 
107
+ \`loadProjectFromDirectory()\` and \`saveProjectToDirectory()\` both accept \`node:fs\`. \`saveProjectToDirectory()\` writes translation files through import/export plugins. If no exporter plugin is configured, save the canonical packed file with \`project.toBlob()\` instead.
108
+
109
+ ## For coding agents and tool builders
110
+
111
+ Target \`.inlang\` instead of inventing a custom JSON schema.
112
+
113
+ Why target \`.inlang\`?
114
+
115
+ - One file gives every tool the same data model for bundles, messages, variants, locales, metadata, and settings.
116
+ - CRUD operations are available through the SDK instead of custom parsing and rewriting.
117
+ - Version control via [lix](https://lix.dev) keeps localization changes reviewable and mergeable.
118
+ - Plugins handle JSON, ICU, i18next, XLIFF, and other formats for compatibility.
119
+
120
+ Do not:
121
+
122
+ - Invent a custom \`locales/en.json\` schema as the project format.
123
+ - Create a new localization config when a \`.inlang\` project already exists.
124
+ - Edit generated or cached files in this directory.
125
+ - Bypass \`@inlang/sdk\` when reading or writing \`.inlang\`.
126
+
77
127
  ## Ideas for custom tooling
78
128
 
79
129
  - Translation health dashboard (missing/empty/stale messages)
@@ -71,6 +71,18 @@ export type ExportFile = {
71
71
  name: string;
72
72
  /** The binary content of the resource */
73
73
  content: Uint8Array;
74
+ /**
75
+ * Metadata of the exported file.
76
+ *
77
+ * The counterpart of `ImportFile.toBeImportedFilesMetadata`. Plugins can
78
+ * use it to pass information to the writer. For example, a plugin that
79
+ * supports a namespaced `pathPattern` (`Record<namespace, pattern>`)
80
+ * provides `{ namespace }` so that `saveProjectToDirectory` can resolve
81
+ * the pattern each exported file belongs to.
82
+ *
83
+ * https://github.com/opral/inlang/issues/4356
84
+ */
85
+ metadata?: Record<string, any>;
74
86
  };
75
87
 
76
88
  /**
@@ -60,6 +60,292 @@ test("it should overwrite all files to the directory except the db.sqlite file",
60
60
  expect(updatedSettings.locales).toEqual(["en", "fr", "mock"]);
61
61
  });
62
62
 
63
+ test("accepts the node:fs style module with a promises namespace", async () => {
64
+ const volume = Volume.fromJSON({});
65
+
66
+ const project = await loadProjectInMemory({
67
+ blob: await newProject({
68
+ settings: {
69
+ baseLocale: "en",
70
+ locales: ["en"],
71
+ },
72
+ }),
73
+ });
74
+
75
+ await saveProjectToDirectory({
76
+ fs: volume as any,
77
+ project,
78
+ path: "/foo/bar.inlang",
79
+ });
80
+
81
+ const settings = await volume.promises.readFile(
82
+ "/foo/bar.inlang/settings.json",
83
+ "utf-8"
84
+ );
85
+ expect(JSON.parse(settings as string).locales).toEqual(["en"]);
86
+ });
87
+
88
+ test("creates exporter target directories from pathPattern", async () => {
89
+ const volume = Volume.fromJSON({});
90
+ const mockPlugin: InlangPlugin = {
91
+ key: "mock",
92
+ exportFiles: async () => [
93
+ {
94
+ locale: "en",
95
+ name: "fallback.json",
96
+ content: new TextEncoder().encode(JSON.stringify({ greeting: "Hi" })),
97
+ },
98
+ ],
99
+ };
100
+
101
+ const project = await loadProjectInMemory({
102
+ blob: await newProject({
103
+ settings: {
104
+ baseLocale: "en",
105
+ locales: ["en"],
106
+ modules: [],
107
+ mock: {
108
+ pathPattern: "./messages/{locale}.json",
109
+ },
110
+ },
111
+ }),
112
+ providePlugins: [mockPlugin],
113
+ });
114
+
115
+ await project.db
116
+ .insertInto("bundle")
117
+ .values({ id: "greeting", declarations: [] })
118
+ .execute();
119
+
120
+ await saveProjectToDirectory({
121
+ fs: volume as any,
122
+ project,
123
+ path: "/foo/bar.inlang",
124
+ });
125
+
126
+ const exported = await volume.promises.readFile(
127
+ "/foo/messages/en.json",
128
+ "utf-8"
129
+ );
130
+ expect(JSON.parse(exported as string)).toEqual({ greeting: "Hi" });
131
+ });
132
+
133
+ test("writes exported files to every pattern of a pathPattern array", async () => {
134
+ const volume = Volume.fromJSON({});
135
+ const mockPlugin: InlangPlugin = {
136
+ key: "mock",
137
+ exportFiles: async () => [
138
+ {
139
+ locale: "en",
140
+ name: "en.json",
141
+ content: new TextEncoder().encode(JSON.stringify({ greeting: "Hi" })),
142
+ },
143
+ ],
144
+ };
145
+
146
+ const project = await loadProjectInMemory({
147
+ blob: await newProject({
148
+ settings: {
149
+ baseLocale: "en",
150
+ locales: ["en"],
151
+ modules: [],
152
+ mock: {
153
+ pathPattern: ["./messages/{locale}.json", "./backup/{locale}.json"],
154
+ },
155
+ },
156
+ }),
157
+ providePlugins: [mockPlugin],
158
+ });
159
+
160
+ await saveProjectToDirectory({
161
+ fs: volume as any,
162
+ project,
163
+ path: "/foo/bar.inlang",
164
+ });
165
+
166
+ const messages = await volume.promises.readFile(
167
+ "/foo/messages/en.json",
168
+ "utf-8"
169
+ );
170
+ const backup = await volume.promises.readFile("/foo/backup/en.json", "utf-8");
171
+ expect(JSON.parse(messages as string)).toEqual({ greeting: "Hi" });
172
+ expect(JSON.parse(backup as string)).toEqual({ greeting: "Hi" });
173
+ });
174
+
175
+ test("an empty pathPattern array writes nothing", async () => {
176
+ const volume = Volume.fromJSON({});
177
+ const mockPlugin: InlangPlugin = {
178
+ key: "mock",
179
+ exportFiles: async () => [
180
+ {
181
+ locale: "en",
182
+ name: "en.json",
183
+ content: new TextEncoder().encode(JSON.stringify({ greeting: "Hi" })),
184
+ },
185
+ ],
186
+ };
187
+
188
+ const project = await loadProjectInMemory({
189
+ blob: await newProject({
190
+ settings: {
191
+ baseLocale: "en",
192
+ locales: ["en"],
193
+ modules: [],
194
+ mock: {
195
+ pathPattern: [],
196
+ },
197
+ },
198
+ }),
199
+ providePlugins: [mockPlugin],
200
+ });
201
+
202
+ await saveProjectToDirectory({
203
+ fs: volume as any,
204
+ project,
205
+ path: "/foo/bar.inlang",
206
+ });
207
+
208
+ const files = await volume.promises.readdir("/foo");
209
+ expect(files).not.toContain("en.json");
210
+ });
211
+
212
+ // https://github.com/opral/inlang/issues/4356
213
+ test("resolves a namespaced pathPattern object via export file metadata", async () => {
214
+ const volume = Volume.fromJSON({});
215
+ const mockPlugin: InlangPlugin = {
216
+ key: "mock",
217
+ exportFiles: async () => [
218
+ {
219
+ locale: "en",
220
+ name: "common-en.json",
221
+ content: new TextEncoder().encode(JSON.stringify({ hello: "Hello" })),
222
+ metadata: { namespace: "common" },
223
+ },
224
+ {
225
+ locale: "en",
226
+ name: "app-en.json",
227
+ content: new TextEncoder().encode(JSON.stringify({ title: "My app" })),
228
+ metadata: { namespace: "app" },
229
+ },
230
+ ],
231
+ };
232
+
233
+ const project = await loadProjectInMemory({
234
+ blob: await newProject({
235
+ settings: {
236
+ baseLocale: "en",
237
+ locales: ["en"],
238
+ modules: [],
239
+ mock: {
240
+ pathPattern: {
241
+ common: "./{locale}/common.json",
242
+ app: "./{locale}/app.json",
243
+ },
244
+ },
245
+ },
246
+ }),
247
+ providePlugins: [mockPlugin],
248
+ });
249
+
250
+ await saveProjectToDirectory({
251
+ fs: volume as any,
252
+ project,
253
+ path: "/foo/bar.inlang",
254
+ });
255
+
256
+ const common = await volume.promises.readFile("/foo/en/common.json", "utf-8");
257
+ const app = await volume.promises.readFile("/foo/en/app.json", "utf-8");
258
+ expect(JSON.parse(common as string)).toEqual({ hello: "Hello" });
259
+ expect(JSON.parse(app as string)).toEqual({ title: "My app" });
260
+ });
261
+
262
+ // old plugin versions don't provide namespace metadata. falling back to
263
+ // file.name is better than throwing "pathPattern.replace is not a function"
264
+ // https://github.com/opral/inlang/issues/4356
265
+ test("falls back to the file name when a namespaced pathPattern can't be resolved", async () => {
266
+ const volume = Volume.fromJSON({});
267
+ const mockPlugin: InlangPlugin = {
268
+ key: "mock",
269
+ exportFiles: async () => [
270
+ {
271
+ locale: "en",
272
+ name: "common-en.json",
273
+ content: new TextEncoder().encode(JSON.stringify({ hello: "Hello" })),
274
+ // no metadata, like plugin versions that predate ExportFile.metadata
275
+ },
276
+ ],
277
+ };
278
+
279
+ const project = await loadProjectInMemory({
280
+ blob: await newProject({
281
+ settings: {
282
+ baseLocale: "en",
283
+ locales: ["en"],
284
+ modules: [],
285
+ mock: {
286
+ pathPattern: {
287
+ common: "./{locale}/common.json",
288
+ },
289
+ },
290
+ },
291
+ }),
292
+ providePlugins: [mockPlugin],
293
+ });
294
+
295
+ await saveProjectToDirectory({
296
+ fs: volume as any,
297
+ project,
298
+ path: "/foo/bar.inlang",
299
+ });
300
+
301
+ const fallback = await volume.promises.readFile(
302
+ "/foo/common-en.json",
303
+ "utf-8"
304
+ );
305
+ expect(JSON.parse(fallback as string)).toEqual({ hello: "Hello" });
306
+ });
307
+
308
+ // https://github.com/opral/inlang/issues/4356
309
+ test("falls back to the file name when the namespace is missing from the pathPattern object", async () => {
310
+ const volume = Volume.fromJSON({});
311
+ const mockPlugin: InlangPlugin = {
312
+ key: "mock",
313
+ exportFiles: async () => [
314
+ {
315
+ locale: "en",
316
+ name: "stray-en.json",
317
+ content: new TextEncoder().encode(JSON.stringify({ hello: "Hello" })),
318
+ metadata: { namespace: "stray" },
319
+ },
320
+ ],
321
+ };
322
+
323
+ const project = await loadProjectInMemory({
324
+ blob: await newProject({
325
+ settings: {
326
+ baseLocale: "en",
327
+ locales: ["en"],
328
+ modules: [],
329
+ mock: {
330
+ pathPattern: {
331
+ common: "./{locale}/common.json",
332
+ },
333
+ },
334
+ },
335
+ }),
336
+ providePlugins: [mockPlugin],
337
+ });
338
+
339
+ await saveProjectToDirectory({
340
+ fs: volume as any,
341
+ project,
342
+ path: "/foo/bar.inlang",
343
+ });
344
+
345
+ const fallback = await volume.promises.readFile("/foo/stray-en.json", "utf-8");
346
+ expect(JSON.parse(fallback as string)).toEqual({ hello: "Hello" });
347
+ });
348
+
63
349
  // Users were confused by project_id, and without sync a stable id is rarely needed.
64
350
  test("it should not write project_id to disk", async () => {
65
351
  const mockFs = Volume.fromJSON({
@@ -404,6 +690,29 @@ test("emits a .meta.json file with the sdk version", async () => {
404
690
  expect(meta.highestSdkVersion).toBe(ENV_VARIABLES.SDK_VERSION);
405
691
  });
406
692
 
693
+ test("throws when saving translation data to a directory without an exporter plugin", async () => {
694
+ const fs = Volume.fromJSON({});
695
+
696
+ const project = await loadProjectInMemory({
697
+ blob: await newProject(),
698
+ });
699
+
700
+ await project.db
701
+ .insertInto("bundle")
702
+ .values({ id: "greeting", declarations: [] })
703
+ .execute();
704
+
705
+ await expect(
706
+ saveProjectToDirectory({
707
+ fs: fs.promises as any,
708
+ project,
709
+ path: "/foo/bar.inlang",
710
+ })
711
+ ).rejects.toThrow(
712
+ "saveProjectToDirectory cannot write bundles, messages, or variants without an import/export plugin"
713
+ );
714
+ });
715
+
407
716
  test("updates an existing README.md file", async () => {
408
717
  const fs = Volume.fromJSON({
409
718
  "/foo/bar.inlang/README.md": "custom readme",
@@ -1,3 +1,4 @@
1
+ import type nodeFs from "node:fs";
1
2
  import type fs from "node:fs/promises";
2
3
  import type { InlangProject } from "./api.js";
3
4
  import path from "node:path";
@@ -18,6 +19,33 @@ async function fileExists(fsModule: typeof fs, filePath: string) {
18
19
  }
19
20
  }
20
21
 
22
+ type SaveProjectFs = typeof fs | typeof nodeFs;
23
+
24
+ function getPromisesFs(fsModule: SaveProjectFs): typeof fs {
25
+ return "promises" in fsModule ? fsModule.promises : fsModule;
26
+ }
27
+
28
+ async function assertTranslationDataCanBeExported(project: InlangProject) {
29
+ const plugins = await project.plugins.get();
30
+ const hasExporter = plugins.some(
31
+ (plugin) => plugin.exportFiles || plugin.saveMessages
32
+ );
33
+ if (hasExporter) {
34
+ return;
35
+ }
36
+
37
+ const [bundle, message, variant] = await Promise.all([
38
+ project.db.selectFrom("bundle").select("id").limit(1).executeTakeFirst(),
39
+ project.db.selectFrom("message").select("id").limit(1).executeTakeFirst(),
40
+ project.db.selectFrom("variant").select("id").limit(1).executeTakeFirst(),
41
+ ]);
42
+ if (bundle || message || variant) {
43
+ throw new Error(
44
+ "saveProjectToDirectory cannot write bundles, messages, or variants without an import/export plugin. Add a plugin to settings.modules/providePlugins, or save the canonical .inlang file with project.toBlob()."
45
+ );
46
+ }
47
+ }
48
+
21
49
  /**
22
50
  * Saves a project to a directory.
23
51
  *
@@ -26,7 +54,7 @@ async function fileExists(fsModule: typeof fs, filePath: string) {
26
54
  *
27
55
  * @example
28
56
  * await saveProjectToDirectory({
29
- * fs: await import("node:fs/promises"),
57
+ * fs: await import("node:fs"),
30
58
  * project,
31
59
  * path: "./project.inlang",
32
60
  * });
@@ -34,8 +62,10 @@ async function fileExists(fsModule: typeof fs, filePath: string) {
34
62
  export async function saveProjectToDirectory(args: {
35
63
  /**
36
64
  * The file system module to use for writing files.
65
+ *
66
+ * Accepts either `node:fs` or `node:fs/promises`.
37
67
  */
38
- fs: typeof fs;
68
+ fs: SaveProjectFs;
39
69
  /**
40
70
  * The inlang project to save.
41
71
  */
@@ -55,6 +85,11 @@ export async function saveProjectToDirectory(args: {
55
85
  if (args.path.endsWith(".inlang") === false) {
56
86
  throw new Error("The path must end with .inlang");
57
87
  }
88
+ if (!args.skipExporting) {
89
+ await assertTranslationDataCanBeExported(args.project);
90
+ }
91
+ const fsModule = getPromisesFs(args.fs);
92
+
58
93
  const files = await args.project.lix.db
59
94
  .selectFrom("file")
60
95
  .selectAll()
@@ -65,7 +100,7 @@ export async function saveProjectToDirectory(args: {
65
100
  );
66
101
 
67
102
  const existingMeta = await readProjectMeta({
68
- fs: args.fs,
103
+ fs: fsModule,
69
104
  projectPath: args.path,
70
105
  });
71
106
  const highestSdkVersion =
@@ -83,9 +118,9 @@ export async function saveProjectToDirectory(args: {
83
118
  const readmePath = path.join(args.path, "README.md");
84
119
  const gitignorePath = path.join(args.path, ".gitignore");
85
120
  const shouldWriteReadme =
86
- shouldWriteMetadata || !(await fileExists(args.fs, readmePath));
121
+ shouldWriteMetadata || !(await fileExists(fsModule, readmePath));
87
122
  const shouldWriteGitignore =
88
- shouldWriteMetadata || !(await fileExists(args.fs, gitignorePath));
123
+ shouldWriteMetadata || !(await fileExists(fsModule, gitignorePath));
89
124
 
90
125
  // write all files to the directory
91
126
  for (const file of files) {
@@ -93,17 +128,17 @@ export async function saveProjectToDirectory(args: {
93
128
  continue;
94
129
  }
95
130
  const p = path.join(args.path, file.path);
96
- await args.fs.mkdir(path.dirname(p), { recursive: true });
97
- await args.fs.writeFile(p, new Uint8Array(file.data));
131
+ await fsModule.mkdir(path.dirname(p), { recursive: true });
132
+ await fsModule.writeFile(p, new Uint8Array(file.data));
98
133
  }
99
134
 
100
135
  if (shouldWriteGitignore) {
101
- await args.fs.writeFile(gitignorePath, gitignoreContent);
136
+ await fsModule.writeFile(gitignorePath, gitignoreContent);
102
137
  }
103
138
 
104
139
  if (shouldWriteReadme) {
105
140
  // Write README.md for coding agents
106
- await args.fs.writeFile(
141
+ await fsModule.writeFile(
107
142
  readmePath,
108
143
  new TextEncoder().encode(README_CONTENT)
109
144
  );
@@ -111,7 +146,7 @@ export async function saveProjectToDirectory(args: {
111
146
 
112
147
  if (shouldWriteMetadata) {
113
148
  const metaContent = JSON.stringify({ highestSdkVersion }, null, 2);
114
- await args.fs.writeFile(
149
+ await fsModule.writeFile(
115
150
  path.join(args.path, ".meta.json"),
116
151
  new TextEncoder().encode(metaContent)
117
152
  );
@@ -148,28 +183,43 @@ export async function saveProjectToDirectory(args: {
148
183
  for (const file of files) {
149
184
  const pathPattern = settings[plugin.key]?.pathPattern;
150
185
 
151
- // We need to check if pathPattern is a string or an array of strings
152
- // and handle both cases.
153
- const formattedPathPatterns = Array.isArray(pathPattern)
154
- ? pathPattern
155
- : [pathPattern];
156
-
157
- for (const pathPattern of formattedPathPatterns) {
158
- const p = pathPattern
159
- ? absolutePathFromProject(
160
- args.path,
161
- pathPattern.replace(/\{(languageTag|locale)\}/g, file.locale)
162
- )
163
- : absolutePathFromProject(args.path, file.name);
164
- const dirname = path.dirname(p);
165
- if ((await args.fs.stat(dirname)).isDirectory() === false) {
166
- await args.fs.mkdir(dirname, { recursive: true });
167
- }
186
+ const resolvePattern = (pattern: string) =>
187
+ absolutePathFromProject(
188
+ args.path,
189
+ pattern.replace(/\{(languageTag|locale)\}/g, file.locale)
190
+ );
191
+
192
+ // pathPattern can be a string, an array of strings, or a record
193
+ // mapping namespaces to patterns (e.g. plugin-i18next).
194
+ // https://github.com/opral/inlang/issues/4356
195
+ let targetPaths: string[];
196
+ if (typeof pathPattern === "string") {
197
+ targetPaths = [resolvePattern(pathPattern)];
198
+ } else if (Array.isArray(pathPattern)) {
199
+ // an empty array writes nothing
200
+ targetPaths = pathPattern.map(resolvePattern);
201
+ } else if (typeof pathPattern === "object" && pathPattern !== null) {
202
+ const namespace = file.metadata?.["namespace"];
203
+ const namespacePattern = namespace
204
+ ? pathPattern[namespace]
205
+ : undefined;
206
+ // no pattern for this file (plugin didn't provide namespace
207
+ // metadata or the namespace is unknown) -> fall back to file.name
208
+ targetPaths =
209
+ typeof namespacePattern === "string"
210
+ ? [resolvePattern(namespacePattern)]
211
+ : [absolutePathFromProject(args.path, file.name)];
212
+ } else {
213
+ targetPaths = [absolutePathFromProject(args.path, file.name)];
214
+ }
215
+
216
+ for (const p of targetPaths) {
217
+ await fsModule.mkdir(path.dirname(p), { recursive: true });
168
218
  if (p.endsWith(".json")) {
169
219
  try {
170
- const existing = await args.fs.readFile(p, "utf-8");
220
+ const existing = await fsModule.readFile(p, "utf-8");
171
221
  const stringify = detectJsonFormatting(existing);
172
- await args.fs.writeFile(
222
+ await fsModule.writeFile(
173
223
  p,
174
224
  new TextEncoder().encode(
175
225
  stringify(JSON.parse(new TextDecoder().decode(file.content)))
@@ -178,10 +228,10 @@ export async function saveProjectToDirectory(args: {
178
228
  } catch {
179
229
  // write the file to disk (json doesn't exist yet)
180
230
  // yeah ugly duplication of write file but it works.
181
- await args.fs.writeFile(p, new Uint8Array(file.content));
231
+ await fsModule.writeFile(p, new Uint8Array(file.content));
182
232
  }
183
233
  } else {
184
- await args.fs.writeFile(p, new Uint8Array(file.content));
234
+ await fsModule.writeFile(p, new Uint8Array(file.content));
185
235
  }
186
236
  }
187
237
  }
@@ -194,7 +244,7 @@ export async function saveProjectToDirectory(args: {
194
244
  await plugin.saveMessages({
195
245
  messages: bundlesNested.map((b) => toMessageV1(b)),
196
246
  // @ts-expect-error - legacy
197
- nodeishFs: withAbsolutePaths(args.fs, args.path),
247
+ nodeishFs: withAbsolutePaths(fsModule, args.path),
198
248
  settings,
199
249
  });
200
250
  }