* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE */ namespace Joomla\Plugin\System\MokoOG\Helper; defined('_JEXEC') or die; use Joomla\CMS\Factory; use Joomla\CMS\Uri\Uri; /** * XML Sitemap builder. * * Generates a sitemap.xml containing all published articles, excluding * those marked with noindex robots directives in the mokoog_tags table. */ class SitemapBuilder { /** * Generate sitemap XML content. * * @param string $changefreq Default change frequency for entries * * @return string Complete sitemap XML */ public static function generate(string $changefreq = 'weekly'): string { $allowed = ['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never']; $changefreq = \in_array($changefreq, $allowed, true) ? $changefreq : 'weekly'; $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); // Only include content the public (guest, user id 0) can view — never // leak registered/special-access articles into the public sitemap. $publicLevels = array_map('intval', \Joomla\CMS\Access\Access::getAuthorisedViewLevels(0)); // Get all published articles $query = $db->getQuery(true) ->select($db->quoteName(['a.id', 'a.alias', 'a.catid', 'a.modified', 'a.language'])) ->from($db->quoteName('#__content', 'a')) ->where($db->quoteName('a.state') . ' = 1'); if (!empty($publicLevels)) { $query->where($db->quoteName('a.access') . ' IN (' . implode(',', $publicLevels) . ')'); } $db->setQuery($query); $articles = $db->loadObjectList(); // Get noindex articles from mokoog_tags $noindexQuery = $db->getQuery(true) ->select($db->quoteName('content_id')) ->from($db->quoteName('#__mokoog_tags')) ->where($db->quoteName('content_type') . ' = ' . $db->quote('com_content')) ->where($db->quoteName('robots') . ' LIKE ' . $db->quote('%noindex%')); $db->setQuery($noindexQuery); $noindexIds = array_map('intval', $db->loadColumn()); $root = rtrim(Uri::root(), '/'); $xml = '' . "\n"; $xml .= '' . "\n"; // Homepage $xml .= ' ' . "\n"; $xml .= ' ' . $root . '/' . "\n"; $xml .= ' daily' . "\n"; $xml .= ' 1.0' . "\n"; $xml .= ' ' . "\n"; foreach ($articles as $article) { // Skip noindexed if (\in_array((int) $article->id, $noindexIds, true)) { continue; } $url = self::articleUrl($article, $root); $lastmod = $article->modified && $article->modified !== '0000-00-00 00:00:00' ? date('Y-m-d', strtotime($article->modified)) : ''; $xml .= ' ' . "\n"; $xml .= ' ' . htmlspecialchars($url, ENT_XML1) . '' . "\n"; if ($lastmod) { $xml .= ' ' . $lastmod . '' . "\n"; } $xml .= ' ' . $changefreq . '' . "\n"; $xml .= ' 0.8' . "\n"; $xml .= ' ' . "\n"; } $xml .= ''; return $xml; } /** * Build the SEF/canonical site URL for an article, with a safe fallback. * * Routes through the site router so the sitemap matches the canonical URLs * the plugin emits. If routing fails (or SEF is off), falls back to the * non-SEF index.php URL — never an empty or broken URL. * * @param object $article Row with id, alias, catid, language * @param string $root Site root without trailing slash * * @return string Absolute URL */ private static function articleUrl(object $article, string $root): string { $fallback = $root . '/index.php?option=com_content&view=article&id=' . (int) $article->id; $internal = 'index.php?option=com_content&view=article&id=' . (int) $article->id . (!empty($article->alias) ? ':' . $article->alias : '') . (!empty($article->catid) ? '&catid=' . (int) $article->catid : ''); try { $routed = \Joomla\CMS\Router\Route::link( 'site', $internal, false, \Joomla\CMS\Router\Route::TLS_IGNORE, true ); if (\is_string($routed) && $routed !== '') { return $routed; } } catch (\Throwable $e) { // Fall back to the non-SEF URL below. } return $fallback; } /** * Write sitemap XML to the site root. * * @param string $xml The sitemap XML content * * @return bool True on success */ public static function writeToFile(string $xml): bool { $path = JPATH_ROOT . '/sitemap.xml'; $tmp = $path . '.' . uniqid('tmp', true); if (file_put_contents($tmp, $xml) === false) { return false; } // Atomic replace so concurrent saves never expose a half-written sitemap. if (!@rename($tmp, $path)) { @unlink($tmp); return false; } return true; } }