Skip to content

Commit 0852849

Browse files
Merge pull request #4292 from opral/3795
Implement README.md generation
2 parents 430a835 + 00e3906 commit 0852849

File tree

7 files changed

+212
-42
lines changed

7 files changed

+212
-42
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@inlang/sdk": minor
3+
---
4+
5+
emit a README.md in .inlang project folders to help coding agents understand the folder

packages/sdk/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
"dev": "npm run env-variables && tsc --watch",
2929
"env-variables": "node ./src/services/env-variables/createIndexFile.js",
3030
"typecheck": "npm run env-variables && tsc --noEmit",
31-
"test": "tsc --noEmit && vitest run",
31+
"test": "npm run env-variables && tsc --noEmit && vitest run",
3232
"test:watch": "vitest",
3333
"lint": "eslint ./src",
3434
"format": "prettier ./src --write",
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* README content that gets written to every .inlang project folder.
3+
*
4+
* The goal is to help coding agents understand what this folder is
5+
* and how to use the inlang SDK to build tooling.
6+
*/
7+
export const README_CONTENT = `// this readme is auto generated
8+
## What is this folder?
9+
10+
This is an [unpacked (git-friendly)](https://inlang.com/docs/unpacked-project) inlang project.
11+
12+
\`\`\`
13+
*.inlang/
14+
├── settings.json # Locales, plugins, and file patterns (source of truth)
15+
├── cache/ # Plugin caches (gitignored)
16+
└── .gitignore # Ignores cache by default
17+
\`\`\`
18+
19+
Everything in this folder is managed by the SDK, except for \`settings.json\`, which can be edited by users.
20+
21+
Translation files (like \`messages/en.json\`) live **outside** this folder and are referenced via plugins in \`settings.json\`.
22+
23+
## What is inlang?
24+
25+
[Inlang](https://inlang.com) is an open file format designed for building localization (i18n) tooling. It provides:
26+
27+
- **CRUD API** — Read and write translations programmatically
28+
- **SQL queries** — Query messages like a database, scale to millions
29+
- **Plugin system** — Import/export any format (JSON, XLIFF, etc.)
30+
- **Version control** — Built-in version control via [lix](https://lix.dev)
31+
32+
\`\`\`
33+
┌──────────┐ ┌───────────┐ ┌────────────┐
34+
│ i18n lib │ │Translation│ │ CI/CD │
35+
│ │ │ Tool │ │ Automation │
36+
└────┬─────┘ └─────┬─────┘ └─────┬──────┘
37+
│ │ │
38+
└─────────┐ │ ┌──────────┘
39+
▼ ▼ ▼
40+
┌──────────────────────────────────┐
41+
│ *.inlang file │
42+
└──────────────────────────────────┘
43+
\`\`\`
44+
45+
## Quick start
46+
47+
\`\`\`bash
48+
npm install @inlang/sdk
49+
\`\`\`
50+
51+
\`\`\`ts
52+
import { loadProjectFromDirectory } from "@inlang/sdk";
53+
54+
const project = await loadProjectFromDirectory({ path: "./project.inlang" });
55+
56+
// Query messages (uses SQLite under the hood)
57+
const messages = await project.db.selectFrom("message").selectAll().execute();
58+
59+
// Insert a new message
60+
await project.db
61+
.insertInto("message")
62+
.values({
63+
id: "new_message_id",
64+
bundleId: "welcome_header",
65+
locale: "en",
66+
})
67+
.execute();
68+
69+
// Save changes back to disk
70+
import { saveProjectToDirectory } from "@inlang/sdk";
71+
await saveProjectToDirectory({ path: "./project.inlang", project });
72+
\`\`\`
73+
74+
## Data model
75+
76+
\`\`\`
77+
bundle (a concept, e.g., "welcome_header")
78+
└── message (per locale, e.g., "en", "de")
79+
└── variant (plural forms, gender, etc.)
80+
\`\`\`
81+
82+
- **bundle**: Groups messages by ID (e.g., \`welcome_header\`)
83+
- **message**: A translation for a specific locale
84+
- **variant**: Handles pluralization/selectors (most messages have one variant)
85+
86+
## Common tasks
87+
88+
| Task | Code |
89+
| ------------------------- | ----------------------------------------------------------------------------------- |
90+
| Get all bundles | \`project.db.selectFrom("bundle").selectAll().execute()\` |
91+
| Get messages for locale | \`project.db.selectFrom("message").where("locale", "=", "en").selectAll().execute()\` |
92+
| Find missing translations | Compare message counts across locales |
93+
| Update a message | \`project.db.updateTable("message").set({ ... }).where("id", "=", "...").execute()\` |
94+
95+
## Links
96+
97+
- [SDK documentation](https://inlang.com/docs)
98+
- [inlang.com](https://inlang.com)
99+
- [List of plugins](https://inlang.com/c/plugins)
100+
- [List of tools](https://inlang.com/c/tools)
101+
`;

packages/sdk/src/project/loadProjectFromDirectory.ts

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -447,19 +447,12 @@ async function syncLixFsFiles(args: {
447447
fsState.state = "known";
448448
} else if (lixState.state === "updated") {
449449
// seems like we saw an update on the file in fs while some changes on lix have not been reached fs? FS -> Winns?
450-
console.warn(
451-
"seems like we saw an update on the file " +
452-
path +
453-
" in fs while some changes on lix have not been reached fs? FS -> Winns?"
454-
);
455450
await upsertFileInLix(args, path, fsState.content);
456451
lixState.content = fsState.content;
457452
lixState.state = "known";
458453
fsState.state = "known";
459454
} else if (lixState.state === "gone") {
460-
console.warn(
461-
"seems like we saw an delete in lix while some changes on fs have not been reached fs? FS -> Winns?"
462-
);
455+
// seems like we saw an delete in lix while some changes on fs have not been reached fs? FS -> Winns?
463456
// TODO update the lix state
464457
lixState.content = fsState.content;
465458
lixState.state = "known";
@@ -485,9 +478,6 @@ async function syncLixFsFiles(args: {
485478
lixState.state = "gone";
486479
} else if (lixState.state === "updated") {
487480
// seems like we saw an update on the file in fs while some changes on lix have not been reached fs? FS -> Winns?
488-
console.warn(
489-
"seems like we saw an update on the file in fs while some changes on lix have not been reached fs? FS -> Winns?"
490-
);
491481
await args.lix.db
492482
.deleteFrom("file")
493483
.where("path", "=", path)
@@ -496,9 +486,7 @@ async function syncLixFsFiles(args: {
496486
lixState.state = "gone";
497487
fsState.state = "gone";
498488
} else if (lixState.state === "gone") {
499-
console.warn(
500-
"seems like we saw an delete in lix while we have a delete in lix simultaniously?"
501-
);
489+
// seems like we saw an delete in lix while we have a delete in lix simultaniously?
502490
lixState.state = "gone";
503491
fsState.state = "gone";
504492
}

packages/sdk/src/project/saveProjectToDirectory.test.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,95 @@ test("adds a gitignore file if it doesn't exist", async () => {
326326
"/foo/bar.inlang/.gitignore",
327327
"utf-8"
328328
);
329-
expect(gitignore).toBe("cache");
329+
expect(gitignore).toBe("# this file is auto generated\ncache\nREADME.md");
330+
});
331+
332+
test("emits a README.md file for coding agents", async () => {
333+
const fs = Volume.fromJSON({});
334+
335+
const project = await loadProjectInMemory({
336+
blob: await newProject(),
337+
});
338+
339+
await saveProjectToDirectory({
340+
fs: fs.promises as any,
341+
project,
342+
path: "/foo/bar.inlang",
343+
});
344+
345+
const readme = await fs.promises.readFile(
346+
"/foo/bar.inlang/README.md",
347+
"utf-8"
348+
);
349+
expect(readme).toContain("// this readme is auto generated");
350+
expect(readme).toContain("## What is this folder?");
351+
expect(readme).toContain("@inlang/sdk");
352+
});
353+
354+
test("updates an existing README.md file", async () => {
355+
const fs = Volume.fromJSON({
356+
"/foo/bar.inlang/README.md": "custom readme",
357+
});
358+
359+
const project = await loadProjectInMemory({
360+
blob: await newProject(),
361+
});
362+
363+
await saveProjectToDirectory({
364+
fs: fs.promises as any,
365+
project,
366+
path: "/foo/bar.inlang",
367+
});
368+
369+
const readme = await fs.promises.readFile(
370+
"/foo/bar.inlang/README.md",
371+
"utf-8"
372+
);
373+
expect(readme).toContain("// this readme is auto generated");
374+
expect(readme).not.toContain("custom readme");
375+
});
376+
377+
test("README.md is gitignored", async () => {
378+
const fs = Volume.fromJSON({});
379+
380+
const project = await loadProjectInMemory({
381+
blob: await newProject(),
382+
});
383+
384+
await saveProjectToDirectory({
385+
fs: fs.promises as any,
386+
project,
387+
path: "/foo/bar.inlang",
388+
});
389+
390+
const gitignore = await fs.promises.readFile(
391+
"/foo/bar.inlang/.gitignore",
392+
"utf-8"
393+
);
394+
expect(gitignore).toContain("README.md");
395+
expect(gitignore).toContain("# this file is auto generated");
396+
});
397+
398+
test("overwrites existing .gitignore with generated entries", async () => {
399+
const fs = Volume.fromJSON({
400+
"/foo/bar.inlang/.gitignore": "custom\nnode_modules",
401+
});
402+
403+
const project = await loadProjectInMemory({
404+
blob: await newProject(),
405+
});
406+
407+
await saveProjectToDirectory({
408+
fs: fs.promises as any,
409+
project,
410+
path: "/foo/bar.inlang",
411+
});
412+
413+
const gitignore = await fs.promises.readFile(
414+
"/foo/bar.inlang/.gitignore",
415+
"utf-8"
416+
);
417+
expect(gitignore).toBe("# this file is auto generated\ncache\nREADME.md");
330418
});
331419

332420
test("uses exportFiles when both exportFiles and saveMessages are defined", async () => {

packages/sdk/src/project/saveProjectToDirectory.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from "./loadProjectFromDirectory.js";
99
import { detectJsonFormatting } from "../utilities/detectJsonFormatting.js";
1010
import { selectBundleNested } from "../query-utilities/selectBundleNested.js";
11+
import { README_CONTENT } from "./README_CONTENT.js";
1112

1213
export async function saveProjectToDirectory(args: {
1314
fs: typeof fs;
@@ -22,26 +23,30 @@ export async function saveProjectToDirectory(args: {
2223
.selectAll()
2324
.execute();
2425

25-
let hasGitignore = false;
26+
const gitignoreContent = new TextEncoder().encode(
27+
"# this file is auto generated\ncache\nREADME.md"
28+
);
2629

2730
// write all files to the directory
2831
for (const file of files) {
2932
if (file.path.endsWith("db.sqlite")) {
3033
continue;
31-
} else if (file.path.endsWith(".gitignore")) {
32-
hasGitignore = true;
3334
}
3435
const p = path.join(args.path, file.path);
3536
await args.fs.mkdir(path.dirname(p), { recursive: true });
3637
await args.fs.writeFile(p, new Uint8Array(file.data));
3738
}
3839

39-
if (hasGitignore === false) {
40-
await args.fs.writeFile(
41-
path.join(args.path, ".gitignore"),
42-
new TextEncoder().encode("cache")
43-
);
44-
}
40+
await args.fs.writeFile(
41+
path.join(args.path, ".gitignore"),
42+
gitignoreContent
43+
);
44+
45+
// Write README.md for coding agents
46+
await args.fs.writeFile(
47+
path.join(args.path, "README.md"),
48+
new TextEncoder().encode(README_CONTENT)
49+
);
4550

4651
// run exporters
4752
const plugins = await args.project.plugins.get();

plan.md

Lines changed: 0 additions & 17 deletions
This file was deleted.

0 commit comments

Comments
 (0)