@brickflow/cli 0.0.6 → 0.0.8
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/CHANGELOG.md +12 -0
- package/index.mjs +31 -80
- package/package.json +13 -1
- package/src/avif/index.js +79 -0
- package/src/filename/index.js +70 -0
- package/src/graph/index.js +78 -0
- package/src/icon/index.js +162 -0
- package/src/icon-check/index.js +928 -0
- package/src/latest/index.js +105 -0
- package/src/size/index.js +64 -0
- package/src/svg/index.js +97 -0
- package/src/translate/ai-context.js +312 -0
- package/src/translate/ai.js +168 -0
- package/src/translate/gemini.js +142 -0
- package/src/translate/index.js +331 -0
- package/src/translate/models.js +12 -0
- package/src/translate/runtime-config.js +118 -0
- package/src/translate/sync.js +238 -0
- package/src/translate/utils.js +409 -0
- package/src/translate-context/index.js +3 -0
- package/src/translate-sync/index.js +25 -0
- package/src/types/index.js +127 -0
- package/src/upgrade/index.js +168 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { generateContentWithLimits } from './gemini.js'
|
|
2
|
+
import { getTranslateRuntimeConfig } from './runtime-config.js'
|
|
3
|
+
|
|
4
|
+
const MODEL = process.env.TRANSLATE_AI_MODEL || 'gemini-3.1-flash-lite-preview'
|
|
5
|
+
|
|
6
|
+
export async function translateBatch(strings, options) {
|
|
7
|
+
const { componentContext = '', sourceLocale = 'en', targetLocales = [] } = options ?? {}
|
|
8
|
+
|
|
9
|
+
if (!Array.isArray(strings) || strings.length === 0) {
|
|
10
|
+
return Object.fromEntries(targetLocales.map((locale) => [locale, {}]))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const contents = buildContents(strings, sourceLocale, targetLocales, componentContext)
|
|
14
|
+
const systemInstruction = buildSystemInstruction()
|
|
15
|
+
const maxRetries = 3
|
|
16
|
+
|
|
17
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
18
|
+
try {
|
|
19
|
+
const raw = await requestTranslation(contents, systemInstruction)
|
|
20
|
+
const parsed = safeParse(raw)
|
|
21
|
+
return normalizeTranslations(parsed, strings, targetLocales)
|
|
22
|
+
} catch (error) {
|
|
23
|
+
console.warn(`AI translate attempt ${attempt}/${maxRetries} failed`)
|
|
24
|
+
|
|
25
|
+
if (attempt === maxRetries) {
|
|
26
|
+
throw new Error('Translation failed after retries', { cause: error })
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return Object.fromEntries(targetLocales.map((locale) => [locale, {}]))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function buildContents(strings, sourceLocale, targetLocales, componentContext) {
|
|
35
|
+
const payload = strings.map((item) => ({
|
|
36
|
+
component: item.component || extractComponent(item.filePath),
|
|
37
|
+
key: item.key,
|
|
38
|
+
text: item.text,
|
|
39
|
+
type: detectType(item.text),
|
|
40
|
+
}))
|
|
41
|
+
|
|
42
|
+
return [
|
|
43
|
+
`Source locale: ${sourceLocale}`,
|
|
44
|
+
`Target locales: ${targetLocales.join(', ')}`,
|
|
45
|
+
'',
|
|
46
|
+
'Component context for translators:',
|
|
47
|
+
componentContext || 'No extra component context available.',
|
|
48
|
+
'',
|
|
49
|
+
'Return this exact schema:',
|
|
50
|
+
'{',
|
|
51
|
+
' "pl": { "some.key": "..." },',
|
|
52
|
+
' "ru": { "some.key": "..." }',
|
|
53
|
+
'}',
|
|
54
|
+
'',
|
|
55
|
+
'Input strings:',
|
|
56
|
+
JSON.stringify(payload, null, 2),
|
|
57
|
+
].join('\n')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildSystemInstruction() {
|
|
61
|
+
const { productContext, terminology, tone } = getTranslateRuntimeConfig()
|
|
62
|
+
|
|
63
|
+
return [
|
|
64
|
+
'You are a professional localization engine for a paid adult content platform.',
|
|
65
|
+
'',
|
|
66
|
+
'PRODUCT CONTEXT:',
|
|
67
|
+
productContext,
|
|
68
|
+
'',
|
|
69
|
+
'TERMINOLOGY:',
|
|
70
|
+
terminology,
|
|
71
|
+
'',
|
|
72
|
+
'TONE:',
|
|
73
|
+
tone,
|
|
74
|
+
'',
|
|
75
|
+
'RULES:',
|
|
76
|
+
'- Return only raw JSON.',
|
|
77
|
+
'- Preserve JSON shape exactly.',
|
|
78
|
+
'- Do not omit keys or locales.',
|
|
79
|
+
'- Never remove words, qualifiers, examples, slang, or awkward fragments from the source.',
|
|
80
|
+
'- Preserve the full meaning of the source even if the text is clumsy, ungrammatical, explicit, or repetitive.',
|
|
81
|
+
'- If the source contains an unusual word or phrase, translate it or preserve it, but do not silently drop it.',
|
|
82
|
+
'- Preserve placeholders like {count}, {price}, %s, :name, \\n and HTML tags.',
|
|
83
|
+
'- Do not translate product or brand names unless source text clearly localizes them.',
|
|
84
|
+
'- Keep short labels concise only when the source itself is short. Do not compress longer messages.',
|
|
85
|
+
'- Example rule: "Username can contain from %s to %s characters only with dildo" must keep the final phrase in translation and must not be shortened.',
|
|
86
|
+
].join('\n')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function cleanJson(text) {
|
|
90
|
+
return text
|
|
91
|
+
.replace(/```json/gi, '')
|
|
92
|
+
.replace(/```/g, '')
|
|
93
|
+
.trim()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function detectType(text) {
|
|
97
|
+
if (text.length <= 12) {
|
|
98
|
+
return 'short_ui'
|
|
99
|
+
}
|
|
100
|
+
if (text.includes('?')) {
|
|
101
|
+
return 'question'
|
|
102
|
+
}
|
|
103
|
+
if (text.includes('{') || text.includes('%')) {
|
|
104
|
+
return 'template'
|
|
105
|
+
}
|
|
106
|
+
return 'text'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function extractComponent(filePath) {
|
|
110
|
+
if (!filePath) {
|
|
111
|
+
return 'Unknown'
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const parts = filePath.split('/').filter(Boolean)
|
|
115
|
+
const file = parts[parts.length - 2] || parts[parts.length - 1]
|
|
116
|
+
|
|
117
|
+
return file || 'Unknown'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function extractJson(text) {
|
|
121
|
+
const match = text.match(/\{[\s\S]*\}/)
|
|
122
|
+
return match ? match[0] : text
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function normalizeTranslations(parsed, strings, targetLocales) {
|
|
126
|
+
const keys = strings.map((item) => item.key)
|
|
127
|
+
const result = {}
|
|
128
|
+
|
|
129
|
+
for (const locale of targetLocales) {
|
|
130
|
+
const localeValues = parsed?.[locale]
|
|
131
|
+
|
|
132
|
+
if (!localeValues || typeof localeValues !== 'object' || Array.isArray(localeValues)) {
|
|
133
|
+
throw new Error(`Invalid locale block: ${locale}`)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
result[locale] = {}
|
|
137
|
+
|
|
138
|
+
for (const key of keys) {
|
|
139
|
+
const value = localeValues[key]
|
|
140
|
+
|
|
141
|
+
if (typeof value !== 'string') {
|
|
142
|
+
throw new Error(`Missing translation for ${locale}.${key}`)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
result[locale][key] = value.trim()
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return result
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function requestTranslation(contents, systemInstruction) {
|
|
153
|
+
const { apiKey } = getTranslateRuntimeConfig()
|
|
154
|
+
|
|
155
|
+
return await generateContentWithLimits({
|
|
156
|
+
apiKey,
|
|
157
|
+
config: {
|
|
158
|
+
systemInstruction,
|
|
159
|
+
},
|
|
160
|
+
contents,
|
|
161
|
+
model: MODEL,
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function safeParse(text) {
|
|
166
|
+
const cleaned = extractJson(cleanJson(text))
|
|
167
|
+
return JSON.parse(cleaned)
|
|
168
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { GoogleGenAI } from '@google/genai'
|
|
2
|
+
|
|
3
|
+
import { getModelRpm } from './models.js'
|
|
4
|
+
|
|
5
|
+
const clientsByApiKey = new Map()
|
|
6
|
+
const modelState = new Map()
|
|
7
|
+
|
|
8
|
+
export async function generateContentWithLimits({ apiKey, config, contents, maxRetries = 5, model }) {
|
|
9
|
+
const client = getClient(apiKey)
|
|
10
|
+
|
|
11
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
12
|
+
await waitForModelSlot(model)
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const response = await client.models.generateContent({
|
|
16
|
+
config,
|
|
17
|
+
contents,
|
|
18
|
+
model,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
markModelRequest(model)
|
|
22
|
+
return response.text ?? ''
|
|
23
|
+
} catch (error) {
|
|
24
|
+
markModelRequest(model)
|
|
25
|
+
|
|
26
|
+
if (!isRateLimitError(error)) {
|
|
27
|
+
throw error
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const delayMs = getRetryDelayMs(error, model)
|
|
31
|
+
console.warn(
|
|
32
|
+
`Rate limited for ${model}, retrying in ${Math.ceil(delayMs / 1000)}s (${attempt}/${maxRetries})`,
|
|
33
|
+
)
|
|
34
|
+
await sleep(delayMs)
|
|
35
|
+
|
|
36
|
+
if (attempt === maxRetries) {
|
|
37
|
+
throw error
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return ''
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getClient(apiKey) {
|
|
46
|
+
if (!apiKey) {
|
|
47
|
+
throw new Error('Gemini API key is required')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const cached = clientsByApiKey.get(apiKey)
|
|
51
|
+
|
|
52
|
+
if (cached) {
|
|
53
|
+
return cached
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const client = new GoogleGenAI({ apiKey })
|
|
57
|
+
clientsByApiKey.set(apiKey, client)
|
|
58
|
+
return client
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getModelState(model) {
|
|
62
|
+
const current = modelState.get(model) ?? {
|
|
63
|
+
nextAllowedAt: 0,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
modelState.set(model, current)
|
|
67
|
+
return current
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getRetryDelayMs(error, model) {
|
|
71
|
+
const retryFromDetails = parseRetryDelayFromDetails(error)
|
|
72
|
+
|
|
73
|
+
if (retryFromDetails !== null) {
|
|
74
|
+
return retryFromDetails
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const rpm = Math.max(getModelRpm(model), 1)
|
|
78
|
+
return Math.ceil(60000 / rpm)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isRateLimitError(error) {
|
|
82
|
+
return error?.status === 429
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function markModelRequest(model) {
|
|
86
|
+
const state = getModelState(model)
|
|
87
|
+
const rpm = Math.max(getModelRpm(model), 1)
|
|
88
|
+
const intervalMs = Math.ceil(60000 / rpm)
|
|
89
|
+
|
|
90
|
+
state.nextAllowedAt = Math.max(state.nextAllowedAt, Date.now()) + intervalMs
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parseDurationMs(value) {
|
|
94
|
+
const match = String(value).match(/^([\d.]+)s$/i)
|
|
95
|
+
|
|
96
|
+
if (!match) {
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return Math.ceil(Number.parseFloat(match[1]) * 1000)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseRetryDelayFromDetails(error) {
|
|
104
|
+
const details = error?.errorInfo?.details ?? error?.details ?? error?.message
|
|
105
|
+
|
|
106
|
+
if (Array.isArray(details)) {
|
|
107
|
+
for (const detail of details) {
|
|
108
|
+
const retryDelay = detail?.retryDelay
|
|
109
|
+
|
|
110
|
+
if (typeof retryDelay === 'string') {
|
|
111
|
+
const parsed = parseDurationMs(retryDelay)
|
|
112
|
+
|
|
113
|
+
if (parsed !== null) {
|
|
114
|
+
return parsed
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (typeof details === 'string') {
|
|
121
|
+
const match = details.match(/retry in ([\d.]+)s/i) || details.match(/"retryDelay":"(\d+)s"/i)
|
|
122
|
+
|
|
123
|
+
if (match) {
|
|
124
|
+
return Math.ceil(Number.parseFloat(match[1]) * 1000)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function sleep(ms) {
|
|
132
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function waitForModelSlot(model) {
|
|
136
|
+
const state = getModelState(model)
|
|
137
|
+
const delayMs = state.nextAllowedAt - Date.now()
|
|
138
|
+
|
|
139
|
+
if (delayMs > 0) {
|
|
140
|
+
await sleep(delayMs)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import { globSync } from 'glob'
|
|
3
|
+
import { dirname, join, relative, resolve } from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
|
|
6
|
+
import { getAiContextState } from './ai-context.js'
|
|
7
|
+
import { translateBatch } from './ai.js'
|
|
8
|
+
import { buildTranslateHelp, parseTranslateRuntimeArgs, setTranslateRuntimeConfig } from './runtime-config.js'
|
|
9
|
+
import { listTranslationTargets, sortObjectKeys, stringifySortedJson } from './utils.js'
|
|
10
|
+
|
|
11
|
+
const currentDir = dirname(fileURLToPath(import.meta.url))
|
|
12
|
+
const workspaceRoot = resolve(currentDir, '../../../..')
|
|
13
|
+
const rawArgs = process.argv.slice(3)
|
|
14
|
+
|
|
15
|
+
if (rawArgs.includes('--help') || rawArgs.includes('-h')) {
|
|
16
|
+
console.log(buildTranslateHelp())
|
|
17
|
+
process.exit(0)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { options } = parseTranslateRuntimeArgs(rawArgs)
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
setTranslateRuntimeConfig(options)
|
|
24
|
+
} catch (error) {
|
|
25
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
26
|
+
console.error('')
|
|
27
|
+
console.error(buildTranslateHelp())
|
|
28
|
+
process.exit(1)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const DEFAULT_LANGUAGE_CODES = [
|
|
32
|
+
'bn',
|
|
33
|
+
'cz',
|
|
34
|
+
'dk',
|
|
35
|
+
'de',
|
|
36
|
+
'en',
|
|
37
|
+
'es',
|
|
38
|
+
'fi',
|
|
39
|
+
'fr',
|
|
40
|
+
'hi',
|
|
41
|
+
'hu',
|
|
42
|
+
'it',
|
|
43
|
+
'ja',
|
|
44
|
+
'nl',
|
|
45
|
+
'no',
|
|
46
|
+
'pl',
|
|
47
|
+
'pt',
|
|
48
|
+
'ru',
|
|
49
|
+
'si',
|
|
50
|
+
'se',
|
|
51
|
+
'sk',
|
|
52
|
+
]
|
|
53
|
+
const BATCH_MAX_ITEMS = readPositiveInt('TRANSLATE_BATCH_MAX_ITEMS', 30)
|
|
54
|
+
const BATCH_MAX_CHARS = readPositiveInt('TRANSLATE_BATCH_MAX_CHARS', 3500)
|
|
55
|
+
const languagePaths = globSync(
|
|
56
|
+
'{packages/brick,apps/*}/{components/**/translate,pages-translate/*,layouts/**,global/*}/generated/*.json',
|
|
57
|
+
{
|
|
58
|
+
absolute: true,
|
|
59
|
+
cwd: workspaceRoot,
|
|
60
|
+
ignore: ['**/node_modules/**', '**/.nuxt/**', '**/dist/**', '**/.output/**', '**/coverage/**', '**/public/**'],
|
|
61
|
+
},
|
|
62
|
+
).sort()
|
|
63
|
+
|
|
64
|
+
const languageCodes = [
|
|
65
|
+
...new Set([
|
|
66
|
+
...languagePaths.map((filePath) => filePath.replace(/.*\/([^/]+)\.json$/, '$1')),
|
|
67
|
+
...DEFAULT_LANGUAGE_CODES,
|
|
68
|
+
]),
|
|
69
|
+
].sort()
|
|
70
|
+
|
|
71
|
+
const tasks = listTranslationTargets(workspaceRoot).map(({ samplePath, sourceFilePath }) => ({
|
|
72
|
+
sample: readJson(samplePath),
|
|
73
|
+
samplePath,
|
|
74
|
+
sourceFilePath,
|
|
75
|
+
}))
|
|
76
|
+
|
|
77
|
+
const total = tasks.reduce((count, task) => count + Object.keys(task.sample).length * languageCodes.length, 0)
|
|
78
|
+
const progress = createProgress(total)
|
|
79
|
+
|
|
80
|
+
if (tasks.length === 0) {
|
|
81
|
+
console.log('✅ No translation sample folders found')
|
|
82
|
+
process.exit(0)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const task of tasks) {
|
|
86
|
+
await processSample(task)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
process.stdout.write('\n')
|
|
90
|
+
console.log(
|
|
91
|
+
`✅ Done: ${tasks.length} sample folders, ${languageCodes.length} languages, batch=${BATCH_MAX_ITEMS}/${BATCH_MAX_CHARS}`,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
function createProgress(totalCount) {
|
|
95
|
+
let done = 0
|
|
96
|
+
let lastRenderedAt = 0
|
|
97
|
+
const start = Date.now()
|
|
98
|
+
|
|
99
|
+
return function update(languageCode, samplePath) {
|
|
100
|
+
done += 1
|
|
101
|
+
|
|
102
|
+
const now = Date.now()
|
|
103
|
+
if (done !== totalCount && now - lastRenderedAt < 80) {
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
lastRenderedAt = now
|
|
108
|
+
|
|
109
|
+
const percent = totalCount === 0 ? 100 : Math.round((done * 100) / totalCount)
|
|
110
|
+
const filled = Math.round(percent / 5)
|
|
111
|
+
const empty = 20 - filled
|
|
112
|
+
const elapsed = ((now - start) / 1000).toFixed(1)
|
|
113
|
+
const shortName = shortenPath(relative(workspaceRoot, samplePath))
|
|
114
|
+
|
|
115
|
+
process.stdout.write(
|
|
116
|
+
`\r🌍 Translation: [${'█'.repeat(filled)}${' '.repeat(empty)}] ` +
|
|
117
|
+
`${percent}% (${done}/${totalCount}) ` +
|
|
118
|
+
`⏱ ${elapsed}s ` +
|
|
119
|
+
`\x1b[90m${languageCode} ${shortName}\x1b[0m\x1b[K`,
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function detectEol(filePath) {
|
|
125
|
+
if (!fs.existsSync(filePath)) {
|
|
126
|
+
return '\n'
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const text = fs.readFileSync(filePath, 'utf-8')
|
|
130
|
+
return text.includes('\r\n') ? '\r\n' : '\n'
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function estimateEntryChars(entry) {
|
|
134
|
+
return String(entry.key).length + String(entry.text).length + String(entry.filePath ?? '').length + 32
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function existsJson(filePath) {
|
|
138
|
+
return fs.existsSync(filePath)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function normalizeEol(content) {
|
|
142
|
+
return String(content).replace(/\r\n/g, '\n')
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function processSample({ sample, samplePath, sourceFilePath }) {
|
|
146
|
+
writeSortedJsonIfNeeded(samplePath, sample)
|
|
147
|
+
|
|
148
|
+
const generatedDir = join(dirname(samplePath), 'generated')
|
|
149
|
+
const enPath = join(generatedDir, 'en.json')
|
|
150
|
+
const currentEn = existsJson(enPath) ? readJson(enPath) : {}
|
|
151
|
+
const currentByLanguage = new Map(
|
|
152
|
+
languageCodes.map((languageCode) => [
|
|
153
|
+
languageCode,
|
|
154
|
+
existsJson(join(generatedDir, `${languageCode}.json`))
|
|
155
|
+
? readJson(join(generatedDir, `${languageCode}.json`))
|
|
156
|
+
: {},
|
|
157
|
+
]),
|
|
158
|
+
)
|
|
159
|
+
const resultByLanguage = new Map(languageCodes.map((languageCode) => [languageCode, {}]))
|
|
160
|
+
const pendingEntriesByLocales = new Map()
|
|
161
|
+
|
|
162
|
+
fs.mkdirSync(generatedDir, { recursive: true })
|
|
163
|
+
|
|
164
|
+
for (const [key, sampleValue] of Object.entries(sample)) {
|
|
165
|
+
const missingLocales = []
|
|
166
|
+
|
|
167
|
+
for (const languageCode of languageCodes) {
|
|
168
|
+
const languageResult = resultByLanguage.get(languageCode)
|
|
169
|
+
|
|
170
|
+
if (!languageResult) {
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (languageCode === 'en' || typeof sampleValue !== 'string') {
|
|
175
|
+
languageResult[key] = sampleValue
|
|
176
|
+
progress(languageCode, samplePath)
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const currentLang = currentByLanguage.get(languageCode) ?? {}
|
|
181
|
+
|
|
182
|
+
if (typeof currentLang[key] === 'string' && currentEn[key] === sampleValue) {
|
|
183
|
+
languageResult[key] = currentLang[key]
|
|
184
|
+
progress(languageCode, samplePath)
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
missingLocales.push(languageCode)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (typeof sampleValue === 'string' && missingLocales.length > 0) {
|
|
192
|
+
const localeKey = missingLocales.join(',')
|
|
193
|
+
const entries = pendingEntriesByLocales.get(localeKey) ?? []
|
|
194
|
+
|
|
195
|
+
entries.push({
|
|
196
|
+
filePath: relative(workspaceRoot, samplePath),
|
|
197
|
+
key,
|
|
198
|
+
text: sampleValue,
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
pendingEntriesByLocales.set(localeKey, entries)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let componentContext = null
|
|
206
|
+
|
|
207
|
+
if (pendingEntriesByLocales.size > 0) {
|
|
208
|
+
componentContext = getAiContextState({
|
|
209
|
+
samplePath,
|
|
210
|
+
sourceFilePath,
|
|
211
|
+
}).description
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
for (const [localeKey, entries] of pendingEntriesByLocales) {
|
|
215
|
+
const targetLocales = localeKey.split(',').filter(Boolean)
|
|
216
|
+
const chunks = splitIntoBatches(entries, BATCH_MAX_ITEMS, BATCH_MAX_CHARS)
|
|
217
|
+
|
|
218
|
+
for (const chunk of chunks) {
|
|
219
|
+
const translations = await translateChunk(chunk, samplePath, targetLocales, componentContext)
|
|
220
|
+
|
|
221
|
+
for (const languageCode of targetLocales) {
|
|
222
|
+
const languageResult = resultByLanguage.get(languageCode)
|
|
223
|
+
const localizedValues = translations[languageCode]
|
|
224
|
+
|
|
225
|
+
if (!languageResult || !localizedValues) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
`Missing locale "${languageCode}" in AI response for ${relative(workspaceRoot, samplePath)}`,
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
for (const entry of chunk) {
|
|
232
|
+
const translatedValue = localizedValues[entry.key]
|
|
233
|
+
|
|
234
|
+
if (typeof translatedValue !== 'string' || translatedValue.length === 0) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`Missing translation for ${languageCode} ${relative(workspaceRoot, samplePath)} :: ${entry.key}`,
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
languageResult[entry.key] = translatedValue
|
|
241
|
+
progress(languageCode, samplePath)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
for (const languageCode of languageCodes) {
|
|
248
|
+
const generatedPath = join(generatedDir, `${languageCode}.json`)
|
|
249
|
+
const languageResult = sortObjectKeys(resultByLanguage.get(languageCode) ?? {})
|
|
250
|
+
writeTextPreservingEol(generatedPath, stringifySortedJson(languageResult))
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function readJson(filePath) {
|
|
255
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function readPositiveInt(name, fallback) {
|
|
259
|
+
const raw = process.env[name]
|
|
260
|
+
|
|
261
|
+
if (!raw) {
|
|
262
|
+
return fallback
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const value = Number.parseInt(raw, 10)
|
|
266
|
+
return Number.isFinite(value) && value > 0 ? value : fallback
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function shortenPath(filePath, maxLength = 72) {
|
|
270
|
+
if (filePath.length <= maxLength) {
|
|
271
|
+
return filePath
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return `...${filePath.slice(-(maxLength - 3))}`
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function splitIntoBatches(entries, maxItems, maxChars) {
|
|
278
|
+
const batches = []
|
|
279
|
+
let current = []
|
|
280
|
+
let currentChars = 0
|
|
281
|
+
|
|
282
|
+
for (const entry of entries) {
|
|
283
|
+
const entryChars = estimateEntryChars(entry)
|
|
284
|
+
const shouldFlush = current.length > 0 && (current.length >= maxItems || currentChars + entryChars > maxChars)
|
|
285
|
+
|
|
286
|
+
if (shouldFlush) {
|
|
287
|
+
batches.push(current)
|
|
288
|
+
current = []
|
|
289
|
+
currentChars = 0
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
current.push(entry)
|
|
293
|
+
currentChars += entryChars
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (current.length > 0) {
|
|
297
|
+
batches.push(current)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return batches
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function translateChunk(entries, samplePath, targetLocales, componentContext) {
|
|
304
|
+
try {
|
|
305
|
+
return await translateBatch(entries, {
|
|
306
|
+
componentContext,
|
|
307
|
+
sourceLocale: 'en',
|
|
308
|
+
targetLocales,
|
|
309
|
+
})
|
|
310
|
+
} catch (error) {
|
|
311
|
+
throw new Error(
|
|
312
|
+
`Translation failed for ${relative(workspaceRoot, samplePath)} (${entries.length} strings, ${targetLocales.join(', ')})`,
|
|
313
|
+
{ cause: error },
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function writeSortedJsonIfNeeded(filePath, value) {
|
|
319
|
+
const nextText = stringifySortedJson(value)
|
|
320
|
+
const prevText = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : null
|
|
321
|
+
|
|
322
|
+
if (prevText === null || normalizeEol(prevText) !== normalizeEol(nextText)) {
|
|
323
|
+
writeTextPreservingEol(filePath, nextText)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function writeTextPreservingEol(filePath, content) {
|
|
328
|
+
const eol = detectEol(filePath)
|
|
329
|
+
const normalized = String(content).replace(/\r?\n/g, eol)
|
|
330
|
+
fs.writeFileSync(filePath, normalized, 'utf-8')
|
|
331
|
+
}
|