├── .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 | [](https://github.com/dereuromark/cakephp-meta/actions/workflows/ci.yml?query=branch%3Amaster)
3 | [](https://coveralls.io/r/dereuromark/cakephp-meta)
4 | [](LICENSE)
5 | [](https://php.net/)
6 | [](https://packagist.org/packages/dereuromark/cakephp-meta)
7 | [](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 |
--------------------------------------------------------------------------------