@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.
- package/README.md +49 -12
- package/dist/lix-plugin/applyChanges.js +1 -1
- package/dist/lix-plugin/applyChanges.js.map +1 -1
- package/dist/project/README_CONTENT.d.ts +1 -1
- package/dist/project/README_CONTENT.d.ts.map +1 -1
- package/dist/project/README_CONTENT.js +61 -11
- package/dist/project/README_CONTENT.js.map +1 -1
- package/dist/project/api.d.ts +12 -0
- package/dist/project/api.d.ts.map +1 -1
- package/dist/project/api.js.map +1 -1
- package/dist/project/saveProjectToDirectory.d.ts +7 -2
- package/dist/project/saveProjectToDirectory.d.ts.map +1 -1
- package/dist/project/saveProjectToDirectory.js +65 -27
- package/dist/project/saveProjectToDirectory.js.map +1 -1
- package/dist/project/saveProjectToDirectory.test.js +260 -0
- package/dist/project/saveProjectToDirectory.test.js.map +1 -1
- package/dist/services/env-variables/index.js +1 -1
- package/dist/services/env-variables/index.js.map +1 -1
- package/package.json +1 -1
- package/src/lix-plugin/applyChanges.ts +1 -1
- package/src/project/README_CONTENT.ts +61 -11
- package/src/project/api.ts +12 -0
- package/src/project/saveProjectToDirectory.test.ts +309 -0
- package/src/project/saveProjectToDirectory.ts +82 -32
|
@@ -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
|
|
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
|
|
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
|
|
33
|
-
├──
|
|
34
|
-
|
|
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
|
|
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
|
|
45
|
-
- **Version control** —
|
|
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({
|
|
70
|
-
|
|
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({
|
|
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)
|
package/src/project/api.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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:
|
|
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(
|
|
121
|
+
shouldWriteMetadata || !(await fileExists(fsModule, readmePath));
|
|
87
122
|
const shouldWriteGitignore =
|
|
88
|
-
shouldWriteMetadata || !(await fileExists(
|
|
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
|
|
97
|
-
await
|
|
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
|
|
136
|
+
await fsModule.writeFile(gitignorePath, gitignoreContent);
|
|
102
137
|
}
|
|
103
138
|
|
|
104
139
|
if (shouldWriteReadme) {
|
|
105
140
|
// Write README.md for coding agents
|
|
106
|
-
await
|
|
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
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
220
|
+
const existing = await fsModule.readFile(p, "utf-8");
|
|
171
221
|
const stringify = detectJsonFormatting(existing);
|
|
172
|
-
await
|
|
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
|
|
231
|
+
await fsModule.writeFile(p, new Uint8Array(file.content));
|
|
182
232
|
}
|
|
183
233
|
} else {
|
|
184
|
-
await
|
|
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(
|
|
247
|
+
nodeishFs: withAbsolutePaths(fsModule, args.path),
|
|
198
248
|
settings,
|
|
199
249
|
});
|
|
200
250
|
}
|