@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
@@ -10,7 +10,7 @@ import * as t from '@babel/types';
10
10
  *
11
11
  * <h1>Welcome back, {user}</h1> → "Welcome back, {user}"
12
12
  * <p>Hi {user.name}</p> → "Hi {userName}"
13
- * <p>Total: {format(sum)} today</p> → "Total: {0} today"
13
+ * <p>Total: {format(sum)} today</p> → "Total: {sum} today"
14
14
  * <p>Read the <Link href="/t">terms</Link></p> → "Read the <link>terms</link>"
15
15
  * <p>You have {count} items</p> → "You have {count, plural, one {# item} other {# items}}"
16
16
  *
@@ -60,6 +60,21 @@ export type JsxMessageResult = {
60
60
  export declare const ELEMENT_LIKE_NAMES: RegExp[];
61
61
  /** Rendered text of a JSXText node, with JSX's whitespace rules (same as Babel's cleanJSXElementLiteralChild). */
62
62
  export declare function cleanJsxText(value: string): string;
63
+ /**
64
+ * A name for an expression that isn't a plain variable, from the value it
65
+ * shows: `formatDate(deal.endsAt)` → dealEndsAt, `(n / 1000).toFixed(1)` → n,
66
+ * `status || 'Unknown'` → status. Null when nothing names it.
67
+ */
68
+ export declare function derivedNameFor(expr: t.Node): string | null;
69
+ /**
70
+ * `item{n === 1 ? '' : 's'}`: a suffix that pluralizes the word before it.
71
+ * The count, and the suffix for one and for other.
72
+ */
73
+ export declare function pluralSuffix(expr: t.Node): {
74
+ count: t.Expression;
75
+ one: string;
76
+ other: string;
77
+ } | null;
63
78
  /**
64
79
  * Whether the expression is very likely a string or number. Message values
65
80
  * must be: next-intl throws on a React element, i18next prints [object Object].
@@ -38,6 +38,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.ELEMENT_LIKE_NAMES = void 0;
40
40
  exports.cleanJsxText = cleanJsxText;
41
+ exports.derivedNameFor = derivedNameFor;
42
+ exports.pluralSuffix = pluralSuffix;
41
43
  exports.isLikelyPrimitive = isLikelyPrimitive;
42
44
  exports.buildJsxMessage = buildJsxMessage;
43
45
  exports.messageKeyText = messageKeyText;
@@ -163,6 +165,57 @@ function placeholderNameFor(expr) {
163
165
  const name = parts.map((p, i) => (i === 0 ? p : p.charAt(0).toUpperCase() + p.slice(1))).join('');
164
166
  return isSafeName(name) ? name : null;
165
167
  }
168
+ /**
169
+ * A name for an expression that isn't a plain variable, from the value it
170
+ * shows: `formatDate(deal.endsAt)` → dealEndsAt, `(n / 1000).toFixed(1)` → n,
171
+ * `status || 'Unknown'` → status. Null when nothing names it.
172
+ */
173
+ function derivedNameFor(expr) {
174
+ const named = (e) => !e || !t.isExpression(e) ? null : placeholderNameFor(e) ?? derivedNameFor(e);
175
+ if (t.isCallExpression(expr) || t.isOptionalCallExpression(expr)) {
176
+ const callee = expr.callee;
177
+ // `price.toFixed(2)`, `date.toLocaleDateString()`: the value being formatted.
178
+ if ((t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) && !t.isIdentifier(callee.object, { name: 'Math' })) {
179
+ const own = named(callee.object);
180
+ if (own)
181
+ return own;
182
+ }
183
+ return named(expr.arguments[0]);
184
+ }
185
+ if (t.isLogicalExpression(expr) || t.isBinaryExpression(expr))
186
+ return named(expr.left) ?? named(expr.right);
187
+ if (t.isTSAsExpression(expr) || t.isTSNonNullExpression(expr) || t.isParenthesizedExpression(expr))
188
+ return named(expr.expression);
189
+ if (t.isUnaryExpression(expr))
190
+ return named(expr.argument);
191
+ return null;
192
+ }
193
+ /**
194
+ * `item{n === 1 ? '' : 's'}`: a suffix that pluralizes the word before it.
195
+ * The count, and the suffix for one and for other.
196
+ */
197
+ function pluralSuffix(expr) {
198
+ if (!t.isConditionalExpression(expr) || !t.isStringLiteral(expr.consequent) || !t.isStringLiteral(expr.alternate))
199
+ return null;
200
+ const test = expr.test;
201
+ if (!t.isBinaryExpression(test) || !t.isExpression(test.left))
202
+ return null;
203
+ const isOne = (n) => t.isNumericLiteral(n, { value: 1 });
204
+ const count = isOne(test.right) ? test.left : isOne(test.left) ? test.right : null;
205
+ if (!count)
206
+ return null;
207
+ const yes = expr.consequent.value;
208
+ const no = expr.alternate.value;
209
+ if (!/^[a-z]{0,3}$/.test(yes) || !/^[a-z]{0,3}$/.test(no) || yes === no)
210
+ return null;
211
+ const flipped = isOne(test.left);
212
+ const op = flipped ? { '>': '<', '<': '>' }[test.operator] ?? test.operator : test.operator;
213
+ if (op === '===' || op === '==')
214
+ return { count, one: yes, other: no };
215
+ if (op === '!==' || op === '!=' || op === '>')
216
+ return { count, one: no, other: yes };
217
+ return null;
218
+ }
166
219
  /** `t('…')`, `t.rich(…)`, `i18n.t(…)`, `$t(…)` — already translated. */
167
220
  function isTranslationCall(node) {
168
221
  if (!t.isCallExpression(node))
@@ -300,10 +353,10 @@ function buildJsxMessage(node, options = {}) {
300
353
  const phByCode = new Map();
301
354
  const tagByCode = new Map();
302
355
  const usedNames = new Set();
303
- let complexIndex = 0;
304
356
  let unsafe;
305
357
  let voidElement;
306
358
  let placeholderCount = 0;
359
+ let suffixPlural;
307
360
  let tagCount = 0;
308
361
  const uniqueName = (base) => {
309
362
  let name = base;
@@ -326,10 +379,9 @@ function buildJsxMessage(node, options = {}) {
326
379
  phByCode.set(exprCode, name);
327
380
  }
328
381
  else {
329
- while (usedNames.has(String(complexIndex)))
330
- complexIndex++;
331
- name = String(complexIndex++);
332
- usedNames.add(name);
382
+ // Named after what it shows, so translators see `{dealEndsAt}`, not `{0}`.
383
+ const derived = derivedNameFor(expr);
384
+ name = uniqueName(derived && !RESERVED_NAMES.has(derived) ? derived : 'value');
333
385
  }
334
386
  entries.push({ kind: 'placeholder', name, expression: expr });
335
387
  return name;
@@ -361,6 +413,30 @@ function buildJsxMessage(node, options = {}) {
361
413
  pieces.push({ type: 'text', value: expr.quasis[0].value.cooked ?? expr.quasis[0].value.raw });
362
414
  continue;
363
415
  }
416
+ // `application{n === 1 ? '' : 's'}`: one message with a plural, not "application{0}".
417
+ // The code already pluralizes, so this isn't the guess `plurals` gates: a sentence
418
+ // only exists with an ICU runtime, and every translator handles ICU plurals.
419
+ const suffix = pluralSuffix(expr);
420
+ const prev = pieces[pieces.length - 1];
421
+ const word = suffix && prev?.type === 'text' ? /([A-Za-z]+)$/.exec(prev.value) : null;
422
+ if (suffix && word && prev?.type === 'text' && scanExpression(suffix.count).sideEffect === undefined) {
423
+ consumed.push(expr);
424
+ placeholderCount++;
425
+ const before = prev.value.slice(0, -word[1].length);
426
+ const name = addPlaceholder(suffix.count);
427
+ suffixPlural ?? (suffixPlural = name);
428
+ const plural = { type: 'plural', name, one: word[1] + suffix.one, other: word[1] + suffix.other, hash: false };
429
+ // `{n} application{…}`: the count goes inside the plural as `#`.
430
+ const counted = before === ' ' && pieces[pieces.length - 2]?.type === 'ph' && pieces[pieces.length - 2].name === name;
431
+ if (counted) {
432
+ pieces.splice(pieces.length - 2, 2, { ...plural, hash: true });
433
+ }
434
+ else {
435
+ prev.value = before;
436
+ pieces.push(plural);
437
+ }
438
+ continue;
439
+ }
364
440
  const scan = scanExpression(expr);
365
441
  if (scan.translated)
366
442
  throw new NotASentence();
@@ -446,12 +522,13 @@ function buildJsxMessage(node, options = {}) {
446
522
  return { kind: 'unsafe', reason: unsafe };
447
523
  if (voidElement)
448
524
  return { kind: 'unsafe', reason: `${voidElement} has no text to wrap` };
449
- if (meaningful.length < 2)
525
+ // One piece is a sentence only when it's a plural: `{n} result{n !== 1 ? 's' : ''}`.
526
+ if (meaningful.length < 2 && !meaningful.some((p) => p.type === 'plural'))
450
527
  return { kind: 'none' };
451
528
  if (/[{}<>]/.test(collectText(pieces))) {
452
529
  return { kind: 'unsafe', reason: 'the text contains { } < or >, which are message syntax' };
453
530
  }
454
- const plural = options.plurals === false ? undefined : makePlural(pieces, entries);
531
+ const plural = (options.plurals === false ? undefined : makePlural(pieces, entries)) ?? suffixPlural;
455
532
  // JSX text was already cleaned by the JSX rules; collapse leftover runs of
456
533
  // ASCII whitespace (HTML renders them as one space) but keep &nbsp; and the
457
534
  // spaces inside inline elements. Outer edge spaces are kept by the injector.
@@ -459,7 +536,9 @@ function buildJsxMessage(node, options = {}) {
459
536
  return { kind: 'message', message: { text, entries, consumed, plural } };
460
537
  }
461
538
  function collectText(pieces) {
462
- return pieces.map((p) => (p.type === 'text' ? p.value : p.type === 'tag' ? collectText(p.children) : ' ')).join('');
539
+ return pieces
540
+ .map((p) => (p.type === 'text' ? p.value : p.type === 'tag' ? collectText(p.children) : p.type === 'plural' ? ` ${p.other} ` : ' '))
541
+ .join('');
463
542
  }
464
543
  function serialize(pieces) {
465
544
  let out = '';
@@ -475,7 +554,8 @@ function serialize(pieces) {
475
554
  out += `{${p.name}}`;
476
555
  }
477
556
  else if (p.type === 'plural') {
478
- out += `{${p.name}, plural, one {# ${p.one}} other {# ${p.other}}}`;
557
+ const hash = p.hash === false ? '' : '# ';
558
+ out += `{${p.name}, plural, one {${hash}${p.one}} other {${hash}${p.other}}}`;
479
559
  }
480
560
  else {
481
561
  const inner = serialize(p.children);
@@ -113,7 +113,7 @@ async function findKeyUsage(workspace) {
113
113
  const dynamicFiles = [];
114
114
  const seen = new Set();
115
115
  for (const dir of workspace.sourceDirs) {
116
- const files = await (0, file_utils_1.findFiles)(path.join(workspace.path, dir), types_1.SUPPORTED_EXTENSIONS, types_1.EXCLUDED_PATTERNS);
116
+ const files = await (0, file_utils_1.findFiles)(path.join(workspace.path, dir), types_1.SUPPORTED_EXTENSIONS, (0, types_1.workspaceExcludes)(workspace));
117
117
  for (const file of files) {
118
118
  if (seen.has(file))
119
119
  continue;
@@ -33,7 +33,7 @@ export interface BabelizeConfig {
33
33
  excludePatterns: string[];
34
34
  customTranslations: Record<string, Record<string, string>>;
35
35
  }
36
- export type FrameworkType = 'react' | 'nextjs' | 'vue' | 'angular' | 'svelte' | 'astro' | 'remix' | 'gatsby' | 'nuxt' | 'static' | 'django' | 'unknown';
36
+ export type FrameworkType = 'react' | 'nextjs' | 'vue' | 'angular' | 'svelte' | 'astro' | 'remix' | 'gatsby' | 'nuxt' | 'static' | 'django' | 'react-native' | 'unknown';
37
37
  export type PackageManagerType = 'npm' | 'yarn' | 'pnpm' | 'bun' | 'pip' | 'poetry' | 'pipenv' | 'uv' | 'pdm';
38
38
  export interface ProjectInfo {
39
39
  framework: FrameworkType;
@@ -43,6 +43,8 @@ export interface ProjectInfo {
43
43
  packageManagers: PackageManagerType[];
44
44
  /** Set when the project is built with something Babelize doesn't support yet, e.g. "Laravel". */
45
45
  unsupportedFramework?: string;
46
+ /** The app's folder in a monorepo (`apps/mobile`); unset when the app is the repository root. */
47
+ appDir?: string;
46
48
  nodeVersion?: string;
47
49
  projectVersion?: string;
48
50
  projectName?: string;
@@ -57,6 +59,8 @@ export interface ProjectInfo {
57
59
  gatsby?: string;
58
60
  nuxt?: string;
59
61
  'react-i18next'?: string;
62
+ 'react-native'?: string;
63
+ expo?: string;
60
64
  };
61
65
  detectedAt: string;
62
66
  }
@@ -56,6 +56,7 @@ const fs = __importStar(require("fs-extra"));
56
56
  const path = __importStar(require("path"));
57
57
  const logger_1 = require("./logger");
58
58
  const stack_1 = require("../detection/stack");
59
+ const validate_1 = require("../modules/workspace/validate");
59
60
  const DEFAULT_CONFIG = {
60
61
  sourceLocale: 'en',
61
62
  targetLocales: [],
@@ -178,12 +179,26 @@ async function detectProjectInfo(workspacePath) {
178
179
  });
179
180
  }
180
181
  }
182
+ // A monorepo whose root has no framework: describe the app package
183
+ // (apps/mobile), the one runs translate.
184
+ let appDir;
185
+ const rootDeps = { ...packageJson.dependencies, ...packageJson.devDependencies };
186
+ if (!['next', 'expo', 'react-native', 'nuxt', 'vue', 'react', 'react-dom'].some((d) => rootDeps[d])) {
187
+ const app = await (0, validate_1.findWorkspaceApp)(workspacePath, packageJson).catch(() => null);
188
+ if (app) {
189
+ appDir = app.dir;
190
+ packageJson = app.packageJson;
191
+ }
192
+ }
181
193
  const deps = { ...packageJson.dependencies, ...packageJson.devDependencies };
182
194
  const frameworks = [];
183
195
  let primaryFramework = 'unknown';
184
196
  let frameworkVersion;
185
197
  const frameworkChecks = [
186
198
  { name: 'nextjs', dep: 'next' },
199
+ // Before react: every Expo / React Native app depends on react too.
200
+ { name: 'react-native', dep: 'expo' },
201
+ { name: 'react-native', dep: 'react-native' },
187
202
  { name: 'nuxt', dep: 'nuxt' },
188
203
  { name: 'gatsby', dep: 'gatsby' },
189
204
  { name: 'remix', dep: '@remix-run/react' },
@@ -194,7 +209,7 @@ async function detectProjectInfo(workspacePath) {
194
209
  { name: 'react', dep: 'react' },
195
210
  ];
196
211
  for (const check of frameworkChecks) {
197
- if (deps[check.dep]) {
212
+ if (deps[check.dep] && !frameworks.includes(check.name)) {
198
213
  frameworks.push(check.name);
199
214
  if (primaryFramework === 'unknown') {
200
215
  primaryFramework = check.name;
@@ -222,6 +237,7 @@ async function detectProjectInfo(workspacePath) {
222
237
  const primaryPackageManager = packageManagers.length > 0 ? packageManagers[0] : 'unknown';
223
238
  const projectInfo = {
224
239
  framework: primaryFramework,
240
+ ...(appDir ? { appDir } : {}),
225
241
  frameworks: frameworks.length > 0 ? frameworks : undefined,
226
242
  frameworkVersion,
227
243
  packageManager: primaryPackageManager,
@@ -240,6 +256,8 @@ async function detectProjectInfo(workspacePath) {
240
256
  gatsby: deps['gatsby'],
241
257
  nuxt: deps['nuxt'],
242
258
  'react-i18next': deps['react-i18next'],
259
+ 'react-native': deps['react-native'],
260
+ expo: deps['expo'],
243
261
  },
244
262
  detectedAt: new Date().toISOString(),
245
263
  };
@@ -11,18 +11,36 @@ import * as t from '@babel/types';
11
11
  * There's no hook at module level, so the strings can't be replaced in
12
12
  * place. When every use of the copy is provably a render (a JSX child, a
13
13
  * translatable attribute, or a React `key`), the constant's strings become
14
- * translation keys and each render becomes `t(tab)`. Otherwise — the values
15
- * are also data (compared, sent, used in URLs) — nothing is rewritten and the
16
- * strings are reported, so a run doesn't claim everything was translated.
14
+ * translation keys and each render becomes `t(tab)`.
15
+ *
16
+ * When the values are also data (compared, sent, used in URLs), the constant
17
+ * stays as it is and only what's displayed is translated, through a map from
18
+ * value to key: `label={t(NICHES_KEYS[n] ?? n)}` while `selected === n` and
19
+ * the API keep the English value. Anything else that can't be wired safely
20
+ * (form data, a sentence, outside a component) is reported, so a run doesn't
21
+ * claim everything was translated.
17
22
  */
18
23
  export interface ModuleConstantPlan {
19
24
  name: string;
25
+ /** Where a value → key map goes: after the constant, or before the statement holding an inline list. */
26
+ anchor: NodePath<t.Statement>;
27
+ insert: 'after' | 'before';
20
28
  /** Every use of the copy is a render: safe to rewrite. */
21
29
  provable: boolean;
30
+ /** Rendered, and also used as data: keep the values, translate the renders through a value → key map. */
31
+ labels: boolean;
22
32
  /** The UI strings that are rendered (rewritten to keys when provable). */
23
33
  literals: Array<NodePath<t.StringLiteral>>;
34
+ /** Field of each literal: '' for an array of strings, the property name for objects. */
35
+ literalFields: string[];
24
36
  /** Expressions that render a string (`tab`, `item.label`), wrapped in `t()` when provable. */
25
37
  renderSites: Array<NodePath<t.Expression>>;
38
+ /** Field of each render site. */
39
+ renderFields: string[];
40
+ /** Value → key maps an earlier run added, by field: their renders are already wrapped. */
41
+ labelMaps: Map<string, string>;
42
+ /** Fields with values that aren't copy: those aren't in the map and are shown as they are. */
43
+ partialFields: Set<string>;
26
44
  /** Why it isn't provable, when it isn't. */
27
45
  reason?: string;
28
46
  }
@@ -77,6 +77,47 @@ function inSentence(p) {
77
77
  }
78
78
  return false;
79
79
  }
80
+ const MAP_NAME = /(?:_KEYS|Keys)\d*$/;
81
+ /** `X_KEYS[v]` with X_KEYS a module const object Babelize named: the map's name. */
82
+ function mapLookup(node, scope) {
83
+ if (!t.isMemberExpression(node) || !node.computed || !t.isIdentifier(node.object) || !MAP_NAME.test(node.object.name))
84
+ return null;
85
+ const binding = scope.getBinding(node.object.name);
86
+ if (!binding || binding.kind !== 'const' || !t.isVariableDeclarator(binding.path.node) || !t.isObjectExpression(binding.path.node.init))
87
+ return null;
88
+ return { map: node.object.name, value: node.property };
89
+ }
90
+ /**
91
+ * A render an earlier run wired through a value → key map, in either form it
92
+ * writes: `t(X_KEYS[v] ?? v)`, or `X_KEYS[v] ? t(X_KEYS[v]) : v` when some
93
+ * values aren't in the map. The map's name, or null.
94
+ */
95
+ function labelSite(node, scope) {
96
+ const translatorCall = (n) => t.isCallExpression(n) && t.isIdentifier(n.callee) && n.arguments.length === 1 ? n.arguments[0] : null;
97
+ const arg = translatorCall(node);
98
+ if (arg && t.isLogicalExpression(arg) && arg.operator === '??') {
99
+ const lookup = mapLookup(arg.left, scope);
100
+ return lookup && t.isNodesEquivalent(lookup.value, arg.right) ? lookup.map : null;
101
+ }
102
+ if (t.isConditionalExpression(node)) {
103
+ const lookup = mapLookup(node.test, scope);
104
+ const inner = mapLookup(translatorCall(node.consequent), scope);
105
+ if (!lookup || !inner || inner.map !== lookup.map)
106
+ return null;
107
+ return t.isNodesEquivalent(lookup.value, inner.value) && t.isNodesEquivalent(lookup.value, node.alternate) ? lookup.map : null;
108
+ }
109
+ return null;
110
+ }
111
+ /** `expr` (the `v`, or `p.label`) is one of the uses inside such a render: the map's name. */
112
+ function labelMapAt(expr) {
113
+ let cur = expr.parentPath;
114
+ for (let depth = 0; cur && depth < 4; depth++, cur = cur.parentPath) {
115
+ const map = labelSite(cur.node, cur.scope);
116
+ if (map)
117
+ return map;
118
+ }
119
+ return null;
120
+ }
80
121
  function memberName(p) {
81
122
  if (!p.isMemberExpression() || p.node.computed || !t.isIdentifier(p.node.property))
82
123
  return null;
@@ -93,21 +134,58 @@ function analyzeModuleConstants(ast) {
93
134
  if (!decl?.isVariableDeclaration() || decl.node.kind !== 'const')
94
135
  continue;
95
136
  for (const d of decl.get('declarations')) {
96
- const plan = planFor(d, exported);
137
+ if (!t.isIdentifier(d.node.id))
138
+ continue;
139
+ const binding = d.scope.getBinding(d.node.id.name);
140
+ if (!binding)
141
+ continue;
142
+ const plan = planFor({
143
+ name: d.node.id.name,
144
+ init: d.get('init'),
145
+ references: binding.referencePaths,
146
+ reassigned: binding.constantViolations.length > 0,
147
+ exported,
148
+ anchor: stmt,
149
+ insert: 'after',
150
+ });
97
151
  if (plan)
98
152
  plans.push(plan);
99
153
  }
100
154
  }
101
- program.stop();
155
+ },
156
+ // `{["Instagram", "Both"].map((p) => <Chip label={p} onPress={() => save(p)} />)}`: an
157
+ // inline list in a component. Shown only, its values are translated in place like any
158
+ // string; also used as data, it gets a map like a constant.
159
+ ArrayExpression(list) {
160
+ const member = list.parentPath;
161
+ if (!member?.isMemberExpression() || member.node.object !== list.node || memberName(member) !== 'map')
162
+ return;
163
+ if (!inComponent(list))
164
+ return;
165
+ const cb = member.parentPath.isCallExpression() ? member.parentPath.get('arguments')[0] : undefined;
166
+ const param = cb && (cb.isArrowFunctionExpression() || cb.isFunctionExpression()) ? cb.node.params[0] : undefined;
167
+ if (!t.isIdentifier(param))
168
+ return;
169
+ const anchor = list.find((p) => !!p.parentPath?.isProgram());
170
+ if (!anchor)
171
+ return;
172
+ const plan = planFor({
173
+ name: param.name.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase(),
174
+ init: list,
175
+ references: [list],
176
+ reassigned: false,
177
+ exported: false,
178
+ anchor,
179
+ insert: 'before',
180
+ });
181
+ // Shown only: the in-place translation handles it.
182
+ if (plan && !plan.provable)
183
+ plans.push(plan);
102
184
  },
103
185
  });
104
186
  return plans;
105
187
  }
106
- function planFor(d, exported) {
107
- if (!t.isIdentifier(d.node.id))
108
- return null;
109
- const name = d.node.id.name;
110
- const init = d.get('init');
188
+ function planFor({ name, init, references, reassigned, exported, anchor, insert }) {
111
189
  const isCopy = (s) => (0, string_utils_1.isTranslatableText)(s.trim());
112
190
  // Per "field" ('' for an array of strings): the UI-copy literals that would
113
191
  // become keys, every translatable literal (reported if not provable), and
@@ -116,7 +194,10 @@ function planFor(d, exported) {
116
194
  // "FAQ"]` with only "Overview" keyed would render a missing key for FAQ.
117
195
  const byField = new Map();
118
196
  const translatable = new Map();
197
+ // A value that isn't a string literal (or is missing): renders can't be translated at all.
119
198
  const incomplete = new Set();
199
+ // A literal that isn't copy ("FMCG", "YouTube"): shown as it is, beside the translated ones.
200
+ const partial = new Map();
120
201
  const push = (map, field, lit) => map.set(field, [...(map.get(field) ?? []), lit]);
121
202
  const consider = (field, value, uiCopy) => {
122
203
  if (!value.isStringLiteral()) {
@@ -128,7 +209,7 @@ function planFor(d, exported) {
128
209
  if (isCopy(value.node.value) && uiCopy(value.node.value))
129
210
  push(byField, field, value);
130
211
  else
131
- incomplete.add(field);
212
+ partial.set(field, [...(partial.get(field) ?? []), value.node.value]);
132
213
  };
133
214
  let kind;
134
215
  const objectFields = (objects) => {
@@ -181,14 +262,19 @@ function planFor(d, exported) {
181
262
  }
182
263
  if (translatable.size === 0)
183
264
  return null;
184
- const binding = d.scope.getBinding(name);
185
- if (!binding)
186
- return null;
187
265
  const sites = new Map();
266
+ // What stops any rewrite, and uses as data (which only rule out rewriting the values).
188
267
  const fail = [];
268
+ const data = [];
269
+ const labelMaps = new Map();
189
270
  const site = (field, expr) => {
190
- if (!translatable.has(field))
191
- return; // nothing translatable rendered from it
271
+ if (!byField.has(field))
272
+ return; // no copy rendered from it
273
+ const mapped = labelMapAt(expr);
274
+ if (mapped) {
275
+ labelMaps.set(field, mapped);
276
+ return;
277
+ }
192
278
  const pos = positionOf(expr);
193
279
  if (pos === 'render') {
194
280
  if (!inComponent(expr))
@@ -197,6 +283,9 @@ function planFor(d, exported) {
197
283
  fail.push('rendered inside a sentence');
198
284
  else if (incomplete.has(field))
199
285
  fail.push('not every value is a translatable string');
286
+ // Values left as they are render through the map's `?? value`; i18next would read a `:` as a namespace.
287
+ else if (partial.has(field))
288
+ data.push('not every value is copy: those are shown as they are');
200
289
  sites.set(field, [...(sites.get(field) ?? []), expr]);
201
290
  }
202
291
  else if (pos === 'form') {
@@ -204,15 +293,16 @@ function planFor(d, exported) {
204
293
  fail.push(`${expr.toString()} is form data (<option> without a value, or <textarea>)`);
205
294
  sites.set(field, [...(sites.get(field) ?? []), expr]);
206
295
  }
207
- else if (pos === 'other') {
208
- fail.push(`${expr.toString()} is used as data`);
296
+ else if (pos === 'other' && byField.has(field)) {
297
+ // A field that isn't copy (`tab.id`, `p.icon`) is never rewritten: using it as data is fine.
298
+ data.push(`${expr.toString()} is used as data`);
209
299
  }
210
300
  };
211
301
  if (exported)
212
- fail.push('exported');
213
- if (binding.constantViolations.length)
302
+ data.push('exported');
303
+ if (reassigned)
214
304
  fail.push('reassigned');
215
- for (const ref of binding.referencePaths) {
305
+ for (const ref of references) {
216
306
  const parent = ref.parentPath;
217
307
  const prop = memberName(parent);
218
308
  if ((kind === 'strings' || kind === 'objects') && prop === 'map' && parent.parentPath?.isCallExpression() && parent.parentPath.node.callee === parent.node) {
@@ -235,7 +325,7 @@ function planFor(d, exported) {
235
325
  const member = use.parentPath;
236
326
  const field = memberName(member);
237
327
  if (field === null || !member.isMemberExpression() || member.node.object !== use.node) {
238
- fail.push(`${param.name} is used as a whole`);
328
+ data.push(`${param.name} is used as a whole`);
239
329
  }
240
330
  else {
241
331
  site(field, member);
@@ -252,14 +342,22 @@ function planFor(d, exported) {
252
342
  site('', parent);
253
343
  continue;
254
344
  }
255
- fail.push(`${name} is used as data`);
345
+ data.push(`${name} is used as data`);
256
346
  }
257
- if (sites.size === 0)
347
+ if (sites.size === 0 && labelMaps.size === 0)
258
348
  return null; // never rendered: it's data
259
- const provable = fail.length === 0;
260
- // Provable: the keyed literals (all values of each rendered field).
349
+ const provable = fail.length === 0 && data.length === 0 && labelMaps.size === 0;
350
+ const labels = fail.length === 0 && !provable;
351
+ // Rewritable: the keyed literals (all values of each rendered field).
261
352
  // Otherwise: every translatable literal that is rendered, to report.
262
- const literals = [...sites.keys()].flatMap((f) => (provable ? byField : translatable).get(f) ?? []);
353
+ const fields = [...new Set([...sites.keys(), ...labelMaps.keys()])];
354
+ const literalFields = [];
355
+ const literals = fields.flatMap((f) => {
356
+ const lits = (provable || labels ? byField : translatable).get(f) ?? [];
357
+ literalFields.push(...lits.map(() => f));
358
+ return lits;
359
+ });
360
+ const renderFields = [...sites].flatMap(([f, list]) => list.map(() => f));
263
361
  const renderSites = [...sites.values()].flat();
264
- return { name, provable, literals, renderSites, reason: fail[0] };
362
+ return { name, anchor, insert, provable, labels, literals, literalFields, renderSites, renderFields, labelMaps, partialFields: new Set(partial.keys()), reason: fail[0] ?? data[0] };
265
363
  }
@@ -0,0 +1,17 @@
1
+ import * as t from '@babel/types';
2
+ import type { NodePath } from '@babel/traverse';
3
+ /**
4
+ * `options={{ title: 'Home' }}`, `screenOptions={{ headerTitle: 'Settings' }}`,
5
+ * `options={({ route }) => ({ tabBarLabel: 'Feed' })}`: the literal is the
6
+ * value of a title key inside an options / screenOptions attribute.
7
+ */
8
+ export declare function isNavigationTitle(path: NodePath): boolean;
9
+ /** `Alert.alert(…)` / `Alert.prompt(…)` from react-native. */
10
+ export declare function isAlertCall(callee: t.Node): boolean;
11
+ /** The title or message argument of an Alert call (button labels are `text:` properties). */
12
+ export declare function isAlertTextArgument(path: NodePath): boolean;
13
+ /**
14
+ * A two-letter word that is still UI copy in a string literal: "OK", "No",
15
+ * "Go". Literals shorter than three characters are otherwise dropped as noise.
16
+ */
17
+ export declare function isShortUiWord(text: string): boolean;