codz-cli 0.1.3 → 0.1.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.
@@ -0,0 +1,540 @@
1
+ // codz locale extract [files...]: the text users see in screens moves into the
2
+ // locale files, and the code reads it back the project's way.
3
+ //
4
+ // Flutter (ARB) Text('Nothing here yet') → Text(AppLocalizations.of(context)!.productsNothingHereYet)
5
+ // (wires AppLocalizations into MaterialApp the first time)
6
+ // Flutter (GetX) Text('Nothing here yet') → Text('products.nothingHereYet'.tr)
7
+ // next-intl <p>Nothing here yet</p> → <p>{t('products.nothingHereYet')}</p>, with useTranslations / getTranslations
8
+ // react-i18next the same with useTranslation()
9
+ // Compose Text("Nothing here yet") → Text(stringResource(R.string.products_nothing_here_yet)), in strings.xml
10
+ //
11
+ // Text used in several screens goes under common.*, and text that already has a
12
+ // key reuses it. Only plain text is moved: strings in conditions, logs and
13
+ // error messages from code are left alone.
14
+ import { join, posix } from 'node:path';
15
+ import { addDartImport } from "../adapters/flutter.js";
16
+ import { addKotlinImports } from "../adapters/kotlinGen.js";
17
+ import { maskCode, matchBrace, skipString } from "../adapters/source.js";
18
+ import { loadMap } from "../core/cache.js";
19
+ import { buildChangeSet } from "../core/changeset.js";
20
+ import { loadConfig } from "../core/config.js";
21
+ import { exists, readText, walk } from "../core/fsutil.js";
22
+ import { ToolError } from "../core/types.js";
23
+ import { camel, pascal, snake } from "../input/naming.js";
24
+ import { localeKeyFiles, readLocales } from "./locale.js";
25
+ const LETTERS = /[A-Za-z\u00C0-\u024F\u0900-\u097F]/;
26
+ const looksLikeText = (s) => LETTERS.test(s.replace(/\{\w+\}/g, '')) && !/^[a-z]+(\.[a-z_]+)+$/i.test(s.trim()) && !/^(https?:|\/|#|@)/.test(s.trim()) && s.trim().length > 0 && s.length < 400;
27
+ // ---------- finding text ----------
28
+ /** A Dart or Kotlin string literal at `i`: its content with interpolations as {name}, or null for raw, triple or complex strings. */
29
+ function readLiteral(text, i, lang) {
30
+ const q = text[i];
31
+ if (q !== "'" && q !== '"')
32
+ return null;
33
+ if (lang === 'kt' && q === "'")
34
+ return null;
35
+ if (text.slice(i, i + 3) === q.repeat(3) || text[i - 1] === 'r')
36
+ return null;
37
+ const end = skipString(text, i, 'dart');
38
+ const raw = text.slice(i + 1, end - 1);
39
+ if (raw.includes('\n'))
40
+ return null;
41
+ const args = [];
42
+ let value = '';
43
+ for (let k = 0; k < raw.length; k++) {
44
+ const c = raw[k];
45
+ if (c === '\\') {
46
+ const n = raw[k + 1];
47
+ value += n === 'n' ? '\n' : n === 't' ? '\t' : n;
48
+ k++;
49
+ continue;
50
+ }
51
+ if (c === '$') {
52
+ const brace = raw[k + 1] === '{';
53
+ const m = brace ? /^\$\{([\w.]+(?:\(\))?)\}/.exec(raw.slice(k)) : /^\$([A-Za-z_]\w*)/.exec(raw.slice(k));
54
+ if (!m)
55
+ return null; // an expression codz should not move
56
+ const expr = m[1];
57
+ const name = camel(expr.replace(/\(\)$/, '').split('.').pop() ?? 'value') || 'value';
58
+ const unique = args.some((a) => a.name === name) ? `${name}${args.length + 1}` : name;
59
+ args.push({ name: unique, expr });
60
+ value += `{${unique}}`;
61
+ k += m[0].length - 1;
62
+ continue;
63
+ }
64
+ value += c;
65
+ }
66
+ return { end, value, args };
67
+ }
68
+ function findDart(file, text) {
69
+ const masked = maskCode(text, 'dart');
70
+ const out = [];
71
+ for (const m of masked.matchAll(/(?<![\w.$])Text\(\s*/g)) {
72
+ const at = m.index + m[0].length;
73
+ const lit = readLiteral(text, at, 'dart');
74
+ if (lit && looksLikeText(lit.value))
75
+ out.push({ file, start: at, end: lit.end, text: lit.value, args: lit.args, kind: 'dart-text' });
76
+ }
77
+ for (const m of masked.matchAll(/\b(hintText|labelText|helperText|tooltip|semanticsLabel|semanticLabel|title)\s*:\s*/g)) {
78
+ const at = m.index + m[0].length;
79
+ const lit = readLiteral(text, at, 'dart');
80
+ if (lit && looksLikeText(lit.value))
81
+ out.push({ file, start: at, end: lit.end, text: lit.value, args: lit.args, kind: 'dart-named' });
82
+ }
83
+ return out;
84
+ }
85
+ function findKotlin(file, text) {
86
+ if (!/@Composable/.test(text))
87
+ return [];
88
+ const masked = maskCode(text, 'dart');
89
+ const out = [];
90
+ for (const m of masked.matchAll(/(?<![\w.])Text\(\s*(?:text\s*=\s*)?/g)) {
91
+ const at = m.index + m[0].length;
92
+ const lit = readLiteral(text, at, 'kt');
93
+ if (lit && looksLikeText(lit.value))
94
+ out.push({ file, start: at, end: lit.end, text: lit.value, args: lit.args, kind: 'kt-text' });
95
+ }
96
+ return out;
97
+ }
98
+ const JSX_ATTRS = ['placeholder', 'title', 'alt', 'aria-label', 'label', 'accessibilityLabel'];
99
+ function findJsx(file, text) {
100
+ const out = [];
101
+ // Text right before a closing tag: <p>Nothing here yet.</p>
102
+ for (const m of text.matchAll(/>([^<>{}\n]*)<\//g)) {
103
+ const raw = m[1];
104
+ if (!looksLikeText(raw) || /[=;]|=>|&&/.test(raw))
105
+ continue;
106
+ const lead = raw.length - raw.trimStart().length;
107
+ const start = m.index + 1 + lead;
108
+ const value = raw.trim();
109
+ out.push({ file, start, end: start + value.length, text: value.replace(/&apos;/g, "'").replace(/&quot;/g, '"').replace(/&amp;/g, '&'), args: [], kind: 'jsx-text' });
110
+ }
111
+ for (const m of text.matchAll(new RegExp(`\\s(${JSX_ATTRS.join('|')})="([^"{}\\n]*)"`, 'g'))) {
112
+ if (!looksLikeText(m[2]))
113
+ continue;
114
+ const start = m.index + m[0].indexOf(`${m[1]}=`);
115
+ out.push({ file, start, end: m.index + m[0].length, text: m[2], args: [], kind: 'jsx-attr', attr: m[1] });
116
+ }
117
+ return out;
118
+ }
119
+ // ---------- which files ----------
120
+ function screenFiles(root, map, style) {
121
+ if (style === 'arb' || style === 'getx') {
122
+ return walk(root, 'lib', { exts: ['.dart'] }).filter((f) => !/\.(g|freezed)\.dart$|(^|\/)l10n\/|app_localizations/.test(f) && /\bWidget\s+build\s*\(|extends\s+GetView/.test(readText(join(root, f)) ?? ''));
123
+ }
124
+ if (style === 'android') {
125
+ const src = map.conventions.srcDir || 'app/src/main/java';
126
+ return walk(root, src, { exts: ['.kt'] }).filter((f) => /@Composable/.test(readText(join(root, f)) ?? ''));
127
+ }
128
+ const dirs = ['app', 'components', 'src'];
129
+ return dirs.flatMap((d) => walk(root, d, { exts: ['.tsx', '.jsx'] })).filter((f) => !/\.(test|spec|stories)\.[jt]sx$|(^|\/)(__tests__|node_modules)\//.test(f));
130
+ }
131
+ // ---------- keys ----------
132
+ function prefixOf(file) {
133
+ const parts = file.split('/');
134
+ let base = parts[parts.length - 1].replace(/\.\w+$/, '');
135
+ if (/^(page|index|layout|_layout|loading|error|not-found)$/.test(base)) {
136
+ // App Router: the route, without groups; a dynamic last segment is the detail page.
137
+ const dirs = parts.slice(0, -1).filter((d) => !/^\(.*\)$|^(app|src|pages|screens)$/.test(d));
138
+ const segs = dirs.map((d, i) => (/^\[.*\]$/.test(d) ? (i === dirs.length - 1 ? 'detail' : '') : d)).filter(Boolean);
139
+ base = segs.join(' ') || 'home';
140
+ }
141
+ base = base.replace(/(_page|_view|_screen|Page|Screen|View|Activity)$/, '') || base;
142
+ return camel(base) || 'app';
143
+ }
144
+ function slugOf(text) {
145
+ const words = text.replace(/\{\w+\}/g, ' ').match(/[A-Za-z0-9]+/g) ?? [];
146
+ return camel(words.slice(0, 4).join(' ')) || 'text';
147
+ }
148
+ // ---------- code changes ----------
149
+ /** Dart: removes `const` from the constructor calls and list literals that enclose `pos`, since the text is no longer constant. */
150
+ function dropDartConsts(text, pos) {
151
+ const masked = maskCode(text, 'dart');
152
+ const stack = [];
153
+ for (let i = 0; i < pos; i++) {
154
+ const c = masked[i];
155
+ if (c === '(' || c === '[' || c === '{')
156
+ stack.push(i);
157
+ else if (c === ')' || c === ']' || c === '}')
158
+ stack.pop();
159
+ }
160
+ const cuts = [];
161
+ for (const open of stack) {
162
+ const before = masked.slice(Math.max(0, open - 160), open);
163
+ const m = masked[open] === '(' ? /\bconst\s+([A-Za-z_][\w.]*(?:<[^()]*>)?)\s*$/.exec(before) : /\bconst\s*(<[^()[\]]*>)?\s*$/.exec(before);
164
+ if (m) {
165
+ const at = open - before.length + m.index;
166
+ cuts.push([at, at + /^const\s*/.exec(masked.slice(at))[0].length]);
167
+ }
168
+ }
169
+ let out = text;
170
+ for (const [a, b] of cuts.sort((x, y) => y[0] - x[0]))
171
+ out = out.slice(0, a) + out.slice(b);
172
+ return out;
173
+ }
174
+ /** Dart: is `pos` inside a function or closure that has a `context` parameter? */
175
+ function hasContext(text, pos) {
176
+ const masked = maskCode(text, 'dart');
177
+ // A State class has a context getter in every method.
178
+ for (const m of masked.matchAll(/class\s+\w+\s+extends\s+(?:State|ConsumerState)<[^{]*\{/g)) {
179
+ const open = m.index + m[0].length - 1;
180
+ if (open < pos && matchBrace(text, open, 'dart') > pos)
181
+ return true;
182
+ }
183
+ for (const m of masked.matchAll(/\(([^()]*\bcontext\b[^()]*)\)\s*(?:async\s*)?(\{|=>)/g)) {
184
+ if (m.index > pos)
185
+ break;
186
+ const bodyAt = m.index + m[0].length - m[2].length;
187
+ if (m[2] === '{') {
188
+ const close = matchBrace(text, bodyAt, 'dart');
189
+ if (close > pos)
190
+ return true;
191
+ }
192
+ else {
193
+ // An arrow body runs to the end of its expression.
194
+ let depth = 0;
195
+ for (let i = bodyAt + 2; i < masked.length; i++) {
196
+ const c = masked[i];
197
+ if (c === '(' || c === '[' || c === '{')
198
+ depth++;
199
+ else if (c === ')' || c === ']' || c === '}') {
200
+ if (depth === 0) {
201
+ if (i > pos)
202
+ return true;
203
+ break;
204
+ }
205
+ depth--;
206
+ }
207
+ else if ((c === ';' || c === ',') && depth === 0) {
208
+ if (i > pos)
209
+ return true;
210
+ break;
211
+ }
212
+ }
213
+ }
214
+ }
215
+ return false;
216
+ }
217
+ /** TSX: the component function that contains `pos`: where its body starts, whether it is async, and whether it already has `t`. */
218
+ function componentAt(text, pos) {
219
+ // JSX text can hold apostrophes, which a brace matcher would read as strings: blank it first.
220
+ const safe = text.replace(/>([^<>{}\n]*)<\//g, (_m, inner) => `>${' '.repeat(inner.length)}</`);
221
+ const heads = /(?:^|\n)[ \t]*(?:export\s+(?:default\s+)?)?(?:(async\s+)?function\s+[A-Z]\w*\s*(\()|const\s+[A-Z]\w*\s*(?::[^=\n]+)?=\s*(async\s*)?(\())/g;
222
+ let best = null;
223
+ for (const m of safe.matchAll(heads)) {
224
+ const paren = m.index + m[0].length - 1;
225
+ if (paren > pos)
226
+ break;
227
+ const close = matchBrace(safe, paren, 'ts');
228
+ if (close < 0)
229
+ continue;
230
+ // After the parameters: an optional return type, then { (function) or => { (arrow).
231
+ const rest = /^\s*(?::\s*[^{=]+?)?\s*(=>\s*)?\{/.exec(safe.slice(close + 1));
232
+ if (!rest || (m[2] && rest[1]) || (m[4] && !rest[1]))
233
+ continue;
234
+ const open = close + rest[0].length;
235
+ const end = matchBrace(safe, open, 'ts');
236
+ if (open < pos && end > pos)
237
+ best = { bodyOpen: open, async: Boolean(m[1] || m[3]), hasT: /\bconst\s+(?:t|\{\s*t\b[^}]*\})\s*=/.test(text.slice(open, end)) };
238
+ }
239
+ return best;
240
+ }
241
+ function addTsImport(text, names, from) {
242
+ const existing = new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*['"]${from.replace(/\//g, '\\/')}['"];?`).exec(text);
243
+ const q = /from "/.test(text) && !/from '/.test(text) ? '"' : "'";
244
+ const semi = /;[ \t]*$/m.test(text) ? ';' : '';
245
+ if (existing) {
246
+ const have = existing[1].split(',').map((x) => x.trim()).filter(Boolean);
247
+ const all = [...new Set([...have, ...names])].sort();
248
+ return text.replace(existing[0], `import { ${all.join(', ')} } from ${q}${from}${q}${semi}`);
249
+ }
250
+ const line = `import { ${names.join(', ')} } from ${q}${from}${q}${semi}`;
251
+ // After 'use client' and the last import.
252
+ const imports = [...text.matchAll(/^import[^;\n]*(?:;|\n|$)(?:[^\n]*from[^\n]*\n)?/gm)];
253
+ if (imports.length) {
254
+ const last = imports[imports.length - 1];
255
+ const at = text.indexOf('\n', last.index) + 1;
256
+ return text.slice(0, at) + line + '\n' + text.slice(at);
257
+ }
258
+ const directive = /^(['"])use client\1;?\n/.exec(text);
259
+ return directive ? text.slice(0, directive[0].length) + line + '\n' + text.slice(directive[0].length) : `${line}\n${text}`;
260
+ }
261
+ // ---------- Flutter AppLocalizations wiring ----------
262
+ function arbSetup(root, map, files, summary, warnings) {
263
+ const read = (f) => files.get(f) ?? readText(join(root, f));
264
+ const dart = walk(root, 'lib', { exts: ['.dart'] });
265
+ // An existing import tells where the generated class is.
266
+ for (const f of dart) {
267
+ const m = /import\s+'([^']*app_localizations\.dart)'/.exec(readText(join(root, f)) ?? '');
268
+ if (m) {
269
+ const ext = dart.map((x) => readText(join(root, x)) ?? '').find((t) => /extension\s+\w+\s+on\s+BuildContext\s*\{[^}]*AppLocalizations\s+get\s+(\w+)/.test(t));
270
+ const getter = ext && /AppLocalizations\s+get\s+(\w+)/.exec(ext)?.[1];
271
+ return { importUri: m[1], access: (ctx) => (getter ? `${ctx}.${getter}` : `AppLocalizations.of(${ctx})!`) };
272
+ }
273
+ }
274
+ const pkgName = /^name:\s*(\S+)/m.exec(read('pubspec.yaml') ?? '')?.[1] ?? 'app';
275
+ const arbFile = map.locales.files.find((f) => f.endsWith('.arb'));
276
+ const arbDir = posix.dirname(arbFile);
277
+ const template = posix.basename(map.locales.files.find((f) => /_en\.arb$/.test(f)) ?? arbFile);
278
+ let yaml = read('l10n.yaml');
279
+ if (yaml === null) {
280
+ yaml = `arb-dir: ${arbDir}\ntemplate-arb-file: ${template}\noutput-localization-file: app_localizations.dart\n`;
281
+ files.set('l10n.yaml', yaml);
282
+ summary.push(`Create l10n.yaml: generate AppLocalizations from ${arbDir}/${template}`);
283
+ }
284
+ const outDir = /^output-dir:\s*(\S+)/m.exec(yaml)?.[1] ?? /^arb-dir:\s*(\S+)/m.exec(yaml)?.[1] ?? arbDir;
285
+ const outFile = /^output-localization-file:\s*(\S+)/m.exec(yaml)?.[1] ?? 'app_localizations.dart';
286
+ const importUri = /^synthetic-package:\s*true/m.test(yaml) ? `package:flutter_gen/gen_l10n/${outFile}` : `package:${pkgName}/${outDir.replace(/^lib\//, '')}/${outFile}`;
287
+ // pubspec: flutter_localizations, intl, generate: true.
288
+ let pub = read('pubspec.yaml') ?? '';
289
+ const before = pub;
290
+ if (!/^\s+flutter_localizations:/m.test(pub))
291
+ pub = pub.replace(/^dependencies:\s*\n/m, (m) => `${m} flutter_localizations:\n sdk: flutter\n`);
292
+ if (!/^\s+intl:/m.test(pub))
293
+ pub = pub.replace(/^dependencies:\s*\n/m, (m) => `${m} intl: any\n`);
294
+ if (!/^\s+generate:\s*true/m.test(pub))
295
+ pub = /^flutter:\s*\n/m.test(pub) ? pub.replace(/^flutter:\s*\n/m, (m) => `${m} generate: true\n`) : `${pub.replace(/\s*$/, '\n')}\nflutter:\n generate: true\n`;
296
+ if (pub !== before) {
297
+ files.set('pubspec.yaml', pub);
298
+ summary.push('Update pubspec.yaml: flutter_localizations, intl and generate: true (run flutter pub get, which generates AppLocalizations)');
299
+ }
300
+ // MaterialApp: the delegates and supported locales.
301
+ for (const f of dart) {
302
+ const t = read(f) ?? '';
303
+ const m = /\b(?:MaterialApp|GetMaterialApp|CupertinoApp)(?:\.router)?\(/.exec(t);
304
+ if (!m)
305
+ continue;
306
+ if (/localizationsDelegates\s*:/.test(t))
307
+ break;
308
+ const open = m.index + m[0].length - 1;
309
+ const close = matchBrace(t, open, 'dart');
310
+ const inner = t.slice(open + 1, close);
311
+ const ind = /\n([ \t]+)\w+\s*:/.exec(inner)?.[1] ?? ' ';
312
+ let next = `${t.slice(0, open + 1)}\n${ind}localizationsDelegates: AppLocalizations.localizationsDelegates,\n${ind}supportedLocales: AppLocalizations.supportedLocales,${inner.startsWith('\n') ? '' : `\n${ind}`}${inner}${t.slice(close)}`;
313
+ next = dropDartConsts(next, open + 2);
314
+ next = addDartImport(next, importUri);
315
+ files.set(f, next);
316
+ summary.push(`Update ${f}: AppLocalizations delegates and supported locales on ${m[0].slice(0, -1)}`);
317
+ break;
318
+ }
319
+ warnings.push('Run flutter pub get (or flutter gen-l10n) to generate AppLocalizations before building.');
320
+ return { importUri, access: (ctx) => `AppLocalizations.of(${ctx})!` };
321
+ }
322
+ // ---------- the plan ----------
323
+ function styleOf(root, adapter, map) {
324
+ if (adapter.id === 'android')
325
+ return 'android';
326
+ if (adapter.id === 'ios')
327
+ throw new ToolError('not_needed', 'SwiftUI already localizes Text("…") literals: Xcode collects them into a String Catalog (Localizable.xcstrings) when you build.', 'Add a String Catalog (File → New → String Catalog) and translate there.');
328
+ if (adapter.id === 'flutter') {
329
+ const set = readLocales(root, map);
330
+ if (set?.kind === 'getx' || (!set && (map.conventions.stateManagement === 'getx' || map.conventions.di === 'getx')))
331
+ return 'getx';
332
+ if (set?.kind === 'arb')
333
+ return 'arb';
334
+ throw new ToolError('no_locales', 'No ARB files were found.', 'Create one first with: codz locale add en');
335
+ }
336
+ const pkg = readText(join(root, 'package.json')) ?? '';
337
+ if (!readLocales(root, map))
338
+ throw new ToolError('no_locales', 'No locale files were found.', 'Create one first with: codz locale add en');
339
+ if (/"next-intl"/.test(pkg))
340
+ return 'next-intl';
341
+ if (/"next-i18next"/.test(pkg))
342
+ return 'next-i18next';
343
+ if (/"react-i18next"/.test(pkg))
344
+ return 'i18next';
345
+ throw new ToolError('not_supported', 'locale extract writes next-intl or react-i18next calls, and this project uses neither.', 'Add next-intl (Next.js) or react-i18next, then run this again.');
346
+ }
347
+ export async function planLocaleExtract(root, adapter, only = []) {
348
+ const { map } = await loadMap(root, adapter, loadConfig(root).conventions);
349
+ const style = styleOf(root, adapter, map);
350
+ const files = new Map();
351
+ const summary = [];
352
+ const warnings = [];
353
+ const targets = only.length ? only.map((f) => f.replace(/\\/g, '/').replace(/^\.\//, '')) : screenFiles(root, map, style);
354
+ for (const f of only)
355
+ if (!exists(join(root, f)))
356
+ throw new ToolError('not_found', `No file ${f}.`);
357
+ // 1. Find the text.
358
+ const found = [];
359
+ for (const f of targets) {
360
+ const t = readText(join(root, f)) ?? '';
361
+ const hits = style === 'arb' || style === 'getx' ? findDart(f, t) : style === 'android' ? findKotlin(f, t) : findJsx(f, t);
362
+ if (style === 'arb') {
363
+ for (const h of hits) {
364
+ if (hasContext(t, h.start))
365
+ found.push(h);
366
+ else
367
+ warnings.push(`${f}: left "${h.text}" alone (no BuildContext there to read AppLocalizations from).`);
368
+ }
369
+ }
370
+ else
371
+ found.push(...hits);
372
+ }
373
+ if (!found.length) {
374
+ summary.push(`No text to move${only.length ? ` in ${only.join(', ')}` : ' in the screens'}.`);
375
+ return buildChangeSet(root, 'locale extract', { files, summary, warnings, generated: [] });
376
+ }
377
+ // 2. Keys: reuse a key with the same text; text in several files goes under common.
378
+ const existing = new Map();
379
+ let stringsXml = null;
380
+ const stringsFile = `${map.conventions.appModule || 'app'}/src/main/res/values/strings.xml`;
381
+ if (style === 'android') {
382
+ stringsXml = readText(join(root, stringsFile)) ?? '<?xml version="1.0" encoding="utf-8"?>\n<resources>\n</resources>\n';
383
+ for (const m of stringsXml.matchAll(/<string name="(\w+)">([^<]*)<\/string>/g))
384
+ if (!existing.has(xmlUnescape(m[2])))
385
+ existing.set(xmlUnescape(m[2]), m[1]);
386
+ }
387
+ else {
388
+ const set = readLocales(root, map);
389
+ for (const [k, v] of set?.keys.get(set.defaultLang) ?? [])
390
+ if (!existing.has(fromStored(v, style)))
391
+ existing.set(fromStored(v, style), k);
392
+ }
393
+ const filesByText = new Map();
394
+ for (const h of found)
395
+ filesByText.set(h.text, (filesByText.get(h.text) ?? new Set()).add(h.file));
396
+ const taken = new Set(existing.values());
397
+ const keyOf = new Map();
398
+ const newKeys = [];
399
+ const makeKey = (prefix, slug) => {
400
+ const join2 = (n) => {
401
+ const s = n === 1 ? slug : `${slug}${n}`;
402
+ return style === 'arb' ? `${prefix}${pascal(s)}` : style === 'android' ? `${snake(prefix)}_${snake(s)}` : `${prefix}.${s}`;
403
+ };
404
+ let n = 1;
405
+ while (taken.has(join2(n)))
406
+ n++;
407
+ taken.add(join2(n));
408
+ return join2(n);
409
+ };
410
+ for (const h of found) {
411
+ const id = `${filesByText.get(h.text).size > 1 ? '' : h.file}\u0000${h.text}`;
412
+ if (keyOf.has(id))
413
+ continue;
414
+ const reuse = existing.get(h.text);
415
+ if (reuse) {
416
+ keyOf.set(id, reuse);
417
+ continue;
418
+ }
419
+ const key = makeKey(filesByText.get(h.text).size > 1 ? 'common' : prefixOf(h.file), slugOf(h.text));
420
+ keyOf.set(id, key);
421
+ newKeys.push({ key, text: h.text });
422
+ }
423
+ const key = (h) => keyOf.get(`${filesByText.get(h.text).size > 1 ? '' : h.file}\u0000${h.text}`);
424
+ // 3. Locale files.
425
+ if (style === 'android') {
426
+ const lines = newKeys.map((k) => ` <string name="${k.key}">${xmlEscape(toAndroid(k.text))}</string>`);
427
+ if (lines.length) {
428
+ files.set(stringsFile, stringsXml.replace(/\s*<\/resources>\s*$/, `\n${lines.join('\n')}\n</resources>\n`));
429
+ summary.push(`${readText(join(root, stringsFile)) === null ? 'Create' : 'Update'} ${stringsFile}: ${lines.length} string${lines.length === 1 ? '' : 's'}`);
430
+ }
431
+ }
432
+ else {
433
+ for (const k of newKeys) {
434
+ const stored = style === 'getx' ? k.text.replace(/\{(\w+)\}/g, '@$1') : style === 'next-intl' ? k.text : style === 'arb' ? k.text : k.text.replace(/\{(\w+)\}/g, '{{$1}}');
435
+ const r = await localeKeyFiles(root, adapter, map, k.key, stored, { overlay: files });
436
+ for (const [f, t] of r.files)
437
+ files.set(f, t);
438
+ warnings.push(...r.warnings.filter((w) => !/uses the \w+ text for/.test(w)));
439
+ }
440
+ const set = readLocales(root, map);
441
+ const others = [...(set?.files.keys() ?? [])].filter((l) => l !== set?.defaultLang);
442
+ if (newKeys.length)
443
+ summary.push(`Add ${newKeys.length} key${newKeys.length === 1 ? '' : 's'} to the locale files${others.length ? ` (${others.join(', ')} get the ${set.defaultLang} text until translated; codz doctor lists them)` : ''}`);
444
+ }
445
+ // 4. Code.
446
+ const arb = style === 'arb' ? arbSetup(root, map, files, summary, warnings) : null;
447
+ const byFile = new Map();
448
+ for (const h of found)
449
+ byFile.set(h.file, [...(byFile.get(h.file) ?? []), h]);
450
+ for (const [f, hits] of byFile) {
451
+ let t = files.get(f) ?? readText(join(root, f));
452
+ const sorted = [...hits].sort((a, b) => b.start - a.start);
453
+ const components = new Map();
454
+ let moved = 0;
455
+ for (const h of sorted) {
456
+ moved++;
457
+ const k = key(h);
458
+ if (style === 'arb' || style === 'getx') {
459
+ const call = style === 'arb' ? `${arb.access('context')}.${k}${h.args.length ? `(${h.args.map((a) => a.expr).join(', ')})` : ''}` : h.args.length ? `'${k}'.trParams({${h.args.map((a) => `'${a.name}': '\${${a.expr}}'`).join(', ')}})` : `'${k}'.tr`;
460
+ t = t.slice(0, h.start) + call + t.slice(h.end);
461
+ t = dropDartConsts(t, h.start);
462
+ }
463
+ else if (style === 'android') {
464
+ t = t.slice(0, h.start) + `stringResource(R.string.${k}${h.args.length ? `, ${h.args.map((a) => a.expr).join(', ')}` : ''})` + t.slice(h.end);
465
+ }
466
+ else {
467
+ const c = componentAt(t, h.start);
468
+ if (!c) {
469
+ warnings.push(`${f}: left "${h.text}" alone (not inside a component function codz recognizes).`);
470
+ moved--;
471
+ continue;
472
+ }
473
+ components.set(c.bodyOpen, { async: c.async, hasT: c.hasT });
474
+ const q = h.text.includes("'") ? '"' : "'";
475
+ const call = `t(${q}${k}${q})`;
476
+ t = t.slice(0, h.start) + (h.kind === 'jsx-attr' ? `${h.attr}={${call}}` : `{${call}}`) + t.slice(h.end);
477
+ }
478
+ }
479
+ if (style === 'arb')
480
+ t = addDartImport(t, arb.importUri);
481
+ if (style === 'getx' && !/package:get\/get\.dart/.test(t))
482
+ t = addDartImport(t, 'package:get/get.dart');
483
+ if (style === 'android') {
484
+ const ns = map.conventions.namespace;
485
+ const pkg = /^package\s+([\w.]+)/m.exec(t)?.[1] ?? '';
486
+ t = addKotlinImports(t, ['androidx.compose.ui.res.stringResource', ...(ns ? [`${ns}.R`] : [])], pkg);
487
+ }
488
+ if (style === 'next-intl' || style === 'i18next' || style === 'next-i18next') {
489
+ // A t for each component, top-down so earlier offsets stay valid... insert from the last one.
490
+ let needsServer = false;
491
+ let needsClient = false;
492
+ for (const [open, c] of [...components].sort((a, b) => b[0] - a[0])) {
493
+ if (c.hasT)
494
+ continue;
495
+ const ind = /\n([ \t]+)\S/.exec(t.slice(open))?.[1] ?? ' ';
496
+ const semi = /;\s*\n/.test(t) ? ';' : '';
497
+ const line = style === 'next-intl' ? (c.async ? `const t = await getTranslations()${semi}` : `const t = useTranslations()${semi}`) : `const { t } = useTranslation()${semi}`;
498
+ if (style === 'next-intl' && c.async)
499
+ needsServer = true;
500
+ else
501
+ needsClient = true;
502
+ t = `${t.slice(0, open + 1)}\n${ind}${line}${t.slice(open + 1)}`;
503
+ }
504
+ if (style === 'next-intl') {
505
+ if (needsServer)
506
+ t = addTsImport(t, ['getTranslations'], 'next-intl/server');
507
+ if (needsClient)
508
+ t = addTsImport(t, ['useTranslations'], 'next-intl');
509
+ }
510
+ else if (needsClient)
511
+ t = addTsImport(t, ['useTranslation'], style === 'next-i18next' ? 'next-i18next' : 'react-i18next');
512
+ }
513
+ if (!moved)
514
+ continue;
515
+ files.set(f, t);
516
+ summary.push(`Update ${f}: ${moved} text${moved === 1 ? '' : 's'} → ${style === 'android' ? 'stringResource' : style === 'arb' ? 'AppLocalizations' : style === 'getx' ? '.tr' : 't()'}`);
517
+ }
518
+ if (style === 'getx' && newKeys.some((k) => /\{\w+\}/.test(k.text)))
519
+ summary.push('Text with values uses trParams; GetX fills @name in the translation.');
520
+ return buildChangeSet(root, 'locale extract', { files, summary, warnings, generated: [] });
521
+ }
522
+ /** The text a stored translation shows, in the form found in code ({name} placeholders). */
523
+ function fromStored(v, style) {
524
+ if (style === 'getx')
525
+ return v.replace(/@(\w+)/g, '{$1}');
526
+ if (style === 'i18next' || style === 'next-i18next')
527
+ return v.replace(/\{\{(\w+)\}\}/g, '{$1}');
528
+ return v;
529
+ }
530
+ /** {name} → %1$s, %2$s for Android resources. */
531
+ function toAndroid(text) {
532
+ let n = 0;
533
+ return text.replace(/%/g, '%%').replace(/\{\w+\}/g, () => `%${++n}$s`);
534
+ }
535
+ function xmlEscape(s) {
536
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/^([@?])/, '\\$1');
537
+ }
538
+ function xmlUnescape(s) {
539
+ return s.replace(/\\'/g, "'").replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').replace(/%%/g, '%');
540
+ }