@inlang/sdk 2.5.0 → 2.6.1

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 (66) hide show
  1. package/README.md +4 -4
  2. package/dist/database/initDbAndSchema.test.js +1 -1
  3. package/dist/database/initDbAndSchema.test.js.map +1 -1
  4. package/dist/database/jsonbPlugin.js +1 -1
  5. package/dist/database/jsonbPlugin.js.map +1 -1
  6. package/dist/database/schema.d.ts +1 -1
  7. package/dist/database/schema.js.map +1 -1
  8. package/dist/lix-plugin/applyChanges.js +1 -1
  9. package/dist/lix-plugin/applyChanges.js.map +1 -1
  10. package/dist/lix-plugin/inlangLixPluginV1.test.js +1 -1
  11. package/dist/lix-plugin/inlangLixPluginV1.test.js.map +1 -1
  12. package/dist/plugin/schema.d.ts +2 -2
  13. package/dist/plugin/schema.js.map +1 -1
  14. package/dist/project/README_CONTENT.d.ts +1 -1
  15. package/dist/project/README_CONTENT.d.ts.map +1 -1
  16. package/dist/project/README_CONTENT.js +1 -1
  17. package/dist/project/README_CONTENT.js.map +1 -1
  18. package/dist/project/api.d.ts +1 -1
  19. package/dist/project/api.js.map +1 -1
  20. package/dist/project/loadProjectFromDirectory.d.ts +18 -25
  21. package/dist/project/loadProjectFromDirectory.d.ts.map +1 -1
  22. package/dist/project/loadProjectFromDirectory.js +54 -56
  23. package/dist/project/loadProjectFromDirectory.js.map +1 -1
  24. package/dist/project/loadProjectFromDirectory.test.js +82 -4
  25. package/dist/project/loadProjectFromDirectory.test.js.map +1 -1
  26. package/dist/project/meta.d.ts +11 -0
  27. package/dist/project/meta.d.ts.map +1 -0
  28. package/dist/project/meta.js +129 -0
  29. package/dist/project/meta.js.map +1 -0
  30. package/dist/project/meta.test.d.ts +2 -0
  31. package/dist/project/meta.test.d.ts.map +1 -0
  32. package/dist/project/meta.test.js +21 -0
  33. package/dist/project/meta.test.js.map +1 -0
  34. package/dist/project/path-helpers.d.ts +27 -0
  35. package/dist/project/path-helpers.d.ts.map +1 -0
  36. package/dist/project/path-helpers.js +59 -0
  37. package/dist/project/path-helpers.js.map +1 -0
  38. package/dist/project/path-helpers.test.d.ts +2 -0
  39. package/dist/project/path-helpers.test.d.ts.map +1 -0
  40. package/dist/project/path-helpers.test.js +27 -0
  41. package/dist/project/path-helpers.test.js.map +1 -0
  42. package/dist/project/saveProjectToDirectory.d.ts +29 -0
  43. package/dist/project/saveProjectToDirectory.d.ts.map +1 -1
  44. package/dist/project/saveProjectToDirectory.js +56 -5
  45. package/dist/project/saveProjectToDirectory.js.map +1 -1
  46. package/dist/project/saveProjectToDirectory.test.js +88 -5
  47. package/dist/project/saveProjectToDirectory.test.js.map +1 -1
  48. package/dist/services/env-variables/index.js +1 -1
  49. package/dist/services/env-variables/index.js.map +1 -1
  50. package/package.json +1 -1
  51. package/src/database/initDbAndSchema.test.ts +1 -1
  52. package/src/database/jsonbPlugin.ts +1 -1
  53. package/src/database/schema.ts +1 -1
  54. package/src/lix-plugin/applyChanges.ts +1 -1
  55. package/src/lix-plugin/inlangLixPluginV1.test.ts +1 -1
  56. package/src/plugin/schema.ts +2 -2
  57. package/src/project/README_CONTENT.ts +1 -1
  58. package/src/project/api.ts +1 -1
  59. package/src/project/loadProjectFromDirectory.test.ts +127 -4
  60. package/src/project/loadProjectFromDirectory.ts +76 -74
  61. package/src/project/meta.test.ts +26 -0
  62. package/src/project/meta.ts +147 -0
  63. package/src/project/path-helpers.test.ts +45 -0
  64. package/src/project/path-helpers.ts +71 -0
  65. package/src/project/saveProjectToDirectory.test.ts +129 -9
  66. package/src/project/saveProjectToDirectory.ts +87 -11
@@ -0,0 +1,45 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { Volume } from "memfs";
3
+ import { absolutePathFromProject, withAbsolutePaths } from "./path-helpers.js";
4
+
5
+ describe("absolutePathFromProject", () => {
6
+ test("resolves relative paths against the project root", () => {
7
+ const result = absolutePathFromProject(
8
+ "/website/project.inlang",
9
+ "./local-plugins/mock-plugin.js"
10
+ );
11
+
12
+ expect(result).toBe("/website/local-plugins/mock-plugin.js");
13
+ });
14
+
15
+ test("keeps absolute paths unchanged", () => {
16
+ const result = absolutePathFromProject(
17
+ "/website/project.inlang",
18
+ "/shared/plugins/plugin.js"
19
+ );
20
+
21
+ expect(result).toBe("/shared/plugins/plugin.js");
22
+ });
23
+ });
24
+
25
+ describe("withAbsolutePaths", () => {
26
+ test("maps read/write operations to the project root", async () => {
27
+ const volume = Volume.fromJSON({
28
+ "/website/local-plugins/mock-plugin.js": "plugin code",
29
+ });
30
+ const mappedFs = withAbsolutePaths(
31
+ volume.promises as any,
32
+ "/website/project.inlang"
33
+ );
34
+
35
+ const content = await mappedFs.readFile("./local-plugins/mock-plugin.js");
36
+ expect(content.toString()).toBe("plugin code");
37
+
38
+ await mappedFs.writeFile("./local-plugins/new-plugin.js", "new plugin");
39
+ const created = await volume.promises.readFile(
40
+ "/website/local-plugins/new-plugin.js",
41
+ "utf-8"
42
+ );
43
+ expect(created).toBe("new plugin");
44
+ });
45
+ });
@@ -0,0 +1,71 @@
1
+ import nodePath from "node:path";
2
+ import type { NodeFsPromisesSubsetLegacy } from "../plugin/schema.js";
3
+
4
+ /**
5
+ * Functions from a path like `./local-plugins/mock-plugin.js` need to be able
6
+ * to be called with relative paths, even if their implementation expects absolute ones.
7
+ *
8
+ * This mapping is required for backwards compatibility.
9
+ * Relative paths in the project.inlang/settings.json
10
+ * file are resolved to absolute paths with `*.inlang`
11
+ * being pruned.
12
+ *
13
+ * @example
14
+ * "/website/project.inlang"
15
+ * "./local-plugins/mock-plugin.js"
16
+ * -> "/website/local-plugins/mock-plugin.js"
17
+ *
18
+ */
19
+ export function withAbsolutePaths(
20
+ fs: NodeFsPromisesSubsetLegacy,
21
+ projectPath: string
22
+ ): NodeFsPromisesSubsetLegacy {
23
+ return {
24
+ // @ts-expect-error - node type mismatch
25
+ readFile: (path, options) => {
26
+ return fs.readFile(absolutePathFromProject(projectPath, path), options);
27
+ },
28
+ writeFile: (path, data) => {
29
+ return fs.writeFile(absolutePathFromProject(projectPath, path), data);
30
+ },
31
+ mkdir: (path) => {
32
+ return fs.mkdir(absolutePathFromProject(projectPath, path));
33
+ },
34
+ readdir: (path) => {
35
+ return fs.readdir(absolutePathFromProject(projectPath, path));
36
+ },
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Joins a path from a project path.
42
+ *
43
+ * @example
44
+ * absolutePathFromProject("/project.inlang", "./local-plugins/mock-plugin.js") -> "/local-plugins/mock-plugin.js"
45
+ *
46
+ * absolutePathFromProject("/website/project.inlang", "./mock-plugin.js") -> "/website/mock-plugin.js"
47
+ */
48
+ export function absolutePathFromProject(
49
+ projectPath: string,
50
+ filePath: string
51
+ ): string {
52
+ // Normalize paths for consistency across platforms
53
+ const normalizedProjectPath = nodePath
54
+ .normalize(projectPath)
55
+ .replace(/\\/g, "/");
56
+ const normalizedFilePath = nodePath.normalize(filePath).replace(/\\/g, "/");
57
+
58
+ // Remove the last part of the project path (file name) to get the project root
59
+ const projectRoot = nodePath.dirname(normalizedProjectPath);
60
+
61
+ // If filePath is already absolute, return it directly
62
+ if (nodePath.isAbsolute(normalizedFilePath)) {
63
+ return normalizedFilePath;
64
+ }
65
+
66
+ // Compute absolute resolved path
67
+ const resolvedPath = nodePath.resolve(projectRoot, normalizedFilePath);
68
+
69
+ // Ensure final path always uses forward slashes
70
+ return resolvedPath.replace(/\\/g, "/");
71
+ }
@@ -9,6 +9,7 @@ import { loadProjectFromDirectory } from "./loadProjectFromDirectory.js";
9
9
  import { selectBundleNested } from "../query-utilities/selectBundleNested.js";
10
10
  import type { ProjectSettings } from "../json-schema/settings.js";
11
11
  import type { MessageV1 } from "../json-schema/old-v1-message/schemaV1.js";
12
+ import { ENV_VARIABLES } from "../services/env-variables/index.js";
12
13
 
13
14
  test("it should throw if the path doesn't end with .inlang", async () => {
14
15
  await expect(() =>
@@ -355,9 +356,8 @@ test("adds a gitignore file if it doesn't exist", async () => {
355
356
  "/foo/bar.inlang/.gitignore",
356
357
  "utf-8"
357
358
  );
358
- expect(gitignore).toBe(
359
- "# this file is auto generated\n# everything is ignored except settings.json\n*\n!settings.json"
360
- );
359
+ expect(gitignore).toContain("*");
360
+ expect(gitignore).toContain("!settings.json");
361
361
  });
362
362
 
363
363
  test("emits a README.md file for coding agents", async () => {
@@ -377,11 +377,33 @@ test("emits a README.md file for coding agents", async () => {
377
377
  "/foo/bar.inlang/README.md",
378
378
  "utf-8"
379
379
  );
380
- expect(readme).toContain("// this readme is auto generated");
381
380
  expect(readme).toContain("## What is this folder?");
382
381
  expect(readme).toContain("@inlang/sdk");
383
382
  });
384
383
 
384
+ test("emits a .meta.json file with the sdk version", async () => {
385
+ const fs = Volume.fromJSON({});
386
+
387
+ const project = await loadProjectInMemory({
388
+ blob: await newProject(),
389
+ });
390
+
391
+ await saveProjectToDirectory({
392
+ fs: fs.promises as any,
393
+ project,
394
+ path: "/foo/bar.inlang",
395
+ });
396
+
397
+ const metaRaw = await fs.promises.readFile(
398
+ "/foo/bar.inlang/.meta.json",
399
+ "utf-8"
400
+ );
401
+ const meta = JSON.parse(
402
+ typeof metaRaw === "string" ? metaRaw : metaRaw.toString()
403
+ );
404
+ expect(meta.highestSdkVersion).toBe(ENV_VARIABLES.SDK_VERSION);
405
+ });
406
+
385
407
  test("updates an existing README.md file", async () => {
386
408
  const fs = Volume.fromJSON({
387
409
  "/foo/bar.inlang/README.md": "custom readme",
@@ -401,10 +423,87 @@ test("updates an existing README.md file", async () => {
401
423
  "/foo/bar.inlang/README.md",
402
424
  "utf-8"
403
425
  );
404
- expect(readme).toContain("// this readme is auto generated");
405
426
  expect(readme).not.toContain("custom readme");
406
427
  });
407
428
 
429
+ test("does not overwrite README.md or .gitignore when meta has a higher sdk version", async () => {
430
+ const fs = Volume.fromJSON({
431
+ "/foo/bar.inlang/.meta.json": JSON.stringify({
432
+ highestSdkVersion: "99.0.0",
433
+ }),
434
+ "/foo/bar.inlang/README.md": "custom readme",
435
+ "/foo/bar.inlang/.gitignore": "custom gitignore",
436
+ });
437
+
438
+ const project = await loadProjectInMemory({
439
+ blob: await newProject(),
440
+ });
441
+
442
+ await saveProjectToDirectory({
443
+ fs: fs.promises as any,
444
+ project,
445
+ path: "/foo/bar.inlang",
446
+ });
447
+
448
+ const readme = await fs.promises.readFile(
449
+ "/foo/bar.inlang/README.md",
450
+ "utf-8"
451
+ );
452
+ const gitignore = await fs.promises.readFile(
453
+ "/foo/bar.inlang/.gitignore",
454
+ "utf-8"
455
+ );
456
+ const metaRaw = await fs.promises.readFile(
457
+ "/foo/bar.inlang/.meta.json",
458
+ "utf-8"
459
+ );
460
+ const meta = JSON.parse(
461
+ typeof metaRaw === "string" ? metaRaw : metaRaw.toString()
462
+ );
463
+ expect(readme).toBe("custom readme");
464
+ expect(gitignore).toBe("custom gitignore");
465
+ expect(meta.highestSdkVersion).toBe("99.0.0");
466
+ });
467
+
468
+ test("recreates missing README.md and .gitignore when meta has a higher sdk version", async () => {
469
+ const fs = Volume.fromJSON({
470
+ "/foo/bar.inlang/.meta.json": JSON.stringify({
471
+ highestSdkVersion: "99.0.0",
472
+ }),
473
+ });
474
+
475
+ const project = await loadProjectInMemory({
476
+ blob: await newProject(),
477
+ });
478
+
479
+ await saveProjectToDirectory({
480
+ fs: fs.promises as any,
481
+ project,
482
+ path: "/foo/bar.inlang",
483
+ });
484
+
485
+ const readme = await fs.promises.readFile(
486
+ "/foo/bar.inlang/README.md",
487
+ "utf-8"
488
+ );
489
+ const gitignore = await fs.promises.readFile(
490
+ "/foo/bar.inlang/.gitignore",
491
+ "utf-8"
492
+ );
493
+ const metaRaw = await fs.promises.readFile(
494
+ "/foo/bar.inlang/.meta.json",
495
+ "utf-8"
496
+ );
497
+ const meta = JSON.parse(
498
+ typeof metaRaw === "string" ? metaRaw : metaRaw.toString()
499
+ );
500
+
501
+ expect(readme).toContain("## What is this folder?");
502
+ expect(gitignore).toContain("*");
503
+ expect(gitignore).toContain("!settings.json");
504
+ expect(meta.highestSdkVersion).toBe("99.0.0");
505
+ });
506
+
408
507
  test("README.md is gitignored", async () => {
409
508
  const fs = Volume.fromJSON({});
410
509
 
@@ -422,7 +521,6 @@ test("README.md is gitignored", async () => {
422
521
  "/foo/bar.inlang/.gitignore",
423
522
  "utf-8"
424
523
  );
425
- expect(gitignore).toContain("# this file is auto generated");
426
524
  expect(gitignore).toContain("# everything is ignored except settings.json");
427
525
  expect(gitignore).toContain("*");
428
526
  expect(gitignore).toContain("!settings.json");
@@ -448,9 +546,8 @@ test("overwrites existing .gitignore with generated entries", async () => {
448
546
  "/foo/bar.inlang/.gitignore",
449
547
  "utf-8"
450
548
  );
451
- expect(gitignore).toBe(
452
- "# this file is auto generated\n# everything is ignored except settings.json\n*\n!settings.json"
453
- );
549
+ expect(gitignore).toContain("*");
550
+ expect(gitignore).toContain("!settings.json");
454
551
  });
455
552
 
456
553
  test("uses exportFiles when both exportFiles and saveMessages are defined", async () => {
@@ -475,6 +572,29 @@ test("uses exportFiles when both exportFiles and saveMessages are defined", asyn
475
572
  expect(saveMessagesSpy).not.toHaveBeenCalled();
476
573
  });
477
574
 
575
+ test("skipExporting prevents exporters from running", async () => {
576
+ const exportFilesSpy = vi.fn().mockResolvedValue([]);
577
+ const saveMessagesSpy = vi.fn();
578
+ const mockPlugin: InlangPlugin = {
579
+ key: "mock",
580
+ exportFiles: exportFilesSpy,
581
+ saveMessages: saveMessagesSpy,
582
+ };
583
+ const volume = Volume.fromJSON({});
584
+ const project = await loadProjectInMemory({
585
+ blob: await newProject(),
586
+ providePlugins: [mockPlugin],
587
+ });
588
+ await saveProjectToDirectory({
589
+ path: "/foo/project.inlang",
590
+ fs: volume.promises as any,
591
+ project,
592
+ skipExporting: true,
593
+ });
594
+ expect(exportFilesSpy).not.toHaveBeenCalled();
595
+ expect(saveMessagesSpy).not.toHaveBeenCalled();
596
+ });
597
+
478
598
  test("uses saveMessages when exportFiles is not defined", async () => {
479
599
  const saveMessagesSpy = vi.fn().mockResolvedValue([]);
480
600
  const mockPlugin: InlangPlugin = {
@@ -2,18 +2,55 @@ import type fs from "node:fs/promises";
2
2
  import type { InlangProject } from "./api.js";
3
3
  import path from "node:path";
4
4
  import { toMessageV1 } from "../json-schema/old-v1-message/toMessageV1.js";
5
- import {
6
- absolutePathFromProject,
7
- withAbsolutePaths,
8
- } from "./loadProjectFromDirectory.js";
5
+ import { absolutePathFromProject, withAbsolutePaths } from "./path-helpers.js";
9
6
  import { detectJsonFormatting } from "../utilities/detectJsonFormatting.js";
10
7
  import { selectBundleNested } from "../query-utilities/selectBundleNested.js";
11
8
  import { README_CONTENT } from "./README_CONTENT.js";
9
+ import { ENV_VARIABLES } from "../services/env-variables/index.js";
10
+ import { compareSemver, pickHighestVersion, readProjectMeta } from "./meta.js";
12
11
 
12
+ async function fileExists(fsModule: typeof fs, filePath: string) {
13
+ try {
14
+ await fsModule.stat(filePath);
15
+ return true;
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Saves a project to a directory.
23
+ *
24
+ * Writes all project files to disk and runs exporters to generate
25
+ * resource files (e.g., JSON translation files).
26
+ *
27
+ * @example
28
+ * await saveProjectToDirectory({
29
+ * fs: await import("node:fs/promises"),
30
+ * project,
31
+ * path: "./project.inlang",
32
+ * });
33
+ */
13
34
  export async function saveProjectToDirectory(args: {
35
+ /**
36
+ * The file system module to use for writing files.
37
+ */
14
38
  fs: typeof fs;
39
+ /**
40
+ * The inlang project to save.
41
+ */
15
42
  project: InlangProject;
43
+ /**
44
+ * The path to the inlang project directory. Must end with `.inlang`.
45
+ */
16
46
  path: string;
47
+ /**
48
+ * If `true`, skips running exporters and only writes internal project files.
49
+ *
50
+ * Useful when you only want to update project metadata without
51
+ * regenerating resource files.
52
+ */
53
+ skipExporting?: boolean;
17
54
  }): Promise<void> {
18
55
  if (args.path.endsWith(".inlang") === false) {
19
56
  throw new Error("The path must end with .inlang");
@@ -24,9 +61,32 @@ export async function saveProjectToDirectory(args: {
24
61
  .execute();
25
62
 
26
63
  const gitignoreContent = new TextEncoder().encode(
27
- "# this file is auto generated\n# everything is ignored except settings.json\n*\n!settings.json"
64
+ "# IF GIT SHOWED THAT THIS FILE CHANGED\n#\n# 1. RUN THE FOLLOWING COMMAND\n#\n# ---\n# git rm --cached '**/*.inlang/.gitignore'\n# ---\n#\n# 2. COMMIT THE CHANGE\n#\n# ---\n# git commit -m \"fix: remove tracked .gitignore from inlang project\"\n# ---\n#\n# Inlang handles the gitignore itself starting with version ^2.5.\n#\n# everything is ignored except settings.json\n*\n!settings.json"
28
65
  );
29
66
 
67
+ const existingMeta = await readProjectMeta({
68
+ fs: args.fs,
69
+ projectPath: args.path,
70
+ });
71
+ const highestSdkVersion =
72
+ pickHighestVersion([
73
+ existingMeta?.highestSdkVersion,
74
+ ENV_VARIABLES.SDK_VERSION,
75
+ ]) ?? ENV_VARIABLES.SDK_VERSION;
76
+ const shouldWriteMetadata = (() => {
77
+ const comparison = compareSemver(
78
+ highestSdkVersion,
79
+ ENV_VARIABLES.SDK_VERSION
80
+ );
81
+ return comparison === null || comparison <= 0;
82
+ })();
83
+ const readmePath = path.join(args.path, "README.md");
84
+ const gitignorePath = path.join(args.path, ".gitignore");
85
+ const shouldWriteReadme =
86
+ shouldWriteMetadata || !(await fileExists(args.fs, readmePath));
87
+ const shouldWriteGitignore =
88
+ shouldWriteMetadata || !(await fileExists(args.fs, gitignorePath));
89
+
30
90
  // write all files to the directory
31
91
  for (const file of files) {
32
92
  if (file.path.endsWith("db.sqlite") || file.path === "/project_id") {
@@ -37,13 +97,29 @@ export async function saveProjectToDirectory(args: {
37
97
  await args.fs.writeFile(p, new Uint8Array(file.data));
38
98
  }
39
99
 
40
- await args.fs.writeFile(path.join(args.path, ".gitignore"), gitignoreContent);
100
+ if (shouldWriteGitignore) {
101
+ await args.fs.writeFile(gitignorePath, gitignoreContent);
102
+ }
41
103
 
42
- // Write README.md for coding agents
43
- await args.fs.writeFile(
44
- path.join(args.path, "README.md"),
45
- new TextEncoder().encode(README_CONTENT)
46
- );
104
+ if (shouldWriteReadme) {
105
+ // Write README.md for coding agents
106
+ await args.fs.writeFile(
107
+ readmePath,
108
+ new TextEncoder().encode(README_CONTENT)
109
+ );
110
+ }
111
+
112
+ if (shouldWriteMetadata) {
113
+ const metaContent = JSON.stringify({ highestSdkVersion }, null, 2);
114
+ await args.fs.writeFile(
115
+ path.join(args.path, ".meta.json"),
116
+ new TextEncoder().encode(metaContent)
117
+ );
118
+ }
119
+
120
+ if (args.skipExporting) {
121
+ return;
122
+ }
47
123
 
48
124
  // run exporters
49
125
  const plugins = await args.project.plugins.get();