Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
73.25% covered (warning)
73.25%
115 / 157
57.14% covered (warning)
57.14%
4 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
SitemapController
73.25% covered (warning)
73.25%
115 / 157
57.14% covered (warning)
57.14%
4 / 7
40.96
0.00% covered (danger)
0.00%
0 / 1
 beforeFilter
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 viewClasses
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 index
88.24% covered (warning)
88.24%
90 / 102
0.00% covered (danger)
0.00%
0 / 1
10.16
 getEnabledLanguages
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 getLastModifiedDateForLanguage
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 getOverallLastModifiedDate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 generateHreflangLinks
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
56
1<?php
2declare(strict_types=1);
3
4namespace App\Controller;
5
6use App\Utility\I18nManager;
7use Cake\Cache\Cache;
8use Cake\Event\EventInterface;
9use Cake\I18n\DateTime;
10use Cake\Routing\Router;
11use Cake\View\XmlView;
12use Exception;
13
14/**
15 * SitemapController handles the generation of XML sitemaps for the application.
16 *
17 * This controller generates a sitemap.xml file that includes all published pages,
18 * articles, and tags with their respective priorities and change frequencies.
19 * The sitemap supports internationalization (i18n) and works across different locales.
20 * It also provides a sitemap index file that lists all language-specific sitemaps.
21 */
22class SitemapController extends AppController
23{
24    /**
25     * Configures authentication requirements before controller actions are executed.
26     *
27     * This method is called before each action in the controller. It configures which
28     * actions can be accessed without authentication. In this case, the 'index' and
29     * 'sitemapIndex' actions are allowed to be accessed by unauthenticated users,
30     * enabling public access to the sitemap files.
31     *
32     * @param \Cake\Event\EventInterface $event The event object
33     * @return void
34     */
35    public function beforeFilter(EventInterface $event): void
36    {
37        parent::beforeFilter($event);
38        $this->Authentication->allowUnauthenticated(['index']);
39    }
40
41    /**
42     * Specifies the view classes that this controller can use.
43     *
44     * @return array<class-string> Array containing XmlView class for sitemap generation
45     */
46    public function viewClasses(): array
47    {
48        return [XmlView::class];
49    }
50
51    /**
52     * Generates the XML sitemap for the application with hreflang support.
53     *
54     * This method fetches all published pages, articles, and tags from the database
55     * and generates a sitemap.xml file according to the sitemap protocol specifications.
56     * It includes hreflang annotations for multi-language support.
57     *
58     * Different content types are assigned different priorities and change frequencies:
59     * - Homepage: Priority 1.0, daily changes
60     * - Pages: Priority 0.8, weekly changes
61     * - Articles: Priority 0.6, daily changes
62     * - Tags: Priority 0.4, weekly changes
63     *
64     * @return void
65     * @link https://www.sitemaps.org/protocol.html Sitemap protocol reference
66     * @link https://support.google.com/webmasters/answer/189077 Hreflang in sitemaps
67     */
68    public function index(): void
69    {
70        try {
71            // Get all enabled languages
72            $enabledLanguages = $this->getEnabledLanguages();
73
74            // Build cache key based on all languages and last modification
75            $lastModified = $this->getOverallLastModifiedDate();
76            $cacheKey = 'sitemap_all_' . $lastModified->format('YmdHis');
77
78            // Try to get from cache
79            $urls = Cache::read($cacheKey, 'default');
80
81            if ($urls === null) {
82                $articlesTable = $this->fetchTable('Articles');
83
84                // Optimize queries by selecting only needed fields
85                // Get published hierarchical pages
86                $pages = $articlesTable->find('threaded')
87                    ->select(['id', 'slug', 'modified', 'lft'])
88                    ->where([
89                        'kind' => 'page',
90                        'is_published' => 1,
91                    ])
92                    ->orderByAsc('lft')
93                    ->all();
94
95                // Get published regular articles
96                $articles = $articlesTable->find()
97                    ->select(['id', 'slug', 'modified'])
98                    ->where([
99                        'kind' => 'article',
100                        'is_published' => 1,
101                    ])
102                    ->orderByDesc('modified')
103                    ->all();
104
105                // Get all tags
106                $tagsTable = $this->fetchTable('Tags');
107                $tags = $tagsTable->find()
108                    ->select(['id', 'slug', 'modified'])
109                    ->orderByAsc('title')
110                    ->all();
111
112                $urls = [];
113
114                // Add homepage for all enabled languages
115                foreach ($enabledLanguages as $lang) {
116                    $urls[] = [
117                        'loc' => Router::url([
118                            '_name' => 'home',
119                            'lang' => $lang,
120                            '_full' => true,
121                        ]),
122                        'changefreq' => 'daily',
123                        'priority' => '1.0',
124                        'lastmod' => $lastModified->format('Y-m-d'),
125                    ];
126                }
127
128                // Add pages for all enabled languages
129                foreach ($pages as $page) {
130                    foreach ($enabledLanguages as $lang) {
131                        $urls[] = [
132                            'loc' => Router::url([
133                                '_name' => 'page-by-slug',
134                                'slug' => $page->slug,
135                                'lang' => $lang,
136                                '_full' => true,
137                            ]),
138                            'lastmod' => $page->modified->format('Y-m-d'),
139                            'changefreq' => 'weekly',
140                            'priority' => '0.8',
141                        ];
142                    }
143                }
144
145                // Add articles for all enabled languages
146                foreach ($articles as $article) {
147                    foreach ($enabledLanguages as $lang) {
148                        $urls[] = [
149                            'loc' => Router::url([
150                                '_name' => 'article-by-slug',
151                                'slug' => $article->slug,
152                                'lang' => $lang,
153                                '_full' => true,
154                            ]),
155                            'lastmod' => $article->modified->format('Y-m-d'),
156                            'changefreq' => 'daily',
157                            'priority' => '0.6',
158                        ];
159                    }
160                }
161
162                // Add tags for all enabled languages
163                foreach ($tags as $tag) {
164                    foreach ($enabledLanguages as $lang) {
165                        $urls[] = [
166                            'loc' => Router::url([
167                                '_name' => 'tag-by-slug',
168                                'slug' => $tag->slug,
169                                'lang' => $lang,
170                                '_full' => true,
171                            ]),
172                            'lastmod' => $tag->modified->format('Y-m-d'),
173                            'changefreq' => 'weekly',
174                            'priority' => '0.4',
175                        ];
176                    }
177                }
178
179                // Cache the generated URLs for 1 hour
180                Cache::write($cacheKey, $urls, 'default');
181            }
182
183            $this->viewBuilder()
184                ->setOption('rootNode', 'urlset')
185                ->setOption('serialize', ['@xmlns', 'url']);
186
187            $this->set([
188                '@xmlns' => 'http://www.sitemaps.org/schemas/sitemap/0.9',
189                'url' => $urls,
190            ]);
191
192            // Set response type and cache headers with smart caching based on last modified
193            $this->response = $this->response
194                ->withType('xml')
195                ->withHeader('Content-Type', 'application/xml')
196                ->withCache($lastModified->toUnixString(), '+1 day');
197        } catch (Exception $e) {
198            $this->log('Sitemap generation failed: ' . $e->getMessage(), 'error');
199
200            // Return empty but valid sitemap on error
201            $this->viewBuilder()
202                ->setOption('rootNode', 'urlset')
203                ->setOption('serialize', ['@xmlns', 'url']);
204
205            $this->set([
206                '@xmlns' => 'http://www.sitemaps.org/schemas/sitemap/0.9',
207                'url' => [],
208            ]);
209
210            $this->response = $this->response
211                ->withType('xml')
212                ->withHeader('Content-Type', 'application/xml');
213        }
214    }
215
216    /**
217     * Gets the list of enabled languages from settings.
218     *
219     * @return array<string> Array of enabled language codes
220     */
221    protected function getEnabledLanguages(): array
222    {
223        // Always include English as default
224        $languages = ['en'];
225
226        // Get enabled languages using I18nManager
227        $enabledLanguages = array_keys(I18nManager::getEnabledLanguages());
228
229        // Merge with default, removing duplicates
230        foreach ($enabledLanguages as $lang) {
231            if (!in_array($lang, $languages)) {
232                $languages[] = $lang;
233            }
234        }
235
236        return $languages;
237    }
238
239    /**
240     * Gets the last modified date for content in a specific language.
241     *
242     * @param string $language The language code
243     * @return \Cake\I18n\DateTime The last modification date
244     */
245    protected function getLastModifiedDateForLanguage(string $language): DateTime
246    {
247        $articlesTable = $this->fetchTable('Articles');
248
249        // Get the most recently modified article or page
250        $lastArticle = $articlesTable->find()
251            ->select(['modified'])
252            ->where(['is_published' => 1])
253            ->orderByDesc('modified')
254            ->first();
255
256        // Get the most recently modified tag
257        $tagsTable = $this->fetchTable('Tags');
258        $lastTag = $tagsTable->find()
259            ->select(['modified'])
260            ->orderByDesc('modified')
261            ->first();
262
263        $dates = [];
264        if ($lastArticle) {
265            $dates[] = $lastArticle->modified;
266        }
267        if ($lastTag) {
268            $dates[] = $lastTag->modified;
269        }
270
271        // Return the most recent date, or current date if no content
272        return !empty($dates) ? max($dates) : new DateTime();
273    }
274
275    /**
276     * Gets the overall last modified date across all languages.
277     *
278     * @return \Cake\I18n\DateTime The last modification date
279     */
280    protected function getOverallLastModifiedDate(): DateTime
281    {
282        // For now, just use the same logic as single language
283        // In the future, this could check translations table
284        return $this->getLastModifiedDateForLanguage('en');
285    }
286
287    /**
288     * Generates hreflang links for a given route and entity.
289     *
290     * @param string $routeName The route name
291     * @param \Cake\Datasource\EntityInterface|null $entity The entity (article, page, tag)
292     * @param array<string> $languages Array of enabled language codes
293     * @return array<array> Array of hreflang link data
294     */
295    protected function generateHreflangLinks(string $routeName, ?object $entity, array $languages): array
296    {
297        $links = [];
298
299        foreach ($languages as $lang) {
300            $urlParams = [
301                '_name' => $routeName,
302                'lang' => $lang,
303                '_full' => true,
304            ];
305
306            // Add slug parameter if entity is provided
307            if ($entity !== null && isset($entity->slug)) {
308                $urlParams['slug'] = $entity->slug;
309            }
310
311            $links[] = [
312                '@rel' => 'alternate',
313                '@hreflang' => $lang,
314                '@href' => Router::url($urlParams),
315            ];
316        }
317
318        // Add x-default for the primary language (first in the list)
319        if (!empty($languages)) {
320            $defaultUrlParams = [
321                '_name' => $routeName,
322                'lang' => $languages[0],
323                '_full' => true,
324            ];
325
326            if ($entity !== null && isset($entity->slug)) {
327                $defaultUrlParams['slug'] = $entity->slug;
328            }
329
330            $links[] = [
331                '@rel' => 'alternate',
332                '@hreflang' => 'x-default',
333                '@href' => Router::url($defaultUrlParams),
334            ];
335        }
336
337        return $links;
338    }
339}