array(), 'to' => array()) * * @var array|null */ protected $subst = null; /** * @var \Magento\Framework\View\File\CollectorInterface */ private $fileSource; /** * @var \Magento\Framework\View\File\CollectorInterface */ private $pageLayoutFileSource; /** * @var \Magento\Framework\App\State */ private $appState; /** * Cache keys to be able to generate different cache id for same handles * * @var LayoutCacheKeyInterface */ private $layoutCacheKey; /** * @var \Magento\Framework\Cache\FrontendInterface */ protected $cache; /** * @var \Magento\Framework\View\Model\Layout\Update\Validator */ protected $layoutValidator; /** * @var \Psr\Log\LoggerInterface */ protected $logger; /** * @var string */ protected $pageLayout; /** * @var string */ protected $cacheSuffix; /** * All processed handles used in this update * * @var array */ protected $allHandles = []; /** * Status for handle being processed * * @var int */ protected $handleProcessing = 1; /** * Status for processed handle * * @var int */ protected $handleProcessed = 2; /** * @var ReadFactory */ private $readFactory; /** * Init merge model * * @param \Magento\Framework\View\DesignInterface $design * @param \Magento\Framework\Url\ScopeResolverInterface $scopeResolver * @param \Magento\Framework\View\File\CollectorInterface $fileSource * @param \Magento\Framework\View\File\CollectorInterface $pageLayoutFileSource * @param \Magento\Framework\App\State $appState * @param \Magento\Framework\Cache\FrontendInterface $cache * @param \Magento\Framework\View\Model\Layout\Update\Validator $validator * @param \Psr\Log\LoggerInterface $logger * @param ReadFactory $readFactory , * @param \Magento\Framework\View\Design\ThemeInterface $theme Non-injectable theme instance * @param string $cacheSuffix * @param LayoutCacheKeyInterface $layoutCacheKey * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( \Magento\Framework\View\DesignInterface $design, \Magento\Framework\Url\ScopeResolverInterface $scopeResolver, \Magento\Framework\View\File\CollectorInterface $fileSource, \Magento\Framework\View\File\CollectorInterface $pageLayoutFileSource, \Magento\Framework\App\State $appState, \Magento\Framework\Cache\FrontendInterface $cache, \Magento\Framework\View\Model\Layout\Update\Validator $validator, \Psr\Log\LoggerInterface $logger, ReadFactory $readFactory, \Magento\Framework\View\Design\ThemeInterface $theme = null, $cacheSuffix = '', LayoutCacheKeyInterface $layoutCacheKey = null ) { $this->theme = $theme ?: $design->getDesignTheme(); $this->scope = $scopeResolver->getScope(); $this->fileSource = $fileSource; $this->pageLayoutFileSource = $pageLayoutFileSource; $this->appState = $appState; $this->cache = $cache; $this->layoutValidator = $validator; $this->logger = $logger; $this->readFactory = $readFactory; $this->cacheSuffix = $cacheSuffix; $this->layoutCacheKey = $layoutCacheKey ?: \Magento\Framework\App\ObjectManager::getInstance()->get(LayoutCacheKeyInterface::class); } /** * Add XML update instruction * * @param string $update * @return $this */ public function addUpdate($update) { if (!in_array($update, $this->updates)) { $this->updates[] = $update; } return $this; } /** * Get all registered updates as array * * @return array */ public function asArray() { return $this->updates; } /** * Get all registered updates as string * * @return string */ public function asString() { return implode('', $this->updates); } /** * Add handle(s) to update * * @param array|string $handleName * @return $this */ public function addHandle($handleName) { if (is_array($handleName)) { foreach ($handleName as $name) { $this->handles[$name] = 1; } } else { $this->handles[$handleName] = 1; } return $this; } /** * Remove handle from update * * @param string $handleName * @return $this */ public function removeHandle($handleName) { unset($this->handles[$handleName]); return $this; } /** * Get handle names array * * @return array */ public function getHandles() { return array_keys($this->handles); } /** * Add the first existing (declared in layout updates) page handle along with all parents to the update. * Return whether any page handles have been added or not. * * @param string[] $handlesToTry * @return bool */ public function addPageHandles(array $handlesToTry) { $handlesAdded = false; foreach ($handlesToTry as $handleName) { if (!$this->pageHandleExists($handleName)) { continue; } $handles[] = $handleName; $this->pageHandles = $handles; $this->addHandle($handles); $handlesAdded = true; } return $handlesAdded; } /** * Whether a page handle is declared in the system or not * * @param string $handleName * @return bool */ public function pageHandleExists($handleName) { return (bool)$this->_getPageHandleNode($handleName); } /** * @return string|null */ public function getPageLayout() { return $this->pageLayout; } /** * Check current handles if layout was defined on it * * @return bool */ public function isLayoutDefined() { $fullLayoutXml = $this->getFileLayoutUpdatesXml(); foreach ($this->getHandles() as $handle) { if ($fullLayoutXml->xpath("layout[@id='{$handle}']")) { return true; } } return false; } /** * Get handle xml node by handle name * * @param string $handleName * @return \Magento\Framework\View\Layout\Element|null */ protected function _getPageHandleNode($handleName) { /* quick validation for non-existing page types */ if (!$handleName) { return null; } $handles = $this->getFileLayoutUpdatesXml()->xpath("handle[@id='{$handleName}']"); if (empty($handles)) { return null; } $nodes = $this->getFileLayoutUpdatesXml()->xpath("/layouts/handle[@id=\"{$handleName}\"]"); return $nodes ? reset($nodes) : null; } /** * Retrieve used page handle names sorted from parent to child * * @return array */ public function getPageHandles() { return $this->pageHandles; } /** * Retrieve all design abstractions that exist in the system. * * Result format: * array( * 'handle_name_1' => array( * 'name' => 'handle_name_1', * 'label' => 'Handle Name 1', * 'design_abstraction' => self::DESIGN_ABSTRACTION_PAGE_LAYOUT or self::DESIGN_ABSTRACTION_CUSTOM * ), * // ... * ) * * @return array */ public function getAllDesignAbstractions() { $result = []; $conditions = [ '(@design_abstraction="' . self::DESIGN_ABSTRACTION_PAGE_LAYOUT . '" or @design_abstraction="' . self::DESIGN_ABSTRACTION_CUSTOM . '")', ]; $xpath = '/layouts/*[' . implode(' or ', $conditions) . ']'; $nodes = $this->getFileLayoutUpdatesXml()->xpath($xpath) ?: []; /** @var $node \Magento\Framework\View\Layout\Element */ foreach ($nodes as $node) { $name = $node->getAttribute('id'); $info = [ 'name' => $name, 'label' => (string)new \Magento\Framework\Phrase((string)$node->getAttribute('label')), 'design_abstraction' => $node->getAttribute('design_abstraction'), ]; $result[$name] = $info; } return $result; } /** * Retrieve the type of a page handle * * @param string $handleName * @return string|null */ public function getPageHandleType($handleName) { $node = $this->_getPageHandleNode($handleName); return $node ? $node->getAttribute('type') : null; } /** * Load layout updates by handles * * @param array|string $handles * @throws \Magento\Framework\Exception\LocalizedException * @return $this */ public function load($handles = []) { if (is_string($handles)) { $handles = [$handles]; } elseif (!is_array($handles)) { throw new \Magento\Framework\Exception\LocalizedException( new \Magento\Framework\Phrase('Invalid layout update handle') ); } $this->addHandle($handles); $cacheId = $this->getCacheId(); $cacheIdPageLayout = $cacheId . '_' . self::PAGE_LAYOUT_CACHE_SUFFIX; $result = $this->_loadCache($cacheId); if ($result) { $this->addUpdate($result); $this->pageLayout = $this->_loadCache($cacheIdPageLayout); foreach ($this->getHandles() as $handle) { $this->allHandles[$handle] = $this->handleProcessed; } return $this; } foreach ($this->getHandles() as $handle) { $this->_merge($handle); } $layout = $this->asString(); $this->_validateMergedLayout($cacheId, $layout); $this->_saveCache($layout, $cacheId, $this->getHandles()); $this->_saveCache((string)$this->pageLayout, $cacheIdPageLayout, $this->getHandles()); return $this; } /** * Validate merged layout * * @param string $cacheId * @param string $layout * @return $this * @throws \Exception */ protected function _validateMergedLayout($cacheId, $layout) { $layoutStr = '' . $layout . ''; try { $this->layoutValidator->isValid($layoutStr, Validator::LAYOUT_SCHEMA_MERGED, false); } catch (\Exception $e) { $messages = $this->layoutValidator->getMessages(); //Add first message to exception $message = reset($messages); $this->logger->info( 'Cache file with merged layout: ' . $cacheId . ' and handles ' . implode(', ', (array)$this->getHandles()) . ': ' . $message ); if ($this->appState->getMode() === \Magento\Framework\App\State::MODE_DEVELOPER) { throw $e; } } return $this; } /** * Get layout updates as \Magento\Framework\View\Layout\Element object * * @return \SimpleXMLElement */ public function asSimplexml() { $updates = trim($this->asString()); $updates = '' . '' . $updates . ''; return $this->_loadXmlString($updates); } /** * Return object representation of XML string * * @param string $xmlString * @return \SimpleXMLElement */ protected function _loadXmlString($xmlString) { return simplexml_load_string($xmlString, \Magento\Framework\View\Layout\Element::class); } /** * Merge layout update by handle * * @param string $handle * @return $this */ protected function _merge($handle) { if (!isset($this->allHandles[$handle])) { $this->allHandles[$handle] = $this->handleProcessing; $this->_fetchPackageLayoutUpdates($handle); $this->_fetchDbLayoutUpdates($handle); $this->allHandles[$handle] = $this->handleProcessed; } elseif ($this->allHandles[$handle] == $this->handleProcessing && $this->appState->getMode() === \Magento\Framework\App\State::MODE_DEVELOPER ) { $this->logger->info('Cyclic dependency in merged layout for handle: ' . $handle); } return $this; } /** * Add updates for the specified handle * * @param string $handle * @return bool */ protected function _fetchPackageLayoutUpdates($handle) { $_profilerKey = 'layout_package_update:' . $handle; \Magento\Framework\Profiler::start($_profilerKey); $layout = $this->getFileLayoutUpdatesXml(); foreach ($layout->xpath("*[self::handle or self::layout][@id='{$handle}']") as $updateXml) { $this->_fetchRecursiveUpdates($updateXml); $updateInnerXml = $updateXml->innerXml(); $this->validateUpdate($handle, $updateInnerXml); $this->addUpdate($updateInnerXml); } \Magento\Framework\Profiler::stop($_profilerKey); return true; } /** * Fetch & add layout updates for the specified handle from the database * * @param string $handle * @return bool */ protected function _fetchDbLayoutUpdates($handle) { $_profilerKey = 'layout_db_update: ' . $handle; \Magento\Framework\Profiler::start($_profilerKey); $updateStr = $this->getDbUpdateString($handle); if (!$updateStr) { \Magento\Framework\Profiler::stop($_profilerKey); return false; } $updateStr = '' . $updateStr . ''; $updateStr = $this->_substitutePlaceholders($updateStr); $updateXml = $this->_loadXmlString($updateStr); $this->_fetchRecursiveUpdates($updateXml); $updateInnerXml = $updateXml->innerXml(); $this->validateUpdate($handle, $updateInnerXml); $this->addUpdate($updateInnerXml); \Magento\Framework\Profiler::stop($_profilerKey); return (bool)$updateStr; } /** * Validate layout update content, throw exception on failure. * * This method is used as a hook for plugins. * * @param string $handle * @param string $updateXml * @return void * @throws \Exception * @SuppressWarnings(PHPMD.UnusedFormalParameter) * @codeCoverageIgnore */ public function validateUpdate($handle, $updateXml) { return; } /** * Substitute placeholders {{placeholder_name}} with their values in XML string * * @param string $xmlString * @return string */ protected function _substitutePlaceholders($xmlString) { if ($this->subst === null) { $placeholders = [ 'baseUrl' => $this->scope->getBaseUrl(), 'baseSecureUrl' => $this->scope->getBaseUrl(\Magento\Framework\UrlInterface::URL_TYPE_LINK, true), ]; $this->subst = []; foreach ($placeholders as $key => $value) { $this->subst['from'][] = '{{' . $key . '}}'; $this->subst['to'][] = $value; } } return str_replace($this->subst['from'], $this->subst['to'], $xmlString); } /** * Get update string * * @param string $handle * @return string * * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ public function getDbUpdateString($handle) { return null; } /** * Add handles declared as '' directives * * @param \SimpleXMLElement $updateXml * @return $this */ protected function _fetchRecursiveUpdates($updateXml) { foreach ($updateXml->children() as $child) { if (strtolower($child->getName()) == 'update' && isset($child['handle'])) { $this->_merge((string)$child['handle']); } } if (isset($updateXml['layout'])) { $this->pageLayout = (string)$updateXml['layout']; } return $this; } /** * Retrieve already merged layout updates from files for specified area/theme/package/store * * @return \Magento\Framework\View\Layout\Element */ public function getFileLayoutUpdatesXml() { if ($this->layoutUpdatesCache) { return $this->layoutUpdatesCache; } $cacheId = $this->generateCacheId($this->cacheSuffix); $result = $this->_loadCache($cacheId); if ($result) { $result = $this->_loadXmlString($result); } else { $result = $this->_loadFileLayoutUpdatesXml(); $this->_saveCache($result->asXML(), $cacheId); } $this->layoutUpdatesCache = $result; return $result; } /** * Generate cache identifier taking into account current area/package/theme/store * * @param string $suffix * @return string */ protected function generateCacheId($suffix = '') { return "LAYOUT_{$this->theme->getArea()}_STORE{$this->scope->getId()}_{$this->theme->getId()}{$suffix}"; } /** * Retrieve data from the cache, if the layout caching is allowed, or FALSE otherwise * * @param string $cacheId * @return string|bool */ protected function _loadCache($cacheId) { return $this->cache->load($cacheId); } /** * Save data to the cache, if the layout caching is allowed * * @param string $data * @param string $cacheId * @param array $cacheTags * @return void */ protected function _saveCache($data, $cacheId, array $cacheTags = []) { $this->cache->save($data, $cacheId, $cacheTags, null); } /** * Collect and merge layout updates from files * * @return \Magento\Framework\View\Layout\Element * @throws \Magento\Framework\Exception\LocalizedException */ protected function _loadFileLayoutUpdatesXml() { $layoutStr = ''; $theme = $this->_getPhysicalTheme($this->theme); $updateFiles = $this->fileSource->getFiles($theme, '*.xml'); $updateFiles = array_merge($updateFiles, $this->pageLayoutFileSource->getFiles($theme, '*.xml')); $useErrors = libxml_use_internal_errors(true); foreach ($updateFiles as $file) { /** @var $fileReader \Magento\Framework\Filesystem\File\Read */ $fileReader = $this->readFactory->create($file->getFilename(), DriverPool::FILE); $fileStr = $fileReader->readAll($file->getName()); $fileStr = $this->_substitutePlaceholders($fileStr); /** @var $fileXml \Magento\Framework\View\Layout\Element */ $fileXml = $this->_loadXmlString($fileStr); if (!$fileXml instanceof \Magento\Framework\View\Layout\Element) { $xmlErrors = $this->getXmlErrors(libxml_get_errors()); $this->_logXmlErrors($file->getFilename(), $xmlErrors); if ($this->appState->getMode() === State::MODE_DEVELOPER) { throw new ValidationException( new \Magento\Framework\Phrase( "Theme layout update file '%1' is not valid.\n%2", [ $file->getFilename(), implode("\n", $xmlErrors) ] ) ); } libxml_clear_errors(); continue; } if (!$file->isBase() && $fileXml->xpath(self::XPATH_HANDLE_DECLARATION)) { throw new \Magento\Framework\Exception\LocalizedException( new \Magento\Framework\Phrase( 'Theme layout update file \'%1\' must not declare page types.', [$file->getFileName()] ) ); } $handleName = basename($file->getFilename(), '.xml'); $tagName = $fileXml->getName() === 'layout' ? 'layout' : 'handle'; $handleAttributes = ' id="' . $handleName . '"' . $this->_renderXmlAttributes($fileXml); $handleStr = '<' . $tagName . $handleAttributes . '>' . $fileXml->innerXml() . ''; $layoutStr .= $handleStr; } libxml_use_internal_errors($useErrors); $layoutStr = '' . $layoutStr . ''; $layoutXml = $this->_loadXmlString($layoutStr); return $layoutXml; } /** * Log xml errors to system log * * @param string $fileName * @param array $xmlErrors * @return void */ protected function _logXmlErrors($fileName, $xmlErrors) { $this->logger->info( sprintf("Theme layout update file '%s' is not valid.\n%s", $fileName, implode("\n", $xmlErrors)) ); } /** * Get formatted xml errors * * @param array $libXmlErrors * @return array */ private function getXmlErrors($libXmlErrors) { $errors = []; if (count($libXmlErrors)) { foreach ($libXmlErrors as $error) { $errors[] = "{$error->message} Line: {$error->line}"; } } return $errors; } /** * Find the closest physical theme among ancestors and a theme itself * * @param \Magento\Framework\View\Design\ThemeInterface $theme * @return \Magento\Theme\Model\Theme * @throws \Magento\Framework\Exception\LocalizedException */ protected function _getPhysicalTheme(\Magento\Framework\View\Design\ThemeInterface $theme) { $result = $theme; while ($result !== null && $result->getId() && !$result->isPhysical()) { $result = $result->getParentTheme(); } if (!$result) { throw new \Magento\Framework\Exception\LocalizedException( new \Magento\Framework\Phrase( 'Unable to find a physical ancestor for a theme \'%1\'.', [$theme->getThemeTitle()] ) ); } return $result; } /** * Return attributes of XML node rendered as a string * * @param \SimpleXMLElement $node * @return string */ protected function _renderXmlAttributes(\SimpleXMLElement $node) { $result = ''; foreach ($node->attributes() as $attributeName => $attributeValue) { $result .= ' ' . $attributeName . '="' . $attributeValue . '"'; } return $result; } /** * Retrieve containers from the update handles that have been already loaded * * Result format: * array( * 'container_name' => 'Container Label', * // ... * ) * * @return array */ public function getContainers() { $result = []; $containerNodes = $this->asSimplexml()->xpath('//container'); /** @var $oneContainerNode \Magento\Framework\View\Layout\Element */ foreach ($containerNodes as $oneContainerNode) { $label = $oneContainerNode->getAttribute('label'); if ($label) { $result[$oneContainerNode->getAttribute('name')] = (string)new \Magento\Framework\Phrase($label); } } return $result; } /** * Cleanup circular references * * Destructor should be called explicitly in order to work around the PHP bug * https://bugs.php.net/bug.php?id=62468 */ public function __destruct() { $this->updates = []; $this->layoutUpdatesCache = null; } /** * @inheritdoc */ public function isCustomerDesignAbstraction(array $abstraction) { if (!isset($abstraction['design_abstraction'])) { return false; } return $abstraction['design_abstraction'] === self::DESIGN_ABSTRACTION_CUSTOM; } /** * @inheritdoc */ public function isPageLayoutDesignAbstraction(array $abstraction) { if (!isset($abstraction['design_abstraction'])) { return false; } return $abstraction['design_abstraction'] === self::DESIGN_ABSTRACTION_PAGE_LAYOUT; } /** * Retrieve theme * * @return \Magento\Framework\View\Design\ThemeInterface */ public function getTheme() { return $this->theme; } /** * Retrieve current scope * * @return \Magento\Framework\Url\ScopeInterface */ public function getScope() { return $this->scope; } /** * Return cache ID based current area/package/theme/store and handles * * @return string */ public function getCacheId() { $layoutCacheKeys = $this->layoutCacheKey->getCacheKeys(); return $this->generateCacheId(md5(implode('|', array_merge($this->getHandles(), $layoutCacheKeys)))); } }