@babelize/engine 0.1.2 → 0.2.0

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.
Files changed (52) hide show
  1. package/dist/config.d.ts +3 -3
  2. package/dist/detection/stack.d.ts +3 -1
  3. package/dist/detection/stack.js +13 -6
  4. package/dist/injection/StrategyFactory.d.ts +5 -1
  5. package/dist/injection/StrategyFactory.js +7 -2
  6. package/dist/injection/strategies/VanillaStrategy.d.ts +8 -0
  7. package/dist/injection/strategies/VanillaStrategy.js +10 -3
  8. package/dist/local/translate-core.d.ts +1 -1
  9. package/dist/local/translate-core.js +1 -1
  10. package/dist/modules/extractor/extract.js +67 -10
  11. package/dist/modules/finalization/dependencies.d.ts +10 -0
  12. package/dist/modules/finalization/dependencies.js +23 -0
  13. package/dist/modules/finalization/finalize.js +41 -39
  14. package/dist/modules/finalization/nextjs-finalize.js +2 -1
  15. package/dist/modules/finalization/react-native-finalize.d.ts +45 -0
  16. package/dist/modules/finalization/react-native-finalize.js +324 -0
  17. package/dist/modules/format-injection/index.js +38 -9
  18. package/dist/modules/format-injection/inject-formats.d.ts +3 -2
  19. package/dist/modules/format-injection/inject-formats.js +4 -3
  20. package/dist/modules/injection/inject.d.ts +14 -1
  21. package/dist/modules/injection/inject.js +307 -154
  22. package/dist/modules/sync-detector/sync-detector.js +11 -4
  23. package/dist/modules/translator/line-breaks.d.ts +8 -0
  24. package/dist/modules/translator/line-breaks.js +63 -0
  25. package/dist/modules/translator/translate.js +19 -7
  26. package/dist/modules/workspace/validate.d.ts +13 -1
  27. package/dist/modules/workspace/validate.js +95 -58
  28. package/dist/runtime/types.d.ts +22 -0
  29. package/dist/runtime/types.js +33 -0
  30. package/dist/utils/ast-helpers.d.ts +30 -2
  31. package/dist/utils/ast-helpers.js +355 -42
  32. package/dist/utils/extraction-options.js +1 -1
  33. package/dist/utils/file-utils.js +4 -1
  34. package/dist/utils/i18n-config-path.d.ts +18 -0
  35. package/dist/utils/i18n-config-path.js +64 -0
  36. package/dist/utils/i18next-icu.js +1 -1
  37. package/dist/utils/icu-message.d.ts +2 -2
  38. package/dist/utils/icu-message.js +43 -7
  39. package/dist/utils/jsx-message.d.ts +16 -1
  40. package/dist/utils/jsx-message.js +89 -9
  41. package/dist/utils/key-usage.js +1 -1
  42. package/dist/utils/metadata.d.ts +5 -1
  43. package/dist/utils/metadata.js +19 -1
  44. package/dist/utils/module-constants.d.ts +21 -3
  45. package/dist/utils/module-constants.js +123 -25
  46. package/dist/utils/react-native.d.ts +17 -0
  47. package/dist/utils/react-native.js +108 -0
  48. package/dist/utils/string-utils.d.ts +8 -0
  49. package/dist/utils/string-utils.js +29 -1
  50. package/dist/utils/ui-copy.d.ts +5 -4
  51. package/dist/utils/ui-copy.js +10 -5
  52. package/package.json +3 -6
@@ -40,7 +40,9 @@ const logger_1 = require("../../utils/logger");
40
40
  const extract_1 = require("../extractor/extract");
41
41
  const lockfile_manager_1 = require("../lockfile/lockfile-manager");
42
42
  const types_1 = require("../../runtime/types");
43
+ const types_2 = require("@babelize/types");
43
44
  const extraction_options_1 = require("../../utils/extraction-options");
45
+ const validate_1 = require("../workspace/validate");
44
46
  /**
45
47
  * Extraction options the project's engine runs use (sentence grouping, ICU
46
48
  * plurals, data-literal rules), so a sync check compares like with like and
@@ -55,7 +57,8 @@ async function runExtractionOptions(workspacePath, options) {
55
57
  packageJson = undefined;
56
58
  }
57
59
  const deps = { ...packageJson?.dependencies, ...packageJson?.devDependencies };
58
- const framework = options.framework ?? (deps.next ? 'nextjs' : deps.react ? 'react' : deps.vue ? 'vue' : 'unknown');
60
+ const framework = options.framework ??
61
+ (deps.next ? 'nextjs' : (0, validate_1.isReactNativePackage)(packageJson) ? 'react-native' : deps.react ? 'react' : deps.vue ? 'vue' : 'unknown');
59
62
  const workspace = {
60
63
  path: workspacePath, framework, sourceDirs: ['.'], hasLocalesDir: false, existingLocales: [], publicDir: 'public',
61
64
  localeFormat: 'json', packageJson,
@@ -112,10 +115,13 @@ const SYNC_IGNORE_PATTERNS = [
112
115
  * Check if a file path matches supported source extensions
113
116
  * and is not excluded by ignore patterns.
114
117
  */
115
- function isSourceFile(filePath) {
118
+ function isSourceFile(filePath, frameworkExcludes = []) {
116
119
  const ext = path.extname(filePath).toLowerCase();
117
120
  if (!types_1.SUPPORTED_EXTENSIONS.includes(ext))
118
121
  return false;
122
+ // What runs never scan for this framework (React Native's ios/android projects, scripts/).
123
+ if ((0, types_1.isExcludedPath)(filePath, frameworkExcludes))
124
+ return false;
119
125
  for (const pattern of SYNC_IGNORE_PATTERNS) {
120
126
  if (pattern.test(filePath))
121
127
  return false;
@@ -140,9 +146,11 @@ async function detectSyncIssues(projectId, workspacePath, changedFiles, commitSh
140
146
  const filesChecked = [];
141
147
  const filesSkipped = [];
142
148
  // 1. Filter changed files to only source files
149
+ const extractOptions = await runExtractionOptions(workspacePath, options);
150
+ const frameworkExcludes = types_2.FRAMEWORK_CONFIGS[extractOptions.framework]?.excludedPatterns ?? [];
143
151
  const sourceFiles = [];
144
152
  for (const file of changedFiles) {
145
- if (isSourceFile(file)) {
153
+ if (isSourceFile(file, frameworkExcludes)) {
146
154
  sourceFiles.push(file);
147
155
  }
148
156
  else {
@@ -168,7 +176,6 @@ async function detectSyncIssues(projectId, workspacePath, changedFiles, commitSh
168
176
  }
169
177
  // 2. Extract strings from changed source files, the way runs do
170
178
  const extractedStrings = {};
171
- const extractOptions = await runExtractionOptions(workspacePath, options);
172
179
  for (const relativePath of sourceFiles) {
173
180
  const absolutePath = path.join(workspacePath, relativePath);
174
181
  try {
@@ -0,0 +1,8 @@
1
+ import type { TranslationProvider } from '../../providers/types';
2
+ /**
3
+ * A plain-text translator (NLLB) works sentence by sentence and drops line
4
+ * breaks: "…is running.\n\nDetails: {x}" came back as one line. Messages with
5
+ * line breaks go line by line, and are put back together with the source's
6
+ * breaks before the usual checks.
7
+ */
8
+ export declare function translateLineByLine(provider: TranslationProvider): TranslationProvider;
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.translateLineByLine = translateLineByLine;
4
+ const BREAK = /([ \t]*\r?\n[\s]*)/;
5
+ /** The translator service's batch limit (apps/translator `max_batch_size`). */
6
+ const MAX_BATCH = 50;
7
+ /**
8
+ * A plain-text translator (NLLB) works sentence by sentence and drops line
9
+ * breaks: "…is running.\n\nDetails: {x}" came back as one line. Messages with
10
+ * line breaks go line by line, and are put back together with the source's
11
+ * breaks before the usual checks.
12
+ */
13
+ function translateLineByLine(provider) {
14
+ return {
15
+ pseudo: provider.pseudo,
16
+ plainText: provider.plainText,
17
+ async translateBatch(request, options) {
18
+ const split = new Map();
19
+ const entries = [];
20
+ for (const entry of request.entries) {
21
+ const parts = entry.value.split(BREAK);
22
+ if (parts.length === 1) {
23
+ entries.push(entry);
24
+ continue;
25
+ }
26
+ split.set(entry.key, parts);
27
+ // Odd indexes are the breaks; empty lines have nothing to translate.
28
+ parts.forEach((part, i) => {
29
+ if (i % 2 === 0 && part.trim())
30
+ entries.push({ ...entry, key: `${entry.key}#line${i}`, value: part });
31
+ });
32
+ }
33
+ if (split.size === 0)
34
+ return provider.translateBatch(request, options);
35
+ // Lines add entries: keep each request within the translator's batch limit.
36
+ const results = new Map();
37
+ for (let i = 0; i < entries.length; i += MAX_BATCH) {
38
+ const part = await provider.translateBatch({ ...request, entries: entries.slice(i, i + MAX_BATCH) }, options);
39
+ for (const r of part)
40
+ results.set(r.key, r);
41
+ }
42
+ return request.entries.map((entry) => {
43
+ const parts = split.get(entry.key);
44
+ if (!parts)
45
+ return results.get(entry.key) ?? { key: entry.key, status: 'failed', reason: 'missing_from_response', retryable: true };
46
+ let value = '';
47
+ for (let i = 0; i < parts.length; i++) {
48
+ if (i % 2 === 1 || !parts[i].trim()) {
49
+ value += parts[i];
50
+ continue;
51
+ }
52
+ const line = results.get(`${entry.key}#line${i}`);
53
+ if (!line)
54
+ return { key: entry.key, status: 'failed', reason: 'missing_from_response', retryable: true };
55
+ if (line.status !== 'ok')
56
+ return { ...line, key: entry.key };
57
+ value += line.value;
58
+ }
59
+ return { key: entry.key, status: 'ok', value };
60
+ });
61
+ },
62
+ };
63
+ }
@@ -9,6 +9,7 @@ const untranslated_1 = require("../../utils/untranslated");
9
9
  const logger_1 = require("../../utils/logger");
10
10
  const translation_quality_1 = require("../../utils/translation-quality");
11
11
  const file_utils_1 = require("../../utils/file-utils");
12
+ const line_breaks_1 = require("./line-breaks");
12
13
  const placeholder_protection_1 = require("../../utils/placeholder-protection");
13
14
  const icu_message_1 = require("../../utils/icu-message");
14
15
  const keygen_1 = require("../keygen");
@@ -34,9 +35,18 @@ const TRANSLATE_CONCURRENCY = 3; // Max concurrent locales/batches
34
35
  * many sentences (the translator service's batch limit).
35
36
  */
36
37
  const BRANCH_BATCH_SIZE = 50;
37
- /** A plain source whose stored translation is ICU only fits a project whose runtime formats ICU. */
38
- function reusableTranslation(source, translated, targetPlurals) {
39
- return targetPlurals || (0, icu_message_1.isIcuMessage)(source) || !(0, icu_message_1.isIcuMessage)(translated);
38
+ /**
39
+ * A plain source whose stored translation is ICU only fits a project whose
40
+ * runtime formats ICU. A stored ICU translation stored before a check it now
41
+ * fails (e.g. Czech without its `few` branch) is translated again.
42
+ */
43
+ function reusableTranslation(source, translated, targetPlurals, targetLocale) {
44
+ if (!targetPlurals && !(0, icu_message_1.isIcuMessage)(source) && (0, icu_message_1.isIcuMessage)(translated))
45
+ return false;
46
+ if (!(0, icu_message_1.isIcuMessage)(translated))
47
+ return true;
48
+ const pluralizable = (0, icu_message_1.isIcuMessage)(source) ? [] : (0, icu_message_1.countArguments)(source);
49
+ return !(0, icu_message_1.compareIcuMessages)(source, translated, targetLocale, { pluralizable }).some((i) => i.severity === 'error');
40
50
  }
41
51
  /** Per-entry instruction: pluralize a count argument if the target language needs it. */
42
52
  function pluralHint(args, targetLocale) {
@@ -419,7 +429,7 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
419
429
  const cacheProblem = cached.hit && cached.data !== null ? reuseProblem(text, cached.data) : null;
420
430
  if (cacheProblem)
421
431
  logger_1.engineLogger.info('Cached translation breaks the glossary; translating again', { key, targetLocale, problem: cacheProblem });
422
- if (cached.hit && cached.data !== null && !cacheProblem && reusableTranslation(text, cached.data, !!options.targetPlurals) && !(0, untranslated_1.isUntranslatedInto)(text, cached.data, targetLocale)) {
432
+ if (cached.hit && cached.data !== null && !cacheProblem && reusableTranslation(text, cached.data, !!options.targetPlurals, targetLocale) && !(0, untranslated_1.isUntranslatedInto)(text, cached.data, targetLocale)) {
423
433
  result[key] = cached.data;
424
434
  cacheHits++;
425
435
  // Reused, not translated this run (billing counts only fresh output).
@@ -497,7 +507,7 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
497
507
  const tmProblem = lookup.hit && lookup.translatedText ? reuseProblem(entry.value, lookup.translatedText) : null;
498
508
  if (tmProblem)
499
509
  logger_1.engineLogger.info('TM translation breaks the glossary; translating again', { key: entry.key, targetLocale, problem: tmProblem });
500
- if (lookup.hit && lookup.translatedText && !tmProblem && reusableTranslation(entry.value, lookup.translatedText, !!options.targetPlurals) && !(0, untranslated_1.isUntranslatedInto)(entry.value, lookup.translatedText, targetLocale)) {
510
+ if (lookup.hit && lookup.translatedText && !tmProblem && reusableTranslation(entry.value, lookup.translatedText, !!options.targetPlurals, targetLocale) && !(0, untranslated_1.isUntranslatedInto)(entry.value, lookup.translatedText, targetLocale)) {
501
511
  result[entry.key] = lookup.translatedText;
502
512
  tmHits++;
503
513
  metadata[entry.key] = { fromMemory: true, inTm: true };
@@ -540,6 +550,8 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
540
550
  // service translates plain text only: ICU messages go branch by branch
541
551
  // (./icu-branches), and mixed-script text fails up front.
542
552
  const isLlm = !!byokConfig || (!!providers.translator && !providers.translator.plainText);
553
+ // A plain-text translator drops line breaks: it gets one line at a time.
554
+ const sender = isLlm ? translator : (0, line_breaks_1.translateLineByLine)(translator);
543
555
  // Scripts FLORES-200 lacks (sr-Latn, uz-Cyrl) only work with an LLM.
544
556
  const nllbTarget = !targetLocale || !(0, language_codes_1.isSupported)(targetLocale) || (0, language_codes_1.isNllbSupported)(targetLocale);
545
557
  if (uncachedEntries.length > 0) {
@@ -670,7 +682,7 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
670
682
  .filter((t) => batch.some((b) => b.icu && (0, glossary_1.sourceHasTerm)(b.value, t)))
671
683
  .map((t) => ({ term: t, translation: t })),
672
684
  ];
673
- const keyResults = await translator.translateBatch({
685
+ const keyResults = await sender.translateBatch({
674
686
  entries: batch.map((b) => ({
675
687
  key: b.key,
676
688
  value: b.sendText,
@@ -791,7 +803,7 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
791
803
  await runBatches(chunk(prepared.filter((b) => !viaBranches.has(b.key)), BATCH_SIZE), isLlm ? firstPassHint : undefined);
792
804
  if (viaBranches.size > 0) {
793
805
  const branchEntries = prepared.filter((b) => viaBranches.has(b.key));
794
- for (const [key, o] of await translateByBranches(branchEntries, translator))
806
+ for (const [key, o] of await translateByBranches(branchEntries, sender))
795
807
  outcomes.set(key, o);
796
808
  // A count whose plural forms couldn't be built is translated as the
797
809
  // plain message it is (as before target-side plurals), and says so.
@@ -1,10 +1,22 @@
1
- import type { EngineConfigV2, Phase0Result } from '../../runtime/types';
1
+ import type { EngineConfigV2, Phase0Result, WorkspaceInfo } from '../../runtime/types';
2
2
  interface PackageJson {
3
3
  dependencies?: Record<string, string>;
4
4
  devDependencies?: Record<string, string>;
5
5
  [key: string]: unknown;
6
6
  }
7
7
  export declare function validateWorkspace(config: EngineConfigV2): Promise<Phase0Result>;
8
+ /** Expo (Router or classic) or bare React Native. */
9
+ export declare function isReactNativePackage(packageJson: PackageJson | null | undefined): boolean;
10
+ /**
11
+ * The app inside a monorepo: the first workspace package (package.json
12
+ * `workspaces`), or else the first top-level folder, whose package.json is
13
+ * a framework Babelize supports. `dir` is relative to the repo root.
14
+ */
15
+ export declare function findWorkspaceApp(workspacePath: string, rootPackageJson: PackageJson | null | undefined): Promise<{
16
+ dir: string;
17
+ packageJson: PackageJson;
18
+ framework: WorkspaceInfo['framework'];
19
+ } | null>;
8
20
  export declare function checkI18nDependencies(packageJson: PackageJson, framework?: string): Promise<{
9
21
  hasReactI18next: boolean;
10
22
  hasI18next: boolean;
@@ -37,6 +37,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.validateWorkspace = validateWorkspace;
40
+ exports.isReactNativePackage = isReactNativePackage;
41
+ exports.findWorkspaceApp = findWorkspaceApp;
40
42
  exports.checkI18nDependencies = checkI18nDependencies;
41
43
  const path = __importStar(require("path"));
42
44
  const fast_glob_1 = __importDefault(require("fast-glob"));
@@ -70,7 +72,7 @@ async function detectDjangoRoot(workspacePath, rootJsFramework) {
70
72
  }
71
73
  return null;
72
74
  }
73
- const SUPPORTED_LIST = 'React, Next.js, Vue, Django and plain HTML/CSS/JS';
75
+ const SUPPORTED_LIST = stack_1.SUPPORTED_SUMMARY;
74
76
  /** The clearest "not supported" message for a workspace we couldn't classify. */
75
77
  async function unsupportedMessage(workspacePath, fallback) {
76
78
  try {
@@ -137,44 +139,21 @@ async function validateWorkspace(config) {
137
139
  else if (framework === 'unknown' && packageJson) {
138
140
  framework = detectFramework(packageJson);
139
141
  }
140
- if (framework === 'unknown' && !isStatic) {
141
- const workspacePatterns = getWorkspacePatterns(packageJson);
142
- if (workspacePatterns.length > 0) {
143
- const matchedDirs = await (0, fast_glob_1.default)(workspacePatterns, { cwd: workspacePath, onlyDirectories: true, deep: 3, absolute: true });
144
- for (const dir of matchedDirs) {
145
- const subPkgPath = path.join(dir, 'package.json');
146
- const subPkg = await (0, file_utils_1.readJsonFile)(subPkgPath);
147
- if (subPkg) {
148
- const subFramework = detectFramework(subPkg);
149
- if (subFramework !== 'unknown') {
150
- framework = subFramework;
151
- packageJson = subPkg;
152
- logger_1.engineLogger.info('Framework detected in workspace', { workspaceDir: path.relative(workspacePath, dir), framework });
153
- break;
154
- }
155
- }
156
- }
157
- }
158
- if (framework === 'unknown' && !isStatic) {
159
- const entries = await (0, file_utils_1.readDir)(workspacePath);
160
- for (const entry of entries) {
161
- if (entry.startsWith('.'))
162
- continue;
163
- const subDirPath = path.join(workspacePath, entry);
164
- if (await (0, file_utils_1.isDirectory)(subDirPath)) {
165
- const subPkgPath = path.join(subDirPath, 'package.json');
166
- const subPkg = await (0, file_utils_1.readJsonFile)(subPkgPath);
167
- if (subPkg) {
168
- const subFramework = detectFramework(subPkg);
169
- if (subFramework !== 'unknown') {
170
- framework = subFramework;
171
- packageJson = subPkg;
172
- logger_1.engineLogger.info('Framework detected in subdirectory', { subDir: entry, framework });
173
- break;
174
- }
175
- }
176
- }
177
- }
142
+ // A monorepo: the app is a workspace package (apps/mobile) or a top-level
143
+ // folder. Everything app-specific — sources, locales, the i18n config, the
144
+ // package.json that gets the dependencies — lives in that folder.
145
+ // Also with an explicit framework (the CLI and the dashboard pass the one
146
+ // they detected): the root package.json isn't the app, so find the package
147
+ // that is, or every path below would point at the repo root.
148
+ let appDir = '.';
149
+ const rootIsApp = !!packageJson && detectFramework(packageJson) !== 'unknown';
150
+ if (!isStatic && framework !== 'django' && !rootIsApp) {
151
+ const app = await findWorkspaceApp(workspacePath, packageJson);
152
+ if (app && (framework === 'unknown' || app.framework === framework)) {
153
+ framework = app.framework;
154
+ packageJson = app.packageJson;
155
+ appDir = app.dir;
156
+ logger_1.engineLogger.info('Framework detected in workspace', { workspaceDir: app.dir, framework });
178
157
  }
179
158
  }
180
159
  if (!types_1.SUPPORTED_FRAMEWORKS.includes(framework)) {
@@ -186,14 +165,38 @@ async function validateWorkspace(config) {
186
165
  if (stack?.unsupported)
187
166
  throw new Error(stack.message);
188
167
  }
189
- // Django: the whole project is scanned (templates/ and apps live anywhere).
190
- const sourceDirs = framework === 'django' ? ['.'] : await findSourceDirectories(workspacePath);
168
+ // Django and React Native scan the whole project (templates/ and apps live
169
+ // anywhere; RN keeps App.tsx at the root next to screens/, navigation/,
170
+ // hooks/); their excludedPatterns skip virtualenvs and native projects.
171
+ const sourceDirs = types_1.FRAMEWORK_CONFIGS[framework]?.scanRoot
172
+ ? [appDir]
173
+ : (await findSourceDirectories(path.join(workspacePath, appDir))).map((d) => path.join(appDir, d));
191
174
  if (sourceDirs.length === 0) {
192
- logger_1.engineLogger.info('No standard source directories found, defaulting to root');
193
- sourceDirs.push('.');
175
+ logger_1.engineLogger.info('No standard source directories found, defaulting to the app root', { appDir });
176
+ sourceDirs.push(appDir);
194
177
  }
195
178
  const frameworkConfig = types_1.FRAMEWORK_CONFIGS[framework] || types_1.FRAMEWORK_CONFIGS['unknown'];
196
179
  const localeFormat = frameworkConfig.localeFormat;
180
+ let publicDir = appDir === '.' ? 'public' : path.join(appDir, 'public');
181
+ if (appDir === '.' && sourceDirs.length > 0 && sourceDirs[0] !== '.') {
182
+ const firstSourceDir = sourceDirs[0];
183
+ const parts = firstSourceDir.split(/[\\/]/);
184
+ if (parts.length > 1) {
185
+ parts.pop();
186
+ publicDir = path.join(...parts, 'public');
187
+ }
188
+ }
189
+ if (framework === 'static') {
190
+ sourceDirs.length = 0;
191
+ sourceDirs.push('.');
192
+ publicDir = '.';
193
+ }
194
+ if (framework === 'react-native') {
195
+ // Translations are bundled with the app (imported by i18n.ts), not served:
196
+ // src/locales when the app has src/, else locales/ — never app/, which
197
+ // Expo Router treats as routes.
198
+ publicDir = (await (0, file_utils_1.isDirectory)(path.join(workspacePath, appDir, 'src'))) ? path.join(appDir, 'src') : appDir;
199
+ }
197
200
  const existingLocales = [];
198
201
  let hasLocalesDir = false;
199
202
  let localeDirRel = '';
@@ -213,7 +216,8 @@ async function validateWorkspace(config) {
213
216
  }
214
217
  }
215
218
  else {
216
- const localesDir = path.join(workspacePath, 'locales');
219
+ // Where localeFilePath() writes them: <publicDir>/locales/<locale>.json.
220
+ const localesDir = path.join(workspacePath, publicDir, 'locales');
217
221
  hasLocalesDir = await (0, file_utils_1.isDirectory)(localesDir);
218
222
  const localeExt = (0, file_utils_1.localeExtension)(localeFormat);
219
223
  if (hasLocalesDir) {
@@ -224,20 +228,6 @@ async function validateWorkspace(config) {
224
228
  }
225
229
  }
226
230
  }
227
- let publicDir = 'public';
228
- if (sourceDirs.length > 0 && sourceDirs[0] !== '.') {
229
- const firstSourceDir = sourceDirs[0];
230
- const parts = firstSourceDir.split(/[\\/]/);
231
- if (parts.length > 1) {
232
- parts.pop();
233
- publicDir = path.join(...parts, 'public');
234
- }
235
- }
236
- if (framework === 'static') {
237
- sourceDirs.length = 0;
238
- sourceDirs.push('.');
239
- publicDir = '.';
240
- }
241
231
  if (localeFormat === 'po') {
242
232
  // For gettext projects publicDir is the locale directory.
243
233
  publicDir = localeDirRel;
@@ -252,6 +242,8 @@ async function validateWorkspace(config) {
252
242
  hasLocalesDir,
253
243
  existingLocales,
254
244
  publicDir,
245
+ ...(appDir !== '.' ? { appDir } : {}),
246
+ ...(frameworkConfig.excludedPatterns ? { excludedPatterns: frameworkConfig.excludedPatterns } : {}),
255
247
  packageJson: (packageJson ?? {}),
256
248
  formatConfig: config.formatConfig,
257
249
  ...(framework === 'django' ? { djangoRoot: djangoRoot ?? '.' } : {}),
@@ -270,6 +262,10 @@ function detectFramework(packageJson) {
270
262
  const deps = { ...packageJson.dependencies, ...packageJson.devDependencies };
271
263
  if (deps['next'])
272
264
  return 'nextjs';
265
+ // Before react: every Expo / React Native app also depends on react (and
266
+ // Expo Router apps on react-dom for the web target).
267
+ if (isReactNativePackage(packageJson))
268
+ return 'react-native';
273
269
  if (deps['nuxt'])
274
270
  return 'vue';
275
271
  if (deps['vue'])
@@ -296,6 +292,40 @@ function detectFramework(packageJson) {
296
292
  return 'react';
297
293
  return 'unknown';
298
294
  }
295
+ /** Expo (Router or classic) or bare React Native. */
296
+ function isReactNativePackage(packageJson) {
297
+ const deps = { ...packageJson?.dependencies, ...packageJson?.devDependencies };
298
+ return Boolean(deps['expo'] || deps['react-native']);
299
+ }
300
+ /**
301
+ * The app inside a monorepo: the first workspace package (package.json
302
+ * `workspaces`), or else the first top-level folder, whose package.json is
303
+ * a framework Babelize supports. `dir` is relative to the repo root.
304
+ */
305
+ async function findWorkspaceApp(workspacePath, rootPackageJson) {
306
+ const candidates = [];
307
+ const workspacePatterns = getWorkspacePatterns(rootPackageJson);
308
+ if (workspacePatterns.length > 0) {
309
+ candidates.push(...(await (0, fast_glob_1.default)(workspacePatterns, { cwd: workspacePath, onlyDirectories: true, deep: 3, absolute: true })).sort());
310
+ }
311
+ for (const entry of await (0, file_utils_1.readDir)(workspacePath)) {
312
+ if (entry.startsWith('.') || entry === 'node_modules')
313
+ continue;
314
+ const dir = path.join(workspacePath, entry);
315
+ if (await (0, file_utils_1.isDirectory)(dir))
316
+ candidates.push(dir);
317
+ }
318
+ for (const dir of candidates) {
319
+ const subPkg = await (0, file_utils_1.readJsonFile)(path.join(dir, 'package.json'));
320
+ if (!subPkg)
321
+ continue;
322
+ const framework = detectFramework(subPkg);
323
+ if (framework !== 'unknown') {
324
+ return { dir: path.relative(workspacePath, dir).split(path.sep).join('/'), packageJson: subPkg, framework };
325
+ }
326
+ }
327
+ return null;
328
+ }
299
329
  async function findSourceDirectories(workspacePath) {
300
330
  const possibleDirs = ['src', 'app', 'pages', 'components', 'features', 'modules', 'lib'];
301
331
  const foundDirs = [];
@@ -322,6 +352,13 @@ async function checkI18nDependencies(packageJson, framework) {
322
352
  if (!deps['vue-i18n'])
323
353
  recommendation = 'Run: bun install vue-i18n';
324
354
  }
355
+ else if (framework === 'react-native') {
356
+ if (!hasI18next || !hasReactI18next) {
357
+ recommendation = deps['expo']
358
+ ? 'Run: npx expo install i18next react-i18next expo-localization'
359
+ : 'Run: npm install i18next react-i18next react-native-localize';
360
+ }
361
+ }
325
362
  else {
326
363
  // React / vanilla / unknown
327
364
  if (!hasI18next)
@@ -87,6 +87,14 @@ export interface WorkspaceInfo {
87
87
  hasLocalesDir: boolean;
88
88
  existingLocales: string[];
89
89
  publicDir: string;
90
+ /**
91
+ * The app's folder in a monorepo (`apps/mobile`), relative to the
92
+ * workspace; unset when the app is the repository root. Its package.json,
93
+ * tsconfig and entry points are the app's; installs still run at the root.
94
+ */
95
+ appDir?: string;
96
+ /** Framework-specific scan exclusions (FrameworkConfig.excludedPatterns); see workspaceExcludes(). */
97
+ excludedPatterns?: readonly string[];
90
98
  packageJson?: Record<string, any>;
91
99
  /** Output locale file format (e.g., 'json' for React, 'po' for Django) */
92
100
  localeFormat: 'json' | 'po' | 'arb';
@@ -424,4 +432,18 @@ export interface Phase7Result {
424
432
  }
425
433
  export declare const SUPPORTED_EXTENSIONS: string[];
426
434
  export declare const EXCLUDED_PATTERNS: string[];
435
+ /**
436
+ * Exclusions for scanning a workspace's sources: the engine defaults plus the
437
+ * framework's own (virtualenvs for Django, the native ios/android projects for
438
+ * React Native). Every source scan goes through this so they can't disagree.
439
+ */
440
+ export declare function workspaceExcludes(workspace: {
441
+ excludedPatterns?: readonly string[];
442
+ }): string[];
443
+ /**
444
+ * Whether a workspace-relative path falls under one of `patterns`, read the
445
+ * way findFiles reads them: a leading `/` anchors to the root (`/ios`), other
446
+ * names match any path segment (`Pods`), `*` globs match file names.
447
+ */
448
+ export declare function isExcludedPath(relPath: string, patterns: readonly string[]): boolean;
427
449
  export declare const JSX_TRANSLATABLE_ATTRS: string[];
@@ -8,6 +8,8 @@
8
8
  */
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
10
  exports.JSX_TRANSLATABLE_ATTRS = exports.EXCLUDED_PATTERNS = exports.SUPPORTED_EXTENSIONS = void 0;
11
+ exports.workspaceExcludes = workspaceExcludes;
12
+ exports.isExcludedPath = isExcludedPath;
11
13
  // ============ Constants ============
12
14
  exports.SUPPORTED_EXTENSIONS = ['.js', '.jsx', '.ts', '.tsx', '.vue', '.html'];
13
15
  exports.EXCLUDED_PATTERNS = [
@@ -27,12 +29,43 @@ exports.EXCLUDED_PATTERNS = [
27
29
  '*.min.js',
28
30
  '*.min.css',
29
31
  ];
32
+ /**
33
+ * Exclusions for scanning a workspace's sources: the engine defaults plus the
34
+ * framework's own (virtualenvs for Django, the native ios/android projects for
35
+ * React Native). Every source scan goes through this so they can't disagree.
36
+ */
37
+ function workspaceExcludes(workspace) {
38
+ return [...exports.EXCLUDED_PATTERNS, ...(workspace.excludedPatterns ?? [])];
39
+ }
40
+ /**
41
+ * Whether a workspace-relative path falls under one of `patterns`, read the
42
+ * way findFiles reads them: a leading `/` anchors to the root (`/ios`), other
43
+ * names match any path segment (`Pods`), `*` globs match file names.
44
+ */
45
+ function isExcludedPath(relPath, patterns) {
46
+ const segments = relPath.split(/[\\/]/).filter(Boolean);
47
+ const rel = segments.join('/');
48
+ return patterns.some((pattern) => {
49
+ if (pattern.includes('*')) {
50
+ const re = new RegExp(`^${pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`);
51
+ return segments.some((segment) => re.test(segment));
52
+ }
53
+ if (pattern.includes('/')) {
54
+ const anchored = pattern.startsWith('/') ? pattern.slice(1) : pattern;
55
+ return rel === anchored || rel.startsWith(`${anchored}/`);
56
+ }
57
+ return segments.includes(pattern);
58
+ });
59
+ }
30
60
  exports.JSX_TRANSLATABLE_ATTRS = [
31
61
  'title',
32
62
  'placeholder',
33
63
  'alt',
34
64
  'aria-label',
35
65
  'aria-description',
66
+ // React Native's screen-reader props (the web uses aria-*).
67
+ 'accessibilityLabel',
68
+ 'accessibilityHint',
36
69
  'label',
37
70
  'description',
38
71
  ];
@@ -13,7 +13,18 @@ export interface ParseOptions {
13
13
  isTypeScript?: boolean;
14
14
  isJSX?: boolean;
15
15
  filename?: string;
16
+ /**
17
+ * Parse for rewriting: `generateCode` then reprints only the nodes that
18
+ * changed and keeps every other line of the file exactly as written.
19
+ */
20
+ preserveFormat?: boolean;
16
21
  }
22
+ /**
23
+ * Where a node starts in the file as written. Recast hands the parser a
24
+ * normalized copy (`\n` line endings, leading tabs expanded), so the node's
25
+ * own offset can differ from the source offsets extraction recorded.
26
+ */
27
+ export declare function sourceOffset(ast: t.File, node: t.Node): number | undefined;
17
28
  export declare function parseCode(code: string, options?: ParseOptions): t.File;
18
29
  export declare function generateCode(ast: t.File, originalCode?: string): string;
19
30
  /**
@@ -26,6 +37,11 @@ export declare function placeStatementsOnOwnLines(code: string, statements: stri
26
37
  export declare function isJSXText(node: t.Node): node is t.JSXText;
27
38
  export declare function isAlreadyTranslated(node: t.Node, path: NodePath): boolean;
28
39
  export declare function isNonTranslatableContext(path: NodePath): boolean;
40
+ /**
41
+ * Expressions a value passes through unchanged on its way to its slot:
42
+ * `a ? 'x' : b`, `k ?? 'x'`, `('x' as K)`, `x!`.
43
+ */
44
+ export declare function isValuePassthrough(node: t.Node, child: t.Node): boolean;
29
45
  export declare function getComponentName(path: NodePath): string | null;
30
46
  export declare function isInsideComponent(path: NodePath): boolean;
31
47
  /**
@@ -33,7 +49,11 @@ export declare function isInsideComponent(path: NodePath): boolean;
33
49
  * goes. Skips callbacks (a hook inside `.map(…)` breaks the rules of hooks);
34
50
  * null when there's no component with a block body.
35
51
  */
36
- export declare function findComponentBody(path: NodePath): NodePath | null;
52
+ export declare function findComponentBody(path: NodePath,
53
+ /** Also lowercase functions returning JSX (render helpers), for when there is no module-level t. */
54
+ options?: {
55
+ renderHelpers?: boolean;
56
+ }): NodePath | null;
37
57
  export declare function hasTranslationImport(ast: t.File): boolean;
38
58
  export declare function hasTranslationHook(functionPath: NodePath): boolean;
39
59
  export declare function createTranslationImport(): t.ImportDeclaration;
@@ -45,7 +65,13 @@ export declare function getParentContext(path: NodePath): {
45
65
  parentContext: string;
46
66
  siblingGroupId: string;
47
67
  } | null;
48
- export declare function extractExpressionContent(node: t.Node, variables?: string[]): string | null;
68
+ /**
69
+ * A string-building expression as one message: `\`Hi ${user.name}\`` and
70
+ * `'Hi ' + name` → "Hi {name}". `variables` receives the placeholder names
71
+ * and, when given, `expressions` the expression behind each one (same order),
72
+ * so injection can rebuild the message as `t(key, { name: user.name })`.
73
+ */
74
+ export declare function extractExpressionContent(node: t.Node, variables?: string[], expressions?: t.Expression[]): string | null;
49
75
  export declare function extractConditionalContent(node: t.ConditionalExpression): {
50
76
  consequent: string | null;
51
77
  alternate: string | null;
@@ -55,3 +81,5 @@ export declare function extractConditionalContent(node: t.ConditionalExpression)
55
81
  */
56
82
  export declare function hasUseClientDirective(ast: t.File): boolean;
57
83
  export { generate, t, traverse };
84
+ /** A top-level import or declaration already binds `name` (e.g. `t` from an earlier run's `@/i18n` import). */
85
+ export declare function bindsTopLevel(ast: t.File, name: string): boolean;