transduck 0.0.5 → 0.1.2

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/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # TransDuck
2
+
3
+ AI-powered translation for apps and websites. Fast. Cheap. No keys to manage.
4
+
5
+ TransDuck translates your app's strings using AI, then caches them locally in DuckDB so you never pay for the same translation twice. No translation files. No key naming. No sync headaches.
6
+
7
+ ```typescript
8
+ import { initialize, setLanguage, ait, aitPlural } from 'transduck';
9
+
10
+ await initialize();
11
+ setLanguage('DE');
12
+
13
+ await ait('Welcome to Mallorca'); // → "Willkommen auf Mallorca"
14
+ await ait('Hello {name}', undefined, { name: 'Tim' }); // → "Hallo Tim"
15
+ await aitPlural('{count} night', '{count} nights', 7); // → "7 Nächte"
16
+ ```
17
+
18
+ ## Why TransDuck?
19
+
20
+ - **No keys to manage** — your source text is the key
21
+ - **No translation files** — translations live in a single in-memory database file
22
+ - **Translate once, pay once** — cached lookups take ~1.5ms, zero API costs after first call
23
+ - **AI that understands context** — project and per-string context for accurate translations
24
+ - **Pluralization that works everywhere** — handles Russian (4 forms), Arabic (6 forms) automatically
25
+ - **Built for vibe coding** — AI coding tools read the docs and wrap strings with `ait()` across your project
26
+ - **Dirt cheap** — 1,000 strings x 5 languages costs about $0.10 with gpt-4.1-mini
27
+
28
+ ## Quick Start
29
+
30
+ ```bash
31
+ npm install transduck
32
+ transduck init
33
+ ```
34
+
35
+ ## React / Next.js
36
+
37
+ TransDuck includes a first-party React integration with synchronous `t()` for client components:
38
+
39
+ ```tsx
40
+ // app/api/translations/route.ts (one-time setup)
41
+ import { createTransDuckHandler } from 'transduck';
42
+ export const POST = createTransDuckHandler();
43
+ ```
44
+
45
+ ```tsx
46
+ // layout.tsx
47
+ import { TransDuckProvider } from 'transduck/react';
48
+
49
+ <TransDuckProvider language="DE">
50
+ <App />
51
+ </TransDuckProvider>
52
+ ```
53
+
54
+ ```tsx
55
+ // Any client component
56
+ 'use client';
57
+ import { t, tPlural, useTransDuck } from 'transduck/react';
58
+
59
+ function Dashboard() {
60
+ useTransDuck();
61
+ return (
62
+ <h1>{t("Dashboard")}</h1>
63
+ <p>{tPlural("{count} property", "{count} properties", 5)}</p>
64
+ );
65
+ }
66
+ ```
67
+
68
+ ## CLI
69
+
70
+ ```bash
71
+ transduck scan --warm --langs DE,ES,IT # scan codebase and translate everything
72
+ transduck translate "Hello" --to DE # translate a single string
73
+ transduck stats # check your translation database
74
+ ```
75
+
76
+ ## Also available for Python
77
+
78
+ ```bash
79
+ pip install transduck
80
+ ```
81
+
82
+ Works with Django, Flask, FastAPI, and Jinja. Same DuckDB database format — share translations between Python and JS.
83
+
84
+ ## Links
85
+
86
+ - [GitHub](https://github.com/timnilson/transduck)
87
+ - [Full Documentation](https://github.com/timnilson/transduck/blob/main/DOCUMENTATION.md)
88
+ - [PyPI Package](https://pypi.org/project/transduck/)
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { createHash } from 'crypto';
3
- import { readFileSync, writeFileSync } from 'fs';
4
- import { join } from 'path';
3
+ import { readFileSync, writeFileSync, mkdirSync } from 'fs';
4
+ import { join, dirname } from 'path';
5
5
  import { Command } from 'commander';
6
6
  import { stringify as yamlStringify } from 'yaml';
7
7
  import { loadConfig } from './config.js';
@@ -456,6 +456,21 @@ program.command('init')
456
456
  targetLangs: targetsRaw.split(',').map(s => s.trim()),
457
457
  });
458
458
  console.log(output);
459
+ const rl2 = readline.createInterface({ input: process.stdin, output: process.stdout });
460
+ const ask2 = (q) => new Promise(r => rl2.question(q, r));
461
+ const useReact = await ask2('Are you using React/Next.js? (y/n): ');
462
+ if (useReact.toLowerCase() === 'y') {
463
+ const routePath = await ask2('API route path (default: app/api/translations/route.ts): ');
464
+ const finalPath = join(dir, routePath.trim() || 'app/api/translations/route.ts');
465
+ // Create directory
466
+ const routeDir = dirname(finalPath);
467
+ mkdirSync(routeDir, { recursive: true });
468
+ // Write route file
469
+ writeFileSync(finalPath, `import { createTransDuckHandler } from 'transduck';\n\nexport const POST = createTransDuckHandler();\n`);
470
+ console.log(`Created ${finalPath}`);
471
+ console.log('Add TransDuckProvider to your layout — see docs: transduck/react');
472
+ }
473
+ rl2.close();
459
474
  });
460
475
  program.command('translate')
461
476
  .description('Translate a single string')
package/dist/config.d.ts CHANGED
@@ -8,5 +8,6 @@ export interface TransduckConfig {
8
8
  backendModel: string;
9
9
  backendTimeout: number;
10
10
  backendMaxRetries: number;
11
+ readOnly: boolean;
11
12
  }
12
13
  export declare function loadConfig(path?: string): TransduckConfig;
package/dist/config.js CHANGED
@@ -39,5 +39,6 @@ export function loadConfig(path) {
39
39
  backendModel: raw.backend.model,
40
40
  backendTimeout: raw.backend.timeout_seconds,
41
41
  backendMaxRetries: raw.backend.max_retries,
42
+ readOnly: raw.runtime?.read_only ?? false,
42
43
  };
43
44
  }
@@ -0,0 +1,23 @@
1
+ interface TranslationRequestString {
2
+ text: string;
3
+ context?: string;
4
+ }
5
+ interface TranslationRequestPlural {
6
+ one: string;
7
+ other: string;
8
+ context?: string | null;
9
+ }
10
+ export interface TranslationRequest {
11
+ language: string;
12
+ strings: TranslationRequestString[];
13
+ plurals: TranslationRequestPlural[];
14
+ }
15
+ export interface TranslationResponse {
16
+ translations: Record<string, string>;
17
+ plurals: Record<string, Record<string, string>>;
18
+ }
19
+ /** @internal Reset the singleton store — for testing only. */
20
+ export declare function _resetHandlerStore(): void;
21
+ export declare function handleTranslationRequest(body: TranslationRequest, configPath?: string): Promise<TranslationResponse>;
22
+ export declare function createTransDuckHandler(configPath?: string): (request: Request) => Promise<Response>;
23
+ export {};
@@ -0,0 +1,153 @@
1
+ import { createHash } from 'crypto';
2
+ import { loadConfig } from './config.js';
3
+ import { TranslationStore } from './storage.js';
4
+ import { translate as backendTranslate, translatePlural as backendTranslatePlural } from './backend.js';
5
+ import { validateTranslation } from './validation.js';
6
+ function hash(text) {
7
+ return createHash('sha256').update(text).digest('hex');
8
+ }
9
+ let _store = null;
10
+ async function getStore(configPath) {
11
+ if (!_store) {
12
+ const cfg = loadConfig(configPath);
13
+ _store = new TranslationStore(cfg.storagePath);
14
+ await _store.initialize();
15
+ }
16
+ return _store;
17
+ }
18
+ /** @internal Reset the singleton store — for testing only. */
19
+ export function _resetHandlerStore() {
20
+ if (_store) {
21
+ _store.close();
22
+ }
23
+ _store = null;
24
+ }
25
+ export async function handleTranslationRequest(body, configPath) {
26
+ const cfg = loadConfig(configPath);
27
+ const store = await getStore(configPath);
28
+ const targetLang = body.language.toUpperCase();
29
+ const projectContextHash = hash(cfg.projectContext);
30
+ const apiKey = process.env[cfg.apiKeyEnv];
31
+ const translations = {};
32
+ const plurals = {};
33
+ // Translate regular strings
34
+ for (const item of body.strings) {
35
+ const stringContextHash = hash(item.context ?? '');
36
+ const key = `${item.text}||${item.context ?? ''}`;
37
+ // Cache lookup
38
+ const cached = await store.lookup({
39
+ sourceText: item.text,
40
+ sourceLang: cfg.sourceLang,
41
+ targetLang,
42
+ projectContextHash,
43
+ stringContextHash,
44
+ });
45
+ if (cached !== null) {
46
+ translations[key] = cached;
47
+ continue;
48
+ }
49
+ // Backend call
50
+ try {
51
+ const translated = await backendTranslate({
52
+ sourceText: item.text,
53
+ sourceLang: cfg.sourceLang,
54
+ targetLang,
55
+ projectContext: cfg.projectContext,
56
+ stringContext: item.context ?? null,
57
+ apiKey: apiKey,
58
+ model: cfg.backendModel,
59
+ timeout: cfg.backendTimeout,
60
+ maxRetries: cfg.backendMaxRetries,
61
+ });
62
+ if (validateTranslation(item.text, translated)) {
63
+ await store.insert({
64
+ sourceText: item.text,
65
+ sourceLang: cfg.sourceLang,
66
+ targetLang,
67
+ projectContextHash,
68
+ stringContextHash,
69
+ translatedText: translated,
70
+ model: cfg.backendModel,
71
+ status: 'translated',
72
+ });
73
+ translations[key] = translated;
74
+ }
75
+ else {
76
+ translations[key] = item.text;
77
+ }
78
+ }
79
+ catch {
80
+ translations[key] = item.text;
81
+ }
82
+ }
83
+ // Translate plurals
84
+ for (const item of body.plurals) {
85
+ const stringContextHash = hash(item.context ?? '');
86
+ const sourceKey = item.one + '\x00' + item.other;
87
+ const responseKey = `${sourceKey}||${item.context ?? ''}`;
88
+ // Cache lookup
89
+ const cachedForms = await store.lookupPlural({
90
+ sourceText: sourceKey,
91
+ sourceLang: cfg.sourceLang,
92
+ targetLang,
93
+ projectContextHash,
94
+ stringContextHash,
95
+ });
96
+ if (Object.keys(cachedForms).length > 0) {
97
+ plurals[responseKey] = cachedForms;
98
+ continue;
99
+ }
100
+ // Backend call
101
+ try {
102
+ const forms = await backendTranslatePlural({
103
+ one: item.one,
104
+ other: item.other,
105
+ sourceLang: cfg.sourceLang,
106
+ targetLang,
107
+ projectContext: cfg.projectContext,
108
+ stringContext: item.context ?? null,
109
+ apiKey: apiKey,
110
+ model: cfg.backendModel,
111
+ timeout: cfg.backendTimeout,
112
+ maxRetries: cfg.backendMaxRetries,
113
+ });
114
+ for (const [cat, translatedText] of Object.entries(forms)) {
115
+ await store.insertPlural({
116
+ sourceText: sourceKey,
117
+ sourceLang: cfg.sourceLang,
118
+ targetLang,
119
+ projectContextHash,
120
+ stringContextHash,
121
+ pluralCategory: cat,
122
+ translatedText: translatedText,
123
+ model: cfg.backendModel,
124
+ status: 'translated',
125
+ });
126
+ }
127
+ plurals[responseKey] = forms;
128
+ }
129
+ catch {
130
+ plurals[responseKey] = { one: item.one, other: item.other };
131
+ }
132
+ }
133
+ return { translations, plurals };
134
+ }
135
+ export function createTransDuckHandler(configPath) {
136
+ return async function POST(request) {
137
+ try {
138
+ const body = await request.json();
139
+ const result = await handleTranslationRequest(body, configPath);
140
+ return new Response(JSON.stringify(result), {
141
+ status: 200,
142
+ headers: { 'Content-Type': 'application/json' },
143
+ });
144
+ }
145
+ catch (err) {
146
+ console.error('[transduck] Handler error:', err);
147
+ return new Response(JSON.stringify({ error: 'Translation failed' }), {
148
+ status: 500,
149
+ headers: { 'Content-Type': 'application/json' },
150
+ });
151
+ }
152
+ };
153
+ }
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ export declare function setLanguage(lang: string): void;
5
5
  export declare function _resetState(): void;
6
6
  export declare function _getStore(): TranslationStore | null;
7
7
  export declare function ait(sourceText: string, context?: string, vars?: Record<string, string | number>): Promise<string>;
8
+ export { createTransDuckHandler } from './handler.js';
8
9
  export declare function aitPlural(one: string, other: string, count: number, opts?: {
9
10
  context?: string;
10
11
  vars?: Record<string, string | number>;
package/dist/index.js CHANGED
@@ -49,6 +49,10 @@ export async function ait(sourceText, context, vars) {
49
49
  });
50
50
  if (cached !== null)
51
51
  return interpolateVars(cached, vars);
52
+ // Read-only mode: skip backend, return source text
53
+ if (cfg.readOnly) {
54
+ return interpolateVars(sourceText, vars);
55
+ }
52
56
  // In-process dedup
53
57
  const lockKey = `${sourceText}|${cfg.sourceLang}|${state.targetLang}|${projectContextHash}|${stringContextHash}`;
54
58
  if (state.pendingTranslations.has(lockKey)) {
@@ -99,6 +103,7 @@ export async function ait(sourceText, context, vars) {
99
103
  const result = await translationPromise;
100
104
  return interpolateVars(result, vars);
101
105
  }
106
+ export { createTransDuckHandler } from './handler.js';
102
107
  export async function aitPlural(one, other, count, opts) {
103
108
  if (!state.config || !state.store) {
104
109
  throw new Error('transduck not initialized. Call initialize() first.');
@@ -141,6 +146,12 @@ export async function aitPlural(one, other, count, opts) {
141
146
  if (category in cached) {
142
147
  return interpolateVars(cached[category], vars);
143
148
  }
149
+ // Read-only mode: skip backend, fall back to source forms
150
+ if (cfg.readOnly) {
151
+ const fallbackCategory = getPluralCategory(cfg.sourceLang, count);
152
+ const fallback = fallbackCategory === 'one' ? one : other;
153
+ return interpolateVars(fallback, vars);
154
+ }
144
155
  // Cache miss — call backend
145
156
  const apiKey = process.env[cfg.apiKeyEnv];
146
157
  try {
@@ -0,0 +1 @@
1
+ export { TransDuckProvider, t, ait, tPlural, aitPlural, useTransDuck, _resetReactState } from './provider.js';
@@ -0,0 +1 @@
1
+ export { TransDuckProvider, t, ait, tPlural, aitPlural, useTransDuck, _resetReactState } from './provider.js';
@@ -0,0 +1,23 @@
1
+ import { type ReactNode } from 'react';
2
+ /**
3
+ * Hook that subscribes to translation updates. Call this in any component
4
+ * that uses t()/ait() to ensure it re-renders when translations are loaded.
5
+ */
6
+ export declare function useTransDuck(): void;
7
+ export declare function _resetReactState(): void;
8
+ export declare function t(sourceText: string, context?: string, vars?: Record<string, string | number>): string;
9
+ export declare function tPlural(one: string, other: string, count: number, opts?: {
10
+ context?: string;
11
+ vars?: Record<string, string | number>;
12
+ }): string;
13
+ export declare const ait: typeof t;
14
+ export declare const aitPlural: typeof tPlural;
15
+ interface TransDuckProviderProps {
16
+ language: string;
17
+ sourceLang?: string;
18
+ endpoint?: string;
19
+ projectName?: string;
20
+ children: ReactNode;
21
+ }
22
+ export declare function TransDuckProvider({ language, sourceLang, endpoint, projectName, children, }: TransDuckProviderProps): import("react/jsx-runtime").JSX.Element;
23
+ export {};
@@ -0,0 +1,218 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { createContext, useContext, useEffect, useState, useCallback } from 'react';
4
+ let _state = {
5
+ language: '',
6
+ sourceLang: 'EN',
7
+ endpoint: '/api/translations',
8
+ cache: new Map(),
9
+ pluralCache: new Map(),
10
+ pendingStrings: new Set(),
11
+ pendingPlurals: new Set(),
12
+ triggerFetch: null,
13
+ };
14
+ // --- Context for triggering child re-renders ---
15
+ // The context value (a version counter) changes when translations are loaded.
16
+ // Components that call useTransDuck() subscribe to this and re-render automatically.
17
+ const TransDuckContext = createContext(0);
18
+ /**
19
+ * Hook that subscribes to translation updates. Call this in any component
20
+ * that uses t()/ait() to ensure it re-renders when translations are loaded.
21
+ */
22
+ export function useTransDuck() {
23
+ useContext(TransDuckContext);
24
+ }
25
+ export function _resetReactState() {
26
+ _state = {
27
+ language: '',
28
+ sourceLang: 'EN',
29
+ endpoint: '/api/translations',
30
+ cache: new Map(),
31
+ pluralCache: new Map(),
32
+ pendingStrings: new Set(),
33
+ pendingPlurals: new Set(),
34
+ triggerFetch: null,
35
+ };
36
+ }
37
+ function interpolateVars(text, vars) {
38
+ if (!vars)
39
+ return text;
40
+ let result = text;
41
+ for (const [key, value] of Object.entries(vars)) {
42
+ result = result.replaceAll(`{${key}}`, String(value));
43
+ }
44
+ return result;
45
+ }
46
+ // --- Stable exported functions ---
47
+ export function t(sourceText, context, vars) {
48
+ // Same language — return source
49
+ if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
50
+ return interpolateVars(sourceText, vars);
51
+ }
52
+ const key = `${sourceText}||${context ?? ''}`;
53
+ // Cache hit
54
+ const cached = _state.cache.get(key);
55
+ if (cached !== undefined) {
56
+ return interpolateVars(cached, vars);
57
+ }
58
+ // Queue for fetch (useEffect in provider will flush after render)
59
+ _state.pendingStrings.add(key);
60
+ // Return source text as fallback
61
+ return interpolateVars(sourceText, vars);
62
+ }
63
+ export function tPlural(one, other, count, opts) {
64
+ const context = opts?.context;
65
+ let vars;
66
+ if (!opts?.vars) {
67
+ vars = { count };
68
+ }
69
+ else if (!('count' in opts.vars)) {
70
+ vars = { ...opts.vars, count };
71
+ }
72
+ else {
73
+ vars = { ...opts.vars };
74
+ }
75
+ // Same language
76
+ if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
77
+ const rules = new Intl.PluralRules(_state.sourceLang.toLowerCase());
78
+ const form = rules.select(count) === 'one' ? one : other;
79
+ return interpolateVars(form, vars);
80
+ }
81
+ const sourceKey = `${one}\x00${other}`;
82
+ const cacheKey = `${sourceKey}||${context ?? ''}`;
83
+ // Cache hit
84
+ const cachedForms = _state.pluralCache.get(cacheKey);
85
+ if (cachedForms) {
86
+ const rules = new Intl.PluralRules(_state.language.toLowerCase());
87
+ const category = rules.select(count);
88
+ const form = cachedForms[category] ?? cachedForms['other'] ?? other;
89
+ return interpolateVars(form, vars);
90
+ }
91
+ // Queue for fetch (useEffect in provider will flush after render)
92
+ _state.pendingPlurals.add(cacheKey);
93
+ // Fallback to source form
94
+ const rules = new Intl.PluralRules(_state.sourceLang.toLowerCase());
95
+ const form = rules.select(count) === 'one' ? one : other;
96
+ return interpolateVars(form, vars);
97
+ }
98
+ // Aliases
99
+ export const ait = t;
100
+ export const aitPlural = tPlural;
101
+ // --- LocalStorage helpers ---
102
+ function loadFromLocalStorage(projectName, language) {
103
+ try {
104
+ const storageKey = `transduck:${projectName}:${language}`;
105
+ const stored = localStorage.getItem(storageKey);
106
+ if (stored) {
107
+ const data = JSON.parse(stored);
108
+ if (data.translations) {
109
+ for (const [k, v] of Object.entries(data.translations)) {
110
+ _state.cache.set(k, v);
111
+ }
112
+ }
113
+ if (data.plurals) {
114
+ for (const [k, v] of Object.entries(data.plurals)) {
115
+ _state.pluralCache.set(k, v);
116
+ }
117
+ }
118
+ }
119
+ }
120
+ catch {
121
+ // localStorage not available or corrupt — ignore
122
+ }
123
+ }
124
+ function saveToLocalStorage(projectName, language) {
125
+ try {
126
+ const storageKey = `transduck:${projectName}:${language}`;
127
+ const data = {
128
+ translations: Object.fromEntries(_state.cache),
129
+ plurals: Object.fromEntries(_state.pluralCache),
130
+ };
131
+ localStorage.setItem(storageKey, JSON.stringify(data));
132
+ }
133
+ catch {
134
+ // localStorage not available — ignore
135
+ }
136
+ }
137
+ export function TransDuckProvider({ language, sourceLang = 'EN', endpoint = '/api/translations', projectName = 'default', children, }) {
138
+ const [version, setVersion] = useState(0);
139
+ // Update module-level state synchronously so t() works during first render
140
+ _state.endpoint = endpoint;
141
+ _state.sourceLang = sourceLang;
142
+ const upperLang = language.toUpperCase();
143
+ if (_state.language !== upperLang) {
144
+ _state.language = upperLang;
145
+ _state.cache.clear();
146
+ _state.pluralCache.clear();
147
+ loadFromLocalStorage(projectName, upperLang);
148
+ }
149
+ // Set up trigger
150
+ const doFetch = useCallback(async () => {
151
+ const stringsToFetch = new Set(_state.pendingStrings);
152
+ const pluralsToFetch = new Set(_state.pendingPlurals);
153
+ _state.pendingStrings.clear();
154
+ _state.pendingPlurals.clear();
155
+ // Filter out already-cached
156
+ for (const key of stringsToFetch) {
157
+ if (_state.cache.has(key))
158
+ stringsToFetch.delete(key);
159
+ }
160
+ for (const key of pluralsToFetch) {
161
+ if (_state.pluralCache.has(key))
162
+ pluralsToFetch.delete(key);
163
+ }
164
+ if (stringsToFetch.size === 0 && pluralsToFetch.size === 0)
165
+ return;
166
+ // Build request body
167
+ const strings = Array.from(stringsToFetch).map(key => {
168
+ const [text, context] = key.split('||');
169
+ return { text, context: context || undefined };
170
+ });
171
+ const plurals = Array.from(pluralsToFetch).map(key => {
172
+ const [sourceKey, context] = key.split('||');
173
+ const [one, other] = sourceKey.split('\x00');
174
+ return { one, other, context: context || undefined };
175
+ });
176
+ try {
177
+ const response = await fetch(endpoint, {
178
+ method: 'POST',
179
+ headers: { 'Content-Type': 'application/json' },
180
+ body: JSON.stringify({
181
+ language: _state.language,
182
+ strings,
183
+ plurals,
184
+ }),
185
+ });
186
+ if (!response.ok) {
187
+ console.warn(`[transduck] Translation fetch failed: ${response.status}`);
188
+ return;
189
+ }
190
+ const data = await response.json();
191
+ // Store translations
192
+ if (data.translations) {
193
+ for (const [key, value] of Object.entries(data.translations)) {
194
+ _state.cache.set(key, value);
195
+ }
196
+ }
197
+ if (data.plurals) {
198
+ for (const [key, value] of Object.entries(data.plurals)) {
199
+ _state.pluralCache.set(key, value);
200
+ }
201
+ }
202
+ saveToLocalStorage(projectName, _state.language);
203
+ setVersion(v => v + 1);
204
+ }
205
+ catch (err) {
206
+ console.warn('[transduck] Translation fetch error:', err);
207
+ }
208
+ }, [endpoint, projectName]);
209
+ // Register trigger
210
+ _state.triggerFetch = doFetch;
211
+ // Flush pending after render
212
+ useEffect(() => {
213
+ if (_state.pendingStrings.size > 0 || _state.pendingPlurals.size > 0) {
214
+ doFetch();
215
+ }
216
+ });
217
+ return (_jsx(TransDuckContext.Provider, { value: version, children: children }));
218
+ }
package/dist/scanner.js CHANGED
@@ -12,6 +12,11 @@ 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
+ // t("text") or t("text", "ctx") — only matched in files with transduck/react import
16
+ const T_POSITIONAL = /(?<![a-zA-Z_.$])t\s*\(\s*(['"])(.*?)\1(?:\s*,\s*(['"])(.*?)\3)?/g;
17
+ // tPlural("one", "other") — only matched in files with transduck/react import
18
+ const T_PLURAL = /(?<![a-zA-Z_.$])tPlural\s*\(\s*(['"])(.*?)\1\s*,\s*(['"])(.*?)\3/g;
19
+ const HAS_TRANSDUCK_REACT = /from\s+['"]transduck\/react['"]/;
15
20
  // File extensions that use JS-style positional context
16
21
  const JS_EXTENSIONS = new Set(['.js', '.ts', '.tsx', '.jsx']);
17
22
  // File extensions that may contain Django template tags
@@ -83,6 +88,27 @@ export function extractStrings(content, filename) {
83
88
  const lineNum = content.slice(0, pos).split('\n').length;
84
89
  results.push({ text, context, line: lineNum });
85
90
  }
91
+ // 4. t() and tPlural() — only in files that import from transduck/react
92
+ if (HAS_TRANSDUCK_REACT.test(content)) {
93
+ // t() calls
94
+ for (const m of content.matchAll(new RegExp(T_POSITIONAL.source, 'g'))) {
95
+ const pos = m.index;
96
+ // Skip if overlaps with plural spans
97
+ if (pluralSpans.some(([start, end]) => pos >= start && pos < end))
98
+ continue;
99
+ const text = m[2];
100
+ const context = m[4] || null;
101
+ const lineNum = content.slice(0, pos).split('\n').length;
102
+ results.push({ text, context, line: lineNum });
103
+ }
104
+ // tPlural() calls
105
+ for (const m of content.matchAll(new RegExp(T_PLURAL.source, 'g'))) {
106
+ const one = m[2];
107
+ const other = m[4];
108
+ const lineNum = content.slice(0, m.index).split('\n').length;
109
+ results.push({ plural: true, one, other, context: null, line: lineNum });
110
+ }
111
+ }
86
112
  return results;
87
113
  }
88
114
  /**