vestrycms 0.0.0-stage → 0.2.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.
Files changed (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +383 -2
  3. package/dist/config/fields.d.ts +30 -0
  4. package/dist/config/fields.js +75 -0
  5. package/dist/config/index.d.ts +9 -0
  6. package/dist/config/index.js +18 -0
  7. package/dist/config/types.d.ts +412 -0
  8. package/dist/config/types.js +10 -0
  9. package/dist/core/api.d.ts +67 -0
  10. package/dist/core/api.js +543 -0
  11. package/dist/core/auth.d.ts +1597 -0
  12. package/dist/core/auth.js +264 -0
  13. package/dist/core/cache.d.ts +27 -0
  14. package/dist/core/cache.js +52 -0
  15. package/dist/core/entries.d.ts +19 -0
  16. package/dist/core/entries.js +127 -0
  17. package/dist/core/index-types.d.ts +20 -0
  18. package/dist/core/index-types.js +1 -0
  19. package/dist/core/index.d.ts +13 -0
  20. package/dist/core/index.js +13 -0
  21. package/dist/core/mail.d.ts +28 -0
  22. package/dist/core/mail.js +62 -0
  23. package/dist/core/paths.d.ts +35 -0
  24. package/dist/core/paths.js +148 -0
  25. package/dist/core/roles.d.ts +24 -0
  26. package/dist/core/roles.js +15 -0
  27. package/dist/core/service.d.ts +102 -0
  28. package/dist/core/service.js +478 -0
  29. package/dist/core/source.d.ts +82 -0
  30. package/dist/core/source.js +75 -0
  31. package/dist/core/sources/github-app.d.ts +26 -0
  32. package/dist/core/sources/github-app.js +161 -0
  33. package/dist/core/sources/github.d.ts +40 -0
  34. package/dist/core/sources/github.js +278 -0
  35. package/dist/core/sources/local.d.ts +16 -0
  36. package/dist/core/sources/local.js +98 -0
  37. package/dist/core/values.d.ts +22 -0
  38. package/dist/core/values.js +123 -0
  39. package/dist/env.d.ts +19 -0
  40. package/dist/index.d.ts +18 -0
  41. package/dist/index.js +167 -0
  42. package/dist/integration/build-index.d.ts +8 -0
  43. package/dist/integration/build-index.js +67 -0
  44. package/dist/integration/client-deps.d.ts +10 -0
  45. package/dist/integration/client-deps.js +43 -0
  46. package/dist/integration/dev-middleware.d.ts +6 -0
  47. package/dist/integration/dev-middleware.js +48 -0
  48. package/dist/integration/formatter.d.ts +7 -0
  49. package/dist/integration/formatter.js +56 -0
  50. package/dist/mdx/expressions.d.ts +14 -0
  51. package/dist/mdx/expressions.js +141 -0
  52. package/dist/mdx/index.d.ts +4 -0
  53. package/dist/mdx/index.js +4 -0
  54. package/dist/mdx/parse.d.ts +6 -0
  55. package/dist/mdx/parse.js +401 -0
  56. package/dist/mdx/serialize.d.ts +9 -0
  57. package/dist/mdx/serialize.js +485 -0
  58. package/dist/mdx/types.d.ts +42 -0
  59. package/dist/mdx/types.js +6 -0
  60. package/dist/pages/admin.astro +27 -0
  61. package/dist/pages/api.d.ts +3 -0
  62. package/dist/pages/api.js +31 -0
  63. package/dist/ui/App.d.ts +4 -0
  64. package/dist/ui/App.js +70 -0
  65. package/dist/ui/api.d.ts +83 -0
  66. package/dist/ui/api.js +97 -0
  67. package/dist/ui/auth-client.d.ts +27 -0
  68. package/dist/ui/auth-client.js +29 -0
  69. package/dist/ui/components/AssetPicker.d.ts +27 -0
  70. package/dist/ui/components/AssetPicker.js +97 -0
  71. package/dist/ui/components/Icon.d.ts +11 -0
  72. package/dist/ui/components/Icon.js +46 -0
  73. package/dist/ui/components/Layout.d.ts +15 -0
  74. package/dist/ui/components/Layout.js +87 -0
  75. package/dist/ui/components/editor/EditorContext.d.ts +15 -0
  76. package/dist/ui/components/editor/EditorContext.js +9 -0
  77. package/dist/ui/components/editor/NodeViews.d.ts +4 -0
  78. package/dist/ui/components/editor/NodeViews.js +127 -0
  79. package/dist/ui/components/editor/RichTextEditor.d.ts +24 -0
  80. package/dist/ui/components/editor/RichTextEditor.js +230 -0
  81. package/dist/ui/components/editor/extensions.d.ts +30 -0
  82. package/dist/ui/components/editor/extensions.js +248 -0
  83. package/dist/ui/components/editor/schema.d.ts +12 -0
  84. package/dist/ui/components/editor/schema.js +37 -0
  85. package/dist/ui/components/fields/CodeInput.d.ts +10 -0
  86. package/dist/ui/components/fields/CodeInput.js +8 -0
  87. package/dist/ui/components/fields/CodeMirrorInput.d.ts +2 -0
  88. package/dist/ui/components/fields/CodeMirrorInput.js +44 -0
  89. package/dist/ui/components/fields/FieldRenderer.d.ts +41 -0
  90. package/dist/ui/components/fields/FieldRenderer.js +299 -0
  91. package/dist/ui/components/fields/ImageTools.d.ts +31 -0
  92. package/dist/ui/components/fields/ImageTools.js +296 -0
  93. package/dist/ui/components/fields/MarkdownField.d.ts +7 -0
  94. package/dist/ui/components/fields/MarkdownField.js +16 -0
  95. package/dist/ui/components/fields/TreeInput.d.ts +13 -0
  96. package/dist/ui/components/fields/TreeInput.js +221 -0
  97. package/dist/ui/components/ui.d.ts +63 -0
  98. package/dist/ui/components/ui.js +113 -0
  99. package/dist/ui/context.d.ts +17 -0
  100. package/dist/ui/context.js +13 -0
  101. package/dist/ui/lib/format.d.ts +5 -0
  102. package/dist/ui/lib/format.js +11 -0
  103. package/dist/ui/lib/utils.d.ts +24 -0
  104. package/dist/ui/lib/utils.js +255 -0
  105. package/dist/ui/main.d.ts +1 -0
  106. package/dist/ui/main.js +11 -0
  107. package/dist/ui/pages/Assets.d.ts +1 -0
  108. package/dist/ui/pages/Assets.js +69 -0
  109. package/dist/ui/pages/CollectionList.d.ts +6 -0
  110. package/dist/ui/pages/CollectionList.js +115 -0
  111. package/dist/ui/pages/Dashboard.d.ts +1 -0
  112. package/dist/ui/pages/Dashboard.js +57 -0
  113. package/dist/ui/pages/EntryEditor.d.ts +3 -0
  114. package/dist/ui/pages/EntryEditor.js +313 -0
  115. package/dist/ui/pages/Login.d.ts +6 -0
  116. package/dist/ui/pages/Login.js +88 -0
  117. package/dist/ui/pages/Users.d.ts +1 -0
  118. package/dist/ui/pages/Users.js +102 -0
  119. package/dist/ui/styles.css +2554 -0
  120. package/package.json +123 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael Spencer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,384 @@
1
- # Temporary Holding Version
1
+ # Vestry CMS
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A git-backed CMS for [Astro](https://astro.build), built as an Astro
4
+ integration. Content, assets and configuration live in your repository;
5
+ content needs no database and there is no external asset storage. The control panel is a
6
+ React app served from your site at `/admin`, and every save is a commit.
7
+
8
+ - **Astro-native content.** Collections map onto the files Astro already
9
+ reads: Markdown/MDX with frontmatter, JSON and YAML, one file per entry or
10
+ one file holding many. Images are referenced the way Astro's `image()`
11
+ schema helper and `public/` expect.
12
+ - **MDX with components.** A Tiptap rich text editor reads and writes MDX.
13
+ Astro components you register show up in an "Insert" menu and are edited
14
+ as blocks with a form for their props (Statamic's Bard sets, for MDX). A
15
+ Source tab exposes the raw MDX.
16
+ - **GitHub backend, swappable.** Reads and writes go through a small
17
+ `ContentSource` interface. The GitHub implementation uses the Git Data API
18
+ with ETag revalidation, per-SHA caching and a build-time index, so the
19
+ panel stays snappy; the only database is the small one sign-in needs. `astro dev` edits your working
20
+ copy directly.
21
+ - **Prettier-clean commits.** When the project has Prettier installed, files
22
+ are formatted with the project's own config before they are committed.
23
+
24
+ ## Setup
25
+
26
+ Vestry needs Astro 6 or 7 and Node 22.12 or later. On Cloudflare, use Astro
27
+ 7.3.4 or later: earlier 7.x releases streamed on-demand pages in workerd as
28
+ `[object Object]`, which breaks the `/admin` shell.
29
+
30
+ ```sh
31
+ pnpm add vestrycms
32
+ ```
33
+
34
+ ```ts
35
+ // vestry.config.ts
36
+ import { collection, component, defineConfig, fields } from "vestrycms/config";
37
+
38
+ export default defineConfig({
39
+ name: "My Site",
40
+ siteUrl: "https://example.com",
41
+ repo: { owner: "me", name: "my-site", branch: "main" },
42
+ collections: {
43
+ posts: collection({
44
+ label: "Posts",
45
+ path: "src/content/posts/*.mdx",
46
+ titleField: "title",
47
+ sort: { field: "date", direction: "desc" },
48
+ status: { field: "draft", publishedWhen: false },
49
+ previewUrl: "/posts/{slug}",
50
+ schema: {
51
+ title: fields.text({ required: true }),
52
+ date: fields.date({ sidebar: true, default: "now" }),
53
+ cover: fields.image({ directory: "src/assets/covers" }),
54
+ draft: fields.boolean({ default: false }),
55
+ },
56
+ body: { components: ["Callout"] },
57
+ }),
58
+ },
59
+ components: {
60
+ Callout: component({
61
+ import: "@/components/Callout.astro",
62
+ children: true,
63
+ props: {
64
+ tone: fields.select({ options: [{ label: "Info", value: "info" }] }),
65
+ },
66
+ }),
67
+ },
68
+ assets: {
69
+ covers: { label: "Covers", directory: "src/assets/covers" },
70
+ },
71
+ });
72
+ ```
73
+
74
+ ```js
75
+ // astro.config.mjs
76
+ import vestry from "vestrycms";
77
+ import vestryConfig from "./vestry.config";
78
+
79
+ export default defineConfig({
80
+ integrations: [vestry(vestryConfig, { base: "/admin" })],
81
+ });
82
+ ```
83
+
84
+ The config is plain data (it is serialised into the server bundle and sent to
85
+ the browser), so it cannot contain functions.
86
+
87
+ `name` titles the control panel; `logo` (a repository path such as
88
+ `src/assets/logo-reverse.svg`, or an absolute URL) replaces it in the dark
89
+ top bar, so a light or reverse variant works best.
90
+
91
+ ### Sign-in and users
92
+
93
+ Sign-in runs on [Better Auth](https://www.better-auth.com) with its email OTP
94
+ and admin plugins. Editors sign in with a six-digit code emailed to them;
95
+ there are no passwords. `auth.admins` lists the addresses that are always
96
+ admins (they can sign in before anyone has invited them, which bootstraps the
97
+ user list). Admins invite everyone else from the **Users** page and choose a
98
+ role:
99
+
100
+ | Role | Can |
101
+ | -------- | ------------------------------------------------------------ |
102
+ | `editor` | Create, edit, publish and delete content; upload assets |
103
+ | `admin` | Everything an editor can, plus invite, edit and remove users |
104
+
105
+ Users, sessions (30 days) and pending codes live in a SQL database that
106
+ Better Auth accepts directly: a Cloudflare D1 binding named `VESTRY_DB` in
107
+ production, a `node:sqlite` file in dev, its memory adapter in tests. The
108
+ tables are created on first use with Better Auth's migrator, so there is no
109
+ migration step. Codes expire after 10 minutes and are burned after five
110
+ wrong guesses; Better Auth rate-limits the sign-in routes per IP (counters in
111
+ the database, so they hold across Worker isolates). Emails go through
112
+ `config.mail` (`resend`, with the key in `VESTRY_RESEND_API_KEY`, or `console`
113
+ for development). Vestry adds invitations, the config-listed admins, a
114
+ `lastLoginAt` field and the last-admin guards on top of the plugins.
115
+
116
+ Commits are made through a GitHub App installed on the repository with
117
+ **Contents: read and write**. Commits are made by the app's bot, with the
118
+ editor credited as a co-author (`Co-authored-by: Name <email>`), so GitHub
119
+ shows both and the editor's account never needs push access.
120
+
121
+ | Variable | Purpose |
122
+ | ------------------------------- | ------------------------------------------------------------------------------------------ |
123
+ | `VESTRY_GITHUB_APP_ID` | GitHub App id |
124
+ | `VESTRY_GITHUB_INSTALLATION_ID` | The app's installation id on the repository |
125
+ | `VESTRY_GITHUB_PRIVATE_KEY` | The app's PEM private key (PKCS#1 as issued, PKCS#8, `\n`-escaped or base64 also accepted) |
126
+ | `VESTRY_RESEND_API_KEY` | Resend API key (when `mail.provider` is `resend`) |
127
+ | `VESTRY_SECRET` | Signs session cookies (`openssl rand -hex 32`); dev modes fall back to a fixed value |
128
+ | `VESTRY_MODE` | `auth` or `github` to turn sign-in on in `astro dev` |
129
+
130
+ ```jsonc
131
+ // wrangler.jsonc
132
+ "d1_databases": [{ "binding": "VESTRY_DB", "database_name": "my-site-vestry" }]
133
+ ```
134
+
135
+ Several sites can share one D1 database (handy on the free plan's database
136
+ limit): give each a different `auth.tablePrefix` (lowercase, e.g. `"mysite_"`), and bind
137
+ the same `database_id` as `VESTRY_DB` in each site's `wrangler.jsonc`. Each
138
+ site then gets its own tables (`mysite_user`, `mysite_session`, …), so users,
139
+ roles and sessions stay separate. Without a prefix the tables keep Better
140
+ Auth's plain names, so don't let two sites share a database unprefixed: they
141
+ would share one user list, and anyone invited to one could sign in to both.
142
+ Changing the prefix on a live site starts it with an empty user list (config
143
+ admins can still sign in and invite everyone again).
144
+
145
+ In `astro dev` no sign-in is needed by default: the panel edits the project
146
+ directory and you commit with git. `VESTRY_MODE=auth` keeps the local source
147
+ but requires sign-in, with users in git-ignored `.vestry/auth.sqlite`, for
148
+ working on the flow itself. Emails print to the terminal unless
149
+ `mail.provider` is `resend` and `VESTRY_RESEND_API_KEY` is set, in which case
150
+ they are really sent. `VESTRY_MODE=github` additionally commits through the
151
+ GitHub App.
152
+
153
+ ## How it works
154
+
155
+ ```
156
+ src
157
+ ├── index.ts Astro integration: routes, Vite plugin, dev middleware
158
+ ├── config/ defineConfig / collection / component / fields
159
+ ├── core/
160
+ │ ├── source.ts ContentSource interface + git blob hashing
161
+ │ ├── sources/ LocalSource (fs), GitHubSource (Git Data API), GitHub App tokens
162
+ │ ├── cache.ts Cache interface, MemoryCache, kvCache adapter
163
+ │ ├── service.ts Collections → entries, singletons, assets
164
+ │ ├── api.ts HTTP API (runtime-agnostic Request → Response)
165
+ │ ├── auth.ts Better Auth setup (email OTP + admin), invitations, bootstrap admins
166
+ │ ├── roles.ts Role list and the PublicUser shape shared with the UI
167
+ │ ├── mail.ts Mailer interface, Resend + console adapters
168
+ │ └── values.ts Asset path normalisation, auto fields
169
+ ├── mdx/ MDX ⇄ ProseMirror document conversion
170
+ ├── pages/ Injected routes: admin.astro shell, api.ts endpoint
171
+ ├── integration/ Build-time index, formatter module, dev bridge, client deps
172
+ └── ui/ React control panel (Tiptap, TanStack Query, react-router)
173
+ ```
174
+
175
+ Routes injected into the site:
176
+
177
+ - `/admin/[...path]` – the control panel shell (on-demand, no auth needed to
178
+ load the shell; data comes from the API).
179
+ - `/admin/api/[...path]` – JSON API. `config`, `status`, `collections/:key`,
180
+ `collections/:key/entries/:slug`, `singletons/:key`, `assets/:container`,
181
+ `file?path=`, `users` (admins), and `auth/*` (Better Auth's routes).
182
+
183
+ Virtual modules provided by the Vite plugin: `virtual:vestry/config`,
184
+ `virtual:vestry/runtime`, `virtual:vestry/index` (build-time frontmatter
185
+ index keyed by git blob SHA) and `virtual:vestry/formatter` (Prettier, when
186
+ installed).
187
+
188
+ ### Dev vs production
189
+
190
+ With the Cloudflare adapter, on-demand routes run inside workerd even in
191
+ `astro dev`, where Node's `fs` is unavailable. The integration therefore
192
+ serves the API from a Vite dev-server middleware (Node) in development, and
193
+ from the injected Astro endpoint in production. Both call the same
194
+ `createApi()`.
195
+
196
+ ### Editing model
197
+
198
+ - Entry values are normalised for the editor: image/file fields always hold
199
+ repository paths; on save they are written back in the configured form
200
+ (`relative` to the entry file, bare `filename`, `public` URL or `repo`
201
+ path).
202
+ - The body editor parses MDX into a ProseMirror document. JSX components
203
+ become `mdxComponent` nodes with their attributes converted to JSON where
204
+ they are literals (anything else is kept verbatim as an expression).
205
+ Imports for known components are regenerated on save; other ESM is kept as
206
+ a raw block.
207
+ - Saving composes the full file (YAML frontmatter + body) in the browser,
208
+ formats it with Prettier when available, and sends it with the blob SHA
209
+ the editor loaded. A changed SHA is reported as a conflict.
210
+ - Uploads chosen from an image/file field are committed together with the
211
+ entry in one commit.
212
+
213
+ ## Editor sections
214
+
215
+ By default the editor shows a "Details" card with every field, the content
216
+ editor, and a rail for fields marked `sidebar: true`. `sections` groups
217
+ fields into titled cards instead:
218
+
219
+ ```ts
220
+ sections: [
221
+ { label: "Hero", fields: ["heroImage", "heroFocalPoint", "heroTitle"] },
222
+ "body", // the content editor goes here
223
+ {
224
+ label: "Events",
225
+ description: "Shown as structured data.",
226
+ fields: ["events"],
227
+ },
228
+ {
229
+ label: "Search engines",
230
+ sidebar: true,
231
+ fields: ["seoTitle", "seoDescription"],
232
+ },
233
+ ];
234
+ ```
235
+
236
+ Sections render in order; `sidebar: true` puts a card in the rail. Fields
237
+ not named by any section still fall into "Details" or the rail, and the
238
+ content editor renders after the sections unless `"body"` places it.
239
+
240
+ ## Trees (menus)
241
+
242
+ `fields.tree` edits nested data such as a navigation menu. Each node has the
243
+ given fields plus a `children` array (configurable via `childrenKey`):
244
+
245
+ ```ts
246
+ navigation: singleton({
247
+ label: "Navigation",
248
+ path: "src/data/navigation.json",
249
+ body: false,
250
+ schema: {
251
+ main: fields.tree({
252
+ maxDepth: 2,
253
+ itemDetail: "url",
254
+ fields: {
255
+ title: fields.text({ required: true }),
256
+ url: fields.text({ required: true }),
257
+ external: fields.boolean(),
258
+ },
259
+ }),
260
+ },
261
+ });
262
+ ```
263
+
264
+ The editor shows an indented tree with collapse toggles, inline editing of a
265
+ node's fields, "add child" and remove actions, and drag-and-drop that both
266
+ reorders and re-parents: drag a node up or down to move it and sideways to
267
+ change its depth, within `maxDepth`. A dashed slot shows where it will land
268
+ and descendants travel with their parent.
269
+
270
+ ## Dashboard
271
+
272
+ Each collection gets a tile with its entry count. Collections whose `sort`
273
+ field is a date or datetime also get a "Recent …" list; set `recent: false`
274
+ to hide it, `recent: true` to show it for other collections, or a number to
275
+ change how many entries appear.
276
+
277
+ ## Image crop and focal point
278
+
279
+ Two fields edit _how_ a sibling image field is displayed, so the site keeps
280
+ one original file in git and decides the framing in templates:
281
+
282
+ ```ts
283
+ cover: fields.image({ directory: "src/assets/covers" }),
284
+ coverCrop: fields.crop({ image: "cover", aspect: 8 / 9 }),
285
+ hero: fields.image({ directory: "src/assets/images", storeAs: "filename" }),
286
+ heroFocalPoint: fields.focalPoint({ image: "hero", previews: ["16:9", "1:1"] }),
287
+ ```
288
+
289
+ - `crop` stores `{ x, y, width, height }` in the image's natural pixel
290
+ coordinates. The editor shows a draggable, resizable rectangle (with the
291
+ aspect ratio locked when `aspect` is set), keyboard nudging, and a "Fit"
292
+ button for the largest centred crop. Use it with sharp's `extract()` or a
293
+ CSS transform.
294
+ - `focalPoint` stores `{ x, y }` as percentages (0–100) for CSS
295
+ `object-position`. The editor shows a crosshair you click or drag, plus
296
+ small previews at a few aspect ratios so you can see what survives a crop.
297
+
298
+ Both read the sibling field named by `image` at the same level of the
299
+ schema (top level, or inside the same object/array item), and preview
300
+ freshly uploaded images before they are committed.
301
+
302
+ ## Field types
303
+
304
+ `text`, `textarea`, `slug`, `number`, `boolean`, `date` (optional time),
305
+ `datetime` (optional `autoUpdate`), `select`, `multiselect`, `color`,
306
+ `image`, `file`, `reference`, `object`, `array`, `markdown`, `code`, `json`,
307
+ `crop`, `focalPoint`, `tree`.
308
+ Fields accept `label`, `description`, `required`, `sidebar`, `hidden`,
309
+ `readOnly` and `width` (`full` | `half` | `third`).
310
+
311
+ ## Development
312
+
313
+ ```sh
314
+ pnpm install
315
+ pnpm build # src/ → dist/ (tsc, plus admin.astro, styles.css, env.d.ts)
316
+ pnpm typecheck
317
+ pnpm test
318
+ pnpm format:check
319
+ ```
320
+
321
+ The package is published as compiled JavaScript in `dist/`; `src/` is not
322
+ shipped. `scripts/build.mjs` runs `tsc -p tsconfig.build.json`, copies the
323
+ files tsc doesn't emit (pointing `admin.astro`'s client script at the
324
+ compiled UI), and fails if `src/integration/client-deps.ts` doesn't list
325
+ exactly the packages the control panel imports in the browser. The
326
+ integration pre-bundles those through Vite's optimizer: an installed Astro
327
+ integration is excluded from it, and dev would otherwise serve CommonJS
328
+ dependencies such as `react/jsx-runtime` raw.
329
+
330
+ To work on Vestry against a site, run `pnpm dev` here (an initial build, then
331
+ `tsc --watch`) and link the package from the site:
332
+
333
+ ```sh
334
+ pnpm link ../vestrycms
335
+ ```
336
+
337
+ ### Tests
338
+
339
+ Tests use [Vitest](https://vitest.dev). Files matching `test/**/*.test.ts`
340
+ run in CI; `pnpm typecheck` covers everything under `test/`.
341
+
342
+ ```sh
343
+ pnpm test # run the suite once
344
+ pnpm test:watch # re-run on change
345
+ pnpm test mdx # only files whose path matches "mdx"
346
+ pnpm test -u # update snapshots after an intended change
347
+ ```
348
+
349
+ - `test/auth.test.ts`: sign-in, users and guards over HTTP (memory adapter).
350
+ - `test/shared-database.test.ts`: `auth.tablePrefix` keeping sites apart in one
351
+ database (`node:sqlite`, real migrations).
352
+ - `test/github-source.test.ts`: commit sequencing against a fake Git Data API.
353
+ - `test/github-app.test.ts`: GitHub App JWT and installation token cache.
354
+ - `test/mdx.test.ts`: MDX round trips. Each file in `test/fixtures/mdx` must
355
+ survive parse → serialize byte for byte, so write fixtures in the
356
+ serializer's canonical style (`_emphasis_`, `-` bullets, blank lines around
357
+ component children). Prettier skips that directory. Record a known gap as
358
+ an `it.fails` test and move it into the fixtures once fixed.
359
+ - `test/editor-schema.test.ts`: the same fixtures pass through the editor's
360
+ Tiptap schema (`src/ui/components/editor/schema.ts`) without losing node
361
+ types or attributes, so a page saved from the editor matches its source.
362
+
363
+ The auth and GitHub source tests are ordered scenarios that build on shared
364
+ state, so filter them by file, not by test name (`-t`).
365
+
366
+ `test/manual` holds scripts that run outside the suite:
367
+
368
+ ```sh
369
+ pnpm test:roundtrip path/to/file.mdx # round-trip a site's content through Prettier
370
+ GH_TOKEN=… node --import tsx/esm test/manual/github-read.ts path/to/site # time the live read path
371
+ GH_TOKEN=… node --import tsx/esm test/manual/github-write.ts # live commits on a throwaway branch
372
+ ```
373
+
374
+ ### Releasing
375
+
376
+ 1. Bump `version` in `package.json` and commit.
377
+ 2. Publish a GitHub release tagged `v<version>`. The **Publish** workflow
378
+ checks the tag against `package.json`, runs the checks and publishes to
379
+ npm with provenance through npm trusted publishing (configure it on
380
+ npmjs.com for this repository and `publish.yml`).
381
+
382
+ The first release has to be published by hand, since npm only offers trusted
383
+ publishing for a package that exists: `pnpm publish` (which builds first via
384
+ `prepack`).
@@ -0,0 +1,30 @@
1
+ import type { ArrayField, BooleanField, CodeField, ColorField, CropField, DateField, DatetimeField, Field, FileField, FocalPointField, ImageField, JsonField, MarkdownField, MultiselectField, NumberField, ObjectField, ReferenceField, SelectField, SlugField, TextField, TextareaField, TreeField } from "./types.js";
2
+ type Opts<T extends Field> = Omit<T, "type">;
3
+ /**
4
+ * Field builders. Each returns a plain object; the `type` discriminant is all
5
+ * the control panel and the server need.
6
+ */
7
+ export declare const fields: {
8
+ text: (opts?: Opts<TextField>) => TextField;
9
+ textarea: (opts?: Opts<TextareaField>) => TextareaField;
10
+ slug: (opts?: Opts<SlugField>) => SlugField;
11
+ number: (opts?: Opts<NumberField>) => NumberField;
12
+ boolean: (opts?: Opts<BooleanField>) => BooleanField;
13
+ date: (opts?: Opts<DateField>) => DateField;
14
+ datetime: (opts?: Opts<DatetimeField>) => DatetimeField;
15
+ select: (opts: Opts<SelectField>) => SelectField;
16
+ multiselect: (opts: Opts<MultiselectField>) => MultiselectField;
17
+ color: (opts?: Opts<ColorField>) => ColorField;
18
+ image: (opts: Opts<ImageField>) => ImageField;
19
+ file: (opts: Opts<FileField>) => FileField;
20
+ reference: (opts: Opts<ReferenceField>) => ReferenceField;
21
+ object: (fieldsOrOpts: ObjectField["fields"] | Opts<ObjectField>, extra?: Omit<Opts<ObjectField>, "fields">) => ObjectField;
22
+ array: (of: Field, opts?: Omit<Opts<ArrayField>, "of">) => ArrayField;
23
+ markdown: (opts?: Opts<MarkdownField>) => MarkdownField;
24
+ code: (opts?: Opts<CodeField>) => CodeField;
25
+ json: (opts?: Opts<JsonField>) => JsonField;
26
+ crop: (opts: Opts<CropField>) => CropField;
27
+ focalPoint: (opts: Opts<FocalPointField>) => FocalPointField;
28
+ tree: (opts: Opts<TreeField>) => TreeField;
29
+ };
30
+ export {};
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Field builders. Each returns a plain object; the `type` discriminant is all
3
+ * the control panel and the server need.
4
+ */
5
+ export const fields = {
6
+ text: (opts = {}) => ({ type: "text", ...opts }),
7
+ textarea: (opts = {}) => ({
8
+ type: "textarea",
9
+ ...opts,
10
+ }),
11
+ slug: (opts = {}) => ({ type: "slug", ...opts }),
12
+ number: (opts = {}) => ({
13
+ type: "number",
14
+ ...opts,
15
+ }),
16
+ boolean: (opts = {}) => ({
17
+ type: "boolean",
18
+ ...opts,
19
+ }),
20
+ date: (opts = {}) => ({ type: "date", ...opts }),
21
+ datetime: (opts = {}) => ({
22
+ type: "datetime",
23
+ ...opts,
24
+ }),
25
+ select: (opts) => ({
26
+ type: "select",
27
+ ...opts,
28
+ }),
29
+ multiselect: (opts) => ({
30
+ type: "multiselect",
31
+ ...opts,
32
+ }),
33
+ color: (opts = {}) => ({
34
+ type: "color",
35
+ ...opts,
36
+ }),
37
+ image: (opts) => ({ type: "image", ...opts }),
38
+ file: (opts) => ({ type: "file", ...opts }),
39
+ reference: (opts) => ({
40
+ type: "reference",
41
+ ...opts,
42
+ }),
43
+ object: (fieldsOrOpts, extra = {}) => {
44
+ if ("fields" in fieldsOrOpts && isSchema(fieldsOrOpts.fields)) {
45
+ return { type: "object", ...fieldsOrOpts };
46
+ }
47
+ return {
48
+ type: "object",
49
+ fields: fieldsOrOpts,
50
+ ...extra,
51
+ };
52
+ },
53
+ array: (of, opts = {}) => ({
54
+ type: "array",
55
+ of,
56
+ ...opts,
57
+ }),
58
+ markdown: (opts = {}) => ({
59
+ type: "markdown",
60
+ ...opts,
61
+ }),
62
+ code: (opts = {}) => ({ type: "code", ...opts }),
63
+ json: (opts = {}) => ({ type: "json", ...opts }),
64
+ crop: (opts) => ({ type: "crop", ...opts }),
65
+ focalPoint: (opts) => ({
66
+ type: "focalPoint",
67
+ ...opts,
68
+ }),
69
+ tree: (opts) => ({ type: "tree", ...opts }),
70
+ };
71
+ function isSchema(value) {
72
+ return (typeof value === "object" &&
73
+ value !== null &&
74
+ Object.values(value).every((f) => typeof f === "object" && f !== null && "type" in f));
75
+ }
@@ -0,0 +1,9 @@
1
+ import type { AssetContainerConfig, CollectionConfig, ComponentConfig, SingletonConfig, VestryConfig } from "./types.js";
2
+ export * from "./types.js";
3
+ export { fields } from "./fields.js";
4
+ /** Identity helper that gives you editor completions for the config shape. */
5
+ export declare function defineConfig(config: VestryConfig): VestryConfig;
6
+ export declare function collection(config: CollectionConfig): CollectionConfig;
7
+ export declare function singleton(config: SingletonConfig): SingletonConfig;
8
+ export declare function component(config?: ComponentConfig): ComponentConfig;
9
+ export declare function assetContainer(config: AssetContainerConfig): AssetContainerConfig;
@@ -0,0 +1,18 @@
1
+ export * from "./types.js";
2
+ export { fields } from "./fields.js";
3
+ /** Identity helper that gives you editor completions for the config shape. */
4
+ export function defineConfig(config) {
5
+ return config;
6
+ }
7
+ export function collection(config) {
8
+ return config;
9
+ }
10
+ export function singleton(config) {
11
+ return config;
12
+ }
13
+ export function component(config = {}) {
14
+ return config;
15
+ }
16
+ export function assetContainer(config) {
17
+ return config;
18
+ }