# Блог `/blog`

Раздел заведён ради поискового трафика: статьи о бизнесе и работе с маркетплейсами. Правится в
панели (Контент → **Блог** и **Темы блога**).

## Адреса

| Страница | Адрес |
|---|---|
| Список | `/blog` |
| Тема | `/blog/category/{slug}` |
| Запись | `/blog/{slug}` |

Тема идёт с явным сегментом `category` и **объявлена выше** маршрута записи, иначе её адрес поймался
бы как slug записи. Слово `category` запрещено в slug записи правилом формы — без этого можно было
создать запись, которая навсегда закрыла бы собой все темы.

## Видимость

`BlogPost::scopePublic()` — единственное определение «видна на сайте»: `is_published` и наступившая
`published_at`. Дата отдельно от `created_at` сознательно: её ставят будущей (отложенный выход) или
прошлой (перенос старого текста), и она же уходит в `datePublished`. Отложенная запись выходит сама,
планировщик для этого не нужен.

Пустые темы не показываются ни в списке тем, ни в sitemap: страница без статей только размывает
раздел в индексе.

## SEO

- **Article JSON-LD** (`Seo::articleJsonLd`): заголовок, `datePublished`/`dateModified`, автор,
  издатель с логотипом, `mainEntityOfPage` на канонический адрес. Автор обязателен по требованиям
  Google, поэтому при пустом поле подставляется организация teeu — «не указан» ломает разметку.
- **BreadcrumbList** на всех трёх страницах, и видимые крошки собираются из того же списка:
  расхождение между разметкой и разметкой на экране Google считает ошибкой.
- **Open Graph**: `og:type=article`, обложка в `og:image`, плюс `article:published_time`,
  `article:modified_time`, `article:section`.
- **Canonical**: у записи — свой адрес; у списков — `Seo::listingCanonical` со страницей пагинации,
  иначе вторая и следующие страницы считаются дублями первой и перестают обходиться.
- **Sitemap**: `/blog`, темы с записями и сами записи попадают в `sitemap-pages.xml`.

## Товары в статье

В редакторе статьи есть блок **«Товары из каталога»** (кнопка «Блоки» на панели редактора). Он
встаёт в место курсора или перетаскивается в нужный абзац, а карандаш на блоке открывает его
настройки:

- **раздел каталога** — поиск по словам пути, как в «Категориях из фидов» (`CategoryPaths`).
  Подразделы входят в раздел, разделы 18+ не предлагаются;
- **сколько товаров** — от 1 до 12. Колонки подбираются под число: 1–2 — по две, 4 и 8 — по четыре,
  остальное — по три, на телефоне всегда по две;
- **слово в названии** — необязательно. От слов отрезаются окончания, поэтому «школьный» находит и
  «школьная», и «школьные». Если слов несколько, в названии должны быть все.

**Как устроено.** Это custom block Filament (`CatalogProductsBlock`), он зарегистрирован на модели
`BlogPost`. В HTML статьи хранится только настройка: `<div data-type="customBlock" data-config=…>`.
Товары подбираются при показе (`ArticleProducts`) в порядке раздела каталога по умолчанию: магазины
вперемешку, сначала заполненные карточки. Выводятся только публичные товары, 18+ не выводятся никогда:
блок видят все. Если ничего не нашлось, блок на сайте не выводится, а превью в редакторе прямо об этом
говорит.

- **Скорость.** Номера товаров кэшируются через `Cache::flexible`: час считаются свежими, до суток
  отдаются устаревшими и пересчитываются после ответа. Выборка по крупному разделу медленная: замер
  17.09.2026 — «Автотовары» (200 тыс. товаров) около 0,8 с, «Строительство и ремонт» (92 тыс.) около
  0,35 с, листовой раздел — миллисекунды. Посетитель эту выборку не ждёт. Сами карточки собираются на
  каждый показ: плашка «не доставляется в ваш город» зависит от посетителя.
- **Без повторов.** Продавцы выкладывают каждый размер и цвет отдельным товаром с тем же названием,
  и блок из трёх одинаковых карточек ничего не показывал. Повторы по названию пропускаются: на одно
  место просматривается до десяти кандидатов, освободившееся место занимает следующая модель. Если
  разных моделей в разделе меньше лимита, блок покажет столько, сколько их есть.
- **Рендер.** Статья с блоками идёт через рендер Filament `toUnsafeHtml()`. Штатный `toHtml()`
  прогоняет HTML через Symfony HtmlSanitizer, а тот вырезает из карточек Alpine и иконки, и кнопка
  «В корзину» оставалась пустой. Текст пишут только админы. Статья без блоков печатается как
  сохранена: разбор TipTap заменил бы в ней «+», «=» и кавычки на сущности.
- **Типографика.** Утилиты `<x-prose>` вида `[&_a]:…` достают до ссылок внутри карточек. Карточки
  отгораживает правило `.article-products` в `app.css`, намеренно вне `@layer`.

## Время чтения

`BlogPost::readingMinutes()` — по словам текста, 180 слов в минуту, минимум минута. Считается на
лету, а не хранится: текст правят, и сохранённое число молча разошлось бы с содержимым. По словам, а
не по символам — так честнее для русского текста с длинными словами.

## Тесты

`tests/Feature/BlogTest.php` (10): список, скрытие черновиков и отложенных записей, разметка Article
и BreadcrumbList, крошки с темой, время чтения, страница темы, невидимая неопубликованная тема,
адрес темы не перехватывается маршрутом записи, sitemap, ссылка в подвале.

`tests/Feature/BlogCatalogProductsBlockTest.php` (8): подбор по разделу с подразделами и по форме слова,
целая карточка (без санитайзера), лимит и только публичные товары, 18+ никогда, блок без раздела не
ломает статью, статья без блоков печатается как сохранена, основы слов, превью в редакторе, форма в
панели открывается.
