extract_comments = $tag; } /** * Disable comments extraction. */ public function disableCommentsExtraction() { $this->extract_comments = false; } /** * {@inheritdoc} */ public function saveGettextFunctions( $translations, array $options ) { // Ignore multiple translations for now. // @todo Add proper support for multiple translations. if ( is_array( $translations ) ) { $translations = $translations[0]; } $peast_options = [ 'sourceType' => Peast::SOURCE_TYPE_MODULE, 'comments' => false !== $this->extract_comments, 'jsx' => true, ]; $ast = Peast::latest( $this->code, $peast_options )->parse(); $traverser = new Traverser(); $all_comments = []; /** * Traverse through JS code to find and extract gettext functions. * * Make sure translator comments in front of variable declarations * and inside nested call expressions are available when parsing the function call. */ $traverser->addFunction( function ( $node ) use ( &$translations, $options, &$all_comments ) { $functions = $options['functions']; $file = $options['file']; $add_reference = ! empty( $options['addReferences'] ); /** @var Node\Node $node */ foreach ( $node->getLeadingComments() as $comment ) { $all_comments[] = $comment; } /** @var Node\CallExpression $node */ if ( 'CallExpression' !== $node->getType() ) { return; } $callee = $this->resolveExpressionCallee( $node ); if ( ! $callee || ! isset( $functions[ $callee['name'] ] ) ) { return; } /** @var Node\CallExpression $node */ foreach ( $node->getArguments() as $argument ) { // Support nested function calls. $argument->setLeadingComments( $argument->getLeadingComments() + $node->getLeadingComments() ); } foreach ( $callee['comments'] as $comment ) { $all_comments[] = $comment; } $domain = null; $original = null; $context = null; $plural = null; $args = []; /** @var Node\Node $argument */ foreach ( $node->getArguments() as $argument ) { foreach ( $argument->getLeadingComments() as $comment ) { $all_comments[] = $comment; } if ( 'Identifier' === $argument->getType() || 'Expression' === substr( $argument->getType(), -strlen( 'Expression' ) ) ) { $args[] = ''; // The value doesn't matter as it's unused. continue; } if ( 'Literal' === $argument->getType() ) { /** @var Node\Literal $argument */ $args[] = $argument->getValue(); continue; } if ( 'TemplateLiteral' === $argument->getType() && 0 === count( $argument->getExpressions() ) ) { /** @var Node\TemplateLiteral $argument */ /** @var Node\TemplateElement[] $parts */ // Since there are no expressions within the TemplateLiteral, there is only one TemplateElement. $parts = $argument->getParts(); $args[] = $parts[0]->getValue(); continue; } // If we reach this, an unsupported argument type has been encountered. // Do not try to parse this function call at all. return; } switch ( $functions[ $callee['name'] ] ) { case 'text_domain': case 'gettext': list( $original, $domain ) = array_pad( $args, 2, null ); break; case 'text_context_domain': list( $original, $context, $domain ) = array_pad( $args, 3, null ); break; case 'single_plural_number_domain': list( $original, $plural, $number, $domain ) = array_pad( $args, 4, null ); break; case 'single_plural_number_context_domain': list( $original, $plural, $number, $context, $domain ) = array_pad( $args, 5, null ); break; } if ( '' === (string) $original ) { return; } if ( $domain !== $translations->getDomain() && null !== $translations->getDomain() ) { return; } if ( isset( $options['line'] ) ) { $line = $options['line']; } else { $line = $node->getLocation()->getStart()->getLine(); } $translation = $translations->insert( $context, $original, $plural ); if ( $add_reference ) { $translation->addReference( $file, $line ); } /** @var Node\Comment $comment */ foreach ( $all_comments as $comment ) { // Comments should be before the translation. if ( ! $this->commentPrecedesNode( $comment, $node ) ) { continue; } if ( in_array( $comment, $this->comments_cache, true ) ) { continue; } $parsed_comment = ParsedComment::create( $comment->getRawText(), $comment->getLocation()->getStart()->getLine() ); $prefixes = array_filter( (array) $this->extract_comments ); if ( $parsed_comment->checkPrefixes( $prefixes ) ) { $translation->addExtractedComment( $parsed_comment->getComment() ); $this->comments_cache[] = $comment; } } if ( isset( $parsed_comment ) ) { $all_comments = []; } } ); /** * Traverse through JS code contained within eval() to find and extract gettext functions. */ $scanner = $this; $traverser->addFunction( function ( $node ) use ( &$translations, $options, $scanner ) { /** @var Node\CallExpression $node */ if ( 'CallExpression' !== $node->getType() ) { return; } $callee = $this->resolveExpressionCallee( $node ); if ( ! $callee || 'eval' !== $callee['name'] ) { return; } $eval_contents = ''; /** @var Node\Node $argument */ foreach ( $node->getArguments() as $argument ) { if ( 'Literal' === $argument->getType() ) { /** @var Node\Literal $argument */ $eval_contents = $argument->getValue(); break; } } if ( ! $eval_contents ) { return; } // Override the line location to be that of the eval(). $options['line'] = $node->getLocation()->getStart()->getLine(); $class = get_class( $scanner ); $evals = new $class( $eval_contents ); $evals->enableCommentsExtraction( $options['extractComments'] ); $evals->saveGettextFunctions( $translations, $options ); } ); $traverser->traverse( $ast ); } /** * Resolve the callee of a call expression using known formats. * * @param Node\CallExpression $node The call expression whose callee to resolve. * * @return array|bool Array containing the name and comments of the identifier if resolved. False if not. */ private function resolveExpressionCallee( Node\CallExpression $node ) { $callee = $node->getCallee(); // If the callee is a simple identifier it can simply be returned. // For example: __( "translation" ). if ( 'Identifier' === $callee->getType() ) { return [ 'name' => $callee->getName(), 'comments' => $callee->getLeadingComments(), ]; } // If the callee is a member expression resolve it to the property. // For example: wp.i18n.__( "translation" ) or u.__( "translation" ). if ( 'MemberExpression' === $callee->getType() && 'Identifier' === $callee->getProperty()->getType() ) { // Make sure to unpack wp.i18n which is a nested MemberExpression. $comments = 'MemberExpression' === $callee->getObject()->getType() ? $callee->getObject()->getObject()->getLeadingComments() : $callee->getObject()->getLeadingComments(); return [ 'name' => $callee->getProperty()->getName(), 'comments' => $comments, ]; } // If the callee is a call expression as created by Webpack resolve it. // For example: Object(u.__)( "translation" ). if ( 'CallExpression' === $callee->getType() && 'Identifier' === $callee->getCallee()->getType() && 'Object' === $callee->getCallee()->getName() && [] !== $callee->getArguments() && 'MemberExpression' === $callee->getArguments()[0]->getType() ) { $property = $callee->getArguments()[0]->getProperty(); // Matches minified webpack statements: Object(u.__)( "translation" ). if ( 'Identifier' === $property->getType() ) { return [ 'name' => $property->getName(), 'comments' => $callee->getCallee()->getLeadingComments(), ]; } // Matches unminified webpack statements: // Object(_wordpress_i18n__WEBPACK_IMPORTED_MODULE_7__["__"])( "translation" ); if ( 'Literal' === $property->getType() ) { $name = $property->getValue(); // Matches mangled webpack statement: // Object(_wordpress_i18n__WEBPACK_IMPORTED_MODULE_7__[/* __ */ "a"])( "translation" ); $leading_property_comments = $property->getLeadingComments(); if ( count( $leading_property_comments ) === 1 && $leading_property_comments[0]->getKind() === 'multiline' ) { $name = trim( $leading_property_comments[0]->getText() ); } return [ 'name' => $name, 'comments' => $callee->getCallee()->getLeadingComments(), ]; } } // If the callee is an indirect function call as created by babel, resolve it. // For example: `(0, u.__)( "translation" )`. if ( 'ParenthesizedExpression' === $callee->getType() && 'SequenceExpression' === $callee->getExpression()->getType() && 2 === count( $callee->getExpression()->getExpressions() ) && 'Literal' === $callee->getExpression()->getExpressions()[0]->getType() && [] !== $node->getArguments() ) { // Matches any general indirect function call: `(0, __)( "translation" )`. if ( 'Identifier' === $callee->getExpression()->getExpressions()[1]->getType() ) { return [ 'name' => $callee->getExpression()->getExpressions()[1]->getName(), 'comments' => $callee->getLeadingComments(), ]; } // Matches indirect function calls used by babel for module imports: `(0, _i18n.__)( "translation" )`. if ( 'MemberExpression' === $callee->getExpression()->getExpressions()[1]->getType() ) { $property = $callee->getExpression()->getExpressions()[1]->getProperty(); if ( 'Identifier' === $property->getType() ) { return [ 'name' => $property->getName(), 'comments' => $callee->getLeadingComments(), ]; } } } // Unknown format. return false; } /** * Returns wether or not a comment precedes a node. * The comment must be before the node and on the same line or the one before. * * @param Node\Comment $comment The comment. * @param Node\Node $node The node. * * @return bool Whether or not the comment precedes the node. */ private function commentPrecedesNode( Node\Comment $comment, Node\Node $node ) { // Comments should be on the same or an earlier line than the translation. if ( $node->getLocation()->getStart()->getLine() - $comment->getLocation()->getEnd()->getLine() > 1 ) { return false; } // Comments on the same line should be before the translation. if ( $node->getLocation()->getStart()->getLine() === $comment->getLocation()->getEnd()->getLine() && $node->getLocation()->getStart()->getColumn() < $comment->getLocation()->getStart()->getColumn() ) { return false; } return true; } }