import * as glob from 'glob' ; import * as path from 'path' ; import ActionRecorder from './ActionRecorder' ; import LocalizationFolder from './LocalizationFolder' ; import pluralForms from './pluralForms' ; export interface Options { /** Audit files in memory instead of changing them on the filesystem and throw an error if any changes would be made */ check ? : boolean ; /** Glob pattern for the resource JSON files */ files ? : string ; /** Primary localization language. Other language files will be changed to match */ primary ? : string ; /** Language files to create if they don't exist, e.g. ['es, 'pt-BR', 'fr'] */ createResources ? : string [ ] ; /** Space value used for JSON.stringify when writing JSON files to disk */ space ? : string | number ; /** Line endings used when writing JSON files to disk */ lineEndings ? : LineEndings ; /** Insert a final newline when writing JSON files to disk */ finalNewline ? : boolean ; /** Use empty string for new keys instead of the primary language value */ newKeysEmpty ? : boolean ; } export type DirectoryMap = Record < string , FileMap > ; export type FileMap = Record < string , object > ; export type LineEndings = 'LF' | 'CRLF' ; type LocalizationValue = Record < string , string > | string ; export default function sync ( { check : isReportMode = false , files = '**/locales/*.json' , primary : primaryLanguage = 'en' , createResources : createFiles = [ ] , space : jsonSpacing = 4 , lineEndings = 'LF' , finalNewline = false , newKeysEmpty = false } ) { const allFiles = glob . sync ( files ) ; const directories = groupFilesByDirectory ( allFiles ) ; let targetLanguage ; let record ; let hasAnyErrors = false ; let hasAnyChanges = false ; let hasValueChanges = false ; for ( const currentDirectory of Object . keys ( directories ) ) { const folder = new LocalizationFolder ( directories [ currentDirectory ] , primaryLanguage , isReportMode ) ; folder . populateFromDisk ( createFiles ) ; const sourceObject = folder . getSourceObject ( ) ; if ( ! sourceObject ) { continue ; } for ( const filename of folder . getFilenames ( ) ) { targetLanguage = normalizeLanguageFromFilename ( filename ) ; record = new ActionRecorder ( filename , isReportMode ) ; syncObjects ( sourceObject , folder . getTargetObject ( filename ) ) ; record . flushToConsole ( ) ; hasValueChanges = hasValueChanges || record . hasAnyActions ( ) ; hasAnyErrors = hasAnyErrors || record . hasAnyErrors ( ) ; } const changedFiles = folder . flushToDisk ( jsonSpacing , lineEndings . toUpperCase ( ) as LineEndings , finalNewline ) ; hasAnyChanges = hasAnyChanges || changedFiles . length > 0 ; } if ( hasAnyErrors ) { throw new Error ( '[i18next-json-sync] found keys unsafe to synchronize' ) ; } if ( isReportMode ) { if ( hasValueChanges ) { throw new Error ( '[i18next-json-sync] check failed -- keys are out of sync. Run again without check mode to synchronize files' ) ; } if ( hasAnyChanges ) { throw new Error ( '[i18next-json-sync] check failed -- files have unordered keys or unexpected whitespace. Run again without check mode to correct files' ) ; } } function groupFilesByDirectory ( allFiles ) { const directories = { } ; for ( const filename of allFiles ) { const directory = path . dirname ( filename ) ; directories [ directory ] = directories [ directory ] || { } ; directories [ directory ] [ filename ] = null ; } return directories ; } function normalizeLanguageFromFilename ( filename ) { return path . basename ( filename , '.json' ) . replace ( /-/g , '_' ) . toLowerCase ( ) ; } function syncObjects ( source , target ) { mergeKeys ( source , target ) ; for ( const key of Object . keys ( target ) ) { if ( source . hasOwnProperty ( key ) && target . hasOwnProperty ( key ) ) { if ( typeof target [ key ] === 'string' && keyIsOnlyPluralForPrimary ( key , Object . keys ( source ) , Object . keys ( target ) ) ) { removeKey ( source , target , key ) ; } } else if ( ! isValidMappedPluralForm ( key , source , target ) ) { removeKey ( source , target , key ) ; } } } function mergeKeys ( source , target ) { for ( const key of Object . keys ( source ) ) { mergeKey ( source , target , key ) ; } } function mergeKey ( source , target , key ) { const sourceValue = source [ key ] ; const targetValue = target [ key ] ; if ( target . hasOwnProperty ( key ) ) { if ( areSameTypes ( sourceValue , targetValue ) ) { if ( isObject ( sourceValue ) ) { syncObjects ( sourceValue , targetValue ) ; } else if ( keyMatchesPluralForLanguage ( key , primaryLanguage ) && ! keyMatchesPluralForLanguage ( key , targetLanguage ) ) { removeKey ( source , target , key ) ; mergeKeys ( createPlurals ( key , source ) , target ) ; } } else { record . error ( file => `${ file } contains type mismatch on key ${ key }` ) ; } } else { copyValue ( source , target , key ) ; } } function copyValue ( source , target , key ) { const sourceValue = source [ key ] ; if ( isObject ( sourceValue ) ) { target [ key ] = { } ; syncObjects ( sourceValue , target [ key ] ) ; } else if ( keyMatchesPluralForLanguageIncludingSingular ( key , Object . keys ( source ) , primaryLanguage ) && ! keyMatchesPluralForLanguage ( key , targetLanguage ) && ! pluralFormsMatch ( ) ) { if ( ! targetPluralsPopulated ( target , key ) ) { copyPlurals ( createPlurals ( key , source ) , target ) ; } } else { target [ key ] = newKeysEmpty ? '' : sourceValue ; record . keyAdded ( key ) ; } } function targetPluralsPopulated ( target , key ) { const singular = getSingularForm ( key ) ; const pluralKeys = getPluralsForLanguage ( targetLanguage ) . map ( p => p . replace ( 'key' , singular ) ) ; const targetKeys = Object . keys ( target ) ; return pluralKeys . every ( expectedPluralKeys => targetKeys . indexOf ( expectedPluralKeys ) > - 1 ) ; } function copyPlurals ( plurals , target ) { for ( const key of Object . keys ( plurals ) ) { if ( target . hasOwnProperty ( key ) ) { continue ; } target [ key ] = target [ key ] = newKeysEmpty ? '' : plurals [ key ] ; record . keyAdded ( key ) ; } } function keyIsOnlyPluralForPrimary ( key , allPimaryKeys , allTargetKeys ) { if ( pluralFormsMatch ( ) ) { return false ; } if ( languageOnlyHasOneForm ( primaryLanguage ) ) { return false ; } return ( keyMatchesPluralForLanguageIncludingSingular ( key , allPimaryKeys , primaryLanguage ) && ! keyMatchesPluralForLanguageIncludingSingular ( key , allTargetKeys , targetLanguage ) ) ; } function pluralFormsMatch ( ) { const primaryForms = Object . keys ( getPluralsForLanguage ( primaryLanguage ) ) ; const targetForms = Object . keys ( getPluralsForLanguage ( targetLanguage ) ) ; return ( primaryForms . length === targetForms . length && primaryForms . every ( form => targetForms . indexOf ( form ) > - 1 ) ) ; } function keyMatchesPluralForLanguageIncludingSingular ( key , allKeys , language ) { /**
         * It's impossible to tell whether a key is a plural for a language with one form shared between singular and plurals.
         * With other languages we can look for relationships between e.g. value and value_plural or value and value_0.
         */ if ( languageOnlyHasOneForm ( language ) ) { return true ; } const matchesAPlural = keyMatchesPluralForLanguage ( key , language ) ; if ( matchesAPlural ) { return true ; } if ( ! languageHasSingularForm ( language ) ) { return false ; } for ( const _key of allKeys ) { if ( key !== _key && isPluralFormForSingular ( _key , key , language ) ) { return true ; } } return false ; } function keyMatchesPluralForLanguage ( key , language ) { const forms = getPluralsForLanguage ( language ) . map ( form => form . replace ( 'key' , '' ) ) ; for ( const form of forms ) { if ( form && key . endsWith ( form ) ) { return true ; } } return false ; } function isValidMappedPluralForm ( key , sourceObject , targetObject ) { const singular = getSingularForm ( key ) ; const isPluralForPrimaryLanguage = Object . keys ( sourceObject ) . some ( key => isPluralFormForSingular ( key , singular , primaryLanguage ) ) ; if ( languageOnlyHasOneForm ( targetLanguage ) ) { return singular === key && isPluralForPrimaryLanguage ; } const isPluralForTargetLanguage = Object . keys ( targetObject ) . some ( key => isPluralFormForSingular ( key , singular , targetLanguage ) ) ; return isPluralForPrimaryLanguage && isPluralForTargetLanguage ; } function getSingularForm ( key ) { return key . replace ( /_(plural|\d)$/ , '' ) ; } function isPluralFormForSingular ( key , singular , language ) { return getPluralsForLanguage ( language ) . map ( form => form . replace ( 'key' , singular ) ) . indexOf ( key ) > - 1 ; } function languageHasSingularForm ( language ) { return getPluralsForLanguage ( language ) . map ( form => form . replace ( 'key' , '' ) ) . indexOf ( '' ) > - 1 ; } function languageOnlyHasOneForm ( language ) { return getPluralsForLanguage ( language ) . length === 1 ; } function getPluralsForLanguage ( language ) { if ( pluralForms . hasOwnProperty ( language ) ) { return pluralForms [ language ] ; } if ( language . indexOf ( '_' ) > - 1 || language . indexOf ( '-' ) > - 1 ) { const baseLanguage = language . split ( /-|_/ ) [ 0 ] ; if ( pluralForms . hasOwnProperty ( baseLanguage ) ) { return pluralForms [ baseLanguage ] ; } } return [ ] ; } function createPlurals ( key , source ) { const singular = getSingularForm ( key ) ; const plurals = { } ; if ( languageOnlyHasOneForm ( primaryLanguage ) ) { plurals [ key ] = source [ key ] ; } else { const fillValue = getPluralFillValue ( singular , source ) ; for ( const form of getPluralsForLanguage ( targetLanguage ) ) { plurals [ form . replace ( 'key' , singular ) ] = fillValue ; } } return plurals ; } function getPluralFillValue ( singular , source ) { if ( languageOnlyHasOneForm ( primaryLanguage ) ) { return source [ singular ] ; } const sourceKeys = Object . keys ( source ) . filter ( k => k !== singular ) ; for ( const form of getPluralsForLanguage ( primaryLanguage ) ) { const pluralKey = form . replace ( 'key' , singular ) ; if ( sourceKeys . indexOf ( pluralKey ) > - 1 ) { return source [ pluralKey ] ; } } return source [ singular ] ; } function removeKey ( source , target , key ) { if ( isObject ( target [ key ] ) ) { gatherKeysFor ( target [ key ] ) . forEach ( k => record . keyRemoved ( k ) ) ; } else { record . keyRemoved ( key ) ; } delete target [ key ] ; } function gatherKeysFor ( object ) { return Object . keys ( object ) . map ( key => gatherPrimitivesForSingleKey ( object , key ) ) . reduce ( ( all , next ) => all . concat ( next ) , [ ] ) ; } function gatherPrimitivesForSingleKey ( object , key ) { if ( isObject ( object [ key ] ) ) { return gatherKeysFor ( object [ key ] ) ; } else { return [ key ] ; } } } function isObject ( value ) { return getTypeName ( value ) === 'Object' ; } function areSameTypes ( value , otherValue ) { return getTypeName ( value ) === getTypeName ( otherValue ) ; } function getTypeName ( object ) { const fullName = Object . prototype . toString . call ( object ) ; return fullName . split ( ' ' ) [ 1 ] . slice ( 0 , - 1 ) ; }