@407dev/cli 0.1.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/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@407dev/cli",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/407dev/cms.git",
8
+ "directory": "packages/cli"
9
+ },
10
+ "bin": {
11
+ "cms": "./dist/index.js"
12
+ },
13
+ "main": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "import": "./dist/index.js",
19
+ "default": "./dist/index.js"
20
+ }
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "skills"
28
+ ],
29
+ "dependencies": {
30
+ "@astrojs/compiler": "^2.13.1",
31
+ "@jridgewell/trace-mapping": "^0.3.25",
32
+ "@supabase/supabase-js": "^2.48.1",
33
+ "typescript": "^5.7.3",
34
+ "zod": "^4.4.3",
35
+ "@407dev/blocks": "0.1.0",
36
+ "@407dev/field-types": "0.1.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^22.13.4",
40
+ "tsup": "^8.4.0",
41
+ "typescript": "^5.7.3",
42
+ "vitest": "3.0.7",
43
+ "@407dev/config": "0.0.1",
44
+ "@407dev/api-client": "0.0.1"
45
+ },
46
+ "scripts": {
47
+ "build": "tsup",
48
+ "dev": "tsup --watch",
49
+ "lint": "biome check src",
50
+ "typecheck": "tsc --noEmit",
51
+ "test": "vitest run",
52
+ "test:integration": "vitest run --config vitest.integration.config.ts"
53
+ }
54
+ }
@@ -0,0 +1,145 @@
1
+ ---
2
+ name: 407dev-cms-fields
3
+ description: Rules and best practices for adding, renaming, removing, or restructuring 407dev CMS content in an Astro site — field helpers (f.text, f.img, f.richText, …), f.scope, f.group, collections (defineCollection, getEntries, f.entryScope), key naming, static-extraction constraints, and the cms check / cms push workflow. Use whenever editing code that imports @407dev/cms-astro, when asked to make something editable or add a CMS field, key, group, or collection, or when fixing a `cms check` diagnostic.
4
+ ---
5
+
6
+ # 407dev CMS: fields, keys, and collections
7
+
8
+ ## Mental model
9
+ - Code owns layout, components, and routing. The CMS stores only **values** under string keys (`home.hero.heading` → `"Welcome"`) plus repeatable **collection entries**.
10
+ - **Usage is the schema.** A helper call both renders the value and declares the field. `cms push` parses `.astro`/`.ts` source statically (it never runs it), builds the site manifest, and syncs it. There is no separate schema file to edit.
11
+ - Editors change values in the dashboard; nothing reaches the live site until they publish. Published builds read a published snapshot only.
12
+
13
+ ## Pick the construct
14
+ | Content | Construct |
15
+ | --- | --- |
16
+ | Copy that appears once on one page | `const intro = f.scope('about.intro')` → `<intro.text k="heading" />` |
17
+ | Site-wide chrome (header, footer, contact info) | `f.scope('site')`, `f.scope('nav')`, `f.scope('footer')` in the layout |
18
+ | The same set of fields reused in several places | `f.group` in `src/cms/groups.ts`, instantiated once per prefix |
19
+ | A list of same-shaped items, or items with their own URL | `defineCollection` in `src/cms/collections.ts` |
20
+
21
+ If you catch yourself numbering keys (`card1.title`, `card2.title`), it's a collection.
22
+
23
+ ## Key naming
24
+ - Keys look like `<page-or-global>.<section>.<field>`, for example `home.hero.heading`, `pricing.faq.intro`, `footer.legal`. Use lowerCamel segments separated by `.`.
25
+ - Keys are permanent identifiers (see *Changing things*). Name them by role (`hero.heading`), never by current copy (`hero.welcomeToAcme`) or styling (`hero.bigRedText`).
26
+ - Never encode data in a key: no ids, slugs, dates, or indexes.
27
+
28
+ ## Fields
29
+ ```astro
30
+ ---
31
+ import { f } from '@407dev/cms-astro';
32
+ const hero = f.scope('home.hero');
33
+ const heading = hero.text('heading', { defaultValue: 'Welcome' }); // raw value
34
+ ---
35
+ <title>{heading}</title>
36
+ <hero.text k="heading" as="h1" class="hero-title" defaultValue="Welcome" />
37
+ ```
38
+ - **Component form** (`<hero.text k="…" />`) renders an element carrying `data-cms-field="home.hero.heading"`, which is what makes it click-to-edit in the editor preview and dev toolbar. Use it for anything visible.
39
+ - **Function form** (`hero.text('…')`) returns the raw value without a marker. Use it only for attributes, `<title>`/meta, logic, and complex types. To keep a function-form element clickable, add `data-cms-field={hero('image')}` yourself (`hero('x')` returns the full key).
40
+ - `as="h1"` sets the rendered tag. Other props (`class`, `id`, `aria-*`) pass through to that element. Put the element's existing attributes on the helper instead of wrapping it, so the markup doesn't grow an extra `<span>`.
41
+ - `defaultValue` renders whenever nothing is stored. Always set one when keying existing copy. Only literal strings, numbers, and booleans are recorded as the field's default on push; object or expression defaults still render but aren't pushed.
42
+ - Missing value with no default: `astro dev` shows `[home.hero.heading]`, and `astro build` renders the type's empty value and prints a warning listing the missing keys.
43
+ - The helper catalog, value shapes, and image/rich-text recipes are in [references/helpers.md](references/helpers.md).
44
+
45
+ ## Groups (reusable field sets)
46
+ ```ts
47
+ // src/cms/groups.ts
48
+ import { f } from '@407dev/cms-astro';
49
+ export const Hero = f.group('Hero', { heading: f.text, subheading: f.text, image: f.img });
50
+ ```
51
+ ```astro
52
+ ---
53
+ // src/pages/index.astro
54
+ import { Hero } from '../cms/groups';
55
+ import HeroSection from '../components/HeroSection.astro';
56
+ const hero = Hero('home.hero'); // declares home.hero.heading / .subheading / .image
57
+ ---
58
+ <HeroSection content={hero} />
59
+ ```
60
+ ```astro
61
+ ---
62
+ // src/components/HeroSection.astro
63
+ import type { BoundGroupAccessor, f } from '@407dev/cms-astro';
64
+ interface Props {
65
+ content: BoundGroupAccessor<{ heading: typeof f.text; subheading: typeof f.text; image: typeof f.img }>;
66
+ }
67
+ const { content } = Astro.props;
68
+ ---
69
+ <section {...content.attrs()}>
70
+ <content.heading as="h1" />
71
+ <content.subheading as="p" />
72
+ <content.image class="hero-img" />
73
+ </section>
74
+ ```
75
+ - Shape values must be helper references (`f.text`), not strings or calls.
76
+ - Never call `f.text('literal')` inside a reusable component. Every usage would share one key. Pass a bound group accessor as a prop instead.
77
+
78
+ ## Collections
79
+ ```ts
80
+ // src/cms/collections.ts
81
+ import { defineCollection, z } from '@407dev/cms-astro';
82
+ export const blog = defineCollection({
83
+ key: 'blog',
84
+ schema: z.object({ title: z.string(), excerpt: z.string().optional(), body: z.any() }),
85
+ route: '/blog/[slug]', // omit for items without their own page (testimonials, team)
86
+ presentation: 'form', // 'form' = document form + preview pane; 'visual' = edit on the page
87
+ titleField: 'title',
88
+ bodyField: 'body', // rich-text body edited in the form view
89
+ orderable: true, // editors can drag to reorder
90
+ });
91
+ ```
92
+ Listing page:
93
+ ```astro
94
+ ---
95
+ import { getEntries } from '@407dev/cms-astro';
96
+ import { blog } from '../../cms/collections';
97
+ const posts = getEntries(blog); // published only in production; includes drafts/scheduled in preview
98
+ ---
99
+ {posts.map((p) => <a href={`/blog/${p.slug}`}>{p.content.title}</a>)}
100
+ ```
101
+ Detail page (`src/pages/blog/[slug].astro`):
102
+ ```astro
103
+ ---
104
+ import { collectionPaths, f, getEntry } from '@407dev/cms-astro';
105
+ import { blog } from '../../cms/collections';
106
+ export async function getStaticPaths() {
107
+ return await collectionPaths(blog); // published entries only
108
+ }
109
+ // Server-rendered preview/dev skips getStaticPaths props and must also see drafts.
110
+ const entry = Astro.props.entry ?? getEntry(blog, Astro.params.slug ?? '');
111
+ if (!entry) return Astro.redirect('/404');
112
+ const post = f.entryScope(blog, entry.slug);
113
+ ---
114
+ <article {...post.attrs()}>
115
+ <post.text k="title" as="h1" defaultValue={entry.content.title} />
116
+ <post.richtext k="body" defaultValue={entry.content.body} />
117
+ </article>
118
+ ```
119
+ - `f.entryScope` keys are paths into the entry's `content` and must match schema property names. They don't create page fields.
120
+ - **Collection schemas are read textually, property by property.** Keep them flat, using the forms below:
121
+ - `z.string()`, `z.number()`, and `z.boolean()` map to their types. Anything else (nested objects, enums) is pushed as a string.
122
+ - A property is required unless its initializer contains `.optional()`. `.default()` alone still counts as required.
123
+ - `z.array(...)` is always pushed as an array of strings.
124
+ - Rich text: set `bodyField`, or use an initializer whose text contains `richtext`.
125
+ - Images or video: the initializer must call a function literally named `image(`, `video(`, or `asset(`. Define it in the collections file, e.g. `const image = () => z.string();` → `hero: image().optional()`. The bundle delivers the resolved asset object.
126
+ - Don't use `.refine()` or `.superRefine()`. They can't be serialized and throw `RefinementError`.
127
+
128
+ ## Extraction rules (`cms check` enforces them)
129
+ - Keys, `k="…"` props, scope prefixes, group prefixes, and collection `key`s must be **static string literals**. `f.text(name)`, ``k={`${x}.title`}``, and `Hero(prefix)` are errors. Dynamic, per-item content belongs in a collection.
130
+ - Groups and collections must be imported **directly from the file that defines them**. Re-exporting through a barrel `index.ts` is a "multiple hops" error.
131
+ - Import `f`, `defineCollection`, and friends from `@407dev/cms-astro`, not through a local wrapper.
132
+ - Diagnostics you'll see and their fixes are in [references/helpers.md](references/helpers.md#diagnostics).
133
+
134
+ ## Changing things safely
135
+ 1. Edit code → `pnpm exec cms check` → `pnpm exec cms push --dry-run` → `pnpm exec cms push`.
136
+ 2. **New keys** start empty. They render `defaultValue` until an editor fills them in and publishes.
137
+ 3. **Renaming or removing a key orphans it.** The stored value is kept but no longer attached to code, and the new key starts empty. `cms push` lists the orphans and asks for confirmation. There is no rename command, so before renaming a key that may hold client content, tell the user and let them decide. Never pass `--yes` to get past an orphan prompt you haven't shown the user.
138
+ 4. Don't change an existing key's helper type (text → richtext). Add a new key and remove the old one, following step 3.
139
+ 5. **Collection schemas:** adding a new `.optional()` property is safe. Renaming or removing properties, or tightening types, can invalidate existing entries, so ask first. Removing a collection orphans it; its entries are preserved.
140
+ 6. Changing a collection `route` changes live URLs. Tell the user so they can add redirects in the dashboard.
141
+
142
+ ## Current limitations (don't work around silently)
143
+ - `f.select` options can't be declared in code: the extractor reads only `defaultValue`. Ask the user how options should be managed rather than inventing an API.
144
+ - `defineCollection({ group: '…' })` is read by the extractor, but it isn't in the TypeScript type yet, so it fails `astro check`. Leave it out unless the user asks.
145
+ - Collection initial data is declared via `defineCollection({ seed: [...] })`. Seeds must be static literals or same-file `const` array literals; `cms push` inserts them once into empty, never-seeded collections, after which dashboard edits own the content. Seeded content is not live until a dashboard publish — a rebuild/redeploy doesn't publish, and deploy tokens can't either. `cms push` keeps warning (`Unpublished: ...`) until someone publishes.
@@ -0,0 +1,78 @@
1
+ # Field helper reference
2
+
3
+ All helpers exist on `f`, on any `f.scope(...)`, on bound group accessors, and on `f.entryScope(...)`.
4
+
5
+ | Helper (alias) | Stored type | Value shape | Component renders |
6
+ | --- | --- | --- | --- |
7
+ | `f.text` | `text` | `string` | `<span>` (override with `as`) |
8
+ | `f.number` | `number` | `number` | `<span>` |
9
+ | `f.toggle` | `toggle` | `boolean` | `<span>true</span>`, so use the function form for logic |
10
+ | `f.select` | `select` | `string` | `<span>` |
11
+ | `f.img` (`f.image`) | `image` | `CmsAsset` `{ url, alt?, width?, height?, focal_x?, focal_y? }` or URL string | `<img>` with `src`/`alt`/`width`/`height` filled from the asset |
12
+ | `f.vid` (`f.video`) | `video` | asset `{ url, poster?, width?, height? }` or URL string | `<video controls>` |
13
+ | `f.richText` (`f.richtext`) | `richtext` | ProseMirror JSON doc | `<div>` of sanitized HTML |
14
+ | `f.videoList` | `media.videoList` | `Array<{ src, poster?, caption?, href? }>` | function form only |
15
+ | `f.geoArea` | `geo.area` | GeoJSON `Polygon` | function form only |
16
+ | `f.address` | `address` | `{ line1, city, region, postal, country, lat?, lng? }` | function form only |
17
+ | `f.addressList` | `address.list` | `Array<address>` | function form only |
18
+
19
+ "Function form only" means the component form would stringify an object. Read the value with the function form and render it yourself:
20
+ ```astro
21
+ ---
22
+ const area = f.scope('home.serviceArea');
23
+ const locations = area.addressList('locations', { defaultValue: [] });
24
+ ---
25
+ <ul data-cms-field={area('locations')} data-cms-kind="address.list">
26
+ {locations.map((l) => <li>{l.line1}, {l.city}</li>)}
27
+ </ul>
28
+ ```
29
+
30
+ ## Images
31
+ - The simple approach is `<hero.img k="image" class="hero-img" />`. Asset `alt`/`width`/`height` apply unless you pass them explicitly, and an explicit `alt` **overrides** the editor's alt text, so don't hard-code it.
32
+ - For a default when nothing is stored, pass an object so it carries alt text: `defaultValue={{ url: '/img/hero.jpg', alt: 'Van in driveway' }}`. With an `astro:assets` import, use `{ url: heroImg.src, width: heroImg.width, height: heroImg.height, alt: '…' }`.
33
+ - For responsive srcsets, CDN transforms, and editor focal points, use `<CmsImage>`:
34
+ ```astro
35
+ ---
36
+ import CmsImage from '@407dev/cms-astro/CmsImage.astro';
37
+ import { f } from '@407dev/cms-astro';
38
+ const hero = f.scope('home.hero');
39
+ const image = hero.img('image');
40
+ const alt = typeof image === 'object' && image ? (image.alt ?? '') : '';
41
+ ---
42
+ {image && (
43
+ <CmsImage src={image} alt={alt} widths={[640, 1280, 1920]} sizes="100vw"
44
+ data-cms-field={hero('image')} data-cms-kind="image" />
45
+ )}
46
+ ```
47
+
48
+ ## Rich text
49
+ - Allowed nodes: `paragraph`, `heading`, `bulletList`, `orderedList`, `listItem`, `blockquote`, `codeBlock`, `hardBreak`, `horizontalRule`, `text`, `image`. Allowed marks: `bold`, `italic`, `underline`, `strike`, `code`, `link` (hrefs are scheme-checked). Anything else is dropped.
50
+ - A `defaultValue` must be a ProseMirror doc:
51
+ ```js
52
+ { type: 'doc', content: [{ type: 'paragraph', content: [
53
+ { type: 'text', text: 'Serving Tulsa ' },
54
+ { type: 'text', text: 'since 1998', marks: [{ type: 'bold' }] },
55
+ ] }] }
56
+ ```
57
+ - To render a stored value with custom components per node, use `<RichText value={doc} components={{ heading: MyHeading }} />` from `@407dev/cms-astro/RichText.astro`.
58
+ - For short copy with a little inline emphasis, prefer splitting it into text fields over using rich text. Rich text lets editors add arbitrary blocks.
59
+
60
+ ## Collection seeds
61
+ - Declared in `defineCollection({ seed: [...] })` as an array of `{ slug: string, content: z.input<TSchema> }`.
62
+ - **Literal constraint:** `seed` must be a static JSON literal (strings, numbers, booleans, null, arrays, objects, no-substitution templates) or an identifier referencing a same-file `const` array literal. Calls, `.map()`, spreads, and imported identifiers are rejected with `seed must be a literal`.
63
+ - **Seed-once semantics:** `cms push` inserts seed entries once only when the collection has never been seeded (`collections.seeded_at IS NULL`) and has zero entries. Once seeded, `seeded_at` is recorded and the dashboard owns all content. Seeds are never re-applied, even if an editor deletes every item, and seeds are never a render-time fallback.
64
+ - **Seeding is not publishing:** seeded entries land as `published` in the draft tables, but the live site only ever serves the last published `site_versions` snapshot. A rebuild or redeploy re-reads that old snapshot — it never publishes. Deploy tokens can't publish either, so ask the user to publish from the dashboard. `cms push` warns with `Unpublished: ...` on every push while any collection has published content missing from the live bundle, not just the push that seeded it.
65
+
66
+ ## Diagnostics
67
+ | `cms check` message | Fix |
68
+ | --- | --- |
69
+ | `Field key in '…' must be a static string literal` | Use a literal key. For per-item data, use a collection. |
70
+ | `JSX attribute 'k' on '<…>' must be a static string literal` | `k="heading"`, not `k={name}` |
71
+ | `Field helper '…' called without a key` | Pass `k="…"` or a first string argument. |
72
+ | `Scope '…' declared without a prefix` / `Scope prefix … must be a static string literal` | `f.scope('home.hero')` with a literal |
73
+ | `Group '…' instantiated without a scope prefix` / `with dynamic non-literal scope` | `Hero('home.hero')` with a literal |
74
+ | `Group '…' field '…' does not reference a known field helper … defaulting to 'text'` | Shape values must be `f.text`, `f.img`, and so on |
75
+ | `… is imported across multiple hops …` | Import the group or collection from its defining file, not a barrel |
76
+ | `Collection defined without a static string key` | `defineCollection({ key: 'blog', … })` |
77
+ | `seed must be a literal` | Provide `seed` as a static array literal or a same-file `const` array literal without spreads, `.map`, or calls. |
78
+ | `Package '…' export '…' lacks a 'source' condition …` | A factory imported from a package can't be statically resolved. Define it in the site source instead. |
@@ -0,0 +1,80 @@
1
+ ---
2
+ name: 407dev-cms-keying
3
+ description: Convert an existing Astro site's hard-coded copy, images, and repeated content into 407dev CMS fields, groups, and collections ("keying" a site) without changing what it renders. Use when the user asks to make an existing site editable, CMS-enable or key pages, move hard-coded content or Markdown/content collections into the CMS, or wire a handed-off site to the CMS.
4
+ ---
5
+
6
+ # 407dev CMS: key an existing site
7
+
8
+ Goal: everything the client should be able to edit comes from the CMS, the built site renders the same text as before any content is entered, and `cms check` is clean.
9
+
10
+ Prerequisites:
11
+ - `cmsAstro` is in `astro.config.*` and `.env` has `CMS_SITE_ID` from `cms link`. If not, follow `407dev-cms-setup` first.
12
+ - Load `407dev-cms-fields` for the rules (naming, helpers, extraction limits). This skill is the workflow.
13
+
14
+ ## 1. Snapshot the baseline
15
+ ```sh
16
+ pnpm astro build
17
+ BASELINE="$(mktemp -d)/dist" && cp -r dist "$BASELINE" && echo "$BASELINE"
18
+ ```
19
+ Keep the printed path for step 5.
20
+
21
+ ## 2. Inventory and plan keys before editing
22
+ Read `src/layouts`, `src/pages`, `src/components`, any `src/content/` + `src/content.config.ts`, and data files (`src/data/*`). Build a key map (file → content → construct → key) with these rules:
23
+
24
+ - **Key it:** headings, body copy, CTA labels, taglines, contact details, hours, prices, testimonials, team bios, and images the client would swap.
25
+ - **Leave it in code:** markup and structure, class names, icons, routes and nav hrefs, form field names, analytics or third-party ids, and anything the code branches on. Ask before keying legal copy or navigation.
26
+ - **Layout chrome** → `site.*`, `nav.*`, `footer.*`.
27
+ - **Page copy** → `<page>.<section>.<field>`, where page is the route name (`home`, `about`, `pricing`) and section is the visual block.
28
+ - **Components that take copy props** (`<Hero title=… subtitle=…>`) → an `f.group`. Replace the copy props with a `content: BoundGroupAccessor<…>` prop and instantiate the group once per page prefix.
29
+ - **Arrays mapped in templates** (features, FAQs, testimonials, team) → a collection. Omit `route` when items have no page, and set `orderable: true` when order matters.
30
+ - **Astro content collections / Markdown** → a CMS collection with the same frontmatter fields as schema properties, plus `body: z.any()` with `bodyField: 'body'`. Replace `astro:content`'s `getCollection`/`getEntry` with `getEntries`/`getEntry`/`collectionPaths` from `@407dev/cms-astro`.
31
+
32
+ If the site has more than a handful of pages, show the key map to the user before editing. Keys are permanent, and renaming them later orphans content.
33
+
34
+ ## 3. Key page by page
35
+ Order: layout first, then one page at a time, running `pnpm exec cms check` after each.
36
+
37
+ Before:
38
+ ```astro
39
+ <h1 class="hero-title">Plumbing you can trust</h1>
40
+ <img src="/img/hero.jpg" alt="Van in driveway" class="hero-img" />
41
+ ```
42
+ After:
43
+ ```astro
44
+ ---
45
+ import { f } from '@407dev/cms-astro';
46
+ const hero = f.scope('home.hero');
47
+ ---
48
+ <hero.text k="heading" as="h1" class="hero-title" defaultValue="Plumbing you can trust" />
49
+ <hero.img k="image" class="hero-img" defaultValue={{ url: '/img/hero.jpg', alt: 'Van in driveway' }} />
50
+ ```
51
+ - Move the element's tag (`as`) and attributes onto the helper so the markup doesn't gain a wrapper `<span>`.
52
+ - Copy the current text into `defaultValue` verbatim. That's what keeps the output identical until the client edits it.
53
+ - Copy with inline markup (`<strong>`, links): split it into several text fields, or use a `richtext` field with a ProseMirror `defaultValue` (see `407dev-cms-fields` references).
54
+ - `<title>` and meta tags: use the function form, `site.text('title', { defaultValue: '…' })`.
55
+
56
+ ## 4. Collections and seed data
57
+ - Move the existing hard-coded array into `defineCollection({ seed: [...] })` (typed against the collection schema). `seed` must be a static literal or a same-file `const` array literal.
58
+ - `cms push` inserts seed entries **once** into empty, never-seeded collections. After that, dashboard edits take over and seeds are never re-applied.
59
+ - Workflow: push manifest (`cms push`) → publish in dashboard → deploy. Built output remains identical before and after keying.
60
+ - The publish step is mandatory: seeded entries are `published` in the draft tables, but the live site serves only the last published snapshot, so a rebuild/redeploy without publishing leaves seeded content missing. Deploy tokens can't publish — ask the user to. `cms push` warns (`Unpublished: ...`) until they do.
61
+
62
+ ## 5. Verify
63
+ ```sh
64
+ pnpm exec cms check
65
+ pnpm astro build
66
+ strip() { sed -E 's/ data-cms-[a-z-]+="[^"]*"//g; s/ data-astro-cid-[a-z0-9]+//g' "$1"; }
67
+ (cd "$BASELINE" && find . -name '*.html') | while read -r p; do
68
+ diff -q <(strip "$BASELINE/$p") <(strip "dist/$p") >/dev/null 2>&1 || echo "changed: $p"
69
+ done
70
+ ```
71
+ - Inspect every `changed:` file. Expected differences are CSS asset hashes. Anything else means a default was lost or markup changed, so fix it.
72
+ - `pnpm exec cms extract --json`: review the full key list for naming consistency and typos.
73
+ - `pnpm exec cms push --dry-run`, then `pnpm exec cms push`.
74
+ - `pnpm astro dev`: keyed elements should highlight in the CMS toolbar app, and un-keyed copy the client will expect to edit should be rare.
75
+
76
+ ## 6. Hand off
77
+ Report to the user:
78
+ - Keys added, per page
79
+ - Collections seeded (keys and entry counts)
80
+ - Anything deliberately left hard-coded, and why
@@ -0,0 +1,98 @@
1
+ ---
2
+ name: 407dev-cms-setup
3
+ description: Set up an Astro site with the 407dev CMS — scaffold a new Astro project or integrate an existing one, install @407dev/cms-astro and @407dev/cli, link the repo to a CMS site, and run the first manifest push, plus CI/deploy wiring. Use when the user asks to connect, add, install, bootstrap, or wire up the CMS (407dev, cms-astro, `cms link`, `cms push`) in a site repo, or when a repo has no `cmsAstro` integration yet.
4
+ ---
5
+
6
+ # 407dev CMS: site setup
7
+
8
+ The CMS is a content dictionary. Layout, components, and routing stay in code, and the CMS stores only editable values under string keys plus repeatable collection entries. A field helper call in a template both renders the value and declares the field. Load `407dev-cms-fields` before writing any keys.
9
+
10
+ ## 1. Detect the starting point
11
+ - **No Astro project:** `pnpm create astro@latest <dir> -- --template minimal --install --git --yes`. If the repo already uses npm, yarn, or bun (check the lockfile), use that manager everywhere below.
12
+ - **Existing Astro project:** confirm `astro` is major 5, which is the `@407dev/cms-astro` peer range. If it's older, stop and ask before upgrading.
13
+ - **Already has `@407dev/cms-astro` and `cmsAstro` in `astro.config.*`:** skip to step 4.
14
+ - Requires Node ≥ 20.12, because the config below uses `process.loadEnvFile`.
15
+
16
+ ## 2. Install
17
+ ```sh
18
+ pnpm add @407dev/cms-astro @astrojs/cloudflare
19
+ pnpm add -D @407dev/cli
20
+ ```
21
+ The CLI binary is `cms` (`pnpm exec cms --help`). Sites deploy to Cloudflare Pages, and preview deployments render on demand, so they need the Cloudflare adapter.
22
+
23
+ ## 3. Add the integration
24
+ Merge this into `astro.config.mjs`, keeping existing integrations and options:
25
+ ```js
26
+ import fs from 'node:fs';
27
+ import cloudflare from '@astrojs/cloudflare';
28
+ import { cmsAstro } from '@407dev/cms-astro';
29
+ import { defineConfig } from 'astro/config';
30
+
31
+ // Astro doesn't load .env into process.env at config time, and cmsAstro() reads process.env.
32
+ // Values already in the environment (CI, Pages) take precedence.
33
+ if (fs.existsSync('.env')) process.loadEnvFile('.env');
34
+
35
+ const isPreviewBuild = process.env.CMS_PREVIEW_BUILD === '1';
36
+ // Static output prerenders even under `astro dev`, which would skip the per-request draft bundle.
37
+ const isDevServer = process.argv.includes('dev');
38
+
39
+ export default defineConfig({
40
+ site: 'https://example.com', // the production origin; used for canonical URLs and sitemap.xml
41
+ output: isPreviewBuild || isDevServer ? 'server' : 'static',
42
+ adapter: cloudflare(),
43
+ integrations: [cmsAstro()],
44
+ });
45
+ ```
46
+ `cmsAstro()` takes no arguments. It reads `CMS_SITE_ID` and `CMS_CONTENT_URL`. Production builds emit `_redirects`, `robots.txt`, and `sitemap.xml` from CMS site settings.
47
+
48
+ ## 4. Log in and link
49
+ ```sh
50
+ pnpm exec cms login # opens the browser
51
+ pnpm exec cms link # pick the site; or: pnpm exec cms link --site <site-id>
52
+ ```
53
+ - If `cms link` reports no sites, **stop**. Ask the user to create the site in the CMS web dashboard (inside the right organization), then re-run `cms link`. Don't try to create sites from the CLI or API.
54
+ - `cms link` writes `CMS_SITE_ID`, `CMS_API_URL`, and `CMS_CONTENT_URL` into `.env` without touching other lines. Make sure `.env` and `.cms/` are in `.gitignore`.
55
+ - Optional: `CMS_PREVIEW_SECRET` in `.env` is only used locally during `astro dev` if you want local preview token minting. Deployed site hosts never hold preview secrets.
56
+
57
+ ## 5. Scaffold
58
+ - Create `src/cms/groups.ts` and `src/cms/collections.ts` (empty exports are fine). Groups and collections must live in files that pages import directly, with no barrel re-exports.
59
+ - Key one real field in the layout to prove the loop works:
60
+ ```astro
61
+ ---
62
+ import { f } from '@407dev/cms-astro';
63
+ const site = f.scope('site');
64
+ ---
65
+ <title>{site.text('title', { defaultValue: 'Current Site Title' })}</title>
66
+ ```
67
+ - Add these scripts to `package.json`, keeping existing ones:
68
+ ```json
69
+ "cms:check": "cms check",
70
+ "cms:push": "cms push"
71
+ ```
72
+ If there's a `lint` script, prefix it with `cms check && `.
73
+ - If the site already has hard-coded content to make editable, continue with `407dev-cms-keying` after step 6.
74
+
75
+ ## 6. Verify and push
76
+ ```sh
77
+ pnpm exec cms check # must report no errors
78
+ pnpm exec cms push --dry-run # shows what would be created
79
+ pnpm exec cms push
80
+ pnpm astro dev
81
+ ```
82
+ In the dev server:
83
+ - Unfilled keys without defaults render as `[site.title]`.
84
+ - The Astro dev toolbar shows a **CMS Content** app listing the page's fields.
85
+ - Against a non-local API, `cms login` (from step 4) is sufficient to enable inline toolbar edits; `CMS_DEV_TOKEN` is an optional override.
86
+
87
+ ## 7. CI and deploy
88
+ - The user creates a **deploy token** for the site in the dashboard. It's shown once and starts with `cms_dt_`. Store it as the CI secret `CMS_AUTH_TOKEN`. It only authorizes manifest pushes and migrations for that site.
89
+ - The pipeline order is `pnpm exec cms check` → `pnpm exec cms push --yes` → `pnpm astro build`. Use `--yes` only in CI, where orphan prompts can't be answered. Review orphans locally first.
90
+ - Cloudflare Pages env vars:
91
+ - Production: `CMS_SITE_ID`, `CMS_CONTENT_URL`.
92
+ - Preview deployment: `CMS_SITE_ID`, `CMS_CONTENT_URL`, `CMS_PREVIEW_BUILD=1`. (Site hosts hold zero preview secrets; content-worker verifies preview tokens directly).
93
+ - The Pages project name and branch are set on the site in the dashboard. Publishing in the CMS triggers the rebuild, so no webhook setup is needed in the repo.
94
+
95
+ ## Gotchas
96
+ - A missing or wrong `CMS_SITE_ID` doesn't fail the build: bundle fetch errors are swallowed and every field renders its default. If the content looks stale, check `.cms/bundle.json`.
97
+ - `cms push` skips unchanged manifests using `.cms/state.json`. `--force` pushes anyway.
98
+ - Preview responses must stay `Cache-Control: no-store` and `noindex`, which the integration's middleware already handles. Don't add caching middleware in front of `/_preview/*`.