#!/usr/bin/env node
const scriptHelpDocument = `
NAME
intl-imports.js — Script to generate the src/i18n/index.js file that exports messages from all the languages for Micro-frontends.
SYNOPSIS
intl-imports.js [DIRECTORY ...]
DESCRIPTION
This script is intended to run after 'atlas' has pulled the files.
This expects to run inside a Micro-frontend root directory with the following structure:
frontend-app-learning $ tree src/i18n/
src/i18n/
├── index.js
└── messages
├── frontend-app-example
│ ├── ar.json
│ ├── es_419.json
│ └── zh_CN.json
├── frontend-component-footer
│ ├── ar.json
│ ├── es_419.json
│ └── zh_CN.json
└── frontend-component-header (empty directory)
With the structure above it's expected to run with the following command in Makefile:
$ node_modules/.bin/intl-imports.js frontend-component-footer frontend-component-header frontend-app-example
It will generate two type of files:
- Main src/i18n/index.js which overrides the Micro-frontend provided with a sample output of:
"""
import messagesFromFrontendComponentFooter from './messages/frontend-component-footer';
// Skipped import due to missing './messages/frontend-component-footer/index.js' likely due to empty translations.
import messagesFromFrontendAppExample from './messages/frontend-app-example';
export default [
messagesFromFrontendComponentFooter,
messagesFromFrontendAppExample,
];
"""
- Each sub-directory has src/i18n/messages/frontend-component-header/index.js which is imported by the main file.:
"""
import messagesOfArLanguage from './ar.json';
import messagesOfDeLanguage from './de.json';
import messagesOfEs419Language from './es_419.json';
export default {
'ar': messagesOfArLanguage,
'de': messagesOfDeLanguage,
'es-419': messagesOfEs419Language,
};
"""
`;
const fs = require('fs');
const path = require('path');
const camelCase = require('lodash.camelcase');
const loggingPrefix = path.basename(`${__filename}`); // the name of this JS file
// Header note for generated src/i18n/index.js file
const filesCodeGeneratorNoticeHeader = `// This file is generated by the openedx/frontend-platform's "intl-import.js" script.
//
// Refer to the i18n documents in https://docs.openedx.org/en/latest/developers/references/i18n.html to update
// the file and use the Micro-frontend i18n pattern in new repositories.
//
`;
/**
* Create frontend-app-example/index.js file with proper imports.
*
* @param directory - a directory name containing .json files from Transifex e.g. "frontend-app-example".
* @param log - Mockable process.stdout.write
* @param writeFileSync - Mockable fs.writeFileSync
* @param i18nDir - Path to `src/i18n` directory
*
* @return object - An object containing directory name and whether its "index.js" file was successfully written.
*/
function generateSubdirectoryMessageFile({
directory,
log,
writeFileSync,
i18nDir,
}) {
const importLines = [];
const messagesLines = [];
const counter = { nonEmptyLanguages: 0 };
const messagesDir = `${i18nDir}/messages`; // The directory of Micro-frontend i18n messages
try {
const files = fs.readdirSync(`${messagesDir}/${directory}`, { withFileTypes: true });
files.sort(); // Sorting ensures a consistent generated `index.js` order of imports cross-platforms.
const jsonFiles = files.filter(file => file.isFile() && file.name.endsWith('.json'));
if (!jsonFiles.length) {
log(`${loggingPrefix}: Not creating '${directory}/index.js' because no .json translation files were found.\n`);
return {
directory,
isWritten: false,
};
}
jsonFiles.forEach((file) => {
const filename = file.name;
// Gets `fr_CA` from `fr_CA.json`
const languageCode = filename.replace(/\.json$/, '');
// React-friendly language code fr_CA --> fr-ca
const reactIntlLanguageCode = languageCode.toLowerCase().replace(/_/g, '-');
// camelCase variable name
const messagesCamelCaseVar = camelCase(`messages_Of_${languageCode}_Language`);
const filePath = `${messagesDir}/${directory}/${filename}`;
try {
const entries = JSON.parse(fs.readFileSync(filePath, { encoding: 'utf8' }));
if (!Object.keys(entries).length) {
importLines.push(`// Note: Skipped empty '${filename}' messages file.`);
return; // Skip the language
}
} catch (e) {
importLines.push(`// Error: unable to parse '${filename}' messages file.`);
log(`${loggingPrefix}: NOTICE: Skipping '${directory}/${filename}' due to error: ${e}.\n`);
return; // Skip the language
}
counter.nonEmptyLanguages += 1;
importLines.push(`import ${messagesCamelCaseVar} from './${filename}';`);
messagesLines.splice(1, 0, ` '${reactIntlLanguageCode}': ${messagesCamelCaseVar},`);
});
if (counter.nonEmptyLanguages) {
// See the help message above for sample output.
const messagesFileContent = [
filesCodeGeneratorNoticeHeader,
importLines.join('\n'),
'\nexport default {',
messagesLines.join('\n'),
'};\n',
].join('\n');
writeFileSync(`${messagesDir}/${directory}/index.js`, messagesFileContent);
return {
directory,
isWritten: true,
};
}
log(`${loggingPrefix}: Skipping '${directory}' because no languages were found.\n`);
} catch (e) {
log(`${loggingPrefix}: NOTICE: Skipping '${directory}' due to error: ${e}.\n`);
}
return {
directory,
isWritten: false,
};
}
/**
* Create main `src/i18n/index.js` messages import file.
*
*
* @param processedDirectories - List of directories with a boolean flag whether its "index.js" file is written
* The format is "[\{ directory: "frontend-component-example", isWritten: false \}, ...]"
* @param log - Mockable process.stdout.write
* @param writeFileSync - Mockable fs.writeFileSync
* @param i18nDir` - Path to `src/i18n` directory
*/
function generateMainMessagesFile({
processedDirectories,
log,
writeFileSync,
i18nDir,
}) {
const importLines = [];
const exportLines = [];
processedDirectories.forEach(processedDirectory => {
const { directory, isWritten } = processedDirectory;
if (isWritten) {
const moduleCamelCaseVariableName = camelCase(`messages_from_${directory}`);
importLines.push(`import ${moduleCamelCaseVariableName} from './messages/${directory}';`);
exportLines.push(` ${moduleCamelCaseVariableName},`);
} else {
const skipMessage = `Skipped import due to missing '${directory}/index.js' likely due to empty translations.`;
importLines.push(`// ${skipMessage}.`);
log(`${loggingPrefix}: ${skipMessage}\n`);
}
});
// See the help message above for sample output.
const indexFileContent = [
filesCodeGeneratorNoticeHeader,
importLines.join('\n'),
'\nexport default [',
exportLines.join('\n'),
'];\n',
].join('\n');
writeFileSync(`${i18nDir}/index.js`, indexFileContent);
}
/*
* Main function of the file.
*/
function main({
directories,
log,
writeFileSync,
pwd,
}) {
const i18nDir = `${pwd}/src/i18n`; // The Micro-frontend i18n root directory
if (directories.includes('--help') || directories.includes('-h')) {
log(scriptHelpDocument);
} else if (!directories.length) {
log(scriptHelpDocument);
log(`${loggingPrefix}: Error: A list of directories is required.\n`);
} else if (!fs.existsSync(i18nDir) || !fs.lstatSync(i18nDir).isDirectory()) {
log(scriptHelpDocument);
log(`${loggingPrefix}: Error: src/i18n directory was not found.\n`);
} else {
const processedDirectories = directories.map(directory => generateSubdirectoryMessageFile({
directory,
log,
writeFileSync,
i18nDir,
}));
generateMainMessagesFile({
processedDirectories,
log,
writeFileSync,
i18nDir,
});
}
}
if (require.main === module) {
// Run the main() function if called from the command line.
main({
directories: process.argv.slice(2),
log: text => process.stdout.write(text),
writeFileSync: fs.writeFileSync,
pwd: process.env.PWD,
});
}
module.exports.main = main; // Allow tests to use the main function.