Контракт рендеринга
Это единый нормативный чек-лист, которому должен соответствовать <head> любого фронтенд-стека при выводе SEO-данных Rankbeam. Он служит источником требований для:
- модульных тестов структуры вывода рендерера в ядре (
tests/Unit/Services/RenderingContractTest.php) — быстрой части проверок без фреймворка, выполняемой в CI пакета; - эталонных приложений каждого стека в
rankbeam-examples(Blade, Inertia + Vue / React / Svelte, Livewire), чьи браузерные и SSR-тесты проверяют те же условия в реальном DOM; - руководств по фреймворкам (Blade, Inertia и JSON, Livewire), которые не должны описывать решения, нарушающие контракт.
Если стек не может выполнить пункт, это дефект или документированное ограничение, а не причина ослаблять контракт. Слой данных (SEOResolver → неизменяемый SEOData → TagRenderer) не зависит от фреймворка. Между стеками различается только то, как итоговые данные попадают в DOM, сохраняют корректность при клиентской навигации и остаются видимыми роботам, — именно это фиксирует контракт.
Эта спецификация была усилена по результатам независимого дизайн-ревью. Повторное ревью требуется только при существенном изменении.
1. Значения — содержимое соответствующего контракту <head>
Заголовок, описание, канонический URL
- Ровно один
<title>с итоговым заголовком, без двойного суффикса (резолвер добавляетseo.title_suffixодин раз, учитывая, что заголовок уже может им заканчиваться). - Одно метаописание, только если удалось определить описание (без пустого тега).
- Один
<link rel="canonical">.
Robots
- Выводите
<meta name="robots">только при отличии директивы от значения сайта по умолчанию. Избыточныйindex,followсоздаёт шум, а его отсутствие робот как раз трактует какindex,follow. Сравнение не учитывает пробелы (index, follow≡index,follow); отличающаяся директива выводится дословно.seo.robots.emit_default = trueпринудительно включает тег. - Поддерживайте детерминированные расширенные директивы:
noindex,nofollow,noarchive,nosnippet,max-snippet,max-image-preview,max-video-preview,notranslate,unavailable_after. Это итоговые строковые значения; их приоритет задаёт цепочка резолвера (глобальные → маршрут → модель → явные). Одинаковые входные данные ⇒ одинаковый результат.
Open Graph
og:title,og:description,og:type,og:url,og:site_name,og:locale.article:*(published_time,modified_time,author,section,tag) только приog:type === 'article'и наличии реального значения — без выдуманных данных и не на страницах, не являющихся статьями.og:imageсog:image:width/og:image:height/og:image:altиog:image:type, когда они известны. Несколько изображений группируются: сразу после каждогоog:imageидут его собственные свойства размеров, alt и типа.
Карточки Twitter
twitter:card,twitter:title,twitter:description,twitter:imageиtwitter:image:alt(когда известен alt изображения).twitter:siteиtwitter:creatorнеобязательны и независимы: одно может присутствовать без другого, ни одно не выводится искусственно из другого.
hreflang и локаль
- Для hreflang предусмотрен отдельный путь резолвера через хук модели
getSEOAlternates(). - Альтернативы hreflang, если они есть, должны быть абсолютными, нормализованными и уникальными по языку, а при полных данных — взаимными.
x-defaultвыводится только при настройке. og:locale:alternateотражает только локали с реальным вариантом для соцсетей (сопоставляйтеen-US→en_US; сравнивайте преобразованную форму, не требуйте буквального равенства).<html lang>должен соответствовать итоговой локали (пункт включён в контракт, хотя элемент<html>выводит приложение).
JSON-LD для каждой страницы
- Корректно разбирается и защищён от
</script>(данные кодируются сJSON_HEX_TAG, чтобы ни одно значение не могло преждевременно закрыть элемент script; это защита от хранимого XSS). - Допустимы и несколько блоков
<script>, и объединённый@graph. - Стабильный
@idиспользуется только там, где сущности действительно связаны (Organization ↔ WebSite ↔ WebPage); для отдельных узлов стабильный@idне обязателен.
2. Нормализация и инварианты
- Абсолютные URL
http(s)дляcanonical,og:url,og:image,twitter:image. Теги с пустыми или null-значениями никогда не должны попадать в DOM. canonicalиog:urlДОЛЖНЫ давать один и тот же нормализованный URL. Расхождение — КРИТИЧЕСКАЯ ошибка, а не предупреждение.- Политика нормализации канонического URL едина во всех точках вывода: схема, хост, порт, регистр пути, список разрешённых параметров запроса и завершающий слеш каждый раз обрабатываются одинаково. Индексируемые страницы ссылаются на себя; страница
noindexне наследует стратегию канонического URL другой страницы. - Экранирование зависит от контекста вывода: атрибут HTML, текст и JSON используют соответствующий кодировщик. Проверки сравнивают декодированные смысловые значения, а не байты.
- Соответствие между рендерерами — смысловое, а не побайтовое.
render()(HTML) ≡toArray()≡toInertiaHead()после нормализации: порядок и форма тегов в трёх представлениях могут обоснованно различаться. Правила для единичных и повторяемых свойств явные (одинog:title; несколькоarticle:tag). - Принадлежность тегов: клиентский рендерер заменяет теги пакета (по ключам, см. §4), не удаляя посторонние теги приложения.
3. Поведение — клиентская навигация
После каждого перехода Inertia или Livewire wire:navigate:
- существует ровно один экземпляр каждого единичного тега (
<title>, description, canonical, каждыйog:*/twitter:*), устаревших нет; - JSON-LD не накапливается: разметка предыдущей страницы удаляется, а не остаётся под новой (Livewire считает
<script>неудаляемым ресурсом, поэтому скрипты разметки помечаютсяdata-seo-schemaи идентификатором для каждого URL, а скрипты предыдущей страницы удаляются приlivewire:navigated— см. руководство по Livewire); - переход со страницы с подробными метаданными на страницу без них удаляет лишние теги (новая страница не сохраняет description/og/schema предыдущей);
- нет предупреждений гидратации, а метаданные до и после гидратации семантически одинаковы.
4. Ключи head в Inertia (принадлежность тегов)
toInertiaHead() добавляет стабильный head-key каждой записи meta/link. Inertia устраняет дубли элементов head по этому атрибуту: тег <Head> страницы с тем же head-key, что и у тега шаблона, заменяет его, а не добавляет дубль.
- Базовый ключ =
name ?? propertyдля meta,relдля ссылок. - Повторяемые теги получают различимые ключи, чтобы каждый оставался уникальным:
article:tag→article:tag,article:tag:1, …; hreflang →alternate:en-US,alternate:fr-FR.
В шаблонах привязывайте его как :head-key, а не как Vue :key (это другой ключ согласования v-for, который не влияет на устранение дублей head в Inertia).
5. Видимость для роботов (явные режимы)
- SSR / предварительный рендеринг ДОЛЖЕН выводить весь контракт в исходном HTML HTTP-ответа. Это проверяется отдельно от гидратированного DOM, с отключённым JS.
- Режим только CSR не может заявлять соответствие требованиям видимости для роботов. По умолчанию Inertia без SSR вставляет метаданные на клиенте: в исходном HTML, который загружает робот, SEO-метаданных нет. Это документируется, а не скрывается: для видимых роботам метаданных нужен Inertia SSR или предварительный рендеринг (JSON-LD для роботов также следует формировать на сервере).
6. За пределами контракта
- Ответственность приложения, а не рендерера:
charset,viewport, favicon. (Примечание:<meta charset>должен предшествовать любым метаданным с символами вне ASCII, поэтому порядок этих элементов head задаёт приложение.) - E2E проверяет только сформированный вывод. Он не подтверждает индексацию Google, выбор канонического URL, право на расширенные результаты или позиции; также он не проверяет MIME и доступность удалённого изображения. Это задачи необязательных интеграционных/HTTP-тестов, а не браузерной матрицы.
7. Состояние соответствия
Чем сегодня подтверждается каждый пункт. Модульные = RenderingContractTest (ядро, CI пакета). Браузер/SSR = rankbeam-examples (матрица по расписанию). Приложение = ответственность использующего пакет приложения. Запланировано = целевое требование контракта, данные которого ещё не представлены в SEOData, поэтому рендерер выводит безопасное подмножество.
| Пункт | Статус |
|---|---|
Ровно один итоговый <title>, без двойного суффикса | Модульные + Браузер |
| Метаописание только при наличии | Модульные + Браузер |
Один <link rel="canonical">, никогда не пустой | Модульные + Браузер |
Robots выводится только при отличии от значения по умолчанию; дословно; переключатель emit_default | Модульные + Браузер |
| Расширенные директивы robots с приоритетами резолвера | Модульные (резолвер) |
og:title/description/type/url/site_name/locale; локаль en-US→en_US | Модульные + Браузер |
article:* только при og:type=article и реальных данных | Модульные + Браузер |
og:image присутствует и абсолютен | Модульные + Браузер |
og:image:width/height/alt, og:image:type, группировка нескольких изображений | Запланировано — SEOData содержит одну строку ogImage; размеры, alt и тип ещё не представлены. Рендерер выводит один абсолютный og:image. |
twitter:card/title/description/image; site/creator независимы | Модульные + Браузер |
twitter:image:alt | Запланировано — поле alt изображения ещё не представлено. |
| hreflang абсолютен, уникален по языку | Модульные + Браузер |
Взаимность hreflang, x-default при настройке | Браузер (зависит от данных) |
og:locale:alternate отражает реальные варианты для соцсетей | Запланировано — карта вариантов для соцсетей по локалям ещё не представлена. |
Соответствие <html lang> | Приложение (+ проверка браузером) |
JSON-LD корректно разбирается и защищён от </script> | Модульные + Браузер |
Несколько скриптов ИЛИ @graph; стабильный @id при связанных сущностях | Модульные (граф merchant) + Браузер |
| Абсолютные URL; отсутствие пустых/null-тегов | Модульные + Браузер |
canonical ≡ og:url (критическая ошибка при расхождении) | Модульные + Браузер |
| Единая нормализация канонических URL; ссылки на себя; изоляция noindex | Браузер |
| Экранирование по контексту вывода; равенство декодированных смысловых значений | Модульные |
Смысловое соответствие между рендерерами (render() ≡ toArray() ≡ toInertiaHead()) | Модульные |
Стабильный head-key Inertia и различимые повторяемые теги | Модульные + Браузер |
| Клиентская навигация: один единичный тег, нет устаревших, JSON-LD не накапливается, лишние теги удаляются | Браузер — рендерер предоставляет необходимые для очистки хуки data-seo-schema |
| Нет предупреждений гидратации; соответствие до и после гидратации | Браузер |
| SSR выводит полный контракт в исходном HTML; режим только CSR документирован как несоответствующий | Браузер + документация |
Запланированные пункты — осознанные, документированные пробелы. Контракт остаётся долгосрочной целью; это аддитивные, обратно совместимые расширения для будущей задачи (им потребуются новые поля/столбцы SEOData, то есть минорный выпуск SemVer). Сегодня рендерер выводит безопасное подмножество и никогда не выдумывает отсутствующие значения.