Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
73.25% |
115 / 157 |
|
57.14% |
4 / 7 |
CRAP | |
0.00% |
0 / 1 |
| SitemapController | |
73.25% |
115 / 157 |
|
57.14% |
4 / 7 |
40.96 | |
0.00% |
0 / 1 |
| beforeFilter | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| viewClasses | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| index | |
88.24% |
90 / 102 |
|
0.00% |
0 / 1 |
10.16 | |||
| getEnabledLanguages | |
66.67% |
4 / 6 |
|
0.00% |
0 / 1 |
3.33 | |||
| getLastModifiedDateForLanguage | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
4 | |||
| getOverallLastModifiedDate | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| generateHreflangLinks | |
0.00% |
0 / 28 |
|
0.00% |
0 / 1 |
56 | |||
| 1 | <?php |
| 2 | declare(strict_types=1); |
| 3 | |
| 4 | namespace App\Controller; |
| 5 | |
| 6 | use App\Utility\I18nManager; |
| 7 | use Cake\Cache\Cache; |
| 8 | use Cake\Event\EventInterface; |
| 9 | use Cake\I18n\DateTime; |
| 10 | use Cake\Routing\Router; |
| 11 | use Cake\View\XmlView; |
| 12 | use 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 | */ |
| 22 | class 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 | } |