mirror of
https://github.com/getgrav/grav.git
synced 2026-07-04 13:38:07 +02:00
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', []));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 . ')';
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
34
system/src/Grav/Installer/updates/1.8.0_2025-09-21_0.php
Normal file
34
system/src/Grav/Installer/updates/1.8.0_2025-09-21_0.php
Normal 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);
|
||||
}
|
||||
}
|
||||
];
|
||||
Reference in New Issue
Block a user