@avocadostudio-ai/site-sdk 0.5.1 → 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.
- package/dist/cli/register-notice.d.ts +20 -0
- package/dist/cli/register-notice.js +34 -0
- package/dist/cli/register-notice.test.d.ts +1 -0
- package/dist/cli/register-notice.test.js +21 -0
- package/dist/cli/register.js +7 -2
- package/dist/draft.d.ts +1 -0
- package/dist/draft.js +3 -0
- package/dist/editor-render.d.ts +43 -0
- package/dist/editor-render.js +71 -0
- package/dist/editor-render.test.d.ts +1 -0
- package/dist/editor-render.test.js +44 -0
- package/dist/editor.d.ts +1 -1
- package/dist/editor.js +1 -1
- package/dist/lens/create-lens.d.ts +48 -0
- package/dist/lens/create-lens.js +349 -0
- package/dist/lens/index.d.ts +7 -0
- package/dist/lens/index.js +25 -0
- package/dist/lens/lens.test.d.ts +1 -0
- package/dist/lens/lens.test.js +221 -0
- package/dist/lens/register.d.ts +34 -0
- package/dist/lens/register.js +148 -0
- package/dist/lens/register.test.d.ts +1 -0
- package/dist/lens/register.test.js +81 -0
- package/dist/lens/sanity.d.ts +28 -0
- package/dist/lens/sanity.js +187 -0
- package/dist/lens/scalar-codecs.d.ts +26 -0
- package/dist/lens/scalar-codecs.js +92 -0
- package/dist/lens/storyblok.d.ts +28 -0
- package/dist/lens/storyblok.js +232 -0
- package/dist/lens/types.d.ts +243 -0
- package/dist/lens/types.js +28 -0
- package/dist/markers.d.ts +68 -0
- package/dist/markers.js +62 -0
- package/dist/markers.test.d.ts +1 -0
- package/dist/markers.test.js +35 -0
- package/dist/middleware.d.ts +1 -0
- package/dist/middleware.js +6 -0
- package/dist/proxy.d.ts +26 -0
- package/dist/proxy.js +88 -26
- package/dist/proxy.test.js +61 -2
- package/package.json +21 -5
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Storyblok's answers to the six questions a lens asks.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is a fact about Storyblok rather than about a site: an asset
|
|
5
|
+
* is `{ fieldtype, filename, alt }`, a link is a multilink whose `linktype`
|
|
6
|
+
* decides whether its href is editable at all, a child row is a blok carrying
|
|
7
|
+
* `_uid` and `component`, and rich text is a ProseMirror document — the pivot
|
|
8
|
+
* itself, which is why the converter is a rename rather than a translation.
|
|
9
|
+
*/
|
|
10
|
+
import { fromStoryblok, toStoryblok, isEmptyRichText } from "@avocadostudio-ai/richtext";
|
|
11
|
+
import { changed } from "./scalar-codecs.js";
|
|
12
|
+
import { suffixNaming } from "./types.js";
|
|
13
|
+
const isObj = (v) => v != null && typeof v === "object" && !Array.isArray(v);
|
|
14
|
+
const str = (v) => (v == null ? "" : String(v));
|
|
15
|
+
export function assetUrl(asset) {
|
|
16
|
+
if (!asset)
|
|
17
|
+
return "";
|
|
18
|
+
if (typeof asset === "string")
|
|
19
|
+
return asset;
|
|
20
|
+
return str(asset.filename);
|
|
21
|
+
}
|
|
22
|
+
export function assetAlt(asset) {
|
|
23
|
+
if (!isObj(asset))
|
|
24
|
+
return "";
|
|
25
|
+
return str(asset.alt);
|
|
26
|
+
}
|
|
27
|
+
export function linkHref(link) {
|
|
28
|
+
if (!link)
|
|
29
|
+
return "";
|
|
30
|
+
if (typeof link === "string")
|
|
31
|
+
return link;
|
|
32
|
+
const l = link;
|
|
33
|
+
return str(l.url || l.cached_url || l.href);
|
|
34
|
+
}
|
|
35
|
+
/** A link that points at a story is a reference, whatever it renders as. */
|
|
36
|
+
export function isStoryLink(link) {
|
|
37
|
+
return isObj(link) && link.linktype === "story";
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* An asset object built from a URL, keeping whatever the source knew.
|
|
41
|
+
*
|
|
42
|
+
* The id must not be carried over when the URL did not come from Storyblok's
|
|
43
|
+
* asset service: there is no asset record behind it, and an id pointing at the
|
|
44
|
+
* image it replaced is worse than no id at all.
|
|
45
|
+
*/
|
|
46
|
+
function assetFrom(url, alt, before) {
|
|
47
|
+
const base = isObj(before) ? before : {};
|
|
48
|
+
return {
|
|
49
|
+
fieldtype: "asset",
|
|
50
|
+
...base,
|
|
51
|
+
id: url.includes("a.storyblok.com") ? (base.id ?? null) : null,
|
|
52
|
+
filename: url,
|
|
53
|
+
...(alt === undefined ? {} : { alt })
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const imageCodec = (naming) => ({
|
|
57
|
+
project(key, raw) {
|
|
58
|
+
return { [naming.url(key)]: assetUrl(raw), [naming.alt(key)]: assetAlt(raw) };
|
|
59
|
+
},
|
|
60
|
+
merge(key, props, before) {
|
|
61
|
+
const urlKey = naming.url(key);
|
|
62
|
+
const altKey = naming.alt(key);
|
|
63
|
+
if (!(urlKey in props) && !(altKey in props))
|
|
64
|
+
return null;
|
|
65
|
+
const nextUrl = str(props[urlKey] ?? assetUrl(before));
|
|
66
|
+
const nextAlt = str(props[altKey] ?? assetAlt(before));
|
|
67
|
+
if (nextUrl === assetUrl(before) && nextAlt === assetAlt(before))
|
|
68
|
+
return null;
|
|
69
|
+
// Rule 2: clearing a field that was never set for this language is a diff
|
|
70
|
+
// with no visible effect. Clearing one that was set is real.
|
|
71
|
+
if (!nextUrl)
|
|
72
|
+
return assetUrl(before) ? { value: null } : null;
|
|
73
|
+
return { value: assetFrom(nextUrl, nextAlt, before) };
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
const fileCodec = {
|
|
77
|
+
project(key, raw) {
|
|
78
|
+
return { [key]: assetUrl(raw) };
|
|
79
|
+
},
|
|
80
|
+
merge(key, props, before) {
|
|
81
|
+
if (!(key in props))
|
|
82
|
+
return null;
|
|
83
|
+
const nextUrl = str(props[key]);
|
|
84
|
+
if (nextUrl === assetUrl(before))
|
|
85
|
+
return null;
|
|
86
|
+
if (!nextUrl && !assetUrl(before))
|
|
87
|
+
return null;
|
|
88
|
+
return { value: nextUrl ? assetFrom(nextUrl, undefined, before) : null };
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* A link, and the one shape of it that cannot be written.
|
|
93
|
+
*
|
|
94
|
+
* Storyblok stores a story link as `faq` and serves `/fr/faq` on the French
|
|
95
|
+
* page and `/faq` on the German one — the same stored value, two different
|
|
96
|
+
* strings, neither of them what is in the document. Flattening it to an href
|
|
97
|
+
* therefore cannot round-trip: the projection never equals the source, so every
|
|
98
|
+
* publish of a page nobody edited wants to rewrite every link on it, and
|
|
99
|
+
* writing the rendered href back replaces the reference with a hard-coded URL
|
|
100
|
+
* that stops following renames — the one thing the reference was for.
|
|
101
|
+
*
|
|
102
|
+
* External links have no such problem, because the stored value *is* the href.
|
|
103
|
+
* Those stay editable, which covers the case that actually matters: booking and
|
|
104
|
+
* shop URLs.
|
|
105
|
+
*/
|
|
106
|
+
const linkCodec = {
|
|
107
|
+
project(key, raw) {
|
|
108
|
+
return { [key]: linkHref(raw) };
|
|
109
|
+
},
|
|
110
|
+
merge(key, props, before, ctx) {
|
|
111
|
+
if (!(key in props))
|
|
112
|
+
return null;
|
|
113
|
+
const nextHref = str(props[key]);
|
|
114
|
+
if (nextHref === linkHref(before))
|
|
115
|
+
return null;
|
|
116
|
+
if (!nextHref && !linkHref(before))
|
|
117
|
+
return null;
|
|
118
|
+
if (isStoryLink(before)) {
|
|
119
|
+
return {
|
|
120
|
+
warning: `"${key}" points at a page in the CMS, not at a URL. Its address is derived from ` +
|
|
121
|
+
`that page and cannot be edited here — move or rename the page in Storyblok instead.`
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const base = isObj(before) ? before : {};
|
|
125
|
+
return { value: { fieldtype: "multilink", ...base, linktype: "url", url: nextHref, cached_url: nextHref } };
|
|
126
|
+
},
|
|
127
|
+
fieldKind: "link"
|
|
128
|
+
};
|
|
129
|
+
/** A reference is never written from a rendered href. It is read-only here. */
|
|
130
|
+
const referenceCodec = {
|
|
131
|
+
project(key, raw) {
|
|
132
|
+
return { [key]: linkHref(raw) };
|
|
133
|
+
},
|
|
134
|
+
merge(key, props, before) {
|
|
135
|
+
if (!(key in props))
|
|
136
|
+
return null;
|
|
137
|
+
if (str(props[key]) === linkHref(before))
|
|
138
|
+
return null;
|
|
139
|
+
return {
|
|
140
|
+
warning: `"${key}" is a reference to another document. Its address is derived from that ` +
|
|
141
|
+
`document and cannot be edited here.`
|
|
142
|
+
};
|
|
143
|
+
},
|
|
144
|
+
fieldKind: "reference"
|
|
145
|
+
};
|
|
146
|
+
const richTextCodec = {
|
|
147
|
+
project(key, raw) {
|
|
148
|
+
return { [key]: fromStoryblok(raw) };
|
|
149
|
+
},
|
|
150
|
+
merge(key, props, before) {
|
|
151
|
+
if (!(key in props))
|
|
152
|
+
return null;
|
|
153
|
+
const nextDoc = toStoryblok(props[key]);
|
|
154
|
+
if (JSON.stringify(nextDoc) === JSON.stringify(before ?? {}))
|
|
155
|
+
return null;
|
|
156
|
+
// Two different spellings of "nothing" are not a change.
|
|
157
|
+
if (isEmptyRichText(nextDoc) && isEmptyRichText(before))
|
|
158
|
+
return null;
|
|
159
|
+
return { value: nextDoc };
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
const imageListCodec = {
|
|
163
|
+
project(key, raw) {
|
|
164
|
+
return {
|
|
165
|
+
[key]: (Array.isArray(raw) ? raw : []).map((a) => ({
|
|
166
|
+
image: assetUrl(a),
|
|
167
|
+
alt: assetAlt(a),
|
|
168
|
+
_uid: isObj(a) && a.id != null ? String(a.id) : undefined
|
|
169
|
+
}))
|
|
170
|
+
};
|
|
171
|
+
},
|
|
172
|
+
merge(key, props, before) {
|
|
173
|
+
if (!(key in props))
|
|
174
|
+
return null;
|
|
175
|
+
const items = Array.isArray(props[key]) ? props[key] : [];
|
|
176
|
+
const sourceItems = Array.isArray(before) ? before : [];
|
|
177
|
+
const merged = items.map((item, i) => {
|
|
178
|
+
const src = sourceItems[i] ?? {};
|
|
179
|
+
const url = str(item?.image);
|
|
180
|
+
const alt = str(item?.alt);
|
|
181
|
+
if (url === assetUrl(src) && alt === assetAlt(src))
|
|
182
|
+
return src;
|
|
183
|
+
return assetFrom(url, alt, src);
|
|
184
|
+
});
|
|
185
|
+
if (JSON.stringify(merged) === JSON.stringify(sourceItems))
|
|
186
|
+
return null;
|
|
187
|
+
return { value: merged };
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
/**
|
|
191
|
+
* The locale lens for Storyblok's field-level i18n.
|
|
192
|
+
*
|
|
193
|
+
* The default language lives in the bare key and every other language in
|
|
194
|
+
* `<key>__i18n__<lang>` beside it. Supplied rather than left to the integrator
|
|
195
|
+
* because the conditional is one line and getting it wrong is silent: a lens
|
|
196
|
+
* that reads `title__i18n__de` finds nothing, falls back to `title`, and looks
|
|
197
|
+
* correct until somebody edits the German page.
|
|
198
|
+
*/
|
|
199
|
+
export function storyblokLocale(defaultLang, languages) {
|
|
200
|
+
return {
|
|
201
|
+
default: defaultLang,
|
|
202
|
+
languages,
|
|
203
|
+
path: (key, lang) => (lang === defaultLang ? [key] : [`${key}__i18n__${lang}`])
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Storyblok's answers, ready to hand to `createLens` and `registerFieldTable`.
|
|
208
|
+
*
|
|
209
|
+
* The default naming is the one a tri-lingual Storyblok site on Next 16
|
|
210
|
+
* arrived at: an image field `background_image` projects to
|
|
211
|
+
* `background_image` and `background_image_alt`.
|
|
212
|
+
*/
|
|
213
|
+
export function storyblokPrimitives(options) {
|
|
214
|
+
const imageNaming = options?.imageNaming ?? suffixNaming("", "_alt");
|
|
215
|
+
return {
|
|
216
|
+
imageNaming,
|
|
217
|
+
rowIdKey: "_uid",
|
|
218
|
+
rowTypeKey: "component",
|
|
219
|
+
newRowId: () => typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
220
|
+
? crypto.randomUUID()
|
|
221
|
+
: `uid_${Math.random().toString(36).slice(2, 12)}`,
|
|
222
|
+
codecs: {
|
|
223
|
+
image: imageCodec(imageNaming),
|
|
224
|
+
file: fileCodec,
|
|
225
|
+
link: linkCodec,
|
|
226
|
+
reference: referenceCodec,
|
|
227
|
+
richtext: richTextCodec,
|
|
228
|
+
imageList: imageListCodec
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
export { changed };
|
|
@@ -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
|
@@ -76,3 +76,71 @@ export declare function editableProps(path: string, options?: {
|
|
|
76
76
|
readonly "data-editable-target": string;
|
|
77
77
|
readonly "data-editable-target-label": string;
|
|
78
78
|
};
|
|
79
|
+
/**
|
|
80
|
+
* Mark an element as the scope its marked descendants sit inside.
|
|
81
|
+
*
|
|
82
|
+
* The field path is scoped from the block down — `items[3].question` — which
|
|
83
|
+
* a renderer can only write if it knows where it sits. That holds while one
|
|
84
|
+
* component draws the whole block, and stops holding the moment a list row is
|
|
85
|
+
* drawn by a component of its own: the child knows it has a `question` and
|
|
86
|
+
* cannot know it is `items[3]`. Without this, every component that can appear
|
|
87
|
+
* inside a list takes a prefix prop from its parent, and every parent passes
|
|
88
|
+
* one.
|
|
89
|
+
*
|
|
90
|
+
* Forgetting to is silent and *wrong*, not silent and absent. The child marks
|
|
91
|
+
* a bare `question`, the overlay resolves it against the enclosing block, and
|
|
92
|
+
* an edit to a headline inside a column patches a prop the section does not
|
|
93
|
+
* have.
|
|
94
|
+
*
|
|
95
|
+
* ```tsx
|
|
96
|
+
* {props.items.map((item, i) => (
|
|
97
|
+
* <div key={item.id} {...editableScopeProps(`items[${i}]`)}>
|
|
98
|
+
* <FaqRow item={item} /> // marks a bare "question"; needs no prefix
|
|
99
|
+
* </div>
|
|
100
|
+
* ))}
|
|
101
|
+
* ```
|
|
102
|
+
*
|
|
103
|
+
* Scopes nest, and compose outermost first — a `left[1]` scope inside a
|
|
104
|
+
* `sections[0]` scope makes a child's `text` into `sections[0].left[1].text`.
|
|
105
|
+
* A block boundary ends the composition, so a scope outside a block never
|
|
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.
|
|
134
|
+
*/
|
|
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;
|
|
145
|
+
readonly "data-editable-scope": string;
|
|
146
|
+
};
|
package/dist/markers.js
CHANGED
|
@@ -85,3 +85,65 @@ export function editableProps(path, options) {
|
|
|
85
85
|
...(options?.kind ? { "data-editable-kind": options.kind } : {})
|
|
86
86
|
};
|
|
87
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Mark an element as the scope its marked descendants sit inside.
|
|
90
|
+
*
|
|
91
|
+
* The field path is scoped from the block down — `items[3].question` — which
|
|
92
|
+
* a renderer can only write if it knows where it sits. That holds while one
|
|
93
|
+
* component draws the whole block, and stops holding the moment a list row is
|
|
94
|
+
* drawn by a component of its own: the child knows it has a `question` and
|
|
95
|
+
* cannot know it is `items[3]`. Without this, every component that can appear
|
|
96
|
+
* inside a list takes a prefix prop from its parent, and every parent passes
|
|
97
|
+
* one.
|
|
98
|
+
*
|
|
99
|
+
* Forgetting to is silent and *wrong*, not silent and absent. The child marks
|
|
100
|
+
* a bare `question`, the overlay resolves it against the enclosing block, and
|
|
101
|
+
* an edit to a headline inside a column patches a prop the section does not
|
|
102
|
+
* have.
|
|
103
|
+
*
|
|
104
|
+
* ```tsx
|
|
105
|
+
* {props.items.map((item, i) => (
|
|
106
|
+
* <div key={item.id} {...editableScopeProps(`items[${i}]`)}>
|
|
107
|
+
* <FaqRow item={item} /> // marks a bare "question"; needs no prefix
|
|
108
|
+
* </div>
|
|
109
|
+
* ))}
|
|
110
|
+
* ```
|
|
111
|
+
*
|
|
112
|
+
* Scopes nest, and compose outermost first — a `left[1]` scope inside a
|
|
113
|
+
* `sections[0]` scope makes a child's `text` into `sections[0].left[1].text`.
|
|
114
|
+
* A block boundary ends the composition, so a scope outside a block never
|
|
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.
|
|
143
|
+
*/
|
|
144
|
+
export function editableScopeProps(scope, options) {
|
|
145
|
+
return {
|
|
146
|
+
"data-editable-scope": scope,
|
|
147
|
+
...(options?.display === "contents" ? { style: { display: "contents" } } : {})
|
|
148
|
+
};
|
|
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
|
+
});
|