twig3 compatiblity layer

Signed-off-by: Andy Miller <rhuk@mac.com>
This commit is contained in:
Andy Miller
2025-09-21 11:40:23 -06:00
parent 51ddb3984c
commit 9116079e97
10 changed files with 289 additions and 19 deletions

View File

@@ -4,6 +4,8 @@
1. [](#bugfix)
* Deferred Extension support in Forked version of Twig 3
* Fix for cache blowing up when upgrading from 1.7 to 1.8
1. [](#new)
* Added separate `strict_mode.twig2_compat` and `strict_mode.twig3_compat` toggles to manage auto-escape behaviour and automatic Twig 3 template rewrites during upgrades
# v1.8.0-beta.4
## 01/27/2025

View File

@@ -775,8 +775,8 @@ form:
flex.cache.index.enabled:
type: toggle
label: PLUGIN_ADMIN.FLEX_INDEX_CACHE_ENABLED
highlight: 1
default: 1
highlight: 0
default: 0
options:
1: PLUGIN_ADMIN.ENABLED
0: PLUGIN_ADMIN.DISABLED
@@ -793,8 +793,8 @@ form:
flex.cache.object.enabled:
type: toggle
label: PLUGIN_ADMIN.FLEX_OBJECT_CACHE_ENABLED
highlight: 1
default: 1
highlight: 0
default: 0
options:
1: PLUGIN_ADMIN.ENABLED
0: PLUGIN_ADMIN.DISABLED
@@ -1756,8 +1756,8 @@ form:
http_x_forwarded.host:
type: toggle
label: HTTP_X_FORWARDED_HOST Enabled
highlight: 0
default: 0
highlight: 1
default: 1
options:
1: PLUGIN_ADMIN.YES
0: PLUGIN_ADMIN.NO
@@ -1790,8 +1790,8 @@ form:
strict_mode.blueprint_compat:
type: toggle
label: PLUGIN_ADMIN.STRICT_BLUEPRINT_COMPAT
highlight: 0
default: 0
highlight: 1
default: 1
help: PLUGIN_ADMIN.STRICT_BLUEPRINT_COMPAT_HELP
options:
1: PLUGIN_ADMIN.YES
@@ -1811,7 +1811,7 @@ form:
validate:
type: bool
strict_mode.twig_compat:
strict_mode.twig2_compat:
type: toggle
label: PLUGIN_ADMIN.STRICT_TWIG_COMPAT
highlight: 0
@@ -1823,6 +1823,18 @@ form:
validate:
type: bool
strict_mode.twig3_compat:
type: toggle
label: Twig 3 Compatibility
highlight: 0
default: 0
help: Enable automatic rewrites for legacy Twig 1/2 syntax that breaks on Twig 3 (e.g. `for ... if ...` guards)
options:
1: PLUGIN_ADMIN.YES
0: PLUGIN_ADMIN.NO
validate:
type: bool
accounts:
type: tab
@@ -1885,6 +1897,3 @@ form:
#
# pages.type:
# type: hidden

View File

@@ -230,5 +230,6 @@ flex:
strict_mode:
yaml_compat: false # Set to true to enable YAML backwards compatibility
twig_compat: false # Set to true to enable deprecated Twig settings (autoescape: false)
twig2_compat: false # Set to true to enable deprecated Twig settings (autoescape: false)
twig3_compat: false # Set to true to enable automatic fixes for Twig 3 syntax changes
blueprint_compat: false # Set to true to enable backward compatible strict support for blueprints

View File

@@ -1739,7 +1739,7 @@ class Page implements PageInterface
$config = Grav::instance()['config'];
$escape = !$config->get('system.strict_mode.twig_compat', false) || $config->get('system.twig.autoescape', true);
$escape = !$config->get('system.strict_mode.twig2_compat', false) || $config->get('system.twig.autoescape', true);
// Get initial metadata for the page
$metadata = array_merge($metadata, $config->get('site.metadata', []));

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Grav\Common\Twig\Compatibility;
use Twig\Loader\LoaderInterface;
use Twig\Source;
/**
* Decorates the active Twig loader to rewrite legacy Twig 1/2 constructs on the fly.
*/
class Twig3CompatibilityLoader implements LoaderInterface
{
public function __construct(
private readonly LoaderInterface $inner,
private readonly Twig3CompatibilityTransformer $transformer
) {
}
public function getSourceContext(string $name): Source
{
$source = $this->inner->getSourceContext($name);
return new Source(
$this->transformer->transform($source->getCode()),
$source->getName(),
$source->getPath()
);
}
public function exists(string $name): bool
{
return $this->inner->exists($name);
}
public function getCacheKey(string $name): string
{
return $this->inner->getCacheKey($name);
}
public function isFresh(string $name, int $time): bool
{
return $this->inner->isFresh($name, $time);
}
}

View File

@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace Grav\Common\Twig\Compatibility;
/**
* Applies automatic rewrites that help legacy Twig 1/2 templates compile under Twig 3.
*/
class Twig3CompatibilityTransformer
{
/**
* Transform raw Twig source code.
*/
public function transform(string $code): string
{
$code = $this->rewriteForLoopGuards($code);
$code = $this->rewriteSpacelessBlocks($code);
$code = $this->rewriteFilterBlocks($code);
$code = $this->rewriteSameAsTests($code);
$code = $this->rewriteReplaceFilterSignatures($code);
return $code;
}
/**
* Convert legacy "{% for ... if ... %}" guard syntax to a Twig 3 friendly form that
* filters the sequence before iteration.
*/
private function rewriteForLoopGuards(string $code): string
{
$pattern = '/(\{%-?\s*for\s+)(.+?)(\s*-?%\})/s';
return (string) preg_replace_callback($pattern, function (array $matches) {
$clause = $matches[2];
// Find the last " if " (including leading whitespace) to reduce false positives
if (!preg_match_all('/\sif\s+/i', $clause, $ifs, PREG_OFFSET_CAPTURE)) {
return $matches[0];
}
$lastIf = end($ifs[0]);
if ($lastIf === false) {
return $matches[0];
}
$ifPos = (int) $lastIf[1];
$ifLength = strlen($lastIf[0]);
$head = trim(substr($clause, 0, $ifPos));
$condition = trim(substr($clause, $ifPos + $ifLength));
if ($head === '' || $condition === '') {
return $matches[0];
}
if (!preg_match('/^(.*)\s+in\s+(.*)$/is', $head, $parts)) {
return $matches[0];
}
$targetSpec = trim($parts[1]);
$sequence = trim($parts[2]);
if ($targetSpec === '' || $sequence === '') {
return $matches[0];
}
$targets = array_map(static fn (string $value): string => trim($value), explode(',', $targetSpec));
if (count($targets) === 1) {
$arrow = sprintf('%s => %s', $targets[0], $condition);
} elseif (count($targets) === 2) {
[$keyVar, $valueVar] = $targets;
if ($valueVar === '') {
return $matches[0];
}
$arrow = sprintf('(%s, %s) => %s', $valueVar, $keyVar, $condition);
} else {
// Unsupported target list: fall back to the original clause.
return $matches[0];
}
$sequence = $this->ensureWrapped($sequence);
$rewrittenClause = sprintf('%s in %s|filter(%s)', $targetSpec, $sequence, $arrow);
return $matches[1] . $rewrittenClause . $matches[3];
}, $code);
}
private function rewriteSpacelessBlocks(string $code): string
{
$openPattern = '/\{%(\-?)\s*spaceless\s*(\-?)%\}/i';
$code = (string) preg_replace_callback($openPattern, static function (array $matches): string {
$leading = $matches[1] === '-' ? '-' : '';
$trailing = $matches[2] === '-' ? '-' : '';
return '{%' . $leading . ' apply spaceless ' . $trailing . '%}';
}, $code);
$closePattern = '/\{%(\-?)\s*endspaceless\s*(\-?)%\}/i';
return (string) preg_replace_callback($closePattern, static function (array $matches): string {
$leading = $matches[1] === '-' ? '-' : '';
$trailing = $matches[2] === '-' ? '-' : '';
return '{%' . $leading . ' endapply ' . $trailing . '%}';
}, $code);
}
private function rewriteFilterBlocks(string $code): string
{
$openPattern = '/\{%(\-?)\s*filter\s+(.+?)\s*(\-?)%\}/i';
$code = (string) preg_replace_callback($openPattern, static function (array $matches): string {
$leading = $matches[1] === '-' ? '-' : '';
$expression = trim($matches[2]);
$trailing = $matches[3] === '-' ? '-' : '';
if ($expression === '') {
return $matches[0];
}
return '{%' . $leading . ' apply ' . $expression . ' ' . $trailing . '%}';
}, $code);
$closePattern = '/\{%(\-?)\s*endfilter\s*(\-?)%\}/i';
return (string) preg_replace_callback($closePattern, static function (array $matches): string {
$leading = $matches[1] === '-' ? '-' : '';
$trailing = $matches[2] === '-' ? '-' : '';
return '{%' . $leading . ' endapply ' . $trailing . '%}';
}, $code);
}
private function rewriteSameAsTests(string $code): string
{
$pattern = '/\bis\s+sameas(\s*\()/i';
return (string) preg_replace($pattern, 'is same as$1', $code);
}
private function rewriteReplaceFilterSignatures(string $code): string
{
$pattern = '/\|replace\(\s*(["\])(.*?)\1\s*,\s*(["\])(.*?)\3\s*\)/';
$code = (string) preg_replace_callback($pattern, static function (array $matches): string {
$keyQuote = $matches[1];
$key = $matches[2];
$valueQuote = $matches[3];
$value = $matches[4];
return sprintf('|replace({%1$s%2$s%1$s: %3$s%4$s%3$s})', $keyQuote, $key, $valueQuote, $value);
}, $code);
return $code;
}
private function ensureWrapped(string $expression): string
{
$trimmed = trim($expression);
if ($trimmed === '') {
return $expression;
}
$startsWithParen = str_starts_with($trimmed, '(') && str_ends_with($trimmed, ')');
return $startsWithParen ? $expression : '(' . $expression . ')';
}
}

View File

@@ -17,6 +17,8 @@ use Grav\Common\Language\LanguageCodes;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Page\Pages;
use Grav\Common\Security;
use Grav\Common\Twig\Compatibility\Twig3CompatibilityLoader;
use Grav\Common\Twig\Compatibility\Twig3CompatibilityTransformer;
use Grav\Common\Twig\Exception\TwigException;
use Grav\Common\Twig\Extension\FilesystemExtension;
use Grav\Common\Twig\Extension\GravExtension;
@@ -163,13 +165,19 @@ class Twig
$this->loaderArray = new ArrayLoader([]);
$loader_chain = new ChainLoader([$this->loaderArray, $this->loader]);
$activeLoader = $loader_chain;
if ($config->get('system.strict_mode.twig3_compat', false)) {
$transformer = new Twig3CompatibilityTransformer();
$activeLoader = new Twig3CompatibilityLoader($loader_chain, $transformer);
}
$params = $config->get('system.twig');
if (!empty($params['cache'])) {
$cachePath = $locator->findResource('cache://twig', true, true);
$params['cache'] = new FilesystemCache($cachePath, FilesystemCache::FORCE_BYTECODE_INVALIDATION);
}
if (!$config->get('system.strict_mode.twig_compat', false)) {
if (!$config->get('system.strict_mode.twig2_compat', false)) {
// Force autoescape on for all files if in strict mode.
$params['autoescape'] = 'html';
} elseif (!empty($this->autoescape)) {
@@ -177,10 +185,10 @@ class Twig
}
if (empty($params['autoescape'])) {
user_error('Grav 2.0 will have Twig auto-escaping forced on (can be emulated by turning off \'system.strict_mode.twig_compat\' setting in your configuration)', E_USER_DEPRECATED);
user_error('Grav 2.0 will have Twig auto-escaping forced on (can be emulated by turning off \'system.strict_mode.twig2_compat\' setting in your configuration)', E_USER_DEPRECATED);
}
$this->twig = new TwigEnvironment($loader_chain, $params);
$this->twig = new TwigEnvironment($activeLoader, $params);
$this->twig->registerUndefinedFunctionCallback(function (string $name) use ($config) {
$allowed = $config->get('system.twig.safe_functions');

View File

@@ -630,7 +630,7 @@ trait PageLegacyTrait
$metadata = array_merge($defaultMetadata, $siteMetadata, $headerMetadata);
$header_tag_http_equivs = ['content-type', 'default-style', 'refresh', 'x-ua-compatible', 'content-security-policy'];
$escape = !$config->get('system.strict_mode.twig_compat', false) || $config->get('system.twig.autoescape', true);
$escape = !$config->get('system.strict_mode.twig2_compat', false) || $config->get('system.twig.autoescape', true);
// Build an array of meta objects..
foreach ($metadata as $key => $value) {

View File

@@ -14,7 +14,7 @@ return [
$yaml = YamlUpdater::instance(GRAV_ROOT . '/user/config/system.yaml');
$yaml->define('twig.autoescape', false);
$yaml->define('strict_mode.yaml_compat', true);
$yaml->define('strict_mode.twig_compat', true);
$yaml->define('strict_mode.twig2_compat', true);
$yaml->define('strict_mode.blueprint_compat', true);
$yaml->save();
} catch (\Exception $e) {

View File

@@ -0,0 +1,34 @@
<?php
use Grav\Installer\InstallException;
use Grav\Installer\VersionUpdate;
use Grav\Installer\YamlUpdater;
return [
'preflight' => null,
'postflight' =>
function () {
/** @var VersionUpdate $this */
try {
$yaml = YamlUpdater::instance(GRAV_ROOT . '/user/config/system.yaml');
if ($yaml->exists('strict_mode.twig_compat')) {
$value = $yaml->get('strict_mode.twig_compat');
$yaml->undefine('strict_mode.twig_compat');
$yaml->define('strict_mode.twig2_compat', $value);
}
if (!$yaml->exists('strict_mode.twig2_compat')) {
$yaml->define('strict_mode.twig2_compat', false);
}
if (!$yaml->exists('strict_mode.twig3_compat')) {
$yaml->define('strict_mode.twig3_compat', true);
}
$yaml->save();
} catch (\Exception $e) {
throw new InstallException('Could not update system configuration for Twig compatibility settings', $e);
}
}
];