grunt-banana-checker
Version:
Checker for the 'Banana' JSON-file format for interface messages, as used by MediaWiki and jQuery.i18n.
728 lines (619 loc) • 24.6 kB
JavaScript
;
const path = require( 'path' );
const fs = require( 'fs' );
const chalk = require( 'chalk' );
let optionsFromConfigFile = {};
[ '.bananaconfig', 'bananaconfig' ].some( ( fileName ) => {
const filePath = path.resolve( process.cwd(), fileName );
try {
require.resolve( filePath );
} catch ( err ) {
return false;
}
// eslint-disable-next-line security/detect-non-literal-require
optionsFromConfigFile = require( filePath );
return true;
} );
/**
* Checker for the 'Banana' JSON-file format for interface messages.
*
* @param {string} dir
* @param {Object} options Additional options, overriding defatuls
* and options loaded from config file.
* @param {Function} logErr Callback accepting an error message
* as first string parameter.
* @param {Function} logWarn Callback accepting a warning message
* as first string parameter.
* @param {boolean} [useColour]
* @return {boolean} Success
*/
module.exports = function bananaChecker( dir, options, logErr, logWarn, useColour ) {
let ok = true;
function log( type, msg ) {
if ( type === 'error' ) {
logErr( useColour ? chalk.red( msg ) : msg );
ok = false;
} else if ( type === 'warn' ) {
logWarn( useColour ? chalk.yellow( msg ) : msg );
} else {
throw new Error( `Invalid log type ${ type } for message ${ msg }.` );
}
}
function plural( n, singularMsg, pluralMsg ) {
return n > 1 ? pluralMsg : singularMsg;
}
// Step 1: Read config and get set up.
options = Object.assign( {
sourceFile: 'en.json',
documentationFile: 'qqq.json',
disallowBlankTranslations: 'error',
disallowDuplicateTranslations: 'off',
disallowUnusedDocumentation: 'error',
disallowUnusedTranslations: 'off',
disallowEmptyDocumentation: 'error',
requireCompleteMessageDocumentation: 'error',
requireCompleteTranslationLanguages: 'off',
requireCompleteTranslationMessages: 'off',
requireCompletelyUsedParameters: 'off',
requireKeyPrefix: 'off',
requireLowerCase: 'error',
requireMetadata: 'error',
disallowLeadingWhitespace: 'off',
disallowLeadingWhitespaceTranslated: 'off',
disallowTrailingWhitespace: 'error',
disallowTrailingWhitespaceTranslated: 'warn',
autofix: false,
autofixTranslated: false
}, optionsFromConfigFile, options );
const jsonFilenameRegex = /(.*)\.json$/;
const leadingWhitespaceRegex = /^\s+/;
const trailingWhitespaceRegex = /\s+$/;
const translatedData = {};
function isValidLevel( l ) {
return l === 'error' || l === 'warn' || l === 'off';
}
function getRuleLevel( rule ) {
return Array.isArray( rule ) ? rule[ 0 ] : rule;
}
function getRuleOptions( rule ) {
return Array.isArray( rule ) ? ( rule[ 1 ] || {} ) : {};
}
function isRuleEnabled( rule ) {
return getRuleLevel( rule ) !== 'off';
}
function messages( filename, type ) {
let messageArray;
try {
// eslint-disable-next-line security/detect-non-literal-require
messageArray = require( path.resolve( dir, filename ) );
} catch ( e ) {
log( 'error', `Loading ${ type } messages failed: "${ e }".` );
throw e;
}
return messageArray;
}
function keysNoMetadata( messageArray, type ) {
const keys = Object.keys( messageArray );
const keyOffset = keys.indexOf( '@metadata' );
if ( keyOffset === -1 ) {
if ( isRuleEnabled( options.requireMetadata ) ) {
log( getRuleLevel( options.requireMetadata ), `No metadata block in the ${ type } messages file.` );
ok = false;
}
} else {
keys.splice( keyOffset, 1 );
}
return keys;
}
function writeFile( filename, messageData ) {
// eslint-disable-next-line security/detect-non-literal-fs-filename
fs.writeFileSync(
path.resolve( dir, filename ),
JSON.stringify( messageData, null, '\t' ) + '\n'
);
}
/* Begin legacy config mappings */
// Errors
[
'disallowDuplicateTranslations',
'disallowEmptyDocumentation',
'disallowUnusedDocumentation',
'disallowUnusedTranslations',
'requireCompletelyUsedParameters',
'requireCompleteMessageDocumentation',
'requireLowerCase',
'requireMetadata'
].forEach( ( key ) => {
if ( typeof options[ key ] === 'boolean' ) {
options[ key ] = options[ key ] ? 'error' : 'off';
}
} );
// Warnings
if ( typeof options.disallowBlankTranslations === 'boolean' ) {
options.disallowBlankTranslations = options.disallowBlankTranslations ? 'warn' : 'off';
}
// Warnings inverted
if ( typeof options.allowLeadingWhitespace === 'boolean' ) {
options.disallowLeadingWhitespace = options.allowLeadingWhitespace ? 'off' : 'error';
options.disallowLeadingWhitespaceTranslated = options.allowLeadingWhitespace ? 'off' : 'warn';
}
if ( typeof options.allowTrailingWhitespace === 'boolean' ) {
options.disallowTrailingWhitespace = options.allowTrailingWhitespace ? 'off' : 'error';
options.disallowTrailingWhitespaceTranslated = options.allowTrailingWhitespace ? 'off' : 'warn';
}
if ( options.requireLowerCase === 'initial' ) {
options.requireLowerCase = [ 'error', { initial: true } ];
}
if ( !Array.isArray( options.requireKeyPrefix ) ) {
options.requireKeyPrefix = [ options.requireKeyPrefix ];
}
if (
Array.isArray( options.requireKeyPrefix ) &&
!isValidLevel( options.requireKeyPrefix[ 0 ] )
) {
options.requireKeyPrefix = [ 'error', { prefix: options.requireKeyPrefix } ];
}
if (
Array.isArray( options.requireCompleteTranslationLanguages ) &&
!isValidLevel( options.requireCompleteTranslationLanguages )
) {
options.requireCompleteTranslationLanguages = [ 'error', { langs: options.requireCompleteTranslationLanguages, ignoreMissing: true } ];
}
if (
Array.isArray( options.requireCompleteTranslationMessages ) &&
!isValidLevel( options.requireCompleteTranslationMessages )
) {
options.requireCompleteTranslationMessages = [ 'error', { keys: options.requireCompleteTranslationMessages } ];
}
function setRuleOption( rule, key, val ) {
let newRule = rule;
if ( !Array.isArray( rule ) ) {
newRule = [ rule ];
}
newRule[ 1 ] = newRule[ 1 ] || {};
newRule[ 1 ][ key ] = val;
return newRule;
}
// ignoreMissingBlankTranslations is now an additional option
// to requireCompleteTranslationLanguages
// The default is true, so only change if set to false
if ( options.ignoreMissingBlankTranslations === false ) {
options.requireCompleteTranslationLanguage = setRuleOption( options.requireCompleteTranslationLanguage, 'ignoreMissing', false );
}
// skipIncompleteMessageDocumentation is now an additional option
// to requireCompleteMessageDocumentation
if ( options.skipIncompleteMessageDocumentation ) {
options.requireCompleteMessageDocumentation = setRuleOption( options.requireCompleteMessageDocumentation, 'skip', options.skipIncompleteMessageDocumentation );
}
/* End legacy config mappings */
// Source message data
const sourceMessages = messages( options.sourceFile, 'source' );
const sourceMessageKeys = keysNoMetadata( sourceMessages, 'source' );
let sourceMessagesModified = false;
// Documentation message data
const documentationMessages = messages( options.documentationFile, 'documentation' );
const documentationMessageKeys = keysNoMetadata( documentationMessages, 'documentation' );
let documentationMessagesModified = false;
// Translated message data
// eslint-disable-next-line security/detect-non-literal-fs-filename
const translatedFiles = fs.readdirSync( dir ).filter( ( value ) =>
value !== options.sourceFile &&
value !== options.documentationFile &&
jsonFilenameRegex.test( value )
);
// Step 2: Walk through files and check for failures.
translatedFiles.forEach( ( languageFile ) => {
const language = languageFile.match( jsonFilenameRegex )[ 1 ];
const languageMessages = messages( languageFile, language );
const keys = keysNoMetadata( languageMessages, language );
const blanks = [];
const duplicates = [];
const unuseds = [];
const unusedParameters = [];
let leadingWhitespace = [];
let trailingWhitespace = [];
let missing = sourceMessageKeys.slice( 0 );
let stack, originalParameters;
let modified = false;
for ( const key in keys ) {
const message = keys[ key ];
if ( sourceMessages[ message ] === undefined ) {
// An unused translation. This happens on commits that remove messages,
// which are typically removed from en.json and qqq.json, letting
// translations be removed by a localisation update instead.
originalParameters = null;
} else {
originalParameters = sourceMessages[ message ].match( /\$\d/g );
}
if ( missing.includes( message ) ) {
if ( languageMessages[ message ] === sourceMessages[ message ] ) {
duplicates.push( message );
}
missing.splice( missing.indexOf( message ), 1 );
} else {
unuseds.push( message );
}
if ( originalParameters ) {
stack = originalParameters.filter( ( originalParameter ) =>
!languageMessages[ message ].includes( originalParameter )
);
if ( stack.length ) {
unusedParameters.push( { message, stack } );
}
}
if ( typeof languageMessages[ message ] !== 'string' ) {
continue;
}
if ( languageMessages[ message ].trim() === '' ) {
blanks.push( message );
}
}
if ( isRuleEnabled( options.disallowLeadingWhitespace ) ) {
leadingWhitespace = keys.filter( ( message ) =>
leadingWhitespaceRegex.test( languageMessages[ message ] )
);
if ( leadingWhitespace.length > 0 && options.autofixTranslated ) {
modified = true;
leadingWhitespace.forEach( ( message ) => {
languageMessages[ message ] = languageMessages[ message ].replace( leadingWhitespaceRegex, '' );
} );
}
}
if ( isRuleEnabled( options.disallowTrailingWhitespace ) ) {
trailingWhitespace = keys.filter( ( message ) =>
trailingWhitespaceRegex.test( languageMessages[ message ] )
);
if ( trailingWhitespace.length > 0 && options.autofixTranslated ) {
modified = true;
trailingWhitespace.forEach( ( message ) => {
languageMessages[ message ] = languageMessages[ message ].replace( trailingWhitespaceRegex, '' );
} );
}
}
if (
getRuleOptions( options.requireCompleteTranslationLanguages ).ignoreMissing !== false
) {
missing = missing.filter( ( messageName ) =>
languageMessages[ messageName ] !== ''
);
}
if ( modified ) {
writeFile( languageFile, languageMessages );
}
translatedData[ language ] = {
messages: languageMessages,
keys: keys,
blank: blanks,
duplicate: duplicates,
unused: unuseds,
missing: missing,
unusedParameters: unusedParameters,
leadingWhitespace: leadingWhitespace,
trailingWhitespace: trailingWhitespace
};
} );
let sourceMessageWrongCase = [];
if ( isRuleEnabled( options.requireLowerCase ) ) {
if ( getRuleOptions( options.requireLowerCase ).initial ) {
sourceMessageWrongCase = sourceMessageKeys.filter( ( value ) =>
( value !== '' && value[ 0 ] !== value[ 0 ].toLowerCase() )
);
} else {
sourceMessageWrongCase = sourceMessageKeys.filter( ( value ) =>
value !== value.toLowerCase()
);
}
}
let sourceMessageWrongPrefix = [];
if ( isRuleEnabled( options.requireKeyPrefix ) ) {
let requireKeyPrefix = getRuleOptions( options.requireKeyPrefix ).prefix || [];
if ( typeof requireKeyPrefix === 'string' ) {
requireKeyPrefix = [ requireKeyPrefix ];
}
sourceMessageWrongPrefix = sourceMessageKeys.filter( ( key ) =>
!requireKeyPrefix.some( ( prefix ) =>
key.startsWith( prefix )
)
);
}
let sourceMessageLeadingWhitespace = [];
let documentationMessageLeadingWhitespace = [];
if ( isRuleEnabled( options.disallowLeadingWhitespace ) ) {
sourceMessageLeadingWhitespace = sourceMessageKeys.filter(
( message ) => leadingWhitespaceRegex.test( sourceMessages[ message ] )
);
if ( sourceMessageLeadingWhitespace.length > 0 && options.autofix ) {
sourceMessagesModified = true;
// This is modifying the sourceMessages object - from this point onward
// it's not always the exact same thing that was loaded from the source.
// This should be fine - we do want to run the lints on the messages we're
// writing to the file anyway.
sourceMessageLeadingWhitespace.forEach( ( message ) => {
sourceMessages[ message ] = sourceMessages[ message ].replace( leadingWhitespaceRegex, '' );
} );
}
documentationMessageLeadingWhitespace = documentationMessageKeys.filter(
( message ) => leadingWhitespaceRegex.test( documentationMessages[ message ] )
);
if ( documentationMessageLeadingWhitespace.length > 0 && options.autofix ) {
documentationMessagesModified = true;
// We modify things here too - see the comment above.
documentationMessageLeadingWhitespace.forEach( ( message ) => {
documentationMessages[ message ] = documentationMessages[ message ].replace( leadingWhitespaceRegex, '' );
} );
}
}
let sourceMessageTrailingWhitespace = [];
let documentationMessageTrailingWhitespace = [];
if ( isRuleEnabled( options.disallowTrailingWhitespace ) ) {
sourceMessageTrailingWhitespace = sourceMessageKeys.filter(
( message ) => trailingWhitespaceRegex.test( sourceMessages[ message ] )
);
if ( sourceMessageTrailingWhitespace.length > 0 && options.autofix ) {
sourceMessagesModified = true;
sourceMessageTrailingWhitespace.forEach( ( message ) => {
sourceMessages[ message ] = sourceMessages[ message ].replace( trailingWhitespaceRegex, '' );
} );
}
documentationMessageTrailingWhitespace = documentationMessageKeys.filter(
( message ) => trailingWhitespaceRegex.test( documentationMessages[ message ] )
);
if ( documentationMessageTrailingWhitespace.length > 0 && options.autofix ) {
documentationMessagesModified = true;
documentationMessageTrailingWhitespace.forEach( ( message ) => {
documentationMessages[ message ] = documentationMessages[ message ].replace( trailingWhitespaceRegex, '' );
} );
}
}
let sourceMessageMissing = [];
const documentationMessageBlanks = [];
while ( sourceMessageKeys.length > 0 ) {
const message = sourceMessageKeys[ 0 ];
const offset = documentationMessageKeys.indexOf( message );
if ( offset !== -1 ) {
if ( documentationMessages[ message ].trim() === '' ) {
documentationMessageBlanks.push( message );
}
documentationMessageKeys.splice( offset, 1 );
} else {
sourceMessageMissing.push( message );
}
sourceMessageKeys.splice( 0, 1 );
}
if ( sourceMessagesModified ) {
writeFile( options.sourceFile, sourceMessages );
}
if ( documentationMessagesModified ) {
writeFile( options.documentationFile, documentationMessages );
}
// Step 3: Go through failures and report them, based on config.
let level;
let count = 0;
if ( isRuleEnabled( options.requireCompleteMessageDocumentation ) ) {
const skip = getRuleOptions( options.requireCompleteMessageDocumentation ).skip || [];
if ( skip.length ) {
// Filter out any missing message that is OK to be skipped
sourceMessageMissing = sourceMessageMissing.filter(
( value ) => !skip.includes( value )
);
}
count = sourceMessageMissing.length;
if ( count > 0 ) {
level = getRuleLevel( options.requireCompleteMessageDocumentation );
log( level, `${ count } ${ plural( count, 'message lacks', 'messages lack' ) } documentation in qqq.json:` );
sourceMessageMissing.forEach( ( messageName ) => {
log( level, `* ${ messageName }` );
} );
}
}
if ( isRuleEnabled( options.disallowEmptyDocumentation ) ) {
count = documentationMessageBlanks.length;
if ( count > 0 ) {
level = getRuleLevel( options.disallowEmptyDocumentation );
log( level, `${ count } ${ plural( count, 'message is', 'messages are' ) } documented with a blank string:` );
documentationMessageBlanks.forEach( ( messageName ) => {
log( level, `* ${ messageName }` );
} );
}
}
if ( isRuleEnabled( options.requireLowerCase ) ) {
count = sourceMessageWrongCase.length;
if ( count > 0 ) {
level = getRuleLevel( options.requireLowerCase );
if ( getRuleOptions( options.requireLowerCase ).initial ) {
log( level, `${ count } ${ plural( count, 'message does', 'messages do' ) } not start with a lowercase character:` );
sourceMessageWrongCase.forEach( ( messageName ) => {
log( level, `* ${ messageName }" should start with a lowercase character.` );
} );
} else {
log( level, `${ count } ${ plural( count, 'message is', 'messages are' ) } not wholly lowercase:` );
sourceMessageWrongCase.forEach( ( messageName ) => {
log( level, `* ${ messageName }" should be in lowercase.` );
} );
}
}
}
if ( isRuleEnabled( options.requireKeyPrefix ) ) {
count = sourceMessageWrongPrefix.length;
if ( count > 0 ) {
level = getRuleLevel( options.requireKeyPrefix );
let requireKeyPrefix = getRuleOptions( options.requireKeyPrefix ).prefix || [];
if ( typeof requireKeyPrefix === 'string' ) {
requireKeyPrefix = [ requireKeyPrefix ];
}
if ( requireKeyPrefix.length === 1 ) {
log( level, `${ count } ${ plural( count, 'message does', 'messages do' ) } not start with the required prefix "${ requireKeyPrefix[ 0 ] }":` );
sourceMessageWrongPrefix.forEach( ( messageName ) => {
log( level, `* ${ messageName }" should start with the required prefix "${ requireKeyPrefix[ 0 ] }".` );
} );
} else {
log( level, `${ count } ${ plural( count, 'message does', 'messages do' ) } not start with any of the required prefices:` );
sourceMessageWrongPrefix.forEach( ( messageName ) => {
log( level, `* ${ messageName }" should start with one of the required prefices.` );
} );
}
}
}
if ( isRuleEnabled( options.disallowUnusedDocumentation ) ) {
count = documentationMessageKeys.length;
if ( count > 0 ) {
level = getRuleLevel( options.disallowUnusedDocumentation );
log( level, `${ count } ${ plural( count, 'message is', 'messages are' ) } documented but undefined:` );
documentationMessageKeys.forEach( ( messageName ) => {
log( level, `* ${ messageName }` );
} );
}
}
if ( isRuleEnabled( options.disallowLeadingWhitespace ) ) {
count = sourceMessageLeadingWhitespace.length;
level = getRuleLevel( options.disallowLeadingWhitespace );
if ( count > 0 ) {
log( level, `${ count } ${ plural( count, 'message has', 'messages have' ) } leading whitespace:` );
sourceMessageLeadingWhitespace.forEach( ( message ) => {
log( level, `* ${ message }` );
} );
}
count = documentationMessageLeadingWhitespace.length;
if ( count > 0 ) {
log( level, `${ count } message ${ plural( count, 'documentation has', 'documentations have' ) } leading whitespace:` );
documentationMessageLeadingWhitespace.forEach( ( message ) => {
log( level, `* ${ message }` );
} );
}
}
if ( isRuleEnabled( options.disallowTrailingWhitespace ) ) {
count = sourceMessageTrailingWhitespace.length;
level = getRuleLevel( options.disallowTrailingWhitespace );
if ( count > 0 ) {
log( level, `${ count } ${ plural( count, 'message has', 'messages have' ) } trailing whitespace:` );
sourceMessageTrailingWhitespace.forEach( ( message ) => {
log( level, `* ${ message }` );
} );
}
count = documentationMessageTrailingWhitespace.length;
if ( count > 0 ) {
log( level, `${ count } message ${ plural( count, 'documentation has', 'documentations have' ) } trailing whitespace:` );
documentationMessageTrailingWhitespace.forEach( ( message ) => {
log( level, `* ${ message }` );
} );
}
}
Object.keys( translatedData ).forEach( ( lang ) => {
// eslint-disable-next-line no-prototype-builtins
if ( !translatedData.hasOwnProperty( lang ) ) {
return;
}
if ( isRuleEnabled( options.disallowBlankTranslations ) ) {
count = translatedData[ lang ].blank.length;
level = getRuleLevel( options.disallowBlankTranslations );
if ( count > 0 ) {
log( level, `The "${ lang }" translation has ${ count } blank ${ plural( count, 'translation', 'translations' ) }:` );
translatedData[ lang ].blank.forEach( ( messageName ) => {
log( level, `* ${ messageName }` );
} );
}
}
if ( isRuleEnabled( options.disallowDuplicateTranslations ) ) {
count = translatedData[ lang ].duplicate.length;
level = getRuleLevel( options.disallowDuplicateTranslations );
if ( count > 0 ) {
log( level, `The "${ lang }" translation has ${ count } duplicate ${ plural( count, 'translation', 'translations' ) }:` );
translatedData[ lang ].duplicate.forEach( ( messageName ) => {
log( level, `* ${ messageName }` );
} );
}
}
if ( isRuleEnabled( options.disallowUnusedTranslations ) ) {
count = translatedData[ lang ].unused.length;
level = getRuleLevel( options.disallowUnusedTranslations );
if ( count > 0 ) {
log( level, `The "${ lang }" translation has ${ count } unused ${ plural( count, 'translation', 'translations' ) }:` );
translatedData[ lang ].unused.forEach( ( messageName ) => {
log( level, `* ${ messageName }` );
} );
}
}
if ( isRuleEnabled( options.requireCompletelyUsedParameters ) ) {
count = translatedData[ lang ].unusedParameters.length;
if ( count > 0 ) {
level = getRuleLevel( options.requireCompletelyUsedParameters );
log( level, `The "${ lang }" translation has ${ count } ${ plural( count, 'message which fails', 'messages which fail' ) } to use all parameters:` );
translatedData[ lang ].unusedParameters.forEach( ( report ) => {
log( level, `* "${ report.message }" fails to use the ${ plural( report.stack.length, 'parameter', 'parameters:' ) } "${ report.stack.join( '", "' ) }".` );
} );
}
}
if ( isRuleEnabled( options.disallowLeadingWhitespaceTranslated ) ) {
count = translatedData[ lang ].leadingWhitespace.length;
if ( count > 0 ) {
level = getRuleLevel( options.disallowLeadingWhitespaceTranslated );
log( level, `The "${ lang }" translation has ${ count } ${ plural( count, 'translation', 'translations' ) } with leading whitespace:` );
translatedData[ lang ].leadingWhitespace.forEach( ( message ) => {
log( level, `* ${ message }` );
} );
}
}
if ( isRuleEnabled( options.disallowTrailingWhitespaceTranslated ) ) {
count = translatedData[ lang ].trailingWhitespace.length;
if ( count > 0 ) {
level = getRuleLevel( options.disallowTrailingWhitespaceTranslated );
log( level, `The "${ lang }" translation has ${ count } ${ plural( count, 'translation', 'translations' ) } with trailing whitespace:` );
translatedData[ lang ].trailingWhitespace.forEach( ( message ) => {
log( level, `* ${ message }` );
} );
}
}
} );
if ( isRuleEnabled( options.requireCompleteTranslationLanguages ) ) {
const langs = getRuleOptions( options.requireCompleteTranslationLanguages ).langs || [];
Object.keys( translatedData ).forEach( ( lang ) => {
if (
// eslint-disable-next-line no-prototype-builtins
!translatedData.hasOwnProperty( lang ) ||
( !langs.includes( lang ) )
) {
return;
}
count = translatedData[ lang ].missing.length;
if ( count > 0 ) {
level = getRuleLevel( options.requireCompleteTranslationLanguages );
log( level, `The "${ lang }" translation has ${ count } missing ${ plural( count, 'translation', 'translations' ) }:` );
translatedData[ lang ].missing.forEach( ( messageName ) => {
log( level, `* ${ messageName }` );
} );
}
} );
}
if ( isRuleEnabled( options.requireCompleteTranslationMessages ) ) {
const keys = getRuleOptions( options.requireCompleteTranslationMessages ).keys || [];
Object.keys( translatedData ).forEach( ( lang ) => {
// eslint-disable-next-line no-prototype-builtins
if ( !translatedData.hasOwnProperty( lang ) ) {
return;
}
for ( const message in translatedData[ lang ].missing ) {
if (
// eslint-disable-next-line no-prototype-builtins
!translatedData[ lang ].missing.hasOwnProperty( sourceMessageKeys[ message ] )
) {
continue;
}
const offset = keys.langOf(
sourceMessageKeys[ message ]
);
if ( offset === -1 ) {
translatedData[ lang ].missing.splice( offset, 1 );
}
}
count = translatedData[ lang ].missing.length;
if ( count > 0 ) {
level = getRuleLevel( options.requireCompleteTranslationMessages );
log( level, `The "${ lang }" translation is missing ${ count } required ${ plural( count, 'message', 'messages' ) }:` );
translatedData[ lang ].missing.forEach( ( messageName ) => {
log( level, `* ${ messageName }` );
} );
}
} );
}
return ok;
};