diff --git a/CHANGELOG b/CHANGELOG index 443f4f0101b..bf494da9f78 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,6 @@ # 3.27.2 (2026-XX-XX) + * Add the `include_only` function to render a template without giving it access to the current context * Fix nested `block()` calls to resolve against the overriding template when a block rendered through `block(name, template)` calls `parent()` * Stop reporting a skipped test in `IntegrationTestCase` when there is no legacy test to run * Make the `IntegrationTestCase` and `NodeTestCase` test helpers compatible with PHPUnit 11 diff --git a/doc/functions/include.rst b/doc/functions/include.rst index dadac31cdd9..908a08acf1f 100644 --- a/doc/functions/include.rst +++ b/doc/functions/include.rst @@ -22,6 +22,14 @@ HTML). Included templates have access to the variables of the active context. +.. tip:: + + Prefer the :doc:`include_only` function when you can. Sharing + the whole context lets a template silently rely on variables defined by the + caller, which hides its real inputs and couples it to wherever it is + included from. ``include_only`` takes only the variables you pass, making + the data flow explicit and partials easier to reuse. + If you are using the filesystem loader, the templates are looked for in the paths defined by it. diff --git a/doc/functions/include_only.rst b/doc/functions/include_only.rst new file mode 100644 index 00000000000..1c99429895d --- /dev/null +++ b/doc/functions/include_only.rst @@ -0,0 +1,82 @@ +``include_only`` +================ + +.. versionadded:: 3.27.2 + + The ``include_only`` function was added in Twig 3.27.2. + +The ``include_only`` function returns the rendered content of a template +without giving it access to the current context: + +.. code-block:: twig + + {{ include_only('template.html.twig') }} + {{ include_only(some_var) }} + +It behaves like the :doc:`include` function, except that the rendered +template does **not** have access to the variables of the active context. This +makes the data a template relies on explicit, which is often clearer and +easier to reason about. + +If you are using the filesystem loader, the templates are looked for in the +paths defined by it. + +As the context is not passed, variables a template needs must be passed +explicitly: + +.. code-block:: twig + + {# template.html.twig will only have access to the "name" variable #} + {{ include_only('template.html.twig', {name: 'Fabien'}) }} + +When passing a variable from the current context, you can use the following +shortcut: + +.. code-block:: twig + + {{ include_only('template.html.twig', {name, email}) }} + + {# is equivalent to #} + + {{ include_only('template.html.twig', {name: name, email: email}) }} + +And if the expression evaluates to a ``\Twig\TemplateWrapper`` instance, Twig +will use it directly:: + + // {{ include_only(template) }} + + $template = $twig->load('some_template.html.twig'); + + $twig->display('template.html.twig', ['template' => $template]); + +When you set the ``ignore_missing`` flag, Twig will return an empty string if +the template does not exist: + +.. code-block:: twig + + {{ include_only('sidebar.html.twig', ignore_missing: true) }} + +You can also provide a list of templates that are checked for existence before +inclusion. The first template that exists will be rendered: + +.. code-block:: twig + + {{ include_only(['page_detailed.html.twig', 'page.html.twig']) }} + +If ``ignore_missing`` is set, it will fall back to rendering nothing if none +of the templates exist, otherwise it will throw an exception. + +When including a template created by an end user, you should consider +:doc:`sandboxing<../sandbox>` it: + +.. code-block:: twig + + {{ include_only('page.html.twig', 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 diff --git a/doc/functions/index.rst b/doc/functions/index.rst index c0de384bb6c..74194163fef 100644 --- a/doc/functions/index.rst +++ b/doc/functions/index.rst @@ -16,6 +16,7 @@ Functions html_classes html_cva include + include_only max min parent diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php index a49f5795e37..48db0591c36 100644 --- a/src/Extension/CoreExtension.php +++ b/src/Extension/CoreExtension.php @@ -301,6 +301,7 @@ public function getFunctions(): array 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_only', [self::class, 'includeOnly'], ['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]), @@ -1531,6 +1532,21 @@ public static function include(Environment $env, $context, $template, $variables } } + /** + * Renders a template without giving it access to the current context. + * + * @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 includeOnly(Environment $env, $template, $variables = [], $ignoreMissing = false, $sandboxed = false): string + { + return self::include($env, [], $template, $variables, false, $ignoreMissing, $sandboxed); + } + /** * Returns a template content without rendering it. * diff --git a/tests/Fixtures/functions/include_only/basic.test b/tests/Fixtures/functions/include_only/basic.test new file mode 100644 index 00000000000..2957c079e68 --- /dev/null +++ b/tests/Fixtures/functions/include_only/basic.test @@ -0,0 +1,17 @@ +--TEST-- +"include_only" function +--TEMPLATE-- +FOO +{{ include_only("foo.twig") }} + +BAR +--TEMPLATE(foo.twig)-- +FOOBAR +--DATA-- +return [] +--EXPECT-- +FOO + +FOOBAR + +BAR diff --git a/tests/Fixtures/functions/include_only/ignore_missing.test b/tests/Fixtures/functions/include_only/ignore_missing.test new file mode 100644 index 00000000000..99b35d1059f --- /dev/null +++ b/tests/Fixtures/functions/include_only/ignore_missing.test @@ -0,0 +1,9 @@ +--TEST-- +"include_only" function ignores missing templates +--TEMPLATE-- +{{ include_only(["foo.twig", "bar.twig"], ignore_missing = true) }} +{{ include_only("foo.twig", ignore_missing = true) }} +{{ include_only("foo.twig", ignore_missing = true, variables = {}) }} +--DATA-- +return [] +--EXPECT-- diff --git a/tests/Fixtures/functions/include_only/no_context.test b/tests/Fixtures/functions/include_only/no_context.test new file mode 100644 index 00000000000..5500a926913 --- /dev/null +++ b/tests/Fixtures/functions/include_only/no_context.test @@ -0,0 +1,10 @@ +--TEST-- +"include_only" function does not give access to the current context +--TEMPLATE-- +{{ include_only("foo.twig") }} +--TEMPLATE(foo.twig)-- +[{{ foo|default('undefined') }}] +--DATA-- +return ['foo' => 'bar'] +--EXPECT-- +[undefined] diff --git a/tests/Fixtures/functions/include_only/sandbox.test b/tests/Fixtures/functions/include_only/sandbox.test new file mode 100644 index 00000000000..a442b55e3f4 --- /dev/null +++ b/tests/Fixtures/functions/include_only/sandbox.test @@ -0,0 +1,13 @@ +--TEST-- +"include_only" function sandboxed +--TEMPLATE-- +{{ include_only("foo.twig", sandboxed = true) }} +--TEMPLATE(foo.twig)-- + + +{{ 'foo'|e }} +{{ 'foo'|e }} +--DATA-- +return [] +--EXCEPTION-- +Twig\Sandbox\SecurityNotAllowedFilterError: Filter "e" is not allowed in "foo.twig" at line 4. diff --git a/tests/Fixtures/functions/include_only/with_variables.test b/tests/Fixtures/functions/include_only/with_variables.test new file mode 100644 index 00000000000..bb93f99536e --- /dev/null +++ b/tests/Fixtures/functions/include_only/with_variables.test @@ -0,0 +1,12 @@ +--TEST-- +"include_only" function accepts variables +--TEMPLATE-- +{{ include_only("foo.twig", {'foo': 'bar'}) }} +{{- include_only("foo.twig", vars) }} +--TEMPLATE(foo.twig)-- +{{ foo }} +--DATA-- +return ['vars' => ['foo' => 'bar']] +--EXPECT-- +bar +bar