Issue #1927584 by Mark Carver, ezeedub, drupalninja99, Cottser, geoffreyr, John Bickar, jenlampton, penyaskito: Add support for the Twig {% trans %} tag extension.

8.0.x
Nathaniel Catchpole 2013-07-21 17:50:08 +01:00
parent e35e083e84
commit 2460eb7c77
8 changed files with 636 additions and 0 deletions

View File

@ -32,6 +32,14 @@ class TwigExtension extends \Twig_Extension {
public function getFilters() {
return array(
't' => new \Twig_Filter_Function('t'),
'trans' => new \Twig_Filter_Function('t'),
// The "raw" filter is not detectable when parsing "trans" tags. To detect
// which prefix must be used for translation (@, !, %), we must clone the
// "raw" filter and give it identifiable names. These filters should only
// be used in "trans" tags.
// @see TwigNodeTrans::compileString()
'passthrough' => new \Twig_Filter_Function('twig_raw_filter'),
'placeholder' => new \Twig_Filter_Function('twig_raw_filter'),
);
}
@ -47,6 +55,7 @@ class TwigExtension extends \Twig_Extension {
return array(
new TwigFunctionTokenParser('hide'),
new TwigFunctionTokenParser('show'),
new TwigTransTokenParser(),
);
}

View File

@ -0,0 +1,168 @@
<?php
/**
* @file
* Contains \Drupal\Core\Template\TwigNodeTrans.
*
* This Twig extension was originally based on Twig i18n extension. It has been
* severely modified to work properly with the complexities of the Drupal
* translation system.
*
* @see http://twig.sensiolabs.org/doc/extensions/i18n.html
* @see https://github.com/fabpot/Twig-extensions
*/
namespace Drupal\Core\Template;
/**
* A class that defines the Twig 'trans' tag for Drupal.
*/
class TwigNodeTrans extends \Twig_Node {
/**
* {@inheritdoc}
*/
public function __construct(\Twig_NodeInterface $body, \Twig_NodeInterface $plural = NULL, \Twig_Node_Expression $count = NULL, $lineno, $tag = NULL) {
parent::__construct(array(
'count' => $count,
'body' => $body,
'plural' => $plural
), array(), $lineno, $tag);
}
/**
* {@inheritdoc}
*/
public function compile(\Twig_Compiler $compiler) {
$compiler->addDebugInfo($this);
list($singular, $tokens) = $this->compileString($this->getNode('body'));
$plural = NULL;
if (NULL !== $this->getNode('plural')) {
list($plural, $pluralTokens) = $this->compileString($this->getNode('plural'));
$tokens = array_merge($tokens, $pluralTokens);
}
// Start writing with the function to be called.
$compiler->write('echo ' . (empty($plural) ? 't' : 'format_plural') . '(');
// Move the count to the beginning of the parameters list.
if (!empty($plural)) {
$compiler->raw('abs(')->subcompile($this->getNode('count'))->raw('), ');
}
// Write the singular text parameter.
$compiler->subcompile($singular);
// Write the plural text parameter, if necessary.
if (!empty($plural)) {
$compiler->raw(', ')->subcompile($plural);
}
// Write any tokens found as an associative array parameter.
if (!empty($tokens)) {
$compiler->raw(', array(');
foreach ($tokens as $token) {
$compiler->string($token->getAttribute('placeholder'))->raw(' => ')->subcompile($token)->raw(', ');
}
$compiler->raw(')');
}
// Write function closure.
$compiler->raw(')');
// Append translation debug markup, if necessary.
if (settings()->get('twig_debug', FALSE)) {
$compiler->raw(" . '\n<!-- TRANSLATION: ");
$compiler->subcompile($singular);
if (!empty($plural)) {
$compiler->raw(', PLURAL: ')->subcompile($plural);
}
$compiler->raw(" -->\n'");
}
// End writing.
$compiler->raw(";\n");
}
/**
* Extracts the text and tokens for the "trans" tag.
*
* @param \Twig_NodeInterface $body
* The node to compile.
*
* @return array
* Returns an array containing the two following parameters:
* - string $text
* The extracted text.
* - array $tokens
* The extracted tokens as new \Twig_Node_Expression_Name instances.
*/
protected function compileString(\Twig_NodeInterface $body) {
if ($body instanceof \Twig_Node_Expression_Name || $body instanceof \Twig_Node_Expression_Constant || $body instanceof \Twig_Node_Expression_TempName) {
return array($body, array());
}
$tokens = array();
if (count($body)) {
$text = '';
foreach ($body as $node) {
if (get_class($node) === 'Twig_Node' && $node->getNode(0) instanceof \Twig_Node_SetTemp) {
$node = $node->getNode(1);
}
if ($node instanceof \Twig_Node_Print) {
$n = $node->getNode('expr');
while ($n instanceof \Twig_Node_Expression_Filter) {
$n = $n->getNode('node');
}
$args = $n->getNode('arguments')->getNode(0);
// Detect if a token implements one of the filters reserved for
// modifying the prefix of a token. The default prefix used for
// translations is "@". This escapes the printed token and makes them
// safe for templates.
// @see TwigExtension::getFilters()
$argPrefix = '@';
while ($args instanceof \Twig_Node_Expression_Filter) {
switch ($args->getNode('filter')->getAttribute('value')) {
case 'passthrough':
$argPrefix = '!';
break;
case 'placeholder':
$argPrefix = '%';
break;
}
$args = $args->getNode('node');
}
if ($args instanceof \Twig_Node_Expression_GetAttr) {
$argName = $args->getNode('attribute')->getAttribute('value');
$expr = $n;
}
else {
$argName = $n->getAttribute('name');
if (!is_null($args)) {
$argName = $args->getAttribute('name');
}
$expr = new \Twig_Node_Expression_Name($argName, $n->getLine());
}
$placeholder = sprintf('%s%s', $argPrefix, $argName);
$text .= $placeholder;
$expr->setAttribute('placeholder', $placeholder);
$tokens[] = $expr;
}
else {
$text .= $node->getAttribute('data');
}
}
}
else {
$text = $body->getAttribute('data');
}
return array(new \Twig_Node(array(new \Twig_Node_Expression_Constant(trim($text), $body->getLine()))), $tokens);
}
}

View File

@ -0,0 +1,103 @@
<?php
/**
* @file
* Contains \Drupal\Core\Template\TwigTransTokenParser.
*
* @see http://twig.sensiolabs.org/doc/extensions/i18n.html
* @see https://github.com/fabpot/Twig-extensions
*/
namespace Drupal\Core\Template;
/**
* A class that defines the Twig 'trans' token parser for Drupal.
*
* The token parser converts a token stream created from template source
* code into an Abstract Syntax Tree (AST). The AST will later be compiled
* into PHP code usable for runtime execution of the template.
*
* @see \Twig_TokenParser
*/
class TwigTransTokenParser extends \Twig_TokenParser {
/**
* {@inheritdoc}
*/
public function parse(\Twig_Token $token) {
$lineno = $token->getLine();
$stream = $this->parser->getStream();
$count = NULL;
$plural = NULL;
if (!$stream->test(\Twig_Token::BLOCK_END_TYPE)) {
$body = $this->parser->getExpressionParser()->parseExpression();
}
else {
$stream->expect(\Twig_Token::BLOCK_END_TYPE);
$body = $this->parser->subparse(array($this, 'decideForFork'));
if ('plural' === $stream->next()->getValue()) {
$count = $this->parser->getExpressionParser()->parseExpression();
$stream->expect(\Twig_Token::BLOCK_END_TYPE);
$plural = $this->parser->subparse(array($this, 'decideForEnd'), TRUE);
}
}
$stream->expect(\Twig_Token::BLOCK_END_TYPE);
$this->checkTransString($body, $lineno);
$node = new TwigNodeTrans($body, $plural, $count, $lineno, $this->getTag());
return $node;
}
/**
* Detect a 'plural' switch or the end of a 'trans' tag.
*/
public function decideForFork($token) {
return $token->test(array('plural', 'endtrans'));
}
/**
* Detect the end of a 'trans' tag.
*/
public function decideForEnd($token) {
return $token->test('endtrans');
}
/**
* {@inheritdoc}
*/
public function getTag() {
return 'trans';
}
/**
* Ensure that any nodes that are parsed are only of allowed types.
*
* @param \Twig_NodeInterface $body
* The expression to check.
* @param integer $lineno
* The source line.
*
* @throws \Twig_Error_Syntax
*/
protected function checkTransString(\Twig_NodeInterface $body, $lineno) {
foreach ($body as $node) {
if (
$node instanceof \Twig_Node_Text
||
($node instanceof \Twig_Node_Print && $node->getNode('expr') instanceof \Twig_Node_Expression_Name)
||
($node instanceof \Twig_Node_Print && $node->getNode('expr') instanceof \Twig_Node_Expression_GetAttr)
||
($node instanceof \Twig_Node_Print && $node->getNode('expr') instanceof \Twig_Node_Expression_Filter)
) {
continue;
}
throw new \Twig_Error_Syntax(sprintf('The text to be translated with "trans" can only contain references to simple variables'), $lineno);
}
}
}

View File

@ -0,0 +1,274 @@
<?php
/**
* @file
* Contains \Drupal\system\Tests\Theme\TwigTransTest.
*/
namespace Drupal\system\Tests\Theme;
use Drupal\simpletest\WebTestBase;
/**
* Tests Twig "trans" tags.
*/
class TwigTransTest extends WebTestBase {
/**
* Modules to enable.
*
* @var array
*/
public static $modules = array(
'theme_test',
'twig_theme_test',
'locale',
'language'
);
/**
* An administrative user for testing.
*
* @var \Drupal\user\Plugin\Core\Entity\User
*/
protected $admin_user;
/**
* Custom language code.
*
* @var string
*/
protected $langcode = 'xx';
/**
* Custom language name.
*
* @var string
*/
protected $name = 'Lolspeak';
/**
* Defines information about this test.
*
* @return array
* An associative array of information.
*/
public static function getInfo() {
return array(
'name' => 'Twig Translation',
'description' => 'Test Twig "trans" tags.',
'group' => 'Theme',
);
}
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
// Setup test_theme.
theme_enable(array('test_theme'));
\Drupal::config('system.theme')->set('default', 'test_theme')->save();
// Create and log in as admin.
$this->admin_user = $this->drupalCreateUser(array(
'administer languages',
'access administration pages',
'administer site configuration',
'translate interface'
));
$this->drupalLogin($this->admin_user);
// Add test language for translation testing.
$edit = array(
'predefined_langcode' => 'custom',
'langcode' => $this->langcode,
'name' => $this->name,
'direction' => '0',
);
// Install the lolspeak language.
$this->drupalPost('admin/config/regional/language/add', $edit, t('Add custom language'));
$this->assertRaw('"edit-languages-' . $this->langcode . '-weight"', 'Language code found.');
// Import a custom .po file for the lolspeak language.
$this->importPoFile($this->examplePoFile(), array(
'langcode' => $this->langcode,
'customized' => TRUE,
));
// Assign lolspeak to be the default language.
$edit = array('site_default_language' => $this->langcode);
$this->drupalPost('admin/config/regional/settings', $edit, t('Save configuration'));
// Reset the static cache of the language list.
drupal_static_reset('language_list');
// Check that lolspeak is the default language for the site.
$this->assertEqual(language_default()->id, $this->langcode, $this->name . ' is the default language');
}
/**
* Test Twig "trans" tags.
*/
public function testTwigTransTags() {
$this->drupalGet('twig-theme-test/trans', array('language' => language_load('xx')));
$this->assertText(
'OH HAI SUNZ',
'{% trans "Hello sun." %} was successfully translated.'
);
$this->assertText(
'O HERRO ERRRF.',
'{{ "Hello Earth."|trans }} was successfully translated.'
);
$this->assertText(
'OH HAI TEH MUUN',
'{% trans %}Hello moon.{% endtrans %} was successfully translated.'
);
$this->assertText(
'O HAI STARRRRR',
'{% trans %} with {% plural count = 1 %} was successfully translated.'
);
$this->assertText(
'O HAI 2 STARZZZZ',
'{% trans %} with {% plural count = 2 %} was successfully translated.'
);
$this->assertRaw(
'ESCAPEE: &amp;&quot;&lt;&gt;',
'{{ token }} was successfully translated and prefixed with "@".'
);
$this->assertRaw(
'PAS-THRU: &"<>',
'{{ token|passthrough }} was successfully translated and prefixed with "!".'
);
$this->assertRaw(
'PLAYSHOLDR: <em class="placeholder">&amp;&quot;&lt;&gt;</em>',
'{{ token|placeholder }} was successfully translated and prefixed with "%".'
);
$this->assertRaw(
'DIS complex token HAZ LENGTH OV: 3. IT CONTAYNZ: <em class="placeholder">12345</em> AN &amp;&quot;&lt;&gt;. LETS PAS TEH BAD TEXT THRU: &"<>.',
'{{ complex.tokens }} were successfully translated with appropriate prefixes.'
);
// Ensure debug output does not print.
$this->checkForDebugMarkup(FALSE);
}
/**
* Test Twig "trans" debug markup.
*/
public function testTwigTransDebug() {
// Enable twig debug and write to the test settings.php file.
$this->settingsSet('twig_debug', TRUE);
$settings['settings']['twig_debug'] = (object) array(
'value' => TRUE,
'required' => TRUE,
);
$this->writeSettings($settings);
// Get page for assertion testing.
$this->drupalGet('twig-theme-test/trans', array('language' => language_load('xx')));
// Ensure debug output is printed.
$this->checkForDebugMarkup(TRUE);
}
/**
* Helper function: test twig debug translation markup.
*
* @param bool $visible
* Toggle determining which assertion to use for test.
*/
protected function checkForDebugMarkup($visible) {
$tests = array(
'{% trans "Hello sun." %}' => '<!-- TRANSLATION: "Hello sun." -->',
'{{ "Hello moon."|trans }}' => '<!-- TRANSLATION: "Hello moon." -->',
'{% trans %} with {% plural %}' => '<!-- TRANSLATION: "Hello star.", PLURAL: "Hello @count stars." -->',
'{{ token }}' => '<!-- TRANSLATION: "Escaped: @string" -->',
'{{ token|passthrough }}' => '<!-- TRANSLATION: "Pass-through: !string" -->',
'{{ token|placeholder }}' => '<!-- TRANSLATION: "Placeholder: %string" -->',
'{{ complex.tokens }}' => '<!-- TRANSLATION: "This @name has a length of: @count. It contains: %numbers and @bad_text. Lets pass the bad text through: !bad_text." -->',
);
foreach ($tests as $test => $markup) {
if ($visible) {
$this->assertRaw($markup, "Twig debug translation markup exists in source for: $test");
}
else {
$this->assertNoRaw($markup, "Twig debug translation markup does not exist in source for: $test");
}
}
}
/**
* Helper function: import a standalone .po file in a given language.
*
* Borrowed from \Drupal\locale\Tests\LocaleImportFunctionalTest.
*
* @param string $contents
* Contents of the .po file to import.
* @param array $options
* Additional options to pass to the translation import form.
*/
protected function importPoFile($contents, array $options = array()) {
$name = tempnam('temporary://', "po_") . '.po';
file_put_contents($name, $contents);
$options['files[file]'] = $name;
$this->drupalPost('admin/config/regional/translate/import', $options, t('Import'));
drupal_unlink($name);
}
/**
* An example .po file.
*
* @return string
* The .po contents used for this test.
*/
protected function examplePoFile() {
return <<< EOF
msgid ""
msgstr ""
"Project-Id-Version: Drupal 8\\n"
"MIME-Version: 1.0\\n"
"Content-Type: text/plain; charset=UTF-8\\n"
"Content-Transfer-Encoding: 8bit\\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\\n"
msgid "Hello sun."
msgstr "OH HAI SUNZ"
msgid "Hello Earth."
msgstr "O HERRO ERRRF."
msgid "Hello moon."
msgstr "OH HAI TEH MUUN"
msgid "Hello star."
msgid_plural "Hello @count stars."
msgstr[0] "O HAI STARRRRR"
msgstr[1] "O HAI @count STARZZZZ"
msgid "Escaped: @string"
msgstr "ESCAPEE: @string"
msgid "Pass-through: !string"
msgstr "PAS-THRU: !string"
msgid "Placeholder: %string"
msgstr "PLAYSHOLDR: %string"
msgid "This @name has a length of: @count. It contains: %numbers and @bad_text. Lets pass the bad text through: !bad_text."
msgstr "DIS @name HAZ LENGTH OV: @count. IT CONTAYNZ: %numbers AN @bad_text. LETS PAS TEH BAD TEXT THRU: !bad_text."
EOF;
}
}

View File

@ -29,4 +29,13 @@ class TwigThemeTestController implements ControllerInterface {
return theme('twig_theme_test_php_variables');
}
/**
* Menu callback for testing translation blocks in a Twig template.
*/
public function transBlockRender() {
return array(
'#theme' => 'twig_theme_test_trans',
);
}
}

View File

@ -0,0 +1,63 @@
{# Test trans tag with string argument. #}
<div>
{% trans 'Hello sun.' %}
</div>
{# Test trans filter. #}
<div>
{{ 'Hello Earth.'|trans }}
</div>
{# Test trans tag with text content. #}
<div>
{% trans %}
Hello moon.
{% endtrans %}
</div>
{# Test trans/plural tag where count = 1. #}
<div>
{% set count = 1 %}
{% trans %}
Hello star.
{% plural count %}
Hello {{ count }} stars.
{% endtrans %}
</div>
{# Test trans/plural tag where count = 2. #}
<div>
{% set count = 2 %}
{% trans %}
Hello star.
{% plural count %}
Hello {{ count }} stars.
{% endtrans %}
</div>
{# Test trans tag with different filters applied to tokens. #}
{% set string = '&"<>' %}
<div>
{% trans %}
Escaped: {{ string }}
{% endtrans %}
</div>
<div>
{% trans %}
Pass-through: {{ string|passthrough }}
{% endtrans %}
</div>
<div>
{% trans %}
Placeholder: {{ string|placeholder }}
{% endtrans %}
</div>
{# Test trans tag with complex tokens. #}
{% set token = {'name': 'complex token', 'numbers': '12345', 'bad_text': '&"<>' } %}
{% set count = token|length %}
<div>
{% trans %}
This {{ token.name }} has a length of: {{ count }}. It contains: {{ token.numbers|placeholder }} and {{ token.bad_text }}. Lets pass the bad text through: {{ token.bad_text|passthrough }}.
{% endtrans %}
</div>

View File

@ -7,6 +7,10 @@ function twig_theme_test_theme($existing, $type, $theme, $path) {
$items['twig_theme_test_php_variables'] = array(
'template' => 'twig_theme_test.php_variables',
);
$items['twig_theme_test_trans'] = array(
'variables' => array(),
'template' => 'twig_theme_test.trans',
);
return $items;
}

View File

@ -4,3 +4,9 @@ twig_theme_test_php_variables:
_content: '\Drupal\twig_theme_test\TwigThemeTestController::phpVariablesRender'
requirements:
_permission: 'access content'
twig_theme_test_trans:
pattern: '/twig-theme-test/trans'
defaults:
_content: '\Drupal\twig_theme_test\TwigThemeTestController::transBlockRender'
requirements:
_permission: 'access content'