@softkit/i18n
Version:
This library is a simple wrapper based on [nestjs-i18n](https://nestjs-i18n.com/)
388 lines • 15.4 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.GenerateTypesCommand = void 0;
const tslib_1 = require("tslib");
const loaders_1 = require("../loaders");
const chalk_1 = tslib_1.__importDefault(require("chalk"));
const utils_1 = require("../utils");
const node_fs_1 = tslib_1.__importDefault(require("node:fs"));
const node_path_1 = tslib_1.__importDefault(require("node:path"));
const node_process_1 = tslib_1.__importDefault(require("node:process"));
const chokidar_1 = tslib_1.__importDefault(require("chokidar"));
const fs_extra_1 = require("fs-extra");
const import_1 = require("../utils/import");
const typescript_1 = require("../utils/typescript");
class GenerateTypesCommand {
constructor() {
this.command = 'generate-types';
this.describe = 'Generate types for translations. Supports json and yaml files.';
}
builder(args) {
return args
.option('debounce', {
alias: 'd',
type: 'number',
describe: 'Debounce time in ms',
default: 200,
demandOption: false,
})
.option('optionsFile', {
alias: 'opt',
type: 'string',
describe: 'Options file path',
demandOption: false,
})
.option('watch', {
alias: 'w',
type: 'boolean',
describe: 'Watch for changes and generate types',
default: false,
demandOption: false,
})
.option('typesOutputPath', {
alias: 'o',
type: 'string',
describe: 'Path to output types file',
default: 'src/generated/i18n.generated.ts',
demandOption: false,
})
.option('loaderType', {
alias: 't',
type: 'string',
array: true,
options: ['json', 'yaml'],
describe: 'Loader type',
demandOption: false,
default: [],
})
.option('translationsPath', {
alias: 'p',
type: 'string',
describe: 'Path to translations',
array: true,
default: [],
demandOption: false,
});
}
async handler(args) {
const { packageConfig = {}, packageJsonFilePath } = (await getPackageConfig()) || {};
packageConfig['i18n'] = packageConfig['i18n'] ?? {};
if (!args.typesOutputPath && packageConfig['i18n'].typesOutputPath) {
args.typesOutputPath = packageConfig['i18n'].typesOutputPath;
}
if (args.optionsFile) {
args.optionsFile = node_path_1.default.resolve(node_process_1.default.cwd(), args.optionsFile);
}
if (!args.optionsFile && packageConfig['i18n'].optionsFile) {
const packageJsonFolder = node_path_1.default.dirname(packageJsonFilePath);
args.optionsFile = node_path_1.default.join(packageJsonFolder, packageConfig['i18n'].optionsFile);
}
if (!args.typesOutputPath) {
console.log(chalk_1.default.red(`Error: typesOutputPath is not defined. Please provide a path to output types file, in params or in package.json`));
node_process_1.default.exit(1);
}
args.translationsPath = sanitizePaths(args.translationsPath);
validateInputParams(args);
validatePathsNotEmbeddedInEachOther(args.translationsPath);
const optionsFromFile = await validateAndGetOptionsFile(args.optionsFile);
const loaders = args.loaderType.map((loaderType, index) => {
const path = args.translationsPath[index];
validatePath(path, loaderType, index);
return {
path,
loader: getLoaderByType(loaderType, path),
};
});
for (const loader of optionsFromFile?.loaders || []) {
const p = loader?.options?.path;
loaders.push({
path: p ?? sanitizePath(p),
loader: loader,
});
}
const translationsWithPaths = await loadTranslations(loaders);
const validTranslationsWithPaths = translationsWithPaths.filter((item) => Boolean(item.path));
let hasError = false;
const translationsMapped = translationsWithPaths.map(({ translations, error, path }) => {
if (error) {
console.log(chalk_1.default.red(`Error while loading translations from ${path}: ${error.message}`));
hasError = true;
}
return translations;
});
const validTranslations = translationsMapped.filter((translation) => translation !== null && translation !== undefined);
if (!hasError && validTranslations.length > 0) {
const mergedTranslations = reduceTranslations(validTranslations);
await generateAndSaveTypes(mergedTranslations, args);
}
else if (!args.watch) {
node_process_1.default.exit(1);
}
if (args.watch) {
console.log(chalk_1.default.green(`Listening for changes in ${args.translationsPath.join(', ')}...`));
if (this.fsWatcher === undefined) {
this.fsWatcher = await listenForChanges(loaders, validTranslationsWithPaths, args);
}
}
else {
node_process_1.default.exit(0);
}
}
async stopWatcher() {
if (this.fsWatcher) {
await this.fsWatcher.close();
}
}
}
exports.GenerateTypesCommand = GenerateTypesCommand;
/**
* we do not support nested paths, because listeners will be triggered multiple times
* and it doesn't really make sense to have the same folder twice
* */
function validatePathsNotEmbeddedInEachOther(paths) {
for (let i = 0; i < paths.length; i++) {
const pathToCheck = paths[i];
for (const [j, pathToCompare] of paths.entries()) {
if (j !== i && pathToCheck.startsWith(pathToCompare)) {
console.log(chalk_1.default.red(`Path ${pathToCheck} is embedded in ${pathToCompare}. This is not supported.`));
node_process_1.default.exit(1);
}
}
}
}
function listenForChanges(loadersWithPaths, translationsWithPaths, args) {
const allPaths = loadersWithPaths.map(({ path }) => path);
const loadersByPath = loadersWithPaths.reduce((acc, { path, loader }) => {
acc[path] = loader;
return acc;
}, {});
return new Promise((resolve, reject) => {
const fsWatcher = chokidar_1.default
.watch(allPaths, {
ignoreInitial: true,
})
.on('ready', () => {
resolve(fsWatcher);
})
.on('error', (error) => {
console.log(chalk_1.default.red(`Error while watching files: ${error.message}`));
reject(error);
})
.on('all', customDebounce(handleFileChangeEvents(allPaths, loadersByPath, translationsWithPaths, args), args.debounce));
});
}
function sanitizePath(path) {
// adding trailing slash
const newPath = path.endsWith('/') ? path : `${path}/`;
// removing starting slash
return newPath.startsWith('./') ? newPath.slice(2) : newPath;
}
function sanitizePaths(paths) {
return paths.map((path) => {
return sanitizePath(path);
});
}
function handleFileChangeEvents(listenToPaths, loadersByPath, translationsWithPaths, args) {
return async (events, paths) => {
console.log(chalk_1.default.blue(`Change detected`));
console.log(chalk_1.default.green(
// eslint-disable-next-line sonarjs/no-nested-template-literals
`${events.map((e, idx) => `\t${e} - ${paths[idx]}`).join('\n')}`));
console.log(chalk_1.default.blue(`Re-generating types...`));
const uniquePaths = new Set();
for (const changePath of paths) {
const foundPath = listenToPaths.find((path) => changePath.startsWith(path));
if (foundPath) {
uniquePaths.add(foundPath);
}
if (uniquePaths.size === paths.length) {
break;
}
}
let hasError = false;
for (const path of uniquePaths) {
const loader = loadersByPath[path];
try {
const translation = (await loader.load());
for (const translationWithPath of translationsWithPaths) {
if (translationWithPath.path === path) {
translationWithPath.translations = translation;
}
}
}
catch (error) {
hasError = true;
if (error instanceof Error) {
console.log(chalk_1.default.red(`Error while loading translations from ${path}. Error: ${error.message}`));
}
else {
console.log(chalk_1.default.red(`Error while loading translations from ${path}. Error: ${JSON.stringify(error)}`));
}
}
}
if (hasError) {
console.log(chalk_1.default.red(`Waiting for changes to generate proper types`));
return;
}
const mergedTranslations = reduceTranslations(translationsWithPaths.map(({ translations }) => translations));
await generateAndSaveTypes(mergedTranslations, args);
};
}
async function generateAndSaveTypes(translations, args) {
const object = Object.keys(translations).reduce((result, key) => (0, utils_1.mergeDeep)(result, translations[key]), {});
const rawContent = await (0, typescript_1.createTypesFile)(object);
const outputFile = (0, typescript_1.annotateSourceCode)(rawContent);
node_fs_1.default.mkdirSync(node_path_1.default.dirname(args.typesOutputPath), {
recursive: true,
});
let currentFileContent = null;
try {
currentFileContent = node_fs_1.default.readFileSync(args.typesOutputPath, 'utf8');
}
catch {
// expected empty line
// eslint-disable-next-line no-empty
}
if (currentFileContent == outputFile) {
console.log(`
${chalk_1.default.yellow('No changes generated in a result output type file.')}
`);
}
else {
node_fs_1.default.writeFileSync(args.typesOutputPath, outputFile);
console.log(`
${chalk_1.default.green(`Types generated and saved to: ${args.typesOutputPath}`)}
`);
}
}
function customDebounce(func, wait) {
let args = [];
let timeoutId;
return function (...rest) {
// User formal parameters to make sure we add a slot even if a param
// is not passed in
if (func.length > 0) {
for (let i = 0; i < func.length; i++) {
if (!args[i]) {
args[i] = [];
}
args[i].push(rest[i]);
}
}
// No formal parameters, just track the whole argument list
else {
args.push(...rest);
}
clearTimeout(timeoutId);
timeoutId = setTimeout(function () {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
func.apply(this, args);
args = [];
}, wait);
};
}
async function loadTranslations(loaders) {
const loadedTranslations = await Promise.all(loaders.map(({ loader }) => loader.load().catch((error) => error)));
return loadedTranslations.map((result, index) => {
const isError = result instanceof Error;
return {
translations: isError ? null : result,
error: isError ? result : null,
path: loaders[index].path,
};
});
}
async function validateAndGetOptionsFile(optionsFile) {
if (optionsFile) {
let optionsFileExport;
try {
optionsFileExport = await (0, import_1.importOrRequireFile)(optionsFile);
}
catch (error) {
throw error instanceof Error
? new Error(`Unable to open file: "${optionsFile}". ${error.message}`)
: new Error(`Unable to open file: "${optionsFile}". `);
}
if (!optionsFileExport || typeof optionsFileExport !== 'object') {
throw new Error(`Given options file must contain export of a I18nOptions instance`);
}
const optionsExported = [];
for (const key in optionsFileExport) {
const options = optionsFileExport[key];
if (options.loaders) {
optionsExported.push(options);
}
}
if (optionsExported.length === 0) {
throw new Error(`Given options file must contain export of a I18nOptions`);
}
if (optionsExported.length > 1) {
throw new Error(`Given options file must contain only one export of I18nOptions`);
}
return optionsExported[0];
}
}
function validateInputParams(args) {
if (args.loaderType.length !== args.translationsPath.length) {
console.log(chalk_1.default.red(`Error: translationsPath and loaderType must have the same number of elements.
You provided ${args.loaderType.length} loader types and ${args.translationsPath.length} paths`));
node_process_1.default.exit(1);
}
if ((args.loaderType.length === 0 || args.loaderType.length === 0) &&
!args.optionsFile) {
console.log(chalk_1.default.red(`Error: you must provide at least one loader type or options file`));
node_process_1.default.exit(1);
}
}
function validatePath(path, loaderType, index) {
if (path === undefined) {
console.log(chalk_1.default.red(`Error: translationsPath is not defined for loader type ${loaderType},
please provide a path to translations, index ${index}`));
node_process_1.default.exit(1);
}
}
async function getPackageConfig(basePath = node_process_1.default.cwd()) {
const packageJsonFilePath = `${basePath}/package.json`;
if (await (0, fs_extra_1.pathExists)(packageJsonFilePath)) {
/* istanbul ignore next */
try {
const packageConfig = await require(packageJsonFilePath);
return {
packageJsonFilePath,
packageConfig,
};
}
catch {
throw new Error(`Failed to load package.json`);
}
}
const parentFolder = await (0, fs_extra_1.realpath)(`${basePath}/..`);
// we reached the root folder
if (basePath === parentFolder) {
throw new Error(`Reached the root folder without finding package.json in ${basePath}`);
}
return getPackageConfig(parentFolder);
}
function getLoaderByType(loaderType, path) {
switch (loaderType) {
case 'json': {
return new loaders_1.I18nJsonLoader({
path,
});
}
case 'yaml': {
return new loaders_1.I18nYamlLoader({
path,
});
}
default: {
console.log(chalk_1.default.red(`Error: loader type ${loaderType} is not supported`));
node_process_1.default.exit(1);
}
}
}
function reduceTranslations(translations) {
return translations.reduce((acc, t) => (0, utils_1.mergeTranslations)(acc, t), {});
}
//# sourceMappingURL=generate-types.command.js.map