Skip to content

Commit d22010a

Browse files
committed
AC-2461: Implement email and newsletter template compatibility CLI check
1 parent fb82ada commit d22010a

File tree

5 files changed

+437
-0
lines changed

5 files changed

+437
-0
lines changed
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php
2+
/**
3+
* Copyright © Magento, Inc. All rights reserved.
4+
* See COPYING.txt for license details.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\Email\Console\Command;
9+
10+
use Magento\Email\Model\ResourceModel\Template\Collection;
11+
use Magento\Email\Model\ResourceModel\Template\CollectionFactory;
12+
use Magento\Email\Model\Template;
13+
use Magento\Email\Model\Template\CompatibilityChecker;
14+
use Symfony\Component\Console\Command\Command;
15+
use Symfony\Component\Console\Input\InputInterface;
16+
use Symfony\Component\Console\Output\OutputInterface;
17+
use Magento\Framework\Console\Cli;
18+
19+
/**
20+
* Scan DB templates for directive incompatibilities
21+
*/
22+
class DbTemplateCheckCommand extends Command
23+
{
24+
/**
25+
* @var CollectionFactory
26+
*/
27+
private CollectionFactory $templateCollection;
28+
29+
/**
30+
* @var CompatibilityChecker
31+
*/
32+
private CompatibilityChecker $compatibilityChecker;
33+
34+
public function __construct(
35+
CollectionFactory $templateCollection,
36+
CompatibilityChecker $compatibilityChecker,
37+
string $name = null
38+
) {
39+
parent::__construct($name);
40+
$this->templateCollection = $templateCollection;
41+
$this->compatibilityChecker = $compatibilityChecker;
42+
}
43+
44+
/**
45+
* @inheritdoc
46+
*/
47+
protected function configure()
48+
{
49+
$this->setName('setup:email-override-compatibility-check')
50+
->setDescription('Scans email template overrides for potential compatibility issues');
51+
52+
parent::configure();
53+
}
54+
55+
/**
56+
* Executes compatibility checker command
57+
*
58+
* @param InputInterface $input
59+
* @param OutputInterface $output
60+
* @return int|null
61+
*/
62+
protected function execute(InputInterface $input, OutputInterface $output)
63+
{
64+
/** @var Collection $collection */
65+
$collection = $this->templateCollection->create();
66+
$collection->load();
67+
68+
$hasErrors = false;
69+
foreach ($collection as $template) {
70+
/** @var Template $template */
71+
$errors = $this->compatibilityChecker->getCompatibilityIssues($template->getTemplateText());
72+
if (!empty($errors)) {
73+
$hasErrors = true;
74+
$templateName = $template->getTemplateCode();
75+
$output->writeln(
76+
'<error>Template "' . $templateName . '" has the following compatibility issues:</error>'
77+
);
78+
$this->renderErrors($output, $errors);
79+
}
80+
$errors = $this->compatibilityChecker->getCompatibilityIssues($template->getTemplateSubject());
81+
if (!empty($errors)) {
82+
$hasErrors = true;
83+
$templateName = $template->getTemplateCode();
84+
$output->writeln(
85+
'<error>Template "' . $templateName . '" subject has the following compatibility issues:</error>'
86+
);
87+
$this->renderErrors($output, $errors);
88+
}
89+
}
90+
91+
if (!$hasErrors) {
92+
$output->writeln('<info>No errors detected</info>');
93+
}
94+
return $hasErrors ? Cli::RETURN_FAILURE : Cli::RETURN_SUCCESS;
95+
}
96+
97+
/**
98+
* Render given errors
99+
*
100+
* @param OutputInterface $output
101+
* @param array $errors
102+
*/
103+
private function renderErrors(OutputInterface $output, array $errors): void
104+
{
105+
foreach ($errors as $error) {
106+
$error = str_replace(PHP_EOL, PHP_EOL . ' ', $error);
107+
$output->writeln(
108+
'<error> - ' . $error . '</error>'
109+
);
110+
}
111+
$output->writeln('');
112+
}
113+
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
<?php
2+
/**
3+
* Copyright © Magento, Inc. All rights reserved.
4+
* See COPYING.txt for license details.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\Email\Model\Template;
9+
10+
use Magento\Framework\Filter\Template\Tokenizer\Parameter;
11+
use Magento\Framework\Filter\Template\Tokenizer\Variable;
12+
13+
/**
14+
* Scan an email template for compatibility with the strict resolver
15+
*/
16+
class CompatibilityChecker
17+
{
18+
private const CONSTRUCTION_DEPEND_PATTERN = '/{{depend\s*(.*?)}}(.*?){{\\/depend\s*}}/si';
19+
private const CONSTRUCTION_IF_PATTERN = '/{{if\s*(.*?)}}(.*?)({{else}}(.*?))?{{\\/if\s*}}/si';
20+
private const LOOP_PATTERN = '/{{for(?P<loopItem>.*? )(in)(?P<loopData>.*?)}}(?P<loopBody>.*?){{\/for}}/si';
21+
private const CONSTRUCTION_PATTERN = '/{{([a-z]{0,10})(.*?)}}(?:(.*?)(?:{{\/(?:\\1)}}))?/si';
22+
23+
/**
24+
* @var array
25+
*/
26+
private array $errors = [];
27+
28+
/**
29+
* @var Variable
30+
*/
31+
private Variable $variableTokenizer;
32+
33+
/**
34+
* @var Parameter
35+
*/
36+
private Parameter $parameterTokenizer;
37+
38+
public function __construct(Variable $variableTokenizer, Parameter $parameterTokenizer)
39+
{
40+
$this->variableTokenizer = $variableTokenizer;
41+
$this->parameterTokenizer = $parameterTokenizer;
42+
}
43+
44+
/**
45+
* Detect invalid usage of template filter directives
46+
*
47+
*/
48+
public function getCompatibilityIssues(string $template): array
49+
{
50+
$this->errors = [];
51+
52+
if (empty($template)) {
53+
return [];
54+
}
55+
56+
$template = $this->processIfDirectives($template);
57+
$template = $this->processDependDirectives($template);
58+
$template = $this->processForDirectives($template);
59+
$this->processVarDirectivesAndParams($template);
60+
61+
return $this->errors;
62+
}
63+
64+
/**
65+
* Process the {{if}} directives in the file
66+
*
67+
* @param string $html
68+
* @return string The processed template
69+
*/
70+
private function processIfDirectives(string $html): string
71+
{
72+
if (preg_match_all(self::CONSTRUCTION_IF_PATTERN, $html, $constructions, PREG_SET_ORDER)) {
73+
foreach ($constructions as $construction) {
74+
// validate {{if <var>}}
75+
$this->validateVariableUsage($construction[1]);
76+
$html = str_replace($construction[0], $construction[2] . ($construction[4] ?? ''), $html);
77+
}
78+
}
79+
80+
return $html;
81+
}
82+
83+
/**
84+
* Process the {{depend}} directives in the file
85+
*
86+
* @param string $html
87+
* @return string The processed template
88+
*/
89+
private function processDependDirectives(string $html): string
90+
{
91+
if (preg_match_all(self::CONSTRUCTION_DEPEND_PATTERN, $html, $constructions, PREG_SET_ORDER)) {
92+
foreach ($constructions as $construction) {
93+
// validate {{depend <var>}}
94+
$this->validateVariableUsage($construction[1]);
95+
$html = str_replace($construction[0], $construction[2], $html);
96+
}
97+
}
98+
99+
return $html;
100+
}
101+
102+
/**
103+
* Process the {{for}} directives in the file
104+
*
105+
* @param string $html
106+
* @return string The processed template
107+
*/
108+
private function processForDirectives(string $html): string
109+
{
110+
if (preg_match_all(self::LOOP_PATTERN, $html, $constructions, PREG_SET_ORDER)) {
111+
foreach ($constructions as $construction) {
112+
// validate {{for in <var>}}
113+
$this->validateVariableUsage($construction['loopData']);
114+
$html = str_replace($construction[0], $construction['loopBody'], $html);
115+
}
116+
}
117+
118+
return $html;
119+
}
120+
121+
/**
122+
* Process the all var directives and var directive params in the file
123+
*
124+
* @param string $html
125+
* @return string The processed template
126+
*/
127+
private function processVarDirectivesAndParams(string $html): string
128+
{
129+
if (preg_match_all(self::CONSTRUCTION_PATTERN, $html, $constructions, PREG_SET_ORDER)) {
130+
foreach ($constructions as $construction) {
131+
if (empty($construction[2])) {
132+
continue;
133+
}
134+
135+
if ($construction[1] === 'var') {
136+
$this->validateVariableUsage($construction[2]);
137+
} else {
138+
$this->validateDirectiveBody($construction[2]);
139+
}
140+
}
141+
}
142+
143+
return $html;
144+
}
145+
146+
/**
147+
* Validate directive body is valid. e.g. {{somedir <directive body>}}
148+
*
149+
* @param string $body
150+
*/
151+
private function validateDirectiveBody(string $body): void
152+
{
153+
$this->parameterTokenizer->setString($body);
154+
$params = $this->parameterTokenizer->tokenize();
155+
156+
foreach ($params as $param) {
157+
if (substr($param, 0, 1) === '$') {
158+
$this->validateVariableUsage(substr($param, 1));
159+
}
160+
}
161+
}
162+
163+
/**
164+
* Validate directive variable usage is valid. e.g. {{var <variable body>}} or {{somedir some_param="$foo.bar()"}}
165+
*
166+
* @param string $body
167+
*/
168+
private function validateVariableUsage(string $body): void
169+
{
170+
$this->variableTokenizer->setString($body);
171+
$stack = $this->variableTokenizer->tokenize();
172+
173+
if (empty($stack)) {
174+
return;
175+
}
176+
177+
foreach ($stack as $token) {
178+
// As a static analyzer there are no data types to know if this is a DataObject so allow all get* methods
179+
if ($token['type'] === 'method' && substr($token['name'], 0, 3) !== 'get') {
180+
$this->addError(
181+
'Template directives may not invoke methods. Only scalar array access is allowed.' . PHP_EOL
182+
. 'Found "' . trim($body) . '"'
183+
);
184+
}
185+
}
186+
}
187+
188+
/**
189+
* Add an error to the current processing template
190+
*
191+
* @param string $error
192+
*/
193+
private function addError(string $error): void
194+
{
195+
$this->errors[] = $error;
196+
}
197+
}

app/code/Magento/Email/etc/di.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,4 +93,11 @@
9393
</argument>
9494
</arguments>
9595
</type>
96+
<type name="Magento\Framework\Console\CommandListInterface">
97+
<arguments>
98+
<argument name="commands" xsi:type="array">
99+
<item name="emailTemplateCheck" xsi:type="object">Magento\Email\Console\Command\DbTemplateCheckCommand</item>
100+
</argument>
101+
</arguments>
102+
</type>
96103
</config>

0 commit comments

Comments
 (0)