@babelize/engine 0.1.3 → 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 (50) 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 +5 -2
  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/jsx-message.d.ts +16 -1
  38. package/dist/utils/jsx-message.js +89 -9
  39. package/dist/utils/key-usage.js +1 -1
  40. package/dist/utils/metadata.d.ts +5 -1
  41. package/dist/utils/metadata.js +19 -1
  42. package/dist/utils/module-constants.d.ts +21 -3
  43. package/dist/utils/module-constants.js +123 -25
  44. package/dist/utils/react-native.d.ts +17 -0
  45. package/dist/utils/react-native.js +108 -0
  46. package/dist/utils/string-utils.d.ts +8 -0
  47. package/dist/utils/string-utils.js +29 -1
  48. package/dist/utils/ui-copy.d.ts +5 -4
  49. package/dist/utils/ui-copy.js +10 -5
  50. package/package.json +3 -2
@@ -37,12 +37,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.traverse = exports.t = exports.generate = void 0;
40
+ exports.sourceOffset = sourceOffset;
40
41
  exports.parseCode = parseCode;
41
42
  exports.generateCode = generateCode;
42
43
  exports.placeStatementsOnOwnLines = placeStatementsOnOwnLines;
43
44
  exports.isJSXText = isJSXText;
44
45
  exports.isAlreadyTranslated = isAlreadyTranslated;
45
46
  exports.isNonTranslatableContext = isNonTranslatableContext;
47
+ exports.isValuePassthrough = isValuePassthrough;
46
48
  exports.getComponentName = getComponentName;
47
49
  exports.isInsideComponent = isInsideComponent;
48
50
  exports.findComponentBody = findComponentBody;
@@ -57,6 +59,7 @@ exports.getParentContext = getParentContext;
57
59
  exports.extractExpressionContent = extractExpressionContent;
58
60
  exports.extractConditionalContent = extractConditionalContent;
59
61
  exports.hasUseClientDirective = hasUseClientDirective;
62
+ exports.bindsTopLevel = bindsTopLevel;
60
63
  const generator_1 = __importDefault(require("@babel/generator"));
61
64
  exports.generate = generator_1.default;
62
65
  const parser = __importStar(require("@babel/parser"));
@@ -64,7 +67,80 @@ const traverse_1 = __importDefault(require("@babel/traverse"));
64
67
  exports.traverse = traverse_1.default;
65
68
  const t = __importStar(require("@babel/types"));
66
69
  exports.t = t;
70
+ const recast = __importStar(require("recast"));
71
+ const jsx_message_1 = require("./jsx-message");
72
+ /** ASTs parsed with `preserveFormat`, which `generateCode` prints with recast. */
73
+ const formatPreserving = new WeakMap();
74
+ const LINE_BREAK = /\r\n|\r(?!\n)|\n|\u2028|\u2029/g;
75
+ /** The file's own layout, which recast keeps when it prints new code. */
76
+ function layoutOf(code) {
77
+ const tabIndented = (code.match(/^\t/gm)?.length ?? 0) > (code.match(/^ {2}/gm)?.length ?? 0);
78
+ const unit = code.match(/^( {2,})\S/m)?.[1].length ?? 2;
79
+ return {
80
+ lineTerminator: code.includes('\r\n') ? '\r\n' : '\n',
81
+ tabWidth: tabIndented ? 2 : Math.min(unit, 4),
82
+ useTabs: tabIndented,
83
+ };
84
+ }
85
+ /** Columns a line's leading whitespace takes in recast's copy of the source (its `countSpaces`). */
86
+ function indentColumns(spaces, tabWidth) {
87
+ let count = 0;
88
+ for (const ch of spaces) {
89
+ if (ch === '\t') {
90
+ const next = Math.ceil(count / tabWidth) * tabWidth;
91
+ count = next === count ? count + tabWidth : next;
92
+ }
93
+ else if (!'\v\f\r'.includes(ch)) {
94
+ count++;
95
+ }
96
+ }
97
+ return count;
98
+ }
99
+ /**
100
+ * Where a node starts in the file as written. Recast hands the parser a
101
+ * normalized copy (`\n` line endings, leading tabs expanded), so the node's
102
+ * own offset can differ from the source offsets extraction recorded.
103
+ */
104
+ function sourceOffset(ast, node) {
105
+ const preserved = formatPreserving.get(ast);
106
+ if (!preserved || !node.loc)
107
+ return node.start ?? undefined;
108
+ const { line, column } = node.loc.start;
109
+ const text = preserved.lines[line - 1];
110
+ if (text === undefined)
111
+ return undefined;
112
+ const lead = /^\s*/.exec(text)[0];
113
+ const indent = indentColumns(lead, preserved.layout.tabWidth ?? 4);
114
+ return preserved.lineStarts[line - 1] + (column >= indent ? lead.length + (column - indent) : column);
115
+ }
67
116
  function parseCode(code, options = {}) {
117
+ if (options.preserveFormat) {
118
+ try {
119
+ // One line ending throughout (recast prints one), no exotic terminators.
120
+ const crlf = code.split('\r\n').length - 1;
121
+ if (crlf > 0 && crlf !== code.split('\n').length - 1)
122
+ throw new Error('mixed line endings');
123
+ if (/\r(?!\n)|[\u2028\u2029]/.test(code))
124
+ throw new Error('unusual line terminators');
125
+ const layout = layoutOf(code);
126
+ const ast = recast.parse(code, {
127
+ ...layout,
128
+ parser: { parse: (source) => babelParse(source, { ...options, tokens: true }) },
129
+ });
130
+ const lines = code.split(LINE_BREAK);
131
+ const lineStarts = [0];
132
+ for (const m of code.matchAll(LINE_BREAK))
133
+ lineStarts.push(m.index + m[0].length);
134
+ formatPreserving.set(ast, { layout, source: code, lines, lineStarts });
135
+ return ast;
136
+ }
137
+ catch {
138
+ // Syntax recast can't track: fall back to reprinting the whole file.
139
+ }
140
+ }
141
+ return babelParse(code, options);
142
+ }
143
+ function babelParse(code, options) {
68
144
  const { sourceType = 'module', isTypeScript = options.filename ? !/\.jsx?$/i.test(options.filename) : true, isJSX = true } = options;
69
145
  const plugins = [];
70
146
  if (isJSX)
@@ -76,9 +152,19 @@ function parseCode(code, options = {}) {
76
152
  sourceType,
77
153
  plugins,
78
154
  errorRecovery: true,
155
+ tokens: options.tokens,
79
156
  });
80
157
  }
81
158
  function generateCode(ast, originalCode) {
159
+ const preserved = formatPreserving.get(ast);
160
+ if (preserved) {
161
+ try {
162
+ return printPreservingFormat(ast, preserved.layout, preserved.source);
163
+ }
164
+ catch {
165
+ // Fall through to a full reprint.
166
+ }
167
+ }
82
168
  const result = (0, generator_1.default)(ast, {
83
169
  retainLines: true,
84
170
  retainFunctionParens: true,
@@ -98,6 +184,169 @@ function generateCode(ast, originalCode) {
98
184
  }
99
185
  return code;
100
186
  }
187
+ const SNIPPET_PARSER = {
188
+ parser: { parse: (source) => babelParse(source, { tokens: true }) },
189
+ };
190
+ /**
191
+ * One line of Babel output for a node the engine added, or null when it holds
192
+ * original code that spans lines (a component body wrapped in a block): that
193
+ * is left to recast, which keeps the original part as written.
194
+ */
195
+ function oneLineCode(node) {
196
+ let multiLine = false;
197
+ t.traverseFast(node, (n) => {
198
+ if (n.loc && n.loc.start.line !== n.loc.end.line)
199
+ multiLine = true;
200
+ });
201
+ if (multiLine)
202
+ return null;
203
+ // Without locations, retainLines prints everything on one line.
204
+ let code;
205
+ try {
206
+ code = (0, generator_1.default)(t.cloneNode(node, true, true), { comments: false, retainLines: true, jsescOption: { minimal: true } }).code.trim();
207
+ }
208
+ catch {
209
+ return null; // a node Babel can't print on its own: recast prints it
210
+ }
211
+ return code.includes('\n') ? null : code;
212
+ }
213
+ /**
214
+ * Recast prints an added expression with its own printer, which puts every
215
+ * object on several lines (`t("k", {\n total\n})`). Instead, print it with
216
+ * Babel and swap in that node parsed from its own text: recast reuses a
217
+ * parsed node's source as it is. Statements and blocks stay with recast,
218
+ * which spaces them within the surrounding code.
219
+ */
220
+ function snippetNode(node, parent) {
221
+ if (t.isStatement(node) || t.isProgram(node) || t.isFunction(node))
222
+ return null;
223
+ // A name (it may carry a type annotation) or a pattern prints fine as it is.
224
+ if (t.isIdentifier(node) || t.isPattern(node))
225
+ return null;
226
+ // A declared object (a map of labels) reads better one entry per line.
227
+ if (t.isObjectExpression(node) && t.isVariableDeclarator(parent))
228
+ return null;
229
+ const swappable = t.isExpression(node) || t.isJSXExpressionContainer(node) || t.isJSXText(node) || t.isJSXAttribute(node) || t.isObjectProperty(node);
230
+ if (!swappable)
231
+ return null;
232
+ const code = oneLineCode(node);
233
+ if (code === null)
234
+ return null;
235
+ const parse = (source) => recast.parse(source, SNIPPET_PARSER).program.body;
236
+ const init = (source) => {
237
+ const decl = parse(source)[0];
238
+ return t.isVariableDeclaration(decl) ? decl.declarations[0].init : null;
239
+ };
240
+ if (t.isExpression(node))
241
+ return init(`const __babelize = ${code};`) ?? null;
242
+ if (t.isJSXExpressionContainer(node) || t.isJSXText(node)) {
243
+ const fragment = init(`const __babelize = <>${code}</>;`);
244
+ return t.isJSXFragment(fragment) && fragment.children.length === 1 ? fragment.children[0] : null;
245
+ }
246
+ if (t.isJSXAttribute(node)) {
247
+ const el = init(`const __babelize = <x ${code} />;`);
248
+ return t.isJSXElement(el) ? (el.openingElement.attributes[0] ?? null) : null;
249
+ }
250
+ if (t.isObjectProperty(node)) {
251
+ const obj = init(`const __babelize = { ${code} };`);
252
+ return t.isObjectExpression(obj) ? (obj.properties[0] ?? null) : null;
253
+ }
254
+ return null;
255
+ }
256
+ /** A node recast didn't parse from this file's source: the engine added (or cloned) it. */
257
+ function isAdded(node) {
258
+ return !node.original;
259
+ }
260
+ const escapeRegex = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
261
+ function printPreservingFormat(ast, layout, source) {
262
+ // Added statements recast may print across lines (`const {\n t\n} = …`), as one line each.
263
+ const statements = new Set();
264
+ (0, traverse_1.default)(ast, {
265
+ enter(path) {
266
+ if (!isAdded(path.node) || path.isProgram() || path.isFile())
267
+ return;
268
+ if (path.isStatement() && !path.isBlockStatement()) {
269
+ // Short ones (a hook, an import); a long one, like a map of labels, reads better across lines.
270
+ const declaresObject = path.isVariableDeclaration() && path.node.declarations.some((d) => t.isObjectExpression(d.init));
271
+ const code = declaresObject ? null : oneLineCode(path.node);
272
+ if (code && code.length <= 100)
273
+ statements.add(code);
274
+ }
275
+ // Comments the engine attached stay with recast's printer.
276
+ if (path.node.leadingComments?.length || path.node.trailingComments?.length)
277
+ return;
278
+ const replacement = snippetNode(path.node, path.parent);
279
+ if (!replacement)
280
+ return; // recast prints it; the added nodes inside get their turn
281
+ // Not replaceWith: it copies comment arrays onto the node, and recast then
282
+ // sees it as changed and reprints it (wrapping at 74 columns).
283
+ // Recast reuses the snippet's text through `.original`; without locations
284
+ // of their own, the swapped-in nodes are also safe for the Babel fallback.
285
+ t.traverseFast(replacement, (n) => {
286
+ n.loc = null;
287
+ });
288
+ if (Array.isArray(path.container))
289
+ path.container[path.key] = replacement;
290
+ else
291
+ path.parent[path.key] = replacement;
292
+ path.skip();
293
+ },
294
+ });
295
+ let code = recast.print(ast, { ...layout, quote: 'double' }).code;
296
+ // Recast reprints a directive whose program body changed with a second `;`.
297
+ const directive = /^([ \t]*(['"])use [\w ]+\2);;/gm;
298
+ if (!directive.test(source))
299
+ code = code.replace(directive, '$1;');
300
+ for (const statement of statements) {
301
+ const tokens = statement.match(/[A-Za-z_$][\w$]*|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\S/g) ?? [];
302
+ if (tokens.length === 0)
303
+ continue;
304
+ // Recast follows a statement it printed across lines with a blank line; that goes too.
305
+ const re = new RegExp(`${tokens.map(escapeRegex).join('\\s*')}(\\r?\\n[ \\t]*(?=\\r?\\n))?`, 'g');
306
+ code = code.replace(re, (match, blank) => {
307
+ const printed = blank === undefined ? match : match.slice(0, -blank.length);
308
+ // Only what recast printed for the added statement: the same code the user wrote stays theirs.
309
+ if (!printed.includes('\n') || source.includes(printed))
310
+ return match;
311
+ return statement;
312
+ });
313
+ // An added import is followed by a blank line when code, not another import, comes next.
314
+ if (statement.startsWith('import ')) {
315
+ const importLine = new RegExp(`^(${escapeRegex(statement)})(\\r?\\n)(?!\\r?\\n|import\\b)`, 'm');
316
+ code = code.replace(importLine, '$1$2$2');
317
+ }
318
+ }
319
+ // Recast indents the lines it adds with spaces even with useTabs; give them the file's tabs.
320
+ if (layout.useTabs)
321
+ code = tabsOnAddedLines(code, layout, source);
322
+ return code;
323
+ }
324
+ /** Space indentation → tabs on lines recast added, never inside a template literal (that's the string's content). */
325
+ function tabsOnAddedLines(code, layout, source) {
326
+ const eol = layout.lineTerminator ?? '\n';
327
+ const originalLines = new Set(source.split(eol));
328
+ const lines = code.split(eol);
329
+ if (lines.every((line) => originalLines.has(line) || !/^ /.test(line)))
330
+ return code;
331
+ const inTemplate = new Set();
332
+ try {
333
+ (0, traverse_1.default)(babelParse(code, {}), {
334
+ TemplateElement(p) {
335
+ const loc = p.node.loc;
336
+ if (loc)
337
+ for (let l = loc.start.line + 1; l <= loc.end.line; l++)
338
+ inTemplate.add(l);
339
+ },
340
+ });
341
+ }
342
+ catch {
343
+ return code; // can't tell where literals are: leave the indentation as printed
344
+ }
345
+ const width = layout.tabWidth ?? 2;
346
+ return lines
347
+ .map((line, i) => originalLines.has(line) || inTemplate.has(i + 1) ? line : line.replace(/^ +/, (spaces) => '\t'.repeat(Math.ceil(spaces.length / width))))
348
+ .join(eol);
349
+ }
101
350
  /**
102
351
  * With `retainLines`, statements the injector adds (which have no source
103
352
  * location) are printed on the line of the preceding token, e.g.
@@ -178,6 +427,9 @@ function isNonTranslatableContext(path) {
178
427
  let current = path;
179
428
  let isInFunctionOrClass = false;
180
429
  let hasFunctionOrClassBetween = false;
430
+ // The expression that would be a computed key: the literal itself, or the
431
+ // ternary / logical expression it is a branch of.
432
+ let keyCandidate = path.node;
181
433
  while (current) {
182
434
  if (current.isFunction() || current.isClass()) {
183
435
  isInFunctionOrClass = true;
@@ -227,7 +479,9 @@ function isNonTranslatableContext(path) {
227
479
  return true;
228
480
  }
229
481
  }
230
- if (current.isMemberExpression() && current.node.computed && current.node.property === path.node) {
482
+ // An object key, also when picked by a ternary or fallback:
483
+ // `Colors[scheme === 'unspecified' ? 'light' : scheme]`, `map[key ?? 'default']`.
484
+ if (current.isMemberExpression() && current.node.computed && current.node.property === keyCandidate) {
231
485
  return true;
232
486
  }
233
487
  if (current.isSwitchCase() && current.node.test === path.node) {
@@ -272,10 +526,25 @@ function isNonTranslatableContext(path) {
272
526
  return true;
273
527
  }
274
528
  }
275
- current = current.parentPath;
529
+ const parent = current.parentPath;
530
+ if (parent && keyCandidate === current.node && isValuePassthrough(parent.node, current.node)) {
531
+ keyCandidate = parent.node;
532
+ }
533
+ current = parent;
276
534
  }
277
535
  return false;
278
536
  }
537
+ /**
538
+ * Expressions a value passes through unchanged on its way to its slot:
539
+ * `a ? 'x' : b`, `k ?? 'x'`, `('x' as K)`, `x!`.
540
+ */
541
+ function isValuePassthrough(node, child) {
542
+ if (t.isConditionalExpression(node))
543
+ return node.consequent === child || node.alternate === child;
544
+ if (t.isLogicalExpression(node))
545
+ return node.left === child || node.right === child;
546
+ return t.isTSAsExpression(node) || t.isTSSatisfiesExpression(node) || t.isTSNonNullExpression(node) || t.isParenthesizedExpression(node);
547
+ }
279
548
  function getComponentName(path) {
280
549
  if (path.isVariableDeclarator()) {
281
550
  if (t.isIdentifier(path.node.id))
@@ -390,11 +659,16 @@ function returnsJsx(fn) {
390
659
  return found;
391
660
  }
392
661
  /**
393
- * Whether `fn` is a component the hook can go in: not a callback passed to
394
- * another call (`items.map((x) => …)`, except memo/forwardRef), and either
395
- * PascalCase-named, the default export, or returning JSX itself.
662
+ * Whether `fn` is a component (or custom hook) the hook can go in: not a
663
+ * callback passed to another call (`items.map((x) => …)`, except
664
+ * memo/forwardRef), and PascalCase-named, `use*`-named, the default export,
665
+ * or an unnamed function returning JSX. A lowercase function that returns JSX
666
+ * (`function renderHint() { return <Text>…</Text> }`) is a helper: a hook in
667
+ * it breaks the rules of hooks (and `react-hooks/rules-of-hooks` fails the
668
+ * user's lint), so its strings belong to the enclosing component, or to the
669
+ * module-level `t` when there is none.
396
670
  */
397
- function isComponentFunction(fn) {
671
+ function isComponentFunction(fn, renderHelpers = false) {
398
672
  const parent = fn.parentPath;
399
673
  if (parent?.isCallExpression() && parent.node.arguments.includes(fn.node) && !isComponentWrapperCall(parent))
400
674
  return false;
@@ -412,8 +686,8 @@ function isComponentFunction(fn) {
412
686
  }
413
687
  if (parent?.isExportDefaultDeclaration())
414
688
  return true;
415
- if (name && /^[A-Z]/.test(name))
416
- return true;
689
+ if (name)
690
+ return /^[A-Z]/.test(name) || /^use[A-Z0-9]/.test(name) || (renderHelpers && returnsJsx(fn));
417
691
  return returnsJsx(fn);
418
692
  }
419
693
  /**
@@ -421,14 +695,16 @@ function isComponentFunction(fn) {
421
695
  * goes. Skips callbacks (a hook inside `.map(…)` breaks the rules of hooks);
422
696
  * null when there's no component with a block body.
423
697
  */
424
- function findComponentBody(path) {
698
+ function findComponentBody(path,
699
+ /** Also lowercase functions returning JSX (render helpers), for when there is no module-level t. */
700
+ options = {}) {
425
701
  let current = path;
426
702
  while (current) {
427
703
  if (current.isArrowFunctionExpression() ||
428
704
  current.isFunctionExpression() ||
429
705
  current.isFunctionDeclaration()) {
430
706
  const body = current.get('body');
431
- if (!Array.isArray(body) && body.isBlockStatement() && isComponentFunction(current))
707
+ if (!Array.isArray(body) && body.isBlockStatement() && isComponentFunction(current, options.renderHelpers))
432
708
  return body;
433
709
  }
434
710
  current = current.parentPath;
@@ -527,25 +803,50 @@ function getParentContext(path) {
527
803
  siblingGroupId: Math.abs(hash).toString(36),
528
804
  };
529
805
  }
530
- function extractExpressionContent(node, variables = []) {
806
+ /**
807
+ * A string-building expression as one message: `\`Hi ${user.name}\`` and
808
+ * `'Hi ' + name` → "Hi {name}". `variables` receives the placeholder names
809
+ * and, when given, `expressions` the expression behind each one (same order),
810
+ * so injection can rebuild the message as `t(key, { name: user.name })`.
811
+ */
812
+ function extractExpressionContent(node, variables = [], expressions = []) {
531
813
  if (t.isStringLiteral(node))
532
814
  return node.value;
533
815
  if (t.isTemplateLiteral(node)) {
534
816
  let result = '';
535
817
  const quasis = node.quasis;
536
- const expressions = node.expressions;
818
+ const exprs = node.expressions;
819
+ const nameOf = (expr) => t.isIdentifier(expr) ? expr.name
820
+ : t.isMemberExpression(expr) && t.isIdentifier(expr.property) ? expr.property.name
821
+ : `var${variables.length}`;
537
822
  for (let i = 0; i < quasis.length; i++) {
538
- result += quasis[i].value.raw;
539
- if (i < expressions.length) {
540
- const expr = expressions[i];
541
- let varName = `var${variables.length}`;
542
- if (t.isIdentifier(expr)) {
543
- varName = expr.name;
544
- }
545
- else if (t.isMemberExpression(expr) && t.isIdentifier(expr.property)) {
546
- varName = expr.property.name;
823
+ // The text as it runs (`\n` is a line break), not as it's escaped in the source.
824
+ result += quasis[i].value.cooked ?? quasis[i].value.raw;
825
+ if (i < exprs.length) {
826
+ const expr = exprs[i];
827
+ // `${n} item${n === 1 ? '' : 's'}` → `{n, plural, one {# item} other {# items}}`.
828
+ const suffix = (0, jsx_message_1.pluralSuffix)(expr);
829
+ const word = suffix ? /([A-Za-z]+)$/.exec(result) : null;
830
+ if (suffix && word) {
831
+ const at = variables.findIndex((_, j) => t.isNodesEquivalent(expressions[j], suffix.count));
832
+ // A new count gets a name of its own: `${user.count} item${count === 1 …}` has two counts.
833
+ let name = at >= 0 ? variables[at] : nameOf(suffix.count);
834
+ for (let n = 2; at < 0 && variables.includes(name); n++)
835
+ name = `${nameOf(suffix.count)}${n}`;
836
+ if (at < 0) {
837
+ variables.push(name);
838
+ expressions.push(suffix.count);
839
+ }
840
+ const before = result.slice(0, -word[1].length);
841
+ const counted = before.endsWith(`{${name}} `);
842
+ const hash = counted ? '# ' : '';
843
+ result = (counted ? before.slice(0, -`{${name}} `.length) : before)
844
+ + `{${name}, plural, one {${hash}${word[1]}${suffix.one}} other {${hash}${word[1]}${suffix.other}}}`;
845
+ continue;
547
846
  }
847
+ const varName = nameOf(expr);
548
848
  variables.push(varName);
849
+ expressions.push(expr);
549
850
  result += `{${varName}}`;
550
851
  }
551
852
  }
@@ -554,36 +855,35 @@ function extractExpressionContent(node, variables = []) {
554
855
  if (t.isBinaryExpression(node) && node.operator === '+') {
555
856
  const leftVars = [];
556
857
  const rightVars = [];
557
- const left = extractExpressionContent(node.left, leftVars);
558
- const right = extractExpressionContent(node.right, rightVars);
858
+ const leftExprs = [];
859
+ const rightExprs = [];
860
+ const left = extractExpressionContent(node.left, leftVars, leftExprs);
861
+ const right = extractExpressionContent(node.right, rightVars, rightExprs);
862
+ const operandName = (operand) => t.isIdentifier(operand) ? operand.name
863
+ : t.isMemberExpression(operand) && t.isIdentifier(operand.property) ? operand.property.name
864
+ : null;
559
865
  if (left !== null && right !== null) {
560
866
  variables.push(...leftVars, ...rightVars);
867
+ expressions.push(...leftExprs, ...rightExprs);
561
868
  return left + right;
562
869
  }
563
- if (left !== null && t.isIdentifier(node.right)) {
564
- const varName = node.right.name;
565
- variables.push(...leftVars, varName);
566
- return left + `{${varName}}`;
870
+ // Identifiers before member expressions, as before: `'Hi ' + name`, `'Hi ' + user.name`.
871
+ const rightName = left !== null ? operandName(node.right) : null;
872
+ if (left !== null && rightName !== null) {
873
+ variables.push(...leftVars, rightName);
874
+ expressions.push(...leftExprs, node.right);
875
+ return left + `{${rightName}}`;
567
876
  }
568
- if (right !== null && t.isIdentifier(node.left)) {
569
- const varName = node.left.name;
570
- variables.push(varName, ...rightVars);
571
- return `{${varName}}` + right;
572
- }
573
- if (left !== null && t.isMemberExpression(node.right) && t.isIdentifier(node.right.property)) {
574
- const varName = node.right.property.name;
575
- variables.push(...leftVars, varName);
576
- return left + `{${varName}}`;
577
- }
578
- if (right !== null && t.isMemberExpression(node.left) && t.isIdentifier(node.left.property)) {
579
- const varName = node.left.property.name;
580
- variables.push(varName, ...rightVars);
581
- return `{${varName}}` + right;
877
+ const leftName = right !== null ? operandName(node.left) : null;
878
+ if (right !== null && leftName !== null) {
879
+ variables.push(leftName, ...rightVars);
880
+ expressions.push(node.left, ...rightExprs);
881
+ return `{${leftName}}` + right;
582
882
  }
583
883
  return null;
584
884
  }
585
885
  if (t.isJSXExpressionContainer(node)) {
586
- return extractExpressionContent(node.expression, variables);
886
+ return extractExpressionContent(node.expression, variables, expressions);
587
887
  }
588
888
  return null;
589
889
  }
@@ -606,3 +906,16 @@ function hasUseClientDirective(ast) {
606
906
  }
607
907
  return false;
608
908
  }
909
+ /** A top-level import or declaration already binds `name` (e.g. `t` from an earlier run's `@/i18n` import). */
910
+ function bindsTopLevel(ast, name) {
911
+ for (const node of ast.program.body) {
912
+ if (t.isImportDeclaration(node) && node.specifiers.some((sp) => sp.local.name === name))
913
+ return true;
914
+ const decl = t.isExportNamedDeclaration(node) ? node.declaration : node;
915
+ if (t.isVariableDeclaration(decl) && decl.declarations.some((d) => t.isIdentifier(d.id, { name })))
916
+ return true;
917
+ if ((t.isFunctionDeclaration(decl) || t.isClassDeclaration(decl)) && decl.id?.name === name)
918
+ return true;
919
+ }
920
+ return false;
921
+ }
@@ -19,7 +19,7 @@ async function resolveIcuOptions(workspace, llm) {
19
19
  return { sentences: false, plurals: true, icuRuntime: false, targetPlurals: false };
20
20
  }
21
21
  const deps = { ...workspace.packageJson?.dependencies, ...workspace.packageJson?.devDependencies };
22
- const strategy = ['react', 'nextjs'].includes(workspace.framework) ? StrategyFactory_1.StrategyFactory.getStrategy(workspace.framework, deps).id : 'other';
22
+ const strategy = ['react', 'nextjs', 'react-native'].includes(workspace.framework) ? StrategyFactory_1.StrategyFactory.getStrategy(workspace.framework, deps).id : 'other';
23
23
  let icuRuntime = false;
24
24
  let sentences = true;
25
25
  if (strategy === 'next-intl') {
@@ -130,9 +130,12 @@ async function findFilesUncached(dir, extensions, excludePatterns = []) {
130
130
  }
131
131
  // Whole path segments only: a substring match excluded files like
132
132
  // `components/Calibrate.tsx` ("lib") or `app/distance/page.tsx` ("dist").
133
+ // A leading `/` anchors to the walk root (`/ios` is the native
134
+ // project, not `src/screens/ios`).
133
135
  if (pattern.includes('/')) {
134
136
  const rel = relativePath.split(path.sep).join('/');
135
- return rel === pattern || rel.startsWith(`${pattern}/`);
137
+ const anchored = pattern.startsWith('/') ? pattern.slice(1) : pattern;
138
+ return rel === anchored || rel.startsWith(`${anchored}/`);
136
139
  }
137
140
  if (entry.name !== pattern)
138
141
  return false;
@@ -0,0 +1,18 @@
1
+ import type { WorkspaceInfo } from '../runtime/types';
2
+ type ConfigLocation = Pick<WorkspaceInfo, 'framework' | 'sourceDirs' | 'publicDir'>;
3
+ /**
4
+ * The directory (workspace-relative) holding Babelize's generated i18next
5
+ * config, `i18n.ts`/`i18n.js`. Web: the source directory (`src`). React
6
+ * Native: next to the bundled locales, `src/` or the project root — never
7
+ * Expo Router's `app/`, where every file is a route.
8
+ */
9
+ export declare function i18nConfigDir(workspace: ConfigLocation): string;
10
+ /** The config's module path without extension, workspace-relative and '/'-separated (`src/i18n`, `i18n`). */
11
+ export declare function i18nConfigModule(workspace: ConfigLocation): string;
12
+ /**
13
+ * Import specifier for the generated config from `fromFile` (workspace-relative):
14
+ * `./i18n`, `../i18n`, `../../src/i18n`. Relative, because Metro and plain
15
+ * Vite/CRA projects have no `@/` alias.
16
+ */
17
+ export declare function i18nImportPath(workspace: ConfigLocation, fromFile: string): string;
18
+ export {};
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.i18nConfigDir = i18nConfigDir;
37
+ exports.i18nConfigModule = i18nConfigModule;
38
+ exports.i18nImportPath = i18nImportPath;
39
+ const path = __importStar(require("path"));
40
+ /**
41
+ * The directory (workspace-relative) holding Babelize's generated i18next
42
+ * config, `i18n.ts`/`i18n.js`. Web: the source directory (`src`). React
43
+ * Native: next to the bundled locales, `src/` or the project root — never
44
+ * Expo Router's `app/`, where every file is a route.
45
+ */
46
+ function i18nConfigDir(workspace) {
47
+ if (workspace.framework === 'react-native')
48
+ return workspace.publicDir || '.';
49
+ return workspace.sourceDirs.find((d) => d.includes('src')) || workspace.sourceDirs[0] || '.';
50
+ }
51
+ /** The config's module path without extension, workspace-relative and '/'-separated (`src/i18n`, `i18n`). */
52
+ function i18nConfigModule(workspace) {
53
+ return path.posix.join(i18nConfigDir(workspace).split(path.sep).join('/'), 'i18n');
54
+ }
55
+ /**
56
+ * Import specifier for the generated config from `fromFile` (workspace-relative):
57
+ * `./i18n`, `../i18n`, `../../src/i18n`. Relative, because Metro and plain
58
+ * Vite/CRA projects have no `@/` alias.
59
+ */
60
+ function i18nImportPath(workspace, fromFile) {
61
+ const from = path.posix.dirname(fromFile.split(path.sep).join('/'));
62
+ const rel = path.posix.relative(from, i18nConfigModule(workspace));
63
+ return rel.startsWith('.') ? rel : `./${rel}`;
64
+ }
@@ -96,7 +96,7 @@ async function detectI18nextIcu(workspace) {
96
96
  for (const dir of new Set(['.', ...workspace.sourceDirs])) {
97
97
  let files = [];
98
98
  try {
99
- files = (await (0, file_utils_1.findFiles)(path.join(workspace.path, dir), types_1.SUPPORTED_EXTENSIONS, CONFIG_SCAN_EXCLUDES)) ?? [];
99
+ files = (await (0, file_utils_1.findFiles)(path.join(workspace.path, dir), types_1.SUPPORTED_EXTENSIONS, [...CONFIG_SCAN_EXCLUDES, ...(workspace.excludedPatterns ?? [])])) ?? [];
100
100
  }
101
101
  catch {
102
102
  files = [];