UNPKG

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
'use strict'; 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; };