Search by

besnovatyj / yii2-cms-sitemap

besnovatyj

Карта сайта для Yii2 CMS: собирает адреса модулей через контракт SitemapProvider в XML-карту (с индексом и нарезкой по лимитам протокола) и человеческую HTML-карту. Ни одного контентного модуля не знает поимённо.

Package info

github.com/besnovatyj/yii2-cms-sitemap

Type:yii2-extension

pkg:composer/besnovatyj/yii2-cms-sitemap

Statistics

Installs: 4

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.1.0 2026-09-13 16:03 UTC

This package is auto-updated.

Last update: 2026-09-13 16:08:06 UTC


README

Карта сайта для Yii2 CMS: XML-карта для поисковых систем (/sitemap.xml + файлы разделов) и человеческая карта /sitemap.

Модуль не знает ни одного контентного модуля поимённо. Адреса приходят от модулей, которые реализовали нейтральный контракт Besnovatyj\Contracts\sitemap\SitemapProvider, — тем же приёмом, что источники сквозного поиска и цели меню. Выключили модуль в менеджере — его раздел исчез из карты сам, без правок здесь.

Что делает

  • собирает разделы модулей в XML-карту, режет по лимитам протокола (50 000 адресов / 50 МБ на файл) и пишет индекс;
  • из того же прохода строит дерево HTML-карты — XML и HTML не могут разойтись;
  • строит адреса через frontendUrlManager, поэтому короткие URL модуля алиасов учитываются автоматически;
  • хранит артефакты в @runtime/sitemap (вне корня сайта) и отдаёт их без единого запроса к базе;
  • проверяет, объявлена ли карта в robots.txt, и показывает готовую строку.

Миграций и таблиц у модуля нет: всё состояние — файлы, которые полностью восстанавливаются одной командой.

Как подключить модуль к карте

use Besnovatyj\Contracts\sitemap\ChangeFrequency;
use Besnovatyj\Contracts\sitemap\SitemapProvider;
use Besnovatyj\Contracts\sitemap\SitemapSection;
use Besnovatyj\Contracts\sitemap\SitemapUrl;

class Module extends CmsModule implements SitemapProvider
{
    public function sitemapSections(): array
    {
        return [
            new SitemapSection(
                key: 'shop.product',      // стабильный ключ: попадает в имя файла карты
                label: 'Товары',
                changeFrequency: ChangeFrequency::Daily,
                priority: 0.7,
                icon: 'bi bi-box-seam',
            ),
        ];
    }

    public function sitemapUrls(string $section): iterable
    {
        return match ($section) {
            'shop.product' => (new ProductReadRepository())->sitemapUrls(),
            default => [],   // неизвестный ключ — пустой итератор, не исключение
        };
    }
}

А сам поток адресов — генератором, из readModels (только публичное!):

public function sitemapUrls(): iterable
{
    foreach (Product::find()->visible()->each(200) as $product) {
        yield new SitemapUrl(
            route: '/Shop/product/view',          // роут, а НЕ готовый URL
            params: ['slug' => $product->slug],
            title: $product->name,                // нужен человеческой карте
            lastModified: strtotime($product->updated_at) ?: null,
        );
    }
}

Тяжёлому разделу стоит добавить SitemapFreshness — тогда неизменившийся раздел не перечитывается:

public function sitemapRevision(string $section): ?string
{
    $row = Product::find()->visible()->select(['n' => 'COUNT(*)', 'm' => 'MAX(updated_at)'])->asArray()->one();

    return $row['n'] . ':' . $row['m'];   // именно пара: MAX(updated_at) не замечает удаления
}

Соглашения, по которым подключены штатные модули

Так устроены провайдеры в page, blog, actors, documents, gallery, performance; новому модулю проще повторить схему, чем изобретать свою.

  • Раздел — единица нарезки файлов, включения в настройках и блока на человеческой карте. Поэтому у модуля их обычно два: навигационная ветка (blog.taxonomy, documents.category) и сами записи (blog.post, documents.document).
  • Список раздела (/blog, /actors) — первый адрес навигационной секции с priority: 0.9. Заводить под один адрес отдельный раздел значит засорить и настройки, и индекс файлов.
  • Массовые записи — только в XML (inHtmlMap: false у секции): сотни статей или приказов превратили бы оглавление сайта в ленту. Спектакли, актёры, альбомы, наоборот, на карте полезны — их немного и они читаются как оглавление.
  • lastModified — дата изменения записи (updated_at), а не дата события (премьеры, приказа): краулера интересует, поменялась ли сама страница.
  • depth отдаётся как есть — сборщик сам сдвигает глубину так, чтобы верхний уровень раздела начинался с нуля; провайдеру не нужно знать, есть ли у его дерева корень-обёртка.
  • Роуты — те же, что строят вьюхи сайта (Url::to(['view', 'id' => …])) и отдаёт сквозной поиск: тогда починка URL-правил модуля исправляет все каналы разом.
  • sitemapRevision() только у сущностей с updated_at. Деревьям разделов отпечаток не нужен: колонок времени там нет, а суррогат не заметил бы переименования; их полный обход дёшев.

Эксплуатация

php yii Sitemap/build/run      # собрать заново (крон, деплой)
php yii Sitemap/build/status   # что собрано и когда

На боевом сервере — от пользователя веб-сервера: sudo -u www-data php yii Sitemap/build/run. Иначе процесс не прочитает секреты базы, а созданные им файлы окажутся недоступны на запись веб-процессу. Крон — раз в сутки:

0 4 * * * www-data cd /path/to/app && php yii Sitemap/build/run >/dev/null

Если крон не настроен, карта чинит себя сама: устаревшую (старше ttl) или отсутствующую пересоберёт первый же запрос — под замком, поэтому одновременных сборок не бывает. Совпадение крона с заходом краулера или нажатием кнопки — штатная ситуация, а не ошибка: второй запуск получает BuildInProgressException, консоль завершается кодом TEMPFAIL (75), админка показывает предупреждение, а сайт продолжает отдавать прошлую карту.

Сборка атомарна: каждый файл пишется во временный и переименовывается, манифест — последним, и только потом удаляются осиротевшие файлы. Читатель никогда не видит полусобранной карты. Артефакты — XML и JSON, ни одного .php: на боевом сервере opcache.validate_timestamps=0, и PHP-файл, перезаписанный консолью, веб-процесс не перечитал бы до перезагрузки FPM.

Файлы отдаёт контроллер через sendFile(). Если нужен ноль PHP на запросах краулера, достаточно location-секции в nginx с alias на @runtime/sitemap — код при этом не меняется.

Ссылка на человеческую карту добавляется в меню штатно: модуль отдаёт цель Sitemap/map/index модулю меню (пока HTML-карта включена в настройках — пункт, ведущий на 404, не предлагается).

В robots.txt карту нужно объявить руками — модуль не правит файл скелета приложения, но показывает готовую строку на своей странице в админке:

Sitemap: https://example.test/sitemap.xml

Настройки

Раздел «Sitemap» в настройках приложения: исключённые разделы, переопределение приоритетов и частоты, дополнительные адреса (главная страница объявляется там), число адресов в файле, срок годности карты, показ человеческой карты и вариант её оформления из активной темы.

В строках вида blog.post: 0.8, page.page: 0.6 десятичный разделитель — только точка: запятая разделяет элементы. Нечисловое значение пропускается, 0 — допустимый приоритет.

Параметр robotsFile (по умолчанию @frontend/pub/robots.txt) в настройки не вынесен намеренно: это параметр развёртывания, а не решение редактора. Задаётся в конфигурации модуля; пустое значение отключает проверку (например, если robots.txt раздаётся не файлом).

Чего нет намеренно

  • Событийной инвалидации («пост опубликован» → пересборка): доменные события у модулей конкретные, подписка на них вернула бы карте знание о чужих модулях. Свежесть по данным (sitemapRevision()) даёт тот же результат без связанности.
  • Пинга поисковиков: Google выключил ping в 2024-м, Яндекс его игнорирует. Если понадобится оперативность — это IndexNow, отдельная осознанная фича.
  • .gz-артефактов: nginx жмёт на лету, отдельные файлы только удваивают состояние.
  • Таблиц и миграций: манифест-файл, консистентно с генерируемой конфигурацией.

Отложено до реальной потребности: расширения image:image / video:video в urlset (когда галерея станет крупной), флаг «не включать в карту» на уровне сущности (расширение yii2-cms-meta + миграции в чужих модулях), hreflang (сайт одноязычный).