55 */
66import * as dotenv from 'dotenv' ;
77import fs from 'fs' ;
8+ // eslint-disable-next-line you-dont-need-lodash-underscore/get
9+ import get from 'lodash/get' ;
810import path from 'path' ;
911import type { TemplateExpression } from 'typescript' ;
1012import ts from 'typescript' ;
@@ -14,6 +16,8 @@ import dedent from '@libs/StringUtils/dedent';
1416import hashStr from '@libs/StringUtils/hash' ;
1517import { isTranslationTargetLocale , LOCALES , TRANSLATION_TARGET_LOCALES } from '@src/CONST/LOCALES' ;
1618import type { Locale , TranslationTargetLocale } from '@src/CONST/LOCALES' ;
19+ import en from '@src/languages/en' ;
20+ import type { TranslationPaths } from '@src/languages/types' ;
1721import CLI from './utils/CLI' ;
1822import Prettier from './utils/Prettier' ;
1923import 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 */
567647async 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
0 commit comments