Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
21494b6
readme installation line, installer-name seo added
soullessxo Apr 10, 2023
1a93298
+ seo global settings, + favicon, + .txt enable/disable, and bit more
soullessxo Apr 10, 2023
fbaa2b1
Meta tags now sets only if empty
soullessxo Apr 10, 2023
50dae0b
+ support of external model metadata by metadata_from public property
soullessxo Apr 10, 2023
4b993e8
+ output external model's metadata support, json field names prefixed…
soullessxo Apr 10, 2023
a50e69f
+ support of HTML compression
soullessxo Apr 11, 2023
9a0a9fa
PR preparations attempt #1
soullessxo Apr 11, 2023
e8925cc
PR preparations attempt 2
soullessxo Apr 11, 2023
9828757
PR preparation attempt 3. Config values added, now settings defaults …
soullessxo Apr 11, 2023
9c8835e
PR preparation attempt 4. Indentetion fixes
soullessxo Apr 11, 2023
217617c
PR preparation attempt 5. New lines fixes
soullessxo Apr 11, 2023
37cde6d
PR preparation attempt 6. New lines fixex
soullessxo Apr 11, 2023
e002022
debug removed
soullessxo Apr 11, 2023
0583818
meta_title & meta_description empty check returned
soullessxo Apr 11, 2023
0128098
* counter script update
soullessxo Apr 13, 2023
784eb9f
* favicon resizing improved
soullessxo Apr 13, 2023
69b5037
* global_ config became nested
soullessxo Apr 13, 2023
8f16b01
* fields.yml texts update
soullessxo Apr 13, 2023
c38dd8c
* minor texts fixes
soullessxo Apr 13, 2023
bc1fe29
* yaml indentation fix
soullessxo Apr 13, 2023
7d69146
* favicon now uses fileupload instead media
soullessxo Apr 13, 2023
44ab26c
* <title> removed from SEOTags component
soullessxo Apr 13, 2023
833e50f
* custom models configuration added to config.php
soullessxo Apr 14, 2023
a5cc6d6
* favicon closure fix
soullessxo Apr 14, 2023
b260ff1
Merge branch 'main' into main
LukeTowers Jun 2, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Winter\SEO\Classes\Meta;
use Winter\SEO\Models\Settings;
use Yaml;
use Config;

/**
* SEO Plugin Information File
Expand Down Expand Up @@ -99,6 +100,7 @@ public function boot(): void
$this->extendBackendForms();

$this->extendPagesForms();
$this->extendExternalPlugins();
$this->populateGlobalTags();
}

Expand Down Expand Up @@ -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);
});
}

/**
Expand Down
60 changes: 60 additions & 0 deletions assets/counter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
(function (win, doc) {
Comment thread
soullessxo marked this conversation as resolved.
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: <b>${count}</b> 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) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be better to look at rewriting this as a Snowboard.js plugin, see https://github.com/wintercms/winter/blob/develop/modules/backend/formwidgets/iconpicker/assets/js/src/iconpicker.js as an example, specifically the last two lines which enable the use of data-control="iconpicker" to automatically attach the JS to the relevant elements.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, on second thought with the goal of attaching this to fields using the attributes property on fields this is probably fine as it is, although the indentation still needs to be fixed here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kinda feel like that would still be useful as a plugin, unless we have specific fields in core that have limits on length.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you have any objections to writing it as a Snowboard plugin that's available in the core (with initial support for the text / textarea / maybe Froala fields)? That way we can use it in this plugin without having to have it only included in this plugin.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll write it as a first party plugin, as I would like to create an example of a plugin that provides Snowboard assets. If you feel afterwards that it should be in core, we can move it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Works for me. Are you going to do it in this plugin?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nah, I'll do it as its own plugin, and just add it as a requirement for this plugin. You never know - there may be other plugins that could use the same feature down the line.

counter.seo.charCountHandler(el)
el.oninput = event => counter.seo.charCountHandler(el);
});
})(window, document);
86 changes: 80 additions & 6 deletions components/SEOTags.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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');
}

Expand All @@ -61,6 +102,7 @@ protected function processPageMeta()
'next' => 'paginateNext',
],
];

foreach ($metaMap as $class => $map) {
foreach ($map as $name => $pageProp) {
if (
Expand Down Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion components/seotags/default.htm
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
{# <meta> tags #}
{% for tagName, tagContent in __SELF__.getMetaTags() %}
{# TODO: print title tag if not exists in page layout #}
{% if tagContent is iterable %}
<meta{% for attrName, attrValue in tagContent %} {{ attrName }}="{{ attrValue }}"{% endfor %}>
{% else %}
<meta name="{{ tagName }}" content="{{ tagContent }}">
<meta name="{{ tagName }}" property="{{ tagName }}" content="{{ tagContent }}">
{% endif %}
{% endfor %}

Expand Down
36 changes: 33 additions & 3 deletions config/config.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
],

/*
Expand Down
43 changes: 43 additions & 0 deletions middleware/CompressHTML.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php namespace Winter\SEO\Middleware;

use Closure;
use Cache;
use Illuminate\Http\Request;

class CompressHTML {
public function handle (Request $request, Closure $next)
{
// Key to store HTML in cache
$cacheKey = 'winter.seo.minified'.$request->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);
}
}
12 changes: 12 additions & 0 deletions models/Settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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));
}

}
Loading