import { JsonFile , FileSystem , ITerminal , NewlineKind } from '@rushstack/node-core-library' ; import * as Webpack from 'webpack' ; import * as path from 'path' ; import * as Tapable from 'tapable' ; import { Constants } from './utilities/Constants' ; import { IWebpackConfigurationUpdaterOptions , WebpackConfigurationUpdater } from './WebpackConfigurationUpdater' ; import { ILocalizationPluginOptions , ILocalizationStats , ILocaleFileData , ILocalizationFile , ILocaleElementMap , ILocalizedStrings , IResolvedMissingTranslations } from './interfaces' ; import { ILocalizedWebpackChunk } from './webpackInterfaces' ; import { LocFileTypingsGenerator } from './LocFileTypingsGenerator' ; import { Pseudolocalization } from './Pseudolocalization' ; import { EntityMarker } from './utilities/EntityMarker' ; import { IAsset , IProcessAssetResult , AssetProcessor , PLACEHOLDER_REGEX } from './AssetProcessor' ; import { LocFileParser } from './utilities/LocFileParser' ; internal export interface IStringPlaceholder { value : string ; suffix : string ; } internal export interface IAddDefaultLocFileResult { /**
     * A list of paths to translation files that were loaded
     */ additionalLoadedFilePaths : string [ ] ; errors : Error [ ] ; } interface IExtendedMainTemplate { hooks : { assetPath : Tapable . SyncHook < string , IAssetPathOptions > ; } ; } interface IExtendedConfiguration extends Webpack . compilation . Compilation { options : Webpack . Configuration ; } interface IExtendedChunkGroup extends Webpack . compilation . ChunkGroup { getChildren ( ) : Webpack . compilation . Chunk [ ] ; } interface IExtendedChunk extends Webpack . compilation . Chunk { filenameTemplate : string ; } interface IAssetPathOptions { chunk : Webpack . compilation . Chunk ; contentHashType : string ; filename : string ; } internal export interface IStringSerialNumberData { values : ILocaleElementMap ; locFilePath : string ; stringName : string ; } const PLUGIN_NAME = 'localization' ; public export class LocalizationPlugin implements Webpack . Plugin { internal public stringKeys : Map < string , IStringPlaceholder > = new Map < string , IStringPlaceholder > ( ) ; private _options : ILocalizationPluginOptions ; private _resolvedTranslatedStringsFromOptions ! : ILocalizedStrings ; private _globsToIgnore : string [ ] | undefined ; private _stringPlaceholderCounter : number = 0 ; private _stringPlaceholderMap : Map < string , IStringSerialNumberData > = new Map < string , IStringSerialNumberData > ( ) ; private _locales : Set < string > = new Set < string > ( ) ; private _passthroughLocaleName ! : string ; private _defaultLocale ! : string ; private _noStringsLocaleName ! : string ; private _fillMissingTranslationStrings ! : boolean ; private _pseudolocalizers : Map < string , ( str : string ) => string > = new Map < string , ( str : string ) => string > ( ) ; private _resxNewlineNormalization : NewlineKind | undefined ; /**
     * The outermost map's keys are the locale names.
     * The middle map's keys are the resolved, file names.
     * The innermost map's keys are the string identifiers and its values are the string values.
     */ private _resolvedLocalizedStrings : Map < string , Map < string , Map < string , string > > > = new Map < string , Map < string , Map < string , string > > > ( ) ; public constructor ( options ) { if ( options . filesToIgnore ) { throw new Error ( 'The filesToIgnore option is no longer supported. Please use globsToIgnore instead.' ) ; } this . _options = options ; } public apply ( compiler ) { const isWebpack4 = ! ! compiler . hooks ; if ( ! isWebpack4 ) { throw new Error ( `The ${ LocalizationPlugin . name } plugin requires Webpack 4` ) ; } if ( this . _options . typingsOptions && compiler . context ) { if ( this . _options . typingsOptions . generatedTsFolder && ! path . isAbsolute ( this . _options . typingsOptions . generatedTsFolder ) ) { this . _options . typingsOptions . generatedTsFolder = path . resolve ( compiler . context , this . _options . typingsOptions . generatedTsFolder ) ; } if ( this . _options . typingsOptions . sourceRoot && ! path . isAbsolute ( this . _options . typingsOptions . sourceRoot ) ) { this . _options . typingsOptions . sourceRoot = path . resolve ( compiler . context , this . _options . typingsOptions . sourceRoot ) ; } } const isWebpackDevServer = process . env . WEBPACK_DEV_SERVER === 'true' ; const { errors , warnings } = this . _initializeAndValidateOptions ( compiler . options , isWebpackDevServer ) ; let typingsPreprocessor ; if ( this . _options . typingsOptions ) { typingsPreprocessor = new LocFileTypingsGenerator ( { srcFolder : this . _options . typingsOptions . sourceRoot || compiler . context , generatedTsFolder : this . _options . typingsOptions . generatedTsFolder , exportAsDefault : this . _options . typingsOptions . exportAsDefault , globsToIgnore : this . _options . globsToIgnore } ) ; } else { typingsPreprocessor = undefined ; } const webpackConfigurationUpdaterOptions = { pluginInstance : this , configuration : compiler . options , globsToIgnore : this . _globsToIgnore , localeNameOrPlaceholder : Constants . LOCALE_NAME_PLACEHOLDER , resxNewlineNormalization : this . _resxNewlineNormalization } ; if ( errors . length > 0 || warnings . length > 0 ) { compiler . hooks . compilation . tap ( PLUGIN_NAME , ( compilation ) => { compilation . errors . push ( ... errors ) ; compilation . warnings . push ( ... warnings ) ; } ) ; if ( errors . length > 0 ) { WebpackConfigurationUpdater . amendWebpackConfigurationForInPlaceLocFiles ( webpackConfigurationUpdaterOptions ) ; return ; } } if ( isWebpackDevServer ) { if ( typingsPreprocessor ) { compiler . hooks . afterEnvironment . tap ( PLUGIN_NAME , ( ) => typingsPreprocessor ! . runWatcherAsync ( ) ) ; if ( ! compiler . options . plugins ) { compiler . options . plugins = [ ] ; } compiler . options . plugins . push ( new Webpack . WatchIgnorePlugin ( [ this . _options . typingsOptions ! . generatedTsFolder ] ) ) ; } WebpackConfigurationUpdater . amendWebpackConfigurationForInPlaceLocFiles ( webpackConfigurationUpdaterOptions ) ; } else { if ( typingsPreprocessor ) { compiler . hooks . beforeRun . tapPromise ( PLUGIN_NAME , async ( ) => await typingsPreprocessor ! . generateTypingsAsync ( ) ) ; } WebpackConfigurationUpdater . amendWebpackConfigurationForMultiLocale ( webpackConfigurationUpdaterOptions ) ; if ( errors . length === 0 ) { compiler . hooks . thisCompilation . tap ( PLUGIN_NAME , ( untypedCompilation ) => { const compilation = untypedCompilation as IExtendedConfiguration ; ( compilation . mainTemplate as unknown as IExtendedMainTemplate ) . hooks . assetPath . tap ( PLUGIN_NAME , ( assetPath , options ) => { if ( options . contentHashType === 'javascript' && assetPath . match ( Constants . LOCALE_FILENAME_TOKEN_REGEX ) ) { if ( typeof options . chunk . id === 'string' && ( options . chunk . id as string ) . match ( /^\" \+/ ) ) { return assetPath . replace ( Constants . LOCALE_FILENAME_TOKEN_REGEX , `" + ${ Constants . JSONP_PLACEHOLDER } + "` ) ; } else { return assetPath . replace ( Constants . LOCALE_FILENAME_TOKEN_REGEX , Constants . LOCALE_NAME_PLACEHOLDER ) ; } } else if ( assetPath . match ( Constants . NO_LOCALE_SOURCE_MAP_FILENAME_TOKEN_REGEX ) ) { const deLocalizedFilename = options . filename . replace ( PLACEHOLDER_REGEX , Constants . LOCALE_FILENAME_TOKEN ) ; return assetPath . replace ( Constants . NO_LOCALE_SOURCE_MAP_FILENAME_TOKEN_REGEX , deLocalizedFilename ) ; } else { return assetPath ; } } ) ; compilation . hooks . optimizeChunks . tap ( PLUGIN_NAME , ( untypedChunks , untypedChunkGroups ) => { const chunks = untypedChunks as IExtendedChunk [ ] ; const chunkGroups = untypedChunkGroups as IExtendedChunkGroup [ ] ; let chunksHaveAnyChildren = false ; for ( const chunkGroup of chunkGroups ) { const children = chunkGroup . getChildren ( ) ; if ( children . length > 0 ) { chunksHaveAnyChildren = true ; break ; } } if ( chunksHaveAnyChildren && ( ! compilation . options . output || ! compilation . options . output . chunkFilename || compilation . options . output . chunkFilename . indexOf ( Constants . LOCALE_FILENAME_TOKEN ) === - 1 ) ) { compilation . errors . push ( new Error ( 'The configuration.output.chunkFilename property must be provided and must include ' + `the ${ Constants . LOCALE_FILENAME_TOKEN } placeholder` ) ) ; return ; } for ( const chunk of chunks ) { const localizedChunk = this . _chunkHasLocalizedModules ( chunk ) ; const replacementValue = localizedChunk ? Constants . LOCALE_NAME_PLACEHOLDER : this . _noStringsLocaleName ; if ( chunk . hasRuntime ( ) ) { chunk . filenameTemplate = ( compilation . options . output ! . filename as string ) . replace ( Constants . LOCALE_FILENAME_TOKEN_REGEX , replacementValue ) ; } else { chunk . filenameTemplate = compilation . options . output ! . chunkFilename ! . replace ( Constants . LOCALE_FILENAME_TOKEN_REGEX , replacementValue ) ; } } } ) ; } ) ; compiler . hooks . emit . tap ( PLUGIN_NAME , ( compilation ) => { const localizationStats = { entrypoints : { } , namedChunkGroups : { } } ; const alreadyProcessedAssets = new Set < string > ( ) ; const hotUpdateRegex = /\.hot-update\.js$/ ; for ( const untypedChunk of compilation . chunks ) { const chunk = untypedChunk ; const chunkFilesSet = new Set ( chunk . files ) ; function processChunkJsFile ( callback ) { let alreadyProcessedAFileInThisChunk = false ; for ( const chunkFilename of chunk . files ) { if ( chunkFilename . endsWith ( '.js' ) && ! hotUpdateRegex . test ( chunkFilename ) && ! alreadyProcessedAssets . has ( chunkFilename ) ) { if ( alreadyProcessedAFileInThisChunk ) { throw new Error ( `Found more than one JS file in chunk "${ chunk . name }". This is not expected.` ) ; } alreadyProcessedAFileInThisChunk = true ; alreadyProcessedAssets . add ( chunkFilename ) ; callback ( chunkFilename ) ; } } } if ( this . _chunkHasLocalizedModules ( chunk ) ) { processChunkJsFile ( ( chunkFilename ) => { if ( chunkFilename . indexOf ( Constants . LOCALE_NAME_PLACEHOLDER ) === - 1 ) { throw new Error ( `Asset ${ chunkFilename } is expected to be localized, but is missing a locale placeholder` ) ; } const asset = compilation . assets [ chunkFilename ] ; const resultingAssets = AssetProcessor . processLocalizedAsset ( { plugin : this , compilation , assetName : chunkFilename , asset , chunk , chunkHasLocalizedModules : this . _chunkHasLocalizedModules . bind ( this ) , locales : this . _locales , noStringsLocaleName : this . _noStringsLocaleName , fillMissingTranslationStrings : this . _fillMissingTranslationStrings , defaultLocale : this . _defaultLocale } ) ; delete compilation . assets [ chunkFilename ] ; chunkFilesSet . delete ( chunkFilename ) ; const localizedChunkAssets = { } ; for ( const [ locale , newAsset ] of resultingAssets ) { compilation . assets [ newAsset . filename ] = newAsset . asset ; localizedChunkAssets [ locale ] = newAsset . filename ; chunkFilesSet . add ( newAsset . filename ) ; } if ( chunk . hasRuntime ( ) ) { localizationStats . entrypoints [ chunk . name ] = { localizedAssets : localizedChunkAssets } ; } else { if ( chunk . name ) { localizationStats . namedChunkGroups [ chunk . name ] = { localizedAssets : localizedChunkAssets } ; } } chunk . localizedFiles = localizedChunkAssets ; } ) ; } else { processChunkJsFile ( ( chunkFilename ) => { const asset = compilation . assets [ chunkFilename ] ; const resultingAsset = AssetProcessor . processNonLocalizedAsset ( { plugin : this , compilation , assetName : chunkFilename , asset , chunk , noStringsLocaleName : this . _noStringsLocaleName , chunkHasLocalizedModules : this . _chunkHasLocalizedModules . bind ( this ) } ) ; delete compilation . assets [ chunkFilename ] ; chunkFilesSet . delete ( chunkFilename ) ; compilation . assets [ resultingAsset . filename ] = resultingAsset . asset ; chunkFilesSet . add ( resultingAsset . filename ) ; } ) ; } chunk . files = Array . from ( chunkFilesSet ) ; } if ( this . _options . localizationStats ) { if ( this . _options . localizationStats . dropPath ) { const resolvedLocalizationStatsDropPath = path . resolve ( compiler . outputPath , this . _options . localizationStats . dropPath ) ; JsonFile . save ( localizationStats , resolvedLocalizationStatsDropPath , { ensureFolderExists : true } ) ; } if ( this . _options . localizationStats . callback ) { try { this . _options . localizationStats . callback ( localizationStats ) ; } catch ( e ) { } } } } ) ; } } } internal returns public addDefaultLocFile ( terminal , localizedResourcePath , localizedResourceData ) { const additionalLoadedFilePaths = [ ] ; const errors = [ ] ; const locFileData = this . _convertLocalizationFileToLocData ( localizedResourceData ) ; this . _addLocFile ( this . _defaultLocale , localizedResourcePath , locFileData ) ; const normalizeLocalizedData = ( localizedData ) => { if ( typeof localizedData === 'string' ) { additionalLoadedFilePaths . push ( localizedData ) ; const localizationFile = LocFileParser . parseLocFile ( { filePath : localizedData , content : FileSystem . readFile ( localizedData ) , terminal : terminal , resxNewlineNormalization : this . _resxNewlineNormalization } ) ; return this . _convertLocalizationFileToLocData ( localizationFile ) ; } else { return localizedData ; } } ; const missingLocales = [ ] ; for ( const [ translatedLocaleName , translatedStrings ] of Object . entries ( this . _resolvedTranslatedStringsFromOptions ) ) { const translatedLocFileFromOptions = translatedStrings [ localizedResourcePath ] ; if ( ! translatedLocFileFromOptions ) { missingLocales . push ( translatedLocaleName ) ; } else { const translatedLocFileData = normalizeLocalizedData ( translatedLocFileFromOptions ) ; this . _addLocFile ( translatedLocaleName , localizedResourcePath , translatedLocFileData ) ; } } if ( missingLocales . length > 0 && this . _options . localizedData . resolveMissingTranslatedStrings ) { let resolvedTranslatedData = undefined ; try { resolvedTranslatedData = this . _options . localizedData . resolveMissingTranslatedStrings ( missingLocales , localizedResourcePath ) ; } catch ( e ) { errors . push ( e as Error ) ; } if ( resolvedTranslatedData ) { for ( const [ resolvedLocaleName , resolvedLocaleData ] of Object . entries ( resolvedTranslatedData ) ) { if ( resolvedLocaleData ) { const translatedLocFileData = normalizeLocalizedData ( resolvedLocaleData ) ; this . _addLocFile ( resolvedLocaleName , localizedResourcePath , translatedLocFileData ) ; } } } } this . _pseudolocalizers . forEach ( ( pseudolocalizer , pseudolocaleName ) => { const pseudolocFileData = { } ; for ( const [ stringName , stringValue ] of Object . entries ( locFileData ) ) { pseudolocFileData [ stringName ] = pseudolocalizer ( stringValue ) ; } this . _addLocFile ( pseudolocaleName , localizedResourcePath , pseudolocFileData ) ; } ) ; return { additionalLoadedFilePaths , errors } ; } internal public getDataForSerialNumber ( serialNumber ) { return this . _stringPlaceholderMap . get ( serialNumber ) ; } private _addLocFile ( localeName , localizedFilePath , localizedFileData ) { const filesMap = this . _resolvedLocalizedStrings . get ( localeName ) ! ; const stringsMap = new Map < string , string > ( ) ; filesMap . set ( localizedFilePath , stringsMap ) ; for ( const [ stringName , stringValue ] of Object . entries ( localizedFileData ) ) { const stringKey = `${ localizedFilePath }?${ stringName }` ; if ( ! this . stringKeys . has ( stringKey ) ) { const placeholder = this . _getPlaceholderString ( ) ; this . stringKeys . set ( stringKey , placeholder ) ; } const placeholder = this . stringKeys . get ( stringKey ) ! ; if ( ! this . _stringPlaceholderMap . has ( placeholder . suffix ) ) { this . _stringPlaceholderMap . set ( placeholder . suffix , { values : { [ this . _passthroughLocaleName ] : stringName } , locFilePath : localizedFilePath , stringName : stringName } ) ; } this . _stringPlaceholderMap . get ( placeholder . suffix ) ! . values [ localeName ] = stringValue ; stringsMap . set ( stringName , stringValue ) ; } } private _initializeAndValidateOptions ( configuration , isWebpackDevServer ) { const errors = [ ] ; const warnings = [ ] ; function ensureValidLocaleName ( localeName ) { const LOCALE_NAME_REGEX = /[a-z-]/i ; if ( ! localeName . match ( LOCALE_NAME_REGEX ) ) { errors . push ( new Error ( `Invalid locale name: ${ localeName }. Locale names may only contain letters and hyphens.` ) ) ; return false ; } else { return true ; } } if ( ! configuration . output || ! configuration . output . filename || typeof configuration . output . filename !== 'string' || configuration . output . filename . indexOf ( Constants . LOCALE_FILENAME_TOKEN ) === - 1 ) { errors . push ( new Error ( 'The configuration.output.filename property must be provided, must be a string, and must include ' + `the ${ Constants . LOCALE_FILENAME_TOKEN } placeholder` ) ) ; } { this . _globsToIgnore = this . _options . globsToIgnore ; } if ( this . _options . localizedData ) { if ( this . _options . localizedData . passthroughLocale ) { const { usePassthroughLocale , passthroughLocaleName = 'passthrough' } = this . _options . localizedData . passthroughLocale ; if ( usePassthroughLocale ) { this . _passthroughLocaleName = passthroughLocaleName ; this . _locales . add ( passthroughLocaleName ) ; } } const { translatedStrings } = this . _options . localizedData ; this . _resolvedTranslatedStringsFromOptions = { } ; if ( translatedStrings ) { for ( const [ localeName , locale ] of Object . entries ( translatedStrings ) ) { if ( this . _locales . has ( localeName ) ) { errors . push ( Error ( `The locale "${ localeName }" appears multiple times. ` + 'There may be multiple instances with different casing.' ) ) ; return { errors , warnings } ; } if ( ! ensureValidLocaleName ( localeName ) ) { return { errors , warnings } ; } this . _locales . add ( localeName ) ; this . _resolvedLocalizedStrings . set ( localeName , new Map < string , Map < string , string > > ( ) ) ; this . _resolvedTranslatedStringsFromOptions [ localeName ] = { } ; const locFilePathsInLocale = new Set < string > ( ) ; for ( const [ locFilePath , locFileDataFromOptions ] of Object . entries ( locale ) ) { if ( locale . hasOwnProperty ( locFilePath ) ) { const normalizedLocFilePath = path . resolve ( configuration . context ! , locFilePath ) ; if ( locFilePathsInLocale . has ( normalizedLocFilePath ) ) { errors . push ( new Error ( `The localization file path "${ locFilePath }" appears multiple times in locale ${ localeName }. ` + 'There may be multiple instances with different casing.' ) ) ; return { errors , warnings } ; } locFilePathsInLocale . add ( normalizedLocFilePath ) ; const normalizedLocFileDataFromOptions = typeof locFileDataFromOptions === 'string' ? path . resolve ( configuration . context ! , locFileDataFromOptions ) : locFileDataFromOptions ; this . _resolvedTranslatedStringsFromOptions [ localeName ] [ normalizedLocFilePath ] = normalizedLocFileDataFromOptions ; } } } } if ( this . _options . localizedData . defaultLocale ) { const { localeName , fillMissingTranslationStrings } = this . _options . localizedData . defaultLocale ; if ( this . _options . localizedData . defaultLocale . localeName ) { if ( this . _locales . has ( localeName ) ) { errors . push ( new Error ( 'The default locale is also specified in the translated strings.' ) ) ; return { errors , warnings } ; } else if ( ! ensureValidLocaleName ( localeName ) ) { return { errors , warnings } ; } this . _locales . add ( localeName ) ; this . _resolvedLocalizedStrings . set ( localeName , new Map < string , Map < string , string > > ( ) ) ; this . _defaultLocale = localeName ; this . _fillMissingTranslationStrings = ! ! fillMissingTranslationStrings ; } else { errors . push ( new Error ( 'Missing default locale name' ) ) ; return { errors , warnings } ; } } else { errors . push ( new Error ( 'Missing default locale options.' ) ) ; return { errors , warnings } ; } if ( this . _options . localizedData . pseudolocales ) { for ( const [ pseudolocaleName , pseudoLocaleOpts ] of Object . entries ( this . _options . localizedData . pseudolocales ) ) { if ( this . _defaultLocale === pseudolocaleName ) { errors . push ( new Error ( `A pseudolocale (${ pseudolocaleName }) name is also the default locale name.` ) ) ; return { errors , warnings } ; } if ( this . _locales . has ( pseudolocaleName ) ) { errors . push ( new Error ( `A pseudolocale (${ pseudolocaleName }) name is also specified in the translated strings.` ) ) ; return { errors , warnings } ; } this . _pseudolocalizers . set ( pseudolocaleName , Pseudolocalization . getPseudolocalizer ( pseudoLocaleOpts ) ) ; this . _locales . add ( pseudolocaleName ) ; this . _resolvedLocalizedStrings . set ( pseudolocaleName , new Map < string , Map < string , string > > ( ) ) ; } } if ( this . _options . localizedData . normalizeResxNewlines ) { switch ( this . _options . localizedData . normalizeResxNewlines ) { case 'crlf' : { this . _resxNewlineNormalization = NewlineKind . CrLf ; break ; } case 'lf' : { this . _resxNewlineNormalization = NewlineKind . Lf ; break ; } default : { errors . push ( new Error ( `Unexpected value "${ this . _options . localizedData . normalizeResxNewlines }" for option ` + '"localizedData.normalizeResxNewlines"' ) ) ; break ; } } } } else if ( ! isWebpackDevServer ) { throw new Error ( 'Localized data must be provided unless webpack dev server is running.' ) ; } if ( this . _options . noStringsLocaleName === undefined || this . _options . noStringsLocaleName === null || ! ensureValidLocaleName ( this . _options . noStringsLocaleName ) ) { this . _noStringsLocaleName = 'none' ; } else { this . _noStringsLocaleName = this . _options . noStringsLocaleName ; } return { errors , warnings } ; } private _getPlaceholderString ( ) { const suffix = ( this . _stringPlaceholderCounter ++ ) . toString ( ) ; return { value : `${ Constants . STRING_PLACEHOLDER_PREFIX }_\\_${ Constants . STRING_PLACEHOLDER_LABEL }_${ suffix }` , suffix : suffix } ; } private _chunkHasLocalizedModules ( chunk ) { let chunkHasAnyLocModules = EntityMarker . getMark ( chunk ) ; if ( chunkHasAnyLocModules === undefined ) { chunkHasAnyLocModules = false ; for ( const module of chunk . getModules ( ) ) { if ( EntityMarker . getMark ( module ) ) { chunkHasAnyLocModules = true ; break ; } } if ( ! chunkHasAnyLocModules && chunk . hasRuntime ( ) ) { for ( const asyncChunk of chunk . getAllAsyncChunks ( ) ) { if ( this . _chunkHasLocalizedModules ( asyncChunk ) ) { chunkHasAnyLocModules = true ; break ; } } } EntityMarker . markEntity ( chunk , chunkHasAnyLocModules ) ; } return chunkHasAnyLocModules ; } private _convertLocalizationFileToLocData ( locFile ) { const locFileData = { } ; for ( const [ stringName , locFileEntry ] of Object . entries ( locFile ) ) { locFileData [ stringName ] = locFileEntry . value ; } return locFileData ; } }