From 29310ecc3518f96024569db4be97da8de52e4f25 Mon Sep 17 00:00:00 2001 From: Mark Carver Date: Tue, 16 Jul 2013 20:38:30 -0500 Subject: Issue #1927584 by Mark Carver, drupalninja99, penyaskito, Cottser, jenlampton, John Bickar, geoffreyr, ezeedub: Add support for the Twig {% trans %} tag extension --- core/lib/Drupal/Core/Template/TwigExtension.php | 8 + core/lib/Drupal/Core/Template/TwigNodeTrans.php | 205 ++++++++++++++++ .../Drupal/Core/Template/TwigTransTokenParser.php | 103 ++++++++ .../Drupal/system/Tests/Theme/TwigTransTest.php | 264 +++++++++++++++++++++ .../twig_theme_test/TwigThemeTestController.php | 9 + .../templates/twig_theme_test.trans.html.twig | 53 +++++ .../modules/twig_theme_test/twig_theme_test.module | 4 + .../twig_theme_test/twig_theme_test.routing.yml | 6 + 8 files changed, 652 insertions(+) create mode 100644 core/lib/Drupal/Core/Template/TwigNodeTrans.php create mode 100644 core/lib/Drupal/Core/Template/TwigTransTokenParser.php create mode 100644 core/modules/system/lib/Drupal/system/Tests/Theme/TwigTransTest.php create mode 100644 core/modules/system/tests/modules/twig_theme_test/templates/twig_theme_test.trans.html.twig diff --git a/core/lib/Drupal/Core/Template/TwigExtension.php b/core/lib/Drupal/Core/Template/TwigExtension.php index 3c86bd1..d738b23 100644 --- a/core/lib/Drupal/Core/Template/TwigExtension.php +++ b/core/lib/Drupal/Core/Template/TwigExtension.php @@ -32,6 +32,13 @@ public function getFunctions() { public function getFilters() { return array( 't' => 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_SimpleFilter('passthrough', 'twig_raw_filter'), + 'placeholder' => new \Twig_SimpleFilter('placeholder', 'twig_raw_filter'), ); } @@ -47,6 +54,7 @@ public function getTokenParsers() { return array( new TwigFunctionTokenParser('hide'), new TwigFunctionTokenParser('show'), + new TwigTransTokenParser(), ); } diff --git a/core/lib/Drupal/Core/Template/TwigNodeTrans.php b/core/lib/Drupal/Core/Template/TwigNodeTrans.php new file mode 100644 index 0000000..13677cc --- /dev/null +++ b/core/lib/Drupal/Core/Template/TwigNodeTrans.php @@ -0,0 +1,205 @@ + $count, + 'body' => $body, + 'plural' => $plural + ), array(), $lineno, $tag); + } + + /** + * {@inheritdoc} + */ + public function compile(\Twig_Compiler $compiler) { + $compiler->addDebugInfo($this); + + list($msg, $vars) = $this->compileString($this->getNode('body')); + + if (NULL !== $this->getNode('plural')) { + list($msg1, $vars1) = $this->compileString($this->getNode('plural')); + $vars = array_merge($vars, $vars1); + } + + $function = NULL === $this->getNode('plural') ? 't' : 'format_plural'; + + if ($vars) { + $compiler->write('echo ' . $function . '('); + + // Move count in format_plural to first argument. + if (NULL !== $this->getNode('plural')) { + $compiler + ->raw('abs(') + ->subcompile($this->getNode('count')) + ->raw('),'); + } + + $compiler->subcompile($msg); + + if (NULL !== $this->getNode('plural')) { + $compiler + ->raw(', ') + ->subcompile($msg1); + } + + $compiler->raw(', array('); + + foreach ($vars as $var) { + if (NULL !== $this->getNode('plural') && 'count' === $var->getAttribute('name')) { + $compiler + ->string('@count') + ->raw(' => abs(') + ->subcompile($this->getNode('count')) + ->raw('), '); + } + else { + $compiler + ->string($var->getAttribute('placeholder')) + ->raw(' => ') + ->subcompile($var) + ->raw(', '); + } + } + + $compiler->raw("))"); + if (settings()->get('twig_debug', FALSE)) { + $compiler->raw(" . '\n\n'"); + } + $compiler->raw(";\n"); + } + else { + $compiler->write('echo ' . $function . '('); + + // Move count in format_plural to first argument. + if (NULL !== $this->getNode('plural')) { + $compiler + ->raw('abs(') + ->subcompile($this->getNode('count')) + ->raw('),'); + } + + $compiler->subcompile($msg); + + if (NULL !== $this->getNode('plural')) { + $compiler + ->raw(', ') + ->subcompile($msg1); + } + + $compiler->raw(")"); + + if (settings()->get('twig_debug', FALSE)) { + $compiler->raw(" . '\n\n'"); + } + $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 $msg + * The extracted text. + * - array $vars + * 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()); + } + + $vars = array(); + if (count($body)) { + $msg = ''; + + 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); + $msg .= $placeholder; + $expr->setAttribute('placeholder', $placeholder); + $vars[] = $expr; + } + else { + $msg .= $node->getAttribute('data'); + } + } + } + else { + $msg = $body->getAttribute('data'); + } + + return array(new \Twig_Node(array(new \Twig_Node_Expression_Constant(trim($msg), $body->getLine()))), $vars); + } + +} diff --git a/core/lib/Drupal/Core/Template/TwigTransTokenParser.php b/core/lib/Drupal/Core/Template/TwigTransTokenParser.php new file mode 100644 index 0000000..44f6d21 --- /dev/null +++ b/core/lib/Drupal/Core/Template/TwigTransTokenParser.php @@ -0,0 +1,103 @@ +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); + } + } + +} diff --git a/core/modules/system/lib/Drupal/system/Tests/Theme/TwigTransTest.php b/core/modules/system/lib/Drupal/system/Tests/Theme/TwigTransTest.php new file mode 100644 index 0000000..f8c0a19 --- /dev/null +++ b/core/modules/system/lib/Drupal/system/Tests/Theme/TwigTransTest.php @@ -0,0 +1,264 @@ + '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( + '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: &"<>', + '{{ token }} was successfully translated and prefixed with "@".' + ); + + $this->assertRaw( + 'PAS-THRU: &"<>', + '{{ token|passthrough }} was successfully translated and prefixed with "!".' + ); + + $this->assertRaw( + 'PLAYSHOLDR: &"<>', + '{{ token|placeholder }} was successfully translated and prefixed with "%".' + ); + + $this->assertRaw( + 'DIS complex token HAZ LENGTH OV: 3. IT CONTAYNZ: 12345 AN &"<>. 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 %} with {% plural %}' => '', + '{{ token }}' => '', + '{{ token|passthrough }}' => '', + '{{ token|placeholder }}' => '', + '{{ complex.tokens }}' => '', + ); + 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 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; + } + +} diff --git a/core/modules/system/tests/modules/twig_theme_test/lib/Drupal/twig_theme_test/TwigThemeTestController.php b/core/modules/system/tests/modules/twig_theme_test/lib/Drupal/twig_theme_test/TwigThemeTestController.php index ef5fb70..6eb318a 100644 --- a/core/modules/system/tests/modules/twig_theme_test/lib/Drupal/twig_theme_test/TwigThemeTestController.php +++ b/core/modules/system/tests/modules/twig_theme_test/lib/Drupal/twig_theme_test/TwigThemeTestController.php @@ -29,4 +29,13 @@ public function phpVariablesRender() { 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', + ); + } + } diff --git a/core/modules/system/tests/modules/twig_theme_test/templates/twig_theme_test.trans.html.twig b/core/modules/system/tests/modules/twig_theme_test/templates/twig_theme_test.trans.html.twig new file mode 100644 index 0000000..bf2200b --- /dev/null +++ b/core/modules/system/tests/modules/twig_theme_test/templates/twig_theme_test.trans.html.twig @@ -0,0 +1,53 @@ +{# Output for the Twig trans block test. #} +
+ {% trans "Hello sun." %} +
+ +
+ {% trans %} + Hello moon. + {% endtrans %} +
+ +
+ {% set count = 1 %} + {% trans %} + Hello star. + {% plural count %} + Hello {{ count }} stars. + {% endtrans %} +
+ +
+ {% set count = 2 %} + {% trans %} + Hello star. + {% plural count %} + Hello {{ count }} stars. + {% endtrans %} +
+ +{% set string = '&"<>' %} +
+ {% trans %} + Escaped: {{ string }} + {% endtrans %} +
+
+ {% trans %} + Pass-through: {{ string|passthrough }} + {% endtrans %} +
+
+ {% trans %} + Placeholder: {{ string|placeholder }} + {% endtrans %} +
+ +{% set token = {'name': 'complex token', 'numbers': '12345', 'bad_text': '&"<>' } %} +{% set count = token|length %} +
+ {% 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 %} +
diff --git a/core/modules/system/tests/modules/twig_theme_test/twig_theme_test.module b/core/modules/system/tests/modules/twig_theme_test/twig_theme_test.module index 2ca2cd0..934d1b0 100644 --- a/core/modules/system/tests/modules/twig_theme_test/twig_theme_test.module +++ b/core/modules/system/tests/modules/twig_theme_test/twig_theme_test.module @@ -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; } diff --git a/core/modules/system/tests/modules/twig_theme_test/twig_theme_test.routing.yml b/core/modules/system/tests/modules/twig_theme_test/twig_theme_test.routing.yml index cdc0ac1..17ac5b0 100644 --- a/core/modules/system/tests/modules/twig_theme_test/twig_theme_test.routing.yml +++ b/core/modules/system/tests/modules/twig_theme_test/twig_theme_test.routing.yml @@ -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' -- 1.8.2