Skip to content

Commit 7c53d4d

Browse files
authored
feat: new intl-imports script for atlas src/i18n/index.js generation (#463)
This is a script to be run after atlas has pulled the translations. Micro-frontends needs to run this as part of `make pull_translations` once OEP-58 is finalized. References ---------- This pull request is part of the [FC-0012 project](https://openedx.atlassian.net/l/cp/XGS0iCcQ) which is sparked by the [Translation Infrastructure update OEP-58](https://open-edx-proposals.readthedocs.io/en/latest/architectural-decisions/oep-0058-arch-translations-management.html#specification).
1 parent 2027ce4 commit 7c53d4d

File tree

11 files changed

+448
-5
lines changed

11 files changed

+448
-5
lines changed

example/src/i18n/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Test i18n directories
2+
3+
These test files are used by the `src/i18n/scripts/intl-imports.test.js` file.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"learning.accessExpiration.deadline": "قم بالترقية قبل {date} للاستفادة من دخول غير محدود للمساق طالما هو موجود على الموقع.",
3+
"learning.accessExpiration.header": "تنتهي صلاحية دخول المساق كمستمع في {date}"
4+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"learning.accessExpiration.deadline": "Mejora de categoría antes del {fecha} para obtener acceso ilimitado al curso mientras exista en el sitio.",
3+
"learning.accessExpiration.header": "El acceso a tomar el curso de forma gratuita expira el {fecha}"
4+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Placeholder file
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"learning.accessExpiration.header3": "تنتهي صلاحية دخول المساق كمستمع في {date}"
3+
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"test:watch": "npm run test -- --watch"
1919
},
2020
"bin": {
21+
"intl-imports.js": "i18n/scripts/intl-imports.js",
2122
"transifex-utils.js": "i18n/scripts/transifex-utils.js"
2223
},
2324
"repository": {

src/i18n/scripts/README.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,29 @@
11
# i18n/scripts
22

3-
This directory contains the `transifex-utils.js` file which is shared across all micro-frontends.
3+
This directory contains the `transifex-utils.js` and `intl-imports.js` files which are shared across all micro-frontends.
44

5-
The package.json of `frontend-platform` includes the following section:
5+
The package.json of `frontend-platform` includes the following sections:
66

77
```
88
"bin": {
9+
"intl-imports.js": "i18n/scripts/intl-imports.js"
910
"transifex-utils.js": "i18n/scripts/transifex-utils.js"
1011
},
1112
```
1213

13-
This config block causes `transifex-utils.js` to be copied to the following path when `frontend-platform` is installed as a dependency of an micro-frontend:
14+
This config block causes boths scripts to be copied to the following path when `frontend-platform` is installed as a
15+
dependency of a micro-frontend:
1416

1517
```
18+
/node_modules/.bin/intl-imports.js
1619
/node_modules/.bin/transifex-utils.js
1720
```
1821

19-
All micro-frontends have a `Makefile` with a line that loads `transifex-utils.js` from the above path:
22+
All micro-frontends have a `Makefile` with a line that loads the scripts from the above path:
2023

2124
```
25+
intl_imports = ./node_modules/.bin/intl-imports.js
2226
transifex_utils = ./node_modules/.bin/transifex-utils.js
2327
```
2428

25-
So if you delete `transifex-utils.js` or the `scripts` directory, you'll break all micro-frontend builds. Happy coding!
29+
So if you delete either of the files or the `scripts` directory, you'll break all micro-frontend builds. Happy coding!

src/i18n/scripts/intl-imports.js

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
#!/usr/bin/env node
2+
3+
const scriptHelpDocument = `
4+
NAME
5+
intl-imports.js — Script to generate the src/i18n/index.js file that exports messages from all the languages for Micro-frontends.
6+
7+
SYNOPSIS
8+
intl-imports.js [DIRECTORY ...]
9+
10+
DESCRIPTION
11+
This script is intended to run after 'atlas' has pulled the files.
12+
13+
This expects to run inside a Micro-frontend root directory with the following structure:
14+
15+
frontend-app-learning $ tree src/i18n/
16+
src/i18n/
17+
├── index.js
18+
└── messages
19+
├── frontend-app-example
20+
│ ├── ar.json
21+
│ ├── es_419.json
22+
│ └── zh_CN.json
23+
├── frontend-component-footer
24+
│ ├── ar.json
25+
│ ├── es_419.json
26+
│ └── zh_CN.json
27+
└── frontend-component-header (empty directory)
28+
29+
30+
31+
With the structure above it's expected to run with the following command in Makefile:
32+
33+
34+
$ node_modules/.bin/intl-imports.js frontend-component-footer frontend-component-header frontend-app-example
35+
36+
37+
It will generate two type of files:
38+
39+
- Main src/i18n/index.js which overrides the Micro-frontend provided with a sample output of:
40+
41+
"""
42+
import messagesFromFrontendComponentFooter from './messages/frontend-component-footer';
43+
// Skipped import due to missing './messages/frontend-component-footer/index.js' likely due to empty translations.
44+
import messagesFromFrontendAppExample from './messages/frontend-app-example';
45+
46+
export default [
47+
messagesFromFrontendComponentFooter,
48+
messagesFromFrontendAppExample,
49+
];
50+
"""
51+
52+
- Each sub-directory has src/i18n/messages/frontend-component-header/index.js which is imported by the main file.:
53+
54+
"""
55+
import messagesOfArLanguage from './ar.json';
56+
import messagesOfDeLanguage from './de.json';
57+
import messagesOfEs419Language from './es_419.json';
58+
export default {
59+
'ar': messagesOfArLanguage,
60+
'de': messagesOfDeLanguage,
61+
'es-419': messagesOfEs419Language,
62+
};
63+
"""
64+
`;
65+
66+
const fs = require('fs');
67+
const path = require('path');
68+
const camelCase = require('lodash.camelcase');
69+
70+
const loggingPrefix = path.basename(`${__filename}`); // the name of this JS file
71+
72+
// Header note for generated src/i18n/index.js file
73+
const filesCodeGeneratorNoticeHeader = `// This file is generated by the openedx/frontend-platform's "intl-import.js" script.
74+
//
75+
// Refer to the i18n documents in https://docs.openedx.org/en/latest/developers/references/i18n.html to update
76+
// the file and use the Micro-frontend i18n pattern in new repositories.
77+
//
78+
`;
79+
80+
/**
81+
* Create frontend-app-example/index.js file with proper imports.
82+
*
83+
* @param directory - a directory name containing .json files from Transifex e.g. "frontend-app-example".
84+
* @param log - Mockable process.stdout.write
85+
* @param writeFileSync - Mockable fs.writeFileSync
86+
* @param i18nDir - Path to `src/i18n` directory
87+
*
88+
* @return object - An object containing directory name and whether its "index.js" file was successfully written.
89+
*/
90+
function generateSubdirectoryMessageFile({
91+
directory,
92+
log,
93+
writeFileSync,
94+
i18nDir,
95+
}) {
96+
const importLines = [];
97+
const messagesLines = [];
98+
const counter = { nonEmptyLanguages: 0 };
99+
const messagesDir = `${i18nDir}/messages`; // The directory of Micro-frontend i18n messages
100+
101+
try {
102+
const files = fs.readdirSync(`${messagesDir}/${directory}`, { withFileTypes: true });
103+
files.sort(); // Sorting ensures a consistent generated `index.js` order of imports cross-platforms.
104+
105+
const jsonFiles = files.filter(file => file.isFile() && file.name.endsWith('.json'));
106+
107+
if (!jsonFiles.length) {
108+
log(`${loggingPrefix}: Not creating '${directory}/index.js' because no .json translation files were found.\n`);
109+
return {
110+
directory,
111+
isWritten: false,
112+
};
113+
}
114+
115+
jsonFiles.forEach((file) => {
116+
const filename = file.name;
117+
// Gets `fr_CA` from `fr_CA.json`
118+
const languageCode = filename.replace(/\.json$/, '');
119+
// React-friendly language code fr_CA --> fr-ca
120+
const reactIntlLanguageCode = languageCode.toLowerCase().replace(/_/g, '-');
121+
// camelCase variable name
122+
const messagesCamelCaseVar = camelCase(`messages_Of_${languageCode}_Language`);
123+
const filePath = `${messagesDir}/${directory}/${filename}`;
124+
125+
try {
126+
const entries = JSON.parse(fs.readFileSync(filePath, { encoding: 'utf8' }));
127+
128+
if (!Object.keys(entries).length) {
129+
importLines.push(`// Note: Skipped empty '${filename}' messages file.`);
130+
return; // Skip the language
131+
}
132+
} catch (e) {
133+
importLines.push(`// Error: unable to parse '${filename}' messages file.`);
134+
log(`${loggingPrefix}: NOTICE: Skipping '${directory}/${filename}' due to error: ${e}.\n`);
135+
return; // Skip the language
136+
}
137+
138+
counter.nonEmptyLanguages += 1;
139+
importLines.push(`import ${messagesCamelCaseVar} from './${filename}';`);
140+
messagesLines.splice(1, 0, ` '${reactIntlLanguageCode}': ${messagesCamelCaseVar},`);
141+
});
142+
143+
if (counter.nonEmptyLanguages) {
144+
// See the help message above for sample output.
145+
const messagesFileContent = [
146+
filesCodeGeneratorNoticeHeader,
147+
importLines.join('\n'),
148+
'\nexport default {',
149+
messagesLines.join('\n'),
150+
'};\n',
151+
].join('\n');
152+
153+
writeFileSync(`${messagesDir}/${directory}/index.js`, messagesFileContent);
154+
return {
155+
directory,
156+
isWritten: true,
157+
};
158+
}
159+
log(`${loggingPrefix}: Skipping '${directory}' because no languages were found.\n`);
160+
} catch (e) {
161+
log(`${loggingPrefix}: NOTICE: Skipping '${directory}' due to error: ${e}.\n`);
162+
}
163+
164+
return {
165+
directory,
166+
isWritten: false,
167+
};
168+
}
169+
170+
/**
171+
* Create main `src/i18n/index.js` messages import file.
172+
*
173+
*
174+
* @param processedDirectories - List of directories with a boolean flag whether its "index.js" file is written
175+
* The format is "[\{ directory: "frontend-component-example", isWritten: false \}, ...]"
176+
* @param log - Mockable process.stdout.write
177+
* @param writeFileSync - Mockable fs.writeFileSync
178+
* @param i18nDir` - Path to `src/i18n` directory
179+
*/
180+
function generateMainMessagesFile({
181+
processedDirectories,
182+
log,
183+
writeFileSync,
184+
i18nDir,
185+
}) {
186+
const importLines = [];
187+
const exportLines = [];
188+
189+
processedDirectories.forEach(processedDirectory => {
190+
const { directory, isWritten } = processedDirectory;
191+
if (isWritten) {
192+
const moduleCamelCaseVariableName = camelCase(`messages_from_${directory}`);
193+
importLines.push(`import ${moduleCamelCaseVariableName} from './messages/${directory}';`);
194+
exportLines.push(` ${moduleCamelCaseVariableName},`);
195+
} else {
196+
const skipMessage = `Skipped import due to missing '${directory}/index.js' likely due to empty translations.`;
197+
importLines.push(`// ${skipMessage}.`);
198+
log(`${loggingPrefix}: ${skipMessage}\n`);
199+
}
200+
});
201+
202+
// See the help message above for sample output.
203+
const indexFileContent = [
204+
filesCodeGeneratorNoticeHeader,
205+
importLines.join('\n'),
206+
'\nexport default [',
207+
exportLines.join('\n'),
208+
'];\n',
209+
].join('\n');
210+
211+
writeFileSync(`${i18nDir}/index.js`, indexFileContent);
212+
}
213+
214+
/*
215+
* Main function of the file.
216+
*/
217+
function main({
218+
directories,
219+
log,
220+
writeFileSync,
221+
pwd,
222+
}) {
223+
const i18nDir = `${pwd}/src/i18n`; // The Micro-frontend i18n root directory
224+
225+
if (directories.includes('--help') || directories.includes('-h')) {
226+
log(scriptHelpDocument);
227+
} else if (!directories.length) {
228+
log(scriptHelpDocument);
229+
log(`${loggingPrefix}: Error: A list of directories is required.\n`);
230+
} else if (!fs.existsSync(i18nDir) || !fs.lstatSync(i18nDir).isDirectory()) {
231+
log(scriptHelpDocument);
232+
log(`${loggingPrefix}: Error: src/i18n directory was not found.\n`);
233+
} else {
234+
const processedDirectories = directories.map(directory => generateSubdirectoryMessageFile({
235+
directory,
236+
log,
237+
writeFileSync,
238+
i18nDir,
239+
}));
240+
generateMainMessagesFile({
241+
processedDirectories,
242+
log,
243+
writeFileSync,
244+
i18nDir,
245+
});
246+
}
247+
}
248+
249+
if (require.main === module) {
250+
// Run the main() function if called from the command line.
251+
main({
252+
directories: process.argv.slice(2),
253+
log: text => process.stdout.write(text),
254+
writeFileSync: fs.writeFileSync,
255+
pwd: process.env.PWD,
256+
});
257+
}
258+
259+
module.exports.main = main; // Allow tests to use the main function.

0 commit comments

Comments
 (0)