├── .gitattributes ├── Api ├── CategoryUrlRetrieverInterface.php ├── CmsPageUrlRetrieverInterface.php ├── ProductUrlRetrieverInterface.php └── UrlRetrieverInterface.php ├── Block └── HrefLang.php ├── CHANGELOG.md ├── LICENSE ├── Model ├── Adapter.php ├── Adapter │ ├── Category.php │ ├── Page.php │ └── Product.php ├── AdapterInterface.php ├── BlockParser.php ├── Config.php ├── Property.php └── PropertyInterface.php ├── README.md ├── Service └── HrefLang │ ├── AlternativeUrlService.php │ ├── CategoryUrlRetriever.php │ ├── CmsPageUrlRetriever.php │ └── ProductUrlRetriever.php ├── composer.json ├── etc ├── di.xml └── module.xml ├── registration.php └── view └── frontend ├── layout └── default.xml └── templates └── hreflang.phtml /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /Api/CategoryUrlRetrieverInterface.php: -------------------------------------------------------------------------------- 1 | alternativeUrlService = $alternativeUrlService; 25 | } 26 | 27 | /** 28 | * @return array in format [en_us => $url] or [en => $url] 29 | */ 30 | public function getAlternatives() 31 | { 32 | $data = []; 33 | foreach ($this->getStores() as $store) { 34 | $url = $this->getStoreUrl($store); 35 | if ($url) { 36 | $data[$this->getLocaleCode($store)] = $url; 37 | } 38 | } 39 | return $data; 40 | } 41 | 42 | /** 43 | * @param Store $store 44 | * @return string 45 | */ 46 | private function getStoreUrl($store) 47 | { 48 | return $this->alternativeUrlService->getAlternativeUrl($store); 49 | } 50 | 51 | /** 52 | * @param StoreInterface $store 53 | * @return bool 54 | */ 55 | private function isCurrentStore($store) 56 | { 57 | return $store->getId() == $this->_storeManager->getStore()->getId(); 58 | } 59 | 60 | /** 61 | * @param StoreInterface $store 62 | * @return string 63 | */ 64 | private function getLocaleCode($store) 65 | { 66 | 67 | $localeCode = $this->_scopeConfig->getValue('general/locale/code', 'stores', $store->getId()); 68 | return str_replace('_', '-', strtolower($localeCode)); 69 | } 70 | 71 | /** 72 | * @return Store[] 73 | */ 74 | private function getStores() 75 | { 76 | $config = $this->_scopeConfig->getValue('seo/hreflang/same_website_only'); 77 | if ($config === null || $config === '1') { 78 | return $this->getSameWebsiteStores(); 79 | } 80 | else{ 81 | return $this->_storeManager->getStores(); 82 | } 83 | } 84 | 85 | /** 86 | * @return Store[] 87 | */ 88 | private function getSameWebsiteStores() 89 | { 90 | $stores = []; 91 | /** @var Website $website */ 92 | $website = $this->_storeManager->getWebsite(); 93 | foreach ($website->getGroups() as $group) { 94 | /** @var Group $group */ 95 | foreach ($group->getStores() as $store) { 96 | $stores[] = $store; 97 | } 98 | } 99 | return $stores; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Magento 2 HREF LANG SEO with Multi Store Support Changelog 2 | 3 | ## 1.1.1 - 2021-02-09 4 | - Fix Composer and README, add CHANGELOG file #3 5 | 6 | ## 1.1.0 - 2021-02-09 7 | - Update README and License files #2 8 | 9 | ## 1.0.0 - 2021-02-09 10 | - Make the module usable with Composer install #1 11 | 12 | ## 0.1.0 - 2019-12-08 13 | - Initial release -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 202021 BRUNO BUENO 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | The main author's name and GitHub repository link should be mentioned and proper credits given. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /Model/Adapter.php: -------------------------------------------------------------------------------- 1 | adapters = $adapters; 22 | $this->property = $property; 23 | } 24 | 25 | public function getProperty() : PropertyInterface 26 | { 27 | foreach ($this->adapters as $item) { 28 | /** @var AdapterInterface $item */ 29 | $property = $item->getProperty(); 30 | if ($property->hasData()) { 31 | return $property; 32 | } 33 | } 34 | return $this->property; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Model/Adapter/Category.php: -------------------------------------------------------------------------------- 1 | registry = $registry; 40 | $this->property = $property; 41 | $this->blockParser = $blockParser; 42 | } 43 | 44 | public function getProperty() : PropertyInterface 45 | { 46 | /** 47 | * @var $category \Magento\Catalog\Model\Category 48 | */ 49 | $category = $this->registry->registry('current_category'); 50 | if ($category) { 51 | $this->property->setTitle((string) $category->getName()); 52 | $this->property->setUrl((string) $category->getUrl()); 53 | 54 | foreach ($this->messageAttributes as $messageAttribute) { 55 | if ($category->getData($messageAttribute)) { 56 | $this->property->setDescription($category->getData($messageAttribute)); 57 | } 58 | } 59 | 60 | if ($category->hasLandingPage() && !$this->property->getProperty('description')) { 61 | $this->property->setDescription( 62 | $this->blockParser->getBlockContentById( 63 | (int) $category->getLandingPage() 64 | ) 65 | ); 66 | } 67 | 68 | if ($category->getImageUrl()) { 69 | $this->property->setImage((string) $category->getImageUrl()); 70 | } 71 | $this->property->addProperty('item', $category->getData(), Property::META_DATA_GROUP); 72 | } 73 | return $this->property; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Model/Adapter/Page.php: -------------------------------------------------------------------------------- 1 | property = $property; 39 | $this->page = $page; 40 | $this->url = $url; 41 | $this->filterProvider = $filterProvider; 42 | } 43 | 44 | public function getProperty() : PropertyInterface 45 | { 46 | if ($this->page->getId()) { 47 | $this->property->setTitle((string) $this->page->getTitle()); 48 | $this->property->setDescription( 49 | (string) $this->filterProvider->getBlockFilter()->filter($this->page->getContent()) 50 | ); 51 | $this->property->setUrl((string) $this->url->getUrl($this->page->getIdentifier())); 52 | $this->property->addProperty('item', $this->page->getData(), Property::META_DATA_GROUP); 53 | } 54 | return $this->property; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Model/Adapter/Product.php: -------------------------------------------------------------------------------- 1 | property = $property; 42 | $this->registry = $registry; 43 | $this->imageBuilder = $imageBuilder; 44 | } 45 | 46 | public function getProperty() : PropertyInterface 47 | { 48 | $product = $this->registry->registry('current_product'); 49 | if ($product) { 50 | $this->property->addProperty('og:type', 'og:product', 'product'); 51 | $this->property->setTitle((string) $product->getName()); 52 | 53 | foreach ($this->messageAttributes as $messageAttribute) { 54 | if ($product->getData($messageAttribute)) { 55 | $this->property->setDescription((string) $product->getData($messageAttribute)); 56 | } 57 | } 58 | 59 | if ($product->getImage()) { 60 | $this->property->setImage((string) $this->getImage($product, 'product_base_image')->getImageUrl()); 61 | } 62 | 63 | $this->property->setUrl($product->getProductUrl()); 64 | $this->property->addProperty('product:price:amount', (string) $product->getFinalPrice(), 'product'); 65 | $this->property->addProperty('item', $product->getData(), Property::META_DATA_GROUP); 66 | } 67 | return $this->property; 68 | } 69 | 70 | private function getImage(\Magento\Catalog\Model\Product $product, string $imageId, array $attributes = []) : Image 71 | { 72 | return $this->imageBuilder->setProduct($product) 73 | ->setImageId($imageId) 74 | ->setAttributes($attributes) 75 | ->create(); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Model/AdapterInterface.php: -------------------------------------------------------------------------------- 1 | blockRepository = $blockRepository; 18 | } 19 | 20 | public function getBlockContentById(int $blockId) : string 21 | { 22 | try { 23 | $cmsBlock = $this->blockRepository->getById($blockId); 24 | return html_entity_decode($cmsBlock->getData('content')) ?? ''; //@codingStandardsIgnoreLine 25 | } catch (\Exception $e) { 26 | return ''; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Model/Config.php: -------------------------------------------------------------------------------- 1 | scopeConfig = $scopeConfig; 28 | } 29 | 30 | public function isActive(string $configPath): bool 31 | { 32 | return $this->scopeConfig->isSetFlag( 33 | $configPath, 34 | ScopeInterface::SCOPE_STORE 35 | ); 36 | } 37 | 38 | private function getConfigValue(string $configPath) : string 39 | { 40 | $result = $this->scopeConfig->getValue( 41 | $configPath, 42 | ScopeInterface::SCOPE_STORE 43 | ); 44 | return $result ?? ''; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Model/Property.php: -------------------------------------------------------------------------------- 1 | prefix = $prefix; 45 | return $this; 46 | } 47 | 48 | /** 49 | * @param string $attributeName 50 | * @return $this 51 | */ 52 | public function setMetaAttributeName(string $attributeName) 53 | { 54 | $this->attributeName = $attributeName; 55 | return $this; 56 | } 57 | 58 | /** 59 | * @param string $title 60 | * @return $this 61 | */ 62 | public function setTitle(string $title) 63 | { 64 | return $this->addProperty('title', $this->getFilteredInput($title)); 65 | } 66 | 67 | /** 68 | * @param string $description 69 | * @return $this 70 | */ 71 | public function setDescription(string $description) 72 | { 73 | $description = $this->getFilteredInput($description); 74 | if (strlen($description) >= self::MAX_DESCRIPTION_LENGTH) { 75 | $description = substr($description, 0, (self::MAX_DESCRIPTION_LENGTH - 4)) . ' ...'; 76 | } 77 | return $this->addProperty( 78 | 'description', 79 | $description 80 | ); 81 | } 82 | 83 | /** 84 | * @param string $url 85 | * @return $this 86 | */ 87 | public function setUrl(string $url) 88 | { 89 | if (filter_var($url, FILTER_VALIDATE_URL) !== false) { 90 | return $this->addProperty('url', $url); 91 | } 92 | throw new \LogicException( 93 | sprintf( 94 | 'Not a valid URL: [%s]', 95 | $url 96 | ) 97 | ); 98 | } 99 | 100 | /** 101 | * @param string $image 102 | * @return $this 103 | */ 104 | public function setImage(string $image) 105 | { 106 | $extension = strtolower(pathinfo($image, PATHINFO_EXTENSION)); //@codingStandardsIgnoreLine 107 | if (in_array($extension, $this->validImageFormats)) { 108 | return $this->addProperty('image', $image); 109 | } 110 | throw new \LogicException( 111 | sprintf( 112 | 'Invalid image format provided: [%s], please use on of these [%s]', 113 | $extension, 114 | implode( 115 | ',', 116 | $this->validImageFormats 117 | ) 118 | ) 119 | ); 120 | } 121 | 122 | /** 123 | * @param string $imageAlt 124 | * @return $this 125 | */ 126 | public function setImageAlt(string $imageAlt) 127 | { 128 | return $this->addProperty('image:alt', htmlentities(strip_tags($imageAlt))); 129 | } 130 | 131 | /** 132 | * @param string $key 133 | * @param string|array $value 134 | * @param string $group 135 | * @return string 136 | */ 137 | public function addProperty(string $key, $value, string $group = self::DEFAULT_GROUP) 138 | { 139 | $this->properties[$group][$key] = $value; 140 | return $this; 141 | } 142 | 143 | public function getProperty(string $key, string $group = self::DEFAULT_GROUP) 144 | { 145 | return $this->properties[$group][$key] ?? ''; 146 | } 147 | 148 | public function removeProperty(string $key, string $group = self::DEFAULT_GROUP) 149 | { 150 | unset($this->properties[$group][$key]); 151 | return $this; 152 | } 153 | 154 | public function toHtml(string $group = self::DEFAULT_GROUP) : string 155 | { 156 | $html = $this->renderProperties($this->properties, $group); 157 | $this->resetValues($group); 158 | return $html; 159 | } 160 | 161 | public function hasData(string $group = self::DEFAULT_GROUP) : bool 162 | { 163 | if ($this->properties[$group] ?? false) { 164 | return true; 165 | } 166 | return false; 167 | } 168 | 169 | private function renderProperties(array $properties, string $group = self::DEFAULT_GROUP) : string 170 | { 171 | $html = []; 172 | 173 | if (isset($properties[$group])) { 174 | $properties = $properties[$group]; 175 | } 176 | 177 | foreach ($properties as $property => $value) { 178 | if ($property === self::META_DATA_GROUP) { 179 | continue; 180 | } 181 | if (is_array($value)) { 182 | $subList = $this->renderProperties($value); 183 | $html[] = $subList; 184 | } else { 185 | if (empty($value)) { 186 | continue; 187 | } 188 | $html[] = $this->getMetaTag($property, $value); 189 | } 190 | } 191 | return implode($html); 192 | } 193 | 194 | private function getMetaTag(string $key, string $value) : string 195 | { 196 | return sprintf( 197 | '%s', 198 | $this->attributeName, 199 | $this->prefix, 200 | strip_tags($key), 201 | strip_tags($value), 202 | PHP_EOL 203 | ); 204 | } 205 | 206 | private function getFilteredInput(string $input) : string 207 | { 208 | $input = trim(strip_tags(str_replace(["\r\n", "\r", "\n"], "", $input))); 209 | $input = preg_replace('/\s+/', ' ', $input); 210 | return htmlentities($input); 211 | } 212 | 213 | private function resetValues(string $group = self::DEFAULT_GROUP) 214 | { 215 | $this->properties[$group] = self::DEFAULT_PROPERTIES; 216 | $this->prefix = self::DEFAULT_PREFIX; 217 | $this->attributeName = self::DEFAULT_ATTRIBUTE_NAME; 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /Model/PropertyInterface.php: -------------------------------------------------------------------------------- 1 | 4 | 5 | [](https://GitHub.com/bruno-canada/magento2-hrefLang/releases/) [](https://github.com/bruno-canada/magento2-hrefLang/blob/master/LICENSE) [](https://github.com/bruno-canada/magento2-hrefLang/releases/) [](https://github.com/bruno-canada/magento2-hrefLang/network) 6 | 7 | 8 | 9 | --- 10 | 11 |
This Magento 2 multi store extension adds the alternate hreflang tag for: Pages, Product and Category.
12 |
13 |
This extension solves a SEO specific problem of content duplication for Google and other search engines.
27 | 28 |It automatically adds the HREFLANG tag to pages, product and category of Magento considering your multi store structure.
29 | 30 |Google Hreflang reference: https://support.google.com/webmasters/answer/189077?hl=en
31 |MOZ Hreflang reference: https://moz.com/learn/seo/hreflang-tag
32 | 33 | # Getting Started 34 | 35 | ## Prerequisites 36 | 37 | ``` 38 | PHP 7+ 39 | Magento 2 40 | Zend Framework 41 | Composer 1.10.16 42 | ``` 43 | 44 | ## Installing via Composer 45 | 46 | 1. Access your Magento 2 root directory 47 | 2. Run `composer require brunocanada/hreflang` 48 | 3. Double-check if it is installed and enabled, run `bin/magento module:status BrunoCanada_HrefLang` 49 | 50 | ## Manual Instalation 51 | 52 | 1) Download this package; 53 | 54 | 2) Create the following folder inside your Magento 2 installation 55 | 56 | ``` 57 | app/code/BrunoCanada/HrefLang 58 | ``` 59 | 60 | 3) Paste the files of this package inside the created folder (step 2) 61 | 62 | 4) Run the following command inside your Magento 2 installation: 63 | 64 | ``` 65 | $ bin/magento setup:upgrade 66 | ``` 67 | 68 | ## Module Management 69 | 70 | - Enable module: `bin/magento module:enable BrunoCanada_HrefLang` 71 | - Disable module: `bin/magento module:disable BrunoCanada_HrefLang` 72 | -------------------------------------------------------------------------------- /Service/HrefLang/AlternativeUrlService.php: -------------------------------------------------------------------------------- 1 | cmsPageUrlRetriever = $cmsPageUrlRetriever; 37 | $this->categoryUrlRetriever = $categoryUrlRetriever; 38 | $this->productUrlRetriever = $productUrlRetriever; 39 | $this->request = $request; 40 | } 41 | 42 | /** 43 | * @param Store $store 44 | * @return string 45 | */ 46 | public function getAlternativeUrl($store) 47 | { 48 | switch ($this->request->getFullActionName()) { 49 | case 'catalog_category_view': 50 | return $this->categoryUrlRetriever->getUrl($this->request->getParam('id'), $store); 51 | case 'catalog_product_view': 52 | return $this->productUrlRetriever->getUrl($this->request->getParam('id'), $store); 53 | case 'cms_page_view': 54 | return $this->cmsPageUrlRetriever->getUrl($this->request->getParam('page_id'), $store); 55 | case 'cms_index_index': 56 | return $store->getBaseUrl(); 57 | } 58 | return ''; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Service/HrefLang/CategoryUrlRetriever.php: -------------------------------------------------------------------------------- 1 | categoryRepository = $categoryRepository; 32 | $this->categoryUrlPathGenerator = $categoryUrlPathGenerator; 33 | $this->registry = $registry; 34 | } 35 | 36 | /** 37 | * @param int $identifier the category ID 38 | * @param Store $store 39 | * @return string 40 | */ 41 | public function getUrl($identifier, $store) 42 | { 43 | /** @var Category $category */ 44 | $category = $this->registry->registry('category'); 45 | if(!$category) { 46 | $category = $this->categoryRepository->get($identifier, $store->getId()); 47 | } 48 | $path = $this->categoryUrlPathGenerator->getUrlPathWithSuffix($category); 49 | return $store->getBaseUrl() . $path; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Service/HrefLang/CmsPageUrlRetriever.php: -------------------------------------------------------------------------------- 1 | pageRepository = $pageRepository; 33 | $this->cmsPageUrlPathGenerator = $cmsPageUrlPathGenerator; 34 | $this->pageResource = $pageResource; 35 | } 36 | 37 | /** 38 | * @param int $identifier The page ID 39 | * @param Store $store 40 | * @return string 41 | */ 42 | public function getUrl($identifier, $store) 43 | { 44 | try { 45 | $page = $this->pageRepository->getById($identifier); 46 | $pageId = $this->pageResource->checkIdentifier($page->getIdentifier(), $store->getId()); 47 | $storePage = $this->pageRepository->getById($pageId); 48 | $path = $this->cmsPageUrlPathGenerator->getUrlPath($storePage); 49 | return $store->getBaseUrl() . $path; 50 | } catch (LocalizedException $e) { 51 | return ''; 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Service/HrefLang/ProductUrlRetriever.php: -------------------------------------------------------------------------------- 1 | productRepository = $productRepository; 32 | $this->productUrlPathGenerator = $productUrlPathGenerator; 33 | $this->registry = $registry; 34 | } 35 | 36 | /** 37 | * @param int $identifier the product ID 38 | * @param Store $store 39 | * @return string 40 | */ 41 | public function getUrl($identifier, $store) 42 | { 43 | /** @var Product $product */ 44 | $product = $this->registry->registry('product'); 45 | //if (!$product) 46 | { 47 | $product = $this->productRepository->getById($identifier, false, $store->getId()); 48 | } 49 | $path = $this->productUrlPathGenerator->getUrlPathWithSuffix($product, $store->getId()); 50 | return $store->getBaseUrl() . $path; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "brunocanada/hreflang", 3 | "description": "This Magento 2 multi store extension adds the alternate hreflang tag for: Pages, Product and Category.", 4 | "homepage": "https://github.com/bruno-canada/magento2-hrefLang", 5 | "type": "magento2-module", 6 | "license": "MIT", 7 | "keywords": [ 8 | "magento 2", 9 | "seo", 10 | "magento 2 seo", 11 | "href lang", 12 | "magento 2 multi store" 13 | ], 14 | "authors": [ 15 | { 16 | "name": "Bruno Bueno", 17 | "homepage": "https://github.com/bruno-canada/magento2-hrefLang" 18 | } 19 | ], 20 | "require": {}, 21 | "autoload": { 22 | "files": [ 23 | "registration.php" 24 | ], 25 | "psr-4": { 26 | "BrunoCanada\\HrefLang\\": "" 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /etc/di.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 |