@o3r/localization
Version:
This module provides a runtime dynamic language/translation support and debug tools.
413 lines • 20.1 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.loadTranslations = loadTranslations;
exports.getTranslationsForLanguage = getTranslationsForLanguage;
const fs = require("node:fs");
const path = require("node:path");
const architect_1 = require("@angular-devkit/architect");
const extractors_1 = require("@o3r/extractors");
const schematics_1 = require("@o3r/schematics");
const globby_1 = require("globby");
const rxjs_1 = require("rxjs");
const operators_1 = require("rxjs/operators");
/** Maximum number of steps */
const STEP_NUMBER = 5;
/** File System debounce time */
const FS_DEBOUNCE_TIME = 200;
/**
* Get the list of translation files provided in the current package
* @param languages List of languages
* @param assets Folder containing the package override translations bundles
* @param context Ng Builder context
*/
function getAppTranslationFiles(languages, assets, context) {
const assetsList = (typeof assets === 'string' ? [assets] : assets);
const posixWorkspaceRoot = context.workspaceRoot.split(path.sep).join(path.posix.sep);
const languageFileEntries = (0, globby_1.sync)(assetsList.map((asset) => path.posix.join(posixWorkspaceRoot, asset, '*.json')), {
objectMode: true
});
const filesPerLanguage = languageFileEntries.reduce((acc, languageFileEntry) => {
const language = languageFileEntry.name.slice(0, -5); // Remove .json extension from the name
acc[language] = acc[language] || [];
acc[language].push(languageFileEntry.path);
return acc;
}, {});
const languagesToDelete = Object.keys(filesPerLanguage).filter((language) => !languages.includes(language));
languagesToDelete.forEach((language) => delete filesPerLanguage[language]);
return filesPerLanguage;
}
/**
* Check if a translation has been overridden at application level but not defined in the metadata
* @param language Language
* @param defaultBundle Default translation bundle
* @param customTranslations Override of translations provided by assets
* @param context ng Builder context
* @param metaData
* @param failIfMissingMetadata
*/
function checkUnusedTranslation(language, defaultBundle, customTranslations, context, metaData, failIfMissingMetadata) {
const dictionaryKeysPatterns = metaData
.filter((metadataItem) => metadataItem.dictionary)
.map((metadataItem) => metadataItem.key)
.map((key) => `${key}.`);
const missingMetadata = Object.keys(customTranslations)
.filter((key) => defaultBundle[key] === undefined)
.filter((key) => !dictionaryKeysPatterns.some((dictionaryKeyPattern) => key.startsWith(dictionaryKeyPattern)));
missingMetadata.forEach((key) => context.logger[failIfMissingMetadata ? 'error' : 'warn'](`The key "${key}" from "${language}" is not part of the MetaData`));
if (missingMetadata.length > 0 && failIfMissingMetadata) {
throw new schematics_1.O3rCliError(`There is missing metadata for ${language}`);
}
}
/**
* Clean localization json file.
* Note: it is used to remove $schema potentially added by the customization
* @param translationBundle Translation bundle
*/
function sanitizeLocalization(translationBundle) {
delete translationBundle.$schema;
return translationBundle;
}
/**
* Load all the translation files in the given array and created a single Object that associated to each key its localized value.
* @param files
*/
function loadTranslations(files) {
const translationList = files.map((file) => sanitizeLocalization(JSON.parse(fs.readFileSync(file).toString())));
return translationList.reduce((acc, translation) => Object.assign(acc, translation), {});
}
/**
* Computes the translation bundle for a given language, by resolving overrides specified in defaultLanguageMapping
* @param language
* @param filesPerLanguage
* @param defaultLanguageMapping
* @param memory
* @param dependencyPath
*/
function getTranslationsForLanguage(language, filesPerLanguage, defaultLanguageMapping, memory, dependencyPath = new Set()) {
if (memory[language]) {
return memory[language];
}
dependencyPath.add(language);
const files = filesPerLanguage[language];
let translations = files ? loadTranslations(files) : {};
const defaultLanguage = defaultLanguageMapping[language];
// If a default language has been configured for this language
if (defaultLanguage) {
// Throw an error if we find a circular dependency
if (dependencyPath.has(defaultLanguage)) {
throw new schematics_1.O3rCliError(`Circular dependency found: ${[...Array.from(dependencyPath), defaultLanguage].join('->')}`);
}
else {
// Else, recursively resolve its bundle and use it as a base for the current language
const defaultTranslations = getTranslationsForLanguage(defaultLanguage, filesPerLanguage, defaultLanguageMapping, memory, dependencyPath);
translations = {
...defaultTranslations,
...translations
};
}
}
memory[language] = translations;
return translations;
}
/**
* Get the translation bundle for each languages
* @param languages List of languages
* @param defaultBundle Default translation bundle
* @param fileMapping Mapping of translations per languages
* @param mapReferencesDictionary mapReferencesDictionary
* @param shouldCheckUnusedTranslation Specify if we need to check the translation not in metadata file
* @param context Ng Builder context
* @param metadata metadata
* @param refKeysMapping mapping for referencing towards referenced keys
* @param defaultLanguageMapping
* @param useMetadataAsDefault
* @param ignoreReferenceIfNotDefault
* @param failIfMissingMetadata
*/
function getBundlesPerLanguages(languages, defaultBundle, fileMapping, mapReferencesDictionary, shouldCheckUnusedTranslation, context, metadata = [], refKeysMapping, defaultLanguageMapping, useMetadataAsDefault, ignoreReferenceIfNotDefault, failIfMissingMetadata) {
const bundles = {};
const memory = {};
for (const language of languages) {
const translations = getTranslationsForLanguage(language, fileMapping, defaultLanguageMapping, memory);
if (shouldCheckUnusedTranslation) {
checkUnusedTranslation(language, defaultBundle, translations, context, metadata, failIfMissingMetadata);
}
const bundle = useMetadataAsDefault
? {
...defaultBundle,
...translations
}
: translations;
bundles[language] = bundle;
Object.keys(mapReferencesDictionary)
.forEach((key) => Object.keys(bundles[language])
.filter((k) => k.startsWith(mapReferencesDictionary[key]))
.forEach((k) => {
const newKey = k.replace(mapReferencesDictionary[key], key);
if (!bundle[newKey]) {
bundle[newKey] = bundle[k];
}
}));
Object.entries(refKeysMapping)
.forEach(([key, mappedKey]) => {
if (bundle[mappedKey] && (!ignoreReferenceIfNotDefault || bundle[key] === undefined || bundle[key] === defaultBundle[key])) {
bundle[key] = bundle[mappedKey];
}
});
}
return bundles;
}
/**
* Extract localization referred in metadata
* @param metaData Localization metadata
* @param key Translation key
*/
function getExtractReferredTranslationValue(metaData, key) {
const translationObj = metaData.find((data) => data.key === key);
if (translationObj) {
return translationObj.ref ? getExtractReferredTranslationValue(metaData, translationObj.ref) : translationObj.value || key;
}
else {
return key;
}
}
/**
* Extract localization key referred in metadata
* @param metaData Localization metadata
* @param key Translation key
*/
function getExtractReferredTranslationKey(metaData, key) {
const translationObj = metaData.find((data) => data.key === key);
return (translationObj && !!translationObj.ref) ? getExtractReferredTranslationKey(metaData, translationObj.ref) : key;
}
/**
* Extract some data from the provided localization metadata:
* - The bundle containing default translations
* - The map that associates to every key containing a reference, the key it resolves to
* - The map that associated to every key being a dictionary reference, the key it resolves to
* @param metadata
*/
function processMetadata(metadata) {
const defaultTranslations = {};
const keyReferences = {};
const dictionaryReferences = {};
metadata.forEach((localization) => {
if (localization.value === undefined && localization.ref === undefined) {
return;
}
if (localization.ref) {
const extractedReference = getExtractReferredTranslationKey(metadata, localization.ref);
if (localization.dictionary) {
dictionaryReferences[localization.key] = extractedReference;
}
keyReferences[localization.key] = extractedReference;
defaultTranslations[localization.key] = getExtractReferredTranslationValue(metadata, localization.ref);
}
else {
defaultTranslations[localization.key] = localization.value;
}
});
return { defaultTranslations, keyReferences, dictionaryReferences };
}
/**
* Start the metadata generator in watch mode
* @param localizationExtractorTarget Target of the localization extractor builder
* @param context Builder context
*/
function startMetadataGenerator(localizationExtractorTarget, context) {
const logger = context.logger.createChild('Metadata Logger');
const extractorBuild = context.scheduleTarget(localizationExtractorTarget, { watch: true }, { logger });
return (0, rxjs_1.firstValueFrom)((0, rxjs_1.merge)(logger.pipe(), (0, rxjs_1.from)(extractorBuild.then((build) => build.result))).pipe((0, operators_1.filter)((entry) => !entry.message || /Localization metadata bundle extracted/.test(entry.message))));
}
/**
* Regenerate the metadata if missing
* @param localizationMetaDataFile Path to the localization metadata file
* @param localizationExtractorTarget Localization Extractor target configured in the angular.json
* @param context Ng Builder context
*/
async function checkMetadata(localizationMetaDataFile, localizationExtractorTarget, context) {
let metaDataExists = fs.existsSync(localizationMetaDataFile);
if (!metaDataExists) {
context.logger.warn(`The file ${localizationMetaDataFile} does not exist, the extractor will be run`);
context.reportProgress(2, STEP_NUMBER, 'Generating Localization metadata file');
const extractorBuild = await context.scheduleTarget(localizationExtractorTarget, { watch: false });
const extractorBuildResult = await extractorBuild.result;
if (extractorBuildResult.success) {
metaDataExists = fs.existsSync(localizationMetaDataFile);
if (!metaDataExists) {
return {
success: false,
error: `The file ${localizationMetaDataFile} has not been generated`
};
}
}
else {
return extractorBuildResult;
}
}
}
exports.default = (0, architect_1.createBuilder)((0, extractors_1.createBuilderWithMetricsIfInstalled)(async (options, context) => {
context.reportRunning();
// Load Targets to get build options
context.reportProgress(0, STEP_NUMBER, 'Checking required options');
let [project, target, configuration] = options.browserTarget.split(':');
const browserTarget = { project, target, configuration };
[project, target, configuration] = options.localizationExtracterTarget.split(':');
const localizationExtractorTarget = { project, target, configuration };
const [browserTargetRawOptions, localizationExtracterTargetRawOptions, browserTargetBuilder, localizationExtracterTargetBuilder] = await Promise.all([
context.getTargetOptions(browserTarget),
context.getTargetOptions(localizationExtractorTarget),
context.getBuilderNameForTarget(browserTarget),
context.getBuilderNameForTarget(localizationExtractorTarget)
]);
const [browserTargetOptions, localizationExtracterTargetOptions] = await Promise.all([
context.validateOptions(browserTargetRawOptions, browserTargetBuilder),
context.validateOptions(localizationExtracterTargetRawOptions, localizationExtracterTargetBuilder)
]);
let browserTargetOptionsOutputPath;
// Check the minimum of mandatory options to the builders
if (typeof browserTargetOptions.outputPath !== 'string' && typeof browserTargetOptions.outputPath?.base !== 'string') {
browserTargetOptionsOutputPath = path.join(context.workspaceRoot, 'dist', browserTarget.project); // outputPath became optional in Angular v20
}
else if (typeof browserTargetOptions.outputPath === 'string') {
browserTargetOptionsOutputPath = browserTargetOptions.outputPath;
}
else {
browserTargetOptionsOutputPath = path.join(browserTargetOptions.outputPath.base, typeof browserTargetOptions.outputPath.browser === 'string' ? browserTargetOptions.outputPath.browser : '');
}
if (typeof localizationExtracterTargetOptions.outputFile !== 'string') {
return {
success: false,
error: `The targetLocalizationExtracter ${options.localizationExtracterTarget} does not provide 'outputFile' option`
};
}
/** Path to the build output folder */
const outputPath = path.resolve(context.workspaceRoot, browserTargetOptionsOutputPath);
/** Path to the metadata file */
const localizationMetaDataFile = path.resolve(context.workspaceRoot, localizationExtracterTargetOptions.outputFile);
/**
* Generate translation bundles
* @param languageToRegenerate language to focus on
*/
const execute = (languageToRegenerate) => {
/** Localization metadata */
context.reportProgress(1, STEP_NUMBER, 'Checking Metadata');
const metaData = JSON.parse(fs.readFileSync(localizationMetaDataFile, { encoding: 'utf8' }));
context.reportProgress(3, STEP_NUMBER, 'Loading translation files');
/** List of translation files provided in the current package */
const appTranslationFiles = options.assets?.length ? getAppTranslationFiles(options.locales, options.assets, context) : {};
/** Mapping between the language and the custom translation of the package */
const fileMapping = languageToRegenerate ? { [languageToRegenerate]: appTranslationFiles[languageToRegenerate] } : appTranslationFiles;
const { defaultTranslations, keyReferences, dictionaryReferences } = processMetadata(metaData);
context.reportProgress(4, STEP_NUMBER, 'Merging translations');
try {
/** List of final translation bundles */
const bundles = getBundlesPerLanguages(languageToRegenerate ? [languageToRegenerate] : options.locales, defaultTranslations, fileMapping, dictionaryReferences, options.checkUnusedTranslation, context, metaData, keyReferences, options.defaultLanguageMapping, options.useMetadataAsDefault, options.ignoreReferencesIfNotDefault, options.failIfMissingMetadata);
context.reportProgress(5, STEP_NUMBER, 'Writing translations');
// Write translation files
const writingFolder = options.outputPath || outputPath || '.';
if (!fs.existsSync(writingFolder)) {
fs.mkdirSync(writingFolder, { recursive: true });
}
Object.entries(bundles).forEach(([language, bundle]) => {
const filePath = path.resolve(writingFolder, `${language}.json`);
context.logger.info(`Writing file to disk ${filePath}`);
fs.writeFileSync(filePath, JSON.stringify(bundle, Object.keys(bundle).sort(), 2));
});
return {
success: true
};
}
catch (error) {
return {
success: false,
error: typeof error === 'string' ? error : error.message
};
}
};
/** Timeout to handle nodejs issue (#1970) */
const fsTimeout = {};
/**
* Run a translation generation and report the result
* @param language Language that has changed and requires a regeneration
*/
const generateWithReport = (language) => {
const result = execute(language);
if (result.success && language) {
context.logger.info(`Translations updated based on changes in ${language}`);
}
else if (result.error) {
context.logger.error(result.error);
}
context.reportStatus('Waiting for changes');
return result;
};
/**
* Run a translation generation for metadata change
*/
const generateForMetadataChange = () => {
const METADATA_LABEL = 'metadata';
if (fsTimeout[METADATA_LABEL]) {
return;
}
fsTimeout[METADATA_LABEL] = setTimeout(() => {
fsTimeout[METADATA_LABEL] = null;
const result = generateWithReport();
if (result.success) {
context.logger.info('Translations updated based on metadata');
}
}, FS_DEBOUNCE_TIME);
};
/**
* Run a translation generation for asset change
* @param filename File that has changed and requires a regeneration
* @param fullFilePath
*/
const generateForAssetsChange = (filename, fullFilePath) => {
if (fsTimeout[filename]) {
return;
}
fsTimeout[filename] = setTimeout(() => {
fsTimeout[filename] = null;
context.logger.info(`Change triggered in ${fullFilePath}`);
generateWithReport(path.parse(filename).name);
}, FS_DEBOUNCE_TIME);
};
if (options.watch) {
const metaDataGeneration = await startMetadataGenerator(localizationExtractorTarget, context);
const metaDataGenerationResult = metaDataGeneration && metaDataGeneration.output && metaDataGeneration.result;
if (metaDataGenerationResult && !metaDataGenerationResult.success) {
return metaDataGenerationResult;
}
// Execute the generation a first time
generateWithReport();
// Create file watchers
let assetsWatchers = [];
if (options.assets) {
const assetsList = (typeof options.assets === 'string' ? [options.assets] : options.assets);
const posixWorkspaceRoot = context.workspaceRoot.split(path.sep).join(path.posix.sep);
const filenamesToInclude = `(${options.locales.join('|')}).json`;
const assets = (0, globby_1.sync)(assetsList.map((asset) => path.posix.join(posixWorkspaceRoot, asset, filenamesToInclude)));
assetsWatchers = assetsWatchers.concat(assets.map((asset) => fs.watch(asset, (_eventType, filename) => generateForAssetsChange(filename, asset))));
}
const metadataWatcher = fs.watch(localizationMetaDataFile, () => generateForMetadataChange());
context.addTeardown(() => {
assetsWatchers.forEach((assetsWatcher) => assetsWatcher.close());
metadataWatcher.close();
});
// Exit on watcher failure
return new Promise((_resolve, reject) => {
assetsWatchers.forEach((assetsWatcher) => assetsWatcher.once('error', (err) => reject(err)));
metadataWatcher.once('error', (err) => reject(err));
});
}
else {
// Execute the generation only once if not watch mode
const metaDataGeneration = await checkMetadata(localizationMetaDataFile, localizationExtractorTarget, context);
if (metaDataGeneration && !metaDataGeneration.success) {
return metaDataGeneration;
}
return execute();
}
}));
//# sourceMappingURL=index.js.map