├── .github └── workflows │ └── ci.yml ├── LICENSE ├── README.md ├── composer.json ├── docs ├── Contributing.md ├── Install.md ├── MetaHelper.md └── README.md ├── phpcs.xml ├── phpstan.neon ├── src └── View │ └── Helper │ └── MetaHelper.php └── tests ├── TestCase └── View │ └── Helper │ └── MetaHelperTest.php ├── bootstrap.php └── config └── routes.php /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | 8 | jobs: 9 | testsuite: 10 | runs-on: ubuntu-22.04 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | php-version: ['8.1', '8.4'] 15 | prefer-lowest: [''] 16 | include: 17 | - php-version: '8.1' 18 | prefer-lowest: 'prefer-lowest' 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | 23 | - name: Setup PHP 24 | uses: shivammathur/setup-php@v2 25 | with: 26 | php-version: ${{ matrix.php-version }} 27 | extensions: mbstring, intl 28 | coverage: pcov 29 | 30 | - name: Get composer cache directory 31 | id: composercache 32 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 33 | 34 | - name: Cache dependencies 35 | uses: actions/cache@v4 36 | with: 37 | path: ${{ steps.composercache.outputs.dir }} 38 | key: ${{ runner.os }}-composer-${{ steps.key-date.outputs.date }}-${{ hashFiles('composer.json') }}-${{ matrix.prefer-lowest }} 39 | 40 | - name: Composer install 41 | run: | 42 | composer --version 43 | if ${{ matrix.prefer-lowest == 'prefer-lowest' }} 44 | then 45 | composer update --prefer-lowest --prefer-stable 46 | composer require --dev dereuromark/composer-prefer-lowest:dev-master 47 | else 48 | composer install --no-progress --prefer-dist --optimize-autoloader 49 | fi 50 | - name: Run PHPUnit 51 | run: | 52 | if [[ ${{ matrix.php-version }} == '8.1' ]] 53 | then 54 | vendor/bin/phpunit --coverage-clover=coverage.xml 55 | else 56 | vendor/bin/phpunit 57 | fi 58 | - name: Validate prefer-lowest 59 | if: matrix.prefer-lowest == 'prefer-lowest' 60 | run: vendor/bin/validate-prefer-lowest -m 61 | 62 | - name: Upload coverage reports to Codecov 63 | if: success() && matrix.php-version == '8.1' 64 | uses: codecov/codecov-action@v4 65 | with: 66 | token: ${{ secrets.CODECOV_TOKEN }} 67 | 68 | validation: 69 | name: Coding Standard & Static Analysis 70 | runs-on: ubuntu-22.04 71 | 72 | steps: 73 | - uses: actions/checkout@v4 74 | 75 | - name: Setup PHP 76 | uses: shivammathur/setup-php@v2 77 | with: 78 | php-version: '8.1' 79 | extensions: mbstring, intl 80 | coverage: none 81 | 82 | - name: Get composer cache directory 83 | id: composercache 84 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 85 | 86 | - name: Cache dependencies 87 | uses: actions/cache@v4 88 | with: 89 | path: ${{ steps.composercache.outputs.dir }} 90 | key: ${{ runner.os }}-composer-${{ steps.key-date.outputs.date }}-${{ hashFiles('composer.json') }}-${{ matrix.prefer-lowest }} 91 | 92 | - name: Composer Install 93 | run: composer stan-setup 94 | 95 | - name: Run phpstan 96 | run: composer stan 97 | 98 | - name: Run phpcs 99 | run: composer cs-check 100 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Mark Scherer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Meta plugin for CakePHP 2 | [![CI](https://github.com/dereuromark/cakephp-meta/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/dereuromark/cakephp-meta/actions/workflows/ci.yml?query=branch%3Amaster) 3 | [![Coverage Status](https://coveralls.io/repos/dereuromark/cakephp-meta/badge.svg)](https://coveralls.io/r/dereuromark/cakephp-meta) 4 | [![License](https://poser.pugx.org/dereuromark/cakephp-meta/license.svg)](LICENSE) 5 | [![Minimum PHP Version](https://img.shields.io/badge/php-%3E%3D%208.1-8892BF.svg)](https://php.net/) 6 | [![Latest Stable Version](https://poser.pugx.org/dereuromark/cakephp-meta/v/stable.svg)](https://packagist.org/packages/dereuromark/cakephp-meta) 7 | [![Coding Standards](https://img.shields.io/badge/cs-PSR--2--R-yellow.svg)](https://github.com/php-fig-rectified/fig-rectified-standards) 8 | 9 | This branch is for **CakePHP 5.1+**. For details see [version map](https://github.com/dereuromark/cakephp-meta/wiki#cakephp-version-map). 10 | 11 | ## What is this plugin for? 12 | This plugin helps to maintain and output meta tags for your HTML pages, including SEO relevant parts like 13 | "title", "keywords", "description", "robots" and "canonical". 14 | 15 | It can be used as a simple view-only approach using the included helper, it can also be DB driven if desired, or dynamically 16 | be created from the controller context by passing the meta data to the view. 17 | 18 | ## Installation and Usage 19 | Please see [Docs](docs) 20 | 21 | ## ToDos 22 | 23 | ### DB driven approach 24 | Adding a Meta component and Metas Table we can pull data from an admin backend inserted DB table. 25 | Those could overwrite then any defaults set via actions or ctp templates. 26 | 27 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dereuromark/cakephp-meta", 3 | "description": "Meta plugin for CakePHP", 4 | "license": "MIT", 5 | "type": "cakephp-plugin", 6 | "keywords": [ 7 | "cakephp", 8 | "plugin", 9 | "view", 10 | "SEO", 11 | "meta", 12 | "canonical" 13 | ], 14 | "homepage": "https://github.com/dereuromark/cakephp-meta", 15 | "require": { 16 | "php": ">=8.1", 17 | "cakephp/cakephp": "^5.1.1" 18 | }, 19 | "require-dev": { 20 | "fig-r/psr2r-sniffer": "dev-master", 21 | "phpunit/phpunit": "^10.5 || ^11.5 || ^12.1" 22 | }, 23 | "autoload": { 24 | "psr-4": { 25 | "Meta\\": "src/" 26 | } 27 | }, 28 | "autoload-dev": { 29 | "psr-4": { 30 | "Cake\\Test\\": "vendor/cakephp/cakephp/tests/", 31 | "Meta\\Test\\": "tests/" 32 | } 33 | }, 34 | "config": { 35 | "allow-plugins": { 36 | "dealerdirect/phpcodesniffer-composer-installer": true 37 | } 38 | }, 39 | "scripts": { 40 | "cs-check": "phpcs --extensions=php", 41 | "cs-fix": "phpcbf --extensions=php", 42 | "lowest": "validate-prefer-lowest", 43 | "lowest-setup": "composer update --prefer-lowest --prefer-stable --prefer-dist --no-interaction && cp composer.json composer.backup && composer require --dev dereuromark/composer-prefer-lowest && mv composer.backup composer.json", 44 | "stan": "phpstan analyse", 45 | "stan-setup": "cp composer.json composer.backup && composer require --dev phpstan/phpstan:^2.0.0 && mv composer.backup composer.json", 46 | "test": "phpunit", 47 | "test-coverage": "phpunit --log-junit tmp/coverage/unitreport.xml --coverage-html tmp/coverage --coverage-clover tmp/coverage/coverage.xml" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /docs/Contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Getting Started 4 | 5 | * Make sure you have a [GitHub account](https://github.com/signup/free) 6 | * Fork the repository on GitHub. 7 | 8 | ## Making Changes 9 | 10 | I am looking forward to your contributions. There are several ways to help out: 11 | * Write missing testcases 12 | * Write patches for bugs/features, preferably with testcases included 13 | 14 | There are a few guidelines that I need contributors to follow: 15 | * Coding standards (see link below) 16 | * Passing tests (you can enable travis to assert your changes pass) for Windows and Unix 17 | 18 | Protip: Use my [MyCakePHP](https://github.com/dereuromark/cakephp-codesniffer/tree/master/Vendor/PHP/CodeSniffer/Standards/MyCakePHP) sniffs to 19 | assert coding standards are met. You can either use this pre-build repo and the convenience shell command `cake CodeSniffer.CodeSniffer run -p Tools --standard=MyCakePHP` or the manual `phpcs --standard=MyCakePHP /path/to/Tools`. 20 | 21 | # Additional Resources 22 | 23 | * [Coding standards guide (extending/overwriting the CakePHP ones)](https://github.com/php-fig-rectified/fig-rectified-standards/) 24 | * [CakePHP coding standards](https://book.cakephp.org/3.0/en/contributing/cakephp-coding-conventions.html) 25 | * [General GitHub documentation](https://help.github.com/) 26 | * [GitHub pull request documentation](https://help.github.com/send-pull-requests/) 27 | -------------------------------------------------------------------------------- /docs/Install.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | You can install this plugin into your CakePHP application using [composer](https://getcomposer.org). 4 | The recommended way to install composer packages is: 5 | 6 | ``` 7 | composer require dereuromark/cakephp-meta:dev-master 8 | ``` 9 | Details @ https://packagist.org/packages/dereuromark/cakephp-meta 10 | 11 | This will load the plugin (within your boostrap file): 12 | ```php 13 | Plugin::load('Meta'); 14 | ``` 15 | or 16 | ```php 17 | Plugin::loadAll(...); 18 | ``` 19 | -------------------------------------------------------------------------------- /docs/MetaHelper.md: -------------------------------------------------------------------------------- 1 | # Meta Helper 2 | 3 | ## Enabling 4 | You can enable the helper in your AppView class: 5 | ```php 6 | $this->loadHelper('Meta.Meta'); 7 | 8 | // or setting different defaults 9 | $this->loadHelper('Meta.Meta', ['robots' => ['index' => true, 'follow' => true]]); 10 | ``` 11 | 12 | ## Configs 13 | 14 | - 'title' => null, 15 | - 'charset' => null, 16 | - 'icon' => null, 17 | - 'canonical' => null, // Set to true for auto-detect 18 | - 'language' => null, // Set to true for auto-detect 19 | - 'robots' => ['index' => false, 'follow' => false, 'archive' => false] 20 | 21 | and a few more. 22 | 23 | You can define your defaults in various places, the lowest is the Configure level in your app.php: 24 | ```php 25 | $config = [ 26 | 'Meta' => [ 27 | 'language' => 'de', 28 | 'robots' => ['index' => true, 'follow' => true] 29 | ] 30 | ]; 31 | ``` 32 | 33 | You can pass them to the loadHelper() method as shown above. 34 | 35 | If you need to customize them per controller or per action you can pass them from the controller to the view or modify them in the view template. 36 | 37 | In your controller, you could do the following: 38 | ```php 39 | $_meta = [ 40 | 'title' => 'Foo Bar', 41 | 'robots' => ['index' => false] 42 | ]; 43 | $this->set(compact('_meta'))); 44 | ``` 45 | 46 | In your view ctp you can also do: 47 | ```php 48 | $this->Meta->setKeywords('I, am, English', 'en'); 49 | $this->Meta->setKeywords('Ich, bin, deutsch', 'de'); 50 | $this->Meta->setDescription('Foo Bar'); 51 | $this->Meta->setRobots(['index' => false]); 52 | ``` 53 | All this data will be collected it inside the helper across teh whole request. 54 | Those calls can be best made in a view or element (because those are rendered before the layout). 55 | If you do it inside a layout make sure this happens before you call `out()`. 56 | 57 | ## Output 58 | Remove all your meta output in the layout and replace it with 59 | ```php 60 | echo $this->Meta->out(); // This contains all the tags 61 | echo $this->fetch('meta'); // This is a fallback (optional) for view blocks 62 | ``` 63 | It will iterate over all defined meta tags and output them. 64 | Note that you can skip some of those, if you want using the `skip` option. 65 | 66 | If you don't manually output them, you must define all tags prior to the `out()` call. 67 | The `out()` call should be the last PHP code in your `` section the layout HTML. 68 | 69 | You can also manually output each group of meta tags, e.g. all keywords and descriptions (which you defined before) in all languages using 70 | ```php 71 | echo $this->Meta->getKeywords(); 72 | echo $this->Meta->getDescription(); 73 | ``` 74 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # CakePHP Meta Plugin Documentation 2 | 3 | ## Installation 4 | * [Installation](Install.md) 5 | 6 | ## Documentation 7 | * [MetaHelper](MetaHelper.md) 8 | 9 | ## Contributing 10 | Your help is greatly appreciated. 11 | 12 | Make sure tests pass: `composer test` 13 | 14 | Make sure CS pass: `composer cs-check` and `composer cs-fix` to auto-fix 15 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | src/ 7 | tests/ 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 8 3 | paths: 4 | - src/ 5 | bootstrapFiles: 6 | - %rootDir%/../../../tests/bootstrap.php 7 | ignoreErrors: 8 | - identifier: missingType.iterableValue 9 | -------------------------------------------------------------------------------- /src/View/Helper/MetaHelper.php: -------------------------------------------------------------------------------- 1 | 30 | */ 31 | protected array $_defaultConfig = [ 32 | 'multiLanguage' => true, // Disable to only display the localized tag to the current language 33 | ]; 34 | 35 | /** 36 | * Meta headers for the response 37 | * 38 | * @var array 39 | */ 40 | protected array $meta = [ 41 | 'title' => null, 42 | 'charset' => null, 43 | 'icon' => null, 44 | 'canonical' => null, // Set to true for auto-detect 45 | 'language' => null, // Set to true for auto-detect 46 | 'robots' => ['index' => false, 'follow' => false, 'archive' => false], 47 | 'description' => null, 48 | ]; 49 | 50 | /** 51 | * Class Constructor 52 | * 53 | * Merges defaults with 54 | * - Configure::read(Meta) 55 | * - Helper options 56 | * - viewVars _meta 57 | * in that order (the latter trumps) 58 | * 59 | * @param \Cake\View\View $View 60 | * @param array $options 61 | */ 62 | public function __construct(View $View, array $options = []) { 63 | parent::__construct($View, $options); 64 | 65 | $configureMeta = (array)Configure::read('Meta'); 66 | if (Configure::read('Meta.robots') && is_array(Configure::read('Meta.robots'))) { 67 | $configureMeta['robots'] = Hash::merge($this->meta['robots'], Configure::read('Meta.robots')); 68 | } 69 | $this->meta = $configureMeta + $this->meta; 70 | 71 | if (!empty($options['robots']) && is_array($options['robots'])) { 72 | $options['robots'] = Hash::merge($this->meta['robots'], $options['robots']); 73 | } 74 | 75 | unset($options['className']); 76 | $this->meta = $options + $this->meta; 77 | 78 | $viewVarsMeta = (array)$this->getView()->get('_meta'); 79 | if ($viewVarsMeta) { 80 | if (!empty($viewVarsMeta['robots']) && is_array($viewVarsMeta['robots'])) { 81 | $viewVarsMeta['robots'] = Hash::merge($this->meta['robots'], $viewVarsMeta['robots']); 82 | } 83 | $this->meta = $viewVarsMeta + $this->meta; 84 | } 85 | 86 | if ($this->meta['charset'] === null) { 87 | // By default include this 88 | $this->meta['charset'] = true; 89 | } 90 | 91 | if ($this->meta['icon'] === null) { 92 | // By default include this 93 | $this->meta['icon'] = true; 94 | } 95 | 96 | if ($this->meta['title'] === null) { 97 | $controller = $this->getView()->getRequest()->getParam('controller'); 98 | $action = $this->getView()->getRequest()->getParam('action'); 99 | if ($controller && $action) { 100 | $controllerName = Inflector::humanize(Inflector::underscore($controller)); 101 | $actionName = Inflector::humanize(Inflector::underscore($action)); 102 | $this->meta['title'] = __($controllerName) . ' - ' . __($actionName); 103 | } 104 | } 105 | } 106 | 107 | /** 108 | * Guesses language from system defaults. 109 | * 110 | * Autoformats de_DE to de-DE. 111 | * 112 | * @return string|null 113 | */ 114 | protected function _guessLanguage(): ?string { 115 | $locale = ini_get('intl.default_locale'); 116 | if (!$locale) { 117 | return null; 118 | } 119 | 120 | if (strpos($locale, '_') !== false) { 121 | $locale = str_replace('_', '-', $locale); 122 | } 123 | 124 | return $locale; 125 | } 126 | 127 | /** 128 | * @param string $value 129 | * @return void 130 | */ 131 | public function setTitle(string $value): void { 132 | $this->meta['title'] = $value; 133 | } 134 | 135 | /** 136 | * @return string 137 | */ 138 | public function getTitle(): string { 139 | $value = $this->meta['title']; 140 | if ($value === false) { 141 | return ''; 142 | } 143 | 144 | return $this->Html->tag('title', $value); 145 | } 146 | 147 | /** 148 | * @param string $value 149 | * @return void 150 | */ 151 | public function setCharset(string $value): void { 152 | $this->meta['charset'] = $value; 153 | } 154 | 155 | /** 156 | * @return string 157 | */ 158 | public function getCharset(): string { 159 | $value = $this->meta['charset']; 160 | if ($value === false) { 161 | return ''; 162 | } 163 | if ($value === true) { 164 | $value = null; 165 | } 166 | 167 | return $this->Html->charset($value); 168 | } 169 | 170 | /** 171 | * @param string $value 172 | * @return void 173 | */ 174 | public function setIcon(string $value): void { 175 | $this->meta['icon'] = $value; 176 | } 177 | 178 | /** 179 | * @return string 180 | */ 181 | public function getIcon(): string { 182 | $value = $this->meta['icon']; 183 | if ($value === false) { 184 | return ''; 185 | } 186 | if ($value === true) { 187 | $value = null; 188 | } 189 | 190 | return (string)$this->Html->meta('icon', $value); 191 | } 192 | 193 | /** 194 | * @param string $url 195 | * @param int $size 196 | * @param array $options 197 | * @return void 198 | */ 199 | public function setSizesIcon(string $url, int $size, array $options = []): void { 200 | $options += [ 201 | 'size' => $size, 202 | 'prefix' => null, 203 | ]; 204 | $this->meta['sizesIcon'][$url] = $options; 205 | } 206 | 207 | /** 208 | * @param string $url 209 | * 210 | * @return string 211 | */ 212 | public function getSizesIcon(string $url): string { 213 | /** @var array $value */ 214 | $value = $this->meta['sizesIcon'][$url]; 215 | 216 | $options = [ 217 | 'rel' => $value['prefix'] . 'icon', 218 | 'sizes' => $value['size'] . 'x' . $value['size'], 219 | ] + $value; 220 | $array = [ 221 | 'url' => $url, 222 | 'attrs' => $this->Html->templater()->formatAttributes($options, ['prefix', 'size']), 223 | ]; 224 | 225 | return $this->Html->templater()->format('metalink', $array); 226 | } 227 | 228 | /** 229 | * @return string 230 | */ 231 | public function getSizesIcons(): string { 232 | /** @var array> $sizesIcons */ 233 | $sizesIcons = $this->meta['sizesIcon'] ?? []; 234 | 235 | $icons = []; 236 | foreach ($sizesIcons as $url => $options) { 237 | $icons[] = $this->getSizesIcon($url); 238 | } 239 | 240 | return implode(PHP_EOL, $icons); 241 | } 242 | 243 | /** 244 | * @param string|null $value 245 | * @return void 246 | */ 247 | public function setLanguage(string|null $value): void { 248 | if ($value === null) { 249 | $value = true; 250 | } 251 | 252 | $this->meta['language'] = $value; 253 | } 254 | 255 | /** 256 | * @return string 257 | */ 258 | public function getLanguage(): string { 259 | $value = $this->meta['language']; 260 | if (!$value) { 261 | return ''; 262 | } 263 | 264 | if ($value === true) { 265 | $value = $this->_guessLanguage(); 266 | } 267 | 268 | $array = [ 269 | 'http-equiv' => 'language', 270 | 'content' => $value, 271 | ]; 272 | 273 | return (string)$this->Html->meta($array); 274 | } 275 | 276 | /** 277 | * @param array|string|false $value 278 | * @return void 279 | */ 280 | public function setRobots(array|string|false $value): void { 281 | if (is_array($value)) { 282 | $defaults = $this->meta['robots']; 283 | $value += $defaults; 284 | } 285 | $this->meta['robots'] = $value; 286 | } 287 | 288 | /** 289 | * @return string 290 | */ 291 | public function getRobots(): string { 292 | $robots = $this->meta['robots']; 293 | if ($robots === false) { 294 | return ''; 295 | } 296 | 297 | if (is_array($robots)) { 298 | foreach ($robots as $robot => $use) { 299 | $robots[$robot] = $use ? $robot : 'no' . $robot; 300 | } 301 | $robots = implode(',', $robots); 302 | } 303 | 304 | return (string)$this->Html->meta('robots', $robots); 305 | } 306 | 307 | /** 308 | * @param string $value 309 | * @param string|null $lang 310 | * @return void 311 | */ 312 | public function setDescription(string $value, ?string $lang = null): void { 313 | if ($lang && $this->meta['language'] && $lang !== $this->meta['language'] && !$this->getConfig('multiLanguage')) { 314 | throw new RuntimeException('Not configured as multi-language'); 315 | } 316 | 317 | if ($lang === null) { 318 | $lang = $this->meta['language'] ?: '*'; 319 | } 320 | 321 | $this->meta['description'][$lang] = $value; 322 | } 323 | 324 | /** 325 | * @param string|null $lang 326 | * @return string 327 | */ 328 | public function getDescription(?string $lang = null): string { 329 | if (!is_array($this->meta['description'])) { 330 | if ($lang === null) { 331 | $lang = $this->meta['language'] ?: '*'; 332 | } 333 | $this->meta['description'] = [$lang => $this->meta['description']]; 334 | } 335 | 336 | if ($lang === null) { 337 | /** @var array $description */ 338 | $description = $this->meta['description']; 339 | 340 | $res = []; 341 | foreach ($description as $lang => $content) { 342 | if ($lang === '*') { 343 | $lang = null; 344 | if (count($this->meta['description']) > 1) { 345 | continue; 346 | } 347 | } 348 | $array = [ 349 | 'name' => 'description', 350 | 'content' => $description, 351 | 'lang' => $lang, 352 | ]; 353 | 354 | $res[] = (string)$this->Html->meta($array); 355 | } 356 | 357 | return implode(PHP_EOL, $res); 358 | } 359 | 360 | $description = $this->meta['description'][$lang] ?? false; 361 | 362 | if ($description === false) { 363 | return ''; 364 | } 365 | 366 | $array = [ 367 | 'name' => 'description', 368 | 'content' => $description, 369 | 'lang' => $lang !== '*' ? $lang : null, 370 | ]; 371 | 372 | return (string)$this->Html->meta($array); 373 | } 374 | 375 | /** 376 | * @param array|string $value 377 | * @param string|null $lang 378 | * 379 | * @return void 380 | */ 381 | public function setKeywords(array|string $value, ?string $lang = null): void { 382 | if ($lang && $this->meta['language'] && $lang !== $this->meta['language'] && !$this->getConfig('multiLanguage')) { 383 | throw new RuntimeException('Not configured as multi-language'); 384 | } 385 | 386 | if ($lang === null) { 387 | $lang = $this->meta['language'] ?: '*'; 388 | } 389 | 390 | $this->meta['keywords'][$lang] = $value; 391 | } 392 | 393 | /** 394 | * @param string|null $lang 395 | * 396 | * @return string 397 | */ 398 | public function getKeywords(?string $lang = null): string { 399 | if ($lang === null) { 400 | /** @var array|string $keywords */ 401 | $keywords = $this->meta['keywords']; 402 | if (!is_array($keywords)) { 403 | return $this->keywords($keywords, $lang); 404 | } 405 | 406 | $res = []; 407 | foreach ($keywords as $lang => $keyword) { 408 | if ($lang === '*') { 409 | $lang = null; 410 | if (count($this->meta['keywords']) > 1) { 411 | continue; 412 | } 413 | } 414 | 415 | $res[] = $this->keywords($keyword, $lang); 416 | } 417 | 418 | return implode('', $res); 419 | } 420 | 421 | $keywords = $this->meta['keywords'][$lang] ?? false; 422 | 423 | return $this->keywords($keywords, $lang); 424 | } 425 | 426 | /** 427 | * @param mixed $keywords 428 | * @param string|null $lang 429 | * 430 | * @return string 431 | */ 432 | protected function keywords(mixed $keywords, ?string $lang): string { 433 | if ($keywords === false) { 434 | return ''; 435 | } 436 | 437 | if (is_array($keywords)) { 438 | $keywords = implode(',', $keywords); 439 | } 440 | 441 | $array = [ 442 | 'name' => 'keywords', 443 | 'content' => $keywords, 444 | 'lang' => $lang !== '*' ? $lang : null, 445 | ]; 446 | 447 | return (string)$this->Html->meta($array); 448 | } 449 | 450 | /** 451 | * @param string|null $name 452 | * @param string|null $value 453 | * @throws \Exception 454 | * @return string 455 | */ 456 | public function custom($name = null, $value = null): string { 457 | if ($value !== null) { 458 | if ($name === null) { 459 | throw new Exception('Name must be provided'); 460 | } 461 | 462 | $this->meta['custom'][$name] = $value; 463 | } 464 | 465 | if ($name === null) { 466 | $res = []; 467 | foreach ($this->meta['custom'] as $name => $content) { 468 | $res[] = $this->custom($name, $content); 469 | } 470 | 471 | return implode('', $res); 472 | } 473 | 474 | if (!isset($this->meta['custom'][$name]) || $this->meta['custom'][$name] === false) { 475 | return ''; 476 | } 477 | $value = $this->meta['custom'][$name]; 478 | 479 | $array = [ 480 | 'name' => $name, 481 | 'content' => $value, 482 | ]; 483 | 484 | return (string)$this->Html->meta($array); 485 | } 486 | 487 | /** 488 | * @param array|string|bool $value 489 | * @return void 490 | */ 491 | public function setCanonical(array|string|bool $value): void { 492 | $this->meta['canonical'] = $value; 493 | } 494 | 495 | /** 496 | * @param bool $full 497 | * 498 | * @return string 499 | */ 500 | public function getCanonical(bool $full = false): string { 501 | $url = $this->meta['canonical']; 502 | if ($url === false) { 503 | return ''; 504 | } 505 | 506 | $options = [ 507 | 'fullBase' => $full, 508 | ]; 509 | 510 | if ($url === true) { 511 | $url = $this->getView()->getRequest()->getAttribute('here'); 512 | } elseif (is_array($url)) { 513 | $url = $this->Url->build($url, $options); 514 | } elseif (!preg_match('/^(https:\/\/|http:\/\/)/', $url)) { 515 | $url = $this->Url->build($url, $options); 516 | } 517 | 518 | $array = [ 519 | 'url' => $url, 520 | 'rel' => 'canonical', 521 | ]; 522 | 523 | return $this->Html->templater()->format('css', $array); 524 | } 525 | 526 | /** 527 | * @param string $type 528 | * @param string|false $value 529 | * @return void 530 | */ 531 | public function setHttpEquiv(string $type, string|false $value): void { 532 | $this->meta['http-equiv'][$type] = $value; 533 | } 534 | 535 | /** 536 | * @param string|null $type 537 | * @return string 538 | */ 539 | public function getHttpEquiv(?string $type = null): string { 540 | if ($type === null) { 541 | $res = []; 542 | foreach ($this->meta['http-equiv'] as $type => $content) { 543 | $res[] = $this->httpEquiv($type, $content); 544 | } 545 | 546 | return implode('', $res); 547 | } 548 | 549 | if (!isset($this->meta['http-equiv'][$type]) || $this->meta['http-equiv'][$type] === false) { 550 | return ''; 551 | } 552 | $value = $this->meta['http-equiv'][$type]; 553 | 554 | return $this->httpEquiv($type, $value); 555 | } 556 | 557 | /** 558 | * @param string $type 559 | * @param string|false $value 560 | * @return string 561 | */ 562 | protected function httpEquiv(string $type, string|false $value): string { 563 | if ($value === false) { 564 | return ''; 565 | } 566 | 567 | $array = [ 568 | 'http-equiv' => $type, 569 | 'content' => $value, 570 | ]; 571 | 572 | return (string)$this->Html->meta($array); 573 | } 574 | 575 | /** 576 | * Outputs a meta header or series of meta headers 577 | * 578 | * Covered are: 579 | * - charset 580 | * - title 581 | * - canonical 582 | * - robots 583 | * - language 584 | * - keywords 585 | * - 586 | * 587 | * Options: 588 | * - skip 589 | * - implode 590 | * 591 | * @param string|null $header Specific meta header to output 592 | * @param array $options 593 | * @return string 594 | */ 595 | public function out(?string $header = null, array $options = []): string { 596 | $defaults = [ 597 | 'implode' => Configure::read('debug') ? PHP_EOL : '', 598 | 'skip' => [], 599 | ]; 600 | $options += $defaults; 601 | 602 | if (!is_array($options['skip'])) { 603 | $options['skip'] = (array)$options['skip']; 604 | } 605 | 606 | if ($header) { 607 | if (!isset($this->meta[$header]) || $this->meta[$header] === false) { 608 | return ''; 609 | } 610 | 611 | if ($header === 'charset') { 612 | return $this->getCharset(); 613 | } 614 | 615 | if ($header === 'icon') { 616 | return $this->getIcon(); 617 | } 618 | 619 | if ($header === 'title') { 620 | return $this->getTitle(); 621 | } 622 | 623 | if ($header === 'canonical') { 624 | return $this->getCanonical(); 625 | } 626 | 627 | if ($header === 'robots') { 628 | return $this->getRobots(); 629 | } 630 | 631 | if ($header === 'language') { 632 | return $this->getLanguage(); 633 | } 634 | 635 | if ($header === 'keywords') { 636 | return $this->getKeywords(); 637 | } 638 | 639 | if ($header === 'description') { 640 | return $this->getDescription(); 641 | } 642 | 643 | if ($header === 'custom') { 644 | return $this->custom(); 645 | } 646 | 647 | $meta = ['name' => $header, 'content' => $this->meta[$header]]; 648 | $pos = strpos($header, ':'); 649 | if ($pos !== false) { 650 | $meta['name'] = substr($header, $pos + 1); 651 | $meta['property'] = $header; 652 | } 653 | 654 | return (string)$this->Html->meta($meta); 655 | } 656 | 657 | $results = []; 658 | 659 | foreach ($this->meta as $header => $value) { 660 | if (in_array($header, $options['skip'])) { 661 | continue; 662 | } 663 | $out = $this->out($header, $options); 664 | if ($out === '') { 665 | continue; 666 | } 667 | $results[] = $out; 668 | } 669 | 670 | return implode($options['implode'], $results); 671 | } 672 | 673 | } 674 | -------------------------------------------------------------------------------- /tests/TestCase/View/Helper/MetaHelperTest.php: -------------------------------------------------------------------------------- 1 | defaultLocale === null) { 36 | $this->defaultLocale = ini_get('intl.default_locale'); 37 | } 38 | 39 | ini_set('intl.default_locale', 'de_DE'); 40 | Configure::delete('Meta'); 41 | 42 | $request = (new ServerRequest()) 43 | ->withParam('controller', 'ControllerName') 44 | ->withParam('action', 'actionName'); 45 | 46 | $this->View = new View($request); 47 | $this->Meta = new MetaHelper($this->View); 48 | 49 | $builder = Router::createRouteBuilder('/'); 50 | $builder->setRouteClass(DashedRoute::class); 51 | $builder->connect('/:controller/:action/*'); 52 | $builder->plugin('Meta', function (RouteBuilder $routes): void { 53 | $routes->fallbacks(DashedRoute::class); 54 | }); 55 | } 56 | 57 | /** 58 | * @return void 59 | */ 60 | public function testMetaLanguage() { 61 | $result = $this->Meta->getLanguage(); 62 | $expected = ''; 63 | $this->assertEquals($expected, $result); 64 | 65 | $this->Meta->setLanguage(null); 66 | $result = $this->Meta->getLanguage(); 67 | $expected = ''; 68 | $this->assertEquals($expected, $result); 69 | 70 | $this->Meta->setLanguage('deu'); 71 | $result = $this->Meta->getLanguage(); 72 | $expected = ''; 73 | $this->assertEquals($expected, $result); 74 | } 75 | 76 | /** 77 | * @return void 78 | */ 79 | public function testMetaLanguageConfiguration() { 80 | ini_set('intl.default_locale', 'en_US'); 81 | 82 | $this->Meta = new MetaHelper($this->View, ['language' => true]); 83 | 84 | $result = $this->Meta->getLanguage(); 85 | $expected = ''; 86 | $this->assertEquals($expected, $result); 87 | 88 | $this->Meta->setLanguage('en'); 89 | $result = $this->Meta->getLanguage(); 90 | $expected = ''; 91 | $this->assertEquals($expected, $result); 92 | 93 | $result = $this->Meta->getLanguage(); 94 | $this->assertEquals($expected, $result); 95 | } 96 | 97 | /** 98 | * @return void 99 | */ 100 | public function testMetaRobots() { 101 | $result = $this->Meta->getRobots(); 102 | $this->assertEquals('', $result); 103 | 104 | $this->Meta->setRobots(['index' => true]); 105 | $result = $this->Meta->getRobots(); 106 | $this->assertEquals('', $result); 107 | 108 | $this->Meta->setRobots('noindex,nofollow,archive'); 109 | $result = $this->Meta->getRobots(); 110 | $this->assertEquals('', $result); 111 | 112 | $this->Meta->setRobots(false); 113 | $result = $this->Meta->getRobots(); 114 | $this->assertEquals('', $result); 115 | } 116 | 117 | /** 118 | * @return void 119 | */ 120 | public function testMetaRobotsConfiguration() { 121 | Configure::write('Meta', ['robots' => ['index' => true]]); 122 | $options = ['robots' => ['follow' => true]]; 123 | $this->Meta = new MetaHelper($this->View, $options); 124 | 125 | $result = $this->Meta->getRobots(); 126 | $this->assertEquals('', $result); 127 | 128 | $this->Meta->setRobots(['index' => false]); 129 | $result = $this->Meta->getRobots(); 130 | $this->assertEquals('', $result); 131 | } 132 | 133 | /** 134 | * @return void 135 | */ 136 | public function _testMetaName() { 137 | $result = $this->Meta->metaName('foo', [1, 2, 3]); 138 | $expected = ''; 139 | $this->assertEquals($expected, $result); 140 | } 141 | 142 | /** 143 | * @return void 144 | */ 145 | public function testMetaDescription() { 146 | $result = $this->Meta->getDescription(); 147 | $expected = ''; 148 | $this->assertEquals($expected, $result); 149 | 150 | $this->Meta->setDescription('descr'); 151 | $result = $this->Meta->getDescription(); 152 | $expected = ''; 153 | $this->assertEquals($expected, $result); 154 | 155 | $this->Meta->setDescription('foo', 'deu'); 156 | 157 | $result = $this->Meta->getDescription(); 158 | $expected = ''; 159 | $this->assertEquals($expected, $result); 160 | } 161 | 162 | /** 163 | * @return void 164 | */ 165 | public function testMetaDescriptionString() { 166 | $this->View->set('_meta', ['description' => 'Foo Bar']); 167 | $this->Meta = new MetaHelper($this->View); 168 | 169 | $result = $this->Meta->getDescription(); 170 | $expected = ''; 171 | $this->assertEquals($expected, $result); 172 | } 173 | 174 | /** 175 | * MetaHelperTest::testMetaKeywords() 176 | * 177 | * @return void 178 | */ 179 | public function testMetaKeywords() { 180 | $this->Meta->setKeywords('mystring'); 181 | $result = $this->Meta->getKeywords(); 182 | $expected = ''; 183 | $this->assertEquals($expected, $result); 184 | 185 | $this->Meta->setKeywords(['foo', 'bar']); 186 | $result = $this->Meta->getKeywords(); 187 | $expected = ''; 188 | $this->assertEquals($expected, $result); 189 | 190 | $result = $this->Meta->getKeywords(); 191 | $this->assertEquals($expected, $result); 192 | 193 | // Locale keywords trump global ones 194 | $this->Meta->setKeywords(['fooD', 'barD'], 'deu'); 195 | $result = $this->Meta->getKeywords('deu'); 196 | $expected = ''; 197 | $this->assertEquals($expected, $result); 198 | 199 | $result = $this->Meta->getKeywords(); 200 | $this->assertEquals($expected, $result); 201 | 202 | // But you can force-get them 203 | $result = $this->Meta->getKeywords('*'); 204 | $expected = ''; 205 | $this->assertEquals($expected, $result); 206 | 207 | $this->Meta->setKeywords(['fooE', 'barE'], 'eng'); 208 | $result = $this->Meta->getKeywords('eng'); 209 | $expected = ''; 210 | $this->assertEquals($expected, $result); 211 | 212 | // Having multiple locale keywords combines them 213 | $result = $this->Meta->getKeywords(); 214 | $expected = ''; 215 | $this->assertEquals($expected, $result); 216 | 217 | // Retrieve a specific one 218 | $result = $this->Meta->getKeywords('eng'); 219 | $expected = ''; 220 | $this->assertEquals($expected, $result); 221 | } 222 | 223 | /** 224 | * @return void 225 | */ 226 | public function testMetaKeywordsString() { 227 | $this->View->set('_meta', ['keywords' => 'Foo,Bar']); 228 | $this->Meta = new MetaHelper($this->View); 229 | 230 | $result = $this->Meta->getKeywords(); 231 | $expected = ''; 232 | $this->assertEquals($expected, $result); 233 | } 234 | 235 | /** 236 | * @return void 237 | */ 238 | public function _testMetaRss() { 239 | $result = $this->Meta->metaRss('/some/url', 'some title'); 240 | $expected = ''; 241 | $this->assertEquals($expected, $result); 242 | } 243 | 244 | /** 245 | * @return void 246 | */ 247 | public function testSizesIcon() { 248 | $this->Meta->setSizesIcon('/favicon-16x16.png', 16); 249 | $expected1 = ''; 250 | 251 | $this->Meta->setSizesIcon('/favicon-32x32.png', 32, ['type' => 'image/png']); 252 | $expected2 = ''; 253 | 254 | $this->Meta->setSizesIcon('/apple-touch-icon-57x57.png', 57, ['prefix' => 'apple-touch-']); 255 | $expected3 = ''; 256 | 257 | $result = $this->Meta->getSizesIcons(); 258 | $this->assertEquals($expected1 . PHP_EOL . $expected2 . PHP_EOL . $expected3, $result); 259 | } 260 | 261 | /** 262 | * MetaHelperTest::testMetaEquiv() 263 | * 264 | * @return void 265 | */ 266 | public function testMetaHttpEquiv() { 267 | $this->Meta->setHttpEquiv('expires', '0'); 268 | $result = $this->Meta->getHttpEquiv(); 269 | $expected = ''; 270 | $this->assertEquals($expected, $result); 271 | 272 | $this->Meta->setHttpEquiv('foo', 'bar'); 273 | $result = $this->Meta->getHttpEquiv(); 274 | $expected = ''; 275 | $this->assertEquals($expected, $result); 276 | 277 | $result = $this->Meta->getHttpEquiv(); 278 | $expected = ''; 279 | $this->assertEquals($expected, $result); 280 | 281 | $this->Meta->setHttpEquiv('expires', false); 282 | $result = $this->Meta->getHttpEquiv(); 283 | $expected = ''; 284 | $this->assertEquals($expected, $result); 285 | } 286 | 287 | /** 288 | * @return void 289 | */ 290 | public function testMetaCanonical() { 291 | $this->Meta->setCanonical('/some/url/param1'); 292 | $is = $this->Meta->getCanonical(); 293 | $this->assertEquals('', $is); 294 | 295 | $this->Meta->setCanonical(['plugin' => 'Meta', 'controller' => 'Foo', 'action' => 'bar'], true); 296 | $is = $this->Meta->getCanonical(); 297 | $this->assertEquals('', $is); 298 | } 299 | 300 | /** 301 | * @return void 302 | */ 303 | public function _testMetaAlternate() { 304 | $is = $this->Meta->metaAlternate('/some/url/param1', 'de-de', true); 305 | $this->assertEquals('', trim($is)); 306 | 307 | $is = $this->Meta->metaAlternate(['controller' => 'some', 'action' => 'url'], 'de', true); 308 | $this->assertEquals('', trim($is)); 309 | 310 | $is = $this->Meta->metaAlternate(['controller' => 'some', 'action' => 'url'], ['de', 'de-ch'], true); 311 | $this->assertEquals('' . PHP_EOL . '', trim($is)); 312 | 313 | $is = $this->Meta->metaAlternate(['controller' => 'some', 'action' => 'url'], ['de' => ['ch', 'at'], 'en' => ['gb', 'us']], true); 314 | $this->assertEquals('' . PHP_EOL . 315 | '' . PHP_EOL . 316 | '' . PHP_EOL . 317 | '', trim($is)); 318 | } 319 | 320 | /** 321 | * @return void 322 | */ 323 | public function testOut() { 324 | $result = $this->Meta->out(); 325 | 326 | $expected = 'Controller Name - Action Name'; 327 | $expected .= PHP_EOL . ''; 328 | $expected .= PHP_EOL . ''; 329 | $expected .= PHP_EOL . ''; 330 | $this->assertTextEquals($expected, $result); 331 | 332 | $this->Meta->setCharset('utf-8'); 333 | $this->Meta->setTitle('Foo'); 334 | $this->Meta->setCanonical(true); 335 | $this->Meta->setLanguage('de'); 336 | $this->Meta->setKeywords('foo bar'); 337 | $this->Meta->setKeywords('foo bar EN', 'en'); 338 | $this->Meta->setDescription('A sentence'); 339 | $this->Meta->setHttpEquiv('expires', '0'); 340 | $this->Meta->setRobots(['index' => true]); 341 | $this->Meta->custom('viewport', 'width=device-width, initial-scale=1'); 342 | $this->Meta->custom('x', 'y'); 343 | 344 | $result = $this->Meta->out(null, ['implode' => PHP_EOL]); 345 | 346 | $expected = 'Foo 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | '; 356 | $this->assertTextEquals($expected, $result); 357 | } 358 | 359 | /** 360 | * @return void 361 | */ 362 | public function testOutMultiLanguageFalse() { 363 | $this->Meta->setConfig('multiLanguage', false); 364 | 365 | $this->Meta->setLanguage('de'); 366 | 367 | $this->expectException(RuntimeException::class); 368 | 369 | $this->Meta->setKeywords('foo bar'); 370 | $this->Meta->setKeywords('foo bar EN', 'en'); 371 | 372 | $this->Meta->setDescription('A sentence', 'de'); 373 | $this->Meta->setDescription('A sentence EN', 'en'); 374 | } 375 | 376 | /** 377 | * TearDown method 378 | * 379 | * @return void 380 | */ 381 | public function tearDown(): void { 382 | parent::tearDown(); 383 | 384 | unset($this->Meta); 385 | 386 | ini_set('intl.default_locale', $this->defaultLocale); 387 | } 388 | 389 | } 390 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | 'App', 41 | 'encoding' => 'UTF-8']); 42 | Configure::write('debug', true); 43 | 44 | Configure::write('Config', [ 45 | 'adminEmail' => 'test@example.com', 46 | 'adminName' => 'Mark']); 47 | Mailer::setConfig('default', ['transport' => 'Debug']); 48 | TransportFactory::setConfig('Debug', [ 49 | 'className' => 'Debug', 50 | ]); 51 | 52 | mb_internal_encoding('UTF-8'); 53 | 54 | /* 55 | $Tmp = new Folder(TMP); 56 | $Tmp->create(TMP . 'cache/models', 0770); 57 | $Tmp->create(TMP . 'cache/persistent', 0770); 58 | $Tmp->create(TMP . 'cache/views', 0770); 59 | */ 60 | 61 | $cache = [ 62 | 'default' => [ 63 | 'engine' => 'File', 64 | 'path' => CACHE, 65 | ], 66 | '_cake_translations_' => [ 67 | 'className' => 'File', 68 | 'prefix' => 'myapp_cake_translations_', 69 | 'path' => CACHE . 'persistent/', 70 | 'serialize' => true, 71 | 'duration' => '+10 seconds', 72 | ], 73 | '_cake_model_' => [ 74 | 'className' => 'File', 75 | 'prefix' => 'myapp_cake_model_', 76 | 'path' => CACHE . 'models/', 77 | 'serialize' => 'File', 78 | 'duration' => '+10 seconds', 79 | ], 80 | ]; 81 | 82 | Cache::setConfig($cache); 83 | 84 | // Ensure default test connection is defined 85 | if (!getenv('DB_URL')) { 86 | putenv('DB_URL=sqlite:///:memory:'); 87 | } 88 | 89 | ConnectionManager::setConfig('test', [ 90 | 'url' => getenv('DB_URL') ?: null, 91 | 'timezone' => 'UTC', 92 | 'quoteIdentifiers' => true, 93 | 'cacheMetadata' => true, 94 | ]); 95 | -------------------------------------------------------------------------------- /tests/config/routes.php: -------------------------------------------------------------------------------- 1 | plugin('Meta', function (RouteBuilder $routes) { 10 | $routes->fallbacks(DashedRoute::class); 11 | }); 12 | --------------------------------------------------------------------------------