Контракт документа страницы
Страница из секций в Dee Wan — обычная запись контента, в модели которой есть поле slides типа json
и поле title типа text. Поле slides содержит документ страницы: структурированные данные
с упорядоченным списком секций, текстом, ссылками и ссылками на медиа. В нём нет HTML, CSS
или скриптов. Внешний вид секций определяют ваш рендерер и тема.
Эта страница описывает документ, который рендерер получает из публичного API чтения, и то, как его разобрать.
Откуда берётся документ
Заголовок раздела «Откуда берётся документ»Получите запись страницы через GET /v1/<model>/<slug>?locale=<locale>. Документ находится в
data.fields.slides, а заголовок страницы — в data.fields.title. У каждой локали свой документ,
поэтому запрашивайте язык, на котором отображаете страницу.
const res = await fetch("https://example.com/v1/page/about?locale=en");if (!res.ok) throw new Error(`${res.status}`);const { data } = await res.json();const parsed = parsePublicRenderablePage(data.fields.slides);Запись также содержит data.page_contract.required — минимальную версию контракта,
поддерживающую эту страницу. См. Совместимость.
Где находится парсер
Заголовок раздела «Где находится парсер»Контракт — это небольшой модуль на TypeScript без зависимостей. Он не импортирует ничего за пределами своего каталога, поэтому работает в Cloudflare Workers, в браузерах и в Node.
- Сгенерированный сайт уже содержит неизменённую копию модуля в
src/lib/page-contract/. - В остальных случаях он находится в репозитории Dee Wan в
backend/src/lib/page-contract/. Опубликованного npm-пакета нет. Скопируйте весь каталог из той версии Dee Wan, которую вы запускаете, и замените его при обновлении. Не переписывайте типы вручную.
Какой парсер использовать
Заголовок раздела «Какой парсер использовать»| Функция | Возвращает |
|---|---|
parsePublicRenderablePage(raw) |
{ sections } |
parsePublicPageDocument(raw) |
разобранный документ |
Используйте parsePublicRenderablePage. Она превращает сохранённую страницу в один упорядоченный
список секций.
Каждый парсер принимает один аргумент — исходное значение fields.slides (объект или JSON-строку). Ему
не нужна база данных, и он не делает сетевых вызовов.
Каждый парсер возвращает значение и никогда не выбрасывает исключение:
type Parsed<T> = | { ok: true; value: T } | { ok: false; error: { code: string; message: string; detail: Record<string, unknown> }; };Если парсер отклонил страницу, не отображайте её частично. Покажите ошибку для этого URL.
Поддерживаемые форматы
Заголовок раздела «Поддерживаемые форматы»PAGE_FORMATS равен [2, 3]. Документ указывает format: 2, а если хотя бы одна его
секция закрепляет повторно используемый компонент — format: 3 (см. Повторно используемые компоненты).
Отсутствующий формат или любое другое значение отклоняется с кодом unreadable_page. Пустой или
отсутствующий документ тоже отклоняется с кодом unreadable_page, поэтому элемент без содержимого
страницы никогда не рендерится как пустая страница.
Формат 2 не меняется: его component равен null и больше ничему. Документ формата 3, в котором не
закреплён ни один компонент, отклоняется с кодом noncanonical_default, поэтому у одной страницы ровно
одно представление.
Формат определяет способ хранения страницы. Версия контракта — отдельное понятие: она определяет набор поддерживаемых возможностей (см. Совместимость).
Сохранённая страница
Заголовок раздела «Сохранённая страница»Каждая локаль хранит собственный документ. Структура (секции, их порядок, варианты
и раскладки) задаётся отдельно для каждой локали. Языковые версии одной страницы могут различаться
в любой части структуры. Содержимое локали находится в props, где ключи — id секций.
{ "format": 2, "sections": [ { "id": "intro", "type": "hero", "variant": "split", "layout": "default", "component": null }, { "id": "story", "type": "text_media", "variant": "balanced", "layout": "media_side=start", "component": null } ], "props": { "intro": { "heading": "Fresh cakes daily", "subheading": null, "media": null, "link": { "label": "See the menu", "href": "/menu" } }, "story": { "heading": "Our story", "paragraphs": ["We started in 2019."], "media": null } }}Правила:
- У каждой секции должны быть props, и каждая запись props должна принадлежать секции.
componentдолжен присутствовать. В странице формата 2 он равенnull. В странице формата 3 он равенnullили, для секцииcustom, ссылке на закреплённую версию компонента.- Props не должны повторять
id,typeили ключ раскладки, напримерmedia_side. Они относятся к структуре. - Структура секции может содержать необязательное поле
appearance. Как иvariant, оно задаётся отдельно для каждой локали. См. Оформление секции.
Что возвращает парсер
Заголовок раздела «Что возвращает парсер»Результат парсера имеет единую структуру:
type SectionType = | "hero" | "rich_text" | "text_media" | "feature_grid" | "cta" | "cards" | "stats" | "faq" | "testimonials" | "media" | "logos" | "steps" | "pricing" | "menu" | "contact" | "latest_entries" | "form" | "custom" | "search";
type RenderablePage = { sections: Array<{ id: string; type: SectionType; variant: string; layout: string; component: { component_id: string; version_id: string; artifact_digest: string; } | null; appearance?: { background?: "muted" | "inverse" | "accent"; spacing?: "compact" | "roomy"; motion?: "reveal" | "parallax"; }; /** `custom` sections only: the registered section version this section pins. */ definition?: { key: string; version: number; digest: string }; props: SectionPropsMap[SectionType]; }>;};В коде используется объединение типов с дискриминатором type, поэтому switch по section.type
сужает тип props. SectionPropsMap описывает props каждого типа секции.
Типы секций
Заголовок раздела «Типы секций»Необязательные значения при отсутствии возвращаются как null. Исключение — более поздние поля: их
просто нет. У текста удаляются начальные и конечные пробелы, кроме текста фрагментов форматирования.
В объектах списка допустим только заданный набор ключей. Неизвестные ключи отклоняются.
| Тип | Props |
|---|---|
hero |
heading (обязательно), subheading, media, link. Позже: eyebrow, secondary_link |
rich_text |
heading, paragraphs (обязательно, 1–12) |
text_media |
heading, paragraphs (обязательно, 1–12), media |
feature_grid |
heading, features (обязательно, 1–12 элементов вида { title, body }) |
cta |
heading (обязательно), body, link (обязательно). Позже: secondary_link |
cards |
heading, items (обязательно, 1–12 элементов вида { title, body, media, link }. title обязателен) |
stats |
heading, items (обязательно, 1–8 элементов вида { value, label, note }. value и label обязательны) |
faq |
heading, items (обязательно, 1–20 элементов вида { question, answer }, оба обязательны) |
testimonials |
heading, items (обязательно, 1–12 элементов вида { quote, name, role, media }. quote и name обязательны) |
media |
heading, items (обязательно, 1–24 элемента-изображения или видео) |
logos |
heading, items (обязательно, 1–24 элемента вида { media, name, link }. media и name обязательны) |
steps |
heading, items (обязательно, 1–12 элементов вида { title, body }. title обязателен) |
pricing |
heading, plans (обязательно, 1–4 элемента вида { name, price, period, description, features, link, highlighted }. name и price обязательны) |
menu |
heading, groups (обязательно, 1–8 элементов вида { title, items }. items содержит 1–30 элементов вида { name, description, price }, name обязателен) |
contact |
heading, body, address, hours (0–14 элементов вида { label, value }), phone, email, map_link, link |
latest_entries |
heading, model (обязательно), title_field (обязательно), cover_field, summary_field, date_field, limit (обязательно, целое число 1–12), empty_text (до 200 символов) |
form |
heading, body, fields (обязательно, 1–12 элементов вида { name, kind, label, required, help, options }), consent, submit_label (обязательно), success_text (обязательно) |
custom |
heading, fields (значения зарегистрированной секции, которую закрепляет структура) |
search |
heading, placeholder (до 80 символов) |
shared |
нет. Структура указывает общую секцию (shared.entry). Рендерер никогда её не получает. |
Ссылка — это { label, href }. Ссылка на медиа — это { media_id, url, alt }, где alt может быть
null. Медиа есть и в элементах списков: media в элементах cards, testimonials и logos, а
также media или poster в элементах media.
Ссылка на медиа может также содержать параметры кадрирования:
crop:{ x, y, width, height }— доли сохранённого изображения от 0 до 1. Ширина и высота больше 0, прямоугольник не выходит за изображение.focal:{ x, y }— доли от 0 до 1. Точка, которую нужно держать в кадре, когда блок обрезает изображение.
Ключ присутствует только при заданном значении и никогда не равен null. Любая другая структура отклоняется.
Кадрирование относится к одному использованию изображения, поэтому одно изображение может быть
кадрировано по-разному в двух секциях. Обложка в ответе списка (EntryMedia) содержит параметры
кадрирования записи в том же виде.
Более поздние поля
Заголовок раздела «Более поздние поля»| Поле | Значение |
|---|---|
hero.eyebrow |
Короткая строка над заголовком. |
hero.secondary_link |
Вторая ссылка. Допустима, только когда задан link. |
cta.secondary_link |
Вторая ссылка. |
Неиспользуемое поле не записывается — ни как null, ни как пустое. После разбора неиспользуемого
поля нет (undefined). При публичном чтении явно заданные null и "" отклоняются с кодом noncanonical_default.
Элементы списочных типов
Заголовок раздела «Элементы списочных типов»media: каждый элемент — это{ kind: "image", media, caption }или{ kind: "video", provider, video_id, title, poster, caption }. Изображению нуженmedia. Видео нужныprovider,video_idиtitle. См. Видео.pricing:features— список текстов, 0–12 записей. Отсутствие читается как[].highlightedравенtrueили отсутствует, и в секции выделен не более чем один тариф.falseотклоняется с кодомnoncanonical_default.contact: нужен хотя бы один изaddress,phone,email,map_linkили одна записьhours. Отсутствиеhoursчитается как[].phoneсодержит цифры, пробелы,(,),.и-, с необязательным+в начале, не короче 3 символов.email— один адрес, а не ссылкаmailto:.map_link.hrefдолжен начинаться сhttps://.logos: каждому элементу нужно изображение.
latest_entries
Заголовок раздела «latest_entries»Секция хранит настройки запроса и сопоставления полей, а не записи или курсор. model и каждое *_field — это slug
(^[a-z][a-z0-9_-]{0,63}$). При запросе страницы рендерер получает данные через публичный API списка записей сайта,
GET /v1/<model>, выбирая только сопоставленные поля: сначала новые по date_field, если оно задано
(записи без него — в конце), иначе сначала недавно опубликованные, не больше limit записей.
Отсутствующая модель или поле, отклонённый запрос или нечитаемый ответ — это ошибка рендеринга этой
секции, а не пустой список. empty_text или нейтральный текст показывается только при корректном
ответе без записей. Краткое описание из поля rich text перед вставкой рендерится функцией
renderRichDocument (rich-document.ts). Передайте разрешённые сайтом источники встраиваний
(GET /v1/_embeds) как embedProviders; без них вместо встраивания из настроенного источника отображается
обычная ссылка.
collection.ts содержит общий контракт запроса, который использует каждый рендерер Dee Wan:
entriesQuery, readEntriesPage, resolveLatestEntries, walkToPage и коды отказов в
COLLECTION_REFUSALS. Редактор описан в разделе Сайты с блогом.
Форма задаёт поля, но не адрес отправки, получателя или секрет. Адресат задаётся в настройках сайта (Настройки → Интеграции), и посетитель не может его выбрать.
- Каждое поле —
{ name, kind, label, required, help, options }. Все ключи указаны явно. name— 1–32 строчные буквы, цифры или_, начинается с буквы. Имена в форме уникальны.kind—text,email,phone,textarea,select,checkbox,number,dateилиfile.numberпринимает конечное десятичное число, в том числе в экспоненциальной записи, и сохраняет его как число JSON.dateпринимает существующую календарную дату в форматеYYYY-MM-DDс годом от 0001 до 9999, без преобразования часового пояса. Пустое необязательное поле сохраняется как""; обязательное поле нельзя оставить пустым.optionsсодержит 2–20 вариантов дляselectи равен[]для остальных видов.- У поля
fileможно также задатьaccept(до 10 точных MIME-типов или расширений),max_files(1–5, по умолчанию 1) иmax_bytes(1–5242880, по умолчанию 5242880). Другие виды эти ключи не принимают. Пустойacceptразрешает каталог изображений и документовFORM_FILE_TYPES; шаблоны типов не принимаются. На заявку допускается до 5 файлов общим размером до 10485760 байт. Содержимое файла должно соответствовать поддерживаемому формату. consent— согласие, которое посетитель должен принять, илиnull, если флажка согласия нет.
forms.ts — реестр взаимодействий и контракт отправки, общий для всех рендереров и сервиса приёма данных:
| Экспорт | Что это |
|---|---|
INTERACTIONS, FORM_SUBMIT_PATH |
Зарегистрированные действия (версия реестра 3): form.submit, отправляемое на /v1/interactions/form.submit; comment.submit, отправляемое на /v1/interactions/comment.submit; и comment.react, отправляемое на /v1/interactions/comment.react. |
readSubmission(raw) |
Объект отправки с фиксированным набором ключей: { action, page, locale, section, key, values, consent, website }. Всё остальное — null. |
checkSubmission(form, values, consent, files?) |
Проверка значений по опубликованной форме; для каждой проблемы возвращается FieldError. |
FORM_STATES, FORM_STATE_COPY, FIELD_PROBLEM_COPY |
Все отображаемые состояния формы и тексты для них. |
readSubmissionAnswer(raw), stateOfAnswer(answer) |
JSON-ответ сервиса приёма данных и соответствующее ему состояние формы. |
formViews(sections, context), readFormResult(query, section) |
Что рендерер передаёт компоненту формы, включая результат, прочитанный из адреса страницы. |
Формы с файловыми полями отправляют multipart/form-data: обычные значения в values.<name>,
флажки в checks.<name> со значением true, если отмечены, а файлы — повторяющимися частями
files.<name>. Поля action, page, locale, section, key, consent и website остаются
текстовыми частями. Для форм без файлов JSON-объект отправки не меняется. Файлы не входят в его
строковые и логические значения. Тело multipart ограничено 10 МиБ файлов и 64 КиБ служебных данных;
текстовые части — 16 КиБ. Точный повтор включает те же имена, типы, размеры и байты файлов и
возвращает первую квитанцию. Изменённый файл с прежним ключом отклоняется.
В сохранённых деталях может быть attachments: Record<fieldName, Array<{ id, name, type, size }>>.
Маршрут GET /api/forms/submissions/:id/attachments/:attachmentId отдаёт файл по праву site:infra
для выбранного сайта. Истёкшие, стёртые, отсутствующие и чужие вложения скачать нельзя.
Ключи хранилища и публичные ссылки не раскрываются. См. Файлы во вложениях.
website — ловушка для ботов: человек её не видит, а отправка со значением в ней получает ответ
«принято» и не сохраняется. key (16–64 буквы, цифры, _ или -) позволяет распознать повторную отправку:
тот же ключ с теми же значениями возвращает первую квитанцию, а с другими значениями отклоняется.
Страница никогда не содержит разметку формы. Рендерер отображает её компонентом платформы: в управляемом
рендерере src/runtime/Form.astro, в экспортированном сайте src/components/FormRuntime.astro.
Шаблон может разместить этот компонент, но не может самостоятельно добавлять <form> или <script>.
comments.ts — контракт комментариев для каждой модели, которая принимает комментарии: readCommentSubmission
и checkComment для комментария, readReactionSubmission и readReactionAnswer для реакции Helpful,
readCommentsPage и walkComments для одобренных комментариев, которые отдаёт GET /v1/_comments
в одном из порядков COMMENT_ORDERS (oldest, newest, best), и commentsView для содержимого
страницы записи: ветки со счётчиками реакций, ссылки для смены порядка, пагинацию, форму и результат отправки
из адреса страницы. Оба рендерера отображают комментарии компонентом платформы: src/runtime/Comments.astro в управляемом
рендерере (посты блога и страницы записей с маршрутом) и src/components/Comments.astro в
экспортированном сайте (посты блога и страницы записей моделей, которые принимают комментарии). Ответ не содержит
адреса электронной почты, статуса модерации или данных об авторе реакции. См. Комментарии посетителей.
Зарегистрированные секции (custom)
Заголовок раздела «Зарегистрированные секции (custom)»Разработчик может зарегистрировать типизированную секцию через управляемый шаблон. Её определение и
компонент Astro собираются в закреплённой офлайн-песочнице, а принятие шаблона с правом site:deploy
фиксирует их как неизменяемую версию. Страница закрепляет эту точную версию в структуре секции:
{ "id": "sl_team", "type": "custom", "variant": "grid", "layout": "default", "component": null, "definition": { "key": "team-grid", "version": 2, "digest": "<64 hex characters>" }}definitionобязателен для секцииcustomи отклоняется у любого другого типа. В странице формата 2componentостаётсяnull: в этом формате значение поля не меняет смысла.digest— SHA-256 канонического JSON{ definition, source }: определение, которое объявил разработчик, и точные байты компонента. Рендерер никогда не выбирает «последнюю» версию по ключу.- Props локали —
{ heading, fields }.fieldsсодержит значения определения: текст, флаг, ссылку, ссылку на медиа,nullили список элементов из них. - Без определения парсер проверяет
fieldsтолько по этой ограниченной грамматике: разметка, небезопасные ссылки и неверные медиа отклоняются всё равно. С ним (ParseContext.definitions) каждое значение проверяется по закреплённому определению, а неизвестное закрепление даётunknown_section_definition. sections.tsчитает определение (parseSectionDefinitionSource,parseSectionDefinition), классифицирует изменение между двумя версиями (definitionChange: без изменений, совместимое или нарушающее совместимость, с причинами) и переносит содержимое на новую версию (migrateCustomFields), оставляя только значения, которые новая версия ещё принимает.
Если у рендерера нет компонента по закреплённому адресу, он отклоняет страницу. Экспортированный
сайт содержит компонент каждой закреплённой версии в src/registered/<digest>/. Если для страницы
нужна версия, которую нельзя включить в сборку, сборка отклоняется.
Повторно используемые компоненты (формат 3)
Заголовок раздела «Повторно используемые компоненты (формат 3)»Повторно используемый компонент — это зарегистрированная секция, созданная из секции страницы: сгенерированная,
собранная в закреплённой изолированной песочнице, проверенная и принятая с правом site:deploy (см.
Создание и правка страниц). Страница, которая его использует,
хранится в формате 3, а секция custom закрепляет точную версию рядом со своим определением:
{ "id": "sl_team", "type": "custom", "variant": "grid", "layout": "default", "component": { "component_id": "rcp_…", "version_id": "rcv_…", "artifact_digest": "<64 hex characters>" }, "definition": { "key": "team-grid", "version": 2, "digest": "<64 hex characters>" }}componentпринимается только в странице формата 3 и только у секцииcustom, у которой есть иdefinition. Простое имя или slug отклоняется (invalid_slide): имя не говорит, какой код имелся в виду.component_idуказывает компонент сайта,version_id— неизменяемую версию, аartifact_digest— SHA-256 скомпилированной сборки, с которой эта версия была принята. Ссылкаdefinitionуказывает определение этой версии, поэтому рендерер используетdigest, как и для любой зарегистрированной секции.- Обработчик чтения, которому доступны компоненты сайта (
ParseContext.components), проверяет, что версия принята, а закреплённая версия соответствует определению. Иначе страница отклоняется сunknown_component_version. При разборе запроса без такого обработчика отклоняются любые ссылки на закреплённые версии: они не создаются из данных, которые сайт не может проверить. - Ничто не заменяет закрепление более новой версией: ни при отрисовке, ни при сохранении, ни при восстановлении. Более новая версия — это явная смена закрепления.
- Страница без закреплений остаётся в формате 2, а
storedPageзаписывает формат, который нужен её содержимому.
Собственное поле поиска сайта. Страница хранит только слова: heading и placeholder, оба
необязательны. Она не задаёт индекс, адрес API или запрос. Результаты берутся из поиска сайта, на
языке страницы.
- Редактор предлагает секцию, только пока поиск по сайту включён (
GET /api/pages/renderer-capability→site_search.active), при любом рендерере. - Управляемый релиз отображает её компонентом поиска платформы независимо от шаблона
(
PLATFORM_DRAWN_TYPES). Форма отправляетqна ту же страницу, которая читаетGET /v1/_searchи отображает результаты на той же странице. Шаблон не задаёт для неё компонента. - Экспортированный сайт отображает её своим компонентом
SiteSearch. Страница с такой секцией рендерится при каждом запросе и читаетGET /v1/_searchна собственном хосте сайта. Сайт с выключенным поиском не может собрать страницу с такой секцией. - Оба рендерера добавляют подсказки, пока посетитель печатает: форма читает собственный
/search?suggest=своего сайта и без JavaScript продолжает работать как обычная GET-форма. - Пока поиск выключен, проверка качества в редакторе блокирует сохранение страницы с такой секцией.
Общие секции (shared)
Заголовок раздела «Общие секции (shared)»Версия контракта 5. Общая секция — контент, который сайт хранит в одном месте и показывает на многих страницах: объявление, акция, содержимое подвала, призыв к действию. Это обычная запись зарезервированной модели shared_section (title и slides, как у страницы), которая редактируется, версионируется и публикуется как страница.
Страница хранит ссылку, а не копию:
{ "id": "notice", "type": "shared", "variant": "default", "layout": "default", "component": null, "shared": { "entry": "ci_…" }}Её props — {}. shared.entry — id записи, содержащей общую секцию. Каждая локаль хранит собственные ссылки. variant и layout — default. appearance у неё нет.
- Ссылка заменяется содержимым при выдаче страницы. Каждое публичное чтение (
/v1/:model,/v1/:model/:slug, предпросмотр), отрисовка текущей рабочей версии управляемого сайта, предпросмотр шаблона и экспортированный сайт заменяют ссылку ОПУБЛИКОВАННЫМИ секциями общей секции на языке страницы (действует цепочка запасных языков сайта). Id секций становятся<reference id>.<n>. Черновик нигде не показывается. Общая секция, у которой на этом языке ничего не опубликовано, ничего не показывает. Поэтому рендерер никогда не получаетtype: "shared":parsePublicRenderablePageотклоняет такую секцию сshared_section_unexpanded. - Кеш. Ответ со встроенной общей секцией содержит
instance:<shared section id>— тег, который очищает её публикация. - Что содержит общая секция. До
SHARED_SECTION_MAX(6) секций. Никаких других общих секций (shared_section_nested) и никаких секцийform,searchилиcustom(shared_section_type_unsupported). Слишком много секций —shared_section_too_many. Во всех этих случаях сохранение отклоняется. - Публикация страницы проверяет только публикуемые языки. Если нужная общая секция не опубликована ни на одном языке, возвращается
shared_section_not_live; если число секций после разворачивания превышаетLIMITS.slides—shared_section_page_limit. Публикация общей секции отклоняется сshared_section_page_limit, если её новая опубликованная версия превысила бы лимит на зависимой опубликованной странице. Связанные опубликованные версии и набор зависимых страниц проверяются повторно в том же пакете операций, который фиксирует публикацию. Параллельное изменение отклоняет публикацию с409 shared_section_publication_changed; повторите её с учётом текущего контента. Импорт воспроизводит сайт, где обе записи были опубликованы, поэтому пропускает первую проверку. - Снятие общей секции. Снятие общей секции с публикации перечисляет каждую использующую её страницу в подтверждении зависимостей (
field_keys: ["slides"]). Чтобы удалить её, сначала снимите её с публикации. После этого каждая страница ничего не показывает на её месте. - Не страница. У неё нет адреса, маршрута (
shared_section_not_routableв карте маршрутов), записи в карте сайта и записи в поиске. - Сохранённые паттерны остаются копиями: паттерн отклоняет общую секцию (
pattern_shared_section). - Каждая версия записывает используемые ею общие секции в
meta.shared_sections; они читаются через SQL, даже когда снимок сжат.
Варианты и раскладки
Заголовок раздела «Варианты и раскладки»| Тип | Варианты | Вариант по умолчанию | Раскладки |
|---|---|---|---|
hero |
centred, split, full_bleed |
centred |
default |
rich_text |
single_column, wide, narrow, statement |
single_column |
default |
text_media |
balanced, media_dominant, text_dominant |
balanced |
media_side=start, media_side=end |
feature_grid |
two_column, three_column, list |
three_column |
default |
cta |
banner, card, inline |
banner |
default |
cards |
grid, bento, scroller |
grid |
default |
stats |
row, grid, spotlight |
row |
default |
faq |
accordion, list, split |
accordion |
default |
testimonials |
spotlight, grid, scroller |
spotlight |
default |
media |
full_width, grid, masonry, scroller |
full_width |
default |
logos |
grid, row, marquee |
grid |
default |
steps |
numbered, timeline, columns |
numbered |
default |
pricing |
cards, compact, stacked |
cards |
default |
menu |
list, two_column, cards |
list |
default |
contact |
split, card, compact |
split |
default |
latest_entries |
grid, list |
grid |
default |
form |
stacked, split, card |
stacked |
default |
custom |
собственные варианты закреплённого определения | первый из них | default |
search |
default |
default |
default |
Соответствующие константы — SECTION_VISUAL_VARIANTS, DEFAULT_VISUAL_VARIANT и SECTION_LAYOUTS.
Оформление секции
Заголовок раздела «Оформление секции»Секция может содержать appearance рядом с variant:
{ "id": "story", "type": "rich_text", "variant": "wide", "layout": "default", "component": null, "appearance": { "background": "muted", "motion": "reveal" }}| Ключ | Значения | Когда ключа нет |
|---|---|---|
background |
muted, inverse, accent |
фон по умолчанию |
spacing |
compact, roomy |
обычные отступы |
motion |
reveal, parallax |
без движения |
- Каждое значение — это имя. Как оно выглядит, решает ваша тема.
- Ключ со значением по умолчанию не записывается. У секции без заданных ключей нет
appearanceвовсе. Пустой объект отклоняется с кодомnoncanonical_default. - Любой другой ключ или значение отклоняется с кодом
unknown_section_appearance. - Отключайте все анимации, если посетитель включил уменьшение движения
(
prefers-reduced-motion: reduce).
Соответствующая константа — SECTION_APPEARANCE.
| Что | Максимум |
|---|---|
| Слайдов или секций на страницу | 24 |
| Абзацев в секции | 12 |
| Фрагментов форматирования в абзаце | 32 |
| Элементов в сетке | 12 |
| Карточек, отзывов или шагов в секции | 12 |
| Показателей в секции | 8 |
| Вопросов FAQ в секции | 20 |
| Медиа-элементов или логотипов в секции | 24 |
| Тарифов в секции | 4 |
| Пунктов в тарифе | 12 |
| Групп меню в секции | 8 |
| Позиций в группе меню | 30 |
| Записей часов работы | 14 |
| Заголовок | 200 символов |
| Абзац, подзаголовок, текст CTA или контактов | 2000 символов |
| Строка над заголовком (eyebrow) | 80 символов |
| Подпись ссылки | 80 символов |
| URL ссылки или медиа | 2000 символов |
| Заголовок элемента сетки | 120 символов |
| Текст элемента сетки | 600 символов |
| Заголовок карточки или шага, название группы меню | 120 символов |
| Текст карточки или шага | 600 символов |
| Значение показателя | 24 символа |
| Подпись показателя | 80 символов |
| Примечание к показателю | 200 символов |
| Вопрос FAQ | 200 символов |
| Ответ FAQ | 2000 символов |
| Цитата отзыва | 600 символов |
| Имя человека | 80 символов |
| Должность человека | 120 символов |
| Подпись к медиа или название видео | 200 символов |
| Название логотипа | 80 символов |
| Название тарифа | 80 символов |
| Цена или период | 24 символа |
| Описание тарифа | 300 символов |
| Пункт тарифа | 120 символов |
| Название позиции меню | 120 символов |
| Описание позиции меню | 300 символов |
| Адрес | 500 символов |
| Подпись или значение часов работы | 80 символов |
| Телефон | 40 символов |
| 254 символа | |
| Id слайда или секции | 64 символа |
| Id медиа | 64 символа |
| Alt-текст медиа | 1000 символов |
| Имя варианта или раскладки | 64 символа |
| Поля формы | 12 |
| Подпись поля формы | 80 символов |
| Подсказка поля формы | 200 символов |
| Варианты в поле выбора | 20 |
| Вариант выбора | 80 символов |
| Текст согласия | 300 символов |
| Текст кнопки формы | 40 символов |
| Сообщение после отправки | 300 символов |
| Значения зарегистрированной секции | 24 |
| Элементы зарегистрированного списка | 24 |
| Текст зарегистрированного значения | 2000 символов |
| Подсказка в поле поиска | 80 символов |
Соответствующие константы — LIMITS и SECTION_LIMITS.
Правила содержимого
Заголовок раздела «Правила содержимого»Текст не содержит разметки. Заголовки, абзацы, подписи, текст элементов и фрагменты
форматирования не могут содержать <, >, <, >, числовые HTML-сущности, javascript: или
data:text/html. Выводите их как текст со стандартным экранированием шаблонизатора.
Ссылки (link.href и href во фрагменте форматирования) должны быть одним из следующего:
- путь на том же сайте, начинающийся ровно с одного
/. - URL
https://с именованным хостом (содержащим точку). - простой адрес
mailto:без параметров запроса.
URL медиа проверяются парсером URL:
| URL | Результат |
|---|---|
https://cdn.example.com/a.jpg (query и fragment допускаются) |
принимается |
/local-media/abc (ровно одна ведущая косая черта) |
принимается |
http://localhost…, http://127.0.0.1…, http://[::1]… |
принимается, для локальной разработки |
http:// с любым другим хостом |
отклоняется |
https:// без хоста |
отклоняется |
https://user:pass@host/a.jpg |
отклоняется |
//evil.example/a.jpg |
отклоняется |
| любая обратная косая черта или пробельный символ | отклоняется |
javascript:, data: или любая другая схема |
отклоняется |
Alt-текст медиа проверяется по длине, но не проверяется на разметку. Помещайте его в экранированный атрибут.
Встроенное форматирование
Заголовок раздела «Встроенное форматирование»В rich_text и text_media запись в paragraphs — это строка или форматированный абзац:
type RichParagraph = { inline: Array<{ text: string; bold?: true; italic?: true; href?: string }>; list?: "bullet" | "number";};Для каждого значения есть одно каноническое представление:
- Используйте объект, только когда в нём есть полужирное начертание, курсив, ссылка или список. Простой абзац
остаётся строкой. Объект без форматирования отклоняется с кодом
noncanonical_default. boldиitalicравныtrueили отсутствуют.falseотклоняется с кодомnoncanonical_default.- Два соседних фрагмента с одинаковым форматированием и ссылкой — это один фрагмент. Запись их по
отдельности отклоняется с кодом
noncanonical_default. inlineсодержит 1–32 фрагмента. Пробелы по краям фрагмента не удаляются, поэтому пробелы между фрагментами сохраняются. Фрагмент не может быть пустым, а абзац не может состоять только из пробелов.- Идущие подряд записи с одинаковым
listобразуют один список.
paragraphBlocks(paragraphs) превращает список абзацев в блоки для отрисовки: { kind: 'paragraph', runs }
или { kind: 'list', ordered, items }. Простая строка становится одним фрагментом без форматирования.
Экранируйте run.text. Никогда не собирайте из него сырой HTML. paragraphText(paragraph) возвращает
слова одного абзаца без форматирования.
Элемент видео содержит провайдера и id видео. Код встраивания и URL плеера в нём не хранятся.
provider |
video_id |
|---|---|
youtube |
ровно 11 латинских букв, цифр, _ или - |
vimeo |
от 6 до 12 цифр |
Любой другой провайдер или id неверного вида отклоняется с кодом unsafe_link. Это встроенные
правила реестра встраиваний (embed.ts), те же, что у встраивания в rich text, а редактор секции
читает вставленную ссылку на страницу просмотра, ссылку из меню «Поделиться», Shorts или плеер тем же парсером.
По умолчанию показывайте постер (или нейтральную рамку) и ссылку на страницу провайдера.
videoPageHref(item) возвращает эту ссылку. Не делайте запросов к третьим сторонам до клика посетителя
и не загружайте клиентский скрипт. Тема, которая проигрывает видео на странице, создаёт фрейм плеера
только после клика, с youtube-nocookie.com или player.vimeo.com, с dnt=1.
Коды отклонения
Заголовок раздела «Коды отклонения»DOCUMENT_REFUSALS перечисляет все коды, которые парсер может вернуть для документа страницы:
| Код | Значение |
|---|---|
unreadable_page |
Не документ страницы, пустой документ или слайд без пригодного id. |
unknown_slide_type |
Тип секции вне списка выше. |
invalid_slide |
Неверная форма, отсутствующее обязательное значение, неподдерживаемое свойство, некорректный телефон или email. |
markup_not_allowed |
Текст, содержащий разметку. |
unsafe_link |
Ссылка, нарушающая правила для ссылок, или недопустимый провайдер либо id видео. |
unknown_media |
Медиа, которого нет в библиотеке сайта. Публичные парсеры никогда его не возвращают. |
duplicate_slide_target |
Два слайда или две секции имеют один и тот же id. |
unknown_section_variant |
Вариант, которого тип не предлагает. |
unknown_section_layout |
Раскладка, которую тип не поддерживает. |
missing_section_props |
Секция без props для этой локали. |
orphan_section_props |
Props для секции, которой не существует. |
too_many |
Превышен лимит. Его значение указано в detail.max. |
noncanonical_default |
Значение в представлении, которое контракт не допускает. Пустое более позднее поле, false для признака форматирования или highlighted, форматированный абзац без форматирования, два фрагмента вместо одного или пустое appearance. |
unknown_section_appearance |
Ключ или значение оформления, которых контракт не предлагает. |
unknown_section_definition |
Зарегистрированная секция закрепляет версию, которой у этого обработчика чтения нет. |
unknown_component_version |
Секция формата 3 закрепляет версию повторно используемого компонента, которой у этого обработчика чтения нет, или версию, не совпадающую с её определением. |
shared_section_unexpanded |
Рендереру передали общую секцию. Этот код возвращает только parsePublicRenderablePage. |
Вспомогательные функции
Заголовок раздела «Вспомогательные функции»Контракт также экспортирует:
| Функция или константа | Возвращает |
|---|---|
paragraphBlocks(paragraphs) |
Абзацы в виде блоков для отрисовки. См. Встроенное форматирование. |
paragraphText(paragraph) |
Слова одного абзаца без форматирования. |
videoPageHref(item) |
Страницу провайдера для видео-элемента. |
slideStrings(slide) |
Все авторские строки, которые показывает слайд, в порядке чтения. Для секции передайте slideFromSection(section). |
pageMediaReferences(document) |
Все ссылки на медиа в документе, включая элементы списков. |
mapPageMedia(document, visit) |
Тот же документ, где каждая ссылка на медиа прошла через visit. Функция никогда не перемещает секцию и не меняет её вариант, раскладку или оформление. |
slideMediaPaths(type) |
Где тип может хранить медиа, в виде путей по ключам, где * — индекс в списке. |
SLIDE_ITEM_MEDIA_KEYS |
Ключи медиа внутри элементов списков, по типам. |
currentPageContractVersion() |
Версию контракта, которую читает эта копия (PAGE_CONTRACT_VERSION). |
isPageContractVersion(value) |
Является ли значение этой версией. Версия одна; любое другое значение даёт false. |
pageContractCapabilities(version) |
Одну запись из PAGE_CONTRACT_VERSIONS. |
PAGE_CONTRACT_VERSIONS |
Текущую версию контракта и то, что она читает. |
sectionTypeName(type) |
Название типа, как его показывает админка, например Text with image. |
componentSectionTypes(version) |
Типы, которые шаблон отображает своими компонентами: все типы, кроме custom и PLATFORM_DRAWN_TYPES. |
PLATFORM_DRAWN_TYPES |
Типы, которые управляемый релиз отображает компонентами платформы: search. |
DRAWN_SLIDE_TYPES, DrawnSlideType |
Каждый тип, который может получить рендерер: все, кроме shared. |
expandSharedSections(document, lookup) |
Документ, в котором каждая общая секция заменена секциями lookup(entry). Всегда проходит разбор. |
sharedEntries(document), storedSharedEntries(raw) |
Общие секции, которые использует разобранный или сохранённый документ. |
isMediaValue(value) |
Является ли одно значение зарегистрированной секции ссылкой на медиа. |
Аргумент document в этих функциях — уже разобранный документ. parsePublicPageDocument(raw)
возвращает его без базы данных.
Тестирование рендерера
Заголовок раздела «Тестирование рендерера»fixtures.ts в том же каталоге содержит документы, на которых Dee Wan тестирует собственный парсер:
ACCEPTED_PAGES, REFUSED_PAGES, STORED_ACCEPTED_PAGES и STORED_REFUSED_PAGES. Они покрывают каждый
встроенный тип секции, каждое более позднее поле, форматированные абзацы, каждое значение оформления
и значения ровно на каждом лимите. V4_REFUSED_FORMS содержит отклонённые формы. V4_CUSTOM_PAGES,
V4_CUSTOM_REFUSED и V4_CUSTOM_REFUSED_WITH_DEFINITION покрывают зарегистрированные секции по
SECTION_DEFINITION_FIXTURE. V5_SHARED_PAGE и V5_SHARED_REFUSED покрывают общие секции; V5_RENDERER_REFUSED — то, что рендерер обязан отклонить. Прогоните ваш рендерер по принятым наборам, чтобы проверить, что он
обрабатывает каждый тип секции. Они не экспортируются из
index.ts, поэтому не попадают в ваш продакшен-бандл. Импортируйте ./fixtures только в тестах.
Совместимость
Заголовок раздела «Совместимость»Форматы и версии контракта
Заголовок раздела «Форматы и версии контракта»Различайте два номера версии.
- Формат (
formatв документе,2или3) говорит, как страница хранится. Формат3содержит ссылки на закреплённые версии повторно используемых компонентов. Ваша копия контракта отклоняет форматы внеPAGE_FORMATS, а не угадывает. - Версия контракта (
PAGE_CONTRACT_VERSION, сейчас5) определяет набор возможностей: типы секций, варианты, раскладки, поля, ключи структуры и лимиты. Расширение возможностей (новый тип, необязательное поле, вариант, значение раскладки или ключ структуры) повышает её. Сохранённые страницы сохраняют свой формат и остаются корректными байт в байт.
PAGE_CONTRACT_VERSIONS содержит только текущую версию и точный набор её возможностей.
Эта версия поддерживает только указанный набор. Dee Wan вычисляет дайджест версии из собственных исходников контракта. Более поздняя версия
будет надмножеством этой.
Что сообщает публичный API
Заголовок раздела «Что сообщает публичный API»Каждый ответ /v1 содержит contracts.page.served — версию контракта, по которой он сформирован.
Каждая запись страницы содержит page_contract.required — версию контракта, поддерживающую её документ.
Сохранённый документ не меняется: fields.slides.format по-прежнему формат хранения.
{ "contracts": { "page": { "served": 5 } }, "data": { "id": "ci_…", "slug": "about", "model": "page", "locale": "en", "version": 4, "published_at": "2026-09-01T08:00:00.000Z", "fields": { "title": "About", "slides": { "format": 2, "sections": [ { "id": "intro", "type": "hero", "variant": "centred", "layout": "default", "component": null } ], "props": { "intro": { "heading": "About us", "subheading": null, "media": null, "link": null } } } }, "relations": {}, "media": {}, "page_contract": { "required": 5 } }}- Записи моделей без поля
slidesне содержатpage_contract. - С
fields=ключpage_contractсохраняется, только когда выбранslides. - Запись с нечитаемым документом страницы не содержит
page_contract. Парсер всё равно отклонит эту страницу.
Рендерер, собранный под версию N, рендерит страницу, только когда required равен N или меньше. Иначе
он считает всю страницу ошибкой для этого URL и показывает свою общую страницу ошибки. Он никогда не
рендерит часть страницы.
Как объявить версию рендерера
Заголовок раздела «Как объявить версию рендерера»Dee Wan проверяет рендерер предпросмотра через /.well-known/dee-wan-renderer.json.
versionравен2. Любой другой протокол отклоняется (preview_contract_incompatible).page_contract_version— версия контракта, под которую собран рендерер.renderer_contract_digestдолжен совпадать с дайджестом этой версии, иначе Dee Wan отклоняет рендерер с кодомrenderer_contract_unknown.page_formatsдолжен совпадать сPAGE_FORMATS.
{ "version": 2, "site_id": "site_…", "routes": { "page": "/preview/page/{slug}" }, "token_param": "preview", "page_contract_version": 5, "renderer_contract_digest": "<64 hex characters>", "page_formats": [2, 3]}Сгенерированный сайт отдаёт этот файл из src/lib/renderer-contract.ts. Сохранённая проверка без
page_contract_version не считается проверкой: проверьте рендерер заново.
Dee Wan отказывается публиковать страницу, которую активный рендерер не может отобразить
(renderer_capability_exceeded): зарегистрированную секцию, которой нет в активном управляемом
шаблоне. Сайт без проверенного рендерера по-прежнему может публиковать.
Мост редактора и ссылки на поля
Заголовок раздела «Мост редактора и ссылки на поля»editor_bridge в контракте рендерера необязателен. Dee Wan читает { "version": 1 } или
{ "version": 3 } и отбрасывает любое другое значение. Версия 1 выбирает секции. Версия 3 также связывает
каждое отрисованное значение с полем, которое его содержит. Сгенерированный сайт объявляет версию 3.
Более старые сгенерированные сайты сохраняют версию 1, и их предпросмотр работает как раньше; редактор
сообщает, что ссылки на поля недоступны.
Путь ссылки на поле указывает одно значение одной записи (field-links.ts). Сегменты соединяются через
/. Каждый сегмент — ключ поля, индекс в списке или ~ плюс id экземпляра в percent-кодировке: секции
страницы, экземпляра компонента (variant_id) или смешанного блока (_id).
| Путь | Значение |
|---|---|
slides/~hero-1/heading |
заголовок секции hero-1 |
slides/~hero-1/features/2/title |
заголовок третьего элемента features этой секции |
slides/~hero-1/link/label, slides/~hero-1/link |
текст кнопки, ссылка кнопки |
slides/~hero-1/media |
изображение секции |
faqs/~v1/question |
вопрос экземпляра компонента v1 |
body/3 |
блок 3 форматированного поля |
seo/image |
изображение SEO в месте его отображения |
readFieldLinkPath отклоняет любое другое представление пути. pageFieldLinks(sections) перечисляет для каждой
секции текстовые значения, ссылки и медиа, которые могут отображаться на странице. Альтернативный текст,
имена провайдеров и id не перечисляются. entryFieldLinks(id, values, rich) перечисляет страницу
записи, например пост блога: простые значения по ключу поля и каждое сохранённое форматированное значение
поблочно (body/3), со словами блока и медиа из библиотеки.
Сгенерированный сайт находит секцию по её обёртке data-dw-section. Управляемый рендерер находит её по
позиционному комментарию-маркеру секции, поэтому id в списке — это id этого маркера. Страница записи
помечает всю свою статью через data-dw-section, и выбор за пределами перечисленных значений не
указывает ни на одно поле. Для сайта с блогом оба встроенных рендерера объявляют маршрут модели post в своём
контракте.
Ссылки на поля присутствуют только в предпросмотре. Сгенерированный маршрут предпросмотра записывает
список как JSON (fieldLinksJson, безопасный внутри элемента <script>) и запускает мост. Мост находит
элементы, которые показывают каждое значение, и помечает их data-dw-field (путь) и
data-dw-occurrence (0 для первого элемента, показывающего это значение). Эти элементы становятся доступны для фокуса с клавиатуры.
Если у двух полей один и тот же текст, они сопоставляются с его элементами по порядку, и только когда
количества совпадают. Опубликованные страницы не содержат ни списка, ни атрибутов, ни моста.
Сообщения версии 3. Фрейм отправляет сообщения только на точный origin редактора из editor_origin. Сообщение
содержит editor_nonce из адреса фрейма. Обе стороны проверяют окно, origin, nonce и точный набор ключей и
игнорируют любое другое сообщение.
| От кого | Сообщение | Ключи помимо type, version: 3, nonce |
|---|---|---|
| фрейм | dee-wan:preview:ready |
current_version_id, locale, model, entry, links |
| фрейм | dee-wan:preview:pick |
path, occurrence |
| фрейм | dee-wan:preview:edit |
path, occurrence, anchor |
| фрейм | dee-wan:preview:close |
нет |
| редактор | dee-wan:preview:show |
path |
| редактор | dee-wan:preview:focus |
path |
| редактор | dee-wan:preview:clear |
нет |
Собственный рендерер может поддерживать версию 3 со своей разметкой: он сам помечает элементы и
отправляет те же сообщения. Для FIELD_LINK_BRIDGE нужны маркер идентичности ниже, JSON со ссылками
на поля и корневые элементы секций, описанные выше. Замените значения примера фактическими данными
отображаемой записи. data-dw-editor-version — id её текущей сохранённой версии, а не версия моста 3.
data-dw-editor-model содержит slug модели, data-dw-editor-entry — id записи, а data-dw-editor-locale —
язык отображаемой записи. При проверке готовности редактор сопоставляет версию и язык.
<div data-dw-editor-version="cver_example" data-dw-editor-locale="en" data-dw-editor-model="post" data-dw-editor-entry="instance_example" data-dw-editor-inplace hidden></div>Наличие data-dw-editor-inplace включает редактирование в предпросмотре. Для отключения удалите
атрибут: значение "false" тоже включает этот режим. Без него мост отправляет pick, и открываются
обычные поля редактора. Без маркера идентичности мост не запускается; обычный редактор остаётся доступен.
При включённом режиме выбор элемента отправляет edit вместо pick. Собственный мост может
отправить такое сообщение напрямую: target — выбранный элемент, editorNonce берётся из
editor_nonce, а editorOrigin — точный editor_origin из адреса фрейма.
const box = target.getBoundingClientRect();window.parent.postMessage( { type: "dee-wan:preview:edit", version: 3, nonce: editorNonce, path: "body/3", occurrence: 0, anchor: { x: box.x, y: box.y, width: box.width, height: box.height }, }, editorOrigin,);anchor — прямоугольник выбранного элемента в CSS-пикселях относительно области просмотра iframe
на момент выбора. Используйте результат getBoundingClientRect() напрямую: не добавляйте смещение
прокрутки страницы или положение iframe на родительской странице. В объекте ровно четыре ключа:
x, y, width, height. Все значения — конечные числа с модулем не более 100000; ширина и высота
неотрицательны. Отрицательные x/y допустимы. occurrence — целое число от 0 до 9999.
Добавлять anchor в pick нельзя: отправляйте edit.
path должен проходить readFieldLinkPath: не более 512 символов и 12 сегментов, первый — ключ поля.
Ключ соответствует [A-Za-z][A-Za-z0-9_]{0,63}; индекс — 0–9999 без ведущих нулей. Сегмент экземпляра —
~ и каноническое представление непустого id через encodeURIComponent; декодированный id содержит
не более 128 кодовых единиц UTF-16. Сообщение и anchor должны быть обычными объектами с точным набором ключей
из таблицы. Сообщения с лишними ключами или недопустимыми значениями игнорируются. links в сообщении
готовности — целое число от 0 до 999999.
В обычных записях поддерживаются поля text/rich верхнего уровня и отдельный блок rich-поля по индексу,
например body/3. Вложенные пути компонентов и составные страницы используют обычный выбор поля.
Редактор открывает свои обычные поля ввода только для видимого поля, которое пользователь может изменять.
Если путь не поддерживает редактирование в предпросмотре,
выполняется обычный выбор поля. Если поля нет в форме, редактор сообщает об этом, а не меняет данные.
Фрейм не записывает данные в CMS. Изменения попадают в несохранённую форму редактора с обычными проверками
прав и значений. Готово закрывает редактирование и возвращает фокус через dee-wan:preview:focus,
но ничего не сохраняет. Сохранить сохраняет запись. Получив focus, мост раскрывает и фокусирует
первый отображаемый элемент для path.
Стабильная версия ещё не выпущена (pre-MVP)
Коммит исходного кода: e207e63239a627fbd2ae9c414ef7c6eb19fcfe5a (с локальными изменениями)
Дата сборки (UTC): 2026-10-21T09:28:24.214Z
Коммит для обновления: не определён: в рабочей копии есть изменения. Примечания к выпуску