Перейти к содержимому

Контракт рендеринга

Это единый нормативный чек-лист, которому должен соответствовать <head> любого фронтенд-стека при выводе SEO-данных Rankbeam. Он служит источником требований для:

  • модульных тестов структуры вывода рендерера в ядре (tests/Unit/Services/RenderingContractTest.php) — быстрой части проверок без фреймворка, выполняемой в CI пакета;
  • эталонных приложений каждого стека в rankbeam-examples (Blade, Inertia + Vue / React / Svelte, Livewire), чьи браузерные и SSR-тесты проверяют те же условия в реальном DOM;
  • руководств по фреймворкам (Blade, Inertia и JSON, Livewire), которые не должны описывать решения, нарушающие контракт.

Если стек не может выполнить пункт, это дефект или документированное ограничение, а не причина ослаблять контракт. Слой данных (SEOResolver → неизменяемый SEODataTagRenderer) не зависит от фреймворка. Между стеками различается только то, как итоговые данные попадают в DOM, сохраняют корректность при клиентской навигации и остаются видимыми роботам, — именно это фиксирует контракт.

Эта спецификация была усилена по результатам независимого дизайн-ревью. Повторное ревью требуется только при существенном изменении.


1. Значения — содержимое соответствующего контракту <head>

Заголовок, описание, канонический URL

  • Ровно один <title> с итоговым заголовком, без двойного суффикса (резолвер добавляет seo.title_suffix один раз, учитывая, что заголовок уже может им заканчиваться).
  • Одно метаописание, только если удалось определить описание (без пустого тега).
  • Один <link rel="canonical">.

Robots

  • Выводите <meta name="robots"> только при отличии директивы от значения сайта по умолчанию. Избыточный index,follow создаёт шум, а его отсутствие робот как раз трактует как index,follow. Сравнение не учитывает пробелы (index, followindex,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-USen_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:tagarticle: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-USen_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-теговМодульные + Браузер
canonicalog:url (критическая ошибка при расхождении)Модульные + Браузер
Единая нормализация канонических URL; ссылки на себя; изоляция noindexБраузер
Экранирование по контексту вывода; равенство декодированных смысловых значенийМодульные
Смысловое соответствие между рендерерами (render()toArray()toInertiaHead())Модульные
Стабильный head-key Inertia и различимые повторяемые тегиМодульные + Браузер
Клиентская навигация: один единичный тег, нет устаревших, JSON-LD не накапливается, лишние теги удаляютсяБраузер — рендерер предоставляет необходимые для очистки хуки data-seo-schema
Нет предупреждений гидратации; соответствие до и после гидратацииБраузер
SSR выводит полный контракт в исходном HTML; режим только CSR документирован как несоответствующийБраузер + документация

Запланированные пункты — осознанные, документированные пробелы. Контракт остаётся долгосрочной целью; это аддитивные, обратно совместимые расширения для будущей задачи (им потребуются новые поля/столбцы SEOData, то есть минорный выпуск SemVer). Сегодня рендерер выводит безопасное подмножество и никогда не выдумывает отсутствующие значения.

rankbeam/laravel-seo распространяется под лицензией MIT.