xgettext-js
Version:
xgettext string extractor tool capable of parsing JavaScript files
281 lines (242 loc) • 8.69 kB
JavaScript
var _ = require( 'lodash' ),
parser = require( '@babel/parser' ),
walk = require( 'estree-walker' ).walk;
/**
* XGettext will parse a given input string for any instances of i18n function
* calls, returning an array of objects for all translatable strings
* discovered.
*
* @param {Object} options Options to use when p.arsing the input. Refer to
* XGettext.defaultOptions for available options and a
* description for each
*/
var XGettext = module.exports = function( options ) {
if ( 'object' !== typeof options ) {
options = {};
}
this.options = _.extend( {}, XGettext.defaultOptions, options );
this.options.keywords = this._normalizeKeywords( this.options.keywords );
this.options.keywordFunctions = Object.keys( this.options.keywords );
this.options.parseOptions = _.extend( { locations: true }, this.options.parseOptions );
};
XGettext.defaultOptions = {
/**
* A key-value pair of keyword function names to be mapped into their
* desired string value. Transform functions are passed a match including
* three keys: `keyword` (the matched keyword), `arguments` (a
* CallExpression arguments array), and `comment` if one exists. It is
* expected that this function will return a string or an array of strings.
* Alternatively, define value as number to return value in that argument
* position on a 1-based index.
*
* @type {Object}
* @see https://github.com/babel/babylon/blob/master/ast/spec.md
*/
keywords: {
_: 1,
},
/**
* Optionally match translator comments to be included with translatable
* strings.
*
* If undesired, set as `undefined`. A comment will be matched if it is
* prefixed by this option and occurs either on the same or previous line
* as the matched keyword.
*
* @type {String,undefined}
*/
commentPrefix: 'translators:',
/**
* Options for the parser. Babylon has some extra ones.
*
* @type {Object}
* @see https://www.npmjs.com/package/@babel/parser
*/
parseOptions: {},
};
/**
* Returns an array of objects for all strings matched by the keywords defined
* in the `keyword` option property.
*
* Each object in the array contains a `string` key where the value is
* determined by the corresponding keyword mapping function. An object may also
* contain a `comment` key if the `commentPrefix` option is provided and a
* comment is associated with the matched keyword.
*
* @param {String} input String from which to find matches
* @return {Array} An array containing objects for each matched
* occurrance of a keyword function
*/
XGettext.prototype.getMatches = function( input ) {
var parsedInput, matches, transformedMatches;
// Parse input as AST and matching comments
parsedInput = this._parseInput( input );
// Find matches (i.e. where keyword functions are used)
matches = this._discoverMatches( parsedInput );
// Use configured keyword transforms to parse string value
transformedMatches = _( matches ).map( function( match ) {
return this._transformMatch( match );
}.bind( this ) ).flatten().value();
return transformedMatches;
};
/**
* Returns an object containing keyword functions where number values are
* replaced with a function returning the nth argument on a 1-based index.
*
* @private
* @see https://www.gnu.org/software/gettext/manual/html_node/xgettext-Invocation.html
*
* @param {Object} keywords Original keywords object configuration
* @return {Object} An object containing keyword functions where
* number values are replaced with a function
* returning the nth argument on a 1-based index
*/
XGettext.prototype._normalizeKeywords = function( keywords ) {
var normalizedKeywords = {};
for ( var fn in keywords ) {
normalizedKeywords[ fn ] = this._normalizeKeyword( keywords[ fn ] );
}
return normalizedKeywords;
};
/**
* If passed a number, returns a function which returns the nth argument on a
* 1-based index. Otherwise, returns the passed argument.
*
* @param {(Number|Function)} keyword A number or function to be normalized
* @return {Function} A function to be used in place of the
* passed argument
*/
XGettext.prototype._normalizeKeyword = function( keyword ) {
if ( 'number' === typeof keyword ) {
return ( function( argnum ) {
var argumentPosition = argnum - 1;
return function( match ) {
if ( match.arguments.length > argumentPosition &&
typeof match.arguments[ argumentPosition ].value === 'string' ) {
return match.arguments[ argumentPosition ].value;
}
};
}( keyword ) );
}
return keyword;
};
/**
* Returns an object containing as AST representation of the input (as `ast`)
* and any matching comments discovered during parsing (as `comments`)
*
* @private
* @param {String} input String from which to find matches
* @return {Array} An object containing as AST representation of the
* input (as `ast`) and any matching comments discovered
* during parsing (as `comments`)
*/
XGettext.prototype._parseInput = function( input ) {
var comments = [],
parseOptions = this.options.parseOptions,
ast;
ast = parser.parse( input, parseOptions );
if ( typeof this.options.commentPrefix !== 'undefined' ) {
// Optionally locate translator comments
var rxCommentMatch = new RegExp( '^\\s*' + this.options.commentPrefix, 'i' );
ast.comments.forEach( function( comment ) {
var text = comment.value;
var isTranslatorComment = rxCommentMatch.test( text );
if ( isTranslatorComment ) {
comments.push( {
value: text.replace( rxCommentMatch, '' ).trim(),
line: comment.loc.start.line,
} );
}
} );
}
return {
comments: comments,
ast: ast.program,
};
};
/**
* Returns an array of objects representing all matched keywords, including the
* matched keyword (as `keyword`), the CallExpression arguments array (as
* `arguments`), and potentially any comment associated with the match (as
* `comment`)
*
* @private
*
* @param {Object} parsedInput Parse results
* @return {Array} An array of objects representing all matched
* keywords
*/
XGettext.prototype._discoverMatches = function( parsedInput ) {
var keywordFunctions = this.options.keywordFunctions,
matches = [];
walk( parsedInput.ast, {
enter: function( node ) {
if ( node.type !== 'CallExpression' ) {
return;
}
// Pull the resultingFunction out of (0, resultingFunction)()
var callee = node.callee;
while ( 'SequenceExpression' === callee.type ) {
callee = _.last( callee.expressions );
}
var functionName = ( callee.property ) ? callee.property.name : callee.name;
// Validate is named function
if ( ! functionName ) {
return;
}
// Validate desired function name
if ( keywordFunctions.indexOf( functionName ) === -1 ) {
return;
}
// Build discovered match
var match = {
arguments: node.arguments,
keyword: functionName,
line: node.loc.start.line,
column: node.loc.start.column,
};
// Find translator comment
_.each( parsedInput.comments, function( translatorComment ) {
if ( node.loc.start.line === translatorComment.line ||
node.loc.start.line - 1 === translatorComment.line ) {
match.comment = translatorComment.value;
}
} );
matches.push( match );
},
} );
return matches;
};
/**
* Returns an object representing a single transformed matched keyword,
* including the transformed keyword string value (as `string`), and
* potentially any comment associated with the match (as `comment`)
*
* @private
*
* @param {Object} match Match object
* @return {Object} An object representing a single transformed matched
* keyword
*/
XGettext.prototype._transformMatch = function( match ) {
var strings = this.options.keywords[ match.keyword ]( match );
// If transformed result is object, immediately return
if ( _.isPlainObject( strings ) ) {
return strings;
}
// Cast strings to single-element array to enable mapping
if ( ! ( strings instanceof Array ) ) {
strings = [ strings ];
}
// Remove falsey string values
strings = strings.filter( Boolean );
// Transform string back to object with comment
strings = _.map( strings, function( string ) {
var transformed = { string: string, line: match.line, column: match.column };
if ( typeof match.comment !== 'undefined' ) {
transformed.comment = match.comment;
}
return transformed;
} );
return strings;
};