@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.
- package/dist/config.d.ts +3 -3
- package/dist/detection/stack.d.ts +3 -1
- package/dist/detection/stack.js +13 -6
- package/dist/injection/StrategyFactory.d.ts +5 -1
- package/dist/injection/StrategyFactory.js +7 -2
- package/dist/injection/strategies/VanillaStrategy.d.ts +8 -0
- package/dist/injection/strategies/VanillaStrategy.js +10 -3
- package/dist/local/translate-core.d.ts +1 -1
- package/dist/local/translate-core.js +1 -1
- package/dist/modules/extractor/extract.js +67 -10
- package/dist/modules/finalization/dependencies.d.ts +10 -0
- package/dist/modules/finalization/dependencies.js +23 -0
- package/dist/modules/finalization/finalize.js +41 -39
- package/dist/modules/finalization/nextjs-finalize.js +2 -1
- package/dist/modules/finalization/react-native-finalize.d.ts +45 -0
- package/dist/modules/finalization/react-native-finalize.js +324 -0
- package/dist/modules/format-injection/index.js +38 -9
- package/dist/modules/format-injection/inject-formats.d.ts +3 -2
- package/dist/modules/format-injection/inject-formats.js +4 -3
- package/dist/modules/injection/inject.d.ts +14 -1
- package/dist/modules/injection/inject.js +307 -154
- package/dist/modules/sync-detector/sync-detector.js +11 -4
- package/dist/modules/translator/line-breaks.d.ts +8 -0
- package/dist/modules/translator/line-breaks.js +63 -0
- package/dist/modules/translator/translate.js +19 -7
- package/dist/modules/workspace/validate.d.ts +13 -1
- package/dist/modules/workspace/validate.js +95 -58
- package/dist/runtime/types.d.ts +22 -0
- package/dist/runtime/types.js +33 -0
- package/dist/utils/ast-helpers.d.ts +30 -2
- package/dist/utils/ast-helpers.js +355 -42
- package/dist/utils/extraction-options.js +1 -1
- package/dist/utils/file-utils.js +4 -1
- package/dist/utils/i18n-config-path.d.ts +18 -0
- package/dist/utils/i18n-config-path.js +64 -0
- package/dist/utils/i18next-icu.js +1 -1
- package/dist/utils/icu-message.d.ts +2 -2
- package/dist/utils/icu-message.js +43 -7
- package/dist/utils/jsx-message.d.ts +16 -1
- package/dist/utils/jsx-message.js +89 -9
- package/dist/utils/key-usage.js +1 -1
- package/dist/utils/metadata.d.ts +5 -1
- package/dist/utils/metadata.js +19 -1
- package/dist/utils/module-constants.d.ts +21 -3
- package/dist/utils/module-constants.js +123 -25
- package/dist/utils/react-native.d.ts +17 -0
- package/dist/utils/react-native.js +108 -0
- package/dist/utils/string-utils.d.ts +8 -0
- package/dist/utils/string-utils.js +29 -1
- package/dist/utils/ui-copy.d.ts +5 -4
- package/dist/utils/ui-copy.js +10 -5
- package/package.json +3 -6
|
@@ -190,11 +190,29 @@ function pluralCategories(locale, type) {
|
|
|
190
190
|
return [...CLDR_ORDER];
|
|
191
191
|
}
|
|
192
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* Whole numbers (0–999) a plural category is used for, e.g. Czech few → 2, 3, 4,
|
|
195
|
+
* Czech many → none (only fractions like 1.5). CLDR integer rules repeat
|
|
196
|
+
* within every hundred, so this range covers them.
|
|
197
|
+
*/
|
|
198
|
+
function integersOf(locale, type, category) {
|
|
199
|
+
try {
|
|
200
|
+
const rules = new Intl.PluralRules(locale, { type });
|
|
201
|
+
const out = [];
|
|
202
|
+
for (let n = 0; n < 1000; n++)
|
|
203
|
+
if (rules.select(n) === category)
|
|
204
|
+
out.push(n);
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
193
211
|
/**
|
|
194
212
|
* Compare a translated ICU message to its source. Errors mean the message is
|
|
195
213
|
* broken at runtime (unparseable, arguments renamed, select branches changed,
|
|
196
|
-
* no `other
|
|
197
|
-
* falls back to `other` for
|
|
214
|
+
* no `other`, a target plural category whole numbers use). A missing category
|
|
215
|
+
* only fractions use is a warning: ICU falls back to `other` for it.
|
|
198
216
|
*/
|
|
199
217
|
function compareIcuMessages(source, translated, targetLocale, options = {}) {
|
|
200
218
|
let sourceAst;
|
|
@@ -291,11 +309,29 @@ function compareIcuMessages(source, translated, targetLocale, options = {}) {
|
|
|
291
309
|
}
|
|
292
310
|
const missing = allowed.filter((c) => !target.keys.includes(c));
|
|
293
311
|
if (missing.length) {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
312
|
+
// A missing category whole numbers reach (Czech few: 2–4) shows the
|
|
313
|
+
// wrong form for common counts ("2 souborů"): an error. One only
|
|
314
|
+
// fractions reach (Czech many: 1.5), or whose numbers all have `=n`
|
|
315
|
+
// branches, falls back to `other` rarely: a warning.
|
|
316
|
+
const type = target.kind === 'selectordinal' ? 'ordinal' : 'cardinal';
|
|
317
|
+
const reached = missing
|
|
318
|
+
.map((c) => ({ c, ns: integersOf(targetLocale, type, c).filter((n) => !target.keys.includes(`=${n}`)) }))
|
|
319
|
+
.filter((m) => m.ns.length > 0);
|
|
320
|
+
if (reached.length) {
|
|
321
|
+
const examples = (ns) => `${ns.slice(0, 3).join(', ')}${ns.length > 3 ? ', …' : ''}`;
|
|
322
|
+
issues.push({
|
|
323
|
+
severity: 'error',
|
|
324
|
+
code: 'icu_plural_category_missing',
|
|
325
|
+
message: `{${target.name}, ${target.kind}} has no ${reached.map((m) => `${m.c} branch (${examples(m.ns)})`).join(', ')} for ${targetLocale}; those counts would get the "other" form`,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
issues.push({
|
|
330
|
+
severity: 'warning',
|
|
331
|
+
code: 'icu_plural_category_missing',
|
|
332
|
+
message: `{${target.name}, ${target.kind}} has no [${missing.join(', ')}] branch for ${targetLocale}; "other" will be used`,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
299
335
|
}
|
|
300
336
|
}
|
|
301
337
|
return issues;
|
|
@@ -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: {
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
name =
|
|
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
|
-
|
|
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 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
|
|
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
|
-
|
|
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);
|
package/dist/utils/key-usage.js
CHANGED
|
@@ -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.
|
|
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;
|
package/dist/utils/metadata.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/utils/metadata.js
CHANGED
|
@@ -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)`.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 (!
|
|
191
|
-
return; //
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
if (
|
|
302
|
+
data.push('exported');
|
|
303
|
+
if (reassigned)
|
|
214
304
|
fail.push('reassigned');
|
|
215
|
-
for (const ref of
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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;
|