Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for translation extraction from VueJs templates #178

Merged
merged 10 commits into from
May 14, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ composer.lock
/.settings/
/.buildpath
/.project

# PhpStorm-specific files
/.idea/
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ Name | Description | Example
**Xliff** | Gets the messages from [xliff (2.0)](http://docs.oasis-open.org/xliff/xliff-core/v2.0/os/xliff-core-v2.0-os.html). | [example](https://github.com/oscarotero/Gettext/blob/master/tests/assets/po/Xliff.xlf)
**Yaml** | Gets the messages from yaml. | [example](https://github.com/oscarotero/Gettext/blob/master/tests/assets/po/Yaml.yml)
**YamlDictionary** | Gets the messages from a yaml (without plurals and context). | [example](https://github.com/oscarotero/Gettext/blob/master/tests/assets/po/YamlDictionary.yml)
**VueJs** | Gets the messages from a VueJs template. | [example](https://github.com/oscarotero/Gettext/blob/master/tests/assets/vuejs/input.vue)

## Generators

Expand Down
1 change: 1 addition & 0 deletions src/Extractors/JsCode.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class JsCode extends Extractor implements ExtractorInterface

/**
* {@inheritdoc}
* @throws \Exception
*/
public static function fromString($string, Translations $translations, array $options = [])
{
Expand Down
234 changes: 234 additions & 0 deletions src/Extractors/VueJs.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
<?php

namespace Gettext\Extractors;

use DOMAttr;
use DOMDocument;
use DOMElement;
use Gettext\Translations;
use Gettext\Utils\JsFunctionsScanner;

/**
* Class to get gettext strings from VueJS template files.
*/
class VueJs extends JsCode implements ExtractorInterface
{
/**
* @inheritdoc
* @throws \Exception
*/
public static function fromString($string, Translations $translations, array $options = [])
{
$options += self::$options;

// Ok, this is the weirdest hack, but let me explain:
// On Linux (Mac is fine), when converting HTML to DOM, new lines get trimmed after the first tag.
// So if there are new lines between <template> and next element, they are lost
// So we insert a "." which is a text node, and it will prevent that newlines are stripped between elements.
// Same thing happens between template and script tag.
$string = str_replace('<template>', '<template>.', $string);
$string = str_replace('</template>', '</template>.', $string);

// Normalize newlines
$string = str_replace(["\r\n", "\n\r", "\r"], "\n", $string);

// VueJS files are valid HTML files, we will operate with the DOM here
$dom = self::convertHtmlToDom($string);

// Parse the script part as a regular JS code
$script = $dom->getElementsByTagName('script')->item(0);
if ($script) {
self::getScriptTranslationsFromString(
$script->textContent,
$translations,
$options,
$script->getLineNo() - 1
);
}

// Template part is parsed separately, all variables will be extracted
// and handled as a regular JS code
$template = $dom->getElementsByTagName('template')->item(0);
if ($template) {
self::getTemplateTranslations(
$template,
$translations,
$options,
$template->getLineNo() - 1
);
}
}

/**
* @param string $html
* @return DOMDocument
*/
private static function convertHtmlToDom($html)
{
$dom = new DOMDocument;

libxml_use_internal_errors(true);
$dom->loadHTML($html);

libxml_clear_errors();

return $dom;
}

/**
* Extract translations from script part
*
* @param string $scriptContents Only script tag contents, not the whole template
* @param Translations $translations
* @param array $options
* @param int $lineOffset Number of lines the script is offset in the vue template file
* @throws \Exception
*/
private static function getScriptTranslationsFromString(
$scriptContents,
Translations $translations,
array $options = [],
$lineOffset = 0
) {
$functions = new JsFunctionsScanner($scriptContents);
$options['lineOffset'] = $lineOffset;
$functions->saveGettextFunctions($translations, $options);
}

/**
* Parse template to extract all translations (element content and dynamic element attributes)
*
* @param DOMElement $dom
* @param Translations $translations
* @param array $options
* @param int $lineOffset Line number where the template part starts in the vue file
* @throws \Exception
*/
private static function getTemplateTranslations(
DOMElement $dom,
Translations $translations,
array $options,
$lineOffset = 0
) {
// Build a JS string from all template attribute expressions
$fakeAttributeJs = self::getTemplateAttributeFakeJs($dom);

// 1 line offset is necessary because parent template element was ignored when converting to DOM
self::getScriptTranslationsFromString($fakeAttributeJs, $translations, $options, $lineOffset);

// Build a JS string from template element content expressions
$fakeTemplateJs = self::getTemplateFakeJs($dom);
self::getScriptTranslationsFromString($fakeTemplateJs, $translations, $options, $lineOffset);
}

/**
* Extract JS expressions from element attribute bindings (excluding text within elements)
* For example: <span :title="__('extract this')"> skip element content </span>
*
* @param DOMElement $dom
* @return string JS code
*/
private static function getTemplateAttributeFakeJs(DOMElement $dom)
{
$expressionsByLine = self::getVueAttributeExpressions($dom);

$maxLines = max(array_keys($expressionsByLine));
$fakeJs = '';

for ($line = 1; $line <= $maxLines; $line++) {
if (isset($expressionsByLine[$line])) {
$fakeJs .= implode("; ", $expressionsByLine[$line]);
}
$fakeJs .= "\n";
}

return $fakeJs;
}

/**
* Loop DOM element recursively and parse out all dynamic vue attributes which are basically JS expressions
*
* @param DOMElement $dom
* @param array $expressionByLine [lineNumber => [jsExpression, ..], ..]
* @return array [lineNumber => [jsExpression, ..], ..]
*/
private static function getVueAttributeExpressions(DOMElement $dom, array &$expressionByLine = [])
{
$children = $dom->childNodes;

for ($i = 0; $i < $children->length; $i++) {
$node = $children->item($i);

if (!($node instanceof DOMElement)) {
continue;
}

$attrList = $node->attributes;

for ($j = 0; $j < $attrList->length; $j++) {
/** @var DOMAttr $domAttr */
$domAttr = $attrList->item($j);

// Check if this is a dynamic vue attribute
if (strpos($domAttr->name, ':') === 0 || strpos($domAttr->name, 'v-bind:') === 0) {
$line = $domAttr->getLineNo();
$expressionByLine += [$line => []];
$expressionByLine[$line][] = $domAttr->value;
}
}

if ($node->hasChildNodes()) {
$expressionByLine = self::getVueAttributeExpressions($node, $expressionByLine);
}
}

return $expressionByLine;
}

/**
* Extract JS expressions from within template elements (excluding attributes)
* For example: <span :title="skip attributes"> {{__("extract element content")}} </span>
*
* @param DOMElement $dom
* @return string JS code
*/
private static function getTemplateFakeJs(DOMElement $dom)
{
$fakeJs = '';
$lines = explode("\n", $dom->textContent);

// Build a fake JS file from template by extracting JS expressions within each template line
foreach ($lines as $line) {
$expressionMatched = self::parseOneTemplateLine($line);

$fakeJs .= implode("; ", $expressionMatched) . "\n";
}

return $fakeJs;
}

/**
* Match JS expressions in a template line
*
* @param string $line
* @return string[]
*/
private static function parseOneTemplateLine($line)
{
$line = trim($line);

if (!$line) {
return [];
}

$regex = '#\{\{(.*?)\}\}#';

preg_match_all($regex, $line, $matches);

$matched = array_map(function ($v) {
return trim($v, '\'"{}');
}, $matches[1]);

return $matched;
}
}
9 changes: 7 additions & 2 deletions src/Utils/FunctionsScanner.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ abstract public function getFunctions(array $constants = []);
* Search for specific functions and create translations.
*
* @param Translations $translations The translations instance where save the values
* @param array $options The extractor options
* @param array $options The extractor options
* @throws Exception
*/
public function saveGettextFunctions(Translations $translations, array $options)
{
Expand All @@ -30,6 +31,10 @@ public function saveGettextFunctions(Translations $translations, array $options)
foreach ($this->getFunctions($options['constants']) as $function) {
list($name, $line, $args) = $function;

if (isset($options['lineOffset'])) {
$line += $options['lineOffset'];
}

if (!isset($functions[$name])) {
continue;
}
Expand Down Expand Up @@ -106,7 +111,7 @@ public function saveGettextFunctions(Translations $translations, array $options)
throw new Exception(sprintf('Not valid function %s', $functions[$name]));
}

if ((string) $original !== '' && ($domain === null || $domain === $translations->getDomain())) {
if ((string)$original !== '' && ($domain === null || $domain === $translations->getDomain())) {
$translation = $translations->insert($context, $original, $plural);
$translation->addReference($file, $line);

Expand Down
3 changes: 2 additions & 1 deletion src/Utils/JsFunctionsScanner.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ class JsFunctionsScanner extends FunctionsScanner
*/
public function __construct($code)
{
$this->code = $code;
// Normalize newline characters
$this->code = str_replace(["\r\n", "\n\r", "\r"], "\n", $code);
}

/**
Expand Down
8 changes: 8 additions & 0 deletions tests/AbstractTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,20 @@ abstract class AbstractTest extends PHPUnit_Framework_TestCase
'Xliff' => 'xlf',
'Yaml' => 'yml',
'YamlDictionary' => 'yml',
'VueJs' => 'vue',
];

protected static function asset($file)
{
return './tests/assets/'.$file;
}

/**
* @param string $file
* @param string|null $format
* @param array $options
* @return Translations
*/
protected static function get($file, $format = null, array $options = [])
{
if ($format === null) {
Expand Down Expand Up @@ -74,6 +81,7 @@ protected function runTestFormat($file, $countTranslations, $countTranslated = 0
$format = basename($file);
$method = "from{$format}File";

/** @var Translations $translations */
$translations = Translations::$method(static::asset($file.'.'.static::$ext[$format]));

$this->assertCount($countTranslations, $translations);
Expand Down
39 changes: 38 additions & 1 deletion tests/AssetsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ public function testPhpCode2()
$translations = static::get('phpcode2/input', 'PhpCode', [
'constants' => [
'CONTEXT' => 'my-context',
]
],
]);
$countTranslations = 13;
$countTranslated = 0;
Expand Down Expand Up @@ -415,4 +415,41 @@ public function testTwig()
$this->runTestFormat('twig/Yaml', $countTranslations, $countTranslated, $countHeaders);
$this->runTestFormat('twig/YamlDictionary', $countTranslations, $countTranslated);
}

public function testVueJs()
{
$translations = static::get('vuejs/input', 'VueJs');
$countTranslations = 28;
$countTranslated = 0;
$countHeaders = 8;

$this->assertCount($countTranslations, $translations);
$this->assertCount($countHeaders, $translations->getHeaders());
$this->assertEquals(0, $translations->countTranslated());

$this->assertContent($translations, 'vuejs/Po');
$this->assertContent($translations, 'vuejs/Mo');
$this->assertContent($translations, 'vuejs/PhpArray');
$this->assertContent($translations, 'vuejs/Jed');
$this->assertContent($translations, 'vuejs/Json');
$this->assertContent($translations, 'vuejs/JsonDictionary');
$this->assertContent($translations, 'vuejs/Csv');
$this->assertContent($translations, 'vuejs/CsvDictionary');
$this->assertContent($translations, 'vuejs/Xliff');
$this->assertContent($translations, 'vuejs/Yaml');
$this->assertContent($translations, 'vuejs/YamlDictionary');

$this->runTestFormat('vuejs/Po', $countTranslations, $countTranslated, $countHeaders);
$this->runTestFormat('vuejs/Mo', 0, $countTranslated, $countHeaders);
$this->runTestFormat('vuejs/PhpArray', $countTranslations, $countTranslated, $countHeaders);
$this->runTestFormat('vuejs/Jed', $countTranslations, $countTranslated, 10);
$this->runTestFormat('vuejs/Xliff', $countTranslations, $countTranslated, $countHeaders);
$this->runTestFormat('vuejs/Json', $countTranslations, $countTranslated, $countHeaders);
$this->runTestFormat('vuejs/JsonDictionary', $countTranslations, $countTranslated);
$this->runTestFormat('vuejs/Csv', $countTranslations, $countTranslated, $countHeaders);
$this->runTestFormat('vuejs/CsvDictionary', $countTranslations, $countTranslated);
$this->runTestFormat('vuejs/Yaml', $countTranslations, $countTranslated, $countHeaders);
$this->runTestFormat('vuejs/YamlDictionary', $countTranslations, $countTranslated);
}

}
Loading