Перейти к содержимому
Версия 1.0 ещё не выпущена; проект находится на стадии pre-MVP. См. возможности и ограничения версии 1.0.

Контракт документа страницы

Страница из секций в 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: каждому элементу нужно изображение.

Секция хранит настройки запроса и сопоставления полей, а не записи или курсор. 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 в экспортированном сайте (посты блога и страницы записей моделей, которые принимают комментарии). Ответ не содержит адреса электронной почты, статуса модерации или данных об авторе реакции. См. Комментарии посетителей.

Разработчик может зарегистрировать типизированную секцию через управляемый шаблон. Её определение и компонент 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 и отклоняется у любого другого типа. В странице формата 2 component остаётся 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-форма.
  • Пока поиск выключен, проверка качества в редакторе блокирует сохранение страницы с такой секцией.

Версия контракта 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 символов
Email 254 символа
Id слайда или секции 64 символа
Id медиа 64 символа
Alt-текст медиа 1000 символов
Имя варианта или раскладки 64 символа
Поля формы 12
Подпись поля формы 80 символов
Подсказка поля формы 200 символов
Варианты в поле выбора 20
Вариант выбора 80 символов
Текст согласия 300 символов
Текст кнопки формы 40 символов
Сообщение после отправки 300 символов
Значения зарегистрированной секции 24
Элементы зарегистрированного списка 24
Текст зарегистрированного значения 2000 символов
Подсказка в поле поиска 80 символов

Соответствующие константы — LIMITS и SECTION_LIMITS.

Текст не содержит разметки. Заголовки, абзацы, подписи, текст элементов и фрагменты форматирования не могут содержать <, >, &lt;, &gt;, числовые 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 вычисляет дайджест версии из собственных исходников контракта. Более поздняя версия будет надмножеством этой.

Каждый ответ /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
Коммит для обновления: не определён: в рабочей копии есть изменения. Примечания к выпуску