transduck 0.0.4 → 0.1.1
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.d.ts +8 -0
- package/dist/cli.js +167 -2
- package/dist/config.d.ts +1 -0
- package/dist/config.js +1 -0
- package/dist/handler.d.ts +23 -0
- package/dist/handler.js +153 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +11 -0
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.js +1 -0
- package/dist/react/provider.d.ts +23 -0
- package/dist/react/provider.js +218 -0
- package/dist/scanner.d.ts +21 -0
- package/dist/scanner.js +180 -0
- package/package.json +25 -1
- package/src/cli.ts +195 -2
- package/src/config.ts +2 -0
- package/src/handler.ts +193 -0
- package/src/index.ts +14 -0
- package/src/react/index.ts +1 -0
- package/src/react/provider.tsx +287 -0
- package/src/scanner.ts +215 -0
- package/tests/cli.test.ts +63 -2
- package/tests/handler.test.ts +136 -0
- package/tests/react-provider.test.tsx +162 -0
- package/tests/scanner.test.ts +191 -0
- package/tsconfig.json +2 -1
- package/vitest.config.ts +9 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source code scanner for TransDuck ait() and ait_plural()/aitPlural() calls.
|
|
3
|
+
*/
|
|
4
|
+
export interface ScanEntry {
|
|
5
|
+
text?: string;
|
|
6
|
+
context?: string | null;
|
|
7
|
+
plural?: true;
|
|
8
|
+
one?: string;
|
|
9
|
+
other?: string;
|
|
10
|
+
line?: number;
|
|
11
|
+
files?: string[];
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Extract translatable strings from file content.
|
|
15
|
+
*/
|
|
16
|
+
export declare function extractStrings(content: string, filename: string): ScanEntry[];
|
|
17
|
+
/**
|
|
18
|
+
* Walk directories and extract all translatable strings.
|
|
19
|
+
* Returns deduplicated list of entries with 'files' field listing all locations.
|
|
20
|
+
*/
|
|
21
|
+
export declare function scanDirectory(dirs: string[]): ScanEntry[];
|
package/dist/scanner.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source code scanner for TransDuck ait() and ait_plural()/aitPlural() calls.
|
|
3
|
+
*/
|
|
4
|
+
import { readdirSync, readFileSync, statSync } from 'fs';
|
|
5
|
+
import { join, extname } from 'path';
|
|
6
|
+
// --- Regex patterns ---
|
|
7
|
+
// ait("text") or ait("text", context="ctx") — Python/template keyword style
|
|
8
|
+
const AIT_KEYWORD_CTX = /ait\s*\(\s*(['"])(.*?)\1(?:\s*,\s*context\s*=\s*(['"])(.*?)\3)?/g;
|
|
9
|
+
// ait("text") or ait("text", "ctx") — JS positional style
|
|
10
|
+
const AIT_POSITIONAL_CTX = /ait\s*\(\s*(['"])(.*?)\1(?:\s*,\s*(['"])(.*?)\3)?/g;
|
|
11
|
+
// ait_plural("one", "other") or aitPlural("one", "other")
|
|
12
|
+
const AIT_PLURAL = /ait(?:_p|P)lural\s*\(\s*(['"])(.*?)\1\s*,\s*(['"])(.*?)\3/g;
|
|
13
|
+
// {% ait "text" %} or {% ait "text" context="ctx" %}
|
|
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['"]/;
|
|
20
|
+
// File extensions that use JS-style positional context
|
|
21
|
+
const JS_EXTENSIONS = new Set(['.js', '.ts', '.tsx', '.jsx']);
|
|
22
|
+
// File extensions that may contain Django template tags
|
|
23
|
+
const TEMPLATE_EXTENSIONS = new Set(['.html', '.jinja', '.jinja2']);
|
|
24
|
+
// Supported file extensions for scanning
|
|
25
|
+
const SCAN_EXTENSIONS = new Set(['.py', '.js', '.ts', '.tsx', '.jsx', '.html', '.jinja', '.jinja2']);
|
|
26
|
+
// Directories to skip
|
|
27
|
+
const SKIP_DIRS = new Set(['node_modules', '.venv', 'venv', '__pycache__', '.git', 'dist', 'build', '.next']);
|
|
28
|
+
function shouldSkipDir(dirname) {
|
|
29
|
+
if (SKIP_DIRS.has(dirname))
|
|
30
|
+
return true;
|
|
31
|
+
if (dirname.includes('egg-info'))
|
|
32
|
+
return true;
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Extract translatable strings from file content.
|
|
37
|
+
*/
|
|
38
|
+
export function extractStrings(content, filename) {
|
|
39
|
+
const results = [];
|
|
40
|
+
const ext = extname(filename).toLowerCase();
|
|
41
|
+
const isJs = JS_EXTENSIONS.has(ext);
|
|
42
|
+
const isTemplate = TEMPLATE_EXTENSIONS.has(ext);
|
|
43
|
+
// Track positions of plural matches so we don't double-match them as ait()
|
|
44
|
+
const pluralSpans = [];
|
|
45
|
+
// 1. Find all plural calls
|
|
46
|
+
const pluralRegex = new RegExp(AIT_PLURAL.source, 'g');
|
|
47
|
+
let match;
|
|
48
|
+
while ((match = pluralRegex.exec(content)) !== null) {
|
|
49
|
+
const one = match[2];
|
|
50
|
+
const other = match[4];
|
|
51
|
+
const lineNum = content.slice(0, match.index).split('\n').length;
|
|
52
|
+
results.push({
|
|
53
|
+
plural: true,
|
|
54
|
+
one,
|
|
55
|
+
other,
|
|
56
|
+
context: null,
|
|
57
|
+
line: lineNum,
|
|
58
|
+
});
|
|
59
|
+
pluralSpans.push([match.index, match.index + match[0].length]);
|
|
60
|
+
}
|
|
61
|
+
// 2. Find Django template tags (only in template files)
|
|
62
|
+
if (isTemplate) {
|
|
63
|
+
const djangoRegex = new RegExp(DJANGO_TAG.source, 'g');
|
|
64
|
+
while ((match = djangoRegex.exec(content)) !== null) {
|
|
65
|
+
const text = match[2];
|
|
66
|
+
const context = match[4] || null;
|
|
67
|
+
const lineNum = content.slice(0, match.index).split('\n').length;
|
|
68
|
+
results.push({ text, context, line: lineNum });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// 3. Find ait() calls
|
|
72
|
+
const pattern = isJs ? AIT_POSITIONAL_CTX : AIT_KEYWORD_CTX;
|
|
73
|
+
const aitRegex = new RegExp(pattern.source, 'g');
|
|
74
|
+
while ((match = aitRegex.exec(content)) !== null) {
|
|
75
|
+
// Skip if this is part of a plural match
|
|
76
|
+
const pos = match.index;
|
|
77
|
+
if (pluralSpans.some(([start, end]) => pos >= start && pos < end)) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
// Check that this is specifically "ait(" not "ait_plural(" or "aitPlural("
|
|
81
|
+
const prefixStart = Math.max(0, pos - 10);
|
|
82
|
+
const prefix = content.slice(prefixStart, pos + 4);
|
|
83
|
+
if (prefix.toLowerCase().includes('plural') || prefix.includes('Plural')) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const text = match[2];
|
|
87
|
+
const context = match[4] || null;
|
|
88
|
+
const lineNum = content.slice(0, pos).split('\n').length;
|
|
89
|
+
results.push({ text, context, line: lineNum });
|
|
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
|
+
}
|
|
112
|
+
return results;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Walk directories and extract all translatable strings.
|
|
116
|
+
* Returns deduplicated list of entries with 'files' field listing all locations.
|
|
117
|
+
*/
|
|
118
|
+
export function scanDirectory(dirs) {
|
|
119
|
+
const rawMatches = new Map();
|
|
120
|
+
for (const scanDir of dirs) {
|
|
121
|
+
walkDir(scanDir, rawMatches);
|
|
122
|
+
}
|
|
123
|
+
return Array.from(rawMatches.values());
|
|
124
|
+
}
|
|
125
|
+
function walkDir(dir, rawMatches) {
|
|
126
|
+
let entries;
|
|
127
|
+
try {
|
|
128
|
+
entries = readdirSync(dir);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
for (const name of entries) {
|
|
134
|
+
const fullPath = join(dir, name);
|
|
135
|
+
let stat;
|
|
136
|
+
try {
|
|
137
|
+
stat = statSync(fullPath);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (stat.isDirectory()) {
|
|
143
|
+
if (!shouldSkipDir(name)) {
|
|
144
|
+
walkDir(fullPath, rawMatches);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
else if (stat.isFile()) {
|
|
148
|
+
const ext = extname(name).toLowerCase();
|
|
149
|
+
if (!SCAN_EXTENSIONS.has(ext))
|
|
150
|
+
continue;
|
|
151
|
+
let content;
|
|
152
|
+
try {
|
|
153
|
+
content = readFileSync(fullPath, 'utf-8');
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const entries = extractStrings(content, name);
|
|
159
|
+
for (const entry of entries) {
|
|
160
|
+
// Build dedup key
|
|
161
|
+
let key;
|
|
162
|
+
if (entry.plural) {
|
|
163
|
+
key = `plural:${entry.one}\x00${entry.other}\x00${entry.context ?? ''}`;
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
key = `text:${entry.text}\x00${entry.context ?? ''}`;
|
|
167
|
+
}
|
|
168
|
+
const fileLoc = `${fullPath}:${entry.line}`;
|
|
169
|
+
if (rawMatches.has(key)) {
|
|
170
|
+
rawMatches.get(key).files.push(fileLoc);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
const resultEntry = { ...entry, files: [fileLoc] };
|
|
174
|
+
delete resultEntry.line;
|
|
175
|
+
rawMatches.set(key, resultEntry);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "transduck",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "AI-native translation tool using source text as keys",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./react": {
|
|
14
|
+
"types": "./dist/react/index.d.ts",
|
|
15
|
+
"default": "./dist/react/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
8
18
|
"bin": {
|
|
9
19
|
"transduck": "./dist/cli.js"
|
|
10
20
|
},
|
|
@@ -21,7 +31,21 @@
|
|
|
21
31
|
},
|
|
22
32
|
"devDependencies": {
|
|
23
33
|
"@types/node": "^22.0.0",
|
|
34
|
+
"@types/react": "^19.0.0",
|
|
35
|
+
"@types/react-dom": "^19.0.0",
|
|
36
|
+
"@testing-library/react": "^16.0.0",
|
|
37
|
+
"jsdom": "^25.0.0",
|
|
38
|
+
"react": "^19.0.0",
|
|
39
|
+
"react-dom": "^19.0.0",
|
|
24
40
|
"typescript": "^5.0.0",
|
|
25
41
|
"vitest": "^2.0.0"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"react": ">=18.0.0",
|
|
45
|
+
"react-dom": ">=18.0.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"react": { "optional": true },
|
|
49
|
+
"react-dom": { "optional": true }
|
|
26
50
|
}
|
|
27
51
|
}
|
package/src/cli.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { createHash } from 'crypto';
|
|
4
|
-
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
5
|
-
import { resolve, join } from 'path';
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
5
|
+
import { resolve, join, dirname } from 'path';
|
|
6
6
|
import { Command } from 'commander';
|
|
7
7
|
import { stringify as yamlStringify } from 'yaml';
|
|
8
8
|
import { loadConfig } from './config.js';
|
|
@@ -10,6 +10,7 @@ import { TranslationStore } from './storage.js';
|
|
|
10
10
|
import { translate as backendTranslate, translatePlural as backendTranslatePlural } from './backend.js';
|
|
11
11
|
import { validateTranslation, extractPlaceholders } from './validation.js';
|
|
12
12
|
import { getPluralCategory, interpolateVars } from './plural.js';
|
|
13
|
+
import { scanDirectory, type ScanEntry } from './scanner.js';
|
|
13
14
|
|
|
14
15
|
function hash(text: string): string {
|
|
15
16
|
return createHash('sha256').update(text).digest('hex');
|
|
@@ -336,6 +337,161 @@ export async function runWarm(opts: WarmOptions): Promise<string> {
|
|
|
336
337
|
return `Translated: ${translated} | Skipped: ${skipped} | Failed: ${failed}`;
|
|
337
338
|
}
|
|
338
339
|
|
|
340
|
+
export interface ScanOptions {
|
|
341
|
+
dirs: string[];
|
|
342
|
+
warm?: boolean;
|
|
343
|
+
langs?: string[];
|
|
344
|
+
outputPath?: string;
|
|
345
|
+
configPath?: string;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export async function runScan(opts: ScanOptions): Promise<string> {
|
|
349
|
+
const cfg = loadConfig(opts.configPath);
|
|
350
|
+
const scanDirs = opts.dirs.length > 0 ? opts.dirs : [process.cwd()];
|
|
351
|
+
const entries = scanDirectory(scanDirs);
|
|
352
|
+
|
|
353
|
+
const regular = entries.filter(e => !e.plural);
|
|
354
|
+
const plurals = entries.filter(e => e.plural);
|
|
355
|
+
|
|
356
|
+
// Count scanned files
|
|
357
|
+
const allFiles = new Set<string>();
|
|
358
|
+
for (const e of entries) {
|
|
359
|
+
for (const f of (e.files ?? [])) {
|
|
360
|
+
allFiles.add(f);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const lines: string[] = [];
|
|
365
|
+
lines.push(`Scanned files with matches: ${allFiles.size}`);
|
|
366
|
+
lines.push(`Found ${entries.length} strings (${regular.length} regular, ${plurals.length} plural)`);
|
|
367
|
+
lines.push('');
|
|
368
|
+
|
|
369
|
+
for (const e of entries) {
|
|
370
|
+
const locations = (e.files ?? []).join(', ');
|
|
371
|
+
if (e.plural) {
|
|
372
|
+
lines.push(` ait_plural("${e.one}", "${e.other}") ${locations}`);
|
|
373
|
+
} else {
|
|
374
|
+
const ctx = e.context ? `, context="${e.context}"` : '';
|
|
375
|
+
lines.push(` ait("${e.text}"${ctx}) ${locations}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Output to JSON
|
|
380
|
+
if (opts.outputPath) {
|
|
381
|
+
const outputEntries = entries.map(e => {
|
|
382
|
+
const out: Record<string, unknown> = {};
|
|
383
|
+
for (const [k, v] of Object.entries(e)) {
|
|
384
|
+
if (k !== 'files') out[k] = v;
|
|
385
|
+
}
|
|
386
|
+
return out;
|
|
387
|
+
});
|
|
388
|
+
writeFileSync(opts.outputPath, JSON.stringify(outputEntries, null, 2));
|
|
389
|
+
lines.push(`\nWrote ${entries.length} entries to ${opts.outputPath}`);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Warm
|
|
393
|
+
if (opts.warm) {
|
|
394
|
+
const targetLangs = opts.langs && opts.langs.length > 0
|
|
395
|
+
? opts.langs.map(l => l.toUpperCase())
|
|
396
|
+
: cfg.targetLangs;
|
|
397
|
+
|
|
398
|
+
const store = new TranslationStore(cfg.storagePath);
|
|
399
|
+
await store.initialize();
|
|
400
|
+
const apiKey = process.env[cfg.apiKeyEnv];
|
|
401
|
+
const projectContextHash = hash(cfg.projectContext);
|
|
402
|
+
|
|
403
|
+
let translated = 0;
|
|
404
|
+
let skipped = 0;
|
|
405
|
+
let failed = 0;
|
|
406
|
+
|
|
407
|
+
for (const entry of entries) {
|
|
408
|
+
if (entry.plural) {
|
|
409
|
+
const sourceKey = entry.one + '\x00' + entry.other;
|
|
410
|
+
const stringContextHash = hash(entry.context ?? '');
|
|
411
|
+
|
|
412
|
+
for (const lang of targetLangs) {
|
|
413
|
+
const cachedForms = await store.lookupPlural({
|
|
414
|
+
sourceText: sourceKey, sourceLang: cfg.sourceLang, targetLang: lang,
|
|
415
|
+
projectContextHash, stringContextHash,
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
if (Object.keys(cachedForms).length > 0) {
|
|
419
|
+
skipped++;
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
try {
|
|
424
|
+
const forms = await backendTranslatePlural({
|
|
425
|
+
one: entry.one!, other: entry.other!,
|
|
426
|
+
sourceLang: cfg.sourceLang, targetLang: lang,
|
|
427
|
+
projectContext: cfg.projectContext, stringContext: entry.context ?? null,
|
|
428
|
+
apiKey: apiKey!, model: cfg.backendModel,
|
|
429
|
+
timeout: cfg.backendTimeout, maxRetries: cfg.backendMaxRetries,
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
for (const [cat, translatedText] of Object.entries(forms)) {
|
|
433
|
+
await store.insertPlural({
|
|
434
|
+
sourceText: sourceKey, sourceLang: cfg.sourceLang, targetLang: lang,
|
|
435
|
+
projectContextHash, stringContextHash,
|
|
436
|
+
pluralCategory: cat, translatedText: translatedText as string,
|
|
437
|
+
model: cfg.backendModel, status: 'translated',
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
translated++;
|
|
441
|
+
} catch {
|
|
442
|
+
failed++;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
} else {
|
|
446
|
+
const stringContextHash = hash(entry.context ?? '');
|
|
447
|
+
|
|
448
|
+
for (const lang of targetLangs) {
|
|
449
|
+
const cached = await store.lookup({
|
|
450
|
+
sourceText: entry.text!, sourceLang: cfg.sourceLang, targetLang: lang,
|
|
451
|
+
projectContextHash, stringContextHash,
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
if (cached !== null) {
|
|
455
|
+
skipped++;
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
try {
|
|
460
|
+
const result = await backendTranslate({
|
|
461
|
+
sourceText: entry.text!, sourceLang: cfg.sourceLang, targetLang: lang,
|
|
462
|
+
projectContext: cfg.projectContext, stringContext: entry.context ?? null,
|
|
463
|
+
apiKey: apiKey!, model: cfg.backendModel,
|
|
464
|
+
timeout: cfg.backendTimeout, maxRetries: cfg.backendMaxRetries,
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
if (validateTranslation(entry.text!, result)) {
|
|
468
|
+
await store.insert({
|
|
469
|
+
sourceText: entry.text!, sourceLang: cfg.sourceLang, targetLang: lang,
|
|
470
|
+
projectContextHash, stringContextHash,
|
|
471
|
+
translatedText: result, model: cfg.backendModel, status: 'translated',
|
|
472
|
+
});
|
|
473
|
+
translated++;
|
|
474
|
+
} else {
|
|
475
|
+
failed++;
|
|
476
|
+
}
|
|
477
|
+
} catch {
|
|
478
|
+
failed++;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
store.close();
|
|
485
|
+
lines.push(`\nTranslated: ${translated} | Skipped: ${skipped} | Failed: ${failed}`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (!opts.warm && !opts.outputPath) {
|
|
489
|
+
lines.push(`\nRun 'transduck scan --warm --langs DE,ES' to translate all strings.`);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return lines.join('\n');
|
|
493
|
+
}
|
|
494
|
+
|
|
339
495
|
export interface StatsOptions {
|
|
340
496
|
configPath?: string;
|
|
341
497
|
}
|
|
@@ -384,6 +540,25 @@ program.command('init')
|
|
|
384
540
|
targetLangs: targetsRaw.split(',').map(s => s.trim()),
|
|
385
541
|
});
|
|
386
542
|
console.log(output);
|
|
543
|
+
|
|
544
|
+
const rl2 = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
545
|
+
const ask2 = (q: string): Promise<string> => new Promise(r => rl2.question(q, r));
|
|
546
|
+
|
|
547
|
+
const useReact = await ask2('Are you using React/Next.js? (y/n): ');
|
|
548
|
+
if (useReact.toLowerCase() === 'y') {
|
|
549
|
+
const routePath = await ask2('API route path (default: app/api/translations/route.ts): ');
|
|
550
|
+
const finalPath = join(dir, routePath.trim() || 'app/api/translations/route.ts');
|
|
551
|
+
|
|
552
|
+
// Create directory
|
|
553
|
+
const routeDir = dirname(finalPath);
|
|
554
|
+
mkdirSync(routeDir, { recursive: true });
|
|
555
|
+
|
|
556
|
+
// Write route file
|
|
557
|
+
writeFileSync(finalPath, `import { createTransDuckHandler } from 'transduck';\n\nexport const POST = createTransDuckHandler();\n`);
|
|
558
|
+
console.log(`Created ${finalPath}`);
|
|
559
|
+
console.log('Add TransDuckProvider to your layout — see docs: transduck/react');
|
|
560
|
+
}
|
|
561
|
+
rl2.close();
|
|
387
562
|
});
|
|
388
563
|
|
|
389
564
|
program.command('translate')
|
|
@@ -441,6 +616,24 @@ program.command('stats')
|
|
|
441
616
|
console.log(output);
|
|
442
617
|
});
|
|
443
618
|
|
|
619
|
+
program.command('scan')
|
|
620
|
+
.description('Scan source code for translatable strings')
|
|
621
|
+
.option('--dir <path...>', 'Directories to scan (repeatable)')
|
|
622
|
+
.option('--warm', 'Translate all found strings')
|
|
623
|
+
.option('--langs <langs>', 'Comma-separated target languages (for --warm)')
|
|
624
|
+
.option('--output <path>', 'Write found strings to JSON')
|
|
625
|
+
.option('--config <path>', 'Path to transduck.yaml')
|
|
626
|
+
.action(async (opts: { dir?: string[]; warm?: boolean; langs?: string; output?: string; config?: string }) => {
|
|
627
|
+
const output = await runScan({
|
|
628
|
+
dirs: opts.dir ?? [],
|
|
629
|
+
warm: opts.warm ?? false,
|
|
630
|
+
langs: opts.langs ? opts.langs.split(',').map(s => s.trim()) : undefined,
|
|
631
|
+
outputPath: opts.output,
|
|
632
|
+
configPath: opts.config,
|
|
633
|
+
});
|
|
634
|
+
console.log(output);
|
|
635
|
+
});
|
|
636
|
+
|
|
444
637
|
export { program };
|
|
445
638
|
|
|
446
639
|
// Run CLI when executed directly (not when imported by tests or other modules)
|