Рецепт собирает **любую** страницу из готовых блоков: лендинг, статью, рубрику
каталога, корзину, FAQ. Тип страницы определяется набором выбранных блоков —
сам рецепт универсален.

Годится и человеку со `curl`, и ИИ-агенту. Агент, поставивший
[скиллы](/docs/api/skills), делает всё это сам; здесь — что именно он делает.

## Подготовка

```bash
export API='https://page-craft.4partners.io/api/v1'
export KEY='pb_a1b2c3d4e5f6...'
```

Нужны `curl` и `jq`. Ключ создаётся по инструкции
[API-ключи](/docs/api/api-keys).

## Контракт `POST /pages` — прочитать до первого запроса

Половина ошибок здесь — не в содержимом, а в именах полей.

| Поле | Тип | Обяз. | Заметка |
|---|---|---|---|
| `name` | string | да | **не `title`** — именно `name` |
| `site_id` | integer | да | число, **не строка** |
| `kind` | string | нет | вид документа, по умолчанию `article`; список типов — `GET /page-types`. Неизменяем после создания |
| `slug` | string | нет | уникален в пределах сайта; **только у `kind=article`** |
| `folder_id` | integer | нет | ID папки внутри сайта |
| `meta_title` | string | нет | **плоское поле верхнего уровня**, не `meta: { title }` |
| `meta_description` | string | нет | так же плоское |
| `meta_keywords` | string | нет | так же плоское |
| `meta_robots` | string | нет | так же плоское |
| `blocks` | array | нет | элементы — см. шаг 4 |

Минимальный валидный payload:

```json
{ "site_id": 1, "name": "Заголовок", "slug": "page-slug", "blocks": [] }
```

## Шаг 1. Выбрать сайт

```bash
curl -s -H "Authorization: Bearer $KEY" "$API/sites?limit=50" | jq '.data[] | {id, name}'
```

Идентификатор нужного сайта — в `$SITE`.

## Шаг 2. Найти блоки

```bash
# поиском по названию и описанию
curl -s -H "Authorization: Bearer $KEY" "$API/blocks?search=hero&limit=20" \
  | jq '.data[] | {id, name, description}'

# или по тегам
curl -s -H "Authorization: Bearer $KEY" "$API/tags?limit=50" | jq '.data[] | {id, name}'
curl -s -H "Authorization: Bearer $KEY" "$API/blocks?tags=1,3&limit=20" | jq '.data[] | {id, name}'
```

Полнотекстовый поиск с ранжированием — `GET /search/blocks?q=…` (запрос
от двух символов).

## Шаг 3. Взять контракт каждого блока

**Критичный шаг, пропускать нельзя.** Контракт говорит, какие поля у блока есть
и какого они типа.

```bash
for BID in 3 7 12; do
  echo "=== Block $BID ==="
  curl -s -H "Authorization: Bearer $KEY" "$API/blocks/$BID/placeholder-schema" \
    | jq '{block_type, schema, example, binding_slots}'
done
```

| Поле ответа | Что означает |
|---|---|
| `block_type` | `schema` — обычный блок; `markdown` — WYSIWYG-блок |
| `schema` | JSON Schema (Draft-07) для `placeholder_values` |
| `example` | минимальный валидный пример из значений по умолчанию — можно отправить как есть |
| `binding_slots` | слоты живых данных с полями `name`, `allowed_sources`, `default`, `result_type`; пустой массив у статических блоков |

## Шаг 4. Заполнить `placeholder_values`

Структура зависит от `block_type`.

### `block_type: "schema"` — обычный блок

Поля заполняются строго по `schema`:

```json
{
  "block_id": 3,
  "placeholder_values": {
    "title": "Современный сайт для вашего бизнеса",
    "subtitle": "Запустим лендинг за 3 дня",
    "cta_label": "Оставить заявку",
    "cta_url": "#contact"
  }
}
```

Соответствие типов полей формату значения разобрано на странице
[Схема полей](/docs/blocks/schema). Поля, которых нет в схеме, но которые
использует Liquid-шаблон, тоже допустимы — валидация их не отклоняет.

### `block_type: "markdown"` — WYSIWYG-блок

Основной контент кладётся в зарезервированный ключ `_markdown`; поля схемы
остаются опциональными и отвечают за оформление.

```json
{
  "block_id": 7,
  "placeholder_values": {
    "_markdown": "## О компании\n\nМы делаем сайты с 2018 года.\n\n- Лендинги за 72 часа\n- 50+ проектов"
  }
}
```

Формат — GFM (GitHub Flavored Markdown): заголовки `#`, списки, ссылки,
`**bold**`, `~~strike~~`, картинки, цитаты, блоки кода и таблицы:

```
| Заголовок 1 | Заголовок 2 |
| ----------- | ----------- |
| Ячейка 1    | Ячейка 2    |
```

У таблицы обязательна строка-шапка и разделитель `---` под ней; перенос строки
внутри ячейки — `<br>`. Сервер конвертирует Markdown в HTML при публикации.
Подробнее — [Markdown в блоках](/docs/templates/markdown).

### Блоки с живыми данными

Если `binding_slots` непустой, слоту назначается источник. PageCraft подставляет
в предпросмотр заглушки (`stub`), поэтому «есть ли данные» проверять негде —
важно правильно описать, **что должно прилететь на витрине**. Значения слотов
по умолчанию — разумная заглушка, но не замена осмысленному выбору `params`.

```json
{
  "block_id": 12,
  "placeholder_values": { "headline": "Свяжитесь с нами" },
  "bindings": {
    "products": {
      "source": "catalog.products",
      "params": { "limit": 8, "sort": "popular", "filter": { "rubric": "women-shoes" } }
    }
  }
}
```

**Для каждого слота проходятся три запроса — это не опция.**

**1. Определение источника** (для каждого значения из `allowed_sources`):

```bash
curl -s -H "Authorization: Bearer $KEY" "$API/data-sources/catalog.products" | jq '.'
```

В ответе: `id`, `title`, `description` — назначение источника; `result_type_id` —
идентификатор формы данных; `params_schema` — **JSON Schema объекта `params`**.
Ключи `properties` — ровно то, что кладётся в `params`. Не угадывайте.

Все источники: `GET /data-sources?limit=50`.

**2. Форма результата** — что источник передаст в шаблон:

```bash
curl -s -H "Authorization: Bearer $KEY" "$API/data-result-types/offer_list" | jq '{schema_json, stub}'
```

`schema_json` — JSON Schema результата, `stub` — готовый пример. В предпросмотре
подставляется именно `stub`, на витрине — реальные данные той же формы.
Список всех типов: `GET /data-result-types?limit=50`.

**3. Вместимость блока против `params.limit`:**

```bash
curl -s -H "Authorization: Bearer $KEY" "$API/blocks/12" | jq -r '.current_revision.template_content'
```

В шаблоне ищется конструкция вывода (`{% for item in products limit: 8 %}`) или
фиксированное число слотов в разметке. `params.limit` должен совпадать с ним:
`limit: 15` при `limit: 8` в шаблоне — семь товаров потеряны; `limit: 3`
при восьми слотах — полблока пустое.

**Три оси подбора `params`:**

1. **Что брать** — `filter`, `slug`, `category_id`. Конкретный ресурс под смысл
   секции, а не «всё подряд».
2. **Сколько брать** — `limit`, синхронизированный с вместимостью блока.
3. **В каком порядке** — `sort` по смыслу секции: «хиты» → `popular`,
   «новинки» → `newest`, «акции» → `discount`.

Разбор слотов и источников целиком — [Биндинги и слоты](/docs/data/bindings).

## Шаг 5. Создать страницу

```bash
cat > /tmp/page-payload.json <<EOF
{
  "name": "Страница нового продукта",
  "site_id": $SITE,
  "slug": "new-product-2026",
  "meta_title": "Новый продукт — лучшее решение",
  "meta_description": "Краткое описание для поисковиков.",
  "blocks": [
    { "block_id": 3, "placeholder_values": { } },
    { "block_id": 7, "placeholder_values": { } },
    { "block_id": 12, "placeholder_values": { }, "bindings": { } }
  ]
}
EOF

curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  --data-binary @/tmp/page-payload.json "$API/pages" | jq '.'
```

Порядок элементов `blocks[]` задаёт порядок блоков на странице. Успех — `201`
с созданной страницей; её `id` пригодится дальше как `$PID`. Ответ разворачивает
`blocks[]` с id созданных вхождений — они нужны, если дальше вы кладёте блоки
в слоты (`parent_page_block_id`), и отдельный запрос за ними не требуется.

## Шаг 6. Разобрать ошибку валидации

Не создалась — в ответе `errors[]`:

```json
{
  "error": "Ошибка валидации блоков",
  "errors": [
    { "path": "blocks[0].placeholder_values.title", "message": "поле обязательно" },
    { "path": "blocks[2].bindings.products.source", "message": "источник не разрешён для слота" },
    { "path": "site_id", "message": "This value should be of type int." }
  ]
}
```

Как чинить:

1. Найдите место в payload по `path` — dot-путь `blocks[0].placeholder_values.title`
   для блочной валидации, имя корневого поля (`site_id`, `name`, `slug`) для ошибок
   верхнего уровня.
2. Исправьте по `message`. Типичное: «поле обязательно» — добавить поле;
   «источник не разрешён» — свериться с `binding_slots[*].allowed_sources`;
   `should be of type int/string` — привести тип; «should not be blank» — заполнить.
3. Повторите запрос.

Отдельный случай — **`409`**: `slug` уже занят другой страницей этого сайта.
В `errors[]` придёт запись с `path: "slug"`; поменяйте slug и повторите.

## Шаг 7. Разобрать `404`

`404` с текстом вида `blocks[i]: блок N не найден` означает, что блок удалён или
недоступен вашему ключу. Подберите замену через `GET /blocks?search=…`
и повторите шаги 3–5.

## Шаг 8. Проверить результат

```bash
curl -s -H "Authorization: Bearer $KEY" "$API/pages/$PID?with_blocks=1" \
  | jq '{id, name, blocks: [.blocks[] | {block_id: .block.id, name: .block.name, order_index, placeholder_values}]}'
```

Без `with_blocks=1` поле `blocks` равно `null` — это не пустая страница,
а «блоки не запрашивались». Правило про `null` касается только `GET`: ответы
на создание и на изменяющие операции разворачивают блоки всегда.

## Шаг 9. Правки и уборка

| Действие | Эндпоинт |
|---|---|
| Заменить состав блоков целиком | `PUT /pages/{id}` с `blocks` |
| Изменить только метаданные | `PUT /pages/{id}` без `blocks` |
| Дописать блоки (опционально с `position`) | `POST /pages/{id}/blocks` |
| Поправить значения одного блока | `PUT /pages/{pageId}/blocks/{pbId}` |
| Переставить блок | `PUT /pages/{pageId}/blocks/{pbId}/move` |
| Заменить блок другим | `PUT /pages/{pageId}/blocks/{pbId}/replace` |
| Снять блок со страницы | `DELETE /pages/{pageId}/blocks/{pbId}` |
| Подтянуть minor-версии всех блоков | `POST /pages/{pageId}/blocks/upgrade-all` |
| Удалить страницу | `DELETE /pages/{id}` → `204` |

Обновление до major-версии блока не выполняется автоматически: `upgrade` вернёт
`422` с `error: "major_upgrade_blocked"`, списком удалённых полей и текущей
привязкой версии. Это не сбой, а требование перенести значения вручную —
см. [Устаревание и апгрейды](/docs/blocks/deprecation).

## Шаг 10. Опубликовать на витрину

Страница в PageCraft и статья на витрине — разные сущности. Пока страница
не опубликована, её никто, кроме вас, не видит.

**10a. Узнать статус.** Запрос локальный, во внешний API не ходит:

```bash
curl -s -H "Authorization: Bearer $KEY" "$API/pages/$PID/publication" | jq '.'
# → { "is_published": false, "external_id": null, "external_slug": null,
#     "published_at": null, "updated_at": "..." }
```

**10b. `is_published: false` — первая публикация:**

```bash
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{
    "rubric_ids": [42],
    "meta_title": "Новый продукт | Мой сайт",
    "meta_description": "Краткое описание для поисковиков.",
    "slug": "new-product",
    "preview_text": "Анонс для списка статей."
  }' "$API/pages/$PID/publish" | jq '.'
```

Обязательное поле одно — `rubric_ids` (минимум один идентификатор рубрики 4CMS).
Остальные опциональны; `name` по умолчанию берётся из `name` страницы.

У документа с `kind` ≠ `article` (шаблон, макет) тело публикации пустое — `{}`:
`rubric_ids`, `slug` и `meta_*` шаблону не передаются, их ведёт 4CMS. Подробности —
[Публикация и обновление](/docs/pages/publishing#публикация-шаблона-и-макета).

**Список рубрик это API не возвращает** — `rubric_ids` берутся напрямую из API
4partners.io.

**10c. `is_published: true` — обновление опубликованной статьи:**

```bash
# перегенерировать HTML/CSS/JS блоков, метаданные на витрине оставить как есть
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{}' "$API/pages/$PID/publish-update" | jq '.'

# или поменять часть метаданных
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"meta_title":"Новый заголовок","rubric_ids":[42,51]}' \
  "$API/pages/$PID/publish-update" | jq '.'
```

Оба эндпоинта отвечают тем же объектом статуса, что и `GET /pages/{id}/publication`.

**Ошибки публикации:**

| Код | Причина |
|---|---|
| `400` | вызван `publish` на уже опубликованной странице — нужен `publish-update` |
| `400` | вызван `publish-update` на неопубликованной — нужен `publish` |
| `400` | у страницы нет сайта — сначала `PUT /pages/{id}` с `site_id` |
| `404` | страница не найдена или принадлежит другому пользователю |
| `502` | ошибка провайдера (4CMS): неверный токен, несуществующая рубрика и подобное |

## Чеклист

- [ ] сайт выбран, `site_id` известен
- [ ] блоки отобраны
- [ ] для каждого вызван `GET /blocks/{id}/placeholder-schema`
- [ ] `placeholder_values` заполнены по `schema`
- [ ] `bindings` заданы только для блоков с непустым `binding_slots`
- [ ] по каждому слоту пройдены `data-sources` и `data-result-types`
- [ ] `params.limit` сверен с вместимостью шаблона
- [ ] `POST /pages` отправлен с `site_id`
- [ ] `errors[]` разобраны, запрос повторён
- [ ] `GET /pages/{id}?with_blocks=1` подтверждает состав
- [ ] перед публикацией вызван `GET /pages/{id}/publication`
- [ ] при первой публикации передан непустой `rubric_ids[]`

## Источники

- `openapi/v1.json` — пути, коды ответов, схемы `CreatePagePublicRequest`,
  `PageBlockInput`, `BlockPlaceholderSchemaResponse`, `PublishPagePublicRequest`,
  `PagePublicationStatusResponse`, `MajorUpgradeBlockedError`
- `backend/src/Controller/V1/Page/**`, `backend/src/Controller/V1/PageBlock/**`
- `backend/src/Exception/BlockPayloadValidationException.php`,
  `backend/src/Exception/SlugConflictException.php`
- предшественник страницы: `backend/public/docs/agents/playbooks/page-factory.md`