Skip to content

Commit

Permalink
Add the render function, deprecate the include one
Browse files Browse the repository at this point in the history
  • Loading branch information
fabpot committed Nov 4, 2024
1 parent b99d73c commit cfd3254
Show file tree
Hide file tree
Showing 51 changed files with 461 additions and 90 deletions.
4 changes: 3 additions & 1 deletion CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# 3.15.0 (2024-XX-XX)


* Deprecate the `include` function, use `render` instead
* Add the `render` function to replace the `include` one
* Add Spanish inflector support for the `plural` and `singular` filters in the String extension
* Deprecate `TempNameExpression` in favor of `LocalVariable`
* Deprecate `NameExpression` in favor of `ContextVariable`
Expand Down
5 changes: 5 additions & 0 deletions doc/deprecated.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ Functions
Note that it won't be removed in 4.0 to allow a smoother upgrade path.

* The ``include`` function is deprecated as of Twig 3.15, use ``render``
instead. The ``render`` function does the same as ``include``, but the
rendered template never has access to the current context (all variables must
be passed explicitely).

Extensions
----------

Expand Down
5 changes: 5 additions & 0 deletions doc/functions/include.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
``include``
===========

.. warning::

The ``include`` function is deprecated as of Twig 3.15, use the ``render``
function instead.

The ``include`` function returns the rendered content of a template:

.. code-block:: twig
Expand Down
1 change: 1 addition & 0 deletions doc/functions/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Functions
html_classes
html_cva
include
render
max
min
parent
Expand Down
90 changes: 90 additions & 0 deletions doc/functions/render.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
``render``
==========

.. versionadded:: 3.15

The ``render`` function was added in Twig 3.15.

The ``render`` function returns the rendered content of a template:

.. code-block:: twig
{{ render('template.html') }}
Templates
---------

Templates are loaded via the current Twig loader.

Templates can be a string or an expression evaluating to a string:

.. code-block:: twig
{{ render('template.html') }}
{{ render(template_var) }}
A template can also be an instance of ``\Twig\Template`` or a
``\Twig\TemplateWrapper``::

$template = $twig->load('some_template.twig');
$twig->display('template.twig', ['template' => $template]);

// You can render it like this:
// {{ render(template) }}

When you set the ``ignore_missing`` flag, Twig will return an empty string if
the template does not exist:

.. code-block:: twig
{{ render('sidebar.html', ignore_missing = true) }}
You can also provide a list of templates that are checked for existence. The
first template that exists will be rendered:

.. code-block:: twig
{{ render(['page_detailed.html', 'page.html']) }}
If ``ignore_missing`` is set, it will fall back to rendering nothing if none
of the templates exist, otherwise it will throw an exception.

Variables
---------

Rendered templates **do not** have access to the variables defined in the
context, but you can pass variables explicitely:

.. code-block:: twig
{# template.html will have access to the "name" variable #}
{{ render('template.html', { name: 'Fabien' }) }}
You can pass existing variables from the current context:

.. code-block:: twig
{{ render('template.html', { first_name: first_name, last_name: last_name }) }}
{# or using the following shortcut #}
{{ render('template.html', { first_name, last_name }) }}
Sandboxing
----------

When rendering a template created by an end user, you should consider
:doc:`sandboxing<../sandbox>` it:

.. code-block:: twig
{{ render('page.html', sandboxed: true) }}
Arguments
---------

* ``template``: The template to render
* ``variables``: The variables to pass to the template
* ``ignore_missing``: Whether to ignore missing templates or not
* ``sandboxed``: Whether to sandbox the template or not
49 changes: 48 additions & 1 deletion src/Extension/CoreExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,8 @@ public function getFunctions(): array
new TwigFunction('cycle', [self::class, 'cycle']),
new TwigFunction('random', [self::class, 'random'], ['needs_charset' => true]),
new TwigFunction('date', [$this, 'convertDate']),
new TwigFunction('include', [self::class, 'include'], ['needs_environment' => true, 'needs_context' => true, 'is_safe' => ['all']]),
new TwigFunction('include', [self::class, 'include'], ['needs_environment' => true, 'needs_context' => true, 'is_safe' => ['all'], 'deprecation_info' => new DeprecatedCallableInfo('twig/twig', '3.15', 'render')]),
new TwigFunction('render', [self::class, 'render'], ['needs_environment' => true, 'is_safe' => ['all']]),
new TwigFunction('source', [self::class, 'source'], ['needs_environment' => true, 'is_safe' => ['all']]),
new TwigFunction('enum_cases', [self::class, 'enumCases'], ['node_class' => EnumCasesFunction::class]),
new TwigFunction('enum', [self::class, 'enum'], ['node_class' => EnumFunction::class]),
Expand Down Expand Up @@ -1468,6 +1469,52 @@ public static function include(Environment $env, $context, $template, $variables
}
}

/**
* Renders a template.
*
* @param string|array|TemplateWrapper $template The template to render or an array of templates to try consecutively
* @param array $variables The variables to pass to the template
* @param bool $ignoreMissing Whether to ignore missing templates or not
* @param bool $sandboxed Whether to sandbox the template or not
*
* @internal
*/
public static function render(Environment $env, $template, $variables = [], $ignoreMissing = false, $sandboxed = false): string
{
$alreadySandboxed = false;
$sandbox = null;

if ($isSandboxed = $sandboxed && $env->hasExtension(SandboxExtension::class)) {
$sandbox = $env->getExtension(SandboxExtension::class);
if (!$alreadySandboxed = $sandbox->isSandboxed()) {
$sandbox->enableSandbox();
}
}

try {
$loaded = null;
try {
$loaded = $env->resolveTemplate($template);
} catch (LoaderError $e) {
if (!$ignoreMissing) {
throw $e;
}

return '';
}

if ($isSandboxed) {
$loaded->unwrap()->checkSecurity();
}

return $loaded->render($variables);
} finally {
if ($isSandboxed && !$alreadySandboxed) {
$sandbox->disableSandbox();
}
}
}

/**
* Returns a template content without rendering it.
*
Expand Down
1 change: 1 addition & 0 deletions src/NodeVisitor/OptimizerNodeVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ private function enterOptimizeFor(Node $node): void
}

// include function without the with_context=false parameter
// to be removed in 4.0
elseif ($node instanceof FunctionExpression
&& 'include' === $node->getAttribute('name')
&& (!$node->getNode('arguments')->hasNode('with_context')
Expand Down
10 changes: 5 additions & 5 deletions tests/Extension/CoreTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -360,13 +360,13 @@ public static function provideCompareCases()
];
}

public function testSandboxedInclude()
public function testSandboxedRender()
{
$twig = new Environment(new ArrayLoader([
'index' => '{{ include("included", sandboxed: true) }}',
'index' => '{{ render("included", sandboxed: true) }}',
'included' => '{{ "included"|e }}',
]));
$policy = new SecurityPolicy(allowedFunctions: ['include']);
$policy = new SecurityPolicy(allowedFunctions: ['render']);
$sandbox = new SandboxExtension($policy, false);
$twig->addExtension($sandbox);

Expand All @@ -378,10 +378,10 @@ public function testSandboxedInclude()
public function testSandboxedIncludeWithPreloadedTemplate()
{
$twig = new Environment(new ArrayLoader([
'index' => '{{ include("included", sandboxed: true) }}',
'index' => '{{ render("included", sandboxed: true) }}',
'included' => '{{ "included"|e }}',
]));
$policy = new SecurityPolicy(allowedFunctions: ['include']);
$policy = new SecurityPolicy(allowedFunctions: ['render']);
$sandbox = new SandboxExtension($policy, false);
$twig->addExtension($sandbox);

Expand Down
24 changes: 12 additions & 12 deletions tests/Extension/SandboxTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,18 +50,18 @@ protected function setUp(): void
'1_basic3' => '{% if name %}foo{% endif %}',
'1_basic4' => '{{ obj.bar }}',
'1_basic5' => '{{ obj }}',
'1_basic7' => '{{ cycle(["foo","bar"], 1) }}',
'1_basic7' => '{{ cycle(["foo", "bar"], 1) }}',
'1_basic8' => '{{ obj.getfoobar }}{{ obj.getFooBar }}',
'1_basic9' => '{{ obj.foobar }}{{ obj.fooBar }}',
'1_basic' => '{% if obj.foo %}{{ obj.foo|upper }}{% endif %}',
'1_layout' => '{% block content %}{% endblock %}',
'1_child' => "{% extends \"1_layout\" %}\n{% block content %}\n{{ \"a\"|json_encode }}\n{% endblock %}",
'1_include' => '{{ include("1_basic1", sandboxed=true) }}',
'1_basic2_include_template_from_string_sandboxed' => '{{ include(template_from_string("{{ name|upper }}"), sandboxed=true) }}',
'1_basic2_include_template_from_string' => '{{ include(template_from_string("{{ name|upper }}")) }}',
'1_render' => '{{ render("1_basic1", { obj }, sandboxed: true) }}',
'1_basic2_include_template_from_string_sandboxed' => '{{ render(template_from_string("{{ name|upper }}"), sandboxed=true) }}',
'1_basic2_include_template_from_string' => '{{ render(template_from_string("{{ name|upper }}"), { name }) }}',
'1_range_operator' => '{{ (1..2)[0] }}',
'1_syntax_error_wrapper_legacy' => '{% sandbox %}{% include "1_syntax_error" %}{% endsandbox %}',
'1_syntax_error_wrapper' => '{{ include("1_syntax_error", sandboxed: true) }}',
'1_syntax_error_wrapper' => '{{ render("1_syntax_error", sandboxed: true) }}',
'1_syntax_error' => '{% syntax error }}',
'1_childobj_parentmethod' => '{{ child_obj.ParentMethod() }}',
'1_childobj_childmethod' => '{{ child_obj.ChildMethod() }}',
Expand Down Expand Up @@ -193,7 +193,7 @@ public function testSandboxGloballyFalseUnallowedFilterWithIncludeTemplateFromSt

public function testSandboxGloballyTrueUnallowedFilterWithIncludeTemplateFromStringSandboxed()
{
$twig = $this->getEnvironment(true, [], self::$templates, [], [], [], [], ['include', 'template_from_string']);
$twig = $this->getEnvironment(true, [], self::$templates, [], [], [], [], ['render', 'template_from_string']);
$twig->addExtension(new StringLoaderExtension());
try {
$twig->load('1_basic2_include_template_from_string_sandboxed')->render(self::$params);
Expand All @@ -212,7 +212,7 @@ public function testSandboxGloballyFalseUnallowedFilterWithIncludeTemplateFromSt

public function testSandboxGloballyTrueUnallowedFilterWithIncludeTemplateFromStringNotSandboxed()
{
$twig = $this->getEnvironment(true, [], self::$templates, [], [], [], [], ['include', 'template_from_string']);
$twig = $this->getEnvironment(true, [], self::$templates, [], [], [], [], ['render', 'template_from_string']);
$twig->addExtension(new StringLoaderExtension());
try {
$twig->load('1_basic2_include_template_from_string')->render(self::$params);
Expand Down Expand Up @@ -414,11 +414,11 @@ public function testSandboxLocallySetForAnInclude()
$this->assertEquals('fooFOOfoo', $twig->load('2_basic')->render(self::$params), 'Sandbox does nothing if disabled globally and sandboxed not used for the include');

self::$templates = [
'3_basic' => '{{ include("3_included", sandboxed: true) }}',
'3_basic' => '{{ render("3_included", sandboxed: true) }}',
'3_included' => '{% if true %}{{ "foo"|upper }}{% endif %}',
];

$twig = $this->getEnvironment(true, [], self::$templates, functions: ['include']);
$twig = $this->getEnvironment(true, [], self::$templates, functions: ['render']);
try {
$twig->load('3_basic')->render(self::$params);
$this->fail('Sandbox throws a SecurityError exception when the included file is sandboxed');
Expand All @@ -441,20 +441,20 @@ public function testMacrosInASandbox()
$this->assertEquals('<p>username</p>', $twig->load('index')->render([]));
}

public function testSandboxDisabledAfterIncludeFunctionError()
public function testSandboxDisabledAfterRenderFunctionError()
{
$twig = $this->getEnvironment(false, [], self::$templates);

$e = null;
try {
$twig->load('1_include')->render(self::$params);
$twig->load('1_render')->render(self::$params);
} catch (\Throwable $e) {
}
if (null === $e) {
$this->fail('An exception should be thrown for this test to be valid.');
}

$this->assertFalse($twig->getExtension(SandboxExtension::class)->isSandboxed(), 'Sandboxed include() function call should not leave Sandbox enabled when an error occurs.');
$this->assertFalse($twig->getExtension(SandboxExtension::class)->isSandboxed(), 'Sandboxed render() function call should not leave Sandbox enabled when an error occurs.');
}

public function testSandboxWithNoClosureFilter()
Expand Down
4 changes: 2 additions & 2 deletions tests/Fixtures/autoescape/block.test
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
--TEST--
blocks and autoescape
--TEMPLATE--
{{ include('unrelated.txt.twig') -}}
{{ include('template.html.twig') -}}
{{ render('unrelated.txt.twig') -}}
{{ render('template.html.twig', { br }) -}}
--TEMPLATE(unrelated.txt.twig)--
{% block content %}{% endblock %}
--TEMPLATE(template.html.twig)--
Expand Down
6 changes: 3 additions & 3 deletions tests/Fixtures/autoescape/name.test
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
"name" autoescape strategy
--TEMPLATE--
{{ br -}}
{{ include('index.js.twig') -}}
{{ include('index.html.twig') -}}
{{ include('index.txt.twig') -}}
{{ render('index.js.twig', { br }) -}}
{{ render('index.html.twig', { br }) -}}
{{ render('index.txt.twig', { br }) -}}
--TEMPLATE(index.js.twig)--
{{ br -}}
--TEMPLATE(index.html.twig)--
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
--TEST--
Exception for multile function with undefined variable
Exception for multi-line function with undefined variable
--TEMPLATE--
{{ include('foo',
with_context=with_context
{{ render('foo',
sandboxed=with_sandbox
) }}
--TEMPLATE(foo)--
Foo
--DATA--
return []
--EXCEPTION--
Twig\Error\RuntimeError: Variable "with_context" does not exist in "index.twig" at line 3.
Twig\Error\RuntimeError: Variable "with_sandbox" does not exist in "index.twig" at line 3.
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Exception for an undefined template in a child template
{% extends 'base.twig' %}

{% block sidebar %}
{{ include('include.twig') }}
{{ render('include.twig') }}
{% endblock %}
--TEMPLATE(base.twig)--
{% block sidebar %}
Expand Down
15 changes: 15 additions & 0 deletions tests/Fixtures/functions/include/assignment.legacy.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
--TEST--
"include" function
--DEPRECATION--
Since twig/twig 3.15: Twig Function "include" is deprecated; use "render" instead in index.twig at line 2.
--TEMPLATE--
{% set tmp = include("foo.twig") %}

FOO{{ tmp }}BAR
--TEMPLATE(foo.twig)--
FOOBAR
--DATA--
return []
--EXPECT--
FOO
FOOBARBAR
12 changes: 12 additions & 0 deletions tests/Fixtures/functions/include/autoescaping.legacy.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
--TEST--
"include" function is safe for auto-escaping
--DEPRECATION--
Since twig/twig 3.15: Twig Function "include" is deprecated; use "render" instead in index.twig at line 2.
--TEMPLATE--
{{ include("foo.twig") }}
--TEMPLATE(foo.twig)--
<p>Test</p>
--DATA--
return []
--EXPECT--
<p>Test</p>
Loading

0 comments on commit cfd3254

Please sign in to comment.