# Introduction > Vesper adds an editor to your Astro site that reads your content collections and saves to the files in your repository. Test edit... Vesper is a content editor for Astro sites. It adds one page to your site, the studio at `/admin`, where people edit your content in plain forms instead of files. {% callout type="note" title="Not released yet" %} Vesper is in development, and its packages aren't on npm yet. These docs describe how it works in the repository today. {% /callout %} ## How it works **Your schema is the editor.** Vesper reads the content collections in `src/content.config.ts` and builds a form for each one: text, dates, lists, references, images. There is no second schema to write or keep in step. **Content stays as files.** A save writes to the same Markdown, Markdoc, MDX, JSON or YAML file the site already reads, and changes only what the editor changed. Formatting, comments and key order are kept. **The site stays static.** The studio is built with your site, as plain files. Nothing runs on a server, so the site works on any host, from Netlify to a shared host you upload to by FTP. ## Three ways to edit - **Local.** While `astro dev` runs, the studio saves straight to the files on your computer. - **GitHub.** On the deployed site, editors sign in with their own GitHub accounts, and each publish is a commit to your repository. Free. - **Cloud.** Editors sign in with an email link instead of a GitHub account, save drafts, and publish after review. Paid. Everything except the cloud is open source under the MIT license: the studio, local and GitHub editing, accessibility checks and site search. ## Where to start - [Install](/docs/getting-started/install/) Vesper on an Astro site. - [Make your first edit](/docs/getting-started/first-edit/) in the studio. - See how [fields](/docs/content/fields/) in your schema become controls. --- # Getting started > Install Vesper, make a first edit in the studio on your own computer, then choose how edits reach the live site. Vesper adds a studio at `/admin` to an Astro site you already have. Getting started takes three steps: install the integration, edit something in local mode, then decide how edits get published. Local mode needs no account and no setup. The studio runs inside `astro dev` and saves to the files on your computer, and you commit them as you would any change. Many sites never need more than that. When other people need to edit from the deployed site, add GitHub mode, where editors sign in with their own GitHub accounts, or the cloud, where they sign in with an email link and work in drafts, with review where you want it. You can add either later without changing your content. --- # Install > Add Vesper to an Astro site with one command, then open the studio. ## Before you start You need an Astro 6 or 7 site with its content collections in `src/content.config.ts`. Static output is fine: Vesper needs no adapter and no server. ## Add Vesper Run this in your site's folder: ```sh npx astro add @vesper/astro ``` It installs `@vesper/astro` and adds it to the integrations in `astro.config.mjs`: ```js import { defineConfig } from 'astro/config'; import vesper from '@vesper/astro'; export default defineConfig({ integrations: [vesper()], }); ``` The studio is built with React, so npm installs `react` and `react-dom` beside it. They load only at `/admin`, never on your site's own pages. If your package manager doesn't install peer dependencies, add them yourself. ## Open the studio Start the dev server: ```sh npm run dev ``` Then open `/admin/` on it, for example `http://localhost:4321/admin/`. Every collection is there, with a form built from its schema. Change something and save: the file in `src/content/` changes, and the page reloads with your edit. {% callout type="tip" %} There's nothing to configure. Vesper reads `src/content.config.ts` as Astro does, so a new field in your schema is a new control in the studio the next time it loads. {% /callout %} ## What Vesper can edit - Collections that use Astro's `glob()` or `file()` loaders, in Markdown, Markdoc, MDX, JSON or YAML. - Collections with another loader, or in TOML, are listed but read only: Vesper can't tell where their entries live, or can't write their format without changing it. Import `z` from `astro/zod` in your config, as Astro's docs do. A second copy of Zod from the `zod` package loses the hints Vesper reads. ## Next steps - [Make your first edit](/docs/getting-started/first-edit/) and see what the studio does with each file. - [Publish](/docs/getting-started/publishing/) from the deployed site, with GitHub or the cloud. - Give the studio labels, URLs and icons with [vesper.config.ts](/docs/reference/integration/). --- # Your first edit > A tour of the studio in local mode, from opening an entry to saving it, with the body editor, images and page trees. In local mode the studio runs inside `astro dev` and saves straight to the files on your computer. Start the dev server and open `/admin/` on it: ```sh npm run dev ``` ## Home The first screen, headed Content, lists your collections, with how many entries each has and when one was last edited. Collections with `singleton: true`, such as site settings, are listed under Settings. The top bar's Content menu reaches the same collections, and its Site menu has Media, Redirects and View site. ## A collection's list A collection opens as a table of its entries: title, any columns you name in `listFields`, when each was edited, and status in words, such as Draft (`draft: true`) or Read only. Search filters by title, and the column headings sort. New entry starts one. Its file is named after its title the first time you save it. A singleton skips the list and opens its one entry. ## The entry editor The title field is the page's heading. Below it come the main fields, in your schema's order, then the body. **Details** holds secondary fields: slugs, dates, tags, `draft`, ordering and SEO. On a wide screen the Details button in the top bar opens them in a drawer; on a narrow one they're a section at the end of the form. A field's `.meta({ placement: 'details' })` or `'main'` overrides the rule. The top bar also has the entry's status ("Unsaved changes", "Saved just now"), Save, and a "…" menu with Delete and, for a collection with a `url`, View on site. ## What Save does Nothing is written until you press Save, or Ctrl+S (⌘S on a Mac). There's no autosave: every write makes Astro reload the site, and a half-typed value could fail your schema. When you save: - Your schema checks the values. Problems show under their fields and in a summary at the top, and nothing is written. - Only the entry's own file changes, apart from the page-tree cases below. - The change is spliced into that file: only the bytes of the values you changed are rewritten. Comments, quoting, key order and line endings stay as they were, and in the body only the blocks you touched change. - If the file changed on disk since you opened it, nothing is written, and the studio offers Reload this entry. The one exception is frontmatter with YAML anchors, aliases or tags, which can't be edited in place. It's rewritten in a standard layout, and the studio says so. ## The body editor Markdown, Markdoc and MDX bodies open in a writing view, with a toolbar for bold, italic, headings, lists, quotes, code, links and images. The link dialog searches your pages by title. Markdoc tags from `markdoc.config.mjs`, and MDX components you declare in `vesper.config.ts`, are blocks: a frame with a Settings button, whose form comes from the tag's attributes. - Type `/` at the start of a line or after a space to insert a block, or use the toolbar's Insert menu. Both offer only what fits at the cursor. - Arrow onto a block to select it. Enter opens its settings, and Alt+↑ and Alt+↓ move it. - Escape leaves a block, then moves from the text to the toolbar. Anything the writing view can't save back exactly is a source block, a dashed box such as "Table, kept as written": tables, HTML, comments, footnotes, imports and undeclared MDX components. They're kept byte for byte, and Edit as text changes one as plain text. Edit as Markdoc (or Markdown, or MDX) shows the whole body as text, exactly as it's saved. A body your site's build would fail on, such as a tag without a required attribute, can't be saved; the problem shows on its block. More in [Markdoc and MDX](/docs/content/markdoc-and-mdx/). ## Add an image Use Choose image in an image field, or Image in the body's toolbar. Both open one dialog: upload from your computer, or choose an image already in the site. Large photos are scaled down, turned upright and stripped of location data, and a file the site already has is offered for reuse. Then describe the image. Alt text is required unless you mark it decorative, an image that adds nothing a reader needs. New images go to `src/assets//`, or beside the image they replace, and the entry stores a path relative to its own file, so Astro's `image()` processes them. The image file is written as soon as it's uploaded; the entry changes when you save. For focal points and crops, see [Images](/docs/content/images/). ## Pages in a tree A collection with `tree: true` opens as an outline: pages as they nest on the site, each with its address. Each page's "…" menu has Add child page, Move to…, Move up, Move down and Delete. From a page, the right arrow or Shift+F10 reaches its menu, and Alt+↑ and Alt+↓ move it. You can drag pages too. - **Move up** and **Move down** rewrite the folder's `_order.yaml`. - **Add child page** opens a new page under that one. Saving it adds it to the folder's `_order.yaml`, if there is one. A parent that's a single file becomes a folder's section page, so `about.md` moves to `about/index.md`, at the same address. - **Move to…** asks for the new parent and the last part of the address. Review move says what will happen, such as "8 links on other pages will be updated", before anything changes. The move writes the page and every page under it, both folders' `_order.yaml`, a redirect for each old address in `vesper.redirects.yaml`, and the links to them on other pages. More in [Pages and URLs](/docs/content/pages-and-urls/). ## Edit this page In `astro dev`, Astro's toolbar gets a Vesper app, a crescent. On any page of your site it lists the entries the page shows, each linked to its editor, then the ones it only lists. It sees only entries the page reads through `vesper:content`, a drop-in for `astro:content`: ```astro --- // was: import { getCollection, getEntry, render } from 'astro:content'; import { getCollection, getEntry, render } from 'vesper:content'; --- ``` In a build, `vesper:content` is Astro's own module, unchanged. ## Next steps - [Publish](/docs/getting-started/publishing/) your edits, yourself or from the deployed site. - See how [fields](/docs/content/fields/) in your schema become controls. --- # Publishing > How an edit reaches the live site in each mode, from committing local changes yourself to editors publishing from the deployed studio. Vesper never deploys your site. Every published change is a commit to your repository, and your host builds the site from it as it does for any push. The modes differ in who makes that commit. ## Local mode: you commit In local mode the studio exists only while `astro dev` runs, and a save writes files on your computer. Nothing reaches the live site until you commit and push. `git status` lists every file the studio changed: entries, new images, `_order.yaml` files and `vesper.redirects.yaml`. ```sh git add -A git commit -m "Update the admissions page" git push ``` If the site is only ever edited this way, leave `/admin` out of production builds. Otherwise a deployed `/admin` with neither GitHub mode nor the cloud only says that editing isn't available there. ```js vesper({ studio: { deploy: false } }) ``` ## GitHub mode: editors publish from the live site GitHub mode is free. Editors sign in with their own GitHub accounts on the deployed `/admin`, and each Publish is one commit to the branch, with the editor as its author. An image uploaded from a field goes into the same commit as its entry. ### The relay GitHub's sign-in ends with a call that needs your app's secret and can't be made from a browser, so a relay makes it: one Cloudflare Worker that stores nothing, on the free plan. `templates/oauth-relay/README.md` has every step: 1. Create a GitHub App with one repository permission, Contents: Read and write, its webhook off, and its callback URL set to your relay's address plus `/callback`. 2. Note its Client ID, generate a client secret, and install the app on the site's repository only. 3. Copy `templates/oauth-relay`. In `wrangler.toml`, set `GITHUB_CLIENT_ID`, and `ALLOWED_ORIGINS` to every origin editors open `/admin` from, separated by commas. 4. Deploy it: ```sh npx wrangler secret put GITHUB_CLIENT_SECRET npx wrangler deploy ``` Editors need write access to the repository too. Their sign-in never reaches more than they could already change there. ### Turn it on ```js vesper({ github: { relay: 'https://vesper-github-relay.your-account.workers.dev' } }) ``` At build, Vesper reads the repository, its default branch and the site's folder in it (for a monorepo) from git or the host's environment, and the log says where it will publish: "GitHub mode: publishing to owner/name (main)". Set `repo: 'owner/name'`, `branch` or `root` when they can't be read or aren't what you want. GitHub mode is on in built sites only; `astro dev` stays local mode. Editors see Sign in with GitHub, and the editor's main button reads Publish. ### Hosts and branch rules - **Hosts that check commit authors.** On a private repository, Vercel builds only commits by members of your team, and Netlify holds commits from authors it doesn't recognize for approval. Add your editors there, or build with GitHub Actions or Cloudflare Pages. - **Branch rules.** Publishes go straight to the branch, so a rule that requires pull requests refuses them, and the studio says so. GitHub mode's commits aren't signed, so a rule that requires signed commits would refuse them too. - **A Content Security Policy** for `/admin`, if your host sets headers, needs `connect-src` for `https://api.github.com` and your relay, and `img-src blob:` for previews. ## The cloud: drafts, review and publish The cloud is paid. Editors sign in with an email link instead of a GitHub account, and their saves are drafts, kept by the cloud until someone who may publish does. Publishing is one commit by Vesper's GitHub App. ### Set up the site In the dashboard at `app.vespercms.com`: 1. **Add site**, with its production address and any preview and development addresses. The studio signs in only at these. 2. **Connect repository.** This installs Vesper's GitHub App, VesperCMS, on the repository. You sign in to GitHub once, so the cloud can check the installation is yours, then choose the repository, and its branch and the site's folder if they aren't the defaults. In a repository with more than one Astro site, type the site's folder, such as `apps/site`. The dashboard warns if a branch rule won't let it publish. If VesperCMS is already on your GitHub account or organization, for another site, use **Choose a repository VesperCMS can reach** instead. Add the repository to the app on GitHub first if it isn't one of them. GitHub doesn't bring you back after you change an installation, so come back to the site's page and choose it there. 3. **Copy the project ID** from the site's page into your config. It's an identifier, not a secret, and `VESPER_PROJECT` works too. ```js vesper({ cloud: { project: 'site_…' } }) ``` As with GitHub mode, the cloud is on in built sites, and `astro dev` stays local mode. There's no sign-up yet: Vesper creates each account, and everyone else joins by invite. ### People, roles and grants Under Manage people, invite someone by email as a site admin (everything, including people and settings), an editor (edits and publishes), a contributor (edits; an approver publishes) or a viewer (reads only). An editor or contributor can be limited to one collection or folder, and "Publishing needs approval" sends their changes there to an approver. Roles and grants live in the dashboard, not in `vesper.config.ts`, and take effect at once. The studio hides what someone can't change, and the cloud refuses it. The choices come from the deployed site's `_vesper/manifest.json`, so deploy the site once before limiting anyone. ### What editors see - **Sign in with Vesper** goes to Vesper's sign-in page, which emails a link that works once, within 15 minutes. - **Drafts** save themselves a moment after typing stops, after the same checks as a save. A new entry's first draft is saved with Save draft. - **Publish**, or **Request review** for contributors, sends a draft on. Approvers get an email whose link opens Changes in the studio, after signing in if they need to. - **Changes**, in the top bar, lists the site's drafts with "Publish 3 changes", and for approvers the reviews waiting for them. An open studio checks for new drafts and reviews once a minute, and whenever its tab comes back into view. - **After a publish**, the studio follows the site's build, with a link to it: building, live or failed. A host that reports nothing gets "It will be live after the site is next built." - **If Vesper can't be reached**, unsaved changes are kept on the device and saved once it's back. - **Signing out** of the studio also signs you out of the dashboard on that browser, and the other way round. Other browsers and devices stay signed in. The app is the commit's author, so GitHub marks it Verified, and the person who published is named in a `Co-authored-by` trailer with a no-reply address, never their email. If a file changed on GitHub since its draft began, the studio shows both versions side by side and overwrites nothing. On a private repository, Netlify and Vercel may hold or skip builds of commits by authors they don't know, the app included. Check that its commits build, or build with GitHub Actions or Cloudflare Pages. A Content Security Policy needs `connect-src` for `https://api.vespercms.com`, and `img-src blob:`. ## Both on one site A site can set both `github` and `cloud`. The sign-in screen then offers Sign in with Vesper and Sign in with GitHub. Someone who signs in with GitHub publishes their own commits, as in GitHub mode; someone who signs in with Vesper works with drafts. --- # Content > How the studio reads your content collections, and where its presentation settings go. The studio has no schema of its own. It reads the collections in `src/content.config.ts` the way Astro does: each loader says where the files are, and each Zod schema says what the fields are. A field you add to a schema is a new control in the studio the next time it loads. Collections loaded with `glob()` or `file()` can be edited; others are listed, read only. Import `z` from `astro/zod`, not from the `zod` package. Vesper reads the hints you attach with `.meta()`, such as a label or help text, and a second copy of Zod loses them. The `fields.*` helpers are plain Zod from `astro/zod` too, so Astro checks them like any other schema. `vesper.config.ts`, in your site's folder beside `astro.config.mjs`, is optional. It holds presentation only: what collections are called, their icons and URLs, image ratios, hints for Markdoc tags, MDX components and custom widgets. It never defines fields, so `content.config.ts` stays the one place your content's shape is written down. --- # Fields and widgets > How each Zod type in your schema becomes a control in the studio, and how hints, helpers and custom widgets change it. Every field in a collection's schema becomes a control in the studio's editor. The type decides what's stored and how it's checked. A hint can choose a different control for the same type. Content files always hold plain values, so they stay readable without Vesper. ## From type to control | In your schema | In the studio | |---|---| | `z.string()` | A one-line text field. `z.email()` and `z.url()` get email and web address fields. | | `z.number()` | A number field that follows `min()`, `max()`, `int()` and `multipleOf()` | | `z.boolean()` | A switch | | `z.date()` or `z.coerce.date()` | A date picker. The file keeps a calendar date, such as `2026-10-17`. | | `z.enum([...])` | Radio buttons for four options or fewer, otherwise a select | | `z.array(z.string())` | A tag list: type a value and press Enter | | `z.array(z.enum([...]))` | A multi-select | | `z.object({ ... })` | A group of fields under its own heading | | `z.array(z.object({ ... }))` | A repeater: add, remove and reorder items | | `reference('authors')` | A searchable list of that collection's entries. The file stores the entry's ID. | | `z.array(reference('posts'))` | An ordered list of entries, with Move up, Move down and Remove | | `image()` | An image picker. See [Images](/docs/content/images/). | A few more rules: - `.optional()` and `.default()` make a field optional, and a default fills in new entries. - A union of string literals counts as an enum. - `.refine()` keeps the field's control. The refinement still runs when the entry is saved. - Fields appear in schema order. The first text field called `title`, `name`, `heading` or `label` becomes the page's heading in the editor. Every save is checked against your schema. A problem shows under its field and in a summary at the top of the form. ## Raw YAML fields When Vesper has no control for a type, it shows the value as a small YAML text box marked "Edited as YAML", instead of hiding it or guessing. That happens for: - unions of different types, such as `z.union([z.string(), z.object({ ... })])`, discriminated unions included - `.transform()` and `z.preprocess()`: the file holds something other than what the site reads, so editing the parsed value would write the wrong thing back - records, tuples, `z.any()`, recursive schemas, and literals that aren't strings - a schema that isn't a `z.object()` at the top level, whose whole frontmatter is then edited as YAML The text is read the way Astro reads it, and what you type is kept while it's invalid. If a field shows as YAML and you'd rather it didn't, a simpler type usually gives it a real control. ## Hints with .meta() ```ts summary: z.string().meta({ widget: 'textarea', description: 'One or two sentences for search results and link previews.', }), ``` | Key | What it does | |---|---| | `label` | The field's label. Without one, Vesper writes the key in sentence case: `publishedAt` becomes "Published at", `seoTitle` "SEO title". | | `description` | Help text under the label | | `widget` | The control to use instead of the one the type implies | | `widgetOptions` | Settings for that control, such as `{ labels }` for an enum's options | | `placeholder` | Sample text in an empty field. It's never a label. | | `placement` | `'main'` or `'details'`: see below | | `hidden` | Leaves the field out of the editor. Saves don't change its value. | | `readOnly` | Shows the value, but editors can't change it | | `autocomplete` | The browser's autofill hint, such as `email` | `.meta()` works before or after `.optional()` and `.default()`. Values must be plain data: a function in `widgetOptions` is dropped, with a warning. The built-in widgets you can name are `text`, `textarea`, `markdown`, `email`, `url`, `slug`, `color`, `file`, `number`, `switch`, `date`, `datetime`, `select`, `radio`, `tags`, `multiselect`, `group`, `repeater`, `reference`, `references`, `image`, `geo` and `raw`. A name that doesn't exist, or a widget that can't edit the field's type (`color` on a number), falls back to the control the type implies. Vesper warns in the terminal, naming the field and the widget. ## Field helpers `@vesper/astro/fields` has helpers for common fields. Each returns plain Zod from `astro/zod` with `.meta()` attached, so Astro checks it like any other schema. Paste the raw Zod instead and you get the same field: a test keeps the two equal. ```ts import { z } from 'astro/zod'; import { fields } from '@vesper/astro/fields'; const schema = z.object({ brandColor: fields.color({ palette: { Indigo: '#28335f', Pine: '#2f5d50' } }), website: fields.url(), startsAt: fields.datetime(), }); ``` | Helper | Raw Zod | Stored as | |---|---|---| | `fields.color({ palette?, alpha?, contrast? })` | `z.string().regex(/^#[0-9a-f]{6}$/i).meta({ widget: 'color' })` | `"#1a73e8"` | | `fields.geo({ defaultCenter?, zoom? })` | `z.object({ lat: z.number().min(-90).max(90), lng: z.number().min(-180).max(180) }).meta({ widget: 'geo' })` | `{ lat, lng }` | | `fields.slug({ from })` | `z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).meta({ widget: 'slug', widgetOptions: { from } })` | `"my-post"` | | `fields.url()` | `z.url().meta({ widget: 'url' })` | `"https://…"` | | `fields.email()` | `z.email().meta({ widget: 'email', autocomplete: 'email' })` | `"a@b.com"` | | `fields.datetime()` | `z.coerce.date().meta({ widget: 'datetime' })` | ISO 8601, with an offset | | `fields.select(options)` | `z.enum([...]).meta({ widgetOptions: { labels } })` | `"value"` | | `fields.markdown({ inline? })` | `z.string().meta({ widget: 'markdown' })` | A Markdown string | | `fields.file({ accept })` | `z.string().meta({ widget: 'file', widgetOptions: { accept } })` | A path, such as `/files/brochure.pdf` | | `fields.image(image, { ratios? })` | See [Images](/docs/content/images/) | `{ src, alt, … }` | | `fields.hideFromSearch()` | `z.boolean().default(false).meta({ label: 'Hide from site search', description, placement: 'details', search: 'hide' })` | `true` or `false` | Options you pass go into `widgetOptions`, as they would in the raw form. What they do: - `color()` with a `palette` shows the named swatches as radio buttons instead of a colour picker. `alpha` allows eight digits. `contrast: 'text'` (or `'large-text'`, `'graphic'`) checks the colour against your declared backgrounds: see [Accessibility](/docs/site/accessibility/). - `slug({ from: 'title' })` follows that field on a new entry until someone edits the slug. - `geo()` shows latitude and longitude fields. A map loads only when an editor asks for it with "Show on a map", and `defaultCenter` and `zoom` set where it starts. - `datetime()` is entered in the site's time zone (`timeZone` in `vesper.config.ts`, otherwise each editor's own) and stored with that day's offset. - `markdown()` is a small editor for bold, italic, links, paragraphs and lists. With `inline: true` it leaves out paragraphs and lists. - `select()` takes a list of values, an object of values and labels (`{ news: 'News' }`), or `[{ value, label }]`. The file stores the value. - `hideFromSearch()` keeps the entry's page out of the site's own search: see [Site search](/docs/site/search/). ## Main column and Details The editor shows the title as the page's heading, then the main fields. Secondary fields go to Details, at the end of the form. On wide screens, a Details button in the top bar opens them in a drawer instead. Details gets: - slugs, dates and tags - ordering and layout fields, such as `order`, `weight`, `layout` and `template` - `draft`, SEO fields such as `seo`, `seoTitle` and `metaDescription`, and `canonical`, `noindex` and `aliases` To move a field, set `.meta({ placement: 'main' })` or `'details'` on it, or set it per collection in `vesper.config.ts`, which wins: ```ts // vesper.config.ts collections: { events: { placement: { startsAt: 'main', endsAt: 'main' } }, }, ``` ## Custom widgets When no built-in control fits, write your own as a React component and register it in `vesper.config.ts`: ```ts import { defineConfig } from '@vesper/astro/config'; export default defineConfig({ widgets: { 'store-hours': { component: () => import('./src/cms/StoreHours.tsx'), accepts: (field) => field.kind === 'array', match: (field) => field.path.at(-1) === 'hours', }, }, }); ``` - A field uses the widget when its `.meta({ widget: 'store-hours' })` names it, or when `match` returns true for it. `accepts` limits which fields it can edit. - Vesper picks a control in this order: the widget a field names, then a custom widget whose `match` claims it, then the control its type implies, then a raw YAML field. - The file's default export is the component. It receives `WidgetProps` (`import type { WidgetProps } from '@vesper/astro/config'`): `value`, `onChange`, `field`, `id`, `describedBy`, `invalid` and `disabled`. - The editor draws the label, help text and errors. Your control takes `id`, passes `describedBy` and `invalid` on to `aria-describedby` and `aria-invalid`, works with the keyboard, and never validates: the schema does. - Vesper compiles widget files as React, so your site needs no `@astrojs/react`. Start each file with `/** @jsxImportSource react */` so TypeScript checks it as React. - If a widget fails to load, the field falls back to its built-in control, with a note. ## Collections in the studio `vesper.config.ts` also says how each collection appears: ```ts collections: { blog: { label: 'Blog posts', icon: 'newspaper', listFields: ['title', 'publishedAt'] }, settings: { label: 'Site settings', singleton: true, icon: 'settings' }, }, ``` - `label` is the collection's name in the studio. Without one, it's the collection's key in sentence case. - `singleton: true` is for a collection with one entry, such as site settings. Home lists it on its own, and it opens straight into its editor. - `listFields` names the fields the entry list shows beside each title. - `icon` names a [Lucide](https://lucide.dev/icons) icon. Install the `lucide` package to use one. Every option is on [vesper() and vesper.config.ts](/docs/reference/integration/). --- # Images > Image fields with alt text, focal points and crops, what happens to an upload before it's saved, and how to render the result. ## Add an image field Use `fields.image()` and pass it Astro's `image` helper from the schema function, so `src` still goes through Astro's image pipeline: ```ts import { defineCollection } from 'astro:content'; import { glob } from 'astro/loaders'; import { z } from 'astro/zod'; import { fields } from '@vesper/astro/fields'; const blog = defineCollection({ loader: glob({ pattern: '**/[^_]*.md', base: './src/content/blog' }), schema: ({ image }) => z.object({ title: z.string(), hero: fields.image(image, { ratios: ['wide', 'square'] }).optional(), }), }); ``` `ratios` names the crops the studio previews for this field. Without it, the studio previews every ratio the site declares. A plain `image()` field works too, but it stores only a path: no alt text, focal point or crops. ## What the file stores ```yaml hero: src: ../../assets/blog/harbor-at-dawn.jpg alt: An orange sun low over a dark harbor, with one small boat heading out focus: { x: 0.62, y: 0.35 } crops: square: { x: 0.40, y: 0.10, w: 0.45, h: 0.675 } ``` - `src` is relative to the entry's file, as Astro's `image()` expects. - `alt` is required unless the image is decorative, which stores `alt: ""` and `decorative: true`. The schema checks this, so a save without either is refused: "Describe this image, or mark it decorative." - `focus` is the focal point and `crops` holds a rectangle per ratio. Every number is a share of the image, 0 to 1 from the top left. ## In the studio The image field shows the picture with "Replace image" and "Remove image", then the alt text: "Describe what this image communicates on this page." Accessibility notes beside the box point out alt text that's a file name, starts with "Image of", or runs too long: see [Accessibility](/docs/site/accessibility/). The "Decorative image" checkbox is for pictures that add nothing a reader needs. "Focal point & crops" opens below it: - **Focal point.** Click or tap the image to set it, or drag the marker. With focus on the image, the arrow keys move it 1%, or 10% with Shift. "Across (%)" and "Down (%)" set it by number. - **Crops.** There's a preview for each ratio, such as "Wide · 16:9", cut around the focal point. "Adjust crop" opens a box fixed to that ratio: drag it or a corner, or use the arrow keys, with Shift to resize. Enter applies and Escape cancels. The preview then says "Custom crop", and "Reset to focal point" goes back. These edits change only the entry. The image file itself is never rewritten. Replacing the image clears its focal point and crops, which were chosen for the old picture, and keeps its alt text, with a note to check that the words still describe the new one. ## Ratios Declare the site's ratios once, in `vesper.config.ts`, as width divided by height: ```ts // vesper.config.ts images: { ratios: { wide: 16 / 9, square: 1, portrait: 4 / 5 }, maxUploadEdge: 3000, }, ``` The studio previews these, and `` renders the same crop, so editors see what visitors get. `og` (1.91:1, for social images) is built in unless you declare your own. A field that lists a ratio the site doesn't declare gets a warning. ## Uploads An image uploaded from a field goes into the folder of the image it replaces, otherwise `src/assets//`. A file field (`fields.file()`) uploads to `public/files/` and stores `/files/name.pdf`, because a file people download has to be served as it is. The Media screen, in the Site menu, lists everything in `src/assets` and `public` with the pages that use it. In GitHub mode, an upload from a field is published with the entry, in one commit. Before anything is saved, the studio cleans each upload in the browser: - **Location and camera data** (EXIF, XMP, IPTC) are removed from JPEG, PNG and WebP files. When the pixels don't change, the image data stays byte for byte the original, colour profile included. - **Sideways photos** are turned upright, from their EXIF orientation. - **Large photos** are scaled down so the longer edge is at most `maxUploadEdge` pixels (3000 by default). Turning or scaling a photo means encoding it again, which drops all its metadata. - **iPhone photos (HEIC)** become JPEG where the browser can decode them, which today means Safari. Elsewhere the studio asks you to export a JPEG first, or to upload from Safari. - **SVG files** lose scripts, event handlers, `javascript:` and `data:` links, and embedded HTML. - **Duplicates** are found by content. If the file is already in the site, you choose between "Use the existing file" and "Upload a copy anyway". - **Size.** A file over 5 MB asks first, and over 20 MB is refused. Image fields accept JPEG, PNG, WebP, AVIF, GIF and SVG, checked by the file's contents, not its name. ## Rendering on your site `` renders an image field through Astro's own ``, `` and `getImage()`: ```astro --- import { Img } from '@vesper/astro/components'; const { post } = Astro.props; --- ``` | Props | What you get | |---|---| | `ratio` | A ratio's name from `images.ratios`, or a number. The editor's crop for that ratio if there is one, otherwise the crop around the focal point. | | `art` | A `` with a source per media query, each with its own crop, and `base` for everything else. `formats` and `fallbackFormat` work as in Astro's ``. | | neither | The whole image, fluid. The focal point becomes `object-position`, so CSS cropping keeps the subject in frame. | Alt text always comes from the field, and a decorative image gets `alt=""`. Every other Astro image prop passes through, and `srcset` never offers a width larger than the crop. ## Social images For places an `` can't go, such as `og:image`, `getImg()` returns Astro's `getImage()` result for the same crop: ```astro --- import { getImg } from '@vesper/astro/components'; const og = await getImg({ image: post.data.hero, ratio: 'og', width: 1200 }); --- ``` ## How crops are cut With Astro's default Sharp image service, Vesper adds a step to it: the stored rectangle is cut from the upright image, then Astro resizes and encodes it as usual. The crops are ordinary files in `dist/`, so they work on any host. The build log says the step is on. It uses the copy of `sharp` that Astro's own service loads. `vesper({ imageService: false })` turns the step off. Then, and with a host's image service (Cloudflare, Netlify, Vercel), `` asks Astro for the ratio's size, which is Astro's own server crop, and keeps the focal point as `object-position`. Crops an editor adjusted by hand are applied only with the step on. {% callout type="warning" title="Keep focal points out of position" %} Never pass a focal point to Astro's `position` prop. Sharp accepts only its own keywords there, such as `top` or `left`, and a value like `62% 35%` fails the build. `` writes the focal point into `style` as `object-position`. Do the same if you render an image field with Astro's components directly. {% /callout %} --- # Pages, URLs and redirects > Give entries their addresses, arrange pages in a tree, move them safely, and keep old addresses working with redirects. ## Give a collection URLs Set each collection's `url` in `vesper.config.ts`: ```ts import { defineConfig } from '@vesper/astro/config'; export default defineConfig({ collections: { pages: { url: '/:path', tree: true }, blog: { url: '/blog/:publishedAt|yyyy/:slug' }, }, }); ``` `url` is the one record of where an entry lives. "View on site", the link picker, moves, redirects, breadcrumbs, the site helpers and the build's accessibility report all read it, so they can't disagree. It doesn't create pages: your routes in `src/pages` still render them, and Vesper warns when no route can produce a collection's URLs. A collection without `url` is data only. The tokens: - `:id` is the entry's ID. - `:path` is the ID without a trailing `/index`, and a top-level `index` is the root. `academics/nursing/index.md` is `/academics/nursing/`. - `:slug` is the last part of `:path`. - `:category`, or any other field's name, is that field's value. - `:publishedAt|yyyy`, `|mm` and `|dd` are parts of a date field. A calendar date keeps its day, and a date with a time is read in the site's `timeZone`. An entry that lacks a token's value has no URL. URLs follow Astro's `base`, `trailingSlash` and `build.format`. For addresses a pattern can't express, `url` can be a function that takes the entry and returns a path. Keep it pure, with no server-only imports, because the studio runs it in the browser too. ## Page trees With `tree: true`, a collection's folders are its hierarchy, and the studio shows it as an outline instead of a list. It works for collections loaded with `glob()`. | File in the collection | URL with `url: '/:path'` | |---|---| | `index.md` | `/` | | `about.md` | `/about/` | | `academics/index.md` | `/academics/`, the folder's section page | | `academics/nursing/index.md` | `/academics/nursing/` | | `academics/nursing/clinicals.md` | `/academics/nursing/clinicals/` | A folder with no `index.md` is still part of the tree, with no page of its own. The outline marks it "No section page". Each folder may have an `_order.yaml` that lists its children by slug: ```yaml # src/content/pages/academics/_order.yaml - nursing - business - admissions ``` Listed pages come first, in that order. The rest follow by title, with numbers in order ("Step 2" before "Step 10"). Keep files that start with `_` out of the collection, with a pattern such as `glob({ pattern: '**/[^_]*.md', base: './src/content/pages' })`. Vesper warns when a pattern would pick up `_order.yaml`. `maxDepth` limits how deep pages can go, as in `{ tree: true, maxDepth: 4 }`. There's no default. With it set, a new page or a move deeper than that is refused before anything is written. ## The outline In the studio, each row of the outline has the page's title, its address and its status in words, such as "Draft". Enter opens a page. Each row's "…" menu, also reached with Shift+F10, has Open, Add child page, Move to…, Move up, Move down and Delete…, plus Create section page for a folder without one. Alt+↑ and Alt+↓ move the focused page among its neighbours, and you can drag pages, with the keyboard too. ## Moving a page A move is one change, saved together: - the page's file, or its folder and everything in it - the `_order.yaml` of the folder it leaves and of the one it joins, where they have one - a redirect from the old address of the page, and of every page under it - every link to those addresses, in every content file - `vercel.json`, when the site has one In GitHub mode, that's one commit. Before anything is written, the studio says what will happen: "This moves “Nursing” and its 3 pages to the top level. Its address becomes /nursing/. 8 links on other pages will be updated. Visitors to the old addresses will be sent to the new ones." A move is refused, in plain words, when the new address belongs to another page or to a file in `src/pages`, is under `/admin` or `/_vesper`, would put the page inside itself, or goes past `maxDepth`. A page whose frontmatter sets its own `slug` can't be moved, because its address doesn't follow its file. Rewritten links keep their form: base or not, trailing slash or not, and any `?query` or `#anchor`. Links inside code are left alone. ## The redirects file Redirects live in `vesper.redirects.yaml`, in your site's folder beside its Astro config: ```yaml - { from: /programs/nursing, to: /academics/nursing, status: 301, reason: move, at: 2026-10-01 } - { from: /apply-now, to: /admissions/apply, status: 302, reason: manual, at: 2026-10-01 } ``` - `from` is a path on the site, without the base or a trailing slash. - `to` is a path written the same way, or a full URL elsewhere. - `status` is 301 (the default) or 308 for a permanent move, or 302, 303 or 307 for a temporary one. - `reason` (`move`, `manual` or `import`) and `at` are notes for people. Keys you add are kept. Every save and every build cleans the list: - A chain collapses onto its final address. It becomes temporary if any step was. - A loop is removed. - A redirect whose old address is a real page again is dropped, with a warning: the page wins. - A repeated `from` keeps the later entry. - An entry that isn't a valid redirect stays in the file, but is ignored, with a warning. - A target that's no page Vesper knows is flagged, never removed. It may be a file or a route Vesper can't see. ## How redirects are served At build, every old address gets an HTML redirect page aimed at the new page's exact URL, so a visitor lands in one step. That works on any host, including one you upload to by FTP. True 301s need the host's own rules. Vesper writes them when it detects the host, or when you name it in `redirects.native` in `vesper.config.ts`: | Host | Detected by | What Vesper writes | |---|---|---| | Netlify | `NETLIFY` | `_redirects` in `dist`, forced (`301!`), with Vesper's rules first | | Cloudflare Pages | `CF_PAGES` | `_redirects` in `dist` | | Vercel | `VERCEL` | `redirects` in the site's `vercel.json`, kept in step by the studio, since Vercel reads the file before it builds | | Apache or LiteSpeed | `native: ['apache']` | Lines in `dist/.htaccess` between `# BEGIN vesper` and `# END vesper` | | nginx | `native: ['nginx']` | `dist/_vesper/nginx-redirects.conf`, for the server's admin to include | An address a host rule covers gets no HTML page. `astro dev` doesn't serve redirects. For more on hosts, see [Hosting](/docs/site/hosting/). ## The Redirects screen Redirects is in the studio's Site menu. It lists every redirect with its kind in words, Permanent or Temporary, and what needs a look. "Add redirect" adds one, and each row's "…" menu edits or deletes it. "Import from CSV…" reads plain `from,to,status` rows, with or without a header, and exports from WordPress's Redirection plugin and Drupal's Redirect module. Full addresses from an old site keep only their path. A preview shows what's added, what replaces an existing redirect, and each skipped row with its reason, such as a pattern redirect a static site can't serve. Nothing is saved until you confirm. In the cloud, only people who can edit the whole site see Redirects. ## Linking to pages The body editor's link dialog suggests pages by title as you type, and URL fields get "Choose a page…". Both write the page's address as it is now, with base and trailing slash, so a later move can rewrite it. A field that only takes full URLs, such as `fields.url()`, gets the site's address in front when Astro's `site` is set, and no picker when it isn't. ## Site helpers `vesper:content` is a drop-in for `astro:content`, plus four helpers that use the same URL and tree code as the studio: ```astro --- import { getBreadcrumbs, getChildren, render } from 'vesper:content'; const { page } = Astro.props; const { Content } = await render(page); const crumbs = await getBreadcrumbs('pages', page); const children = await getChildren('pages', page); ---
    {children.map((child) => (
  • {child.url ? {child.title} : child.title}
  • ))}
``` - `getTree(collection)` returns the whole tree, nested by folder and ordered by `_order.yaml`, with the home page first. Each node has `title`, `url`, `entry` and `children`. - `getChildren(collection, entry)` returns a page's children, in order. - `getBreadcrumbs(collection, entry)` returns `{ title, url }` for each step from the home page to the entry. - `getUrl(entry)` returns the entry's URL from its pattern, with base and trailing slash, or `undefined` for a data-only collection. A folder with no section page is a node with `url: null`. In `astro dev`, a change to an `_order.yaml` reloads the site's pages. --- # Markdoc and MDX > How the body editor shows Markdoc tags and MDX components as blocks, what it keeps exactly as written, and what stops a save. ## The body editor An entry's body opens in a writing view: paragraphs, headings, lists, quotes, code, links and images are edited as formatted text. "Edit as Markdoc" (or MDX, or Markdown) switches to the whole body as text, exactly as it's saved, and "Back to the writing view" returns. A save rewrites only the blocks you changed. Untouched blocks keep their bytes, and so does the space between them. A changed block is written in one house style. Vesper reads the body with the same parser and options your site builds with, so the studio and the build see the same document. ## Markdoc tags as blocks The studio reads the tags in `markdoc.config.mjs`, as `@astrojs/markdoc` does, and builds each tag's block from them. There's nothing to declare twice. ```js // markdoc.config.mjs import { component, defineMarkdocConfig } from '@astrojs/markdoc/config'; export default defineMarkdocConfig({ tags: { callout: { render: component('./src/components/Callout.astro'), children: ['paragraph', 'list'], attributes: { type: { type: String, default: 'note', matches: ['note', 'tip', 'warning'] }, title: { type: String }, }, }, youtube: { render: component('./src/components/YouTube.astro'), selfClosing: true, attributes: { id: { type: String, required: true }, title: { type: String, required: true }, }, }, abbr: { render: component('./src/components/Abbr.astro'), inline: true, attributes: { title: { type: String, required: true } }, }, }, }); ``` How a tag sits in the body decides how it shows: | In the body | In the writing view | |---|---| | An opening line, content, then a closing line | A frame. Its content stays editable, and only what the tag's `children` lists fits inside. | | Self-closing, or with its content on the opening line | A card: one piece, with its settings | | Inside a line of text (`inline: true`) | A chip in the text | Each frame and card is named after its tag, with a Settings button. Its settings are the tag's attributes, with the same controls as frontmatter fields: | Attribute | Control | |---|---| | `String` with a list of `matches` | Radio buttons for four values or fewer, otherwise a select | | `String` with a pattern in `matches` | A text field checked against the pattern | | `String`, or a custom type | A text field | | `Number` | A number field | | `Boolean` | A switch | | `Array`, `Object` or anything else | A raw YAML field | Changes apply as you make them, so Ctrl+Z undoes them like typing. Changing a setting changes only that attribute's bytes in the tag. The rest of the tag, its content and its closing stay as written. A new tag is written with its attributes in schema order, leaving out empty values and defaults. On the keyboard: arrow onto a block to select it, Enter opens its settings, Alt+↑ and Alt+↓ move it, and Delete removes it. Inside a frame, Escape selects the frame, and Escape again moves to the toolbar. Type "/" at the start of a line or after a space for a list of the blocks that fit where you are. The toolbar's Insert menu has the same list. ## Hints in vesper.config.ts Names and help text for the studio live in `vesper.config.ts`, never in `markdoc.config.mjs`: ```ts // vesper.config.ts markdoc: { tags: { callout: { label: 'Callout', summary: 'type', attributes: { type: { labels: { note: 'Note', tip: 'Tip', warning: 'Warning' } }, title: { description: 'Shown above the callout. Leave it empty to use the type.' }, }, }, youtube: { label: 'YouTube video', summary: 'title', attributes: { id: { label: 'Video ID', description: 'The part after “v=” in the video’s address.' }, title: { description: 'Describes the video for screen reader users.' }, }, }, }, }, ``` - `label` is the block's name. Without one, it's the tag's name in sentence case. - `description` is help text for the tag. - `summary` names the attribute whose value shows in the block's header, as in "Callout · Warning". - Each attribute can have a `label`, a `description`, `labels` for its `matches` values, and a `widget` to choose its control by name. A widget applies as it would to a schema field of the attribute's type: a `String` attribute can take `textarea`, `url`, `color` or a custom widget. `Object` and `Array` attributes are edited as YAML, so a widget such as `geo` doesn't apply to them; naming one warns in the terminal and keeps the YAML field. ## Partials and conditionals Markdoc's `partial` tag is a card with a file picker. It lists `.mdoc` files whose names start with `_` or that sit in a `partials` folder, nearest the entry first, and stores the path relative to the entry, as Astro resolves it. A conditional is a frame named "Conditional content". Its content is editable, and its settings hold the condition as Markdoc writes it, such as `$product.beta` or `equals($tier, "free")`. An `else` inside it shows as "Otherwise": ```md {% process=false %} {% if $product.beta %} This feature is in beta, and may change. {% else /%} This feature is ready to use. {% /if %} ``` The "/" list doesn't offer conditionals. Write a new one in the source view. ## Kept as written Some things aren't safe to retype in a writing view, so the studio protects them. It never reformats them. - **Variables, functions and annotations** are chips in the text. Their settings are a small text field, edited as written. - **Everything else the writing view can't show faithfully** is a block named in words, such as "Table, kept as written": tags `markdoc.config.mjs` doesn't define, HTML, comments, tables, footnotes and reference-style links. You edit it as raw text, in its own dialog or in the source view. ## What stops a save The studio checks the body as you type. A problem stops the save only when your site's build would fail on it: - a required attribute that's missing - a value the attribute's type or `matches` doesn't allow - an attribute the tag doesn't have - a partial whose file doesn't exist - MDX props that fail their schema Each message names the block, as in "YouTube video: Title is required." Other problems are warnings. Markdoc's own warnings stay warnings, such as content a tag's `children` doesn't list. Undefined variables aren't reported at all, because pages can pass variables in. Tags and functions the site doesn't define depend on the mode. In local mode, the dev server checks every save again with your real `markdoc.config.mjs`. Unknown tags and functions then stop the save, and custom attribute types and `validate` functions run too. On the deployed site, in GitHub mode and the cloud, the studio checks with what it knows of your config, so unknown tags only warn, and your site's build is the last check for custom validators. Tags and declared components also get accessibility notes from their own schema. A required `title`, `alt`, `label` or `aria-label` left empty is a note, and a tag with a `captions` attribute asks you to confirm captions when it's absent or off. See [Accessibility](/docs/site/accessibility/). ## MDX components MDX components don't declare their props anywhere the studio can read. So a component is a block only when you declare it in `vesper.config.ts`, with a Zod props schema from `astro/zod`: ```ts // vesper.config.ts import { defineConfig } from '@vesper/astro/config'; import { fields } from '@vesper/astro/fields'; import { z } from 'astro/zod'; export default defineConfig({ mdx: { components: { Callout: { label: 'Callout', summary: 'type', children: true, props: z.object({ type: fields.select({ note: 'Note', tip: 'Tip', warning: 'Warning' }).default('note'), title: z.string().optional(), }), }, YouTube: { label: 'YouTube video', props: z.object({ id: z.string(), title: z.string() }), }, }, }, }); ``` ```md Check the deadline for your program. ``` - `children: true` makes the component a frame whose content stays editable. Without it, the component is a card. - `inline: true` puts it inside a line of text instead. - `label`, `description` and `summary` work as they do for Markdoc tags. Props get the same controls as frontmatter fields, so `fields.*` helpers and `.meta()` hints work there too. - The studio checks props with your own schema, in every mode. The schema loads the first time an MDX body needs it. - A prop that holds code, such as a function, or a spread of props, is shown read only and kept byte for byte. A component with a spread isn't checked. - Undeclared components, expressions, imports and exports are kept as written. --- # On your site > What Vesper adds to the site it builds, and why all of it is plain static files. Vesper does more than edit content. Every `astro build` also indexes your site for search, checks each built page's accessibility structure, and turns the studio's redirects into pages any host can serve. Everything it adds is static files in `dist/`, beside the pages Astro builds: the studio at `/admin`, the search index, and a redirect page at each old address. Nothing needs a server, a function or a host setting, so the site works the same on Netlify as on a shared host you upload to by FTP. The accessibility report is the one thing that stays out of `dist/`. It's written to `.vesper/` in your project, so it's never deployed. --- # Site search > Add one component to your layout, and every build indexes your site with Pagefind for a search dialog that works on any host. Search is free and static. Once a page uses ``, every `astro build` indexes your pages with [Pagefind](https://pagefind.app) and writes the index into `dist/_vesper/search/`. It's plain files, so there's no service to run, and no MIME type or rewrite to set on the host. ## Add search to your layout ```astro --- import { Search, SearchHead } from '@vesper/astro/components'; ---
``` - `` renders a **Search** button that opens a dialog with the field, filters and results. - `` goes in ``. It links the site's `opensearch.xml`, so browsers can offer your search from the address bar. It needs Astro's `site` setting, and renders nothing without it. There's no other step: no command to run and no change to how you deploy. Until a page uses ``, the build writes no index and no `/search` page, and its log says so. ## Using the dialog - **Ctrl+K** (⌘K on a Mac) opens it from anywhere on the page, and the button's description tells screen reader users so. There's no single-key shortcut such as "/", because WCAG requires that visitors can turn one off; a shortcut with Ctrl or ⌘ needs no switch. - Results appear as you type, and the count is announced once typing pauses: "12 results for “nursing”". - Down Arrow moves from the field to the first result, and Up and Down Arrow move between results. Results come ten at a time; **Show more results** moves focus to the first new one. - Escape closes the dialog, and focus returns to the button. A click outside it closes it too. Each result shows the page's title as its link, the entry's date, an excerpt with the matches marked, and the square crop of the entry's first image, so a focal point set in the studio carries through. Thumbnails need `sharp`, the package Astro's own image service uses. | Prop | Default | What it does | |---|---|---| | `variant` | `'dialog'` | `'inline'` puts the field and results in the page instead. Use it on a search page. | | `shortcut` | `true` | `false` turns off Ctrl+K. Only the first dialog on a page takes the shortcut. | | `lang` | Astro's current locale, then `en` | The language of the words written into the page. | | `id` | | Needed only for a second `` on one page. | | `class` | | Added to the component's outer element. | ## Filters and sorting Every entry's page has a **Section** filter: its collection's label. You can add your own filters, and fields to sort by, in `astro.config.mjs`: ```js vesper({ search: { filters: { blog: ['tags', 'author'] }, sort: { blog: ['publishedAt'] }, }, }); ``` - Keys are collection names, and values are field names from its schema, with a dotted path for a field inside an object (`seo.keywords`). - **Filters** can be text, lists of text, selects and references. Each is a group of checkboxes under the field's label, showing a select's labels and a referenced entry's title. - **Sort** fields can be dates, numbers and text. The Sort menu appears once a visitor picks a section that has them, because Pagefind leaves out pages with no value for the chosen field. - A result's date is its first date field in `sort`, or else the entry's first date field. The build warns about a field that doesn't exist or can't filter or sort, and leaves it out. ## What's indexed Every built page, except: - Astro's redirect pages, the studio at `/admin` and the 404 page - the search page, and any page with `` - pages with `` - pages that match a pattern in `search.exclude` - entries of a collection with `search: false` in `vesper.config.ts` - entries hidden from search, below ```js vesper({ search: { exclude: ['/tags/**'] } }); ``` Patterns match the page's path without Astro's `base`. `*` matches within one part of the path, `**` across parts. The index reads each page's `
`, so headers, menus and footers stay out of the excerpts. If your pages mark their own content with Pagefind's `data-pagefind-body`, Vesper uses that instead, and warns about the pages Pagefind then drops for not having it. Pagefind's `data-pagefind-ignore` works as usual. Vesper adds the entry's title, date and filters to the copy of each page it hands Pagefind; the HTML in `dist/` is never changed. {% callout type="warning" %} Use the inline variant only on your search page. Vesper leaves every page with an inline `` out of the index, so an inline search in a shared layout would hide every page built with that layout. {% /callout %} ### Hide one entry Add `fields.hideFromSearch()` to the collection's schema: ```ts // src/content.config.ts import { fields } from '@vesper/astro/fields'; const blog = defineCollection({ loader: glob({ pattern: '**/[^_]*.md', base: './src/content/blog' }), schema: z.object({ title: z.string(), hideFromSearch: fields.hideFromSearch(), }), }); ``` Editors get a **Hide from site search** switch in the entry's Details. The page stays on the site. The value is a plain `true` or `false` in the file, so your layout can also use it for ``. Without this field, Vesper uses a boolean `noindex` field if your schema has one, at the top level or under `seo`. Vesper matches pages to entries through each collection's `url` in `vesper.config.ts`, so the switch and `search: false` apply only to collections with a `url`. See [Pages and URLs](/docs/content/pages-and-urls/). ## The /search page When your pages use ``, Vesper adds a plain page at `/search`. It reads `?q=`, so a search can be linked and bookmarked, and asks search engines not to index it. For a search page in your own layout, add `src/pages/search.astro`. Yours replaces Vesper's: ```astro --- import { Search } from '@vesper/astro/components'; import Layout from '../layouts/Layout.astro'; ---

Search

``` The inline variant keeps the query in the page's address as you type, so a reload or a shared link searches again. ## Without JavaScript - The **Search** button is a link to `/search`. - `/search` says that search needs JavaScript, and links to the home page, or to `search.noJsLink`. If the site uses `@astrojs/sitemap`, it links to the site map too. - With `noJsFallback: 'duckduckgo'`, the form searches your site on DuckDuckGo instead, and says so. It needs Astro's `site`. ```js vesper({ search: { noJsFallback: 'duckduckgo' } }); ``` ## Theming `` uses your site's font and colours. Four custom properties do the rest: ```css :root { --vesper-search-accent: #1f5fd1; /* focus ring and the tint of matches; default: the system highlight */ --vesper-search-surface: #ffffff; /* the dialog's background; default: Canvas */ --vesper-search-border: #4d566b; /* borders, at 3:1 against the surface; default: currentColor */ --vesper-search-radius: 0.5rem; /* default: 0.5rem */ } ``` In Windows high contrast and other forced-colour modes, matches use the system's own colours so they stay visible. ## Other languages Pagefind keeps one index per ``, so a multilingual site gets results in the page's own language. ``'s words are English. Give other languages with `search.strings`: ```js vesper({ search: { strings: { fr: { button: 'Rechercher', label: 'Rechercher sur ce site', close: 'Fermer' }, }, }, }); ``` - Keys are lower-case language codes. A page in `fr-CA` uses the words under `fr-ca` if you give them, else those under `fr`. A word you leave out stays English. - The button and the field's label are written into the page in the `lang` you pass to ``, which defaults to Astro's current locale. Without Astro's i18n routing, pass it yourself: ``. The words you can replace: - **The button and field:** `button`, `label`, `close`, `shortcut` (with `{key}`) - **Results:** `results`, `oneResult` and `noResults` (with `{count}` and `{query}`), `resultsList`, `more`, `loadError`, `noIndexYet` - **Sorting:** `sort`, `bestMatch`, and `newest`, `oldest`, `lowest`, `highest`, `aToZ`, `zToA` (each with `{field}`) - **The search page:** `pageTitle`, `noJs`, `noJsHome`, `noJsLink`, `noJsSitemap`, `noJsDuckDuckGo` ## Size The build log says how many pages were indexed, how large the index is, and what a first search loads. Pagefind loads the index in chunks as they're needed, so a large site stays fast. The build warns when a first search loads more than 300 KB, counted uncompressed, which is what a host without compression sends. If you see it, leave pages out with `exclude`, or turn on compression at your host. ## In astro dev Search uses the index from your last build. Before the first build, it says "Search works after astro build." ## Turn search off ```js vesper({ search: false }); ``` The build then writes no index, no `/search` page and no `opensearch.xml`. --- # Accessibility checks > Vesper checks content in the studio as it's edited, and every page at build, and reports what it finds in plain words. Vesper checks accessibility in two places: in the studio, while someone edits an entry, and at build, on every page the site produces. Both use one set of rules and the same plain words, with one sentence on why each problem matters. {% callout type="note" title="What the checks can't do" %} Automated checks find many problems, not all. Vesper reports what it finds. It never says a page or a site is compliant, and it never adds an accessibility overlay or widget to your site. Test your pages with a keyboard and a screen reader too. {% /callout %} ## In the studio The studio checks each entry as it's edited, in every mode: local, GitHub and the cloud. A note is text beside the field it's about, and notes on the body sit under the body. The top bar counts them ("2 accessibility notes"), and the count opens a list of every note with **Go to field**. | Rule | What it looks for | |---|---| | `missing-alt` | Images with no alt text that aren't marked decorative | | `alt-file-name` | Alt text that is a file name | | `alt-redundant` | Alt text that starts with "Image of" | | `alt-too-long` | Alt text longer than 150 characters | | `heading-skip` | Headings that skip a level | | `heading-extra-top` | An extra top-level heading in the body | | `heading-empty` | Empty headings | | `link-vague` | Link text such as "click here", "read more" or a bare address | | `link-new-window` | Links that open a new tab without saying so | | `table-header` | Tables without a header row | | `fake-list` | Lines typed with "-" or "1." to look like a list | | `tag-name-empty` | Embeds and components whose accessible name is empty | | `video-captions` | Videos that may not have captions | | `color-contrast` | Text colours with too little contrast against your backgrounds | | `pdf-untagged` | PDFs without tags | | `pdf-no-title` | PDFs without a document title | | `pdf-no-language` | PDFs without a language | - **Images** are checked in `fields.image()` fields and in the body. An image marked decorative needs no alt text. - **Headings** in the body should start at level 2, because the page's title is its h1. If a collection's bodies hold their own h1, set `headingStart: 1` for it in `vesper.config.ts`. - **Markdoc tags and MDX components** are checked from their own schemas. A required `title`, `alt`, `label` or `aria-label` attribute left empty is a note, and a tag with a `captions` attribute asks the editor to confirm captions when it's missing or off. - **PDFs** in file fields, and the site's own PDFs linked from the body, are read in the browser the first time an entry has one. - **Markdown fields** from `fields.markdown()` get the body's checks, except headings. ### Text colours against your backgrounds A colour field can ask to be checked against the colours your site draws text on. Give the field a use, and name the backgrounds in `vesper.config.ts`: ```ts // src/content.config.ts accentText: fields.color({ contrast: 'text' }), ``` ```ts // vesper.config.ts export default defineConfig({ a11y: { backgrounds: { 'Page, light': '#ffffff', 'Page, dark': '#12151f' }, }, }); ``` `'text'` needs a ratio of 4.5:1, and `'large-text'` and `'graphic'` need 3:1, as WCAG 2.1 sets them. The colour is checked against each background, and the note names the one it fails: "Contrast 3.1:1 against Page, dark is below 4.5:1." Backgrounds are hex colours. If a field asks for a check and the site names no backgrounds, Vesper warns that there's nothing to check it against. ### Notes that block a save Every note only warns by default. To make some rules stop a save, list them: ```ts // vesper.config.ts export default defineConfig({ a11y: { blockPublishOn: ['missing-alt'] }, }); ``` - **GitHub mode.** Save is refused while a note on a listed rule remains. The error summary takes focus and links each note to its field, and the count says how many block saving. - **Local mode.** The save is refused too, then **Save anyway** is offered: saving there only changes the file on your computer, and the studio still won't publish the entry with those notes. - **The cloud.** Drafts save with any notes. The listed rules apply when someone publishes. A name that isn't one of the rules above gets a warning and blocks nothing. ## At build After every `astro build`, Vesper loads each page into jsdom, a browser-like document with no screen, and runs axe-core's structure rules: - the page's language and title - landmarks: one main area, no duplicate header or footer, content inside landmarks, a way to skip to the main content - heading order, a main heading, empty headings - alt text on images, image buttons, SVG images and embedded objects - labels on form fields, and names on links, buttons and frames - duplicate IDs - ARIA roles, attributes and values, and hidden content that can take focus - list and table structure What it doesn't check: - **Colour contrast**, and anything else that needs a rendered page. jsdom has no layout. - **What your scripts add.** The page's own scripts never run. - **Astro's redirect pages and the studio**, which are skipped. It adds about a tenth of a second a page, plus a few seconds to start. ### The report The report is `.vesper/a11y-report.json` in your project, never in `dist/`, so it's never deployed. Vesper also writes `.vesper/.gitignore`, so the report stays out of git. If your output folder would contain `.vesper/`, no report is written and the build log says so. Each page with issues lists them in plain words. An issue's `level` is `error` for axe's critical and serious problems and `warning` for the rest. A trimmed example: ```json { "summary": { "errors": 1, "warnings": 0, "pages": 1 }, "pages": [ { "url": "/news/open-day/", "file": "news/open-day/index.html", "entry": { "collection": "news", "id": "open-day", "file": "src/content/news/open-day.md", "studio": "/admin/#/c/news/e/open-day" }, "issues": [ { "rule": "image-alt", "level": "error", "message": "Describe this image, or mark it decorative.", "why": "Screen readers announce an image with no description by its file name, or not at all.", "help": "https://dequeuniversity.com/rules/axe/4.14/image-alt", "target": "main > img", "html": "" } ] } ] } ``` ### From a page to its entry Vesper traces each built page to the entry that produced it through the collection's `url` in `vesper.config.ts`, the same addresses the studio uses. The report then gives the entry's file and its link in the studio, so you can fix it there. Pages no entry produced, such as listings, the 404 page and pages of collections without a `url`, have `"entry": null`. See [Pages and URLs](/docs/content/pages-and-urls/). ### Fail the build The build never fails on an issue unless you ask it to: ```js // astro.config.mjs vesper({ a11y: { failBuildOn: 'error' } }); ``` `'error'` fails it on errors only, and `'warning'` on any issue. The build fails after the report is written, so the reasons are there to read. ### Turn it off ```js vesper({ a11y: false }); ``` --- # Hosting > The built site and the studio are plain static files, so they work on any host, from Netlify to a shared host you upload to by FTP. `astro build` makes ordinary static output in `dist/`, and the studio is part of it. There's no adapter, no server and no function to deploy. Copy `dist/` to anything that serves files, and every Vesper feature works there. ## Any host The built site and the studio work on Netlify, Cloudflare Pages, Vercel, Amazon S3 (with or without CloudFront), GitHub Pages, and a shared host you upload to by FTP or SFTP. No feature depends on a host's rewrites, headers, functions or image service. Where a host has something better, such as its own redirect rules, Vesper uses it as an extra, and the plain version still works everywhere else. ## What Vesper adds to dist/ | Path | What it is | |---|---| | `admin/` | The studio. | | `_vesper/manifest.json` | Your collections and their fields, as the studio and the cloud read them. It holds no entries and no secrets. | | `_vesper/search/` | The [search](/docs/site/search/) index, once a page uses ``. | | `search/` | The search page, once a page uses ``, unless you have your own. | | `opensearch.xml` | So browsers can offer your search, when Astro's `site` is set. | | `_vesper/schemas.js` | With GitHub mode or the cloud: your content schemas, built for the browser so the studio checks entries as your build does. A schema that imports Node-only code can't be built, and the build log says why. | | A page at each old address | Your redirects, below. | | `_vesper/redirects.json` | The cleaned list of redirects, when there are any. | The [accessibility report](/docs/site/accessibility/) goes to `.vesper/` in your project, never into `dist/`. ## Subfolders and trailing slashes Vesper follows Astro's `base`, `trailingSlash` and `build.format`. A site with `base: '/college/'` gets its studio at `/college/admin/`, and every address Vesper writes, from links to redirects to search results, has the base and the trailing slash your site uses. That's what a subfolder on a shared host needs, and what a GitHub Pages project site needs too. The studio's own screens use addresses after a `#`, such as `/admin/#/c/blog`. That part never reaches the server, so a link to any screen survives a reload on a host with no rewrite rules. ## Redirects When a page moves in the studio, Vesper adds a redirect from its old address (see [Pages and URLs](/docs/content/pages-and-urls/)). At build, each old address gets an HTML page that sends visitors on to the new page's exact URL in one step. That works on every host. Real 301 redirects need the host's own rules. Vesper writes them for these hosts: | Host | How Vesper knows | What it writes | |---|---|---| | Netlify | The `NETLIFY` build variable | `_redirects` in `dist/`, with Vesper's rules first | | Cloudflare Pages | The `CF_PAGES` build variable | `_redirects` in `dist/` | | Vercel | The `VERCEL` build variable | Nothing at build: the studio keeps the `redirects` in your site's `vercel.json` up to date | | Apache, LiteSpeed | `native: ['apache']` | Rules in `dist/.htaccess`, between `# BEGIN vesper` and `# END vesper` | | nginx | `native: ['nginx']` | `dist/_vesper/nginx-redirects.conf`, for your server's admin to include | Name a host that can't be detected in `vesper.config.ts`. Most shared hosting runs Apache: ```ts // vesper.config.ts import { defineConfig } from '@vesper/astro/config'; export default defineConfig({ redirects: { native: ['apache'] }, }); ``` An address a host rule covers gets no HTML page. Vercel reads `vercel.json` before it builds, so the file can't be written at build. Instead, the studio updates it whenever it writes redirects, if the site has a `vercel.json` or `native` lists `vercel`. ## How a publish goes live In GitHub mode and the cloud, a publish is a commit to your repository. A host connected to the repository, such as Netlify, Cloudflare Pages or Vercel, or a GitHub Actions workflow, builds on every commit, so there's nothing to set up. On a host you upload to yourself, the change goes live after your next build and upload, and the studio says so: "Published. It will be live after the site is next built." ## Notes for GitHub mode **Hosts that check commit authors.** In GitHub mode each editor is the author of their own commits. On private repositories, Vercel builds only commits by members of your team, and Netlify holds commits from unknown authors for approval. Add your editors there, or build with GitHub Actions or Cloudflare Pages. **A Content Security Policy for `/admin`.** If your host sets headers and you give `/admin` a policy, it needs: - `connect-src` for `https://api.github.com` and your sign-in relay - `img-src blob:` for image previews With the cloud, the studio's requests go to `https://api.vespercms.com` instead of GitHub. A custom widget, or the map in a `fields.geo()` field, loads from its own servers too. ## Leave the studio off the live site `/admin` deploys with the site by default. It's a static page that shows nothing until someone signs in, and it asks search engines not to index it. A site edited only on your computer can leave it out of production builds: ```js // astro.config.mjs vesper({ studio: { deploy: false } }); ``` The studio is still at `/admin` in `astro dev`. --- # AI coding agents > Give a coding agent your site's content rules with one command, and point it at these docs as Markdown. Your content is files in your repository, so a coding agent can already edit it. What it doesn't know is how your site's content works: which collections exist, which fields each one needs, which Markdoc tags it may use, and what to leave alone so the studio keeps working. Vesper writes that down for it. ## Write your site's instructions Run this in your site's folder: ```sh npx vesper agents ``` It reads `src/content.config.ts` and `vesper.config.ts`, as the studio does, and writes a section of `AGENTS.md` with: - **The rules** that keep the studio and your build working: change only what you mean to change, never reformat content files, write values as the file stores them, and check the result with `astro build`. - **What your site uses**: how image fields are stored, how page trees and `_order.yaml` work, how to add a redirect, and every Markdoc tag and MDX component with its attributes. Each part appears only if your site has it. - **Every collection**: where its files are, its URL pattern, and each field with its type, whether it's required, its default and its help text. The section sits between two markers. Everything outside them is yours, and Vesper never changes it. Run the command again after you change your schema: it replaces its own section and nothing else. {% callout type="tip" title="Claude Code" %} Many coding agents read `AGENTS.md` at the top of a project. Claude Code reads `CLAUDE.md` instead, and can import another file from it. Add this line to your `CLAUDE.md`: ```md @AGENTS.md ``` Or write the section straight into `CLAUDE.md` with `npx vesper agents --file CLAUDE.md`. {% /callout %} ## Keep it current A schema change makes the instructions out of date. `--check` writes nothing, and fails when the file doesn't match your schema: ```sh npx vesper agents --check ``` Run it in CI beside your build, and a changed schema can't leave the instructions behind. A script in `package.json` makes both easy to remember: ```json { "scripts": { "agents": "vesper agents" } } ``` The [command reference](/docs/reference/cli/) lists every option, including `--root` for a site inside a larger repository. ## These docs, for agents Agents read Markdown more reliably than web pages, so the docs come in both: - [/llms.txt](/llms.txt) lists every page, with a one-line summary and a link to its Markdown. - [/llms-full.txt](/llms-full.txt) is all of the docs in one file. - Every page is Markdown too: put `.md` in place of the trailing slash, as in `/docs/agents.md`. The link at the bottom of each page goes there. Give an agent the address of the page it needs, or the whole file when it's learning Vesper from scratch. ## Reviewing an agent's changes An agent's edit is an ordinary change to a file, so review it like anyone else's: the diff shows exactly what changed. When a move or rename was done by hand, check that both `_order.yaml` files, the redirects and the links changed with it. Moving pages in the studio does all of that for you. --- # Reference > Every setting Vesper reads, with its default, and the vesper command. These pages list every setting in one place, with its exact name and default. The guides explain when you'd want each one; come here when you know what you want and need the details. Vesper reads two files of yours. `vesper()` in `astro.config.mjs` sets up the integration: the studio's deployment, search, the build's accessibility check, and publishing with GitHub or the cloud. `vesper.config.ts` says how the studio presents your collections and where their entries live on the site. Neither holds field definitions: those stay in `src/content.config.ts`. --- # Configuration > Every option of vesper() in astro.config.mjs and every key of vesper.config.ts, with its default. Vesper reads two files, and neither is required. `vesper()` with no options and no `vesper.config.ts` is a complete setup: every collection gets a form built from its schema. ## vesper() in astro.config.mjs ```js // astro.config.mjs import { defineConfig } from 'astro/config'; import vesper from '@vesper/astro'; export default defineConfig({ site: 'https://www.example.edu', integrations: [ vesper({ siteName: 'Riverside College', search: { filters: { news: ['tags'] } }, a11y: { failBuildOn: 'error' }, }), ], }); ``` | Option | Default | What it does | |---|---|---| | `siteName` | The host of Astro's `site`, else your package's name | The site's name in the studio's top bar and page titles, and on the search page. | | `studio.deploy` | `true` | `false` leaves `/admin` out of production builds. It's still there in `astro dev`. | | `imageService` | `true` | Wraps Astro's Sharp image service, when your site uses it, so the crops editors set are cut before Astro resizes each image. `false` leaves Astro's service alone; `` then uses Astro's own crop for each ratio, and manual crops aren't applied. | | `a11y` | `{}` | The [accessibility check](/docs/site/accessibility/) at build. `false` turns it off. | | `search` | `{}` | [Site search](/docs/site/search/). `false` turns it off: no index and no `/search` page. | | `github` | Off | [GitHub mode](/docs/getting-started/publishing/): editors sign in with GitHub on the deployed site. | | `cloud` | Off | [The cloud](/docs/getting-started/publishing/): editors sign in to Vesper, and saves are drafts until published. | ### a11y | Key | Default | What it does | |---|---|---| | `failBuildOn` | `false` | `'error'` fails the build when the report has an error (axe's critical and serious issues). `'warning'` fails it on any issue. The report is written first. | ### search | Key | Default | What it does | |---|---|---| | `filters` | None | Per collection, the fields offered as filters: text, lists of text, selects and references. | | `sort` | None | Per collection, the fields results can be sorted by: dates, numbers and text. | | `exclude` | `[]` | Pages to leave out, by path, with `*` and `**`, such as `'/tags/**'`. | | `noJsLink` | The home page | Where `/search` sends visitors who have no JavaScript. | | `noJsFallback` | None | `'duckduckgo'`: without JavaScript, the search form searches the site on DuckDuckGo. Needs Astro's `site`. | | `strings` | English | The search interface's words in other languages, keyed by ``. | ```js vesper({ search: { filters: { blog: ['tags', 'author'] }, sort: { blog: ['publishedAt'] }, exclude: ['/tags/**'], strings: { fr: { button: 'Rechercher' } }, }, }); ``` ### github GitHub mode is on in built sites only; `astro dev` always saves to your files. | Key | Default | What it does | |---|---|---| | `relay` | Required | The address of your sign-in relay: the `oauth-relay` template from Vesper's repository, deployed as a Cloudflare Worker. | | `repo` | Read from the host's build environment, then git | The repository, as `owner/name`. | | `branch` | The repository's default branch | Where publishes go. If git can't tell, `main`, and the build log says so. | | `root` | Read from git | The site's folder inside the repository, for a monorepo. | | `apiUrl` | `https://api.github.com` | For GitHub Enterprise Server: its API, such as `https://github.example.edu/api/v3`. | ```js vesper({ github: { relay: 'https://vesper-github-relay.your-account.workers.dev' } }); ``` ### cloud The cloud is on in built sites only. A site can have GitHub mode too, and the sign-in screen then offers both. | Key | Default | What it does | |---|---|---| | `project` | The `VESPER_PROJECT` variable | The site's project ID, from its settings in the dashboard. It's an identifier, not a secret. | | `app`, `api` | `https://app.vespercms.com`, `https://api.vespercms.com` | For staging and tests only, and always set together, as here or with `VESPER_CLOUD_APP` and `VESPER_CLOUD_API`. | ```js vesper({ cloud: { project: 'site_…' } }); ``` `cloud: true` reads the project ID from `VESPER_PROJECT`. ## vesper.config.ts `vesper.config.ts` sits next to `astro.config.mjs` (`.mts`, `.js` and `.mjs` work too). It holds how the studio presents your content and where entries live on the site, never field definitions. Every key is optional. A key Vesper doesn't know is ignored with a warning, so a config written for a newer version still loads, and a value of the wrong type is reported with what it should be. A change to the file restarts the dev server. ```ts // vesper.config.ts import { defineConfig } from '@vesper/astro/config'; export default defineConfig({ timeZone: 'America/New_York', collections: { pages: { url: '/:path', tree: true, maxDepth: 3, icon: 'panels-top-left' }, news: { label: 'News', url: '/news/:slug', listFields: ['publishedAt'], icon: 'newspaper' }, settings: { label: 'Site settings', singleton: true }, }, images: { ratios: { wide: 16 / 9, square: 1 } }, }); ``` | Key | What it holds | |---|---| | `collections` | How the studio presents each collection, and where its entries live. | | `images` | The aspect ratios your site crops to, and the upload size limit. | | `a11y` | The rules that block a save, and the colours your site draws text on. | | `redirects` | Hosts to write native redirect rules for. | | `timeZone` | The site's time zone. | | `markdoc` | Labels and help text for your Markdoc tags. | | `mdx` | MDX components the body editor shows as blocks. | | `widgets` | Your own editor controls. | Roles and what each person may edit are set in the cloud's dashboard, not here. A `roles` key is ignored with a warning. ### collections Keyed by the collection's name in `src/content.config.ts`. A name that isn't a collection gets a warning. | Key | Default | What it does | |---|---|---| | `label` | From the name: `blogPosts` is "Blog posts" | The collection's name in the studio, and its Section filter in search. | | `icon` | A page; an outline for a tree; sliders for a singleton | A [Lucide](https://lucide.dev/icons) icon name, as that site lists it, such as `newspaper`. Install the `lucide` package to use one. | | `url` | None | Where each entry lives on the site: a pattern or a function, below. | | `tree` | `false` | Shows the collection as an outline: folders are sections, and an optional `_order.yaml` in each folder sets its order. Collections loaded with `glob()` only. | | `maxDepth` | No limit | For a tree: how many levels deep a page may be, 1 or more. | | `singleton` | `false` | The collection holds one entry, and the studio opens it directly. | | `listFields` | None | Top-level fields shown as columns in the entry list. The first date field among them sorts the list, newest first. | | `newEntryPath` | `:slug` | Where new entries are saved, inside the collection's folder, with `:slug`, `:year`, `:month` and `:day`. Trees place new pages under their parent instead. | | `placement` | Each field's own `.meta({ placement })` | Per top-level field, `'main'` for the main form or `'details'` for the Details section. | | `headingStart` | `2` | The level the body's top headings should have, from 1 to 6. Set `1` when the body holds the page's h1. | | `search` | `true` | `false` leaves the collection's pages out of site search. | | `schedule` | None | Reserved for scheduled publishing, which isn't available yet. | A `url` pattern is built from tokens: - `:id` is the entry's ID. - `:path` is the ID without a trailing `/index`. A top-level `index` is the collection's root. - `:slug` is the last part of the path. - `:title`, or any other field's name, is that field's value. - `:publishedAt|yyyy`, `|mm` and `|dd` are parts of a date field. A date with a time is read in the site's `timeZone`. Addresses follow Astro's `base`, `trailingSlash` and `build.format`. The studio, links, redirects, breadcrumbs, search and the build's accessibility report all use the same `url`. For anything a pattern can't say, give a function. It gets the entry's `id`, `collection` and `data` (the frontmatter, typed `unknown`, so check a field before using it) and returns its path on the site. Keep it pure, with no server-only imports, because the studio runs it in the browser too: ```ts collections: { news: { url: '/news/:publishedAt|yyyy/:slug' }, people: { url: (entry) => `/people/${entry.id}` }, }, ``` `/admin` and `/_vesper` are Vesper's, so no page may live there. See [Pages and URLs](/docs/content/pages-and-urls/) for trees, section pages and moves. ### images | Key | Default | What it does | |---|---|---| | `ratios` | Only `og` | Named aspect ratios, as width / height, such as `{ wide: 16 / 9, square: 1 }`. The studio previews each crop, and `` renders it. `og` (1.91:1, for social images) is always there. | | `maxUploadEdge` | `3000` | Uploads whose longer edge is bigger, in pixels, are scaled down in the browser before they're saved. | See [Images](/docs/content/images/). ### a11y | Key | Default | What it does | |---|---|---| | `blockPublishOn` | `[]` | Rules whose notes stop a save, such as `['missing-alt']`. In the cloud they stop a publish, not a draft. | | `backgrounds` | None | The colours your site draws text on, by name, as hex: `{ Page: '#ffffff', Footer: '#1d2433' }`. Colour fields with `contrast` are checked against each. | ```ts a11y: { blockPublishOn: ['missing-alt'], backgrounds: { 'Page, light': '#ffffff', 'Page, dark': '#12151f' }, }, ``` The rule names are on [Accessibility checks](/docs/site/accessibility/). ### redirects | Key | Default | What it does | |---|---|---| | `native` | Detected | Hosts to write their own redirect rules for: `netlify`, `cloudflare`, `vercel`, `apache` or `nginx`. Netlify, Cloudflare Pages and Vercel are detected from the build environment; name `apache` or `nginx` yourself. | Every host also gets an HTML redirect page at each old address. See [Hosting](/docs/site/hosting/). ### timeZone An IANA time zone name, such as `'America/New_York'`. The studio shows and takes times in it, and stores them as ISO 8601 with the offset. Date tokens in `url` and dates in search results use it too. Without it, each editor sees their own time zone, and addresses use UTC. ### markdoc Your tags stay in `markdoc.config.mjs`, and the studio builds each tag's settings from their attributes. These hints only add words for editors: ```ts markdoc: { tags: { callout: { label: 'Callout', summary: 'type', attributes: { type: { labels: { note: 'Note', tip: 'Tip', warning: 'Warning' } }, title: { description: 'Shown above the callout.' }, }, }, }, }, ``` | Key | What it does | |---|---| | `label` | The tag's name in the studio. | | `description` | Help text for the tag. | | `summary` | The attribute whose value the block's header shows: "Callout · Warning". | | `attributes` | Per attribute: `label`, `description`, `labels` for each value, and `widget`, such as `'color'`. | ### mdx MDX components declare their props nowhere the studio can read, so they're edited as blocks only when you declare them here, with a Zod schema from `astro/zod`. Other components stay as protected source. ```ts import { z } from 'astro/zod'; import { defineConfig } from '@vesper/astro/config'; export default defineConfig({ mdx: { components: { YouTube: { label: 'YouTube video', props: z.object({ id: z.string(), title: z.string() }) }, Aside: { label: 'Aside', children: true, props: z.object({ title: z.string().optional() }) }, }, }, }); ``` | Key | Default | What it does | |---|---|---| | `label` | The component's name | Its name in the studio. | | `description` | None | Help text. | | `summary` | None | The prop the block's header shows. | | `children` | `false` | `true` shows a frame whose content stays editable. | | `inline` | `false` | `true` inserts the component inside a line of text. | | `props` | None | A Zod object schema for its props. Saves check props with it, as your site does. | See [Markdoc and MDX](/docs/content/markdoc-and-mdx/). ### widgets Your own React controls for fields: ```ts widgets: { 'store-hours': { component: () => import('./src/cms/StoreHours.tsx'), accepts: (field) => field.kind === 'array', match: (field) => field.path.at(-1) === 'hours', }, }, ``` | Key | Default | What it does | |---|---|---| | `component` | Required | Loads the component, which receives `WidgetProps` from `@vesper/astro/config`. | | `accepts` | Any field | Which fields it may be used on. | | `match` | None | Claims fields on its own. Without it, a field asks for the widget with `.meta({ widget: 'store-hours' })`. | Vesper compiles widget files with React's automatic runtime, so your site doesn't need `@astrojs/react`. Start each widget file with `/** @jsxImportSource react */` so TypeScript checks it as React. A widget that fails to load falls back to the built-in control, with a note. See [Fields](/docs/content/fields/). --- # The vesper command > vesper agents writes instructions for coding agents, and vesper manifest shows what Vesper reads from your site. `@vesper/astro` installs a `vesper` command. Run it with `npx` in your site's folder, or from a script in `package.json`. It reads your configs with your site's own Astro and Vite, so run it where your site's dependencies are installed. ## vesper agents Writes instructions for coding agents, generated from your schema, between Vesper's markers in `AGENTS.md`. It creates the file if there isn't one, and leaves everything outside the markers as it was. [AI coding agents](/docs/agents/) explains what goes in it and how agents find it. ```sh npx vesper agents ``` | Option | Default | | |---|---|---| | `--file ` | `AGENTS.md` | The file to write, relative to the site's folder. | | `--check` | off | Write nothing. Exit with an error when the file is out of date, for CI. | | `--root ` | the current folder | The site's folder, for a site inside a larger repository. | | `--src-dir ` | `src` | Astro's `srcDir`, when the site changed it. | It prints any problems it finds in your schema or config, the ones the studio would show you in development. ## vesper manifest Prints the manifest: the JSON the studio is built from. It lists every collection with its fields as the studio sees them, URL patterns, page trees, Markdoc tags, declared MDX components, image ratios and accessibility settings. Use it to see how Vesper reads a schema, for example why a field shows as raw YAML. ```sh npx vesper manifest ``` | Option | Default | | |---|---|---| | `--out ` | none | Write the manifest to a file instead of printing it. | | `--root ` | the current folder | The site's folder. | | `--src-dir ` | `src` | Astro's `srcDir`, when the site changed it. | Problems with your schema or config go to standard error. The command exits with an error when one of them is an error rather than a warning. `astro build` writes the same manifest to `dist/_vesper/manifest.json`. That file is how the cloud learns your site's schema without reading your repository's code.