Commit 28b6bc5e by Alexander Makarov

Merge pull request #3978 from yiisoft/twig-syntax-changes

Fixes #3535: Syntax changes
parents 06be8f7a c9fe74b2
......@@ -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() }}
```
......
......@@ -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
......
<?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
<?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
......@@ -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)
......
......@@ -8,7 +8,7 @@ use yii\base\Model;
*/
class Singer extends Model
{
public $fistName;
public $firstName;
public $lastName;
public function rules()
......
<?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');
......
{{ json_encode('echo') | raw }}
{{ void(json_encode('silence')) }}
{% set var = json_encode('variable') %}
{{ json_encode(var) | raw }}
\ No newline at end of file
{# DEFAULT_TWIG_COMMENT #}
{* CUSTOM_LEXER_TWIG_COMMENT *}
\ No newline at end of file
{{ 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
{{ 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
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment