transduck 0.6.2 → 0.6.4

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/dist/cli.js CHANGED
@@ -544,7 +544,7 @@ export async function runStats(opts) {
544
544
  }
545
545
  // CLI entry point
546
546
  const program = new Command();
547
- program.name('transduck').description('AI-native translation tool').version('0.6.2');
547
+ program.name('transduck').description('AI-native translation tool').version('0.6.4');
548
548
  program.command('init')
549
549
  .description('Initialize a new transduck project')
550
550
  .action(async () => {
package/dist/handler.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type TranslationHook } from './hooks.js';
1
2
  interface TranslationRequestString {
2
3
  text: string;
3
4
  context?: string;
@@ -20,6 +21,10 @@ export interface TranslationResponse {
20
21
  }
21
22
  /** @internal Reset the singleton store — for testing only. */
22
23
  export declare function _resetHandlerStore(): void;
23
- export declare function handleTranslationRequest(body: TranslationRequest, configPath?: string): Promise<TranslationResponse>;
24
- export declare function createTransDuckHandler(configPath?: string): (request: Request) => Promise<Response>;
24
+ export declare function handleTranslationRequest(body: TranslationRequest, configPath?: string, opts?: {
25
+ onTranslate?: TranslationHook;
26
+ }): Promise<TranslationResponse>;
27
+ export declare function createTransDuckHandler(configPath?: string, opts?: {
28
+ onTranslate?: TranslationHook;
29
+ }): (request: Request) => Promise<Response>;
25
30
  export {};
package/dist/handler.js CHANGED
@@ -4,6 +4,7 @@ import { TranslationStore } from './storage.js';
4
4
  import { SharedStore } from './shared-store.js';
5
5
  import { translate as backendTranslate, translatePlural as backendTranslatePlural } from './backend.js';
6
6
  import { validateTranslation } from './validation.js';
7
+ import { fireHooks } from './hooks.js';
7
8
  function hash(text) {
8
9
  return createHash('sha256').update(text).digest('hex');
9
10
  }
@@ -40,7 +41,7 @@ export function _resetHandlerStore() {
40
41
  _store = null;
41
42
  _sharedStore = null;
42
43
  }
43
- export async function handleTranslationRequest(body, configPath) {
44
+ export async function handleTranslationRequest(body, configPath, opts) {
44
45
  const cfg = loadConfig(configPath);
45
46
  const store = await getStore(configPath);
46
47
  const shared = await getSharedStore(configPath);
@@ -87,6 +88,15 @@ export async function handleTranslationRequest(body, configPath) {
87
88
  await store.insert(insertParams);
88
89
  if (shared)
89
90
  await shared.insert(insertParams);
91
+ if (opts?.onTranslate) {
92
+ const event = {
93
+ sourceText: item.text, translatedText: translated,
94
+ sourceLang, lang: targetLang, context: item.context ?? null,
95
+ projectContext: cfg.projectContext, source: 'translated',
96
+ plural: false,
97
+ };
98
+ await fireHooks([], event, opts.onTranslate);
99
+ }
90
100
  translations[key] = translated;
91
101
  }
92
102
  else {
@@ -142,6 +152,15 @@ export async function handleTranslationRequest(body, configPath) {
142
152
  if (shared)
143
153
  await shared.insertPlural(insertParams);
144
154
  }
155
+ if (opts?.onTranslate && 'other' in forms) {
156
+ const event = {
157
+ sourceText: sourceKey, translatedText: forms.other ?? '',
158
+ sourceLang, lang: targetLang, context: item.context ?? null,
159
+ projectContext: cfg.projectContext, source: 'translated',
160
+ plural: true, pluralForms: forms,
161
+ };
162
+ await fireHooks([], event, opts.onTranslate);
163
+ }
145
164
  plurals[responseKey] = forms;
146
165
  }
147
166
  catch {
@@ -150,11 +169,11 @@ export async function handleTranslationRequest(body, configPath) {
150
169
  }
151
170
  return { translations, plurals };
152
171
  }
153
- export function createTransDuckHandler(configPath) {
172
+ export function createTransDuckHandler(configPath, opts) {
154
173
  return async function POST(request) {
155
174
  try {
156
175
  const body = await request.json();
157
- const result = await handleTranslationRequest(body, configPath);
176
+ const result = await handleTranslationRequest(body, configPath, opts);
158
177
  return new Response(JSON.stringify(result), {
159
178
  status: 200,
160
179
  headers: { 'Content-Type': 'application/json' },
@@ -0,0 +1,22 @@
1
+ /**
2
+ * TransDuck hooks — event types and dispatch helpers.
3
+ *
4
+ * Field names match TranslationResult for consistency.
5
+ */
6
+ export interface TranslationEvent {
7
+ sourceText: string;
8
+ translatedText: string;
9
+ sourceLang: string;
10
+ lang: string;
11
+ context: string | null;
12
+ projectContext: string;
13
+ source: 'cache' | 'translated' | 'shared_cache';
14
+ plural: boolean;
15
+ pluralCategory?: string;
16
+ pluralForms?: Record<string, string>;
17
+ }
18
+ export type TranslationHook = (event: TranslationEvent) => void | Promise<void>;
19
+ /**
20
+ * Invoke global hooks then per-call hook. Errors are logged, never raised.
21
+ */
22
+ export declare function fireHooks(hooks: TranslationHook[], event: TranslationEvent, perCallHook?: TranslationHook): Promise<void>;
package/dist/hooks.js ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * TransDuck hooks — event types and dispatch helpers.
3
+ *
4
+ * Field names match TranslationResult for consistency.
5
+ */
6
+ /**
7
+ * Invoke global hooks then per-call hook. Errors are logged, never raised.
8
+ */
9
+ export async function fireHooks(hooks, event, perCallHook) {
10
+ for (const hook of hooks) {
11
+ try {
12
+ await hook(event);
13
+ }
14
+ catch (err) {
15
+ console.warn('[transduck] Global translation hook error:', err);
16
+ }
17
+ }
18
+ if (perCallHook) {
19
+ try {
20
+ await perCallHook(event);
21
+ }
22
+ catch (err) {
23
+ console.warn('[transduck] Per-call translation hook error:', err);
24
+ }
25
+ }
26
+ }
package/dist/index.d.ts CHANGED
@@ -2,9 +2,12 @@ import { type TransduckConfig } from './config.js';
2
2
  import { TranslationStore } from './storage.js';
3
3
  import { SharedStore } from './shared-store.js';
4
4
  import { TranslationResult } from './result.js';
5
+ import { type TranslationHook } from './hooks.js';
5
6
  export declare function initialize(config?: TransduckConfig): Promise<void>;
6
7
  export declare function setLanguage(lang: string): void;
7
8
  export declare function _resetState(): void;
9
+ export declare function on(eventName: string, callback: TranslationHook): void;
10
+ export declare function off(eventName: string, callback: TranslationHook): void;
8
11
  export declare function _getStore(): TranslationStore | null;
9
12
  export declare function _getSharedStore(): SharedStore | null;
10
13
  export declare function _setSharedStore(shared: SharedStore | null): void;
@@ -13,15 +16,18 @@ export interface AitOptions {
13
16
  vars?: Record<string, string | number>;
14
17
  sourceLang?: string;
15
18
  background?: boolean;
19
+ onTranslate?: TranslationHook;
16
20
  }
17
21
  export declare function ait(sourceText: string, contextOrOpts?: string | AitOptions, vars?: Record<string, string | number>): Promise<TranslationResult>;
18
22
  export { createTransDuckHandler } from './handler.js';
19
23
  export { TranslationResult } from './result.js';
20
24
  export { SharedStore } from './shared-store.js';
25
+ export type { TranslationEvent, TranslationHook } from './hooks.js';
21
26
  export declare function detectLanguage(text: string): Promise<string>;
22
27
  export declare function aitPlural(one: string, other: string, count: number, opts?: {
23
28
  context?: string;
24
29
  vars?: Record<string, string | number>;
25
30
  sourceLang?: string;
26
31
  background?: boolean;
32
+ onTranslate?: TranslationHook;
27
33
  }): Promise<TranslationResult>;
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { TranslationResult } from './result.js';
6
6
  import { translate as backendTranslate, translatePlural as backendTranslatePlural, detectLanguage as backendDetectLanguage } from './backend.js';
7
7
  import { validateTranslation, extractPlaceholders } from './validation.js';
8
8
  import { getPluralCategory, getPluralCategories, interpolateVars } from './plural.js';
9
+ import { fireHooks } from './hooks.js';
9
10
  let state = {
10
11
  config: null,
11
12
  store: null,
@@ -13,6 +14,7 @@ let state = {
13
14
  targetLang: null,
14
15
  pendingTranslations: new Map(),
15
16
  backgroundKeys: new Set(),
17
+ hooks: new Map(),
16
18
  };
17
19
  function hash(text) {
18
20
  return createHash('sha256').update(text).digest('hex');
@@ -42,8 +44,40 @@ export function _resetState() {
42
44
  state = {
43
45
  config: null, store: null, sharedStore: null, targetLang: null,
44
46
  pendingTranslations: new Map(), backgroundKeys: new Set(),
47
+ hooks: new Map(),
45
48
  };
46
49
  }
50
+ export function on(eventName, callback) {
51
+ const hooks = state.hooks.get(eventName) ?? [];
52
+ hooks.push(callback);
53
+ state.hooks.set(eventName, hooks);
54
+ }
55
+ export function off(eventName, callback) {
56
+ const hooks = state.hooks.get(eventName);
57
+ if (hooks) {
58
+ const idx = hooks.indexOf(callback);
59
+ if (idx !== -1)
60
+ hooks.splice(idx, 1);
61
+ }
62
+ }
63
+ async function _maybeFireHooks(opts) {
64
+ const globalHooks = state.hooks.get('translate');
65
+ if (!globalHooks?.length && !opts.onTranslate)
66
+ return;
67
+ const event = {
68
+ sourceText: opts.sourceText,
69
+ translatedText: opts.translatedText,
70
+ sourceLang: opts.sourceLang,
71
+ lang: opts.lang,
72
+ context: opts.context,
73
+ projectContext: state.config?.projectContext ?? '',
74
+ source: opts.source,
75
+ plural: opts.plural ?? false,
76
+ pluralCategory: opts.pluralCategory,
77
+ pluralForms: opts.pluralForms,
78
+ };
79
+ await fireHooks(globalHooks ?? [], event, opts.onTranslate);
80
+ }
47
81
  export function _getStore() {
48
82
  return state.store;
49
83
  }
@@ -65,6 +99,7 @@ export async function ait(sourceText, contextOrOpts, vars) {
65
99
  let resolvedVars;
66
100
  let sourceLang;
67
101
  let background = false;
102
+ let onTranslate;
68
103
  if (typeof contextOrOpts === 'string') {
69
104
  context = contextOrOpts;
70
105
  resolvedVars = vars;
@@ -74,6 +109,7 @@ export async function ait(sourceText, contextOrOpts, vars) {
74
109
  resolvedVars = contextOrOpts.vars;
75
110
  sourceLang = contextOrOpts.sourceLang;
76
111
  background = contextOrOpts.background ?? false;
112
+ onTranslate = contextOrOpts.onTranslate;
77
113
  }
78
114
  else {
79
115
  resolvedVars = vars;
@@ -93,7 +129,11 @@ export async function ait(sourceText, contextOrOpts, vars) {
93
129
  // Tier 1: local SQLite lookup
94
130
  const cached = await state.store.lookup(lookupParams);
95
131
  if (cached !== null) {
96
- return new TranslationResult(interpolateVars(cached, resolvedVars), { pending: false, sourceLang: effectiveSourceLang, lang: targetLang });
132
+ await _maybeFireHooks({
133
+ sourceText, translatedText: cached, sourceLang: effectiveSourceLang,
134
+ lang: targetLang, context: context ?? null, source: 'cache', onTranslate,
135
+ });
136
+ return new TranslationResult(interpolateVars(cached, resolvedVars), { pending: false, sourceLang: effectiveSourceLang, lang: targetLang, source: 'cache' });
97
137
  }
98
138
  // Tier 2: shared Postgres lookup
99
139
  if (state.sharedStore) {
@@ -104,7 +144,11 @@ export async function ait(sourceText, contextOrOpts, vars) {
104
144
  ...lookupParams, stringContext: context ?? '',
105
145
  translatedText: sharedCached, model: cfg.backendModel, status: 'translated',
106
146
  });
107
- return new TranslationResult(interpolateVars(sharedCached, resolvedVars), { pending: false, sourceLang: effectiveSourceLang, lang: targetLang });
147
+ await _maybeFireHooks({
148
+ sourceText, translatedText: sharedCached, sourceLang: effectiveSourceLang,
149
+ lang: targetLang, context: context ?? null, source: 'shared_cache', onTranslate,
150
+ });
151
+ return new TranslationResult(interpolateVars(sharedCached, resolvedVars), { pending: false, sourceLang: effectiveSourceLang, lang: targetLang, source: 'shared_cache' });
108
152
  }
109
153
  }
110
154
  // Read-only mode: skip backend, return source text
@@ -117,6 +161,14 @@ export async function ait(sourceText, contextOrOpts, vars) {
117
161
  if (!state.backgroundKeys.has(bgKey)) {
118
162
  state.backgroundKeys.add(bgKey);
119
163
  _doTranslate(sourceText, effectiveSourceLang, targetLang, projectContextHash, stringContextHash, context ?? '', cfg)
164
+ .then(async (translated) => {
165
+ if (translated !== sourceText) {
166
+ await _maybeFireHooks({
167
+ sourceText, translatedText: translated, sourceLang: effectiveSourceLang,
168
+ lang: targetLang, context: context ?? null, source: 'translated', onTranslate,
169
+ });
170
+ }
171
+ })
120
172
  .finally(() => state.backgroundKeys.delete(bgKey));
121
173
  }
122
174
  return new TranslationResult(interpolateVars(sourceText, resolvedVars), { pending: true, sourceLang: effectiveSourceLang, lang: targetLang });
@@ -131,7 +183,14 @@ export async function ait(sourceText, contextOrOpts, vars) {
131
183
  state.pendingTranslations.set(lockKey, translationPromise);
132
184
  const result = await translationPromise;
133
185
  state.pendingTranslations.delete(lockKey);
134
- return new TranslationResult(interpolateVars(result, resolvedVars), { pending: false, sourceLang: effectiveSourceLang, lang: targetLang });
186
+ const isTranslated = result !== sourceText;
187
+ if (isTranslated) {
188
+ await _maybeFireHooks({
189
+ sourceText, translatedText: result, sourceLang: effectiveSourceLang,
190
+ lang: targetLang, context: context ?? null, source: 'translated', onTranslate,
191
+ });
192
+ }
193
+ return new TranslationResult(interpolateVars(result, resolvedVars), { pending: false, sourceLang: effectiveSourceLang, lang: targetLang, source: isTranslated ? 'translated' : undefined });
135
194
  }
136
195
  async function _doTranslate(sourceText, sourceLang, targetLang, projectContextHash, stringContextHash, stringContext, cfg) {
137
196
  // Double-check local cache
@@ -184,6 +243,7 @@ export async function aitPlural(one, other, count, opts) {
184
243
  const context = opts?.context;
185
244
  const effectiveSourceLang = opts?.sourceLang?.toUpperCase() ?? cfg.sourceLang;
186
245
  const background = opts?.background ?? false;
246
+ const onTranslatePlural = opts?.onTranslate;
187
247
  // Build vars with count
188
248
  let vars;
189
249
  if (!opts?.vars) {
@@ -217,7 +277,12 @@ export async function aitPlural(one, other, count, opts) {
217
277
  // Tier 1: local cache lookup
218
278
  const cached = await state.store.lookupPlural(lookupParams);
219
279
  if (category in cached) {
220
- return new TranslationResult(interpolateVars(cached[category], vars), { pending: false, sourceLang: effectiveSourceLang, lang: targetLang });
280
+ await _maybeFireHooks({
281
+ sourceText: sourceKey, translatedText: cached[category], sourceLang: effectiveSourceLang,
282
+ lang: targetLang, context: context ?? null, source: 'cache', onTranslate: onTranslatePlural,
283
+ plural: true, pluralCategory: category, pluralForms: cached,
284
+ });
285
+ return new TranslationResult(interpolateVars(cached[category], vars), { pending: false, sourceLang: effectiveSourceLang, lang: targetLang, source: 'cache' });
221
286
  }
222
287
  // Tier 2: shared store lookup
223
288
  if (state.sharedStore) {
@@ -231,7 +296,12 @@ export async function aitPlural(one, other, count, opts) {
231
296
  model: cfg.backendModel, status: 'translated',
232
297
  });
233
298
  }
234
- return new TranslationResult(interpolateVars(sharedCached[category], vars), { pending: false, sourceLang: effectiveSourceLang, lang: targetLang });
299
+ await _maybeFireHooks({
300
+ sourceText: sourceKey, translatedText: sharedCached[category], sourceLang: effectiveSourceLang,
301
+ lang: targetLang, context: context ?? null, source: 'shared_cache', onTranslate: onTranslatePlural,
302
+ plural: true, pluralCategory: category, pluralForms: sharedCached,
303
+ });
304
+ return new TranslationResult(interpolateVars(sharedCached[category], vars), { pending: false, sourceLang: effectiveSourceLang, lang: targetLang, source: 'shared_cache' });
235
305
  }
236
306
  }
237
307
  // Read-only mode: skip backend, fall back to source forms
@@ -246,6 +316,15 @@ export async function aitPlural(one, other, count, opts) {
246
316
  if (!state.backgroundKeys.has(bgKey)) {
247
317
  state.backgroundKeys.add(bgKey);
248
318
  _doPluralTranslate(one, other, sourceKey, effectiveSourceLang, targetLang, projectContextHash, stringContextHash, context ?? '', cfg)
319
+ .then(async (forms) => {
320
+ if (forms !== null && category in forms) {
321
+ await _maybeFireHooks({
322
+ sourceText: sourceKey, translatedText: forms[category], sourceLang: effectiveSourceLang,
323
+ lang: targetLang, context: context ?? null, source: 'translated', onTranslate: onTranslatePlural,
324
+ plural: true, pluralCategory: category, pluralForms: forms,
325
+ });
326
+ }
327
+ })
249
328
  .finally(() => state.backgroundKeys.delete(bgKey));
250
329
  }
251
330
  const fallbackCategory = getPluralCategory(effectiveSourceLang, count);
@@ -255,7 +334,12 @@ export async function aitPlural(one, other, count, opts) {
255
334
  // Cache miss — call backend
256
335
  const translatedText = await _doPluralTranslate(one, other, sourceKey, effectiveSourceLang, targetLang, projectContextHash, stringContextHash, context ?? '', cfg);
257
336
  if (translatedText !== null && category in translatedText) {
258
- return new TranslationResult(interpolateVars(translatedText[category], vars), { pending: false, sourceLang: effectiveSourceLang, lang: targetLang });
337
+ await _maybeFireHooks({
338
+ sourceText: sourceKey, translatedText: translatedText[category], sourceLang: effectiveSourceLang,
339
+ lang: targetLang, context: context ?? null, source: 'translated', onTranslate: onTranslatePlural,
340
+ plural: true, pluralCategory: category, pluralForms: translatedText,
341
+ });
342
+ return new TranslationResult(interpolateVars(translatedText[category], vars), { pending: false, sourceLang: effectiveSourceLang, lang: targetLang, source: 'translated' });
259
343
  }
260
344
  // Fallback
261
345
  const fallbackCategory = getPluralCategory(effectiveSourceLang, count);
package/dist/result.d.ts CHANGED
@@ -8,10 +8,12 @@ export declare class TranslationResult extends String {
8
8
  readonly pending: boolean;
9
9
  readonly sourceLang: string;
10
10
  readonly lang: string;
11
+ readonly source: string | null;
11
12
  constructor(text: string, opts: {
12
13
  pending: boolean;
13
14
  sourceLang: string;
14
15
  lang: string;
16
+ source?: string | null;
15
17
  });
16
18
  toString(): string;
17
19
  valueOf(): string;
package/dist/result.js CHANGED
@@ -8,11 +8,13 @@ export class TranslationResult extends String {
8
8
  pending;
9
9
  sourceLang;
10
10
  lang;
11
+ source;
11
12
  constructor(text, opts) {
12
13
  super(text);
13
14
  this.pending = opts.pending;
14
15
  this.sourceLang = opts.sourceLang;
15
16
  this.lang = opts.lang;
17
+ this.source = opts.source ?? null;
16
18
  }
17
19
  toString() {
18
20
  return super.toString();
package/dist/scanner.js CHANGED
@@ -12,6 +12,8 @@ const AIT_POSITIONAL_CTX = /ait\s*\(\s*(['"])(.*?)\1(?:\s*,\s*(['"])(.*?)\3)?/g;
12
12
  const AIT_PLURAL = /ait(?:_p|P)lural\s*\(\s*(['"])(.*?)\1\s*,\s*(['"])(.*?)\3/g;
13
13
  // {% ait "text" %} or {% ait "text" context="ctx" %}
14
14
  const DJANGO_TAG = /\{%\s*ait\s+(['"])(.*?)\1(?:\s+context=(['"])(.*?)\3)?\s*%\}/g;
15
+ // {% ait_plural "one" "other" %} or {% ait_plural "one" "other" context="ctx" %}
16
+ const DJANGO_PLURAL_TAG = /\{%\s*ait_plural\s+(['"])(.*?)\1\s+(['"])(.*?)\3(?:\s+context=(['"])(.*?)\5)?\s*%\}/g;
15
17
  // t("text") or t("text", "ctx") — only matched in files with transduck/react import
16
18
  const T_POSITIONAL = /(?<![a-zA-Z_.$])t\s*\(\s*(['"])(.*?)\1(?:\s*,\s*(['"])(.*?)\3)?/g;
17
19
  // tPlural("one", "other") — only matched in files with transduck/react import
@@ -81,6 +83,15 @@ export function extractStrings(content, filename) {
81
83
  const lineNum = content.slice(0, match.index).split('\n').length;
82
84
  results.push({ text, context, line: lineNum });
83
85
  }
86
+ const djangoPluralRegex = new RegExp(DJANGO_PLURAL_TAG.source, 'g');
87
+ while ((match = djangoPluralRegex.exec(content)) !== null) {
88
+ const one = match[2];
89
+ const other = match[4];
90
+ const context = match[6] || null;
91
+ const lineNum = content.slice(0, match.index).split('\n').length;
92
+ results.push({ plural: true, one, other, context, line: lineNum });
93
+ pluralSpans.push([match.index, match.index + match[0].length]);
94
+ }
84
95
  }
85
96
  // 3. Find ait() calls
86
97
  const pattern = isJs ? AIT_POSITIONAL_CTX : AIT_KEYWORD_CTX;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "transduck",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "AI-native translation tool using source text as keys",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/cli.ts CHANGED
@@ -661,7 +661,7 @@ export async function runStats(opts: StatsOptions): Promise<string> {
661
661
  // CLI entry point
662
662
  const program = new Command();
663
663
 
664
- program.name('transduck').description('AI-native translation tool').version('0.6.2');
664
+ program.name('transduck').description('AI-native translation tool').version('0.6.4');
665
665
 
666
666
  program.command('init')
667
667
  .description('Initialize a new transduck project')
package/src/handler.ts CHANGED
@@ -4,6 +4,7 @@ import { TranslationStore } from './storage.js';
4
4
  import { SharedStore } from './shared-store.js';
5
5
  import { translate as backendTranslate, translatePlural as backendTranslatePlural } from './backend.js';
6
6
  import { validateTranslation } from './validation.js';
7
+ import { type TranslationEvent, type TranslationHook, fireHooks } from './hooks.js';
7
8
 
8
9
  function hash(text: string): string {
9
10
  return createHash('sha256').update(text).digest('hex');
@@ -70,6 +71,7 @@ export function _resetHandlerStore(): void {
70
71
  export async function handleTranslationRequest(
71
72
  body: TranslationRequest,
72
73
  configPath?: string,
74
+ opts?: { onTranslate?: TranslationHook },
73
75
  ): Promise<TranslationResponse> {
74
76
  const cfg = loadConfig(configPath);
75
77
  const store = await getStore(configPath);
@@ -126,6 +128,15 @@ export async function handleTranslationRequest(
126
128
  };
127
129
  await store.insert(insertParams);
128
130
  if (shared) await shared.insert(insertParams);
131
+ if (opts?.onTranslate) {
132
+ const event: TranslationEvent = {
133
+ sourceText: item.text, translatedText: translated,
134
+ sourceLang, lang: targetLang, context: item.context ?? null,
135
+ projectContext: cfg.projectContext, source: 'translated',
136
+ plural: false,
137
+ };
138
+ await fireHooks([], event, opts.onTranslate);
139
+ }
129
140
  translations[key] = translated;
130
141
  } else {
131
142
  translations[key] = item.text;
@@ -186,6 +197,15 @@ export async function handleTranslationRequest(
186
197
  await store.insertPlural(insertParams);
187
198
  if (shared) await shared.insertPlural(insertParams);
188
199
  }
200
+ if (opts?.onTranslate && 'other' in forms) {
201
+ const event: TranslationEvent = {
202
+ sourceText: sourceKey, translatedText: forms.other ?? '',
203
+ sourceLang, lang: targetLang, context: item.context ?? null,
204
+ projectContext: cfg.projectContext, source: 'translated',
205
+ plural: true, pluralForms: forms,
206
+ };
207
+ await fireHooks([], event, opts.onTranslate);
208
+ }
189
209
  plurals[responseKey] = forms;
190
210
  } catch {
191
211
  plurals[responseKey] = { one: item.one, other: item.other };
@@ -195,11 +215,11 @@ export async function handleTranslationRequest(
195
215
  return { translations, plurals };
196
216
  }
197
217
 
198
- export function createTransDuckHandler(configPath?: string) {
218
+ export function createTransDuckHandler(configPath?: string, opts?: { onTranslate?: TranslationHook }) {
199
219
  return async function POST(request: Request): Promise<Response> {
200
220
  try {
201
221
  const body = await request.json() as TranslationRequest;
202
- const result = await handleTranslationRequest(body, configPath);
222
+ const result = await handleTranslationRequest(body, configPath, opts);
203
223
  return new Response(JSON.stringify(result), {
204
224
  status: 200,
205
225
  headers: { 'Content-Type': 'application/json' },
package/src/hooks.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * TransDuck hooks — event types and dispatch helpers.
3
+ *
4
+ * Field names match TranslationResult for consistency.
5
+ */
6
+
7
+ export interface TranslationEvent {
8
+ sourceText: string;
9
+ translatedText: string;
10
+ sourceLang: string;
11
+ lang: string; // target language — matches TranslationResult.lang
12
+ context: string | null;
13
+ projectContext: string;
14
+ source: 'cache' | 'translated' | 'shared_cache';
15
+ plural: boolean;
16
+ pluralCategory?: string;
17
+ pluralForms?: Record<string, string>;
18
+ }
19
+
20
+ export type TranslationHook = (event: TranslationEvent) => void | Promise<void>;
21
+
22
+ /**
23
+ * Invoke global hooks then per-call hook. Errors are logged, never raised.
24
+ */
25
+ export async function fireHooks(
26
+ hooks: TranslationHook[],
27
+ event: TranslationEvent,
28
+ perCallHook?: TranslationHook,
29
+ ): Promise<void> {
30
+ for (const hook of hooks) {
31
+ try {
32
+ await hook(event);
33
+ } catch (err) {
34
+ console.warn('[transduck] Global translation hook error:', err);
35
+ }
36
+ }
37
+ if (perCallHook) {
38
+ try {
39
+ await perCallHook(event);
40
+ } catch (err) {
41
+ console.warn('[transduck] Per-call translation hook error:', err);
42
+ }
43
+ }
44
+ }
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ import { TranslationResult } from './result.js';
6
6
  import { translate as backendTranslate, translatePlural as backendTranslatePlural, detectLanguage as backendDetectLanguage } from './backend.js';
7
7
  import { validateTranslation, extractPlaceholders } from './validation.js';
8
8
  import { getPluralCategory, getPluralCategories, interpolateVars } from './plural.js';
9
+ import { type TranslationEvent, type TranslationHook, fireHooks } from './hooks.js';
9
10
 
10
11
  interface State {
11
12
  config: TransduckConfig | null;
@@ -14,6 +15,7 @@ interface State {
14
15
  targetLang: string | null;
15
16
  pendingTranslations: Map<string, Promise<string>>;
16
17
  backgroundKeys: Set<string>;
18
+ hooks: Map<string, TranslationHook[]>;
17
19
  }
18
20
 
19
21
  let state: State = {
@@ -23,6 +25,7 @@ let state: State = {
23
25
  targetLang: null,
24
26
  pendingTranslations: new Map(),
25
27
  backgroundKeys: new Set(),
28
+ hooks: new Map(),
26
29
  };
27
30
 
28
31
  function hash(text: string): string {
@@ -56,9 +59,47 @@ export function _resetState(): void {
56
59
  state = {
57
60
  config: null, store: null, sharedStore: null, targetLang: null,
58
61
  pendingTranslations: new Map(), backgroundKeys: new Set(),
62
+ hooks: new Map(),
59
63
  };
60
64
  }
61
65
 
66
+ export function on(eventName: string, callback: TranslationHook): void {
67
+ const hooks = state.hooks.get(eventName) ?? [];
68
+ hooks.push(callback);
69
+ state.hooks.set(eventName, hooks);
70
+ }
71
+
72
+ export function off(eventName: string, callback: TranslationHook): void {
73
+ const hooks = state.hooks.get(eventName);
74
+ if (hooks) {
75
+ const idx = hooks.indexOf(callback);
76
+ if (idx !== -1) hooks.splice(idx, 1);
77
+ }
78
+ }
79
+
80
+ async function _maybeFireHooks(opts: {
81
+ sourceText: string; translatedText: string; sourceLang: string; lang: string;
82
+ context: string | null; source: 'cache' | 'translated' | 'shared_cache';
83
+ onTranslate?: TranslationHook; plural?: boolean;
84
+ pluralCategory?: string; pluralForms?: Record<string, string>;
85
+ }): Promise<void> {
86
+ const globalHooks = state.hooks.get('translate');
87
+ if (!globalHooks?.length && !opts.onTranslate) return;
88
+ const event: TranslationEvent = {
89
+ sourceText: opts.sourceText,
90
+ translatedText: opts.translatedText,
91
+ sourceLang: opts.sourceLang,
92
+ lang: opts.lang,
93
+ context: opts.context,
94
+ projectContext: state.config?.projectContext ?? '',
95
+ source: opts.source,
96
+ plural: opts.plural ?? false,
97
+ pluralCategory: opts.pluralCategory,
98
+ pluralForms: opts.pluralForms,
99
+ };
100
+ await fireHooks(globalHooks ?? [], event, opts.onTranslate);
101
+ }
102
+
62
103
  export function _getStore(): TranslationStore | null {
63
104
  return state.store;
64
105
  }
@@ -76,6 +117,7 @@ export interface AitOptions {
76
117
  vars?: Record<string, string | number>;
77
118
  sourceLang?: string;
78
119
  background?: boolean;
120
+ onTranslate?: TranslationHook;
79
121
  }
80
122
 
81
123
  export async function ait(
@@ -96,6 +138,8 @@ export async function ait(
96
138
  let sourceLang: string | undefined;
97
139
  let background = false;
98
140
 
141
+ let onTranslate: TranslationHook | undefined;
142
+
99
143
  if (typeof contextOrOpts === 'string') {
100
144
  context = contextOrOpts;
101
145
  resolvedVars = vars;
@@ -104,6 +148,7 @@ export async function ait(
104
148
  resolvedVars = contextOrOpts.vars;
105
149
  sourceLang = contextOrOpts.sourceLang;
106
150
  background = contextOrOpts.background ?? false;
151
+ onTranslate = contextOrOpts.onTranslate;
107
152
  } else {
108
153
  resolvedVars = vars;
109
154
  }
@@ -129,9 +174,13 @@ export async function ait(
129
174
  // Tier 1: local SQLite lookup
130
175
  const cached = await state.store.lookup(lookupParams);
131
176
  if (cached !== null) {
177
+ await _maybeFireHooks({
178
+ sourceText, translatedText: cached, sourceLang: effectiveSourceLang,
179
+ lang: targetLang, context: context ?? null, source: 'cache', onTranslate,
180
+ });
132
181
  return new TranslationResult(
133
182
  interpolateVars(cached, resolvedVars),
134
- { pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
183
+ { pending: false, sourceLang: effectiveSourceLang, lang: targetLang, source: 'cache' },
135
184
  );
136
185
  }
137
186
 
@@ -144,9 +193,13 @@ export async function ait(
144
193
  ...lookupParams, stringContext: context ?? '',
145
194
  translatedText: sharedCached, model: cfg.backendModel, status: 'translated',
146
195
  });
196
+ await _maybeFireHooks({
197
+ sourceText, translatedText: sharedCached, sourceLang: effectiveSourceLang,
198
+ lang: targetLang, context: context ?? null, source: 'shared_cache', onTranslate,
199
+ });
147
200
  return new TranslationResult(
148
201
  interpolateVars(sharedCached, resolvedVars),
149
- { pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
202
+ { pending: false, sourceLang: effectiveSourceLang, lang: targetLang, source: 'shared_cache' },
150
203
  );
151
204
  }
152
205
  }
@@ -165,6 +218,14 @@ export async function ait(
165
218
  if (!state.backgroundKeys.has(bgKey)) {
166
219
  state.backgroundKeys.add(bgKey);
167
220
  _doTranslate(sourceText, effectiveSourceLang, targetLang, projectContextHash, stringContextHash, context ?? '', cfg)
221
+ .then(async (translated) => {
222
+ if (translated !== sourceText) {
223
+ await _maybeFireHooks({
224
+ sourceText, translatedText: translated, sourceLang: effectiveSourceLang,
225
+ lang: targetLang, context: context ?? null, source: 'translated', onTranslate,
226
+ });
227
+ }
228
+ })
168
229
  .finally(() => state.backgroundKeys.delete(bgKey));
169
230
  }
170
231
  return new TranslationResult(
@@ -189,9 +250,17 @@ export async function ait(
189
250
  state.pendingTranslations.set(lockKey, translationPromise);
190
251
  const result = await translationPromise;
191
252
  state.pendingTranslations.delete(lockKey);
253
+
254
+ const isTranslated = result !== sourceText;
255
+ if (isTranslated) {
256
+ await _maybeFireHooks({
257
+ sourceText, translatedText: result, sourceLang: effectiveSourceLang,
258
+ lang: targetLang, context: context ?? null, source: 'translated', onTranslate,
259
+ });
260
+ }
192
261
  return new TranslationResult(
193
262
  interpolateVars(result, resolvedVars),
194
- { pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
263
+ { pending: false, sourceLang: effectiveSourceLang, lang: targetLang, source: isTranslated ? 'translated' : undefined },
195
264
  );
196
265
  }
197
266
 
@@ -241,6 +310,7 @@ async function _doTranslate(
241
310
  export { createTransDuckHandler } from './handler.js';
242
311
  export { TranslationResult } from './result.js';
243
312
  export { SharedStore } from './shared-store.js';
313
+ export type { TranslationEvent, TranslationHook } from './hooks.js';
244
314
  export async function detectLanguage(text: string): Promise<string> {
245
315
  if (!state.config) {
246
316
  throw new Error('transduck not initialized. Call initialize() first.');
@@ -252,7 +322,7 @@ export async function aitPlural(
252
322
  one: string,
253
323
  other: string,
254
324
  count: number,
255
- opts?: { context?: string; vars?: Record<string, string | number>; sourceLang?: string; background?: boolean },
325
+ opts?: { context?: string; vars?: Record<string, string | number>; sourceLang?: string; background?: boolean; onTranslate?: TranslationHook },
256
326
  ): Promise<TranslationResult> {
257
327
  if (!state.config || !state.store) {
258
328
  throw new Error('transduck not initialized. Call initialize() first.');
@@ -265,6 +335,7 @@ export async function aitPlural(
265
335
  const context = opts?.context;
266
336
  const effectiveSourceLang = opts?.sourceLang?.toUpperCase() ?? cfg.sourceLang;
267
337
  const background = opts?.background ?? false;
338
+ const onTranslatePlural = opts?.onTranslate;
268
339
 
269
340
  // Build vars with count
270
341
  let vars: Record<string, string | number>;
@@ -305,9 +376,14 @@ export async function aitPlural(
305
376
  // Tier 1: local cache lookup
306
377
  const cached = await state.store.lookupPlural(lookupParams);
307
378
  if (category in cached) {
379
+ await _maybeFireHooks({
380
+ sourceText: sourceKey, translatedText: cached[category], sourceLang: effectiveSourceLang,
381
+ lang: targetLang, context: context ?? null, source: 'cache', onTranslate: onTranslatePlural,
382
+ plural: true, pluralCategory: category, pluralForms: cached,
383
+ });
308
384
  return new TranslationResult(
309
385
  interpolateVars(cached[category], vars),
310
- { pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
386
+ { pending: false, sourceLang: effectiveSourceLang, lang: targetLang, source: 'cache' },
311
387
  );
312
388
  }
313
389
 
@@ -323,9 +399,14 @@ export async function aitPlural(
323
399
  model: cfg.backendModel, status: 'translated',
324
400
  });
325
401
  }
402
+ await _maybeFireHooks({
403
+ sourceText: sourceKey, translatedText: sharedCached[category], sourceLang: effectiveSourceLang,
404
+ lang: targetLang, context: context ?? null, source: 'shared_cache', onTranslate: onTranslatePlural,
405
+ plural: true, pluralCategory: category, pluralForms: sharedCached,
406
+ });
326
407
  return new TranslationResult(
327
408
  interpolateVars(sharedCached[category], vars),
328
- { pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
409
+ { pending: false, sourceLang: effectiveSourceLang, lang: targetLang, source: 'shared_cache' },
329
410
  );
330
411
  }
331
412
  }
@@ -346,6 +427,15 @@ export async function aitPlural(
346
427
  if (!state.backgroundKeys.has(bgKey)) {
347
428
  state.backgroundKeys.add(bgKey);
348
429
  _doPluralTranslate(one, other, sourceKey, effectiveSourceLang, targetLang, projectContextHash, stringContextHash, context ?? '', cfg)
430
+ .then(async (forms) => {
431
+ if (forms !== null && category in forms) {
432
+ await _maybeFireHooks({
433
+ sourceText: sourceKey, translatedText: forms[category], sourceLang: effectiveSourceLang,
434
+ lang: targetLang, context: context ?? null, source: 'translated', onTranslate: onTranslatePlural,
435
+ plural: true, pluralCategory: category, pluralForms: forms,
436
+ });
437
+ }
438
+ })
349
439
  .finally(() => state.backgroundKeys.delete(bgKey));
350
440
  }
351
441
  const fallbackCategory = getPluralCategory(effectiveSourceLang, count);
@@ -360,9 +450,14 @@ export async function aitPlural(
360
450
  const translatedText = await _doPluralTranslate(one, other, sourceKey, effectiveSourceLang, targetLang, projectContextHash, stringContextHash, context ?? '', cfg);
361
451
 
362
452
  if (translatedText !== null && category in translatedText) {
453
+ await _maybeFireHooks({
454
+ sourceText: sourceKey, translatedText: translatedText[category], sourceLang: effectiveSourceLang,
455
+ lang: targetLang, context: context ?? null, source: 'translated', onTranslate: onTranslatePlural,
456
+ plural: true, pluralCategory: category, pluralForms: translatedText,
457
+ });
363
458
  return new TranslationResult(
364
459
  interpolateVars(translatedText[category], vars),
365
- { pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
460
+ { pending: false, sourceLang: effectiveSourceLang, lang: targetLang, source: 'translated' },
366
461
  );
367
462
  }
368
463
 
package/src/result.ts CHANGED
@@ -8,12 +8,14 @@ export class TranslationResult extends String {
8
8
  readonly pending: boolean;
9
9
  readonly sourceLang: string;
10
10
  readonly lang: string;
11
+ readonly source: string | null;
11
12
 
12
- constructor(text: string, opts: { pending: boolean; sourceLang: string; lang: string }) {
13
+ constructor(text: string, opts: { pending: boolean; sourceLang: string; lang: string; source?: string | null }) {
13
14
  super(text);
14
15
  this.pending = opts.pending;
15
16
  this.sourceLang = opts.sourceLang;
16
17
  this.lang = opts.lang;
18
+ this.source = opts.source ?? null;
17
19
  }
18
20
 
19
21
  override toString(): string {
package/src/scanner.ts CHANGED
@@ -31,6 +31,9 @@ const AIT_PLURAL = /ait(?:_p|P)lural\s*\(\s*(['"])(.*?)\1\s*,\s*(['"])(.*?)\3/g;
31
31
  // {% ait "text" %} or {% ait "text" context="ctx" %}
32
32
  const DJANGO_TAG = /\{%\s*ait\s+(['"])(.*?)\1(?:\s+context=(['"])(.*?)\3)?\s*%\}/g;
33
33
 
34
+ // {% ait_plural "one" "other" %} or {% ait_plural "one" "other" context="ctx" %}
35
+ const DJANGO_PLURAL_TAG = /\{%\s*ait_plural\s+(['"])(.*?)\1\s+(['"])(.*?)\3(?:\s+context=(['"])(.*?)\5)?\s*%\}/g;
36
+
34
37
  // t("text") or t("text", "ctx") — only matched in files with transduck/react import
35
38
  const T_POSITIONAL = /(?<![a-zA-Z_.$])t\s*\(\s*(['"])(.*?)\1(?:\s*,\s*(['"])(.*?)\3)?/g;
36
39
 
@@ -113,6 +116,16 @@ export function extractStrings(content: string, filename: string): ScanEntry[] {
113
116
  const lineNum = content.slice(0, match.index).split('\n').length;
114
117
  results.push({ text, context, line: lineNum });
115
118
  }
119
+
120
+ const djangoPluralRegex = new RegExp(DJANGO_PLURAL_TAG.source, 'g');
121
+ while ((match = djangoPluralRegex.exec(content)) !== null) {
122
+ const one = match[2];
123
+ const other = match[4];
124
+ const context = match[6] || null;
125
+ const lineNum = content.slice(0, match.index).split('\n').length;
126
+ results.push({ plural: true, one, other, context, line: lineNum });
127
+ pluralSpans.push([match.index, match.index + match[0].length]);
128
+ }
116
129
  }
117
130
 
118
131
  // 3. Find ait() calls
@@ -0,0 +1,274 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { createHash } from 'crypto';
3
+ import { join } from 'path';
4
+ import { mkdtempSync } from 'fs';
5
+ import { tmpdir } from 'os';
6
+ import type { TransduckConfig } from '../src/config.js';
7
+ import type { TranslationEvent, TranslationHook } from '../src/hooks.js';
8
+
9
+ function hash(text: string): string {
10
+ return createHash('sha256').update(text).digest('hex');
11
+ }
12
+
13
+ function makeConfig(tmpDir: string, overrides?: Partial<TransduckConfig>): TransduckConfig {
14
+ return {
15
+ projectName: 'test',
16
+ projectContext: 'A test site',
17
+ sourceLang: 'EN',
18
+ targetLangs: ['DE', 'ES'],
19
+ storagePath: join(tmpDir, 'test.db'),
20
+ provider: 'openai',
21
+ apiKeyEnv: 'OPENAI_API_KEY',
22
+ tokenEnv: 'CLAUDE_CODE_OAUTH_TOKEN',
23
+ backendModel: 'gpt-4.1-mini',
24
+ backendTimeout: 10,
25
+ backendMaxRetries: 2,
26
+ sharedUrl: null,
27
+ readOnly: false,
28
+ ...overrides,
29
+ };
30
+ }
31
+
32
+ describe('hooks', () => {
33
+ let tmpDir: string;
34
+
35
+ beforeEach(async () => {
36
+ const mod = await import('../src/index.js');
37
+ mod._resetState();
38
+ tmpDir = mkdtempSync(join(tmpdir(), 'transduck-hooks-test-'));
39
+ process.env.OPENAI_API_KEY = 'test-key';
40
+ });
41
+
42
+ async function setup() {
43
+ const mod = await import('../src/index.js');
44
+ const cfg = makeConfig(tmpDir);
45
+ await mod.initialize(cfg);
46
+ mod.setLanguage('DE');
47
+ return mod;
48
+ }
49
+
50
+ async function preInsert(sourceText = 'Hello', translatedText = 'Hallo', context = '') {
51
+ const mod = await import('../src/index.js');
52
+ const store = mod._getStore()!;
53
+ await store.insert({
54
+ sourceText,
55
+ sourceLang: 'EN',
56
+ targetLang: 'DE',
57
+ projectContextHash: hash('A test site'),
58
+ stringContextHash: hash(context),
59
+ stringContext: context,
60
+ translatedText,
61
+ model: 'gpt-4.1-mini',
62
+ status: 'translated',
63
+ });
64
+ }
65
+
66
+ // --- Global hook tests ---
67
+
68
+ it('global hook fires on cache hit', async () => {
69
+ const mod = await setup();
70
+ await preInsert();
71
+ const events: TranslationEvent[] = [];
72
+ mod.on('translate', (e: TranslationEvent) => { events.push(e); });
73
+ await mod.ait('Hello');
74
+ expect(events).toHaveLength(1);
75
+ expect(events[0].source).toBe('cache');
76
+ expect(events[0].translatedText).toBe('Hallo');
77
+ expect(events[0].sourceText).toBe('Hello');
78
+ expect(events[0].sourceLang).toBe('EN');
79
+ expect(events[0].lang).toBe('DE');
80
+ expect(events[0].projectContext).toBe('A test site');
81
+ expect(events[0].plural).toBe(false);
82
+ });
83
+
84
+ it('global hook fires on fresh translation', async () => {
85
+ const mod = await setup();
86
+ vi.spyOn(await import('../src/backend.js'), 'translate').mockResolvedValue('Hallo');
87
+ const events: TranslationEvent[] = [];
88
+ mod.on('translate', (e: TranslationEvent) => { events.push(e); });
89
+ await mod.ait('Hello');
90
+ expect(events).toHaveLength(1);
91
+ expect(events[0].source).toBe('translated');
92
+ expect(events[0].translatedText).toBe('Hallo');
93
+ });
94
+
95
+ it('global hook receives context and project_context', async () => {
96
+ const mod = await setup();
97
+ vi.spyOn(await import('../src/backend.js'), 'translate').mockResolvedValue('Buchen');
98
+ const events: TranslationEvent[] = [];
99
+ mod.on('translate', (e: TranslationEvent) => { events.push(e); });
100
+ await mod.ait('Book', { context: 'Button to book a trip' });
101
+ expect(events[0].context).toBe('Button to book a trip');
102
+ expect(events[0].projectContext).toBe('A test site');
103
+ });
104
+
105
+ it('off() removes a hook', async () => {
106
+ const mod = await setup();
107
+ await preInsert();
108
+ const events: TranslationEvent[] = [];
109
+ const hook: TranslationHook = (e) => { events.push(e); };
110
+ mod.on('translate', hook);
111
+ await mod.ait('Hello');
112
+ expect(events).toHaveLength(1);
113
+ mod.off('translate', hook);
114
+ await mod.ait('Hello');
115
+ expect(events).toHaveLength(1); // no new event
116
+ });
117
+
118
+ it('multiple global hooks fire in order', async () => {
119
+ const mod = await setup();
120
+ await preInsert();
121
+ const order: string[] = [];
122
+ mod.on('translate', () => { order.push('first'); });
123
+ mod.on('translate', () => { order.push('second'); });
124
+ await mod.ait('Hello');
125
+ expect(order).toEqual(['first', 'second']);
126
+ });
127
+
128
+ // --- Per-call hook tests ---
129
+
130
+ it('per-call hook fires on cache hit', async () => {
131
+ const mod = await setup();
132
+ await preInsert();
133
+ const events: TranslationEvent[] = [];
134
+ await mod.ait('Hello', { onTranslate: (e: TranslationEvent) => { events.push(e); } });
135
+ expect(events).toHaveLength(1);
136
+ expect(events[0].translatedText).toBe('Hallo');
137
+ });
138
+
139
+ it('per-call hook fires on fresh translation', async () => {
140
+ const mod = await setup();
141
+ vi.spyOn(await import('../src/backend.js'), 'translate').mockResolvedValue('Hallo');
142
+ const events: TranslationEvent[] = [];
143
+ await mod.ait('Hello', { onTranslate: (e: TranslationEvent) => { events.push(e); } });
144
+ expect(events).toHaveLength(1);
145
+ expect(events[0].source).toBe('translated');
146
+ });
147
+
148
+ // --- Global + per-call combination ---
149
+
150
+ it('global fires before per-call', async () => {
151
+ const mod = await setup();
152
+ await preInsert();
153
+ const order: string[] = [];
154
+ mod.on('translate', () => { order.push('global'); });
155
+ await mod.ait('Hello', { onTranslate: () => { order.push('per_call'); } });
156
+ expect(order).toEqual(['global', 'per_call']);
157
+ });
158
+
159
+ // --- Error isolation ---
160
+
161
+ it('hook error does not crash ait()', async () => {
162
+ const mod = await setup();
163
+ await preInsert();
164
+ mod.on('translate', () => { throw new Error('hook exploded'); });
165
+ const result = await mod.ait('Hello');
166
+ expect(result.toString()).toBe('Hallo');
167
+ });
168
+
169
+ it('per-call hook error does not crash ait()', async () => {
170
+ const mod = await setup();
171
+ await preInsert();
172
+ const result = await mod.ait('Hello', {
173
+ onTranslate: () => { throw new Error('boom'); },
174
+ });
175
+ expect(result.toString()).toBe('Hallo');
176
+ });
177
+
178
+ // --- Async hooks ---
179
+
180
+ it('async hooks are awaited', async () => {
181
+ const mod = await setup();
182
+ await preInsert();
183
+ let hookCompleted = false;
184
+ mod.on('translate', async () => {
185
+ await new Promise((r) => setTimeout(r, 50));
186
+ hookCompleted = true;
187
+ });
188
+ await mod.ait('Hello');
189
+ expect(hookCompleted).toBe(true);
190
+ });
191
+
192
+ // --- TranslationResult.source property ---
193
+
194
+ it('result has source="cache" on cache hit', async () => {
195
+ const mod = await setup();
196
+ await preInsert();
197
+ const result = await mod.ait('Hello');
198
+ expect(result.source).toBe('cache');
199
+ });
200
+
201
+ it('result has source="translated" on fresh translation', async () => {
202
+ const mod = await setup();
203
+ vi.spyOn(await import('../src/backend.js'), 'translate').mockResolvedValue('Hallo');
204
+ const result = await mod.ait('Hello');
205
+ expect(result.source).toBe('translated');
206
+ });
207
+
208
+ // --- Plural hook tests ---
209
+
210
+ it('plural hook fires on fresh translation', async () => {
211
+ const mod = await setup();
212
+ vi.spyOn(await import('../src/backend.js'), 'translatePlural').mockResolvedValue({
213
+ one: '{count} Nacht',
214
+ other: '{count} Nächte',
215
+ });
216
+ const events: TranslationEvent[] = [];
217
+ mod.on('translate', (e: TranslationEvent) => { events.push(e); });
218
+ await mod.aitPlural('{count} night', '{count} nights', 1);
219
+ expect(events).toHaveLength(1);
220
+ expect(events[0].plural).toBe(true);
221
+ expect(events[0].pluralCategory).toBe('one');
222
+ expect(events[0].pluralForms).toEqual({ one: '{count} Nacht', other: '{count} Nächte' });
223
+ expect(events[0].source).toBe('translated');
224
+ });
225
+
226
+ it('plural per-call hook fires', async () => {
227
+ const mod = await setup();
228
+ vi.spyOn(await import('../src/backend.js'), 'translatePlural').mockResolvedValue({
229
+ one: '{count} Nacht',
230
+ other: '{count} Nächte',
231
+ });
232
+ const events: TranslationEvent[] = [];
233
+ await mod.aitPlural('{count} night', '{count} nights', 7, {
234
+ onTranslate: (e: TranslationEvent) => { events.push(e); },
235
+ });
236
+ expect(events).toHaveLength(1);
237
+ expect(events[0].plural).toBe(true);
238
+ expect(events[0].pluralCategory).toBe('other');
239
+ });
240
+
241
+ it('plural hook fires on cache hit', async () => {
242
+ const mod = await setup();
243
+ const store = mod._getStore()!;
244
+ const sourceKey = '{count} night\x00{count} nights';
245
+ const lookupParams = {
246
+ sourceText: sourceKey,
247
+ sourceLang: 'EN',
248
+ targetLang: 'DE',
249
+ projectContextHash: hash('A test site'),
250
+ stringContextHash: hash(''),
251
+ stringContext: '',
252
+ model: 'gpt-4.1-mini',
253
+ status: 'translated',
254
+ };
255
+ await store.insertPlural({ ...lookupParams, pluralCategory: 'one', translatedText: '{count} Nacht' });
256
+ await store.insertPlural({ ...lookupParams, pluralCategory: 'other', translatedText: '{count} Nächte' });
257
+
258
+ const events: TranslationEvent[] = [];
259
+ mod.on('translate', (e: TranslationEvent) => { events.push(e); });
260
+ await mod.aitPlural('{count} night', '{count} nights', 1);
261
+ expect(events).toHaveLength(1);
262
+ expect(events[0].source).toBe('cache');
263
+ expect(events[0].pluralCategory).toBe('one');
264
+ });
265
+
266
+ // --- No hooks = no overhead ---
267
+
268
+ it('no hooks still works fine', async () => {
269
+ const mod = await setup();
270
+ vi.spyOn(await import('../src/backend.js'), 'translate').mockResolvedValue('Hallo');
271
+ const result = await mod.ait('Hello');
272
+ expect(result.toString()).toBe('Hallo');
273
+ });
274
+ });
@@ -156,6 +156,37 @@ ait_plural("{count} item", "{count} items", count=n)
156
156
  expect(result[0].other).toBe('{count} long items description here');
157
157
  });
158
158
 
159
+ it('extracts Django plural template tag', () => {
160
+ const result = extractStrings('{% ait_plural "{count} night" "{count} nights" %}', 'test.html');
161
+ expect(result).toHaveLength(1);
162
+ expect(result[0].plural).toBe(true);
163
+ expect(result[0].one).toBe('{count} night');
164
+ expect(result[0].other).toBe('{count} nights');
165
+ expect(result[0].context).toBeNull();
166
+ });
167
+
168
+ it('extracts Django plural template tag with context', () => {
169
+ const result = extractStrings('{% ait_plural "{count} night" "{count} nights" context="Hotel stay duration" %}', 'test.html');
170
+ expect(result).toHaveLength(1);
171
+ expect(result[0].plural).toBe(true);
172
+ expect(result[0].one).toBe('{count} night');
173
+ expect(result[0].other).toBe('{count} nights');
174
+ expect(result[0].context).toBe('Hotel stay duration');
175
+ });
176
+
177
+ it('extracts Django plural tag with single quotes', () => {
178
+ const result = extractStrings("{% ait_plural '{count} item' '{count} items' %}", 'test.jinja2');
179
+ expect(result).toHaveLength(1);
180
+ expect(result[0].plural).toBe(true);
181
+ expect(result[0].one).toBe('{count} item');
182
+ expect(result[0].other).toBe('{count} items');
183
+ });
184
+
185
+ it('does not extract Django plural tag in .py files', () => {
186
+ const result = extractStrings('{% ait_plural "{count} night" "{count} nights" %}', 'test.py');
187
+ expect(result).toHaveLength(0);
188
+ });
189
+
159
190
  it('extracts multi-line single-quoted strings', () => {
160
191
  const code = `ait(\n 'This is a long '\n 'paragraph'\n)`;
161
192
  const result = extractStrings(code, 'test.py');