# Composing pages

> The page composer writes real .astro files from your own section components: four panes, declared props only, and a parser that refuses rather than rewrites.

Source: https://editor.astrocraftthemes.com/docs/page-composer/

The composer builds pages out of **your** section components and writes them as `.astro` files under `src/pages/`. It is the one place in the CMS that writes code rather than content, which changes almost every rule about it.

## Four panes

| Pane      | Width | Holds                         |
| :-------- | :---- | :---------------------------- |
| Rail      | 56px  | the sidebar rail              |
| Outline   | 216px | the page's sections, in order |
| Canvas    | 688px | the page, rendered            |
| Inspector | 320px | the selected section's props  |

The canvas is a **real render of the page**, not a wireframe mock-up. Two modes: **COMPOSE**, where every click in the page is cancelled so clicking selects a section, and **PREVIEW**, which hands the clicks back so you can use the page.

## The library lists your components

Discovered by reading the two directories you named in `src/admin.config.ts` — recursively, files before subdirectories, each sorted. A component appears because it exists, not because it was registered.

The composer **reads** those files and never imports one. An `.astro` component's props do not survive to runtime anyway, so importing would buy nothing and would breach the package boundary besides.

What it reads from each file:

- **`interface Props`** — the declared props and their types.
- **The `Astro.props` destructure** — the frontmatter's last word on a prop, so an unset control can say what the component actually renders. A quoted string, a number, `true` or `false` is taken as a default; an expression, an array or an object is not.
- **`<slot />`** — a component that renders one is listed and tagged, because a section with a slot the composer cannot fill is a silent dead end otherwise.

Anything the source cannot state — a nicer label, a grouping, a wireframe, the props of a component whose types live in another file — goes in `components.overrides`. See [The component library](/docs/component-library/).

## Declared props, never CSS

The inspector renders a component's **declared props**: text, number, boolean, select over an enum, chip list, and a repeater for an array of objects. There is no Style panel, no spacing control and no positioning.

This is the load-bearing decision of the whole screen. The output is a diff in your repository that the developer who wrote those components has to read. Free positioning would emit `<Hero style="margin-top: 47px">` into your source, which is the kind of diff that gets a CMS deleted. Storyblok's model, not Webflow's.

## What it writes

A composed page is one **layout** wrapping a list of sections. The layout wrapper is required — a page with none renders no `<html>`, no `<head>` and no stylesheet.

Prop values are emitted in exactly **two shapes**: a quoted attribute, or a JSON literal in braces. The quoted branch escapes nothing, because it is only ever reached when there is nothing to escape. An interpolation is treated as data if and only if `JSON.parse` accepts it — that one call is the entire expression check, and it is what keeps a prop value from becoming code running in your build.

A prop **name** must be a plain identifier. Colon-namespaced directives like `set:html` are refused outright.

Page slugs are `[a-z0-9-]` plus `/`; the `.astro` extension is added server-side.

## The parser refuses rather than rewrites

The composer reads back exactly what it writes — it is not a general `.astro` parser, and it does not try to be. A page it cannot model opens **read-only**, showing the line it stopped at, and is never rewritten.

Hand-written pages, including your `index.astro`, list as **read only**. That is the honest answer for a page nobody composed, and it is better than an empty list that implies the page has no sections.

Parser and printer live in one file, so their disagreements are visible on one screen.

> One real gotcha: the import regex matches **double-quoted** specifiers only. A composable page whose frontmatter uses single quotes opens read-only with a message that does not explain why.

## Editing live

Type in the inspector and the canvas catches up after a **400 ms pause** — a typing pause, not a keystroke.

In development that pause writes a working copy to disk and the canvas re-renders from it, so what you see is the real page. Deployed, the canvas posts the **model** to the composer's own route and renders the answer, because a deployed checkout must not diverge from `HEAD` and a built bundle cannot re-render a file that changed.

The catch-up is a **patch**, not a navigation: only the section roots whose markup actually changed are replaced, so there is no flash and the page keeps its scroll. It falls back to one full navigation when the diff cannot be answered — a `<head>` this document has not loaded, or a section count that moved.

A draft that throws keeps the last good render underneath an amber note, which is a feature: you can see which edit broke it.

**A replaced section's hoisted script does not re-run.** A patched-in section stays un-enhanced until the next real navigation. That is a known ceiling.

## Keyboard

| Key                 | Does                                                                                                    |
| :------------------ | :------------------------------------------------------------------------------------------------------ |
| `⌘S`                | Save — intercepted always, even while typing, because the alternative is the browser's Save Page dialog |
| `⌘Z` / `⌘⇧Z` / `⌘Y` | Undo / redo                                                                                             |
| `↑` `↓`             | Move the selection                                                                                      |
| `⌫`                 | Remove the selected section                                                                             |
| `Esc`               | Deselect                                                                                                |

Every shortcut but `⌘S` is refused while focus is in a control.

Sections reorder by drag or by the move up/down buttons. The buttons are not a convenience — HTML5 drag-and-drop does not fire on touch, so they are the phone route and the keyboard route both.

## The three answers

Every save answers one of three things: **saved**, **conflict**, or **refused**. Transport failures join that union — an action error and a dead connection are one event to an author, so they read as one message.

A refusal happens when the model has gaps: a required prop with no value. `<Features />` with no `items` throws at render and fails your build, so the composer will not commit it.

Deleting a page needs no special handling — staging a removal is the same commit verb everything else uses.

## Troubleshooting

**A page I can clearly see opens read-only.** Run down the list: single-quoted imports in the frontmatter, a construct the printer does not emit, or genuinely hand-written. The message names the line it stopped at.

**A section shows a labelled placeholder.** Its component is gone from disk, or it was added in a commit this deployment's bundle predates. The row still exists, because a vanished component that silently disappeared would make the counter lie and the save invisible.

**The canvas refuses and says it cannot find the section boundary.** The composer identifies sections by walking breadth-first from `<body>` for the first element whose child count matches the section count. A layout with siblings beside its `<slot />` breaks that, and the stage says so rather than guessing. One root per section is the ceiling.

**Closing the tab is refused.** The composer has unsaved changes and guards against losing them. Undo back to clean, or save.

**Two people are composing the same page in dev.** One working tree means one live draft per page across every author on that dev server. That is a real ceiling of the dev-mode draft, not of the CMS.