vespercms 0.0.0-stage → 0.1.0-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +393 -2
- package/components/Img.astro +101 -0
- package/components/Search.astro +355 -0
- package/components/SearchHead.astro +18 -0
- package/components/env.d.ts +36 -0
- package/components/index.ts +29 -0
- package/components/search/strings.ts +55 -0
- package/components/search/ui.ts +513 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/config/index.d.ts +94 -0
- package/dist/config/index.js +355 -0
- package/dist/config/index.js.map +1 -0
- package/dist/fields/index.d.ts +176 -0
- package/dist/fields/index.js +232 -0
- package/dist/fields/index.js.map +1 -0
- package/dist/images/index.d.ts +2 -0
- package/dist/images/index.js +8 -0
- package/dist/images/index.js.map +1 -0
- package/dist/images/plan.d.ts +85 -0
- package/dist/images/plan.js +201 -0
- package/dist/images/plan.js.map +1 -0
- package/dist/images/register.d.ts +23 -0
- package/dist/images/register.js +33 -0
- package/dist/images/register.js.map +1 -0
- package/dist/images/service.d.ts +17 -0
- package/dist/images/service.js +153 -0
- package/dist/images/service.js.map +1 -0
- package/dist/integration/a11y.d.ts +108 -0
- package/dist/integration/a11y.js +195 -0
- package/dist/integration/a11y.js.map +1 -0
- package/dist/integration/dev-api.d.ts +21 -0
- package/dist/integration/dev-api.js +243 -0
- package/dist/integration/dev-api.js.map +1 -0
- package/dist/integration/index.d.ts +70 -0
- package/dist/integration/index.js +398 -0
- package/dist/integration/index.js.map +1 -0
- package/dist/integration/react-check.d.ts +4 -0
- package/dist/integration/react-check.js +31 -0
- package/dist/integration/react-check.js.map +1 -0
- package/dist/integration/redirects.d.ts +36 -0
- package/dist/integration/redirects.js +156 -0
- package/dist/integration/redirects.js.map +1 -0
- package/dist/integration/routes.d.ts +41 -0
- package/dist/integration/routes.js +83 -0
- package/dist/integration/routes.js.map +1 -0
- package/dist/integration/schedule.d.ts +37 -0
- package/dist/integration/schedule.js +115 -0
- package/dist/integration/schedule.js.map +1 -0
- package/dist/integration/search-plugin.d.ts +15 -0
- package/dist/integration/search-plugin.js +81 -0
- package/dist/integration/search-plugin.js.map +1 -0
- package/dist/integration/search.d.ts +77 -0
- package/dist/integration/search.js +418 -0
- package/dist/integration/search.js.map +1 -0
- package/dist/integration/site-check.d.ts +10 -0
- package/dist/integration/site-check.js +71 -0
- package/dist/integration/site-check.js.map +1 -0
- package/dist/integration/site-model.d.ts +19 -0
- package/dist/integration/site-model.js +41 -0
- package/dist/integration/site-model.js.map +1 -0
- package/dist/integration/vite-plugin.d.ts +42 -0
- package/dist/integration/vite-plugin.js +356 -0
- package/dist/integration/vite-plugin.js.map +1 -0
- package/dist/integration/widget-jsx.d.ts +4 -0
- package/dist/integration/widget-jsx.js +76 -0
- package/dist/integration/widget-jsx.js.map +1 -0
- package/dist/node/agents.d.ts +11 -0
- package/dist/node/agents.js +515 -0
- package/dist/node/agents.js.map +1 -0
- package/dist/node/cli.d.ts +2 -0
- package/dist/node/cli.js +145 -0
- package/dist/node/cli.js.map +1 -0
- package/dist/node/cloud.d.ts +22 -0
- package/dist/node/cloud.js +51 -0
- package/dist/node/cloud.js.map +1 -0
- package/dist/node/deploy-init.d.ts +26 -0
- package/dist/node/deploy-init.js +240 -0
- package/dist/node/deploy-init.js.map +1 -0
- package/dist/node/deploy.d.ts +62 -0
- package/dist/node/deploy.js +477 -0
- package/dist/node/deploy.js.map +1 -0
- package/dist/node/github.d.ts +47 -0
- package/dist/node/github.js +173 -0
- package/dist/node/github.js.map +1 -0
- package/dist/node/index.d.ts +4 -0
- package/dist/node/index.js +5 -0
- package/dist/node/index.js.map +1 -0
- package/dist/node/load-site.d.ts +58 -0
- package/dist/node/load-site.js +566 -0
- package/dist/node/load-site.js.map +1 -0
- package/dist/node/manifest.d.ts +33 -0
- package/dist/node/manifest.js +81 -0
- package/dist/node/manifest.js.map +1 -0
- package/dist/node/storage-local.d.ts +38 -0
- package/dist/node/storage-local.js +192 -0
- package/dist/node/storage-local.js.map +1 -0
- package/dist/runtime/helpers.d.ts +23 -0
- package/dist/runtime/helpers.js +38 -0
- package/dist/runtime/helpers.js.map +1 -0
- package/dist/runtime/page-entries.d.ts +12 -0
- package/dist/runtime/page-entries.js +44 -0
- package/dist/runtime/page-entries.js.map +1 -0
- package/dist/runtime/schedule.d.ts +36 -0
- package/dist/runtime/schedule.js +44 -0
- package/dist/runtime/schedule.js.map +1 -0
- package/dist/runtime/toolbar-app.d.ts +2 -0
- package/dist/runtime/toolbar-app.js +97 -0
- package/dist/runtime/toolbar-app.js.map +1 -0
- package/package.json +99 -4
- package/pages/admin.astro +60 -0
- package/pages/search.astro +48 -0
- package/src/config/index.ts +479 -0
- package/src/fields/index.ts +277 -0
- package/src/images/index.ts +19 -0
- package/src/images/plan.ts +369 -0
- package/src/images/register.ts +44 -0
- package/src/images/service.ts +194 -0
- package/src/integration/a11y.ts +307 -0
- package/src/integration/dev-api.ts +288 -0
- package/src/integration/index.ts +482 -0
- package/src/integration/jsdom.d.ts +23 -0
- package/src/integration/react-check.ts +32 -0
- package/src/integration/redirects.ts +232 -0
- package/src/integration/routes.ts +118 -0
- package/src/integration/schedule.ts +157 -0
- package/src/integration/search-plugin.ts +94 -0
- package/src/integration/search.ts +576 -0
- package/src/integration/site-check.ts +99 -0
- package/src/integration/site-model.ts +44 -0
- package/src/integration/vite-plugin.ts +435 -0
- package/src/integration/widget-jsx.ts +83 -0
- package/src/node/agents.ts +577 -0
- package/src/node/cli.ts +168 -0
- package/src/node/cloud.ts +75 -0
- package/src/node/deploy-init.ts +340 -0
- package/src/node/deploy.ts +539 -0
- package/src/node/github.ts +226 -0
- package/src/node/index.ts +10 -0
- package/src/node/load-site.ts +677 -0
- package/src/node/manifest.ts +126 -0
- package/src/node/storage-local.ts +200 -0
- package/src/runtime/env.d.ts +8 -0
- package/src/runtime/helpers.ts +88 -0
- package/src/runtime/page-entries.ts +65 -0
- package/src/runtime/schedule.ts +68 -0
- package/src/runtime/toolbar-app.ts +118 -0
package/README.md
CHANGED
|
@@ -1,3 +1,394 @@
|
|
|
1
|
-
#
|
|
1
|
+
# vespercms
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Vesper integration for Astro.
|
|
4
|
+
|
|
5
|
+
**Status:** Phases 2 to 3 complete, and Phase 4's half in this repository. The integration injects the studio at `/admin`, serves local mode's API in `astro dev`, reads the site's Markdoc config and declared MDX components for the body editor, handles URLs, page trees and redirects, renders images with their focal points and crops, checks every built page's accessibility structure, builds the site's search, and in GitHub mode or through the cloud lets editors publish from the deployed site. Its `vesper` command writes instructions for coding agents from the site's schema.
|
|
6
|
+
|
|
7
|
+
## The integration
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
// astro.config.mjs
|
|
11
|
+
import vesper from 'vespercms';
|
|
12
|
+
export default defineConfig({ integrations: [vesper()] });
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Zero config. It:
|
|
16
|
+
|
|
17
|
+
- injects `/admin`, one **prerendered** page, so it works with `output: 'static'` and no adapter. Studio screens use hash routes (`/admin/#/c/blog`), so deep links work on any host. Astro's `base` is respected.
|
|
18
|
+
- in `astro dev`, serves local mode's API at `<base>_vesper/api/`. It reads and writes content files on disk, validates every save with the site's own Zod schemas, refuses a body the site couldn't build (Markdoc checked with the site's real config, custom attribute types and `validate` functions included; MDX props with their Zod schemas), and refuses any request that isn't the studio on this machine (DECISIONS.md).
|
|
19
|
+
- reads `markdoc.config.*` the way @astrojs/markdoc does (merged by its own `setupConfig`) and learns its `allowHTML` and `ignoreIndentation` options from `astro.config`, so the studio parses Markdoc exactly as the site does. The manifest's `markdoc` section keeps only the schema parts: tags, attributes, `children`, `selfClosing`, `inline`, partials, function names and variable keys (never values).
|
|
20
|
+
- learns the site's routes from `astro:routes:resolved`: a page can't take an address a file in `src/pages` owns, each collection's rendering route is recorded in the manifest, and a collection whose URLs no route can produce gets a warning.
|
|
21
|
+
- at build, cleans `vesper.redirects.yaml` and hands it to Astro, and writes the host's own redirect rules when it has them (see below).
|
|
22
|
+
- when the site uses Astro's own Sharp image service, wraps it so each image's stored crop is cut before Astro resizes it (see "Images" below). The build log says so.
|
|
23
|
+
- writes `dist/_vesper/manifest.json` after `astro build`, which is how the cloud learns the schema.
|
|
24
|
+
- after `astro build`, checks every page's accessibility structure and writes `.vesper/a11y-report.json` beside `dist/` (see "Accessibility checks at build" below).
|
|
25
|
+
- after `astro build`, when a page uses `<Search>`, indexes the site with Pagefind into `dist/_vesper/search/`, and injects a plain `/search` page unless the site has its own. With no `<Search>` in the site's own pages, the build writes no index and leaves the injected page out (see "Site search" below).
|
|
26
|
+
|
|
27
|
+
| Option | Default | |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| `studio.deploy` | `true` | `false` leaves `/admin` out of production builds, for sites edited only locally. |
|
|
30
|
+
| `siteName` | the host of Astro's `site`, then the package name | Shown in the studio's top bar and page titles. |
|
|
31
|
+
| `a11y` | `{}` | Accessibility checks at build. `{ failBuildOn: 'error' }` (or `'warning'`) fails the build on an issue at that level; `false` turns the check off. |
|
|
32
|
+
| `github` | off | GitHub mode (see below): `{ relay }`, plus `repo`, `branch`, `root` or `apiUrl` when git can't tell them. |
|
|
33
|
+
| `cloud` | off | The cloud (see below): `{ project }`, or `true` to read `VESPER_PROJECT`. |
|
|
34
|
+
| `search` | `{}` | Site search. `filters` and `sort` name each collection's fields, `exclude` leaves pages out by URL, `noJsLink` and `noJsFallback` decide what `/search` offers without JavaScript, `strings` gives other languages' words. `false` turns search off. |
|
|
35
|
+
| `imageService` | `true` | `false` leaves Astro's image service alone. `<Img>` then uses Astro's own server crop for each ratio, with the focal point as `object-position`, and manual crops aren't applied. |
|
|
36
|
+
|
|
37
|
+
The studio comes precompiled from `@vespercms/admin`. React and React DOM are peer dependencies, shared with any custom widgets.
|
|
38
|
+
|
|
39
|
+
### `vesper:content`
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { getCollection, getEntry, render } from 'vesper:content';
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
A drop-in for `astro:content` (spec §6.11). In builds it re-exports Astro's module, and for scheduled collections its `getCollection`, `getEntry` and `getEntries` leave out entries outside their publish window (below). In `astro dev` it also notes which entries each page shows (`render`, `getEntry`) or lists (`getCollection`, `getEntries`), and the dev toolbar's **Vesper** app links each one straight to its editor ("Edit this page"). Types are injected, so no setup is needed.
|
|
46
|
+
|
|
47
|
+
It adds the site helpers (spec §6.15.7), which use the same URL and tree code as the studio:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { getBreadcrumbs, getChildren, getTree, getUrl } from 'vesper:content';
|
|
51
|
+
const tree = await getTree('pages'); // nested by folder, ordered by _order.yaml, the home page first
|
|
52
|
+
const kids = await getChildren('pages', entry); // [{ title, url, entry, children, … }]
|
|
53
|
+
const crumbs = await getBreadcrumbs('pages', entry); // [{ title, url }], from Home to the entry
|
|
54
|
+
const href = getUrl(entry); // from the collection's url pattern, with base and trailing slash
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
A folder with no section page is still a node, with no entry and `url: null`. The module embeds each collection's `_order.yaml` files; in `astro dev` a change to one reloads the site's pages.
|
|
58
|
+
|
|
59
|
+
### Custom widgets
|
|
60
|
+
|
|
61
|
+
Register them in `vesper.config.ts` (spec §6.3.3):
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
widgets: {
|
|
65
|
+
'store-hours': {
|
|
66
|
+
component: () => import('./src/cms/StoreHours.tsx'),
|
|
67
|
+
accepts: (field) => field.kind === 'array',
|
|
68
|
+
match: (field) => field.path.at(-1) === 'hours',
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
A widget is a React component that receives `WidgetProps` (`import type { WidgetProps } from 'vespercms/config'`). The integration compiles widget files, and the JSX files they import, with React's automatic runtime, so the site needs no `@astrojs/react`; nothing else in the site is affected. Start widget files with `/** @jsxImportSource react */` so TypeScript checks them as React. A widget that fails to load falls back to the built-in control, with a note.
|
|
74
|
+
|
|
75
|
+
## Field helpers (`vespercms/fields`)
|
|
76
|
+
|
|
77
|
+
Each helper returns **plain Zod with `.meta()` attached**, imported from `astro/zod`, so Astro validates it like any other schema. Content files hold plain values. Drop the import and paste the raw Zod, and the behavior is identical:
|
|
78
|
+
|
|
79
|
+
| Helper | Raw Zod equivalent | Stored as |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `fields.color({ palette?, alpha?, contrast? })` | `z.string().regex(/^#[0-9a-f]{6}$/i).meta({ widget: 'color' })` | `"#1a73e8"` |
|
|
82
|
+
| `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 }` |
|
|
83
|
+
| `fields.slug({ from })` | `z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).meta({ widget: 'slug', widgetOptions: { from } })` | `"my-post"` |
|
|
84
|
+
| `fields.url()` | `z.url().meta({ widget: 'url' })` | `"https://…"` |
|
|
85
|
+
| `fields.email()` | `z.email().meta({ widget: 'email', autocomplete: 'email' })` | `"a@b.com"` |
|
|
86
|
+
| `fields.datetime()` | `z.coerce.date().meta({ widget: 'datetime' })` | ISO 8601 |
|
|
87
|
+
| `fields.select(options)` | `z.enum([...]).meta({ widgetOptions: { labels } })` | `"value"` |
|
|
88
|
+
| `fields.icon(names)` | `z.enum([...]).meta({ widget: 'icon' })` | `"calendar-days"` |
|
|
89
|
+
| `fields.markdown({ inline? })` | `z.string().meta({ widget: 'markdown' })` | Markdown string |
|
|
90
|
+
| `fields.file({ accept })` | `z.string().meta({ widget: 'file', widgetOptions: { accept } })` | `"/files/brochure.pdf"` |
|
|
91
|
+
| `fields.hideFromSearch()` | `z.boolean().default(false).meta({ label: 'Hide from site search', description, placement: 'details', search: 'hide' })` | `true` / `false` |
|
|
92
|
+
|
|
93
|
+
A test checks that each helper equals its raw equivalent, so this table can't drift. Options a helper takes go into `widgetOptions`, as they would in the raw form:
|
|
94
|
+
|
|
95
|
+
- `fields.color({ contrast: 'text' })` (or `'large-text'`, `'graphic'`) adds `widgetOptions: { contrast }`: the studio then checks the colour against the site's `a11y.backgrounds`, at 4.5:1 for text and 3:1 otherwise.
|
|
96
|
+
- `fields.geo({ defaultCenter: { lat, lng }, zoom })` adds `widgetOptions: { defaultCenter, zoom }`: where the map starts when the field is empty.
|
|
97
|
+
- `fields.markdown({ inline: true })` adds `widgetOptions: { inline: true }`: a single paragraph with bold, italic, code and links, and no lists.
|
|
98
|
+
- `fields.icon(['leaf', 'calendar-days'])` offers [Lucide](https://lucide.dev/icons) icons by name, in kebab case. The studio shows a searchable list with each icon beside its name, and the file stores the name for the site to draw. Like `select()`, it also takes `{ name: label }` or `[{ value, label }]`, which adds `widgetOptions: { labels }`. When the manifest is built, the site's own `lucide` (an optional peer, as for collection icons) draws the offered names, and only those go into the studio. A name Lucide doesn't have is still offered, by name alone, and the build warns.
|
|
99
|
+
|
|
100
|
+
`fields.file()` uploads go to `public/files/` (or beside the file they replace) and store the public path, `/files/brochure.pdf`. A file chosen from under `src/` is stored relative to the entry instead, such as `./brochure.pdf`.
|
|
101
|
+
|
|
102
|
+
`fields.image(image, { ratios? })` takes Astro's `image` helper from the schema function, so `src` still resolves to `ImageMetadata` (spec §6.6.2):
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
schema: ({ image }) => z.object({ hero: fields.image(image, { ratios: ['wide', 'square'] }) }),
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
It stores `{ src, alt, decorative?, focus?: { x, y }, crops?: { [ratio]: { x, y, w, h } } }`, every number 0–1 from the top left. `alt` is required unless the image is decorative (`alt: ""`, `decorative: true`); focal points and crops must lie inside the image. Its raw Zod is in the helper's doc comment, and a test keeps the two equal. `ratios` names the crops the studio previews; without it, every ratio the site declares.
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
import { defineCollection } from 'astro:content';
|
|
112
|
+
import { glob } from 'astro/loaders';
|
|
113
|
+
import { z } from 'astro/zod';
|
|
114
|
+
import { fields } from 'vespercms/fields';
|
|
115
|
+
|
|
116
|
+
const settings = defineCollection({
|
|
117
|
+
loader: glob({ pattern: 'site.yaml', base: './src/content/settings' }),
|
|
118
|
+
schema: z.object({ brandColor: fields.color({ palette: { Indigo: '#28335f' } }), website: fields.url() }),
|
|
119
|
+
});
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## `vesper.config.ts` (`vespercms/config`)
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
import { defineConfig } from 'vespercms/config';
|
|
126
|
+
export default defineConfig({
|
|
127
|
+
collections: {
|
|
128
|
+
pages: { url: '/:path', tree: true },
|
|
129
|
+
settings: { singleton: true, label: 'Site settings' },
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
It holds presentation, URLs, widgets and permissions, never field definitions (D2). Unknown keys warn rather than fail.
|
|
135
|
+
|
|
136
|
+
### Collection icons
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
collections: {
|
|
140
|
+
blog: { icon: 'newspaper' },
|
|
141
|
+
events: { icon: 'calendar-days' },
|
|
142
|
+
},
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
- **`icon`** names a [Lucide](https://lucide.dev/icons) icon as that page lists it, in kebab case. The studio shows it beside the collection's name on Home, in the Content menu and before the collection's heading. Without one, a collection gets a page, a tree collection an outline and a singleton sliders.
|
|
146
|
+
- The drawing is looked up when the manifest is built, from the site's own `lucide` package, so the studio never carries the whole icon set. `lucide` is an optional peer dependency: install it (`npm install lucide`) only if you name an icon. Without it, or for a name Lucide doesn't have, the build warns and the collection keeps its default.
|
|
147
|
+
|
|
148
|
+
### URLs, trees and redirects (spec §6.15)
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
collections: {
|
|
152
|
+
pages: { url: '/:path', tree: true, maxDepth: 4 },
|
|
153
|
+
blog: { url: '/blog/:publishedAt|yyyy/:slug' },
|
|
154
|
+
people: { url: (entry) => `/people/${entry.id}` }, // the escape hatch: pure, no server-only imports
|
|
155
|
+
},
|
|
156
|
+
redirects: { native: ['apache'] },
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
- **`url`** is the one source of truth for where an entry lives: "View on site", the link picker, redirects, breadcrumbs and the helpers all use it. Tokens: `:id`, `:path` (the ID without a trailing `/index`; a top-level `index` is the root), `:slug`, `:<field>`, and `:<dateField>|yyyy`, `|mm`, `|dd` (calendar dates keep their day; times are read in the site's `timeZone`). Generated URLs follow Astro's `base`, `trailingSlash` and `build.format`. A function gets the entry's `id`, `collection` and `data` (its frontmatter, typed `unknown`, so narrow it before reading a field) and returns the entry's path on the site.
|
|
160
|
+
- **`tree: true`** shows the collection as an outline: the folder is the hierarchy, `folder/index.md` is the folder's section page, and an optional `_order.yaml` in each folder lists its children by slug (unlisted ones follow by title). Keep `_` files out of the glob, as in `'**/[^_]*.md'`; the manifest warns when a pattern would pick up `_order.yaml`.
|
|
161
|
+
- **`maxDepth`** limits how deep pages may go. There's no default (DECISIONS.md).
|
|
162
|
+
|
|
163
|
+
**Redirects** live in `vesper.redirects.yaml` at the site root (next to `astro.config`): `- { from: /old, to: /new, status: 301, reason: move, at: 2026-10-18 }`. Moving a page in the studio adds one for the page and each page under it; the studio's Redirects screen adds the rest, with CSV import. Every write and every build cleans the list: chains collapse, loops go, and a redirect whose old address is a real page again is dropped.
|
|
164
|
+
|
|
165
|
+
At build, the cleaned redirects go to Astro's `redirects`, so static output gets an HTML redirect page at each old URL, aimed at the new page's exact URL so it lands in one hop. That works on any host. True 301s come from the host's own rules, detected from the build environment or set in `redirects.native`:
|
|
166
|
+
|
|
167
|
+
| Host | Detected by | Output |
|
|
168
|
+
|---|---|---|
|
|
169
|
+
| Netlify | `NETLIFY` | `_redirects` in `dist`, forced (`301!`), Vesper's block first |
|
|
170
|
+
| Cloudflare Pages | `CF_PAGES` | `_redirects` in `dist` |
|
|
171
|
+
| Cloudflare Workers static assets | `WORKERS_CI` (Workers Builds) | `_redirects` in `dist` |
|
|
172
|
+
| Vercel | `VERCEL` | `redirects` in the site's `vercel.json`, kept in step by the studio whenever it writes redirects (Vercel reads the file before the build) |
|
|
173
|
+
| Apache / LiteSpeed | `native: ['apache']` | `RedirectMatch` lines in `dist/.htaccess` between `# BEGIN vesper` and `# END vesper` |
|
|
174
|
+
| nginx | `native: ['nginx']` | `dist/_vesper/nginx-redirects.conf`, for the server admin to include |
|
|
175
|
+
|
|
176
|
+
URLs a native rule covers get no HTML page. `dist/_vesper/redirects.json` lists the cleaned redirects (the S3 deploy template's input, Phase 9). Redirects aren't served by `astro dev`.
|
|
177
|
+
|
|
178
|
+
### Scheduling (spec §6.17)
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
events: { url: '/events/:id', schedule: true },
|
|
182
|
+
// or: schedule: { unpublish: false, publishAt: 'goesLive', unpublishAt: 'expires', unpublishRedirect: 'thenGoTo' }
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The collection's schema needs the fields (`publishAt: fields.datetime().optional(),` and `unpublishAt`, plus an optional `unpublishRedirect` string); Astro drops values a schema doesn't list, and Vesper warns when they're missing. A build then leaves out, through `vesper:content`, every entry whose `publishAt` is still to come or whose `unpublishAt` has passed: its page isn't built, and no list or tree shows it. Pages that read `astro:content` directly aren't filtered, and the build warns about any it finds. `astro dev` keeps every entry, so it can be edited.
|
|
186
|
+
|
|
187
|
+
- **One instant per build.** Every check in a build uses the time the build started, or `VESPER_BUILD_TIME` (an ISO time) when it's set, to build the site as it will be.
|
|
188
|
+
- **Unpublished pages** answer 404, or redirect where their `unpublishRedirect` says: a temporary redirect, through the same host rules as `vesper.redirects.yaml`, and never written into it. A redirect in the file for the same address wins.
|
|
189
|
+
- **`dist/_vesper/schedule.json`** says when the site next changes, never what: `{ "version": 1, "builtAt": "…", "next": "…" }`, with `next` null when nothing is scheduled.
|
|
190
|
+
|
|
191
|
+
The site's content changes only when it's built, so a post goes live at the first build after its time. `rebuildEvery` (minutes, at least 5) in `vesper.config.ts` says how often the site's scheduled rebuild runs, so the studio can say "This site rebuilds every hour".
|
|
192
|
+
|
|
193
|
+
In the studio, a scheduled collection's editor has **Publish ▾** (Save ▾ in local mode), whose menu holds **Schedule…**: when the entry goes live, when it comes down and where visitors go then, in the site's time zone. Details shows the schedule in words with **Change schedule…**, the lists say each entry's schedule ("Scheduled · Sat, Oct 31, 8:00 AM") and filter to what's scheduled, and the **Scheduled** view (`#/scheduled`) lists every change still to come. In `astro dev` a scheduled entry's own page carries a note saying when visitors will see it, and the dev toolbar's Vesper app lists each entry's schedule.
|
|
194
|
+
|
|
195
|
+
### Images (spec §6.6.3)
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
images: {
|
|
199
|
+
ratios: { wide: 16 / 9, square: 1, portrait: 4 / 5 }, // width / height; og (1.91) is built in
|
|
200
|
+
maxUploadEdge: 3000, // uploads with a longer edge are scaled down in the browser
|
|
201
|
+
},
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
The ratios drive the studio's crop previews and `<Img ratio>`, so editors see exactly the crops the site renders.
|
|
205
|
+
|
|
206
|
+
### Accessibility (spec §6.18)
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
a11y: {
|
|
210
|
+
backgrounds: { 'Page, light': '#ffffff', 'Page, dark': '#12151f' }, // what text is drawn on
|
|
211
|
+
blockPublishOn: ['missing-alt'], // optional: these rules' notes refuse a save
|
|
212
|
+
},
|
|
213
|
+
collections: {
|
|
214
|
+
docs: { headingStart: 1 }, // the body holds its own h1; 2 by default
|
|
215
|
+
},
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
The studio checks each entry as it's edited (layer 1): alt text, heading order and empty headings, vague and new-tab links, tables without a header row, typed lists, empty names on embeds, video captions, text colours against the declared backgrounds, and PDFs (tagged, title, language). Notes appear beside the field they concern, and every note only warns unless its rule is in `blockPublishOn`. Rule IDs: `missing-alt`, `alt-file-name`, `alt-redundant`, `alt-too-long`, `heading-skip`, `heading-extra-top`, `heading-empty`, `link-vague`, `link-new-window`, `table-header`, `color-contrast`, `tag-name-empty`, `fake-list`, `pdf-untagged`, `pdf-no-title`, `pdf-no-language`, `video-captions`.
|
|
219
|
+
|
|
220
|
+
A Markdoc tag or declared MDX component gets the embed checks from its own schema: a required `title`, `alt`, `label` or `aria-label` attribute left empty is a note, and a tag with a `captions` attribute asks to confirm captions when it's absent or off.
|
|
221
|
+
|
|
222
|
+
### Markdoc hints and MDX components (spec §6.5.1, §6.5.2)
|
|
223
|
+
|
|
224
|
+
Markdoc tags already declare their attributes in `markdoc.config.mjs`; the studio builds each tag's settings form from them. Optional UI hints live here, never in Markdoc's config:
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
markdoc: {
|
|
228
|
+
tags: {
|
|
229
|
+
callout: {
|
|
230
|
+
label: 'Callout',
|
|
231
|
+
summary: 'type', // shown in the block's header: "Callout · Warning"
|
|
232
|
+
attributes: { type: { labels: { note: 'Note', warning: 'Warning', tip: 'Tip' } } },
|
|
233
|
+
},
|
|
234
|
+
youtube: { label: 'YouTube video', attributes: { title: { description: 'Describes the video for screen reader users.' } } },
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Hints cover a friendly name, help text, the summary attribute, value labels and a `widget` per attribute. A widget applies as it would to a schema field of the attribute's type: a `String` attribute can take `textarea`, `url` or `color` (the full picker, since hints carry no `widgetOptions`), or a custom widget. `Object` and `Array` attributes are edited as YAML, so `geo` can't apply; naming it warns in the terminal and keeps the YAML field. An `Array` or `Object` attribute can take a `schema` hint instead, a Zod schema for what's inside (`z.array(z.object({ question: z.string(), answer: z.string() }))`), and its settings then use the frontmatter's controls; Markdoc's own validation still decides what saves. A list written one item, or one key, per line changes item by item.
|
|
240
|
+
|
|
241
|
+
MDX components don't declare their props anywhere the editor can read, so they are blocks only when declared here with a Zod props schema (from `astro/zod`). Undeclared components, expressions, imports and exports stay protected source blocks:
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
mdx: {
|
|
245
|
+
components: {
|
|
246
|
+
Callout: { label: 'Callout', summary: 'type', children: true, props: z.object({ type: fields.select({ note: 'Note', tip: 'Tip' }).default('note'), title: z.string().optional() }) },
|
|
247
|
+
YouTube: { label: 'YouTube video', props: z.object({ id: z.string(), title: z.string() }) },
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
`children: true` makes a frame whose content stays editable; `inline: true` inserts the component inside a line of text. The studio loads `vesper.config.ts`, props schemas included, the first time an MDX body needs them, so it checks props exactly as the site does.
|
|
253
|
+
|
|
254
|
+
## Accessibility checks at build (spec §6.18, layer 2)
|
|
255
|
+
|
|
256
|
+
After every `astro build`, each built page is loaded into jsdom (no browser) and axe-core runs only its structure rules: language, page title, landmarks, heading order, image alt, form labels, names on links and buttons, duplicate IDs, basic ARIA, list and table structure. Colour contrast and other visual rules need a rendered page and aren't checked here. Astro's redirect pages and the studio's shell are skipped.
|
|
257
|
+
|
|
258
|
+
- **The report** is `.vesper/a11y-report.json` in the project, never in `dist/`, so it's never deployed. The integration writes `.vesper/.gitignore` so it stays out of git. Each page with issues lists them in plain words, with axe's impact as `error` or `warning`, the element, and a link to axe's explanation.
|
|
259
|
+
- **Page to entry.** A page is traced to the entry that produced it through its collection's `url` in vesper.config.ts, the URLs the studio uses, with the entry's file and its studio link ("fix in studio"). Pages without one (listings, the 404 page, collections with no `url`) have `entry: null`.
|
|
260
|
+
- **Failing the build** is off by default. `vesper({ a11y: { failBuildOn: 'error' } })` fails it after the report is written.
|
|
261
|
+
- It adds about a tenth of a second a page, plus a few seconds to load jsdom. `vesper({ a11y: false })` turns it off.
|
|
262
|
+
|
|
263
|
+
Automated checks find some problems, not all. The report says so, and nothing replaces testing with a keyboard and a screen reader (D19).
|
|
264
|
+
|
|
265
|
+
## Images (`vespercms/components`)
|
|
266
|
+
|
|
267
|
+
`<Img>` renders an image field (spec §6.6.5) through Astro's own `<Image>`, `<Picture>` and `getImage()`:
|
|
268
|
+
|
|
269
|
+
```astro
|
|
270
|
+
---
|
|
271
|
+
import { Img, getImg } from 'vespercms/components';
|
|
272
|
+
const og = await getImg({ image: post.data.hero, ratio: 'og', width: 1200 }); // for <meta property="og:image">
|
|
273
|
+
---
|
|
274
|
+
<Img image={post.data.hero} ratio="wide" widths={[480, 960, 1440]} sizes="100vw" />
|
|
275
|
+
<Img image={post.data.hero} art={{ base: 'square', '(min-width: 48rem)': 'wide' }} />
|
|
276
|
+
<Img image={post.data.hero} layout="full-width" />
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
| Prop | What it does |
|
|
280
|
+
|---|---|
|
|
281
|
+
| `ratio` | A name from `images.ratios`, or a number. Uses the editor's own crop for that ratio if there is one, otherwise the crop the focal point gives, at that exact ratio. A fixed `width` and `height` without `ratio` count as a ratio too (with the focal point's crop). |
|
|
282
|
+
| `art` | Art direction: a `<picture>` with a `<source media>` per query, each with its own crop and size, then `base` for everything else. `formats` (default WebP) and `fallbackFormat` work as in Astro's `<Picture>`. |
|
|
283
|
+
| neither | Fluid: the whole image, no server crop; the focal point becomes `object-position` (`62% 35%`), so CSS cropping keeps the subject in frame. |
|
|
284
|
+
| always | `alt` from the field, `alt=""` when decorative. Every other Astro image prop passes through. `srcset` widths never go past the crop's own width. |
|
|
285
|
+
|
|
286
|
+
**How crops are cut.** With Astro's default Sharp service, the integration wraps it (`vespercms/image-service`): 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 every host (D16). `sharp` is an optional peer dependency: the copy Astro's own service already uses.
|
|
287
|
+
|
|
288
|
+
**With the wrapper off** (`vesper({ imageService: false })`) or **with a host's image service** (Cloudflare, Netlify, Vercel), `<Img>` still works: it asks for the ratio's size, which is Astro's own server crop (at the nearest of Sharp's anchors on Astro's Sharp service), and keeps the focal point as `object-position`. Manual crop rectangles are only applied with the wrapper, on Astro's default build-time Sharp service.
|
|
289
|
+
|
|
290
|
+
Never pass a focal point as Astro's `position`: Sharp accepts only its own keywords there, and `"62% 35%"` fails the build (DECISIONS.md).
|
|
291
|
+
|
|
292
|
+
## Site search (`vespercms/components`)
|
|
293
|
+
|
|
294
|
+
Search is free and static (D23): once a page uses `<Search>`, every build indexes the site with [Pagefind](https://pagefind.app) into `dist/_vesper/search/`. It's plain files, so it works on any host, with no MIME type or rewrite to set. Until then the build writes no index and no `/search` page, and its log says so.
|
|
295
|
+
|
|
296
|
+
```astro
|
|
297
|
+
---
|
|
298
|
+
// In your layout
|
|
299
|
+
import { Search, SearchHead } from 'vespercms/components';
|
|
300
|
+
---
|
|
301
|
+
<head>
|
|
302
|
+
<SearchHead /> <!-- opensearch.xml, when Astro's `site` is set -->
|
|
303
|
+
</head>
|
|
304
|
+
<header>
|
|
305
|
+
<Search /> <!-- a "Search" button; Ctrl/⌘+K opens it too -->
|
|
306
|
+
</header>
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
```js
|
|
310
|
+
// astro.config.mjs
|
|
311
|
+
vesper({
|
|
312
|
+
search: {
|
|
313
|
+
filters: { blog: ['tags', 'author'] }, // text, lists of text, selects, references
|
|
314
|
+
sort: { blog: ['publishedAt'] }, // dates, numbers, text
|
|
315
|
+
exclude: ['/tags/**'],
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
- **`<Search />`** is a button that opens a dialog: the field, filters as checkboxes (every entry's `Section` is its collection's label, then your `filters`), "Sort" once a section with a sort field is chosen, and results as a list of links with the date, an excerpt and a thumbnail of the entry's first image (its square crop). The count is announced once typing pauses. `<Search variant="inline" />` puts the same in the page; `<Search shortcut={false} />` drops Ctrl/⌘+K.
|
|
321
|
+
- **What's indexed:** every built page except Astro's redirect pages, `/admin`, `404`, the search page itself, pages with `<meta name="robots" content="noindex">`, and `exclude` patterns; and of entry pages, none from a collection with `search: false` in vesper.config.ts, none hidden with `fields.hideFromSearch()` (or a boolean `noindex`, at the top level or under `seo`), and none outside their publish window. The index reads each page's `<main>`, unless your pages mark their own content with `data-pagefind-body`. The deployed HTML is never changed.
|
|
322
|
+
- **`/search`** is injected as a plain page unless the site has `src/pages/search.astro` (put `<Search variant="inline" />` in your layout there). The injected page doesn't count as a use of `<Search>`, and the build leaves it out when nothing else uses it. It reads `?q=`. Without JavaScript it says search needs JavaScript and links to the home page (or `noJsLink`); `noJsFallback: 'duckduckgo'` makes its form search the site on DuckDuckGo instead.
|
|
323
|
+
- **In `astro dev`**, search uses the last build's index; before the first build it says so.
|
|
324
|
+
- **The build log** says how many pages it indexed and what a first search loads, and warns above 300 KB (uncompressed, as a host without compression sends it).
|
|
325
|
+
- **Other languages:** Pagefind keeps an index per `<html lang>`. `<Search>`'s words are English; give others with `search: { strings: { fr: { button: 'Rechercher', label: 'Rechercher sur ce site' } } }` (the keys are in `components/search/strings.ts`). The words written into the page (the button, the field's label, Close and the no-JavaScript text) follow `<Search>`'s `lang` prop, which defaults to Astro's current locale, then `en`; only the script reads `<html lang>`. Without Astro's i18n routing, a page in another language passes it: `<Search lang="fr" />`. The injected `/search` page takes its language the same way, so such a site puts its own in `src/pages/search.astro`.
|
|
326
|
+
|
|
327
|
+
**Theming.** `<Search>` inherits the site's font and colours. Four custom properties do the rest:
|
|
328
|
+
|
|
329
|
+
```css
|
|
330
|
+
:root {
|
|
331
|
+
--vesper-search-accent: #1f5fd1; /* focus ring and the tint of matches */
|
|
332
|
+
--vesper-search-surface: #ffffff; /* the dialog's background (default: Canvas) */
|
|
333
|
+
--vesper-search-border: #4d566b; /* borders: keep 3:1 against the surface */
|
|
334
|
+
--vesper-search-radius: 0.5rem;
|
|
335
|
+
}
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
## GitHub mode (spec §6.8)
|
|
339
|
+
|
|
340
|
+
Editors sign in with their own GitHub account on the deployed site's `/admin` and publish from there. Each save is one commit to the repository's default branch, and the site's host rebuilds as on any push.
|
|
341
|
+
|
|
342
|
+
```js
|
|
343
|
+
// astro.config.mjs
|
|
344
|
+
vesper({ github: { relay: 'https://vesper-github-relay.your-account.workers.dev' } })
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
1. **The relay.** Deploy `templates/oauth-relay` (its README walks through creating the GitHub App and installing it on the repository). GitHub's token exchange needs the app's secret and can't be called from a browser, so it goes through the relay, which stores nothing.
|
|
348
|
+
2. **The repository.** `owner/name`, the default branch and the site's folder in the repository (for a monorepo) are read from git, or from the host's environment. Set `repo`, `branch` or `root` when they can't be read; the build log says so.
|
|
349
|
+
3. **The schemas.** At build, `content.config.ts` is also built for the browser into `_vesper/schemas.js`, so the studio checks saves with your real schemas. A config that imports Node-only code can't be; the log says why, the site still builds, and the studio checks what the manifest knows, with your build as the last check.
|
|
350
|
+
|
|
351
|
+
`astro dev` stays local mode. GitHub mode is on in built sites only.
|
|
352
|
+
|
|
353
|
+
**Hosts that check commit authors.** In GitHub mode each editor is the author of their commits. On private repositories, Vercel builds only commits by team members and Netlify holds unknown authors for approval: add your editors there, or build with GitHub Actions or Cloudflare Pages.
|
|
354
|
+
|
|
355
|
+
**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.
|
|
356
|
+
|
|
357
|
+
## The cloud (spec §6.9)
|
|
358
|
+
|
|
359
|
+
Editors sign in to Vesper on the deployed site's `/admin`. Their saves are drafts, kept by the cloud until someone who may publish does, as one commit by Vesper's GitHub App. Editors need no GitHub account.
|
|
360
|
+
|
|
361
|
+
```js
|
|
362
|
+
// astro.config.mjs
|
|
363
|
+
vesper({ cloud: { project: 'site_…' } })
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
- **The project ID** is the site's, from its settings in the dashboard: an identifier, not a secret. `VESPER_PROJECT` works too.
|
|
367
|
+
- **The cloud's addresses** are Vesper's own, `https://app.vespercms.com` and `https://api.vespercms.com`, so the project ID is all a site needs. Staging and tests set `app` and `api` (or `VESPER_CLOUD_APP` and `VESPER_CLOUD_API`), always both: one alone would sign in at one cloud and save to another.
|
|
368
|
+
- **The schemas** are built for the browser as in GitHub mode, so drafts are checked with your real schemas.
|
|
369
|
+
|
|
370
|
+
`astro dev` stays local mode; the cloud is on in built sites. A site can have GitHub mode too: the sign-in screen then offers both. Roles and grants are set per person in the dashboard, not in `vesper.config.ts`.
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
## The `vesper` command
|
|
374
|
+
|
|
375
|
+
The package installs a `vesper` command (`src/node/cli.ts`). It loads the site's configs through the site's own Vite, like the integration.
|
|
376
|
+
|
|
377
|
+
```sh
|
|
378
|
+
npx vesper agents # write instructions for coding agents into AGENTS.md
|
|
379
|
+
npx vesper agents --check # in CI: fail when AGENTS.md no longer matches the schema
|
|
380
|
+
npx vesper manifest # print the manifest the studio is built from
|
|
381
|
+
npx vesper deploy init --target sftp --rebuild-every 60 # a deploy workflow for GitHub Actions
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
- **`vesper agents`** turns the manifest into a guide for coding agents: the rules that keep the studio and the build working (splice, never reformat; raw values; check with `astro build`), the parts the site uses (images, page trees, redirects, Markdoc tags, declared MDX components), and every collection with its files, URL and fields. It goes between `<!-- vesper:agents:start -->` and `<!-- vesper:agents:end -->` in `AGENTS.md`, or `--file` relative to the site; the rest of the file is left alone. The output is deterministic, so `--check` compares bytes. Claude Code reads `CLAUDE.md`: an `@AGENTS.md` line there imports the file.
|
|
385
|
+
- **`vesper manifest`** prints the manifest, or writes it with `--out`, and exits with an error when a diagnostic is an error.
|
|
386
|
+
- Both take `--root <site>` and `--src-dir <dir>`.
|
|
387
|
+
- **`vesper deploy init`** writes one GitHub Actions workflow, `.github/workflows/vesper-deploy.yml` at the repository's root, and lists the repository secrets it reads. Vesper never deploys a site itself: the workflow is plain YAML that runs in your GitHub account, and it's yours to read and change. It never replaces a workflow without `--force`.
|
|
388
|
+
- **`--target sftp` or `ftp`** builds on every push to the default branch and mirrors Astro's output folder to the server with `lftp`, deleting what the build no longer has, so an unpublished page 404s. The server's own `.well-known` and `cgi-bin` folders are left alone unless the build has its own. SFTP checks the server against `VESPER_SFTP_KNOWN_HOSTS` (`ssh-keyscan -p 22 <server>`), and FTP insists on TLS.
|
|
389
|
+
- **`--target workers`** builds on every push to the default branch and runs `wrangler deploy` in the site's folder, with the site's own wrangler (`npx wrangler deploy`, or pnpm's or Yarn's), so each deploy runs the version in the lockfile; no third-party Action holds the token. It reads `CLOUDFLARE_API_TOKEN`, and `CLOUDFLARE_ACCOUNT_ID` for when the Wrangler config has no `account_id` and the token reaches more than one account (wrangler treats an empty one as unset). The command says when there's no Wrangler config or no `wrangler` dependency between the site and the lockfile, and that a build on GitHub needs `redirects: { native: ['cloudflare'] }` for the host's own redirects. A site Workers Builds already builds from git wants `hook` instead.
|
|
390
|
+
- **`--target hook`** is for hosts that build from git (Netlify, Cloudflare Pages or Workers Builds, Vercel): it only posts to the build hook in `VESPER_BUILD_HOOK`.
|
|
391
|
+
- **`--rebuild-every <minutes>`** (an hour by default for `hook`) adds a scheduled run. It reads the live site's `_vesper/schedule.json` first and rebuilds only once a scheduled change is due. When it can't read the file, that run fails without building and names the address it tried, and the next run tries again. It takes 5, 6, 10, 12, 15, 20 or 30 minutes, or 1, 2, 3, 4, 6, 8, 12 or 24 hours, which a schedule keeps exactly. The site's address comes from `site` and `base` in `astro.config`, or `--url`. The command prints the `rebuildEvery` line for `vesper.config.ts`.
|
|
392
|
+
- Every workflow also runs on the cloud's `repository_dispatch` (`vesper-rebuild`), at a scheduled change's exact time, and from the Actions tab. It installs with the package manager whose lockfile is nearest the site (npm, pnpm or Yarn), with the Node version in `.nvmrc` or `.node-version`, or Node 24. `--branch` names the default branch when git can't.
|
|
393
|
+
|
|
394
|
+
`agentGuide(manifest)` and `withAgentGuide(file, guide)` do the same as functions, from `vespercms/node`.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
---
|
|
2
|
+
/*
|
|
3
|
+
* <Img> (spec §6.6.5): Astro's own <Image>, <Picture> or getImage() for an
|
|
4
|
+
* image field, with the crop for a ratio, art direction, and the focal point
|
|
5
|
+
* as object-position. Every Astro image prop passes through; the planning
|
|
6
|
+
* (src/images/plan.ts) decides what Astro is asked for.
|
|
7
|
+
*
|
|
8
|
+
* <Img image={post.data.hero} ratio="wide" widths={[480, 960, 1440]} sizes="100vw" />
|
|
9
|
+
* <Img image={post.data.hero} art={{ base: 'square', '(min-width: 48rem)': 'wide' }} />
|
|
10
|
+
* <Img image={post.data.hero} layout="full-width" />
|
|
11
|
+
*/
|
|
12
|
+
import type { GetImageResult } from 'astro';
|
|
13
|
+
import { Image, Picture, getImage, imageConfig } from 'astro:assets';
|
|
14
|
+
import { imageContext, mimeType, planArt, planImg, type ImgProps } from 'vespercms/images';
|
|
15
|
+
import { ratios } from 'virtual:vesper/images';
|
|
16
|
+
|
|
17
|
+
type Props = ImgProps;
|
|
18
|
+
type Options = Parameters<typeof getImage>[0];
|
|
19
|
+
|
|
20
|
+
const ctx = imageContext(imageConfig, ratios);
|
|
21
|
+
const { art, formats, fallbackFormat, pictureAttributes } = Astro.props;
|
|
22
|
+
|
|
23
|
+
/** `srcset` for a `<source>`: width descriptors alone, or the 1x image plus densities. */
|
|
24
|
+
const srcsetOf = (image: GetImageResult) =>
|
|
25
|
+
image.srcSet.values.length === 0
|
|
26
|
+
? image.src
|
|
27
|
+
: image.srcSet.values.some((value) => value.descriptor.endsWith('w'))
|
|
28
|
+
? image.srcSet.attribute
|
|
29
|
+
: `${image.src}, ${image.srcSet.attribute}`;
|
|
30
|
+
|
|
31
|
+
type Source = {
|
|
32
|
+
media: string | undefined;
|
|
33
|
+
srcset: string;
|
|
34
|
+
sizes: unknown;
|
|
35
|
+
type: string;
|
|
36
|
+
width: unknown;
|
|
37
|
+
height: unknown;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
async function sourceOf(
|
|
41
|
+
options: Record<string, unknown>,
|
|
42
|
+
format: string,
|
|
43
|
+
media: string | undefined,
|
|
44
|
+
): Promise<Source> {
|
|
45
|
+
const image = await getImage({ ...options, format } as Options);
|
|
46
|
+
return {
|
|
47
|
+
media,
|
|
48
|
+
srcset: srcsetOf(image),
|
|
49
|
+
sizes: image.attributes.sizes ?? options.sizes,
|
|
50
|
+
type: mimeType(image.options.format ?? format),
|
|
51
|
+
// Each breakpoint's own shape, so the browser reserves the right box.
|
|
52
|
+
width: image.attributes.width,
|
|
53
|
+
height: image.attributes.height,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let picture: { sources: Source[]; img: GetImageResult } | null = null;
|
|
58
|
+
if (art) {
|
|
59
|
+
const plan = planArt(Astro.props, ctx);
|
|
60
|
+
const sources = await Promise.all([
|
|
61
|
+
...plan.sources.flatMap((source) =>
|
|
62
|
+
plan.formats.map((format) => sourceOf(source.options, format, source.media)),
|
|
63
|
+
),
|
|
64
|
+
...plan.formats.map((format) => sourceOf(plan.base, format, undefined)),
|
|
65
|
+
]);
|
|
66
|
+
const img = await getImage({ ...plan.base, format: plan.fallbackFormat } as Options);
|
|
67
|
+
picture = { sources, img };
|
|
68
|
+
}
|
|
69
|
+
const single = art ? null : planImg(Astro.props, ctx);
|
|
70
|
+
const { class: imgClass, ...imgAttributes } = picture?.img.attributes ?? {};
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
{picture ? (
|
|
74
|
+
<picture {...pictureAttributes}>
|
|
75
|
+
{picture.sources.map((source) => (
|
|
76
|
+
<source
|
|
77
|
+
media={source.media}
|
|
78
|
+
srcset={source.srcset}
|
|
79
|
+
sizes={source.sizes}
|
|
80
|
+
type={source.type}
|
|
81
|
+
width={source.width}
|
|
82
|
+
height={source.height}
|
|
83
|
+
/>
|
|
84
|
+
))}
|
|
85
|
+
<img
|
|
86
|
+
src={picture.img.src}
|
|
87
|
+
srcset={picture.img.srcSet.values.length > 0 ? picture.img.srcSet.attribute : undefined}
|
|
88
|
+
{...imgAttributes}
|
|
89
|
+
class={imgClass}
|
|
90
|
+
/>
|
|
91
|
+
</picture>
|
|
92
|
+
) : formats ? (
|
|
93
|
+
<Picture
|
|
94
|
+
{...(single as Parameters<typeof Picture>[0])}
|
|
95
|
+
formats={formats as never}
|
|
96
|
+
fallbackFormat={fallbackFormat as never}
|
|
97
|
+
pictureAttributes={pictureAttributes}
|
|
98
|
+
/>
|
|
99
|
+
) : (
|
|
100
|
+
<Image {...(single as Parameters<typeof Image>[0])} />
|
|
101
|
+
)}
|