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' ]);
+ });
+ }
});