diff --git a/patches.json b/patches.json index c5e1448..34dbd5f 100644 --- a/patches.json +++ b/patches.json @@ -203,8 +203,10 @@ ">=2.3.2 <2.3.4": "MAGECLOUD-3392__reduce_q-ty_of_error_report_files__2.3.2.patch" }, "Fix pagebuilder module": { - "2.3.1": "MDVA-22979__fix_pagebuilder_module__2.3.1.patch", - "2.3.2": "MDVA-22979__fix_pagebuilder_module__2.3.2.patch" + "2.3.1": "PB-319__fix_pagebuilder_module__2.3.1.patch", + "2.3.2": "PB-320__fix_pagebuilder_module__2.3.2.patch", + ">=2.3.2-p1 <2.3.3": "PB-322__fix_pagebuilder_module__2.3.2-p1.patch", + "2.3.3": "PB-323__fix_pagebuilder_module__2.3.3.patch" }, "Fix XSS in order history": { "2.2.0 - 2.2.6": "PRODSECBUG-2233__fix_xss_in_order_history__2.2.0.patch", diff --git a/patches/MDVA-22979__fix_pagebuilder_module__2.3.1.patch b/patches/MDVA-22979__fix_pagebuilder_module__2.3.1.patch deleted file mode 100644 index 839f713..0000000 --- a/patches/MDVA-22979__fix_pagebuilder_module__2.3.1.patch +++ /dev/null @@ -1,172 +0,0 @@ -diff -Nuar a/vendor/magento/module-email/Block/Adminhtml/Template/Preview.php b/vendor/magento/module-email/Block/Adminhtml/Template/Preview.php ---- a/vendor/magento/module-email/Block/Adminhtml/Template/Preview.php -+++ b/vendor/magento/module-email/Block/Adminhtml/Template/Preview.php -@@ -53,19 +53,26 @@ class Preview extends \Magento\Backend\Block\Widget - * Prepare html output - * - * @return string -+ * @throws \Magento\Framework\Exception\LocalizedException - */ - protected function _toHtml() - { -+ $request = $this->getRequest(); -+ -+ if (!$request instanceof \Magento\Framework\App\RequestSafetyInterface || !$request->isSafeMethod()) { -+ throw new \Magento\Framework\Exception\LocalizedException(__('Wrong request.')); -+ } -+ - $storeId = $this->getAnyStoreView()->getId(); - /** @var $template \Magento\Email\Model\Template */ - $template = $this->_emailFactory->create(); - -- if ($id = (int)$this->getRequest()->getParam('id')) { -+ if ($id = (int)$request->getParam('id')) { - $template->load($id); - } else { -- $template->setTemplateType($this->getRequest()->getParam('type')); -- $template->setTemplateText($this->getRequest()->getParam('text')); -- $template->setTemplateStyles($this->getRequest()->getParam('styles')); -+ $template->setTemplateType($request->getParam('type')); -+ $template->setTemplateText($request->getParam('text')); -+ $template->setTemplateStyles($request->getParam('styles')); - } - - $template->setTemplateText($this->_maliciousCode->filter($template->getTemplateText())); -diff -Nuar a/vendor/magento/module-page-builder/Controller/ContentType/Preview.php b/vendor/magento/module-page-builder/Controller/ContentType/Preview.php ---- a/vendor/magento/module-page-builder/Controller/ContentType/Preview.php -+++ b/vendor/magento/module-page-builder/Controller/ContentType/Preview.php -@@ -26,19 +26,28 @@ class Preview extends \Magento\Framework\App\Action\Action implements HttpPostAc - */ - private $rendererPool; - -+ /** -+ * @var \Magento\Backend\Model\Auth -+ */ -+ private $auth; -+ - /** - * Constructor - * - * @param \Magento\Backend\App\Action\Context $context - * @param \Magento\PageBuilder\Model\Stage\RendererPool $rendererPool -+ * @param \Magento\Backend\Model\Auth $auth - */ - public function __construct( - \Magento\Backend\App\Action\Context $context, -- \Magento\PageBuilder\Model\Stage\RendererPool $rendererPool -+ \Magento\PageBuilder\Model\Stage\RendererPool $rendererPool, -+ \Magento\Backend\Model\Auth $auth = null - ) { - parent::__construct($context); - - $this->rendererPool = $rendererPool; -+ $this->auth = $auth ?? \Magento\Framework\App\ObjectManager::getInstance() -+ ->get(\Magento\Backend\Model\Auth::class); - } - - /** -@@ -48,14 +57,18 @@ class Preview extends \Magento\Framework\App\Action\Action implements HttpPostAc - */ - public function execute() - { -- $pageResult = $this->resultFactory->create(ResultFactory::TYPE_PAGE); -- // Some template filters and directive processors expect this to be called in order to function. -- $pageResult->initLayout(); -+ if ($this->auth->isLoggedIn()) { -+ $pageResult = $this->resultFactory->create(ResultFactory::TYPE_PAGE); -+ // Some template filters and directive processors expect this to be called in order to function. -+ $pageResult->initLayout(); -+ -+ $params = $this->getRequest()->getParams(); -+ $renderer = $this->rendererPool->getRenderer($params['role']); -+ $result = ['data' => $renderer->render($params)]; - -- $params = $this->getRequest()->getParams(); -- $renderer = $this->rendererPool->getRenderer($params['role']); -- $result = ['data' => $renderer->render($params)]; -+ return $this->resultFactory->create(ResultFactory::TYPE_JSON)->setData($result); -+ } - -- return $this->resultFactory->create(ResultFactory::TYPE_JSON)->setData($result); -+ $this->_forward('noroute'); - } - } -diff -Nuar a/vendor/magento/module-page-builder/Model/Stage/Config.php b/vendor/magento/module-page-builder/Model/Stage/Config.php ---- a/vendor/magento/module-page-builder/Model/Stage/Config.php -+++ b/vendor/magento/module-page-builder/Model/Stage/Config.php -@@ -135,7 +135,9 @@ class Config - 'content_types' => $this->getContentTypes(), - 'stage_config' => $this->data, - 'media_url' => $this->urlBuilder->getBaseUrl(['_type' => UrlInterface::URL_TYPE_MEDIA]), -- 'preview_url' => $this->frontendUrlBuilder->getUrl('pagebuilder/contenttype/preview'), -+ 'preview_url' => $this->frontendUrlBuilder -+ ->addSessionParam() -+ ->getUrl('pagebuilder/contenttype/preview'), - 'column_grid_default' => $this->scopeConfig->getValue(self::XML_PATH_COLUMN_GRID_DEFAULT), - 'column_grid_max' => $this->scopeConfig->getValue(self::XML_PATH_COLUMN_GRID_MAX), - 'can_use_inline_editing_on_stage' => $this->isWysiwygProvisionedForEditingOnStage(), -diff -Nuar a/vendor/magento/module-page-builder/Plugin/Framework/Session/SidResolver.php b/vendor/magento/module-page-builder/Plugin/Framework/Session/SidResolver.php -new file mode 100644 ---- /dev/null -+++ b/vendor/magento/module-page-builder/Plugin/Framework/Session/SidResolver.php -@@ -0,0 +1,49 @@ -+request = $request; -+ } -+ -+ /** -+ * Get Sid for pagebuilder preview -+ * -+ * @param \Magento\Framework\Session\SidResolver $subject -+ * @param string|null $result -+ * @param \Magento\Framework\Session\SessionManagerInterface $session -+ * -+ * @return string|null -+ */ -+ public function afterGetSid( -+ \Magento\Framework\Session\SidResolver $subject, -+ $result, -+ \Magento\Framework\Session\SessionManagerInterface $session -+ ) { -+ if (strpos($this->request->getPathInfo(), '/pagebuilder/contenttype/preview') === 0) { -+ return $this->request->getQuery( -+ $subject->getSessionIdQueryParam($session) -+ ); -+ } -+ -+ return $result; -+ } -+} -diff -Nuar a/vendor/magento/module-page-builder/etc/di.xml b/vendor/magento/module-page-builder/etc/di.xml ---- a/vendor/magento/module-page-builder/etc/di.xml -+++ b/vendor/magento/module-page-builder/etc/di.xml -@@ -140,4 +140,7 @@ - - - -+ -+ -+ - diff --git a/patches/MDVA-22979__fix_pagebuilder_module__2.3.2.patch b/patches/MDVA-22979__fix_pagebuilder_module__2.3.2.patch deleted file mode 100644 index 97b68b8..0000000 --- a/patches/MDVA-22979__fix_pagebuilder_module__2.3.2.patch +++ /dev/null @@ -1,172 +0,0 @@ -diff -Nuar a/vendor/magento/module-email/Block/Adminhtml/Template/Preview.php b/vendor/magento/module-email/Block/Adminhtml/Template/Preview.php ---- a/vendor/magento/module-email/Block/Adminhtml/Template/Preview.php -+++ b/vendor/magento/module-email/Block/Adminhtml/Template/Preview.php -@@ -55,19 +55,26 @@ class Preview extends \Magento\Backend\Block\Widget - * Prepare html output - * - * @return string -+ * @throws \Magento\Framework\Exception\LocalizedException - */ - protected function _toHtml() - { -+ $request = $this->getRequest(); -+ -+ if (!$request instanceof \Magento\Framework\App\RequestSafetyInterface || !$request->isSafeMethod()) { -+ throw new \Magento\Framework\Exception\LocalizedException(__('Wrong request.')); -+ } -+ - $storeId = $this->getAnyStoreView()->getId(); - /** @var $template \Magento\Email\Model\Template */ - $template = $this->_emailFactory->create(); - -- if ($id = (int)$this->getRequest()->getParam('id')) { -+ if ($id = (int)$request->getParam('id')) { - $template->load($id); - } else { -- $template->setTemplateType($this->getRequest()->getParam('type')); -- $template->setTemplateText($this->getRequest()->getParam('text')); -- $template->setTemplateStyles($this->getRequest()->getParam('styles')); -+ $template->setTemplateType($request->getParam('type')); -+ $template->setTemplateText($request->getParam('text')); -+ $template->setTemplateStyles($request->getParam('styles')); - } - - \Magento\Framework\Profiler::start($this->profilerName); -diff -Nuar a/vendor/magento/module-page-builder/Controller/ContentType/Preview.php b/vendor/magento/module-page-builder/Controller/ContentType/Preview.php ---- a/vendor/magento/module-page-builder/Controller/ContentType/Preview.php -+++ b/vendor/magento/module-page-builder/Controller/ContentType/Preview.php -@@ -26,19 +26,28 @@ class Preview extends \Magento\Framework\App\Action\Action implements HttpPostAc - */ - private $rendererPool; - -+ /** -+ * @var \Magento\Backend\Model\Auth -+ */ -+ private $auth; -+ - /** - * Constructor - * - * @param \Magento\Backend\App\Action\Context $context - * @param \Magento\PageBuilder\Model\Stage\RendererPool $rendererPool -+ * @param \Magento\Backend\Model\Auth $auth - */ - public function __construct( - \Magento\Backend\App\Action\Context $context, -- \Magento\PageBuilder\Model\Stage\RendererPool $rendererPool -+ \Magento\PageBuilder\Model\Stage\RendererPool $rendererPool, -+ \Magento\Backend\Model\Auth $auth = null - ) { - parent::__construct($context); - - $this->rendererPool = $rendererPool; -+ $this->auth = $auth ?? \Magento\Framework\App\ObjectManager::getInstance() -+ ->get(\Magento\Backend\Model\Auth::class); - } - - /** -@@ -48,14 +57,18 @@ class Preview extends \Magento\Framework\App\Action\Action implements HttpPostAc - */ - public function execute() - { -- $pageResult = $this->resultFactory->create(ResultFactory::TYPE_PAGE); -- // Some template filters and directive processors expect this to be called in order to function. -- $pageResult->initLayout(); -+ if ($this->auth->isLoggedIn()) { -+ $pageResult = $this->resultFactory->create(ResultFactory::TYPE_PAGE); -+ // Some template filters and directive processors expect this to be called in order to function. -+ $pageResult->initLayout(); -+ -+ $params = $this->getRequest()->getParams(); -+ $renderer = $this->rendererPool->getRenderer($params['role']); -+ $result = ['data' => $renderer->render($params)]; - -- $params = $this->getRequest()->getParams(); -- $renderer = $this->rendererPool->getRenderer($params['role']); -- $result = ['data' => $renderer->render($params)]; -+ return $this->resultFactory->create(ResultFactory::TYPE_JSON)->setData($result); -+ } - -- return $this->resultFactory->create(ResultFactory::TYPE_JSON)->setData($result); -+ $this->_forward('noroute'); - } - } -diff -Nuar a/vendor/magento/module-page-builder/Model/Stage/Config.php b/vendor/magento/module-page-builder/Model/Stage/Config.php ---- a/vendor/magento/module-page-builder/Model/Stage/Config.php -+++ b/vendor/magento/module-page-builder/Model/Stage/Config.php -@@ -135,7 +135,9 @@ class Config - 'content_types' => $this->getContentTypes(), - 'stage_config' => $this->data, - 'media_url' => $this->urlBuilder->getBaseUrl(['_type' => UrlInterface::URL_TYPE_MEDIA]), -- 'preview_url' => $this->frontendUrlBuilder->getUrl('pagebuilder/contenttype/preview'), -+ 'preview_url' => $this->frontendUrlBuilder -+ ->addSessionParam() -+ ->getUrl('pagebuilder/contenttype/preview'), - 'column_grid_default' => $this->scopeConfig->getValue(self::XML_PATH_COLUMN_GRID_DEFAULT), - 'column_grid_max' => $this->scopeConfig->getValue(self::XML_PATH_COLUMN_GRID_MAX), - 'can_use_inline_editing_on_stage' => $this->isWysiwygProvisionedForEditingOnStage(), -diff -Nuar a/vendor/magento/module-page-builder/Plugin/Framework/Session/SidResolver.php b/vendor/magento/module-page-builder/Plugin/Framework/Session/SidResolver.php -new file mode 100644 ---- /dev/null -+++ b/vendor/magento/module-page-builder/Plugin/Framework/Session/SidResolver.php -@@ -0,0 +1,49 @@ -+request = $request; -+ } -+ -+ /** -+ * Get Sid for pagebuilder preview -+ * -+ * @param \Magento\Framework\Session\SidResolver $subject -+ * @param string|null $result -+ * @param \Magento\Framework\Session\SessionManagerInterface $session -+ * -+ * @return string|null -+ */ -+ public function afterGetSid( -+ \Magento\Framework\Session\SidResolver $subject, -+ $result, -+ \Magento\Framework\Session\SessionManagerInterface $session -+ ) { -+ if (strpos($this->request->getPathInfo(), '/pagebuilder/contenttype/preview') === 0) { -+ return $this->request->getQuery( -+ $subject->getSessionIdQueryParam($session) -+ ); -+ } -+ -+ return $result; -+ } -+} -diff -Nuar a/vendor/magento/module-page-builder/etc/di.xml b/vendor/magento/module-page-builder/etc/di.xml ---- a/vendor/magento/module-page-builder/etc/di.xml -+++ b/vendor/magento/module-page-builder/etc/di.xml -@@ -140,4 +140,7 @@ - - - -+ -+ -+ - diff --git a/patches/PB-319__fix_pagebuilder_module__2.3.1.patch b/patches/PB-319__fix_pagebuilder_module__2.3.1.patch new file mode 100644 index 0000000..d8adfb1 --- /dev/null +++ b/patches/PB-319__fix_pagebuilder_module__2.3.1.patch @@ -0,0 +1,1341 @@ +diff -Nuar a/vendor/magento/module-page-builder/Controller/Adminhtml/Stage/Preview.php b/vendor/magento/module-page-builder/Controller/Adminhtml/Stage/Preview.php +new file mode 100644 +--- /dev/null ++++ b/vendor/magento/module-page-builder/Controller/Adminhtml/Stage/Preview.php +@@ -0,0 +1,71 @@ ++rendererPool = $rendererPool; ++ $this->preview = $preview; ++ } ++ ++ /** ++ * Generates an HTML preview for the stage ++ * ++ * @return \Magento\Framework\App\ResponseInterface|\Magento\Framework\Controller\ResultInterface|mixed ++ * @throws \Exception ++ */ ++ public function execute() ++ { ++ return $this->preview->startPreviewMode( ++ function () { ++ $pageResult = $this->resultFactory->create(ResultFactory::TYPE_PAGE); ++ // Some template filters and directive processors expect this to be called in order to function. ++ $pageResult->initLayout(); ++ ++ $params = $this->getRequest()->getParams(); ++ $renderer = $this->rendererPool->getRenderer($params['role']); ++ $result = ['data' => $renderer->render($params)]; ++ ++ return $this->resultFactory->create(ResultFactory::TYPE_JSON)->setData($result); ++ } ++ ); ++ } ++} +diff -Nuar a/vendor/magento/module-page-builder/Controller/ContentType/Preview.php b/vendor/magento/module-page-builder/Controller/ContentType/Preview.php +--- a/vendor/magento/module-page-builder/Controller/ContentType/Preview.php ++++ b/vendor/magento/module-page-builder/Controller/ContentType/Preview.php +@@ -17,6 +17,8 @@ use Magento\Framework\App\Action\HttpPostActionInterface; + * This isn't placed within the adminhtml folder as it has to extend from the front-end controllers app action to + * ensure the content is rendered in the storefront scope. + * ++ * @deprecated use \Magento\PageBuilder\Controller\Adminhtml\Stage\Preview ++ * + * @api + */ + class Preview extends \Magento\Framework\App\Action\Action implements HttpPostActionInterface +@@ -26,19 +28,28 @@ class Preview extends \Magento\Framework\App\Action\Action implements HttpPostAc + */ + private $rendererPool; + ++ /** ++ * @var \Magento\Backend\Model\Auth ++ */ ++ private $auth; ++ + /** + * Constructor + * + * @param \Magento\Backend\App\Action\Context $context + * @param \Magento\PageBuilder\Model\Stage\RendererPool $rendererPool ++ * @param \Magento\Backend\Model\Auth $auth + */ + public function __construct( + \Magento\Backend\App\Action\Context $context, +- \Magento\PageBuilder\Model\Stage\RendererPool $rendererPool ++ \Magento\PageBuilder\Model\Stage\RendererPool $rendererPool, ++ \Magento\Backend\Model\Auth $auth = null + ) { + parent::__construct($context); + + $this->rendererPool = $rendererPool; ++ $this->auth = $auth ?? \Magento\Framework\App\ObjectManager::getInstance() ++ ->get(\Magento\Backend\Model\Auth::class); + } + + /** +@@ -48,14 +59,18 @@ class Preview extends \Magento\Framework\App\Action\Action implements HttpPostAc + */ + public function execute() + { +- $pageResult = $this->resultFactory->create(ResultFactory::TYPE_PAGE); +- // Some template filters and directive processors expect this to be called in order to function. +- $pageResult->initLayout(); ++ if ($this->auth->isLoggedIn()) { ++ $pageResult = $this->resultFactory->create(ResultFactory::TYPE_PAGE); ++ // Some template filters and directive processors expect this to be called in order to function. ++ $pageResult->initLayout(); ++ ++ $params = $this->getRequest()->getParams(); ++ $renderer = $this->rendererPool->getRenderer($params['role']); ++ $result = ['data' => $renderer->render($params)]; + +- $params = $this->getRequest()->getParams(); +- $renderer = $this->rendererPool->getRenderer($params['role']); +- $result = ['data' => $renderer->render($params)]; ++ return $this->resultFactory->create(ResultFactory::TYPE_JSON)->setData($result); ++ } + +- return $this->resultFactory->create(ResultFactory::TYPE_JSON)->setData($result); ++ $this->_forward('noroute'); + } + } +diff -Nuar a/vendor/magento/module-page-builder/Model/Filter/Template.php b/vendor/magento/module-page-builder/Model/Filter/Template.php +new file mode 100644 +--- /dev/null ++++ b/vendor/magento/module-page-builder/Model/Filter/Template.php +@@ -0,0 +1,334 @@ ++logger = $logger; ++ $this->viewConfig = $viewConfig; ++ $this->mathRandom = $mathRandom; ++ $this->json = $json; ++ } ++ ++ /** ++ * After filter of template data apply transformations ++ * ++ * @param string $result ++ * ++ * @return string ++ * @SuppressWarnings(PHPMD.UnusedFormalParameter) ++ */ ++ public function filter(string $result) : string ++ { ++ $this->domDocument = false; ++ ++ // Validate if the filtered result requires background image processing ++ if (preg_match(\Magento\PageBuilder\Plugin\Filter\TemplatePlugin::BACKGROUND_IMAGE_PATTERN, $result)) { ++ $document = $this->getDomDocument($result); ++ $this->generateBackgroundImageStyles($document); ++ } ++ ++ // Process any HTML content types, they need to be decoded on the front-end ++ if (preg_match(\Magento\PageBuilder\Plugin\Filter\TemplatePlugin::HTML_CONTENT_TYPE_PATTERN, $result)) { ++ $document = $this->getDomDocument($result); ++ $uniqueNodeNameToDecodedOuterHtmlMap = $this->generateDecodedHtmlPlaceholderMappingInDocument($document); ++ } ++ ++ // If a document was retrieved we've modified the output so need to retrieve it from within the document ++ if (isset($document)) { ++ // Match the contents of the body from our generated document ++ preg_match( ++ '/(.+)<\/body><\/html>$/si', ++ $document->saveHTML(), ++ $matches ++ ); ++ ++ if (!empty($matches)) { ++ $docHtml = $matches[1]; ++ ++ // restore any encoded directives ++ $docHtml = preg_replace_callback( ++ '/=\"(%7B%7B[^"]*%7D%7D)\"/m', ++ function ($matches) { ++ return urldecode($matches[0]); ++ }, ++ $docHtml ++ ); ++ ++ if (isset($uniqueNodeNameToDecodedOuterHtmlMap)) { ++ foreach ($uniqueNodeNameToDecodedOuterHtmlMap as $uniqueNodeName => $decodedOuterHtml) { ++ $docHtml = str_replace( ++ '<' . $uniqueNodeName . '>' . '', ++ $decodedOuterHtml, ++ $docHtml ++ ); ++ } ++ } ++ ++ $result = $docHtml; ++ } ++ } ++ ++ return $result; ++ } ++ ++ /** ++ * Create a DOM document from a given string ++ * ++ * @param string $html ++ * ++ * @return \DOMDocument ++ */ ++ private function getDomDocument(string $html) : \DOMDocument ++ { ++ if (!$this->domDocument) { ++ $this->domDocument = $this->createDomDocument($html); ++ } ++ ++ return $this->domDocument; ++ } ++ ++ /** ++ * Create a DOMDocument from a string ++ * ++ * @param string $html ++ * ++ * @return \DOMDocument ++ */ ++ private function createDomDocument(string $html) : \DOMDocument ++ { ++ $domDocument = new \DOMDocument('1.0', 'UTF-8'); ++ set_error_handler( ++ function ($errorNumber, $errorString) { ++ throw new \DOMException($errorString, $errorNumber); ++ } ++ ); ++ $string = mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'); ++ try { ++ libxml_use_internal_errors(true); ++ $domDocument->loadHTML( ++ '' . $string . '' ++ ); ++ libxml_clear_errors(); ++ } catch (\Exception $e) { ++ restore_error_handler(); ++ $this->logger->critical($e); ++ } ++ restore_error_handler(); ++ ++ return $domDocument; ++ } ++ ++ /** ++ * Convert encoded HTML content types to placeholders and generate decoded outer html map for future replacement ++ * ++ * @param \DOMDocument $document ++ * @return array ++ * @throws \Magento\Framework\Exception\LocalizedException ++ */ ++ private function generateDecodedHtmlPlaceholderMappingInDocument(\DOMDocument $document): array ++ { ++ $xpath = new \DOMXPath($document); ++ ++ // construct xpath query to fetch top-level ancestor html content type nodes ++ /** @var $htmlContentTypeNodes \DOMNode[] */ ++ $htmlContentTypeNodes = $xpath->query( ++ '//*[@data-content-type="html" and not(@data-decoded="true")]' . ++ '[not(ancestor::*[@data-content-type="html"])]' ++ ); ++ ++ $uniqueNodeNameToDecodedOuterHtmlMap = []; ++ ++ foreach ($htmlContentTypeNodes as $htmlContentTypeNode) { ++ // Set decoded attribute on all encoded html content types so we don't double decode; ++ $htmlContentTypeNode->setAttribute('data-decoded', 'true'); ++ ++ // if nothing exists inside the node, continue ++ if (!strlen(trim($htmlContentTypeNode->nodeValue))) { ++ continue; ++ } ++ ++ // clone html code content type to save reference to its attributes/outerHTML, which we are not going to ++ // decode ++ $clonedHtmlContentTypeNode = clone $htmlContentTypeNode; ++ ++ // clear inner contents of cloned node for replacement later with $decodedInnerHtml using sprintf; ++ // we want to retain html content type node and avoid doing any manipulation on it ++ $clonedHtmlContentTypeNode->nodeValue = '%s'; ++ ++ // remove potentially harmful attributes on html content type node itself ++ while ($htmlContentTypeNode->attributes->length) { ++ $htmlContentTypeNode->removeAttribute($htmlContentTypeNode->attributes->item(0)->name); ++ } ++ ++ // decode outerHTML safely ++ $preDecodedOuterHtml = $document->saveHTML($htmlContentTypeNode); ++ ++ // clear empty
wrapper around outerHTML to replace with $clonedHtmlContentTypeNode ++ // phpcs:ignore Magento2.Functions.DiscouragedFunction ++ $decodedInnerHtml = preg_replace('#^<[^>]*>|]*>$#', '', html_entity_decode($preDecodedOuterHtml)); ++ ++ // Use $clonedHtmlContentTypeNode's placeholder to inject decoded inner html ++ $decodedOuterHtml = sprintf($document->saveHTML($clonedHtmlContentTypeNode), $decodedInnerHtml); ++ ++ // generate unique node name element to replace with decoded html contents at end of processing; ++ // goal is to create a document as few times as possible to prevent inadvertent parsing of contents as html ++ // by the dom library ++ $uniqueNodeName = $this->mathRandom->getRandomString(32, $this->mathRandom::CHARS_LOWERS); ++ ++ $uniqueNode = new \DOMElement($uniqueNodeName); ++ $htmlContentTypeNode->parentNode->replaceChild($uniqueNode, $htmlContentTypeNode); ++ ++ $uniqueNodeNameToDecodedOuterHtmlMap[$uniqueNodeName] = $decodedOuterHtml; ++ } ++ ++ return $uniqueNodeNameToDecodedOuterHtmlMap; ++ } ++ ++ /** ++ * Generate the CSS for any background images on the page ++ * ++ * @param \DOMDocument $document ++ */ ++ private function generateBackgroundImageStyles(\DOMDocument $document) : void ++ { ++ $xpath = new \DOMXPath($document); ++ $nodes = $xpath->query('//*[@data-background-images]'); ++ foreach ($nodes as $node) { ++ /* @var \DOMElement $node */ ++ $backgroundImages = $node->attributes->getNamedItem('data-background-images'); ++ if ($backgroundImages->nodeValue !== '') { ++ $elementClass = uniqid('background-image-'); ++ // phpcs:ignore Magento2.Functions.DiscouragedFunction ++ $images = $this->json->unserialize(stripslashes($backgroundImages->nodeValue)); ++ if (count($images) > 0) { ++ $style = $xpath->document->createElement( ++ 'style', ++ $this->generateCssFromImages($elementClass, $images) ++ ); ++ $style->setAttribute('type', 'text/css'); ++ $node->parentNode->appendChild($style); ++ ++ // Append our new class to the DOM element ++ $classes = ''; ++ if ($node->attributes->getNamedItem('class')) { ++ $classes = $node->attributes->getNamedItem('class')->nodeValue . ' '; ++ } ++ $node->setAttribute('class', $classes . $elementClass); ++ } ++ } ++ } ++ } ++ ++ /** ++ * Generate CSS based on the images array from our attribute ++ * ++ * @param string $elementClass ++ * @param array $images ++ * ++ * @return string ++ */ ++ private function generateCssFromImages(string $elementClass, array $images) : string ++ { ++ $css = []; ++ if (isset($images['desktop_image'])) { ++ $css['.' . $elementClass] = [ ++ 'background-image' => 'url(' . $images['desktop_image'] . ')', ++ ]; ++ } ++ if (isset($images['mobile_image']) && $this->getMobileMediaQuery()) { ++ $css[$this->getMobileMediaQuery()]['.' . $elementClass] = [ ++ 'background-image' => 'url(' . $images['mobile_image'] . ')', ++ ]; ++ } ++ return $this->cssFromArray($css); ++ } ++ ++ /** ++ * Generate a CSS string from an array ++ * ++ * @param array $css ++ * ++ * @return string ++ */ ++ private function cssFromArray(array $css) : string ++ { ++ $output = ''; ++ foreach ($css as $selector => $body) { ++ if (is_array($body)) { ++ $output .= $selector . ' {'; ++ $output .= $this->cssFromArray($body); ++ $output .= '}'; ++ } else { ++ $output .= $selector . ': ' . $body . ';'; ++ } ++ } ++ return $output; ++ } ++ ++ /** ++ * Generate the mobile media query from view configuration ++ * ++ * @return null|string ++ */ ++ private function getMobileMediaQuery() : ?string ++ { ++ $breakpoints = $this->viewConfig->getViewConfig()->getVarValue( ++ 'Magento_PageBuilder', ++ 'breakpoints/mobile/conditions' ++ ); ++ if ($breakpoints && count($breakpoints) > 0) { ++ $mobileBreakpoint = '@media only screen '; ++ foreach ($breakpoints as $key => $value) { ++ $mobileBreakpoint .= 'and (' . $key . ': ' . $value . ') '; ++ } ++ return rtrim($mobileBreakpoint); ++ } ++ return null; ++ } ++} +diff -Nuar a/vendor/magento/module-page-builder/Model/Stage/Config.php b/vendor/magento/module-page-builder/Model/Stage/Config.php +--- a/vendor/magento/module-page-builder/Model/Stage/Config.php ++++ b/vendor/magento/module-page-builder/Model/Stage/Config.php +@@ -135,7 +135,7 @@ class Config + 'content_types' => $this->getContentTypes(), + 'stage_config' => $this->data, + 'media_url' => $this->urlBuilder->getBaseUrl(['_type' => UrlInterface::URL_TYPE_MEDIA]), +- 'preview_url' => $this->frontendUrlBuilder->getUrl('pagebuilder/contenttype/preview'), ++ 'preview_url' => $this->urlBuilder->getUrl('pagebuilder/stage/preview'), + 'column_grid_default' => $this->scopeConfig->getValue(self::XML_PATH_COLUMN_GRID_DEFAULT), + 'column_grid_max' => $this->scopeConfig->getValue(self::XML_PATH_COLUMN_GRID_MAX), + 'can_use_inline_editing_on_stage' => $this->isWysiwygProvisionedForEditingOnStage(), +diff -Nuar a/vendor/magento/module-page-builder/Model/Stage/Preview.php b/vendor/magento/module-page-builder/Model/Stage/Preview.php +new file mode 100644 +--- /dev/null ++++ b/vendor/magento/module-page-builder/Model/Stage/Preview.php +@@ -0,0 +1,134 @@ ++emulation = $emulation; ++ $this->appState = $appState; ++ $this->design = $design; ++ $this->themeProvider = $themeProvider; ++ $this->storeManager = $storeManager; ++ $this->scopeConfig = $scopeConfig; ++ } ++ ++ /** ++ * @var bool ++ */ ++ private $isPreview; ++ ++ /** ++ * Retrieve the area in which the preview needs to be ran in ++ * ++ * @return string ++ */ ++ public function getPreviewArea() : string ++ { ++ return \Magento\Framework\App\Area::AREA_FRONTEND; ++ } ++ ++ /** ++ * Start Page Builder preview mode and emulate store front ++ * ++ * @param callable $callback ++ * @param int $storeId ++ * @return mixed ++ * @throws \Exception ++ */ ++ public function startPreviewMode($callback, $storeId = null) ++ { ++ $this->isPreview = true; ++ ++ if (!$storeId) { ++ $storeId = $this->storeManager->getDefaultStoreView()->getId(); ++ } ++ $this->emulation->startEnvironmentEmulation($storeId); ++ ++ return $this->appState->emulateAreaCode( ++ $this->getPreviewArea(), ++ function () use ($callback) { ++ $themeId = $this->scopeConfig->getValue( ++ 'design/theme/theme_id', ++ \Magento\Store\Model\ScopeInterface::SCOPE_STORE ++ ); ++ $theme = $this->themeProvider->getThemeById($themeId); ++ $this->design->setDesignTheme($theme, $this->getPreviewArea()); ++ ++ try { ++ $result = $callback(); ++ } catch (\Exception $e) { ++ $this->isPreview = false; ++ throw $e; ++ } ++ ++ $this->emulation->stopEnvironmentEmulation(); ++ return $result; ++ } ++ ); ++ } ++ ++ /** ++ * Determine if the system is in preview mode ++ * ++ * @return bool ++ */ ++ public function isPreviewMode() : bool ++ { ++ return $this->isPreview; ++ } ++} +diff -Nuar a/vendor/magento/module-page-builder/Model/Stage/Renderer/Block.php b/vendor/magento/module-page-builder/Model/Stage/Renderer/Block.php +--- a/vendor/magento/module-page-builder/Model/Stage/Renderer/Block.php ++++ b/vendor/magento/module-page-builder/Model/Stage/Renderer/Block.php +@@ -9,6 +9,7 @@ declare(strict_types=1); + namespace Magento\PageBuilder\Model\Stage\Renderer; + + use Magento\Framework\Controller\ResultFactory; ++use Magento\PageBuilder\Model\Filter\Template; + + /** + * Renders a block for the stage +@@ -31,20 +32,27 @@ class Block implements \Magento\PageBuilder\Model\Stage\RendererInterface + private $resultFactory; + + /** +- * Constructor +- * ++ * @var Template ++ */ ++ private $templateFilter; ++ ++ /** + * @param \Magento\PageBuilder\Model\Config $config + * @param \Magento\Framework\View\Element\BlockFactory $blockFactory + * @param ResultFactory $resultFactory ++ * @param Template|null $templateFilter + */ + public function __construct( + \Magento\PageBuilder\Model\Config $config, + \Magento\Framework\View\Element\BlockFactory $blockFactory, +- ResultFactory $resultFactory ++ ResultFactory $resultFactory, ++ Template $templateFilter = null + ) { + $this->config = $config; + $this->blockFactory = $blockFactory; + $this->resultFactory = $resultFactory; ++ $this->templateFilter = $templateFilter ?? \Magento\Framework\App\ObjectManager::getInstance() ++ ->get(\Magento\PageBuilder\Model\Filter\Template::class); + } + + /** +@@ -77,7 +85,7 @@ class Block implements \Magento\PageBuilder\Model\Stage\RendererInterface + $pageResult = $this->resultFactory->create(ResultFactory::TYPE_PAGE); + $pageResult->getLayout()->addBlock($backendBlockInstance); + +- $result['content'] = $backendBlockInstance->toHtml(); ++ $result['content'] = $this->templateFilter->filter($backendBlockInstance->toHtml()); + } + + return $result; +diff -Nuar a/vendor/magento/module-page-builder/Model/Stage/Renderer/CmsStaticBlock.php b/vendor/magento/module-page-builder/Model/Stage/Renderer/CmsStaticBlock.php +--- a/vendor/magento/module-page-builder/Model/Stage/Renderer/CmsStaticBlock.php ++++ b/vendor/magento/module-page-builder/Model/Stage/Renderer/CmsStaticBlock.php +@@ -9,6 +9,8 @@ declare(strict_types=1); + namespace Magento\PageBuilder\Model\Stage\Renderer; + + use Psr\Log\LoggerInterface; ++use Magento\PageBuilder\Model\Stage\HtmlFilter; ++use Magento\PageBuilder\Model\Filter\Template; + + /** + * Renders a CMS Block for the stage +@@ -33,28 +35,35 @@ class CmsStaticBlock implements \Magento\PageBuilder\Model\Stage\RendererInterfa + private $loggerInterface; + + /** +- * @var \Magento\PageBuilder\Model\Stage\HtmlFilter ++ * @var HtmlFilter + */ + private $htmlFilter; + + /** +- * CmsStaticBlock constructor. +- * ++ * @var Template ++ */ ++ private $templateFilter; ++ ++ /** + * @param \Magento\Cms\Model\ResourceModel\Block\CollectionFactory $blockCollectionFactory + * @param WidgetDirective $widgetDirectiveRenderer + * @param LoggerInterface $loggerInterface + * @param \Magento\PageBuilder\Model\Stage\HtmlFilter $htmlFilter ++ * @param \Magento\PageBuilder\Model\Filter\Template|null $templateFilter + */ + public function __construct( + \Magento\Cms\Model\ResourceModel\Block\CollectionFactory $blockCollectionFactory, + WidgetDirective $widgetDirectiveRenderer, + LoggerInterface $loggerInterface, +- \Magento\PageBuilder\Model\Stage\HtmlFilter $htmlFilter ++ HtmlFilter $htmlFilter, ++ Template $templateFilter = null + ) { + $this->blockCollectionFactory = $blockCollectionFactory; + $this->widgetDirectiveRenderer = $widgetDirectiveRenderer; + $this->loggerInterface = $loggerInterface; + $this->htmlFilter = $htmlFilter; ++ $this->templateFilter = $templateFilter ?? \Magento\Framework\App\ObjectManager::getInstance() ++ ->get(\Magento\PageBuilder\Model\Filter\Template::class); + } + + /** +@@ -96,7 +105,9 @@ class CmsStaticBlock implements \Magento\PageBuilder\Model\Stage\RendererInterfa + + if ($block->isActive()) { + $directiveResult = $this->widgetDirectiveRenderer->render($params); +- $result['content'] = $this->htmlFilter->filterHtml($directiveResult['content']); ++ $result['content'] = $this->htmlFilter->filterHtml( ++ $this->templateFilter->filter($directiveResult['content']) ++ ); + } else { + $result['error'] = __('Block disabled'); + } +diff -Nuar a/vendor/magento/module-page-builder/Model/Stage/Renderer/WidgetDirective.php b/vendor/magento/module-page-builder/Model/Stage/Renderer/WidgetDirective.php +--- a/vendor/magento/module-page-builder/Model/Stage/Renderer/WidgetDirective.php ++++ b/vendor/magento/module-page-builder/Model/Stage/Renderer/WidgetDirective.php +@@ -9,6 +9,7 @@ declare(strict_types=1); + namespace Magento\PageBuilder\Model\Stage\Renderer; + + use Magento\Store\Model\Store; ++use Magento\PageBuilder\Model\Filter\Template; + + /** + * Renders a widget directive for the stage +@@ -28,17 +29,24 @@ class WidgetDirective implements \Magento\PageBuilder\Model\Stage\RendererInterf + private $directiveFilter; + + /** +- * Constructor +- * ++ * @var Template ++ */ ++ private $templateFilter; ++ ++ /** + * @param \Magento\Store\Model\StoreManagerInterface $storeManager + * @param \Magento\Widget\Model\Template\Filter $directiveFilter ++ * @param Template $templateFilter + */ + public function __construct( + \Magento\Store\Model\StoreManagerInterface $storeManager, +- \Magento\Widget\Model\Template\Filter $directiveFilter ++ \Magento\Widget\Model\Template\Filter $directiveFilter, ++ Template $templateFilter = null + ) { + $this->storeManager = $storeManager; + $this->directiveFilter = $directiveFilter; ++ $this->templateFilter = $templateFilter ?? \Magento\Framework\App\ObjectManager::getInstance() ++ ->get(\Magento\PageBuilder\Model\Filter\Template::class); + } + + /** +@@ -61,7 +69,7 @@ class WidgetDirective implements \Magento\PageBuilder\Model\Stage\RendererInterf + try { + $result['content'] = $this->directiveFilter + ->setStoreId(Store::DEFAULT_STORE_ID) +- ->filter($params['directive']); ++ ->filter($this->templateFilter->filter($params['directive'])); + } catch (\Exception $e) { + $result['error'] = __($e->getMessage()); + } +diff -Nuar a/vendor/magento/module-page-builder/Plugin/Catalog/Block/Product/ProductsListPlugin.php b/vendor/magento/module-page-builder/Plugin/Catalog/Block/Product/ProductsListPlugin.php +new file mode 100644 +--- /dev/null ++++ b/vendor/magento/module-page-builder/Plugin/Catalog/Block/Product/ProductsListPlugin.php +@@ -0,0 +1,48 @@ ++stock = $stock; ++ } ++ ++ /** ++ * Allow to sort product collection ++ * ++ * @param ProductsList $subject ++ * @param Collection $result ++ * @return Collection ++ * @SuppressWarnings(PHPMD.UnusedFormalParameter) ++ */ ++ public function afterCreateCollection( ++ ProductsList $subject, ++ Collection $result ++ ) { ++ $this->stock->addIsInStockFilterToCollection($result); ++ return $result; ++ } ++} +diff -Nuar a/vendor/magento/module-page-builder/Plugin/DesignLoader.php b/vendor/magento/module-page-builder/Plugin/DesignLoader.php +new file mode 100644 +--- /dev/null ++++ b/vendor/magento/module-page-builder/Plugin/DesignLoader.php +@@ -0,0 +1,98 @@ ++designLoader = $designLoader; ++ $this->messageManager = $messageManager; ++ $this->appState = $appState; ++ $this->preview = $preview; ++ } ++ ++ /** ++ * Before create load the design files ++ * ++ * @param \Magento\Catalog\Block\Product\ImageFactory $subject ++ * @param Product $product ++ * @param string $imageId ++ * @param array|null $attributes ++ * @throws \Exception ++ * ++ * @SuppressWarnings(PHPMD.UnusedFormalParameter) ++ */ ++ public function beforeCreate( ++ \Magento\Catalog\Block\Product\ImageFactory $subject, ++ Product $product, ++ string $imageId, ++ array $attributes = null ++ ) { ++ if ($this->preview->isPreviewMode()) { ++ $this->appState->emulateAreaCode( ++ $this->preview->getPreviewArea(), ++ [$this, 'loadDesignConfig'] ++ ); ++ } ++ } ++ ++ /** ++ * Load the design config ++ */ ++ public function loadDesignConfig() ++ { ++ try { ++ $this->designLoader->load(); ++ } catch (\Magento\Framework\Exception\LocalizedException $e) { ++ if ($e->getPrevious() instanceof \Magento\Framework\Config\Dom\ValidationException) { ++ /** @var MessageInterface $message */ ++ $message = $this->messageManager ++ ->createMessage(MessageInterface::TYPE_ERROR) ++ ->setText($e->getMessage()); ++ $this->messageManager->addUniqueMessages([$message]); ++ } ++ } ++ } ++} +diff -Nuar a/vendor/magento/module-page-builder/Plugin/Filter/TemplatePlugin.php b/vendor/magento/module-page-builder/Plugin/Filter/TemplatePlugin.php +--- a/vendor/magento/module-page-builder/Plugin/Filter/TemplatePlugin.php ++++ b/vendor/magento/module-page-builder/Plugin/Filter/TemplatePlugin.php +@@ -7,50 +7,27 @@ declare(strict_types=1); + + namespace Magento\PageBuilder\Plugin\Filter; + +-use Magento\Store\Model\Store; +- + /** + * Plugin to the template filter to process any background images added by Page Builder + */ + class TemplatePlugin + { +- const BACKGROUND_IMAGE_PATTERN = '/data-background-images/si'; ++ const BACKGROUND_IMAGE_PATTERN = '/data-background-images=(?:\'|"){.+}(?:\'|")/si'; + + const HTML_CONTENT_TYPE_PATTERN = '/data-content-type="html"/si'; + + /** +- * @var \Magento\Framework\View\ConfigInterface +- */ +- private $viewConfig; +- +- /** +- * @var \Psr\Log\LoggerInterface +- */ +- private $logger; +- +- /** +- * @var \DOMDocument +- */ +- private $domDocument; +- +- /** +- * @var \Magento\Framework\Math\Random ++ * @var \Magento\PageBuilder\Model\Filter\Template + */ +- private $mathRandom; ++ private $templateFilter; + + /** +- * @param \Psr\Log\LoggerInterface $logger +- * @param \Magento\Framework\View\ConfigInterface $viewConfig +- * @param \Magento\Framework\Math\Random $mathRandom ++ * @param \Magento\PageBuilder\Model\Filter\Template $templateFilter + */ + public function __construct( +- \Psr\Log\LoggerInterface $logger, +- \Magento\Framework\View\ConfigInterface $viewConfig, +- \Magento\Framework\Math\Random $mathRandom ++ \Magento\PageBuilder\Model\Filter\Template $templateFilter + ) { +- $this->logger = $logger; +- $this->viewConfig = $viewConfig; +- $this->mathRandom = $mathRandom; ++ $this->templateFilter = $templateFilter; + } + + /** +@@ -64,284 +41,6 @@ class TemplatePlugin + */ + public function afterFilter(\Magento\Framework\Filter\Template $subject, string $result) : string + { +- $this->domDocument = false; +- +- // Validate if the filtered result requires background image processing +- if (preg_match(self::BACKGROUND_IMAGE_PATTERN, $result)) { +- $document = $this->getDomDocument($result); +- $this->generateBackgroundImageStyles($document); +- } +- +- // Process any HTML content types, they need to be decoded on the front-end +- if (preg_match(self::HTML_CONTENT_TYPE_PATTERN, $result)) { +- $document = $this->getDomDocument($result); +- $uniqueNodeNameToDecodedOuterHtmlMap = $this->generateDecodedHtmlPlaceholderMappingInDocument($document); +- } +- +- // If a document was retrieved we've modified the output so need to retrieve it from within the document +- if (isset($document)) { +- // Match the contents of the body from our generated document +- preg_match( +- '/(.+)<\/body><\/html>$/si', +- $document->saveHTML(), +- $matches +- ); +- +- if (!empty($matches)) { +- $docHtml = $matches[1]; +- +- if (isset($uniqueNodeNameToDecodedOuterHtmlMap)) { +- foreach ($uniqueNodeNameToDecodedOuterHtmlMap as $uniqueNodeName => $decodedOuterHtml) { +- $docHtml = str_replace( +- '<' . $uniqueNodeName . '>' . '', +- $decodedOuterHtml, +- $docHtml +- ); +- } +- } +- +- $result = $docHtml; +- } +- } +- +- return $result; +- } +- +- /** +- * Determine if custom variable directive's return value needs to be escaped and do so if true +- * +- * @param \Magento\Framework\Filter\Template $subject +- * @param \Closure $proceed +- * @param string[] $construction +- * @return string +- */ +- public function aroundCustomvarDirective( +- \Magento\Framework\Filter\Template $subject, +- \Closure $proceed, +- $construction +- ) { +- // Determine the need to escape the return value of observed method. +- // Admin context requires store ID of 0; in that context return value should be escaped +- $shouldEscape = $subject->getStoreId() !== null && (int) $subject->getStoreId() === Store::DEFAULT_STORE_ID; +- +- if (!$shouldEscape) { +- return $proceed($construction); +- } +- +- $result = $proceed($construction); +- +- return htmlspecialchars($result); +- } +- +- /** +- * Create a DOM document from a given string +- * +- * @param string $html +- * +- * @return \DOMDocument +- */ +- private function getDomDocument(string $html) : \DOMDocument +- { +- if (!$this->domDocument) { +- $this->domDocument = $this->createDomDocument($html); +- } +- +- return $this->domDocument; +- } +- +- /** +- * Create a DOMDocument from a string +- * +- * @param string $html +- * +- * @return \DOMDocument +- */ +- private function createDomDocument(string $html) : \DOMDocument +- { +- $domDocument = new \DOMDocument('1.0', 'UTF-8'); +- set_error_handler( +- function ($errorNumber, $errorString) { +- throw new \Exception($errorString, $errorNumber); +- } +- ); +- $string = mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'); +- try { +- libxml_use_internal_errors(true); +- $domDocument->loadHTML( +- '' . $string . '' +- ); +- libxml_clear_errors(); +- } catch (\Exception $e) { +- restore_error_handler(); +- $this->logger->critical($e); +- } +- restore_error_handler(); +- +- return $domDocument; +- } +- +- /** +- * Convert encoded HTML content types to placeholders and generate decoded outer html map for future replacement +- * +- * @param \DOMDocument $document +- * @return array - map of unique node name to decoded html +- */ +- private function generateDecodedHtmlPlaceholderMappingInDocument(\DOMDocument $document): array +- { +- $xpath = new \DOMXPath($document); +- +- // construct xpath query to fetch top-level ancestor html content type nodes +- /** @var $htmlContentTypeNodes \DOMNode[] */ +- $htmlContentTypeNodes = $xpath->query( +- '//*[@data-content-type="html" and not(@data-decoded="true")]' . +- '[not(ancestor::*[@data-content-type="html"])]' +- ); +- +- $uniqueNodeNameToDecodedOuterHtmlMap = []; +- +- foreach ($htmlContentTypeNodes as $htmlContentTypeNode) { +- // Set decoded attribute on all encoded html content types so we don't double decode; +- $htmlContentTypeNode->setAttribute('data-decoded', 'true'); +- +- // if nothing exists inside the node, continue +- if (!strlen(trim($htmlContentTypeNode->nodeValue))) { +- continue; +- } +- +- // clone html code content type to save reference to its attributes/outerHTML, which we are not going to +- // decode +- $clonedHtmlContentTypeNode = clone $htmlContentTypeNode; +- +- // clear inner contents of cloned node for replacement later with $decodedInnerHtml using sprintf; +- // we want to retain html content type node and avoid doing any manipulation on it +- $clonedHtmlContentTypeNode->nodeValue = '%s'; +- +- // remove potentially harmful attributes on html content type node itself +- while ($htmlContentTypeNode->attributes->length) { +- $htmlContentTypeNode->removeAttribute($htmlContentTypeNode->attributes->item(0)->name); +- } +- +- // decode outerHTML safely +- $preDecodedOuterHtml = $document->saveHTML($htmlContentTypeNode); +- +- // clear empty
wrapper around outerHTML to replace with $clonedHtmlContentTypeNode +- $decodedInnerHtml = preg_replace('#^<[^>]*>|]*>$#', '', html_entity_decode($preDecodedOuterHtml)); +- +- // Use $clonedHtmlContentTypeNode's placeholder to inject decoded inner html +- $decodedOuterHtml = sprintf($document->saveHTML($clonedHtmlContentTypeNode), $decodedInnerHtml); +- +- // generate unique node name element to replace with decoded html contents at end of processing; +- // goal is to create a document as few times as possible to prevent inadvertent parsing of contents as html +- // by the dom library +- $uniqueNodeName = $this->mathRandom->getRandomString(32, $this->mathRandom::CHARS_LOWERS); +- +- $uniqueNode = new \DOMElement($uniqueNodeName); +- $htmlContentTypeNode->parentNode->replaceChild($uniqueNode, $htmlContentTypeNode); +- +- $uniqueNodeNameToDecodedOuterHtmlMap[$uniqueNodeName] = $decodedOuterHtml; +- } +- +- return $uniqueNodeNameToDecodedOuterHtmlMap; +- } +- +- /** +- * Generate the CSS for any background images on the page +- * +- * @param \DOMDocument $document +- */ +- private function generateBackgroundImageStyles(\DOMDocument $document) : void +- { +- $xpath = new \DOMXPath($document); +- $nodes = $xpath->query('//*[@data-background-images]'); +- foreach ($nodes as $node) { +- /* @var \DOMElement $node */ +- $backgroundImages = $node->attributes->getNamedItem('data-background-images'); +- if ($backgroundImages->nodeValue !== '') { +- $elementClass = uniqid('background-image-'); +- $images = json_decode(stripslashes($backgroundImages->nodeValue), true); +- if (count($images) > 0) { +- $style = $xpath->document->createElement( +- 'style', +- $this->generateCssFromImages($elementClass, $images) +- ); +- $style->setAttribute('type', 'text/css'); +- $node->parentNode->appendChild($style); +- +- // Append our new class to the DOM element +- $classes = ''; +- if ($node->attributes->getNamedItem('class')) { +- $classes = $node->attributes->getNamedItem('class')->nodeValue . ' '; +- } +- $node->setAttribute('class', $classes . $elementClass); +- } +- } +- } +- } +- +- /** +- * Generate CSS based on the images array from our attribute +- * +- * @param string $elementClass +- * @param array $images +- * +- * @return string +- */ +- private function generateCssFromImages(string $elementClass, array $images) : string +- { +- $css = []; +- if (isset($images['desktop_image'])) { +- $css['.' . $elementClass] = [ +- 'background-image' => 'url(' . $images['desktop_image'] . ')', +- ]; +- } +- if (isset($images['mobile_image']) && $this->getMobileMediaQuery()) { +- $css[$this->getMobileMediaQuery()]['.' . $elementClass] = [ +- 'background-image' => 'url(' . $images['mobile_image'] . ')', +- ]; +- } +- return $this->cssFromArray($css); +- } +- +- /** +- * Generate a CSS string from an array +- * +- * @param array $css +- * +- * @return string +- */ +- private function cssFromArray(array $css) : string +- { +- $output = ''; +- foreach ($css as $selector => $body) { +- if (is_array($body)) { +- $output .= $selector . ' {'; +- $output .= $this->cssFromArray($body); +- $output .= '}'; +- } else { +- $output .= $selector . ': ' . $body . ';'; +- } +- } +- return $output; +- } +- +- /** +- * Generate the mobile media query from view configuration +- * +- * @return null|string +- */ +- private function getMobileMediaQuery() : ?string +- { +- $breakpoints = $this->viewConfig->getViewConfig()->getVarValue( +- 'Magento_PageBuilder', +- 'breakpoints/mobile/conditions' +- ); +- if ($breakpoints && count($breakpoints) > 0) { +- $mobileBreakpoint = '@media only screen '; +- foreach ($breakpoints as $key => $value) { +- $mobileBreakpoint .= 'and (' . $key . ': ' . $value . ') '; +- } +- return rtrim($mobileBreakpoint); +- } +- return null; ++ return $this->templateFilter->filter($result); + } + } +diff -Nuar a/vendor/magento/module-page-builder/etc/adminhtml/di.xml b/vendor/magento/module-page-builder/etc/adminhtml/di.xml +--- a/vendor/magento/module-page-builder/etc/adminhtml/di.xml ++++ b/vendor/magento/module-page-builder/etc/adminhtml/di.xml +@@ -6,6 +6,9 @@ + */ + --> + ++ ++ ++ + + + ns = pagebuilder_modal_form, index = modal +diff -Nuar a/vendor/magento/module-page-builder/etc/di.xml b/vendor/magento/module-page-builder/etc/di.xml +--- a/vendor/magento/module-page-builder/etc/di.xml ++++ b/vendor/magento/module-page-builder/etc/di.xml +@@ -131,6 +131,7 @@ + + pageBuilderProductCollectionFactory + ++ + + + +diff -Nuar a/vendor/magento/module-page-builder/view/adminhtml/layout/pagebuilder_stage_preview.xml b/vendor/magento/module-page-builder/view/adminhtml/layout/pagebuilder_stage_preview.xml +new file mode 100644 +--- /dev/null ++++ b/vendor/magento/module-page-builder/view/adminhtml/layout/pagebuilder_stage_preview.xml +@@ -0,0 +1,14 @@ ++ ++ ++ ++ ++ ++ ++ ++ ++ diff --git a/patches/PB-320__fix_pagebuilder_module__2.3.2.patch b/patches/PB-320__fix_pagebuilder_module__2.3.2.patch new file mode 100644 index 0000000..d8adfb1 --- /dev/null +++ b/patches/PB-320__fix_pagebuilder_module__2.3.2.patch @@ -0,0 +1,1341 @@ +diff -Nuar a/vendor/magento/module-page-builder/Controller/Adminhtml/Stage/Preview.php b/vendor/magento/module-page-builder/Controller/Adminhtml/Stage/Preview.php +new file mode 100644 +--- /dev/null ++++ b/vendor/magento/module-page-builder/Controller/Adminhtml/Stage/Preview.php +@@ -0,0 +1,71 @@ ++rendererPool = $rendererPool; ++ $this->preview = $preview; ++ } ++ ++ /** ++ * Generates an HTML preview for the stage ++ * ++ * @return \Magento\Framework\App\ResponseInterface|\Magento\Framework\Controller\ResultInterface|mixed ++ * @throws \Exception ++ */ ++ public function execute() ++ { ++ return $this->preview->startPreviewMode( ++ function () { ++ $pageResult = $this->resultFactory->create(ResultFactory::TYPE_PAGE); ++ // Some template filters and directive processors expect this to be called in order to function. ++ $pageResult->initLayout(); ++ ++ $params = $this->getRequest()->getParams(); ++ $renderer = $this->rendererPool->getRenderer($params['role']); ++ $result = ['data' => $renderer->render($params)]; ++ ++ return $this->resultFactory->create(ResultFactory::TYPE_JSON)->setData($result); ++ } ++ ); ++ } ++} +diff -Nuar a/vendor/magento/module-page-builder/Controller/ContentType/Preview.php b/vendor/magento/module-page-builder/Controller/ContentType/Preview.php +--- a/vendor/magento/module-page-builder/Controller/ContentType/Preview.php ++++ b/vendor/magento/module-page-builder/Controller/ContentType/Preview.php +@@ -17,6 +17,8 @@ use Magento\Framework\App\Action\HttpPostActionInterface; + * This isn't placed within the adminhtml folder as it has to extend from the front-end controllers app action to + * ensure the content is rendered in the storefront scope. + * ++ * @deprecated use \Magento\PageBuilder\Controller\Adminhtml\Stage\Preview ++ * + * @api + */ + class Preview extends \Magento\Framework\App\Action\Action implements HttpPostActionInterface +@@ -26,19 +28,28 @@ class Preview extends \Magento\Framework\App\Action\Action implements HttpPostAc + */ + private $rendererPool; + ++ /** ++ * @var \Magento\Backend\Model\Auth ++ */ ++ private $auth; ++ + /** + * Constructor + * + * @param \Magento\Backend\App\Action\Context $context + * @param \Magento\PageBuilder\Model\Stage\RendererPool $rendererPool ++ * @param \Magento\Backend\Model\Auth $auth + */ + public function __construct( + \Magento\Backend\App\Action\Context $context, +- \Magento\PageBuilder\Model\Stage\RendererPool $rendererPool ++ \Magento\PageBuilder\Model\Stage\RendererPool $rendererPool, ++ \Magento\Backend\Model\Auth $auth = null + ) { + parent::__construct($context); + + $this->rendererPool = $rendererPool; ++ $this->auth = $auth ?? \Magento\Framework\App\ObjectManager::getInstance() ++ ->get(\Magento\Backend\Model\Auth::class); + } + + /** +@@ -48,14 +59,18 @@ class Preview extends \Magento\Framework\App\Action\Action implements HttpPostAc + */ + public function execute() + { +- $pageResult = $this->resultFactory->create(ResultFactory::TYPE_PAGE); +- // Some template filters and directive processors expect this to be called in order to function. +- $pageResult->initLayout(); ++ if ($this->auth->isLoggedIn()) { ++ $pageResult = $this->resultFactory->create(ResultFactory::TYPE_PAGE); ++ // Some template filters and directive processors expect this to be called in order to function. ++ $pageResult->initLayout(); ++ ++ $params = $this->getRequest()->getParams(); ++ $renderer = $this->rendererPool->getRenderer($params['role']); ++ $result = ['data' => $renderer->render($params)]; + +- $params = $this->getRequest()->getParams(); +- $renderer = $this->rendererPool->getRenderer($params['role']); +- $result = ['data' => $renderer->render($params)]; ++ return $this->resultFactory->create(ResultFactory::TYPE_JSON)->setData($result); ++ } + +- return $this->resultFactory->create(ResultFactory::TYPE_JSON)->setData($result); ++ $this->_forward('noroute'); + } + } +diff -Nuar a/vendor/magento/module-page-builder/Model/Filter/Template.php b/vendor/magento/module-page-builder/Model/Filter/Template.php +new file mode 100644 +--- /dev/null ++++ b/vendor/magento/module-page-builder/Model/Filter/Template.php +@@ -0,0 +1,334 @@ ++logger = $logger; ++ $this->viewConfig = $viewConfig; ++ $this->mathRandom = $mathRandom; ++ $this->json = $json; ++ } ++ ++ /** ++ * After filter of template data apply transformations ++ * ++ * @param string $result ++ * ++ * @return string ++ * @SuppressWarnings(PHPMD.UnusedFormalParameter) ++ */ ++ public function filter(string $result) : string ++ { ++ $this->domDocument = false; ++ ++ // Validate if the filtered result requires background image processing ++ if (preg_match(\Magento\PageBuilder\Plugin\Filter\TemplatePlugin::BACKGROUND_IMAGE_PATTERN, $result)) { ++ $document = $this->getDomDocument($result); ++ $this->generateBackgroundImageStyles($document); ++ } ++ ++ // Process any HTML content types, they need to be decoded on the front-end ++ if (preg_match(\Magento\PageBuilder\Plugin\Filter\TemplatePlugin::HTML_CONTENT_TYPE_PATTERN, $result)) { ++ $document = $this->getDomDocument($result); ++ $uniqueNodeNameToDecodedOuterHtmlMap = $this->generateDecodedHtmlPlaceholderMappingInDocument($document); ++ } ++ ++ // If a document was retrieved we've modified the output so need to retrieve it from within the document ++ if (isset($document)) { ++ // Match the contents of the body from our generated document ++ preg_match( ++ '/(.+)<\/body><\/html>$/si', ++ $document->saveHTML(), ++ $matches ++ ); ++ ++ if (!empty($matches)) { ++ $docHtml = $matches[1]; ++ ++ // restore any encoded directives ++ $docHtml = preg_replace_callback( ++ '/=\"(%7B%7B[^"]*%7D%7D)\"/m', ++ function ($matches) { ++ return urldecode($matches[0]); ++ }, ++ $docHtml ++ ); ++ ++ if (isset($uniqueNodeNameToDecodedOuterHtmlMap)) { ++ foreach ($uniqueNodeNameToDecodedOuterHtmlMap as $uniqueNodeName => $decodedOuterHtml) { ++ $docHtml = str_replace( ++ '<' . $uniqueNodeName . '>' . '', ++ $decodedOuterHtml, ++ $docHtml ++ ); ++ } ++ } ++ ++ $result = $docHtml; ++ } ++ } ++ ++ return $result; ++ } ++ ++ /** ++ * Create a DOM document from a given string ++ * ++ * @param string $html ++ * ++ * @return \DOMDocument ++ */ ++ private function getDomDocument(string $html) : \DOMDocument ++ { ++ if (!$this->domDocument) { ++ $this->domDocument = $this->createDomDocument($html); ++ } ++ ++ return $this->domDocument; ++ } ++ ++ /** ++ * Create a DOMDocument from a string ++ * ++ * @param string $html ++ * ++ * @return \DOMDocument ++ */ ++ private function createDomDocument(string $html) : \DOMDocument ++ { ++ $domDocument = new \DOMDocument('1.0', 'UTF-8'); ++ set_error_handler( ++ function ($errorNumber, $errorString) { ++ throw new \DOMException($errorString, $errorNumber); ++ } ++ ); ++ $string = mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'); ++ try { ++ libxml_use_internal_errors(true); ++ $domDocument->loadHTML( ++ '' . $string . '' ++ ); ++ libxml_clear_errors(); ++ } catch (\Exception $e) { ++ restore_error_handler(); ++ $this->logger->critical($e); ++ } ++ restore_error_handler(); ++ ++ return $domDocument; ++ } ++ ++ /** ++ * Convert encoded HTML content types to placeholders and generate decoded outer html map for future replacement ++ * ++ * @param \DOMDocument $document ++ * @return array ++ * @throws \Magento\Framework\Exception\LocalizedException ++ */ ++ private function generateDecodedHtmlPlaceholderMappingInDocument(\DOMDocument $document): array ++ { ++ $xpath = new \DOMXPath($document); ++ ++ // construct xpath query to fetch top-level ancestor html content type nodes ++ /** @var $htmlContentTypeNodes \DOMNode[] */ ++ $htmlContentTypeNodes = $xpath->query( ++ '//*[@data-content-type="html" and not(@data-decoded="true")]' . ++ '[not(ancestor::*[@data-content-type="html"])]' ++ ); ++ ++ $uniqueNodeNameToDecodedOuterHtmlMap = []; ++ ++ foreach ($htmlContentTypeNodes as $htmlContentTypeNode) { ++ // Set decoded attribute on all encoded html content types so we don't double decode; ++ $htmlContentTypeNode->setAttribute('data-decoded', 'true'); ++ ++ // if nothing exists inside the node, continue ++ if (!strlen(trim($htmlContentTypeNode->nodeValue))) { ++ continue; ++ } ++ ++ // clone html code content type to save reference to its attributes/outerHTML, which we are not going to ++ // decode ++ $clonedHtmlContentTypeNode = clone $htmlContentTypeNode; ++ ++ // clear inner contents of cloned node for replacement later with $decodedInnerHtml using sprintf; ++ // we want to retain html content type node and avoid doing any manipulation on it ++ $clonedHtmlContentTypeNode->nodeValue = '%s'; ++ ++ // remove potentially harmful attributes on html content type node itself ++ while ($htmlContentTypeNode->attributes->length) { ++ $htmlContentTypeNode->removeAttribute($htmlContentTypeNode->attributes->item(0)->name); ++ } ++ ++ // decode outerHTML safely ++ $preDecodedOuterHtml = $document->saveHTML($htmlContentTypeNode); ++ ++ // clear empty
wrapper around outerHTML to replace with $clonedHtmlContentTypeNode ++ // phpcs:ignore Magento2.Functions.DiscouragedFunction ++ $decodedInnerHtml = preg_replace('#^<[^>]*>|]*>$#', '', html_entity_decode($preDecodedOuterHtml)); ++ ++ // Use $clonedHtmlContentTypeNode's placeholder to inject decoded inner html ++ $decodedOuterHtml = sprintf($document->saveHTML($clonedHtmlContentTypeNode), $decodedInnerHtml); ++ ++ // generate unique node name element to replace with decoded html contents at end of processing; ++ // goal is to create a document as few times as possible to prevent inadvertent parsing of contents as html ++ // by the dom library ++ $uniqueNodeName = $this->mathRandom->getRandomString(32, $this->mathRandom::CHARS_LOWERS); ++ ++ $uniqueNode = new \DOMElement($uniqueNodeName); ++ $htmlContentTypeNode->parentNode->replaceChild($uniqueNode, $htmlContentTypeNode); ++ ++ $uniqueNodeNameToDecodedOuterHtmlMap[$uniqueNodeName] = $decodedOuterHtml; ++ } ++ ++ return $uniqueNodeNameToDecodedOuterHtmlMap; ++ } ++ ++ /** ++ * Generate the CSS for any background images on the page ++ * ++ * @param \DOMDocument $document ++ */ ++ private function generateBackgroundImageStyles(\DOMDocument $document) : void ++ { ++ $xpath = new \DOMXPath($document); ++ $nodes = $xpath->query('//*[@data-background-images]'); ++ foreach ($nodes as $node) { ++ /* @var \DOMElement $node */ ++ $backgroundImages = $node->attributes->getNamedItem('data-background-images'); ++ if ($backgroundImages->nodeValue !== '') { ++ $elementClass = uniqid('background-image-'); ++ // phpcs:ignore Magento2.Functions.DiscouragedFunction ++ $images = $this->json->unserialize(stripslashes($backgroundImages->nodeValue)); ++ if (count($images) > 0) { ++ $style = $xpath->document->createElement( ++ 'style', ++ $this->generateCssFromImages($elementClass, $images) ++ ); ++ $style->setAttribute('type', 'text/css'); ++ $node->parentNode->appendChild($style); ++ ++ // Append our new class to the DOM element ++ $classes = ''; ++ if ($node->attributes->getNamedItem('class')) { ++ $classes = $node->attributes->getNamedItem('class')->nodeValue . ' '; ++ } ++ $node->setAttribute('class', $classes . $elementClass); ++ } ++ } ++ } ++ } ++ ++ /** ++ * Generate CSS based on the images array from our attribute ++ * ++ * @param string $elementClass ++ * @param array $images ++ * ++ * @return string ++ */ ++ private function generateCssFromImages(string $elementClass, array $images) : string ++ { ++ $css = []; ++ if (isset($images['desktop_image'])) { ++ $css['.' . $elementClass] = [ ++ 'background-image' => 'url(' . $images['desktop_image'] . ')', ++ ]; ++ } ++ if (isset($images['mobile_image']) && $this->getMobileMediaQuery()) { ++ $css[$this->getMobileMediaQuery()]['.' . $elementClass] = [ ++ 'background-image' => 'url(' . $images['mobile_image'] . ')', ++ ]; ++ } ++ return $this->cssFromArray($css); ++ } ++ ++ /** ++ * Generate a CSS string from an array ++ * ++ * @param array $css ++ * ++ * @return string ++ */ ++ private function cssFromArray(array $css) : string ++ { ++ $output = ''; ++ foreach ($css as $selector => $body) { ++ if (is_array($body)) { ++ $output .= $selector . ' {'; ++ $output .= $this->cssFromArray($body); ++ $output .= '}'; ++ } else { ++ $output .= $selector . ': ' . $body . ';'; ++ } ++ } ++ return $output; ++ } ++ ++ /** ++ * Generate the mobile media query from view configuration ++ * ++ * @return null|string ++ */ ++ private function getMobileMediaQuery() : ?string ++ { ++ $breakpoints = $this->viewConfig->getViewConfig()->getVarValue( ++ 'Magento_PageBuilder', ++ 'breakpoints/mobile/conditions' ++ ); ++ if ($breakpoints && count($breakpoints) > 0) { ++ $mobileBreakpoint = '@media only screen '; ++ foreach ($breakpoints as $key => $value) { ++ $mobileBreakpoint .= 'and (' . $key . ': ' . $value . ') '; ++ } ++ return rtrim($mobileBreakpoint); ++ } ++ return null; ++ } ++} +diff -Nuar a/vendor/magento/module-page-builder/Model/Stage/Config.php b/vendor/magento/module-page-builder/Model/Stage/Config.php +--- a/vendor/magento/module-page-builder/Model/Stage/Config.php ++++ b/vendor/magento/module-page-builder/Model/Stage/Config.php +@@ -135,7 +135,7 @@ class Config + 'content_types' => $this->getContentTypes(), + 'stage_config' => $this->data, + 'media_url' => $this->urlBuilder->getBaseUrl(['_type' => UrlInterface::URL_TYPE_MEDIA]), +- 'preview_url' => $this->frontendUrlBuilder->getUrl('pagebuilder/contenttype/preview'), ++ 'preview_url' => $this->urlBuilder->getUrl('pagebuilder/stage/preview'), + 'column_grid_default' => $this->scopeConfig->getValue(self::XML_PATH_COLUMN_GRID_DEFAULT), + 'column_grid_max' => $this->scopeConfig->getValue(self::XML_PATH_COLUMN_GRID_MAX), + 'can_use_inline_editing_on_stage' => $this->isWysiwygProvisionedForEditingOnStage(), +diff -Nuar a/vendor/magento/module-page-builder/Model/Stage/Preview.php b/vendor/magento/module-page-builder/Model/Stage/Preview.php +new file mode 100644 +--- /dev/null ++++ b/vendor/magento/module-page-builder/Model/Stage/Preview.php +@@ -0,0 +1,134 @@ ++emulation = $emulation; ++ $this->appState = $appState; ++ $this->design = $design; ++ $this->themeProvider = $themeProvider; ++ $this->storeManager = $storeManager; ++ $this->scopeConfig = $scopeConfig; ++ } ++ ++ /** ++ * @var bool ++ */ ++ private $isPreview; ++ ++ /** ++ * Retrieve the area in which the preview needs to be ran in ++ * ++ * @return string ++ */ ++ public function getPreviewArea() : string ++ { ++ return \Magento\Framework\App\Area::AREA_FRONTEND; ++ } ++ ++ /** ++ * Start Page Builder preview mode and emulate store front ++ * ++ * @param callable $callback ++ * @param int $storeId ++ * @return mixed ++ * @throws \Exception ++ */ ++ public function startPreviewMode($callback, $storeId = null) ++ { ++ $this->isPreview = true; ++ ++ if (!$storeId) { ++ $storeId = $this->storeManager->getDefaultStoreView()->getId(); ++ } ++ $this->emulation->startEnvironmentEmulation($storeId); ++ ++ return $this->appState->emulateAreaCode( ++ $this->getPreviewArea(), ++ function () use ($callback) { ++ $themeId = $this->scopeConfig->getValue( ++ 'design/theme/theme_id', ++ \Magento\Store\Model\ScopeInterface::SCOPE_STORE ++ ); ++ $theme = $this->themeProvider->getThemeById($themeId); ++ $this->design->setDesignTheme($theme, $this->getPreviewArea()); ++ ++ try { ++ $result = $callback(); ++ } catch (\Exception $e) { ++ $this->isPreview = false; ++ throw $e; ++ } ++ ++ $this->emulation->stopEnvironmentEmulation(); ++ return $result; ++ } ++ ); ++ } ++ ++ /** ++ * Determine if the system is in preview mode ++ * ++ * @return bool ++ */ ++ public function isPreviewMode() : bool ++ { ++ return $this->isPreview; ++ } ++} +diff -Nuar a/vendor/magento/module-page-builder/Model/Stage/Renderer/Block.php b/vendor/magento/module-page-builder/Model/Stage/Renderer/Block.php +--- a/vendor/magento/module-page-builder/Model/Stage/Renderer/Block.php ++++ b/vendor/magento/module-page-builder/Model/Stage/Renderer/Block.php +@@ -9,6 +9,7 @@ declare(strict_types=1); + namespace Magento\PageBuilder\Model\Stage\Renderer; + + use Magento\Framework\Controller\ResultFactory; ++use Magento\PageBuilder\Model\Filter\Template; + + /** + * Renders a block for the stage +@@ -31,20 +32,27 @@ class Block implements \Magento\PageBuilder\Model\Stage\RendererInterface + private $resultFactory; + + /** +- * Constructor +- * ++ * @var Template ++ */ ++ private $templateFilter; ++ ++ /** + * @param \Magento\PageBuilder\Model\Config $config + * @param \Magento\Framework\View\Element\BlockFactory $blockFactory + * @param ResultFactory $resultFactory ++ * @param Template|null $templateFilter + */ + public function __construct( + \Magento\PageBuilder\Model\Config $config, + \Magento\Framework\View\Element\BlockFactory $blockFactory, +- ResultFactory $resultFactory ++ ResultFactory $resultFactory, ++ Template $templateFilter = null + ) { + $this->config = $config; + $this->blockFactory = $blockFactory; + $this->resultFactory = $resultFactory; ++ $this->templateFilter = $templateFilter ?? \Magento\Framework\App\ObjectManager::getInstance() ++ ->get(\Magento\PageBuilder\Model\Filter\Template::class); + } + + /** +@@ -77,7 +85,7 @@ class Block implements \Magento\PageBuilder\Model\Stage\RendererInterface + $pageResult = $this->resultFactory->create(ResultFactory::TYPE_PAGE); + $pageResult->getLayout()->addBlock($backendBlockInstance); + +- $result['content'] = $backendBlockInstance->toHtml(); ++ $result['content'] = $this->templateFilter->filter($backendBlockInstance->toHtml()); + } + + return $result; +diff -Nuar a/vendor/magento/module-page-builder/Model/Stage/Renderer/CmsStaticBlock.php b/vendor/magento/module-page-builder/Model/Stage/Renderer/CmsStaticBlock.php +--- a/vendor/magento/module-page-builder/Model/Stage/Renderer/CmsStaticBlock.php ++++ b/vendor/magento/module-page-builder/Model/Stage/Renderer/CmsStaticBlock.php +@@ -9,6 +9,8 @@ declare(strict_types=1); + namespace Magento\PageBuilder\Model\Stage\Renderer; + + use Psr\Log\LoggerInterface; ++use Magento\PageBuilder\Model\Stage\HtmlFilter; ++use Magento\PageBuilder\Model\Filter\Template; + + /** + * Renders a CMS Block for the stage +@@ -33,28 +35,35 @@ class CmsStaticBlock implements \Magento\PageBuilder\Model\Stage\RendererInterfa + private $loggerInterface; + + /** +- * @var \Magento\PageBuilder\Model\Stage\HtmlFilter ++ * @var HtmlFilter + */ + private $htmlFilter; + + /** +- * CmsStaticBlock constructor. +- * ++ * @var Template ++ */ ++ private $templateFilter; ++ ++ /** + * @param \Magento\Cms\Model\ResourceModel\Block\CollectionFactory $blockCollectionFactory + * @param WidgetDirective $widgetDirectiveRenderer + * @param LoggerInterface $loggerInterface + * @param \Magento\PageBuilder\Model\Stage\HtmlFilter $htmlFilter ++ * @param \Magento\PageBuilder\Model\Filter\Template|null $templateFilter + */ + public function __construct( + \Magento\Cms\Model\ResourceModel\Block\CollectionFactory $blockCollectionFactory, + WidgetDirective $widgetDirectiveRenderer, + LoggerInterface $loggerInterface, +- \Magento\PageBuilder\Model\Stage\HtmlFilter $htmlFilter ++ HtmlFilter $htmlFilter, ++ Template $templateFilter = null + ) { + $this->blockCollectionFactory = $blockCollectionFactory; + $this->widgetDirectiveRenderer = $widgetDirectiveRenderer; + $this->loggerInterface = $loggerInterface; + $this->htmlFilter = $htmlFilter; ++ $this->templateFilter = $templateFilter ?? \Magento\Framework\App\ObjectManager::getInstance() ++ ->get(\Magento\PageBuilder\Model\Filter\Template::class); + } + + /** +@@ -96,7 +105,9 @@ class CmsStaticBlock implements \Magento\PageBuilder\Model\Stage\RendererInterfa + + if ($block->isActive()) { + $directiveResult = $this->widgetDirectiveRenderer->render($params); +- $result['content'] = $this->htmlFilter->filterHtml($directiveResult['content']); ++ $result['content'] = $this->htmlFilter->filterHtml( ++ $this->templateFilter->filter($directiveResult['content']) ++ ); + } else { + $result['error'] = __('Block disabled'); + } +diff -Nuar a/vendor/magento/module-page-builder/Model/Stage/Renderer/WidgetDirective.php b/vendor/magento/module-page-builder/Model/Stage/Renderer/WidgetDirective.php +--- a/vendor/magento/module-page-builder/Model/Stage/Renderer/WidgetDirective.php ++++ b/vendor/magento/module-page-builder/Model/Stage/Renderer/WidgetDirective.php +@@ -9,6 +9,7 @@ declare(strict_types=1); + namespace Magento\PageBuilder\Model\Stage\Renderer; + + use Magento\Store\Model\Store; ++use Magento\PageBuilder\Model\Filter\Template; + + /** + * Renders a widget directive for the stage +@@ -28,17 +29,24 @@ class WidgetDirective implements \Magento\PageBuilder\Model\Stage\RendererInterf + private $directiveFilter; + + /** +- * Constructor +- * ++ * @var Template ++ */ ++ private $templateFilter; ++ ++ /** + * @param \Magento\Store\Model\StoreManagerInterface $storeManager + * @param \Magento\Widget\Model\Template\Filter $directiveFilter ++ * @param Template $templateFilter + */ + public function __construct( + \Magento\Store\Model\StoreManagerInterface $storeManager, +- \Magento\Widget\Model\Template\Filter $directiveFilter ++ \Magento\Widget\Model\Template\Filter $directiveFilter, ++ Template $templateFilter = null + ) { + $this->storeManager = $storeManager; + $this->directiveFilter = $directiveFilter; ++ $this->templateFilter = $templateFilter ?? \Magento\Framework\App\ObjectManager::getInstance() ++ ->get(\Magento\PageBuilder\Model\Filter\Template::class); + } + + /** +@@ -61,7 +69,7 @@ class WidgetDirective implements \Magento\PageBuilder\Model\Stage\RendererInterf + try { + $result['content'] = $this->directiveFilter + ->setStoreId(Store::DEFAULT_STORE_ID) +- ->filter($params['directive']); ++ ->filter($this->templateFilter->filter($params['directive'])); + } catch (\Exception $e) { + $result['error'] = __($e->getMessage()); + } +diff -Nuar a/vendor/magento/module-page-builder/Plugin/Catalog/Block/Product/ProductsListPlugin.php b/vendor/magento/module-page-builder/Plugin/Catalog/Block/Product/ProductsListPlugin.php +new file mode 100644 +--- /dev/null ++++ b/vendor/magento/module-page-builder/Plugin/Catalog/Block/Product/ProductsListPlugin.php +@@ -0,0 +1,48 @@ ++stock = $stock; ++ } ++ ++ /** ++ * Allow to sort product collection ++ * ++ * @param ProductsList $subject ++ * @param Collection $result ++ * @return Collection ++ * @SuppressWarnings(PHPMD.UnusedFormalParameter) ++ */ ++ public function afterCreateCollection( ++ ProductsList $subject, ++ Collection $result ++ ) { ++ $this->stock->addIsInStockFilterToCollection($result); ++ return $result; ++ } ++} +diff -Nuar a/vendor/magento/module-page-builder/Plugin/DesignLoader.php b/vendor/magento/module-page-builder/Plugin/DesignLoader.php +new file mode 100644 +--- /dev/null ++++ b/vendor/magento/module-page-builder/Plugin/DesignLoader.php +@@ -0,0 +1,98 @@ ++designLoader = $designLoader; ++ $this->messageManager = $messageManager; ++ $this->appState = $appState; ++ $this->preview = $preview; ++ } ++ ++ /** ++ * Before create load the design files ++ * ++ * @param \Magento\Catalog\Block\Product\ImageFactory $subject ++ * @param Product $product ++ * @param string $imageId ++ * @param array|null $attributes ++ * @throws \Exception ++ * ++ * @SuppressWarnings(PHPMD.UnusedFormalParameter) ++ */ ++ public function beforeCreate( ++ \Magento\Catalog\Block\Product\ImageFactory $subject, ++ Product $product, ++ string $imageId, ++ array $attributes = null ++ ) { ++ if ($this->preview->isPreviewMode()) { ++ $this->appState->emulateAreaCode( ++ $this->preview->getPreviewArea(), ++ [$this, 'loadDesignConfig'] ++ ); ++ } ++ } ++ ++ /** ++ * Load the design config ++ */ ++ public function loadDesignConfig() ++ { ++ try { ++ $this->designLoader->load(); ++ } catch (\Magento\Framework\Exception\LocalizedException $e) { ++ if ($e->getPrevious() instanceof \Magento\Framework\Config\Dom\ValidationException) { ++ /** @var MessageInterface $message */ ++ $message = $this->messageManager ++ ->createMessage(MessageInterface::TYPE_ERROR) ++ ->setText($e->getMessage()); ++ $this->messageManager->addUniqueMessages([$message]); ++ } ++ } ++ } ++} +diff -Nuar a/vendor/magento/module-page-builder/Plugin/Filter/TemplatePlugin.php b/vendor/magento/module-page-builder/Plugin/Filter/TemplatePlugin.php +--- a/vendor/magento/module-page-builder/Plugin/Filter/TemplatePlugin.php ++++ b/vendor/magento/module-page-builder/Plugin/Filter/TemplatePlugin.php +@@ -7,50 +7,27 @@ declare(strict_types=1); + + namespace Magento\PageBuilder\Plugin\Filter; + +-use Magento\Store\Model\Store; +- + /** + * Plugin to the template filter to process any background images added by Page Builder + */ + class TemplatePlugin + { +- const BACKGROUND_IMAGE_PATTERN = '/data-background-images/si'; ++ const BACKGROUND_IMAGE_PATTERN = '/data-background-images=(?:\'|"){.+}(?:\'|")/si'; + + const HTML_CONTENT_TYPE_PATTERN = '/data-content-type="html"/si'; + + /** +- * @var \Magento\Framework\View\ConfigInterface +- */ +- private $viewConfig; +- +- /** +- * @var \Psr\Log\LoggerInterface +- */ +- private $logger; +- +- /** +- * @var \DOMDocument +- */ +- private $domDocument; +- +- /** +- * @var \Magento\Framework\Math\Random ++ * @var \Magento\PageBuilder\Model\Filter\Template + */ +- private $mathRandom; ++ private $templateFilter; + + /** +- * @param \Psr\Log\LoggerInterface $logger +- * @param \Magento\Framework\View\ConfigInterface $viewConfig +- * @param \Magento\Framework\Math\Random $mathRandom ++ * @param \Magento\PageBuilder\Model\Filter\Template $templateFilter + */ + public function __construct( +- \Psr\Log\LoggerInterface $logger, +- \Magento\Framework\View\ConfigInterface $viewConfig, +- \Magento\Framework\Math\Random $mathRandom ++ \Magento\PageBuilder\Model\Filter\Template $templateFilter + ) { +- $this->logger = $logger; +- $this->viewConfig = $viewConfig; +- $this->mathRandom = $mathRandom; ++ $this->templateFilter = $templateFilter; + } + + /** +@@ -64,284 +41,6 @@ class TemplatePlugin + */ + public function afterFilter(\Magento\Framework\Filter\Template $subject, string $result) : string + { +- $this->domDocument = false; +- +- // Validate if the filtered result requires background image processing +- if (preg_match(self::BACKGROUND_IMAGE_PATTERN, $result)) { +- $document = $this->getDomDocument($result); +- $this->generateBackgroundImageStyles($document); +- } +- +- // Process any HTML content types, they need to be decoded on the front-end +- if (preg_match(self::HTML_CONTENT_TYPE_PATTERN, $result)) { +- $document = $this->getDomDocument($result); +- $uniqueNodeNameToDecodedOuterHtmlMap = $this->generateDecodedHtmlPlaceholderMappingInDocument($document); +- } +- +- // If a document was retrieved we've modified the output so need to retrieve it from within the document +- if (isset($document)) { +- // Match the contents of the body from our generated document +- preg_match( +- '/(.+)<\/body><\/html>$/si', +- $document->saveHTML(), +- $matches +- ); +- +- if (!empty($matches)) { +- $docHtml = $matches[1]; +- +- if (isset($uniqueNodeNameToDecodedOuterHtmlMap)) { +- foreach ($uniqueNodeNameToDecodedOuterHtmlMap as $uniqueNodeName => $decodedOuterHtml) { +- $docHtml = str_replace( +- '<' . $uniqueNodeName . '>' . '', +- $decodedOuterHtml, +- $docHtml +- ); +- } +- } +- +- $result = $docHtml; +- } +- } +- +- return $result; +- } +- +- /** +- * Determine if custom variable directive's return value needs to be escaped and do so if true +- * +- * @param \Magento\Framework\Filter\Template $subject +- * @param \Closure $proceed +- * @param string[] $construction +- * @return string +- */ +- public function aroundCustomvarDirective( +- \Magento\Framework\Filter\Template $subject, +- \Closure $proceed, +- $construction +- ) { +- // Determine the need to escape the return value of observed method. +- // Admin context requires store ID of 0; in that context return value should be escaped +- $shouldEscape = $subject->getStoreId() !== null && (int) $subject->getStoreId() === Store::DEFAULT_STORE_ID; +- +- if (!$shouldEscape) { +- return $proceed($construction); +- } +- +- $result = $proceed($construction); +- +- return htmlspecialchars($result); +- } +- +- /** +- * Create a DOM document from a given string +- * +- * @param string $html +- * +- * @return \DOMDocument +- */ +- private function getDomDocument(string $html) : \DOMDocument +- { +- if (!$this->domDocument) { +- $this->domDocument = $this->createDomDocument($html); +- } +- +- return $this->domDocument; +- } +- +- /** +- * Create a DOMDocument from a string +- * +- * @param string $html +- * +- * @return \DOMDocument +- */ +- private function createDomDocument(string $html) : \DOMDocument +- { +- $domDocument = new \DOMDocument('1.0', 'UTF-8'); +- set_error_handler( +- function ($errorNumber, $errorString) { +- throw new \Exception($errorString, $errorNumber); +- } +- ); +- $string = mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'); +- try { +- libxml_use_internal_errors(true); +- $domDocument->loadHTML( +- '' . $string . '' +- ); +- libxml_clear_errors(); +- } catch (\Exception $e) { +- restore_error_handler(); +- $this->logger->critical($e); +- } +- restore_error_handler(); +- +- return $domDocument; +- } +- +- /** +- * Convert encoded HTML content types to placeholders and generate decoded outer html map for future replacement +- * +- * @param \DOMDocument $document +- * @return array - map of unique node name to decoded html +- */ +- private function generateDecodedHtmlPlaceholderMappingInDocument(\DOMDocument $document): array +- { +- $xpath = new \DOMXPath($document); +- +- // construct xpath query to fetch top-level ancestor html content type nodes +- /** @var $htmlContentTypeNodes \DOMNode[] */ +- $htmlContentTypeNodes = $xpath->query( +- '//*[@data-content-type="html" and not(@data-decoded="true")]' . +- '[not(ancestor::*[@data-content-type="html"])]' +- ); +- +- $uniqueNodeNameToDecodedOuterHtmlMap = []; +- +- foreach ($htmlContentTypeNodes as $htmlContentTypeNode) { +- // Set decoded attribute on all encoded html content types so we don't double decode; +- $htmlContentTypeNode->setAttribute('data-decoded', 'true'); +- +- // if nothing exists inside the node, continue +- if (!strlen(trim($htmlContentTypeNode->nodeValue))) { +- continue; +- } +- +- // clone html code content type to save reference to its attributes/outerHTML, which we are not going to +- // decode +- $clonedHtmlContentTypeNode = clone $htmlContentTypeNode; +- +- // clear inner contents of cloned node for replacement later with $decodedInnerHtml using sprintf; +- // we want to retain html content type node and avoid doing any manipulation on it +- $clonedHtmlContentTypeNode->nodeValue = '%s'; +- +- // remove potentially harmful attributes on html content type node itself +- while ($htmlContentTypeNode->attributes->length) { +- $htmlContentTypeNode->removeAttribute($htmlContentTypeNode->attributes->item(0)->name); +- } +- +- // decode outerHTML safely +- $preDecodedOuterHtml = $document->saveHTML($htmlContentTypeNode); +- +- // clear empty
wrapper around outerHTML to replace with $clonedHtmlContentTypeNode +- $decodedInnerHtml = preg_replace('#^<[^>]*>|]*>$#', '', html_entity_decode($preDecodedOuterHtml)); +- +- // Use $clonedHtmlContentTypeNode's placeholder to inject decoded inner html +- $decodedOuterHtml = sprintf($document->saveHTML($clonedHtmlContentTypeNode), $decodedInnerHtml); +- +- // generate unique node name element to replace with decoded html contents at end of processing; +- // goal is to create a document as few times as possible to prevent inadvertent parsing of contents as html +- // by the dom library +- $uniqueNodeName = $this->mathRandom->getRandomString(32, $this->mathRandom::CHARS_LOWERS); +- +- $uniqueNode = new \DOMElement($uniqueNodeName); +- $htmlContentTypeNode->parentNode->replaceChild($uniqueNode, $htmlContentTypeNode); +- +- $uniqueNodeNameToDecodedOuterHtmlMap[$uniqueNodeName] = $decodedOuterHtml; +- } +- +- return $uniqueNodeNameToDecodedOuterHtmlMap; +- } +- +- /** +- * Generate the CSS for any background images on the page +- * +- * @param \DOMDocument $document +- */ +- private function generateBackgroundImageStyles(\DOMDocument $document) : void +- { +- $xpath = new \DOMXPath($document); +- $nodes = $xpath->query('//*[@data-background-images]'); +- foreach ($nodes as $node) { +- /* @var \DOMElement $node */ +- $backgroundImages = $node->attributes->getNamedItem('data-background-images'); +- if ($backgroundImages->nodeValue !== '') { +- $elementClass = uniqid('background-image-'); +- $images = json_decode(stripslashes($backgroundImages->nodeValue), true); +- if (count($images) > 0) { +- $style = $xpath->document->createElement( +- 'style', +- $this->generateCssFromImages($elementClass, $images) +- ); +- $style->setAttribute('type', 'text/css'); +- $node->parentNode->appendChild($style); +- +- // Append our new class to the DOM element +- $classes = ''; +- if ($node->attributes->getNamedItem('class')) { +- $classes = $node->attributes->getNamedItem('class')->nodeValue . ' '; +- } +- $node->setAttribute('class', $classes . $elementClass); +- } +- } +- } +- } +- +- /** +- * Generate CSS based on the images array from our attribute +- * +- * @param string $elementClass +- * @param array $images +- * +- * @return string +- */ +- private function generateCssFromImages(string $elementClass, array $images) : string +- { +- $css = []; +- if (isset($images['desktop_image'])) { +- $css['.' . $elementClass] = [ +- 'background-image' => 'url(' . $images['desktop_image'] . ')', +- ]; +- } +- if (isset($images['mobile_image']) && $this->getMobileMediaQuery()) { +- $css[$this->getMobileMediaQuery()]['.' . $elementClass] = [ +- 'background-image' => 'url(' . $images['mobile_image'] . ')', +- ]; +- } +- return $this->cssFromArray($css); +- } +- +- /** +- * Generate a CSS string from an array +- * +- * @param array $css +- * +- * @return string +- */ +- private function cssFromArray(array $css) : string +- { +- $output = ''; +- foreach ($css as $selector => $body) { +- if (is_array($body)) { +- $output .= $selector . ' {'; +- $output .= $this->cssFromArray($body); +- $output .= '}'; +- } else { +- $output .= $selector . ': ' . $body . ';'; +- } +- } +- return $output; +- } +- +- /** +- * Generate the mobile media query from view configuration +- * +- * @return null|string +- */ +- private function getMobileMediaQuery() : ?string +- { +- $breakpoints = $this->viewConfig->getViewConfig()->getVarValue( +- 'Magento_PageBuilder', +- 'breakpoints/mobile/conditions' +- ); +- if ($breakpoints && count($breakpoints) > 0) { +- $mobileBreakpoint = '@media only screen '; +- foreach ($breakpoints as $key => $value) { +- $mobileBreakpoint .= 'and (' . $key . ': ' . $value . ') '; +- } +- return rtrim($mobileBreakpoint); +- } +- return null; ++ return $this->templateFilter->filter($result); + } + } +diff -Nuar a/vendor/magento/module-page-builder/etc/adminhtml/di.xml b/vendor/magento/module-page-builder/etc/adminhtml/di.xml +--- a/vendor/magento/module-page-builder/etc/adminhtml/di.xml ++++ b/vendor/magento/module-page-builder/etc/adminhtml/di.xml +@@ -6,6 +6,9 @@ + */ + --> + ++ ++ ++ + + + ns = pagebuilder_modal_form, index = modal +diff -Nuar a/vendor/magento/module-page-builder/etc/di.xml b/vendor/magento/module-page-builder/etc/di.xml +--- a/vendor/magento/module-page-builder/etc/di.xml ++++ b/vendor/magento/module-page-builder/etc/di.xml +@@ -131,6 +131,7 @@ + + pageBuilderProductCollectionFactory + ++ + + + +diff -Nuar a/vendor/magento/module-page-builder/view/adminhtml/layout/pagebuilder_stage_preview.xml b/vendor/magento/module-page-builder/view/adminhtml/layout/pagebuilder_stage_preview.xml +new file mode 100644 +--- /dev/null ++++ b/vendor/magento/module-page-builder/view/adminhtml/layout/pagebuilder_stage_preview.xml +@@ -0,0 +1,14 @@ ++ ++ ++ ++ ++ ++ ++ ++ ++ diff --git a/patches/PB-322__fix_pagebuilder_module__2.3.2-p1.patch b/patches/PB-322__fix_pagebuilder_module__2.3.2-p1.patch new file mode 100644 index 0000000..b806b01 --- /dev/null +++ b/patches/PB-322__fix_pagebuilder_module__2.3.2-p1.patch @@ -0,0 +1,1271 @@ +diff -Nuar a/vendor/magento/module-page-builder/Controller/Adminhtml/Stage/Preview.php b/vendor/magento/module-page-builder/Controller/Adminhtml/Stage/Preview.php +new file mode 100644 +--- /dev/null ++++ b/vendor/magento/module-page-builder/Controller/Adminhtml/Stage/Preview.php +@@ -0,0 +1,71 @@ ++rendererPool = $rendererPool; ++ $this->preview = $preview; ++ } ++ ++ /** ++ * Generates an HTML preview for the stage ++ * ++ * @return \Magento\Framework\App\ResponseInterface|\Magento\Framework\Controller\ResultInterface|mixed ++ * @throws \Exception ++ */ ++ public function execute() ++ { ++ return $this->preview->startPreviewMode( ++ function () { ++ $pageResult = $this->resultFactory->create(ResultFactory::TYPE_PAGE); ++ // Some template filters and directive processors expect this to be called in order to function. ++ $pageResult->initLayout(); ++ ++ $params = $this->getRequest()->getParams(); ++ $renderer = $this->rendererPool->getRenderer($params['role']); ++ $result = ['data' => $renderer->render($params)]; ++ ++ return $this->resultFactory->create(ResultFactory::TYPE_JSON)->setData($result); ++ } ++ ); ++ } ++} +diff -Nuar a/vendor/magento/module-page-builder/Controller/ContentType/Preview.php b/vendor/magento/module-page-builder/Controller/ContentType/Preview.php +--- a/vendor/magento/module-page-builder/Controller/ContentType/Preview.php ++++ b/vendor/magento/module-page-builder/Controller/ContentType/Preview.php +@@ -17,6 +17,8 @@ use Magento\Framework\App\Action\HttpPostActionInterface; + * This isn't placed within the adminhtml folder as it has to extend from the front-end controllers app action to + * ensure the content is rendered in the storefront scope. + * ++ * @deprecated use \Magento\PageBuilder\Controller\Adminhtml\Stage\Preview ++ * + * @api + */ + class Preview extends \Magento\Framework\App\Action\Action implements HttpPostActionInterface +diff -Nuar a/vendor/magento/module-page-builder/Model/Filter/Template.php b/vendor/magento/module-page-builder/Model/Filter/Template.php +new file mode 100644 +--- /dev/null ++++ b/vendor/magento/module-page-builder/Model/Filter/Template.php +@@ -0,0 +1,334 @@ ++logger = $logger; ++ $this->viewConfig = $viewConfig; ++ $this->mathRandom = $mathRandom; ++ $this->json = $json; ++ } ++ ++ /** ++ * After filter of template data apply transformations ++ * ++ * @param string $result ++ * ++ * @return string ++ * @SuppressWarnings(PHPMD.UnusedFormalParameter) ++ */ ++ public function filter(string $result) : string ++ { ++ $this->domDocument = false; ++ ++ // Validate if the filtered result requires background image processing ++ if (preg_match(\Magento\PageBuilder\Plugin\Filter\TemplatePlugin::BACKGROUND_IMAGE_PATTERN, $result)) { ++ $document = $this->getDomDocument($result); ++ $this->generateBackgroundImageStyles($document); ++ } ++ ++ // Process any HTML content types, they need to be decoded on the front-end ++ if (preg_match(\Magento\PageBuilder\Plugin\Filter\TemplatePlugin::HTML_CONTENT_TYPE_PATTERN, $result)) { ++ $document = $this->getDomDocument($result); ++ $uniqueNodeNameToDecodedOuterHtmlMap = $this->generateDecodedHtmlPlaceholderMappingInDocument($document); ++ } ++ ++ // If a document was retrieved we've modified the output so need to retrieve it from within the document ++ if (isset($document)) { ++ // Match the contents of the body from our generated document ++ preg_match( ++ '/(.+)<\/body><\/html>$/si', ++ $document->saveHTML(), ++ $matches ++ ); ++ ++ if (!empty($matches)) { ++ $docHtml = $matches[1]; ++ ++ // restore any encoded directives ++ $docHtml = preg_replace_callback( ++ '/=\"(%7B%7B[^"]*%7D%7D)\"/m', ++ function ($matches) { ++ return urldecode($matches[0]); ++ }, ++ $docHtml ++ ); ++ ++ if (isset($uniqueNodeNameToDecodedOuterHtmlMap)) { ++ foreach ($uniqueNodeNameToDecodedOuterHtmlMap as $uniqueNodeName => $decodedOuterHtml) { ++ $docHtml = str_replace( ++ '<' . $uniqueNodeName . '>' . '', ++ $decodedOuterHtml, ++ $docHtml ++ ); ++ } ++ } ++ ++ $result = $docHtml; ++ } ++ } ++ ++ return $result; ++ } ++ ++ /** ++ * Create a DOM document from a given string ++ * ++ * @param string $html ++ * ++ * @return \DOMDocument ++ */ ++ private function getDomDocument(string $html) : \DOMDocument ++ { ++ if (!$this->domDocument) { ++ $this->domDocument = $this->createDomDocument($html); ++ } ++ ++ return $this->domDocument; ++ } ++ ++ /** ++ * Create a DOMDocument from a string ++ * ++ * @param string $html ++ * ++ * @return \DOMDocument ++ */ ++ private function createDomDocument(string $html) : \DOMDocument ++ { ++ $domDocument = new \DOMDocument('1.0', 'UTF-8'); ++ set_error_handler( ++ function ($errorNumber, $errorString) { ++ throw new \DOMException($errorString, $errorNumber); ++ } ++ ); ++ $string = mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'); ++ try { ++ libxml_use_internal_errors(true); ++ $domDocument->loadHTML( ++ '' . $string . '' ++ ); ++ libxml_clear_errors(); ++ } catch (\Exception $e) { ++ restore_error_handler(); ++ $this->logger->critical($e); ++ } ++ restore_error_handler(); ++ ++ return $domDocument; ++ } ++ ++ /** ++ * Convert encoded HTML content types to placeholders and generate decoded outer html map for future replacement ++ * ++ * @param \DOMDocument $document ++ * @return array ++ * @throws \Magento\Framework\Exception\LocalizedException ++ */ ++ private function generateDecodedHtmlPlaceholderMappingInDocument(\DOMDocument $document): array ++ { ++ $xpath = new \DOMXPath($document); ++ ++ // construct xpath query to fetch top-level ancestor html content type nodes ++ /** @var $htmlContentTypeNodes \DOMNode[] */ ++ $htmlContentTypeNodes = $xpath->query( ++ '//*[@data-content-type="html" and not(@data-decoded="true")]' . ++ '[not(ancestor::*[@data-content-type="html"])]' ++ ); ++ ++ $uniqueNodeNameToDecodedOuterHtmlMap = []; ++ ++ foreach ($htmlContentTypeNodes as $htmlContentTypeNode) { ++ // Set decoded attribute on all encoded html content types so we don't double decode; ++ $htmlContentTypeNode->setAttribute('data-decoded', 'true'); ++ ++ // if nothing exists inside the node, continue ++ if (!strlen(trim($htmlContentTypeNode->nodeValue))) { ++ continue; ++ } ++ ++ // clone html code content type to save reference to its attributes/outerHTML, which we are not going to ++ // decode ++ $clonedHtmlContentTypeNode = clone $htmlContentTypeNode; ++ ++ // clear inner contents of cloned node for replacement later with $decodedInnerHtml using sprintf; ++ // we want to retain html content type node and avoid doing any manipulation on it ++ $clonedHtmlContentTypeNode->nodeValue = '%s'; ++ ++ // remove potentially harmful attributes on html content type node itself ++ while ($htmlContentTypeNode->attributes->length) { ++ $htmlContentTypeNode->removeAttribute($htmlContentTypeNode->attributes->item(0)->name); ++ } ++ ++ // decode outerHTML safely ++ $preDecodedOuterHtml = $document->saveHTML($htmlContentTypeNode); ++ ++ // clear empty
wrapper around outerHTML to replace with $clonedHtmlContentTypeNode ++ // phpcs:ignore Magento2.Functions.DiscouragedFunction ++ $decodedInnerHtml = preg_replace('#^<[^>]*>|]*>$#', '', html_entity_decode($preDecodedOuterHtml)); ++ ++ // Use $clonedHtmlContentTypeNode's placeholder to inject decoded inner html ++ $decodedOuterHtml = sprintf($document->saveHTML($clonedHtmlContentTypeNode), $decodedInnerHtml); ++ ++ // generate unique node name element to replace with decoded html contents at end of processing; ++ // goal is to create a document as few times as possible to prevent inadvertent parsing of contents as html ++ // by the dom library ++ $uniqueNodeName = $this->mathRandom->getRandomString(32, $this->mathRandom::CHARS_LOWERS); ++ ++ $uniqueNode = new \DOMElement($uniqueNodeName); ++ $htmlContentTypeNode->parentNode->replaceChild($uniqueNode, $htmlContentTypeNode); ++ ++ $uniqueNodeNameToDecodedOuterHtmlMap[$uniqueNodeName] = $decodedOuterHtml; ++ } ++ ++ return $uniqueNodeNameToDecodedOuterHtmlMap; ++ } ++ ++ /** ++ * Generate the CSS for any background images on the page ++ * ++ * @param \DOMDocument $document ++ */ ++ private function generateBackgroundImageStyles(\DOMDocument $document) : void ++ { ++ $xpath = new \DOMXPath($document); ++ $nodes = $xpath->query('//*[@data-background-images]'); ++ foreach ($nodes as $node) { ++ /* @var \DOMElement $node */ ++ $backgroundImages = $node->attributes->getNamedItem('data-background-images'); ++ if ($backgroundImages->nodeValue !== '') { ++ $elementClass = uniqid('background-image-'); ++ // phpcs:ignore Magento2.Functions.DiscouragedFunction ++ $images = $this->json->unserialize(stripslashes($backgroundImages->nodeValue)); ++ if (count($images) > 0) { ++ $style = $xpath->document->createElement( ++ 'style', ++ $this->generateCssFromImages($elementClass, $images) ++ ); ++ $style->setAttribute('type', 'text/css'); ++ $node->parentNode->appendChild($style); ++ ++ // Append our new class to the DOM element ++ $classes = ''; ++ if ($node->attributes->getNamedItem('class')) { ++ $classes = $node->attributes->getNamedItem('class')->nodeValue . ' '; ++ } ++ $node->setAttribute('class', $classes . $elementClass); ++ } ++ } ++ } ++ } ++ ++ /** ++ * Generate CSS based on the images array from our attribute ++ * ++ * @param string $elementClass ++ * @param array $images ++ * ++ * @return string ++ */ ++ private function generateCssFromImages(string $elementClass, array $images) : string ++ { ++ $css = []; ++ if (isset($images['desktop_image'])) { ++ $css['.' . $elementClass] = [ ++ 'background-image' => 'url(' . $images['desktop_image'] . ')', ++ ]; ++ } ++ if (isset($images['mobile_image']) && $this->getMobileMediaQuery()) { ++ $css[$this->getMobileMediaQuery()]['.' . $elementClass] = [ ++ 'background-image' => 'url(' . $images['mobile_image'] . ')', ++ ]; ++ } ++ return $this->cssFromArray($css); ++ } ++ ++ /** ++ * Generate a CSS string from an array ++ * ++ * @param array $css ++ * ++ * @return string ++ */ ++ private function cssFromArray(array $css) : string ++ { ++ $output = ''; ++ foreach ($css as $selector => $body) { ++ if (is_array($body)) { ++ $output .= $selector . ' {'; ++ $output .= $this->cssFromArray($body); ++ $output .= '}'; ++ } else { ++ $output .= $selector . ': ' . $body . ';'; ++ } ++ } ++ return $output; ++ } ++ ++ /** ++ * Generate the mobile media query from view configuration ++ * ++ * @return null|string ++ */ ++ private function getMobileMediaQuery() : ?string ++ { ++ $breakpoints = $this->viewConfig->getViewConfig()->getVarValue( ++ 'Magento_PageBuilder', ++ 'breakpoints/mobile/conditions' ++ ); ++ if ($breakpoints && count($breakpoints) > 0) { ++ $mobileBreakpoint = '@media only screen '; ++ foreach ($breakpoints as $key => $value) { ++ $mobileBreakpoint .= 'and (' . $key . ': ' . $value . ') '; ++ } ++ return rtrim($mobileBreakpoint); ++ } ++ return null; ++ } ++} +diff -Nuar a/vendor/magento/module-page-builder/Model/Stage/Config.php b/vendor/magento/module-page-builder/Model/Stage/Config.php +--- a/vendor/magento/module-page-builder/Model/Stage/Config.php ++++ b/vendor/magento/module-page-builder/Model/Stage/Config.php +@@ -135,9 +135,7 @@ class Config + 'content_types' => $this->getContentTypes(), + 'stage_config' => $this->data, + 'media_url' => $this->urlBuilder->getBaseUrl(['_type' => UrlInterface::URL_TYPE_MEDIA]), +- 'preview_url' => $this->frontendUrlBuilder +- ->addSessionParam() +- ->getUrl('pagebuilder/contenttype/preview'), ++ 'preview_url' => $this->urlBuilder->getUrl('pagebuilder/stage/preview'), + 'render_url' => $this->urlBuilder->getUrl('pagebuilder/stage/render'), + 'column_grid_default' => $this->scopeConfig->getValue(self::XML_PATH_COLUMN_GRID_DEFAULT), + 'column_grid_max' => $this->scopeConfig->getValue(self::XML_PATH_COLUMN_GRID_MAX), +diff -Nuar a/vendor/magento/module-page-builder/Model/Stage/Preview.php b/vendor/magento/module-page-builder/Model/Stage/Preview.php +new file mode 100644 +--- /dev/null ++++ b/vendor/magento/module-page-builder/Model/Stage/Preview.php +@@ -0,0 +1,134 @@ ++emulation = $emulation; ++ $this->appState = $appState; ++ $this->design = $design; ++ $this->themeProvider = $themeProvider; ++ $this->storeManager = $storeManager; ++ $this->scopeConfig = $scopeConfig; ++ } ++ ++ /** ++ * @var bool ++ */ ++ private $isPreview; ++ ++ /** ++ * Retrieve the area in which the preview needs to be ran in ++ * ++ * @return string ++ */ ++ public function getPreviewArea() : string ++ { ++ return \Magento\Framework\App\Area::AREA_FRONTEND; ++ } ++ ++ /** ++ * Start Page Builder preview mode and emulate store front ++ * ++ * @param callable $callback ++ * @param int $storeId ++ * @return mixed ++ * @throws \Exception ++ */ ++ public function startPreviewMode($callback, $storeId = null) ++ { ++ $this->isPreview = true; ++ ++ if (!$storeId) { ++ $storeId = $this->storeManager->getDefaultStoreView()->getId(); ++ } ++ $this->emulation->startEnvironmentEmulation($storeId); ++ ++ return $this->appState->emulateAreaCode( ++ $this->getPreviewArea(), ++ function () use ($callback) { ++ $themeId = $this->scopeConfig->getValue( ++ 'design/theme/theme_id', ++ \Magento\Store\Model\ScopeInterface::SCOPE_STORE ++ ); ++ $theme = $this->themeProvider->getThemeById($themeId); ++ $this->design->setDesignTheme($theme, $this->getPreviewArea()); ++ ++ try { ++ $result = $callback(); ++ } catch (\Exception $e) { ++ $this->isPreview = false; ++ throw $e; ++ } ++ ++ $this->emulation->stopEnvironmentEmulation(); ++ return $result; ++ } ++ ); ++ } ++ ++ /** ++ * Determine if the system is in preview mode ++ * ++ * @return bool ++ */ ++ public function isPreviewMode() : bool ++ { ++ return $this->isPreview; ++ } ++} +diff -Nuar a/vendor/magento/module-page-builder/Model/Stage/Renderer/Block.php b/vendor/magento/module-page-builder/Model/Stage/Renderer/Block.php +--- a/vendor/magento/module-page-builder/Model/Stage/Renderer/Block.php ++++ b/vendor/magento/module-page-builder/Model/Stage/Renderer/Block.php +@@ -9,6 +9,7 @@ declare(strict_types=1); + namespace Magento\PageBuilder\Model\Stage\Renderer; + + use Magento\Framework\Controller\ResultFactory; ++use Magento\PageBuilder\Model\Filter\Template; + + /** + * Renders a block for the stage +@@ -31,20 +32,27 @@ class Block implements \Magento\PageBuilder\Model\Stage\RendererInterface + private $resultFactory; + + /** +- * Constructor +- * ++ * @var Template ++ */ ++ private $templateFilter; ++ ++ /** + * @param \Magento\PageBuilder\Model\Config $config + * @param \Magento\Framework\View\Element\BlockFactory $blockFactory + * @param ResultFactory $resultFactory ++ * @param Template|null $templateFilter + */ + public function __construct( + \Magento\PageBuilder\Model\Config $config, + \Magento\Framework\View\Element\BlockFactory $blockFactory, +- ResultFactory $resultFactory ++ ResultFactory $resultFactory, ++ Template $templateFilter = null + ) { + $this->config = $config; + $this->blockFactory = $blockFactory; + $this->resultFactory = $resultFactory; ++ $this->templateFilter = $templateFilter ?? \Magento\Framework\App\ObjectManager::getInstance() ++ ->get(\Magento\PageBuilder\Model\Filter\Template::class); + } + + /** +@@ -77,7 +85,7 @@ class Block implements \Magento\PageBuilder\Model\Stage\RendererInterface + $pageResult = $this->resultFactory->create(ResultFactory::TYPE_PAGE); + $pageResult->getLayout()->addBlock($backendBlockInstance); + +- $result['content'] = $backendBlockInstance->toHtml(); ++ $result['content'] = $this->templateFilter->filter($backendBlockInstance->toHtml()); + } + + return $result; +diff -Nuar a/vendor/magento/module-page-builder/Model/Stage/Renderer/CmsStaticBlock.php b/vendor/magento/module-page-builder/Model/Stage/Renderer/CmsStaticBlock.php +--- a/vendor/magento/module-page-builder/Model/Stage/Renderer/CmsStaticBlock.php ++++ b/vendor/magento/module-page-builder/Model/Stage/Renderer/CmsStaticBlock.php +@@ -9,6 +9,8 @@ declare(strict_types=1); + namespace Magento\PageBuilder\Model\Stage\Renderer; + + use Psr\Log\LoggerInterface; ++use Magento\PageBuilder\Model\Stage\HtmlFilter; ++use Magento\PageBuilder\Model\Filter\Template; + + /** + * Renders a CMS Block for the stage +@@ -33,28 +35,35 @@ class CmsStaticBlock implements \Magento\PageBuilder\Model\Stage\RendererInterfa + private $loggerInterface; + + /** +- * @var \Magento\PageBuilder\Model\Stage\HtmlFilter ++ * @var HtmlFilter + */ + private $htmlFilter; + + /** +- * CmsStaticBlock constructor. +- * ++ * @var Template ++ */ ++ private $templateFilter; ++ ++ /** + * @param \Magento\Cms\Model\ResourceModel\Block\CollectionFactory $blockCollectionFactory + * @param WidgetDirective $widgetDirectiveRenderer + * @param LoggerInterface $loggerInterface + * @param \Magento\PageBuilder\Model\Stage\HtmlFilter $htmlFilter ++ * @param \Magento\PageBuilder\Model\Filter\Template|null $templateFilter + */ + public function __construct( + \Magento\Cms\Model\ResourceModel\Block\CollectionFactory $blockCollectionFactory, + WidgetDirective $widgetDirectiveRenderer, + LoggerInterface $loggerInterface, +- \Magento\PageBuilder\Model\Stage\HtmlFilter $htmlFilter ++ HtmlFilter $htmlFilter, ++ Template $templateFilter = null + ) { + $this->blockCollectionFactory = $blockCollectionFactory; + $this->widgetDirectiveRenderer = $widgetDirectiveRenderer; + $this->loggerInterface = $loggerInterface; + $this->htmlFilter = $htmlFilter; ++ $this->templateFilter = $templateFilter ?? \Magento\Framework\App\ObjectManager::getInstance() ++ ->get(\Magento\PageBuilder\Model\Filter\Template::class); + } + + /** +@@ -96,7 +105,9 @@ class CmsStaticBlock implements \Magento\PageBuilder\Model\Stage\RendererInterfa + + if ($block->isActive()) { + $directiveResult = $this->widgetDirectiveRenderer->render($params); +- $result['content'] = $this->htmlFilter->filterHtml($directiveResult['content']); ++ $result['content'] = $this->htmlFilter->filterHtml( ++ $this->templateFilter->filter($directiveResult['content']) ++ ); + } else { + $result['error'] = __('Block disabled'); + } +diff -Nuar a/vendor/magento/module-page-builder/Model/Stage/Renderer/WidgetDirective.php b/vendor/magento/module-page-builder/Model/Stage/Renderer/WidgetDirective.php +--- a/vendor/magento/module-page-builder/Model/Stage/Renderer/WidgetDirective.php ++++ b/vendor/magento/module-page-builder/Model/Stage/Renderer/WidgetDirective.php +@@ -9,6 +9,7 @@ declare(strict_types=1); + namespace Magento\PageBuilder\Model\Stage\Renderer; + + use Magento\Store\Model\Store; ++use Magento\PageBuilder\Model\Filter\Template; + + /** + * Renders a widget directive for the stage +@@ -28,17 +29,24 @@ class WidgetDirective implements \Magento\PageBuilder\Model\Stage\RendererInterf + private $directiveFilter; + + /** +- * Constructor +- * ++ * @var Template ++ */ ++ private $templateFilter; ++ ++ /** + * @param \Magento\Store\Model\StoreManagerInterface $storeManager + * @param \Magento\Widget\Model\Template\Filter $directiveFilter ++ * @param Template $templateFilter + */ + public function __construct( + \Magento\Store\Model\StoreManagerInterface $storeManager, +- \Magento\Widget\Model\Template\Filter $directiveFilter ++ \Magento\Widget\Model\Template\Filter $directiveFilter, ++ Template $templateFilter = null + ) { + $this->storeManager = $storeManager; + $this->directiveFilter = $directiveFilter; ++ $this->templateFilter = $templateFilter ?? \Magento\Framework\App\ObjectManager::getInstance() ++ ->get(\Magento\PageBuilder\Model\Filter\Template::class); + } + + /** +@@ -61,7 +69,7 @@ class WidgetDirective implements \Magento\PageBuilder\Model\Stage\RendererInterf + try { + $result['content'] = $this->directiveFilter + ->setStoreId(Store::DEFAULT_STORE_ID) +- ->filter($params['directive']); ++ ->filter($this->templateFilter->filter($params['directive'])); + } catch (\Exception $e) { + $result['error'] = __($e->getMessage()); + } +diff -Nuar a/vendor/magento/module-page-builder/Plugin/Catalog/Block/Product/ProductsListPlugin.php b/vendor/magento/module-page-builder/Plugin/Catalog/Block/Product/ProductsListPlugin.php +new file mode 100644 +--- /dev/null ++++ b/vendor/magento/module-page-builder/Plugin/Catalog/Block/Product/ProductsListPlugin.php +@@ -0,0 +1,48 @@ ++stock = $stock; ++ } ++ ++ /** ++ * Allow to sort product collection ++ * ++ * @param ProductsList $subject ++ * @param Collection $result ++ * @return Collection ++ * @SuppressWarnings(PHPMD.UnusedFormalParameter) ++ */ ++ public function afterCreateCollection( ++ ProductsList $subject, ++ Collection $result ++ ) { ++ $this->stock->addIsInStockFilterToCollection($result); ++ return $result; ++ } ++} +diff -Nuar a/vendor/magento/module-page-builder/Plugin/DesignLoader.php b/vendor/magento/module-page-builder/Plugin/DesignLoader.php +new file mode 100644 +--- /dev/null ++++ b/vendor/magento/module-page-builder/Plugin/DesignLoader.php +@@ -0,0 +1,98 @@ ++designLoader = $designLoader; ++ $this->messageManager = $messageManager; ++ $this->appState = $appState; ++ $this->preview = $preview; ++ } ++ ++ /** ++ * Before create load the design files ++ * ++ * @param \Magento\Catalog\Block\Product\ImageFactory $subject ++ * @param Product $product ++ * @param string $imageId ++ * @param array|null $attributes ++ * @throws \Exception ++ * ++ * @SuppressWarnings(PHPMD.UnusedFormalParameter) ++ */ ++ public function beforeCreate( ++ \Magento\Catalog\Block\Product\ImageFactory $subject, ++ Product $product, ++ string $imageId, ++ array $attributes = null ++ ) { ++ if ($this->preview->isPreviewMode()) { ++ $this->appState->emulateAreaCode( ++ $this->preview->getPreviewArea(), ++ [$this, 'loadDesignConfig'] ++ ); ++ } ++ } ++ ++ /** ++ * Load the design config ++ */ ++ public function loadDesignConfig() ++ { ++ try { ++ $this->designLoader->load(); ++ } catch (\Magento\Framework\Exception\LocalizedException $e) { ++ if ($e->getPrevious() instanceof \Magento\Framework\Config\Dom\ValidationException) { ++ /** @var MessageInterface $message */ ++ $message = $this->messageManager ++ ->createMessage(MessageInterface::TYPE_ERROR) ++ ->setText($e->getMessage()); ++ $this->messageManager->addUniqueMessages([$message]); ++ } ++ } ++ } ++} +diff -Nuar a/vendor/magento/module-page-builder/Plugin/Filter/TemplatePlugin.php b/vendor/magento/module-page-builder/Plugin/Filter/TemplatePlugin.php +--- a/vendor/magento/module-page-builder/Plugin/Filter/TemplatePlugin.php ++++ b/vendor/magento/module-page-builder/Plugin/Filter/TemplatePlugin.php +@@ -7,56 +7,29 @@ declare(strict_types=1); + + namespace Magento\PageBuilder\Plugin\Filter; + ++use Magento\Store\Model\Store; ++ + /** + * Plugin to the template filter to process any background images added by Page Builder + */ + class TemplatePlugin + { +- const BACKGROUND_IMAGE_PATTERN = '/data-background-images/si'; ++ const BACKGROUND_IMAGE_PATTERN = '/data-background-images=(?:\'|"){.+}(?:\'|")/si'; + + const HTML_CONTENT_TYPE_PATTERN = '/data-content-type="html"/si'; + + /** +- * @var \Magento\Framework\View\ConfigInterface +- */ +- private $viewConfig; +- +- /** +- * @var \Psr\Log\LoggerInterface +- */ +- private $logger; +- +- /** +- * @var \DOMDocument +- */ +- private $domDocument; +- +- /** +- * @var \Magento\Framework\Math\Random +- */ +- private $mathRandom; +- +- /** +- * @var \Magento\Framework\Serialize\Serializer\Json ++ * @var \Magento\PageBuilder\Model\Filter\Template + */ +- private $json; ++ private $templateFilter; + + /** +- * @param \Psr\Log\LoggerInterface $logger +- * @param \Magento\Framework\View\ConfigInterface $viewConfig +- * @param \Magento\Framework\Math\Random $mathRandom +- * @param \Magento\Framework\Serialize\Serializer\Json $json ++ * @param \Magento\PageBuilder\Model\Filter\Template $templateFilter + */ + public function __construct( +- \Psr\Log\LoggerInterface $logger, +- \Magento\Framework\View\ConfigInterface $viewConfig, +- \Magento\Framework\Math\Random $mathRandom, +- \Magento\Framework\Serialize\Serializer\Json $json ++ \Magento\PageBuilder\Model\Filter\Template $templateFilter + ) { +- $this->logger = $logger; +- $this->viewConfig = $viewConfig; +- $this->mathRandom = $mathRandom; +- $this->json = $json; ++ $this->templateFilter = $templateFilter; + } + + /** +@@ -70,260 +43,6 @@ class TemplatePlugin + */ + public function afterFilter(\Magento\Framework\Filter\Template $subject, string $result) : string + { +- $this->domDocument = false; +- +- // Validate if the filtered result requires background image processing +- if (preg_match(self::BACKGROUND_IMAGE_PATTERN, $result)) { +- $document = $this->getDomDocument($result); +- $this->generateBackgroundImageStyles($document); +- } +- +- // Process any HTML content types, they need to be decoded on the front-end +- if (preg_match(self::HTML_CONTENT_TYPE_PATTERN, $result)) { +- $document = $this->getDomDocument($result); +- $uniqueNodeNameToDecodedOuterHtmlMap = $this->generateDecodedHtmlPlaceholderMappingInDocument($document); +- } +- +- // If a document was retrieved we've modified the output so need to retrieve it from within the document +- if (isset($document)) { +- // Match the contents of the body from our generated document +- preg_match( +- '/(.+)<\/body><\/html>$/si', +- $document->saveHTML(), +- $matches +- ); +- +- if (!empty($matches)) { +- $docHtml = $matches[1]; +- +- if (isset($uniqueNodeNameToDecodedOuterHtmlMap)) { +- foreach ($uniqueNodeNameToDecodedOuterHtmlMap as $uniqueNodeName => $decodedOuterHtml) { +- $docHtml = str_replace( +- '<' . $uniqueNodeName . '>' . '', +- $decodedOuterHtml, +- $docHtml +- ); +- } +- } +- +- $result = $docHtml; +- } +- } +- +- return $result; +- } +- +- /** +- * Create a DOM document from a given string +- * +- * @param string $html +- * +- * @return \DOMDocument +- */ +- private function getDomDocument(string $html) : \DOMDocument +- { +- if (!$this->domDocument) { +- $this->domDocument = $this->createDomDocument($html); +- } +- +- return $this->domDocument; +- } +- +- /** +- * Create a DOMDocument from a string +- * +- * @param string $html +- * +- * @return \DOMDocument +- */ +- private function createDomDocument(string $html) : \DOMDocument +- { +- $domDocument = new \DOMDocument('1.0', 'UTF-8'); +- set_error_handler( +- function ($errorNumber, $errorString) { +- throw new \DOMException($errorString, $errorNumber); +- } +- ); +- $string = mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'); +- try { +- libxml_use_internal_errors(true); +- $domDocument->loadHTML( +- '' . $string . '' +- ); +- libxml_clear_errors(); +- } catch (\Exception $e) { +- restore_error_handler(); +- $this->logger->critical($e); +- } +- restore_error_handler(); +- +- return $domDocument; +- } +- +- /** +- * Convert encoded HTML content types to placeholders and generate decoded outer html map for future replacement +- * +- * @param \DOMDocument $document +- * @return array - map of unique node name to decoded html +- */ +- private function generateDecodedHtmlPlaceholderMappingInDocument(\DOMDocument $document): array +- { +- $xpath = new \DOMXPath($document); +- +- // construct xpath query to fetch top-level ancestor html content type nodes +- /** @var $htmlContentTypeNodes \DOMNode[] */ +- $htmlContentTypeNodes = $xpath->query( +- '//*[@data-content-type="html" and not(@data-decoded="true")]' . +- '[not(ancestor::*[@data-content-type="html"])]' +- ); +- +- $uniqueNodeNameToDecodedOuterHtmlMap = []; +- +- foreach ($htmlContentTypeNodes as $htmlContentTypeNode) { +- // Set decoded attribute on all encoded html content types so we don't double decode; +- $htmlContentTypeNode->setAttribute('data-decoded', 'true'); +- +- // if nothing exists inside the node, continue +- if (!strlen(trim($htmlContentTypeNode->nodeValue))) { +- continue; +- } +- +- // clone html code content type to save reference to its attributes/outerHTML, which we are not going to +- // decode +- $clonedHtmlContentTypeNode = clone $htmlContentTypeNode; +- +- // clear inner contents of cloned node for replacement later with $decodedInnerHtml using sprintf; +- // we want to retain html content type node and avoid doing any manipulation on it +- $clonedHtmlContentTypeNode->nodeValue = '%s'; +- +- // remove potentially harmful attributes on html content type node itself +- while ($htmlContentTypeNode->attributes->length) { +- $htmlContentTypeNode->removeAttribute($htmlContentTypeNode->attributes->item(0)->name); +- } +- +- // decode outerHTML safely +- $preDecodedOuterHtml = $document->saveHTML($htmlContentTypeNode); +- +- // clear empty
wrapper around outerHTML to replace with $clonedHtmlContentTypeNode +- // phpcs:ignore Magento2.Functions.DiscouragedFunction +- $decodedInnerHtml = preg_replace('#^<[^>]*>|]*>$#', '', html_entity_decode($preDecodedOuterHtml)); +- +- // Use $clonedHtmlContentTypeNode's placeholder to inject decoded inner html +- $decodedOuterHtml = sprintf($document->saveHTML($clonedHtmlContentTypeNode), $decodedInnerHtml); +- +- // generate unique node name element to replace with decoded html contents at end of processing; +- // goal is to create a document as few times as possible to prevent inadvertent parsing of contents as html +- // by the dom library +- $uniqueNodeName = $this->mathRandom->getRandomString(32, $this->mathRandom::CHARS_LOWERS); +- +- $uniqueNode = new \DOMElement($uniqueNodeName); +- $htmlContentTypeNode->parentNode->replaceChild($uniqueNode, $htmlContentTypeNode); +- +- $uniqueNodeNameToDecodedOuterHtmlMap[$uniqueNodeName] = $decodedOuterHtml; +- } +- +- return $uniqueNodeNameToDecodedOuterHtmlMap; +- } +- +- /** +- * Generate the CSS for any background images on the page +- * +- * @param \DOMDocument $document +- */ +- private function generateBackgroundImageStyles(\DOMDocument $document) : void +- { +- $xpath = new \DOMXPath($document); +- $nodes = $xpath->query('//*[@data-background-images]'); +- foreach ($nodes as $node) { +- /* @var \DOMElement $node */ +- $backgroundImages = $node->attributes->getNamedItem('data-background-images'); +- if ($backgroundImages->nodeValue !== '') { +- $elementClass = uniqid('background-image-'); +- // phpcs:ignore Magento2.Functions.DiscouragedFunction +- $images = $this->json->unserialize(stripslashes($backgroundImages->nodeValue)); +- if (count($images) > 0) { +- $style = $xpath->document->createElement( +- 'style', +- $this->generateCssFromImages($elementClass, $images) +- ); +- $style->setAttribute('type', 'text/css'); +- $node->parentNode->appendChild($style); +- +- // Append our new class to the DOM element +- $classes = ''; +- if ($node->attributes->getNamedItem('class')) { +- $classes = $node->attributes->getNamedItem('class')->nodeValue . ' '; +- } +- $node->setAttribute('class', $classes . $elementClass); +- } +- } +- } +- } +- +- /** +- * Generate CSS based on the images array from our attribute +- * +- * @param string $elementClass +- * @param array $images +- * +- * @return string +- */ +- private function generateCssFromImages(string $elementClass, array $images) : string +- { +- $css = []; +- if (isset($images['desktop_image'])) { +- $css['.' . $elementClass] = [ +- 'background-image' => 'url(' . $images['desktop_image'] . ')', +- ]; +- } +- if (isset($images['mobile_image']) && $this->getMobileMediaQuery()) { +- $css[$this->getMobileMediaQuery()]['.' . $elementClass] = [ +- 'background-image' => 'url(' . $images['mobile_image'] . ')', +- ]; +- } +- return $this->cssFromArray($css); +- } +- +- /** +- * Generate a CSS string from an array +- * +- * @param array $css +- * +- * @return string +- */ +- private function cssFromArray(array $css) : string +- { +- $output = ''; +- foreach ($css as $selector => $body) { +- if (is_array($body)) { +- $output .= $selector . ' {'; +- $output .= $this->cssFromArray($body); +- $output .= '}'; +- } else { +- $output .= $selector . ': ' . $body . ';'; +- } +- } +- return $output; +- } +- +- /** +- * Generate the mobile media query from view configuration +- * +- * @return null|string +- */ +- private function getMobileMediaQuery() : ?string +- { +- $breakpoints = $this->viewConfig->getViewConfig()->getVarValue( +- 'Magento_PageBuilder', +- 'breakpoints/mobile/conditions' +- ); +- if ($breakpoints && count($breakpoints) > 0) { +- $mobileBreakpoint = '@media only screen '; +- foreach ($breakpoints as $key => $value) { +- $mobileBreakpoint .= 'and (' . $key . ': ' . $value . ') '; +- } +- return rtrim($mobileBreakpoint); +- } +- return null; ++ return $this->templateFilter->filter($result); + } + } +diff -Nuar a/vendor/magento/module-page-builder/etc/adminhtml/di.xml b/vendor/magento/module-page-builder/etc/adminhtml/di.xml +--- a/vendor/magento/module-page-builder/etc/adminhtml/di.xml ++++ b/vendor/magento/module-page-builder/etc/adminhtml/di.xml +@@ -6,6 +6,9 @@ + */ + --> + ++ ++ ++ + + + ns = pagebuilder_modal_form, index = modal +diff -Nuar a/vendor/magento/module-page-builder/etc/di.xml b/vendor/magento/module-page-builder/etc/di.xml +--- a/vendor/magento/module-page-builder/etc/di.xml ++++ b/vendor/magento/module-page-builder/etc/di.xml +@@ -131,6 +131,7 @@ + + pageBuilderProductCollectionFactory + ++ + + + +diff -Nuar a/vendor/magento/module-page-builder/view/adminhtml/layout/pagebuilder_stage_preview.xml b/vendor/magento/module-page-builder/view/adminhtml/layout/pagebuilder_stage_preview.xml +new file mode 100644 +--- /dev/null ++++ b/vendor/magento/module-page-builder/view/adminhtml/layout/pagebuilder_stage_preview.xml +@@ -0,0 +1,14 @@ ++ ++ ++ ++ ++ ++ ++ ++ ++ diff --git a/patches/PB-323__fix_pagebuilder_module__2.3.3.patch b/patches/PB-323__fix_pagebuilder_module__2.3.3.patch new file mode 100644 index 0000000..3ec55eb --- /dev/null +++ b/patches/PB-323__fix_pagebuilder_module__2.3.3.patch @@ -0,0 +1,1280 @@ +diff -Nuar a/vendor/magento/module-page-builder/Controller/Adminhtml/Stage/Preview.php b/vendor/magento/module-page-builder/Controller/Adminhtml/Stage/Preview.php +new file mode 100644 +--- /dev/null ++++ b/vendor/magento/module-page-builder/Controller/Adminhtml/Stage/Preview.php +@@ -0,0 +1,71 @@ ++rendererPool = $rendererPool; ++ $this->preview = $preview; ++ } ++ ++ /** ++ * Generates an HTML preview for the stage ++ * ++ * @return \Magento\Framework\App\ResponseInterface|\Magento\Framework\Controller\ResultInterface|mixed ++ * @throws \Exception ++ */ ++ public function execute() ++ { ++ return $this->preview->startPreviewMode( ++ function () { ++ $pageResult = $this->resultFactory->create(ResultFactory::TYPE_PAGE); ++ // Some template filters and directive processors expect this to be called in order to function. ++ $pageResult->initLayout(); ++ ++ $params = $this->getRequest()->getParams(); ++ $renderer = $this->rendererPool->getRenderer($params['role']); ++ $result = ['data' => $renderer->render($params)]; ++ ++ return $this->resultFactory->create(ResultFactory::TYPE_JSON)->setData($result); ++ } ++ ); ++ } ++} +diff -Nuar a/vendor/magento/module-page-builder/Controller/ContentType/Preview.php b/vendor/magento/module-page-builder/Controller/ContentType/Preview.php +--- a/vendor/magento/module-page-builder/Controller/ContentType/Preview.php ++++ b/vendor/magento/module-page-builder/Controller/ContentType/Preview.php +@@ -17,6 +17,8 @@ use Magento\Framework\App\Action\HttpPostActionInterface; + * This isn't placed within the adminhtml folder as it has to extend from the front-end controllers app action to + * ensure the content is rendered in the storefront scope. + * ++ * @deprecated use \Magento\PageBuilder\Controller\Adminhtml\Stage\Preview ++ * + * @api + */ + class Preview extends \Magento\Framework\App\Action\Action implements HttpPostActionInterface +diff -Nuar a/vendor/magento/module-page-builder/Model/Filter/Template.php b/vendor/magento/module-page-builder/Model/Filter/Template.php +new file mode 100644 +--- /dev/null ++++ b/vendor/magento/module-page-builder/Model/Filter/Template.php +@@ -0,0 +1,334 @@ ++logger = $logger; ++ $this->viewConfig = $viewConfig; ++ $this->mathRandom = $mathRandom; ++ $this->json = $json; ++ } ++ ++ /** ++ * After filter of template data apply transformations ++ * ++ * @param string $result ++ * ++ * @return string ++ * @SuppressWarnings(PHPMD.UnusedFormalParameter) ++ */ ++ public function filter(string $result) : string ++ { ++ $this->domDocument = false; ++ ++ // Validate if the filtered result requires background image processing ++ if (preg_match(\Magento\PageBuilder\Plugin\Filter\TemplatePlugin::BACKGROUND_IMAGE_PATTERN, $result)) { ++ $document = $this->getDomDocument($result); ++ $this->generateBackgroundImageStyles($document); ++ } ++ ++ // Process any HTML content types, they need to be decoded on the front-end ++ if (preg_match(\Magento\PageBuilder\Plugin\Filter\TemplatePlugin::HTML_CONTENT_TYPE_PATTERN, $result)) { ++ $document = $this->getDomDocument($result); ++ $uniqueNodeNameToDecodedOuterHtmlMap = $this->generateDecodedHtmlPlaceholderMappingInDocument($document); ++ } ++ ++ // If a document was retrieved we've modified the output so need to retrieve it from within the document ++ if (isset($document)) { ++ // Match the contents of the body from our generated document ++ preg_match( ++ '/(.+)<\/body><\/html>$/si', ++ $document->saveHTML(), ++ $matches ++ ); ++ ++ if (!empty($matches)) { ++ $docHtml = $matches[1]; ++ ++ // restore any encoded directives ++ $docHtml = preg_replace_callback( ++ '/=\"(%7B%7B[^"]*%7D%7D)\"/m', ++ function ($matches) { ++ return urldecode($matches[0]); ++ }, ++ $docHtml ++ ); ++ ++ if (isset($uniqueNodeNameToDecodedOuterHtmlMap)) { ++ foreach ($uniqueNodeNameToDecodedOuterHtmlMap as $uniqueNodeName => $decodedOuterHtml) { ++ $docHtml = str_replace( ++ '<' . $uniqueNodeName . '>' . '', ++ $decodedOuterHtml, ++ $docHtml ++ ); ++ } ++ } ++ ++ $result = $docHtml; ++ } ++ } ++ ++ return $result; ++ } ++ ++ /** ++ * Create a DOM document from a given string ++ * ++ * @param string $html ++ * ++ * @return \DOMDocument ++ */ ++ private function getDomDocument(string $html) : \DOMDocument ++ { ++ if (!$this->domDocument) { ++ $this->domDocument = $this->createDomDocument($html); ++ } ++ ++ return $this->domDocument; ++ } ++ ++ /** ++ * Create a DOMDocument from a string ++ * ++ * @param string $html ++ * ++ * @return \DOMDocument ++ */ ++ private function createDomDocument(string $html) : \DOMDocument ++ { ++ $domDocument = new \DOMDocument('1.0', 'UTF-8'); ++ set_error_handler( ++ function ($errorNumber, $errorString) { ++ throw new \DOMException($errorString, $errorNumber); ++ } ++ ); ++ $string = mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'); ++ try { ++ libxml_use_internal_errors(true); ++ $domDocument->loadHTML( ++ '' . $string . '' ++ ); ++ libxml_clear_errors(); ++ } catch (\Exception $e) { ++ restore_error_handler(); ++ $this->logger->critical($e); ++ } ++ restore_error_handler(); ++ ++ return $domDocument; ++ } ++ ++ /** ++ * Convert encoded HTML content types to placeholders and generate decoded outer html map for future replacement ++ * ++ * @param \DOMDocument $document ++ * @return array ++ * @throws \Magento\Framework\Exception\LocalizedException ++ */ ++ private function generateDecodedHtmlPlaceholderMappingInDocument(\DOMDocument $document): array ++ { ++ $xpath = new \DOMXPath($document); ++ ++ // construct xpath query to fetch top-level ancestor html content type nodes ++ /** @var $htmlContentTypeNodes \DOMNode[] */ ++ $htmlContentTypeNodes = $xpath->query( ++ '//*[@data-content-type="html" and not(@data-decoded="true")]' . ++ '[not(ancestor::*[@data-content-type="html"])]' ++ ); ++ ++ $uniqueNodeNameToDecodedOuterHtmlMap = []; ++ ++ foreach ($htmlContentTypeNodes as $htmlContentTypeNode) { ++ // Set decoded attribute on all encoded html content types so we don't double decode; ++ $htmlContentTypeNode->setAttribute('data-decoded', 'true'); ++ ++ // if nothing exists inside the node, continue ++ if (!strlen(trim($htmlContentTypeNode->nodeValue))) { ++ continue; ++ } ++ ++ // clone html code content type to save reference to its attributes/outerHTML, which we are not going to ++ // decode ++ $clonedHtmlContentTypeNode = clone $htmlContentTypeNode; ++ ++ // clear inner contents of cloned node for replacement later with $decodedInnerHtml using sprintf; ++ // we want to retain html content type node and avoid doing any manipulation on it ++ $clonedHtmlContentTypeNode->nodeValue = '%s'; ++ ++ // remove potentially harmful attributes on html content type node itself ++ while ($htmlContentTypeNode->attributes->length) { ++ $htmlContentTypeNode->removeAttribute($htmlContentTypeNode->attributes->item(0)->name); ++ } ++ ++ // decode outerHTML safely ++ $preDecodedOuterHtml = $document->saveHTML($htmlContentTypeNode); ++ ++ // clear empty
wrapper around outerHTML to replace with $clonedHtmlContentTypeNode ++ // phpcs:ignore Magento2.Functions.DiscouragedFunction ++ $decodedInnerHtml = preg_replace('#^<[^>]*>|]*>$#', '', html_entity_decode($preDecodedOuterHtml)); ++ ++ // Use $clonedHtmlContentTypeNode's placeholder to inject decoded inner html ++ $decodedOuterHtml = sprintf($document->saveHTML($clonedHtmlContentTypeNode), $decodedInnerHtml); ++ ++ // generate unique node name element to replace with decoded html contents at end of processing; ++ // goal is to create a document as few times as possible to prevent inadvertent parsing of contents as html ++ // by the dom library ++ $uniqueNodeName = $this->mathRandom->getRandomString(32, $this->mathRandom::CHARS_LOWERS); ++ ++ $uniqueNode = new \DOMElement($uniqueNodeName); ++ $htmlContentTypeNode->parentNode->replaceChild($uniqueNode, $htmlContentTypeNode); ++ ++ $uniqueNodeNameToDecodedOuterHtmlMap[$uniqueNodeName] = $decodedOuterHtml; ++ } ++ ++ return $uniqueNodeNameToDecodedOuterHtmlMap; ++ } ++ ++ /** ++ * Generate the CSS for any background images on the page ++ * ++ * @param \DOMDocument $document ++ */ ++ private function generateBackgroundImageStyles(\DOMDocument $document) : void ++ { ++ $xpath = new \DOMXPath($document); ++ $nodes = $xpath->query('//*[@data-background-images]'); ++ foreach ($nodes as $node) { ++ /* @var \DOMElement $node */ ++ $backgroundImages = $node->attributes->getNamedItem('data-background-images'); ++ if ($backgroundImages->nodeValue !== '') { ++ $elementClass = uniqid('background-image-'); ++ // phpcs:ignore Magento2.Functions.DiscouragedFunction ++ $images = $this->json->unserialize(stripslashes($backgroundImages->nodeValue)); ++ if (count($images) > 0) { ++ $style = $xpath->document->createElement( ++ 'style', ++ $this->generateCssFromImages($elementClass, $images) ++ ); ++ $style->setAttribute('type', 'text/css'); ++ $node->parentNode->appendChild($style); ++ ++ // Append our new class to the DOM element ++ $classes = ''; ++ if ($node->attributes->getNamedItem('class')) { ++ $classes = $node->attributes->getNamedItem('class')->nodeValue . ' '; ++ } ++ $node->setAttribute('class', $classes . $elementClass); ++ } ++ } ++ } ++ } ++ ++ /** ++ * Generate CSS based on the images array from our attribute ++ * ++ * @param string $elementClass ++ * @param array $images ++ * ++ * @return string ++ */ ++ private function generateCssFromImages(string $elementClass, array $images) : string ++ { ++ $css = []; ++ if (isset($images['desktop_image'])) { ++ $css['.' . $elementClass] = [ ++ 'background-image' => 'url(' . $images['desktop_image'] . ')', ++ ]; ++ } ++ if (isset($images['mobile_image']) && $this->getMobileMediaQuery()) { ++ $css[$this->getMobileMediaQuery()]['.' . $elementClass] = [ ++ 'background-image' => 'url(' . $images['mobile_image'] . ')', ++ ]; ++ } ++ return $this->cssFromArray($css); ++ } ++ ++ /** ++ * Generate a CSS string from an array ++ * ++ * @param array $css ++ * ++ * @return string ++ */ ++ private function cssFromArray(array $css) : string ++ { ++ $output = ''; ++ foreach ($css as $selector => $body) { ++ if (is_array($body)) { ++ $output .= $selector . ' {'; ++ $output .= $this->cssFromArray($body); ++ $output .= '}'; ++ } else { ++ $output .= $selector . ': ' . $body . ';'; ++ } ++ } ++ return $output; ++ } ++ ++ /** ++ * Generate the mobile media query from view configuration ++ * ++ * @return null|string ++ */ ++ private function getMobileMediaQuery() : ?string ++ { ++ $breakpoints = $this->viewConfig->getViewConfig()->getVarValue( ++ 'Magento_PageBuilder', ++ 'breakpoints/mobile/conditions' ++ ); ++ if ($breakpoints && count($breakpoints) > 0) { ++ $mobileBreakpoint = '@media only screen '; ++ foreach ($breakpoints as $key => $value) { ++ $mobileBreakpoint .= 'and (' . $key . ': ' . $value . ') '; ++ } ++ return rtrim($mobileBreakpoint); ++ } ++ return null; ++ } ++} +diff -Nuar a/vendor/magento/module-page-builder/Model/Stage/Config.php b/vendor/magento/module-page-builder/Model/Stage/Config.php +--- a/vendor/magento/module-page-builder/Model/Stage/Config.php ++++ b/vendor/magento/module-page-builder/Model/Stage/Config.php +@@ -135,9 +135,7 @@ class Config + 'content_types' => $this->getContentTypes(), + 'stage_config' => $this->data, + 'media_url' => $this->urlBuilder->getBaseUrl(['_type' => UrlInterface::URL_TYPE_MEDIA]), +- 'preview_url' => $this->frontendUrlBuilder +- ->addSessionParam() +- ->getUrl('pagebuilder/contenttype/preview'), ++ 'preview_url' => $this->urlBuilder->getUrl('pagebuilder/stage/preview'), + 'render_url' => $this->urlBuilder->getUrl('pagebuilder/stage/render'), + 'column_grid_default' => $this->scopeConfig->getValue(self::XML_PATH_COLUMN_GRID_DEFAULT), + 'column_grid_max' => $this->scopeConfig->getValue(self::XML_PATH_COLUMN_GRID_MAX), +diff -Nuar a/vendor/magento/module-page-builder/Model/Stage/Preview.php b/vendor/magento/module-page-builder/Model/Stage/Preview.php +new file mode 100644 +--- /dev/null ++++ b/vendor/magento/module-page-builder/Model/Stage/Preview.php +@@ -0,0 +1,134 @@ ++emulation = $emulation; ++ $this->appState = $appState; ++ $this->design = $design; ++ $this->themeProvider = $themeProvider; ++ $this->storeManager = $storeManager; ++ $this->scopeConfig = $scopeConfig; ++ } ++ ++ /** ++ * @var bool ++ */ ++ private $isPreview; ++ ++ /** ++ * Retrieve the area in which the preview needs to be ran in ++ * ++ * @return string ++ */ ++ public function getPreviewArea() : string ++ { ++ return \Magento\Framework\App\Area::AREA_FRONTEND; ++ } ++ ++ /** ++ * Start Page Builder preview mode and emulate store front ++ * ++ * @param callable $callback ++ * @param int $storeId ++ * @return mixed ++ * @throws \Exception ++ */ ++ public function startPreviewMode($callback, $storeId = null) ++ { ++ $this->isPreview = true; ++ ++ if (!$storeId) { ++ $storeId = $this->storeManager->getDefaultStoreView()->getId(); ++ } ++ $this->emulation->startEnvironmentEmulation($storeId); ++ ++ return $this->appState->emulateAreaCode( ++ $this->getPreviewArea(), ++ function () use ($callback) { ++ $themeId = $this->scopeConfig->getValue( ++ 'design/theme/theme_id', ++ \Magento\Store\Model\ScopeInterface::SCOPE_STORE ++ ); ++ $theme = $this->themeProvider->getThemeById($themeId); ++ $this->design->setDesignTheme($theme, $this->getPreviewArea()); ++ ++ try { ++ $result = $callback(); ++ } catch (\Exception $e) { ++ $this->isPreview = false; ++ throw $e; ++ } ++ ++ $this->emulation->stopEnvironmentEmulation(); ++ return $result; ++ } ++ ); ++ } ++ ++ /** ++ * Determine if the system is in preview mode ++ * ++ * @return bool ++ */ ++ public function isPreviewMode() : bool ++ { ++ return $this->isPreview; ++ } ++} +diff -Nuar a/vendor/magento/module-page-builder/Model/Stage/Renderer/Block.php b/vendor/magento/module-page-builder/Model/Stage/Renderer/Block.php +--- a/vendor/magento/module-page-builder/Model/Stage/Renderer/Block.php ++++ b/vendor/magento/module-page-builder/Model/Stage/Renderer/Block.php +@@ -9,6 +9,7 @@ declare(strict_types=1); + namespace Magento\PageBuilder\Model\Stage\Renderer; + + use Magento\Framework\Controller\ResultFactory; ++use Magento\PageBuilder\Model\Filter\Template; + + /** + * Renders a block for the stage +@@ -31,20 +32,27 @@ class Block implements \Magento\PageBuilder\Model\Stage\RendererInterface + private $resultFactory; + + /** +- * Constructor +- * ++ * @var Template ++ */ ++ private $templateFilter; ++ ++ /** + * @param \Magento\PageBuilder\Model\Config $config + * @param \Magento\Framework\View\Element\BlockFactory $blockFactory + * @param ResultFactory $resultFactory ++ * @param Template|null $templateFilter + */ + public function __construct( + \Magento\PageBuilder\Model\Config $config, + \Magento\Framework\View\Element\BlockFactory $blockFactory, +- ResultFactory $resultFactory ++ ResultFactory $resultFactory, ++ Template $templateFilter = null + ) { + $this->config = $config; + $this->blockFactory = $blockFactory; + $this->resultFactory = $resultFactory; ++ $this->templateFilter = $templateFilter ?? \Magento\Framework\App\ObjectManager::getInstance() ++ ->get(\Magento\PageBuilder\Model\Filter\Template::class); + } + + /** +@@ -77,7 +85,7 @@ class Block implements \Magento\PageBuilder\Model\Stage\RendererInterface + $pageResult = $this->resultFactory->create(ResultFactory::TYPE_PAGE); + $pageResult->getLayout()->addBlock($backendBlockInstance); + +- $result['content'] = $backendBlockInstance->toHtml(); ++ $result['content'] = $this->templateFilter->filter($backendBlockInstance->toHtml()); + } + + return $result; +diff -Nuar a/vendor/magento/module-page-builder/Model/Stage/Renderer/CmsStaticBlock.php b/vendor/magento/module-page-builder/Model/Stage/Renderer/CmsStaticBlock.php +--- a/vendor/magento/module-page-builder/Model/Stage/Renderer/CmsStaticBlock.php ++++ b/vendor/magento/module-page-builder/Model/Stage/Renderer/CmsStaticBlock.php +@@ -9,6 +9,8 @@ declare(strict_types=1); + namespace Magento\PageBuilder\Model\Stage\Renderer; + + use Psr\Log\LoggerInterface; ++use Magento\PageBuilder\Model\Stage\HtmlFilter; ++use Magento\PageBuilder\Model\Filter\Template; + + /** + * Renders a CMS Block for the stage +@@ -33,28 +35,35 @@ class CmsStaticBlock implements \Magento\PageBuilder\Model\Stage\RendererInterfa + private $loggerInterface; + + /** +- * @var \Magento\PageBuilder\Model\Stage\HtmlFilter ++ * @var HtmlFilter + */ + private $htmlFilter; + + /** +- * CmsStaticBlock constructor. +- * ++ * @var Template ++ */ ++ private $templateFilter; ++ ++ /** + * @param \Magento\Cms\Model\ResourceModel\Block\CollectionFactory $blockCollectionFactory + * @param WidgetDirective $widgetDirectiveRenderer + * @param LoggerInterface $loggerInterface + * @param \Magento\PageBuilder\Model\Stage\HtmlFilter $htmlFilter ++ * @param \Magento\PageBuilder\Model\Filter\Template|null $templateFilter + */ + public function __construct( + \Magento\Cms\Model\ResourceModel\Block\CollectionFactory $blockCollectionFactory, + WidgetDirective $widgetDirectiveRenderer, + LoggerInterface $loggerInterface, +- \Magento\PageBuilder\Model\Stage\HtmlFilter $htmlFilter ++ HtmlFilter $htmlFilter, ++ Template $templateFilter = null + ) { + $this->blockCollectionFactory = $blockCollectionFactory; + $this->widgetDirectiveRenderer = $widgetDirectiveRenderer; + $this->loggerInterface = $loggerInterface; + $this->htmlFilter = $htmlFilter; ++ $this->templateFilter = $templateFilter ?? \Magento\Framework\App\ObjectManager::getInstance() ++ ->get(\Magento\PageBuilder\Model\Filter\Template::class); + } + + /** +@@ -96,7 +105,9 @@ class CmsStaticBlock implements \Magento\PageBuilder\Model\Stage\RendererInterfa + + if ($block->isActive()) { + $directiveResult = $this->widgetDirectiveRenderer->render($params); +- $result['content'] = $this->htmlFilter->filterHtml($directiveResult['content']); ++ $result['content'] = $this->htmlFilter->filterHtml( ++ $this->templateFilter->filter($directiveResult['content']) ++ ); + } else { + $result['error'] = __('Block disabled'); + } +diff -Nuar a/vendor/magento/module-page-builder/Model/Stage/Renderer/WidgetDirective.php b/vendor/magento/module-page-builder/Model/Stage/Renderer/WidgetDirective.php +--- a/vendor/magento/module-page-builder/Model/Stage/Renderer/WidgetDirective.php ++++ b/vendor/magento/module-page-builder/Model/Stage/Renderer/WidgetDirective.php +@@ -9,6 +9,7 @@ declare(strict_types=1); + namespace Magento\PageBuilder\Model\Stage\Renderer; + + use Magento\Store\Model\Store; ++use Magento\PageBuilder\Model\Filter\Template; + + /** + * Renders a widget directive for the stage +@@ -28,17 +29,24 @@ class WidgetDirective implements \Magento\PageBuilder\Model\Stage\RendererInterf + private $directiveFilter; + + /** +- * Constructor +- * ++ * @var Template ++ */ ++ private $templateFilter; ++ ++ /** + * @param \Magento\Store\Model\StoreManagerInterface $storeManager + * @param \Magento\Widget\Model\Template\Filter $directiveFilter ++ * @param Template $templateFilter + */ + public function __construct( + \Magento\Store\Model\StoreManagerInterface $storeManager, +- \Magento\Widget\Model\Template\Filter $directiveFilter ++ \Magento\Widget\Model\Template\Filter $directiveFilter, ++ Template $templateFilter = null + ) { + $this->storeManager = $storeManager; + $this->directiveFilter = $directiveFilter; ++ $this->templateFilter = $templateFilter ?? \Magento\Framework\App\ObjectManager::getInstance() ++ ->get(\Magento\PageBuilder\Model\Filter\Template::class); + } + + /** +@@ -61,7 +69,7 @@ class WidgetDirective implements \Magento\PageBuilder\Model\Stage\RendererInterf + try { + $result['content'] = $this->directiveFilter + ->setStoreId(Store::DEFAULT_STORE_ID) +- ->filter($params['directive']); ++ ->filter($this->templateFilter->filter($params['directive'])); + } catch (\Exception $e) { + $result['error'] = __($e->getMessage()); + } +diff -Nuar a/vendor/magento/module-page-builder/Plugin/Catalog/Block/Product/ProductsListPlugin.php b/vendor/magento/module-page-builder/Plugin/Catalog/Block/Product/ProductsListPlugin.php +--- a/vendor/magento/module-page-builder/Plugin/Catalog/Block/Product/ProductsListPlugin.php ++++ b/vendor/magento/module-page-builder/Plugin/Catalog/Block/Product/ProductsListPlugin.php +@@ -9,6 +9,7 @@ declare(strict_types=1); + namespace Magento\PageBuilder\Plugin\Catalog\Block\Product; + + use Magento\PageBuilder\Model\Catalog\Sorting; ++use Magento\CatalogInventory\Helper\Stock; + + /** + * Catalog Products List widget block plugin +@@ -20,15 +21,23 @@ class ProductsListPlugin + */ + private $sorting; + ++ /** ++ * @var Stock ++ */ ++ private $stock; ++ + /** + * ProductsListPlugin constructor. + * + * @param Sorting $sorting ++ * @param Stock $stock + */ + public function __construct( +- Sorting $sorting ++ Sorting $sorting, ++ Stock $stock + ) { + $this->sorting = $sorting; ++ $this->stock = $stock; + } + + /** +@@ -42,7 +51,7 @@ class ProductsListPlugin + \Magento\CatalogWidget\Block\Product\ProductsList $subject, + \Magento\Catalog\Model\ResourceModel\Product\Collection $result + ) { +- ++ $this->stock->addIsInStockFilterToCollection($result); + $sortOption = $subject->getData('sort_order'); + if (isset($sortOption)) { + $sortedResult = $this->sorting->applySorting($sortOption, $result); +diff -Nuar a/vendor/magento/module-page-builder/Plugin/DesignLoader.php b/vendor/magento/module-page-builder/Plugin/DesignLoader.php +new file mode 100644 +--- /dev/null ++++ b/vendor/magento/module-page-builder/Plugin/DesignLoader.php +@@ -0,0 +1,98 @@ ++designLoader = $designLoader; ++ $this->messageManager = $messageManager; ++ $this->appState = $appState; ++ $this->preview = $preview; ++ } ++ ++ /** ++ * Before create load the design files ++ * ++ * @param \Magento\Catalog\Block\Product\ImageFactory $subject ++ * @param Product $product ++ * @param string $imageId ++ * @param array|null $attributes ++ * @throws \Exception ++ * ++ * @SuppressWarnings(PHPMD.UnusedFormalParameter) ++ */ ++ public function beforeCreate( ++ \Magento\Catalog\Block\Product\ImageFactory $subject, ++ Product $product, ++ string $imageId, ++ array $attributes = null ++ ) { ++ if ($this->preview->isPreviewMode()) { ++ $this->appState->emulateAreaCode( ++ $this->preview->getPreviewArea(), ++ [$this, 'loadDesignConfig'] ++ ); ++ } ++ } ++ ++ /** ++ * Load the design config ++ */ ++ public function loadDesignConfig() ++ { ++ try { ++ $this->designLoader->load(); ++ } catch (\Magento\Framework\Exception\LocalizedException $e) { ++ if ($e->getPrevious() instanceof \Magento\Framework\Config\Dom\ValidationException) { ++ /** @var MessageInterface $message */ ++ $message = $this->messageManager ++ ->createMessage(MessageInterface::TYPE_ERROR) ++ ->setText($e->getMessage()); ++ $this->messageManager->addUniqueMessages([$message]); ++ } ++ } ++ } ++} +diff -Nuar a/vendor/magento/module-page-builder/Plugin/Filter/TemplatePlugin.php b/vendor/magento/module-page-builder/Plugin/Filter/TemplatePlugin.php +--- a/vendor/magento/module-page-builder/Plugin/Filter/TemplatePlugin.php ++++ b/vendor/magento/module-page-builder/Plugin/Filter/TemplatePlugin.php +@@ -7,56 +7,29 @@ declare(strict_types=1); + + namespace Magento\PageBuilder\Plugin\Filter; + ++use Magento\Store\Model\Store; ++ + /** + * Plugin to the template filter to process any background images added by Page Builder + */ + class TemplatePlugin + { +- const BACKGROUND_IMAGE_PATTERN = '/data-background-images/si'; ++ const BACKGROUND_IMAGE_PATTERN = '/data-background-images=(?:\'|"){.+}(?:\'|")/si'; + + const HTML_CONTENT_TYPE_PATTERN = '/data-content-type="html"/si'; + + /** +- * @var \Magento\Framework\View\ConfigInterface +- */ +- private $viewConfig; +- +- /** +- * @var \Psr\Log\LoggerInterface +- */ +- private $logger; +- +- /** +- * @var \DOMDocument +- */ +- private $domDocument; +- +- /** +- * @var \Magento\Framework\Math\Random +- */ +- private $mathRandom; +- +- /** +- * @var \Magento\Framework\Serialize\Serializer\Json ++ * @var \Magento\PageBuilder\Model\Filter\Template + */ +- private $json; ++ private $templateFilter; + + /** +- * @param \Psr\Log\LoggerInterface $logger +- * @param \Magento\Framework\View\ConfigInterface $viewConfig +- * @param \Magento\Framework\Math\Random $mathRandom +- * @param \Magento\Framework\Serialize\Serializer\Json $json ++ * @param \Magento\PageBuilder\Model\Filter\Template $templateFilter + */ + public function __construct( +- \Psr\Log\LoggerInterface $logger, +- \Magento\Framework\View\ConfigInterface $viewConfig, +- \Magento\Framework\Math\Random $mathRandom, +- \Magento\Framework\Serialize\Serializer\Json $json ++ \Magento\PageBuilder\Model\Filter\Template $templateFilter + ) { +- $this->logger = $logger; +- $this->viewConfig = $viewConfig; +- $this->mathRandom = $mathRandom; +- $this->json = $json; ++ $this->templateFilter = $templateFilter; + } + + /** +@@ -70,260 +43,6 @@ class TemplatePlugin + */ + public function afterFilter(\Magento\Framework\Filter\Template $subject, string $result) : string + { +- $this->domDocument = false; +- +- // Validate if the filtered result requires background image processing +- if (preg_match(self::BACKGROUND_IMAGE_PATTERN, $result)) { +- $document = $this->getDomDocument($result); +- $this->generateBackgroundImageStyles($document); +- } +- +- // Process any HTML content types, they need to be decoded on the front-end +- if (preg_match(self::HTML_CONTENT_TYPE_PATTERN, $result)) { +- $document = $this->getDomDocument($result); +- $uniqueNodeNameToDecodedOuterHtmlMap = $this->generateDecodedHtmlPlaceholderMappingInDocument($document); +- } +- +- // If a document was retrieved we've modified the output so need to retrieve it from within the document +- if (isset($document)) { +- // Match the contents of the body from our generated document +- preg_match( +- '/(.+)<\/body><\/html>$/si', +- $document->saveHTML(), +- $matches +- ); +- +- if (!empty($matches)) { +- $docHtml = $matches[1]; +- +- if (isset($uniqueNodeNameToDecodedOuterHtmlMap)) { +- foreach ($uniqueNodeNameToDecodedOuterHtmlMap as $uniqueNodeName => $decodedOuterHtml) { +- $docHtml = str_replace( +- '<' . $uniqueNodeName . '>' . '', +- $decodedOuterHtml, +- $docHtml +- ); +- } +- } +- +- $result = $docHtml; +- } +- } +- +- return $result; +- } +- +- /** +- * Create a DOM document from a given string +- * +- * @param string $html +- * +- * @return \DOMDocument +- */ +- private function getDomDocument(string $html) : \DOMDocument +- { +- if (!$this->domDocument) { +- $this->domDocument = $this->createDomDocument($html); +- } +- +- return $this->domDocument; +- } +- +- /** +- * Create a DOMDocument from a string +- * +- * @param string $html +- * +- * @return \DOMDocument +- */ +- private function createDomDocument(string $html) : \DOMDocument +- { +- $domDocument = new \DOMDocument('1.0', 'UTF-8'); +- set_error_handler( +- function ($errorNumber, $errorString) { +- throw new \DOMException($errorString, $errorNumber); +- } +- ); +- $string = mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'); +- try { +- libxml_use_internal_errors(true); +- $domDocument->loadHTML( +- '' . $string . '' +- ); +- libxml_clear_errors(); +- } catch (\Exception $e) { +- restore_error_handler(); +- $this->logger->critical($e); +- } +- restore_error_handler(); +- +- return $domDocument; +- } +- +- /** +- * Convert encoded HTML content types to placeholders and generate decoded outer html map for future replacement +- * +- * @param \DOMDocument $document +- * @return array - map of unique node name to decoded html +- */ +- private function generateDecodedHtmlPlaceholderMappingInDocument(\DOMDocument $document): array +- { +- $xpath = new \DOMXPath($document); +- +- // construct xpath query to fetch top-level ancestor html content type nodes +- /** @var $htmlContentTypeNodes \DOMNode[] */ +- $htmlContentTypeNodes = $xpath->query( +- '//*[@data-content-type="html" and not(@data-decoded="true")]' . +- '[not(ancestor::*[@data-content-type="html"])]' +- ); +- +- $uniqueNodeNameToDecodedOuterHtmlMap = []; +- +- foreach ($htmlContentTypeNodes as $htmlContentTypeNode) { +- // Set decoded attribute on all encoded html content types so we don't double decode; +- $htmlContentTypeNode->setAttribute('data-decoded', 'true'); +- +- // if nothing exists inside the node, continue +- if (!strlen(trim($htmlContentTypeNode->nodeValue))) { +- continue; +- } +- +- // clone html code content type to save reference to its attributes/outerHTML, which we are not going to +- // decode +- $clonedHtmlContentTypeNode = clone $htmlContentTypeNode; +- +- // clear inner contents of cloned node for replacement later with $decodedInnerHtml using sprintf; +- // we want to retain html content type node and avoid doing any manipulation on it +- $clonedHtmlContentTypeNode->nodeValue = '%s'; +- +- // remove potentially harmful attributes on html content type node itself +- while ($htmlContentTypeNode->attributes->length) { +- $htmlContentTypeNode->removeAttribute($htmlContentTypeNode->attributes->item(0)->name); +- } +- +- // decode outerHTML safely +- $preDecodedOuterHtml = $document->saveHTML($htmlContentTypeNode); +- +- // clear empty
wrapper around outerHTML to replace with $clonedHtmlContentTypeNode +- // phpcs:ignore Magento2.Functions.DiscouragedFunction +- $decodedInnerHtml = preg_replace('#^<[^>]*>|]*>$#', '', html_entity_decode($preDecodedOuterHtml)); +- +- // Use $clonedHtmlContentTypeNode's placeholder to inject decoded inner html +- $decodedOuterHtml = sprintf($document->saveHTML($clonedHtmlContentTypeNode), $decodedInnerHtml); +- +- // generate unique node name element to replace with decoded html contents at end of processing; +- // goal is to create a document as few times as possible to prevent inadvertent parsing of contents as html +- // by the dom library +- $uniqueNodeName = $this->mathRandom->getRandomString(32, $this->mathRandom::CHARS_LOWERS); +- +- $uniqueNode = new \DOMElement($uniqueNodeName); +- $htmlContentTypeNode->parentNode->replaceChild($uniqueNode, $htmlContentTypeNode); +- +- $uniqueNodeNameToDecodedOuterHtmlMap[$uniqueNodeName] = $decodedOuterHtml; +- } +- +- return $uniqueNodeNameToDecodedOuterHtmlMap; +- } +- +- /** +- * Generate the CSS for any background images on the page +- * +- * @param \DOMDocument $document +- */ +- private function generateBackgroundImageStyles(\DOMDocument $document) : void +- { +- $xpath = new \DOMXPath($document); +- $nodes = $xpath->query('//*[@data-background-images]'); +- foreach ($nodes as $node) { +- /* @var \DOMElement $node */ +- $backgroundImages = $node->attributes->getNamedItem('data-background-images'); +- if ($backgroundImages->nodeValue !== '') { +- $elementClass = uniqid('background-image-'); +- // phpcs:ignore Magento2.Functions.DiscouragedFunction +- $images = $this->json->unserialize(stripslashes($backgroundImages->nodeValue)); +- if (count($images) > 0) { +- $style = $xpath->document->createElement( +- 'style', +- $this->generateCssFromImages($elementClass, $images) +- ); +- $style->setAttribute('type', 'text/css'); +- $node->parentNode->appendChild($style); +- +- // Append our new class to the DOM element +- $classes = ''; +- if ($node->attributes->getNamedItem('class')) { +- $classes = $node->attributes->getNamedItem('class')->nodeValue . ' '; +- } +- $node->setAttribute('class', $classes . $elementClass); +- } +- } +- } +- } +- +- /** +- * Generate CSS based on the images array from our attribute +- * +- * @param string $elementClass +- * @param array $images +- * +- * @return string +- */ +- private function generateCssFromImages(string $elementClass, array $images) : string +- { +- $css = []; +- if (isset($images['desktop_image'])) { +- $css['.' . $elementClass] = [ +- 'background-image' => 'url(' . $images['desktop_image'] . ')', +- ]; +- } +- if (isset($images['mobile_image']) && $this->getMobileMediaQuery()) { +- $css[$this->getMobileMediaQuery()]['.' . $elementClass] = [ +- 'background-image' => 'url(' . $images['mobile_image'] . ')', +- ]; +- } +- return $this->cssFromArray($css); +- } +- +- /** +- * Generate a CSS string from an array +- * +- * @param array $css +- * +- * @return string +- */ +- private function cssFromArray(array $css) : string +- { +- $output = ''; +- foreach ($css as $selector => $body) { +- if (is_array($body)) { +- $output .= $selector . ' {'; +- $output .= $this->cssFromArray($body); +- $output .= '}'; +- } else { +- $output .= $selector . ': ' . $body . ';'; +- } +- } +- return $output; +- } +- +- /** +- * Generate the mobile media query from view configuration +- * +- * @return null|string +- */ +- private function getMobileMediaQuery() : ?string +- { +- $breakpoints = $this->viewConfig->getViewConfig()->getVarValue( +- 'Magento_PageBuilder', +- 'breakpoints/mobile/conditions' +- ); +- if ($breakpoints && count($breakpoints) > 0) { +- $mobileBreakpoint = '@media only screen '; +- foreach ($breakpoints as $key => $value) { +- $mobileBreakpoint .= 'and (' . $key . ': ' . $value . ') '; +- } +- return rtrim($mobileBreakpoint); +- } +- return null; ++ return $this->templateFilter->filter($result); + } + } +diff -Nuar a/vendor/magento/module-page-builder/composer.json b/vendor/magento/module-page-builder/composer.json +--- a/vendor/magento/module-page-builder/composer.json ++++ b/vendor/magento/module-page-builder/composer.json +@@ -9,6 +9,7 @@ + "magento/module-backend": "~101.0.3", + "magento/module-cms": "*", + "magento/module-catalog": "*", ++ "magento/module-catalog-inventory": "~100.3.3", + "magento/module-config": "~101.1.3", + "magento/module-ui": "*", + "magento/module-variable": "*", +diff -Nuar a/vendor/magento/module-page-builder/etc/adminhtml/di.xml b/vendor/magento/module-page-builder/etc/adminhtml/di.xml +--- a/vendor/magento/module-page-builder/etc/adminhtml/di.xml ++++ b/vendor/magento/module-page-builder/etc/adminhtml/di.xml +@@ -6,6 +6,9 @@ + */ + --> + ++ ++ ++ + + + ns = pagebuilder_modal_form, index = modal +diff -Nuar a/vendor/magento/module-page-builder/etc/module.xml b/vendor/magento/module-page-builder/etc/module.xml +--- a/vendor/magento/module-page-builder/etc/module.xml ++++ b/vendor/magento/module-page-builder/etc/module.xml +@@ -6,11 +6,12 @@ + */ + --> + +- ++ + + + + ++ + + + +diff -Nuar a/vendor/magento/module-page-builder/view/adminhtml/layout/pagebuilder_stage_preview.xml b/vendor/magento/module-page-builder/view/adminhtml/layout/pagebuilder_stage_preview.xml +new file mode 100644 +--- /dev/null ++++ b/vendor/magento/module-page-builder/view/adminhtml/layout/pagebuilder_stage_preview.xml +@@ -0,0 +1,14 @@ ++ ++ ++ ++ ++ ++ ++ ++ ++