diff --git a/Plugin.php b/Plugin.php index d289591..009733e 100644 --- a/Plugin.php +++ b/Plugin.php @@ -9,6 +9,7 @@ use Winter\SEO\Classes\Meta; use Winter\SEO\Models\Settings; use Yaml; +use Config; /** * SEO Plugin Information File @@ -99,6 +100,7 @@ public function boot(): void $this->extendBackendForms(); $this->extendPagesForms(); + $this->extendExternalPlugins(); $this->populateGlobalTags(); } @@ -270,6 +272,55 @@ protected function populateGlobalTags() // Add the Winter CMS generator tag Meta::set('generator', 'Winter CMS'); + } + + /** + * Extends external plugins with the SEO form fields + */ + protected function extendExternalPlugins(): void + { + $modelsToAttach = Config::get('winter.seo::models_to_attach', []); + + Event::listen('backend.form.extendFieldsBefore', function (\Backend\Widgets\Form $widget) use ($modelsToAttach) { + if($widget->isNested) return; + $model = $widget->model; + $modelClass = get_class($model); + $shouldExtend = isset($modelsToAttach[$modelClass]) + || in_array($modelClass, $modelsToAttach) + || property_exists($model, 'metadata_from'); + if(!$shouldExtend) return; + + // Read config or fallback to default + if($model->metadata_from) { + $config = ['metadata_field' => $model->metadata_from]; + } else { + $config = is_array($modelsToAttach[$modelClass] ?? null) ? $modelsToAttach[$modelClass] : []; + } + $config = array_merge(['metadata_field' => 'metadata'], $config); + + // Reference to proper widget tabs container + if($widget->tabs || !$widget->secondaryTabs) { + $tabs = &$widget->tabs; + } else { + $tabs = &$widget->secondaryTabs; + } + + // Model's metadata column can be set explicitly `public $metadata_from = 'column_name';` + $metadataField = $config['metadata_field']; + + // TODO: not we gotta decide how to deal with the models that has no metadata field, pivot table? + + $form = Yaml::parseFile(plugins_path('winter/seo/models/meta/fields.yaml')); + $tab = 'winter.seo::lang.models.meta.label'; + $fields = []; + foreach ($form['fields'] as $name => $config) { + $config['tab'] = $tab; + $fields["{$metadataField}[seo][meta_{$name}]"] = $config; + } + $tabs['paneCssClass'][$tab] = 'padded-pane'; + $tabs['icons'][$tab] = 'icon-magnifying-glass'; + $tabs['fields'] = array_merge($tabs['fields'] ?? [], $fields); + }); } /** diff --git a/assets/counter.js b/assets/counter.js new file mode 100644 index 0000000..3ddebba --- /dev/null +++ b/assets/counter.js @@ -0,0 +1,60 @@ +(function (win, doc) { + counter = win.counter || {} + counter.seo = { + charCountHandler: function(target) { + $target = $(target); + let $helpBlock = $target.next('.help-block'); + let min = $target.data('min'); + let max = $target.data('max'); + let count = $target.val().replace(/[{}]/g, "").length; + $helpBlock.html(`Symbols: ${count} of ${min} - ${max} optimal`); + let $number = $helpBlock.find('b'); + if (count < max && count > min) { + $number.css({color: 'lime'}); + } else if(count < min) { + $number.css({color: 'darkred'}); + } else { + $number.css({color: 'coral'}); + } + } + } + var listeners = [], + doc = win.document, + MutationObserver = win.MutationObserver || win.WebKitMutationObserver, + observer; + + const ready = (selector, fn) => { + listeners.push({ + selector: selector, + fn: fn + }); + if (!observer) { + observer = new MutationObserver(check); + observer.observe(doc.documentElement, { + childList: true, + subtree: true + }); + } + check(); + } + + const check = () => { + for (var i = 0, len = listeners.length, listener, elements; i < len; i++) { + listener = listeners[i]; + elements = doc.querySelectorAll(listener.selector); + for (var j = 0, jLen = elements.length, element; j < jLen; j++) { + element = elements[j]; + if (!element.ready) { + element.ready = true; + listener.fn.call(element, element); + } + } + } + } + win.ready = ready; + win.counter = counter; + win.ready('[data-counter]', (el) => { + counter.seo.charCountHandler(el) + el.oninput = event => counter.seo.charCountHandler(el); + }); +})(window, document); diff --git a/components/SEOTags.php b/components/SEOTags.php index 021ebce..efc11f7 100644 --- a/components/SEOTags.php +++ b/components/SEOTags.php @@ -12,6 +12,7 @@ use Url; use Winter\SEO\Classes\Link; use Winter\SEO\Classes\Meta; +use Winter\SEO\Models\Settings; class SEOTags extends ComponentBase { @@ -21,31 +22,71 @@ class SEOTags extends ComponentBase public function componentDetails() { return [ - 'name' => 'SEOTags Component', - 'description' => 'No description provided yet...' + 'name' => 'SEOTags', + 'description' => 'Outputs meta tags to the page' ]; } /** * Processes the meta tags for CMS pages and Winter.Pages static pages */ - protected function processPageMeta() + protected function processPageMeta(object $page = null) { // $this['page_title'] = $this->page->title ?? Meta::get('og:title') ?? ''; // $this['app_name'] = BrandSetting::get('app_name'); + // Store page settings in order to substitute with model settings if needed + if (!$page) { + $page = $this->page; + } + + // Handle global settings + if (Settings::getOrDefault('global.enable_tags')) { + $name = Settings::getOrDefault('global.app_name'); + $position = Settings::getOrDefault('global.app_name_pos'); + $separator = Settings::getOrDefault('global.separator'); + + // Substitute empty title by global setting + if(empty(trim($page->meta_title))) { + $page->meta_title = Settings::getOrDefault('global.app_title'); + } + + // Substitute empty description by global setting + if(empty(trim($page->meta_description))) { + $page->meta_description = Settings::getOrDefault('global.app_description'); + } + + if(empty($name)) { + // Skip or do something about that? + } else if($position === 'prefix') { + $page->meta_title = "{$name} {$separator} {$page->meta_title}"; + } elseif($position === 'suffix') { + $page->meta_title = "{$page->meta_title} {$separator} {$name}"; + } + } + + // Set the page title + if (!empty($page->meta_title) && empty(trim(Meta::get('title')))) { + Meta::set('title', $page->meta_title); + } + + // Set the page description + if (!empty($page->meta_description) && empty(trim(Meta::get('description')))) { + Meta::set('description', $page->meta_description); + } + // Set the cannonical URL if (empty(Link::get('canonical'))) { Link::set('canonical', Url::current()); } // Parse the meta_image as a media library image - if (!empty($this->page->meta_image)) { - $this->page->meta_image = MediaLibrary::url($this->page->meta_image); + if (!empty($page->meta_image)) { + $page->meta_image = MediaLibrary::url($page->meta_image); } // Handle the nofollow meta property being set - if (!empty($this->page->meta_nofollow)) { + if (!empty($page->meta_nofollow)) { Link::set('robots', 'nofollow'); } @@ -61,6 +102,7 @@ protected function processPageMeta() 'next' => 'paginateNext', ], ]; + foreach ($metaMap as $class => $map) { foreach ($map as $name => $pageProp) { if ( @@ -171,8 +213,40 @@ protected function processOgSiteName(): void } } + /** + * Processes the icon link tag if favicon enabled + */ + protected function processFavicon(): void + { + if(Settings::getOrDefault('favicon.enabled') && Settings::instance()->app_favicon) { + Link::set('icon', '/favicon.ico'); + } + } + + /** + * Processes external model's metadata by calling it from controller + */ + public function useMetadataModel(\Model $model): void + { + // Im not sure about try/catch, but just logging seems to be not bad option + try { + $metadataField = strlen(trim($model->metadata_from)) ? $model->metadata_from : 'metadata'; + $metadata = $model->{$metadataField}; + if(!is_array($metadata)) { + $metadata = json_decode($metadata, true); + } + if(!isset($metadata['seo']) || empty($metadata['seo'])) { + return; + } + $this->processPageMeta((object)$metadata['seo']); + } catch(Exception $e) { + Log::error($e->getMessage()); + } + } + public function getMetaTags(): array { + $this->processFavicon(); $this->processPageMeta(); $this->processOgImage(); $this->processDescription(); diff --git a/components/seotags/default.htm b/components/seotags/default.htm index 79ba666..9a6ae7d 100644 --- a/components/seotags/default.htm +++ b/components/seotags/default.htm @@ -1,9 +1,10 @@ {# tags #} {% for tagName, tagContent in __SELF__.getMetaTags() %} + {# TODO: print title tag if not exists in page layout #} {% if tagContent is iterable %} {% else %} - + {% endif %} {% endfor %} diff --git a/config/config.php b/config/config.php index f0bba47..3828949 100644 --- a/config/config.php +++ b/config/config.php @@ -40,15 +40,45 @@ */ 'humans_txt' => [ - 'path' => $resolvePath('humans.txt'), + 'path' => base_path('humans.txt'), + 'enabled' => true, ], 'robots_txt' => [ - 'path' => $resolvePath('robots.txt'), + 'path' => base_path('robots.txt'), + 'enabled' => true, ], 'security_txt' => [ - 'path' => $resolvePath('security.txt'), + 'path' => base_path('security.txt'), + 'enabled' => true, + ], + + 'favicon' => [ + 'enabled' => false, + ], + + 'global' => [ + + 'enable_tags' => false, + + 'minify_html' => false, + + 'app_name' => null, + + 'app_name_pos' => null, + + 'separator' => null, + + 'app_title' => null, + + 'app_description' => null, + + ], + + 'models_to_attach' => [ + \Winter\Blog\Models\Post::class, + \Winter\Blog\Models\Category::class, ], /* diff --git a/middleware/CompressHTML.php b/middleware/CompressHTML.php new file mode 100644 index 0000000..6b389dc --- /dev/null +++ b/middleware/CompressHTML.php @@ -0,0 +1,43 @@ +getRequestUri(); + // Time to live in seconds + $cacheTTL = 3600; + // Get cached HTML or compress and save if cache not exists + $content = Cache::remember($cacheKey, $cacheTTL, function() use ($request, $next) { + return $this->compress($next($request)->getContent()); + }); + return response($content); + } + + protected function compress($buffer) + { + $replace = [ + // Remove HTML whitespaces + "/\n([\S])/" => '$1', + "/\r/" => '', + "/\n/" => '', + "/\t/" => '', + "/ +/" => ' ', + "/> + '><', + // Remove HTML comments + '//s' => '', + // Remove unnecessary url parts + '/https:/' => '', + '/http:/' => '', + // Replace attributes with short notation + '/ method=("get"|get)/' => '', + '/ disabled=[^ >]*(.*?)/' => ' disabled', + '/ selected=[^ >]*(.*?)/' => ' selected', + ]; + return preg_replace(array_keys($replace), array_values($replace), $buffer); + } +} diff --git a/models/Settings.php b/models/Settings.php index 7af75df..69bc242 100644 --- a/models/Settings.php +++ b/models/Settings.php @@ -30,6 +30,13 @@ class Settings extends Model */ public $rules = []; + /** + * @var array One-to-one relations + */ + public $attachOne = [ + 'app_favicon' => 'System\Models\File' + ]; + /** * Initialize the seed data for this model. This only executes when the * model is first created or reset to default. @@ -52,4 +59,9 @@ public function initSettingsData(): void $this->robots_txt = $contentsFromConfig('robots_txt'); $this->security_txt = $contentsFromConfig('security_txt'); } + + public static function getOrDefault($prop) { + return self::get($prop, Config::get("winter.seo::{$prop}", null)); + } + } diff --git a/models/meta/fields.yaml b/models/meta/fields.yaml index 605eee4..528b12f 100644 --- a/models/meta/fields.yaml +++ b/models/meta/fields.yaml @@ -14,12 +14,22 @@ fields: label: winter.seo::lang.models.meta.fields.title span: right permissions: winter.seo.manage_meta + comment: '[data-counter]' + attributes: + data-counter: 1 + data-min: 50 + data-max: 60 description: label: winter.seo::lang.models.meta.fields.description span: right type: textarea size: tiny permissions: winter.seo.manage_meta + comment: '[data-counter]' + attributes: + data-counter: 1 + data-min: 100 + data-max: 160 nofollow: label: winter.seo::lang.models.meta.fields.nofollow type: switch diff --git a/models/settings/fields.yaml b/models/settings/fields.yaml index eab78e5..3935928 100644 --- a/models/settings/fields.yaml +++ b/models/settings/fields.yaml @@ -4,12 +4,75 @@ tabs: icons: + 'Global': icon-globe + 'Favicon': icon-image winter.seo::lang.models.meta.label_plural: icon-tag winter.seo::lang.models.link.label_plural: icon-link winter.seo::lang.models.settings.humans_txt: icon-user-group winter.seo::lang.models.settings.robots_txt: icon-robot winter.seo::lang.models.settings.security_txt: icon-lock fields: + global.enable_tags: + tab: 'Global' + label: 'Enable global meta tags (not overrides page settings)' + span: auto + type: switch + default: 0 + global.minify_html: + tab: 'Global' + label: 'Minify HTML (whitespaces and comments will be removed)' + span: auto + type: switch + default: 0 + global.app_name: + tab: 'Global' + label: 'App name' + span: auto + placeholder: '' + type: text + default: '' + comment: 'App name can be visible in the title meta tag' + global.separator: + tab: 'Global' + label: 'App name separator' + span: auto + placeholder: '' + type: text + default: '|' + comment: 'Sybmol to separate title from app name: {app name} {sepratator} {title}' + global.app_name_pos: + tab: 'Global' + label: 'Select how the app name should appear in the title' + options: + hide: 'Hide' + prefix: 'Prefix' + suffix: 'Suffix' + span: auto + type: balloon-selector + global.app_title: + tab: 'Global' + label: 'Default meta title' + size: tiny + span: full + placeholder: 'Meta description that is used for the page when one isn''t set already.' + type: text + comment: '[data-counter]' + attributes: + data-counter: 1 + data-min: 50 + data-max: 60 + global.app_description: + tab: 'Global' + label: 'Default meta desciption' + size: tiny + span: full + placeholder: 'Meta description that is used for the page when one isn''t set already.' + type: textarea + comment: '[data-counter]' + attributes: + data-counter: 1 + data-min: 100 + data-max: 160 meta_tags: tab: winter.seo::lang.models.meta.label_plural commentAbove: winter.seo::lang.models.meta.comment @@ -50,16 +113,50 @@ tabs: type: textarea size: tiny label: winter.seo::lang.models.link.description + favicon.enabled: + tab: 'Favicon' + label: 'Enable favicon' + span: full + type: switch + default: 1 + app_favicon: + tab: 'Favicon' + label: 'Favicon' + comment: 'Shortcut icon of your website' + type: fileupload + mode: image + fileTypes: png,gif,svg,ico + useCaption: true + imageHeight: 32 + imageWidth: 32 + humans_txt.enabled: + tab: winter.seo::lang.models.settings.humans_txt + label: 'Enable humans.txt' + span: full + type: switch + default: 1 humans_txt: tab: winter.seo::lang.models.settings.humans_txt commentAbove: winter.seo::lang.models.settings.humans_txt_comment type: textarea size: small + robots_txt.enabled: + tab: winter.seo::lang.models.settings.robots_txt + label: 'Enable robots.txt' + span: full + type: switch + default: 1 robots_txt: tab: winter.seo::lang.models.settings.robots_txt commentAbove: winter.seo::lang.models.settings.robots_txt_comment type: textarea size: small + security_txt.enabled: + tab: winter.seo::lang.models.settings.security_txt + label: 'Enable security.txt' + span: full + type: switch + default: 1 security_txt: tab: winter.seo::lang.models.settings.security_txt commentAbove: winter.seo::lang.models.settings.security_txt_comment diff --git a/routes.php b/routes.php index 83e6bed..357d16a 100644 --- a/routes.php +++ b/routes.php @@ -1,6 +1,19 @@ middleware('Winter\SEO\Middleware\CompressHTML'); + } +}); + +Event::listen('backend.page.beforeDisplay', function($controller, $action, $params) { + $controller->addJs('/plugins/winter/seo/assets/counter.js'); +}); Event::listen('system.beforeRoute', function () { $txtResponse = function ($key) { @@ -25,9 +38,21 @@ return Response::make($contents, 200, ['Content-Type' => 'text/plain']); }; - - Route::get('/humans.txt', fn() => $txtResponse('humans_txt')); - Route::get('/robots.txt', fn() => $txtResponse('robots_txt')); - Route::get('/security.txt', fn() => $txtResponse('security_txt')); - Route::get('/.well-known/security.txt', fn() => $txtResponse('security_txt')); + if(Settings::getOrDefault('humans_txt.enabled')) { + Route::get('/humans.txt', fn() => $txtResponse('humans_txt')); + } + if(Settings::getOrDefault('robots_txt.enabled')) { + Route::get('/robots.txt', fn() => $txtResponse('robots_txt')); + } + if(Settings::getOrDefault('security_txt.enabled')) { + Route::get('/security.txt', fn() => $txtResponse('security_txt')); + Route::get('/.well-known/security.txt', fn() => $txtResponse('security_txt')); + } + $settings = Settings::instance(); + if(Settings::getOrDefault('favicon.enabled') && $settings->app_favicon) { + Route::get('favicon.ico', function() use ($settings) { + $outputPath = $settings->app_favicon->getLocalPath(); + return response()->file($outputPath, [ 'Content-Type'=> 'image/x-icon' ]); + }); + } });