diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af537adf1..659b46f91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: "Install PHP with extensions" uses: shivammathur/setup-php@v2 @@ -72,7 +72,7 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: "Install PHP with extensions" uses: shivammathur/setup-php@v2 @@ -90,6 +90,11 @@ jobs: working-directory: extra/${{ matrix.extension }} run: "composer require --no-update 'symfony/translation-contracts:^1.1|^2.0'" + - name: "Prevent installing tempest/markdown, which requires PHP 8.5" + if: "matrix.extension == 'markdown-extra' && matrix.php-version != '8.5'" + working-directory: extra/${{ matrix.extension }} + run: composer remove --dev --no-update tempest/markdown + - name: "Composer install ${{ matrix.extension }}" working-directory: extra/${{ matrix.extension }} run: composer install @@ -116,7 +121,7 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: "Install PHP with extensions" uses: shivammathur/setup-php@v2 diff --git a/.gitignore b/.gitignore index 485ba481f..ce3a7a451 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /phpunit.phar /phpunit.xml /vendor +.php-cs-fixer.cache .phpunit.cache .phpunit.result.cache diff --git a/doc/filters/markdown_to_html.rst b/doc/filters/markdown_to_html.rst index 780f0112b..4218ed37c 100644 --- a/doc/filters/markdown_to_html.rst +++ b/doc/filters/markdown_to_html.rst @@ -84,10 +84,11 @@ Using a Custom Converter The ``markdown_to_html`` filter delegates the conversion to a class implementing ``Twig\Extra\Markdown\MarkdownInterface``. Several implementations are provided: ``LeagueMarkdown`` (``league/commonmark``), ``MichelfMarkdown`` -(``michelf/php-markdown``), and ``ErusevMarkdown`` (``erusev/parsedown``). Each -accepts a pre-configured converter in its constructor, so you can tune the -underlying library or switch to another implementation (for instance -``ParsedownExtra``, which extends ``Parsedown``):: +(``michelf/php-markdown``), ``ErusevMarkdown`` (``erusev/parsedown``), and +``TempestMarkdown`` (``tempest/markdown``). Each accepts a pre-configured +converter in its constructor, so you can tune the underlying library or switch +to another implementation (for instance ``ParsedownExtra``, which extends +``Parsedown``):: use Twig\Extra\Markdown\ErusevMarkdown; @@ -96,6 +97,14 @@ underlying library or switch to another implementation (for instance $markdown = new ErusevMarkdown($parsedown); +.. note:: + + ``tempest/markdown`` requires PHP 8.5 or later. It also differs from the + other libraries on two points worth knowing about: Setext headings + (``Title`` underlined with ``===``) are not supported, so use ATX headings + (``# Title``) instead, and any YAML front matter is parsed out of the + rendered HTML rather than being rendered. + When using ``twig/extra-bundle``, register your converter as the ``twig.markdown.default`` service to make it the one used by the filter: diff --git a/doc/functions/include.rst b/doc/functions/include.rst index 4e51ae120..ac31b21bb 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 #} - -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). - Included templates have access to the variables of the active context. -If you are using the filesystem loader, the templates are looked for in the -paths defined by it. +.. tip:: -The context is passed by default to the template but you can also pass + 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. + +Its documentation also covers the template loading, ``ignore_missing`` and +return-value behavior shared by both functions. + +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: render the untrusted template with the ``Twig\Sandbox\Sandbox`` class from PHP or the diff --git a/doc/functions/include_only.rst b/doc/functions/include_only.rst new file mode 100644 index 000000000..be3ffe9a6 --- /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 c5ca047e5..1d1d815b2 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/extra/markdown-extra/DefaultMarkdown.php b/extra/markdown-extra/DefaultMarkdown.php index a20993d45..389022082 100644 --- a/extra/markdown-extra/DefaultMarkdown.php +++ b/extra/markdown-extra/DefaultMarkdown.php @@ -13,6 +13,7 @@ namespace Twig\Extra\Markdown; use League\CommonMark\CommonMarkConverter; use Michelf\MarkdownExtra; +use Tempest\Markdown\Markdown; class DefaultMarkdown implements MarkdownInterface { @@ -26,6 +27,8 @@ class DefaultMarkdown implements MarkdownInterface $this->converter = new MichelfMarkdown(); } elseif (class_exists(\Parsedown::class)) { $this->converter = new ErusevMarkdown(); + } elseif (class_exists(Markdown::class)) { + $this->converter = new TempestMarkdown(); } else { throw new \LogicException('You cannot use the "markdown_to_html" filter as no Markdown library is available; try running "composer require league/commonmark".'); } diff --git a/extra/markdown-extra/TempestMarkdown.php b/extra/markdown-extra/TempestMarkdown.php new file mode 100644 index 000000000..1e037ac99 --- /dev/null +++ b/extra/markdown-extra/TempestMarkdown.php @@ -0,0 +1,29 @@ +converter = $converter ?: new Markdown(); + } + + public function convert(string $body): string + { + return $this->converter->parse($body)->html; + } +} diff --git a/extra/markdown-extra/Tests/FunctionalTest.php b/extra/markdown-extra/Tests/FunctionalTest.php index b5e58d30b..77741db41 100644 --- a/extra/markdown-extra/Tests/FunctionalTest.php +++ b/extra/markdown-extra/Tests/FunctionalTest.php @@ -21,6 +21,7 @@ use Twig\Extra\Markdown\MarkdownExtension; use Twig\Extra\Markdown\MarkdownInterface; use Twig\Extra\Markdown\MarkdownRuntime; use Twig\Extra\Markdown\MichelfMarkdown; +use Twig\Extra\Markdown\TempestMarkdown; use Twig\Loader\ArrayLoader; use Twig\RuntimeLoader\RuntimeLoaderInterface; @@ -29,12 +30,16 @@ class FunctionalTest extends TestCase #[DataProvider('getMarkdownTests')] public function testMarkdown(string $template, string $expected): void { - foreach ([LeagueMarkdown::class, ErusevMarkdown::class, /* MichelfMarkdown::class, */ DefaultMarkdown::class] as $class) { + $classes = [LeagueMarkdown::class, ErusevMarkdown::class, /* MichelfMarkdown::class, */ DefaultMarkdown::class]; + if (class_exists(\Tempest\Markdown\Markdown::class)) { + $classes[] = TempestMarkdown::class; + } + + foreach ($classes as $class) { $twig = new Environment(new ArrayLoader([ 'index' => $template, 'html' => <<Hello\n+

Great!

"], +EOF, "]*>Hello\n+

Great!\s*

"], [<<Hello\n+

Great!

"], - ["{{ include('html')|markdown_to_html }}", "

Hello

\n+

Great!

"], +EOF, "]*>Hello\n+

Great!\s*

"], + ["{{ include('html')|markdown_to_html }}", "]*>Hello\n+

Great!\s*

"], [<<Paragraph 1

\n+

Paragraph 2

"], +EOF, "

Paragraph 1

\n+

Paragraph 2\s*

"], ]; } diff --git a/extra/markdown-extra/composer.json b/extra/markdown-extra/composer.json index 7967a0f77..edbb05b45 100644 --- a/extra/markdown-extra/composer.json +++ b/extra/markdown-extra/composer.json @@ -24,7 +24,8 @@ "league/commonmark": "^2.7", "league/html-to-markdown": "^4.8|^5.0", "michelf/php-markdown": "^1.8|^2.0", - "symfony/phpunit-bridge": "^6.4|^7.0" + "symfony/phpunit-bridge": "^6.4|^7.0", + "tempest/markdown": "^1.2" }, "autoload": { "psr-4" : { "Twig\\Extra\\Markdown\\" : "" }, diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php index a64af72d7..b0bbf1534 100644 --- a/src/Extension/CoreExtension.php +++ b/src/Extension/CoreExtension.php @@ -304,6 +304,7 @@ final class CoreExtension extends AbstractExtension new TwigFunction('random', self::random(...), ['needs_charset' => true]), new TwigFunction('date', $this->convertDate(...)), new TwigFunction('include', self::include(...), ['needs_environment' => true, 'needs_context' => true, 'is_safe' => ['all']]), + new TwigFunction('include_only', self::includeOnly(...), ['needs_environment' => true, 'is_safe' => ['all']]), new TwigFunction('source', self::source(...), ['needs_environment' => true, 'is_safe' => ['all']]), new TwigFunction('enum_cases', self::enumCases(...), ['node_class' => EnumCasesFunction::class]), new TwigFunction('enum', self::enum(...), ['node_class' => EnumFunction::class]), @@ -1450,6 +1451,22 @@ final class CoreExtension extends AbstractExtension return '' === $rendered ? '' : new Markup($rendered, $env->getCharset()); } + /** + * 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/src/Template.php b/src/Template.php index 4c653a9cb..d6b31c9c4 100644 --- a/src/Template.php +++ b/src/Template.php @@ -346,23 +346,8 @@ abstract class Template try { $this->ensureSecurityChecked(); yield from $this->doDisplay($context, $blocks); - } catch (Error $e) { - if (!$e->getSourceContext()) { - $e->setSourceContext($this->getSourceContext()); - } - - // this is mostly useful for \Twig\Error\LoaderError exceptions - // see \Twig\Error\LoaderError - if (-1 === $e->getTemplateLine()) { - $e->guess(); - } - - throw $e; } catch (\Throwable $e) { - $e = new RuntimeError(\sprintf('An exception has been thrown during the rendering of a template ("%s").', $e->getMessage()), -1, $this->getSourceContext(), $e); - $e->guess(); - - throw $e; + $this->handleException($e); } } @@ -393,23 +378,8 @@ abstract class Template try { $template->ensureSecurityChecked(); yield from $template->$block($context, $blocks); - } catch (Error $e) { - if (!$e->getSourceContext()) { - $e->setSourceContext($template->getSourceContext()); - } - - // this is mostly useful for \Twig\Error\LoaderError exceptions - // see \Twig\Error\LoaderError - if (-1 === $e->getTemplateLine()) { - $e->guess(); - } - - throw $e; } catch (\Throwable $e) { - $e = new RuntimeError(\sprintf('An exception has been thrown during the rendering of a template ("%s").', $e->getMessage()), -1, $template->getSourceContext(), $e); - $e->guess(); - - throw $e; + $template->handleException($e); } } elseif ($parent = $this->getParent($context)) { yield from $parent->unwrap()->yieldBlock($name, $context, array_merge($this->blocks, $blocks), false, $templateContext ?? $this); @@ -459,4 +429,23 @@ abstract class Template public function ensureSecurityChecked(): void { } + + private function handleException(\Throwable $error): never + { + if ($error instanceof Error) { + if (!$error->getSourceContext()) { + $error->setSourceContext($this->getSourceContext()); + } + if (-1 === $error->getTemplateLine()) { + $error->guess(); + } + + throw $error; + } + + $error = new RuntimeError(\sprintf('An exception has been thrown during the rendering of a template ("%s").', $error->getMessage()), -1, $this->getSourceContext(), $error); + $error->guess(); + + throw $error; + } } diff --git a/tests/ErrorTest.php b/tests/ErrorTest.php index 8cd0386ce..c4f36803a 100644 --- a/tests/ErrorTest.php +++ b/tests/ErrorTest.php @@ -36,6 +36,7 @@ use Twig\Node\Node; use Twig\Source; use Twig\Token; use Twig\TokenParser\AbstractTokenParser; +use Twig\TwigFunction; class ErrorTest extends TestCase { @@ -47,6 +48,24 @@ class ErrorTest extends TestCase $this->assertStringContainsString('tests'.\DIRECTORY_SEPARATOR.'ErrorTest.php', $error->getMessage()); } + public function testTwigErrorIsEnrichedWithoutBeingWrapped(): void + { + $twig = new Environment(new ArrayLoader(['index' => "foo\n{{ fail() }}"]), ['debug' => true, 'cache' => false]); + $error = null; + $twig->addFunction(new TwigFunction('fail', static function () use (&$error): never { + throw $error = new RuntimeError('Runtime error.'); + })); + + try { + $twig->render('index'); + $this->fail(); + } catch (RuntimeError $e) { + $this->assertSame($error, $e); + $this->assertSame(2, $e->getTemplateLine()); + $this->assertSame('index', $e->getSourceContext()->getName()); + } + } + public function testTwigExceptionGuessWithMissingVarAndArrayLoader(): void { $loader = new ArrayLoader([ 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 000000000..8074cf42c --- /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 000000000..2957c079e --- /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 000000000..99b35d105 --- /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 000000000..5500a9269 --- /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 000000000..bb93f9953 --- /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