dialekt 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.
Files changed (73) hide show
  1. package/README.md +62 -0
  2. package/TESTING.md +66 -0
  3. package/dist/cli/main.d.mts +1 -0
  4. package/dist/cli/main.mjs +412 -0
  5. package/dist/formatters-De4Q-X1d.mjs +577 -0
  6. package/dist/index.d.mts +329 -0
  7. package/dist/index.mjs +60 -0
  8. package/package.json +39 -0
  9. package/pnpm-workspace.yaml +7 -0
  10. package/src/adapter/types.test.ts +98 -0
  11. package/src/adapter/types.ts +73 -0
  12. package/src/benchmark/metrics.test.ts +180 -0
  13. package/src/benchmark/metrics.ts +69 -0
  14. package/src/benchmark/report.test.ts +129 -0
  15. package/src/benchmark/report.ts +21 -0
  16. package/src/benchmark/runner.test.ts +162 -0
  17. package/src/benchmark/runner.ts +27 -0
  18. package/src/cli/commands/add.test.ts +267 -0
  19. package/src/cli/commands/add.ts +123 -0
  20. package/src/cli/commands/benchmark.test.ts +346 -0
  21. package/src/cli/commands/benchmark.ts +148 -0
  22. package/src/cli/commands/languages.test.ts +127 -0
  23. package/src/cli/commands/languages.ts +42 -0
  24. package/src/cli/commands/missing.test.ts +256 -0
  25. package/src/cli/commands/missing.ts +88 -0
  26. package/src/cli/commands/translate.test.ts +384 -0
  27. package/src/cli/commands/translate.ts +106 -0
  28. package/src/cli/commands/unused.test.ts +192 -0
  29. package/src/cli/commands/unused.ts +87 -0
  30. package/src/cli/commands/validate.test.ts +245 -0
  31. package/src/cli/commands/validate.ts +96 -0
  32. package/src/cli/config-resolution.test.ts +99 -0
  33. package/src/cli/config-resolution.ts +29 -0
  34. package/src/cli/format.test.ts +117 -0
  35. package/src/cli/format.ts +205 -0
  36. package/src/cli/formatters.test.ts +186 -0
  37. package/src/cli/formatters.ts +350 -0
  38. package/src/cli/main.ts +31 -0
  39. package/src/config/define-config.test.ts +66 -0
  40. package/src/config/define-config.ts +5 -0
  41. package/src/config/load-config.test.ts +35 -0
  42. package/src/config/load-config.ts +21 -0
  43. package/src/config/types.test.ts +101 -0
  44. package/src/config/types.ts +28 -0
  45. package/src/index.ts +56 -0
  46. package/src/keys/flatten.test.ts +111 -0
  47. package/src/keys/flatten.ts +41 -0
  48. package/src/sdk/file-io.test.ts +139 -0
  49. package/src/sdk/file-io.ts +21 -0
  50. package/src/sdk/node-layer.test.ts +54 -0
  51. package/src/sdk/node-layer.ts +10 -0
  52. package/src/sdk/php-array-reader.test.ts +114 -0
  53. package/src/sdk/php-array-reader.ts +26 -0
  54. package/src/translation/chunking.test.ts +118 -0
  55. package/src/translation/chunking.ts +57 -0
  56. package/src/translation/missing-keys.test.ts +179 -0
  57. package/src/translation/missing-keys.ts +36 -0
  58. package/src/translation/model-registry.test.ts +54 -0
  59. package/src/translation/model-registry.ts +43 -0
  60. package/src/translation/one-shot-strategy.test.ts +259 -0
  61. package/src/translation/one-shot-strategy.ts +48 -0
  62. package/src/translation/orchestrator.test.ts +276 -0
  63. package/src/translation/orchestrator.ts +83 -0
  64. package/src/translation/prompt.test.ts +149 -0
  65. package/src/translation/prompt.ts +42 -0
  66. package/src/translation/tool-loop-strategy.test.ts +279 -0
  67. package/src/translation/tool-loop-strategy.ts +68 -0
  68. package/src/translation/types.test.ts +37 -0
  69. package/src/translation/types.ts +21 -0
  70. package/tsconfig.json +9 -0
  71. package/tsconfig.tsbuildinfo +1 -0
  72. package/tsdown.config.ts +7 -0
  73. package/vitest.config.ts +8 -0
@@ -0,0 +1,259 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { Effect, Either } from 'effect';
3
+ import { MockLanguageModelV3 } from 'ai/test';
4
+ import { createOneShotStrategy } from './one-shot-strategy.js';
5
+ import { TranslationFailedError } from './types.js';
6
+
7
+ describe('createOneShotStrategy', () => {
8
+ it('returns translated map on success', async () => {
9
+ const model = new MockLanguageModelV3({
10
+ doGenerate: async (options: { prompt: unknown }) => {
11
+ return {
12
+ text: '',
13
+ content: [
14
+ {
15
+ type: 'text',
16
+ text: JSON.stringify({ hello: 'Hallo', bye: 'Tschüss' }),
17
+ },
18
+ ],
19
+ finishReason: { unified: 'stop' as const, raw: 'stop' },
20
+ usage: {
21
+ inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 },
22
+ outputTokens: { total: 4, text: 4, reasoning: 0 },
23
+ },
24
+ response: {
25
+ modelId: 'mock',
26
+ timestamp: new Date(),
27
+ },
28
+ request: { body: {} },
29
+ warnings: [],
30
+ };
31
+ },
32
+ });
33
+
34
+ const strategy = createOneShotStrategy({
35
+ model,
36
+ retry: { maxAttempts: 3, baseDelayMs: 10 },
37
+ });
38
+
39
+ const ctx = {
40
+ sourceLocale: 'en',
41
+ targetLocale: 'de',
42
+ sourceMap: { hello: 'Hello', bye: 'Bye' },
43
+ targetMap: {},
44
+ keys: ['hello', 'bye'],
45
+ };
46
+
47
+ const result = await Effect.runPromise(strategy.translateChunk(ctx));
48
+ expect(result).toEqual({ hello: 'Hallo', bye: 'Tschüss' });
49
+ });
50
+
51
+ it('retries when model omits a key and fails after exhaustion', async () => {
52
+ let calls = 0;
53
+ const model = new MockLanguageModelV3({
54
+ doGenerate: async () => {
55
+ calls++;
56
+ return {
57
+ text: '',
58
+ content: [
59
+ {
60
+ type: 'text',
61
+ text: JSON.stringify({ hello: 'Hallo' }),
62
+ },
63
+ ],
64
+ finishReason: { unified: 'stop' as const, raw: 'stop' },
65
+ usage: {
66
+ inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 },
67
+ outputTokens: { total: 2, text: 2, reasoning: 0 },
68
+ },
69
+ response: {
70
+ modelId: 'mock',
71
+ timestamp: new Date(),
72
+ },
73
+ request: { body: {} },
74
+ warnings: [],
75
+ };
76
+ },
77
+ });
78
+
79
+ const strategy = createOneShotStrategy({
80
+ model,
81
+ retry: { maxAttempts: 2, baseDelayMs: 10 },
82
+ });
83
+
84
+ const ctx = {
85
+ sourceLocale: 'en',
86
+ targetLocale: 'de',
87
+ sourceMap: { hello: 'Hello', bye: 'Bye' },
88
+ targetMap: {},
89
+ keys: ['hello', 'bye'],
90
+ };
91
+
92
+ const exit = await Effect.runPromise(Effect.either(strategy.translateChunk(ctx))) as Either.Either<unknown, TranslationFailedError>;
93
+ expect(calls).toBeGreaterThan(1);
94
+ if (exit._tag === 'Left') {
95
+ expect(exit.left._tag).toBe('TranslationFailedError');
96
+ } else {
97
+ throw new Error('Expected Left');
98
+ }
99
+ });
100
+
101
+ it('handles empty keys', async () => {
102
+ const model = new MockLanguageModelV3({
103
+ doGenerate: async () => ({
104
+ text: '',
105
+ content: [{ type: 'text', text: JSON.stringify({}) }],
106
+ finishReason: { unified: 'stop' as const, raw: 'stop' },
107
+ usage: { inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, outputTokens: { total: 1, text: 1, reasoning: 0 } },
108
+ response: { modelId: 'mock', timestamp: new Date() },
109
+ request: { body: {} },
110
+ warnings: [],
111
+ }),
112
+ });
113
+
114
+ const strategy = createOneShotStrategy({
115
+ model,
116
+ retry: { maxAttempts: 1, baseDelayMs: 10 },
117
+ });
118
+
119
+ const ctx = {
120
+ sourceLocale: 'en',
121
+ targetLocale: 'de',
122
+ sourceMap: {},
123
+ targetMap: {},
124
+ keys: [],
125
+ };
126
+
127
+ const result = await Effect.runPromise(strategy.translateChunk(ctx));
128
+ expect(result).toEqual({});
129
+ });
130
+
131
+ it('handles malformed JSON response by retrying', async () => {
132
+ let calls = 0;
133
+ const model = new MockLanguageModelV3({
134
+ doGenerate: async () => {
135
+ calls++;
136
+ return {
137
+ text: '',
138
+ content: [{ type: 'text', text: calls < 2 ? 'not-json' : JSON.stringify({ k: 'v' }) }],
139
+ finishReason: { unified: 'stop' as const, raw: 'stop' },
140
+ usage: { inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, outputTokens: { total: 1, text: 1, reasoning: 0 } },
141
+ response: { modelId: 'mock', timestamp: new Date() },
142
+ request: { body: {} },
143
+ warnings: [],
144
+ };
145
+ },
146
+ });
147
+
148
+ const strategy = createOneShotStrategy({
149
+ model,
150
+ retry: { maxAttempts: 3, baseDelayMs: 10 },
151
+ });
152
+
153
+ const ctx = {
154
+ sourceLocale: 'en',
155
+ targetLocale: 'de',
156
+ sourceMap: { k: 'K' },
157
+ targetMap: {},
158
+ keys: ['k'],
159
+ };
160
+
161
+ const result = await Effect.runPromise(strategy.translateChunk(ctx));
162
+ expect(result).toEqual({ k: 'v' });
163
+ expect(calls).toBeGreaterThan(1);
164
+ });
165
+
166
+ it('fails after all retries exhausted', async () => {
167
+ const model = new MockLanguageModelV3({
168
+ doGenerate: async () => ({
169
+ text: '',
170
+ content: [{ type: 'text', text: 'not-json' }],
171
+ finishReason: { unified: 'stop' as const, raw: 'stop' },
172
+ usage: { inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, outputTokens: { total: 1, text: 1, reasoning: 0 } },
173
+ response: { modelId: 'mock', timestamp: new Date() },
174
+ request: { body: {} },
175
+ warnings: [],
176
+ }),
177
+ });
178
+
179
+ const strategy = createOneShotStrategy({
180
+ model,
181
+ retry: { maxAttempts: 2, baseDelayMs: 10 },
182
+ });
183
+
184
+ const ctx = {
185
+ sourceLocale: 'en',
186
+ targetLocale: 'de',
187
+ sourceMap: { k: 'K' },
188
+ targetMap: {},
189
+ keys: ['k'],
190
+ };
191
+
192
+ const exit = await Effect.runPromise(Effect.either(strategy.translateChunk(ctx))) as Either.Either<unknown, TranslationFailedError>;
193
+ if (exit._tag === 'Left') {
194
+ expect(exit.left._tag).toBe('TranslationFailedError');
195
+ } else {
196
+ throw new Error('Expected Left');
197
+ }
198
+ });
199
+
200
+ it('handles model returning extra keys (ignores them)', async () => {
201
+ const model = new MockLanguageModelV3({
202
+ doGenerate: async () => ({
203
+ text: '',
204
+ content: [{ type: 'text', text: JSON.stringify({ hello: 'Hallo', extra: 'ignored' }) }],
205
+ finishReason: { unified: 'stop' as const, raw: 'stop' },
206
+ usage: { inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, outputTokens: { total: 1, text: 1, reasoning: 0 } },
207
+ response: { modelId: 'mock', timestamp: new Date() },
208
+ request: { body: {} },
209
+ warnings: [],
210
+ }),
211
+ });
212
+
213
+ const strategy = createOneShotStrategy({
214
+ model,
215
+ retry: { maxAttempts: 1, baseDelayMs: 10 },
216
+ });
217
+
218
+ const ctx = {
219
+ sourceLocale: 'en',
220
+ targetLocale: 'de',
221
+ sourceMap: { hello: 'Hello' },
222
+ targetMap: {},
223
+ keys: ['hello'],
224
+ };
225
+
226
+ const result = await Effect.runPromise(strategy.translateChunk(ctx));
227
+ expect(result).toEqual({ hello: 'Hallo' });
228
+ });
229
+
230
+ it('handles single key translation', async () => {
231
+ const model = new MockLanguageModelV3({
232
+ doGenerate: async () => ({
233
+ text: '',
234
+ content: [{ type: 'text', text: JSON.stringify({ greeting: 'Hallo' }) }],
235
+ finishReason: { unified: 'stop' as const, raw: 'stop' },
236
+ usage: { inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, outputTokens: { total: 1, text: 1, reasoning: 0 } },
237
+ response: { modelId: 'mock', timestamp: new Date() },
238
+ request: { body: {} },
239
+ warnings: [],
240
+ }),
241
+ });
242
+
243
+ const strategy = createOneShotStrategy({
244
+ model,
245
+ retry: { maxAttempts: 1, baseDelayMs: 10 },
246
+ });
247
+
248
+ const ctx = {
249
+ sourceLocale: 'en',
250
+ targetLocale: 'de',
251
+ sourceMap: { greeting: 'Hello' },
252
+ targetMap: {},
253
+ keys: ['greeting'],
254
+ };
255
+
256
+ const result = await Effect.runPromise(strategy.translateChunk(ctx));
257
+ expect(result).toEqual({ greeting: 'Hallo' });
258
+ });
259
+ });
@@ -0,0 +1,48 @@
1
+ import { generateText, Output } from 'ai';
2
+ import { Effect, Schedule } from 'effect';
3
+ import { z } from 'zod';
4
+ import type { LanguageModel } from 'ai';
5
+ import type { TranslationContext, TranslationStrategy } from './types.js';
6
+ import { TranslationFailedError } from './types.js';
7
+ import { buildSystemPrompt, buildUserPrompt } from './prompt.js';
8
+
9
+ async function tryTranslateChunk(
10
+ model: LanguageModel,
11
+ ctx: TranslationContext,
12
+ ): Promise<Record<string, string>> {
13
+ const schema = z.object(
14
+ Object.fromEntries(ctx.keys.map((key: string) => [key, z.string()])),
15
+ );
16
+ const { output } = await generateText({
17
+ model,
18
+ system: buildSystemPrompt(ctx.sourceLocale, ctx.targetLocale),
19
+ prompt: buildUserPrompt(ctx),
20
+ output: Output.object({ schema }),
21
+ });
22
+ const missing = ctx.keys.filter((key: string) => !(key in output));
23
+ if (missing.length > 0) {
24
+ throw new Error(`Model omitted keys: ${missing.join(', ')}`);
25
+ }
26
+ return output as Record<string, string>;
27
+ }
28
+
29
+ export function createOneShotStrategy(deps: {
30
+ model: LanguageModel;
31
+ retry: { maxAttempts: number; baseDelayMs: number };
32
+ }): TranslationStrategy {
33
+ return {
34
+ name: 'one-shot',
35
+ translateChunk: (ctx: TranslationContext) =>
36
+ Effect.tryPromise({
37
+ try: () => tryTranslateChunk(deps.model, ctx),
38
+ catch: (cause) => cause,
39
+ }).pipe(
40
+ Effect.retry(
41
+ Schedule.exponential(`${deps.retry.baseDelayMs} millis`).pipe(
42
+ Schedule.compose(Schedule.recurs(deps.retry.maxAttempts - 1)),
43
+ ),
44
+ ),
45
+ Effect.mapError((cause) => new TranslationFailedError({ keys: ctx.keys, cause })),
46
+ ),
47
+ };
48
+ }
@@ -0,0 +1,276 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { Effect, Either } from 'effect';
3
+ import { runTranslation } from './orchestrator.js';
4
+ import type { TranslationAdapter, ResourceRef, AdapterReadError, AdapterWriteError } from '../adapter/types.js';
5
+ import { TranslationFailedError } from './types.js';
6
+
7
+ describe('runTranslation', () => {
8
+ it('translates missing keys across adapters and writes merged results', async () => {
9
+ const writes: Array<{ locale: string; resource: string; entries: Record<string, string> }> = [];
10
+
11
+ const adapter: TranslationAdapter = {
12
+ name: 'test',
13
+ capabilities: { canCreateResource: true, unusedKeyDetection: false },
14
+ listLocales: () => Effect.succeed(['en', 'de']),
15
+ listResources: () =>
16
+ Effect.succeed([{ key: 'messages', label: 'messages' }]),
17
+ readResource: (locale: string) =>
18
+ Effect.succeed(
19
+ locale === 'en'
20
+ ? { hello: 'Hello', bye: 'Bye' }
21
+ : { hello: 'Hallo' },
22
+ ),
23
+ writeResource: (locale: string, resource: ResourceRef, entries: Record<string, string>) =>
24
+ Effect.sync(() => {
25
+ writes.push({ locale, resource: resource.key, entries });
26
+ }),
27
+ };
28
+
29
+ const strategy = {
30
+ name: 'one-shot' as const,
31
+ translateChunk: (ctx: { keys: readonly string[]; targetLocale: string }) =>
32
+ Effect.succeed(
33
+ Object.fromEntries(ctx.keys.map((k: string) => [k, `${ctx.targetLocale}:${k}`])),
34
+ ),
35
+ };
36
+
37
+ const program = runTranslation({
38
+ adapters: [adapter],
39
+ strategy,
40
+ sourceLocale: 'en',
41
+ targetLocales: ['de'],
42
+ chunking: { maxTokens: 3000, charsPerToken: 3.0, concurrency: 3 },
43
+ });
44
+
45
+ await Effect.runPromise(program);
46
+ expect(writes).toHaveLength(1);
47
+ expect(writes[0]!.locale).toBe('de');
48
+ expect(writes[0]!.entries).toEqual({ hello: 'Hallo', bye: 'de:bye' });
49
+ });
50
+
51
+ it('respects concurrency limit', async () => {
52
+ let maxConcurrent = 0;
53
+ let current = 0;
54
+
55
+ const adapter: TranslationAdapter = {
56
+ name: 'test',
57
+ capabilities: { canCreateResource: true, unusedKeyDetection: false },
58
+ listLocales: () => Effect.succeed(['en', 'de']),
59
+ listResources: () =>
60
+ Effect.succeed([{ key: 'messages', label: 'messages' }]),
61
+ readResource: (locale: string) =>
62
+ Effect.succeed(
63
+ locale === 'en'
64
+ ? { a: 'A', b: 'B', c: 'C', d: 'D' }
65
+ : {},
66
+ ),
67
+ writeResource: () => Effect.void,
68
+ };
69
+
70
+ const strategy = {
71
+ name: 'one-shot' as const,
72
+ translateChunk: (ctx: { keys: readonly string[] }) =>
73
+ Effect.gen(function* () {
74
+ current++;
75
+ maxConcurrent = Math.max(maxConcurrent, current);
76
+ yield* Effect.sleep('50 millis');
77
+ current--;
78
+ return Object.fromEntries(ctx.keys.map((k: string) => [k, k]));
79
+ }),
80
+ };
81
+
82
+ const program = runTranslation({
83
+ adapters: [adapter],
84
+ strategy,
85
+ sourceLocale: 'en',
86
+ targetLocales: ['de'],
87
+ chunking: { maxTokens: 10, charsPerToken: 3.0, concurrency: 2 },
88
+ });
89
+
90
+ await Effect.runPromise(program);
91
+ expect(maxConcurrent).toBeLessThanOrEqual(2);
92
+ });
93
+
94
+ it('collects failures and reports them at the end', async () => {
95
+ const adapter: TranslationAdapter = {
96
+ name: 'test',
97
+ capabilities: { canCreateResource: true, unusedKeyDetection: false },
98
+ listLocales: () => Effect.succeed(['en', 'de']),
99
+ listResources: () =>
100
+ Effect.succeed([{ key: 'messages', label: 'messages' }]),
101
+ readResource: (locale: string) =>
102
+ Effect.succeed(
103
+ locale === 'en' ? { a: 'A' } : {},
104
+ ),
105
+ writeResource: () => Effect.void,
106
+ };
107
+
108
+ const strategy = {
109
+ name: 'one-shot' as const,
110
+ translateChunk: () =>
111
+ Effect.fail(
112
+ new TranslationFailedError({ keys: ['a'], cause: 'boom' }),
113
+ ),
114
+ };
115
+
116
+ const program = runTranslation({
117
+ adapters: [adapter],
118
+ strategy,
119
+ sourceLocale: 'en',
120
+ targetLocales: ['de'],
121
+ chunking: { maxTokens: 3000, charsPerToken: 3.0, concurrency: 3 },
122
+ });
123
+
124
+ const exit = await Effect.runPromise(Effect.either(program)) as Either.Either<void, TranslationFailedError>;
125
+ if (exit._tag === 'Left') {
126
+ expect((exit.left as TranslationFailedError)._tag).toBe('TranslationFailedError');
127
+ } else {
128
+ throw new Error('Expected Left');
129
+ }
130
+ });
131
+
132
+ it('handles empty adapters list', async () => {
133
+ const strategy = {
134
+ name: 'one-shot' as const,
135
+ translateChunk: () => Effect.succeed({}),
136
+ };
137
+
138
+ const program = runTranslation({
139
+ adapters: [],
140
+ strategy,
141
+ sourceLocale: 'en',
142
+ targetLocales: ['de'],
143
+ chunking: { maxTokens: 3000, charsPerToken: 3.0, concurrency: 3 },
144
+ });
145
+
146
+ await Effect.runPromise(program);
147
+ expect(true).toBe(true); // should not throw
148
+ });
149
+
150
+ it('handles single-locale adapter (no targets)', async () => {
151
+ const adapter: TranslationAdapter = {
152
+ name: 'mono',
153
+ capabilities: { canCreateResource: true, unusedKeyDetection: false },
154
+ listLocales: () => Effect.succeed(['en']),
155
+ listResources: () => Effect.succeed([{ key: 'messages', label: 'messages' }]),
156
+ readResource: () => Effect.succeed({ hello: 'Hello' }),
157
+ writeResource: () => Effect.void,
158
+ };
159
+
160
+ const strategy = {
161
+ name: 'one-shot' as const,
162
+ translateChunk: () => Effect.succeed({}),
163
+ };
164
+
165
+ const program = runTranslation({
166
+ adapters: [adapter],
167
+ strategy,
168
+ sourceLocale: 'en',
169
+ targetLocales: ['de'],
170
+ chunking: { maxTokens: 3000, charsPerToken: 3.0, concurrency: 3 },
171
+ });
172
+
173
+ await Effect.runPromise(program);
174
+ expect(true).toBe(true); // should not throw
175
+ });
176
+
177
+ it('handles multiple resources', async () => {
178
+ const writes: Array<{ locale: string; resource: string }> = [];
179
+ const adapter: TranslationAdapter = {
180
+ name: 'multi',
181
+ capabilities: { canCreateResource: true, unusedKeyDetection: false },
182
+ listLocales: () => Effect.succeed(['en', 'de']),
183
+ listResources: () =>
184
+ Effect.succeed([
185
+ { key: 'auth', label: 'auth' },
186
+ { key: 'validation', label: 'validation' },
187
+ ]),
188
+ readResource: (locale: string) =>
189
+ Effect.succeed(
190
+ locale === 'en' ? { k1: 'V1' } : {},
191
+ ),
192
+ writeResource: (locale: string, resource: ResourceRef) =>
193
+ Effect.sync(() => {
194
+ writes.push({ locale, resource: resource.key });
195
+ }),
196
+ };
197
+
198
+ const strategy = {
199
+ name: 'one-shot' as const,
200
+ translateChunk: (ctx: { keys: readonly string[] }) =>
201
+ Effect.succeed(Object.fromEntries(ctx.keys.map((k: string) => [k, k]))),
202
+ };
203
+
204
+ const program = runTranslation({
205
+ adapters: [adapter],
206
+ strategy,
207
+ sourceLocale: 'en',
208
+ targetLocales: ['de'],
209
+ chunking: { maxTokens: 3000, charsPerToken: 3.0, concurrency: 3 },
210
+ });
211
+
212
+ await Effect.runPromise(program);
213
+ expect(writes.length).toBeGreaterThanOrEqual(2);
214
+ });
215
+
216
+ it('handles adapter readResource failure', async () => {
217
+ const adapter: TranslationAdapter = {
218
+ name: 'broken',
219
+ capabilities: { canCreateResource: true, unusedKeyDetection: false },
220
+ listLocales: () => Effect.succeed(['en', 'de']),
221
+ listResources: () => Effect.succeed([{ key: 'messages', label: 'messages' }]),
222
+ readResource: () => Effect.fail(new Error('read failed') as AdapterReadError),
223
+ writeResource: () => Effect.void,
224
+ };
225
+
226
+ const strategy = {
227
+ name: 'one-shot' as const,
228
+ translateChunk: () => Effect.succeed({}),
229
+ };
230
+
231
+ const program = runTranslation({
232
+ adapters: [adapter],
233
+ strategy,
234
+ sourceLocale: 'en',
235
+ targetLocales: ['de'],
236
+ chunking: { maxTokens: 3000, charsPerToken: 3.0, concurrency: 3 },
237
+ });
238
+
239
+ await expect(Effect.runPromise(program)).rejects.toThrow('read failed');
240
+ });
241
+
242
+ it('handles multiple target locales', async () => {
243
+ const writes: Array<{ locale: string }> = [];
244
+ const adapter: TranslationAdapter = {
245
+ name: 'multi-locale',
246
+ capabilities: { canCreateResource: true, unusedKeyDetection: false },
247
+ listLocales: () => Effect.succeed(['en', 'de', 'fr']),
248
+ listResources: () => Effect.succeed([{ key: 'messages', label: 'messages' }]),
249
+ readResource: (locale: string) =>
250
+ Effect.succeed(locale === 'en' ? { hello: 'Hello' } : {}),
251
+ writeResource: (locale: string) =>
252
+ Effect.sync(() => {
253
+ writes.push({ locale });
254
+ }),
255
+ };
256
+
257
+ const strategy = {
258
+ name: 'one-shot' as const,
259
+ translateChunk: (ctx: { keys: readonly string[]; targetLocale: string }) =>
260
+ Effect.succeed(Object.fromEntries(ctx.keys.map((k: string) => [k, `${ctx.targetLocale}:${k}`]))),
261
+ };
262
+
263
+ const program = runTranslation({
264
+ adapters: [adapter],
265
+ strategy,
266
+ sourceLocale: 'en',
267
+ targetLocales: ['de', 'fr'],
268
+ chunking: { maxTokens: 3000, charsPerToken: 3.0, concurrency: 3 },
269
+ });
270
+
271
+ await Effect.runPromise(program);
272
+ const locales = writes.map((w) => w.locale);
273
+ expect(locales).toContain('de');
274
+ expect(locales).toContain('fr');
275
+ });
276
+ });
@@ -0,0 +1,83 @@
1
+ import { Effect } from 'effect';
2
+ import { chunkKeys } from './chunking.js';
3
+ import { diffKeys } from '../keys/flatten.js';
4
+ import type { TranslationAdapter } from '../adapter/types.js';
5
+ import type { TranslationStrategy } from './types.js';
6
+ import { TranslationFailedError } from './types.js';
7
+ import type { ChunkingConfig } from '../config/types.js';
8
+
9
+ export interface TranslationRunConfig {
10
+ readonly adapters: readonly TranslationAdapter[];
11
+ readonly strategy: TranslationStrategy;
12
+ readonly sourceLocale: string;
13
+ readonly targetLocales: readonly string[];
14
+ readonly chunking: ChunkingConfig;
15
+ }
16
+
17
+ export function runTranslation(config: TranslationRunConfig) {
18
+ return Effect.gen(function* () {
19
+ const failures: TranslationFailedError[] = [];
20
+
21
+ for (const adapter of config.adapters) {
22
+ const locales =
23
+ config.targetLocales.length > 0
24
+ ? config.targetLocales
25
+ : yield* adapter.listLocales();
26
+ const sourceLocale = config.sourceLocale;
27
+ const targetLocales = locales.filter((l: string) => l !== sourceLocale);
28
+
29
+ for (const locale of targetLocales) {
30
+ const resources = yield* adapter.listResources(sourceLocale);
31
+ for (const resource of resources) {
32
+ const sourceMap = yield* adapter.readResource(sourceLocale, resource);
33
+ const targetMap = yield* adapter.readResource(locale, resource);
34
+ const missing = diffKeys(sourceMap, targetMap);
35
+ if (missing.length === 0) continue;
36
+
37
+ const chunks = chunkKeys(missing, sourceMap, targetMap, {
38
+ maxTokens: config.chunking.maxTokens,
39
+ charsPerToken: config.chunking.charsPerToken,
40
+ });
41
+
42
+ const translatedChunks: Record<string, string>[] = [];
43
+
44
+ yield* Effect.forEach(chunks, (chunkKeysArr) =>
45
+ Effect.gen(function* () {
46
+ const result = yield* config.strategy.translateChunk({
47
+ sourceLocale,
48
+ targetLocale: locale,
49
+ sourceMap,
50
+ targetMap,
51
+ keys: chunkKeysArr,
52
+ });
53
+ translatedChunks.push(result);
54
+ }).pipe(
55
+ Effect.catchAll((err) => {
56
+ failures.push(err);
57
+ return Effect.void;
58
+ }),
59
+ ),
60
+ { concurrency: config.chunking.concurrency, discard: true },
61
+ );
62
+
63
+ const merged = { ...targetMap };
64
+ for (const chunk of translatedChunks) {
65
+ Object.assign(merged, chunk);
66
+ }
67
+ yield* adapter.writeResource(locale, resource, merged);
68
+ }
69
+ }
70
+ }
71
+
72
+ if (failures.length > 0) {
73
+ return yield* Effect.fail(
74
+ new TranslationFailedError({
75
+ keys: failures.flatMap((f) => [...f.keys]),
76
+ cause: failures.map((f) => f.cause),
77
+ }),
78
+ );
79
+ }
80
+
81
+ return void 0;
82
+ });
83
+ }