@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 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
+ ```
@@ -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,2 @@
1
+ export { paraglide } from './adapter.js';
2
+ export type { ParaglideAdapterOptions } from './adapter.js';
@@ -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
+ });