@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
@@ -46,6 +46,8 @@ const html_injector_1 = require("../../injection/html/html-injector");
46
46
  const inject_file_1 = require("../../django/inject-file");
47
47
  const types_2 = require("../../runtime/types");
48
48
  const file_utils_1 = require("../../utils/file-utils");
49
+ const i18n_config_path_1 = require("../../utils/i18n-config-path");
50
+ const i18next_icu_1 = require("../../utils/i18next-icu");
49
51
  const string_utils_1 = require("../../utils/string-utils");
50
52
  const ast_helpers_1 = require("../../utils/ast-helpers");
51
53
  /**
@@ -119,14 +121,21 @@ function transLocalName(ast, content) {
119
121
  }
120
122
  /**
121
123
  * `<p> {t(…)} </p>`: keep the spaces at the edges of the children that JSX
122
- * renders (no line break), as the fragment path does.
124
+ * renders (no line break), as the fragment path does. Edges with a line break
125
+ * render nothing and are kept as written, so the element keeps its layout.
123
126
  */
124
127
  function withEdgeSpaces(children, node) {
125
128
  const first = children[0];
126
129
  const last = children[children.length - 1];
127
130
  const lead = ast_helpers_1.t.isJSXText(first) && /^[ \t]+/.test(first.value) && !/^[ \t]*[\r\n]/.test(first.value);
128
131
  const trail = ast_helpers_1.t.isJSXText(last) && /[ \t]+$/.test(last.value) && !/[\r\n][ \t]*$/.test(last.value);
129
- return [...(lead ? [ast_helpers_1.t.jsxText(' ')] : []), node, ...(trail ? [ast_helpers_1.t.jsxText(' ')] : [])];
132
+ const leadLayout = !lead && ast_helpers_1.t.isJSXText(first) ? (first.value.match(/^\s*[\r\n]\s*/)?.[0] ?? '') : '';
133
+ const trailLayout = !trail && ast_helpers_1.t.isJSXText(last) ? (last.value.match(/\s*[\r\n]\s*$/)?.[0] ?? '') : '';
134
+ return [
135
+ ...(lead ? [ast_helpers_1.t.jsxText(' ')] : leadLayout ? [ast_helpers_1.t.jsxText(leadLayout)] : []),
136
+ node,
137
+ ...(trail ? [ast_helpers_1.t.jsxText(' ')] : trailLayout ? [ast_helpers_1.t.jsxText(trailLayout)] : []),
138
+ ];
130
139
  }
131
140
  const HOOK_CALLEES = new Set(['useTranslations', 'getTranslations', 'useTranslation']);
132
141
  /** `const t = useTranslations()`, `const t = await getTranslations()`, `const { t } = useTranslation()`. */
@@ -242,18 +251,97 @@ function replaceJSXTextKeepingSpaces(path, tCall) {
242
251
  const trailing = raw.match(/\s*$/)?.[0] ?? '';
243
252
  const keepLeading = leading.length > 0 && !leading.includes('\n');
244
253
  const keepTrailing = trailing.length > 0 && !trailing.includes('\n') && trailing.length < raw.length;
245
- if (!keepLeading && !keepTrailing) {
254
+ // A line break with its indentation renders nothing, so it stays as written:
255
+ // `>\n Decline\n</Text>` becomes `>\n {t("…")}\n</Text>`, not `>{t("…")}</Text>`.
256
+ const layoutLeading = !keepLeading && leading.includes('\n') ? leading : '';
257
+ const layoutTrailing = !keepTrailing && trailing.includes('\n') && trailing.length < raw.length ? trailing : '';
258
+ if (!keepLeading && !keepTrailing && !layoutLeading && !layoutTrailing) {
246
259
  path.replaceWith(tCall);
247
260
  return;
248
261
  }
249
262
  const nodes = [];
250
263
  if (keepLeading)
251
264
  nodes.push(ast_helpers_1.t.jsxText(' '));
265
+ else if (layoutLeading)
266
+ nodes.push(ast_helpers_1.t.jsxText(layoutLeading));
252
267
  nodes.push(tCall);
253
268
  if (keepTrailing)
254
269
  nodes.push(ast_helpers_1.t.jsxText(' '));
270
+ else if (layoutTrailing)
271
+ nodes.push(ast_helpers_1.t.jsxText(layoutTrailing));
255
272
  path.replaceWithMultiple(nodes);
256
273
  }
274
+ /** `NICHES` → `NICHES_KEYS`, `PLANS` + `label` → `PLANS_LABEL_KEYS`, `tabs` + `label` → `tabsLabelKeys`. */
275
+ function labelMapName(constant, field) {
276
+ if (/^[A-Z0-9_]+$/.test(constant))
277
+ return `${constant}${field ? `_${field.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase()}` : ''}_KEYS`;
278
+ const cap = (x) => x.charAt(0).toUpperCase() + x.slice(1);
279
+ return `${constant}${cap(field)}Keys`;
280
+ }
281
+ /**
282
+ * The value → key map of each rendered field of a constant that is also data,
283
+ * declared right after it (`const NICHES_KEYS: Record<string, string> = { "Fashion": "…" }`).
284
+ * A map an earlier run added is reused, with any new values added to it.
285
+ */
286
+ function addLabelMaps(plan, keys, isTypeScript, usedNames) {
287
+ const entries = new Map();
288
+ plan.literals.forEach((lit, i) => {
289
+ const field = plan.literalFields[i];
290
+ const values = entries.get(field) ?? new Map();
291
+ if (!values.has(lit.node.value))
292
+ values.set(lit.node.value, keys[i]);
293
+ entries.set(field, values);
294
+ });
295
+ const maps = new Map(plan.labelMaps);
296
+ const scope = plan.anchor.scope;
297
+ const added = [];
298
+ for (const [field, values] of entries) {
299
+ const existing = maps.get(field);
300
+ if (existing) {
301
+ const init = scope.getBinding(existing)?.path.node;
302
+ if (ast_helpers_1.t.isVariableDeclarator(init) && ast_helpers_1.t.isObjectExpression(init.init)) {
303
+ const obj = init.init;
304
+ const has = new Set(obj.properties.flatMap((p) => (ast_helpers_1.t.isObjectProperty(p) && ast_helpers_1.t.isStringLiteral(p.key) ? [p.key.value] : [])));
305
+ for (const [value, key] of values) {
306
+ if (!has.has(value))
307
+ obj.properties.push(ast_helpers_1.t.objectProperty(ast_helpers_1.t.stringLiteral(value), ast_helpers_1.t.stringLiteral(key)));
308
+ }
309
+ }
310
+ continue;
311
+ }
312
+ // Maps added for earlier lists in this file aren't scope bindings yet.
313
+ const taken = (x) => scope.hasBinding(x) || usedNames.has(x) || [...maps.values()].includes(x);
314
+ let name = labelMapName(plan.name, field);
315
+ for (let n = 2; taken(name); n++)
316
+ name = `${labelMapName(plan.name, field)}${n}`;
317
+ const id = ast_helpers_1.t.identifier(name);
318
+ if (isTypeScript) {
319
+ id.typeAnnotation = ast_helpers_1.t.tsTypeAnnotation(ast_helpers_1.t.tsTypeReference(ast_helpers_1.t.identifier('Record'), ast_helpers_1.t.tsTypeParameterInstantiation([ast_helpers_1.t.tsStringKeyword(), ast_helpers_1.t.tsStringKeyword()])));
320
+ }
321
+ const object = ast_helpers_1.t.objectExpression([...values].map(([value, key]) => ast_helpers_1.t.objectProperty(ast_helpers_1.t.stringLiteral(value), ast_helpers_1.t.stringLiteral(key))));
322
+ added.push(ast_helpers_1.t.variableDeclaration('const', [ast_helpers_1.t.variableDeclarator(id, object)]));
323
+ maps.set(field, name);
324
+ usedNames.add(name);
325
+ }
326
+ if (added.length > 0) {
327
+ if (plan.insert === 'after')
328
+ plan.anchor.insertAfter(added);
329
+ else
330
+ plan.anchor.insertBefore(added);
331
+ }
332
+ return maps;
333
+ }
334
+ /** Whether `p` sits in the parameters (a default value) of the function whose body is `body`. */
335
+ function isInComponentParams(p, body) {
336
+ const fn = body.parentPath;
337
+ if (!fn?.isFunction())
338
+ return false;
339
+ for (let cur = p; cur && cur !== fn; cur = cur.parentPath) {
340
+ if (cur.listKey === 'params' && cur.parentPath === fn)
341
+ return true;
342
+ }
343
+ return false;
344
+ }
257
345
  /**
258
346
  * Phase 5: Injection
259
347
  *
@@ -273,9 +361,23 @@ positions) {
273
361
  const sourceFiles = [];
274
362
  for (const sourceDir of workspace.sourceDirs) {
275
363
  const dirPath = path.join(workspace.path, sourceDir);
276
- const files = await (0, file_utils_1.findFiles)(dirPath, extensions, types_2.EXCLUDED_PATTERNS);
364
+ const files = await (0, file_utils_1.findFiles)(dirPath, extensions, (0, types_2.workspaceExcludes)(workspace));
277
365
  sourceFiles.push(...files);
278
366
  }
367
+ // react-i18next projects get Babelize's config, which exports a module-level
368
+ // `t`: strings outside any component the hook can go in use it. A config
369
+ // the user owns may not export `t`, so those strings stay as they are.
370
+ const deps = { ...workspace.packageJson?.dependencies, ...workspace.packageJson?.devDependencies };
371
+ const library = StrategyFactory_1.StrategyFactory.getStrategy(workspace.framework, deps).id;
372
+ let babelizeOwnsConfig = false;
373
+ // `t(key, { total })` fills "{total}" only where messages are ICU:
374
+ // next-intl always, react-i18next with i18next-icu (Babelize's configs load it).
375
+ let placeholders = library === 'next-intl';
376
+ if (library === 'react-i18next') {
377
+ const status = await (0, i18next_icu_1.detectI18nextIcu)(workspace).catch(() => null);
378
+ babelizeOwnsConfig = status ? !status.userOwnsInit : false;
379
+ placeholders = status?.available ?? false;
380
+ }
279
381
  // Collect keys by file for efficient lookup
280
382
  const keysByFile = new Map();
281
383
  Array.from(keyMapping.entries()).forEach(([extractionId, key]) => {
@@ -315,7 +417,8 @@ positions) {
315
417
  }
316
418
  else {
317
419
  const existingDeps = { ...workspace.packageJson?.dependencies, ...workspace.packageJson?.devDependencies };
318
- result = injectIntoFile(originalContent, relativePath, fileKeys, confidenceThreshold, workspace.framework, existingDeps, undefined, positions);
420
+ const moduleTranslator = babelizeOwnsConfig ? (0, i18n_config_path_1.i18nImportPath)(workspace, relativePath) : undefined;
421
+ result = injectIntoFile(originalContent, relativePath, fileKeys, confidenceThreshold, workspace.framework, existingDeps, undefined, positions, { moduleTranslator, placeholders });
319
422
  }
320
423
  if (result.injections.length > 0) {
321
424
  modifiedFiles.push(result);
@@ -342,6 +445,7 @@ positions) {
342
445
  }
343
446
  const ALLOWED_PARENT_TYPES = [
344
447
  'ObjectProperty',
448
+ 'AssignmentPattern',
345
449
  'ArrayExpression',
346
450
  'VariableDeclarator',
347
451
  'AssignmentExpression',
@@ -357,7 +461,8 @@ const ALLOWED_PARENT_TYPES = [
357
461
  */
358
462
  function injectIntoFile(content, filePath, keyMapping, confidenceThreshold, framework, existingDeps, forceTypeScript,
359
463
  /** extractionId → source offsets of the nodes extraction selected (see ExtractedString.offsets). */
360
- positions) {
464
+ positions, options = {}) {
465
+ const { moduleTranslator, placeholders = false } = options;
361
466
  const injections = [];
362
467
  let importAdded = false;
363
468
  let hookAdded = false;
@@ -371,11 +476,13 @@ positions) {
371
476
  }
372
477
  }
373
478
  const isTypeScript = forceTypeScript || filePath.endsWith('.ts') || filePath.endsWith('.tsx');
374
- const ast = (0, ast_helpers_1.parseCode)(content, { isTypeScript });
479
+ const ast = (0, ast_helpers_1.parseCode)(content, { isTypeScript, preserveFormat: true });
375
480
  // Track components that need the hook
376
481
  const componentsNeedingHook = new Set();
377
- let activeStrategy = StrategyFactory_1.StrategyFactory.getStrategy(framework, existingDeps);
482
+ let activeStrategy = StrategyFactory_1.StrategyFactory.getStrategy(framework, existingDeps, moduleTranslator);
378
483
  let hasNonComponentInjections = false;
484
+ // Plain t() for code outside any component the hook can go in.
485
+ const moduleStrategy = StrategyFactory_1.StrategyFactory.getStrategy('vanilla', undefined, moduleTranslator);
379
486
  // Concise-body arrow components (`const Button = () => <button …/>`) have no
380
487
  // body to put the translation hook in. Give the ones that hold a string to
381
488
  // inject a block body (`=> { return …; }`) so they can be wired like any
@@ -384,7 +491,11 @@ positions) {
384
491
  // another node that happens to have the same text on the same line.
385
492
  const selected = (id, node) => {
386
493
  const starts = positions?.get(id);
387
- return !starts || (typeof node.start === 'number' && starts.has(node.start));
494
+ if (!starts)
495
+ return true;
496
+ // Where the node starts in the file as written, which is what extraction recorded.
497
+ const start = (0, ast_helpers_1.sourceOffset)(ast, node);
498
+ return typeof start === 'number' && starts.has(start);
388
499
  };
389
500
  const hasKey = (text, node) => {
390
501
  const id = `${filePath}:${node.loc?.start.line || 0}:${(0, string_utils_1.hashString)(text)}`;
@@ -445,10 +556,31 @@ positions) {
445
556
  // each render becomes t(…). Their literals are left to this, not to the
446
557
  // StringLiteral visitors (which would put a module-level t() call there).
447
558
  const moduleLiterals = new WeakSet();
559
+ /**
560
+ * The function body the translation hook goes in. Lowercase render helpers
561
+ * get it only when there is no module-level t to use instead (next-intl, a
562
+ * config the user owns): a hook there runs at render time, while skipping
563
+ * would leave a string that was extracted and translated hardcoded.
564
+ */
565
+ const hookBody = (p) => (0, ast_helpers_1.findComponentBody)(p) ?? (moduleTranslator ? null : (0, ast_helpers_1.findComponentBody)(p, { renderHelpers: true }));
566
+ /** Pre-scan: where a string the main pass will replace gets its t() from. */
567
+ const noteSite = (p) => {
568
+ const isComponent = (0, ast_helpers_1.isInsideComponent)(p);
569
+ const body = isComponent ? hookBody(p) : null;
570
+ if (body && !isInComponentParams(p, body))
571
+ componentsNeedingHook.add(body);
572
+ else if (!isComponent || moduleTranslator)
573
+ hasNonComponentInjections = true;
574
+ };
448
575
  const moduleRewrites = [];
576
+ const labelRewrites = [];
449
577
  if (activeStrategy.id !== 'vanilla') {
450
578
  for (const plan of (0, module_constants_1.analyzeModuleConstants)(ast)) {
579
+ // Values that are also data are never translated in place, wired or not.
451
580
  if (!plan.provable)
581
+ for (const lit of plan.literals)
582
+ moduleLiterals.add(lit.node);
583
+ if (!plan.provable && !plan.labels)
452
584
  continue;
453
585
  const keys = plan.literals.map((lit) => {
454
586
  const id = `${filePath}:${lit.node.loc?.start.line || 0}:${(0, string_utils_1.hashString)(lit.node.value.trim())}`;
@@ -457,30 +589,72 @@ positions) {
457
589
  });
458
590
  if (keys.some((k) => !k) || confidenceThreshold > 100)
459
591
  continue;
592
+ // Every render site needs a t(): its component's hook, or the config's module-level t.
593
+ if (!moduleTranslator && plan.renderSites.some((site) => !hookBody(site)))
594
+ continue;
460
595
  for (const lit of plan.literals)
461
596
  moduleLiterals.add(lit.node);
462
597
  for (const site of plan.renderSites) {
463
- const body = (0, ast_helpers_1.findComponentBody)(site);
598
+ const body = hookBody(site);
464
599
  if (body)
465
600
  componentsNeedingHook.add(body);
466
601
  }
467
- moduleRewrites.push({ plan, keys: keys });
602
+ (plan.provable ? moduleRewrites : labelRewrites).push({ plan, keys: keys });
468
603
  }
469
604
  }
605
+ /**
606
+ * A template literal or `+` concatenation extraction selected:
607
+ * `\`Charged ${total}\`` → key + `{ total }`. Null when it isn't one, the
608
+ * runtime can't fill placeholders, or two placeholders share a name.
609
+ */
610
+ const placeholderMessage = (path) => {
611
+ if (!placeholders)
612
+ return null;
613
+ if (path.isBinaryExpression() && path.node.operator !== '+')
614
+ return null;
615
+ if (!path.isTemplateLiteral() && !path.isBinaryExpression())
616
+ return null;
617
+ const names = [];
618
+ const exprs = [];
619
+ const text = (0, ast_helpers_1.extractExpressionContent)(path.node, names, exprs)?.trim();
620
+ if (!text)
621
+ return null;
622
+ const extractionId = `${filePath}:${path.node.loc?.start.line || 0}:${(0, string_utils_1.hashString)(text)}`;
623
+ const key = keyMapping.get(extractionId);
624
+ if (!key || !selected(extractionId, path.node) || confidenceThreshold > 100)
625
+ return null;
626
+ if ((0, ast_helpers_1.isNonTranslatableContext)(path))
627
+ return null;
628
+ const byName = new Map();
629
+ for (let i = 0; i < names.length; i++) {
630
+ const prev = byName.get(names[i]);
631
+ if (prev && !ast_helpers_1.t.isNodesEquivalent(prev, exprs[i]))
632
+ return null;
633
+ byName.set(names[i], exprs[i]);
634
+ }
635
+ if (byName.size === 0)
636
+ return { key, values: null };
637
+ const props = [...byName].map(([name, expr]) => {
638
+ const shorthand = ast_helpers_1.t.isIdentifier(expr) && expr.name === name;
639
+ const keyNode = ast_helpers_1.t.isValidIdentifier(name) ? ast_helpers_1.t.identifier(name) : ast_helpers_1.t.stringLiteral(name);
640
+ // The original node, not a clone: a clone loses its source offset, so a
641
+ // fallback string inside (`name || 'your business'`) couldn't be wired.
642
+ return ast_helpers_1.t.objectProperty(keyNode, expr, false, shorthand);
643
+ });
644
+ return { key, values: ast_helpers_1.t.objectExpression(props) };
645
+ };
470
646
  // Pre-scan to determine if we should fall back to vanilla strategy and identify hooks needed
471
647
  (0, ast_helpers_1.traverse)(ast, {
648
+ 'TemplateLiteral|BinaryExpression'(path) {
649
+ if (!placeholderMessage(path))
650
+ return;
651
+ noteSite(path);
652
+ },
472
653
  'JSXElement|JSXFragment'(visited) {
473
654
  const path = visited;
474
655
  if (!sentenceFor(path) || confidenceThreshold > 100)
475
656
  return;
476
- if ((0, ast_helpers_1.isInsideComponent)(path)) {
477
- const componentPath = (0, ast_helpers_1.findComponentBody)(path);
478
- if (componentPath)
479
- componentsNeedingHook.add(componentPath);
480
- }
481
- else {
482
- hasNonComponentInjections = true;
483
- }
657
+ noteSite(path);
484
658
  },
485
659
  JSXText(path) {
486
660
  const text = (0, ast_helpers_1.extractJSXTextContent)(path.node);
@@ -500,24 +674,7 @@ positions) {
500
674
  return;
501
675
  if (confidenceThreshold > 100)
502
676
  return;
503
- const isComponent = (0, ast_helpers_1.isInsideComponent)(path);
504
- if (isComponent) {
505
- if (activeStrategy.id !== 'vanilla') {
506
- const componentPath = (0, ast_helpers_1.findComponentBody)(path);
507
- if (!componentPath)
508
- return;
509
- componentsNeedingHook.add(componentPath);
510
- }
511
- else {
512
- const componentPath = (0, ast_helpers_1.findComponentBody)(path);
513
- if (componentPath) {
514
- componentsNeedingHook.add(componentPath);
515
- }
516
- }
517
- }
518
- else {
519
- hasNonComponentInjections = true;
520
- }
677
+ noteSite(path);
521
678
  },
522
679
  JSXAttribute(attributes) {
523
680
  const name = attributes.node.name;
@@ -543,24 +700,7 @@ positions) {
543
700
  return;
544
701
  if (confidenceThreshold > 100)
545
702
  return;
546
- const isComponent = (0, ast_helpers_1.isInsideComponent)(attributes);
547
- if (isComponent) {
548
- if (activeStrategy.id !== 'vanilla') {
549
- const componentPath = (0, ast_helpers_1.findComponentBody)(attributes);
550
- if (!componentPath)
551
- return;
552
- componentsNeedingHook.add(componentPath);
553
- }
554
- else {
555
- const componentPath = (0, ast_helpers_1.findComponentBody)(attributes);
556
- if (componentPath) {
557
- componentsNeedingHook.add(componentPath);
558
- }
559
- }
560
- }
561
- else {
562
- hasNonComponentInjections = true;
563
- }
703
+ noteSite(attributes);
564
704
  },
565
705
  StringLiteral(path) {
566
706
  if (moduleLiterals.has(path.node))
@@ -597,31 +737,16 @@ positions) {
597
737
  return;
598
738
  if (confidenceThreshold > 100)
599
739
  return;
600
- const isComponent = (0, ast_helpers_1.isInsideComponent)(path);
601
- if (isComponent) {
602
- if (activeStrategy.id !== 'vanilla') {
603
- const componentPath = (0, ast_helpers_1.findComponentBody)(path);
604
- if (!componentPath)
605
- return;
606
- componentsNeedingHook.add(componentPath);
607
- }
608
- else {
609
- const componentPath = (0, ast_helpers_1.findComponentBody)(path);
610
- if (componentPath) {
611
- componentsNeedingHook.add(componentPath);
612
- }
613
- }
614
- }
615
- else {
616
- hasNonComponentInjections = true;
617
- }
740
+ noteSite(path);
618
741
  }
619
742
  });
620
743
  // Determine active strategy based on pre-scan results.
621
744
  // If there are ONLY non-component injections (no components needing hooks),
622
745
  // fall back to vanilla strategy which doesn't require React hooks.
623
- if (hasNonComponentInjections && componentsNeedingHook.size === 0 && activeStrategy.id !== 'vanilla') {
624
- activeStrategy = StrategyFactory_1.StrategyFactory.getStrategy('vanilla');
746
+ // Only with a real module-level t: without one (next-intl, a config the user
747
+ // owns) such strings are left as they are rather than importing `@/i18n`.
748
+ if (hasNonComponentInjections && componentsNeedingHook.size === 0 && activeStrategy.id !== 'vanilla' && moduleTranslator) {
749
+ activeStrategy = moduleStrategy;
625
750
  }
626
751
  // Compute hasUseClient AFTER strategy selection — the strategy may have fallen back
627
752
  // to vanilla, which doesn't care about use client directives.
@@ -634,13 +759,33 @@ positions) {
634
759
  const nameFor = (p) => {
635
760
  if (activeStrategy.id === 'vanilla')
636
761
  return 't';
637
- const body = (0, ast_helpers_1.findComponentBody)(p);
762
+ const body = hookBody(p);
638
763
  if (!body)
639
764
  return 't';
640
765
  if (!translatorNames.has(body.node))
641
766
  translatorNames.set(body.node, translatorNameFor(body));
642
767
  return translatorNames.get(body.node);
643
768
  };
769
+ const translatorFor = (p) => {
770
+ if (activeStrategy.id === 'vanilla')
771
+ return 'module';
772
+ const body = (0, ast_helpers_1.isInsideComponent)(p) ? hookBody(p) : null;
773
+ // A default value (`({ title = 'Untitled' }) =>`) runs before the body's hook.
774
+ if (body && !isInComponentParams(p, body))
775
+ return 'hook';
776
+ return moduleTranslator ? 'module' : null;
777
+ };
778
+ let moduleTranslatorUsed = false;
779
+ let hookTranslatorUsed = false;
780
+ /** The t() expression for a replacement, recording which import it needs. */
781
+ const pickTranslator = (p, source) => {
782
+ if (source === 'module') {
783
+ moduleTranslatorUsed = true;
784
+ return 't';
785
+ }
786
+ hookTranslatorUsed = true;
787
+ return nameFor(p);
788
+ };
644
789
  for (const { plan, keys } of moduleRewrites) {
645
790
  plan.literals.forEach((lit, i) => {
646
791
  injections.push({
@@ -655,10 +800,37 @@ positions) {
655
800
  lit.replaceWith(ast_helpers_1.t.stringLiteral(keys[i]));
656
801
  });
657
802
  for (const site of plan.renderSites) {
658
- site.replaceWith(ast_helpers_1.t.callExpression(ast_helpers_1.t.identifier(nameFor(site)), [site.node]));
803
+ const name = pickTranslator(site, hookBody(site) ? 'hook' : 'module');
804
+ site.replaceWith(ast_helpers_1.t.callExpression(ast_helpers_1.t.identifier(name), [site.node]));
659
805
  }
660
806
  needsHook = true;
661
807
  }
808
+ const labelMapNames = new Set();
809
+ for (const { plan, keys } of labelRewrites) {
810
+ const maps = addLabelMaps(plan, keys, isTypeScript, labelMapNames);
811
+ plan.literals.forEach((lit, i) => {
812
+ injections.push({
813
+ file: filePath,
814
+ line: lit.node.loc?.start.line || 0,
815
+ column: lit.node.loc?.start.column || 0,
816
+ original: lit.node.value,
817
+ replacement: `${maps.get(plan.literalFields[i])}[…]`,
818
+ key: keys[i],
819
+ confidence: 100,
820
+ });
821
+ });
822
+ plan.renderSites.forEach((site, i) => {
823
+ const name = pickTranslator(site, hookBody(site) ? 'hook' : 'module');
824
+ const field = plan.renderFields[i];
825
+ const lookup = () => ast_helpers_1.t.memberExpression(ast_helpers_1.t.identifier(maps.get(field)), ast_helpers_1.t.cloneNode(site.node, true, true), true);
826
+ // Values not in the map (not copy) are shown as they are, never looked up as keys.
827
+ site.replaceWith(plan.partialFields.has(field)
828
+ ? ast_helpers_1.t.conditionalExpression(lookup(), ast_helpers_1.t.callExpression(ast_helpers_1.t.identifier(name), [lookup()]), site.node)
829
+ : ast_helpers_1.t.callExpression(ast_helpers_1.t.identifier(name), [ast_helpers_1.t.logicalExpression('??', lookup(), site.node)]));
830
+ });
831
+ if (plan.renderSites.length > 0)
832
+ needsHook = true;
833
+ }
662
834
  const messageOptions = { transName: transLocalName(ast, content) };
663
835
  // Second pass: identify and replace strings
664
836
  (0, ast_helpers_1.traverse)(ast, {
@@ -671,15 +843,17 @@ positions) {
671
843
  const confidence = 100;
672
844
  if (confidence < confidenceThreshold)
673
845
  return;
674
- const isComponent = (0, ast_helpers_1.isInsideComponent)(path);
675
- if (isComponent && activeStrategy.id !== 'vanilla' && !(0, ast_helpers_1.findComponentBody)(path))
846
+ const source = translatorFor(path);
847
+ if (!source)
676
848
  return;
677
- // Outside a component there is no hook: only the plain t() call works.
678
- const strategy = isComponent || activeStrategy.id === 'vanilla' ? activeStrategy : StrategyFactory_1.StrategyFactory.getStrategy('vanilla');
849
+ // Outside a component there is no hook: the plain t() call, or with
850
+ // react-i18next <Trans t={t}> on the config's module-level t.
851
+ const strategy = source === 'hook' || activeStrategy.id === 'react-i18next' ? activeStrategy : moduleStrategy;
679
852
  const rendering = renderMessage(strategy, sentence.key, sentence.message, messageOptions);
680
853
  if (!rendering)
681
854
  return;
682
- path.node.children = withEdgeSpaces(path.node.children, isComponent ? useTranslatorName(rendering.node, nameFor(path)) : rendering.node);
855
+ const name = pickTranslator(path, source);
856
+ path.node.children = withEdgeSpaces(path.node.children, name === 't' ? rendering.node : useTranslatorName(rendering.node, name));
683
857
  extraImports.push(...(rendering.imports ?? []));
684
858
  injections.push({
685
859
  file: filePath,
@@ -713,31 +887,10 @@ positions) {
713
887
  const confidence = 100;
714
888
  if (confidence < confidenceThreshold)
715
889
  return;
716
- const isComponent = (0, ast_helpers_1.isInsideComponent)(path);
717
- const isSetupSafe = isComponent || activeStrategy.id === 'vanilla';
718
- // Skip injection entirely when isComponent is true but componentPath is null and the activeStrategy requires hooks
719
- if (isComponent && activeStrategy.id !== 'vanilla') {
720
- const componentPath = (0, ast_helpers_1.findComponentBody)(path);
721
- if (!componentPath)
722
- return;
723
- }
724
- // Fallback for non-component JSX text: use vanilla t() call instead of silently skipping
725
- if (!isSetupSafe) {
726
- const tCall = (0, ast_helpers_1.createJSXTCall)(key);
727
- replaceJSXTextKeepingSpaces(path, tCall);
728
- injections.push({
729
- file: filePath,
730
- line,
731
- column,
732
- original: text,
733
- replacement: `{t('${key}')}`,
734
- key,
735
- confidence,
736
- });
737
- needsHook = true;
890
+ const source = translatorFor(path);
891
+ if (!source)
738
892
  return;
739
- }
740
- const tFunctionName = nameFor(path);
893
+ const tFunctionName = pickTranslator(path, source);
741
894
  const tCall = useTranslatorName((0, ast_helpers_1.createJSXTCall)(key), tFunctionName);
742
895
  replaceJSXTextKeepingSpaces(path, tCall);
743
896
  injections.push({
@@ -777,31 +930,10 @@ positions) {
777
930
  const confidence = 100;
778
931
  if (confidence < confidenceThreshold)
779
932
  return;
780
- const isComponent = (0, ast_helpers_1.isInsideComponent)(attributes);
781
- const isSetupSafe = isComponent || activeStrategy.id === 'vanilla';
782
- // Skip injection entirely when isComponent is true but componentPath is null and the activeStrategy requires hooks
783
- if (isComponent && activeStrategy.id !== 'vanilla') {
784
- const componentPath = (0, ast_helpers_1.findComponentBody)(attributes);
785
- if (!componentPath)
786
- return;
787
- }
788
- // Fallback for non-component JSX attributes: use vanilla t() call
789
- if (!isSetupSafe) {
790
- const tFunctionName = 't';
791
- attributes.node.value = ast_helpers_1.t.jsxExpressionContainer((0, ast_helpers_1.createTCall)(key));
792
- injections.push({
793
- file: filePath,
794
- line,
795
- column: attributes.node.loc?.start.column || 0,
796
- original: text,
797
- replacement: `{${tFunctionName}('${key}')}`,
798
- key,
799
- confidence,
800
- });
801
- needsHook = true;
933
+ const source = translatorFor(attributes);
934
+ if (!source)
802
935
  return;
803
- }
804
- const tFunctionName = nameFor(attributes);
936
+ const tFunctionName = pickTranslator(attributes, source);
805
937
  attributes.node.value = ast_helpers_1.t.jsxExpressionContainer(useTranslatorName((0, ast_helpers_1.createTCall)(key), tFunctionName));
806
938
  injections.push({
807
939
  file: filePath,
@@ -814,6 +946,36 @@ positions) {
814
946
  });
815
947
  needsHook = true;
816
948
  },
949
+ // `\`Charged ${total}\`` / `'Hi ' + name` → t(key, { total }). Visited
950
+ // before the string literals inside, which are then part of the message.
951
+ 'TemplateLiteral|BinaryExpression'(path) {
952
+ const message = placeholderMessage(path);
953
+ if (!message)
954
+ return;
955
+ const source = translatorFor(path);
956
+ if (!source)
957
+ return;
958
+ const name = pickTranslator(path, source);
959
+ const args = [ast_helpers_1.t.stringLiteral(message.key)];
960
+ if (message.values)
961
+ args.push(message.values);
962
+ const call = ast_helpers_1.t.callExpression(ast_helpers_1.t.identifier(name), args);
963
+ const line = path.node.loc?.start.line || 0;
964
+ const column = path.node.loc?.start.column || 0;
965
+ const original = (0, ast_helpers_1.generate)(path.node).code;
966
+ path.replaceWith(call);
967
+ // Not skipped: the values are visited next, for copy inside them.
968
+ injections.push({
969
+ file: filePath,
970
+ line,
971
+ column,
972
+ original,
973
+ replacement: (0, ast_helpers_1.generate)(call).code,
974
+ key: message.key,
975
+ confidence: 100,
976
+ });
977
+ needsHook = true;
978
+ },
817
979
  // Handle string literals in safe contexts (ObjectProperty, ArrayExpression, VariableDeclarator, etc.)
818
980
  StringLiteral(path) {
819
981
  if (moduleLiterals.has(path.node))
@@ -830,6 +992,8 @@ positions) {
830
992
  // Specific checks to ensure the string literal is in a value/translatable position
831
993
  if (parent.isObjectProperty() && parent.node.key === path.node)
832
994
  return;
995
+ if (parent.isAssignmentPattern() && parent.node.right !== path.node)
996
+ return;
833
997
  if (parent.isConditionalExpression() && parent.node.test === path.node)
834
998
  return;
835
999
  if (parent.isCallExpression() && parent.node.callee === path.node)
@@ -853,30 +1017,10 @@ positions) {
853
1017
  return;
854
1018
  if (confidenceThreshold > 100)
855
1019
  return;
856
- const isComponent = (0, ast_helpers_1.isInsideComponent)(path);
857
- const isSetupSafe = isComponent || activeStrategy.id === 'vanilla';
858
- // Skip injection entirely when isComponent is true but componentPath is null and the activeStrategy requires hooks
859
- if (isComponent && activeStrategy.id !== 'vanilla') {
860
- const componentPath = (0, ast_helpers_1.findComponentBody)(path);
861
- if (!componentPath)
862
- return;
863
- }
864
- // Fallback for non-component string literals: use vanilla t() call directly
865
- if (!isSetupSafe) {
866
- path.replaceWith((0, ast_helpers_1.createTCall)(key));
867
- injections.push({
868
- file: filePath,
869
- line,
870
- column: path.node.loc?.start.column || 0,
871
- original: text,
872
- replacement: `t('${key}')`,
873
- key,
874
- confidence: 100,
875
- });
876
- needsHook = true;
1020
+ const source = translatorFor(path);
1021
+ if (!source)
877
1022
  return;
878
- }
879
- const tFunctionName = nameFor(path);
1023
+ const tFunctionName = pickTranslator(path, source);
880
1024
  path.replaceWith(useTranslatorName((0, ast_helpers_1.createTCall)(key), tFunctionName));
881
1025
  injections.push({
882
1026
  file: filePath,
@@ -928,10 +1072,19 @@ positions) {
928
1072
  const decl = activeStrategy.createImport(componentContext);
929
1073
  neededImports.set(importSignature(decl), decl);
930
1074
  });
931
- if (neededImports.size === 0 && !activeStrategy.hasImport(ast)) {
1075
+ // A hook already in place (no new hook) still needs its import; vanilla
1076
+ // files need the config's `t`. Module-level t() in a hook-strategy file
1077
+ // imports `t` from the config next to the hook's import.
1078
+ const strategyUsed = activeStrategy.id === 'vanilla' ? moduleTranslatorUsed : hookTranslatorUsed;
1079
+ // A file that already binds `t` (an earlier run's `import { t } from '@/i18n'`) keeps it.
1080
+ const tBound = (0, ast_helpers_1.bindsTopLevel)(ast, 't');
1081
+ if (neededImports.size === 0 && strategyUsed && !activeStrategy.hasImport(ast) && !(activeStrategy.id === 'vanilla' && tBound)) {
932
1082
  const decl = activeStrategy.createImport(fileContext);
933
1083
  neededImports.set(importSignature(decl), decl);
934
1084
  }
1085
+ if (moduleTranslatorUsed && activeStrategy.id !== 'vanilla' && moduleTranslator && !tBound) {
1086
+ extraImports.push({ source: moduleTranslator, name: 't' });
1087
+ }
935
1088
  for (const decl of [...neededImports.values()].reverse()) {
936
1089
  if (hasImportSpecifiers(ast, decl))
937
1090
  continue;