# Compose pages through the API

Insert patterns, place components, edit a component's fields and variants, and choose a page's Layout from a script, with a dry run before each change.

Everything an editor does to compose a page can be done through the API: insert a
pattern, place a component, change a placement's values or variant, choose a page's
Layout, and edit a component's exposed fields and variants. A script or an agent can
ask what a change would do before making it.

> [!WARNING]
> These routes are experimental: their requests and answers may still change
> before they are marked stable.

The routes are plugin routes under `/admin/api/plugins/@nextlyhq/plugin-page-builder`.
They run as the caller, signed in or with an API key, with the same checks as the
admin, and answer in the usual `{ message, item }` shape.

## How every action works

* **`dryRun: true`** answers what the action would write, and whether the site would
  accept it, and writes nothing. The write is made as the caller and rolled back, so
  the site's own checks answer: field rules over the whole page, locks, component
  loops and the caller's rights. Why the site would refuse it is in `item.refusals`,
  empty when it would accept it. The answer shows the item as the save would store it,
  after the site's hooks.
* **Hooks run for a dry run's write too**, with `context["pageBuilder.transferJudgement"]`
  set to `true`. A hook with an effect a rollback cannot undo, such as sending a message,
  should skip it then and act on the write that commits.
* **`item.fingerprint`** names the stored item the answer was planned against. Send it
  back as **`expect`** and the action applies only to that item: one that changed since
  answers `409`; plan again. An action is also refused with `409` when someone else saves
  the item between the action's read and its write.
* **`item.by`** says who the action ran as: `{ kind: "user", userId }`, or
  `{ kind: "api-key", userId, apiKeyId }`. A change made by a signed-in person is
  recorded against them on the item's content events.
* **`publish: true`** publishes the item with the change and needs the publish right.
  Without it the change is saved to the item's working draft, as the editor saves it.

An action the site refuses answers `422`, with each reason in the error's
`data.errors`, and writes nothing.

## Change a page

```http
POST /admin/api/plugins/@nextlyhq/plugin-page-builder/pages/actions
Content-Type: application/json

{
  "page": { "collection": "pages", "id": "<page id>" },
  "action": { "kind": "insert-pattern", "patternId": "<pattern id>" },
  "dryRun": true
}
```

`page` may also name the blocks `field` (`content` by default) and, for a translated
field, the `locale`; a field that holds no blocks is refused. The caller needs **update**
on the page's collection. A collection with a Draft/Published lifecycle but no working
drafts saves an edit straight onto the published entry, so there the caller needs
**publish** too. `action` is one of:

| `kind`                 | Fields                                      | What it does                                                                                                                    |
| ---------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `insert-pattern`       | `patternId`, `at?`                          | Copies a published pattern's blocks into the page, as the editor's insert panel does.                                           |
| `place-component`      | `componentId`, `variant?`, `values?`, `at?` | Places a component by reference, with a variant's id and values keyed by exposed field id.                                      |
| `set-placement-values` | `instanceId`, `values?`, `reset?`           | Sets a placement's values. `{ "$unset": true }` empties a field; `reset` lists fields to restore.                               |
| `choose-variant`       | `instanceId`, `variant`                     | Chooses a variant by its id, or `null` for the component's defaults.                                                            |
| `set-layout`           | `layout`, `noHeaderFooter?`                 | Chooses the page's Layout by id, or `null` for the site default. `noHeaderFooter: true` switches it off. Pages collection only. |

`at` is `{ "index": 2 }` for the page's top level, or `{ "parentId", "slot", "index" }`
inside a block; without it the block goes at the end of the page. The answer holds the
`ops` the change makes, the page's blocks as it leaves them (`document`) or its Layout
fields (`layout`), and `insertedId`, the new block's id for the next action to address.
New blocks get fresh ids on every write, as in the editor, so the ids in a dry run's
answer are not the ones an apply stores: address what the apply's own answer names.

## Change a component's definition

```http
POST /admin/api/plugins/@nextlyhq/plugin-page-builder/components/:id/definition
Content-Type: application/json

{ "action": { "kind": "add-variant", "label": "Dark" }, "dryRun": true }
```

The caller needs **update** on components. Each edit is held to the rules of the
component's own view: a variant's label is unique, ids are stable and never handed out
twice, a removed field's id is never reused, and a value must fit its field.

| `kind`                | Fields                                                 |
| --------------------- | ------------------------------------------------------ |
| `expose`              | `label`, `nodeId`, and `propPath` or `visibility`      |
| `rename-exposure`     | `exposureId`, `label`                                  |
| `retarget-exposure`   | `exposureId`, `nodeId`, and `propPath` or `visibility` |
| `remove-exposure`     | `exposureId`                                           |
| `remove-exposed-slot` | `slotId`                                               |
| `add-variant`         | `label`                                                |
| `rename-variant`      | `variantId`, `label`                                   |
| `delete-variant`      | `variantId`                                            |
| `set-variant-value`   | `variantId`, `exposureId`, `value`                     |
| `reset-variant-value` | `variantId`, `exposureId`                              |

The answer holds the `definition` as the edit leaves it, `targetId` (the field, slot or
variant made or changed), `warnings`, and, when a removed field emptied variants,
`prunedVariants`. A refused edit answers `422` with the planner's reason as the error
code, such as `label-taken`.

## Other routes with a dry run

* `POST /save-as-pattern` takes `dryRun: true` and answers the pattern it would save,
  with `refusals` saying why the site would refuse it.
* [Making a Layout the default and removing one](https://nextlyhq.com/docs/page-builder/layouts.md) both take
  `dryRun`, and their answers carry `by`.
* [Importing a file](https://nextlyhq.com/docs/page-builder/moving-between-sites.md#through-the-api) takes
  `dryRun` and `expect` in the same way.
