├── .gitignore ├── assets ├── icons │ ├── module.png │ └── tests.svg ├── images │ └── google.png ├── .htaccess ├── js │ ├── tests.min.js │ ├── engine │ │ ├── page.min.js │ │ ├── events.min.js │ │ ├── news.min.js │ │ ├── article.min.js │ │ ├── page.js │ │ ├── events.js │ │ ├── news.js │ │ └── article.js │ ├── tests.js │ ├── preview.min.js │ └── preview.js └── css │ ├── tests.min.css │ ├── tests.css │ ├── module.min.css │ ├── preview.min.css │ ├── module.css │ └── preview.css ├── package.json ├── config ├── autoload.ini ├── autoload.php └── config.php ├── languages ├── en │ ├── modules.php │ ├── seo_serp_tests.php │ ├── tl_page.php │ ├── tl_calendar.php │ ├── tl_news_archive.php │ ├── default.php │ └── seo_serp_module.php └── de │ └── default.php ├── src └── Derhaeuptling │ └── SeoSerpPreview │ ├── Test │ ├── Exception │ │ ├── ErrorException.php │ │ └── WarningException.php │ ├── TestInterface.php │ └── DescriptionTest.php │ ├── Engine │ ├── EngineInterface.php │ ├── AbstractEngine.php │ ├── PageEngine.php │ ├── NewsEngine.php │ ├── EventsEngine.php │ └── ArticleEngine.php │ ├── TestsHandler │ ├── NewsHandler.php │ ├── EventsHandler.php │ ├── ArticleHandler.php │ ├── PageHandler.php │ └── AbstractHandler.php │ ├── TestsManager.php │ ├── PreviewWidget.php │ ├── StatusManager.php │ └── PreviewModule.php ├── dca ├── tl_calendar.php ├── tl_news_archive.php ├── tl_news.php ├── tl_calendar_events.php ├── tl_article.php └── tl_page.php ├── templates └── backend │ ├── be_seo_serp_tests.html5 │ ├── be_seo_serp_preview.html5 │ └── be_seo_serp_module.html5 ├── README.md ├── gulpfile.js └── composer.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | yarn.lock 3 | -------------------------------------------------------------------------------- /assets/icons/module.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DERHAEUPTLING/contao-seo-serp-preview/HEAD/assets/icons/module.png -------------------------------------------------------------------------------- /assets/images/google.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DERHAEUPTLING/contao-seo-serp-preview/HEAD/assets/images/google.png -------------------------------------------------------------------------------- /assets/.htaccess: -------------------------------------------------------------------------------- 1 | 2 | Order allow,deny 3 | Allow from all 4 | 5 | 6 | Require all granted 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "gulp": "^3.9.1", 4 | "gulp-clean-css": "^2.0.11", 5 | "gulp-rename": "^1.2.2", 6 | "gulp-uglify": "^1.5.3", 7 | "gulp-util": "^3.0.7" 8 | } 9 | } -------------------------------------------------------------------------------- /config/autoload.ini: -------------------------------------------------------------------------------- 1 | ;; 2 | ; List modules which are required to be loaded beforehand 3 | ;; 4 | requires[] = "core" 5 | requires[] = "*calendar" 6 | requires[] = "*news" 7 | 8 | ;; 9 | ; Configure what you want the autoload creator to register 10 | ;; 11 | register_namespaces = false 12 | register_classes = false 13 | register_templates = false -------------------------------------------------------------------------------- /languages/en/modules.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | /** 15 | * Backend modules 16 | */ 17 | $GLOBALS['TL_LANG']['MOD']['seo_serp_preview'] = ['SEO SERP']; -------------------------------------------------------------------------------- /src/Derhaeuptling/SeoSerpPreview/Test/Exception/ErrorException.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | namespace Derhaeuptling\SeoSerpPreview\Test\Exception; 15 | 16 | class ErrorException extends \Exception 17 | { 18 | } 19 | -------------------------------------------------------------------------------- /src/Derhaeuptling/SeoSerpPreview/Test/Exception/WarningException.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | namespace Derhaeuptling\SeoSerpPreview\Test\Exception; 15 | 16 | class WarningException extends \Exception 17 | { 18 | } 19 | -------------------------------------------------------------------------------- /assets/js/tests.min.js: -------------------------------------------------------------------------------- 1 | var SeoSerpTests={colorRows:function(){for(var e=["error","warning"],s=document.getElements("[data-seo-serp-messages]"),t=0;t 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | /** 15 | * Test - description 16 | */ 17 | $GLOBALS['TL_LANG']['SST']['test.description'] = [ 18 | 'empty' => 'The description does not exist.', 19 | 'length' => 'The description has more than %s characters.', 20 | ]; -------------------------------------------------------------------------------- /languages/en/tl_page.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | /** 15 | * Fields 16 | */ 17 | $GLOBALS['TL_LANG']['tl_page']['seo_serp_ignore'] = [ 18 | 'Ignore this page', 19 | 'Ignore this page during SEO SERP analysis.', 20 | ]; 21 | 22 | /** 23 | * Legends 24 | */ 25 | $GLOBALS['TL_LANG']['tl_page']['seo_serp_legend'] = 'SEO SERP settings'; -------------------------------------------------------------------------------- /languages/en/tl_calendar.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | /** 15 | * Fields 16 | */ 17 | $GLOBALS['TL_LANG']['tl_calendar']['seo_serp_ignore'] = [ 18 | 'Ignore this calendar', 19 | 'Ignore this calendar during SEO SERP analysis.', 20 | ]; 21 | 22 | /** 23 | * Legends 24 | */ 25 | $GLOBALS['TL_LANG']['tl_calendar']['seo_serp_legend'] = 'SEO SERP settings'; -------------------------------------------------------------------------------- /assets/css/tests.min.css: -------------------------------------------------------------------------------- 1 | .seo-serp-test-messages{width:100%;padding:2px 0;text-indent:0;clear:both}.seo-serp-test-messages .seo-serp-test-error,.seo-serp-test-messages .seo-serp-test-warning{padding:4px 5px 5px 25px;background-repeat:no-repeat;background-position:5px center}.seo-serp-test-messages .seo-serp-test-error{background-color:#faeeee;background-image:url(../../../../themes/default/images/error.gif);color:#c33}.seo-serp-test-error-row{background-color:#faeeee!important}.seo-serp-test-messages .seo-serp-test-warning{background-color:#faf8df;background-image:url(../../../../themes/default/images/about.gif);color:#c58204}.seo-serp-test-warning-row{background-color:#faf8df!important} -------------------------------------------------------------------------------- /languages/en/tl_news_archive.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | /** 15 | * Fields 16 | */ 17 | $GLOBALS['TL_LANG']['tl_news_archive']['seo_serp_ignore'] = [ 18 | 'Ignore this archive', 19 | 'Ignore this archive during SEO SERP analysis.', 20 | ]; 21 | 22 | /** 23 | * Legends 24 | */ 25 | $GLOBALS['TL_LANG']['tl_news_archive']['seo_serp_legend'] = 'SEO SERP settings'; -------------------------------------------------------------------------------- /assets/icons/tests.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | -------------------------------------------------------------------------------- /dca/tl_calendar.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | /** 15 | * Extend palettes 16 | */ 17 | $GLOBALS['TL_DCA']['tl_calendar']['palettes']['default'] .= ';{seo_serp_legend},seo_serp_ignore'; 18 | 19 | /** 20 | * Add fields 21 | */ 22 | $GLOBALS['TL_DCA']['tl_calendar']['fields']['seo_serp_ignore'] = [ 23 | 'label' => &$GLOBALS['TL_LANG']['tl_calendar']['seo_serp_ignore'], 24 | 'exclude' => true, 25 | 'inputType' => 'checkbox', 26 | 'eval' => ['tl_class' => 'clr'], 27 | 'sql' => "char(1) NOT NULL default ''", 28 | ]; -------------------------------------------------------------------------------- /dca/tl_news_archive.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | /** 15 | * Extend palettes 16 | */ 17 | $GLOBALS['TL_DCA']['tl_news_archive']['palettes']['default'] .= ';{seo_serp_legend},seo_serp_ignore'; 18 | 19 | /** 20 | * Add fields 21 | */ 22 | $GLOBALS['TL_DCA']['tl_news_archive']['fields']['seo_serp_ignore'] = [ 23 | 'label' => &$GLOBALS['TL_LANG']['tl_news_archive']['seo_serp_ignore'], 24 | 'exclude' => true, 25 | 'inputType' => 'checkbox', 26 | 'eval' => ['tl_class' => 'clr'], 27 | 'sql' => "char(1) NOT NULL default ''", 28 | ]; -------------------------------------------------------------------------------- /languages/de/default.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @author Kirsten Roschanski 12 | * @license LGPL 13 | */ 14 | 15 | /** 16 | * Miscellaneous 17 | */ 18 | $GLOBALS['TL_LANG']['MSC']['seo_serp_preview.headline'] = 'Mögl. SERP Vorschau (kann von Google gekürzt werden)'; 19 | $GLOBALS['TL_LANG']['MSC']['seo_serp_preview.hint'] = 'Start entering the data in your form to enable the SEO SERP preview.'; 20 | $GLOBALS['TL_LANG']['MSC']['seo_serp_preview.notIndexed'] = 'This page will not be indexed by the search engines (due to selected robots tag value).'; 21 | -------------------------------------------------------------------------------- /config/autoload.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | /** 15 | * Register PSR-0 namespace 16 | */ 17 | if (class_exists('NamespaceClassLoader')) { 18 | NamespaceClassLoader::add('Derhaeuptling\SeoSerpPreview', 'system/modules/seo_serp_preview/src'); 19 | } 20 | 21 | /** 22 | * Register the templates 23 | */ 24 | TemplateLoader::addFiles([ 25 | 'be_seo_serp_module' => 'system/modules/seo_serp_preview/templates/backend', 26 | 'be_seo_serp_preview' => 'system/modules/seo_serp_preview/templates/backend', 27 | 'be_seo_serp_tests' => 'system/modules/seo_serp_preview/templates/backend', 28 | ]); 29 | -------------------------------------------------------------------------------- /templates/backend/be_seo_serp_tests.html5: -------------------------------------------------------------------------------- 1 | = $this->label ?> 2 | 3 | errors || $this->warnings): ?> 4 | 5 | errors): ?> 6 | 7 | errors as $error): ?> 8 | = $error ?> 9 | 10 | 11 | 12 | warnings): ?> 13 | 14 | warnings as $warning): ?> 15 | = $warning ?> 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /assets/css/tests.css: -------------------------------------------------------------------------------- 1 | .seo-serp-test-messages { 2 | width: 100%; 3 | padding: 2px 0; 4 | text-indent: 0; 5 | clear: both; 6 | } 7 | 8 | .seo-serp-test-messages .seo-serp-test-error, 9 | .seo-serp-test-messages .seo-serp-test-warning { 10 | padding: 4px 5px 5px 25px; 11 | background-repeat: no-repeat; 12 | background-position: 5px center; 13 | } 14 | 15 | .seo-serp-test-messages .seo-serp-test-error { 16 | background-color: #faeeee; 17 | background-image: url("../../../../themes/default/images/error.gif"); 18 | color: #c33; 19 | } 20 | 21 | .seo-serp-test-error-row { 22 | background-color: #faeeee !important; 23 | } 24 | 25 | .seo-serp-test-messages .seo-serp-test-warning { 26 | background-color: #faf8df; 27 | background-image: url("../../../../themes/default/images/about.gif"); 28 | color: #c58204; 29 | } 30 | 31 | .seo-serp-test-warning-row { 32 | background-color: #faf8df !important; 33 | } -------------------------------------------------------------------------------- /assets/js/engine/page.min.js: -------------------------------------------------------------------------------- 1 | SeoSerpPreview.PageEngine=new Class({Implements:[Events],init:function(){this.title=document.id("ctrl_title"),this.pageTitle=document.id("ctrl_pageTitle"),this.alias=document.id("ctrl_alias"),this.description=document.id("ctrl_description"),this.robots=document.id("ctrl_robots"),this.addEventListeners(),this.fireEvent("ready")},addEventListeners:function(){for(var t=[this.title,this.pageTitle,this.alias,this.description],e=0;e 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | /** 15 | * Backend module 16 | */ 17 | $GLOBALS['BE_MOD']['content']['seo_serp_preview'] = [ 18 | 'icon' => 'system/modules/seo_serp_preview/assets/icons/module.png', 19 | 'callback' => 'Derhaeuptling\SeoSerpPreview\PreviewModule', 20 | ]; 21 | 22 | /** 23 | * Backend form fields 24 | */ 25 | $GLOBALS['BE_FFL']['seoSerpPreview'] = 'Derhaeuptling\SeoSerpPreview\PreviewWidget'; 26 | 27 | /** 28 | * Hooks 29 | */ 30 | $GLOBALS['TL_HOOKS']['getUserNavigation'][] = ['Derhaeuptling\SeoSerpPreview\StatusManager', 'setMenuStatus']; 31 | 32 | /** 33 | * SEO SERP Tests 34 | */ 35 | \Derhaeuptling\SeoSerpPreview\TestsManager::add('description', 'Derhaeuptling\SeoSerpPreview\Test\DescriptionTest'); -------------------------------------------------------------------------------- /assets/js/engine/events.min.js: -------------------------------------------------------------------------------- 1 | SeoSerpPreview.EventsEngine=new Class({Implements:[Events],init:function(){this.title=document.id("ctrl_title"),this.alias=document.id("ctrl_alias");var t=20,e=setInterval(function(){return tinyMCE.hasOwnProperty("editors")?(clearInterval(e),this.description={textarea:document.id("ctrl_teaser"),tinymce:tinyMCE.get("ctrl_teaser")},this.addEventListeners(),void this.fireEvent("ready")):void(t--<=1&&(console.error("Unable to determine the tinyMCE instance for SEO SERP Preview extension."),clearInterval(e)))}.bind(this),500)},addEventListeners:function(){for(var t=[this.title,this.alias],e=0;epages, news, events and Article Teaser if enabled will look like in search engines. 10 | Overview thas analyses the whole website. 11 | 12 |  13 | 14 | Sometimes customers are not aware why they should take care of meta data and what they will result in search engines result pages. 15 | 16 | SEO SERP Preview generates a preview of how Contao pages, news, events and Article Teaser will look like in googles search results. 17 | 18 | The menu entry "SEO SERP" outlines missing or wrong entries. 19 | 20 | 21 | by Martin Schwenzer [derhaeuptling.com](https://derhaeuptling.com/) 22 | 23 | -------------------------------------------------------------------------------- /src/Derhaeuptling/SeoSerpPreview/Test/TestInterface.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | namespace Derhaeuptling\SeoSerpPreview\Test; 15 | 16 | use Derhaeuptling\SeoSerpPreview\Test\Exception\WarningException; 17 | use Derhaeuptling\SeoSerpPreview\Test\Exception\ErrorException; 18 | 19 | interface TestInterface 20 | { 21 | /** 22 | * Return true if the test supports table 23 | * 24 | * @param string $table 25 | * 26 | * @return bool 27 | */ 28 | public function supports($table); 29 | 30 | /** 31 | * Run the test 32 | * 33 | * @param array $data 34 | * @param string $table 35 | * 36 | * @throws ErrorException 37 | * @throws WarningException 38 | */ 39 | public function run(array $data, $table); 40 | } 41 | -------------------------------------------------------------------------------- /assets/css/module.min.css: -------------------------------------------------------------------------------- 1 | .seo-serp-module .module{margin-bottom:12px;padding-top:7px;padding-bottom:12px;border-bottom:1px solid #e9e9e9}.seo-serp-module .module:last-child{border-bottom:none}.seo-serp-module .module .name{margin-bottom:5px}.seo-serp-module .module .name a{font-weight:700}.seo-serp-module .module .name span{font-style:italic}.seo-serp-module .module .wrapper{display:block;margin-top:0;margin-bottom:5px}.seo-serp-module .module .wrapper:last-child{margin-bottom:0}.seo-serp-module .module .wrapper:after{content:"";display:table;clear:both}.seo-serp-module .module .wrapper .status{float:left}.seo-serp-module .module .wrapper .reference{float:right;color:#444}.seo-serp-module .module ul.notes{margin-bottom:10px;padding-left:25px;list-style-type:square}.seo-serp-module .module ul.notes li{list-style:square}.seo-serp-module .seo-serp-test-messages a{text-decoration:underline}.seo-serp-module .seo-serp-test-messages .seo-serp-test-error a{color:#c33}.seo-serp-module .seo-serp-test-messages .seo-serp-test-warning a{color:#c58204}.seo-serp-module .no-permission{-webkit-filter:grayscale(100%);filter:grayscale(100%)}.seo-serp-module .no-permission a,.seo-serp-module a.no-permission{cursor:not-allowed} -------------------------------------------------------------------------------- /src/Derhaeuptling/SeoSerpPreview/Engine/EngineInterface.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | namespace Derhaeuptling\SeoSerpPreview\Engine; 15 | 16 | interface EngineInterface 17 | { 18 | /** 19 | * Get the JavaScript engine name 20 | * 21 | * @return string 22 | */ 23 | public function getJavaScriptEngineName(); 24 | 25 | /** 26 | * Get the JavaScript engine file path 27 | * 28 | * @return string 29 | */ 30 | public function getJavaScriptEngineSource(); 31 | 32 | /** 33 | * Get the URL path 34 | * 35 | * @param int $id 36 | * 37 | * @return string 38 | */ 39 | public function getUrlPath($id); 40 | 41 | /** 42 | * Get the page title with ##title## as placeholder for dynamic title 43 | * 44 | * @param int $id 45 | * 46 | * @return string 47 | */ 48 | public function getPageTitle($id); 49 | } 50 | -------------------------------------------------------------------------------- /assets/css/preview.min.css: -------------------------------------------------------------------------------- 1 | .seo-serp-preview{padding-top:14px}.seo-serp-preview .preview-header{margin-bottom:14px}.seo-serp-preview .preview-header:after{content:"";display:table;clear:both}.seo-serp-preview .preview-header a{float:left}.seo-serp-preview .preview-header img{width:80px;height:auto;vertical-align:middle}.seo-serp-preview .preview-header span{float:left;margin:4px 0 0 14px;font-size:16px;color:#999}.seo-serp-preview .preview-index{margin-top:10px}.seo-serp-preview .preview-body{padding:10px;border:1px dashed #ddd}.seo-serp-preview .title{font-size:18px;font-family:arial,sans-serif;color:#1a0dab;line-height:1.2}.seo-serp-preview .url{font-size:14px;font-family:arial,sans-serif;color:#006621;line-height:16px}.seo-serp-preview .description{font-size:13px;font-family:arial,sans-serif;color:#545454;line-height:18px}.seo-serp-preview .keyword-mark{background:#c3ff7f}.seo-serp-preview.no-index .preview-header img{-webkit-filter:grayscale(100%);filter:grayscale(100%)}.seo-serp-preview.no-index .preview-body .description,.seo-serp-preview.no-index .preview-body .title,.seo-serp-preview.no-index .preview-body .url{color:#999}.seo-serp-preview-counter{margin-left:5px;font-size:.6875rem;color:#aaa}.seo-serp-preview-counter.limit-exceeded{color:#c33} -------------------------------------------------------------------------------- /dca/tl_news.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | /** 15 | * Initialize the tests 16 | */ 17 | $GLOBALS['TL_DCA']['tl_news']['config']['onload_callback'][] = [ 18 | 'Derhaeuptling\SeoSerpPreview\TestsHandler\NewsHandler', 19 | 'initialize', 20 | ]; 21 | 22 | $GLOBALS['TL_DCA']['tl_news']['config']['onsubmit_callback'][] = [ 23 | 'Derhaeuptling\SeoSerpPreview\StatusManager', 24 | 'rebuildCache', 25 | ]; 26 | 27 | /** 28 | * Extend palettes 29 | */ 30 | $GLOBALS['TL_DCA']['tl_news']['palettes']['default'] = str_replace( 31 | 'teaser;', 32 | 'teaser,seo_serp_preview;', 33 | $GLOBALS['TL_DCA']['tl_news']['palettes']['default'] 34 | ); 35 | 36 | /** 37 | * Add fields 38 | */ 39 | $GLOBALS['TL_DCA']['tl_news']['fields']['seo_serp_preview'] = [ 40 | 'exclude' => true, 41 | 'inputType' => 'seoSerpPreview', 42 | 'eval' => ['engine' => 'Derhaeuptling\SeoSerpPreview\Engine\NewsEngine', 'tl_class' => 'clr'], 43 | ]; 44 | -------------------------------------------------------------------------------- /assets/js/engine/article.min.js: -------------------------------------------------------------------------------- 1 | SeoSerpPreview.ArticleEngine=new Class({Implements:[Events],init:function(){this.title=document.id("ctrl_title"),this.alias=document.id("ctrl_alias"),this.showTeaser=document.id("opt_showTeaser_0");var t=20,e=setInterval(function(){return tinyMCE.hasOwnProperty("editors")?(clearInterval(e),this.description={textarea:document.id("ctrl_teaser"),tinymce:tinyMCE.get("ctrl_teaser")},this.addEventListeners(),void this.fireEvent("ready")):void(t--<=1&&(console.error("Unable to determine the tinyMCE instance for SEO SERP Preview extension."),clearInterval(e)))}.bind(this),500)},addEventListeners:function(){for(var t=[this.title,this.alias],e=0;e=5.4.0", 21 | "contao/core-bundle": "~3.5 || <4.9", 22 | "contao-community-alliance/composer-plugin": "~2.4 || ~3.0" 23 | }, 24 | "autoload": { 25 | "psr-4": { 26 | "Derhaeuptling\\SeoSerpPreview\\": "src/Derhaeuptling/SeoSerpPreview/" 27 | } 28 | }, 29 | "replace": { 30 | "contao-legacy/seo_serp_preview": "self.version" 31 | }, 32 | "extra": { 33 | "contao": { 34 | "sources": { 35 | "": "system/modules/seo_serp_preview" 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /dca/tl_calendar_events.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | /** 15 | * Initialize the tests 16 | */ 17 | $GLOBALS['TL_DCA']['tl_calendar_events']['config']['onload_callback'][] = [ 18 | 'Derhaeuptling\SeoSerpPreview\TestsHandler\EventsHandler', 19 | 'initialize', 20 | ]; 21 | 22 | $GLOBALS['TL_DCA']['tl_calendar_events']['config']['onsubmit_callback'][] = [ 23 | 'Derhaeuptling\SeoSerpPreview\StatusManager', 24 | 'rebuildCache', 25 | ]; 26 | 27 | /** 28 | * Extend palettes 29 | */ 30 | $GLOBALS['TL_DCA']['tl_calendar_events']['palettes']['default'] = str_replace( 31 | 'teaser;', 32 | 'teaser,seo_serp_preview;', 33 | $GLOBALS['TL_DCA']['tl_calendar_events']['palettes']['default'] 34 | ); 35 | 36 | /** 37 | * Add fields 38 | */ 39 | $GLOBALS['TL_DCA']['tl_calendar_events']['fields']['seo_serp_preview'] = [ 40 | 'exclude' => true, 41 | 'inputType' => 'seoSerpPreview', 42 | 'eval' => ['engine' => 'Derhaeuptling\SeoSerpPreview\Engine\EventsEngine', 'tl_class' => 'clr'], 43 | ]; 44 | -------------------------------------------------------------------------------- /dca/tl_article.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | /** 15 | * Initialize the tests 16 | */ 17 | $GLOBALS['TL_DCA']['tl_article']['config']['onload_callback'][] = [ 18 | 'Derhaeuptling\SeoSerpPreview\TestsHandler\ArticleHandler', 19 | 'initialize', 20 | ]; 21 | 22 | $GLOBALS['TL_DCA']['tl_article']['config']['onsubmit_callback'][] = [ 23 | 'Derhaeuptling\SeoSerpPreview\StatusManager', 24 | 'rebuildCache', 25 | ]; 26 | 27 | /** 28 | * Extend palettes 29 | */ 30 | $GLOBALS['TL_DCA']['tl_article']['palettes']['default'] = str_replace( 31 | 'teaser;', 32 | 'teaser,seo_serp_preview;', 33 | $GLOBALS['TL_DCA']['tl_article']['palettes']['default'] 34 | ); 35 | 36 | /** 37 | * Add fields 38 | */ 39 | $GLOBALS['TL_DCA']['tl_article']['fields']['seo_serp_preview'] = [ 40 | 'exclude' => true, 41 | 'inputType' => 'seoSerpPreview', 42 | 'eval' => [ 43 | 'engine' => 'Derhaeuptling\SeoSerpPreview\Engine\ArticleEngine', 44 | 'hidden' => true, 45 | 'tl_class' => 'clr', 46 | ], 47 | ]; 48 | -------------------------------------------------------------------------------- /languages/en/default.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | /** 15 | * SERP preview 16 | */ 17 | $GLOBALS['TL_LANG']['MSC']['seo_serp_preview.headline'] = 'Likely SERP Preview (can be truncated by google)'; 18 | $GLOBALS['TL_LANG']['MSC']['seo_serp_preview.hint'] = 'Start entering the data in your form to enable the SEO SERP preview.'; 19 | $GLOBALS['TL_LANG']['MSC']['seo_serp_preview.notIndexed'] = 'This page will not be indexed by the search engines (due to selected robots tag value).'; 20 | 21 | /** 22 | * SERP tests 23 | */ 24 | $GLOBALS['TL_LANG']['MSC']['seo_serp_tests.enable'] = ['Enable SERP', 'Enable the SERP analyzer']; 25 | $GLOBALS['TL_LANG']['MSC']['seo_serp_tests.disable'] = ['Disable SERP', 'Disable the SERP analyzer']; 26 | $GLOBALS['TL_LANG']['MSC']['seo_serp_tests.filter'] = ['SERP filters:', 'Message type']; 27 | $GLOBALS['TL_LANG']['MSC']['seo_serp_tests.filterRef'] = [ 28 | 'all' => 'All messages', 29 | 'errors' => 'Errors', 30 | 'warnings' => 'Warnings', 31 | ]; 32 | 33 | /** 34 | * SERP status 35 | */ 36 | $GLOBALS['TL_LANG']['MSC']['seo_serp_status.fixErrors'] = 'fix errors'; 37 | -------------------------------------------------------------------------------- /assets/js/tests.js: -------------------------------------------------------------------------------- 1 | var SeoSerpTests = { 2 | /** 3 | * Color the full record rows based on the message colors they have inside 4 | */ 5 | colorRows: function () { 6 | var priority = ['error', 'warning']; 7 | var els = document.getElements('[data-seo-serp-messages]'); 8 | 9 | for (var i = 0; i < els.length; i++) { 10 | var row = els[i].getParent('.tl_file') || els[i].getParent('.tl_content'); 11 | 12 | if (!row) { 13 | continue; 14 | } 15 | 16 | var rowType = null; 17 | var messageType = null; 18 | var messages = els[i].getElements('[data-seo-serp-message]'); 19 | 20 | // Determine the row type including the message type priority 21 | for (var j = 0; j < messages.length; j++) { 22 | messageType = messages[j].get('data-seo-serp-message'); 23 | 24 | if (!rowType || priority.indexOf(messageType) < priority.indexOf(rowType)) { 25 | rowType = messageType; 26 | } 27 | } 28 | 29 | if (rowType) { 30 | row.addClass('seo-serp-test-' + rowType + '-row'); 31 | } 32 | } 33 | } 34 | }; 35 | 36 | window.addEvent('domready', function () { 37 | SeoSerpTests.colorRows(); 38 | }); 39 | 40 | window.addEvent('ajax_change', function () { 41 | SeoSerpTests.colorRows(); 42 | }); -------------------------------------------------------------------------------- /languages/en/seo_serp_module.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | /** 15 | * SEO SERP module 16 | */ 17 | $GLOBALS['TL_LANG']['MSC']['seo_serp_module.headline'] = 'SEO SERP Analysis'; 18 | $GLOBALS['TL_LANG']['MSC']['seo_serp_module.noModules'] = 'There are no modules to be analyzed.'; 19 | $GLOBALS['TL_LANG']['MSC']['seo_serp_module.clear'] = 'There are no errors or warnings'; 20 | $GLOBALS['TL_LANG']['MSC']['seo_serp_module.error'] = '%s error'; 21 | $GLOBALS['TL_LANG']['MSC']['seo_serp_module.errors'] = '%s errors'; 22 | $GLOBALS['TL_LANG']['MSC']['seo_serp_module.warning'] = '%s warning'; 23 | $GLOBALS['TL_LANG']['MSC']['seo_serp_module.warnings'] = '%s warnings'; 24 | $GLOBALS['TL_LANG']['MSC']['seo_serp_module.single'] = 'There is'; 25 | $GLOBALS['TL_LANG']['MSC']['seo_serp_module.multiple'] = 'There are'; 26 | $GLOBALS['TL_LANG']['MSC']['seo_serp_module.and'] = 'and'; 27 | $GLOBALS['TL_LANG']['MSC']['seo_serp_module.noPermission'] = '(no permission to access this module)'; 28 | $GLOBALS['TL_LANG']['MSC']['seo_serp_module.pagesNote'] = 'There may be some pages with errors or warnings that you do not have access to.'; -------------------------------------------------------------------------------- /dca/tl_page.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | /** 15 | * Initialize the tests 16 | */ 17 | $GLOBALS['TL_DCA']['tl_page']['config']['onload_callback'][] = [ 18 | 'Derhaeuptling\SeoSerpPreview\TestsHandler\PageHandler', 19 | 'initialize', 20 | ]; 21 | 22 | $GLOBALS['TL_DCA']['tl_page']['config']['onsubmit_callback'][] = [ 23 | 'Derhaeuptling\SeoSerpPreview\StatusManager', 24 | 'rebuildCache', 25 | ]; 26 | 27 | /** 28 | * Extend palettes 29 | */ 30 | $GLOBALS['TL_DCA']['tl_page']['palettes']['regular'] = str_replace( 31 | 'description;', 32 | 'description,seo_serp_preview;', 33 | $GLOBALS['TL_DCA']['tl_page']['palettes']['regular'] 34 | ); 35 | 36 | $GLOBALS['TL_DCA']['tl_page']['palettes']['root'] = str_replace( 37 | ';{publish_legend', 38 | ';{seo_serp_legend},seo_serp_ignore;{publish_legend', 39 | $GLOBALS['TL_DCA']['tl_page']['palettes']['root'] 40 | ); 41 | 42 | /** 43 | * Add fields 44 | */ 45 | $GLOBALS['TL_DCA']['tl_page']['fields']['seo_serp_preview'] = [ 46 | 'exclude' => true, 47 | 'inputType' => 'seoSerpPreview', 48 | 'eval' => ['engine' => 'Derhaeuptling\SeoSerpPreview\Engine\PageEngine', 'tl_class' => 'clr'], 49 | ]; 50 | 51 | $GLOBALS['TL_DCA']['tl_page']['fields']['seo_serp_ignore'] = [ 52 | 'label' => &$GLOBALS['TL_LANG']['tl_page']['seo_serp_ignore'], 53 | 'exclude' => true, 54 | 'inputType' => 'checkbox', 55 | 'eval' => ['tl_class' => 'clr'], 56 | 'sql' => "char(1) NOT NULL default ''", 57 | ]; -------------------------------------------------------------------------------- /assets/css/module.css: -------------------------------------------------------------------------------- 1 | .seo-serp-module .module { 2 | margin-bottom: 12px; 3 | padding-top: 7px; 4 | padding-bottom: 12px; 5 | border-bottom: 1px solid #e9e9e9; 6 | } 7 | 8 | .seo-serp-module .module:last-child { 9 | border-bottom: none; 10 | } 11 | 12 | .seo-serp-module .module .name { 13 | margin-bottom: 5px; 14 | } 15 | 16 | .seo-serp-module .module .name a { 17 | font-weight: bold; 18 | } 19 | 20 | .seo-serp-module .module .name span { 21 | font-style: italic; 22 | } 23 | 24 | .seo-serp-module .module .wrapper { 25 | display: block; 26 | margin-top: 0; 27 | margin-bottom: 5px; 28 | } 29 | 30 | .seo-serp-module .module .wrapper:last-child { 31 | margin-bottom: 0; 32 | } 33 | 34 | .seo-serp-module .module .wrapper:after { 35 | content: ""; 36 | display: table; 37 | clear: both; 38 | } 39 | 40 | .seo-serp-module .module .wrapper .status { 41 | float: left; 42 | } 43 | 44 | .seo-serp-module .module .wrapper .reference { 45 | float: right; 46 | color: #444; 47 | } 48 | 49 | .seo-serp-module .module ul.notes { 50 | margin-bottom: 10px; 51 | padding-left: 25px; 52 | list-style-type: square; 53 | } 54 | 55 | .seo-serp-module .module ul.notes li { 56 | list-style: square; 57 | } 58 | 59 | .seo-serp-module .seo-serp-test-messages a { 60 | text-decoration: underline; 61 | } 62 | 63 | .seo-serp-module .seo-serp-test-messages .seo-serp-test-error a { 64 | color: #c33; 65 | } 66 | 67 | .seo-serp-module .seo-serp-test-messages .seo-serp-test-warning a { 68 | color: #c58204; 69 | } 70 | 71 | .seo-serp-module .no-permission { 72 | -webkit-filter: grayscale(100%); 73 | filter: grayscale(100%); 74 | } 75 | 76 | .seo-serp-module a.no-permission, 77 | .seo-serp-module .no-permission a { 78 | cursor: not-allowed; 79 | } -------------------------------------------------------------------------------- /src/Derhaeuptling/SeoSerpPreview/TestsHandler/NewsHandler.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | namespace Derhaeuptling\SeoSerpPreview\TestsHandler; 15 | 16 | use Contao\Database; 17 | use Contao\DataContainer; 18 | 19 | class NewsHandler extends AbstractHandler 20 | { 21 | /** 22 | * Initialize the tests handler 23 | * 24 | * @param DataContainer|null $dc 25 | */ 26 | public function initialize(DataContainer $dc = null) 27 | { 28 | if (CURRENT_ID) { 29 | $archive = Database::getInstance()->prepare("SELECT seo_serp_ignore FROM tl_news_archive WHERE id=?") 30 | ->limit(1) 31 | ->execute(CURRENT_ID); 32 | 33 | // Do not initialize handler if archive is ignored 34 | if ($archive->seo_serp_ignore) { 35 | unset($GLOBALS['TL_DCA']['tl_news']['fields']['seo_serp_preview']); 36 | 37 | return; 38 | } 39 | } 40 | 41 | parent::initialize($dc); 42 | } 43 | 44 | /** 45 | * Get the table name 46 | * 47 | * @return string 48 | */ 49 | protected function getTableName() 50 | { 51 | return 'tl_news'; 52 | } 53 | 54 | /** 55 | * Get the applicable record IDs 56 | * 57 | * @return array 58 | */ 59 | protected function getRecordIds() 60 | { 61 | return Database::getInstance()->prepare("SELECT id FROM tl_news WHERE pid=?") 62 | ->execute(CURRENT_ID) 63 | ->fetchEach('id'); 64 | } 65 | } -------------------------------------------------------------------------------- /src/Derhaeuptling/SeoSerpPreview/TestsHandler/EventsHandler.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | namespace Derhaeuptling\SeoSerpPreview\TestsHandler; 15 | 16 | use Contao\Database; 17 | use Contao\DataContainer; 18 | 19 | class EventsHandler extends AbstractHandler 20 | { 21 | /** 22 | * Initialize the tests handler 23 | * 24 | * @param DataContainer|null $dc 25 | */ 26 | public function initialize(DataContainer $dc = null) 27 | { 28 | if (CURRENT_ID) { 29 | $calendar = Database::getInstance()->prepare("SELECT seo_serp_ignore FROM tl_calendar WHERE id=?") 30 | ->limit(1) 31 | ->execute(CURRENT_ID); 32 | 33 | // Do not initialize handler if calendar is ignored 34 | if ($calendar->seo_serp_ignore) { 35 | unset($GLOBALS['TL_DCA']['tl_calendar_events']['fields']['seo_serp_preview']); 36 | 37 | return; 38 | } 39 | } 40 | 41 | parent::initialize($dc); 42 | } 43 | 44 | /** 45 | * Get the table name 46 | * 47 | * @return string 48 | */ 49 | protected function getTableName() 50 | { 51 | return 'tl_calendar_events'; 52 | } 53 | 54 | /** 55 | * Get the applicable record IDs 56 | * 57 | * @return array 58 | */ 59 | protected function getRecordIds() 60 | { 61 | return Database::getInstance()->prepare("SELECT id FROM tl_calendar_events WHERE pid=?") 62 | ->execute(CURRENT_ID) 63 | ->fetchEach('id'); 64 | } 65 | } -------------------------------------------------------------------------------- /templates/backend/be_seo_serp_preview.html5: -------------------------------------------------------------------------------- 1 | getJavaScriptEngineSource(); 5 | ?> 6 | 7 | hidden): ?> style="display:none;"> 8 | 9 | 10 | 11 | 12 | 13 | = $GLOBALS['TL_LANG']['MSC']['seo_serp_preview.headline'] ?> 14 | 15 | 16 | 17 | = $GLOBALS['TL_LANG']['MSC']['seo_serp_preview.hint'] ?> 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | = $this->getUrlPath() ?>= $this->getUrlSuffix() ?> 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | = $GLOBALS['TL_LANG']['MSC']['seo_serp_preview.notIndexed'] ?> 34 | 35 | 36 | 37 | 45 | -------------------------------------------------------------------------------- /assets/css/preview.css: -------------------------------------------------------------------------------- 1 | /* Preview widget */ 2 | .seo-serp-preview { 3 | padding-top: 14px; 4 | } 5 | 6 | .seo-serp-preview .preview-header { 7 | margin-bottom: 14px; 8 | } 9 | 10 | .seo-serp-preview .preview-header:after { 11 | content: ""; 12 | display: table; 13 | clear: both; 14 | } 15 | 16 | .seo-serp-preview .preview-header a { 17 | float: left; 18 | } 19 | 20 | .seo-serp-preview .preview-header img { 21 | width: 80px; 22 | height: auto; 23 | vertical-align: middle; 24 | } 25 | 26 | .seo-serp-preview .preview-header span { 27 | float: left; 28 | margin: 4px 0 0 14px; 29 | font-size: 16px; 30 | color: #999; 31 | } 32 | 33 | .seo-serp-preview .preview-index { 34 | margin-top: 10px; 35 | } 36 | 37 | .seo-serp-preview .preview-body { 38 | padding: 10px; 39 | border: 1px dashed #ddd; 40 | } 41 | 42 | .seo-serp-preview .title { 43 | font-size: 18px; 44 | font-family: arial, sans-serif; 45 | color: #1a0dab; 46 | line-height: 1.2; 47 | } 48 | 49 | .seo-serp-preview .url { 50 | font-size: 14px; 51 | font-family: arial, sans-serif; 52 | color: #006621; 53 | line-height: 16px; 54 | } 55 | 56 | .seo-serp-preview .description { 57 | font-size: 13px; 58 | font-family: arial, sans-serif; 59 | color: #545454; 60 | line-height: 18px; 61 | } 62 | 63 | .seo-serp-preview .keyword-mark { 64 | background: #c3ff7f; 65 | } 66 | 67 | /* The page is will not be indexed */ 68 | .seo-serp-preview.no-index .preview-header img { 69 | -webkit-filter: grayscale(100%); 70 | filter: grayscale(100%); 71 | } 72 | 73 | .seo-serp-preview.no-index .preview-body .title, 74 | .seo-serp-preview.no-index .preview-body .url, 75 | .seo-serp-preview.no-index .preview-body .description { 76 | color: #999; 77 | } 78 | 79 | /* Character counter next to field */ 80 | .seo-serp-preview-counter { 81 | margin-left: 5px; 82 | font-size: .6875rem; 83 | color: #aaa; 84 | } 85 | 86 | .seo-serp-preview-counter.limit-exceeded { 87 | color: #c33; 88 | } -------------------------------------------------------------------------------- /src/Derhaeuptling/SeoSerpPreview/TestsManager.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | namespace Derhaeuptling\SeoSerpPreview; 15 | 16 | use Derhaeuptling\SeoSerpPreview\Test\TestInterface; 17 | 18 | class TestsManager 19 | { 20 | /** 21 | * Tests 22 | * @var array 23 | */ 24 | protected static $tests = []; 25 | 26 | /** 27 | * Add the test 28 | * 29 | * @param string $key 30 | * @param string $class 31 | */ 32 | public static function add($key, $class) 33 | { 34 | static::$tests[$key] = $class; 35 | } 36 | 37 | /** 38 | * Remove the test 39 | * 40 | * @param string $key 41 | */ 42 | public static function remove($key) 43 | { 44 | if (!isset(static::$tests[$key])) { 45 | throw new \InvalidArgumentException(sprintf('The test "%s" has been not found', $key)); 46 | } 47 | 48 | unset(static::$tests[$key]); 49 | } 50 | 51 | /** 52 | * Get all tests 53 | * 54 | * @return array 55 | */ 56 | public static function getAll() 57 | { 58 | $tests = []; 59 | $types = array_keys(static::$tests); 60 | 61 | foreach ($types as $key) { 62 | $tests[] = static::get($key); 63 | } 64 | 65 | return $tests; 66 | } 67 | 68 | /** 69 | * Get the test 70 | * 71 | * @param string $key 72 | * 73 | * @return TestInterface 74 | * 75 | * @throws \InvalidArgumentException 76 | */ 77 | public static function get($key) 78 | { 79 | if (!isset(static::$tests[$key])) { 80 | throw new \InvalidArgumentException(sprintf('The test "%s" has been not found', $key)); 81 | } 82 | 83 | $class = static::$tests[$key]; 84 | 85 | return new $class(); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Derhaeuptling/SeoSerpPreview/Engine/AbstractEngine.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | namespace Derhaeuptling\SeoSerpPreview\Engine; 15 | 16 | use Contao\Controller; 17 | use Contao\Environment; 18 | use Contao\LayoutModel; 19 | use Contao\PageModel; 20 | 21 | abstract class AbstractEngine 22 | { 23 | /** 24 | * Generate the URL path 25 | * 26 | * @param PageModel $pageModel 27 | * 28 | * @return string 29 | */ 30 | protected function generateUrlPath(PageModel $pageModel) 31 | { 32 | $pageModel->loadDetails(); 33 | 34 | if (($rootModel = PageModel::findByPk($pageModel->rootId)) === null) { 35 | return ''; 36 | } 37 | 38 | return ($rootModel->rootUseSSL ? 'https://' : 'http://').($rootModel->domain ?: Environment::get('host')).TL_PATH.'/'.$pageModel->alias.'/'; 39 | } 40 | 41 | /** 42 | * Generate the page title with ##title## as placeholder for dynamic title 43 | * 44 | * @param PageModel $pageModel 45 | * 46 | * @return string 47 | */ 48 | protected function generatePageTitle(PageModel $pageModel) 49 | { 50 | $pageModel->loadDetails(); 51 | $layoutModel = LayoutModel::findByPk($pageModel->layout); 52 | 53 | if ($layoutModel === null) { 54 | return '##title##'; 55 | } 56 | 57 | $title = $layoutModel->titleTag ?: '{{page::pageTitle}} - {{page::rootPageTitle}}'; 58 | $title = str_replace('{{page::pageTitle}}', '##title##', $title); 59 | 60 | // Fake the global page object 61 | $GLOBALS['objPage'] = $pageModel; 62 | 63 | // Replace the insert tags 64 | $title = Controller::replaceInsertTags($title, false); 65 | 66 | // Remove the faked global page object 67 | unset($GLOBALS['objPage']); 68 | 69 | return $title; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Derhaeuptling/SeoSerpPreview/TestsHandler/ArticleHandler.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | namespace Derhaeuptling\SeoSerpPreview\TestsHandler; 15 | 16 | use Contao\Database; 17 | use Contao\DataContainer; 18 | 19 | class ArticleHandler extends AbstractHandler 20 | { 21 | /** 22 | * Initialize the tests handler 23 | * 24 | * @param DataContainer|null $dc 25 | */ 26 | public function initialize(DataContainer $dc = null) 27 | { 28 | $articles = Database::getInstance()->prepare("SELECT id FROM tl_article WHERE showTeaser!=''") 29 | ->limit(1) 30 | ->execute(); 31 | 32 | // Do not initialize handler if there are no articles to analyze 33 | if (!$articles->numRows) { 34 | return; 35 | } 36 | 37 | parent::initialize($dc); 38 | } 39 | 40 | /** 41 | * Get the table name 42 | * 43 | * @return string 44 | */ 45 | protected function getTableName() 46 | { 47 | return 'tl_article'; 48 | } 49 | 50 | /** 51 | * Get the applicable record IDs 52 | * 53 | * @return array 54 | */ 55 | protected function getRecordIds() 56 | { 57 | return Database::getInstance()->execute("SELECT id FROM tl_article WHERE showTeaser!=''")->fetchEach('id'); 58 | } 59 | 60 | /** 61 | * Filter the records by message type 62 | */ 63 | protected function filterRecords() 64 | { 65 | parent::filterRecords(); 66 | 67 | $articles = $GLOBALS['TL_DCA'][$this->table]['list']['sorting']['root']; 68 | 69 | if (!is_array($articles) || count($articles) < 1) { 70 | return; 71 | } 72 | 73 | if ($articles === [0]) { 74 | $root = []; 75 | } else { 76 | $root = Database::getInstance()->execute( 77 | "SELECT pid FROM tl_article WHERE id IN (".implode(',', $articles).")" 78 | )->fetchEach('pid'); 79 | } 80 | 81 | $GLOBALS['TL_DCA']['tl_page']['list']['sorting']['root'] = (count($root) === 0) ? [0] : $root; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Derhaeuptling/SeoSerpPreview/Engine/PageEngine.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | namespace Derhaeuptling\SeoSerpPreview\Engine; 15 | 16 | use Contao\Environment; 17 | use Contao\PageModel; 18 | 19 | /** 20 | * Handle the tl_page table. 21 | */ 22 | class PageEngine extends AbstractEngine implements EngineInterface 23 | { 24 | /** 25 | * Get the JavaScript engine name 26 | * 27 | * @return string 28 | */ 29 | public function getJavaScriptEngineName() 30 | { 31 | return 'SeoSerpPreview.PageEngine'; 32 | } 33 | 34 | /** 35 | * Get the JavaScript engine file path 36 | * 37 | * @return string 38 | */ 39 | public function getJavaScriptEngineSource() 40 | { 41 | return 'system/modules/seo_serp_preview/assets/js/engine/page.min.js'; 42 | } 43 | 44 | /** 45 | * Get the URL path 46 | * 47 | * @param int $id 48 | * 49 | * @return string 50 | */ 51 | public function getUrlPath($id) 52 | { 53 | if (($pageModel = $this->getPageModel($id)) === null) { 54 | return ''; 55 | } 56 | 57 | $pageModel->loadDetails(); 58 | 59 | if (($rootModel = PageModel::findByPk($pageModel->rootId)) === null) { 60 | return ''; 61 | } 62 | 63 | return ($rootModel->rootUseSSL ? 'https://' : 'http://').($rootModel->dns ?: Environment::get('host')).TL_PATH.'/'; 64 | } 65 | 66 | /** 67 | * Get the page title with ##title## as placeholder for dynamic title 68 | * 69 | * @param int $id 70 | * 71 | * @return string 72 | */ 73 | public function getPageTitle($id) 74 | { 75 | if (($pageModel = $this->getPageModel($id)) === null) { 76 | return ''; 77 | } 78 | 79 | return $this->generatePageTitle($pageModel); 80 | } 81 | 82 | /** 83 | * Get the page model 84 | * 85 | * @param int $id 86 | * 87 | * @return PageModel|null 88 | */ 89 | protected function getPageModel($id) 90 | { 91 | return PageModel::findByPk($id); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /assets/js/engine/page.js: -------------------------------------------------------------------------------- 1 | SeoSerpPreview.PageEngine = new Class({ 2 | Implements: [Events], 3 | 4 | /** 5 | * Initialize the engine 6 | */ 7 | init: function () { 8 | this.title = document.id('ctrl_title'); 9 | this.pageTitle = document.id('ctrl_pageTitle'); 10 | this.alias = document.id('ctrl_alias'); 11 | this.description = document.id('ctrl_description'); 12 | this.robots = document.id('ctrl_robots'); 13 | 14 | this.addEventListeners(); 15 | this.fireEvent('ready'); 16 | }, 17 | 18 | /** 19 | * Add the event listeners 20 | */ 21 | addEventListeners: function () { 22 | var fields = [this.title, this.pageTitle, this.alias, this.description]; 23 | 24 | for (var i = 0; i < fields.length; i++) { 25 | fields[i].addEvent('keyup', function () { 26 | this.fireEvent('change'); 27 | }.bind(this)); 28 | } 29 | 30 | if (this.robots) { 31 | this.robots.addEvent('change', function () { 32 | this.fireEvent('change'); 33 | }.bind(this)); 34 | } 35 | }, 36 | 37 | /** 38 | * Add the description character counter 39 | * 40 | * @param {object} el 41 | * 42 | * @return {object} 43 | */ 44 | addDescriptionCounter: function (el) { 45 | return el.inject(this.description.getPrevious(), 'bottom'); 46 | }, 47 | 48 | /** 49 | * Get the title 50 | * 51 | * @returns {string} 52 | */ 53 | getTitle: function () { 54 | var pageTitle = this.pageTitle.get('value'); 55 | 56 | return pageTitle ? pageTitle : this.title.get('value'); 57 | }, 58 | 59 | /** 60 | * Get the URL 61 | * 62 | * @returns {string} 63 | */ 64 | getUrl: function () { 65 | return this.alias.get('value'); 66 | }, 67 | 68 | /** 69 | * Get the description 70 | * 71 | * @returns {string} 72 | */ 73 | getDescription: function () { 74 | return this.description.get('value'); 75 | }, 76 | 77 | /** 78 | * Get the index (true if the page is indexed, false otherwise) 79 | * 80 | * @returns {bool} 81 | */ 82 | getIndex: function () { 83 | if (!this.robots) { 84 | return true; 85 | } 86 | 87 | return this.robots.get('value').indexOf('noindex') === -1; 88 | } 89 | }); 90 | -------------------------------------------------------------------------------- /src/Derhaeuptling/SeoSerpPreview/Engine/NewsEngine.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | namespace Derhaeuptling\SeoSerpPreview\Engine; 15 | 16 | use Contao\NewsArchiveModel; 17 | use Contao\NewsModel; 18 | use Contao\PageModel; 19 | 20 | /** 21 | * Handle the tl_news table. 22 | */ 23 | class NewsEngine extends AbstractEngine implements EngineInterface 24 | { 25 | /** 26 | * Get the JavaScript engine name 27 | * 28 | * @return string 29 | */ 30 | public function getJavaScriptEngineName() 31 | { 32 | return 'SeoSerpPreview.NewsEngine'; 33 | } 34 | 35 | /** 36 | * Get the JavaScript engine file path 37 | * 38 | * @return string 39 | */ 40 | public function getJavaScriptEngineSource() 41 | { 42 | return 'system/modules/seo_serp_preview/assets/js/engine/news.min.js'; 43 | } 44 | 45 | /** 46 | * Get the URL path 47 | * 48 | * @param int $id 49 | * 50 | * @return string 51 | */ 52 | public function getUrlPath($id) 53 | { 54 | if (($pageModel = $this->getPageModel($id)) === null) { 55 | return ''; 56 | } 57 | 58 | return $this->generateUrlPath($pageModel); 59 | } 60 | 61 | /** 62 | * Get the page title with ##title## as placeholder for dynamic title 63 | * 64 | * @param int $id 65 | * 66 | * @return string 67 | */ 68 | public function getPageTitle($id) 69 | { 70 | if (($pageModel = $this->getPageModel($id)) === null) { 71 | return ''; 72 | } 73 | 74 | return $this->generatePageTitle($pageModel); 75 | } 76 | 77 | /** 78 | * Get the page model 79 | * 80 | * @param int $id 81 | * 82 | * @return PageModel|null 83 | */ 84 | protected function getPageModel($id) 85 | { 86 | if (($newsModel = NewsModel::findByPk($id)) === null) { 87 | return null; 88 | } 89 | 90 | if (($newsArchiveModel = NewsArchiveModel::findByPk($newsModel->pid)) === null) { 91 | return null; 92 | } 93 | 94 | return PageModel::findByPk($newsArchiveModel->jumpTo); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Derhaeuptling/SeoSerpPreview/Engine/EventsEngine.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | namespace Derhaeuptling\SeoSerpPreview\Engine; 15 | 16 | use Contao\CalendarEventsModel; 17 | use Contao\CalendarModel; 18 | use Contao\PageModel; 19 | 20 | /** 21 | * Handle the tl_calendar_events table. 22 | */ 23 | class EventsEngine extends AbstractEngine implements EngineInterface 24 | { 25 | /** 26 | * Get the JavaScript engine name 27 | * 28 | * @return string 29 | */ 30 | public function getJavaScriptEngineName() 31 | { 32 | return 'SeoSerpPreview.EventsEngine'; 33 | } 34 | 35 | /** 36 | * Get the JavaScript engine file path 37 | * 38 | * @return string 39 | */ 40 | public function getJavaScriptEngineSource() 41 | { 42 | return 'system/modules/seo_serp_preview/assets/js/engine/events.min.js'; 43 | } 44 | 45 | /** 46 | * Get the URL path 47 | * 48 | * @param int $id 49 | * 50 | * @return string 51 | */ 52 | public function getUrlPath($id) 53 | { 54 | if (($pageModel = $this->getPageModel($id)) === null) { 55 | return ''; 56 | } 57 | 58 | return $this->generateUrlPath($pageModel); 59 | } 60 | 61 | /** 62 | * Get the page title with ##title## as placeholder for dynamic title 63 | * 64 | * @param int $id 65 | * 66 | * @return string 67 | */ 68 | public function getPageTitle($id) 69 | { 70 | if (($pageModel = $this->getPageModel($id)) === null) { 71 | return ''; 72 | } 73 | 74 | return $this->generatePageTitle($pageModel); 75 | } 76 | 77 | /** 78 | * Get the page model 79 | * 80 | * @param int $id 81 | * 82 | * @return PageModel|null 83 | */ 84 | protected function getPageModel($id) 85 | { 86 | if (($eventModel = CalendarEventsModel::findByPk($id)) === null) { 87 | return ''; 88 | } 89 | 90 | if (($calendarModel = CalendarModel::findByPk($eventModel->pid)) === null) { 91 | return ''; 92 | } 93 | 94 | return PageModel::findByPk($calendarModel->jumpTo); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /assets/js/engine/events.js: -------------------------------------------------------------------------------- 1 | SeoSerpPreview.EventsEngine = new Class({ 2 | Implements: [Events], 3 | 4 | /** 5 | * Initialize the engine 6 | */ 7 | init: function () { 8 | this.title = document.id('ctrl_title'); 9 | this.alias = document.id('ctrl_alias'); 10 | 11 | var attempts = 20; 12 | 13 | var interval = setInterval(function () { 14 | if (!tinyMCE.hasOwnProperty('editors')) { 15 | // Resign after the attempts limit is reached 16 | if (attempts-- <= 1) { 17 | console.error('Unable to determine the tinyMCE instance for SEO SERP Preview extension.'); 18 | clearInterval(interval); 19 | } 20 | 21 | return; 22 | } 23 | 24 | clearInterval(interval); 25 | 26 | this.description = { 27 | 'textarea': document.id('ctrl_teaser'), 28 | 'tinymce': tinyMCE.get('ctrl_teaser') 29 | }; 30 | 31 | this.addEventListeners(); 32 | this.fireEvent('ready'); 33 | }.bind(this), 500); 34 | }, 35 | 36 | /** 37 | * Add the event listeners 38 | */ 39 | addEventListeners: function () { 40 | var fields = [this.title, this.alias]; 41 | 42 | for (var i = 0; i < fields.length; i++) { 43 | fields[i].addEvent('keyup', function () { 44 | this.fireEvent('change'); 45 | }.bind(this)); 46 | } 47 | 48 | this.description.tinymce.on('keyup', function () { 49 | this.fireEvent('change'); 50 | }.bind(this)); 51 | }, 52 | 53 | /** 54 | * Add the description character counter 55 | * 56 | * @param {object} el 57 | * 58 | * @return {object} 59 | */ 60 | addDescriptionCounter: function (el) { 61 | return el.inject(this.description.textarea.getPrevious('h3'), 'bottom'); 62 | }, 63 | 64 | /** 65 | * Get the title 66 | * 67 | * @returns {string} 68 | */ 69 | getTitle: function () { 70 | return this.title.get('value'); 71 | }, 72 | 73 | /** 74 | * Get the URL 75 | * 76 | * @returns {string} 77 | */ 78 | getUrl: function () { 79 | return this.alias.get('value'); 80 | }, 81 | 82 | /* 83 | * Get the description 84 | * 85 | * @returns {string} 86 | */ 87 | getDescription: function () { 88 | return this.description.tinymce.getContent({ format: 'text' }); 89 | } 90 | }); 91 | -------------------------------------------------------------------------------- /assets/js/engine/news.js: -------------------------------------------------------------------------------- 1 | SeoSerpPreview.NewsEngine = new Class({ 2 | Implements: [Events], 3 | 4 | /** 5 | * Initialize the engine 6 | */ 7 | init: function () { 8 | this.title = document.id('ctrl_headline'); 9 | this.alias = document.id('ctrl_alias'); 10 | 11 | var attempts = 20; 12 | 13 | var interval = setInterval(function () { 14 | if (!tinyMCE.hasOwnProperty('editors')) { 15 | // Resign after the attempts limit is reached 16 | if (attempts-- <= 1) { 17 | console.error('Unable to determine the tinyMCE instance for SEO SERP Preview extension.'); 18 | clearInterval(interval); 19 | } 20 | 21 | return; 22 | } 23 | 24 | clearInterval(interval); 25 | 26 | this.description = { 27 | 'textarea': document.id('ctrl_teaser'), 28 | 'tinymce': tinyMCE.get('ctrl_teaser') 29 | }; 30 | 31 | this.addEventListeners(); 32 | this.fireEvent('ready'); 33 | }.bind(this), 500); 34 | }, 35 | 36 | /** 37 | * Add the event listeners 38 | */ 39 | addEventListeners: function () { 40 | var fields = [this.title, this.alias]; 41 | 42 | for (var i = 0; i < fields.length; i++) { 43 | fields[i].addEvent('keyup', function () { 44 | this.fireEvent('change'); 45 | }.bind(this)); 46 | } 47 | 48 | this.description.tinymce.on('keyup', function () { 49 | this.fireEvent('change'); 50 | }.bind(this)); 51 | }, 52 | 53 | /** 54 | * Add the description character counter 55 | * 56 | * @param {object} el 57 | * 58 | * @return {object} 59 | */ 60 | addDescriptionCounter: function (el) { 61 | return el.inject(this.description.textarea.getPrevious('h3'), 'bottom'); 62 | }, 63 | 64 | /** 65 | * Get the title 66 | * 67 | * @returns {string} 68 | */ 69 | getTitle: function () { 70 | return this.title.get('value'); 71 | }, 72 | 73 | /** 74 | * Get the URL 75 | * 76 | * @returns {string} 77 | */ 78 | getUrl: function () { 79 | return this.alias.get('value'); 80 | }, 81 | 82 | /* 83 | * Get the description 84 | * 85 | * @returns {string} 86 | */ 87 | getDescription: function () { 88 | return this.description.tinymce.getContent({ format: 'text' }); 89 | } 90 | }); 91 | -------------------------------------------------------------------------------- /src/Derhaeuptling/SeoSerpPreview/Engine/ArticleEngine.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | namespace Derhaeuptling\SeoSerpPreview\Engine; 15 | 16 | use Contao\ArticleModel; 17 | use Contao\PageModel; 18 | 19 | /** 20 | * Handle the tl_article table. 21 | */ 22 | class ArticleEngine extends AbstractEngine implements EngineInterface 23 | { 24 | /** 25 | * Get the JavaScript engine name 26 | * 27 | * @return string 28 | */ 29 | public function getJavaScriptEngineName() 30 | { 31 | return 'SeoSerpPreview.ArticleEngine'; 32 | } 33 | 34 | /** 35 | * Get the JavaScript engine file path 36 | * 37 | * @return string 38 | */ 39 | public function getJavaScriptEngineSource() 40 | { 41 | return 'system/modules/seo_serp_preview/assets/js/engine/article.min.js'; 42 | } 43 | 44 | /** 45 | * Get the URL path 46 | * 47 | * @param int $id 48 | * 49 | * @return string 50 | */ 51 | public function getUrlPath($id) 52 | { 53 | if (($articleModel = $this->getArticleModel($id)) === null 54 | || ($pageModel = $this->getPageModel($articleModel)) === null 55 | ) { 56 | return ''; 57 | } 58 | 59 | return $this->generateUrlPath($pageModel).'articles/'; 60 | } 61 | 62 | /** 63 | * Get the page title with ##title## as placeholder for dynamic title 64 | * 65 | * @param int $id 66 | * 67 | * @return string 68 | */ 69 | public function getPageTitle($id) 70 | { 71 | if (($articleModel = $this->getArticleModel($id)) === null 72 | || ($pageModel = $this->getPageModel($articleModel)) === null 73 | ) { 74 | return ''; 75 | } 76 | 77 | return $this->generatePageTitle($pageModel); 78 | } 79 | 80 | /** 81 | * Get the article model 82 | * 83 | * @param int $id 84 | * 85 | * @return ArticleModel|null 86 | */ 87 | protected function getArticleModel($id) 88 | { 89 | return ArticleModel::findByPk($id); 90 | } 91 | 92 | /** 93 | * Get the page model 94 | * 95 | * @param ArticleModel $articleModel 96 | * 97 | * @return PageModel|null 98 | */ 99 | protected function getPageModel(ArticleModel $articleModel) 100 | { 101 | return PageModel::findByPk($articleModel->pid); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /assets/js/engine/article.js: -------------------------------------------------------------------------------- 1 | SeoSerpPreview.ArticleEngine = new Class({ 2 | Implements: [Events], 3 | 4 | /** 5 | * Initialize the engine 6 | */ 7 | init: function () { 8 | this.title = document.id('ctrl_title'); 9 | this.alias = document.id('ctrl_alias'); 10 | this.showTeaser = document.id('opt_showTeaser_0'); 11 | 12 | var attempts = 20; 13 | 14 | var interval = setInterval(function () { 15 | if (!tinyMCE.hasOwnProperty('editors')) { 16 | // Resign after the attempts limit is reached 17 | if (attempts-- <= 1) { 18 | console.error('Unable to determine the tinyMCE instance for SEO SERP Preview extension.'); 19 | clearInterval(interval); 20 | } 21 | 22 | return; 23 | } 24 | 25 | clearInterval(interval); 26 | 27 | this.description = { 28 | 'textarea': document.id('ctrl_teaser'), 29 | 'tinymce': tinyMCE.get('ctrl_teaser') 30 | }; 31 | 32 | this.addEventListeners(); 33 | this.fireEvent('ready'); 34 | }.bind(this), 500); 35 | }, 36 | 37 | /** 38 | * Add the event listeners 39 | */ 40 | addEventListeners: function () { 41 | var fields = [this.title, this.alias]; 42 | 43 | for (var i = 0; i < fields.length; i++) { 44 | fields[i].addEvent('keyup', function () { 45 | this.fireEvent('change'); 46 | }.bind(this)); 47 | } 48 | 49 | this.description.tinymce.on('keyup', function () { 50 | this.fireEvent('change'); 51 | }.bind(this)); 52 | 53 | if (this.showTeaser) { 54 | this.showTeaser.addEvent('change', function () { 55 | this.fireEvent('change'); 56 | }.bind(this)); 57 | } 58 | }, 59 | 60 | /** 61 | * Return true to show the element 62 | * 63 | * @return {boolean} 64 | */ 65 | showElement: function() { 66 | return !!this.showTeaser.checked; 67 | }, 68 | 69 | /** 70 | * Add the description character counter 71 | * 72 | * @param {object} el 73 | * 74 | * @return {object} 75 | */ 76 | addDescriptionCounter: function (el) { 77 | return el.inject(this.description.textarea.getPrevious('h3'), 'bottom'); 78 | }, 79 | 80 | /** 81 | * Get the title 82 | * 83 | * @returns {string} 84 | */ 85 | getTitle: function () { 86 | return this.title.get('value'); 87 | }, 88 | 89 | /** 90 | * Get the URL 91 | * 92 | * @returns {string} 93 | */ 94 | getUrl: function () { 95 | return this.alias.get('value'); 96 | }, 97 | 98 | /** 99 | * Get the description 100 | * 101 | * @returns {string} 102 | */ 103 | getDescription: function () { 104 | return this.description.tinymce.getContent({ format: 'text' }); 105 | } 106 | }); 107 | -------------------------------------------------------------------------------- /src/Derhaeuptling/SeoSerpPreview/Test/DescriptionTest.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | namespace Derhaeuptling\SeoSerpPreview\Test; 15 | 16 | use Contao\Date; 17 | use Derhaeuptling\SeoSerpPreview\Test\Exception\ErrorException; 18 | use Derhaeuptling\SeoSerpPreview\Test\Exception\WarningException; 19 | 20 | /** 21 | * Class DescriptionTest 22 | * 23 | * Check if the page description is correct. 24 | */ 25 | class DescriptionTest implements TestInterface 26 | { 27 | const MAX_LENGTH = 320; 28 | 29 | /** 30 | * Return true if the test supports table 31 | * 32 | * @param string $table 33 | * 34 | * @return bool 35 | */ 36 | public function supports($table) 37 | { 38 | return in_array($table, ['tl_article', 'tl_calendar_events', 'tl_news', 'tl_page'], true); 39 | } 40 | 41 | /** 42 | * Run the test 43 | * 44 | * @param array $data 45 | * @param string $table 46 | * 47 | * @throws ErrorException 48 | * @throws WarningException 49 | */ 50 | public function run(array $data, $table) 51 | { 52 | switch ($table) { 53 | case 'tl_article': 54 | case 'tl_calendar_events': 55 | case 'tl_news': 56 | $this->check($data['teaser']); 57 | break; 58 | 59 | case 'tl_page': 60 | $time = Date::floorToMinute(); 61 | 62 | if ($data['type'] === 'regular' 63 | && $data['robots'] !== 'noindex,nofollow' 64 | && $data['published'] 65 | && (!$data['start'] || $data['start'] <= $time) 66 | && (!$data['stop'] || $data['stop'] > $time) 67 | ) { 68 | $this->check($data['description']); 69 | } 70 | break; 71 | } 72 | } 73 | 74 | /** 75 | * Run the test 76 | * 77 | * @param array $data 78 | * @param string $table 79 | * 80 | * @throws ErrorException 81 | * @throws WarningException 82 | */ 83 | private function check($value) 84 | { 85 | $value = strip_tags(strip_insert_tags($value)); 86 | 87 | // The description does not exist 88 | if (!$value) { 89 | throw new ErrorException($GLOBALS['TL_LANG']['SST']['test.description']['empty']); 90 | } 91 | 92 | // The description is too long 93 | if (utf8_strlen($value) > self::MAX_LENGTH) { 94 | throw new WarningException( 95 | sprintf( 96 | $GLOBALS['TL_LANG']['SST']['test.description']['length'], 97 | self::MAX_LENGTH 98 | ) 99 | ); 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /templates/backend/be_seo_serp_module.html5: -------------------------------------------------------------------------------- 1 | 2 | = $GLOBALS['TL_LANG']['MSC']['backBT'] ?> 3 | 4 | 5 | = $GLOBALS['TL_LANG']['MSC']['seo_serp_module.headline'] ?> 6 | 7 | 8 | modules): ?> 9 | 10 | modules as $module): ?> 11 | 12 | 13 | style="background-image:url('= $module['icon'] ?>')" onclick="return false;">= $module['label'] ?> 14 | = $GLOBALS['TL_LANG']['MSC']['seo_serp_module.noPermission'] ?> 15 | 16 | 17 | 18 | 19 | 20 | = $module['tests'][0]['message'] ?> 21 | 22 | 23 | 24 | 25 | 26 | 27 | = $note ?> 28 | 29 | 30 | 31 | 32 | 33 | onclick="return false;"> 34 | = $test['message'] ?> 35 | 36 | 37 | = $test['reference'] ?> 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | = $GLOBALS['TL_LANG']['MSC']['seo_serp_module.noModules'] ?> 49 | 50 | 51 | -------------------------------------------------------------------------------- /src/Derhaeuptling/SeoSerpPreview/PreviewWidget.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | namespace Derhaeuptling\SeoSerpPreview; 15 | 16 | use Contao\Config; 17 | use Derhaeuptling\SeoSerpPreview\Engine\EngineInterface; 18 | 19 | class PreviewWidget extends \Widget 20 | { 21 | /** 22 | * Submit user input 23 | * @var boolean 24 | */ 25 | protected $blnSubmitInput = false; 26 | 27 | /** 28 | * Add a for attribute 29 | * @var boolean 30 | */ 31 | protected $blnForAttribute = false; 32 | 33 | /** 34 | * Template 35 | * @var string 36 | */ 37 | protected $strTemplate = 'be_seo_serp_preview'; 38 | 39 | /** 40 | * Engine 41 | * @var EngineInterface 42 | */ 43 | protected $engine; 44 | 45 | /** 46 | * Validate the engine 47 | * 48 | * @param array|null $attributes 49 | * 50 | * @throws \InvalidArgumentException 51 | */ 52 | public function __construct(array $attributes = null) 53 | { 54 | if (!isset($attributes['engine'])) { 55 | throw new \InvalidArgumentException('The engine is not specified'); 56 | } 57 | 58 | $this->engine = new $attributes['engine'](); 59 | 60 | if (!($this->engine instanceof EngineInterface)) { 61 | throw new \InvalidArgumentException(sprintf( 62 | 'Engine "%s" must be an instance of Derhaeuptling\SeoSerpPreview\Engine\EngineInterface', 63 | $attributes['engine'] 64 | )); 65 | } 66 | 67 | unset($attributes['engine']); // do not override $this->engine 68 | 69 | parent::__construct($attributes); 70 | } 71 | 72 | /** 73 | * Get the URL path from engine 74 | * 75 | * @return string 76 | */ 77 | public function getUrlPath() 78 | { 79 | return $this->engine->getUrlPath($this->objDca->activeRecord->id); 80 | } 81 | 82 | /** 83 | * Get the page title 84 | * 85 | * @return string 86 | */ 87 | public function getPageTitle() 88 | { 89 | return $this->engine->getPageTitle($this->objDca->activeRecord->id); 90 | } 91 | 92 | /** 93 | * Get the URL suffix 94 | * 95 | * @return string 96 | */ 97 | public function getUrlSuffix() 98 | { 99 | return Config::get('urlSuffix'); 100 | } 101 | 102 | /** 103 | * Get the JavaScript engine name 104 | * 105 | * @return string 106 | */ 107 | public function getJavaScriptEngineName() 108 | { 109 | return $this->engine->getJavaScriptEngineName(); 110 | } 111 | 112 | /** 113 | * Get the JavaScript engine source 114 | * 115 | * @return string 116 | */ 117 | public function getJavaScriptEngineSource() 118 | { 119 | return $this->engine->getJavaScriptEngineSource(); 120 | } 121 | 122 | /** 123 | * Generate the widget and return it as string 124 | * 125 | * @return string 126 | */ 127 | public function generate() 128 | { 129 | return ''; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/Derhaeuptling/SeoSerpPreview/StatusManager.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | namespace Derhaeuptling\SeoSerpPreview; 15 | 16 | use Contao\Config; 17 | use Contao\Database; 18 | use Derhaeuptling\SeoSerpPreview\Test\Exception\ErrorException; 19 | use Derhaeuptling\SeoSerpPreview\Test\Exception\WarningException; 20 | use Derhaeuptling\SeoSerpPreview\Test\TestInterface; 21 | 22 | class StatusManager 23 | { 24 | /** 25 | * Cache key 26 | * @var string 27 | */ 28 | protected $cacheKey = 'seoSerpCache'; 29 | 30 | /** 31 | * Tables to be analyzed 32 | * @var array 33 | */ 34 | protected $tables = ['tl_calendar_events', 'tl_news', 'tl_page']; 35 | 36 | /** 37 | * Rebuild the cache 38 | */ 39 | public function rebuildCache() 40 | { 41 | Config::persist($this->cacheKey, $this->getStatus()); 42 | } 43 | 44 | /** 45 | * Get the status 46 | * 47 | * @return bool 48 | */ 49 | public function getStatus() 50 | { 51 | foreach ($this->tables as $table) { 52 | $db = Database::getInstance(); 53 | 54 | if (!$db->tableExists($table)) { 55 | continue; 56 | } 57 | 58 | switch ($table) { 59 | case 'tl_news': 60 | $records = $db->execute( 61 | "SELECT * FROM tl_news WHERE pid IN (SELECT id FROM tl_news_archive WHERE seo_serp_ignore='')" 62 | ); 63 | break; 64 | 65 | case 'tl_calendar_events': 66 | $records = $db->execute( 67 | "SELECT * FROM tl_calendar_events WHERE pid IN (SELECT id FROM tl_calendar WHERE seo_serp_ignore='')" 68 | ); 69 | break; 70 | 71 | case 'tl_page': 72 | $rootIds = $db->execute("SELECT id FROM tl_page WHERE type='root' AND seo_serp_ignore=''"); 73 | $pageIds = $db->getChildRecords($rootIds->fetchEach('id'), 'tl_page'); 74 | 75 | if (count($pageIds) < 1) { 76 | return true; 77 | } 78 | 79 | $records = $db->execute("SELECT * FROM tl_page WHERE id IN (".implode(',', $pageIds).")"); 80 | break; 81 | 82 | default: 83 | $records = $db->execute("SELECT * FROM ".$table); 84 | break; 85 | } 86 | 87 | while ($records->next()) { 88 | try { 89 | $this->runTests($table, $records->row()); 90 | } catch (ErrorException $e) { 91 | return false; 92 | } catch (WarningException $e) { 93 | // do nothing 94 | } 95 | } 96 | } 97 | 98 | return true; 99 | } 100 | 101 | /** 102 | * Run the tests 103 | * 104 | * @param string $table 105 | * @param array $data 106 | * 107 | * @throws ErrorException 108 | * @throws WarningException 109 | */ 110 | protected function runTests($table, array $data) 111 | { 112 | /** @var TestInterface $test */ 113 | foreach (TestsManager::getAll() as $test) { 114 | if (!$test->supports($table)) { 115 | continue; 116 | } 117 | 118 | $test->run($data, $table); 119 | } 120 | } 121 | 122 | /** 123 | * Set the module status in the menu 124 | * 125 | * @param array $modules 126 | * 127 | * @return array 128 | */ 129 | public function setMenuStatus(array $modules) 130 | { 131 | if (isset($modules['content']['modules']['seo_serp_preview']) && Config::get($this->cacheKey) === false) { 132 | $modules['content']['modules']['seo_serp_preview']['label'] .= ' ('.$GLOBALS['TL_LANG']['MSC']['seo_serp_status.fixErrors'].')'; 133 | } 134 | 135 | return $modules; 136 | } 137 | } -------------------------------------------------------------------------------- /assets/js/preview.min.js: -------------------------------------------------------------------------------- 1 | var SeoSerpPreview=new Class({Implements:[Options],options:{hidden:!1,bodySelector:".preview-body",hintSelector:".preview-hint",indexSelector:".preview-index",titleSelector:"[data-ssp-title]",urlSelector:"[data-ssp-url]",descriptionSelector:"[data-ssp-description]",keywordMarkClass:"keyword-mark",counterClass:"seo-serp-preview-counter",counterLimitClass:"limit-exceeded",noIndexClass:"no-index",titleLimit:70,descriptionLimit:320,keywordCharacterLength:4,titleFormat:"##title##",entitiesMapper:{"[&]":"&","[&]":"&","[lt]":"<","[gt]":">","[nbsp]":" ","[-]":""}},initialize:function(t,e,i){this.el=t,this.engine=e,this.setOptions(i),this.collectElements(),this.engine.addEvent("ready",function(){this.addDescriptionCounter(),this.engine.addEvent("change",this.refresh.bind(this)),this.refresh()}.bind(this)),this.engine.init()},addDescriptionCounter:function(){this.descriptionCounter=this.engine.addDescriptionCounter(new Element("span",{"class":this.options.counterClass}))},updateDescriptionCounter:function(t){this.descriptionCounter.set("text","("+t.length+"/"+this.options.descriptionLimit+")"),t.length>this.options.descriptionLimit?this.descriptionCounter.addClass(this.options.counterLimitClass):this.descriptionCounter.removeClass(this.options.counterLimitClass)},refresh:function(){if(this.options.hidden&&this.engine.showElement&&!this.engine.showElement())return this.el.hide(),void this.descriptionCounter.hide();var t=this.collectData();return this.updateDescriptionCounter(t.description),this.validateData(t)?(this.renderData(t),this.showBody(),void(this.options.hidden&&(this.el.show(),this.descriptionCounter.show()))):void this.hideBody()},collectElements:function(){this.body=this.el.getElement(this.options.bodySelector),this.hint=this.el.getElement(this.options.hintSelector),this.index=this.el.getElement(this.options.indexSelector),this.title=this.el.getElement(this.options.titleSelector),this.url=this.el.getElement(this.options.urlSelector),this.description=this.el.getElement(this.options.descriptionSelector)},collectData:function(){return data={title:this.restoreEntities(this.engine.getTitle()),url:this.engine.getUrl(),description:this.restoreEntities(this.engine.getDescription()),index:!this.engine.getIndex||this.engine.getIndex()}},restoreEntities:function(t){for(var e in this.options.entitiesMapper)t=t.replace(new RegExp(e.replace(/[-\/\\^$*+?.()|[\]{}]/g,"\\$&"),"g"),this.options.entitiesMapper[e]);return t},validateData:function(t){var e=!1;for(var i in t)""!=t[i]&&(e=!0);return e},renderData:function(t){this.textElements=[],this.setTitle(t.title),this.setUrl(t.url),this.setDescription(t.description),t.index?this.hideIndex():this.showIndex()},setTitle:function(t){var e=document.createElement("div");e.innerHTML=t,t=e.textContent,t=this.options.titleFormat.replace("##title##",t),t.length>this.options.titleLimit&&(t=t.substr(0,this.options.titleLimit)+"..."),this.generateTextElements(t,this.title)},setUrl:function(t){this.generateTextElements(t,this.url)},setDescription:function(t){var e=document.createElement("div");e.innerHTML=t,t=e.textContent,t.length>this.options.descriptionLimit&&(t=t.substr(0,this.options.descriptionLimit)+"..."),this.generateTextElements(t,this.description)},generateTextElements:function(t,e){var i=new RegExp(/\,|\.|\;|\!|\?|\-|\s|\\|\//),s=[];e.set("html","");for(var n=0;n0&&(this.createTextElement(s.join(""),e),s=[]),new Element("span",{text:t[n]}).inject(e,"bottom")):s.push(t[n]);s.length>0&&this.createTextElement(s.join(""),e)},createTextElement:function(t,e){var i=this,s=new Element("span",{text:t});s.addEvent("mouseenter",function(){i.markKeywords.call(i,this.get("text"),this)}),s.addEvent("mouseleave",this.unmarkKeywords.bind(this)),s.inject(e,"bottom"),this.textElements.push(s)},markKeywords:function(t,e){var i=0,s=[];if(t=t.toLowerCase(),t.length>=this.options.keywordCharacterLength){var n=[];for(i=0;i<=t.length-this.options.keywordCharacterLength;i++)n.push(t.substr(i,this.options.keywordCharacterLength));for(i=0;i1)for(i=0;i 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | namespace Derhaeuptling\SeoSerpPreview\TestsHandler; 15 | 16 | use Contao\Backend; 17 | use Contao\Database; 18 | use Contao\DataContainer; 19 | use Contao\Environment; 20 | use Contao\Input; 21 | use Contao\Session; 22 | 23 | class PageHandler extends AbstractHandler 24 | { 25 | /** 26 | * Initialize the tests handler 27 | * 28 | * @param DataContainer|null $dc 29 | */ 30 | public function initialize(DataContainer $dc = null) 31 | { 32 | $pages = Database::getInstance()->prepare("SELECT id FROM tl_page WHERE type='root' AND seo_serp_ignore=''") 33 | ->limit(1) 34 | ->execute(); 35 | 36 | // Do not initialize handler if there are no pages to analyze 37 | if (!$pages->numRows) { 38 | unset($GLOBALS['TL_DCA']['tl_page']['fields']['seo_serp_preview']); 39 | 40 | return; 41 | } 42 | 43 | parent::initialize($dc); 44 | } 45 | 46 | /** 47 | * Get the table name 48 | * 49 | * @return string 50 | */ 51 | protected function getTableName() 52 | { 53 | return 'tl_page'; 54 | } 55 | 56 | /** 57 | * Initialize the tests handler if enabled 58 | */ 59 | protected function initializeEnabled() 60 | { 61 | parent::initializeEnabled(); 62 | 63 | $this->autoExpandTree(); 64 | } 65 | 66 | /** 67 | * Auto expand the tree 68 | * 69 | * @throws \Exception 70 | */ 71 | public function autoExpandTree() 72 | { 73 | $session = Session::getInstance()->getData(); 74 | 75 | if ($session['seo_serp_expand_tree'] !== 'tl_page' && !Input::get('serp_tests_expand')) { 76 | return; 77 | } 78 | 79 | $nodes = Database::getInstance()->execute("SELECT DISTINCT pid FROM tl_page WHERE pid>0"); 80 | 81 | // Reset the array first 82 | $session['tl_page_tree'] = []; 83 | 84 | // Expand the tree 85 | while ($nodes->next()) { 86 | $session['tl_page_tree'][$nodes->pid] = 1; 87 | } 88 | 89 | // Avoid redirect loop 90 | $session['seo_serp_expand_tree'] = null; 91 | 92 | Session::getInstance()->setData($session); 93 | Backend::redirect(str_replace('serp_tests_expand=1', '', Environment::get('request'))); 94 | } 95 | 96 | /** 97 | * Generate the message filter 98 | * 99 | * @return string 100 | */ 101 | public function generateMessageFilter() 102 | { 103 | if (Input::post('FORM_SUBMIT') === 'tl_filters' 104 | && Input::post($this->filterName, true) !== 'tl_'.$this->filterName 105 | ) { 106 | $session = Session::getInstance()->getData(); 107 | $session['seo_serp_expand_tree'] = 'tl_page'; 108 | Session::getInstance()->setData($session); 109 | } 110 | 111 | return parent::generateMessageFilter(); 112 | } 113 | 114 | /** 115 | * Get the applicable record IDs 116 | * 117 | * @return array 118 | */ 119 | protected function getRecordIds() 120 | { 121 | $pageNode = Session::getInstance()->get('tl_page_node'); 122 | 123 | return array_merge([$pageNode], Database::getInstance()->getChildRecords($pageNode, 'tl_page')); 124 | } 125 | 126 | /** 127 | * Filter the records by message type 128 | */ 129 | protected function filterRecords() 130 | { 131 | parent::filterRecords(); 132 | $pageNode = Session::getInstance()->get('tl_page_node'); 133 | 134 | // If there is a page node selected and there are no root IDs (e.g. due to filter settings), 135 | // make sure that page node is displayed 136 | if ($pageNode && $GLOBALS['TL_DCA'][$this->table]['list']['sorting']['root'] === [0]) { 137 | $GLOBALS['TL_DCA'][$this->table]['list']['sorting']['root'] = [$pageNode]; 138 | } 139 | } 140 | 141 | /** 142 | * Get the global operation 143 | * 144 | * @param bool $enabled 145 | * 146 | * @return array 147 | */ 148 | protected function getGlobalOperation($enabled) 149 | { 150 | $operation = parent::getGlobalOperation($enabled); 151 | 152 | if ($enabled) { 153 | $operation['href'] .= '&serp_tests_expand=1'; 154 | } 155 | 156 | return $operation; 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /assets/js/preview.js: -------------------------------------------------------------------------------- 1 | var SeoSerpPreview = new Class({ 2 | Implements: [Options], 3 | 4 | /** 5 | * Default options 6 | */ 7 | options: { 8 | 'hidden': false, 9 | 'bodySelector': '.preview-body', 10 | 'hintSelector': '.preview-hint', 11 | 'indexSelector': '.preview-index', 12 | 'titleSelector': '[data-ssp-title]', 13 | 'urlSelector': '[data-ssp-url]', 14 | 'descriptionSelector': '[data-ssp-description]', 15 | 'keywordMarkClass': 'keyword-mark', 16 | 'counterClass': 'seo-serp-preview-counter', 17 | 'counterLimitClass': 'limit-exceeded', 18 | 'noIndexClass': 'no-index', 19 | 'titleLimit': 70, 20 | 'descriptionLimit': 320, 21 | 'keywordCharacterLength': 4, 22 | 'titleFormat': '##title##', 23 | 'entitiesMapper': { 24 | '[&]': '&', 25 | '[&]': '&', 26 | '[lt]': '<', 27 | '[gt]': '>', 28 | '[nbsp]': ' ', 29 | '[-]': '', 30 | } 31 | }, 32 | 33 | /** 34 | * Initialize the class 35 | * 36 | * @param {object} el 37 | * @param {object} engine 38 | * @param {object} options 39 | */ 40 | initialize: function (el, engine, options) { 41 | this.el = el; 42 | this.engine = engine; 43 | this.setOptions(options); 44 | 45 | // Collect the widget elements 46 | this.collectElements(); 47 | 48 | // Wait until the engine is ready 49 | this.engine.addEvent('ready', function () { 50 | // Add the description character counter 51 | this.addDescriptionCounter(); 52 | 53 | // Add the event listener 54 | this.engine.addEvent('change', this.refresh.bind(this)); 55 | 56 | // Refresh the preview state 57 | this.refresh(); 58 | }.bind(this)); 59 | 60 | this.engine.init(); 61 | }, 62 | 63 | /** 64 | * Add the description character counter to the fields 65 | */ 66 | addDescriptionCounter: function () { 67 | this.descriptionCounter = this.engine.addDescriptionCounter( 68 | new Element('span', {'class': this.options.counterClass}) 69 | ); 70 | }, 71 | 72 | /** 73 | * Update the description character counter 74 | * 75 | * @param {string} text 76 | */ 77 | updateDescriptionCounter: function (text) { 78 | this.descriptionCounter.set('text', '(' + text.length + '/' + this.options.descriptionLimit + ')'); 79 | 80 | if (text.length > this.options.descriptionLimit) { 81 | this.descriptionCounter.addClass(this.options.counterLimitClass); 82 | } else { 83 | this.descriptionCounter.removeClass(this.options.counterLimitClass); 84 | } 85 | }, 86 | 87 | /** 88 | * Refresh the preview state 89 | */ 90 | refresh: function () { 91 | if (this.options.hidden && this.engine.showElement && !this.engine.showElement()) { 92 | this.el.hide(); 93 | this.descriptionCounter.hide(); 94 | return; 95 | } 96 | 97 | var data = this.collectData(); 98 | 99 | this.updateDescriptionCounter(data.description); 100 | 101 | if (!this.validateData(data)) { 102 | this.hideBody(); 103 | return; 104 | } 105 | 106 | this.renderData(data); 107 | this.showBody(); 108 | 109 | // Show element if it was hidden 110 | if (this.options.hidden) { 111 | this.el.show(); 112 | this.descriptionCounter.show(); 113 | } 114 | }, 115 | 116 | /** 117 | * Collect the elements 118 | */ 119 | collectElements: function () { 120 | this.body = this.el.getElement(this.options.bodySelector); 121 | this.hint = this.el.getElement(this.options.hintSelector); 122 | this.index = this.el.getElement(this.options.indexSelector); 123 | this.title = this.el.getElement(this.options.titleSelector); 124 | this.url = this.el.getElement(this.options.urlSelector); 125 | this.description = this.el.getElement(this.options.descriptionSelector); 126 | }, 127 | 128 | /** 129 | * Collect the data 130 | * 131 | * @returns {object} 132 | */ 133 | collectData: function () { 134 | return data = { 135 | 'title': this.restoreEntities(this.engine.getTitle()), 136 | 'url': this.engine.getUrl(), 137 | 'description': this.restoreEntities(this.engine.getDescription()), 138 | 'index': this.engine.getIndex ? this.engine.getIndex() : true 139 | }; 140 | }, 141 | 142 | /** 143 | * Restore the entities 144 | * 145 | * @param {string} value 146 | * 147 | * @returns {string} 148 | */ 149 | restoreEntities: function (value) { 150 | for (var key in this.options.entitiesMapper) { 151 | // Create the RegExp from escaped string and replace the value 152 | // @see https://stackoverflow.com/a/3561711/3628692 153 | value = value.replace(new RegExp(key.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'), 'g'), this.options.entitiesMapper[key]); 154 | } 155 | 156 | return value; 157 | }, 158 | 159 | /** 160 | * Validate the data if it can be applied to the preview 161 | * 162 | * @param {object} data 163 | * 164 | * @returns {boolean} 165 | */ 166 | validateData: function (data) { 167 | var valid = false; 168 | 169 | for (var i in data) { 170 | if (data[i] != '') { 171 | valid = true; 172 | } 173 | } 174 | 175 | return valid; 176 | }, 177 | 178 | /** 179 | * Render the data to the preview 180 | * 181 | * @param {object} data 182 | */ 183 | renderData: function (data) { 184 | this.textElements = []; // initialize the text elements array 185 | 186 | this.setTitle(data.title); 187 | this.setUrl(data.url); 188 | this.setDescription(data.description); 189 | 190 | // Toggle the index message 191 | data.index ? this.hideIndex() : this.showIndex(); 192 | }, 193 | 194 | /** 195 | * Set the title 196 | * 197 | * @param {string} value 198 | */ 199 | setTitle: function (value) { 200 | // Strip the HTML tags 201 | var tmp = document.createElement('div'); 202 | tmp.innerHTML = value; 203 | value = tmp.textContent; 204 | 205 | value = this.options.titleFormat.replace('##title##', value); 206 | 207 | if (value.length > this.options.titleLimit) { 208 | value = value.substr(0, this.options.titleLimit) + '...'; 209 | } 210 | 211 | this.generateTextElements(value, this.title); 212 | }, 213 | 214 | /** 215 | * Set the URL 216 | * 217 | * @param {string} value 218 | */ 219 | setUrl: function (value) { 220 | this.generateTextElements(value, this.url); 221 | }, 222 | 223 | /** 224 | * Set the description 225 | * 226 | * @param {string} value 227 | */ 228 | setDescription: function (value) { 229 | // Strip the HTML tags 230 | var tmp = document.createElement('div'); 231 | tmp.innerHTML = value; 232 | value = tmp.textContent; 233 | 234 | if (value.length > this.options.descriptionLimit) { 235 | value = value.substr(0, this.options.descriptionLimit) + '...'; 236 | } 237 | 238 | this.generateTextElements(value, this.description); 239 | }, 240 | 241 | /** 242 | * Generate the text elements 243 | * 244 | * @param {string} text 245 | * @param {object} el 246 | */ 247 | generateTextElements: function (text, el) { 248 | var rgxp = new RegExp(/\,|\.|\;|\!|\?|\-|\s|\\|\//); 249 | var word = []; 250 | 251 | // Empty the description 252 | el.set('html', ''); 253 | 254 | // Generate the text with DOM elements 255 | for (var i = 0; i < text.length; i++) { 256 | if (rgxp.test(text[i])) { 257 | // Create the word 258 | if (word.length > 0) { 259 | this.createTextElement(word.join(''), el); 260 | 261 | // Reset the word 262 | word = []; 263 | } 264 | 265 | (new Element('span', {'text': text[i]})).inject(el, 'bottom'); 266 | 267 | continue; 268 | } 269 | 270 | word.push(text[i]); 271 | } 272 | 273 | // Create the last word if present 274 | if (word.length > 0) { 275 | this.createTextElement(word.join(''), el); 276 | } 277 | }, 278 | 279 | /** 280 | * Create the text element 281 | * 282 | * @param {string} text 283 | * @param {object} el 284 | */ 285 | createTextElement: function (text, el) { 286 | var self = this; 287 | var span = new Element('span', {'text': text}); 288 | 289 | span.addEvent('mouseenter', function () { 290 | self.markKeywords.call(self, this.get('text'), this); 291 | }); 292 | 293 | span.addEvent('mouseleave', this.unmarkKeywords.bind(this)); 294 | span.inject(el, 'bottom'); 295 | 296 | // Add as text element 297 | this.textElements.push(span); 298 | }, 299 | 300 | /** 301 | * Mark the keywords 302 | * 303 | * @param {string} text 304 | * @param {object} origin 305 | */ 306 | markKeywords: function (text, origin) { 307 | var i = 0; 308 | var els = []; 309 | 310 | // Lowercase the text 311 | text = text.toLowerCase(); 312 | 313 | // Create the multiple variations if the highlighted text is longer than the keyword character length 314 | if (text.length >= this.options.keywordCharacterLength) { 315 | var variations = []; 316 | 317 | // Generate variations 318 | for (i = 0; i <= (text.length - this.options.keywordCharacterLength); i++) { 319 | variations.push(text.substr(i, this.options.keywordCharacterLength)); 320 | } 321 | 322 | for (i = 0; i < this.textElements.length; i++) { 323 | var elText = this.textElements[i].get('text').toLowerCase(); 324 | 325 | // Do not look for variations in origin element 326 | if (origin && this.textElements[i] === origin) { 327 | els.push(origin); 328 | continue; 329 | } 330 | 331 | for (var j = 0; j < variations.length; j++) { 332 | // Highlight the word if it only contains the variation 333 | if (elText.indexOf(variations[j]) !== -1) { 334 | els.push(this.textElements[i]); 335 | } 336 | } 337 | } 338 | } else { 339 | // If the word is not longer than character limit do not create multiple variations 340 | for (i = 0; i < this.textElements.length; i++) { 341 | if (this.textElements[i].get('text').toLowerCase() === text) { 342 | els.push(this.textElements[i]); 343 | } 344 | } 345 | } 346 | 347 | // Highlight words if there is more than one element 348 | if (els.length > 1) { 349 | for (i = 0; i < els.length; i++) { 350 | els[i].addClass(this.options.keywordMarkClass); 351 | } 352 | } 353 | }, 354 | 355 | /** 356 | * Unmark the keywords 357 | */ 358 | unmarkKeywords: function () { 359 | for (var i = 0; i < this.textElements.length; i++) { 360 | this.textElements[i].removeClass(this.options.keywordMarkClass); 361 | } 362 | }, 363 | 364 | /** 365 | * Hide the body and show hint 366 | */ 367 | hideBody: function () { 368 | this.hint.show(); 369 | this.body.hide(); 370 | }, 371 | 372 | /** 373 | * Show the body and hide hint 374 | */ 375 | showBody: function () { 376 | this.hint.hide(); 377 | this.body.show(); 378 | }, 379 | 380 | /** 381 | * Hide the index message 382 | */ 383 | hideIndex: function () { 384 | this.index.hide(); 385 | this.el.removeClass(this.options.noIndexClass); 386 | }, 387 | 388 | /** 389 | * Show the index message 390 | */ 391 | showIndex: function () { 392 | this.index.show(); 393 | this.el.addClass(this.options.noIndexClass); 394 | } 395 | }); 396 | -------------------------------------------------------------------------------- /src/Derhaeuptling/SeoSerpPreview/TestsHandler/AbstractHandler.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | namespace Derhaeuptling\SeoSerpPreview\TestsHandler; 15 | 16 | use Contao\BackendTemplate; 17 | use Contao\Controller; 18 | use Contao\Database; 19 | use Contao\DataContainer; 20 | use Contao\Environment; 21 | use Contao\Input; 22 | use Contao\Session; 23 | use Contao\System; 24 | use Derhaeuptling\SeoSerpPreview\Test\Exception\ErrorException; 25 | use Derhaeuptling\SeoSerpPreview\Test\Exception\WarningException; 26 | use Derhaeuptling\SeoSerpPreview\Test\TestInterface; 27 | use Derhaeuptling\SeoSerpPreview\TestsManager; 28 | 29 | abstract class AbstractHandler 30 | { 31 | /** 32 | * Table name 33 | * @var string 34 | */ 35 | protected $table; 36 | 37 | /** 38 | * Data container 39 | * @var DataContainer 40 | */ 41 | protected $dc; 42 | 43 | /** 44 | * Session name 45 | * @var string 46 | */ 47 | protected $sessionName = 'seo_serp_tests'; 48 | 49 | /** 50 | * Filter name 51 | * @var string 52 | */ 53 | public static $filterName = 'seo_serp_tests'; 54 | 55 | /** 56 | * Parameter name 57 | * @var string 58 | */ 59 | public static $serpParamName = 'serp_tests'; 60 | 61 | /** 62 | * Parameter name (temporary activation) 63 | * @var string 64 | */ 65 | public static $serpTemporaryParamName = 'serp_tests_tmp'; 66 | 67 | /** 68 | * Initialize the tests handler 69 | * 70 | * @param DataContainer|null $dc 71 | */ 72 | public function initialize(DataContainer $dc = null) 73 | { 74 | if ($dc === null) { 75 | return; 76 | } 77 | 78 | $this->table = $this->getTableName(); 79 | $this->dc = $dc; 80 | 81 | // Enable or disable the analyzer 82 | if (isset($_GET[self::$serpParamName])) { 83 | Input::get(self::$serpParamName) ? static::enable() : static::disable(); 84 | Controller::redirect( 85 | str_replace( 86 | [ 87 | '&'.self::$serpParamName.'='.Input::get(self::$serpParamName), 88 | '&'.self::$serpTemporaryParamName.'='.Input::get(self::$serpTemporaryParamName), 89 | 90 | ], 91 | '', 92 | Environment::get('request') 93 | ) 94 | ); 95 | } 96 | 97 | $this->addGlobalOperations(); 98 | 99 | if ($this->isEnabled()) { 100 | $this->initializeEnabled(); 101 | } 102 | } 103 | 104 | /** 105 | * Enable the analyzer 106 | */ 107 | protected function enable() 108 | { 109 | Session::getInstance()->set($this->sessionName.'_'.$this->table, true); 110 | } 111 | 112 | /** 113 | * Disable the analyzer 114 | */ 115 | protected function disable() 116 | { 117 | Session::getInstance()->set($this->sessionName.'_'.$this->table, false); 118 | } 119 | 120 | /** 121 | * Initialize the tests handler if enabled 122 | */ 123 | protected function initializeEnabled() 124 | { 125 | $this->addMessageFilter(); 126 | $this->filterRecords(); 127 | $this->replaceLabelGenerator(); 128 | } 129 | 130 | /** 131 | * Get the table name 132 | * 133 | * @return string 134 | */ 135 | abstract protected function getTableName(); 136 | 137 | /** 138 | * Get the applicable record IDs 139 | * 140 | * @return array 141 | */ 142 | abstract protected function getRecordIds(); 143 | 144 | /** 145 | * Get the global operation 146 | * 147 | * @param bool $enabled 148 | * 149 | * @return array 150 | */ 151 | protected function getGlobalOperation($enabled) 152 | { 153 | return [ 154 | 'label' => &$GLOBALS['TL_LANG']['MSC'][$enabled ? 'seo_serp_tests.disable' : 'seo_serp_tests.enable'], 155 | 'href' => static::$serpParamName.'='.($enabled ? '0' : '1'), 156 | 'icon' => 'system/modules/seo_serp_preview/assets/icons/tests.svg', 157 | 'attributes' => 'onclick="Backend.getScrollOffset()"', 158 | ]; 159 | } 160 | 161 | /** 162 | * Return true if the tests are enabled 163 | * 164 | * @return bool 165 | */ 166 | protected function isEnabled() 167 | { 168 | if (Input::get(static::$serpTemporaryParamName)) { 169 | return true; 170 | } 171 | 172 | return Session::getInstance()->get($this->sessionName.'_'.$this->table) ? true : false; 173 | } 174 | 175 | /** 176 | * Add the message filter 177 | */ 178 | protected function addMessageFilter() 179 | { 180 | $GLOBALS['TL_DCA'][$this->table]['list']['sorting']['panelLayout'] = 'serp_message_filter,'.$GLOBALS['TL_DCA'][$this->table]['list']['sorting']['panelLayout']; 181 | $GLOBALS['TL_DCA'][$this->table]['list']['sorting']['panel_callback']['serp_message_filter'] = [ 182 | get_called_class(), 183 | 'generateMessageFilter', 184 | ]; 185 | } 186 | 187 | /** 188 | * Generate the message filter 189 | * 190 | * @return string 191 | */ 192 | public function generateMessageFilter() 193 | { 194 | $session = Session::getInstance()->getData(); 195 | 196 | // Set filter from user input 197 | if (Input::post('FORM_SUBMIT') === 'tl_filters') { 198 | if (Input::post(static::$filterName, true) !== 'tl_'.static::$filterName) { 199 | $session['filter'][$this->table][static::$filterName] = Input::post(static::$filterName, true); 200 | } else { 201 | unset($session['filter'][$this->table][static::$filterName]); 202 | } 203 | 204 | Session::getInstance()->setData($session); 205 | } 206 | 207 | $return = ' 208 | '.$GLOBALS['TL_LANG']['MSC']['seo_serp_tests.filter'][0].' 209 | 210 | '.$GLOBALS['TL_LANG']['MSC']['seo_serp_tests.filter'][1].' 211 | ---'; 212 | 213 | foreach (static::getAvailableFilters() as $option) { 214 | $selected = $option === $this->getActiveFilter(); 215 | $label = $GLOBALS['TL_LANG']['MSC']['seo_serp_tests.filterRef'][$option]; 216 | $return .= ''.$label.''; 217 | } 218 | 219 | return $return.''; 220 | } 221 | 222 | /** 223 | * Get the active filter 224 | * 225 | * @return string 226 | */ 227 | protected function getActiveFilter() 228 | { 229 | $session = Session::getInstance()->getData(); 230 | $filter = $session['filter'][$this->table][static::$filterName]; 231 | 232 | return in_array($filter, static::getAvailableFilters(), true) ? $filter : ''; 233 | } 234 | 235 | /** 236 | * Get the available filters 237 | * 238 | * @return array 239 | */ 240 | public static function getAvailableFilters() 241 | { 242 | return ['all', 'errors', 'warnings']; 243 | } 244 | 245 | /** 246 | * Filter the records by message type 247 | */ 248 | protected function filterRecords() 249 | { 250 | $filter = $this->getActiveFilter(); 251 | 252 | if (!$filter) { 253 | return; 254 | } 255 | 256 | $recordIds = array_filter($this->getRecordIds()); 257 | 258 | if (count($recordIds) === 0) { 259 | $recordIds = [0]; 260 | } 261 | 262 | $root = []; 263 | $records = Database::getInstance()->execute( 264 | "SELECT * FROM ".$this->table." WHERE id IN (".implode(',', $recordIds).")" 265 | ); 266 | 267 | while ($records->next()) { 268 | $data = $this->generateTests($records->row()); 269 | 270 | if ($filter === 'all') { 271 | // Add the record to the root if there is at least one message of any type 272 | foreach ($data as $messages) { 273 | if (count($messages) > 0) { 274 | $root[] = $records->id; 275 | } 276 | } 277 | } elseif (count($data[$filter]) > 0) { 278 | $root[] = $records->id; 279 | } 280 | } 281 | 282 | $GLOBALS['TL_DCA'][$this->table]['list']['sorting']['root'] = (count($root) === 0) ? [0] : $root; 283 | } 284 | 285 | /** 286 | * Limit the global operations 287 | */ 288 | protected function addGlobalOperations() 289 | { 290 | array_insert( 291 | $GLOBALS['TL_DCA'][$this->table]['list']['global_operations'], 292 | 0, 293 | [ 294 | 'serp_tests' => $this->getGlobalOperation($this->isEnabled()), 295 | ] 296 | ); 297 | } 298 | 299 | /** 300 | * Replace the default label generator 301 | */ 302 | protected function replaceLabelGenerator() 303 | { 304 | if ($GLOBALS['TL_DCA'][$this->table]['list']['sorting']['mode'] === 4) { 305 | $GLOBALS['TL_DCA'][$this->table]['list']['sorting']['default_child_record_callback'] = $GLOBALS['TL_DCA'][$this->table]['list']['sorting']['child_record_callback']; 306 | $GLOBALS['TL_DCA'][$this->table]['list']['sorting']['child_record_callback'] = [ 307 | get_called_class(), 308 | 'generateLabel', 309 | ]; 310 | } else { 311 | $GLOBALS['TL_DCA'][$this->table]['list']['label']['default_label_callback'] = $GLOBALS['TL_DCA'][$this->table]['list']['label']['label_callback']; 312 | $GLOBALS['TL_DCA'][$this->table]['list']['label']['label_callback'] = [ 313 | get_called_class(), 314 | 'generateLabel', 315 | ]; 316 | } 317 | } 318 | 319 | /** 320 | * Generate the label 321 | * 322 | * @param array $row 323 | * @param string $label 324 | * @param DataContainer $dc 325 | * @param string $imageAttribute 326 | * @param boolean $blnReturnImage 327 | * @param boolean $blnProtected 328 | * 329 | * @return string 330 | */ 331 | public function generateLabel( 332 | array $row, 333 | $label = null, 334 | DataContainer $dc = null, 335 | $imageAttribute = '', 336 | $blnReturnImage = false, 337 | $blnProtected = false 338 | ) { 339 | $default = ''; 340 | 341 | if ($GLOBALS['TL_DCA'][$this->table]['list']['sorting']['mode'] === 4) { 342 | $callback = $GLOBALS['TL_DCA'][$this->table]['list']['sorting']['default_child_record_callback']; 343 | } else { 344 | $callback = $GLOBALS['TL_DCA'][$this->table]['list']['label']['default_label_callback']; 345 | } 346 | 347 | // Get the default label 348 | if (is_array($callback)) { 349 | $default = System::importStatic($callback[0])->{$callback[1]}( 350 | $row, 351 | $label, 352 | $dc, 353 | $imageAttribute, 354 | $blnReturnImage, 355 | $blnProtected 356 | ); 357 | } elseif (is_callable($callback)) { 358 | $default = $callback($row, $label, $dc, $imageAttribute, $blnReturnImage, $blnProtected); 359 | } 360 | 361 | // Do not check records outside the scope 362 | if (!in_array($row['id'], $this->getRecordIds())) { 363 | return $default; 364 | } 365 | 366 | $template = new BackendTemplate('be_seo_serp_tests'); 367 | $template->setData($this->generateTests($row)); 368 | $template->label = $default; 369 | 370 | // Add assets 371 | $GLOBALS['TL_CSS'][] = 'system/modules/seo_serp_preview/assets/css/tests.min.css'; 372 | $GLOBALS['TL_JAVASCRIPT'][] = 'system/modules/seo_serp_preview/assets/js/tests.min.js'; 373 | 374 | return $template->parse(); 375 | } 376 | 377 | /** 378 | * Generate the tests for a record 379 | * 380 | * @param array $data 381 | * 382 | * @return array 383 | */ 384 | protected function generateTests(array $data) 385 | { 386 | System::loadLanguageFile('seo_serp_tests'); 387 | $result = ['errors' => [], 'warnings' => []]; 388 | 389 | /** @var TestInterface $test */ 390 | foreach (TestsManager::getAll() as $test) { 391 | // Skip the unsupported tests 392 | if (!$test->supports($this->table)) { 393 | continue; 394 | } 395 | 396 | try { 397 | $test->run($data, $this->table); 398 | } catch (ErrorException $e) { 399 | $result['errors'][] = $e->getMessage(); 400 | } catch (WarningException $e) { 401 | $result['warnings'][] = $e->getMessage(); 402 | } 403 | } 404 | 405 | return $result; 406 | } 407 | } 408 | -------------------------------------------------------------------------------- /src/Derhaeuptling/SeoSerpPreview/PreviewModule.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Codefog 10 | * @author Kamil Kuzminski 11 | * @license LGPL 12 | */ 13 | 14 | namespace Derhaeuptling\SeoSerpPreview; 15 | 16 | use Contao\Backend; 17 | use Contao\BackendModule; 18 | use Contao\BackendUser; 19 | use Contao\Controller; 20 | use Contao\Database; 21 | use Contao\Input; 22 | use Contao\Session; 23 | use Contao\System; 24 | use Database\Result; 25 | use Derhaeuptling\SeoSerpPreview\Test\Exception\ErrorException; 26 | use Derhaeuptling\SeoSerpPreview\Test\Exception\WarningException; 27 | use Derhaeuptling\SeoSerpPreview\Test\TestInterface; 28 | use Derhaeuptling\SeoSerpPreview\TestsHandler\AbstractHandler; 29 | 30 | class PreviewModule extends BackendModule 31 | { 32 | /** 33 | * Template 34 | * @var string 35 | */ 36 | protected $strTemplate = 'be_seo_serp_module'; 37 | 38 | /** 39 | * Tables to be analyzed 40 | * @var array 41 | */ 42 | protected $tables = ['tl_article', 'tl_calendar_events', 'tl_news', 'tl_page']; 43 | 44 | /** 45 | * Redirect URL param name 46 | * @var string 47 | */ 48 | protected $redirectParamName = 'seo_serp_redirect'; 49 | 50 | /** 51 | * Generate the module 52 | * 53 | * @throws \Exception 54 | */ 55 | protected function compile() 56 | { 57 | if (Input::get($this->redirectParamName)) { 58 | $this->redirectToModule(Input::get($this->redirectParamName, true)); 59 | } 60 | 61 | System::loadLanguageFile('seo_serp_module'); 62 | $this->Template->backHref = System::getReferer(true); 63 | $modules = $this->getModules(); 64 | 65 | if (count($modules) > 0) { 66 | $GLOBALS['TL_CSS'][] = 'system/modules/seo_serp_preview/assets/css/module.min.css'; 67 | $GLOBALS['TL_CSS'][] = 'system/modules/seo_serp_preview/assets/css/tests.min.css'; 68 | 69 | $this->Template->modules = $this->generateModules($modules); 70 | } 71 | 72 | // Rebuild the cache in status manager 73 | $statusManager = new StatusManager(); 74 | $statusManager->rebuildCache(); 75 | } 76 | 77 | /** 78 | * Redirect the user to given module 79 | * 80 | * @param string $target 81 | */ 82 | protected function redirectToModule($target) 83 | { 84 | list ($module, $params) = trimsplit('|', $target); 85 | $modules = $this->getModules(); 86 | 87 | if (!isset($modules[$module])) { 88 | return; 89 | } 90 | 91 | $session = Session::getInstance()->getData(); 92 | 93 | // Set the filters of all module tables to show all message types 94 | foreach ($modules[$module]['tables'] as $table) { 95 | $session['filter'][$table][AbstractHandler::$filterName] = AbstractHandler::getAvailableFilters()[0]; 96 | } 97 | 98 | // Decode the params 99 | if ($params) { 100 | $params = '&'.base64_decode($params); 101 | } 102 | 103 | Session::getInstance()->setData($session); 104 | Controller::redirect('contao/main.php?do='.$module.$params.'&'.AbstractHandler::$serpTemporaryParamName.'=1'); 105 | } 106 | 107 | /** 108 | * Generate the modules 109 | * 110 | * @param array $modules 111 | * 112 | * @return array 113 | */ 114 | protected function generateModules(array $modules) 115 | { 116 | $return = []; 117 | 118 | foreach ($modules as $name => $module) { 119 | $tests = []; 120 | 121 | // Run the tests 122 | foreach ($module['tables'] as $table) { 123 | $tests = array_merge($tests, $this->runTestsForTable($name, $table)); 124 | } 125 | 126 | // Skip modules without tests 127 | if (count($tests) < 1) { 128 | continue; 129 | } 130 | 131 | $success = true; 132 | 133 | // Check if there is a test that failed 134 | foreach ($tests as $test) { 135 | if ($test['result']['errors'] > 0 || $test['result']['warnings'] > 0) { 136 | $success = false; 137 | break; 138 | } 139 | } 140 | 141 | $return[] = [ 142 | 'name' => $name, 143 | 'label' => $GLOBALS['TL_LANG']['MOD'][$name][0], 144 | 'url' => Backend::addToUrl($this->redirectParamName.'='.$name), 145 | 'icon' => $module['icon'], 146 | 'hasAccess' => $this->checkModulePermission($name), 147 | 'tests' => $tests, 148 | 'success' => $success, 149 | ]; 150 | } 151 | 152 | return $return; 153 | } 154 | 155 | /** 156 | * Run the tests for given table 157 | * 158 | * @param string $module 159 | * @param string $table 160 | * 161 | * @return array 162 | */ 163 | protected function runTestsForTable($module, $table) 164 | { 165 | $db = Database::getInstance(); 166 | $user = BackendUser::getInstance(); 167 | $return = []; 168 | 169 | switch ($table) { 170 | case 'tl_article': 171 | $test = $this->runTests( 172 | $table, 173 | $db->execute("SELECT * FROM tl_article WHERE showTeaser!='' ORDER BY title")->fetchAllAssoc() 174 | ); 175 | 176 | $return[] = [ 177 | 'url' => Backend::addToUrl($this->redirectParamName.'='.$module), 178 | 'message' => $this->generateTestMessage($test), 179 | 'result' => $test, 180 | 'hasAccess' => true, 181 | ]; 182 | break; 183 | 184 | case 'tl_calendar_events': 185 | $calendars = $db->execute("SELECT id, title FROM tl_calendar WHERE seo_serp_ignore='' ORDER BY title"); 186 | 187 | while ($calendars->next()) { 188 | $test = $this->runTests( 189 | $table, 190 | $db->prepare("SELECT * FROM tl_calendar_events WHERE pid=?") 191 | ->execute($calendars->id) 192 | ->fetchAllAssoc() 193 | ); 194 | 195 | $return[] = [ 196 | 'url' => Backend::addToUrl( 197 | $this->redirectParamName.'='.$module.'|'.base64_encode( 198 | 'table='.$table.'&id='.$calendars->id 199 | ) 200 | ), 201 | 'reference' => $calendars->title, 202 | 'message' => $this->generateTestMessage($test), 203 | 'result' => $test, 204 | 'hasAccess' => $user->isAdmin || (is_array($user->calendars) && in_array( 205 | $calendars->id, 206 | $user->calendars 207 | )), 208 | ]; 209 | } 210 | break; 211 | 212 | case 'tl_news': 213 | $archives = $db->execute( 214 | "SELECT id, title FROM tl_news_archive WHERE seo_serp_ignore='' ORDER BY title" 215 | ); 216 | 217 | while ($archives->next()) { 218 | $test = $this->runTests( 219 | $table, 220 | $db->prepare("SELECT * FROM tl_news WHERE pid=?")->execute($archives->id)->fetchAllAssoc() 221 | ); 222 | 223 | $return[] = [ 224 | 'url' => Backend::addToUrl( 225 | $this->redirectParamName.'='.$module.'|'.base64_encode( 226 | 'table='.$table.'&id='.$archives->id 227 | ) 228 | ), 229 | 'reference' => $archives->title, 230 | 'message' => $this->generateTestMessage($test), 231 | 'result' => $test, 232 | 'hasAccess' => $user->isAdmin || (is_array($user->news) && in_array( 233 | $archives->id, 234 | $user->news 235 | )), 236 | ]; 237 | } 238 | break; 239 | 240 | case 'tl_page': 241 | $rootIds = $db->execute("SELECT id FROM tl_page WHERE type='root' AND seo_serp_ignore=''"); 242 | $pageIds = $db->getChildRecords($rootIds->fetchEach('id'), 'tl_page'); 243 | 244 | if (count($pageIds) > 0) { 245 | $pages = $db->execute("SELECT * FROM tl_page WHERE id IN (".implode(',', $pageIds).")"); 246 | 247 | if ($pages->numRows) { 248 | $notes = []; 249 | $test = $this->runTests($table, $pages->fetchAllAssoc()); 250 | 251 | // Add the note if the user is not admin and there are some errors or warnings 252 | if (!$user->isAdmin && ($test['errors'] > 0 || $test['warnings'] > 0)) { 253 | $rootCount = 0; 254 | $userCount = 0; 255 | 256 | // Count the total root pages and those the user has access to 257 | while ($pages->next()) { 258 | if ($pages->type === 'root') { 259 | $rootCount++; 260 | 261 | if (in_array($pages->id, (array)$user->pagemounts)) { 262 | $userCount++; 263 | } 264 | } 265 | } 266 | 267 | if ($userCount < $rootCount) { 268 | $notes[] = $GLOBALS['TL_LANG']['MSC']['seo_serp_module.pagesNote']; 269 | } 270 | } 271 | 272 | $return[] = [ 273 | 'url' => Backend::addToUrl($this->redirectParamName.'='.$module), 274 | 'message' => $this->generateTestMessage($test), 275 | 'result' => $test, 276 | 'hasAccess' => true, 277 | 'notes' => $notes, 278 | ]; 279 | } 280 | } 281 | break; 282 | } 283 | 284 | return $return; 285 | } 286 | 287 | /** 288 | * Generate the test message 289 | * 290 | * @param array $test 291 | * 292 | * @return string 293 | */ 294 | protected function generateTestMessage(array $test) 295 | { 296 | $message = ''; 297 | $errors = $test['errors']; 298 | $warnings = $test['warnings']; 299 | 300 | // No errors, no warnings 301 | if ($errors === 0 && $warnings === 0) { 302 | $message = $GLOBALS['TL_LANG']['MSC']['seo_serp_module.clear']; 303 | } 304 | 305 | // There are errors 306 | if ($errors > 0) { 307 | $message = sprintf( 308 | '%s %s', 309 | ($errors === 1) ? $GLOBALS['TL_LANG']['MSC']['seo_serp_module.single'] : $GLOBALS['TL_LANG']['MSC']['seo_serp_module.multiple'], 310 | sprintf( 311 | ($errors === 1) ? $GLOBALS['TL_LANG']['MSC']['seo_serp_module.error'] : $GLOBALS['TL_LANG']['MSC']['seo_serp_module.errors'], 312 | $errors 313 | ) 314 | ); 315 | } 316 | 317 | // There are warnings 318 | if ($warnings > 0) { 319 | $chunk = sprintf( 320 | ($warnings === 1) ? $GLOBALS['TL_LANG']['MSC']['seo_serp_module.warning'] : $GLOBALS['TL_LANG']['MSC']['seo_serp_module.warnings'], 321 | $warnings 322 | ); 323 | 324 | // Append text to the existing message 325 | if (strlen($message) > 0) { 326 | $message = sprintf( 327 | '%s %s %s', 328 | $message, 329 | $GLOBALS['TL_LANG']['MSC']['seo_serp_module.and'], 330 | $chunk 331 | ); 332 | } else { 333 | $message = sprintf( 334 | '%s %s', 335 | ($warnings === 1) ? $GLOBALS['TL_LANG']['MSC']['seo_serp_module.single'] : $GLOBALS['TL_LANG']['MSC']['seo_serp_module.multiple'], 336 | $chunk 337 | ); 338 | } 339 | } 340 | 341 | return $message.'.'; 342 | } 343 | 344 | /** 345 | * Run the tests on a table 346 | * 347 | * @param string $table 348 | * @param array $records 349 | * 350 | * @return array 351 | */ 352 | protected function runTests($table, array $records) 353 | { 354 | System::loadLanguageFile('seo_serp_tests'); 355 | $result = ['errors' => 0, 'warnings' => 0]; 356 | 357 | /** @var TestInterface $test */ 358 | foreach (TestsManager::getAll() as $test) { 359 | // Skip the unsupported tests 360 | if (!$test->supports($table)) { 361 | continue; 362 | } 363 | 364 | foreach ($records as $record) { 365 | try { 366 | $test->run($record, $table); 367 | } catch (ErrorException $e) { 368 | $result['errors']++; 369 | } catch (WarningException $e) { 370 | $result['warnings']++; 371 | } 372 | } 373 | } 374 | 375 | return $result; 376 | } 377 | 378 | /** 379 | * Get the modules 380 | * 381 | * @return array 382 | */ 383 | protected function getModules() 384 | { 385 | $return = []; 386 | 387 | foreach ($GLOBALS['BE_MOD'] as $group => $modules) { 388 | foreach ($modules as $name => $module) { 389 | // Skip modules without tables 390 | if (!is_array($module['tables'])) { 391 | continue; 392 | } 393 | 394 | foreach ($module['tables'] as $table) { 395 | if (in_array($table, $this->tables, true)) { 396 | $return[$name]['icon'] = $module['icon'] ?: null; 397 | $return[$name]['tables'][] = $table; 398 | } 399 | } 400 | } 401 | } 402 | 403 | return $return; 404 | } 405 | 406 | /** 407 | * Check the module permission 408 | * 409 | * @param string $module 410 | * 411 | * @return bool 412 | */ 413 | protected function checkModulePermission($module) 414 | { 415 | $user = BackendUser::getInstance(); 416 | 417 | if ($user->isAdmin) { 418 | return true; 419 | } 420 | 421 | return $user->hasAccess($module, 'modules'); 422 | } 423 | } 424 | --------------------------------------------------------------------------------
20 | = $module['tests'][0]['message'] ?> 21 |
= $GLOBALS['TL_LANG']['MSC']['seo_serp_module.noModules'] ?>