> ## Documentation Index
> Fetch the complete documentation index at: https://docs.avocadostudio.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Astro Integration

> Add Avocado to an Astro site with one integration in astro.config.ts. The site keeps rendering its own .astro components — no React islands, no rewrite.

Avocado ships an Astro integration. Install it, name your content module, and the
editor can open your site — with your own `.astro` components doing the
rendering, no islands added and nothing ported to React.

```bash theme={null}
npm install @avocadostudio-ai/astro @avocadostudio-ai/site-sdk @avocadostudio-ai/shared
```

```ts astro.config.ts theme={null}
import { defineConfig } from 'astro/config'
import avocado from '@avocadostudio-ai/astro'

export default defineConfig({
  output: 'static',
  integrations: [
    avocado({
      siteId: 'my-site',
      content: './src/avocado/content.ts',
      editablePages: ['src/pages/index.astro'],
    }),
  ],
})
```

That is the whole of the wiring. The integration mounts the five `/api/editor/*`
routes, injects the preview bridge, and makes the pages you named renderable on
demand in `astro dev`.

## The site renders itself

This is the mode Avocado calls **site-renders-itself**, and it is what makes
Astro workable. Avocado supplies the schema, the draft props, the editable
markers and publishing. Your components render. Nothing about your template
changes shape.

What you provide is a **content module** — one default export saying where your
content lives:

```ts src/avocado/content.ts theme={null}
import type { PageDoc } from '@avocadostudio-ai/shared'
import { FIELD_TABLE } from './field-table'
import { getPage, getSlugs, writePage } from './pages'
import { registerFieldTable } from '@avocadostudio-ai/site-sdk/lens'

export default {
  getPages: async (): Promise<PageDoc[]> => { /* read your content */ },
  // A function, called per request — not a module side effect. The field
  // registry lives on globalThis, and a transitive import of Avocado's own
  // defaults re-registers those on top of yours with nothing reporting the
  // loss. On Astro a route module is evaluated on the first request to that
  // route, so which registration wins would otherwise depend on which route
  // the editor asks for first.
  registerBlocks: () => registerFieldTable(FIELD_TABLE),
  blockTypes: Object.keys(FIELD_TABLE),
  onPublish: async (pages: PageDoc[]) => {
    for (const page of pages) await writePage(page)
    return { ok: true }
  },
}
```

On a static template, `onPublish` writing the page back to a file under `src/`
is not a limitation to work around — it is what "published" means. The file is
committed, reviewed as a diff, and the site rebuilt from it.

The content module is the editor API's whole configuration, so `publishSecret`
and `maxPagesRemoved` go here too. The publish route refuses a payload that
would remove every page with a 409 unless the body carries `allowDelete: true` —
an empty `pages` array is far more often a client that failed to load its own
state than somebody deleting their site — and it refuses an unconfigured publish
with a 401 under `NODE_ENV=production`. The injected route only exists during
`astro dev` or in a build with an adapter, so the production refusal matters only
if you serve it.

<Note>
  The editor API route cannot live in your `src/pages`. It has to render on
  demand, and `astro build` fails on an on-demand route with no adapter whether or
  not anyone intends to serve it. The integration injects the route instead, which
  is why it needs `content` as a path rather than as a value.
</Note>

## `editablePages` — why it is a list

The pages you name render on demand during `astro dev` and are prerendered in a
build. Both halves are needed.

A prerendered route has no request: `Astro.request.headers` is empty, so the
middleware sees no `__editor` parameter and no draft cookie, and resolves every
request as a visitor's. The preview then renders the *published* page —
correctly, and with no editable markers — which looks exactly like an
integration nobody wired up.

Your site cannot fix that itself. `export const prerender = false` on the page
makes `astro build` fail with `NoAdapterInstalled`, and `!import.meta.env.DEV`
is not a literal by the time Astro's route analysis reads it, so the route stays
prerendered regardless.

It is a list rather than "every page" because a route built from
`getStaticPaths` cannot render on demand at all: its `Astro.props` come from the
path it was generated for, so on demand they are `undefined` and the route
throws on the first property it reads. Naming the pages leaves paginated and
collection routes exactly as they were. `*` matches within a path segment, `**`
across segments.

**In a build this does nothing at all.** Every route is prerendered and the
output is as static as it was before Avocado was installed.

## Rendering the draft

`getPages` is your **published** source. An editor render needs the draft, and
that is `Astro.locals.avocado.getDraftPage()`:

```astro theme={null}
---
import { getPublishedPage } from "../avocado/pages"

const { avocado } = Astro.locals
const page = (await avocado.getDraftPage()) ?? (await getPublishedPage("/"))
---
```

That fallback is the whole contract. `getDraftPage()` returns `null` when the
orchestrator has no draft for this slug or could not be reached, and in both
cases the right answer is your published content. It defaults to the request's
own path; pass a slug when the route renders a page it does not share a URL with.
Nothing is fetched unless a render calls it, and the result is memoised per
request per slug.

<Warning>
  **Skip it and the preview never updates, while everything else reports success.**
  The preview bridge refreshes by re-fetching the page and swapping the rendered
  subtree, not by patching the DOM — so a render that reads your published file
  answers every edit with byte-identical HTML. Markers emit, the outline draws, selection
  works, the property panel loads and accepts typing, and the iframe never moves.
  This was found on the pilot, and the conclusion it leads to is "the bridge is
  broken", which sends you reading the wrong file.
</Warning>

The subtree a refresh swaps is `[data-avocado-root]` if your template has one,
then `<main>`, then the whole `<body>`. Put `data-avocado-root` on the element
that encloses every block when your blocks live outside `<main>` — a header or
footer block does — or when you want the swap narrower than the body.

When you need to tell an editor *why* a draft is missing — "the orchestrator is
unreachable" rather than a silently stale page — `resolvePageRender` from
`@avocadostudio-ai/site-sdk/page/core` returns a `draft-unavailable` outcome that
`getDraftPage` collapses into `null`. It resolves navigation and site chrome as
well, which a template rendering its own header will want to ignore.

## Production

Everything above works in `astro dev` with nothing configured. A deployment that
serves on-demand routes has three things to set, and all three are inert in
development — so none of them can be checked by the loop you develop in.

| Variable            | What it does                                                                                              |
| ------------------- | --------------------------------------------------------------------------------------------------------- |
| `DRAFT_MODE_SECRET` | Authorizes a draft render, and gates `/api/editor/draft`                                                  |
| `PUBLISH_TOKEN`     | Required by `POST /api/editor/publish`, which otherwise refuses every request under `NODE_ENV=production` |
| `ORCHESTRATOR_URL`  | Where `getDraftPage()` reads drafts from                                                                  |

**`__editor=1` authorizes nothing.** It is a routing hint the editor puts on the
iframe URL — no secret in it — so under `NODE_ENV=production` a request carrying
only the parameter is rendered as an ordinary visitor's. Two things authorize: the
signed cookie `/api/editor/draft?secret=…` mints, and a valid `secret` on the
request itself, which is what covers the case the cookie cannot — the editor
renders your site in a cross-origin iframe, where third-party cookies are
frequently blocked outright. Development is unchanged; refusing there would mean
configuring a secret before a local preview could render anything.

**`editorOrigins` is enforced in three places.** The one list in
`astro.config.ts` decides the `postMessage` target, which frame may drive inline
edits, *and* which origin `/api/editor/*` answers cross-origin. You do not need
`EDITOR_CORS_ORIGINS` as well; it still works and is additive.

Both halves check it. The server resolves the origin on the URL against the list
and degrades an unlisted one to your first entry, so the page still renders; the
preview bridge refuses to attach at all and says so in the console, because a
frame naming an origin you never listed has nobody listening on the other side.

**In development your local editor is trusted whatever its port**, listed or
not — the editor's port moves, and requiring it in the list would mean editing
committed config to run the thing locally. So an origin mistake first shows up
in production, which is the argument for naming the real one before you deploy
rather than after.

<Note>
  Astro's dev toolbar renders **inside** the editor's iframe — a floating pill
  over the bottom of the page, covering whatever block is down there. Set
  `devToolbar: { enabled: false }` in `astro.config.ts` while you are editing, or
  expect to lose the footer behind it.
</Note>

## Describing your components

Your `.astro` widgets are described to Avocado with a
[field table](/integration/field-table) — one table giving the schema the
operations engine validates against and the metadata the property panel draws.

One kind matters more on Astro than anywhere else. A widget that takes a prop
either as a value or as a slot renders it with `set:html`, so the stored value
is a string of markup. That is `kind: 'html'`:

```ts theme={null}
const HEADLINE = {
  title: { kind: 'html', label: 'Title' },
  subtitle: { kind: 'html', label: 'Subtitle' },
}
```

Declaring it `richtext` instead is the mistake worth naming, because it compiles
and looks fine: `richtext` means *a document*, so the panel renders the markup
literally and a person editing it writes broken markup back into your source
file. See [when the stored value is HTML](/integration/field-table#when-the-stored-value-is-html).

A prop that is a decorative slot — a background `<div class="absolute inset-0 …">`
— is not a field at all. Leave it out of the table; an undeclared prop rides
through the ops engine untouched, which is how the page keeps it.

## Options

| Option          | Default | What it does                                                                                                    |
| --------------- | ------- | --------------------------------------------------------------------------------------------------------------- |
| `siteId`        | —       | The id the orchestrator keys drafts by. Also read from `AVOCADO_SITE_ID`                                        |
| `content`       | —       | Path to the content module above, relative to the project root                                                  |
| `editablePages` | `[]`    | Pages the editor may preview. On demand in dev, prerendered in a build                                          |
| `editorOrigins` | —       | Origins permitted to drive inline edits. An unlisted origin is accepted in dev, because the editor's port moves |
| `session`       | `"dev"` | Orchestrator draft session                                                                                      |
| `bridge`        | `true`  | Inject the preview bridge script                                                                                |

The editor API is always mounted at `/api/editor`. There was an option for that
and it has been removed: the other end of the contract is not configurable — the
editor fetches `/api/editor/blocks` and `/pages` from the browser and the
orchestrator POSTs `/api/editor/publish`, all spelled out — so moving only this
half mounted the API where nothing would call it, and the manifest and publish
answered 404 against a config that read correctly. A site that needs another
path mounts the route itself with `createAvocadoEditorApi({ basePath })`.

## A worked example

`examples/astro-site` in the repository is the smallest site that exercises the
whole contract: two pages plus one with no `<main>`, a block rendered outside
`<main>`, a list whose rows are drawn by their own component, a `kind: 'html'`
headline, and an `onPublish` that writes JSON back under `src/`.

Two gates drive it, and they divide along the only line that matters here —
whether a browser is running the page.

|                          |                                  |                                                                                                                                                                                    |
| ------------------------ | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pnpm test:astro`        | `scripts/astro-check.mjs`        | Builds it, serves it under `NODE_ENV=production`, asserts on the bytes: the draft gate, the origin allowlist, the cookie, 100% marker coverage, the publish round trip. 29 checks. |
| `pnpm test:astro:bridge` | `scripts/astro-bridge-check.mjs` | Frames the preview cross-origin from a stub parent speaking `site-editor/v1` and drives it in Chromium: click-to-select, refresh, navigation, inline editing. 10 checks.           |

## What is not here yet

Astro support shipped from one pilot integration, and the fixture above is not a
second one — it proves the contract, not that the integration survives contact
with a real template. Expect to find gaps around anything neither exercises, and
say so when you do — that is worth more to us than a clean report.

Re-attachment after a `<ClientRouter />` swap is still verified by construction:
the fixture does not use the client router, so `astro:page-load` and
`astro:before-swap` are exercised only by the initial-load path. Everything else
in the bridge is now driven in a browser on every push.
