@avocadostudio-ai/site-sdk 0.4.0 → 0.5.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
@@ -10,6 +10,25 @@ SDK for integrating any Next.js site with the Avocado Studio. Provides the contr
10
10
  npm install @avocadostudio-ai/site-sdk
11
11
  ```
12
12
 
13
+ That is enough to render a site and serve the editor API. **Library mode — the
14
+ orchestrator running inside your Next app — needs two more:**
15
+
16
+ ```bash
17
+ npm install @avocadostudio-ai/orchestrator-core better-sqlite3
18
+ ```
19
+
20
+ `orchestrator-core` is an *optional* peer dependency, which means no package
21
+ manager installs it for you, and `@avocadostudio-ai/site-sdk/server` — where
22
+ `createOrchestrator` lives — is a hard import of it.
23
+
24
+ Nothing else. In particular you do **not** add `@avocadostudio-ai/shared` to
25
+ describe your own blocks: `registerBlock`, `z` and the block-meta types are
26
+ re-exported from [`@avocadostudio-ai/site-sdk/blocks`](#registering-your-own-block-schemas).
27
+ The SDK does depend on `shared`, `blocks`, `preview-adapter` and `zod`, but a
28
+ dependency of a dependency is not a specifier your own source may import —
29
+ under pnpm those live in `.pnpm/`, where the SDK can reach them and your site
30
+ cannot.
31
+
13
32
  ### 2. Wrap your Next config
14
33
 
15
34
  ```ts
@@ -132,7 +151,33 @@ DRAFT_MODE_SECRET=<random-secret>
132
151
  PUBLISH_TOKEN=<publish-auth-token>
133
152
  ```
134
153
 
135
- That's it. Your site now works with the AI editor.
154
+ ### That is not it six more steps, and each one fails silently
155
+
156
+ Steps 1–6 give you a site that renders `PageDoc` content and an editor API the
157
+ editor can read. They do not give you an editable site, and every gap below
158
+ presents as something *other* than a missing step:
159
+
160
+ | Still missing | What it looks like when you skip it |
161
+ |---|---|
162
+ | the proxy (Next 16) or middleware (Next 15) | `?__editor=1` never reaches the preview route, so the editor frames your **published** page and edits appear to do nothing |
163
+ | a `/preview-draft` route | nothing renders drafts; the iframe shows published content |
164
+ | `EditorOverlay` on that route | the page frames and renders, and does not respond to a click |
165
+ | `createOrchestrator` (library mode) | there is no orchestrator; the chat has nothing to talk to |
166
+ | `registerBlocks`, if you have your own blocks | everything looks wired up until the first AI edit returns `Unknown block type` |
167
+ | `editableProps` in your components | block selection works; not one *field* is editable |
168
+
169
+ In order:
170
+
171
+ - **[Library mode mounts two handlers, not one](#library-mode-mounts-two-handlers-not-one)** — `createOrchestrator` alongside the route in step 4, and both need the same `registerBlocks` and `blockTypes`.
172
+ - **[The editor frames your page, not the preview route](#the-editor-frames-your-page-not-the-preview-route)** — the proxy, and the CSP that lets the editor frame you at all.
173
+ - **[Registering your own block schemas](#registering-your-own-block-schemas)** — if your blocks are not Avocado's.
174
+ - **[Mounting the overlay](#mounting-the-overlay)** — and turning selection *on* in the editor, which defaults to off and makes a correct integration look inert.
175
+ - **[Marking up your components](#marking-up-your-components)** — the block wrapper first, then the fields. Budget for this one; it is the largest single cost.
176
+
177
+ The first editor API request also prints a warning block for the configuration
178
+ it can check from the inside (draft secret, editor origin, orchestrator URL).
179
+ It cannot see the last three rows of that table — those live in your components,
180
+ and the [coverage gate](#budget-for-this-part) is what keeps them honest.
136
181
 
137
182
  ## What You Implement
138
183
 
@@ -145,7 +190,7 @@ That's it. Your site now works with the AI editor.
145
190
 
146
191
  ## Exports
147
192
 
148
- All seventeen, because the six this table used to list were not the six an
193
+ All twenty, because the six this table used to list were not the six an
149
194
  integration needs — `createOrchestrator` is the whole of library mode and was in
150
195
  none of them.
151
196
 
@@ -163,15 +208,20 @@ none of them.
163
208
  | `.../draft` | `resolveEditorContext`, `fetchEditorPage`, `fetchEditorSlugs`, `fetchEditorSiteConfig` |
164
209
  | `.../draft/core` | The draft context without Next's cookie APIs |
165
210
  | `.../editor` | `EditorOverlay`, `LivePreviewProvider`, `buildEditorQuerySuffix` |
211
+ | `.../markers` | `editableProps`, `getPreviewWrapperProps` — the preview attributes, with no React behind them. **Import them from here**, not from `.../editor`, in any component a public page renders |
212
+ | `.../blocks` | `registerBlock`, `z`, and the block-meta types (`FieldKind`, `FieldMeta`, `ListFieldMeta`, `BlockMeta`), so describing your own blocks needs no second package |
213
+ | `.../coverage` | `editableCoverage` (the preview markers) and `panelCoverage` (the property panel) — gate both in CI |
166
214
  | `.../editor-manifest` | `buildBlockManifest` — the built-in blocks as a manifest |
167
215
  | `.../publish` | `diffPage`, `groupPatches`, `describeUnsupported` — the field-level publish walk |
168
216
  | `.../publish-handlers/json-file` | A ready-made `onPublish` that writes a JSON file |
169
217
  | `.../navigation` | `buildNavItems`, `buildSiteHeaderBlock` |
170
218
  | `.../seo` | `buildPageMetadata` and the title/description derivation |
171
219
 
172
- `useLivePreviewBlocks` is not here. It lives in
173
- `@avocadostudio-ai/preview-adapter`, which you must depend on directly to import
174
- from it (`LivePreviewProvider` itself is re-exported above, the hook is not).
220
+ `useLivePreviewBlocks` is in `.../editor` too, beside `LivePreviewProvider`. It
221
+ used to be the one import in our own walkthrough that a site following our own
222
+ install line could not resolve: the provider was re-exported and the hook was
223
+ not, so the example said to import it from `@avocadostudio-ai/preview-adapter`,
224
+ which is a dependency of *this* package and not of yours.
175
225
 
176
226
  It is worth being precise about what it is for, because an earlier version of
177
227
  this paragraph was not. Rendering a live draft through **your own** components
@@ -452,6 +502,36 @@ A workspace link to `@avocadostudio-ai/orchestrator-core` is **not** enough. A
452
502
  linked package's own dependencies are never materialised in the host's tree, and
453
503
  a native module has to be resolvable from there.
454
504
 
505
+
506
+ ### `withAvocado` writes the framing headers for you
507
+
508
+ As of this version you do not hand-write the CSP pair. `withAvocado` appends
509
+ both rules, derived from the same `EDITOR_CORS_ORIGINS` /
510
+ `NEXT_PUBLIC_EDITOR_ORIGIN` allowlist the editor API uses — so the two halves of
511
+ the contract cannot disagree:
512
+
513
+ ```
514
+ frame-ancestors 'self' http://localhost:4100 http://127.0.0.1:4100;
515
+ ```
516
+
517
+ Two things it gets right that hand-written versions did not. It names **both
518
+ loopback spellings**, because the CLI binds `127.0.0.1` and prints that while
519
+ every doc says `localhost`, and `frame-ancestors` compares origins as strings.
520
+ And it puts `missing: [{ type: "query", key: "__editor" }]` on the public rule —
521
+ without that, both rules match an editor request, the browser receives two CSP
522
+ headers and enforces their *intersection*, putting `frame-ancestors 'self'` back
523
+ and blocking the frame it just allowed.
524
+
525
+ Your own `headers()` entries come first and win, since Next uses the first match
526
+ per header. Opt out entirely with `withAvocado(config, { framing: false })`.
527
+
528
+ Serving the editor anywhere but `:4100` — which anyone already running the
529
+ standalone stack has to — means setting `EDITOR_CORS_ORIGINS`, and that one
530
+ variable now covers both halves. Left unset, the API answers **200 with no
531
+ `access-control-allow-origin`** (the Sites page reports your site as *offline*)
532
+ and the browser refuses the frame (**a blank rectangle**). Neither failure names
533
+ a port, and neither says CORS or CSP.
534
+
455
535
  ## The editor frames your page, not the preview route
456
536
 
457
537
  The iframe's `src` is your site's own URL with a query parameter added:
@@ -626,7 +706,7 @@ until the first AI edit:
626
706
 
627
707
  ```ts
628
708
  // lib/register-blocks.ts
629
- import { registerBlock, z } from "@avocadostudio-ai/shared"
709
+ import { registerBlock, z } from "@avocadostudio-ai/site-sdk/blocks"
630
710
 
631
711
  export function registerMyBlocks() {
632
712
  registerBlock("PricingTable", {
@@ -663,7 +743,7 @@ last in the file. Next's bundler does not reliably preserve module order across
663
743
  the RSC, SSR and route-handler layers, so the built-in schemas sometimes
664
744
  re-register on top of yours. The hook exists to replace that trick.
665
745
 
666
- ### Import `z` from `@avocadostudio-ai/shared`, not from `zod`
746
+ ### Import `z` from `site-sdk/blocks`, not from `zod`
667
747
 
668
748
  `registerBlock` takes a `ZodObject`, and a Zod object is assignable only to one
669
749
  built by the *same copy* of the library. Your own `import { z } from "zod"`
@@ -671,7 +751,60 @@ resolves to whatever your tree hoisted — on any site that also uses Sanity tha
671
751
  is zod 3 — and the mismatch reports as a structural type error listing methods
672
752
  you have never called (`loose`, `safeExtend`, `exactPartial`, `def`, "and 21
673
753
  more"), with nothing anywhere saying there are two copies of zod. Importing `z`
674
- from `shared` gives you ours, and you need no direct `zod` dependency at all.
754
+ from `site-sdk/blocks` gives you ours, and you need no direct `zod` dependency
755
+ at all.
756
+
757
+ `site-sdk/blocks` re-exports these from `@avocadostudio-ai/shared`, which the
758
+ SDK already depends on. Import them from there and the rule above stops being
759
+ something you have to remember: there is only one copy to reach.
760
+
761
+ ### What `kind` may be
762
+
763
+ `meta.fields[…].kind` is a closed list, and it is not the list of HTML element
764
+ names — `"select"` and `"textarea"` are both wrong:
765
+
766
+ | What you want | What you write |
767
+ |---|---|
768
+ | a single-line string | `{ kind: "text" }` |
769
+ | a multi-line box | `{ kind: "text", multiline: true }` — `multiline` is a flag, not a kind |
770
+ | markdown / rich text | `{ kind: "richtext" }` |
771
+ | a closed list of values | `{ kind: "enum", options: ["a", "b"] }` — a `string[]`, not `{ value, label }[]` |
772
+ | an image | `{ kind: "image" }`, with its alt text as `{ kind: "imageAlt" }` |
773
+ | a link | `{ kind: "url" }` for a bare href, `{ kind: "link" }` for `{ href, label }` |
774
+ | an uploaded file | `{ kind: "file" }` |
775
+ | the rest | `{ kind: "number" }`, `{ kind: "boolean" }`, `{ kind: "color" }`, `{ kind: "headingLevel" }` |
776
+
777
+ In TypeScript a wrong `kind` is a compile error naming the whole union, which is
778
+ how you would find this out anyway. In JavaScript it is silent: the panel falls
779
+ back to a plain text input and the preview loses whatever affordance the right
780
+ kind would have given the field.
781
+
782
+ ### Lists whose rows are not all the same shape
783
+
784
+ `listFields` has a second form for that, and it is easy to miss because the
785
+ common case does not use it. Give the list a `discriminator` naming the row
786
+ property that says what the row is, and `itemFieldsByType` mapping each value of
787
+ it to that variant's fields; `itemFields` stays as the fallback for a row whose
788
+ type is not listed:
789
+
790
+ ```ts
791
+ listFields: {
792
+ content: {
793
+ label: "Content",
794
+ discriminator: "type",
795
+ itemFields: { type: { kind: "text" }, text: { kind: "text", multiline: true } },
796
+ itemFieldsByType: {
797
+ heading: { text: { kind: "text", label: "Heading" } },
798
+ paragraph: { text: { kind: "text", multiline: true } },
799
+ image: { src: { kind: "image" }, alt: { kind: "imageAlt" } },
800
+ },
801
+ },
802
+ }
803
+ ```
804
+
805
+ Without it, a mixed list is described by whichever single shape you picked, and
806
+ every row of every other shape renders that shape's fields against props it does
807
+ not have.
675
808
 
676
809
  ### `schema` and `meta` are two halves that must agree
677
810
 
@@ -801,38 +934,159 @@ second false is the default.
801
934
  Once the picker is on, blocks are selectable. Individual *fields* are not,
802
935
  until:
803
936
 
804
- ## Making fields editable
937
+ ## Marking up your components
938
+
939
+ Two attributes carry the whole preview contract, and there is a helper for each.
940
+ Import both from `@avocadostudio-ai/site-sdk/markers` — *not* from
941
+ `.../editor`, which also exports `EditorOverlay` and will pull the editor into
942
+ every public page that renders the same component ([see below](#import-the-markers-from-markers-not-from-editor)).
943
+
944
+ ### First, the block wrapper — without it nothing is selectable
945
+
946
+ Selection is built entirely on `data-block-id`: a click resolves through
947
+ `closest("[data-block-id]")`, and *no match* is read as "clicked outside any
948
+ block", which **clears** the selection. So a preview that marks every field and
949
+ no block frames, renders, scrolls and validates correctly, and deselects on
950
+ every click — which looks exactly like the picker being switched off.
951
+
952
+ Do it once, wherever you dispatch on block type, rather than in each block:
953
+
954
+ ```tsx
955
+ import { getPreviewWrapperProps } from "@avocadostudio-ai/site-sdk/markers"
956
+
957
+ export function BlockRenderer({ block, editorMode = false }) {
958
+ const rendered = renderBlock(block)
959
+ if (!editorMode || rendered === null) return rendered
960
+ return <div {...getPreviewWrapperProps(true, block.id, block.type)}>{rendered}</div>
961
+ }
962
+ ```
963
+
964
+ It returns `{}` when `editorMode` is false, so the published page renders
965
+ precisely the markup it rendered before.
966
+
967
+ ### Then the fields
805
968
 
806
969
  The manifest tells the editor which blocks exist and what props they take. It
807
970
  does not tell it **where on the page a prop is rendered**, and nothing can derive
808
- that — so a site whose components carry no annotation gets block selection, the
809
- badge, move and delete, and not one editable field.
810
-
811
- Put `data-editable-target` on the DOM node that renders each prop:
971
+ that — so a block with no field markers gets selection, the badge, move and
972
+ delete, and not one editable field.
812
973
 
813
974
  ```tsx
814
- <h1 data-editable-target="heading">{heading}</h1>
815
- <p data-editable-target="subheading">{subheading}</p>
975
+ import { editableProps } from "@avocadostudio-ai/site-sdk/markers"
976
+
977
+ <h1 {...editableProps("heading")}>{heading}</h1>
978
+ <p {...editableProps("subheading", { kind: "text" })}>{subheading}</p>
816
979
 
817
980
  {cards.map((card, i) => (
818
981
  <article key={card._key}>
819
- <h3 data-editable-target={`cards[${i}].title`}>{card.title}</h3>
820
- <img data-editable-target={`cards[${i}].imageUrl`} src={card.imageUrl} />
982
+ <h3 {...editableProps(`cards[${i}].title`)}>{card.title}</h3>
983
+ {/* on the wrapper, never on the <img> see below */}
984
+ <div {...editableProps(`cards[${i}].imageUrl`, { kind: "image" })}>
985
+ <img src={card.imageUrl} alt={card.alt} />
986
+ </div>
821
987
  </article>
822
988
  ))}
823
989
  ```
824
990
 
825
991
  The path grammar is the same one operations use: `heading` for a scalar,
826
992
  `cards[0].title` for a list item's field, `cards[0].imageUrl` for its image.
993
+ What the overlay reads here is what it sends back as the field to patch.
994
+
995
+ There is no `editorMode` argument on `editableProps`, on purpose: these are
996
+ inert data attributes that do nothing unless the overlay is mounted, and
997
+ threading a flag down to every field is the step that does not get done.
998
+
999
+ ### An image field goes on the wrapper, not on the `<img>`
1000
+
1001
+ The overlay *appends* its Change button into the marked element, and nothing can
1002
+ be appended into an `<img>` — it is a void element. Marking the image itself
1003
+ gives you a field that is listed in the property panel, highlights on hover, and
1004
+ has no button, with no error on either side.
1005
+
1006
+ ### `kind` is how your site says what a field is, in its own words
1007
+
1008
+ Without it the overlay guesses an image from the prop name, against Avocado's
1009
+ own naming (`imageUrl`, `*.src`). A site whose field is `photoUrl` or `heroSrc`
1010
+ gets a picker in the property panel and no button in the preview. Pass the same
1011
+ word the block manifest uses — the [`kind` table](#what-kind-may-be) — and the
1012
+ guess never runs.
1013
+
1014
+ ### Import the markers from `/markers`, not from `/editor`
1015
+
1016
+ `@avocadostudio-ai/site-sdk/editor` re-exports both helpers, so older code keeps
1017
+ working. But that entry also exports `EditorOverlay` and the live-preview
1018
+ provider, and most sites mark up components that their **public** pages render
1019
+ too. A `'use client'` component importing the two-line attribute helper from
1020
+ there drags the whole editor into the public bundle — measured on a real
1021
+ integration at **+66 kB First Load JS on every page**, for byte-identical
1022
+ markup. `/markers` has no React in it at all.
1023
+
1024
+ ### Budget for this part
827
1025
 
828
1026
  This is the one part of an integration that **cannot** live in an integration
829
- layer it has to go inside your own components, one attribute per prop you want
830
- editable. Budget for it: it is usually the largest single cost of adopting the
831
- editor, and there is no way to add it from the outside.
1027
+ layer: it goes inside your own components, one call per prop you want editable.
1028
+ It is usually the largest single cost of adopting the editor, and there is no
1029
+ way to add it from the outside. Two real integrations landed at 36 and 51
1030
+ emission sites.
1031
+
1032
+ Gate it in CI rather than trusting it to stay done. `editableCoverage` from
1033
+ `@avocadostudio-ai/site-sdk/coverage` compares the manifest's fields against the
1034
+ markers a rendered page actually carries, so a field that loses its marker in a
1035
+ refactor fails a build instead of silently losing inline editing:
1036
+
1037
+ ```js
1038
+ import { extractMarkedBlocks, editableCoverage, formatEditableCoverage }
1039
+ from "@avocadostudio-ai/site-sdk/coverage"
1040
+
1041
+ const manifest = await (await fetch(`${BASE}/api/editor/blocks`)).json()
1042
+ const html = await (await fetch(`${BASE}/?__editor=1`)).text()
1043
+ const report = editableCoverage(manifest, extractMarkedBlocks(html), { props })
1044
+ console.log(formatEditableCoverage(report))
1045
+ ```
1046
+
1047
+ Pass `props` — the blocks as rendered — or the report cannot tell a field that
1048
+ lost its marker from one that was empty on that page. Expect a ceiling below
1049
+ 100%: a field that draws nothing (`sectionId`, an input's `placeholder`, a
1050
+ `<video>` poster) has no element to mark and stays panel-only.
1051
+
1052
+ Declare those panel-only fields as `{ kind: "text", panelOnly: true }` and the
1053
+ ceiling goes away — the denominator becomes the set of fields that *could* carry
1054
+ a marker, so 100% is reachable and anything less is actionable.
832
1055
 
833
- Two optional siblings control the labels the overlay draws:
834
- `data-editable-target-label` (the CSS `::before` tooltip) and
835
- `data-editable-label` (the floating pill). Both default to the target path.
1056
+ ### And check the panel, which is the other half
1057
+
1058
+ `editableCoverage` asks whether the preview offers each field. It says nothing
1059
+ about whether the property panel is *intelligible*, and that fails in ways a
1060
+ build, a type check and a manifest validation all pass through:
1061
+
1062
+ ```js
1063
+ import { panelCoverage, formatPanelCoverage } from "@avocadostudio-ai/site-sdk/coverage"
1064
+ import { getAllBlockMeta } from "@avocadostudio-ai/site-sdk/blocks"
1065
+
1066
+ const report = panelCoverage(manifest, pages, { builtinTypes: getAllBlockMeta() })
1067
+ console.log(formatPanelCoverage(report))
1068
+ if (report.findings.length > 0) process.exit(1)
1069
+ ```
1070
+
1071
+ It compares what the panel will render against what your content actually holds,
1072
+ so it needs no browser and no screenshots:
1073
+
1074
+ | finding | what it means |
1075
+ |---|---|
1076
+ | `colliding_type` | your block type name also exists in the editor's built-ins with a different shape. **Read this one first** — it usually explains several of the others |
1077
+ | `unlabelled_row` | the panel titles this list row `Item 4`; nobody can tell it from its neighbour |
1078
+ | `unmatched_branch` | a row's discriminant value has no `itemFieldsByType` entry, so it is edited against the union of every branch |
1079
+ | `incomplete_polymorphism` | a `discriminator` with no branch map, or the reverse — nothing narrows |
1080
+ | `orphan_prop` | your content holds it and nothing describes it, so the panel cannot show or edit it |
1081
+ | `filename_row_label` | an image row titled `IMG_20250904.webp` while a populated alt sits beside it |
1082
+ | `phantom_field` | declared for rows that never have it |
1083
+
1084
+ Pass `builtinTypes` only if you want the collision findings; without it they are
1085
+ simply not reported, because an absent input is not evidence.
1086
+
1087
+ An agent integrating a site can call the same check as
1088
+ `avocado-check-editing-surface` over MCP, and should, before reporting an
1089
+ integration as done.
836
1090
 
837
1091
  ## Environment Variables
838
1092
 
@@ -0,0 +1,3 @@
1
+ export { z } from "@avocadostudio-ai/shared";
2
+ export { registerBlock, declareBlockCatalogue, getBlockMeta, getAllBlockMeta, getChromeTypes, isChrome, IMAGE_PLACEHOLDER, isImagePlaceholder, blockTypeToCamel, camelToBlockType, blockTypeToLower, lowerToBlockType, } from "@avocadostudio-ai/shared";
3
+ export type { FieldKind, FieldMeta, ListFieldMeta, ImageSpec, BlockMeta, BlockType, BlockInstance, BlockRegistration, } from "@avocadostudio-ai/shared";
package/dist/blocks.js ADDED
@@ -0,0 +1,29 @@
1
+ /*
2
+ * Everything a site needs to describe its own blocks, re-exported from the one
3
+ * package its install line names.
4
+ *
5
+ * `registerBlock` and the block-meta types live in `@avocadostudio-ai/shared`,
6
+ * which the SDK depends on — and a dependency of a dependency is not a
7
+ * specifier your own source may import. Under pnpm's isolated `node_modules`
8
+ * it sits under `.pnpm/` where the SDK can reach it and the site cannot, so
9
+ * `import { registerBlock } from "@avocadostudio-ai/shared"` fails outright on
10
+ * a clean `pnpm add @avocadostudio-ai/site-sdk`. Under npm's flat hoisting it
11
+ * resolves, which is worse: it keeps working until an unrelated dependency
12
+ * change re-hoists, and then breaks in a build that never mentions us.
13
+ *
14
+ * Importing `z` from here rather than from `zod` also makes the "same copy of
15
+ * zod" rule structural instead of merely documented: `registerBlock` takes a
16
+ * `z.ZodObject`, and a Zod object is only assignable to one built by the same
17
+ * copy of the library. A site that also uses a zod-3 SDK (Sanity, for one) and
18
+ * writes its schemas against its own hoisted `zod` gets a wall of structural
19
+ * type errors naming methods nobody has heard of, none of which say "you have
20
+ * two copies of zod".
21
+ *
22
+ * This entry is deliberately free of React components — importing it costs a
23
+ * site nothing in its client bundle.
24
+ */
25
+ export { z } from "@avocadostudio-ai/shared";
26
+ export { registerBlock, declareBlockCatalogue, getBlockMeta, getAllBlockMeta, getChromeTypes, isChrome, IMAGE_PLACEHOLDER, isImagePlaceholder,
27
+ // Block-type name conversions, for CMS adapters whose document types are
28
+ // spelled differently from Avocado's (`Hero` vs `hero` vs `featureGrid`).
29
+ blockTypeToCamel, camelToBlockType, blockTypeToLower, lowerToBlockType, } from "@avocadostudio-ai/shared";
@@ -0,0 +1,4 @@
1
+ export { extractMarkedBlocks, editableCoverage, formatEditableCoverage } from "@avocadostudio-ai/shared";
2
+ export type { MarkedBlock, BlockCoverageGap, EditableCoverage } from "@avocadostudio-ai/shared";
3
+ export { panelCoverage, formatPanelCoverage } from "@avocadostudio-ai/shared";
4
+ export type { PanelCoverage, PanelFinding, PanelFindingCode } from "@avocadostudio-ai/shared";
@@ -0,0 +1,24 @@
1
+ /*
2
+ * The CI-side half of the marker contract.
3
+ *
4
+ * `editableCoverage` compares the fields a block manifest declares against the
5
+ * `data-editable-target` markers a rendered page actually carries, so a field
6
+ * that loses its marker in a refactor fails a build instead of silently losing
7
+ * inline editing, its hover pill and its image Change button.
8
+ *
9
+ * Re-exported here because the site cannot import `@avocadostudio-ai/shared`
10
+ * directly — see `./blocks.ts`. Kept out of `./markers` so that nothing a page
11
+ * renders pulls an HTML scanner into its bundle.
12
+ */
13
+ export { extractMarkedBlocks, editableCoverage, formatEditableCoverage } from "@avocadostudio-ai/shared";
14
+ /*
15
+ * The other half. `editableCoverage` asks whether the *preview* offers each
16
+ * field; `panelCoverage` asks whether the *property panel* is intelligible —
17
+ * whether every list row can be told apart, whether polymorphic branches
18
+ * actually narrow, whether anything in your content is described by nothing.
19
+ *
20
+ * Pass the editor's own registry as `builtinTypes` to also learn where your
21
+ * block type names collide with Avocado's built-ins, which is the one finding
22
+ * that explains several of the others.
23
+ */
24
+ export { panelCoverage, formatPanelCoverage } from "@avocadostudio-ai/shared";
@@ -1,10 +1,46 @@
1
1
  import { pageDocSchemaLenient, siteConfigSchema } from "@avocadostudio-ai/shared";
2
+ /*
3
+ * The address of the orchestrator this site should read drafts from.
4
+ *
5
+ * The `127.0.0.1:4200` fallback is the *standalone* orchestrator, and a
6
+ * library-mode site by definition does not run one — its orchestrator is a
7
+ * route inside itself. Falling through to :4200 there is silent when nothing is
8
+ * listening (the fetch fails, the preview quietly serves published content, and
9
+ * every edit looks like it did nothing) and worse when something is: the fetch
10
+ * succeeds against a foreign process and the preview renders another project's
11
+ * pages, with a 200 on both sides. Anyone integrating is likely to have a :4200
12
+ * up, because that is exactly what the standalone stack runs on.
13
+ *
14
+ * So a mounted `createOrchestrator` now publishes its own address on
15
+ * `globalThis`, and it is preferred over the fallback. The key is a literal on
16
+ * both sides because `@avocadostudio-ai/orchestrator-core` is an optional peer
17
+ * — a site not in library mode does not have it installed, so neither package
18
+ * may import the other. See `orchestrator-core/src/handler/library-mount.ts`.
19
+ */
20
+ const LIBRARY_MOUNT_KEY = "__avocado_library_mount__";
21
+ function libraryModeOrchestratorUrl() {
22
+ const mount = globalThis[LIBRARY_MOUNT_KEY];
23
+ return mount?.observed ?? mount?.declared ?? null;
24
+ }
25
+ let warnedAboutFallback = false;
2
26
  export function getOrchestratorUrl() {
3
27
  const value = process.env.ORCHESTRATOR_URL?.trim();
4
28
  if (value)
5
29
  return value.replace(/\/$/, "");
6
- if (process.env.NODE_ENV !== "production")
30
+ const mounted = libraryModeOrchestratorUrl();
31
+ if (mounted)
32
+ return mounted;
33
+ if (process.env.NODE_ENV !== "production") {
34
+ if (!warnedAboutFallback) {
35
+ warnedAboutFallback = true;
36
+ console.warn("[avocado] ORCHESTRATOR_URL is not set, and no library-mode orchestrator " +
37
+ "has registered itself — falling back to http://127.0.0.1:4200. If you " +
38
+ "mounted createOrchestrator inside this app, set ORCHESTRATOR_URL to its " +
39
+ "own URL (e.g. http://localhost:3000/api/avocado). Whatever is listening " +
40
+ "on :4200 is a different process, and it will answer.");
41
+ }
7
42
  return "http://127.0.0.1:4200";
43
+ }
8
44
  return null;
9
45
  }
10
46
  /**
@@ -1,6 +1,6 @@
1
1
  import { test, beforeEach, afterEach } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { fetchEditorPage, fetchEditorSlugs, resetDraftFetchWarnings } from "./draft-fetch.js";
3
+ import { fetchEditorPage, fetchEditorSlugs, getOrchestratorUrl, resetDraftFetchWarnings } from "./draft-fetch.js";
4
4
  let calls = [];
5
5
  let warnings = [];
6
6
  const realFetch = globalThis.fetch;
@@ -85,3 +85,69 @@ test("fetchEditorSlugs refuses the same way, and returns an empty list", async (
85
85
  assert.equal(calls.length, 1);
86
86
  assert.match(warnings[0], /fetchEditorSlugs/);
87
87
  });
88
+ /*
89
+ * VI-04. With `ORCHESTRATOR_URL` unset, a library-mode site used to read its
90
+ * drafts from `127.0.0.1:4200` — the standalone orchestrator it does not run.
91
+ * When nothing is listening there the preview silently serves published content
92
+ * and every edit looks inert; when something is, the fetch succeeds and the
93
+ * preview renders a foreign project's pages. A mounted `createOrchestrator`
94
+ * now publishes its own address, and it must win.
95
+ */
96
+ test("a mounted library-mode orchestrator beats the :4200 fallback", () => {
97
+ const prev = process.env.ORCHESTRATOR_URL;
98
+ const g = globalThis;
99
+ const prevMount = g["__avocado_library_mount__"];
100
+ try {
101
+ delete process.env.ORCHESTRATOR_URL;
102
+ g["__avocado_library_mount__"] = { declared: "http://localhost:3001/api/avocado" };
103
+ assert.equal(getOrchestratorUrl(), "http://localhost:3001/api/avocado");
104
+ // A real request the handler served outranks what config declared.
105
+ g["__avocado_library_mount__"] = {
106
+ declared: "http://localhost:3001/api/avocado",
107
+ observed: "https://villa.example.com/api/avocado"
108
+ };
109
+ assert.equal(getOrchestratorUrl(), "https://villa.example.com/api/avocado");
110
+ // And an explicit env var still outranks both.
111
+ process.env.ORCHESTRATOR_URL = "http://localhost:9999/api/avocado";
112
+ assert.equal(getOrchestratorUrl(), "http://localhost:9999/api/avocado");
113
+ }
114
+ finally {
115
+ if (prev === undefined)
116
+ delete process.env.ORCHESTRATOR_URL;
117
+ else
118
+ process.env.ORCHESTRATOR_URL = prev;
119
+ if (prevMount === undefined)
120
+ delete g["__avocado_library_mount__"];
121
+ else
122
+ g["__avocado_library_mount__"] = prevMount;
123
+ }
124
+ });
125
+ test("with nothing mounted the fallback still applies, and says so out loud", () => {
126
+ const prev = process.env.ORCHESTRATOR_URL;
127
+ const g = globalThis;
128
+ const prevMount = g["__avocado_library_mount__"];
129
+ const said = [];
130
+ const prevWarn = console.warn;
131
+ try {
132
+ delete process.env.ORCHESTRATOR_URL;
133
+ delete g["__avocado_library_mount__"];
134
+ console.warn = (...args) => { said.push(args.join(" ")); };
135
+ assert.equal(getOrchestratorUrl(), "http://127.0.0.1:4200");
136
+ assert.equal(said.length, 1, "the fallback warns exactly once");
137
+ assert.match(said[0], /ORCHESTRATOR_URL is not set/);
138
+ assert.match(said[0], /createOrchestrator/);
139
+ getOrchestratorUrl();
140
+ assert.equal(said.length, 1, "and not on every subsequent read");
141
+ }
142
+ finally {
143
+ console.warn = prevWarn;
144
+ if (prev === undefined)
145
+ delete process.env.ORCHESTRATOR_URL;
146
+ else
147
+ process.env.ORCHESTRATOR_URL = prev;
148
+ if (prevMount === undefined)
149
+ delete g["__avocado_library_mount__"];
150
+ else
151
+ g["__avocado_library_mount__"] = prevMount;
152
+ }
153
+ });
package/dist/editor.d.ts CHANGED
@@ -1,72 +1,8 @@
1
- import type { FieldKind } from "@avocadostudio-ai/shared";
2
1
  export { EditorOverlay } from "./editor-overlay.tsx";
3
2
  export { buildEditorQuerySuffix } from "./editor-query.ts";
4
- export declare function getPreviewWrapperProps(editorMode: boolean, blockId: string, blockType: string): {
5
- readonly "data-block-id"?: undefined;
6
- readonly "data-block-type"?: undefined;
7
- readonly className?: undefined;
8
- readonly style?: undefined;
9
- } | {
10
- readonly "data-block-id": string;
11
- readonly "data-block-type": string;
12
- readonly className: "editor-selectable";
13
- readonly style: {
14
- readonly viewTransitionName: `block-${string}`;
15
- };
16
- };
17
- /**
18
- * Mark the element that draws one editable field.
19
- *
20
- * This is the second half of instrumenting a preview, and until now it was the
21
- * half with no helper. `getPreviewWrapperProps` covers the block boundary and
22
- * is named, exported and documented; the per-field attributes were prose in the
23
- * integration guide with a link to a renderer to copy from. The predictable
24
- * result is an integration that wraps every block and marks no fields — which
25
- * frames, renders, selects, scrolls and opens the property panel correctly, and
26
- * silently has no inline text editing, no field pills and no image buttons in
27
- * the preview, because every one of those is found by walking
28
- * `[data-editable-target]`.
29
- *
30
- * ```tsx
31
- * <div className="hero__media" {...editableProps("imageUrl", { kind: "image" })}>
32
- * <Image src={props.imageUrl} … />
33
- * </div>
34
- * ```
35
- *
36
- * The path is the same grammar an operation uses — `title`, `cards[0].title`,
37
- * `links[0].children[1].label` — because it is the same path: what the overlay
38
- * reads here is what it sends back as the field to patch.
39
- *
40
- * **Put it on an element, not on the image.** For an image field the attribute
41
- * belongs on the wrapper around the `<img>`, never on the image itself: the
42
- * overlay appends its Change button *into* the marked element, and nothing can
43
- * be appended into an `<img>`.
44
- *
45
- * **`kind` is how a site says what a field is in its own vocabulary.** Without
46
- * it the overlay has to guess an image from the prop name, against Avocado's
47
- * own naming (`imageUrl`, `*.src`) — so a site whose field is `photoUrl` or
48
- * `heroSrc` gets a picker in the property panel and no button in the preview,
49
- * with no error on either side. Pass the same word the block manifest uses and
50
- * the guess never runs.
51
- *
52
- * There is no `editorMode` argument on purpose. These are inert data attributes
53
- * that cost a few bytes and do nothing unless the overlay is mounted (all of
54
- * its styling is scoped under `[data-editor-active]`), and most sites render
55
- * their blocks through components shared with the public pages, where threading
56
- * a flag down to every field is the step that does not get done.
57
- */
58
- export declare function editableProps(path: string, options?: {
59
- /** Text for the hover pill. Defaults to the path. */
60
- label?: string;
61
- /** What kind of field this is — the same vocabulary as the block manifest. */
62
- kind?: FieldKind;
63
- }): {
64
- readonly "data-editable-kind"?: FieldKind | undefined;
65
- readonly "data-editable-target": string;
66
- readonly "data-editable-target-label": string;
67
- };
3
+ export { getPreviewWrapperProps, editableProps } from "./markers.ts";
68
4
  export { renderBlocks } from "./render-blocks.tsx";
69
5
  export { RenderedBlocks, PreviewBlock } from "./live-preview-blocks.tsx";
70
- export { LivePreviewProvider } from "@avocadostudio-ai/preview-adapter";
6
+ export { LivePreviewProvider, useLivePreviewBlocks } from "@avocadostudio-ai/preview-adapter";
71
7
  export type { LivePreviewPage, LivePreviewBridgeApi } from "@avocadostudio-ai/preview-adapter";
72
8
  export { getEditorCorsOrigins, applyEditorCors, createEditorCorsOptionsHandler } from "./editor-cors.ts";
package/dist/editor.js CHANGED
@@ -2,69 +2,30 @@
2
2
  export { EditorOverlay } from "./editor-overlay.js";
3
3
  // Editor query param utilities
4
4
  export { buildEditorQuerySuffix } from "./editor-query.js";
5
- // Block wrapper props for preview mode
6
- export function getPreviewWrapperProps(editorMode, blockId, blockType) {
7
- if (!editorMode)
8
- return {};
9
- return {
10
- "data-block-id": blockId,
11
- "data-block-type": blockType,
12
- className: "editor-selectable",
13
- style: { viewTransitionName: `block-${blockId}` }
14
- };
15
- }
16
- /**
17
- * Mark the element that draws one editable field.
18
- *
19
- * This is the second half of instrumenting a preview, and until now it was the
20
- * half with no helper. `getPreviewWrapperProps` covers the block boundary and
21
- * is named, exported and documented; the per-field attributes were prose in the
22
- * integration guide with a link to a renderer to copy from. The predictable
23
- * result is an integration that wraps every block and marks no fields — which
24
- * frames, renders, selects, scrolls and opens the property panel correctly, and
25
- * silently has no inline text editing, no field pills and no image buttons in
26
- * the preview, because every one of those is found by walking
27
- * `[data-editable-target]`.
28
- *
29
- * ```tsx
30
- * <div className="hero__media" {...editableProps("imageUrl", { kind: "image" })}>
31
- * <Image src={props.imageUrl} … />
32
- * </div>
33
- * ```
34
- *
35
- * The path is the same grammar an operation uses — `title`, `cards[0].title`,
36
- * `links[0].children[1].label` — because it is the same path: what the overlay
37
- * reads here is what it sends back as the field to patch.
38
- *
39
- * **Put it on an element, not on the image.** For an image field the attribute
40
- * belongs on the wrapper around the `<img>`, never on the image itself: the
41
- * overlay appends its Change button *into* the marked element, and nothing can
42
- * be appended into an `<img>`.
43
- *
44
- * **`kind` is how a site says what a field is in its own vocabulary.** Without
45
- * it the overlay has to guess an image from the prop name, against Avocado's
46
- * own naming (`imageUrl`, `*.src`) — so a site whose field is `photoUrl` or
47
- * `heroSrc` gets a picker in the property panel and no button in the preview,
48
- * with no error on either side. Pass the same word the block manifest uses and
49
- * the guess never runs.
50
- *
51
- * There is no `editorMode` argument on purpose. These are inert data attributes
52
- * that cost a few bytes and do nothing unless the overlay is mounted (all of
53
- * its styling is scoped under `[data-editor-active]`), and most sites render
54
- * their blocks through components shared with the public pages, where threading
55
- * a flag down to every field is the step that does not get done.
5
+ /*
6
+ * The two field/block attribute helpers live in `site-sdk/markers`, a leaf
7
+ * entry with no React in it. They are re-exported here so existing imports
8
+ * keep working — but import them from `@avocadostudio-ai/site-sdk/markers` in
9
+ * anything a public page can reach. This entry also exports `EditorOverlay`
10
+ * and the live-preview provider, and a `'use client'` component that reaches
11
+ * for the two-line attribute helper from here drags the whole editor into the
12
+ * public bundle: +66 kB First Load JS, for identical markup.
56
13
  */
57
- export function editableProps(path, options) {
58
- return {
59
- "data-editable-target": path,
60
- "data-editable-target-label": options?.label ?? path,
61
- ...(options?.kind ? { "data-editable-kind": options.kind } : {})
62
- };
63
- }
14
+ export { getPreviewWrapperProps, editableProps } from "./markers.js";
64
15
  // Block rendering helper
65
16
  export { renderBlocks } from "./render-blocks.js";
66
17
  // Live-preview store renderer (streams field drafts through React)
67
18
  export { RenderedBlocks, PreviewBlock } from "./live-preview-blocks.js";
68
- export { LivePreviewProvider } from "@avocadostudio-ai/preview-adapter";
19
+ /*
20
+ * The React live-preview path, both halves.
21
+ *
22
+ * `useLivePreviewBlocks` used to be the one documented import a site could not
23
+ * satisfy: `LivePreviewProvider` was re-exported here and the hook was not, so
24
+ * the walkthrough's own example told the reader to import from
25
+ * `@avocadostudio-ai/preview-adapter` — a dependency of this package, not of
26
+ * theirs. Under pnpm that does not resolve at all; under npm it resolves until
27
+ * a re-hoist. A provider with no way to read it was never a coherent boundary.
28
+ */
29
+ export { LivePreviewProvider, useLivePreviewBlocks } from "@avocadostudio-ai/preview-adapter";
69
30
  // Editor CORS utilities
70
31
  export { getEditorCorsOrigins, applyEditorCors, createEditorCorsOptionsHandler } from "./editor-cors.js";
@@ -17,7 +17,7 @@ import { memo } from "react";
17
17
  import { SharedBlockRenderer, BlockErrorBoundary } from "@avocadostudio-ai/blocks";
18
18
  import { getChromeTypes } from "@avocadostudio-ai/shared";
19
19
  import { useLivePreviewBlocks } from "@avocadostudio-ai/preview-adapter";
20
- import { getPreviewWrapperProps } from "./editor.js";
20
+ import { getPreviewWrapperProps } from "./markers.js";
21
21
  const CHROME_BLOCK_TYPES = new Set(getChromeTypes());
22
22
  export const PreviewBlock = memo(function PreviewBlock({ block }) {
23
23
  return (_jsx("div", { id: block.id, ...getPreviewWrapperProps(true, block.id, block.type), children: _jsx(BlockErrorBoundary, { blockId: block.id, blockType: block.type, resetKey: block, children: _jsx(SharedBlockRenderer, { block: block }) }) }));
@@ -0,0 +1,78 @@
1
+ import type { FieldKind } from "@avocadostudio-ai/shared";
2
+ /**
3
+ * Mark the element that is one block, so the editor can select it.
4
+ *
5
+ * Selection is built entirely on `[data-block-id]`: a click in the preview
6
+ * resolves through `closest("[data-block-id]")`, and no match is read as
7
+ * "clicked outside any block", which *clears* the selection. Marking fields
8
+ * without this gets you a preview that frames, renders and scrolls correctly
9
+ * and deselects on every click — which looks exactly like selection mode being
10
+ * switched off.
11
+ *
12
+ * Returns `{}` when `editorMode` is false, so the published page renders
13
+ * precisely the markup it rendered before.
14
+ */
15
+ export declare function getPreviewWrapperProps(editorMode: boolean, blockId: string, blockType: string): {
16
+ readonly "data-block-id"?: undefined;
17
+ readonly "data-block-type"?: undefined;
18
+ readonly className?: undefined;
19
+ readonly style?: undefined;
20
+ } | {
21
+ readonly "data-block-id": string;
22
+ readonly "data-block-type": string;
23
+ readonly className: "editor-selectable";
24
+ readonly style: {
25
+ readonly viewTransitionName: `block-${string}`;
26
+ };
27
+ };
28
+ /**
29
+ * Mark the element that draws one editable field.
30
+ *
31
+ * This is the second half of instrumenting a preview, and until recently it was
32
+ * the half with no helper. `getPreviewWrapperProps` covers the block boundary
33
+ * and is named, exported and documented; the per-field attributes were prose in
34
+ * the integration guide with a link to a renderer to copy from. The predictable
35
+ * result is an integration that wraps every block and marks no fields — which
36
+ * frames, renders, selects, scrolls and opens the property panel correctly, and
37
+ * silently has no inline text editing, no field pills and no image buttons in
38
+ * the preview, because every one of those is found by walking
39
+ * `[data-editable-target]`.
40
+ *
41
+ * ```tsx
42
+ * <div className="hero__media" {...editableProps("imageUrl", { kind: "image" })}>
43
+ * <Image src={props.imageUrl} … />
44
+ * </div>
45
+ * ```
46
+ *
47
+ * The path is the same grammar an operation uses — `title`, `cards[0].title`,
48
+ * `links[0].children[1].label` — because it is the same path: what the overlay
49
+ * reads here is what it sends back as the field to patch.
50
+ *
51
+ * **Put it on an element, not on the image.** For an image field the attribute
52
+ * belongs on the wrapper around the `<img>`, never on the image itself: the
53
+ * overlay appends its Change button *into* the marked element, and nothing can
54
+ * be appended into an `<img>`.
55
+ *
56
+ * **`kind` is how a site says what a field is in its own vocabulary.** Without
57
+ * it the overlay has to guess an image from the prop name, against Avocado's
58
+ * own naming (`imageUrl`, `*.src`) — so a site whose field is `photoUrl` or
59
+ * `heroSrc` gets a picker in the property panel and no button in the preview,
60
+ * with no error on either side. Pass the same word the block manifest uses and
61
+ * the guess never runs.
62
+ *
63
+ * There is no `editorMode` argument on purpose. These are inert data attributes
64
+ * that cost a few bytes and do nothing unless the overlay is mounted (all of
65
+ * its styling is scoped under `[data-editor-active]`), and most sites render
66
+ * their blocks through components shared with the public pages, where threading
67
+ * a flag down to every field is the step that does not get done.
68
+ */
69
+ export declare function editableProps(path: string, options?: {
70
+ /** Text for the hover pill. Defaults to the path. */
71
+ label?: string;
72
+ /** What kind of field this is — the same vocabulary as the block manifest. */
73
+ kind?: FieldKind;
74
+ }): {
75
+ readonly "data-editable-kind"?: FieldKind | undefined;
76
+ readonly "data-editable-target": string;
77
+ readonly "data-editable-target-label": string;
78
+ };
@@ -0,0 +1,87 @@
1
+ /*
2
+ * The two attribute helpers that instrument a preview — and nothing else.
3
+ *
4
+ * These are pure functions returning plain data attributes, but until this
5
+ * entry existed the only way to import them was `@avocadostudio-ai/site-sdk/
6
+ * editor`, which also exports `EditorOverlay` and the live-preview provider.
7
+ * Most sites render their blocks through components shared with their public
8
+ * pages, so following our own advice — mark the fields in the components you
9
+ * already have — pulled the whole editor into the public bundle. Measured on a
10
+ * real integration: **+66 kB First Load JS on every page**, for markup that was
11
+ * byte-for-byte identical.
12
+ *
13
+ * `site-sdk/editor` still re-exports both names, so nothing that already
14
+ * imports them breaks. Import them from here in anything a public page can
15
+ * reach.
16
+ */
17
+ /**
18
+ * Mark the element that is one block, so the editor can select it.
19
+ *
20
+ * Selection is built entirely on `[data-block-id]`: a click in the preview
21
+ * resolves through `closest("[data-block-id]")`, and no match is read as
22
+ * "clicked outside any block", which *clears* the selection. Marking fields
23
+ * without this gets you a preview that frames, renders and scrolls correctly
24
+ * and deselects on every click — which looks exactly like selection mode being
25
+ * switched off.
26
+ *
27
+ * Returns `{}` when `editorMode` is false, so the published page renders
28
+ * precisely the markup it rendered before.
29
+ */
30
+ export function getPreviewWrapperProps(editorMode, blockId, blockType) {
31
+ if (!editorMode)
32
+ return {};
33
+ return {
34
+ "data-block-id": blockId,
35
+ "data-block-type": blockType,
36
+ className: "editor-selectable",
37
+ style: { viewTransitionName: `block-${blockId}` }
38
+ };
39
+ }
40
+ /**
41
+ * Mark the element that draws one editable field.
42
+ *
43
+ * This is the second half of instrumenting a preview, and until recently it was
44
+ * the half with no helper. `getPreviewWrapperProps` covers the block boundary
45
+ * and is named, exported and documented; the per-field attributes were prose in
46
+ * the integration guide with a link to a renderer to copy from. The predictable
47
+ * result is an integration that wraps every block and marks no fields — which
48
+ * frames, renders, selects, scrolls and opens the property panel correctly, and
49
+ * silently has no inline text editing, no field pills and no image buttons in
50
+ * the preview, because every one of those is found by walking
51
+ * `[data-editable-target]`.
52
+ *
53
+ * ```tsx
54
+ * <div className="hero__media" {...editableProps("imageUrl", { kind: "image" })}>
55
+ * <Image src={props.imageUrl} … />
56
+ * </div>
57
+ * ```
58
+ *
59
+ * The path is the same grammar an operation uses — `title`, `cards[0].title`,
60
+ * `links[0].children[1].label` — because it is the same path: what the overlay
61
+ * reads here is what it sends back as the field to patch.
62
+ *
63
+ * **Put it on an element, not on the image.** For an image field the attribute
64
+ * belongs on the wrapper around the `<img>`, never on the image itself: the
65
+ * overlay appends its Change button *into* the marked element, and nothing can
66
+ * be appended into an `<img>`.
67
+ *
68
+ * **`kind` is how a site says what a field is in its own vocabulary.** Without
69
+ * it the overlay has to guess an image from the prop name, against Avocado's
70
+ * own naming (`imageUrl`, `*.src`) — so a site whose field is `photoUrl` or
71
+ * `heroSrc` gets a picker in the property panel and no button in the preview,
72
+ * with no error on either side. Pass the same word the block manifest uses and
73
+ * the guess never runs.
74
+ *
75
+ * There is no `editorMode` argument on purpose. These are inert data attributes
76
+ * that cost a few bytes and do nothing unless the overlay is mounted (all of
77
+ * its styling is scoped under `[data-editor-active]`), and most sites render
78
+ * their blocks through components shared with the public pages, where threading
79
+ * a flag down to every field is the step that does not get done.
80
+ */
81
+ export function editableProps(path, options) {
82
+ return {
83
+ "data-editable-target": path,
84
+ "data-editable-target-label": options?.label ?? path,
85
+ ...(options?.kind ? { "data-editable-kind": options.kind } : {})
86
+ };
87
+ }
@@ -97,14 +97,20 @@ test("withAvocado adds what is missing and keeps what was declared", () => {
97
97
  });
98
98
  test("a config that already declares everything is returned unchanged", () => {
99
99
  /*
100
- * The other two halves are switched off because they always have something to
101
- * add — image hosts, and the server externals and this test is about the
102
- * transpile contract on its own: when there is nothing to add, the wrapper
103
- * hands back the very object it was given rather than a copy.
100
+ * The other halves are switched off because they always have something to add
101
+ * — image hosts, the server externals, and now the framing headers — and this
102
+ * test is about the transpile contract on its own: when there is nothing to
103
+ * add, the wrapper hands back the very object it was given rather than a copy.
104
104
  */
105
105
  const root = fixture({ "@avocadostudio-ai/shared": { main: "src/index.ts" } });
106
106
  const input = { transpilePackages: ["@avocadostudio-ai/shared"] };
107
- assert.equal(withAvocado(input, { cwd: root, silent: true, images: false, serverExternals: false }), input);
107
+ assert.equal(withAvocado(input, {
108
+ cwd: root,
109
+ silent: true,
110
+ images: false,
111
+ serverExternals: false,
112
+ framing: false
113
+ }), input);
108
114
  });
109
115
  test("merging image hosts does not disturb the transpile list", () => {
110
116
  const root = fixture({ "@avocadostudio-ai/shared": { main: "src/index.ts" } });
@@ -353,3 +359,35 @@ test("trailingSlash: false leaves the whole thing to the app", () => {
353
359
  const config = withAvocado({ trailingSlash: true }, { cwd: root, silent: true, trailingSlash: false });
354
360
  assert.equal(config.skipTrailingSlashRedirect, undefined);
355
361
  });
362
+ test("the framing headers are derived from the editor allowlist, both spellings", async () => {
363
+ const config = withAvocado({}, { cwd: EMPTY_DIR, env: { EDITOR_CORS_ORIGINS: "http://localhost:4103" } });
364
+ const rules = await config.headers();
365
+ const editorRule = rules.find((r) => r.has?.[0]?.key === "__editor");
366
+ const csp = editorRule.headers.find((h) => h.key === "Content-Security-Policy").value;
367
+ assert.match(csp, /http:\/\/localhost:4103/);
368
+ assert.match(csp, /http:\/\/127\.0\.0\.1:4103/, "the CLI binds 127.0.0.1 and prints it");
369
+ assert.match(csp, /^frame-ancestors 'self'/);
370
+ });
371
+ test("the public rule uses `missing:`, or the browser intersects two CSP headers", async () => {
372
+ const config = withAvocado({}, { cwd: EMPTY_DIR, env: {} });
373
+ const rules = await config.headers();
374
+ const publicRule = rules.find((r) => r.missing?.[0]?.key === "__editor");
375
+ assert.ok(publicRule, "without `missing:` both rules match an editor request");
376
+ assert.equal(publicRule.headers.find((h) => h.key === "Content-Security-Policy").value, "frame-ancestors 'self';");
377
+ assert.equal(publicRule.headers.find((h) => h.key === "X-Frame-Options").value, "SAMEORIGIN");
378
+ });
379
+ test("a site's own headers come first, and framing can be turned off", async () => {
380
+ const own = { source: "/:path*", headers: [{ key: "X-Mine", value: "1" }] };
381
+ const config = withAvocado({ headers: async () => [own] }, { cwd: EMPTY_DIR, env: {} });
382
+ const rules = await config.headers();
383
+ assert.deepEqual(rules[0], own, "Next uses the first match per header");
384
+ assert.equal(rules.length, 3);
385
+ const off = withAvocado({}, { cwd: EMPTY_DIR, env: {}, framing: false });
386
+ assert.equal(off.headers, undefined);
387
+ });
388
+ test("in production nothing is allowed to frame until a deployment names its editor", async () => {
389
+ const config = withAvocado({}, { cwd: EMPTY_DIR, env: { NODE_ENV: "production" } });
390
+ const rules = await config.headers();
391
+ const editorRule = rules.find((r) => r.has?.[0]?.key === "__editor");
392
+ assert.equal(editorRule.headers.find((h) => h.key === "Content-Security-Policy").value, "frame-ancestors 'self';");
393
+ });
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { SharedBlockRenderer, BlockErrorBoundary, getCustomRenderer } from "@avocadostudio-ai/blocks";
3
3
  import { getChromeTypes } from "@avocadostudio-ai/shared";
4
- import { getPreviewWrapperProps } from "./editor.js";
4
+ import { getPreviewWrapperProps } from "./markers.js";
5
5
  /**
6
6
  * Renders a list of blocks with error boundaries.
7
7
  * When `editable` is true, adds preview wrapper attributes for editor overlay selection.
package/next-config.d.ts CHANGED
@@ -44,6 +44,7 @@ export const AVOCADO_SERVER_EXTERNALS: string[]
44
44
  export function withAvocado<
45
45
  T extends {
46
46
  transpilePackages?: string[]
47
+ headers?: () => Promise<unknown[]> | unknown[]
47
48
  images?: { remotePatterns?: unknown[] }
48
49
  serverExternalPackages?: string[]
49
50
  trailingSlash?: boolean
@@ -70,9 +71,28 @@ export function withAvocado<
70
71
  * `createEditorProxy({ trailingSlash: true })`.
71
72
  */
72
73
  trailingSlash?: boolean
74
+ /**
75
+ * Set false to write the framing headers yourself.
76
+ *
77
+ * When true (the default), `withAvocado` appends a pair of `headers()` rules
78
+ * that let the editor frame `?__editor=1` requests — derived from the same
79
+ * `EDITOR_CORS_ORIGINS` / `NEXT_PUBLIC_EDITOR_ORIGIN` allowlist the editor
80
+ * API uses, so the two halves cannot disagree. Your own `headers()` entries
81
+ * come first and win, since Next uses the first match per header.
82
+ */
83
+ framing?: boolean
73
84
  /** Environment to read the orchestrator origin from. Defaults to `process.env`. */
74
85
  env?: Record<string, string | undefined>
75
86
  }
76
87
  ): T
77
88
 
89
+ /** Every origin allowed to frame the preview, both loopback spellings included. */
90
+ export function avocadoEditorOrigins(env?: Record<string, string | undefined>): string[]
91
+
92
+ /** Just the framing headers, for a site that wants them without the rest. */
93
+ export function withAvocadoFraming<T extends object>(
94
+ config?: T,
95
+ env?: Record<string, string | undefined>
96
+ ): T
97
+
78
98
  export default withAvocado
package/next-config.mjs CHANGED
@@ -403,6 +403,88 @@ function builtOrchestratorCore(from, linked) {
403
403
  * as it was. Never throws — a helper that can break `next.config` is worse than
404
404
  * the bug it fixes.
405
405
  */
406
+
407
+ /*
408
+ * The framing rule, derived from the one allowlist the site already has.
409
+ *
410
+ * A library-mode site names the editor's origin in two places and they must
411
+ * agree: CORS on the editor API, and `frame-ancestors` on the preview. Every
412
+ * integration so far hand-wrote the second in `next.config`, and every one of
413
+ * them hardcoded `:4100` — which is right until the editor is served anywhere
414
+ * else, and anyone who already runs the standalone stack has to serve it
415
+ * somewhere else. Then the two halves fail separately and silently: the API
416
+ * answers 200 with no `access-control-allow-origin` (the Sites page reports the
417
+ * site as *offline*) and the browser refuses the frame (the preview is a blank
418
+ * rectangle). Neither names a port, and neither says CORS or CSP.
419
+ *
420
+ * Two rules that are easy to get wrong by hand, and are why this is worth
421
+ * generating:
422
+ *
423
+ * - **Both spellings.** The CLI binds `127.0.0.1` and prints it; every doc says
424
+ * `localhost`. `frame-ancestors` compares origins as strings.
425
+ * - **`missing:` on the public rule.** Without it both rules match an editor
426
+ * request, the browser receives two CSP headers, and it enforces their
427
+ * *intersection* — putting `frame-ancestors 'self'` back and blocking the
428
+ * frame it just allowed.
429
+ */
430
+ function bothLoopbackSpellings(origin) {
431
+ if (origin.includes("localhost")) return [origin, origin.replace("localhost", "127.0.0.1")]
432
+ if (origin.includes("127.0.0.1")) return [origin, origin.replace("127.0.0.1", "localhost")]
433
+ return [origin]
434
+ }
435
+
436
+ export function avocadoEditorOrigins(env = process.env) {
437
+ const parse = (value) =>
438
+ (value ?? "")
439
+ .split(",")
440
+ .map((entry) => entry.trim().replace(/\/+$/, ""))
441
+ .filter(Boolean)
442
+
443
+ // Mirrors `getEditorCorsOrigins`: a deployment names its editor, development
444
+ // assumes the one the CLI serves by default.
445
+ const defaults = env.NODE_ENV === "production" ? [] : ["http://localhost:4100"]
446
+ const named = [...parse(env.EDITOR_CORS_ORIGINS), ...parse(env.NEXT_PUBLIC_EDITOR_ORIGIN)]
447
+ return [...new Set([...defaults, ...named].flatMap(bothLoopbackSpellings))]
448
+ }
449
+
450
+ export function withAvocadoFraming(config = {}, env = process.env) {
451
+ const origins = avocadoEditorOrigins(env)
452
+ const existing = config.headers
453
+ const framing = [
454
+ {
455
+ source: "/:path*",
456
+ has: [{ type: "query", key: "__editor" }],
457
+ headers: [
458
+ {
459
+ key: "Content-Security-Policy",
460
+ value: `frame-ancestors 'self'${origins.length > 0 ? ` ${origins.join(" ")}` : ""};`
461
+ }
462
+ ]
463
+ },
464
+ {
465
+ source: "/:path*",
466
+ missing: [{ type: "query", key: "__editor" }],
467
+ headers: [
468
+ { key: "Content-Security-Policy", value: "frame-ancestors 'self';" },
469
+ { key: "X-Frame-Options", value: "SAMEORIGIN" }
470
+ ]
471
+ }
472
+ ]
473
+
474
+ return {
475
+ ...config,
476
+ async headers() {
477
+ const own = typeof existing === "function" ? ((await existing()) ?? []) : []
478
+ /*
479
+ * The site's own rules come first: Next uses the first matching entry per
480
+ * header, so a site that already set its own CSP keeps it and this adds
481
+ * nothing. Opt out entirely with `withAvocado(config, { framing: false })`.
482
+ */
483
+ return [...own, ...framing]
484
+ }
485
+ }
486
+ }
487
+
406
488
  export function withAvocado(config = {}, options = {}) {
407
489
  const {
408
490
  cwd = process.cwd(),
@@ -410,6 +492,7 @@ export function withAvocado(config = {}, options = {}) {
410
492
  images = true,
411
493
  serverExternals = true,
412
494
  trailingSlash = true,
495
+ framing = true,
413
496
  env = process.env,
414
497
  } = options
415
498
 
@@ -434,9 +517,10 @@ export function withAvocado(config = {}, options = {}) {
434
517
  */
435
518
  const base = trailingSlash ? withAvocadoTrailingSlash(config) : config
436
519
  const withImages = images ? withAvocadoImages(base, env) : base
520
+ const framed = framing ? withAvocadoFraming(withImages, env) : withImages
437
521
  const result = serverExternals
438
- ? withAvocadoServerExternals(withImages, linked === null ? [] : builtOrchestratorCore(cwd, linked))
439
- : withImages
522
+ ? withAvocadoServerExternals(framed, linked === null ? [] : builtOrchestratorCore(cwd, linked))
523
+ : framed
440
524
 
441
525
  if (linked === null) return result
442
526
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/site-sdk",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -21,6 +21,21 @@
21
21
  "import": "./dist/editor.js",
22
22
  "default": "./dist/editor.js"
23
23
  },
24
+ "./markers": {
25
+ "types": "./dist/markers.d.ts",
26
+ "import": "./dist/markers.js",
27
+ "default": "./dist/markers.js"
28
+ },
29
+ "./blocks": {
30
+ "types": "./dist/blocks.d.ts",
31
+ "import": "./dist/blocks.js",
32
+ "default": "./dist/blocks.js"
33
+ },
34
+ "./coverage": {
35
+ "types": "./dist/coverage.d.ts",
36
+ "import": "./dist/coverage.js",
37
+ "default": "./dist/coverage.js"
38
+ },
24
39
  "./routes": {
25
40
  "types": "./dist/routes.d.ts",
26
41
  "import": "./dist/routes.js",
@@ -107,16 +122,16 @@
107
122
  ],
108
123
  "dependencies": {
109
124
  "zod": "^4.3.6",
110
- "@avocadostudio-ai/blocks": "^0.4.0",
111
- "@avocadostudio-ai/preview-adapter": "^0.4.0",
112
- "@avocadostudio-ai/shared": "^0.4.0"
125
+ "@avocadostudio-ai/blocks": "^0.5.0",
126
+ "@avocadostudio-ai/preview-adapter": "^0.5.0",
127
+ "@avocadostudio-ai/shared": "^0.5.0"
113
128
  },
114
129
  "peerDependencies": {
115
130
  "next": ">=15.0.0",
116
131
  "react": ">=19.0.0",
117
132
  "react-dom": ">=19.0.0",
118
133
  "better-sqlite3": ">=12.0.0",
119
- "@avocadostudio-ai/orchestrator-core": "^0.4.0"
134
+ "@avocadostudio-ai/orchestrator-core": "^0.5.0"
120
135
  },
121
136
  "peerDependenciesMeta": {
122
137
  "@avocadostudio-ai/orchestrator-core": {