diff --git a/docs/guide/tutorial-template-engines.md b/docs/guide/tutorial-template-engines.md index 20c4c93..316d428 100644 --- a/docs/guide/tutorial-template-engines.md +++ b/docs/guide/tutorial-template-engines.md @@ -24,6 +24,7 @@ component's behavior: //'cachePath' => '@runtime/Twig/cache', //'options' => [], /* Array of twig options */ 'globals' => ['html' => '\yii\helpers\Html'], + 'uses' => ['yii\bootstrap'], ], // ... ], @@ -78,15 +79,70 @@ In case you don't need result you shoud use `void` wrapper: ``` {{ void(my_function({'a' : 'b'})) }} -{{ void(myObject.my_function({'a' : 'b'})} }} +{{ void(myObject.my_function({'a' : 'b'})) }} ``` +#### Importing namespaces and classes + +You can import additional classes and namespaces right in the template: + +``` +Namespace import: +{{ use('/app/widgets') }} + +Class import: +{{ use('/yii/widgets/ActiveForm') }} + +Aliased class import: +{{ use({'alias' => '/app/widgets/MyWidget'}) }} +``` + +#### Widgets + +Extension helps using widgets in convenient way converting their syntax to function calls: + +``` +{{ use('yii/bootstrap') }} +{{ nav_bar_begin({ + 'brandLabel': 'My Company', +}) }} + {{ nav_widget({ + 'options': { + 'class': 'navbar-nav navbar-right', + }, + 'items': [{ + 'label': 'Home', + 'url': '/site/index', + }] + }) }} +{{ nav_bar_end() }} +``` + +In the template above `nav_bar_begin`, `nav_bar_end` or `nav_widget` consists of two parts. First part is widget name +coverted to lowercase and underscores: `NavBar` becomes `nav_bar`, `Nav` becomes `nav`. `_begin`, `_end` and `_widget` +are the same as `::begin()`, `::end()` and `::widget()` calls of a widget. + +One could also use more generic `widget_end()` that executes `Widget::end()`. + +#### Assets + +Assets could be registered the following way: + +``` +{{ use('yii/web/JqueryAsset') }} +{{ register_jquery_asset() }} +``` + +In the call above `register` identifies that we're working with assets while `jquery_asset` translates to `JqueryAsset` +class that we've already imported with `use`. + #### Forms -There are two form helper functions `form_begin` and `form_end` to make using forms more convenient: +You can build forms the following way: ``` -{% set form = form_begin({ +{{ use('yii/widgets/ActiveForm') }} +{% set form = active_form_begin({ 'id' : 'login-form', 'options' : {'class' : 'form-horizontal'}, }) %} @@ -96,7 +152,7 @@ There are two form helper functions `form_begin` and `form_end` to make using fo <div class="form-group"> <input type="submit" value="Login" class="btn btn-primary" /> </div> -{{ form_end() }} +{{ active_form_end() }} ``` diff --git a/extensions/twig/CHANGELOG.md b/extensions/twig/CHANGELOG.md index a0d3e6d..c2246aa 100644 --- a/extensions/twig/CHANGELOG.md +++ b/extensions/twig/CHANGELOG.md @@ -8,6 +8,11 @@ Yii Framework 2 twig extension Change Log - Bug #3767: Fixed repeated adding of extensions when using config. One may now pass extension instances as well (grachov) - Bug #3877: Fixed `lexerOptions` throwing exception (dapatrese) - Enh #1799: Added `form_begin`, `form_end` to twig extension (samdark) +- Chg #3535: Syntax changes: + - Removed `form_begin`, `form_end` (samdark) + - Added `use()` and `ViewRenderer::uses` that are importing classes and namespaces (grachov, samdark) + - Added widget dynamic functions `*_begin`, `*_end`, `*_widget`, `widget_end` (grachov, samdark) + - Added more tests (samdark) - Chg: Renamed `TwigSimpleFileLoader` into `FileLoader` (samdark) 2.0.0-beta April 13, 2014 diff --git a/extensions/twig/Extension.php b/extensions/twig/Extension.php new file mode 100644 index 0000000..abd0176 --- /dev/null +++ b/extensions/twig/Extension.php @@ -0,0 +1,198 @@ +<?php +/** + * @link http://www.yiiframework.com/ + * @copyright Copyright (c) 2008 Yii Software LLC + * @license http://www.yiiframework.com/license/ + */ + +namespace yii\twig; + +use yii\base\InvalidCallException; +use yii\helpers\Inflector; +use yii\helpers\StringHelper; +use yii\helpers\Url; + +/** + * Extension provides Yii-specific syntax for Twig templates. + * + * @author Andrey Grachov <andrey.grachov@gmail.com> + * @author Alexander Makarov <sam@rmcreative.ru> + */ +class Extension extends \Twig_Extension +{ + /** + * @var array used namespaces + */ + protected $namespaces = []; + + /** + * @var array used class aliases + */ + protected $aliases = []; + + /** + * @var array used widgets + */ + protected $widgets = []; + + /** + * Creates new instance + * + * @param array $uses namespaces and classes to use in the template + */ + public function __construct(array $uses = []) + { + $this->addUses($uses); + } + + /** + * @inheritdoc + */ + public function getNodeVisitors() + { + return [ + new Optimizer(), + ]; + } + + /** + * @inheritdoc + */ + public function getFunctions() + { + $options = [ + 'is_safe' => ['html'], + ]; + $functions = [ + new \Twig_SimpleFunction('use', [$this, 'addUses'], $options), + new \Twig_SimpleFunction('*_begin', [$this, 'beginWidget'], $options), + new \Twig_SimpleFunction('*_end', [$this, 'endWidget'], $options), + new \Twig_SimpleFunction('widget_end', [$this, 'endWidget'], $options), + new \Twig_SimpleFunction('*_widget', [$this, 'widget'], $options), + new \Twig_SimpleFunction('path', [$this, 'path']), + new \Twig_SimpleFunction('url', [$this, 'url']), + new \Twig_SimpleFunction('void', function(){}), + ]; + + $options = array_merge($options, [ + 'needs_context' => true, + ]); + $functions[] = new \Twig_SimpleFunction('register_*', [$this, 'registerAsset'], $options); + foreach (['begin_page', 'end_page', 'begin_body', 'end_body', 'head'] as $helper) { + $functions[] = new \Twig_SimpleFunction($helper, [$this, 'viewHelper'], $options); + } + return $functions; + } + + public function registerAsset($context, $asset) + { + return $this->resolveAndCall($asset, 'register', [ + isset($context['this']) ? $context['this'] : null, + ]); + } + + public function beginWidget($widget, $config = []) + { + $widget = $this->resolveClassName($widget); + $this->widgets[] = $widget; + return $this->call($widget, 'begin', [ + $config, + ]); + } + + public function endWidget($widget = null) + { + if ($widget === null) { + if (empty($this->widgets)) { + throw new InvalidCallException('Unexpected end_widget() call. A matching begin_widget() is not found.'); + } + $this->call(array_pop($this->widgets), 'end'); + } else { + array_pop($this->widgets); + $this->resolveAndCall($widget, 'end'); + } + } + + public function widget($widget, $config = []) + { + return $this->resolveAndCall($widget, 'widget', [ + $config, + ]); + } + + public function viewHelper($context, $name = null) + { + if ($name !== null && isset($context['this'])) { + $this->call($context['this'], Inflector::variablize($name)); + } + } + + public function resolveAndCall($className, $method, $arguments = null) + { + return $this->call($this->resolveClassName($className), $method, $arguments); + } + + public function call($className, $method, $arguments = null) + { + $callable = [$className, $method]; + if ($arguments === null) { + return call_user_func($callable); + } else { + return call_user_func_array($callable, $arguments); + } + } + + public function resolveClassName($className) + { + $className = Inflector::id2camel($className, '_'); + if (isset($this->aliases[$className])) { + return $this->aliases[$className]; + } + $resolvedClassName = null; + foreach ($this->namespaces as $namespace) { + $resolvedClassName = $namespace . '\\' . $className; + if (class_exists($resolvedClassName)) { + return $this->aliases[$className] = $resolvedClassName; + } + } + return $className; + } + + public function addUses($args) + { + foreach ((array)$args as $key => $value) { + $value = str_replace('/', '\\', $value); + if (is_int($key)) { + // namespace or class import + if (class_exists($value)) { + // class import + $this->aliases[StringHelper::basename($value)] = $value; + } else { + // namespace + $this->namespaces[] = $value; + } + } else { + // aliased class import + $this->aliases[$key] = $value; + } + } + } + + public function path($path, $args = []) + { + return Url::to(array_merge([$path], $args)); + } + + public function url($path, $args = []) + { + return Url::to(array_merge([$path], $args), true); + } + + /** + * @inheritdoc + */ + public function getName() + { + return 'yii2-twig'; + } +} \ No newline at end of file diff --git a/extensions/twig/Optimizer.php b/extensions/twig/Optimizer.php new file mode 100644 index 0000000..9902dd2 --- /dev/null +++ b/extensions/twig/Optimizer.php @@ -0,0 +1,66 @@ +<?php +/** + * @link http://www.yiiframework.com/ + * @copyright Copyright (c) 2008 Yii Software LLC + * @license http://www.yiiframework.com/license/ + */ + +namespace yii\twig; + +/** + * Optimizer removes echo before special functions call and injects function name as an argument for the view helper + * calls. + * + * @author Andrey Grachov <andrey.grachov@gmail.com> + * @author Alexander Makarov <sam@rmcreative.ru> + */ +class Optimizer implements \Twig_NodeVisitorInterface +{ + /** + * @inheritdoc + */ + public function enterNode(\Twig_NodeInterface $node, \Twig_Environment $env) + { + return $node; + } + + /** + * @inheritdoc + */ + public function leaveNode(\Twig_NodeInterface $node, \Twig_Environment $env) + { + if ($node instanceof \Twig_Node_Print) { + $expression = $node->getNode('expr'); + if ($expression instanceof \Twig_Node_Expression_Function) { + $name = $expression->getAttribute('name'); + if (preg_match('/^(?:register_.+_asset|use|.+_begin|.+_end)$/', $name)) { + return new \Twig_Node_Do($expression, $expression->getLine()); + } elseif (in_array($name, ['begin_page', 'end_page', 'begin_body', 'end_body', 'head'])) { + $arguments = [ + new \Twig_Node_Expression_Constant($name, $expression->getLine()), + ]; + if ($expression->hasNode('arguments') && $expression->getNode('arguments') !== null) { + foreach ($expression->getNode('arguments') as $key => $value) { + if (is_int($key)) { + $arguments[] = $value; + } else { + $arguments[$key] = $value; + } + } + } + $expression->setNode('arguments', new \Twig_Node($arguments)); + return new \Twig_Node_Do($expression, $expression->getLine()); + } + } + } + return $node; + } + + /** + * @inheritdoc + */ + public function getPriority() + { + return 100; + } +} \ No newline at end of file diff --git a/extensions/twig/ViewRenderer.php b/extensions/twig/ViewRenderer.php index 49e45cf..c897292 100644 --- a/extensions/twig/ViewRenderer.php +++ b/extensions/twig/ViewRenderer.php @@ -10,8 +10,6 @@ namespace yii\twig; use Yii; use yii\base\View; use yii\base\ViewRenderer as BaseViewRenderer; -use yii\helpers\Url; -use yii\widgets\ActiveForm; /** * TwigViewRenderer allows you to use Twig templates in views. @@ -28,11 +26,13 @@ class ViewRenderer extends BaseViewRenderer * templates cache. */ public $cachePath = '@runtime/Twig/cache'; + /** * @var array Twig options. * @see http://twig.sensiolabs.org/doc/api.html#environment-options */ public $options = []; + /** * @var array Objects or static classes. * Keys of the array are names to call in template, values are objects or names of static classes. @@ -40,6 +40,7 @@ class ViewRenderer extends BaseViewRenderer * In the template you can use it like this: `{{ html.a('Login', 'site/login') | raw }}`. */ public $globals = []; + /** * @var array Custom functions. * Keys of the array are names to call in template, values are names of functions or static methods of some class. @@ -47,6 +48,7 @@ class ViewRenderer extends BaseViewRenderer * In the template you can use it like this: `{{ rot13('test') }}` or `{{ a('Login', 'site/login') | raw }}`. */ public $functions = []; + /** * @var array Custom filters. * Keys of the array are names to call in template, values are names of functions or static methods of some class. @@ -54,13 +56,16 @@ class ViewRenderer extends BaseViewRenderer * In the template you can use it like this: `{{ 'test'|rot13 }}` or `{{ model|jsonEncode }}`. */ public $filters = []; + /** * @var array Custom extensions. * Example: `['Twig_Extension_Sandbox', new \Twig_Extension_Text()]` */ public $extensions = []; + /** * @var array Twig lexer options. + * * Example: Smarty-like syntax: * ```php * [ @@ -72,8 +77,24 @@ class ViewRenderer extends BaseViewRenderer * @see http://twig.sensiolabs.org/doc/recipes.html#customizing-the-syntax */ public $lexerOptions = []; + /** - * @var \Twig_Environment twig environment object that do all rendering twig templates + * @var array namespaces and classes to import. + * + * Example: + * + * ```php + * [ + * 'yii\bootstrap', + * 'app\assets', + * \yii\bootstrap\NavBar::className(), + * ] + * ``` + */ + public $uses = []; + + /** + * @var \Twig_Environment twig environment object that renders twig templates */ public $twig; @@ -99,31 +120,13 @@ class ViewRenderer extends BaseViewRenderer $this->addFilters($this->filters); } + $this->addExtensions([new Extension($this->uses)]); + // Adding custom extensions if (!empty($this->extensions)) { $this->addExtensions($this->extensions); } - // Adding global 'void' function (usage: {{void(App.clientScript.registerScriptFile(...))}}) - $this->twig->addFunction('void', new \Twig_Function_Function(function ($argument) { - })); - - $this->twig->addFunction('path', new \Twig_Function_Function(function ($path, $args = []) { - return Url::to(array_merge([$path], $args)); - })); - - $this->twig->addFunction('url', new \Twig_Function_Function(function ($path, $args = []) { - return Url::to(array_merge([$path], $args), true); - })); - - $this->twig->addFunction('form_begin', new \Twig_Function_Function(function ($args = []) { - return ActiveForm::begin($args); - })); - - $this->twig->addFunction('form_end', new \Twig_Function_Function(function () { - ActiveForm::end(); - })); - $this->twig->addGlobal('app', \Yii::$app); // Change lexer syntax (must be set after other settings) diff --git a/tests/unit/data/base/Singer.php b/tests/unit/data/base/Singer.php index c6a999f..0891690 100644 --- a/tests/unit/data/base/Singer.php +++ b/tests/unit/data/base/Singer.php @@ -8,7 +8,7 @@ use yii\base\Model; */ class Singer extends Model { - public $fistName; + public $firstName; public $lastName; public function rules() diff --git a/tests/unit/extensions/twig/ViewRendererTest.php b/tests/unit/extensions/twig/ViewRendererTest.php index 798c5b2..14efa32 100644 --- a/tests/unit/extensions/twig/ViewRendererTest.php +++ b/tests/unit/extensions/twig/ViewRendererTest.php @@ -1,20 +1,17 @@ <?php -/** - * - * - * @author Carsten Brandt <mail@cebe.cc> - */ - namespace yiiunit\extensions\twig; use yii\web\AssetManager; -use yii\web\JqueryAsset; use yii\web\View; use Yii; +use yiiunit\data\base\Singer; use yiiunit\TestCase; /** - * @group twig + * Tests Twig view renderer + * + * @author Alexander Makarov <sam@rmcreative.ru> + * @author Carsten Brandt <mail@cebe.cc> */ class ViewRendererTest extends TestCase { @@ -29,10 +26,17 @@ class ViewRendererTest extends TestCase public function testLayoutAssets() { $view = $this->mockView(); - JqueryAsset::register($view); $content = $view->renderFile('@yiiunit/extensions/twig/views/layout.twig'); - $this->assertEquals(1, preg_match('#<script src="/assets/[0-9a-z]+/jquery\\.js"></script>\s*</body>#', $content), 'content does not contain the jquery js:' . $content); + $this->assertEquals(1, preg_match('#<script src="/assets/[0-9a-z]+/jquery\\.js"></script>\s*</body>#', $content), 'Content does not contain the jquery js:' . $content); + } + + public function testAppGlobal() + { + $view = $this->mockView(); + $content = $view->renderFile('@yiiunit/extensions/twig/views/layout.twig'); + + $this->assertEquals(1, preg_match('#<meta charset="' . Yii::$app->charset . '"/>#', $content), 'Content does not contain charset:' . $content); } /** @@ -41,39 +45,64 @@ class ViewRendererTest extends TestCase public function testLexerOptions() { $view = $this->mockView(); - $content = $view->renderFile('@yiiunit/extensions/twig/views/layout.twig'); + $content = $view->renderFile('@yiiunit/extensions/twig/views/comments.twig'); + + $this->assertFalse(strpos($content, 'CUSTOM_LEXER_TWIG_COMMENT'), 'Custom comment lexerOptions were not applied: ' . $content); + $this->assertTrue(strpos($content, 'DEFAULT_TWIG_COMMENT') !== false, 'Default comment style was not modified via lexerOptions:' . $content); + } + + public function testForm() + { + $view = $this->mockView(); + $model = new Singer(); + $content = $view->renderFile('@yiiunit/extensions/twig/views/form.twig', ['model' => $model]); + $this->assertEquals(1, preg_match('#<form id="login-form" class="form-horizontal" action="/form-handler" method="post">.*?</form>#s', $content), 'Content does not contain form:' . $content); + } - $this->assertFalse(strpos($content, 'CUSTOM_LEXER_TWIG_COMMENT') , 'custom comment lexerOption is not applied'); - $this->assertTrue(0 < strpos($content, 'DEFAULT_TWIG_COMMENT') , 'default comment style not modified via lexerOptions'); + public function testCalls() + { + $view = $this->mockView(); + $model = new Singer(); + $content = $view->renderFile('@yiiunit/extensions/twig/views/calls.twig', ['model' => $model]); + $this->assertFalse(strpos($content, 'silence'), 'silence should not be echoed when void() used: ' . $content); + $this->assertTrue(strpos($content, 'echo') !== false, 'echo should be there:' . $content); + $this->assertTrue(strpos($content, 'variable') !== false, 'variable should be there:' . $content); } + /** + * Mocks view instance + * @return View + */ protected function mockView() { return new View([ 'renderers' => [ 'twig' => [ 'class' => 'yii\twig\ViewRenderer', - //'cachePath' => '@runtime/Twig/cache', 'options' => [ - 'cache' => false + 'cache' => false, ], 'globals' => [ 'html' => '\yii\helpers\Html', - 'pos_begin' => View::POS_BEGIN + 'pos_begin' => View::POS_BEGIN, ], 'functions' => [ 't' => '\Yii::t', - 'json_encode' => '\yii\helpers\Json::encode' + 'json_encode' => '\yii\helpers\Json::encode', ], 'lexerOptions' => [ 'tag_comment' => [ '{*', '*}' ], - ] + ], ], ], 'assetManager' => $this->mockAssetManager(), ]); } + /** + * Mocks asset manager + * @return AssetManager + */ protected function mockAssetManager() { $assetDir = Yii::getAlias('@runtime/assets'); diff --git a/tests/unit/extensions/twig/views/calls.twig b/tests/unit/extensions/twig/views/calls.twig new file mode 100644 index 0000000..474512a --- /dev/null +++ b/tests/unit/extensions/twig/views/calls.twig @@ -0,0 +1,5 @@ +{{ json_encode('echo') | raw }} +{{ void(json_encode('silence')) }} + +{% set var = json_encode('variable') %} +{{ json_encode(var) | raw }} \ No newline at end of file diff --git a/tests/unit/extensions/twig/views/comments.twig b/tests/unit/extensions/twig/views/comments.twig new file mode 100644 index 0000000..a0ee4e1 --- /dev/null +++ b/tests/unit/extensions/twig/views/comments.twig @@ -0,0 +1,2 @@ +{# DEFAULT_TWIG_COMMENT #} +{* CUSTOM_LEXER_TWIG_COMMENT *} \ No newline at end of file diff --git a/tests/unit/extensions/twig/views/form.twig b/tests/unit/extensions/twig/views/form.twig new file mode 100644 index 0000000..1d0e00f --- /dev/null +++ b/tests/unit/extensions/twig/views/form.twig @@ -0,0 +1,18 @@ +{{ use('yii/widgets/ActiveForm') }} + +{% set form = active_form_begin({ + 'id': 'login-form', + 'action' : '/form-handler', + 'options': { + 'class': 'form-horizontal', + } +}) %} + {{ form.field(model, 'firstName') | raw }} + <div class="form-group"> + <div class="col-lg-offset-1 col-lg-11"> + {{ html.submitButton('Login', { + 'class': 'btn btn-primary', + }) | raw }} + </div> + </div> +{{ active_form_end() }} \ No newline at end of file diff --git a/tests/unit/extensions/twig/views/layout.twig b/tests/unit/extensions/twig/views/layout.twig index 7b28858..9c50a07 100644 --- a/tests/unit/extensions/twig/views/layout.twig +++ b/tests/unit/extensions/twig/views/layout.twig @@ -1,16 +1,16 @@ -{{ this.beginPage() }} +{{ use('yii/web/JqueryAsset') }} +{{ register_jquery_asset() }} +{{ begin_page() }} <!DOCTYPE html> <html lang="en"> <head> - <meta charset="<?php echo Yii::$app->charset; ?>"/> + <meta charset="{{ app.charset }}"/> <title>{{ html.encode(this.title) }}</title> - {{ this.head() }} + {{ head() }} </head> <body> -{{ this.beginBody() }} - {# DEFAULT_TWIG_COMMENT #} - {* CUSTOM_LEXER_TWIG_COMMENT *} +{{ begin_body() }} body -{{ this.endBody() }} +{{ end_body() }} </body> -{{ this.endPage() }} +{{ end_page() }} \ No newline at end of file