1111/* eslint-disable */
1212import * as fs from 'fs'
1313import glob from 'glob'
14- import main , { Message } from 'typescript-react-intl'
1514import chalk from 'chalk'
16- import { ILanguage } from '@client/i18n/reducer'
15+ import csv2json from 'csv2json'
16+ import { stringify , Options } from 'csv-stringify'
17+ import { promisify } from 'util'
18+ import { sortBy } from 'lodash'
19+ import ts from 'typescript'
20+ import { MessageDescriptor } from 'react-intl'
21+ const csvStringify = promisify < Array < Record < string , any > > , Options > ( stringify )
22+
23+ export async function writeJSONToCSV (
24+ filename : string ,
25+ data : Array < Record < string , any > >
26+ ) {
27+ const csv = await csvStringify ( data , {
28+ header : true
29+ } )
30+ return fs . promises . writeFile ( filename , csv , 'utf8' )
31+ }
1732
18- interface IReactIntlDescriptions {
19- [ key : string ] : string
33+ export async function readCSVToJSON < T > ( filename : string ) {
34+ return new Promise < T > ( ( resolve , reject ) => {
35+ const chunks : string [ ] = [ ]
36+ fs . createReadStream ( filename )
37+ . on ( 'error' , reject )
38+ . pipe (
39+ csv2json ( {
40+ separator : ','
41+ } )
42+ )
43+ . on ( 'data' , ( chunk : string ) => chunks . push ( chunk ) )
44+ . on ( 'error' , reject )
45+ . on ( 'end' , ( ) => {
46+ resolve ( JSON . parse ( chunks . join ( '' ) ) )
47+ } )
48+ } )
2049}
2150
51+ type CSVRow = { id : string ; description : string } & Record < string , string >
52+
2253const write = process . argv . includes ( '--write' )
54+ const outdated = process . argv . includes ( '--outdated' )
55+
2356const COUNTRY_CONFIG_PATH = process . argv [ 2 ]
24- type LocalisationFile = {
25- data : Array < {
26- lang : string
27- displayName : string
28- messages : Record < string , string >
29- } >
30- }
57+
58+ type LocalisationFile = CSVRow [ ]
3159
3260function writeTranslations ( data : LocalisationFile ) {
33- fs . writeFileSync (
34- `${ COUNTRY_CONFIG_PATH } /src/api/content/ client/client.json ` ,
35- JSON . stringify ( data , null , 2 )
61+ return writeJSONToCSV (
62+ `${ COUNTRY_CONFIG_PATH } /src/translations/ client.csv ` ,
63+ data
3664 )
3765}
3866
3967function readTranslations ( ) {
40- return JSON . parse (
41- fs
42- . readFileSync ( `${ COUNTRY_CONFIG_PATH } /src/api/content/client/client.json` )
43- . toString ( )
68+ return readCSVToJSON < CSVRow [ ] > (
69+ `${ COUNTRY_CONFIG_PATH } /src/translations/client.csv`
4470 )
4571}
4672
47- function isEnglish ( obj : ILanguage ) {
48- return obj . lang === 'en-US' || obj . lang === 'en'
73+ function findObjectLiteralsWithIdAndDefaultMessage (
74+ filePath : string ,
75+ sourceCode : string
76+ ) : MessageDescriptor [ ] {
77+ const sourceFile = ts . createSourceFile (
78+ 'temp.ts' ,
79+ sourceCode ,
80+ ts . ScriptTarget . Latest ,
81+ true
82+ )
83+ const matches : MessageDescriptor [ ] = [ ]
84+
85+ function visit ( node : ts . Node ) {
86+ if ( ! ts . isObjectLiteralExpression ( node ) ) {
87+ ts . forEachChild ( node , visit )
88+ return
89+ }
90+ const idProperty = node . properties . find (
91+ ( p ) => ts . isPropertyAssignment ( p ) && p . name . getText ( ) === 'id'
92+ )
93+ const defaultMessageProperty = node . properties . find (
94+ ( p ) => ts . isPropertyAssignment ( p ) && p . name . getText ( ) === 'defaultMessage'
95+ )
96+
97+ if ( ! ( idProperty && defaultMessageProperty ) ) {
98+ ts . forEachChild ( node , visit )
99+ return
100+ }
101+
102+ const objectText = node . getText ( sourceFile ) // The source code representation of the object
103+
104+ try {
105+ const func = new Function ( `return (${ objectText } );` )
106+ const objectValue = func ( )
107+ matches . push ( objectValue )
108+ } catch ( error ) {
109+ console . log ( chalk . yellow . bold ( 'Warning' ) )
110+ console . error (
111+ `Found a dynamic message identifier in file ${ filePath } .` ,
112+ 'Message identifiers should never be dynamic and should always be hardcoded instead.' ,
113+ 'This enables us to confidently verify that a country configuration has all required keys.' ,
114+ '\n' ,
115+ objectText ,
116+ '\n'
117+ )
118+ }
119+
120+ ts . forEachChild ( node , visit )
121+ }
122+
123+ visit ( sourceFile )
124+
125+ return matches
49126}
50127
51128async function extractMessages ( ) {
52129 let translations : LocalisationFile
53130 try {
54- translations = readTranslations ( )
131+ translations = await readTranslations ( )
55132 } catch ( error : unknown ) {
56133 const err = error as Error & { code : string }
57134 if ( err . code === 'ENOENT' ) {
@@ -60,88 +137,118 @@ async function extractMessages() {
60137 `Your environment variables may not be set.
61138 Please add valid COUNTRY_CONFIG_PATH, as an environment variable.
62139 If they are set correctly, then something is wrong with
63- this file: ${ COUNTRY_CONFIG_PATH } /src/api/content/ client/client.json `
140+ this file: ${ COUNTRY_CONFIG_PATH } /src/translations/ client.csv `
64141 )
65142 } else {
66143 console . error ( err )
67144 }
68145 process . exit ( 1 )
69146 }
70- let results : Message [ ] = [ ]
71- const pattern = 'src/**/*.@(tsx|ts)'
72- try {
73- // eslint-disable-line no-console
74- console . log ( 'Checking translations in application...' )
75- console . log ( )
76147
77- glob ( pattern , ( err : any , files ) => {
78- if ( err ) {
79- throw new Error ( err )
80- }
148+ const knownLanguages =
149+ translations . length > 0
150+ ? Object . keys ( translations [ 0 ] ) . filter (
151+ ( key ) => ! [ 'id' , 'description' ] . includes ( key )
152+ )
153+ : [ 'en' ]
81154
82- files . forEach ( ( f ) => {
83- const contents = fs . readFileSync ( f ) . toString ( )
84- results = results . concat ( main ( contents ) )
85- } )
155+ console . log ( 'Checking translations in application...' )
156+ console . log ( )
86157
87- const reactIntlDescriptions : IReactIntlDescriptions = { }
88- results . forEach ( ( r ) => {
89- reactIntlDescriptions [ r . id ] = r . description !
90- } )
91- const englishTranslations = translations . data . find ( isEnglish ) ?. messages
92- const missingKeys = Object . keys ( reactIntlDescriptions ) . filter (
93- ( key ) => ! englishTranslations ?. hasOwnProperty ( key )
94- )
158+ const files = await promisify ( glob ) ( 'src/**/*.@(tsx|ts)' , {
159+ ignore : [ '**/*.test.@(tsx|ts)' , 'src/tests/**/*.*' ]
160+ } )
161+
162+ const messagesParsedFromApp : MessageDescriptor [ ] = files
163+ . map ( ( f ) => {
164+ const contents = fs . readFileSync ( f ) . toString ( )
165+ return findObjectLiteralsWithIdAndDefaultMessage ( f , contents )
166+ } )
167+ . flat ( )
168+
169+ const reactIntlDescriptions : Record < string , string > = Object . fromEntries (
170+ messagesParsedFromApp . map ( ( { id, description } ) => [ id , description || '' ] )
171+ )
172+
173+ const missingKeys = Object . keys ( reactIntlDescriptions ) . filter (
174+ ( key ) => ! translations . find ( ( { id } ) => id === key )
175+ )
176+
177+ if ( outdated ) {
178+ const extraKeys = translations
179+ . map ( ( { id } ) => id )
180+ . filter ( ( key ) => ! reactIntlDescriptions [ key ] )
181+
182+ console . log ( chalk . yellow . bold ( 'Potentially outdated translations' ) )
183+ console . log (
184+ 'The following keys were not found in the code, but are part of the copy file:' ,
185+ '\n'
186+ )
187+ console . log ( extraKeys . join ( '\n' ) )
188+ }
95189
96- if ( missingKeys . length > 0 ) {
97- // eslint-disable-line no-console
98- console . log ( chalk . red . bold ( 'Missing translations' ) )
99- console . log ( `You are missing the following content keys from your country configuration package:\n
190+ if ( missingKeys . length > 0 ) {
191+ // eslint-disable-line no-console
192+ console . log ( chalk . red . bold ( 'Missing translations' ) )
193+ console . log ( `You are missing the following content keys from your country configuration package:\n
100194${ chalk . white ( missingKeys . join ( '\n' ) ) } \n
101195Translate the keys and add them to this file:
102- ${ chalk . white ( `${ COUNTRY_CONFIG_PATH } /src/api/content/client/client.json` ) } `)
103-
104- if ( write ) {
105- console . log (
106- `${ chalk . yellow ( 'Warning ⚠️:' ) } ${ chalk . white (
107- 'The --write command is experimental and only adds new translations for English.'
108- ) } `
109- )
110-
111- const defaultsToBeAdded = missingKeys . map ( ( key ) => [
112- key ,
113- results . find ( ( { id } ) => id === key ) ?. defaultMessage
114- ] )
115- const newEnglishTranslations : Record < string , string > = {
116- ...englishTranslations ,
117- ...Object . fromEntries ( defaultsToBeAdded )
118- }
119-
120- const english = translations . data . find ( isEnglish ) !
121- english . messages = newEnglishTranslations
122- writeTranslations ( translations )
123- } else {
124- console . log ( `
125- ${ chalk . green ( 'Tip 🪄' ) } : ${ chalk . white (
126- `If you want this command do add the missing English keys for you, run it with the ${ chalk . bold (
127- '--write'
128- ) } flag. Note that you still need to add non-English translations to the file.`
129- ) } `)
130- }
131-
132- process . exit ( 1 )
133- }
134-
135- fs . writeFileSync (
136- `${ COUNTRY_CONFIG_PATH } /src/api/content/client/descriptions.json` ,
137- JSON . stringify ( { data : reactIntlDescriptions } , null , 2 )
196+ ${ chalk . white ( `${ COUNTRY_CONFIG_PATH } /src/translations/client.csv` ) } `)
197+
198+ if ( write ) {
199+ console . log (
200+ `${ chalk . yellow ( 'Warning ⚠️:' ) } ${ chalk . white (
201+ 'The --write command is experimental and only adds new translations for English.'
202+ ) } `
138203 )
139- } )
140- } catch ( err ) {
141- // eslint-disable-line no-console
142- console . log ( err )
204+
205+ // This is just to ensure that all languages stay in the CVS file
206+ const emptyLanguages = Object . fromEntries (
207+ knownLanguages . map ( ( lang ) => [ lang , '' ] )
208+ )
209+
210+ const defaultsToBeAdded = missingKeys . map (
211+ ( key ) : CSVRow => ( {
212+ id : key ,
213+ description : reactIntlDescriptions [ key ] ,
214+ ...emptyLanguages ,
215+ en :
216+ messagesParsedFromApp
217+ . find ( ( { id } ) => id === key )
218+ ?. defaultMessage ?. toString ( ) || ''
219+ } )
220+ )
221+
222+ const allIds = Array . from (
223+ new Set (
224+ defaultsToBeAdded
225+ . map ( ( { id } ) => id )
226+ . concat ( translations . map ( ( { id } ) => id ) )
227+ )
228+ )
229+
230+ const allTranslations = allIds . map ( ( id ) => {
231+ const existingTranslation = translations . find (
232+ ( translation ) => translation . id === id
233+ )
234+
235+ return (
236+ existingTranslation ||
237+ defaultsToBeAdded . find ( ( translation ) => translation . id === id ) !
238+ )
239+ } )
240+
241+ await writeTranslations ( sortBy ( allTranslations , ( row ) => row . id ) )
242+ } else {
243+ console . log ( `
244+ ${ chalk . green ( 'Tip 🪄' ) } : ${ chalk . white (
245+ `If you want this command to add the missing English keys for you, run it with the ${ chalk . bold (
246+ '--write'
247+ ) } flag. Note that you still need to add non-English translations to the file.`
248+ ) } `)
249+ }
250+
143251 process . exit ( 1 )
144- return
145252 }
146253}
147254
0 commit comments