@avocadostudio-ai/skills 0.17.0 → 0.18.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 CHANGED
@@ -18,7 +18,8 @@ CLAUDE.md only if absent
18
18
  ```
19
19
 
20
20
  Then ask your agent to add Avocado to the site. It will load `avocado`, which
21
- routes to one of `avocado-integrate`, `avocado-demo` or `avocado-blocks`.
21
+ routes to one of `avocado-integrate`, `avocado-demo`, `avocado-blocks` or
22
+ `avocado-cms`.
22
23
 
23
24
  ## Why a package and not a docs page
24
25
 
package/dist/index.js CHANGED
@@ -76,6 +76,7 @@ the job:
76
76
  | Wiring Avocado into this site | \`avocado-integrate\` |
77
77
  | A demo, or a fresh project | \`avocado-demo\` |
78
78
  | Declaring components as editable blocks | \`avocado-blocks\` |
79
+ | Content that comes from a CMS | \`avocado-cms\` |
79
80
 
80
81
  The rule those skills exist to protect: **a component's props are not editable
81
82
  until a schema declares them.** An undeclared prop cannot be reached by any
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/skills",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Install Avocado Studio's agent skills into a project, so a coding agent reads instructions that match the version you have",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -20,6 +20,7 @@ every path.
20
20
  | A Next.js app that already exists, with its own components and content | `avocado-integrate` |
21
21
  | Nothing yet, or wants to see it working before committing | `avocado-demo` |
22
22
  | Either, and you are now declaring their components as editable blocks | `avocado-blocks` |
23
+ | Either, and the content those blocks render comes from a CMS | `avocado-cms` |
23
24
 
24
25
  Before deciding, if the site is live, `npx avocado-scope <url>` will tell you
25
26
  what one of its pages would become as blocks without installing or writing
@@ -102,34 +102,132 @@ Avocado ships twenty built-in types: `Hero`, `FeatureGrid`, `Testimonials`,
102
102
  ## Marking up the renderer
103
103
 
104
104
  A declared field is editable in the panel. It is editable **in the preview**
105
- only once the element that renders it carries a marker:
105
+ only once the markup says where it is. Three attributes do that, all from
106
+ `@avocadostudio-ai/site-sdk/markers` — never from `/editor`, see the rule in the
107
+ `avocado` skill about the public bundle.
108
+
109
+ ### The block boundary comes first
110
+
111
+ ```tsx
112
+ import { getPreviewWrapperProps } from "@avocadostudio-ai/site-sdk/markers"
113
+
114
+ <section {...getPreviewWrapperProps(editorMode, blockId, "PricingTier")}>
115
+ ```
116
+
117
+ Selection is built entirely on `[data-block-id]`: a click in the preview
118
+ resolves through `closest("[data-block-id]")`, and no match is read as "clicked
119
+ outside any block", which *clears* the selection. Mark fields without this and
120
+ the preview frames, renders and scrolls correctly and deselects on every click
121
+ — which looks exactly like selection mode being switched off. It returns `{}`
122
+ when `editorMode` is false, so the published page renders the markup it
123
+ rendered before.
124
+
125
+ ### Then each field
106
126
 
107
127
  ```tsx
108
128
  import { editableProps } from "@avocadostudio-ai/site-sdk/markers"
109
129
 
110
- export function PricingTier({ blockId, name, blurb }) {
130
+ export function PricingTier({ blockId, name, blurb, photoUrl }) {
111
131
  return (
112
- <section>
113
- <h3 {...editableProps(blockId, "name")}>{name}</h3>
114
- <p {...editableProps(blockId, "blurb")}>{blurb}</p>
132
+ <section {...getPreviewWrapperProps(editorMode, blockId, "PricingTier")}>
133
+ <h3 {...editableProps("name")}>{name}</h3>
134
+ <p {...editableProps("blurb", { kind: "richtext" })}>{blurb}</p>
135
+ <div {...editableProps("photoUrl", { kind: "image" })}>
136
+ <img src={photoUrl} alt="" />
137
+ </div>
115
138
  </section>
116
139
  )
117
140
  }
118
141
  ```
119
142
 
120
- Import from `/markers`, never from `/editor` — see the rule in the `avocado`
121
- skill about the public bundle.
143
+ The signature is `editableProps(path, { label?, kind? })` — **one path, then
144
+ options**. It is not `editableProps(blockId, field)`: the block id belongs to
145
+ the wrapper above, and passing it here marks a field literally named after the
146
+ id.
147
+
148
+ - **The path is the same grammar an operation uses** — `title`,
149
+ `cards[0].title`, `links[0].children[1].label` — because it is the same path.
150
+ What the overlay reads here is what it sends back as the field to patch.
151
+ - **Pass `kind`.** Without it the overlay guesses an image from the prop name
152
+ against Avocado's own naming (`imageUrl`, `*.src`), so a field called
153
+ `photoUrl` or `heroSrc` gets a picker in the property panel and no button in
154
+ the preview, with no error on either side. Pass the same word the manifest
155
+ uses and the guess never runs.
156
+ - **For an image, mark the wrapper, not the `<img>`.** The overlay appends its
157
+ Change button *into* the marked element, and nothing can be appended into an
158
+ `<img>`.
159
+ - **The marker goes on the element that renders the text**, not on its wrapper.
160
+
161
+ ### Rows drawn by their own component need a scope
162
+
163
+ The path is scoped from the block down, which a renderer can only write if it
164
+ knows where it sits. That holds while one component draws the whole block and
165
+ stops the moment a list row is a component of its own: the child knows it has a
166
+ `question` and cannot know it is `items[3]`.
167
+
168
+ ```tsx
169
+ import { editableScopeProps } from "@avocadostudio-ai/site-sdk/markers"
170
+
171
+ <div className="flex flex-col gap-6">
172
+ {props.items.map((item, i) => (
173
+ <div key={item.id} {...editableScopeProps(`items[${i}]`, { display: "contents" })}>
174
+ <FaqRow item={item} /> {/* marks a bare "question"; needs no prefix */}
175
+ </div>
176
+ ))}
177
+ </div>
178
+ ```
179
+
180
+ Forgetting the scope is silent and **wrong**, not silent and absent: the child
181
+ marks a bare `question`, the overlay resolves it against the enclosing block,
182
+ and an edit to a headline inside a column patches a prop the section does not
183
+ have. Be systematic — every renderer, every field.
184
+
185
+ Pass `{ display: "contents" }` whenever the wrapper exists only to carry the
186
+ scope. Without it the wrapper becomes the flex or grid item and the layout the
187
+ rows had silently becomes the layout of a column of wrappers: gaps land in
188
+ different places, `align-items` applies to the wrong box, and a grid's rows stop
189
+ being the grid's children at all. Leave it off when the wrapper is one you were
190
+ going to render anyway — a row that already has an `<li>` or a card `<div>`
191
+ should carry the scope on that rather than gain a second element to hold it.
192
+
193
+ Scopes nest and compose outermost first. A block boundary ends the composition,
194
+ so a scope outside a block never reaches into it.
122
195
 
123
196
  Two things this costs on a real site, so plan for them: subcomponents factored
124
- for rendering often do not know their own position and need a path argument
125
- threaded in, and the marker has to go on the element that actually renders the
126
- text, not its wrapper.
197
+ for rendering often do not know their own position and need either a scope
198
+ wrapper or a path threaded in, and marking up a design system is the long pole
199
+ of the whole integration.
127
200
 
128
- ## Verify on a number
201
+ ## Verify on numbers
202
+
203
+ `@avocadostudio-ai/site-sdk/coverage` exports both halves. Run both and report
204
+ the figures — "it builds" is not a result, because a site can build perfectly
205
+ with every field unreachable.
206
+
207
+ ```ts
208
+ import {
209
+ extractMarkedBlocks, editableCoverage, formatEditableCoverage,
210
+ panelCoverage, formatPanelCoverage,
211
+ } from "@avocadostudio-ai/site-sdk/coverage"
212
+ ```
129
213
 
130
- `@avocadostudio-ai/site-sdk/coverage` measures which declared fields a rendered
131
- page actually marks. Run it and report the figure. "It builds" is not a result:
132
- a site can build perfectly with every field unreachable.
214
+ - **`editableCoverage`** reports `marked/expected` — of the fields the manifest
215
+ declares, how many the rendered page actually carries a marker for.
216
+ - **`panelCoverage`** reports `rowsLabelled/rowsExamined` plus findings: list
217
+ rows nobody can tell apart, polymorphic branches that never narrow, props in
218
+ the content that nothing describes, and type names colliding with the
219
+ built-ins.
220
+
221
+ **Target: 100% editable coverage on every page, and zero panel findings.** If
222
+ you cannot reach it, do not quietly stop — list every remaining gap with the
223
+ block type, the field path and why.
224
+
225
+ Markers are emitted only on an **editor render**. Run the check against
226
+ `next dev` through the editor path: pointed at a production build it finds no
227
+ blocks and reports zero, which reads exactly like an integration that marks
228
+ nothing. When building the page URL, use `new URL(page.slug, origin)` — a slug
229
+ already starts with `/`, and string-joining it onto the origin fetches
230
+ `http://localhost:3000//` and measures nothing.
133
231
 
134
232
  Cross-check the manifest directly too — `GET /api/editor/blocks` should list
135
233
  exactly the site's types, with the expected `fields` and `listFields` on each.
@@ -0,0 +1,212 @@
1
+ ---
2
+ name: avocado-cms
3
+ description: Make a CMS-backed site editable in Avocado Studio — the field table, the lens that projects documents into block props and merges edits back, the Storyblok and Sanity primitive packs, and the write rules that stop a publish corrupting content. Use when the site's content lives in Sanity, Storyblok, Contentful, Strapi or another headless CMS rather than in files.
4
+ ---
5
+
6
+ # Editing a CMS through Avocado
7
+
8
+ Read the `avocado` skill first. Load this once the survey in `avocado-integrate`
9
+ has established that content comes from a CMS rather than from files.
10
+
11
+ The job is one declaration, not four. A CMS-backed integration has to agree
12
+ about the same fields in four places — the Zod schema, the panel metadata, the
13
+ projection out of the CMS, and the merge back into it. Written by hand they
14
+ drift, and every way they drift is silent. Declare a **field table** and derive
15
+ all four.
16
+
17
+ **Writes go back into somebody's production content.** More of this page is
18
+ about not corrupting it than about making it work, and that ratio is correct.
19
+
20
+ ## 1. The table
21
+
22
+ Key it by the CMS's own name for each type — `hero_section`, not
23
+ `SiteHeroSection`. `BlockType` is a free string, and one name across the CMS,
24
+ the manifest and a publish diff makes the adapter an identity map rather than a
25
+ translation nobody can grep for.
26
+
27
+ ```ts
28
+ // avocado/table.ts
29
+ import type { FieldTable } from "@avocadostudio-ai/site-sdk/lens"
30
+
31
+ export const TABLE: FieldTable = {
32
+ hero_section: {
33
+ displayName: "Hero",
34
+ topLevel: true,
35
+ fields: {
36
+ title: { kind: "text" },
37
+ body: { kind: "richtext" },
38
+ background_image: { kind: "image", label: "Background" },
39
+ cta_link: { kind: "link" },
40
+ alignment: { kind: "enum", options: ["left", "center"] },
41
+ anchor: { kind: "text", localized: false },
42
+ buttons: { kind: "list", of: ["button"] },
43
+ },
44
+ },
45
+ button: {
46
+ displayName: "Button",
47
+ topLevel: false,
48
+ fields: { label: { kind: "text" }, variant: { kind: "enum", options: ["solid", "outline"] } },
49
+ },
50
+ }
51
+ ```
52
+
53
+ - **`topLevel: false` for row types.** A card or a button is declared so the
54
+ panel can draw it and the merge can construct it, and must never appear in the
55
+ block picker.
56
+ - **A list is `of` or `itemFields`, never both.** `of` names child types that
57
+ are their own documents and carry their own type key; `itemFields` describes a
58
+ row that is a plain object. Every type named in `of` must itself be in the
59
+ table, and the pack must supply a `rowTypeKey`.
60
+ - **A field the table does not declare is invisible to Avocado and untouched by
61
+ it.** It never reaches the planner or the panel, and it survives every publish
62
+ because the merge patches the source document rather than replacing it. That
63
+ is the lever for scope: declare what an editor should change, leave the layout
64
+ and behaviour switches out.
65
+ - **`localized: false`** marks a field that has one value for every language —
66
+ an anchor, a slug fragment, an icon name. It is not decoration: without it the
67
+ lens looks for a per-language value and writes one.
68
+
69
+ ## 2. Register it with `registerLens`, not `registerFieldTable`
70
+
71
+ ```ts
72
+ // avocado/lens.ts
73
+ import { createLens, registerLens } from "@avocadostudio-ai/site-sdk/lens"
74
+ import { storyblokPrimitives, storyblokLocale } from "@avocadostudio-ai/site-sdk/lens/storyblok"
75
+ import { TABLE } from "./table"
76
+
77
+ export const lens = createLens({
78
+ table: TABLE,
79
+ locale: storyblokLocale("de", ["de", "en", "fr"]),
80
+ primitives: storyblokPrimitives(),
81
+ })
82
+
83
+ registerLens(lens)
84
+ ```
85
+
86
+ Sanity is the same with `sanityPrimitives()` and `sanityLocale(…)` from
87
+ `@avocadostudio-ai/site-sdk/lens/sanity`.
88
+
89
+ **Prefer `registerLens(lens)`.** The older two-call form —
90
+ `registerFieldTable(TABLE, { primitives })` beside `createLens({ …, primitives })`
91
+ — is two declarations that have to agree, and when they disagreed about what an
92
+ image field's two props are called, the panel drew `image` / `image_alt` while
93
+ the projection emitted `imageUrl` / `imageAlt`, and every image on the site was
94
+ both invisible and uneditable with nothing erroring. `registerLens` takes the
95
+ table *and* the primitives off the lens, so it cannot disagree with itself.
96
+
97
+ `registerFieldTable` now refuses a table that declares an image when no image
98
+ naming was given, so the old shape fails loudly rather than silently. It remains
99
+ correct for a table with **no lens** — a site registering blocks without
100
+ projecting a CMS through them. If you are writing `createLens`, use
101
+ `registerLens`.
102
+
103
+ For a CMS with no pack, write the primitives yourself — do not skip the table
104
+ and hand-roll a projection. A pack supplies the codecs, the image naming, the
105
+ row id and type keys, and the locale lens.
106
+
107
+ ## 3. Read and write through the lens
108
+
109
+ ```ts
110
+ const props = lens.project(doc, "hero_section", "fr")
111
+ const { doc: next, changed, warnings } = lens.merge(doc, editedProps, "hero_section", "fr")
112
+ ```
113
+
114
+ **`merge` takes the LIVE CMS document as its source**, never a snapshot Avocado
115
+ holds. That is what makes every undeclared field survive by construction.
116
+ Preserve the property. If you find yourself writing a whole document out of
117
+ Avocado props, stop and ask the user.
118
+
119
+ Two write rules govern every field, and neither was reasoned out from the
120
+ shapes — both came from running a projection through its own inverse over a real
121
+ dataset:
122
+
123
+ - **Unchanged means untouched.** A CMS with per-language fallback resolves a
124
+ missing translation to the default language: correct on screen, a lie in
125
+ storage. Merge a projection back wholesale and every fallback becomes a real,
126
+ fabricated translation — dozens per publish, each identical to what the page
127
+ already showed, so nothing looks wrong.
128
+ - **Empty means absent.** Writing `""` into a slot that had no value for this
129
+ language is a no-op on screen and a diff in the document, so a publish that
130
+ touched one field reports every page as modified.
131
+
132
+ A **`reference`** field is a pointer, not a link. The href is a *render* of it —
133
+ the same stored reference serves `/faq` and `/fr/faq` — so a projection can
134
+ never be its own inverse, and writing the rendered href back replaces the
135
+ reference with a hard-coded URL that stops following renames. Declare it
136
+ `reference`, not `link`.
137
+
138
+ ## 4. Prove the round trip before anyone publishes
139
+
140
+ ```ts
141
+ const { clean, fields, warnings } = lens.roundTrip(doc, "hero_section", "fr")
142
+ ```
143
+
144
+ Project, merge the projection straight back, and report what moved. **Nothing
145
+ should.** A non-empty `fields` is a codec that is not the inverse of itself for
146
+ some value in *this* dataset — invisible in the editor, harmless in the preview,
147
+ and visible later as a publish wanting to rewrite documents nobody opened.
148
+
149
+ Run it over **real content, not fixtures**, across every type in the table and
150
+ every language. Every rule above exists because a fixture round-tripped and a
151
+ dataset did not. Report the count of types checked and the fields that moved.
152
+
153
+ ## 5. Never read a write path through a visual-editing client
154
+
155
+ If the site already uses the CMS's visual-editing tooling — Sanity's
156
+ Presentation tool, Contentful's Content Source Maps, the Storyblok bridge — its
157
+ draft client **stega-encodes** every string: hundreds of zero-width characters
158
+ encoding which field produced the text. Invisible everywhere a human looks.
159
+
160
+ Avocado's path is adapter read → session draft → planner edit → publish diff →
161
+ **write back**, so an encoded string poisons four things at once: the planner
162
+ reasons over text that is mostly invisible padding, the panel shows text that
163
+ looks right and is not, the publish diff compares an encoded string against a
164
+ clean one and reports **every field on the site as changed**, and a real publish
165
+ writes the markers into the dataset as real characters.
166
+
167
+ The client an integrator reaches for first is the one already lying around, and
168
+ that is the wrong one. Give the adapter a **second, machine-read client** — same
169
+ token, same `perspective: "drafts"`, `stega: false` — and keep the encoded one
170
+ for rendering:
171
+
172
+ ```ts
173
+ const avocadoClient = client.withConfig({ stega: false })
174
+ ```
175
+
176
+ The rule is category-level: **a perspective read that feeds a write path must be
177
+ the machine read, never the visual-editing read.**
178
+
179
+ ## 6. One page per (document × language)
180
+
181
+ A field-level-i18n CMS stores every language inside one document; Avocado edits
182
+ one page at a time. So the adapter presents one `PageDoc` per document *and*
183
+ language, and the lens is told which language it is projecting. Keep the
184
+ language in the page id and slug so a publish can route the merge back to the
185
+ right slot, and never let two languages of the same document share a page id.
186
+
187
+ Read `https://docs.avocadostudio.dev/integration/multilingual` before writing a
188
+ single CMS write path. A wrong projection corrupts content invisibly.
189
+
190
+ ## Verify
191
+
192
+ Report all of these as numbers, not as "it works":
193
+
194
+ 1. `lens.roundTrip` clean over real content — how many types and languages, and
195
+ every field that moved.
196
+ 2. `GET /api/editor/blocks` lists exactly the table's `topLevel` types, with the
197
+ `fields` and `listFields` the table implies.
198
+ 3. A publish diff on an untouched page is **empty**. A diff that reports
199
+ everything changed is the stega symptom in section 5, or a codec that is not
200
+ its own inverse.
201
+ 4. The coverage figures from `avocado-blocks` — the table gives the manifest,
202
+ the renderers still have to carry the markers.
203
+
204
+ ## What not to do
205
+
206
+ - Do not hand-write the projection or the merge beside a table. Derive both.
207
+ - Do not pass the site's visual-editing client to the adapter.
208
+ - Do not write a whole document back from Avocado props.
209
+ - Do not declare a `reference` as a `link`.
210
+ - Do not skip `roundTrip` because the fixtures pass. Fixtures are how these bugs
211
+ got shipped.
212
+ - Do not report success on a publish diff nobody read.
@@ -105,6 +105,15 @@ Load `avocado-blocks` and follow it. On an existing site the block names are
105
105
  usually already decided by the stored content, which is the case that skill's
106
106
  "names that collide with the built-ins" section is about.
107
107
 
108
+ **If step 1 found a CMS, load `avocado-cms` as well, and before writing any
109
+ write path.** A CMS-backed site declares one field table and derives the schema,
110
+ the panel metadata, the projection and the merge from it, rather than
111
+ hand-writing four things that have to agree. That skill also carries the rules
112
+ that stop a publish corrupting the user's content — chief among them that a
113
+ perspective read feeding a write path must never be the CMS's visual-editing
114
+ client, whose strings carry invisible stega markers that get written back into
115
+ the dataset as real characters.
116
+
108
117
  ## 4. Decide about the page route — carefully
109
118
 
110
119
  `createSitePage` from `@avocadostudio-ai/site-sdk/page` is a full page factory:
@@ -138,17 +147,79 @@ the First Load JS before and after: an unchanged number is the expected result.
138
147
  If it jumped by tens of kilobytes, something on a public page imported from
139
148
  `/editor`.
140
149
 
141
- ## 6. Verify on numbers
150
+ ## 6. Register the site with the orchestrator
151
+
152
+ With the user's dev server running, from the project directory:
153
+
154
+ ```bash
155
+ npx avocado-register --name "My Site" --orchestrator http://localhost:3000/api/avocado
156
+ ```
157
+
158
+ **Pass `--orchestrator`.** The default is `http://localhost:4200`, which is the
159
+ *standalone* server and not what a library-mode project runs. Omitting it is the
160
+ single most common way this step goes wrong: it either cannot connect, or it
161
+ registers against whatever else is on that port.
162
+
163
+ The command does two separable things. Locally, it generates a
164
+ `DRAFT_MODE_SECRET` into `.env.local` if there is not one and fills in
165
+ `NEXT_PUBLIC_DEFAULT_SITE_ID`, `NEXT_PUBLIC_SITE_NAME` and
166
+ `NEXT_PUBLIC_EDITOR_ORIGIN` — that half always runs. Then it POSTs the site
167
+ config to `/sites/register` and writes `ORCHESTRATOR_URL` once that POST has
168
+ been answered, because an address nothing replied at is a guess. Other flags:
169
+ `--id`, `--port`, `--secret`, `--session`, `--purpose`, `--preview-url`,
170
+ `--token`.
171
+
172
+ If it reports that it could not reach an orchestrator, that is a report and not
173
+ a failure — it exits 0 and the local half has already happened. Re-run with the
174
+ right `--orchestrator` to finish the registry entry. Either way, do not register
175
+ by hand-editing `.env.local`.
176
+
177
+ **One `siteId`, spelled identically in three places:** `createSitePage` (or
178
+ whatever supplies the page), the `createOrchestrator` mount, and
179
+ `avocado-register --id`. When they disagree the page asks for a draft session
180
+ the orchestrator never seeded, which looks like the editor showing published
181
+ content for no reason.
182
+
183
+ Registration is optional in library mode — the mount already knows the one site
184
+ it is in and reports it from `GET /sites`. What registration adds is the name,
185
+ preview URL and purpose in the registry. Do not report the step as done if it
186
+ printed `The site is NOT registered`; say which URL it tried.
187
+
188
+ ## 7. Verify on numbers
142
189
 
143
190
  Do all of these and report the figures:
144
191
 
145
192
  1. `next build` succeeds, and the route list still shows the site's own pages.
146
193
  2. `GET /api/editor/blocks` lists exactly the site's types, with the expected
147
194
  `fields` and `listFields`.
148
- 3. The field coverage figure from `@avocadostudio-ai/site-sdk/coverage`.
195
+ 3. **Both coverage figures**, from `@avocadostudio-ai/site-sdk/coverage`:
196
+ `editableCoverage` per page and `panelCoverage` overall. The target is 100%
197
+ editable coverage on every page and zero panel findings. If you cannot reach
198
+ it, list every remaining gap with the block type and the field path — do not
199
+ quietly stop. Run it against `next dev` through the editor path: markers are
200
+ emitted only on an editor render, so a production build reports zero and
201
+ reads exactly like an integration that marks nothing.
149
202
  4. A page still renders the site's own markup — diff the HTML against the
150
203
  pre-integration build if you can.
151
- 5. An unknown slug still answers a real 404.
204
+ 5. Every migrated page is served by the catch-all and not by a leftover route
205
+ file: its HTML under `next dev` carries `data-block-id`. "The URL still
206
+ works" does not prove this — a shadowing route makes it work by serving the
207
+ old component.
208
+ 6. An unknown slug still answers a real 404, and each page has its own
209
+ `<title>` and description.
210
+
211
+ And the four security seams, which are the reason the routes are not
212
+ hand-written — check them rather than assuming the handler did:
213
+
214
+ - `GET /api/editor/draft?secret=<secret>&redirect=/` is a 307 and sets the
215
+ draft cookie.
216
+ - The same call with a wrong secret is rejected.
217
+ - The same call with `redirect=https://evil.example` does **not** redirect
218
+ off-site.
219
+ - `POST /api/editor/publish` with `{"pages":[]}` answers 409 and leaves the
220
+ content store untouched.
221
+
222
+ No `DRAFT_MODE_SECRET` or `PUBLISH_TOKEN` value appears in any committed file.
152
223
 
153
224
  ## What not to do
154
225