@avocadostudio-ai/site-sdk 0.6.0 → 0.7.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.
@@ -0,0 +1,243 @@
1
+ import type { FieldKind, ImageSpec } from "@avocadostudio-ai/shared";
2
+ /** Carried by every field kind. */
3
+ type FieldCommon = {
4
+ /** What the property panel calls it. Defaults to a humanised key. */
5
+ label?: string;
6
+ /**
7
+ * The publisher needs it, the planner must never see it, nobody may edit it.
8
+ *
9
+ * A CMS row identity — `_uid`, `_key`, `_id` — is content-shaped and is not
10
+ * content. Declaring it keeps the merge able to match rows by it while
11
+ * keeping it out of the panel and out of the planner's view.
12
+ */
13
+ internal?: boolean;
14
+ /**
15
+ * `false` for a field with one value for every language.
16
+ *
17
+ * The default is `true`, which is right for a field-level-i18n CMS: most
18
+ * things are translated and the exceptions are declared.
19
+ *
20
+ * **It means the bare key, which is not the same as the default language.**
21
+ * The two coincide on a CMS that localises into a suffixed sibling
22
+ * (`title` and `title__i18n__fr`) and diverge on one that localises into an
23
+ * object under the key, where the default language lives at `title.de` and a
24
+ * non-localised field lives at `title` with no container at all.
25
+ *
26
+ * On the second kind of CMS this is not optional decoration. An image is one
27
+ * asset reference for every language and a list is one array; leave them
28
+ * declared as localised and the projection looks inside a container that is
29
+ * not there, so the image reads as empty and the list as having no rows.
30
+ * There is deliberately no implicit fallback to the bare key, because a read
31
+ * that fell back would pair with a write that did not — and reading one place
32
+ * while writing another is how a lens corrupts a document.
33
+ */
34
+ localized?: boolean;
35
+ };
36
+ /**
37
+ * How one CMS field becomes one or more Avocado props, and back.
38
+ *
39
+ * The kinds are the union of what two real integrations needed, mapped onto the
40
+ * `FieldKind` vocabulary the property panel already speaks — so a table entry
41
+ * is also the panel metadata, with no second mapping to keep in step.
42
+ */
43
+ export type FieldSpec =
44
+ /** Plain string. `multiline` changes the control, not the storage. */
45
+ (FieldCommon & {
46
+ kind: "text";
47
+ multiline?: boolean;
48
+ inlineEditable?: boolean;
49
+ })
50
+ /** A rich-text document, edited as a document rather than flattened. */
51
+ | (FieldCommon & {
52
+ kind: "richtext";
53
+ inline?: boolean;
54
+ })
55
+ /** An image. Projects to a URL string plus a companion alt prop. */
56
+ | (FieldCommon & {
57
+ kind: "image";
58
+ imageSpec?: ImageSpec;
59
+ })
60
+ /** A document asset — a menu PDF. Projects to a URL string, no alt. */
61
+ | (FieldCommon & {
62
+ kind: "file";
63
+ })
64
+ /** A link. Projects to an href string. */
65
+ | (FieldCommon & {
66
+ kind: "link";
67
+ })
68
+ /**
69
+ * A pointer to another document in the CMS.
70
+ *
71
+ * Distinct from `link` because the href is a *render* of it: the same stored
72
+ * reference serves `/faq` and `/fr/faq`, so a projection can never be the
73
+ * inverse of the source, and writing the rendered href back replaces the
74
+ * reference with a hard-coded URL that stops following renames.
75
+ */
76
+ | (FieldCommon & {
77
+ kind: "reference";
78
+ }) | (FieldCommon & {
79
+ kind: "enum";
80
+ options: readonly string[];
81
+ }) | (FieldCommon & {
82
+ kind: "boolean";
83
+ }) | (FieldCommon & {
84
+ kind: "number";
85
+ }) | (FieldCommon & {
86
+ kind: "headingLevel";
87
+ })
88
+ /** An array of plain strings — bullets. */
89
+ | (FieldCommon & {
90
+ kind: "stringList";
91
+ })
92
+ /** An array of bare images, not of documents — a logo strip, a gallery. */
93
+ | (FieldCommon & {
94
+ kind: "imageList";
95
+ })
96
+ /**
97
+ * An array of child rows.
98
+ *
99
+ * `of` names the child types admitted, for a CMS where each row is its own
100
+ * document and carries its own type; `itemFields` describes the row inline,
101
+ * for a CMS where the rows are plain objects. Exactly one of the two.
102
+ */
103
+ | (FieldCommon & {
104
+ kind: "list";
105
+ of: readonly string[];
106
+ }) | (FieldCommon & {
107
+ kind: "list";
108
+ itemFields: Record<string, FieldSpec>;
109
+ });
110
+ /** One block type, as the site's own CMS names it. */
111
+ export type BlockSpec = {
112
+ /** What the block is called in the editor. */
113
+ displayName: string;
114
+ /** Blocks that may appear directly in a page body. Child rows are not. */
115
+ topLevel?: boolean;
116
+ category?: "content" | "media" | "navigation" | "conversion" | "layout";
117
+ fields: Record<string, FieldSpec>;
118
+ };
119
+ /**
120
+ * The table: every type Avocado may edit on this site, keyed by the CMS's own
121
+ * name for it.
122
+ *
123
+ * Use the CMS's spelling — `hero_section`, not `SiteHeroSection`. `BlockType`
124
+ * is a free string, and keeping the name identical in the CMS, in the manifest
125
+ * and in a publish diff makes the adapter an identity map on the type instead
126
+ * of a translation nobody can grep for.
127
+ */
128
+ export type FieldTable = Record<string, BlockSpec>;
129
+ /**
130
+ * Where one language's value for a key is stored.
131
+ *
132
+ * Field-level i18n comes in two shapes and both fit here: a suffixed sibling
133
+ * key (`title__i18n__fr`, Storyblok) and a per-locale object under the key
134
+ * itself (`{de, fr, en}`, Sanity and Contentful) — the second by giving `path`
135
+ * a second segment.
136
+ */
137
+ export type LocaleLens<L extends string = string> = {
138
+ /** The language whose value lives in the bare key. */
139
+ default: L;
140
+ /** Every language this site publishes. */
141
+ languages: readonly L[];
142
+ /**
143
+ * The path at which `lang`'s value for `key` is stored, as one or two
144
+ * segments: `["title__i18n__fr"]` or `["title", "fr"]`.
145
+ */
146
+ path(key: string, lang: L): readonly [string] | readonly [string, string];
147
+ /**
148
+ * `false` when the CMS itself says this field is not translatable.
149
+ *
150
+ * Storyblok leaves old translations in the document after a field stops being
151
+ * marked translatable, and the Delivery API ignores them — so a projection
152
+ * that reads them shows text the site has not rendered in years, and a merge
153
+ * that writes them produces a publish nobody can explain. Defaults to `true`.
154
+ */
155
+ translatable?(type: string, key: string, lang: L): boolean;
156
+ };
157
+ /** What a codec is told beyond the value itself. */
158
+ export type CodecContext = {
159
+ /** The field's own declaration, for `options`, `of`, `itemFields`. */
160
+ spec: FieldSpec;
161
+ /** The language being read or written. */
162
+ lang: string;
163
+ /** The block type this field sits on, for messages. */
164
+ type: string;
165
+ /** A path like `/preise > hero_section > title`, for messages. */
166
+ where: string;
167
+ };
168
+ /**
169
+ * The outcome of merging one edited value back.
170
+ *
171
+ * `null` means *unchanged*, and it is the most important of the three. A CMS
172
+ * with per-language fallback resolves a missing translation to the default
173
+ * language, which is correct on screen and a lie in storage: merge a projection
174
+ * back wholesale and every one of those fallbacks becomes a real, fabricated
175
+ * translation — dozens per publish, each identical to what the page already
176
+ * showed, so nothing looks wrong. A codec returns a value only when the value
177
+ * really differs from what the source holds.
178
+ */
179
+ export type MergeOutcome = null | {
180
+ value: unknown;
181
+ }
182
+ /** The edit cannot be stored, and the editor is owed the reason. */
183
+ | {
184
+ warning: string;
185
+ };
186
+ /**
187
+ * How one CMS reads and writes one kind of field.
188
+ *
189
+ * `project` returns the props this field contributes — more than one for a kind
190
+ * with a companion, like an image and its alt text — so the 1-to-2 case needs
191
+ * no special handling anywhere else.
192
+ */
193
+ export type FieldCodec = {
194
+ project(key: string, raw: unknown, ctx: CodecContext): Record<string, unknown>;
195
+ merge(key: string, props: Record<string, unknown>, before: unknown, ctx: CodecContext): MergeOutcome;
196
+ /** The panel metadata for this kind, when it is not simply `kind`. */
197
+ fieldKind?: FieldKind;
198
+ };
199
+ /**
200
+ * What an image field's two props are called.
201
+ *
202
+ * An image contributes a URL and an alt text, and the names are load-bearing in
203
+ * a place nothing checks: the editor's asset picker keys on the `*imageUrl` /
204
+ * `*Image` name pattern rather than on any declaration. Two integrations chose
205
+ * differently — `image` + `image_alt` against `imageUrl` + `imageAlt` — and
206
+ * both were right for their site.
207
+ *
208
+ * It lives on `Primitives` so the projection and the panel registration read
209
+ * the same answer. Held separately they are two things that must agree with
210
+ * nothing checking that they do, which is the defect class this file exists to
211
+ * delete rather than reproduce.
212
+ */
213
+ export type ImageNaming = {
214
+ url(key: string): string;
215
+ alt(key: string): string;
216
+ };
217
+ /** `suffixNaming("", "_alt")` gives `image` + `image_alt`. */
218
+ export declare function suffixNaming(urlSuffix: string, altSuffix: string): ImageNaming;
219
+ /**
220
+ * A CMS's answers, keyed by field kind.
221
+ *
222
+ * The scalar kinds have CMS-independent defaults, so a pack supplies only what
223
+ * its CMS actually shapes differently — in practice `image`, `file`, `link`,
224
+ * `reference`, `richtext`, `imageList` and `list`.
225
+ */
226
+ export type Primitives = {
227
+ codecs: Partial<Record<FieldSpec["kind"], FieldCodec>>;
228
+ /**
229
+ * The key under which a child row carries its own identity (`_uid`, `_key`).
230
+ *
231
+ * The merge matches rows by it, which is what keeps a list edit a merge
232
+ * rather than a replace — and it is why row identity has to survive the round
233
+ * trip rather than being stripped as "not content".
234
+ */
235
+ rowIdKey: string;
236
+ /** The key under which a child row carries its own type, for `of` lists. */
237
+ rowTypeKey?: string;
238
+ /** A fresh row identity, for a row the editor added. */
239
+ newRowId(): string;
240
+ /** What an image field's URL and alt props are called. */
241
+ imageNaming: ImageNaming;
242
+ };
243
+ export {};
@@ -0,0 +1,28 @@
1
+ /*
2
+ * One table describing the fields a CMS-backed site lets Avocado edit — and
3
+ * the four things derived from it.
4
+ *
5
+ * Two integrations reached this design independently, for different CMSes, and
6
+ * each opened its own file by explaining it in near-identical words: four
7
+ * things have to agree about every block — the Zod schema the operations engine
8
+ * validates against, the field metadata the property panel draws, the
9
+ * projection that turns a CMS document into props, and the merge that writes
10
+ * edited props back — and if they are written four times they disagree within a
11
+ * week.
12
+ *
13
+ * Both were right about the remedy and both had to build it themselves, in
14
+ * mutually unreadable vocabularies (`{ t: "asset" }` against
15
+ * `{ kind: 'image' }`), so nothing carried from the first integration to the
16
+ * second. The derivation is the same program either way: what differs between
17
+ * two CMSes is how one value of a given kind is read and written, which is the
18
+ * `FieldCodec` below, and where a language's value is stored, which is the
19
+ * `LocaleLens`.
20
+ *
21
+ * What stays site-specific is the table. It is the content model, it is
22
+ * irreducible, and it is the whole of what an adopter on a CMS we already know
23
+ * should have to write.
24
+ */
25
+ /** `suffixNaming("", "_alt")` gives `image` + `image_alt`. */
26
+ export function suffixNaming(urlSuffix, altSuffix) {
27
+ return { url: (key) => `${key}${urlSuffix}`, alt: (key) => `${key}${altSuffix}` };
28
+ }
package/dist/markers.d.ts CHANGED
@@ -104,7 +104,43 @@ export declare function editableProps(path: string, options?: {
104
104
  * `sections[0]` scope makes a child's `text` into `sections[0].left[1].text`.
105
105
  * A block boundary ends the composition, so a scope outside a block never
106
106
  * reaches into it.
107
+ *
108
+ * **The scope needs an element, and the element needs to not be there.** A
109
+ * scope is a DOM attribute, so it wants an ancestor to sit on — and a list
110
+ * whose rows map straight into a flex or grid container has none to offer.
111
+ * Adding a plain wrapper around each row gives the scope its element and makes
112
+ * the wrapper the flex item, so the layout the rows had is now the layout of a
113
+ * column of wrappers: gaps land in different places, `align-items` applies to
114
+ * the wrong box, and a grid's rows stop being the grid's children at all.
115
+ *
116
+ * `display: contents` is the whole answer — the element stays in the tree for
117
+ * anything walking it, and lays out as if it were not there, so the rows go on
118
+ * being their parent's children. Pass `{ display: "contents" }` and the helper
119
+ * writes it:
120
+ *
121
+ * ```tsx
122
+ * <div className="flex flex-col gap-6">
123
+ * {props.items.map((item, i) => (
124
+ * <div key={item.id} {...editableScopeProps(`items[${i}]`, { display: "contents" })}>
125
+ * <FaqRow item={item} />
126
+ * </div>
127
+ * ))}
128
+ * </div>
129
+ * ```
130
+ *
131
+ * Leave it off when the wrapper is one you were going to render anyway — a
132
+ * row that already has a `<li>` or a card `<div>` around it should carry the
133
+ * scope on that, not gain a second element to hold it.
107
134
  */
108
- export declare function editableScopeProps(scope: string): {
135
+ export declare function editableScopeProps(scope: string, options?: {
136
+ /**
137
+ * `"contents"` adds `style={{ display: "contents" }}`, for a wrapper that
138
+ * exists only to carry the scope and must not become a box.
139
+ */
140
+ display?: "contents";
141
+ }): {
142
+ readonly style?: {
143
+ display: "contents";
144
+ } | undefined;
109
145
  readonly "data-editable-scope": string;
110
146
  };
package/dist/markers.js CHANGED
@@ -113,7 +113,37 @@ export function editableProps(path, options) {
113
113
  * `sections[0]` scope makes a child's `text` into `sections[0].left[1].text`.
114
114
  * A block boundary ends the composition, so a scope outside a block never
115
115
  * reaches into it.
116
+ *
117
+ * **The scope needs an element, and the element needs to not be there.** A
118
+ * scope is a DOM attribute, so it wants an ancestor to sit on — and a list
119
+ * whose rows map straight into a flex or grid container has none to offer.
120
+ * Adding a plain wrapper around each row gives the scope its element and makes
121
+ * the wrapper the flex item, so the layout the rows had is now the layout of a
122
+ * column of wrappers: gaps land in different places, `align-items` applies to
123
+ * the wrong box, and a grid's rows stop being the grid's children at all.
124
+ *
125
+ * `display: contents` is the whole answer — the element stays in the tree for
126
+ * anything walking it, and lays out as if it were not there, so the rows go on
127
+ * being their parent's children. Pass `{ display: "contents" }` and the helper
128
+ * writes it:
129
+ *
130
+ * ```tsx
131
+ * <div className="flex flex-col gap-6">
132
+ * {props.items.map((item, i) => (
133
+ * <div key={item.id} {...editableScopeProps(`items[${i}]`, { display: "contents" })}>
134
+ * <FaqRow item={item} />
135
+ * </div>
136
+ * ))}
137
+ * </div>
138
+ * ```
139
+ *
140
+ * Leave it off when the wrapper is one you were going to render anyway — a
141
+ * row that already has a `<li>` or a card `<div>` around it should carry the
142
+ * scope on that, not gain a second element to hold it.
116
143
  */
117
- export function editableScopeProps(scope) {
118
- return { "data-editable-scope": scope };
144
+ export function editableScopeProps(scope, options) {
145
+ return {
146
+ "data-editable-scope": scope,
147
+ ...(options?.display === "contents" ? { style: { display: "contents" } } : {})
148
+ };
119
149
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,35 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { test } from "node:test";
3
+ import { editableProps, editableScopeProps, getPreviewWrapperProps } from "./markers.js";
4
+ test("editableProps defaults the label to the path and omits kind when unset", () => {
5
+ assert.deepEqual(editableProps("cards[0].title"), {
6
+ "data-editable-target": "cards[0].title",
7
+ "data-editable-target-label": "cards[0].title"
8
+ });
9
+ });
10
+ test("editableProps carries an explicit label and kind", () => {
11
+ assert.deepEqual(editableProps("photoUrl", { label: "Photo", kind: "image" }), {
12
+ "data-editable-target": "photoUrl",
13
+ "data-editable-target-label": "Photo",
14
+ "data-editable-kind": "image"
15
+ });
16
+ });
17
+ test("editableScopeProps writes only the scope by default", () => {
18
+ assert.deepEqual(editableScopeProps("items[3]"), { "data-editable-scope": "items[3]" });
19
+ });
20
+ /*
21
+ * A scope wants a DOM ancestor, and a list that maps its rows straight into a
22
+ * flex container has none — so the wrapper you add to hold it becomes the flex
23
+ * item and the layout moves. `display: contents` keeps the element in the tree
24
+ * the overlay walks and out of the box tree that lays the rows out.
25
+ */
26
+ test("editableScopeProps can make its wrapper vanish from layout", () => {
27
+ assert.deepEqual(editableScopeProps("items[3]", { display: "contents" }), {
28
+ "data-editable-scope": "items[3]",
29
+ style: { display: "contents" }
30
+ });
31
+ });
32
+ test("getPreviewWrapperProps renders nothing outside editor mode", () => {
33
+ assert.deepEqual(getPreviewWrapperProps(false, "b1", "Hero"), {});
34
+ assert.equal(getPreviewWrapperProps(true, "b1", "Hero")["data-block-id"], "b1");
35
+ });
package/dist/proxy.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import { DEFAULT_PREVIEW_ROUTE, buildEditorMatcher } from "./editor-matcher.js";
3
+ import { EDITOR_RENDER_HEADER } from "./editor-render.js";
3
4
  export { DEFAULT_PREVIEW_ROUTE, buildEditorMatcher } from "./editor-matcher.js";
4
5
  /**
5
6
  * Create a Next.js proxy function that rewrites editor/draft requests
@@ -118,5 +119,20 @@ export function editorPreviewRewrite(request, options) {
118
119
  return null;
119
120
  const url = request.nextUrl.clone();
120
121
  url.pathname = `${previewRoute}${url.pathname}`;
121
- return NextResponse.rewrite(url);
122
+ /*
123
+ * The one fact a layout can get at.
124
+ *
125
+ * A page learns it is being previewed from `resolveEditorContext(searchParams)`.
126
+ * A layout receives no `searchParams`, and the root layout is exactly where a
127
+ * site mounts its consent banner, analytics and tag manager — so every
128
+ * integration loads all three into the editor iframe until somebody notices
129
+ * the pageviews. Stamping the rewritten request lets `isEditorRender()` answer
130
+ * in a layout without the site threading anything down to it.
131
+ *
132
+ * Set, not appended: a client-supplied header of the same name is replaced, so
133
+ * what a layout reads here is the proxy's own answer.
134
+ */
135
+ const headers = new Headers(request.headers);
136
+ headers.set(EDITOR_RENDER_HEADER, "1");
137
+ return NextResponse.rewrite(url, { request: { headers } });
122
138
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/site-sdk",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -26,6 +26,21 @@
26
26
  "import": "./dist/markers.js",
27
27
  "default": "./dist/markers.js"
28
28
  },
29
+ "./lens": {
30
+ "types": "./dist/lens/index.d.ts",
31
+ "import": "./dist/lens/index.js",
32
+ "default": "./dist/lens/index.js"
33
+ },
34
+ "./lens/storyblok": {
35
+ "types": "./dist/lens/storyblok.d.ts",
36
+ "import": "./dist/lens/storyblok.js",
37
+ "default": "./dist/lens/storyblok.js"
38
+ },
39
+ "./lens/sanity": {
40
+ "types": "./dist/lens/sanity.d.ts",
41
+ "import": "./dist/lens/sanity.js",
42
+ "default": "./dist/lens/sanity.js"
43
+ },
29
44
  "./blocks": {
30
45
  "types": "./dist/blocks.d.ts",
31
46
  "import": "./dist/blocks.js",
@@ -122,16 +137,17 @@
122
137
  ],
123
138
  "dependencies": {
124
139
  "zod": "^4.3.6",
125
- "@avocadostudio-ai/blocks": "^0.6.0",
126
- "@avocadostudio-ai/preview-adapter": "^0.6.0",
127
- "@avocadostudio-ai/shared": "^0.6.0"
140
+ "@avocadostudio-ai/blocks": "^0.7.0",
141
+ "@avocadostudio-ai/richtext": "^0.7.0",
142
+ "@avocadostudio-ai/shared": "^0.7.0",
143
+ "@avocadostudio-ai/preview-adapter": "^0.7.0"
128
144
  },
129
145
  "peerDependencies": {
130
146
  "next": ">=15.0.0",
131
147
  "react": ">=19.0.0",
132
148
  "react-dom": ">=19.0.0",
133
149
  "better-sqlite3": ">=12.0.0",
134
- "@avocadostudio-ai/orchestrator-core": "^0.6.0"
150
+ "@avocadostudio-ai/orchestrator-core": "^0.7.0"
135
151
  },
136
152
  "peerDependenciesMeta": {
137
153
  "@avocadostudio-ai/orchestrator-core": {