@ckeditor/ckeditor5-dev-utils
Version:
Utils for CKEditor 5 development tools packages.
446 lines (374 loc) • 15 kB
JavaScript
/**
* @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
'use strict';
const path = require( 'path' );
const fs = require( 'fs' );
const findMessages = require( './findmessages' );
const { EventEmitter } = require( 'events' );
const PO = require( 'pofile' );
/**
* A service that serves translations assets based on the found PO files in the registered packages.
*/
module.exports = class MultipleLanguageTranslationService extends EventEmitter {
/**
* @param {Object} options
* @param {String} options.mainLanguage The target language that will be bundled into the main webpack asset.
* @param {Array.<String>} [options.additionalLanguages] Additional languages which files will be emitted.
* When option is set to 'all', all languages found during the compilation will be added.
* @param {Boolean} [options.compileAllLanguages] When set to `true` languages will be found at runtime.
* @param {Boolean} [options.addMainLanguageTranslationsToAllAssets] When set to `true` the service will not complain
* about multiple JS assets and will output translations for the main language to all found assets.
* @param {Boolean} [options.buildAllTranslationsToSeparateFiles] When set to `true` the service will output all translations
* to separate files.
* @param {String|Function|RegExp} [options.translationsOutputFile] An option allowing outputting all translation file
* to the given file. If a file specified by a path (string) does not exist, then it will be created. Otherwise, translations
* will be outputted to the file.
*/
constructor( {
mainLanguage,
additionalLanguages = [],
compileAllLanguages = false,
addMainLanguageTranslationsToAllAssets = false,
buildAllTranslationsToSeparateFiles = false,
translationsOutputFile,
skipPluralFormFunction
} ) {
super();
/**
* Main language that should be built in to the bundle.
*
* @private
* @type {String}
*/
this._mainLanguage = mainLanguage;
/**
* A set of languages that will be used by translator. This set may be expanded by found languages
* if the `compileAllLanguages` flag is turned on.
*
* @private
* @type {Set.<String>}
*/
this._languages = new Set( [ mainLanguage, ...additionalLanguages ] );
/**
* An option indicating if the languages should be found at runtime.
*
* @private
* @type {Boolean}
*/
this._compileAllLanguages = compileAllLanguages;
/**
* A boolean option. When set to `true` this service won't complain about multiple JS assets
* and will add translation for the main language to all of them. Useful option for manual tests, etc.
*
* @private
* @type {Boolean}
*/
this._addMainLanguageTranslationsToAllAssets = addMainLanguageTranslationsToAllAssets;
/**
* A boolean option. When set to `true` outputs all translations to separate files.
*
* @private
* @type {Boolean}
*/
this._buildAllTranslationsToSeparateFiles = buildAllTranslationsToSeparateFiles;
/**
* A set of handled packages that speeds up the translation process.
*
* @private
* @type {Set.<String>}
*/
this._handledPackages = new Set();
/**
* A map of translation dictionaries in the `language -> messageId -> single & plural forms` format.
*
* @private
* @type {Object.<String, Object.<String,Array.<String>>>}
*/
this._translationDictionaries = {};
/**
* Plural form rules that will be added to generated translation assets.
*
* @private
* @type {Object.<String, String>}
*/
this._pluralFormsRules = {};
/**
* A set of message ids that are found in parsed JS files. For each message id a translation
* (with a single and possible plural forms) should be found for the target languages.
*
* @private
* @type {Set.<String>}
*/
this._foundMessageIds = new Set();
/**
* Whether the `getPluralForm` function should be added in the bundle file.
*
* @private
* @type {Boolean}
*/
this._skipPluralFormFunction = skipPluralFormFunction;
this._translationsOutputFile = translationsOutputFile;
}
/**
* Collects found message ids. Emits a warning when there is a suspicion that the message is created incorrectly
* (e.g. an incorrect `t()` call).
*
* @fires warning
* @param {String} source Content of the source file.
* @param {String} fileName Source file name
* @returns {String}
*/
translateSource( source, fileName ) {
findMessages(
source,
fileName,
message => this.addIdMessage( message.id ),
error => this.emit( 'warning', error )
);
return source;
}
/**
* Loads PO files from the package if the package was not registered already.
* If the `compileAllLanguages` flag is set to `true`, then the language set will be expanded to all found languages.
*
* @fires warning
* @param {String} pathToPackage A path to the package containing translations.
*/
loadPackage( pathToPackage ) {
if ( this._handledPackages.has( pathToPackage ) ) {
return;
}
this._handledPackages.add( pathToPackage );
const pathToTranslationDirectory = this._getPathToTranslationDirectory( pathToPackage );
if ( !fs.existsSync( pathToTranslationDirectory ) ) {
return;
}
if ( this._compileAllLanguages ) {
for ( const fileName of fs.readdirSync( pathToTranslationDirectory ) ) {
if ( !fileName.endsWith( '.po' ) ) {
this.emit(
'error',
`Translation directory (${ pathToTranslationDirectory }) should contain only translation files.`
);
continue;
}
const language = fileName.replace( /\.po$/, '' );
const pathToPoFile = path.join( pathToTranslationDirectory, fileName );
this._languages.add( language );
this._loadPoFile( language, pathToPoFile );
}
return;
}
for ( const language of this._languages ) {
const pathToPoFile = path.join( pathToTranslationDirectory, language + '.po' );
this._loadPoFile( language, pathToPoFile );
}
}
/**
* Returns an array of partial assets containing translations in the executable JS form.
*
* @fires warning
* @fires error
* @param {Object} options
* @param {String} options.outputDirectory Output directory for the translation files relative to the output.
* @param {Array.<String>} options.compilationAssetNames Original asset names from the compiler (e.g. Webpack).
* @returns {Array.<Object>} Returns new and modified assets that will be added to original ones.
*/
getAssets( { outputDirectory, compilationAssetNames } ) {
let bundledLanguage = this._mainLanguage;
if ( compilationAssetNames.length === 0 ) {
return [];
}
if ( this._translationsOutputFile ) {
return this._getAssetsWithTranslationsBundledToTheOutputFile( { outputDirectory, compilationAssetNames } );
}
if ( this._buildAllTranslationsToSeparateFiles ) {
bundledLanguage = null;
compilationAssetNames = [];
} else if ( compilationAssetNames.length > 1 && !this._addMainLanguageTranslationsToAllAssets ) {
this.emit( 'error', [
'Too many JS assets has been found during the compilation. ' +
'You should use one of the following options to specify the strategy:\n' +
'- use `addMainLanguageTranslationsToAllAssets` to add translations for the main language to all assets,' +
'- use `buildAllTranslationsToSeparateFiles` to add translation files via `<script>` tags in HTML file,' +
'- use `translationsOutputFile` to append translation to the existing file or create a new asset.' +
'For more details visit https://github.com/ckeditor/ckeditor5-dev/tree/master/packages/ckeditor5-dev-webpack-plugin.'
].join( '\n' ) );
compilationAssetNames = [];
bundledLanguage = null;
}
const assetLanguages = Array.from( this._languages )
.filter( lang => lang !== bundledLanguage );
return [
// Assets where translations for the main language will be added.
...compilationAssetNames.map( assetName => ( {
outputBody: this._getTranslationAssets( outputDirectory, [ this._mainLanguage ] )[ 0 ].outputBody,
outputPath: assetName,
shouldConcat: true
} ) ),
// Translation assets outputted to separate translation files.
...this._getTranslationAssets( outputDirectory, assetLanguages )
];
}
/**
* Adds the specified `id` to the collection which will be translated to the specified language.
*
* @param {String} id
*/
addIdMessage( id ) {
this._foundMessageIds.add( id );
}
/**
* @param {Object} options
* @param {String} options.outputDirectory Output directory for the translation files relative to the output.
* @param {Array.<String>} options.compilationAssetNames Original asset names from the compiler (e.g. Webpack).
* @returns {Array.<Object>} Returns an array with one asset that
*/
_getAssetsWithTranslationsBundledToTheOutputFile( { outputDirectory, compilationAssetNames } ) {
const assetName = match( this._translationsOutputFile, compilationAssetNames );
if ( !assetName && typeof this._translationsOutputFile !== 'string' ) {
throw new Error( 'No file was matching the `translationsOutputFile` option.' );
}
const translationsBundle = this._getTranslationAssets( outputDirectory, Array.from( this._languages ) )
.map( asset => asset.outputBody )
.join( '' );
return [ {
outputBody: translationsBundle,
outputPath: assetName || this._translationsOutputFile,
// Concat with an existing asset if it exists.
shouldConcat: compilationAssetNames.some( name => name === assetName )
} ];
}
/**
* Returns assets for the given directory and languages.
*
* @private
* @param {String} outputDirectory The output directory for assets.
* @param {Array.<String>} languages Languages for assets.
*/
_getTranslationAssets( outputDirectory, languages ) {
// Sort the array of message ids to provide deterministic results.
const sortedMessageIds = Array.from( this._foundMessageIds ).sort( ( a, b ) => a.localeCompare( b ) );
return languages.map( language => {
const outputPath = path.join( outputDirectory, `${ language }.js` );
if ( !this._translationDictionaries[ language ] ) {
this.emit( 'error', `No translation has been found for the ${ language } language.` );
return { outputBody: '', outputPath };
}
const translations = this._getTranslations( language, sortedMessageIds );
// Examples of plural forms:
// pluralForms="nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2)"
// pluralForms="nplurals=3; plural=n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2"
/** @type {String} */
const pluralFormsRule = this._pluralFormsRules[ language ];
let pluralFormFunction;
// Do not add the `getPluralForm()` function if an integrator disabled it.
if ( !this._skipPluralFormFunction ) {
if ( !pluralFormsRule ) {
// This could be improved in the future by using a 3-rd party library for plural forms.
this.emit( 'warning', `The plural form function for the '${ language }' language has not been set.` );
} else {
const pluralFormFunctionBodyMatch = pluralFormsRule.match( /(?:plural=)(.+)/ );
// Add support for ES5 - this function will not be transpiled.
pluralFormFunction = `function(n){return ${ pluralFormFunctionBodyMatch[ 1 ] };}`;
}
}
// Stringify translations and remove unnecessary `""` around property names.
const stringifiedTranslations = JSON.stringify( translations )
.replace( /"([\w_]+)":/g, '$1:' );
const outputBody = (
'(function(d){' +
` const l = d['${ language }'] = d['${ language }'] || {};` +
' l.dictionary=Object.assign(' +
' l.dictionary||{},' +
` ${ stringifiedTranslations }` +
' );' +
( pluralFormFunction ? `l.getPluralForm=${ pluralFormFunction };` : '' ) +
'})(window.CKEDITOR_TRANSLATIONS||(window.CKEDITOR_TRANSLATIONS={}));'
);
return { outputBody, outputPath };
} );
}
/**
* Walks through the set of found message ids and collects corresponding strings in the target language dictionary.
* Skips messages that lacks their translations.
*
* @private
* @param {String} language The target language
* @param {Array.<String>} sortedMessageIds An array of sorted message ids.
* @returns {Object.<String,String|String[]>}
*/
_getTranslations( language, sortedMessageIds ) {
const langDictionary = this._translationDictionaries[ language ];
const translatedStrings = {};
for ( const messageId of sortedMessageIds ) {
const translatedMessage = langDictionary[ messageId ];
if ( !translatedMessage || translatedMessage.length === 0 ) {
this.emit( 'warning', `A translation is missing for '${ messageId }' in the '${ language }' language.` );
continue;
}
// Register first form as a default form if only one form was provided.
translatedStrings[ messageId ] = translatedMessage.length > 1 ?
translatedMessage :
translatedMessage[ 0 ];
}
return translatedStrings;
}
/**
* Loads translations from the PO file if that file exists.
*
* @private
* @param {String} language PO file's language.
* @param {String} pathToPoFile Path to the target PO file.
*/
_loadPoFile( language, pathToPoFile ) {
if ( !fs.existsSync( pathToPoFile ) ) {
return;
}
const parsedTranslationFile = PO.parse( fs.readFileSync( pathToPoFile, 'utf-8' ) );
this._pluralFormsRules[ language ] = this._pluralFormsRules[ language ] || parsedTranslationFile.headers[ 'Plural-Forms' ];
if ( !this._translationDictionaries[ language ] ) {
this._translationDictionaries[ language ] = {};
}
const dictionary = this._translationDictionaries[ language ];
for ( const item of parsedTranslationFile.items ) {
dictionary[ item.msgid ] = item.msgstr;
}
}
/**
* Returns a path to the translation directory depending on the path to the package.
*
* @protected
* @param {String|null} relativePathToPackage
* @returns {String}
*/
_getPathToTranslationDirectory( relativePathToPackage ) {
// If the `relativePathToPackage` is not specified, translations for a single package are processed.
if ( relativePathToPackage ) {
return path.join( relativePathToPackage, 'lang', 'translations' );
}
return path.join( 'lang', 'translations' );
}
};
/**
* @param {String|Function|RegExp} predicate
* @param {Array.<String>} options
* @returns {String|undefined}
*/
function match( predicate, options ) {
if ( typeof predicate === 'function' ) {
return options.find( predicate );
}
if ( typeof predicate === 'string' ) {
return options.find( option => predicate === option );
}
if ( predicate instanceof RegExp ) {
return options.find( option => predicate.test( option ) );
}
throw new Error(
'The CKEditorWebpackPlugin matching function got an unsupported type of a predicate.' +
`Got '${ predicate }' (${ typeof predicate } ) where supported values are only 'string', 'regexp' and 'function'.`
);
}