From 9116079e97198eda9612115359fbc6fbac79fefe Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Sun, 21 Sep 2025 11:40:23 -0600 Subject: [PATCH] twig3 compatiblity layer Signed-off-by: Andy Miller --- CHANGELOG.md | 2 + system/blueprints/config/system.yaml | 33 ++-- system/config/system.yaml | 3 +- system/src/Grav/Common/Page/Page.php | 2 +- .../Twig3CompatibilityLoader.php | 46 +++++ .../Twig3CompatibilityTransformer.php | 170 ++++++++++++++++++ system/src/Grav/Common/Twig/Twig.php | 14 +- .../Flex/Pages/Traits/PageLegacyTrait.php | 2 +- .../Installer/updates/1.7.0_2020-11-20_1.php | 2 +- .../Installer/updates/1.8.0_2025-09-21_0.php | 34 ++++ 10 files changed, 289 insertions(+), 19 deletions(-) create mode 100644 system/src/Grav/Common/Twig/Compatibility/Twig3CompatibilityLoader.php create mode 100644 system/src/Grav/Common/Twig/Compatibility/Twig3CompatibilityTransformer.php create mode 100644 system/src/Grav/Installer/updates/1.8.0_2025-09-21_0.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c7a8d4e6..c23cb050f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/system/blueprints/config/system.yaml b/system/blueprints/config/system.yaml index 39c2ae274..c409892f8 100644 --- a/system/blueprints/config/system.yaml +++ b/system/blueprints/config/system.yaml @@ -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 - - - diff --git a/system/config/system.yaml b/system/config/system.yaml index 3f4f49280..c4145df2b 100644 --- a/system/config/system.yaml +++ b/system/config/system.yaml @@ -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 diff --git a/system/src/Grav/Common/Page/Page.php b/system/src/Grav/Common/Page/Page.php index 327b42c61..da9542052 100644 --- a/system/src/Grav/Common/Page/Page.php +++ b/system/src/Grav/Common/Page/Page.php @@ -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', [])); diff --git a/system/src/Grav/Common/Twig/Compatibility/Twig3CompatibilityLoader.php b/system/src/Grav/Common/Twig/Compatibility/Twig3CompatibilityLoader.php new file mode 100644 index 000000000..724993daa --- /dev/null +++ b/system/src/Grav/Common/Twig/Compatibility/Twig3CompatibilityLoader.php @@ -0,0 +1,46 @@ +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); + } +} diff --git a/system/src/Grav/Common/Twig/Compatibility/Twig3CompatibilityTransformer.php b/system/src/Grav/Common/Twig/Compatibility/Twig3CompatibilityTransformer.php new file mode 100644 index 000000000..6e7988445 --- /dev/null +++ b/system/src/Grav/Common/Twig/Compatibility/Twig3CompatibilityTransformer.php @@ -0,0 +1,170 @@ +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 . ')'; + } +} diff --git a/system/src/Grav/Common/Twig/Twig.php b/system/src/Grav/Common/Twig/Twig.php index ca286f721..109a50025 100644 --- a/system/src/Grav/Common/Twig/Twig.php +++ b/system/src/Grav/Common/Twig/Twig.php @@ -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'); diff --git a/system/src/Grav/Framework/Flex/Pages/Traits/PageLegacyTrait.php b/system/src/Grav/Framework/Flex/Pages/Traits/PageLegacyTrait.php index dd9ce1513..b3668dcd5 100644 --- a/system/src/Grav/Framework/Flex/Pages/Traits/PageLegacyTrait.php +++ b/system/src/Grav/Framework/Flex/Pages/Traits/PageLegacyTrait.php @@ -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) { diff --git a/system/src/Grav/Installer/updates/1.7.0_2020-11-20_1.php b/system/src/Grav/Installer/updates/1.7.0_2020-11-20_1.php index 61206652b..1c5f0cf2b 100644 --- a/system/src/Grav/Installer/updates/1.7.0_2020-11-20_1.php +++ b/system/src/Grav/Installer/updates/1.7.0_2020-11-20_1.php @@ -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) { diff --git a/system/src/Grav/Installer/updates/1.8.0_2025-09-21_0.php b/system/src/Grav/Installer/updates/1.8.0_2025-09-21_0.php new file mode 100644 index 000000000..280c3f2b3 --- /dev/null +++ b/system/src/Grav/Installer/updates/1.8.0_2025-09-21_0.php @@ -0,0 +1,34 @@ + 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); + } + } +];