Skip to content

Commit 371c8f3

Browse files
authored
Merge pull request Expensify#66175 from Expensify/Rory-TranslatePaths
[No QA] --paths input for generateTranslations
2 parents 38e0501 + 2dbec44 commit 371c8f3

4 files changed

Lines changed: 555 additions & 15 deletions

File tree

scripts/generateTranslations.ts

Lines changed: 114 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
*/
66
import * as dotenv from 'dotenv';
77
import fs from 'fs';
8+
// eslint-disable-next-line you-dont-need-lodash-underscore/get
9+
import get from 'lodash/get';
810
import path from 'path';
911
import type {TemplateExpression} from 'typescript';
1012
import ts from 'typescript';
@@ -14,6 +16,8 @@ import dedent from '@libs/StringUtils/dedent';
1416
import hashStr from '@libs/StringUtils/hash';
1517
import {isTranslationTargetLocale, LOCALES, TRANSLATION_TARGET_LOCALES} from '@src/CONST/LOCALES';
1618
import type {Locale, TranslationTargetLocale} from '@src/CONST/LOCALES';
19+
import en from '@src/languages/en';
20+
import type {TranslationPaths} from '@src/languages/types';
1721
import CLI from './utils/CLI';
1822
import Prettier from './utils/Prettier';
1923
import PromisePool from './utils/PromisePool';
@@ -87,6 +91,11 @@ class TranslationGenerator {
8791
*/
8892
private readonly compareRef: string;
8993

94+
/**
95+
* Specific paths to retranslate (supersedes compareRef).
96+
*/
97+
private readonly paths: TranslationPaths[] | undefined;
98+
9099
/**
91100
* Should we print verbose logs?
92101
*/
@@ -99,13 +108,22 @@ class TranslationGenerator {
99108
*/
100109
private readonly translatedSpanHashToEnglishSpanHash = new Map<number, number>();
101110

102-
constructor(config: {targetLanguages: TranslationTargetLocale[]; languagesDir: string; sourceFile: string; translator: Translator; compareRef: string; verbose: boolean}) {
111+
constructor(config: {
112+
targetLanguages: TranslationTargetLocale[];
113+
languagesDir: string;
114+
sourceFile: string;
115+
translator: Translator;
116+
compareRef: string;
117+
paths?: TranslationPaths[];
118+
verbose: boolean;
119+
}) {
103120
this.targetLanguages = config.targetLanguages;
104121
this.languagesDir = config.languagesDir;
105122
const sourceCode = fs.readFileSync(config.sourceFile, 'utf8');
106123
this.sourceFile = ts.createSourceFile(config.sourceFile, sourceCode, ts.ScriptTarget.Latest, true);
107124
this.translator = config.translator;
108125
this.compareRef = config.compareRef;
126+
this.paths = config.paths;
109127
this.verbose = config.verbose;
110128
}
111129

@@ -115,8 +133,11 @@ class TranslationGenerator {
115133
// map of translations for each locale
116134
const translations = new Map<TranslationTargetLocale, Map<number, string>>();
117135

118-
// If a compareRef is provided, fetch the old version of the files, and traverse the ASTs in parallel to extract existing translations
119-
if (this.compareRef) {
136+
// If paths are specified, we only retranslate those paths and skip comparing with existing translations
137+
const shouldUseExistingTranslations = !this.paths && this.compareRef;
138+
139+
// If a compareRef is provided and we're not filtering by paths, fetch the old version of the files, and traverse the ASTs in parallel to extract existing translations
140+
if (shouldUseExistingTranslations) {
120141
const allLocales: Locale[] = [LOCALES.EN, ...this.targetLanguages];
121142

122143
// An array of labeled "translation nodes", where "translations node" refers to the main object in en.ts and
@@ -265,6 +286,11 @@ class TranslationGenerator {
265286
return false;
266287
}
267288

289+
// Don't translate property keys (the name part of property assignments)
290+
if (node.parent && ts.isPropertyAssignment(node.parent) && node.parent.name === node) {
291+
return false;
292+
}
293+
268294
// Don't translate any strings or expressions that affect code execution by being part of control flow.
269295
// We want to translate only strings that are "leaves" or "results" of any expression or code block
270296
const isPartOfControlFlow =
@@ -402,14 +428,47 @@ class TranslationGenerator {
402428
return hashStr(keyBase);
403429
}
404430

431+
/**
432+
* Check if a given translation path should be translated based on the paths filter.
433+
* If no paths are specified, all paths should be translated.
434+
* If paths are specified, only paths that match exactly or are nested under a specified path should be translated.
435+
*/
436+
private shouldTranslatePath(currentPath: string): boolean {
437+
if (!this.paths || this.paths.length === 0) {
438+
return true;
439+
}
440+
441+
for (const targetPath of this.paths) {
442+
// Exact match
443+
if (currentPath === targetPath) {
444+
return true;
445+
}
446+
// Current path is nested under target path
447+
if (currentPath.startsWith(`${targetPath}.`)) {
448+
return true;
449+
}
450+
// Target path is nested under current path (for parent path matching)
451+
if (targetPath.startsWith(`${currentPath}.`)) {
452+
return true;
453+
}
454+
}
455+
456+
return false;
457+
}
458+
405459
/**
406460
* Recursively extract all string literals and templates to translate from the subtree rooted at the given node.
407461
* Simple templates (as defined by this.isSimpleTemplateExpression) can be translated directly.
408462
* Complex templates must have each of their spans recursively translated first, so we'll extract all the lowest-level strings to translate.
409463
* Then complex templates will be serialized with a hash of complex spans in place of the span text, and we'll translate that.
410464
*/
411-
private extractStringsToTranslate(node: ts.Node, stringsToTranslate: Map<number, StringWithContext>) {
465+
private extractStringsToTranslate(node: ts.Node, stringsToTranslate: Map<number, StringWithContext>, currentPath = '') {
412466
if (this.shouldNodeBeTranslated(node)) {
467+
// Check if this translation path should be included based on the paths filter
468+
if (!this.shouldTranslatePath(currentPath)) {
469+
return; // Skip this node and its children if the path doesn't match
470+
}
471+
413472
const context = this.getContextForNode(node);
414473
const translationKey = this.getTranslationKey(node);
415474

@@ -426,12 +485,33 @@ class TranslationGenerator {
426485
if (this.verbose) {
427486
console.debug('😵‍💫 Encountered complex template, recursively translating its spans first:', node.getText());
428487
}
429-
node.templateSpans.forEach((span) => this.extractStringsToTranslate(span, stringsToTranslate));
488+
node.templateSpans.forEach((span) => this.extractStringsToTranslate(span, stringsToTranslate, currentPath));
430489
stringsToTranslate.set(translationKey, {text: this.templateExpressionToString(node), context});
431490
}
432491
}
433492
}
434-
node.forEachChild((child) => this.extractStringsToTranslate(child, stringsToTranslate));
493+
494+
// Continue traversing children
495+
node.forEachChild((child) => {
496+
let childPath = currentPath;
497+
498+
// If the child is a property assignment, update the path
499+
if (ts.isPropertyAssignment(child)) {
500+
let propName: string | undefined;
501+
502+
if (ts.isIdentifier(child.name)) {
503+
propName = child.name.text;
504+
} else if (ts.isStringLiteral(child.name)) {
505+
propName = child.name.text;
506+
}
507+
508+
if (propName) {
509+
childPath = currentPath ? `${currentPath}.${propName}` : propName;
510+
}
511+
}
512+
513+
this.extractStringsToTranslate(child, stringsToTranslate, childPath);
514+
});
435515
}
436516

437517
/**
@@ -565,6 +645,9 @@ class TranslationGenerator {
565645
* The main function mostly contains CLI and file I/O logic, while TS parsing and translation logic is encapsulated in TranslationGenerator.
566646
*/
567647
async function main(): Promise<void> {
648+
const languagesDir = process.env.LANGUAGES_DIR ?? path.join(__dirname, '../src/languages');
649+
const enSourceFile = path.join(languagesDir, 'en.ts');
650+
568651
/* eslint-disable @typescript-eslint/naming-convention */
569652
const cli = new CLI({
570653
flags: {
@@ -597,6 +680,30 @@ async function main(): Promise<void> {
597680
'For incremental translations, this ref is the previous version of the codebase to compare to. Only strings that changed or had their context changed since this ref will be retranslated.',
598681
default: '',
599682
},
683+
paths: {
684+
description: 'Comma-separated list of specific translation paths to retranslate (e.g., "common.save,errors.generic").',
685+
parse: (val: string): TranslationPaths[] => {
686+
const rawPaths = val.split(',').map((translationPath) => translationPath.trim());
687+
const validatedPaths: TranslationPaths[] = [];
688+
const invalidPaths: string[] = [];
689+
690+
for (const rawPath of rawPaths) {
691+
if (get(en, rawPath)) {
692+
validatedPaths.push(rawPath as TranslationPaths);
693+
} else {
694+
invalidPaths.push(rawPath);
695+
}
696+
}
697+
698+
if (invalidPaths.length > 0) {
699+
throw new Error(`found the following invalid paths: ${JSON.stringify(invalidPaths)}`);
700+
}
701+
702+
return validatedPaths;
703+
},
704+
supersedes: ['compare-ref'],
705+
required: false,
706+
},
600707
},
601708
} as const);
602709
/* eslint-enable @typescript-eslint/naming-convention */
@@ -617,15 +724,13 @@ async function main(): Promise<void> {
617724
translator = new ChatGPTTranslator(process.env.OPENAI_API_KEY);
618725
}
619726

620-
const languagesDir = process.env.LANGUAGES_DIR ?? path.join(__dirname, '../src/languages');
621-
const enSourceFile = path.join(languagesDir, 'en.ts');
622-
623727
const generator = new TranslationGenerator({
624728
targetLanguages: cli.namedArgs.locales,
625729
languagesDir,
626730
sourceFile: enSourceFile,
627731
translator,
628732
compareRef: cli.namedArgs['compare-ref'],
733+
paths: cli.namedArgs.paths,
629734
verbose: cli.flags.verbose,
630735
});
631736

scripts/utils/CLI.ts

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,13 @@ type BooleanArg = CLIArg;
2121
* Any other argument is provided raw in process.argv as a string.
2222
* It can remain a string, or can be transformed into another type by a custom `parse` function.
2323
* It can be optional (by providing a default) or required (no default value).
24+
* It can also supersede other named arguments when provided.
2425
*/
2526
type StringArg<T = unknown> = CLIArg & {
2627
default?: T;
2728
parse?: (val: string) => T;
29+
supersedes?: string[];
30+
required?: boolean;
2831
};
2932

3033
/**
@@ -162,6 +165,8 @@ class CLI<TConfig extends CLIConfig> {
162165

163166
const parsedNamedArgs: Partial<Writable<typeof this.namedArgs>> = {};
164167
const parsedPositionalArgs: Partial<Writable<typeof this.positionalArgs>> = {};
168+
const providedNamedArgs = new Set<string>();
169+
165170
let positionalIndex = 0;
166171
for (let i = 0; i < rawArgs.length; i++) {
167172
const rawArg = rawArgs.at(i);
@@ -177,6 +182,8 @@ class CLI<TConfig extends CLIConfig> {
177182
this.flags[rawArgName as keyof typeof this.flags] = true;
178183
} else if (config.namedArgs && rawArgName in config.namedArgs) {
179184
// Arg is a named arg
185+
providedNamedArgs.add(rawArgName);
186+
180187
// Grab the value from the split token, otherwise go for the next token
181188
let argValueBeforeParse = '';
182189
if (rawArgValue) {
@@ -205,16 +212,39 @@ class CLI<TConfig extends CLIConfig> {
205212
}
206213
}
207214

215+
// Handle supersession logic
216+
const supersededArgs = new Set<string>();
217+
for (const [name, spec] of Object.entries(config.namedArgs ?? {})) {
218+
if (providedNamedArgs.has(name) && spec.supersedes) {
219+
for (const supersededArg of spec.supersedes) {
220+
supersededArgs.add(supersededArg);
221+
if (providedNamedArgs.has(supersededArg)) {
222+
console.warn(`⚠️ Warning: --${supersededArg} is superseded by --${name} and will be ignored.`);
223+
}
224+
}
225+
}
226+
}
227+
208228
// Validate that all required args are present, assign defaults where values are not parsed
209229
for (const [name, spec] of Object.entries(config.namedArgs ?? {})) {
210-
if (!(name in parsedNamedArgs)) {
211-
if (spec.default !== undefined) {
212-
parsedNamedArgs[name as keyof typeof parsedNamedArgs] = spec.default as ValueOf<typeof parsedNamedArgs>;
213-
} else {
214-
throw new Error(`Missing required named argument --${name}`);
230+
if (name in parsedNamedArgs) {
231+
if (supersededArgs.has(name)) {
232+
parsedNamedArgs[name as keyof typeof parsedNamedArgs] = undefined as ValueOf<typeof parsedNamedArgs>;
215233
}
234+
} else if (supersededArgs.has(name)) {
235+
// This arg was superseded, so don't require it and don't assign a default
236+
continue;
237+
} else if (spec.default !== undefined) {
238+
parsedNamedArgs[name as keyof typeof parsedNamedArgs] = spec.default as ValueOf<typeof parsedNamedArgs>;
239+
} else if (spec.required === false) {
240+
// Explicitly marked as optional, leave undefined
241+
continue;
242+
} else {
243+
// Arguments without defaults are required by default (unless explicitly marked as optional)
244+
throw new Error(`Missing required named argument --${name}`);
216245
}
217246
}
247+
218248
for (const spec of config.positionalArgs ?? []) {
219249
if (!(spec.name in parsedPositionalArgs)) {
220250
if (spec.default !== undefined) {
@@ -263,7 +293,8 @@ class CLI<TConfig extends CLIConfig> {
263293
console.log('Named Arguments:');
264294
for (const [name, spec] of Object.entries(namedArgs)) {
265295
const defaultLabel = spec.default !== undefined ? ` (default: ${String(spec.default)})` : '';
266-
console.log(` --${name.padEnd(20)} ${spec.description}${defaultLabel}`);
296+
const supersededLabel = spec.supersedes && spec.supersedes.length > 0 ? ` (supersedes: ${spec.supersedes.join(', ')})` : '';
297+
console.log(` --${name.padEnd(20)} ${spec.description}${defaultLabel}${supersededLabel}`);
267298
}
268299
console.log('');
269300
}

0 commit comments

Comments
 (0)