diff --git a/CHANGELOG b/CHANGELOG index 1e8067d1fe9..af69f3fa9f4 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,7 @@ # 3.29.0 (2026-XX-XX) * Fix imported macros not resolving their own template-level macro imports + * Add the `include_only` function to render a template without giving it access to the current context * Add the `Twig\Sandbox\SandboxInterface` interface and `Twig\Sandbox\Sandbox` class to render untrusted templates through a dedicated, always-sandboxed environment crafted for it * Add the `Twig\Extension\SandboxBridgeExtension` to render sandboxed templates from trusted templates with an explicit output escaping strategy * Extract the sandbox runtime enforcement into a new internal `Twig\Sandbox\SecurityChecker` class used by compiled templates and `CoreExtension` diff --git a/doc/functions/include.rst b/doc/functions/include.rst index de922f5bfa2..a49867acf6c 100644 --- a/doc/functions/include.rst +++ b/doc/functions/include.rst @@ -8,29 +8,25 @@ The ``include`` function returns the rendered content of a template: {{ include('template.html.twig') }} {{ include(some_var) }} -The returned content is a ``\Twig\Markup`` instance, so it is considered safe -and is not escaped again when you store it in a variable and print it later: - -.. code-block:: twig - - {% set body = include('body.html.twig') %} - {{ body }} {# rendered as-is, not re-escaped #} +Included templates have access to the variables of the active context. -Beware that, like any safe value, it is not re-escaped for the context it ends -up in, so only embed it in the same context it was rendered for (typically -HTML). +.. tip:: -Included templates have access to the variables of the active context. + 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. +Its documentation also covers the template loading, ``ignore_missing`` and +return-value behavior shared by both functions. -The context is passed by default to the template but you can also pass +The current context is passed by default to the template but you can also pass additional variables: .. code-block:: twig - {# template.html.twig will have access to the variables from the current context and the additional ones provided #} + {# The included template can access "name" and the current context. #} {{ include('template.html.twig', {name: 'Fabien'}) }} You can disable access to the context by setting ``with_context`` to @@ -38,40 +34,9 @@ You can disable access to the context by setting ``with_context`` to .. code-block:: twig - {# only the name variable will be accessible #} + {# Only the "name" variable will be accessible. #} {{ include('template.html.twig', {name: 'Fabien'}, with_context: false) }} -.. code-block:: twig - - {# no variables will be accessible #} - {{ include('template.html.twig', with_context: false) }} - -And if the expression evaluates to a ``\Twig\Template`` or a -``\Twig\TemplateWrapper`` instance, Twig will use it directly:: - - // {{ include(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('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(['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 :doc:`sandbox<../sandbox>` it. diff --git a/doc/functions/include_only.rst b/doc/functions/include_only.rst new file mode 100644 index 00000000000..be3ffe9a6ac --- /dev/null +++ b/doc/functions/include_only.rst @@ -0,0 +1,97 @@ +``include_only`` +================ + +.. versionadded:: 3.29 + + The ``include_only`` function was added in Twig 3.29. + +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) }} + +Variables from the active context are not passed implicitly. This makes the +data a template relies on explicit, which is often clearer and easier to +reason about. + +Returned Value +-------------- + +The returned content is a ``\Twig\Markup`` instance, so it is considered safe +and is not escaped again when you store it in a variable and print it later: + +.. code-block:: twig + + {% set body = include_only('body.html.twig') %} + {{ body }} {# rendered as-is, not re-escaped #} + +Beware that, like any safe value, it is not re-escaped for the context it ends +up in, so only embed it in the same context it was rendered for (typically +HTML). + +Passing Variables +----------------- + +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}) }} + +Loading Templates +----------------- + +If you are using the filesystem loader, the templates are looked for in the +paths defined by it. + +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. + +To render a template created by an end user, use the +:doc:`render_sandboxed() function `. + +Arguments +--------- + +* ``template``: The template to render +* ``variables``: The variables to pass to the template +* ``ignore_missing``: Whether to ignore missing templates or not diff --git a/doc/functions/index.rst b/doc/functions/index.rst index c5ca047e55e..1d1d815b279 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 6ae95acf295..beedaff0a5a 100644 --- a/src/Extension/CoreExtension.php +++ b/src/Extension/CoreExtension.php @@ -303,6 +303,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]), @@ -1532,6 +1533,22 @@ 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 + * + * @return string|Markup + * + * @internal + */ + public static function includeOnly(Environment $env, $template, array $variables = [], bool $ignoreMissing = false) + { + return self::include($env, [], $template, $variables, false, $ignoreMissing); + } + /** * Returns a template content without rendering it. * diff --git a/tests/Fixtures/functions/include_only/assignment_autoescaping.test b/tests/Fixtures/functions/include_only/assignment_autoescaping.test new file mode 100644 index 00000000000..8074cf42cdf --- /dev/null +++ b/tests/Fixtures/functions/include_only/assignment_autoescaping.test @@ -0,0 +1,10 @@ +--TEST-- +"include_only" function returns Markup so an assigned result is not re-escaped +--TEMPLATE-- +{% set assigned = include_only("included.twig") %}[{{ assigned }}] +--TEMPLATE(included.twig)-- +{{- "a & b"|escape -}} +--DATA-- +return [] +--EXPECT-- +[a & b] 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/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