Skip to content

Commit b06ece4

Browse files
committed
Merge branch 'release/2.0.21'
2 parents 812e57d + c969a63 commit b06ece4

8 files changed

Lines changed: 443 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
# v2.0.21
2+
## 08/22/2026
3+
4+
1. [](#bugfix)
5+
* Form fields no longer print their HTML attributes as text above the field, a problem the Twig update in 2.0.20 introduced on every form [#4256](https://github.com/getgrav/grav/issues/4256)
6+
* A custom text escaper registered by a plugin now works again, instead of stopping the page with an error the first time a template used it
7+
18
# v2.0.20
29
## 08/21/2026
310

composer.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

system/defines.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
// Some standard defines
1111
define("GRAV", true);
12-
define("GRAV_VERSION", "2.0.20");
12+
define("GRAV_VERSION", "2.0.21");
1313
define("GRAV_SCHEMA", "1.8.0_2026-06-09_0");
1414
define("GRAV_TESTING", false);
1515

system/src/Grav/Common/Twig/Twig.php

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
namespace Grav\Common\Twig;
1111

12+
use Closure;
1213
use Grav\Common\Debugger;
1314
use Grav\Common\Grav;
1415
use Grav\Common\Config\Config;
@@ -28,6 +29,8 @@
2829
use Grav\Common\Utils;
2930
use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
3031
use RocketTheme\Toolbox\Event\Event;
32+
use ReflectionException;
33+
use ReflectionFunction;
3134
use RuntimeException;
3235
use Twig\Cache\FilesystemCache;
3336
use Twig\DeferredExtension\DeferredExtension;
@@ -891,14 +894,26 @@ public function setAutoescape($state)
891894
* - Twig 2.x/3.x (< 3.9): Uses EscaperExtension::setEscaper()
892895
* - Twig 3.9+: Uses EscaperRuntime::setEscaper()
893896
*
897+
* The callable may take either the historic Grav signature,
898+
* function($twig, $string, $charset), or the Twig 3.9+ runtime signature,
899+
* function($string, $charset). Both keep working wherever this runs.
900+
*
894901
* @param string $strategy The escaper strategy name (e.g., 'yaml', 'json')
895-
* @param callable $callable The escaper callable: function($twig, $string, $charset)
902+
* @param callable $callable The escaper callable
896903
* @return void
897904
*/
898905
public function setEscaper(string $strategy, callable $callable): void
899906
{
900-
// Twig 3.9+ moved setEscaper to EscaperRuntime
907+
// Twig 3.9+ moved setEscaper to EscaperRuntime, which calls the escaper
908+
// with ($string, $charset) and not the ($twig, $string, $charset) this
909+
// method has always documented. Adapt rather than break the callers.
901910
if (class_exists(EscaperRuntime::class)) {
911+
$twig = $this->twig;
912+
if ($this->escaperExpectsEnvironment($callable)) {
913+
$original = $callable;
914+
$callable = static fn ($string, $charset) => $original($twig, $string, $charset);
915+
}
916+
902917
$this->twig->getRuntime(EscaperRuntime::class)->setEscaper($strategy, $callable);
903918
return;
904919
}
@@ -913,4 +928,23 @@ public function setEscaper(string $strategy, callable $callable): void
913928
$this->twig->getExtension(CoreExtension::class)->setEscaper($strategy, $callable);
914929
}
915930

931+
/**
932+
* Whether an escaper callable takes the Twig environment as its first
933+
* argument. Anything declaring three or more parameters does; two-parameter
934+
* and variadic callables are passed straight through.
935+
*
936+
* @param callable $callable
937+
* @return bool
938+
*/
939+
protected function escaperExpectsEnvironment(callable $callable): bool
940+
{
941+
try {
942+
$reflection = new ReflectionFunction(Closure::fromCallable($callable));
943+
} catch (ReflectionException $e) {
944+
return false;
945+
}
946+
947+
return $reflection->getNumberOfParameters() >= 3;
948+
}
949+
916950
}

tests/unit/Grav/Common/Twig/DeferredExtensionTest.php

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,11 @@ public function set(string $t): string { $this->title = $t; return ''; }
125125

126126
public function testDeferredBlockInsideConditionalChildOverrideRenders(): void
127127
{
128-
// Exercises the Grav-specific Parser::filterBodyNodes() patch that
129-
// treats IfNode as "transparent" for block-definition nesting,
130-
// so that a child can re-declare a parent's block from inside {% if %}.
128+
// Exercises the Grav-specific Parser::cleanupTransparentBodyNodes()
129+
// patch that treats IfNode as "transparent" for block-definition
130+
// nesting, so that a child can re-declare a parent's block from
131+
// inside {% if %}. Here the `if` is one node among others at the root
132+
// of the child; see the sibling test for the whole-body shape.
131133
$parent = <<<'TWIG'
132134
<head>{% block head deferred %}default{% endblock %}</head>
133135
<body>{{ assets.add('/x.css') }}{% block body %}b{% endblock %}</body>
@@ -147,6 +149,43 @@ public function testDeferredBlockInsideConditionalChildOverrideRenders(): void
147149
$out = $env->render('child.twig');
148150

149151
self::assertStringContainsString('<links><link rel="stylesheet" href="/x.css"></links>', $out);
152+
// The definition must not ALSO render where it was declared.
153+
self::assertSame(1, substr_count($out, '<links>'));
154+
}
155+
156+
/**
157+
* Regression coverage for getgrav/grav#4256.
158+
*
159+
* The child wraps its whole body — the `extends` tag included — in a
160+
* single `{% if %}`, the shape the form plugin's field template uses. The
161+
* body cleanup used to miss that case, so the deferred definition was
162+
* rendered where it was declared as well as in the parent's slot, ahead of
163+
* even the `<head>` the parent opens with.
164+
*/
165+
public function testDeferredBlockUnderAnIfWrappingTheWholeChildBody(): void
166+
{
167+
$parent = <<<'TWIG'
168+
<head>{% block head deferred %}DEFAULT{% endblock %}</head><body>{{ assets.add('/x.css') }}{% block body %}b{% endblock %}</body>
169+
TWIG;
170+
171+
$child = <<<'TWIG'
172+
{% if true %}
173+
{% extends 'parent.twig' %}
174+
{% block head deferred %}<links>{{ assets.render() }}</links>{% endblock %}
175+
{% endif %}
176+
TWIG;
177+
178+
$env = $this->env(
179+
['parent.twig' => $parent, 'child.twig' => $child],
180+
['assets' => $this->assets()]
181+
);
182+
$out = $env->render('child.twig');
183+
184+
self::assertSame(
185+
'<head><links><link rel="stylesheet" href="/x.css"></links></head><body>b</body>',
186+
trim($out)
187+
);
188+
self::assertStringNotContainsString('DEFAULT', $out);
150189
}
151190

152191
public function testChildOverridesDeferredParentBlock(): void
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
<?php
2+
3+
use Twig\Environment;
4+
use Twig\Loader\ArrayLoader;
5+
6+
/**
7+
* Regression coverage for getgrav/grav#4256.
8+
*
9+
* Twig's own parser refuses a `block` definition nested under an `if`, so the
10+
* getgrav/Twig fork carries a patch that makes `if` transparent when a child
11+
* template's body is cleaned up. Grav themes and plugins rely on it: the form
12+
* plugin's forms/default/field.html.twig wraps its whole body — the `extends`
13+
* tag included — in a single `{% if not field.validate.ignore %}`.
14+
*
15+
* Upstream 3.27 replaced the recursive Parser::filterBodyNodes() with the flat
16+
* Parser::cleanupBodyForChildTemplates(), and re-homing the fork patch onto it
17+
* lost the case where that `if` IS the body rather than one node inside it. The
18+
* block definitions were then rendered in place as well as through the parent,
19+
* so every form field printed its attributes as text above the field.
20+
*
21+
* These tests pin the parser behaviour the fork has to keep providing. They are
22+
* deliberately free of Grav bootstrapping so they stay a reliable canary for the
23+
* vendored Twig itself.
24+
*/
25+
class TwigConditionalBlockTest extends \PHPUnit\Framework\TestCase
26+
{
27+
/**
28+
* @param array<string, string> $templates
29+
* @return string
30+
*/
31+
protected function render(array $templates, string $name = 'child.twig'): string
32+
{
33+
$twig = new Environment(new ArrayLoader($templates), [
34+
'autoescape' => 'html',
35+
'cache' => false,
36+
'debug' => true,
37+
]);
38+
39+
return $twig->render($name);
40+
}
41+
42+
/**
43+
* The whole child body is one `if`, with `extends` inside it. This is the
44+
* shape the form plugin uses, and the one #4256 broke.
45+
*/
46+
public function testBlocksUnderAnIfWrappingTheWholeBodyAreNotRenderedInPlace()
47+
{
48+
$output = $this->render([
49+
'layout.twig' => '<div {% block outer %}{% endblock %}><input {% block inner %}{% endblock %} /></div>',
50+
'child.twig' => <<<TWIG
51+
{% if true %}
52+
{% extends "layout.twig" %}
53+
{% block outer %}data-a="1"{% endblock %}
54+
{% block inner %}class="c"{% endblock %}
55+
{% endif %}
56+
TWIG,
57+
]);
58+
59+
$this->assertSame('<div data-a="1"><input class="c" /></div>', trim($output));
60+
}
61+
62+
/**
63+
* The same shape one level deeper: a grandchild overriding a block and
64+
* calling parent(), as forms/fields/text/text.html.twig does.
65+
*/
66+
public function testParentResolvesThroughAConditionalChildTemplate()
67+
{
68+
$output = $this->render([
69+
'layout.twig' => '<input {% block attrs %}{% endblock %} />',
70+
'middle.twig' => <<<TWIG
71+
{% if true %}
72+
{% extends "layout.twig" %}
73+
{% block attrs %}class="c"{% endblock %}
74+
{% endif %}
75+
TWIG,
76+
'child.twig' => <<<TWIG
77+
{% extends "middle.twig" %}
78+
{% block attrs %}type="text" {{ parent() }}{% endblock %}
79+
TWIG,
80+
]);
81+
82+
$this->assertSame('<input type="text" class="c" />', trim($output));
83+
}
84+
85+
/**
86+
* The condition only decides whether the definition also renders in place;
87+
* the override itself is registered at compile time either way. A false
88+
* condition must therefore still override the parent, and still not leak.
89+
*/
90+
public function testAFalseConditionStillRegistersTheOverrideWithoutRenderingInPlace()
91+
{
92+
$output = $this->render([
93+
'layout.twig' => '[{% block content %}DEFAULT{% endblock %}]',
94+
'child.twig' => <<<TWIG
95+
{% extends "layout.twig" %}
96+
{% if false %}
97+
{% block content %}OVERRIDE{% endblock %}
98+
{% endif %}
99+
TWIG,
100+
]);
101+
102+
$this->assertSame('[OVERRIDE]', trim($output));
103+
}
104+
105+
/**
106+
* The pre-existing case: the `if` is one node among others at the root of
107+
* the child. Guards against a fix for the above regressing this direction.
108+
*/
109+
public function testBlocksUnderAnIfAlongsideOtherRootNodesAreNotRenderedInPlace()
110+
{
111+
$output = $this->render([
112+
'layout.twig' => '[{% block first %}{% endblock %}][{% block second %}{% endblock %}]',
113+
'child.twig' => <<<TWIG
114+
{% extends "layout.twig" %}
115+
{% block first %}FOO{% endblock %}
116+
{% if true %}
117+
{% block second %}BAR{% endblock %}
118+
{% endif %}
119+
TWIG,
120+
]);
121+
122+
$this->assertSame('[FOO][BAR]', trim($output));
123+
}
124+
}

0 commit comments

Comments
 (0)