@dialekt/adapter-paraglide 0.1.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/TESTING.md +28 -0
- package/dist/index.d.mts +10 -0
- package/dist/index.mjs +127 -0
- package/package.json +27 -0
- package/src/adapter.test.ts +155 -0
- package/src/adapter.ts +116 -0
- package/src/index.ts +2 -0
- package/src/message-file.test.ts +66 -0
- package/src/message-file.ts +68 -0
- package/src/unused-keys.test.ts +51 -0
- package/src/unused-keys.ts +59 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/tsdown.config.ts +7 -0
- package/vitest.config.ts +8 -0
package/TESTING.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# @dialekt/adapter-paraglide Testing Guide
|
|
2
|
+
|
|
3
|
+
## Tested Areas Map
|
|
4
|
+
|
|
5
|
+
| Export | Test File | Status |
|
|
6
|
+
|--------|-----------|--------|
|
|
7
|
+
| `paraglide()` adapter | `src/adapter.test.ts` | ✅ |
|
|
8
|
+
| `readMessageFile` / `writeMessageFile` | `src/message-file.test.ts` | ✅ |
|
|
9
|
+
| `findUnusedParaglideKeys` | `src/unused-keys.test.ts` | ✅ |
|
|
10
|
+
|
|
11
|
+
## Known Coverage Gaps
|
|
12
|
+
|
|
13
|
+
- Invalid JSON in message files — `readMessageFile` would throw but no explicit unhappy-path test
|
|
14
|
+
- Nested objects in Paraglide JSON — `readMessageFile` flattens them but no explicit test for deeply nested structures
|
|
15
|
+
- `scanPaths` edge cases with binary files or very large files
|
|
16
|
+
|
|
17
|
+
## Specialties & Watch-Outs
|
|
18
|
+
|
|
19
|
+
- Tests create real temp directories via `node:fs` and clean up with `rmSync`.
|
|
20
|
+
- `writeMessageFile` preserves `$schema` and other meta keys by reading existing file first.
|
|
21
|
+
- `findUnusedParaglideKeys` matches `m.keyName()` and `m.keyName` (without parens) patterns.
|
|
22
|
+
- The adapter always reports exactly one resource (`messages`).
|
|
23
|
+
|
|
24
|
+
## Running Tests
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pnpm --filter @dialekt/adapter-paraglide test
|
|
28
|
+
```
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { TranslationAdapter } from "dialekt";
|
|
2
|
+
|
|
3
|
+
//#region src/adapter.d.ts
|
|
4
|
+
interface ParaglideAdapterOptions {
|
|
5
|
+
readonly messagesDir: string;
|
|
6
|
+
readonly scanPaths?: readonly string[];
|
|
7
|
+
}
|
|
8
|
+
declare function paraglide(options: ParaglideAdapterOptions): TranslationAdapter;
|
|
9
|
+
//#endregion
|
|
10
|
+
export { type ParaglideAdapterOptions, paraglide };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { Path } from "@effect/platform/Path";
|
|
3
|
+
import { FileSystem } from "@effect/platform/FileSystem";
|
|
4
|
+
import { AdapterReadError, AdapterWriteError, NodePlatformLayer, flattenObject, unflattenObject } from "dialekt";
|
|
5
|
+
import { FileSystem as FileSystem$1, Path as Path$1 } from "@effect/platform";
|
|
6
|
+
//#region src/message-file.ts
|
|
7
|
+
function readMessageFile(path) {
|
|
8
|
+
return Effect.gen(function* () {
|
|
9
|
+
const fs = yield* FileSystem$1.FileSystem;
|
|
10
|
+
if (!(yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false)))) return {
|
|
11
|
+
translations: {},
|
|
12
|
+
meta: {}
|
|
13
|
+
};
|
|
14
|
+
const content = yield* fs.readFileString(path).pipe(Effect.orElseSucceed(() => "{}"));
|
|
15
|
+
const parsed = yield* Effect.try({
|
|
16
|
+
try: () => JSON.parse(content),
|
|
17
|
+
catch: () => ({})
|
|
18
|
+
}).pipe(Effect.orElseSucceed(() => ({})));
|
|
19
|
+
const meta = {};
|
|
20
|
+
const translations = {};
|
|
21
|
+
for (const [key, value] of Object.entries(parsed)) if (key.startsWith("$")) meta[key] = value;
|
|
22
|
+
else if (typeof value === "string") translations[key] = value;
|
|
23
|
+
else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
24
|
+
const flattened = flattenObject(value);
|
|
25
|
+
for (const [flatKey, flatValue] of Object.entries(flattened)) if (typeof flatValue === "string") translations[`${key}.${flatKey}`] = flatValue;
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
translations,
|
|
29
|
+
meta
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function writeMessageFile(path, translations, meta) {
|
|
34
|
+
return Effect.gen(function* () {
|
|
35
|
+
const fs = yield* FileSystem$1.FileSystem;
|
|
36
|
+
const dir = (yield* Path$1.Path).dirname(path);
|
|
37
|
+
yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orElseSucceed(() => void 0));
|
|
38
|
+
const unflattened = unflattenObject(translations);
|
|
39
|
+
const output = { ...meta };
|
|
40
|
+
for (const [key, value] of Object.entries(unflattened)) output[key] = value;
|
|
41
|
+
yield* fs.writeFileString(path, JSON.stringify(output, null, 2) + "\n").pipe(Effect.orElseSucceed(() => void 0));
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
//#endregion
|
|
45
|
+
//#region src/unused-keys.ts
|
|
46
|
+
function findUnusedParaglideKeys(scanPaths, keys) {
|
|
47
|
+
return Effect.gen(function* () {
|
|
48
|
+
const fs = yield* FileSystem$1.FileSystem;
|
|
49
|
+
const path = yield* Path$1.Path;
|
|
50
|
+
const referenced = /* @__PURE__ */ new Set();
|
|
51
|
+
for (const scanPath of scanPaths) {
|
|
52
|
+
if (!(yield* fs.exists(scanPath).pipe(Effect.orElseSucceed(() => false)))) continue;
|
|
53
|
+
const entries = yield* fs.readDirectory(scanPath, { recursive: true }).pipe(Effect.orElseSucceed(() => []));
|
|
54
|
+
for (const relativePath of entries) {
|
|
55
|
+
if (!relativePath.endsWith(".ts") && !relativePath.endsWith(".tsx") && !relativePath.endsWith(".js") && !relativePath.endsWith(".jsx") && !relativePath.endsWith(".svelte") && !relativePath.endsWith(".vue")) continue;
|
|
56
|
+
const filePath = path.join(scanPath, relativePath);
|
|
57
|
+
const content = yield* fs.readFileString(filePath).pipe(Effect.orElseSucceed(() => ""));
|
|
58
|
+
for (const key of keys) if (new RegExp(`\\bm\\.${key}\\b`).test(content)) referenced.add(key);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return keys.filter((key) => !referenced.has(key));
|
|
62
|
+
}).pipe(Effect.mapError((cause) => new AdapterReadError({
|
|
63
|
+
adapter: "paraglide",
|
|
64
|
+
locale: "",
|
|
65
|
+
resource: "messages",
|
|
66
|
+
cause
|
|
67
|
+
})));
|
|
68
|
+
}
|
|
69
|
+
//#endregion
|
|
70
|
+
//#region src/adapter.ts
|
|
71
|
+
function paraglide(options) {
|
|
72
|
+
const { messagesDir, scanPaths = [] } = options;
|
|
73
|
+
return {
|
|
74
|
+
name: "paraglide",
|
|
75
|
+
capabilities: {
|
|
76
|
+
canCreateResource: true,
|
|
77
|
+
unusedKeyDetection: true
|
|
78
|
+
},
|
|
79
|
+
listLocales: () => Effect.gen(function* () {
|
|
80
|
+
const fs = yield* FileSystem;
|
|
81
|
+
yield* Path;
|
|
82
|
+
if (!(yield* fs.exists(messagesDir).pipe(Effect.orElseSucceed(() => false)))) return [];
|
|
83
|
+
const entries = yield* fs.readDirectory(messagesDir).pipe(Effect.orElseSucceed(() => []));
|
|
84
|
+
const locales = [];
|
|
85
|
+
for (const entry of entries) if (entry.endsWith(".json")) locales.push(entry.replace(/\.json$/, ""));
|
|
86
|
+
return locales;
|
|
87
|
+
}).pipe(Effect.mapError((cause) => new AdapterReadError({
|
|
88
|
+
adapter: "paraglide",
|
|
89
|
+
locale: "",
|
|
90
|
+
resource: "",
|
|
91
|
+
cause
|
|
92
|
+
})), Effect.provide([NodePlatformLayer])),
|
|
93
|
+
listResources: () => Effect.succeed([{
|
|
94
|
+
key: "messages",
|
|
95
|
+
label: "messages"
|
|
96
|
+
}]),
|
|
97
|
+
readResource: (locale, resource) => Effect.gen(function* () {
|
|
98
|
+
return (yield* readMessageFile((yield* Path).join(messagesDir, `${locale}.json`)).pipe(Effect.mapError((cause) => new AdapterReadError({
|
|
99
|
+
adapter: "paraglide",
|
|
100
|
+
locale,
|
|
101
|
+
resource: resource.key,
|
|
102
|
+
cause
|
|
103
|
+
})))).translations;
|
|
104
|
+
}).pipe(Effect.provide([NodePlatformLayer])),
|
|
105
|
+
writeResource: (locale, resource, entries) => Effect.gen(function* () {
|
|
106
|
+
const filePath = (yield* Path).join(messagesDir, `${locale}.json`);
|
|
107
|
+
yield* writeMessageFile(filePath, entries, (yield* readMessageFile(filePath).pipe(Effect.orElseSucceed(() => ({
|
|
108
|
+
translations: {},
|
|
109
|
+
meta: {}
|
|
110
|
+
})))).meta).pipe(Effect.mapError((cause) => new AdapterWriteError({
|
|
111
|
+
adapter: "paraglide",
|
|
112
|
+
locale,
|
|
113
|
+
resource: resource.key,
|
|
114
|
+
cause
|
|
115
|
+
})));
|
|
116
|
+
}).pipe(Effect.provide([NodePlatformLayer])),
|
|
117
|
+
findUnusedKeys: (locale, resource) => Effect.gen(function* () {
|
|
118
|
+
const path = yield* Path;
|
|
119
|
+
return yield* findUnusedParaglideKeys(scanPaths.length > 0 ? scanPaths : [path.resolve(messagesDir, "..")], yield* Effect.gen(function* () {
|
|
120
|
+
const map = yield* paraglide(options).readResource(locale, resource);
|
|
121
|
+
return Object.keys(map);
|
|
122
|
+
}));
|
|
123
|
+
}).pipe(Effect.provide([NodePlatformLayer]))
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
//#endregion
|
|
127
|
+
export { paraglide };
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dialekt/adapter-paraglide",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./dist/index.d.mts",
|
|
8
|
+
"import": "./dist/index.mjs"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@effect/platform": "^0.96.0",
|
|
13
|
+
"effect": "^3.21.0",
|
|
14
|
+
"dialekt": "0.1.0"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@types/node": "^26.0.1",
|
|
18
|
+
"tsdown": "^0.22.3",
|
|
19
|
+
"typescript": "^5.8.0",
|
|
20
|
+
"vitest": "^4.0.0"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsdown",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"test": "vitest run"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { Effect } from 'effect';
|
|
3
|
+
import { NodePlatformLayer } from 'dialekt';
|
|
4
|
+
import { paraglide } from './adapter.js';
|
|
5
|
+
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
|
|
9
|
+
describe('paraglide adapter', () => {
|
|
10
|
+
const testDir = join(tmpdir(), `paraglide-adapter-test-${Date.now()}`);
|
|
11
|
+
|
|
12
|
+
it('reads a message resource', async () => {
|
|
13
|
+
mkdirSync(testDir, { recursive: true });
|
|
14
|
+
writeFileSync(join(testDir, 'en.json'), JSON.stringify({ greeting: 'Hello' }));
|
|
15
|
+
|
|
16
|
+
const adapter = paraglide({ messagesDir: testDir, scanPaths: [testDir] });
|
|
17
|
+
const program = adapter.readResource('en', { key: 'messages', label: 'messages' }).pipe(
|
|
18
|
+
Effect.provide(NodePlatformLayer),
|
|
19
|
+
);
|
|
20
|
+
const result = await Effect.runPromise(program);
|
|
21
|
+
expect(result).toEqual({ greeting: 'Hello' });
|
|
22
|
+
|
|
23
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('lists locales from filenames', async () => {
|
|
27
|
+
mkdirSync(testDir, { recursive: true });
|
|
28
|
+
writeFileSync(join(testDir, 'en.json'), '{}');
|
|
29
|
+
writeFileSync(join(testDir, 'de.json'), '{}');
|
|
30
|
+
|
|
31
|
+
const adapter = paraglide({ messagesDir: testDir });
|
|
32
|
+
const program = adapter.listLocales().pipe(Effect.provide(NodePlatformLayer));
|
|
33
|
+
const result = await Effect.runPromise(program);
|
|
34
|
+
expect(result).toContain('en');
|
|
35
|
+
expect(result).toContain('de');
|
|
36
|
+
|
|
37
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('lists exactly one resource', async () => {
|
|
41
|
+
const adapter = paraglide({ messagesDir: testDir });
|
|
42
|
+
const program = adapter.listResources('en').pipe(Effect.provide(NodePlatformLayer));
|
|
43
|
+
const result = await Effect.runPromise(program);
|
|
44
|
+
expect(result).toHaveLength(1);
|
|
45
|
+
expect(result[0]).toEqual({ key: 'messages', label: 'messages' });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('round-trips write and read preserving meta', async () => {
|
|
49
|
+
mkdirSync(testDir, { recursive: true });
|
|
50
|
+
writeFileSync(
|
|
51
|
+
join(testDir, 'de.json'),
|
|
52
|
+
JSON.stringify({ $schema: 'https://inlang.com/schema', greeting: 'Hallo' }),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const adapter = paraglide({ messagesDir: testDir });
|
|
56
|
+
const writeProgram = adapter
|
|
57
|
+
.writeResource('de', { key: 'messages', label: 'messages' }, { greeting: 'Hallo!', farewell: 'Tschüss' })
|
|
58
|
+
.pipe(Effect.provide(NodePlatformLayer));
|
|
59
|
+
await Effect.runPromise(writeProgram);
|
|
60
|
+
|
|
61
|
+
const readProgram = adapter.readResource('de', { key: 'messages', label: 'messages' }).pipe(
|
|
62
|
+
Effect.provide(NodePlatformLayer),
|
|
63
|
+
);
|
|
64
|
+
const result = await Effect.runPromise(readProgram);
|
|
65
|
+
expect(result).toEqual({ greeting: 'Hallo!', farewell: 'Tschüss' });
|
|
66
|
+
|
|
67
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('finds unused keys', async () => {
|
|
71
|
+
mkdirSync(testDir, { recursive: true });
|
|
72
|
+
writeFileSync(join(testDir, 'en.json'), JSON.stringify({ greeting: 'Hello', farewell: 'Goodbye' }));
|
|
73
|
+
mkdirSync(join(testDir, 'src'), { recursive: true });
|
|
74
|
+
writeFileSync(join(testDir, 'src', 'page.ts'), 'm.greeting();');
|
|
75
|
+
|
|
76
|
+
const adapter = paraglide({ messagesDir: testDir, scanPaths: [join(testDir, 'src')] });
|
|
77
|
+
const program = adapter
|
|
78
|
+
.findUnusedKeys!('en', { key: 'messages', label: 'messages' })
|
|
79
|
+
.pipe(Effect.provide(NodePlatformLayer));
|
|
80
|
+
const result = await Effect.runPromise(program);
|
|
81
|
+
expect(result).toEqual(['farewell']);
|
|
82
|
+
|
|
83
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('returns empty for missing locale file', async () => {
|
|
87
|
+
mkdirSync(testDir, { recursive: true });
|
|
88
|
+
|
|
89
|
+
const adapter = paraglide({ messagesDir: testDir });
|
|
90
|
+
const program = adapter.readResource('missing', { key: 'messages', label: 'messages' }).pipe(
|
|
91
|
+
Effect.provide(NodePlatformLayer),
|
|
92
|
+
);
|
|
93
|
+
const result = await Effect.runPromise(program);
|
|
94
|
+
expect(result).toEqual({});
|
|
95
|
+
|
|
96
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('handles empty messages directory', async () => {
|
|
100
|
+
mkdirSync(testDir, { recursive: true });
|
|
101
|
+
|
|
102
|
+
const adapter = paraglide({ messagesDir: testDir });
|
|
103
|
+
const program = adapter.listLocales().pipe(Effect.provide(NodePlatformLayer));
|
|
104
|
+
const result = await Effect.runPromise(program);
|
|
105
|
+
expect(result).toEqual([]);
|
|
106
|
+
|
|
107
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('handles capabilities flags', () => {
|
|
111
|
+
const adapter = paraglide({ messagesDir: testDir });
|
|
112
|
+
expect(adapter.capabilities.canCreateResource).toBe(true);
|
|
113
|
+
expect(adapter.capabilities.unusedKeyDetection).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('reports adapter name', () => {
|
|
117
|
+
const adapter = paraglide({ messagesDir: testDir });
|
|
118
|
+
expect(adapter.name).toBe('paraglide');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('ignores non-JSON files', async () => {
|
|
122
|
+
mkdirSync(testDir, { recursive: true });
|
|
123
|
+
writeFileSync(join(testDir, 'README.md'), '# Messages');
|
|
124
|
+
writeFileSync(join(testDir, 'en.json'), '{}');
|
|
125
|
+
|
|
126
|
+
const adapter = paraglide({ messagesDir: testDir });
|
|
127
|
+
const program = adapter.listLocales().pipe(Effect.provide(NodePlatformLayer));
|
|
128
|
+
const result = await Effect.runPromise(program);
|
|
129
|
+
expect(result).toEqual(['en']);
|
|
130
|
+
|
|
131
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('preserves meta keys on write', async () => {
|
|
135
|
+
mkdirSync(testDir, { recursive: true });
|
|
136
|
+
writeFileSync(
|
|
137
|
+
join(testDir, 'de.json'),
|
|
138
|
+
JSON.stringify({ $schema: 'https://inlang.com/schema', module: 'messages' }),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const adapter = paraglide({ messagesDir: testDir });
|
|
142
|
+
const writeProgram = adapter
|
|
143
|
+
.writeResource('de', { key: 'messages', label: 'messages' }, { hello: 'Hallo' })
|
|
144
|
+
.pipe(Effect.provide(NodePlatformLayer));
|
|
145
|
+
await Effect.runPromise(writeProgram);
|
|
146
|
+
|
|
147
|
+
const readProgram = adapter.readResource('de', { key: 'messages', label: 'messages' }).pipe(
|
|
148
|
+
Effect.provide(NodePlatformLayer),
|
|
149
|
+
);
|
|
150
|
+
const result = await Effect.runPromise(readProgram);
|
|
151
|
+
expect(result).toEqual({ hello: 'Hallo' });
|
|
152
|
+
|
|
153
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
154
|
+
});
|
|
155
|
+
});
|
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
import { Path } from '@effect/platform/Path';
|
|
3
|
+
import { FileSystem } from '@effect/platform/FileSystem';
|
|
4
|
+
import type {
|
|
5
|
+
ResourceRef,
|
|
6
|
+
TranslationAdapter,
|
|
7
|
+
AdapterReadError,
|
|
8
|
+
AdapterWriteError,
|
|
9
|
+
} from 'dialekt';
|
|
10
|
+
import {
|
|
11
|
+
AdapterReadError as AdapterReadErrorClass,
|
|
12
|
+
AdapterWriteError as AdapterWriteErrorClass,
|
|
13
|
+
NodePlatformLayer,
|
|
14
|
+
} from 'dialekt';
|
|
15
|
+
import { readMessageFile, writeMessageFile } from './message-file.js';
|
|
16
|
+
import { findUnusedParaglideKeys } from './unused-keys.js';
|
|
17
|
+
|
|
18
|
+
export interface ParaglideAdapterOptions {
|
|
19
|
+
readonly messagesDir: string;
|
|
20
|
+
readonly scanPaths?: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function paraglide(options: ParaglideAdapterOptions): TranslationAdapter {
|
|
24
|
+
const { messagesDir, scanPaths = [] } = options;
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
name: 'paraglide',
|
|
28
|
+
capabilities: {
|
|
29
|
+
canCreateResource: true,
|
|
30
|
+
unusedKeyDetection: true,
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
listLocales: () =>
|
|
34
|
+
Effect.gen(function* () {
|
|
35
|
+
const fs = yield* FileSystem;
|
|
36
|
+
const path = yield* Path;
|
|
37
|
+
const exists = yield* fs.exists(messagesDir).pipe(Effect.orElseSucceed(() => false));
|
|
38
|
+
if (!exists) return [];
|
|
39
|
+
const entries = yield* fs.readDirectory(messagesDir).pipe(
|
|
40
|
+
Effect.orElseSucceed(() => [] as string[]),
|
|
41
|
+
);
|
|
42
|
+
const locales: string[] = [];
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
if (entry.endsWith('.json')) {
|
|
45
|
+
locales.push(entry.replace(/\.json$/, ''));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return locales;
|
|
49
|
+
}).pipe(
|
|
50
|
+
Effect.mapError(
|
|
51
|
+
(cause) =>
|
|
52
|
+
new AdapterReadErrorClass({
|
|
53
|
+
adapter: 'paraglide',
|
|
54
|
+
locale: '',
|
|
55
|
+
resource: '',
|
|
56
|
+
cause,
|
|
57
|
+
}) as AdapterReadError,
|
|
58
|
+
),
|
|
59
|
+
Effect.provide([NodePlatformLayer]),
|
|
60
|
+
) as Effect.Effect<readonly string[], AdapterReadError, never>,
|
|
61
|
+
|
|
62
|
+
listResources: () =>
|
|
63
|
+
Effect.succeed([{ key: 'messages', label: 'messages' }]),
|
|
64
|
+
|
|
65
|
+
readResource: (locale, resource) =>
|
|
66
|
+
Effect.gen(function* () {
|
|
67
|
+
const path = yield* Path;
|
|
68
|
+
const filePath = path.join(messagesDir, `${locale}.json`);
|
|
69
|
+
const result = yield* readMessageFile(filePath).pipe(
|
|
70
|
+
Effect.mapError(
|
|
71
|
+
(cause) =>
|
|
72
|
+
new AdapterReadErrorClass({
|
|
73
|
+
adapter: 'paraglide',
|
|
74
|
+
locale,
|
|
75
|
+
resource: resource.key,
|
|
76
|
+
cause,
|
|
77
|
+
}) as AdapterReadError,
|
|
78
|
+
),
|
|
79
|
+
);
|
|
80
|
+
return result.translations;
|
|
81
|
+
}).pipe(Effect.provide([NodePlatformLayer])) as Effect.Effect<Record<string, string>, AdapterReadError, never>,
|
|
82
|
+
|
|
83
|
+
writeResource: (locale, resource, entries) =>
|
|
84
|
+
Effect.gen(function* () {
|
|
85
|
+
const path = yield* Path;
|
|
86
|
+
const filePath = path.join(messagesDir, `${locale}.json`);
|
|
87
|
+
const existing = yield* readMessageFile(filePath).pipe(Effect.orElseSucceed(() => ({
|
|
88
|
+
translations: {},
|
|
89
|
+
meta: {},
|
|
90
|
+
})));
|
|
91
|
+
yield* writeMessageFile(filePath, entries, existing.meta).pipe(
|
|
92
|
+
Effect.mapError(
|
|
93
|
+
(cause) =>
|
|
94
|
+
new AdapterWriteErrorClass({
|
|
95
|
+
adapter: 'paraglide',
|
|
96
|
+
locale,
|
|
97
|
+
resource: resource.key,
|
|
98
|
+
cause,
|
|
99
|
+
}) as AdapterWriteError,
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
}).pipe(Effect.provide([NodePlatformLayer])) as Effect.Effect<void, AdapterWriteError, never>,
|
|
103
|
+
|
|
104
|
+
findUnusedKeys: (locale, resource) =>
|
|
105
|
+
Effect.gen(function* () {
|
|
106
|
+
const path = yield* Path;
|
|
107
|
+
const adapterScanPaths =
|
|
108
|
+
scanPaths.length > 0 ? scanPaths : [path.resolve(messagesDir, '..')];
|
|
109
|
+
const keys = yield* Effect.gen(function* () {
|
|
110
|
+
const map = yield* paraglide(options).readResource(locale, resource);
|
|
111
|
+
return Object.keys(map);
|
|
112
|
+
});
|
|
113
|
+
return yield* findUnusedParaglideKeys(adapterScanPaths, keys);
|
|
114
|
+
}).pipe(Effect.provide([NodePlatformLayer])) as Effect.Effect<readonly string[], AdapterReadError, never>,
|
|
115
|
+
};
|
|
116
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { Effect, Layer } from 'effect';
|
|
3
|
+
import { FileSystem, Path } from '@effect/platform';
|
|
4
|
+
import { readMessageFile, writeMessageFile } from './message-file.js';
|
|
5
|
+
|
|
6
|
+
function makeFsLayer(files: Record<string, string>) {
|
|
7
|
+
const stub = FileSystem.makeNoop({
|
|
8
|
+
exists: (path) => Effect.succeed(path in files),
|
|
9
|
+
readFileString: (path) =>
|
|
10
|
+
path in files ? Effect.succeed(files[path]!) : Effect.fail(new Error('ENOENT') as never),
|
|
11
|
+
writeFileString: (path, content) => {
|
|
12
|
+
files[path] = content;
|
|
13
|
+
return Effect.void;
|
|
14
|
+
},
|
|
15
|
+
makeDirectory: () => Effect.void,
|
|
16
|
+
});
|
|
17
|
+
return Layer.succeed(FileSystem.FileSystem, stub);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('readMessageFile', () => {
|
|
21
|
+
it('reads flat translations and preserves meta keys', async () => {
|
|
22
|
+
const files = {
|
|
23
|
+
'/messages/en.json': JSON.stringify({
|
|
24
|
+
$schema: 'https://inlang.com/schema',
|
|
25
|
+
greeting: 'Hello',
|
|
26
|
+
farewell: 'Goodbye',
|
|
27
|
+
}),
|
|
28
|
+
};
|
|
29
|
+
const program = readMessageFile('/messages/en.json').pipe(Effect.provide(makeFsLayer(files)));
|
|
30
|
+
const result = await Effect.runPromise(program);
|
|
31
|
+
expect(result.translations).toEqual({ greeting: 'Hello', farewell: 'Goodbye' });
|
|
32
|
+
expect(result.meta).toEqual({ $schema: 'https://inlang.com/schema' });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('returns empty for missing file', async () => {
|
|
36
|
+
const program = readMessageFile('/messages/missing.json').pipe(Effect.provide(makeFsLayer({})));
|
|
37
|
+
const result = await Effect.runPromise(program);
|
|
38
|
+
expect(result.translations).toEqual({});
|
|
39
|
+
expect(result.meta).toEqual({});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('flattens nested objects', async () => {
|
|
43
|
+
const files = {
|
|
44
|
+
'/messages/en.json': JSON.stringify({
|
|
45
|
+
nav: { home: 'Home', about: 'About' },
|
|
46
|
+
}),
|
|
47
|
+
};
|
|
48
|
+
const program = readMessageFile('/messages/en.json').pipe(Effect.provide(makeFsLayer(files)));
|
|
49
|
+
const result = await Effect.runPromise(program);
|
|
50
|
+
expect(result.translations).toEqual({ 'nav.home': 'Home', 'nav.about': 'About' });
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('writeMessageFile', () => {
|
|
55
|
+
it('round-trips translations and meta', async () => {
|
|
56
|
+
const files: Record<string, string> = {};
|
|
57
|
+
const program = writeMessageFile(
|
|
58
|
+
'/messages/de.json',
|
|
59
|
+
{ greeting: 'Hallo' },
|
|
60
|
+
{ $schema: 'https://inlang.com/schema' },
|
|
61
|
+
).pipe(Effect.provide(makeFsLayer(files)), Effect.provide(Path.layer));
|
|
62
|
+
await Effect.runPromise(program);
|
|
63
|
+
expect(files['/messages/de.json']).toContain('Hallo');
|
|
64
|
+
expect(files['/messages/de.json']).toContain('$schema');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { FileSystem, Path } from '@effect/platform';
|
|
2
|
+
import { Effect } from 'effect';
|
|
3
|
+
import { flattenObject, unflattenObject } from 'dialekt';
|
|
4
|
+
|
|
5
|
+
export interface MessageFileResult {
|
|
6
|
+
readonly translations: Record<string, string>;
|
|
7
|
+
readonly meta: Record<string, unknown>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function readMessageFile(
|
|
11
|
+
path: string,
|
|
12
|
+
): Effect.Effect<MessageFileResult, never, FileSystem.FileSystem> {
|
|
13
|
+
return Effect.gen(function* () {
|
|
14
|
+
const fs = yield* FileSystem.FileSystem;
|
|
15
|
+
const exists = yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false));
|
|
16
|
+
if (!exists) {
|
|
17
|
+
return { translations: {}, meta: {} };
|
|
18
|
+
}
|
|
19
|
+
const content = yield* fs.readFileString(path).pipe(Effect.orElseSucceed(() => '{}'));
|
|
20
|
+
const parsed = yield* Effect.try({
|
|
21
|
+
try: () => JSON.parse(content) as Record<string, unknown>,
|
|
22
|
+
catch: () => ({}),
|
|
23
|
+
}).pipe(Effect.orElseSucceed(() => ({})));
|
|
24
|
+
|
|
25
|
+
const meta: Record<string, unknown> = {};
|
|
26
|
+
const translations: Record<string, string> = {};
|
|
27
|
+
|
|
28
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
29
|
+
if (key.startsWith('$')) {
|
|
30
|
+
meta[key] = value;
|
|
31
|
+
} else if (typeof value === 'string') {
|
|
32
|
+
translations[key] = value;
|
|
33
|
+
} else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
34
|
+
const flattened = flattenObject(value as Record<string, unknown>);
|
|
35
|
+
for (const [flatKey, flatValue] of Object.entries(flattened)) {
|
|
36
|
+
if (typeof flatValue === 'string') {
|
|
37
|
+
translations[`${key}.${flatKey}`] = flatValue;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { translations, meta };
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function writeMessageFile(
|
|
48
|
+
path: string,
|
|
49
|
+
translations: Record<string, string>,
|
|
50
|
+
meta: Record<string, unknown>,
|
|
51
|
+
): Effect.Effect<void, never, FileSystem.FileSystem | Path.Path> {
|
|
52
|
+
return Effect.gen(function* () {
|
|
53
|
+
const fs = yield* FileSystem.FileSystem;
|
|
54
|
+
const path_ = yield* Path.Path;
|
|
55
|
+
const dir = path_.dirname(path);
|
|
56
|
+
yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orElseSucceed(() => undefined));
|
|
57
|
+
|
|
58
|
+
const unflattened = unflattenObject(translations);
|
|
59
|
+
const output: Record<string, unknown> = { ...meta };
|
|
60
|
+
for (const [key, value] of Object.entries(unflattened)) {
|
|
61
|
+
output[key] = value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
yield* fs.writeFileString(path, JSON.stringify(output, null, 2) + '\n').pipe(
|
|
65
|
+
Effect.orElseSucceed(() => undefined),
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { Effect } from 'effect';
|
|
3
|
+
import { NodePlatformLayer } from 'dialekt';
|
|
4
|
+
import { findUnusedParaglideKeys } from './unused-keys.js';
|
|
5
|
+
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
|
|
9
|
+
describe('findUnusedParaglideKeys', () => {
|
|
10
|
+
it('finds keys not referenced in source files', async () => {
|
|
11
|
+
const dir = join(tmpdir(), `paraglide-unused-${Date.now()}`);
|
|
12
|
+
mkdirSync(join(dir, 'src'), { recursive: true });
|
|
13
|
+
writeFileSync(join(dir, 'src', 'page.tsx'), "const t = m.greeting({ name: 'x' });");
|
|
14
|
+
|
|
15
|
+
const program = findUnusedParaglideKeys([join(dir, 'src')], ['greeting', 'farewell']).pipe(
|
|
16
|
+
Effect.provide(NodePlatformLayer),
|
|
17
|
+
);
|
|
18
|
+
const result = await Effect.runPromise(program);
|
|
19
|
+
expect(result).toEqual(['farewell']);
|
|
20
|
+
|
|
21
|
+
rmSync(dir, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('finds m.key reference without call parens', async () => {
|
|
25
|
+
const dir = join(tmpdir(), `paraglide-unused-ref-${Date.now()}`);
|
|
26
|
+
mkdirSync(join(dir, 'src'), { recursive: true });
|
|
27
|
+
writeFileSync(join(dir, 'src', 'util.ts'), 'export const msg = m.greeting;');
|
|
28
|
+
|
|
29
|
+
const program = findUnusedParaglideKeys([join(dir, 'src')], ['greeting']).pipe(
|
|
30
|
+
Effect.provide(NodePlatformLayer),
|
|
31
|
+
);
|
|
32
|
+
const result = await Effect.runPromise(program);
|
|
33
|
+
expect(result).toEqual([]);
|
|
34
|
+
|
|
35
|
+
rmSync(dir, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('does not treat prefixes as matches', async () => {
|
|
39
|
+
const dir = join(tmpdir(), `paraglide-unused-prefix-${Date.now()}`);
|
|
40
|
+
mkdirSync(join(dir, 'src'), { recursive: true });
|
|
41
|
+
writeFileSync(join(dir, 'src', 'page.ts'), 'm.greeting();');
|
|
42
|
+
|
|
43
|
+
const program = findUnusedParaglideKeys([join(dir, 'src')], ['greet', 'greeting']).pipe(
|
|
44
|
+
Effect.provide(NodePlatformLayer),
|
|
45
|
+
);
|
|
46
|
+
const result = await Effect.runPromise(program);
|
|
47
|
+
expect(result).toEqual(['greet']);
|
|
48
|
+
|
|
49
|
+
rmSync(dir, { recursive: true, force: true });
|
|
50
|
+
});
|
|
51
|
+
});
|