├── .gitignore ├── resources ├── plugin_logo.png └── seo-preview.png ├── src ├── translations │ └── en │ │ └── seomate.php ├── variables │ ├── SchemaVariable.php │ └── SEOMateVariable.php ├── templates │ ├── _previews │ │ ├── google │ │ │ └── default.twig │ │ ├── twitter.twig │ │ ├── facebook.twig │ │ └── linkedin.twig │ ├── _output │ │ └── meta.twig │ ├── preview.twig │ ├── _sitemaps │ │ └── xsl.twig │ └── Preview.css ├── twigextensions │ └── SEOMateTwigExtension.php ├── services │ ├── SchemaService.php │ ├── RenderService.php │ ├── UrlsService.php │ ├── SitemapService.php │ └── MetaService.php ├── controllers │ ├── SitemapController.php │ └── PreviewController.php ├── models │ └── Settings.php ├── icon.svg ├── SEOMate.php └── helpers │ ├── SitemapHelper.php │ ├── CacheHelper.php │ └── SEOMateHelper.php ├── LICENSE.md ├── composer.json ├── CHANGELOG.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | composer.lock 3 | /vendor 4 | -------------------------------------------------------------------------------- /resources/plugin_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vaersaagod/seomate/HEAD/resources/plugin_logo.png -------------------------------------------------------------------------------- /resources/seo-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vaersaagod/seomate/HEAD/resources/seo-preview.png -------------------------------------------------------------------------------- /src/translations/en/seomate.php: -------------------------------------------------------------------------------- 1 | 6 |
7 | {% if title %} 8 | {{ title|striptags|raw }} 9 | {% endif %} 10 |
11 |
12 | {% if url %} 13 | {{ url | length > 85 ? url[:85] ~ '...' : url }} 14 | {% endif %} 15 |
16 | 17 |
18 | {% if description %} 19 | {{ (description | length > 150 ? description[:150] ~ '...' : description)|striptags|raw }} 20 | {% endif %} 21 |
22 | 23 | -------------------------------------------------------------------------------- /src/templates/_output/meta.twig: -------------------------------------------------------------------------------- 1 | {% set meta = seomate.meta %} 2 | 3 | {% if craft.app.getResponse().getStatusCode() < 400 %} 4 | 5 | {% if meta['og:url'] is not defined %}{% endif %} 6 | {% if meta['twitter:url'] is not defined %}{% endif %} 7 | {% if meta['og:locale'] is not defined %}{% endif %} 8 | {% endif %} 9 | 10 | {% for key, data in meta %} 11 | {%- if data != '' %} 12 | {{- renderMetaTag(key, data) }} 13 | {% endif %} 14 | {% endfor %} 15 | 16 | {% set alternateUrls = seomate.alternateUrls ?? null %} 17 | {% if alternateUrls %} 18 | {% for alternateUrl in alternateUrls -%} 19 | 20 | {% endfor %} 21 | {% endif %} 22 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Værsågod 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 | 9 | 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. 10 | -------------------------------------------------------------------------------- /src/twigextensions/SEOMateTwigExtension.php: -------------------------------------------------------------------------------- 1 | $this->renderMetaTag($key, $value)), 41 | ]; 42 | } 43 | 44 | public function renderMetaTag(string $key, string|array $value): Markup 45 | { 46 | return SEOMate::getInstance()->render->renderMetaTag($key, $value); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vaersaagod/seomate", 3 | "description": "SEO, mate! It's important.", 4 | "type": "craft-plugin", 5 | "version": "3.2.0", 6 | "keywords": [ 7 | "craft", 8 | "cms", 9 | "craftcms", 10 | "craft-plugin", 11 | "seomate", 12 | "seo", 13 | "json-ld", 14 | "meta" 15 | ], 16 | "support": { 17 | "docs": "https://github.com/vaersaagod/seomate/blob/master/README.md", 18 | "issues": "https://github.com/vaersaagod/seomate/issues" 19 | }, 20 | "license": "MIT", 21 | "authors": [ 22 | { 23 | "name": "Værsågod", 24 | "homepage": "https://www.vaersaagod.no/" 25 | } 26 | ], 27 | "require": { 28 | "php": "^8.2", 29 | "craftcms/cms": "^5.0.0", 30 | "spatie/schema-org": "^2.0|^3.0" 31 | }, 32 | "autoload": { 33 | "psr-4": { 34 | "vaersaagod\\seomate\\": "src/" 35 | } 36 | }, 37 | "extra": { 38 | "name": "SEOMate", 39 | "handle": "seomate", 40 | "hasCpSettings": false, 41 | "hasCpSection": false, 42 | "changelogUrl": "https://raw.githubusercontent.com/vaersaagod/seomate/master/CHANGELOG.md", 43 | "components": { 44 | }, 45 | "class": "vaersaagod\\seomate\\SEOMate" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/templates/preview.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 10 | 11 | 12 | {% set entry = entry ?? category ?? product ?? element ?? null %} 13 | 14 | {% if entry and entry.url|default %} 15 | 16 | {% set entry = entry|merge({ 17 | url: entry.url|split('?')|first 18 | }) %} 19 | 20 |
21 |

Google

22 |
23 | {{ include('seomate/_previews/google/default.twig') }} 24 |
25 |
26 | 27 |
28 |

Facebook

29 |
30 | {{ include('seomate/_previews/facebook.twig') }} 31 |
32 |
33 | 34 |
35 |

X (Twitter)

36 |
37 | {{ include('seomate/_previews/twitter.twig') }} 38 |
39 |
40 | 41 |
42 |

LinkedIn

43 |
44 | {{ include('seomate/_previews/linkedin.twig') }} 45 |
46 |
47 | 48 | {% endif %} 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /src/services/SchemaService.php: -------------------------------------------------------------------------------- 1 | position($i++); 47 | $breadcrumbListItem->item([ 48 | '@id' => $listItem['url'], 49 | 'name' => $listItem['name'], 50 | ]); 51 | 52 | $elements[] = $breadcrumbListItem; 53 | } 54 | 55 | $breadcrumbList->itemListElement($elements); 56 | 57 | return $breadcrumbList; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/templates/_previews/twitter.twig: -------------------------------------------------------------------------------- 1 | {% set image = meta['twitter:image'] ?? meta['og:image'] ?? meta.image ?? null %} 2 | {% set title = meta['twitter:title'] ?? meta.title ?? '' %} 3 | {% set description = meta['twitter:description'] ?? meta.description ?? '' %} 4 | 5 |
6 |
7 | 15 | 41 |
42 |
43 | -------------------------------------------------------------------------------- /src/templates/_previews/facebook.twig: -------------------------------------------------------------------------------- 1 | {% set image = meta['og:image'] ?? meta.image ?? null %} 2 | {% set title = meta['og:title'] ?? meta.title ?? '' %} 3 | {% set description = meta['og:description'] ?? meta.description ?? '' %} 4 | 5 |
6 |
7 | 16 | 43 | 44 |
45 | 46 |
47 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # SEOMate Changelog 2 | 3 | ## 3.2.0 - 2025-07-02 4 | ### Added 5 | - Added short syntax support for nested objects (`'someObject.someProperty'`), enabling support for Content Block fields (among other things). 6 | 7 | ## 3.1.1 - 2024-09-05 8 | ### Fixed 9 | - Fixed a regression error introduced in 3.1.0, where SEOMate could fail to render sitemap priority values 10 | 11 | ## 3.1.0 - 2024-08-24 12 | ### Changed 13 | - SEOMate now strips preview and token params from canonical and alternate URLs 14 | - SEOMate now uses elements' canonical ID when querying for alternates 15 | - SEOMate no longer reads or writes to the meta or sitemap caches for preview and/or tokenized requests 16 | ### Added 17 | - Added `seomate.home` (the current site's URL, stripped of preview and token params) 18 | 19 | ## 3.0.0 - 2024-08-02 20 | ### Added 21 | - Added support for [object templates](https://craftcms.com/docs/5.x/system/object-templates.html) and PHP closures in default meta and field profile definitions. 22 | - Added the ability to create field profiles specific to a particular section, entry type, category group or Commerce product type ([#86](https://github.com/vaersaagod/seomate/pull/86)) 23 | - Added the ability to be specific in the `previewEnabled` setting about which sections, entry types, category groups and/or Commerce product types should be SEO-previewable 24 | - Added support for custom meta templates and template overrides in SEO Preview to categories, nested entries and Commerce products, in addition to regular ol' section entries 25 | - Added support for PHP closures returning `true` or `false` for the `outputAlternate` config setting 26 | ### Changed 27 | - Bumped the `craftcms/cms` requirement to `^5.0.0` 28 | - Removed support for the "SEO Preview" for elements using legacy live preview 29 | ### Fixed 30 | - Fixed several cases where SEOMate could attempt to use string values as callables 31 | -------------------------------------------------------------------------------- /src/variables/SEOMateVariable.php: -------------------------------------------------------------------------------- 1 | render->renderMetaTag($key, $value); 33 | } 34 | 35 | public function breadcrumbSchema(array $breadcrumbArray): Markup 36 | { 37 | $breadcrumbArray = array_map(static function (array $crumb) { 38 | $crumb['url'] = SEOMateHelper::stripTokenParams($crumb['url']); 39 | return $crumb; 40 | }, $breadcrumbArray); 41 | $breadcrumbList = SEOMate::getInstance()->schema->breadcrumb($breadcrumbArray); 42 | return Template::raw($breadcrumbList->toScript()); 43 | } 44 | 45 | /** 46 | * @throws \Throwable 47 | */ 48 | public function getMeta(array $config = []): array 49 | { 50 | $context = array_merge(['seomate' => $config], Craft::$app->getView()->getTwig()->getGlobals()); 51 | $meta = SEOMate::getInstance()->meta->getContextMeta($context); 52 | $canonicalUrl = SEOMate::getInstance()->urls->getCanonicalUrl($context); 53 | $alternateUrls = SEOMate::getInstance()->urls->getAlternateUrls($context); 54 | $home = SEOMate::getInstance()->urls->getHomeUrl(); 55 | return [ 56 | 'meta' => $meta, 57 | 'canonicalUrl' => $canonicalUrl, 58 | 'alternateUrls' => $alternateUrls, 59 | 'home' => $home, 60 | ]; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/services/RenderService.php: -------------------------------------------------------------------------------- 1 | getSettings(); 36 | $tagTemplateMap = SEOMateHelper::expandMap($settings->tagTemplateMap); 37 | 38 | // Set default template 39 | $template = $tagTemplateMap['default'] ?? ''; 40 | 41 | // Check if the key matches a regexp template key 42 | foreach ($tagTemplateMap as $tagTemplateKey => $tagTemplateValue) { 43 | if (str_starts_with($tagTemplateKey, '/') && preg_match($tagTemplateKey, $key)) { 44 | $template = $tagTemplateValue; 45 | } 46 | } 47 | 48 | // Check if we have an exact match. This will overwrite any regexp match. 49 | if (isset($tagTemplateMap[$key])) { 50 | $template = $tagTemplateMap[$key]; 51 | } 52 | 53 | $r = ''; 54 | 55 | if (!is_array($value)) { 56 | $value = [$value]; 57 | } 58 | 59 | try { 60 | foreach ($value as $val) { 61 | $r .= Craft::$app->getView()->renderString($template, ['key' => $key, 'value' => $val]); 62 | } 63 | } catch (\Throwable $throwable) { 64 | Craft::error($throwable->getMessage(), __METHOD__); 65 | } 66 | 67 | return Template::raw($r); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/templates/_previews/linkedin.twig: -------------------------------------------------------------------------------- 1 | {% set image = meta['og:image'] ?? meta.image ?? null %} 2 | {% set title = meta['og:title'] ?? meta.title ?? '' %} 3 | 4 |
5 |
6 |
7 | 19 |
20 | Check out this post: {{ entry.url }} 21 |
22 |
23 |
24 | {% if image %} 25 |
26 | {% if image.getUrl is defined %} 27 | 28 | {% else %} 29 | 30 | {% endif %} 31 |
32 | {% endif %} 33 |
34 |
{{ title }}
35 |
36 | {{ siteUrl | replace({'http://': '', 'https://': '', '//': ''})|trim('/') }} 37 |
38 |
39 |
40 |
41 |
42 | -------------------------------------------------------------------------------- /src/controllers/SitemapController.php: -------------------------------------------------------------------------------- 1 | returnXml( 42 | SEOMate::getInstance()->sitemap->index() 43 | ); 44 | } 45 | 46 | /** 47 | * Action for returning element sitemaps 48 | * 49 | * @return Response 50 | * @throws \Throwable 51 | */ 52 | public function actionElement(): Response 53 | { 54 | $params = Craft::$app->getUrlManager()->getRouteParams(); 55 | 56 | return $this->returnXml( 57 | SEOMate::getInstance()->sitemap->elements($params['handle'], $params['page']) 58 | ); 59 | } 60 | 61 | /** 62 | * Action for returning custom sitemaps 63 | * 64 | * @return Response 65 | */ 66 | public function actionCustom(): Response 67 | { 68 | return $this->returnXml( 69 | SEOMate::getInstance()->sitemap->custom() 70 | ); 71 | } 72 | 73 | /** 74 | * Action for submitting sitemap to search engines 75 | * 76 | * @return void 77 | * @throws ExitException 78 | * @throws \Throwable 79 | */ 80 | public function actionSubmit(): void 81 | { 82 | SEOMate::getInstance()->sitemap->submit(); 83 | Craft::$app->end(); 84 | } 85 | 86 | /** 87 | * Action for returning the XSLT sitemap stylesheet 88 | * 89 | * @return Response 90 | * @throws \Twig\Error\LoaderError 91 | * @throws \Twig\Error\RuntimeError 92 | * @throws \Twig\Error\SyntaxError 93 | * @throws \yii\base\Exception 94 | */ 95 | public function actionXsl(): Response 96 | { 97 | $xml = Craft::$app->getView()->renderTemplate('seomate/_sitemaps/xsl.twig', [], View::TEMPLATE_MODE_CP); 98 | $this->response->headers->set('X-Robots-Tag', 'noindex'); 99 | return $this->returnXml($xml); 100 | } 101 | 102 | /** 103 | * Helper function for returning an XML response 104 | * 105 | * @param string $data 106 | * @return Response 107 | */ 108 | private function returnXml(string $data): Response 109 | { 110 | $this->response->content = $data; 111 | $this->response->format = \yii\web\Response::FORMAT_XML; 112 | return $this->response; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/models/Settings.php: -------------------------------------------------------------------------------- 1 | [ 64 | 'type' => 'text', 65 | 'minLength' => 10, 66 | 'maxLength' => 60, 67 | ], 68 | 'description,og:description,twitter:description' => [ 69 | 'type' => 'text', 70 | 'minLength' => 50, 71 | 'maxLength' => 300, 72 | ], 73 | 'image,og:image,twitter:image' => [ 74 | 'type' => 'image', 75 | ], 76 | ]; 77 | 78 | public bool $applyRestrictions = false; 79 | 80 | public array $validImageExtensions = ['jpg', 'jpeg', 'gif', 'png']; 81 | 82 | public string $truncateSuffix = '...'; 83 | 84 | public bool $returnImageAsset = false; 85 | 86 | public bool $useImagerIfInstalled = true; 87 | 88 | public array $imageTransformMap = [ 89 | 'image' => [ 90 | 'width' => 1200, 91 | 'height' => 675, 92 | 'format' => 'jpg', 93 | ], 94 | 'og:image' => [ 95 | 'width' => 1200, 96 | 'height' => 630, 97 | 'format' => 'jpg', 98 | ], 99 | 'twitter:image' => [ 100 | 'width' => 1200, 101 | 'height' => 600, 102 | 'format' => 'jpg', 103 | ], 104 | ]; 105 | 106 | public array $autofillMap = [ 107 | 'og:title' => 'title', 108 | 'og:description' => 'description', 109 | 'og:image' => 'image', 110 | 'twitter:title' => 'title', 111 | 'twitter:description' => 'description', 112 | 'twitter:image' => 'image', 113 | ]; 114 | 115 | public array $tagTemplateMap = [ 116 | 'default' => '', 117 | 'title' => '{{ value }}', 118 | '/^og:/,/^fb:/' => '', 119 | ]; 120 | 121 | public bool $sitemapEnabled = false; 122 | 123 | public string $sitemapName = 'sitemap'; 124 | 125 | public int $sitemapLimit = 500; 126 | 127 | public array $sitemapConfig = []; 128 | 129 | public array $sitemapSubmitUrlPatterns = [ 130 | 'http://www.google.com/webmasters/sitemaps/ping?sitemap=', 131 | 'http://www.bing.com/webmaster/ping.aspx?siteMap=', 132 | ]; 133 | 134 | 135 | public function rules(): array 136 | { 137 | return [ 138 | 139 | ]; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/services/UrlsService.php: -------------------------------------------------------------------------------- 1 | getSettings(); 44 | 45 | $overrideObject = $context['seomate'] ?? []; 46 | 47 | if (isset($overrideObject['config'])) { 48 | SEOMateHelper::updateSettings($settings, $overrideObject['config']); 49 | } 50 | 51 | if (isset($overrideObject['canonicalUrl']) && $overrideObject['canonicalUrl'] !== '') { 52 | return SEOMateHelper::stripTokenParams($overrideObject['canonicalUrl']); 53 | } 54 | 55 | /** @var Element $element */ 56 | $element = $overrideObject['element'] ?? $craft->urlManager->getMatchedElement(); 57 | 58 | if ($element && $element->getUrl()) { 59 | $siteId = $element->siteId; 60 | $path = $element->uri === '__home__' ? '' : $element->uri; 61 | } else { 62 | try { 63 | $siteId = $craft->getSites()->getCurrentSite()->id; 64 | } catch (SiteNotFoundException $siteNotFoundException) { 65 | $siteId = null; 66 | Craft::error($siteNotFoundException->getMessage(), __METHOD__); 67 | } 68 | 69 | $path = strip_tags(html_entity_decode($craft->getRequest()->getPathInfo(), ENT_NOQUOTES, 'UTF-8')); 70 | } 71 | 72 | $page = Craft::$app->getRequest()->getPageNum(); 73 | if ($page <= 1) { 74 | return SEOMateHelper::stripTokenParams(UrlHelper::siteUrl($path, null, null, $siteId)); 75 | } 76 | 77 | $pageTrigger = Craft::$app->getConfig()->getGeneral()->getPageTrigger(); 78 | $useQueryParam = str_starts_with($pageTrigger, '?'); 79 | if ($useQueryParam) { 80 | $param = trim($pageTrigger, '?='); 81 | return SEOMateHelper::stripTokenParams(UrlHelper::siteUrl($path, [$param => $page], null, $siteId)); 82 | } 83 | 84 | $path .= '/' . $pageTrigger . $page; 85 | return SEOMateHelper::stripTokenParams(UrlHelper::siteUrl($path, null, null, $siteId)); 86 | } 87 | 88 | /** 89 | * Gets the alternate URLs from context 90 | * 91 | * @param $context 92 | * @return array 93 | */ 94 | public function getAlternateUrls($context): array 95 | { 96 | $craft = Craft::$app; 97 | $settings = SEOMate::getInstance()->getSettings(); 98 | $alternateUrls = []; 99 | 100 | $overrideObject = $context['seomate'] ?? null; 101 | 102 | if ($overrideObject && isset($overrideObject['config'])) { 103 | SEOMateHelper::updateSettings($settings, $overrideObject['config']); 104 | } 105 | 106 | if ($settings->outputAlternate === false || !Craft::$app->getIsMultiSite()) { 107 | return []; 108 | } 109 | 110 | if ($overrideObject && isset($overrideObject['element'])) { 111 | $element = $overrideObject['element']; 112 | } else { 113 | $element = $craft->urlManager->getMatchedElement(); 114 | } 115 | 116 | if (!$element instanceof ElementInterface || empty($element->canonicalId)) { 117 | return []; 118 | } 119 | 120 | $siteElements = $element::find() 121 | ->id($element->canonicalId) 122 | ->siteId('*') 123 | ->collect() 124 | ->filter(static fn(ElementInterface $element) => !empty($element->getUrl())); 125 | 126 | if ( 127 | !empty($settings->alternateFallbackSiteHandle) && 128 | $fallbackSite = Craft::$app->getSites()->getSiteByHandle($settings->alternateFallbackSiteHandle, false) 129 | ) { 130 | /** @var ElementInterface|null $fallbackSiteElement */ 131 | $fallbackSiteElement = $siteElements->firstWhere('siteId', $fallbackSite->id); 132 | if ($fallbackSiteElement) { 133 | $alternateUrls[] = [ 134 | 'url' => SEOMateHelper::stripTokenParams($fallbackSiteElement->getUrl()), 135 | 'language' => 'x-default', 136 | ]; 137 | $siteElements = $siteElements->where('siteId', '!=', $fallbackSite->id); 138 | } 139 | } 140 | 141 | /** @var ElementInterface $siteElement */ 142 | foreach ($siteElements->all() as $siteElement) { 143 | if ($settings->outputAlternate instanceof \Closure && !($settings->outputAlternate)($element, $siteElement)) { 144 | continue; 145 | } 146 | $alternateUrls[] = [ 147 | 'url' => SEOMateHelper::stripTokenParams($siteElement->getUrl()), 148 | 'language' => strtolower(str_replace('_', '-', $siteElement->getLanguage())), 149 | ]; 150 | } 151 | 152 | return $alternateUrls; 153 | } 154 | 155 | /** 156 | * @return string 157 | * @throws Exception 158 | */ 159 | public function getHomeUrl(): string 160 | { 161 | return SEOMateHelper::stripTokenParams(UrlHelper::siteUrl()); 162 | } 163 | 164 | } 165 | -------------------------------------------------------------------------------- /src/controllers/PreviewController.php: -------------------------------------------------------------------------------- 1 | getRequest()->getParam('elementId'); 57 | $siteId = Craft::$app->getRequest()->getParam('siteId'); 58 | 59 | /** @var Element|null $element */ 60 | $element = Craft::$app->getElements()->getElementById((int)$elementId, null, $siteId); 61 | if (!$element || !$element->uri) { 62 | return $this->asRaw(''); 63 | } 64 | 65 | $site = Craft::$app->getSites()->getSiteById($element->siteId); 66 | if (!$site) { 67 | throw new ServerErrorHttpException('Invalid site ID: ' . $element->siteId); 68 | } 69 | 70 | if ($site->language) { 71 | Craft::$app->language = $site->language; 72 | Craft::$app->set('locale', Craft::$app->getI18n()->getLocaleById($site->language)); 73 | } 74 | 75 | // Have this element override any freshly queried elements with the same ID/site 76 | Craft::$app->getElements()->setPlaceholderElement($element); 77 | 78 | // Disable caching 79 | \Craft::$app->getConfig()->getGeneral()->enableTemplateCaching = false; 80 | SEOMate::getInstance()->settings->cacheEnabled = false; 81 | 82 | // Get meta 83 | $view = $this->getView(); 84 | $view->getTwig()->disableStrictVariables(); 85 | $view->setTemplateMode(View::TEMPLATE_MODE_SITE); 86 | $context = $view->getTwig()->getGlobals(); 87 | 88 | try { 89 | $meta = $this->_getMetaFromElementPageTemplate($element, $context); 90 | } catch (\Throwable $e) { 91 | Craft::error("An error occurred when attempting to render meta data for element page template: " . $e->getMessage(), __METHOD__); 92 | } 93 | 94 | if (empty($meta)) { 95 | // Fall back to getting the metadata directly from the meta service 96 | $context = array_merge($context, [ 97 | 'seomate' => [ 98 | 'element' => $element, 99 | 'config' => [ 100 | 'cacheEnabled' => false, 101 | ], 102 | ], 103 | ]); 104 | $meta = SEOMate::getInstance()->meta->getContextMeta($context); 105 | } 106 | 107 | // Render previews 108 | $view->setTemplateMode(View::TEMPLATE_MODE_CP); 109 | 110 | return $this->renderTemplate('seomate/preview', [ 111 | 'element' => $element, 112 | 'meta' => $meta, 113 | ]); 114 | } 115 | 116 | /** 117 | * @param ElementInterface $element 118 | * @param array $context 119 | * @return array|null 120 | * @throws Exception 121 | * @throws InvalidConfigException 122 | * @throws LoaderError 123 | * @throws RuntimeError 124 | * @throws SyntaxError 125 | */ 126 | private function _getMetaFromElementPageTemplate(ElementInterface $element, array $context = []): ?array 127 | { 128 | 129 | if (!$element instanceof Element) { 130 | return null; 131 | } 132 | 133 | $refHandle = null; 134 | if (method_exists($element, 'refHandle')) { 135 | $refHandle = $element->refHandle(); 136 | } 137 | 138 | if (empty($refHandle)) { 139 | return null; 140 | } 141 | 142 | $pageTemplate = null; 143 | 144 | if ($element instanceof Entry) { 145 | if (!empty($element->sectionId)) { 146 | $pageTemplate = $element->getSection()?->getSiteSettings()[$element->siteId]['template'] ?? null; 147 | } else if (!empty($element->fieldId)) { // Nested entry 148 | $pageTemplate = $element->getField()->siteSettings[$element->getSite()->uid]['template'] ?? null; 149 | } 150 | } else if ($element instanceof Category) { 151 | $pageTemplate = $element->getGroup()->getSiteSettings()[$element->siteId]['template'] ?? null; 152 | } else if ($element instanceof Product) { 153 | $pageTemplate = $element->getType()->getSiteSettings()[$element->siteId]['template'] ?? null; 154 | } 155 | 156 | if (empty($pageTemplate) || !is_string($pageTemplate)) { 157 | return null; 158 | } 159 | 160 | $variables = array_merge($context, [ 161 | $refHandle => $element, 162 | 'seomatePreviewElement' => $element, 163 | ]); 164 | $html = Craft::$app->getView()->renderTemplate($pageTemplate, $variables); 165 | 166 | return $this->_getMetaFromHtml($html); 167 | } 168 | 169 | /** 170 | * @param string|null $html 171 | * @return array|null 172 | */ 173 | private function _getMetaFromHtml(?string $html): ?array 174 | { 175 | if (empty($html)) { 176 | return null; 177 | } 178 | $tags = []; 179 | $libxmlUseInternalErrors = libxml_use_internal_errors(true); 180 | $html = mb_convert_encoding($html, 'HTML-ENTITIES', Craft::$app->getView()->getTwig()->getCharset()); 181 | if (!is_string($html)) { 182 | return null; 183 | } 184 | $doc = new \DOMDocument(); 185 | $doc->loadHTML($html); 186 | $xpath = new \DOMXPath($doc); 187 | $nodes = $xpath->query('//head/meta'); 188 | /** @var \DOMElement $node */ 189 | foreach ($nodes as $node) { 190 | $key = $node->getAttribute('name') ?: $node->getAttribute('property'); 191 | $value = $node->getAttribute('content'); 192 | if ($key && $value) { 193 | $tags[$key] = $value; 194 | } 195 | } 196 | if ($title = $doc->getElementsByTagName('title')->item(0)?->nodeValue) { 197 | $tags['title'] = $title; 198 | } 199 | libxml_use_internal_errors($libxmlUseInternalErrors); 200 | return $tags; 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 17 | 18 | 24 | 28 | 29 | 30 | 33 | 34 | 36 | 38 | 39 | 44 | 48 | 49 | 51 | 53 | 54 | 59 | 60 | 63 | 64 | 67 | 68 | 71 | 72 | 78 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | -------------------------------------------------------------------------------- /src/templates/_sitemaps/xsl.twig: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | Sitemap 21 | <xsl:if test="sm:sitemapindex">Index</xsl:if> 22 | 23 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 107 | 112 | 113 | 114 | 115 |
URLLast Modified
105 | 106 | 108 | 109 | 110 | 111 |
116 |
117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 144 | 154 | 155 | 156 | 157 |
URLLast ModifiedChange FrequencyPriority
142 | 143 | 145 |

146 | 147 | 148 | 149 |

150 | 151 | 152 | 153 |
158 |
159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 |

174 | Xhtml: 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 |

194 | 195 |
196 | 197 | 198 | 199 | 200 |

201 | Image: 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 |

210 |
211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 |

223 | Video: 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 |

242 |
243 |
244 | -------------------------------------------------------------------------------- /src/SEOMate.php: -------------------------------------------------------------------------------- 1 | [ 69 | 'meta' => MetaService::class, 70 | 'urls' => UrlsService::class, 71 | 'render' => RenderService::class, 72 | 'sitemap' => SitemapService::class, 73 | 'schema' => SchemaService::class, 74 | ], 75 | ]; 76 | } 77 | 78 | /** 79 | * @return void 80 | */ 81 | public function init(): void 82 | { 83 | 84 | parent::init(); 85 | 86 | // Register template variables 87 | Event::on( 88 | CraftVariable::class, 89 | CraftVariable::EVENT_INIT, 90 | static function(Event $event) { 91 | /** @var CraftVariable $variable */ 92 | $variable = $event->sender; 93 | $variable->set('schema', SchemaVariable::class); 94 | $variable->set('seomate', SEOMateVariable::class); 95 | } 96 | ); 97 | 98 | // Add in our Twig extensions 99 | Craft::$app->view->registerTwigExtension(new SEOMateTwigExtension()); 100 | 101 | // Template Hook 102 | Craft::$app->view->hook( 103 | 'seomateMeta', 104 | [$this, 'onRegisterMetaHook'] 105 | ); 106 | 107 | // Adds SEOMate to the Clear Caches tool 108 | Event::on(ClearCaches::class, ClearCaches::EVENT_REGISTER_CACHE_OPTIONS, 109 | static function(RegisterCacheOptionsEvent $event) { 110 | $event->options[] = [ 111 | 'key' => 'seomate-cache', 112 | 'label' => Craft::t('seomate', 'SEOMate cache'), 113 | 'action' => [SEOMate::getInstance(), 'invalidateCaches'], 114 | ]; 115 | } 116 | ); 117 | 118 | // After save element event handler 119 | Event::on( 120 | Elements::class, 121 | Elements::EVENT_AFTER_SAVE_ELEMENT, 122 | static function(ElementEvent $event) { 123 | $element = $event->element; 124 | if (!$element instanceof ElementInterface) { 125 | return; 126 | } 127 | if (!$event->isNew) { 128 | CacheHelper::deleteMetaCacheForElement($element); 129 | } 130 | CacheHelper::deleteCacheForSitemapIndex($element->siteId ?? null); 131 | CacheHelper::deleteCacheForElementSitemapsByElement($element); 132 | } 133 | ); 134 | 135 | $settings = $this->getSettings(); 136 | 137 | // Register sitemap urls? 138 | if ($settings->sitemapEnabled) { 139 | Event::on( 140 | UrlManager::class, 141 | UrlManager::EVENT_REGISTER_SITE_URL_RULES, 142 | static function(RegisterUrlRulesEvent $event) use ($settings) { 143 | $sitemapName = $settings->sitemapName; 144 | 145 | $event->rules[$sitemapName . '.xml'] = 'seomate/sitemap/index'; 146 | $event->rules[$sitemapName . '--.xml'] = 'seomate/sitemap/element'; 147 | $event->rules[$sitemapName . '-custom.xml'] = 'seomate/sitemap/custom'; 148 | $event->rules['sitemap.xsl'] = 'seomate/sitemap/xsl'; 149 | } 150 | ); 151 | } 152 | 153 | // Register preview target? 154 | if (!empty($settings->previewEnabled)) { 155 | Event::on( 156 | Element::class, 157 | Element::EVENT_REGISTER_PREVIEW_TARGETS, 158 | static function(RegisterPreviewTargetsEvent $event) use ($settings) { 159 | try { 160 | $element = $event->sender; 161 | if (!$element instanceof Element || !SEOMateHelper::isElementPreviewable($element)) { 162 | return; 163 | } 164 | $event->previewTargets[] = [ 165 | 'label' => $settings->previewLabel ?: Craft::t('seomate', 'SEO Preview'), 166 | 'url' => UrlHelper::siteUrl('seomate/preview', [ 167 | 'elementId' => $element->id, 168 | 'siteId' => $element->siteId, 169 | ]), 170 | ]; 171 | } catch (\Throwable $e) { 172 | Craft::error("An exception occurred when attempting to register the \"SEO Preview\" preview target: " . $e->getMessage(), __METHOD__); 173 | } 174 | } 175 | ); 176 | 177 | // Register preview site route 178 | $request = Craft::$app->getRequest(); 179 | if ($request->getIsSiteRequest() && !$request->getIsConsoleRequest()) { 180 | Event::on( 181 | UrlManager::class, 182 | UrlManager::EVENT_REGISTER_SITE_URL_RULES, 183 | static function(RegisterUrlRulesEvent $event) { 184 | $event->rules['seomate/preview'] = 'seomate/preview'; 185 | } 186 | ); 187 | } 188 | } 189 | } 190 | 191 | /** 192 | * Invalidates all caches 193 | */ 194 | public function invalidateCaches(): void 195 | { 196 | CacheHelper::clearAllCaches(); 197 | } 198 | 199 | /** 200 | * Process 'seomateMeta' hook 201 | * 202 | * @param array $context 203 | * @return string 204 | * @throws LoaderError 205 | * @throws RuntimeError 206 | * @throws SyntaxError 207 | * @throws \yii\base\Exception 208 | * @throws \yii\base\InvalidConfigException 209 | */ 210 | public function onRegisterMetaHook(array &$context): string 211 | { 212 | 213 | if (isset($context['seomatePreviewElement'])) { 214 | $context['seomate']['element'] = $context['seomate']['element'] ?? $context['seomatePreviewElement']; 215 | } 216 | 217 | $meta = $this->meta->getContextMeta($context); 218 | $canonicalUrl = $this->urls->getCanonicalUrl($context); 219 | $alternateUrls = $this->urls->getAlternateUrls($context); 220 | $home = $this->urls->getHomeUrl(); 221 | 222 | $context['seomate']['meta'] = $meta; 223 | $context['seomate']['canonicalUrl'] = $canonicalUrl; 224 | $context['seomate']['alternateUrls'] = $alternateUrls; 225 | $context['seomate']['home'] = $home; 226 | 227 | $settings = $this->getSettings(); 228 | 229 | if (!empty($settings->metaTemplate)) { 230 | return Craft::$app->view->renderTemplate($settings->metaTemplate, $context); 231 | } 232 | 233 | $oldTemplateMode = Craft::$app->getView()->getTemplateMode(); 234 | Craft::$app->getView()->setTemplateMode(View::TEMPLATE_MODE_CP); 235 | $output = Craft::$app->getView()->renderTemplate('seomate/_output/meta', $context); 236 | Craft::$app->getView()->setTemplateMode($oldTemplateMode); 237 | 238 | return $output; 239 | } 240 | 241 | /** 242 | * @return Settings 243 | */ 244 | protected function createSettingsModel(): Settings 245 | { 246 | return new Settings(); 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /src/helpers/SitemapHelper.php: -------------------------------------------------------------------------------- 1 | getSettings(); 40 | $limit = $settings->sitemapLimit; 41 | $urls = []; 42 | 43 | /** @var Element $elementClass */ 44 | if (isset($definition['elementType']) && class_exists($definition['elementType'])) { 45 | $elementClass = $definition['elementType']; 46 | $criteria = $definition['criteria'] ?? []; 47 | } else { 48 | $elementClass = Entry::class; 49 | $criteria = ['section' => $handle]; 50 | } 51 | 52 | $query = $elementClass::find(); 53 | Craft::configure($query, $criteria); 54 | 55 | $count = $query->limit(null)->count(); 56 | $pages = ceil($count / $limit); 57 | $lastEntry = self::getLastEntry($query); 58 | 59 | for ($i = 1; $i <= $pages; ++$i) { 60 | try { 61 | $urls[] = [ 62 | 'loc' => UrlHelper::siteUrl($settings->sitemapName . '-' . $handle . '-' . $i . '.xml'), 63 | 'lastmod' => $lastEntry ? $lastEntry->dateUpdated->format('c') : DateTimeHelper::currentUTCDateTime()->format('c'), 64 | ]; 65 | } catch (Exception $exception) { 66 | Craft::error($exception->getMessage(), __METHOD__); 67 | } 68 | } 69 | 70 | return $urls; 71 | } 72 | 73 | /** 74 | * Returns sitemap url for custom sitemap for including in sitemap index 75 | */ 76 | public static function getCustomIndexSitemapUrl(): array 77 | { 78 | $settings = SEOMate::getInstance()->getSettings(); 79 | return self::getSitemapUrl($settings->sitemapName . '-custom.xml'); 80 | } 81 | 82 | /** 83 | * Returns sitemap url for sitemap with the given name 84 | * @param $name 85 | */ 86 | public static function getSitemapUrl($name): array 87 | { 88 | try { 89 | return [ 90 | 'loc' => UrlHelper::siteUrl($name), 91 | 'lastmod' => DateTimeHelper::currentUTCDateTime()->format('c'), 92 | ]; 93 | } catch (Exception $exception) { 94 | Craft::error($exception->getMessage(), __METHOD__); 95 | } 96 | 97 | return []; 98 | } 99 | 100 | /** 101 | * Returns URLs for element sitemap based on sitemap handle, definition and page 102 | */ 103 | public static function getElementsSitemapUrls(string $handle, array $definition, int $page): array 104 | { 105 | $settings = SEOMate::getInstance()->getSettings(); 106 | $limit = $settings->sitemapLimit; 107 | $urls = []; 108 | 109 | /** @var ElementInterface $elementClass */ 110 | if (isset($definition['elementType']) && class_exists($definition['elementType'])) { 111 | $elementClass = $definition['elementType']; 112 | $criteria = $definition['criteria'] ?? []; 113 | $query = $elementClass::find(); 114 | $params = $definition['params'] ?? []; 115 | } else { 116 | $elementClass = Entry::class; 117 | $criteria = ['section' => $handle]; 118 | $query = $elementClass::find(); 119 | $params = $definition; 120 | } 121 | 122 | $criteria['uri'] = ':notempty:'; 123 | 124 | Craft::configure($query, $criteria); 125 | 126 | $elements = (clone($query)) 127 | ->limit($limit) 128 | ->offset(($page - 1) * $limit) 129 | ->collect(); 130 | 131 | $siteElements = null; 132 | $fallbackSite = null; 133 | 134 | if ($settings->outputAlternate !== false && Craft::$app->getIsMultiSite()) { 135 | $elementIds = $elements->pluck('id')->all(); 136 | /** @var Collection $siteElements */ 137 | $siteElements = (clone($query)) 138 | ->id($elementIds) 139 | ->siteId('*') 140 | ->collect() 141 | ->filter(static fn (ElementInterface $element) => !empty($element->getUrl())); 142 | if (!empty($settings->alternateFallbackSiteHandle)) { 143 | $fallbackSite = Craft::$app->getSites()->getSiteByHandle($settings->alternateFallbackSiteHandle, false); 144 | } 145 | } 146 | 147 | foreach ($elements->all() as $element) { 148 | 149 | $url = array_merge([ 150 | 'loc' => $element->url, 151 | 'lastmod' => $element->dateUpdated->format('c'), 152 | ], $params); 153 | 154 | if ($siteElements) { 155 | $alternates = $siteElements 156 | ->where('id', $element->getId()) 157 | ->collect(); 158 | if ($fallbackSite && $fallbackAlternate = $alternates->firstWhere('siteId', $fallbackSite->id)) { 159 | $url['alternate'][] = [ 160 | 'hreflang' => 'x-default', 161 | 'href' => $fallbackAlternate->getUrl(), 162 | ]; 163 | $alternates = $alternates->where('siteId', '!=', $fallbackSite->id); 164 | } 165 | /** @var ElementInterface $alternate */ 166 | foreach ($alternates->all() as $alternate) { 167 | if ($settings->outputAlternate instanceof \Closure && !($settings->outputAlternate)($element, $alternate)) { 168 | continue; 169 | } 170 | $url['alternate'][] = [ 171 | 'hreflang' => strtolower(str_replace('_', '-', $alternate->getLanguage())), 172 | 'href' => $alternate->getUrl(), 173 | ]; 174 | } 175 | } 176 | 177 | $urls[] = $url; 178 | } 179 | 180 | return $urls; 181 | } 182 | 183 | /** 184 | * Returns URLs for custom sitemap 185 | */ 186 | public static function getCustomSitemapUrls(?array $customUrls): array 187 | { 188 | 189 | if (empty($customUrls)) { 190 | return []; 191 | } 192 | 193 | $urls = []; 194 | 195 | foreach ($customUrls as $key => $params) { 196 | try { 197 | $urls[] = array_merge([ 198 | 'loc' => UrlHelper::siteUrl($key), 199 | 'lastmod' => DateTimeHelper::currentUTCDateTime()->format('c'), 200 | ], $params); 201 | } catch (Exception $exception) { 202 | Craft::error($exception->getMessage(), __METHOD__); 203 | } 204 | } 205 | 206 | return $urls; 207 | } 208 | 209 | /** 210 | * Helper method for adding URLs to sitemap 211 | */ 212 | public static function addUrlsToSitemap(\DOMDocument $document, \DOMElement $sitemap, string $nodeName, array $urls): void 213 | { 214 | foreach ($urls as $url) { 215 | 216 | try { 217 | 218 | $topNode = $document->createElement($nodeName); 219 | $sitemap->appendChild($topNode); 220 | 221 | $alternates = $url['alternate'] ?? []; 222 | unset($url['alternate']); 223 | 224 | foreach ($url as $key => $val) { 225 | if ($key === 'loc') { 226 | $val = SEOMateHelper::stripTokenParams($val); 227 | } 228 | $node = $document->createElement($key, $val); 229 | $topNode->appendChild($node); 230 | } 231 | 232 | foreach ($alternates as $alternate) { 233 | $node = $document->createElement('xhtml:link'); 234 | $node->setAttribute('rel', 'alternate'); 235 | $node->setAttribute('hreflang', $alternate['hreflang']); 236 | $node->setAttribute('href', SEOMateHelper::stripTokenParams($alternate['href'])); 237 | $topNode->appendChild($node); 238 | } 239 | 240 | } catch (\Throwable $throwable) { 241 | Craft::error($throwable, __METHOD__); 242 | } 243 | 244 | } 245 | } 246 | 247 | /** 248 | * Returns last entry from query 249 | * 250 | * 251 | */ 252 | public static function getLastEntry(ElementQueryInterface $query): mixed 253 | { 254 | return $query->orderBy('elements.dateUpdated DESC')->one(); 255 | } 256 | 257 | /** 258 | * Checks if the supplied config array is a multi-site config. Returns true if 259 | * any of the keys are '*' or matches a site handle. 260 | * 261 | * @param $array 262 | */ 263 | public static function isMultisiteConfig($array): bool 264 | { 265 | if (isset($array['*'])) { 266 | return true; 267 | } 268 | 269 | $sites = Craft::$app->getSites()->getAllSites(); 270 | 271 | foreach ($sites as $site) { 272 | if (isset($array[$site->handle])) { 273 | return true; 274 | } 275 | } 276 | 277 | return false; 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /src/services/SitemapService.php: -------------------------------------------------------------------------------- 1 | getSettings(); 37 | $siteId = Craft::$app->getSites()->getCurrentSite()->id; 38 | 39 | if (CacheHelper::hasCacheForSitemapIndex($siteId)) { 40 | return CacheHelper::getCacheForSitemapIndex($siteId); 41 | } 42 | 43 | $document = $this->getSitemapDocument(); 44 | $topNode = $this->getTopNode($document, 'sitemapindex'); 45 | $document->appendChild($topNode); 46 | $comment = $document->createComment('Created on: ' . date('Y-m-d H:i:s')); 47 | $topNode->appendChild($comment); 48 | 49 | $config = $settings->sitemapConfig; 50 | 51 | if (!empty($config)) { 52 | $elements = $config['elements'] ?? null; 53 | $custom = $config['custom'] ?? null; 54 | $additionalSitemaps = $config['additionalSitemaps'] ?? null; 55 | 56 | if ($elements && \is_array($elements) && $elements !== []) { 57 | foreach ($elements as $key => $definition) { 58 | $indexSitemapUrls = SitemapHelper::getIndexSitemapUrls($key, $definition); 59 | SitemapHelper::addUrlsToSitemap($document, $topNode, 'sitemap', $indexSitemapUrls); 60 | } 61 | } 62 | 63 | if ($custom && \is_array($custom) && $custom !== []) { 64 | $customUrl = SitemapHelper::getCustomIndexSitemapUrl(); 65 | 66 | if (SitemapHelper::isMultisiteConfig($custom)) { 67 | try { 68 | $currentSiteHandle = Craft::$app->getSites()->getCurrentSite()->handle; 69 | 70 | if (isset($custom['*']) || isset($custom[$currentSiteHandle])) { 71 | SitemapHelper::addUrlsToSitemap($document, $topNode, 'sitemap', [$customUrl]); 72 | } 73 | } catch (SiteNotFoundException $siteNotFoundException) { 74 | Craft::error($siteNotFoundException->getMessage(), __METHOD__); 75 | } 76 | } else { 77 | SitemapHelper::addUrlsToSitemap($document, $topNode, 'sitemap', [$customUrl]); 78 | } 79 | } 80 | 81 | if ($additionalSitemaps && \is_array($additionalSitemaps) && $additionalSitemaps !== []) { 82 | $additionalUrls = []; 83 | if (SitemapHelper::isMultisiteConfig($additionalSitemaps)) { 84 | if (isset($additionalSitemaps['*'])) { 85 | $additionalUrls = array_merge($additionalUrls, $additionalSitemaps['*']); 86 | } 87 | 88 | try { 89 | $currentSiteHandle = Craft::$app->getSites()->getCurrentSite()->handle; 90 | 91 | if (isset($additionalSitemaps[$currentSiteHandle])) { 92 | $additionalUrls = array_merge($additionalUrls, $additionalSitemaps[$currentSiteHandle]); 93 | } 94 | } catch (SiteNotFoundException $siteNotFoundException) { 95 | Craft::error($siteNotFoundException->getMessage(), __METHOD__); 96 | } 97 | } else { 98 | $additionalUrls = array_merge($additionalUrls, $additionalSitemaps); 99 | } 100 | 101 | foreach ($additionalUrls as $sitemap) { 102 | $addtnlSitemap = SitemapHelper::getSitemapUrl($sitemap); 103 | SitemapHelper::addUrlsToSitemap($document, $topNode, 'sitemap', [$addtnlSitemap]); 104 | } 105 | } 106 | 107 | $data = $document->saveXML(); 108 | CacheHelper::setCacheForSitemapIndex($siteId, $data); 109 | } 110 | 111 | 112 | return $data ?? $document->saveXML(); 113 | } 114 | 115 | /** 116 | * Returns element sitemap by handle and page 117 | * 118 | * @param $page 119 | * @throws \Throwable 120 | */ 121 | public function elements(string $handle, $page): string 122 | { 123 | $settings = SEOMate::getInstance()->getSettings(); 124 | $siteId = Craft::$app->getSites()->getCurrentSite()->id; 125 | 126 | $document = $this->getSitemapDocument(); 127 | $topNode = $this->getTopNode($document); 128 | $document->appendChild($topNode); 129 | $comment = $document->createComment('Created on: ' . date('Y-m-d H:i:s')); 130 | $topNode->appendChild($comment); 131 | 132 | $config = $settings->sitemapConfig; 133 | 134 | if (!empty($config)) { 135 | $definition = $config['elements'][$handle] ?? null; 136 | 137 | if (!$definition) { 138 | return $document->saveXML(); 139 | } 140 | 141 | if (CacheHelper::hasCacheForElementSitemap($siteId, $handle, $page)) { 142 | return CacheHelper::getCacheForElementSitemap($siteId, $handle, $page); 143 | } 144 | 145 | $elementsSitemapUrls = SitemapHelper::getElementsSitemapUrls($handle, $definition, $page); 146 | SitemapHelper::addUrlsToSitemap($document, $topNode, 'url', $elementsSitemapUrls); 147 | $data = $document->saveXML(); 148 | 149 | CacheHelper::setCacheForElementSitemap($siteId, $data, $handle, $definition, $page); 150 | } 151 | 152 | return $data ?? $document->saveXML(); 153 | } 154 | 155 | /** 156 | * Returns custom sitemap 157 | */ 158 | public function custom(): string 159 | { 160 | $settings = SEOMate::getInstance()->getSettings(); 161 | 162 | $document = $this->getSitemapDocument(); 163 | $topNode = $this->getTopNode($document); 164 | $document->appendChild($topNode); 165 | 166 | $config = $settings->sitemapConfig; 167 | 168 | if (!empty($config)) { 169 | $customUrls = $config['custom'] ?? null; 170 | 171 | if ($customUrls && (is_countable($customUrls) ? count($customUrls) : 0) > 0) { 172 | if (SitemapHelper::isMultisiteConfig($customUrls)) { 173 | try { 174 | $currentSiteHandle = Craft::$app->getSites()->getCurrentSite()->handle; 175 | 176 | if (isset($customUrls[$currentSiteHandle])) { 177 | $customSitemapUrls = SitemapHelper::getCustomSitemapUrls($customUrls[$currentSiteHandle]); 178 | SitemapHelper::addUrlsToSitemap($document, $topNode, 'url', $customSitemapUrls); 179 | } 180 | 181 | if (isset($customUrls['*'])) { 182 | $customSitemapUrls = SitemapHelper::getCustomSitemapUrls($customUrls['*']); 183 | SitemapHelper::addUrlsToSitemap($document, $topNode, 'url', $customSitemapUrls); 184 | } 185 | } catch (SiteNotFoundException $siteNotFoundException) { 186 | Craft::error($siteNotFoundException->getMessage(), __METHOD__); 187 | } 188 | } else { 189 | $customSitemapUrls = SitemapHelper::getCustomSitemapUrls($customUrls); 190 | SitemapHelper::addUrlsToSitemap($document, $topNode, 'url', $customSitemapUrls); 191 | } 192 | } 193 | } 194 | 195 | return $document->saveXML(); 196 | } 197 | 198 | /** 199 | * Submits sitemap index to search engines 200 | * 201 | * @throws \Throwable 202 | */ 203 | public function submit(): void 204 | { 205 | $settings = SEOMate::getInstance()->getSettings(); 206 | $pingUrls = $settings->sitemapSubmitUrlPatterns; 207 | $sitemapPath = $settings->sitemapName . '.xml'; 208 | 209 | foreach ($pingUrls as $url) { 210 | $sites = Craft::$app->getSites()->getAllSites(); 211 | 212 | foreach ($sites as $site) { 213 | $siteId = $site->id; 214 | $sitemapUrl = UrlHelper::siteUrl($sitemapPath, null, null, $siteId); 215 | 216 | if (!empty($sitemapUrl)) { 217 | $submitUrl = $url . $sitemapUrl; 218 | $client = Craft::createGuzzleClient(); 219 | 220 | try { 221 | $client->post($submitUrl); 222 | Craft::info('Index sitemap for site "' . $site->name . ' submitted to: ' . $submitUrl, __METHOD__); 223 | } catch (\Exception $exception) { 224 | Craft::error('Error submitting index sitemap for site "' . $site->name . '" to: ' . $submitUrl . ' :: ' . $exception->getMessage(), __METHOD__); 225 | } 226 | } 227 | } 228 | } 229 | } 230 | 231 | /** 232 | * Returns the top node DOMElement for DOMDocument 233 | */ 234 | private function getTopNode(\DOMDocument &$document, string $type = 'urlset'): \DOMElement 235 | { 236 | $node = null; 237 | try { 238 | $node = $document->createElement($type); 239 | $node->setAttribute( 240 | 'xmlns', 241 | 'http://www.sitemaps.org/schemas/sitemap/0.9' 242 | ); 243 | $node->setAttribute( 244 | 'xmlns:xhtml', 245 | 'http://www.w3.org/1999/xhtml' 246 | ); 247 | } catch (\Throwable $throwable) { 248 | Craft::error($throwable->getMessage()); 249 | } 250 | 251 | return $node; 252 | } 253 | 254 | /** 255 | * @return \DOMDocument 256 | */ 257 | private function getSitemapDocument(): \DOMDocument 258 | { 259 | $document = new \DOMDocument('1.0', 'utf-8'); 260 | $xsl = $document->createProcessingInstruction('xml-stylesheet', 'type="text/xsl" href="sitemap.xsl"'); 261 | $document->appendChild($xsl); 262 | return $document; 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /src/helpers/CacheHelper.php: -------------------------------------------------------------------------------- 1 | getRequest(); 85 | self::$_cacheEnabled = 86 | SEOMate::getInstance()->getSettings()->cacheEnabled && 87 | !$request->getIsConsoleRequest() && 88 | !$request->getIsPreview() && 89 | !$request->getHadToken(); 90 | } 91 | return self::$_cacheEnabled; 92 | } 93 | 94 | /** 95 | * Clears all SEOMate caches 96 | */ 97 | public static function clearAllCaches(): void 98 | { 99 | $cache = Craft::$app->getCache(); 100 | TagDependency::invalidate($cache, self::SEOMATE_TAG); 101 | } 102 | 103 | /** 104 | * Checks if meta data cache for element exists 105 | * 106 | * @param $element 107 | * @return bool 108 | * @throws \yii\web\BadRequestHttpException 109 | */ 110 | public static function hasMetaCacheForElement($element): bool 111 | { 112 | if (!self::getIsCacheEnabled()) { 113 | return false; 114 | } 115 | return (bool)Craft::$app->getCache()?->get(self::getElementKey($element)); 116 | } 117 | 118 | /** 119 | * Returns meta data cache for element 120 | * 121 | * @param $element 122 | * @return mixed 123 | * @throws \yii\web\BadRequestHttpException 124 | */ 125 | public static function getMetaCacheForElement($element): mixed 126 | { 127 | if (!self::getIsCacheEnabled()) { 128 | return false; 129 | } 130 | return Craft::$app->getCache()?->get(self::getElementKey($element)); 131 | } 132 | 133 | /** 134 | * Deletes meta data cache for element 135 | * 136 | * @param $element 137 | */ 138 | public static function deleteMetaCacheForElement($element): void 139 | { 140 | Craft::$app->getCache()?->delete(self::getElementKey($element)); 141 | } 142 | 143 | /** 144 | * @param $element 145 | * @param $meta 146 | * @return void 147 | * @throws InvalidConfigException 148 | * @throws \yii\web\BadRequestHttpException 149 | */ 150 | public static function setMetaCacheForElement($element, $meta): void 151 | { 152 | if (!self::getIsCacheEnabled()) { 153 | return; 154 | } 155 | $settings = SEOMate::getInstance()->getSettings(); 156 | 157 | $cacheDuration = ConfigHelper::durationInSeconds($settings->cacheDuration); 158 | 159 | $dependency = new TagDependency([ 160 | 'tags' => [ 161 | self::SEOMATE_TAG, 162 | self::ELEMENT_TAG, 163 | ], 164 | ]); 165 | 166 | Craft::$app->getCache()?->set(self::getElementKey($element), $meta, $cacheDuration, $dependency); 167 | } 168 | 169 | /** 170 | * Checks if cache for sitemap index exists 171 | * 172 | * @param $siteId 173 | * @return bool 174 | * @throws \yii\web\BadRequestHttpException 175 | */ 176 | public static function hasCacheForSitemapIndex($siteId): bool 177 | { 178 | if (!self::getIsCacheEnabled()) { 179 | return false; 180 | } 181 | return (bool)Craft::$app->getCache()?->get(self::SITEMAP_INDEX_KEY . '_site' . $siteId); 182 | } 183 | 184 | /** 185 | * Returns cached sitemap index 186 | * 187 | * @param $siteId 188 | * @return mixed 189 | * @throws \yii\web\BadRequestHttpException 190 | */ 191 | public static function getCacheForSitemapIndex($siteId): mixed 192 | { 193 | if (!self::getIsCacheEnabled()) { 194 | return false; 195 | } 196 | return Craft::$app->getCache()?->get(self::SITEMAP_INDEX_KEY . '_site' . $siteId); 197 | } 198 | 199 | /** 200 | * Deletes sitemap index cache 201 | * 202 | * @param $siteId 203 | */ 204 | public static function deleteCacheForSitemapIndex($siteId): void 205 | { 206 | Craft::$app->getCache()?->delete(self::SITEMAP_INDEX_KEY . '_site' . $siteId); 207 | } 208 | 209 | /** 210 | * Creates cache for sitemap index 211 | * 212 | * @param $siteId 213 | * @param $data 214 | * @return void 215 | * @throws InvalidConfigException 216 | * @throws \yii\web\BadRequestHttpException 217 | */ 218 | public static function setCacheForSitemapIndex($siteId, $data): void 219 | { 220 | if (!self::getIsCacheEnabled()) { 221 | return; 222 | } 223 | $settings = SEOMate::getInstance()->getSettings(); 224 | 225 | $cacheDuration = ConfigHelper::durationInSeconds($settings->cacheDuration); 226 | 227 | $dependency = new TagDependency([ 228 | 'tags' => [ 229 | self::SEOMATE_TAG, 230 | self::SITEMAP_ELEMENT_TAG, 231 | ], 232 | ]); 233 | 234 | Craft::$app->getCache()?->set(self::SITEMAP_INDEX_KEY . '_site' . $siteId, $data, $cacheDuration, $dependency); 235 | } 236 | 237 | /** 238 | * Checks if cache for element sitemap exists 239 | * 240 | * @param $siteId 241 | * @param $handle 242 | * @param $page 243 | * @return bool 244 | * @throws \yii\web\BadRequestHttpException 245 | */ 246 | public static function hasCacheForElementSitemap($siteId, $handle, $page): bool 247 | { 248 | if (!self::getIsCacheEnabled()) { 249 | return false; 250 | } 251 | return (bool)Craft::$app->getCache()?->get(self::getElementSitemapKey($siteId, $handle, $page)); 252 | } 253 | 254 | /** 255 | * Returns cache for element sitemap 256 | * 257 | * @param $siteId 258 | * @param $handle 259 | * @param $page 260 | * @return mixed 261 | * @throws \yii\web\BadRequestHttpException 262 | */ 263 | public static function getCacheForElementSitemap($siteId, $handle, $page): mixed 264 | { 265 | if (!self::getIsCacheEnabled()) { 266 | return false; 267 | } 268 | return Craft::$app->getCache()?->get(self::getElementSitemapKey($siteId, $handle, $page)); 269 | } 270 | 271 | /** 272 | * Deletes all element sitemaps 273 | */ 274 | public static function deleteCacheForAllElementSitemaps(): void 275 | { 276 | $cache = Craft::$app->getCache(); 277 | TagDependency::invalidate($cache, self::SITEMAP_ELEMENT_TAG); 278 | } 279 | 280 | /** 281 | * Deletes element sitemaps by element 282 | * 283 | * @param $element 284 | */ 285 | public static function deleteCacheForElementSitemapsByElement($element): void 286 | { 287 | $elementClass = $element::class; 288 | $siteId = $element->siteId ?? null; 289 | 290 | $cache = Craft::$app->getCache(); 291 | TagDependency::invalidate($cache, self::getElementSitemapTagForClass($siteId, $elementClass)); 292 | } 293 | 294 | /** 295 | * Creates cache for element sitemap 296 | * 297 | * @param $siteId 298 | * @param $data 299 | * @param $handle 300 | * @param $definition 301 | * @param $page 302 | * @return void 303 | * @throws InvalidConfigException 304 | * @throws \yii\web\BadRequestHttpException 305 | */ 306 | public static function setCacheForElementSitemap($siteId, $data, $handle, $definition, $page): void 307 | { 308 | if (!self::getIsCacheEnabled()) { 309 | return; 310 | } 311 | 312 | $settings = SEOMate::getInstance()->getSettings(); 313 | 314 | $cacheDuration = ConfigHelper::durationInSeconds($settings->cacheDuration); 315 | 316 | $tags = array_merge([self::SEOMATE_TAG, self::SITEMAP_ELEMENT_TAG], self::getElementSitemapTags($siteId, $handle, $definition)); 317 | 318 | $dependency = new TagDependency([ 319 | 'tags' => $tags, 320 | ]); 321 | 322 | Craft::$app->getCache()?->set(self::getElementSitemapKey($siteId, $handle, $page), $data, $cacheDuration, $dependency); 323 | } 324 | 325 | /** 326 | * Creates key for element meta 327 | * 328 | * @param $element 329 | * @return string 330 | */ 331 | private static function getElementKey($element): string 332 | { 333 | $site = Craft::$app->getSites()->getSiteById($element->siteId, true); 334 | $pageNum = Craft::$app->getRequest()->getIsConsoleRequest() ? null : Craft::$app->getRequest()->getPageNum(); 335 | return self::ELEMENT_KEY_PREFIX . '_' . ($site->handle ?? 'unknown') . '_' . $element->id . ($pageNum ? '_' . $pageNum : ''); 336 | } 337 | 338 | /** 339 | * Creates key for element sitemap 340 | * 341 | * @param $siteId 342 | * @param $handle 343 | * @param $page 344 | * @return string 345 | */ 346 | private static function getElementSitemapKey($siteId, $handle, $page): string 347 | { 348 | return self::ELEMENT_SITEMAP_KEY_PREFIX . '_' . $handle . '_' . $page . '_site' . $siteId; 349 | } 350 | 351 | /** 352 | * Gets tags for element sitemaps based on definition 353 | * 354 | * @param $siteId 355 | * @param $handle 356 | * @param $definition 357 | * @return array 358 | */ 359 | private static function getElementSitemapTags($siteId, $handle, $definition): array 360 | { 361 | $tags = []; 362 | 363 | if (isset($definition['elementType']) && class_exists($definition['elementType'])) { 364 | $elementClass = $definition['elementType']; 365 | } else { 366 | $elementClass = Entry::class; 367 | } 368 | 369 | $tags[] = self::getElementSitemapTagForClass($siteId, $elementClass); 370 | $tags[] = self::ELEMENT_SITEMAP_HANDLE_PREFIX . '_' . $handle . '_site' . $siteId; 371 | 372 | // tbd : add more specific tags for criteria params? 373 | 374 | return $tags; 375 | } 376 | 377 | /** 378 | * Creates tag for element 379 | * 380 | * @param $siteId 381 | * @param $class 382 | * @return string 383 | */ 384 | private static function getElementSitemapTagForClass($siteId, $class): string 385 | { 386 | return self::ELEMENT_SITEMAP_CLASS_PREFIX . '_' . str_replace('\\', '-', $class) . '_site' . $siteId; 387 | } 388 | } 389 | -------------------------------------------------------------------------------- /src/templates/Preview.css: -------------------------------------------------------------------------------- 1 | body, html { 2 | margin: 0; 3 | background-color: #ffffff; 4 | } 5 | 6 | hr { 7 | box-sizing: content-box; 8 | height: 0; 9 | overflow: visible 10 | } 11 | 12 | a { 13 | background-color: transparent 14 | } 15 | 16 | button, input, optgroup, select, textarea { 17 | font-family: inherit; 18 | font-size: 100%; 19 | margin: 0 20 | } 21 | 22 | button, input { 23 | overflow: visible 24 | } 25 | 26 | button, select { 27 | text-transform: none 28 | } 29 | 30 | [type=button], [type=reset], [type=submit], button { 31 | -webkit-appearance: button 32 | } 33 | 34 | [type=button]::-moz-focus-inner, [type=reset]::-moz-focus-inner, [type=submit]::-moz-focus-inner, button::-moz-focus-inner { 35 | border-style: none; 36 | padding: 0 37 | } 38 | 39 | [type=button]:-moz-focusring, [type=reset]:-moz-focusring, [type=submit]:-moz-focusring, button:-moz-focusring { 40 | outline: 1px dotted ButtonText 41 | } 42 | 43 | fieldset { 44 | padding: .35em .75em .625em 45 | } 46 | 47 | textarea { 48 | overflow: auto 49 | } 50 | 51 | [type=checkbox], [type=radio] { 52 | box-sizing: border-box; 53 | padding: 0 54 | } 55 | 56 | [type=number]::-webkit-inner-spin-button, [type=number]::-webkit-outer-spin-button { 57 | height: auto 58 | } 59 | 60 | [type=search] { 61 | -webkit-appearance: textfield; 62 | outline-offset: -2px 63 | } 64 | 65 | [type=search]::-webkit-search-decoration { 66 | -webkit-appearance: none 67 | } 68 | 69 | ::-webkit-file-upload-button { 70 | -webkit-appearance: button; 71 | font: inherit 72 | } 73 | 74 | * { 75 | box-sizing: border-box 76 | } 77 | 78 | html { 79 | font-size: 100%; 80 | -webkit-text-size-adjust: 100%; 81 | -ms-text-size-adjust: 100%; 82 | height: 100% 83 | } 84 | 85 | body { 86 | position: relative; 87 | overflow-x: hidden 88 | } 89 | 90 | h1, h2, h3, h4, h5, h6 { 91 | font-size: 1em; 92 | font-weight: 400 93 | } 94 | 95 | h1, h2, h3, h4, h5, h6, li, p, small { 96 | display: block; 97 | margin: 0 98 | } 99 | 100 | ol, ul { 101 | list-style: none; 102 | list-style-image: none; 103 | margin: 0; 104 | padding: 0 105 | } 106 | 107 | img { 108 | max-width: 100%; 109 | border: 0; 110 | -ms-interpolation-mode: bicubic; 111 | vertical-align: middle; 112 | display: inline-block; 113 | width: 100%; 114 | height: auto 115 | } 116 | 117 | figure { 118 | margin: 0 119 | } 120 | 121 | a, button, input { 122 | -ms-touch-action: none !important 123 | } 124 | 125 | input { 126 | border-radius: 0 127 | } 128 | 129 | input[type=radio] { 130 | -webkit-appearance: radio 131 | } 132 | 133 | input[type=checkbox] { 134 | -webkit-appearance: checkbox 135 | } 136 | 137 | textarea { 138 | resize: none 139 | } 140 | 141 | select { 142 | border-radius: 0 143 | } 144 | 145 | input::-ms-clear { 146 | display: none 147 | } 148 | 149 | input[type=search], input[type=text], textarea { 150 | -webkit-appearance: none; 151 | border-radius: 0; 152 | box-sizing: border-box 153 | } 154 | 155 | fieldset { 156 | border: 0; 157 | padding: 0; 158 | margin: 0 159 | } 160 | 161 | picture { 162 | display: block 163 | } 164 | 165 | address { 166 | font-style: normal 167 | } 168 | 169 | a { 170 | color: inherit; 171 | text-decoration: none 172 | } 173 | 174 | b, strong { 175 | font-weight: 400 176 | } 177 | 178 | em, i { 179 | font-style: normal 180 | } 181 | 182 | button:not(:focus) { 183 | outline: 0 184 | } 185 | 186 | .no-outline a, .no-outline button, .no-outline input, .no-outline label, .no-outline option, .no-outline select, .no-outline textarea { 187 | outline: none !important 188 | } 189 | 190 | .l-wrap { 191 | padding-left: 30px; 192 | padding-right: 30px; 193 | max-width: 740px; 194 | margin: 0 auto; 195 | } 196 | 197 | .Facebook { 198 | background: #F0F2F5; 199 | font-family: system-ui, -apple-system, BlinkMacSystemFont, '.SFNSText-Regular', sans-serif; 200 | } 201 | 202 | .Facebook__blue { 203 | color: #365899 204 | } 205 | 206 | .Facebook__context { 207 | width: 100%; 208 | max-width: 680px; 209 | border: 1px solid #dddfe2; 210 | border-radius: 8px; 211 | overflow: hidden; 212 | background-color: #ffffff; 213 | box-shadow: 1px 1px 3px 0px rgba(0,0,0,0.05); 214 | } 215 | 216 | .Facebook__context-post { 217 | padding: 12px; 218 | font-size: 14px; 219 | line-height: 19px; 220 | } 221 | 222 | .Facebook__context-post div:first-child { 223 | position: relative 224 | } 225 | 226 | .Facebook__context-post div:first-child::before { 227 | content: ""; 228 | position: absolute; 229 | top: 0; 230 | left: 0; 231 | width: 40px; 232 | height: 40px; 233 | display: block; 234 | background: #ccc; 235 | border-radius: 50%; 236 | } 237 | 238 | .Facebook__context-post div:first-child::after { 239 | content: "..."; 240 | position: absolute; 241 | top: -2px; 242 | right: 0; 243 | font-weight: 700; 244 | font-size: 18px 245 | } 246 | 247 | .Facebook__context-post div:first-child span { 248 | display: block; 249 | margin-left: 48px 250 | } 251 | 252 | .Facebook__context-post div:first-child span:first-child { 253 | font-size: 14px; 254 | color: #365899; 255 | font-weight: 600 256 | } 257 | 258 | .Facebook__context-post div:first-child span:last-child { 259 | font-size: 12px; 260 | color: #616770 261 | } 262 | 263 | .Facebook__context-post div:last-child { 264 | margin-top: 10px 265 | } 266 | 267 | .Facebook__context-post-bottom { 268 | padding: 12px; 269 | border-top: 1px solid #eff0f1; 270 | height: 51px; 271 | width: 100%; 272 | position: relative 273 | } 274 | 275 | .Facebook__context-post-bottom::before { 276 | content: ""; 277 | position: absolute; 278 | top: 8px; 279 | left: 12px; 280 | width: 34px; 281 | height: 34px; 282 | display: block; 283 | background: #ccc; 284 | border-radius: 50%; 285 | } 286 | 287 | .Facebook__context-post-bottom::after { 288 | content: ""; 289 | position: absolute; 290 | display: block; 291 | width: calc(100% - 66px); 292 | height: 34px; 293 | right: 12px; 294 | top: 8px; 295 | background: #f2f3f5; 296 | border: 1px solid #ccd0d5; 297 | border-radius: 18px; 298 | } 299 | 300 | .Facebook__context-post-share { 301 | height: 40px; 302 | } 303 | 304 | .Facebook__item { 305 | width: 100%; 306 | background-color: #f2f3f5; 307 | } 308 | 309 | .Facebook__image { 310 | height: 0; 311 | padding-bottom: 52.5%; 312 | border-bottom: 1px solid rgba(0, 0, 0, .1); 313 | position: relative; 314 | overflow: hidden; 315 | } 316 | 317 | .Facebook__image::after { 318 | content: ""; 319 | position: absolute; 320 | top: 0; 321 | left: 0; 322 | width: 100%; 323 | height: 1px; 324 | background: rgba(0, 0, 0, .1) 325 | } 326 | 327 | .Facebook__image img { 328 | display: block; 329 | position: absolute; 330 | left: 0; 331 | top: 0; 332 | width: 100%; 333 | height: 100%; 334 | object-fit: cover; 335 | object-position: center center; 336 | } 337 | 338 | .Facebook__meta { 339 | padding: 10px 12px 14px; 340 | background-color: #F0F2F5; 341 | } 342 | 343 | .Facebook__url { 344 | color: #606770; 345 | font-size: 12px; 346 | line-height: 16px; 347 | text-transform: uppercase 348 | } 349 | 350 | .Facebook__text { 351 | max-height: 46px; 352 | overflow: hidden 353 | } 354 | 355 | .Facebook__title { 356 | font-weight: 600; 357 | overflow: hidden; 358 | font-size: 16px; 359 | line-height: 20px; 360 | margin: 5px 0 0; 361 | max-height: 110px; 362 | word-wrap: break-word; 363 | -webkit-line-clamp: 2; 364 | -webkit-box-orient: vertical; 365 | display: -webkit-box; 366 | text-overflow: ellipsis; 367 | white-space: normal; 368 | color: #1d2129 369 | } 370 | 371 | .Facebook__title span { 372 | display: block 373 | } 374 | 375 | .Facebook__description { 376 | color: #606770; 377 | font-size: 14px; 378 | line-height: 20px; 379 | word-break: break-word; 380 | margin-top: 3px; 381 | max-height: 80px; 382 | overflow: hidden; 383 | text-overflow: ellipsis; 384 | white-space: normal; 385 | -webkit-line-clamp: 1; 386 | -webkit-box-orient: vertical; 387 | display: -webkit-box 388 | } 389 | 390 | .Twitter { 391 | width: 100%; 392 | font-family: Helvetica Neue, Helvetica, Arial, sans-serif; 393 | background: #FFFFFF; 394 | -webkit-font-smoothing: antialiased; 395 | } 396 | 397 | .Twitter__context { 398 | width: 100%; 399 | max-width: 600px; 400 | background: #fff; 401 | padding: 12px 16px 24px; 402 | position: relative; 403 | background: #fff; 404 | border: 1px solid #EFF3F4; 405 | } 406 | 407 | .Twitter__context::before { 408 | content: ""; 409 | position: absolute; 410 | width: 40px; 411 | height: 40px; 412 | background: #ccc; 413 | border-radius: 50%; 414 | top: 12px; 415 | left: 16px; 416 | } 417 | 418 | .Twitter__context-inner { 419 | margin-left: 58px 420 | } 421 | 422 | .Twitter__context-tweet { 423 | line-height: 20px; 424 | font-size: 14px; 425 | color: #14171a; 426 | } 427 | 428 | .Twitter__context-meta span:first-child { 429 | font-weight: 700; 430 | margin-right: 4px 431 | } 432 | 433 | .Twitter__context-meta span:nth-child(n+2) { 434 | font-size: 14px; 435 | color: #657786; 436 | margin-right: 5px 437 | } 438 | 439 | .Twitter__context-meta span:last-child::before { 440 | content: "\B7"; 441 | margin-right: 4px 442 | } 443 | 444 | .Twitter__context-content { 445 | margin-bottom: 10px 446 | } 447 | 448 | .Twitter__card { 449 | width: 100%; 450 | border: 1px solid #e1e8ed; 451 | border-radius: 6px; 452 | overflow: hidden; 453 | font-size: 14px; 454 | line-height: 1.3em; 455 | max-width: 508px 456 | } 457 | 458 | .Twitter__image { 459 | height: 0; 460 | padding-bottom: 50%; 461 | border-bottom: 1px solid #e1e8ed; 462 | position: relative; 463 | overflow: hidden; 464 | } 465 | 466 | .Twitter__image img { 467 | display: block; 468 | position: absolute; 469 | left: 0; 470 | top: 0; 471 | width: 100%; 472 | height: 100%; 473 | object-fit: cover; 474 | object-position: center center; 475 | } 476 | 477 | .Twitter__meta { 478 | padding: .75em 1em; 479 | background: #fff 480 | } 481 | 482 | .Twitter__title { 483 | max-height: 1.3em; 484 | white-space: nowrap; 485 | overflow: hidden; 486 | text-overflow: ellipsis; 487 | font-size: 1em; 488 | margin: 0 0 .15em; 489 | font-weight: 700 490 | } 491 | 492 | .Twitter__description { 493 | max-height: 2.6em; 494 | word-wrap: break-word; 495 | -webkit-line-clamp: 2; 496 | -webkit-box-orient: vertical; 497 | display: -webkit-box 498 | } 499 | 500 | .Twitter__description, .Twitter__url { 501 | overflow: hidden; 502 | margin-top: .32333em; 503 | text-overflow: ellipsis 504 | } 505 | 506 | .Twitter__url { 507 | text-transform: lowercase; 508 | color: #8899a6; 509 | max-height: 1.3em; 510 | white-space: nowrap 511 | } 512 | 513 | .Google { 514 | font-family: arial, sans-serif; 515 | padding-top: 20px; 516 | } 517 | 518 | .Google__title { 519 | color: #1a0dab; 520 | font-size: 18px; 521 | line-height: 1.2; 522 | margin-bottom: 1px; 523 | } 524 | 525 | .Google__url { 526 | color: #006621; 527 | line-height: 18px; 528 | max-width: 624px; 529 | position: relative; 530 | font-size: 13px 531 | } 532 | 533 | .Google__description { 534 | font-size: 13px; 535 | line-height: 18px; 536 | max-width: 624px; 537 | color: #545454 538 | } 539 | 540 | .Facebook .l-wrap, .Google .l-wrap, .Twitter .l-wrap, .LinkedIn .l-wrap { 541 | padding-top: 40px; 542 | padding-bottom: 60px; 543 | } 544 | 545 | h2 div.l-wrap { 546 | padding-top: 10px !important; 547 | padding-bottom: 10px !important; 548 | } 549 | 550 | h2 { 551 | font-size: 24px; 552 | font-weight: 700; 553 | } 554 | 555 | .Facebook h2 { 556 | background: #4267b2; 557 | color: #fff 558 | } 559 | 560 | .Twitter h2 { 561 | background: #0F141A; 562 | color: #fff; 563 | font-weight: 300; 564 | } 565 | 566 | .Google h2 { 567 | border-bottom: 1px solid #F1F1F1; 568 | padding: 0 0 10px; 569 | font-weight: 400; 570 | letter-spacing: 1px; 571 | } 572 | 573 | .Google h2 span:first-child { 574 | color: #4285f4 575 | } 576 | 577 | .Google h2 span:nth-child(2) { 578 | color: #ea4335 579 | } 580 | 581 | .Google h2 span:nth-child(3) { 582 | color: #f1b505 583 | } 584 | 585 | .Google h2 span:nth-child(4) { 586 | color: #4184f2 587 | } 588 | 589 | .Google h2 span:nth-child(5) { 590 | color: #34a853 591 | } 592 | 593 | .Google h2 span:nth-child(6) { 594 | color: #e94335 595 | } 596 | 597 | .LinkedIn { 598 | background: #F4F2EE; 599 | font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Fira Sans", Ubuntu, Oxygen, "Oxygen Sans", Cantarell, "Droid Sans", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Lucida Grande", Helvetica, Arial, sans-serif; 600 | } 601 | 602 | .LinkedIn__context { 603 | width: 100%; 604 | max-width: 558px; 605 | background-color: #FFFFFF; 606 | border: 1px solid #DFDEDA; 607 | border-radius: 8px; 608 | overflow: hidden; 609 | width: 100%; 610 | } 611 | 612 | .LinkedIn__post { 613 | width: 100%; 614 | padding: 12px 15px 15px; 615 | } 616 | 617 | .LinkedIn__post-meta { 618 | display: flex; 619 | width: 100%; 620 | margin-bottom: 10px; 621 | color: rgba(0, 0, 0, 0.9); 622 | } 623 | 624 | .LinkedIn__post-metatext { 625 | width: calc(100% - 56px); 626 | max-width: 320px; 627 | flex: none; 628 | } 629 | 630 | .LinkedIn__post-metatext > span { 631 | text-overflow: ellipsis; 632 | overflow: hidden; 633 | width: 100%; 634 | max-width: 100%; 635 | white-space: nowrap; 636 | vertical-align: bottom; 637 | } 638 | 639 | .LinkedIn__post-meta-name { 640 | display: block; 641 | font-size: 14px; 642 | color: rgba(0, 0, 0, .6); 643 | margin-bottom: 3px; 644 | } 645 | 646 | .LinkedIn__post-meta-name span:first-child { 647 | font-weight: 600; 648 | color: rgba(0, 0, 0, 0.9); 649 | } 650 | 651 | .LinkedIn__post-meta-job { 652 | display: block; 653 | font-size: 12px; 654 | color: rgba(0, 0, 0, .6); 655 | margin-bottom: 3px; 656 | } 657 | 658 | .LinkedIn__post-meta-date { 659 | display: flex; 660 | align-items: center; 661 | font-size: 12px; 662 | color: rgba(0, 0, 0, .6); 663 | } 664 | 665 | .LinkedIn__post-meta-date svg { 666 | fill: currentColor; 667 | display: block; 668 | width: 14px; 669 | height: 14px; 670 | flex: none; 671 | margin-left: 4px; 672 | } 673 | 674 | .LinkedIn__post-avatar { 675 | width: 48px; 676 | height: 48px; 677 | background-color: #cccccc; 678 | border-radius: 100%; 679 | flex: none; 680 | margin-right: 8px; 681 | } 682 | 683 | .LinkedIn__post-text { 684 | font-size: 14px; 685 | line-height: 1.5; 686 | color: rgba(0, 0, 0, .9); 687 | } 688 | 689 | .LinkedIn__post-text > span { 690 | display: inline-block; 691 | width: auto; 692 | } 693 | 694 | .LinkedIn__post-url { 695 | text-overflow: ellipsis; 696 | overflow: hidden; 697 | width: 100%; 698 | max-width: 100%; 699 | white-space: nowrap; 700 | vertical-align: bottom; 701 | } 702 | 703 | .LinkedIn__card { 704 | background-color: #EDF3F7; 705 | } 706 | 707 | .LinkedIn__card-image { 708 | width: 100%; 709 | height: 0; 710 | padding-bottom: 52.5%; 711 | position: relative; 712 | overflow: hidden; 713 | } 714 | 715 | .LinkedIn__card-image img { 716 | position: absolute; 717 | left: 0; top: 0; 718 | width: 100%; height: 100%; 719 | object-fit: cover; 720 | } 721 | 722 | .LinkedIn__card-text { 723 | padding: 10px 12px 14px; 724 | } 725 | 726 | .LinkedIn__card-title { 727 | font-size: 16px; 728 | line-height: 1.5; 729 | font-weight: 600; 730 | margin-bottom: 2px; 731 | } 732 | 733 | .LinkedIn__card-domain { 734 | font-size: 12px; 735 | color: rgba(0, 0, 0, .6); 736 | } 737 | 738 | .LinkedIn h2 { 739 | background: #0277B5; 740 | color: #fff 741 | } 742 | -------------------------------------------------------------------------------- /src/services/MetaService.php: -------------------------------------------------------------------------------- 1 | getSettings(); 39 | 40 | $overrideObject = $context['seomate'] ?? null; 41 | 42 | if ($overrideObject && isset($overrideObject['config'])) { 43 | SEOMateHelper::updateSettings($settings, $overrideObject['config']); 44 | } 45 | 46 | if ($overrideObject && isset($overrideObject['element'])) { 47 | $element = $overrideObject['element']; 48 | } else { 49 | $element = $craft->urlManager->getMatchedElement(); 50 | } 51 | 52 | // Check if we have a cache 53 | if ($element && CacheHelper::hasMetaCacheForElement($element)) { 54 | return CacheHelper::getMetaCacheForElement($element); 55 | } 56 | 57 | $meta = []; 58 | 59 | // Get element meta data 60 | if ($element) { 61 | $meta = $this->getElementMeta($element, $overrideObject); 62 | } 63 | 64 | // Additional meta data 65 | if (!empty($settings->additionalMeta)) { 66 | $meta = $this->processAdditionalMeta($meta, $context, $settings); 67 | } 68 | 69 | // Overwrite with pre-generated values from template 70 | if ($overrideObject && isset($overrideObject['meta'])) { 71 | $this->overrideMeta($meta, $overrideObject['meta']); 72 | } 73 | 74 | // Add default meta if available 75 | if (!empty($settings->defaultMeta)) { 76 | $meta = $this->processDefaultMeta($meta, $context, $settings); 77 | } 78 | 79 | // Autofill missing attributes 80 | $meta = $this->autofillMeta($meta, $settings); 81 | 82 | // Parse assets if applicable 83 | if (!$settings->returnImageAsset) { 84 | $meta = $this->transformMetaAssets($meta, $settings); 85 | } 86 | 87 | // Apply restrictions 88 | if ($settings->applyRestrictions) { 89 | $meta = $this->applyMetaRestrictions($meta, $settings); 90 | } 91 | 92 | // Filter and encode 93 | $meta = $this->applyMetaFilters($meta); 94 | 95 | // Add sitename if desirable 96 | if ($settings->includeSitenameInTitle) { 97 | $meta = $this->addSitename($meta, $context, $settings); 98 | } 99 | 100 | // Cache it 101 | if ($element) { 102 | CacheHelper::setMetaCacheForElement($element, $meta); 103 | } 104 | 105 | return $meta; 106 | } 107 | 108 | /** 109 | * Gets all element meta data 110 | * 111 | * @param array|null $overrides 112 | * 113 | */ 114 | public function getElementMeta(Element $element, array $overrides = null): array 115 | { 116 | $settings = SEOMate::getInstance()->getSettings(); 117 | 118 | if ($overrides && isset($overrides['config'])) { 119 | SEOMateHelper::updateSettings($settings, $overrides['config']); 120 | } 121 | 122 | if ($overrides && isset($overrides['profile'])) { 123 | $profile = $overrides['profile']; 124 | } else { 125 | $profile = SEOMateHelper::getElementProfile($element, $settings); 126 | } 127 | 128 | if ($profile === null) { 129 | $profile = $settings->defaultProfile ?? null; 130 | } 131 | 132 | $meta = []; 133 | 134 | if ($profile && isset($settings->fieldProfiles[$profile])) { 135 | $fieldProfile = $settings->fieldProfiles[$profile]; 136 | 137 | $meta = $this->generateElementMetaByProfile($element, $fieldProfile); 138 | } 139 | 140 | return $meta; 141 | } 142 | 143 | /** 144 | * Gets element meta data based on profile 145 | */ 146 | public function generateElementMetaByProfile(Element $element, array $profile): array 147 | { 148 | $r = []; 149 | 150 | foreach ($profile as $key => $value) { 151 | $keyType = SEOMateHelper::getMetaTypeByKey($key); 152 | $r[$key] = $this->getElementPropertyDataByFields($element, $keyType, $value); 153 | } 154 | 155 | return $r; 156 | } 157 | 158 | /** 159 | * Gets the value for a metadata property in *element*, from a list of fields and type. 160 | */ 161 | public function getElementPropertyDataByFields(Element $element, string $type, array $fields): Asset|string 162 | { 163 | foreach ($fields as $fieldDef) { 164 | $value = SEOMateHelper::getPropertyDataByScopeAndHandle($element, $fieldDef, $type); 165 | 166 | if (!empty($value)) { 167 | return $value; 168 | } 169 | } 170 | 171 | return ''; 172 | } 173 | 174 | /** 175 | * Gets the value for a metadata property in *context*, from a list of fields and type. 176 | */ 177 | public function getContextPropertyDataByFields(array $context, string $type, array $fields): Asset|string 178 | { 179 | foreach ($fields as $fieldDef) { 180 | if (is_string($fieldDef) && !str_contains(trim($fieldDef), '{')) { 181 | // Get the deepest scope possible, and the remaining field handle. 182 | [$primaryScope, $fieldDef] = SEOMateHelper::reduceScopeAndHandle($context, $fieldDef); 183 | 184 | if ($primaryScope === null) { 185 | continue; 186 | } 187 | } else { 188 | $primaryScope = $context; 189 | } 190 | 191 | $value = SEOMateHelper::getPropertyDataByScopeAndHandle($primaryScope, $fieldDef, $type); 192 | 193 | if (!empty($value)) { 194 | return $value; 195 | } 196 | } 197 | 198 | return ''; 199 | } 200 | 201 | /** 202 | * Transforms meta data assets. 203 | * 204 | * @param Settings|null $settings 205 | * 206 | */ 207 | public function transformMetaAssets(array $meta, Settings $settings = null): array 208 | { 209 | if ($settings === null) { 210 | $settings = SEOMate::getInstance()->getSettings(); 211 | } 212 | 213 | $imageTransformMap = $settings->imageTransformMap; 214 | 215 | foreach ($imageTransformMap as $key => $value) { 216 | if (isset($meta[$key]) && $meta[$key] !== '') { 217 | $transform = $value; 218 | $asset = $meta[$key]; 219 | 220 | if ($asset) { 221 | try { 222 | $meta[$key] = $this->getTransformedUrl($asset, $transform, $settings); 223 | } catch (\Throwable $throwable) { 224 | Craft::error($throwable->getMessage(), __METHOD__); 225 | } 226 | 227 | $alt = null; 228 | 229 | if ($settings->altTextFieldHandle && $asset[$settings->altTextFieldHandle] && ((string)$asset[$settings->altTextFieldHandle] !== '')) { 230 | $alt = $asset[$settings->altTextFieldHandle]; 231 | } 232 | 233 | if ($key === 'og:image') { 234 | if ($alt) { 235 | $meta[$key . ':alt'] = $alt; 236 | } 237 | 238 | if (isset($transform['format'])) { 239 | $meta[$key . ':type'] = 'image/' . ($transform['format'] === 'jpg' ? 'jpeg' : $transform['format']); 240 | } 241 | 242 | // todo: Ideally, we should get these from the final transform 243 | if (isset($transform['width'])) { 244 | $meta[$key . ':width'] = $transform['width']; 245 | } 246 | 247 | if (isset($transform['height'])) { 248 | $meta[$key . ':height'] = $transform['height']; 249 | } 250 | } 251 | 252 | if ($key === 'twitter:image' && $alt) { 253 | $meta[$key . ':alt'] = $alt; 254 | } 255 | } 256 | } 257 | } 258 | 259 | return $meta; 260 | } 261 | 262 | /** 263 | * Transforms asset and returns URL. 264 | * 265 | * @param null|Settings $settings 266 | * 267 | * @throws SiteNotFoundException 268 | */ 269 | public function getTransformedUrl(Asset|string $asset, array $transform, Settings $settings = null): string 270 | { 271 | if ($settings === null) { 272 | $settings = SEOMate::getInstance()->getSettings(); 273 | } 274 | 275 | $plugins = Craft::$app->getPlugins(); 276 | $imagerPlugin = $plugins->getPlugin('imager-x'); 277 | 278 | $transformedUrl = ''; 279 | 280 | if ($settings->useImagerIfInstalled && $imagerPlugin instanceof ImagerX) { 281 | try { 282 | $transformedAsset = $imagerPlugin->imagerx->transformImage($asset, $transform, [], []); 283 | 284 | if ($transformedAsset) { 285 | $transformedUrl = $transformedAsset->getUrl(); 286 | } 287 | } catch (\Throwable $throwable) { 288 | Craft::error($throwable->getMessage(), __METHOD__); 289 | } 290 | } else { 291 | $generateTransformsBeforePageLoad = Craft::$app->config->general->generateTransformsBeforePageLoad; 292 | Craft::$app->config->general->generateTransformsBeforePageLoad = true; 293 | 294 | try { 295 | $imageTransform = new ImageTransform(); 296 | $validKeys = array_keys($imageTransform->getAttributes()); 297 | 298 | $transform = array_filter($transform, static function($k) use ($validKeys) { 299 | return in_array($k, $validKeys, true); 300 | }, ARRAY_FILTER_USE_KEY); 301 | 302 | $transformedUrl = $asset->getUrl($transform); 303 | } catch (\Throwable $throwable) { 304 | Craft::error($throwable->getMessage(), __METHOD__); 305 | } 306 | 307 | Craft::$app->config->general->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; 308 | } 309 | 310 | if (!$transformedUrl) { 311 | return ''; 312 | } 313 | 314 | return SEOMateHelper::ensureAbsoluteUrl($transformedUrl); 315 | } 316 | 317 | /** 318 | * Applies override meta data 319 | */ 320 | public function overrideMeta(array &$meta, array $overrideMeta): void 321 | { 322 | foreach ($overrideMeta as $key => $value) { 323 | $meta[$key] = $value; 324 | } 325 | } 326 | 327 | /** 328 | * Autofills missing meta data based on autofillMap config setting 329 | * 330 | * @param null|Settings $settings 331 | */ 332 | public function autofillMeta(array $meta, Settings $settings = null): array 333 | { 334 | if ($settings === null) { 335 | $settings = SEOMate::getInstance()->getSettings(); 336 | } 337 | 338 | $autofillMap = SEOMateHelper::expandMap($settings->autofillMap); 339 | 340 | foreach ($autofillMap as $key => $value) { 341 | if (!isset($meta[$key]) && isset($meta[$value])) { 342 | $meta[$key] = $meta[$value]; 343 | } 344 | } 345 | 346 | return $meta; 347 | } 348 | 349 | /** 350 | * Applies restrictions to meta data. 351 | * 352 | * Currently, only maxLength is enforced. 353 | * 354 | * @param array $meta 355 | * @param null|Settings $settings 356 | * @return array 357 | */ 358 | public function applyMetaRestrictions(array $meta, Settings $settings = null): array 359 | { 360 | if ($settings === null) { 361 | $settings = SEOMate::getInstance()->getSettings(); 362 | } 363 | 364 | $restrictionsMap = SEOMateHelper::expandMap($settings->metaPropertyTypes); 365 | 366 | foreach ($meta as $key => $value) { 367 | if (isset($restrictionsMap[$key])) { 368 | $restrictions = $restrictionsMap[$key]; 369 | 370 | if ($restrictions['type'] === 'text' && isset($restrictions['maxLength']) && \strlen($value) > $restrictions['maxLength']) { 371 | $meta[$key] = mb_substr($value, 0, $restrictions['maxLength'] - strlen($settings->truncateSuffix)) . $settings->truncateSuffix; 372 | } 373 | } 374 | } 375 | 376 | return $meta; 377 | } 378 | 379 | /** 380 | * Apply any filters and encoding 381 | */ 382 | public function applyMetaFilters(array $meta): array 383 | { 384 | foreach ($meta as $key => $value) { 385 | if (is_string($value) && !str_starts_with($value, 'http') && !str_starts_with($value, '//')) { 386 | $meta[$key] = htmlspecialchars($value, ENT_QUOTES, 'UTF-8', false); 387 | } 388 | } 389 | 390 | return $meta; 391 | } 392 | 393 | /** 394 | * Adds sitename to meta properties that should have it, as defined 395 | * by sitenameTitleProperties config setting. 396 | * 397 | * @param null|Settings $settings 398 | */ 399 | public function addSitename(array $meta, array $context, Settings $settings = null): array 400 | { 401 | if ($settings === null) { 402 | $settings = SEOMate::getInstance()->getSettings(); 403 | } 404 | 405 | $siteName = ''; 406 | 407 | try { 408 | if (\is_array($settings->siteName)) { 409 | $siteName = $settings->siteName[Craft::$app->getSites()->getCurrentSite()->handle] ?? ''; 410 | } elseif ($settings->siteName && \is_string($settings->siteName)) { 411 | $siteName = $settings->siteName; 412 | } else { 413 | $siteName = Craft::$app->getSites()->getCurrentSite()->name ?? ''; 414 | } 415 | } catch (SiteNotFoundException $siteNotFoundException) { 416 | Craft::error($siteNotFoundException->getMessage(), __METHOD__); 417 | } 418 | 419 | if ($siteName !== '') { 420 | try { 421 | $siteName = Craft::$app->getView()->renderString($siteName, $context); 422 | } catch (\Throwable) { 423 | // Ignore, and continue with the current sitename value 424 | } 425 | 426 | $preString = $settings->sitenamePosition === 'before' ? $siteName . ' ' . $settings->sitenameSeparator . ' ' : ''; 427 | $postString = $settings->sitenamePosition === 'after' ? ' ' . $settings->sitenameSeparator . ' ' . $siteName : ''; 428 | 429 | foreach ($settings->sitenameTitleProperties as $property) { 430 | $metaValue = $preString . ($meta[$property] ?? '') . $postString; 431 | $meta[$property] = \trim($metaValue, sprintf(' %s', $settings->sitenameSeparator)); 432 | } 433 | } 434 | 435 | return $meta; 436 | } 437 | 438 | /** 439 | * Process and return default meta data 440 | * 441 | * @param null|Settings $settings 442 | */ 443 | public function processDefaultMeta(array $meta, array $context = [], Settings $settings = null): array 444 | { 445 | if ($settings === null) { 446 | $settings = SEOMate::getInstance()->getSettings(); 447 | } 448 | 449 | foreach ($settings->defaultMeta as $key => $value) { 450 | if (!isset($meta[$key]) || $meta[$key] === '') { 451 | $keyType = SEOMateHelper::getMetaTypeByKey($key); 452 | $meta[$key] = $this->getContextPropertyDataByFields($context, $keyType, $value); 453 | } 454 | } 455 | 456 | return $meta; 457 | } 458 | 459 | /** 460 | * Processes and returns additional meta data 461 | * 462 | * @param null|Settings $settings 463 | */ 464 | public function processAdditionalMeta(array $meta, array $context = [], Settings $settings = null): array 465 | { 466 | if ($settings === null) { 467 | $settings = SEOMate::getInstance()->getSettings(); 468 | } 469 | 470 | foreach ($settings->additionalMeta as $key => $value) { 471 | if ($value instanceof \Closure) { 472 | $r = $value($context); 473 | $value = $r; 474 | } 475 | 476 | if (is_array($value)) { 477 | foreach ($value as $subValue) { 478 | $renderedValue = SEOMateHelper::renderString($subValue, $context); 479 | 480 | if ($renderedValue && $renderedValue !== '') { 481 | $meta[$key][] = $renderedValue; 482 | } 483 | } 484 | } else { 485 | $meta[$key] = SEOMateHelper::renderString($value, $context); 486 | } 487 | } 488 | 489 | return $meta; 490 | } 491 | } 492 | -------------------------------------------------------------------------------- /src/helpers/SEOMateHelper.php: -------------------------------------------------------------------------------- 1 | $val) { 46 | $settings[$key] = $val; 47 | } 48 | } 49 | 50 | /** 51 | * Gets the profile to use from element and settings 52 | */ 53 | public static function getElementProfile(Element $element, Settings $settings): ?string 54 | { 55 | if (empty($settings->profileMap)) { 56 | return null; 57 | } 58 | 59 | $fieldMap = self::expandMap($settings->profileMap); 60 | 61 | if ($element instanceof Entry) { 62 | $typeHandle = $element->getType()->handle; 63 | $sectionHandle = $element->getSection()?->handle; 64 | $mapIds = [ 65 | "entryType:$typeHandle", 66 | $sectionHandle ? "section:$sectionHandle" : null, 67 | $typeHandle, 68 | $sectionHandle, 69 | ]; 70 | } else if ($element instanceof Category) { 71 | $groupHandle = $element->getGroup()->handle; 72 | $mapIds = [ 73 | "categoryGroup:$groupHandle", 74 | $groupHandle, 75 | ]; 76 | } else if ($element instanceof User) { 77 | $mapIds = [ 78 | "user", 79 | ]; 80 | } else if ($element instanceof Product) { 81 | $productTypeHandle = $element->getType()->handle; 82 | $mapIds = [ 83 | "productType:$productTypeHandle", 84 | $productTypeHandle, 85 | ]; 86 | } else { 87 | return null; 88 | } 89 | 90 | $mapIds = array_values(array_unique(array_filter($mapIds))); 91 | 92 | if (empty($mapIds)) { 93 | return null; 94 | } 95 | 96 | foreach ($mapIds as $mapId) { 97 | if (!empty($fieldMap[$mapId])) { 98 | return $fieldMap[$mapId]; 99 | } 100 | } 101 | 102 | return null; 103 | } 104 | 105 | /** 106 | * Returns the meta type from key 107 | */ 108 | public static function getMetaTypeByKey(string $key): string 109 | { 110 | $settings = SEOMate::getInstance()->getSettings(); 111 | $typeMap = self::expandMap($settings->metaPropertyTypes); 112 | 113 | if (isset($typeMap[$key])) { 114 | if (\is_array($typeMap[$key])) { 115 | return $typeMap[$key]['type']; 116 | } 117 | 118 | return $typeMap[$key]; 119 | } 120 | 121 | return 'text'; 122 | } 123 | 124 | /** 125 | * Reduces a nested scope to the deepest possible target scope, and return it and 126 | * the remaining handle. 127 | */ 128 | public static function reduceScopeAndHandle(array $scope, string $handle): array 129 | { 130 | if (strrpos($handle, '.') === false) { 131 | return [$scope, $handle]; 132 | } 133 | 134 | $currentScope = null; 135 | $handleParts = explode('.', $handle); 136 | $first = true; // a wee bit ugly, but it's to avoid that a wrong target is reached if one part is null. 137 | 138 | for ($i = 0, $iMax = count($handleParts) - 1; $i < $iMax; ++$i) { 139 | $part = $handleParts[$i]; 140 | 141 | if (strrpos($part, ':') !== false) { 142 | return [$currentScope, implode('.', array_slice($handleParts, $i))]; 143 | } 144 | 145 | if ($first) { 146 | $currentScope = $scope[$part] ?? null; 147 | $first = false; 148 | } elseif ($currentScope !== null) { 149 | $currentScope = $currentScope[$part] ?? null; 150 | } 151 | } 152 | 153 | return [$currentScope, $handleParts[count($handleParts) - 1]]; 154 | } 155 | 156 | /** 157 | * @param ElementInterface|array $scope 158 | * @param string|\Closure $handle 159 | * @param string $type 160 | * 161 | * @return Asset|string|null 162 | */ 163 | public static function getPropertyDataByScopeAndHandle(ElementInterface|array $scope, string|\Closure $handle, string $type): Asset|string|null 164 | { 165 | if ($handle instanceof \Closure) { 166 | try { 167 | $result = $handle($scope); 168 | } catch (\Throwable $throwable) { 169 | Craft::error('An error occurred when calling closure: '.$throwable->getMessage(), __METHOD__); 170 | 171 | return null; 172 | } 173 | if ($type === 'text') { 174 | return static::getStringPropertyValue($result); 175 | } 176 | if ($type === 'image') { 177 | return static::getImagePropertyValue($result); 178 | } 179 | 180 | return null; 181 | } 182 | 183 | if (str_contains(trim($handle), '{')) { 184 | try { 185 | $result = Craft::$app->getView()->renderObjectTemplate($handle, $scope); 186 | } catch (\Throwable $throwable) { 187 | Craft::error('An error occurred when trying to render object template: '.$throwable->getMessage(), __METHOD__); 188 | 189 | return null; 190 | } 191 | if ($type === 'text') { 192 | return static::getStringPropertyValue($result); 193 | } 194 | // If this is an "image" meta tag type, assume that the object template has rendered an asset ID 195 | if ($type === 'image' && $assetId = (int)$result) { 196 | $asset = Asset::find()->id($assetId)->one(); 197 | 198 | return static::getImagePropertyValue($asset); 199 | } 200 | 201 | return null; 202 | } 203 | 204 | if (!empty($scope[$handle])) { 205 | if ($type === 'text') { 206 | return static::getStringPropertyValue($scope[$handle]); 207 | } 208 | if ($type === 'image') { 209 | return static::getImagePropertyValue($scope[$handle]); 210 | } 211 | } elseif (strpos($handle, ':')) { 212 | 213 | // Assume subfield, in the format fieldHandle.typeHandle:subFieldHandle – check that the format looks correct, just based on the delimiters used 214 | $delimiters = preg_replace('/[^\\.:]+/', '', $handle); 215 | if ($delimiters !== '.:') { 216 | // This is not something we can work with :/ 217 | Craft::warning("Invalid syntax encountered for sub fields in SEOMate field profile config: \"$handle\". The correct syntax is \"fieldHandle.typeHandle:subFieldHandle\""); 218 | 219 | return null; 220 | } 221 | 222 | // Get field, block type and subfield handles 223 | [$fieldHandle, $blockTypeHandle, $subFieldHandle] = explode('.', str_replace(':', '.', $handle)); 224 | if (!$fieldHandle || !$blockTypeHandle || !$subFieldHandle) { 225 | return null; 226 | } 227 | 228 | // Make sure that the field is in scope, in some form or another 229 | $value = $scope[$fieldHandle] ?? null; 230 | if (empty($value)) { 231 | return null; 232 | } 233 | 234 | // Fetch the blocks 235 | if ($value instanceof EntryQuery) { 236 | $query = (clone $value)->type($blockTypeHandle); 237 | if ($type === 'image') { 238 | $query->with([sprintf('%s:%s', $blockTypeHandle, $subFieldHandle)]); 239 | } 240 | $blocks = $query->all(); 241 | } else { 242 | $blocks = Collection::make($value) 243 | ->filter(static function(mixed $block) use ($blockTypeHandle) { 244 | return $block instanceof Entry && $block->getType()->handle === $blockTypeHandle; 245 | }) 246 | ->all(); 247 | } 248 | 249 | if (empty($blocks)) { 250 | return null; 251 | } 252 | 253 | /** @var Entry[] $blocks */ 254 | foreach ($blocks as $block) { 255 | 256 | if ($type === 'text') { 257 | 258 | if ($value = static::getStringPropertyValue($block->$subFieldHandle ?? null)) { 259 | return $value; 260 | } 261 | } else if ($type === 'image') { 262 | 263 | if ($asset = static::getImagePropertyValue($block->$subFieldHandle ?? null)) { 264 | return $asset; 265 | } 266 | } 267 | } 268 | } elseif (strpos($handle, '.')) { 269 | $segments = explode('.', $handle); 270 | $fieldHandle = array_pop($segments); 271 | 272 | $fieldScope = $scope; 273 | 274 | foreach ($segments as $segment) { 275 | if (!empty($fieldScope[$segment])) { 276 | $fieldScope = $fieldScope[$segment]; 277 | } else { 278 | return null; 279 | } 280 | } 281 | 282 | if (!empty($fieldScope[$fieldHandle])) { 283 | if ($type === 'text') { 284 | return static::getStringPropertyValue($fieldScope[$fieldHandle]); 285 | } 286 | if ($type === 'image') { 287 | return static::getImagePropertyValue($fieldScope[$fieldHandle]); 288 | } 289 | } 290 | } 291 | 292 | return null; 293 | } 294 | 295 | /** 296 | * Return a meta-safe string value from raw input 297 | * 298 | * @param mixed $input 299 | * 300 | * @return string|null 301 | */ 302 | public static function getStringPropertyValue(mixed $input): ?string 303 | { 304 | if (empty($input)) { 305 | return null; 306 | } 307 | 308 | $value = (string)$input; 309 | 310 | // Replace all control characters, newlines and returns with a literal space 311 | $value = preg_replace('/(?|)(?=\S))/iu', '$1 ', $value); 316 | 317 | // Strip tags, trim and return 318 | return trim(strip_tags($value)) ?: null; 319 | } 320 | 321 | /** 322 | * Return a meta-safe image asset from raw input 323 | * 324 | * @param mixed $input 325 | * 326 | * @return Asset|null 327 | */ 328 | public static function getImagePropertyValue(mixed $input): ?Asset 329 | { 330 | if (empty($input)) { 331 | return null; 332 | } 333 | 334 | if ($input instanceof Asset) { 335 | $input = [$input]; 336 | } 337 | 338 | if ($input instanceof AssetQuery) { 339 | $collection = (clone $input)->kind(Asset::KIND_IMAGE)->collect(); 340 | } else if (is_array($input)) { 341 | $collection = Collection::make($input); 342 | } else if ($input instanceof Collection) { 343 | $collection = $input; 344 | } 345 | 346 | if (!isset($collection) || $collection->isEmpty()) { 347 | return null; 348 | } 349 | 350 | $settings = SEOMate::getInstance()->getSettings(); 351 | 352 | return $collection->first(static function(mixed $asset) use ($settings) { 353 | return $asset instanceof Asset && $asset->kind === Asset::KIND_IMAGE && in_array(strtolower($asset->getExtension()), $settings->validImageExtensions, true); 354 | }); 355 | } 356 | 357 | /** 358 | * Expands config setting map where key is exandable 359 | */ 360 | public static function expandMap(array $map): array 361 | { 362 | $r = []; 363 | 364 | foreach ($map as $k => $v) { 365 | $keys = explode(',', $k); 366 | 367 | foreach ($keys as $key) { 368 | $r[trim($key)] = $v; 369 | } 370 | } 371 | 372 | return $r; 373 | } 374 | 375 | /** 376 | * Checks if array is associative 377 | */ 378 | public static function isAssocArray(array $array): bool 379 | { 380 | if ([] === $array) { 381 | return false; 382 | } 383 | 384 | return array_keys($array) !== range(0, \count($array) - 1); 385 | } 386 | 387 | /** 388 | * Renders a string template with context 389 | */ 390 | public static function renderString(string $string, array $context): string 391 | { 392 | try { 393 | return Craft::$app->getView()->renderString($string, $context); 394 | } catch (\Throwable $throwable) { 395 | Craft::error($throwable->getMessage(), __METHOD__); 396 | } 397 | 398 | return ''; 399 | } 400 | 401 | /** 402 | * @throws SiteNotFoundException 403 | */ 404 | public static function ensureAbsoluteUrl(string $url): string 405 | { 406 | if (UrlHelper::isAbsoluteUrl($url)) { 407 | return $url; 408 | } 409 | 410 | // Get the base url and assume it's what we want to use 411 | $siteUrl = UrlHelper::baseSiteUrl(); 412 | $siteUrlParts = parse_url($siteUrl); 413 | $scheme = $siteUrlParts['scheme'] ?? (Craft::$app->getRequest()->isSecureConnection ? 'https' : 'http'); 414 | 415 | if (UrlHelper::isProtocolRelativeUrl($url)) { 416 | return UrlHelper::urlWithScheme($url, $scheme); 417 | } 418 | 419 | if (str_starts_with($url, '/')) { 420 | return $scheme.'://'.$siteUrlParts['host'].$url; 421 | } 422 | 423 | // huh, relative url? Seems unlikely, but... If we've come this far. 424 | return $scheme.'://'.$siteUrlParts['host'].'/'.$url; 425 | } 426 | 427 | /** 428 | * Returns true if the element a) has a URL and b) is eligble to be SEO-previewed as per the `previewEnabled` setting 429 | * 430 | * @param ElementInterface $element 431 | * 432 | * @return bool 433 | * @throws InvalidConfigException 434 | */ 435 | public static function isElementPreviewable(ElementInterface $element): bool 436 | { 437 | if (!$element->getUrl() || empty($element->id)) { 438 | // Anything that doesn't have a URL shouldn't have a SEO preview, and if it doesn't have an ID stuff won't work. 439 | return false; 440 | } 441 | 442 | $settings = SEOMate::getInstance()->getSettings(); 443 | $previewEnabled = $settings->previewEnabled; 444 | 445 | if (empty($previewEnabled)) { 446 | return false; 447 | } 448 | 449 | if (is_bool($previewEnabled)) { 450 | return $previewEnabled; 451 | } 452 | 453 | if (is_string($previewEnabled)) { 454 | $previewEnabled = explode(',', preg_replace('/\s+/', '', $previewEnabled)); 455 | } 456 | 457 | $previewEnabled = array_values(array_filter($previewEnabled)); 458 | 459 | if (empty($previewEnabled)) { 460 | return false; 461 | } 462 | 463 | // ...if the `previewEnabled` setting is an array, it's essentially a whitelist of stuff we want to preview 464 | if ($element instanceof Entry) { 465 | $typeHandle = $element->getType()->handle; 466 | $sectionHandle = $element->getSection()?->handle; 467 | $sourceHandles = [ 468 | "entryType:$typeHandle", 469 | $sectionHandle ? "section:$sectionHandle" : null, 470 | $typeHandle, 471 | $sectionHandle, 472 | ]; 473 | } else if ($element instanceof Category) { 474 | $categoryGroupHandle = $element->getGroup()->handle; 475 | $sourceHandles = [ 476 | "categoryGroup:$categoryGroupHandle", 477 | $categoryGroupHandle, 478 | ]; 479 | } else if ($element instanceof Product) { 480 | $productTypeHandle = $element->getType()->handle; 481 | $sourceHandles = [ 482 | "productType:$productTypeHandle", 483 | $productTypeHandle, 484 | ]; 485 | } else if ($element instanceof User) { 486 | $sourceHandles = [ 487 | 'user', 488 | ]; 489 | } else { 490 | return false; 491 | } 492 | 493 | $sourceHandles = array_values(array_unique(array_filter($sourceHandles))); 494 | 495 | foreach ($sourceHandles as $sourceHandle) { 496 | if (in_array($sourceHandle, $previewEnabled, true)) { 497 | return true; 498 | } 499 | } 500 | 501 | return false; 502 | } 503 | 504 | /** 505 | * @param mixed $url 506 | * 507 | * @return string 508 | */ 509 | public static function stripTokenParams(mixed $url): mixed 510 | { 511 | if (empty($url) || !is_string($url)) { 512 | return $url; 513 | } 514 | $queryParamsToRemove = [ 515 | Craft::$app->getConfig()->getGeneral()->tokenParam, 516 | Craft::$app->getConfig()->getGeneral()->siteToken, 517 | 'x-craft-live-preview', 518 | 'x-craft-preview', 519 | ]; 520 | foreach ($queryParamsToRemove as $queryParamToRemove) { 521 | $url = UrlHelper::removeParam($url, $queryParamToRemove); 522 | } 523 | 524 | return $url; 525 | } 526 | } 527 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | SEOMate plugin for Craft CMS 2 | === 3 | 4 | SEO, mate! It's important. That's why SEOMate provides the tools you need to craft 5 | all the meta tags, sitemaps and JSON-LD microdata you need - in one highly configurable, 6 | open and friendly package - with a super-light footprint. 7 | 8 | SEOMate aims to do less! Unlike other SEO plugins for Craft, there are no control panel 9 | settings or fieldtypes. Instead, you configure everything from the plugin's 10 | config file, which makes it easy and quick to set up, bootstrap and version control your 11 | configuration. All the data is pulled from native Craft fields, which makes for less 12 | maintenance over time, _and keeps you in control of your data_. 13 | 14 | Additionally, SEOMate adds a super-awesome SEO/social media preview to your Control Panel. The SEO preview taps into 15 | Craft's native Preview Targets, giving your clients a nice and familiar interface for previewing how their content will appear on Google, Facebook and Twitter. 16 | 17 | 18 | ![Screenshot](resources/plugin_logo.png) 19 | 20 | ## Requirements 21 | 22 | This plugin requires Craft CMS 5.0.0 or later. 23 | 24 | ## Installation 25 | 26 | To install the plugin, either install it from the plugin store, or follow these instructions: 27 | 28 | 1. Install with composer via `composer require vaersaagod/seomate` from your project directory. 29 | 2. Install the plugin in the Craft Control Panel under Settings → Plugins, or from the command line via `./craft install/plugin seomate`. 30 | 3. For SEOMate to do anything, you need to [configure it](#configuring). But first, continue reading! 31 | 32 | --- 33 | 34 | ## SEOMate Overview 35 | 36 | SEOMate focuses on providing developers with the tools they need to craft their 37 | site's SEO in three main areas; **meta data**, **sitemaps**, and **JSON-LD microdata**. 38 | 39 | ### Meta data 40 | SEOMate doesn't provide any custom field types for entering meta data. 41 | Instead, you use native field types that come with Craft, and just tell SEOMate 42 | which fields to use. 43 | 44 | You do this by configuring _field profiles_ for the different field setups in your site. Sections and category groups 45 | can be mapped to these profiles, or the desired profile can be set at the template level. 46 | 47 | The key config settings for meta data is `fieldProfiles`, `profileMap`, `defaultProfile`, 48 | `defaultMeta` and `additionalMeta`. Refer to the ["Adding meta data"](#adding-meta-data) 49 | section on how to include the meta data in your page, and how to (optionally) override it at the template level. 50 | 51 | ### Sitemaps 52 | SEOMate lets you create completely configuration based sitemaps for all your content. 53 | The sitemaps are automatically updated with new elements, and will automatically be 54 | split into multiple sitemaps for scalability. 55 | 56 | To enable sitemaps, set the `sitemapEnabled` config setting to `true` and configure the 57 | contents of your sitemaps with `sitemapConfig`. Refer to the ["Enabling sitemaps"](#enabling-sitemaps) 58 | section on how to enable and set up your sitemaps. 59 | 60 | ### JSON-LD 61 | SEOMate provides a thin wrapper around the excellent [`spatie/schema-org`](https://github.com/spatie/schema-org) 62 | package used for generating JSON-LD data structures. SEOMate exposes the `craft.schema` 63 | template variable, that directly ties into the fluent [Schema API](https://github.com/spatie/schema-org/blob/master/src/Schema.php). 64 | 65 | _This method uses the exact same approach and signature as [Rias' Schema plugin](https://github.com/Rias500/craft-schema). 66 | If you're only looking for a way to output JSON-LD, we suggest you use that plugin instead_. 67 | 68 | ### SEO preview 69 | SEOMate provides a fancy "SEO Preview" preview target, for any and all elements with URLs, featuring 70 | _photo realistic approximations_ of how your content will appear in Google SERPs, or when shared on Facebook, 71 | Twitter/X and LinkedIn. 72 | 73 | ![img.png](resources/seo-preview.png) 74 | 75 | _If you don't like the SEO preview, or if you'd like it to only appear for entries in specific sections, check 76 | out the [previewEnabled](`#previewenabled-boolarray`) config setting._ 77 | 78 | ### Things that SEOMate doesn't do... 79 | So much! 80 | 81 | --- 82 | 83 | ## Adding meta data 84 | 85 | Out of the box, SEOMate doesn't add anything to your markup. To get started, add 86 | `{% hook 'seomateMeta' %}` to the `` of your layout. Then, if you haven't already, 87 | create a config file named `seomate.php` in your `config` folder alongside your other 88 | Craft config files. This file can use [multi-environment configs](https://docs.craftcms.com/v3/config/environments.html#config-files), 89 | exactly the same as any other config file in Craft, but in the following examples we'll 90 | skip that part to keep things a bit more tidy. 91 | 92 | All the config settings are documented in the [`Configuring`](#configuring) section, 93 | and there are quite a few! But to get you going, these are some fundamental concepts: 94 | 95 | ### Field profiles 96 | 97 | A _field profile_ in SEOMate is, essentially, a mapping of metadata attributes to the fields that SEOMate 98 | should look at for those attributes' metadata values. 99 | 100 | To get started, create a profile called "standard" in `fieldProfiles`, and set that profile as the default 101 | field profile using the `defaultProfile` setting: 102 | 103 | ```php 104 | 'standard', 108 | 109 | 'fieldProfiles' => [ 110 | 'standard' => [ 111 | 'title' => ['seoTitle', 'heading', 'title'], 112 | 'description' => ['seoDescription', 'summary'], 113 | 'image' => ['seoImage', 'mainImage'] 114 | ] 115 | ], 116 | ]; 117 | ``` 118 | 119 | The above tells SEOMate to use the field profile `standard` to get element metadata from, as a default. 120 | So, everytime a page template that has an element (i.e. `entry`, `category` or `product`) is loaded, SEOMate will 121 | start by checking if that element has a field named `seoTitle`, and that this field has a value that can be used for 122 | the title meta tag. If a field named `seoTitle` does not exist – or if it's empty – SEOMate continues to check if 123 | there is a field named `heading`, and does the same thing. If `heading` is empty, it checks for `title`. 124 | And so on, for every key in the field profile. 125 | 126 | _💡 In addition to field handles, field profiles can also contain **functions** (i.e. closures), and/or 127 | **Twig [object templates](https://craftcms.com/docs/5.x/system/object-templates.html)**. For documentation and examples for closures and object templates, 128 | see the [`fieldProfiles` setting](#fieldprofiles-array)!_ 129 | 130 | #### Mapping different field profiles to elements 131 | 132 | Now, let's say we have a section with handle `news` that has a slightly different field setup than 133 | our other sections, so for entries in that section we want to pull data from some other fields. 134 | We'll add another field profile to `fieldProfiles`, and make a mapping between the profile and the 135 | section handle in `profileMap`: 136 | 137 | ```php 138 | 'standard', 142 | 143 | 'fieldProfiles' => [ 144 | 'standard' => [ 145 | 'title' => ['seoTitle', 'heading', 'title'], 146 | 'description' => ['seoDescription', 'summary'], 147 | 'image' => ['seoImage', 'mainImage'] 148 | ], 149 | 'newsprofile' => [ 150 | 'title' => ['seoTitle', 'heading', 'title'], 151 | 'og:title' => ['ogSeo.title', 'ogTitle', 'heading', 'title'], 152 | 'description' => ['seoDescription', 'newsExcerpt', 'introText'], 153 | 'image' => ['seoImage', 'heroImage', 'newsBlocks.image:image'] 154 | 'og:image' => ['ogSeo.image', 'ogImage', 'heroImage', 'newsBlocks.image:image'] 155 | 'twitter:image' => ['twitterImage', 'heroImage', 'newsBlocks.image:image'] 156 | ] 157 | ], 158 | 159 | 'profileMap' => [ 160 | 'news' => 'newsprofile', 161 | ], 162 | ]; 163 | ``` 164 | 165 | The mapping between the "news" section and the profile is simple enough: the _key_ in `profileMap` can 166 | be the handle for a section, entry type, category group, or Commerce product type, and the _value_ 167 | should be the key for the profile in `fieldProfiles` that you want to use for matching elements. 168 | 169 | _In this profile we also we have also used a couple of other SEOMate features._ 170 | 171 | First, notice that we have chosen to specify a field profile for `og:title`, `og:image` 172 | and `twitter:image` that we didn't have in the default profile. By default, the `autofillMap` 173 | defines that if no value are set for `og:title` and `twitter:title`, we want to autofill those 174 | meta tags with the value from `title`. So in the `standard` profile, those values will be 175 | autofilled, while in the `newsprofile` we choose to customize some of them. 176 | 177 | Secondly, we're using a nested object syntax for `ogSeo.title` and `ogSeo.image` which could for 178 | instance be used if you have a Content Block field (new in Craft 5.8) with values. Or any other 179 | field type that returns a nested object. It goes as deep as you want, `someField.withAnObject.that.has.a.deep.structure`. 180 | 181 | Thirdly, notice that we can specify to pull a value from a Matrix subfield by using the syntax 182 | `matrixFieldHandle.blockTypeHandle:subFieldHandle`. 183 | 184 | 185 | 186 | #### Profile map specificity 187 | 188 | **In some cases there might be a need to create more specific field profile mappings.** For example, you might have 189 | a section with the handle `news` _and_ a category group with the handle `news`, and you need their elements to use 190 | different profiles. This can be achieved by using prefixes `section:` and/or `categoryGroup:`, e.g. 191 | 192 | ```php 193 | 'profileMap' => [ 194 | 'section:news' => 'newsprofile', // Will match entries in a section "news" 195 | 'categoryGroup:news' => 'newscategoryprofile', // Will match categories in a group "news" 196 | ], 197 | ``` 198 | 199 | Another use case for specific field profiles is if you need a certain entry type to use a specific profile, in which 200 | case the `entryType:` prefix is the ticket: 201 | 202 | ```php 203 | 'profileMap' => [ 204 | 'section:news' => 'newsprofile', // Will match entries in a section "news" 205 | 'categoryGroup:news' => 'newscategoryprofile', // Will match categories in a group "news" 206 | 'pages' => 'pagesprofile', 207 | 'entryType:listPage' => 'listpageprofile', // Will match entries with an entry type "listPage" 208 | ], 209 | ``` 210 | 211 | The _specific_ field profiles (i.e. the ones using the `{sourceType}:` prefix) will take precedence over _unspecific_ 212 | ones. That means that – with the above config – entries in a section "page" will use the "pagesprofile" profile, 213 | unless they're using an entry type with the handle `listPage`, in which case the "listpageprofile" profile will be 214 | used. And, the "listpageprofile" will also be used for entries in _other_ sections, if they're using that same entry type. 215 | 216 | The following field profile specificity prefixes are supported: 217 | 218 | * Entries: `section:{sectionHandle}` and `entryType:{entryTypeHandle}` 219 | * Categories: `categoryGroup:{categoryGroupHandle}` 220 | * Commerce products: `productType:{productTypeHandle}` 221 | * Users: `user` 222 | 223 | ### Default meta data 224 | 225 | Field profiles are great for templates that have an element associated with them. But what about the ones 226 | that don't? Or – what if there is no valid image in any of those image fields defined in the matching field profile? 227 | This is where `defaultMeta` comes into play. Let's say that we have a global set with handle `globalSeo`, with 228 | fields that we want to fall back on if everything else fails: 229 | 230 | ```php 231 | [ 235 | 'title' => ['globalSeo.seoTitle'], 236 | 'description' => ['globalSeo.seoDescription'], 237 | 'image' => ['globalSeo.seoImages'] 238 | ], 239 | 240 | 'defaultProfile' => 'standard', 241 | 242 | 'fieldProfiles' => [ 243 | 'standard' => [ 244 | 'title' => ['seoTitle', 'heading', 'title'], 245 | 'description' => ['seoDescription', 'summary'], 246 | 'image' => ['seoImage', 'mainImage'] 247 | ], 248 | 'newsprofile' => [ 249 | 'title' => ['seoTitle', 'heading', 'title'], 250 | 'og:title' => ['ogTitle', 'heading', 'title'], 251 | 'description' => ['seoDescription', 'newsExcerpt', 'introText'], 252 | 'image' => ['seoImage', 'heroImage', 'newsBlocks.image:image'] 253 | 'og:image' => ['ogImage', 'heroImage', 'newsBlocks.image:image'] 254 | 'twitter:image' => ['twitterImage', 'heroImage', 'newsBlocks.image:image'] 255 | ] 256 | ], 257 | 258 | 'profileMap' => [ 259 | 'news' => 'newsprofile', 260 | ], 261 | ]; 262 | ``` 263 | 264 | The `defaultMeta` setting works almost exactly the same as `fieldProfiles`, except that it 265 | looks for objects and fields in you current Twig `context`, hence the use of globals. 266 | 267 | ### Additional meta data 268 | 269 | Lastly, we want to add some additional metadata like `og:type` and `twitter:card`, and for 270 | that we have... `additionalMeta`: 271 | 272 | ```php 273 | [ 277 | 'title' => ['globalSeo.seoTitle'], 278 | 'description' => ['globalSeo.seoDescription'], 279 | 'image' => ['globalSeo.seoImages'] 280 | ], 281 | 282 | 'defaultProfile' => 'standard', 283 | 284 | 'fieldProfiles' => [ 285 | 'standard' => [ 286 | 'title' => ['seoTitle', 'heading', 'title'], 287 | 'description' => ['seoDescription', 'summary'], 288 | 'image' => ['seoImage', 'mainImage'] 289 | ], 290 | 'newsprofile' => [ 291 | 'title' => ['seoTitle', 'heading', 'title'], 292 | 'og:title' => ['ogTitle', 'heading', 'title'], 293 | 'description' => ['seoDescription', 'newsExcerpt', 'introText'], 294 | 'image' => ['seoImage', 'heroImage', 'newsBlocks.image:image'] 295 | 'og:image' => ['ogImage', 'heroImage', 'newsBlocks.image:image'] 296 | 'twitter:image' => ['twitterImage', 'heroImage', 'newsBlocks.image:image'] 297 | ] 298 | ], 299 | 300 | 'profileMap' => [ 301 | 'news' => 'newsprofile', 302 | ], 303 | 304 | 'additionalMeta' => [ 305 | 'og:type' => 'website', 306 | 'twitter:card' => 'summary_large_image', 307 | 308 | 'fb:profile_id' => '{{ settings.facebookProfileId }}', 309 | 'twitter:site' => '@{{ settings.twitterHandle }}', 310 | 'twitter:author' => '@{{ settings.twitterHandle }}', 311 | 'twitter:creator' => '@{{ settings.twitterHandle }}', 312 | 313 | 'og:see_also' => function ($context) { 314 | $someLinks = []; 315 | $matrixBlocks = $context['globalSeo']?->someLinks?->all(); 316 | 317 | if (!empty($matrixBlocks)) { 318 | foreach ($matrixBlocks as $matrixBlock) { 319 | $someLinks[] = $matrixBlock->someLinkUrl ?? ''; 320 | } 321 | } 322 | 323 | return $someLinks; 324 | }, 325 | ], 326 | ]; 327 | ``` 328 | 329 | The `additionalMeta` setting takes either a string or an array, or a function that returns 330 | either of those, as the value for each property. Any Twig in the values are parsed, in the current 331 | context. 332 | 333 | 334 | ### Customizing the meta output template 335 | 336 | SEOMate comes with a generic template that outputs the meta data it generates. You can override this 337 | with your own template using the `metaTemplate` config setting. 338 | 339 | 340 | ### Overriding meta data and settings from your templates 341 | 342 | You can override the metadata and config settings directly from your templates by creating a 343 | `seomate` object and overriding accordingly: 344 | 345 | ```twig 346 | {% set seomate = { 347 | profile: 'specialProfile', 348 | element: craft.entries.section('newsListing').one(), 349 | canonicalUrl: someOtherUrl, 350 | 351 | config: { 352 | includeSitenameInTitle: false 353 | }, 354 | 355 | meta: { 356 | title: 'Custom title', 357 | 'twitter:author': '@someauthor' 358 | }, 359 | } %} 360 | ``` 361 | 362 | All relevant config settings can be overridden inside the `config` key, and all metadata 363 | inside the `meta` key. You can also tell seomate to use a specific profile with the `profile` setting. 364 | And to use some other element as the base element to get metadata from, or provide one if the current 365 | template doesn't have one, in the `element` key. And you can customize the canonicalUrl as needed. 366 | And... more. 367 | 368 | 369 | --- 370 | 371 | ## Enabling sitemaps 372 | 373 | To enable sitemaps for your site, you need to set the `sitemapEnabled` config setting to `true`, 374 | and configure the contents of your sitemaps with `sitemapConfig`. In its simplest form, you can supply 375 | an array of section handles to the elements key, with the sitemap settings you want: 376 | 377 | ```php 378 | 'sitemapEnabled' => true, 379 | 'sitemapLimit' => 100, 380 | 'sitemapConfig' => [ 381 | 'elements' => [ 382 | 'news' => ['changefreq' => 'weekly', 'priority' => 1], 383 | 'projects' => ['changefreq' => 'weekly', 'priority' => 0.5], 384 | ], 385 | ], 386 | ``` 387 | 388 | A sitemap index will be created at `sitemap.xml` at the root of your site, with links to 389 | sitemaps for each section, split into chunks based on `sitemapLimit`. 390 | 391 | You can also do more complex element criterias, and manually add custom paths: 392 | 393 | ```php 394 | 'sitemapEnabled' => true, 395 | 'sitemapLimit' => 100, 396 | 'sitemapConfig' => [ 397 | 'elements' => [ 398 | 'news' => ['changefreq' => 'weekly', 'priority' => 1], 399 | 'projects' => ['changefreq' => 'weekly', 'priority' => 0.5], 400 | 'frontpages' => [ 401 | 'elementType' => \craft\elements\Entry::class, 402 | 'criteria' => ['section' => ['homepage', 'newsFrontpage', 'projectsFrontpage']], 403 | 'params' => ['changefreq' => 'daily', 'priority' => 1], 404 | ], 405 | 'newscategories' => [ 406 | 'elementType' => \craft\elements\Category::class, 407 | 'criteria' => ['group' => 'newsCategories'], 408 | 'params' => ['changefreq' => 'weekly', 'priority' => 0.2], 409 | ], 410 | 'semisecret' => [ 411 | 'elementType' => \craft\elements\Entry::class, 412 | 'criteria' => ['section' => 'semiSecret', 'notThatSecret' => true], 413 | 'params' => ['changefreq' => 'daily', 'priority' => 0.5], 414 | ], 415 | ], 416 | 'custom' => [ 417 | '/cookies' => ['changefreq' => 'weekly', 'priority' => 1], 418 | '/terms-and-conditions' => ['changefreq' => 'weekly', 'priority' => 1], 419 | ], 420 | ], 421 | ``` 422 | 423 | Using the expanded criteria syntax, you can add whatever elements to your sitemaps. 424 | 425 | ### Multi-site sitemaps 426 | 427 | For multi-site installs, SEOMate will automatically create sitemaps for each site. 428 | If the [`outputAlternate`](#outputalternate-bool) config setting is enabled, sitemaps will include alternate URLs in entries. 429 | 430 | --- 431 | 432 | ## Configuring 433 | 434 | SEOMate can be configured by creating a file named `seomate.php` in your Craft config folder, 435 | and overriding settings as needed. 436 | 437 | ### cacheEnabled [bool] 438 | *Default: `'true'`* 439 | Enables/disables caching of generated metadata. **The cached data will be automatically 440 | cleared when an element is saved**. To clear the metadata cache manually, Craft's "Clear Caches" CP utility can be used, or the core `clear-caches` CLI command. 441 | 442 | ### cacheDuration [int|string] 443 | *Default: `3600`* 444 | Duration of meta cache in seconds. Can be set to an integer (seconds), or a valid PHP date interval string (e.g. 'PT1H'). 445 | 446 | ### previewEnabled [bool|array] 447 | *Default: `true`* 448 | Enable the "SEO Preview" preview target in the Control Panel everywhere (`true`), nowhere (`false`) or only for particular sections, category groups, entry types or Commerce product types (array of section and/or category group handles; e.g. `['news', 'events', 'homepage', 'section:blog', 'entryType:listPage']`, etc). 449 | _Regardless of this config setting, the "SEO Preview" preview target is only ever added to sections and category groups with URLs._ 450 | 451 | ### previewLabel [string|null] 452 | *Default: "SEO Preview"* 453 | Defines the text label for the "SEO Preview" button and preview target inside the Control Panel. 454 | 455 | ### siteName [string|array|null] 456 | *Default: `null`* 457 | Defines the site name to be used in metadata. Can be a plain string, or an array 458 | with site handles as keys. Example: 459 | 460 | ```php 461 | 'siteName' => 'My site' 462 | 463 | // or 464 | 465 | 'siteName' => [ 466 | 'default' => 'My site', 467 | 'other' => 'Another site', 468 | ] 469 | ``` 470 | 471 | If not set, SEOMate will try to get any site name defined in Craft's general config 472 | for the current site. If that doesn't work, the current site's name will be used. 473 | 474 | ### metaTemplate [string] 475 | *Default: `''`* 476 | SEOMate comes with a default meta template the outputs the configured meta tags. But, 477 | every project is different, so if you want to customize the output you can use this 478 | setting to provide a custom template (it needs to be in your site's template path). 479 | 480 | ### includeSitenameInTitle [bool] 481 | *Default: `true`* 482 | Enables/disabled if the site name should be displayed as part of the meta title. 483 | 484 | ### sitenameTitleProperties [array] 485 | *Default: `['title']`* 486 | Defines which meta title properties the site name should be added to. By default, 487 | the site name is only added to the `title` meta tag. 488 | 489 | Example that also adds it to `og:title` and `twitter:title` tags: 490 | 491 | ```php 492 | 'sitenameTitleProperties' => ['title', 'og:title', 'twitter:title'] 493 | ``` 494 | 495 | ### sitenamePosition [string] 496 | *Default: `'after'`* 497 | Defines if the site name should be placed `before` or `after` the rest of the 498 | meta content. 499 | 500 | ### sitenameSeparator [string] 501 | *Default: `'|'`* 502 | The separator between the meta tag content and the site name. 503 | 504 | ### outputAlternate [bool|Closure] 505 | *Default: `true`* 506 | Enables/disables output of alternate URLs in meta tags and sitemaps. 507 | 508 | Alternate URLs are meant to provide search engines with alternate URLs 509 | _for localized versions of the current page's content_. 510 | 511 | If you have a normal multi-locale website, you'll probably want to leave this setting 512 | enabled (i.e. set to `true`). However, if you're running a multi-site website where the 513 | sites are distinct, you'll might want to set it to `false`, to prevent alternate URLs 514 | from being output at all. 515 | 516 | For the Advanced Use Case (tm) – _e.g. multi-sites that have a mix of translated **and** 517 | distinct content_, it's also possible to break free from the shackles of the binary boolean, 518 | and configure the `outputAlternate` setting with a closure function (that returns either `true` 519 | or `false`). 520 | 521 | The `outputAlternate` closure will receive two parameters; `$element` (the current element) and 522 | `$alternateElement` (the element from a different site, i.e. the *potential* alternate). This makes 523 | it possible to compose custom logic, in order to determine if that alternate element's URL 524 | should be output or not. 525 | 526 | An example: the below closure would make SEOMate only output alternate URLs if the _language_ for 527 | the alternate element is different from the element's language: 528 | 529 | ```php 530 | 'outputAlternate' => static fn($element, $alternateElement) => $element->language !== $alternateElement->language, 531 | ``` 532 | 533 | If this closure returns `true`, SEOMate will create an alternate URL for the `$alternateElement`. 534 | If it returns `false` (or any other falsey value), SEOMate will quietly pretend the `$alternateElement` 535 | does not exist. 536 | 537 | _For more information about alternate URLs, [refer to this article](https://support.google.com/webmasters/answer/189077)._ 538 | 539 | ### alternateFallbackSiteHandle [string|null] 540 | *Default: `null`* 541 | Sets the site handle for the site that should be the fallback for unmatched languages, ie 542 | the alternate URL with `hreflang="x-default"`. 543 | 544 | Usually, this should be the globabl site that doesn't target a specific country. Or a site 545 | with a holding page where the user can select language. For more information about alternate URLs, 546 | (refer to this article)[https://support.google.com/webmasters/answer/189077]. 547 | 548 | ### altTextFieldHandle [string|null] 549 | *Default: `null`* 550 | If you have a field for alternate text on your assets, you should set this 551 | to your field's handle. This will pull and output the text for the `og:image:alt` 552 | and `twitter:image:alt` properties. 553 | 554 | ### defaultProfile [string|null] 555 | *Default: `''`* 556 | Sets the default meta data profile to use (see the `fieldProfiles` config setting). 557 | 558 | ### fieldProfiles [array] 559 | *Default: `[]`* 560 | Field profiles defines "waterfalls" for which fields should be used to fill which 561 | meta tags. You can have as many or as few profiles as you want. You can define a default 562 | profile using the `defaultProfile` setting, and you can map your sections and category 563 | groups using the `profileMap` setting. You can also override which profile to use, directly 564 | from your templates. 565 | 566 | Example: 567 | 568 | ```php 569 | 'defaultProfile' => 'default', 570 | 571 | 'fieldProfiles' => [ 572 | 'default' => [ 573 | 'title' => ['seoTitle', 'heading', 'title'], 574 | 'description' => ['seoDescription', 'summary'], 575 | 'image' => ['seoImage', 'mainImage'] 576 | ], 577 | 'products' => [ 578 | 'title' => ['seoTitle', 'heading', 'title'], 579 | 'description' => ['seoDescription', 'productDescription', 'summary'], 580 | 'image' => ['seoImage', 'mainImage', 'heroMedia:media.image'] 581 | ], 582 | 'landingPages' => [ 583 | 'title' => ['seoTitle', 'heading', 'title'], 584 | 'description' => ['seoDescription'], 585 | 'image' => ['seoImage', 'heroArea:video.image', 'heroArea:singleImage.image', 'heroArea:twoImages.images', 'heroArea:slideshow.images'] 586 | ], 587 | ], 588 | ``` 589 | 590 | Field waterfalls are parsed from left to right. Empty or missing values are ignored, 591 | and SEOMate continues to look for a valid value in the next field. 592 | 593 | #### Closures and object templates 594 | 595 | In addition to field handle references, field profiles can also contain functions (i.e. _closures_) 596 | and/or Twig [object templates](https://craftcms.com/docs/5.x/system/object-templates.html). 597 | 598 | Field profile **closures** take a single argument `$element` (i.e. the element SEOMate is rendering meta data for). 599 | Here's how a closure can look inside a field profile: 600 | 601 | ```php 602 | 'fieldProfiles' => [ 603 | 'default' => [ 604 | 'title' => ['seoTitle', static function ($element) { return "$element->title - ($element->productCode)"; }], 605 | ], 606 | ] 607 | ``` 608 | 609 | Generally, closures should return a string value (or `null`). The exception is image meta tags 610 | (e.g. `'image'`, `'og:image'`, etc.), where SEOMate will expect an asset (or `null`) returned: 611 | 612 | ```php 613 | 'fieldProfiles' => [ 614 | 'default' => [ 615 | 'image' => [static function ($element) { return $element->seoImage->one() ?? null; }], 616 | ], 617 | ] 618 | ``` 619 | 620 | **Object templates** are well documented in [the official Craft docs](https://craftcms.com/docs/5.x/system/object-templates.html). 621 | Here's how they can be used in field profiles (the two examples are using short- and longhand syntaxes, respectively): 622 | 623 | ```php 624 | 'fieldProfiles' => [ 625 | 'default' => [ 626 | 'title' => ['seoTitle', '{title} - ({productCode})', '{{ object.title }} - ({{ object.productCode }})'], 627 | ], 628 | ] 629 | ``` 630 | 631 | Object templates can only render strings, which make them less useful for image meta tags (that expect an asset returned). 632 | But if you really want to, you can render an asset ID, which SEOMate will use to query for the actual asset: 633 | 634 | ```php 635 | 'defaultMeta' => [ 636 | 'default' => [ 637 | 'image' => ['{seoImage.one().id}'], 638 | ], 639 | ] 640 | ``` 641 | 642 | ### profileMap [array] 643 | *Default: `[]`* 644 | The profile map provides a way to map elements to different field profiles defined in `fieldProfiles`, via their 645 | sections, entry types, category groups and Commerce product types. **If no matching profile in this mapping is found, 646 | the profile defined in `defaultProfile` will be used.** 647 | 648 | The keys in the `profileMap` should be a string containing one or several (comma-separated) element source handles, 649 | such as a section handle, entry type handle, category group handle or Commerce product type handle. These keys can 650 | be specific, such as `section:news` (to explicitly match entries belonging to a "news" section) or unspecific, such 651 | as simply `news` (which would match elements belong to _either_ a section, entry type, category group or product type 652 | with the handle `'news'`). 653 | 654 | Keys in `profileMap` are matched to elements from _most_ to _least_ specific, e.g. for an element with an 655 | entry type `listPage`, if the `profileMap` contained both a `listPage` and an `entryType:listPage` key, 656 | the latter would be used for that element. 657 | 658 | The following field profile specificity prefixes are supported: 659 | 660 | * Entries: `section:{sectionHandle}` and `entryType:{entryTypeHandle}` 661 | * Categories: `categoryGroup:{categoryGroupHandle}` 662 | * Commerce products: `productType:{productTypeHandle}` 663 | * Users: `user` 664 | 665 | Example: 666 | 667 | ```php 668 | 'profileMap' => [ 669 | 'news' => 'newsProfile', 670 | 'section:products' => 'productsProfile', 671 | 'section:frontpage,section:campaigns' => 'landingPagesProfile', 672 | 'entryType:listPage' => 'listPageProfile', 673 | 'categoryGroup:newsCategories' => 'newsCategoriesProfile', 674 | ], 675 | ``` 676 | 677 | ### defaultMeta [array] 678 | *Default: `[]`* 679 | This setting defines the default meta data that will be used if no valid meta data 680 | was found for the current element (ie, none of the fields provided in the field profile 681 | existed, or they all had empty values). 682 | 683 | The waterfall looks for meta data in the global _Twig context_. In the example 684 | below, we're falling back to using fields in two global sets, with handles `globalSeo` 685 | and `settings` respectively: 686 | 687 | ```php 688 | 'defaultMeta' => [ 689 | 'title' => ['globalSeo.seoTitle'], 690 | 'description' => ['globalSeo.seoDescription', 'settings.companyInfo'], 691 | 'image' => ['globalSeo.seoImages'] 692 | ], 693 | ``` 694 | 695 | #### Closures and object templates 696 | 697 | In addition to field handle references, `defaultMeta` can also contain functions (i.e. _closures_) 698 | and/or Twig [object templates](https://craftcms.com/docs/5.x/system/object-templates.html). 699 | 700 | Field profile **closures** take a single argument `$context` (i.e. an array; the global Twig context). 701 | Here's how a closure can look inside `defaultMeta`: 702 | 703 | ```php 704 | 'defaultMeta' => [ 705 | 'title' => [static function ($context) { return $context['siteName'] . ' is awesome!'; }], 706 | ] 707 | ``` 708 | 709 | Generally, closures should return a string value (or `null`). The exception is image meta tags 710 | (e.g. `'image'`, `'og:image'`, etc.), where SEOMate will expect an asset (or `null`) returned: 711 | 712 | ```php 713 | 'defaultMeta' => [ 714 | 'image' => [static function ($context) { return $context['defaultSeoImage']->one() ?? null; }], 715 | ] 716 | ``` 717 | 718 | **Object templates** are well documented in [the official Craft docs](https://craftcms.com/docs/5.x/system/object-templates.html). 719 | Here's how they can be used in `defaultMeta` (note that for `defaultMeta`, the `object` variable refers to the global 720 | Twig context): 721 | 722 | ```php 723 | 'defaultMeta' => [ 724 | 'title' => ['{siteName} is awesome!', '{{ object.siteName }} is awesome!'], 725 | ] 726 | ``` 727 | 728 | Object templates can only render strings, which make them less useful for image meta tags (that expect an asset returned). 729 | But if you really want to, you can render an asset ID, which SEOMate will use to query for the actual asset: 730 | 731 | ```php 732 | 'defaultMeta' => [ 733 | 'image' => ['{defaultSeoImage.one().id}'], 734 | ] 735 | ``` 736 | 737 | ### additionalMeta [array] 738 | *Default: `[]`* 739 | 740 | The additional meta setting defines all other meta data that you want SEOMate 741 | to output. This is a convenient way to add more global meta data, that is used 742 | throughout the site. Please note that you don't have to use this, you could also 743 | just add the meta data directly to your meta, or html head, template. 744 | 745 | The key defines the meta data property to output, and the value could be either 746 | a plain text, some twig that will be parsed based on the current context, an array 747 | which will result in multiple tags of this property being output, or a function. 748 | 749 | In the example below, some properties are plain text (`og:type` and `twitter:card`), 750 | some contains twig (for instance `fb:profile_id`), and for `og:see_also` we provide 751 | a function that returns an array. 752 | 753 | ```php 754 | 'additionalMeta' => [ 755 | 'og:type' => 'website', 756 | 'twitter:card' => 'summary_large_image', 757 | 758 | 'fb:profile_id' => '{{ settings.facebookProfileId }}', 759 | 'twitter:site' => '@{{ settings.twitterHandle }}', 760 | 'twitter:author' => '@{{ settings.twitterHandle }}', 761 | 'twitter:creator' => '@{{ settings.twitterHandle }}', 762 | 763 | 'og:see_also' => function ($context) { 764 | $someLinks = []; 765 | $matrixBlocks = $context['globalSeo']->someLinks->all() ?? null; 766 | 767 | if ($matrixBlocks && count($matrixBlocks) > 0) { 768 | foreach ($matrixBlocks as $matrixBlock) { 769 | $someLinks[] = $matrixBlock->someLinkUrl ?? ''; 770 | } 771 | } 772 | 773 | return $someLinks; 774 | }, 775 | ], 776 | ``` 777 | 778 | ### metaPropertyTypes [array] 779 | *Default: (see below)* 780 | This setting defines the type and limitations of the different meta tags. Currently, 781 | there are two valid types, `text` and `image`. 782 | 783 | Example/default value: 784 | ```php 785 | [ 786 | 'title,og:title,twitter:title' => [ 787 | 'type' => 'text', 788 | 'minLength' => 10, 789 | 'maxLength' => 60 790 | ], 791 | 'description,og:description,twitter:description' => [ 792 | 'type' => 'text', 793 | 'minLength' => 50, 794 | 'maxLength' => 300 795 | ], 796 | 'image,og:image,twitter:image' => [ 797 | 'type' => 'image' 798 | ], 799 | ] 800 | ``` 801 | 802 | ### applyRestrictions [bool] 803 | *Default: `false`* 804 | Enables/disables enforcing of restrictions defined in `metaPropertyTypes`. 805 | 806 | ### validImageExtensions [array] 807 | *Default: `['jpg', 'jpeg', 'gif', 'png']`* 808 | Valid filename extensions for image property types. 809 | 810 | ### truncateSuffix [string] 811 | *Default: `'…'`* 812 | Suffix to add to truncated meta values. 813 | 814 | ### returnImageAsset [bool] 815 | *Default: `false`* 816 | By default, assets will be transformed by SEOMate, and the resulting URL is 817 | cached and passed to the template. 818 | 819 | By enabling this setting, the asset itself will instead be returned to the 820 | template. This can be useful if you want to perform more complex transforms, 821 | or output more meta tags where you need more asset data, that can only be done 822 | at the template level. Please note that you'll probably want to provide a custom 823 | `metaTemplate`, and that caching will not work (you should instead use your own 824 | template caching). 825 | 826 | ### useImagerIfInstalled [bool] 827 | *Default: `true`* 828 | If [Imager](https://github.com/aelvan/Imager-Craft) is installed, SEOMate will 829 | automatically use it for transforms (they're mates!), but you can disable this 830 | setting to use native Craft transforms instead. 831 | 832 | ### imageTransformMap [array] 833 | *Default: (see below)* 834 | Defines the image transforms that are to be used for the different meta image 835 | properties. All possible options of Imager or native Craft transforms can be used. 836 | 837 | Default value: 838 | ```php 839 | [ 840 | 'image' => [ 841 | 'width' => 1200, 842 | 'height' => 675, 843 | 'format' => 'jpg', 844 | ], 845 | 'og:image' => [ 846 | 'width' => 1200, 847 | 'height' => 630, 848 | 'format' => 'jpg', 849 | ], 850 | 'twitter:image' => [ 851 | 'width' => 1200, 852 | 'height' => 600, 853 | 'format' => 'jpg', 854 | ], 855 | ] 856 | ``` 857 | 858 | Example where the Facebook and Twitter images has been sharpened, desaturated 859 | and given a stylish blue tint (requires Imager): 860 | 861 | ```php 862 | 'imageTransformMap' => [ 863 | 'image' => [ 864 | 'width' => 1200, 865 | 'height' => 675, 866 | 'format' => 'jpg' 867 | ], 868 | 'og:image' => [ 869 | 'width' => 1200, 870 | 'height' => 630, 871 | 'format' => 'jpg', 872 | 'effects' => [ 873 | 'sharpen' => true, 874 | 'modulate' => [100, 0, 100], 875 | 'colorBlend' => ['rgb(0, 0, 255)', 0.5] 876 | ] 877 | ], 878 | 'twitter:image' => [ 879 | 'width' => 1200, 880 | 'height' => 600, 881 | 'format' => 'jpg', 882 | 'effects' => [ 883 | 'sharpen' => true, 884 | 'modulate' => [100, 0, 100], 885 | 'colorBlend' => ['rgb(0, 0, 255)', 0.5] 886 | ] 887 | ], 888 | ], 889 | ``` 890 | 891 | ### autofillMap [array] 892 | *Default: (see below)* 893 | Map of properties that should be automatically filled by another property, 894 | _if they're empty after the profile has been parsed_. 895 | 896 | Default value: 897 | ```php 898 | [ 899 | 'og:title' => 'title', 900 | 'og:description' => 'description', 901 | 'og:image' => 'image', 902 | 'twitter:title' => 'title', 903 | 'twitter:description' => 'description', 904 | 'twitter:image' => 'image', 905 | ] 906 | ``` 907 | 908 | ### tagTemplateMap [array] 909 | *Default: (see below)* 910 | Map of output templates for the meta properties. 911 | 912 | Example/default value: 913 | ```php 914 | [ 915 | 'default' => '', 916 | 'title' => '{{ value }}', 917 | '/^og:/,/^fb:/' => '', 918 | ] 919 | ``` 920 | 921 | ### sitemapEnabled [bool] 922 | *Default: `false`* 923 | Enables/disables sitemaps. 924 | 925 | ### sitemapName [string] 926 | *Default: `'sitemap'`* 927 | Name of sitemap. By default it will be called `sitemap.xml`. 928 | 929 | ### sitemapLimit [int] 930 | *Default: `500`* 931 | Number of URLs per sitemap. SEOMate will automatically make a sitemap index 932 | and split up your sitemap into chunks with a maximum number of URLs as per 933 | this setting. A lower number could ease the load on your server when the 934 | sitemap is being generated. 935 | 936 | ### sitemapConfig [array] 937 | *Default: `[]`* 938 | Defines the content of the sitemaps. The configuration consists of two main 939 | keys, `elements` and `custom`. In `elements`, you can define sitemaps that 940 | will automatically query for elements in certain sections or based on custom 941 | criterias. 942 | 943 | In `custom` you add paths that are added to a separate custom sitemap, and you 944 | may also add links to manually generated sitemaps in `additionalSitemaps`. Both 945 | of these settings can be a flat array of custom urls or sitemap paths that you 946 | want to add, or a nested array where the keys are site handles, to specify 947 | custom urls/sitemaps that are site specific, or `'*'`, for additional ones. 948 | See the example below. 949 | 950 | In the example below, we get all elements from the sections with handles 951 | `projects` and `news`, query for entries in four specific 952 | sections and all categories in group `newsCategories`. In addition to these, 953 | we add two custom urls, and two additional sitemaps. 954 | 955 | ```php 956 | 'sitemapConfig' => [ 957 | 'elements' => [ 958 | 'projects' => ['changefreq' => 'weekly', 'priority' => 0.5], 959 | 'news' => ['changefreq' => 'weekly', 'priority' => 0.5], 960 | 961 | 'indexpages' => [ 962 | 'elementType' => \craft\elements\Entry::class, 963 | 'criteria' => ['section' => ['frontpage', 'newsListPage', 'membersListPage', 'aboutPage']], 964 | 'params' => ['changefreq' => 'daily', 'priority' => 0.5], 965 | ], 966 | 'newscategories' => [ 967 | 'elementType' => \craft\elements\Category::class, 968 | 'criteria' => ['group' => 'newsCategories'], 969 | 'params' => ['changefreq' => 'weekly', 'priority' => 0.2], 970 | ], 971 | ], 972 | 'custom' => [ 973 | '/custom-1' => ['changefreq' => 'weekly', 'priority' => 1], 974 | '/custom-2' => ['changefreq' => 'weekly', 'priority' => 1], 975 | ], 976 | 'additionalSitemaps' => [ 977 | '/sitemap-from-other-plugin.xml', 978 | '/manually-generated-sitemap.xml' 979 | ] 980 | ], 981 | ``` 982 | 983 | Example with site specific custom urls and additional sitemaps: 984 | 985 | ```php 986 | 'sitemapConfig' => [ 987 | /* ... */ 988 | 989 | 'custom' => [ 990 | '*' => [ 991 | '/custom-global-1' => ['changefreq' => 'weekly', 'priority' => 1], 992 | '/custom-global-2' => ['changefreq' => 'weekly', 'priority' => 1], 993 | ], 994 | 'english' => [ 995 | '/custom-english' => ['changefreq' => 'weekly', 'priority' => 1], 996 | ], 997 | 'norwegian' => [ 998 | '/custom-norwegian' => ['changefreq' => 'weekly', 'priority' => 1], 999 | ] 1000 | ], 1001 | 'additionalSitemaps' => [ 1002 | '*' => [ 1003 | '/sitemap-from-other-plugin.xml', 1004 | '/sitemap-from-another-plugin.xml', 1005 | ], 1006 | 'english' => [ 1007 | '/manually-generated-english-sitemap.xml', 1008 | ], 1009 | 'norwegian' => [ 1010 | '/manually-generated-norwegian-sitemap.xml', 1011 | ] 1012 | ] 1013 | ], 1014 | ``` 1015 | 1016 | **Using the expanded criteria syntax, you can query for whichever type of element, 1017 | as long as they are registered as a valid element type in Craft.** 1018 | 1019 | The main sitemap index will be available on the root of your site, and named 1020 | according to the `sitemapName` config setting (`sitemap.xml` by default). The actual 1021 | sitemaps will be named using the pattern `sitemap__.xml` for 1022 | elements and `sitemap_custom.xml` for the custom urls. 1023 | 1024 | ### sitemapSubmitUrlPatterns [array] 1025 | *Default: (see below)* 1026 | URL patterns that your sitemaps are submitted to. 1027 | 1028 | Example/default value: 1029 | ```php 1030 | 'sitemapSubmitUrlPatterns' => [ 1031 | 'http://www.google.com/webmasters/sitemaps/ping?sitemap=', 1032 | 'http://www.bing.com/webmaster/ping.aspx?siteMap=', 1033 | ]; 1034 | ``` 1035 | 1036 | 1037 | --- 1038 | 1039 | ## Template variables 1040 | 1041 | ### craft.seomate.getMeta([config=[]]) 1042 | Returns an object with the same meta data that is passed to the meta data 1043 | template. 1044 | 1045 | ```twig 1046 | {% set metaData = craft.seomate.getMeta() %} 1047 | Meta Title: {{ metaData.meta.title }} 1048 | Canonical URL: {{ metaData.canonicalUrl }} 1049 | ``` 1050 | 1051 | You can optionally pass in a config object the same way you would in your template 1052 | overrides, to customize the data, or use a custom element as the source: 1053 | 1054 | ```twig 1055 | {% set metaData = craft.seomate.getMeta({ 1056 | profile: 'specialProfile', 1057 | element: craft.entries.section('newsListing').one(), 1058 | canonicalUrl: someOtherUrl, 1059 | 1060 | config: { 1061 | includeSitenameInTitle: false 1062 | }, 1063 | 1064 | meta: { 1065 | title: 'Custom title', 1066 | 'twitter:author': '@someauthor' 1067 | }, 1068 | }) %} 1069 | ``` 1070 | 1071 | ### craft.schema 1072 | You can access all the different schemas in the [`spatie/schema-org`](https://github.com/spatie/schema-org) 1073 | package through this variable endpoint. If you're using PHPStorm and the Symfony plugin, 1074 | you can get full autocompletion by assigning type hinting (see example below) 1075 | 1076 | Example: 1077 | 1078 | ```twig 1079 | {# @var schema \Spatie\SchemaOrg\Schema #} 1080 | {% set schema = craft.schema %} 1081 | 1082 | {{ schema.recipe 1083 | .dateCreated(entry.dateCreated) 1084 | .dateModified(entry.dateUpdated) 1085 | .datePublished(entry.postDate) 1086 | .copyrightYear(entry.postDate | date('Y')) 1087 | .name(entry.title) 1088 | .headline(entry.title) 1089 | .description(entry.summary | striptags) 1090 | .url(entry.url) 1091 | .mainEntityOfPage(entry.url) 1092 | .inLanguage('nb_no') 1093 | .author(schema.organization 1094 | .name('The Happy Chef') 1095 | .url('https://www.thehappychef.xyz/') 1096 | ) 1097 | .recipeCategory(categories) 1098 | .recipeCuisine(entry.cuisine) 1099 | .keywords(ingredientCategories | merge(categories) | join(', ')) 1100 | .recipeIngredient(ingredients) 1101 | .recipeInstructions(steps) 1102 | .recipeYield(entry.portions ~ ' porsjoner') 1103 | .cookTime('PT'~entry.cookTime~'M') 1104 | .prepTime('PT'~entry.prepTime~'M') 1105 | .image(schema.imageObject 1106 | .url(image.url) 1107 | .width(schema.QuantitativeValue.value(image.getWidth())) 1108 | .height(schema.QuantitativeValue.value(image.getHeight())) 1109 | ) 1110 | | raw }} 1111 | ``` 1112 | 1113 | _Again, if you're only looking for a way to output JSON-LD, we suggest you 1114 | use [Rias' Schema plugin](https://github.com/Rias500/craft-schema) instead_. 1115 | 1116 | ### craft.seomate.renderMetaTag(key, value) 1117 | Renders a meta tag based on `key` and `value`. Uses the `tagTemplateMap` config 1118 | setting to determine how the markup should look. 1119 | 1120 | Does exactly the same thing as the `renderMetaTag` twig function. 1121 | 1122 | ### craft.seomate.breadcrumbSchema(breadcrumb) 1123 | A convenient method for outputting a JSON-LD breadcrumb. The method takes an 1124 | array of objects with properties for `url` and `name`, and outputs a valid 1125 | Schema.org JSON-LD data structure. 1126 | 1127 | Example: 1128 | ```twig 1129 | {% set breadcrumb = [ 1130 | { 1131 | 'url': siteUrl, 1132 | 'name': 'Frontpage' 1133 | }, 1134 | { 1135 | 'url': currentCategory.url, 1136 | 'name': currentCategory.title 1137 | }, 1138 | { 1139 | 'url': entry.url, 1140 | 'name': entry.title 1141 | } 1142 | ] %} 1143 | 1144 | {{ craft.seomate.breadcrumbSchema(breadcrumb) }} 1145 | ``` 1146 | 1147 | --- 1148 | 1149 | ## Twig functions 1150 | 1151 | ### renderMetaTag(key, value) 1152 | Renders a meta tag based on `key` and `value`. Uses the `tagTemplateMap` config 1153 | setting to determine how the markup should look. 1154 | 1155 | Does exactly the same thing as the `craft.seomate.renderMetaTag` template variable. 1156 | 1157 | 1158 | --- 1159 | 1160 | ## Price, license and support 1161 | 1162 | The plugin is released under the MIT license, meaning you can do what ever you want with it as long 1163 | as you don't blame us. **It's free**, which means there is absolutely no support included, but you 1164 | might get it anyway. Just post an issue here on github if you have one, and we'll see what we can do. 1165 | 1166 | ## Changelog 1167 | 1168 | See [CHANGELOG.MD](https://raw.githubusercontent.com/vaersaagod/seomate/master/CHANGELOG.md). 1169 | 1170 | ## Credits 1171 | 1172 | Brought to you by [Værsågod](https://www.vaersaagod.no) 1173 | 1174 | Icon designed by [Freepik from Flaticon](https://www.flaticon.com/authors/freepik). 1175 | --------------------------------------------------------------------------------