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,267 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { Effect } from 'effect';
3
+ import { runAdd, addCommand, parseAddTokens } from './add.js';
4
+ import type { DialektConfig } from '../../config/types.js';
5
+ import type { TranslationAdapter, ResourceRef } from '../../adapter/types.js';
6
+
7
+ describe('parseAddTokens', () => {
8
+ it('parses resource.key=value pairs', async () => {
9
+ const errors: string[] = [];
10
+ const program = parseAddTokens(
11
+ ['messages.hello=Hello', 'validation.email=Email'],
12
+ (msg) => Effect.sync(() => errors.push(msg)),
13
+ );
14
+
15
+ const result = await Effect.runPromise(program);
16
+ expect(result).toEqual({
17
+ messages: { hello: 'Hello' },
18
+ validation: { email: 'Email' },
19
+ });
20
+ expect(errors).toHaveLength(0);
21
+ });
22
+
23
+ it('handles empty tokens', async () => {
24
+ const program = parseAddTokens(
25
+ [],
26
+ () => Effect.void,
27
+ );
28
+
29
+ const result = await Effect.runPromise(program);
30
+ expect(result).toEqual({});
31
+ });
32
+
33
+ it('logs error for tokens without =', async () => {
34
+ const errors: string[] = [];
35
+ const program = parseAddTokens(
36
+ ['invalid-token'],
37
+ (msg) => Effect.sync(() => errors.push(msg)),
38
+ );
39
+
40
+ const result = await Effect.runPromise(program);
41
+ expect(result).toEqual({});
42
+ expect(errors[0]).toContain("Invalid token (missing '=')");
43
+ expect(errors[0]).toContain('invalid-token');
44
+ });
45
+
46
+ it('logs error for keys without resource segment', async () => {
47
+ const errors: string[] = [];
48
+ const program = parseAddTokens(
49
+ ['no_dot=value'],
50
+ (msg) => Effect.sync(() => errors.push(msg)),
51
+ );
52
+
53
+ const result = await Effect.runPromise(program);
54
+ expect(result).toEqual({});
55
+ expect(errors[0]).toContain('Invalid key (no resource segment)');
56
+ });
57
+
58
+ it('handles multiple keys in the same resource', async () => {
59
+ const program = parseAddTokens(
60
+ ['messages.hello=Hello', 'messages.bye=Bye'],
61
+ () => Effect.void,
62
+ );
63
+
64
+ const result = await Effect.runPromise(program);
65
+ expect(result).toEqual({
66
+ messages: { hello: 'Hello', bye: 'Bye' },
67
+ });
68
+ });
69
+
70
+ it('handles values containing =', async () => {
71
+ const program = parseAddTokens(
72
+ ['messages.greeting=Hello=World'],
73
+ () => Effect.void,
74
+ );
75
+
76
+ const result = await Effect.runPromise(program);
77
+ expect(result).toEqual({
78
+ messages: { greeting: 'Hello=World' },
79
+ });
80
+ });
81
+
82
+ it('ignores tokens after first = in key portion', async () => {
83
+ const program = parseAddTokens(
84
+ ['messages.key=val=ue'],
85
+ () => Effect.void,
86
+ );
87
+
88
+ const result = await Effect.runPromise(program);
89
+ expect(result).toEqual({
90
+ messages: { key: 'val=ue' },
91
+ });
92
+ });
93
+
94
+ it('handles empty values', async () => {
95
+ const program = parseAddTokens(
96
+ ['messages.empty='],
97
+ () => Effect.void,
98
+ );
99
+
100
+ const result = await Effect.runPromise(program);
101
+ expect(result).toEqual({
102
+ messages: { empty: '' },
103
+ });
104
+ });
105
+
106
+ it('handles deeply nested resource names by splitting on first dot only', async () => {
107
+ const program = parseAddTokens(
108
+ ['a.b.c.key=value'],
109
+ () => Effect.void,
110
+ );
111
+
112
+ const result = await Effect.runPromise(program);
113
+ expect(result).toEqual({
114
+ a: { 'b.c.key': 'value' },
115
+ });
116
+ });
117
+ });
118
+
119
+ describe('runAdd', () => {
120
+ const baseConfig: DialektConfig = {
121
+ sourceLocale: 'en',
122
+ targetLocales: ['de'],
123
+ strategy: 'one-shot',
124
+ model: { provider: 'openai', modelId: 'gpt-4o' },
125
+ fastModel: { provider: 'openai', modelId: 'gpt-4o-mini' },
126
+ chunking: { maxTokens: 3000, charsPerToken: 3.0, concurrency: 3 },
127
+ retry: { maxAttempts: 3, baseDelayMs: 1000 },
128
+ adapters: [],
129
+ };
130
+
131
+ function makeAdapter(name: string): TranslationAdapter {
132
+ return {
133
+ name,
134
+ capabilities: { canCreateResource: true, unusedKeyDetection: false },
135
+ listLocales: () => Effect.succeed([]),
136
+ listResources: () => Effect.succeed([]),
137
+ readResource: () => Effect.succeed({}),
138
+ writeResource: () => Effect.void,
139
+ };
140
+ }
141
+
142
+ it('writes entries to all adapters and triggers translation', async () => {
143
+ const writes: Array<{ adapter: string; locale: string; resource: string; entries: Record<string, string> }> = [];
144
+ let translated = false;
145
+
146
+ const adapter = makeAdapter('test');
147
+ const config = { ...baseConfig, adapters: [adapter] as unknown as DialektConfig['adapters'] };
148
+
149
+ const program = runAdd(
150
+ { config: './config.ts', create: false },
151
+ ['messages.hello=Hello'],
152
+ () => Effect.succeed(config),
153
+ () => Effect.succeed({} as unknown),
154
+ () =>
155
+ Effect.sync(() => {
156
+ translated = true;
157
+ }),
158
+ (msg) => Effect.sync(() => {
159
+ const parsed = JSON.parse(msg);
160
+ expect(parsed.success).toBe(true);
161
+ expect(parsed.message).toBe('Add + translate complete.');
162
+ }),
163
+ () => Effect.void,
164
+ );
165
+
166
+ await Effect.runPromise(program);
167
+ expect(translated).toBe(true);
168
+ });
169
+
170
+ it('fails when configLoader fails', async () => {
171
+ const program = runAdd(
172
+ { config: './missing.ts', create: false },
173
+ ['messages.hello=Hello'],
174
+ () => Effect.fail(new Error('Config not found')),
175
+ () => Effect.succeed({} as unknown),
176
+ () => Effect.void,
177
+ () => Effect.void,
178
+ () => Effect.void,
179
+ );
180
+
181
+ await expect(Effect.runPromise(program)).rejects.toThrow('Config not found');
182
+ });
183
+
184
+ it('fails when modelResolver fails', async () => {
185
+ const adapter = makeAdapter('test');
186
+ const config = { ...baseConfig, adapters: [adapter] as unknown as DialektConfig['adapters'] };
187
+
188
+ const program = runAdd(
189
+ { config: './config.ts', create: false },
190
+ ['messages.hello=Hello'],
191
+ () => Effect.succeed(config),
192
+ () => Effect.fail(new Error('Model unavailable')),
193
+ () => Effect.void,
194
+ () => Effect.void,
195
+ () => Effect.void,
196
+ );
197
+
198
+ await expect(Effect.runPromise(program)).rejects.toThrow('Model unavailable');
199
+ });
200
+
201
+ it('fails when translationRunner fails', async () => {
202
+ const adapter = makeAdapter('test');
203
+ const config = { ...baseConfig, adapters: [adapter] as unknown as DialektConfig['adapters'] };
204
+
205
+ const program = runAdd(
206
+ { config: './config.ts', create: false },
207
+ ['messages.hello=Hello'],
208
+ () => Effect.succeed(config),
209
+ () => Effect.succeed({} as unknown),
210
+ () => Effect.fail(new Error('Translation failed')),
211
+ () => Effect.void,
212
+ () => Effect.void,
213
+ );
214
+
215
+ await expect(Effect.runPromise(program)).rejects.toThrow('Translation failed');
216
+ });
217
+
218
+ it('handles empty tokens list', async () => {
219
+ let translated = false;
220
+ const adapter = makeAdapter('test');
221
+ const config = { ...baseConfig, adapters: [adapter] as unknown as DialektConfig['adapters'] };
222
+
223
+ const program = runAdd(
224
+ { config: './config.ts', create: false },
225
+ [],
226
+ () => Effect.succeed(config),
227
+ () => Effect.succeed({} as unknown),
228
+ () =>
229
+ Effect.sync(() => {
230
+ translated = true;
231
+ }),
232
+ () => Effect.void,
233
+ () => Effect.void,
234
+ );
235
+
236
+ await Effect.runPromise(program);
237
+ expect(translated).toBe(true);
238
+ });
239
+
240
+ it('filters targetLocales to exclude sourceLocale', async () => {
241
+ let usedTargets: readonly string[] | undefined;
242
+ const adapter = makeAdapter('test');
243
+ const config = {
244
+ ...baseConfig,
245
+ sourceLocale: 'en',
246
+ targetLocales: ['en', 'de', 'fr'],
247
+ adapters: [adapter] as unknown as DialektConfig['adapters'],
248
+ };
249
+
250
+ const program = runAdd(
251
+ { config: './config.ts', create: false },
252
+ ['messages.hello=Hello'],
253
+ () => Effect.succeed(config),
254
+ () => Effect.succeed({} as unknown),
255
+ (opts) =>
256
+ Effect.sync(() => {
257
+ usedTargets = opts.targetLocales as readonly string[];
258
+ }),
259
+ () => Effect.void,
260
+ () => Effect.void,
261
+ );
262
+
263
+ await Effect.runPromise(program);
264
+ expect(usedTargets).not.toContain('en');
265
+ expect(usedTargets).toContain('de');
266
+ });
267
+ });
@@ -0,0 +1,123 @@
1
+ import { Command, Options, Args } from '@effect/cli';
2
+ import { Effect, Console, Option } from 'effect';
3
+ import { loadConfig } from '../../config/load-config.js';
4
+ import { resolveEffectiveConfig } from '../config-resolution.js';
5
+ import { resolveModel } from '../../translation/model-registry.js';
6
+ import { createOneShotStrategy } from '../../translation/one-shot-strategy.js';
7
+ import { createToolLoopStrategy } from '../../translation/tool-loop-strategy.js';
8
+ import { runTranslation } from '../../translation/orchestrator.js';
9
+ import { flattenObject } from '../../keys/flatten.js';
10
+ import { detectFormat, type OutputFormat } from '../format.js';
11
+ import { formatAdd, formatError } from '../formatters.js';
12
+ import type { DialektConfig } from '../../config/types.js';
13
+ import type { TranslationAdapter, ResourceRef } from '../../adapter/types.js';
14
+ import type { TranslationStrategy } from '../../translation/types.js';
15
+
16
+ export interface AddFlags {
17
+ readonly config: string;
18
+ readonly create: boolean;
19
+ readonly format?: Option.Option<string>;
20
+ }
21
+
22
+ export function parseAddTokens(
23
+ tokens: readonly string[],
24
+ errorLogger: (msg: string) => Effect.Effect<void>,
25
+ ): Effect.Effect<Record<string, Record<string, string>>, never> {
26
+ return Effect.gen(function* () {
27
+ const entriesByResource: Record<string, Record<string, string>> = {};
28
+
29
+ for (const token of tokens) {
30
+ const eqIdx = token.indexOf('=');
31
+ if (eqIdx === -1) {
32
+ yield* errorLogger(`Invalid token (missing '='): ${token}`);
33
+ continue;
34
+ }
35
+ const key = token.slice(0, eqIdx);
36
+ const value = token.slice(eqIdx + 1);
37
+ const dotIdx = key.indexOf('.');
38
+ if (dotIdx === -1) {
39
+ yield* errorLogger(`Invalid key (no resource segment): ${key}`);
40
+ continue;
41
+ }
42
+ const resource = key.slice(0, dotIdx);
43
+ const subKey = key.slice(dotIdx + 1);
44
+ if (!entriesByResource[resource]) entriesByResource[resource] = {};
45
+ entriesByResource[resource]![subKey] = value;
46
+ }
47
+
48
+ return entriesByResource;
49
+ });
50
+ }
51
+
52
+ export function runAdd(
53
+ flags: AddFlags,
54
+ tokens: readonly string[],
55
+ configLoader: (path: string) => Effect.Effect<DialektConfig, unknown> = loadConfig,
56
+ modelResolver: (config: { provider: string; modelId: string }) => Effect.Effect<unknown, unknown> = resolveModel,
57
+ translationRunner: (opts: {
58
+ adapters: readonly unknown[];
59
+ strategy: TranslationStrategy;
60
+ sourceLocale: string;
61
+ targetLocales: readonly string[];
62
+ chunking: unknown;
63
+ }) => Effect.Effect<void, unknown> = runTranslation as unknown as typeof translationRunner,
64
+ logger: (msg: string) => Effect.Effect<void> = (msg: string) => Console.log(msg),
65
+ errorLogger: (msg: string) => Effect.Effect<void> = (msg: string) => Console.error(msg),
66
+ ): Effect.Effect<void, unknown> {
67
+ return Effect.gen(function* () {
68
+ const loaded = yield* configLoader(flags.config);
69
+ const effective = resolveEffectiveConfig({}, loaded);
70
+
71
+ const entriesByResource = yield* parseAddTokens(tokens, errorLogger);
72
+
73
+ const addedResources: string[] = [];
74
+ for (const adapter of effective.adapters) {
75
+ for (const [resourceKey, entries] of Object.entries(entriesByResource)) {
76
+ const resourceRef = { key: resourceKey, label: resourceKey };
77
+ yield* adapter.writeResource(effective.sourceLocale, resourceRef, entries);
78
+ addedResources.push(`${adapter.name}/${effective.sourceLocale}/${resourceKey}`);
79
+ }
80
+ }
81
+
82
+ const modelConfig = effective.model;
83
+ const model = yield* modelResolver(modelConfig) as Effect.Effect<import('ai').LanguageModel, unknown>;
84
+ const translationStrategy =
85
+ effective.strategy === 'tool-loop-agent'
86
+ ? createToolLoopStrategy({ model, retry: effective.retry })
87
+ : createOneShotStrategy({ model, retry: effective.retry });
88
+
89
+ yield* translationRunner({
90
+ adapters: effective.adapters,
91
+ strategy: translationStrategy,
92
+ sourceLocale: effective.sourceLocale,
93
+ targetLocales: (effective.targetLocales ?? []).filter((l) => l !== effective.sourceLocale),
94
+ chunking: effective.chunking,
95
+ });
96
+
97
+ const format = detectFormat(
98
+ flags.format !== undefined
99
+ ? (Option.getOrUndefined(flags.format) as OutputFormat | undefined)
100
+ : undefined,
101
+ );
102
+
103
+ yield* logger(
104
+ formatAdd(
105
+ {
106
+ success: true,
107
+ message: 'Add + translate complete.',
108
+ addedResources,
109
+ },
110
+ format,
111
+ ),
112
+ );
113
+ });
114
+ }
115
+
116
+ export const addCommand = Command.make('add', {
117
+ config: Options.text('config').pipe(Options.withDefault('./dialekt.config.ts')),
118
+ create: Options.boolean('create'),
119
+ format: Options.optional(Options.text('format')),
120
+ }, ({ config, create, format }) => {
121
+ const rawTokens = process.argv.slice(3).filter((t: string) => !t.startsWith('--') && !t.startsWith('-'));
122
+ return runAdd({ config, create, format }, rawTokens);
123
+ });