@avocadostudio-ai/site-sdk 0.6.0 → 0.8.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/create-site-page.js +47 -84
- package/dist/draft-common.d.ts +1 -0
- package/dist/draft-common.js +13 -0
- package/dist/draft-context-core.d.ts +1 -1
- package/dist/draft-context-core.js +2 -3
- package/dist/draft-context.d.ts +1 -1
- package/dist/draft-context.js +1 -3
- package/dist/draft-routes.d.ts +7 -0
- package/dist/draft-routes.js +8 -3
- package/dist/draft.d.ts +1 -0
- package/dist/draft.js +3 -0
- package/dist/editor-api-handler-core.d.ts +85 -0
- package/dist/editor-api-handler-core.js +101 -0
- package/dist/editor-api-handler.d.ts +7 -49
- package/dist/editor-api-handler.js +12 -66
- package/dist/editor-render.d.ts +43 -0
- package/dist/editor-render.js +71 -0
- package/dist/editor-rewrite-core.d.ts +71 -0
- package/dist/editor-rewrite-core.js +37 -0
- 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/register.d.ts +32 -0
- package/dist/lens/register.js +322 -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 +279 -0
- package/dist/lens/types.js +28 -0
- package/dist/markers.d.ts +37 -1
- package/dist/markers.js +32 -2
- package/dist/page-metadata.d.ts +21 -0
- package/dist/page-metadata.js +64 -0
- package/dist/proxy.d.ts +9 -30
- package/dist/proxy.js +18 -8
- package/dist/resolve-page-render.d.ts +94 -0
- package/dist/resolve-page-render.js +80 -0
- package/dist/routes-core.d.ts +12 -0
- package/dist/routes-core.js +24 -0
- package/package.json +35 -14
- package/dist/cli/register-notice.test.d.ts +0 -1
- package/dist/cli/register-notice.test.js +0 -21
- package/dist/draft-context-core.test.d.ts +0 -10
- package/dist/draft-context-core.test.js +0 -146
- package/dist/draft-fetch.test.d.ts +0 -1
- package/dist/draft-fetch.test.js +0 -153
- package/dist/editor-api-blocks-catalogue.test.d.ts +0 -1
- package/dist/editor-api-blocks-catalogue.test.js +0 -73
- package/dist/editor-cors.test.d.ts +0 -1
- package/dist/editor-cors.test.js +0 -85
- package/dist/manifest-utils.test.d.ts +0 -1
- package/dist/manifest-utils.test.js +0 -72
- package/dist/next-config.test.d.ts +0 -1
- package/dist/next-config.test.js +0 -393
- package/dist/page-metadata.test.d.ts +0 -1
- package/dist/page-metadata.test.js +0 -105
- package/dist/proxy.test.d.ts +0 -1
- package/dist/proxy.test.js +0 -182
- package/dist/publish/field-diff.test.d.ts +0 -1
- package/dist/publish/field-diff.test.js +0 -350
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { FieldTable, Primitives } from "./types.ts";
|
|
2
|
+
export type RegisterOptions = {
|
|
3
|
+
/**
|
|
4
|
+
* The CMS pack, read for its image prop naming and its row keys.
|
|
5
|
+
*
|
|
6
|
+
* Passing the same object `createLens` gets is what makes the panel and the
|
|
7
|
+
* projection agree about what an image field's two props are called. Held
|
|
8
|
+
* apart they are two declarations that must match with nothing checking that
|
|
9
|
+
* they do — and the symptom is an asset picker that never appears.
|
|
10
|
+
*
|
|
11
|
+
* `rowIdKey` and `rowTypeKey` are the same argument one level down: the
|
|
12
|
+
* projection writes a row's identity and type under those keys, so the schema
|
|
13
|
+
* has to expect them there and the list has to discriminate on them.
|
|
14
|
+
*/
|
|
15
|
+
primitives?: Pick<Primitives, "imageNaming"> & Partial<Pick<Primitives, "rowIdKey" | "rowTypeKey">>;
|
|
16
|
+
/**
|
|
17
|
+
* `false` to leave Avocado's own built-in block types in the picker.
|
|
18
|
+
*
|
|
19
|
+
* The default narrows the catalogue to the table, because a site that cannot
|
|
20
|
+
* render `FeatureGrid` should not be offered it — an editor who adds one gets
|
|
21
|
+
* a block that renders as nothing, and the only trace is a warning in a log.
|
|
22
|
+
*/
|
|
23
|
+
narrowCatalogue?: boolean;
|
|
24
|
+
};
|
|
25
|
+
export declare function registerFieldTable(table: FieldTable, options?: RegisterOptions): void;
|
|
26
|
+
/**
|
|
27
|
+
* The types a page body may hold, which is not every type in the table.
|
|
28
|
+
*
|
|
29
|
+
* A row type — a card, a button — is declared so the panel can draw it and the
|
|
30
|
+
* merge can construct it, and must never appear in the block picker.
|
|
31
|
+
*/
|
|
32
|
+
export declare function topLevelTypes(table: FieldTable): string[];
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The table's other two consumers: the schema an edit is validated against, and
|
|
3
|
+
* the metadata the property panel draws with.
|
|
4
|
+
*
|
|
5
|
+
* Both matter and they answer different questions. Skip the Zod schema and
|
|
6
|
+
* every AI edit comes back `Unknown block type`. Skip the metadata and every
|
|
7
|
+
* string gets the same one-line input, because `kind` has no JSON Schema
|
|
8
|
+
* spelling — a rich-text body is then edited as a headline, and an image field
|
|
9
|
+
* gets a text box holding a URL.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately separate from `createLens`. This writes to a global registry and
|
|
12
|
+
* `createLens` returns a value; folding a side effect into a constructor makes
|
|
13
|
+
* an object you cannot build twice, and makes the order of two imports matter.
|
|
14
|
+
*/
|
|
15
|
+
import { registerBlock, declareBlockCatalogue, z, FIELD_KINDS } from "@avocadostudio-ai/shared";
|
|
16
|
+
import { suffixNaming } from "./types.js";
|
|
17
|
+
/**
|
|
18
|
+
* A ProseMirror document, declared so the panel recognises it as one.
|
|
19
|
+
*
|
|
20
|
+
* The recogniser keys off `type` being pinned to the literal `"doc"`, which
|
|
21
|
+
* `z.literal` does survive into JSON Schema as a `const`. `content` is left
|
|
22
|
+
* unconstrained on purpose: it is somebody else's grammar, and validating it
|
|
23
|
+
* here would only reject documents the editor round-trips perfectly well.
|
|
24
|
+
*/
|
|
25
|
+
const richTextSchema = () => z.object({
|
|
26
|
+
type: z.literal("doc"),
|
|
27
|
+
content: z.array(z.any()).optional()
|
|
28
|
+
});
|
|
29
|
+
function humanise(key) {
|
|
30
|
+
return key
|
|
31
|
+
.replace(/[_-]+/g, " ")
|
|
32
|
+
.replace(/([a-z\d])([A-Z])/g, "$1 $2")
|
|
33
|
+
.replace(/^./, (c) => c.toUpperCase());
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The row types a `list: { of }` can actually be given a shape for.
|
|
37
|
+
*
|
|
38
|
+
* A named type the table does not describe is skipped rather than emitted as an
|
|
39
|
+
* empty branch: Avocado cannot project or merge a row of it — `createLens`
|
|
40
|
+
* filters those rows out — so offering it in the panel would be an Add control
|
|
41
|
+
* for something no publish could store.
|
|
42
|
+
*/
|
|
43
|
+
function rowTypesFor(of, ctx) {
|
|
44
|
+
if (!ctx.rowTypeKey)
|
|
45
|
+
return [];
|
|
46
|
+
return [...new Set(of)].filter((type) => ctx.table[type] !== undefined && !ctx.visiting.has(type));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* One list row as an object schema, and `loose` is the load-bearing word.
|
|
50
|
+
*
|
|
51
|
+
* A row is a CMS document: it carries `component`, `_uid`, and every field the
|
|
52
|
+
* table chose not to declare. `z.object` strips what it does not know, and the
|
|
53
|
+
* ops engine's passthrough only covers a block's *top-level* props — so a
|
|
54
|
+
* strict row schema would silently delete the undeclared half of every row an
|
|
55
|
+
* edit touched.
|
|
56
|
+
*/
|
|
57
|
+
function rowSchema(fields, ctx, pinned) {
|
|
58
|
+
const shape = { ...pinned };
|
|
59
|
+
// Declared, not merely tolerated: an undeclared prop present in the content is
|
|
60
|
+
// an `orphan_prop` to the coverage checkers, and a row id is one per row.
|
|
61
|
+
if (ctx.rowIdKey)
|
|
62
|
+
shape[ctx.rowIdKey] = z.string().optional();
|
|
63
|
+
Object.assign(shape, shapeForFields(fields, ctx));
|
|
64
|
+
return z.object(shape).loose();
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* A list's schema, in the one shape the manifest can resolve.
|
|
68
|
+
*
|
|
69
|
+
* It used to be `z.array(z.record(z.string(), z.any()))` for both forms, which
|
|
70
|
+
* reaches the manifest as an array of objects with no properties — so the
|
|
71
|
+
* derivation produced no item fields at all, `resolveManifestFieldMeta` dropped
|
|
72
|
+
* the list, and the panel drew neither rows nor an Add control. The metadata
|
|
73
|
+
* registered beside it was correct the whole time; the schema is what the panel
|
|
74
|
+
* sees, and it said nothing.
|
|
75
|
+
*/
|
|
76
|
+
function listSchemaFor(spec, ctx) {
|
|
77
|
+
if ("itemFields" in spec)
|
|
78
|
+
return z.array(rowSchema(spec.itemFields, ctx, {})).optional();
|
|
79
|
+
const typeKey = ctx.rowTypeKey;
|
|
80
|
+
const types = rowTypesFor(spec.of, ctx);
|
|
81
|
+
// Nothing describable: back to the opaque array, which at least stores the
|
|
82
|
+
// rows untouched rather than rejecting them.
|
|
83
|
+
if (types.length === 0)
|
|
84
|
+
return z.array(z.record(z.string(), z.any())).optional();
|
|
85
|
+
const branches = types.map((type) => rowSchema(ctx.table[type].fields, { ...ctx, visiting: new Set([...ctx.visiting, type]) }, {
|
|
86
|
+
[typeKey]: z.literal(type)
|
|
87
|
+
}));
|
|
88
|
+
return z.array(z.discriminatedUnion(typeKey, branches)).optional();
|
|
89
|
+
}
|
|
90
|
+
function schemaForField(spec, ctx) {
|
|
91
|
+
switch (spec.kind) {
|
|
92
|
+
case "richtext":
|
|
93
|
+
return richTextSchema().optional();
|
|
94
|
+
// Stored as the string the template renders. The document only exists
|
|
95
|
+
// while the panel has it open; `richTextSchema` would also admit a doc,
|
|
96
|
+
// and a doc written back is markup the site cannot render.
|
|
97
|
+
case "html":
|
|
98
|
+
return z.string().optional();
|
|
99
|
+
case "boolean":
|
|
100
|
+
return z.boolean().optional();
|
|
101
|
+
case "number":
|
|
102
|
+
return z.number().optional();
|
|
103
|
+
case "enum":
|
|
104
|
+
// Not `z.enum`: content that predates an option being removed still holds
|
|
105
|
+
// the old value, and a schema that rejects it makes the block uneditable
|
|
106
|
+
// in the one surface that can still fix it. The panel offers the declared
|
|
107
|
+
// options; the merge refuses an undeclared write.
|
|
108
|
+
return z.string().optional();
|
|
109
|
+
case "stringList":
|
|
110
|
+
return z.array(z.string()).optional();
|
|
111
|
+
case "imageList":
|
|
112
|
+
return z.array(z.record(z.string(), z.any())).optional();
|
|
113
|
+
case "list":
|
|
114
|
+
return listSchemaFor(spec, ctx);
|
|
115
|
+
default:
|
|
116
|
+
return z.string().optional();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* A declared field set as an object shape — a block's props, or a row's.
|
|
121
|
+
*
|
|
122
|
+
* An image is two props and `key` may be neither of them: a pack that names
|
|
123
|
+
* them `heroUrl`/`heroAlt` must not also leave a bare `hero` in the schema, or
|
|
124
|
+
* the panel draws a third control that writes nowhere.
|
|
125
|
+
*/
|
|
126
|
+
function shapeForFields(fields, ctx) {
|
|
127
|
+
const shape = {};
|
|
128
|
+
for (const [key, spec] of Object.entries(fields)) {
|
|
129
|
+
if (spec.kind === "image") {
|
|
130
|
+
shape[ctx.naming.url(key)] = z.string().optional();
|
|
131
|
+
shape[ctx.naming.alt(key)] = z.string().optional();
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
shape[key] = schemaForField(spec, ctx);
|
|
135
|
+
}
|
|
136
|
+
return shape;
|
|
137
|
+
}
|
|
138
|
+
function metaForField(key, spec) {
|
|
139
|
+
const meta = {
|
|
140
|
+
/*
|
|
141
|
+
* `list` alone is still `text` here, and it is not a loss: a list travels
|
|
142
|
+
* the separate `listFields` channel, which carries its row shape, and the
|
|
143
|
+
* kind on this side is never read. `stringList` and `imageList` have no
|
|
144
|
+
* such channel — flattening them to `text` was the whole of AW-01, because
|
|
145
|
+
* the panel then discarded the metadata as contradicting an array schema
|
|
146
|
+
* and drew nothing.
|
|
147
|
+
*/
|
|
148
|
+
kind: spec.kind === "list" ? "text" : spec.kind,
|
|
149
|
+
label: spec.label ?? humanise(key)
|
|
150
|
+
};
|
|
151
|
+
if (spec.internal)
|
|
152
|
+
meta.internal = true;
|
|
153
|
+
if (spec.panelOnly)
|
|
154
|
+
meta.panelOnly = true;
|
|
155
|
+
if (spec.kind === "text" && spec.multiline)
|
|
156
|
+
meta.multiline = true;
|
|
157
|
+
if (spec.kind === "text" && spec.inlineEditable !== undefined)
|
|
158
|
+
meta.inlineEditable = spec.inlineEditable;
|
|
159
|
+
if (spec.kind === "richtext" && spec.inline)
|
|
160
|
+
meta.inline = true;
|
|
161
|
+
if (spec.kind === "html" && spec.inline)
|
|
162
|
+
meta.inline = true;
|
|
163
|
+
/*
|
|
164
|
+
* Not editable on the page unless the site says so.
|
|
165
|
+
*
|
|
166
|
+
* Every other kind defaults to inline-editable, and for this one that
|
|
167
|
+
* default corrupts: the overlay edits an element's text, and the value here
|
|
168
|
+
* is markup wrapped around that text. A person fixing a typo in the hero
|
|
169
|
+
* headline would write the typo back without the spans. The panel converts
|
|
170
|
+
* properly, so it is the whole of the UI until a site opts in.
|
|
171
|
+
*/
|
|
172
|
+
if (spec.kind === "html")
|
|
173
|
+
meta.inlineEditable = spec.inlineEditable ?? false;
|
|
174
|
+
if (spec.kind === "enum")
|
|
175
|
+
meta.options = [...spec.options];
|
|
176
|
+
if (spec.kind === "image" && spec.imageSpec)
|
|
177
|
+
meta.imageSpec = spec.imageSpec;
|
|
178
|
+
return meta;
|
|
179
|
+
}
|
|
180
|
+
/** The same expansion as `shapeForFields`, as panel metadata rather than schema. */
|
|
181
|
+
function metaForFields(fields, ctx) {
|
|
182
|
+
const out = {};
|
|
183
|
+
if (ctx.rowIdKey)
|
|
184
|
+
out[ctx.rowIdKey] = { kind: "text", label: humanise(ctx.rowIdKey), internal: true };
|
|
185
|
+
for (const [key, spec] of Object.entries(fields)) {
|
|
186
|
+
if (spec.kind === "image") {
|
|
187
|
+
out[ctx.naming.url(key)] = metaForField(key, spec);
|
|
188
|
+
out[ctx.naming.alt(key)] = { kind: "imageAlt", label: `${spec.label ?? humanise(key)} alt` };
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
out[key] = metaForField(key, spec);
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* A list's panel metadata.
|
|
197
|
+
*
|
|
198
|
+
* Declared rather than left to the derivation because the derivation cannot
|
|
199
|
+
* know all of it: an enum's options never reach the JSON schema (the schema is
|
|
200
|
+
* a plain string on purpose, see above), and neither does a label the table
|
|
201
|
+
* chose. For the `of` form this carries the per-type field sets and the
|
|
202
|
+
* discriminator the panel narrows a row by — and that the ops engine reads to
|
|
203
|
+
* refuse a new row that names no type.
|
|
204
|
+
*/
|
|
205
|
+
function listMetaFor(key, spec, ctx) {
|
|
206
|
+
const label = spec.label ?? humanise(key);
|
|
207
|
+
if ("itemFields" in spec)
|
|
208
|
+
return { label, itemFields: metaForFields(spec.itemFields, ctx) };
|
|
209
|
+
const types = rowTypesFor(spec.of, ctx);
|
|
210
|
+
if (types.length === 0)
|
|
211
|
+
return { label, itemFields: {} };
|
|
212
|
+
const itemFieldsByType = {};
|
|
213
|
+
const merged = {};
|
|
214
|
+
for (const type of types) {
|
|
215
|
+
const fields = metaForFields(ctx.table[type].fields, {
|
|
216
|
+
...ctx,
|
|
217
|
+
visiting: new Set([...ctx.visiting, type])
|
|
218
|
+
});
|
|
219
|
+
itemFieldsByType[type] = fields;
|
|
220
|
+
for (const [fieldKey, meta] of Object.entries(fields))
|
|
221
|
+
if (!(fieldKey in merged))
|
|
222
|
+
merged[fieldKey] = meta;
|
|
223
|
+
}
|
|
224
|
+
return { label, itemFields: merged, discriminator: ctx.rowTypeKey, itemFieldsByType };
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Register every type in the table as an Avocado block type.
|
|
228
|
+
*
|
|
229
|
+
* Adding a field to a block becomes one line in one file: the schema, the
|
|
230
|
+
* panel, the projection and the merge all read the same declaration.
|
|
231
|
+
*/
|
|
232
|
+
/**
|
|
233
|
+
* Refuse a field this table cannot draw, rather than emitting the nearest kind.
|
|
234
|
+
*
|
|
235
|
+
* Every gap this file has produced was found by a person looking at a property
|
|
236
|
+
* panel: a control drawn for something it cannot edit, or — worse — no control
|
|
237
|
+
* at all, with the field valid in the schema and present in the codec. Nothing
|
|
238
|
+
* failed, so nothing was reported, and the integration looked finished.
|
|
239
|
+
*
|
|
240
|
+
* TypeScript already rejects an unknown `kind` for a table written in
|
|
241
|
+
* TypeScript. This is for the tables that are not: `registerFieldTable` is a
|
|
242
|
+
* published entry point, adopters call it from plain JavaScript and from JSON
|
|
243
|
+
* they generated, and a misspelled kind there is currently a field that quietly
|
|
244
|
+
* becomes a text input over a value a text input destroys.
|
|
245
|
+
*
|
|
246
|
+
* It checks the kind only. It cannot tell whether the panel's control for a
|
|
247
|
+
* kind is the *right* control — that is what `FIELD_KINDS` and the editor's own
|
|
248
|
+
* exhaustiveness check are for.
|
|
249
|
+
*/
|
|
250
|
+
function assertDrawable(type, key, spec) {
|
|
251
|
+
const kind = spec.kind;
|
|
252
|
+
if (typeof kind === "string" && KNOWN_KINDS.includes(kind))
|
|
253
|
+
return;
|
|
254
|
+
throw new Error(`registerFieldTable: ${type}.${key} declares kind ${JSON.stringify(kind)}, which is not a field kind. `
|
|
255
|
+
+ `Known kinds: ${KNOWN_KINDS.join(", ")}.`);
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* The kinds a table may declare.
|
|
259
|
+
*
|
|
260
|
+
* `FIELD_KINDS` is the panel's vocabulary; `list` is this layer's own, because
|
|
261
|
+
* a list is not a field — it travels the `listFields` channel instead and has
|
|
262
|
+
* no `FieldKind` spelling.
|
|
263
|
+
*/
|
|
264
|
+
const KNOWN_KINDS = [...FIELD_KINDS, "list"];
|
|
265
|
+
export function registerFieldTable(table, options) {
|
|
266
|
+
const naming = options?.primitives?.imageNaming ?? suffixNaming("", "_alt");
|
|
267
|
+
const ctx = {
|
|
268
|
+
naming,
|
|
269
|
+
table,
|
|
270
|
+
rowIdKey: options?.primitives?.rowIdKey,
|
|
271
|
+
rowTypeKey: options?.primitives?.rowTypeKey,
|
|
272
|
+
visiting: new Set()
|
|
273
|
+
};
|
|
274
|
+
for (const [type, blockSpec] of Object.entries(table)) {
|
|
275
|
+
const shape = shapeForFields(blockSpec.fields, { ...ctx, visiting: new Set([type]) });
|
|
276
|
+
const fields = {};
|
|
277
|
+
const listFields = {};
|
|
278
|
+
for (const [key, spec] of Object.entries(blockSpec.fields)) {
|
|
279
|
+
assertDrawable(type, key, spec);
|
|
280
|
+
if (spec.kind === "imageList") {
|
|
281
|
+
listFields[key] = {
|
|
282
|
+
label: spec.label ?? humanise(key),
|
|
283
|
+
itemFields: { image: { kind: "image", label: "Image" }, alt: { kind: "imageAlt", label: "Alt text" } }
|
|
284
|
+
};
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (spec.kind === "list") {
|
|
288
|
+
listFields[key] = listMetaFor(key, spec, { ...ctx, visiting: new Set([type]) });
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (spec.kind === "image") {
|
|
292
|
+
fields[naming.url(key)] = metaForField(key, spec);
|
|
293
|
+
fields[naming.alt(key)] = { kind: "imageAlt", label: `${spec.label ?? humanise(key)} alt` };
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
fields[key] = metaForField(key, spec);
|
|
297
|
+
}
|
|
298
|
+
const meta = {
|
|
299
|
+
displayName: blockSpec.displayName,
|
|
300
|
+
fields,
|
|
301
|
+
...(blockSpec.category ? { category: blockSpec.category } : {}),
|
|
302
|
+
...(Object.keys(listFields).length > 0 ? { listFields } : {})
|
|
303
|
+
};
|
|
304
|
+
registerBlock(type, { schema: z.object(shape), meta });
|
|
305
|
+
}
|
|
306
|
+
if (options?.narrowCatalogue !== false) {
|
|
307
|
+
declareBlockCatalogue(Object.entries(table)
|
|
308
|
+
.filter(([, spec]) => spec.topLevel !== false)
|
|
309
|
+
.map(([type]) => type));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* The types a page body may hold, which is not every type in the table.
|
|
314
|
+
*
|
|
315
|
+
* A row type — a card, a button — is declared so the panel can draw it and the
|
|
316
|
+
* merge can construct it, and must never appear in the block picker.
|
|
317
|
+
*/
|
|
318
|
+
export function topLevelTypes(table) {
|
|
319
|
+
return Object.entries(table)
|
|
320
|
+
.filter(([, spec]) => spec.topLevel !== false)
|
|
321
|
+
.map(([type]) => type);
|
|
322
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ImageNaming, LocaleLens, Primitives } from "./types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* A resolved image URL, which is a render of a reference and not the reference.
|
|
4
|
+
*
|
|
5
|
+
* This is why only the alt text below is writable. The URL a projection carries
|
|
6
|
+
* was built by the query that resolved `asset._ref` through the CDN; writing it
|
|
7
|
+
* back replaces a reference to an asset document with a string, which is
|
|
8
|
+
* precisely the overwrite a field-level publish diff exists to prevent.
|
|
9
|
+
*/
|
|
10
|
+
export declare function imageUrl(image: unknown): string;
|
|
11
|
+
export declare function imageAlt(image: unknown): string;
|
|
12
|
+
/**
|
|
13
|
+
* The locale lens for a site whose translatable fields are `{de, fr, en}`.
|
|
14
|
+
*
|
|
15
|
+
* One Sanity document becomes one Avocado page *per language*, because an
|
|
16
|
+
* Avocado prop is one value and this content model has three. `/preise` and
|
|
17
|
+
* `/fr/prix` are two pages backed by the same document.
|
|
18
|
+
*/
|
|
19
|
+
export declare function sanityLocale<L extends string>(defaultLang: L, languages: readonly L[]): LocaleLens<L>;
|
|
20
|
+
/**
|
|
21
|
+
* The default naming is the one a tri-lingual Sanity site on Next 15 arrived
|
|
22
|
+
* at, and it is not a style choice: the asset picker recognises a field as an
|
|
23
|
+
* image by the `*imageUrl` / `*Image` name pattern, so an image field `hero`
|
|
24
|
+
* has to project to `heroUrl` for the picker to appear at all.
|
|
25
|
+
*/
|
|
26
|
+
export declare function sanityPrimitives(options?: {
|
|
27
|
+
imageNaming?: ImageNaming;
|
|
28
|
+
}): Primitives;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Sanity's answers to the same six questions.
|
|
3
|
+
*
|
|
4
|
+
* This pack exists as much to prove the abstraction as to serve Sanity. A codec
|
|
5
|
+
* interface derived from one CMS is a description of that CMS wearing a general
|
|
6
|
+
* name; the two that matter here disagree in every particular — a Storyblok
|
|
7
|
+
* asset is a flat object with a `filename`, a Sanity image is a reference to an
|
|
8
|
+
* asset document; Storyblok's rich text is the pivot itself, Sanity's is
|
|
9
|
+
* Portable Text; Storyblok localises into a suffixed sibling key, Sanity into a
|
|
10
|
+
* per-locale object under the key. Nothing above this file changes between the
|
|
11
|
+
* two, which is the claim.
|
|
12
|
+
*/
|
|
13
|
+
import { fromPortableText, toPortableText } from "@avocadostudio-ai/richtext";
|
|
14
|
+
import { changed } from "./scalar-codecs.js";
|
|
15
|
+
import { suffixNaming } from "./types.js";
|
|
16
|
+
const isObj = (v) => v != null && typeof v === "object" && !Array.isArray(v);
|
|
17
|
+
const str = (v) => (v == null ? "" : String(v));
|
|
18
|
+
/**
|
|
19
|
+
* A resolved image URL, which is a render of a reference and not the reference.
|
|
20
|
+
*
|
|
21
|
+
* This is why only the alt text below is writable. The URL a projection carries
|
|
22
|
+
* was built by the query that resolved `asset._ref` through the CDN; writing it
|
|
23
|
+
* back replaces a reference to an asset document with a string, which is
|
|
24
|
+
* precisely the overwrite a field-level publish diff exists to prevent.
|
|
25
|
+
*/
|
|
26
|
+
export function imageUrl(image) {
|
|
27
|
+
if (!image)
|
|
28
|
+
return "";
|
|
29
|
+
if (typeof image === "string")
|
|
30
|
+
return image;
|
|
31
|
+
const img = image;
|
|
32
|
+
if (typeof img.url === "string")
|
|
33
|
+
return img.url;
|
|
34
|
+
const asset = isObj(img.asset) ? img.asset : undefined;
|
|
35
|
+
return str(asset?.url);
|
|
36
|
+
}
|
|
37
|
+
export function imageAlt(image) {
|
|
38
|
+
return isObj(image) ? str(image.alt) : "";
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* An image whose alt text is editable and whose asset is not.
|
|
42
|
+
*
|
|
43
|
+
* The asymmetry is deliberate and it is the honest shape: an editor changing
|
|
44
|
+
* alt text is editing content, and an editor "changing" the URL is proposing to
|
|
45
|
+
* repoint a reference at something the CMS has no record of.
|
|
46
|
+
*/
|
|
47
|
+
const imageCodec = (naming) => ({
|
|
48
|
+
project(key, raw) {
|
|
49
|
+
return { [naming.url(key)]: imageUrl(raw), [naming.alt(key)]: imageAlt(raw) };
|
|
50
|
+
},
|
|
51
|
+
merge(key, props, before) {
|
|
52
|
+
const altKey = naming.alt(key);
|
|
53
|
+
if (!(altKey in props))
|
|
54
|
+
return null;
|
|
55
|
+
const nextAlt = str(props[altKey]);
|
|
56
|
+
if (!changed(nextAlt, imageAlt(before)))
|
|
57
|
+
return null;
|
|
58
|
+
if (!isObj(before))
|
|
59
|
+
return null;
|
|
60
|
+
return { value: { ...before, alt: nextAlt } };
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
const fileCodec = {
|
|
64
|
+
project(key, raw) {
|
|
65
|
+
return { [key]: imageUrl(raw) };
|
|
66
|
+
},
|
|
67
|
+
merge() {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* A reference, which is what Sanity stores every internal link as.
|
|
73
|
+
*
|
|
74
|
+
* Read as the href the query resolved; never written, for the same reason the
|
|
75
|
+
* image URL is never written — the stored value is a `_ref` and the href is one
|
|
76
|
+
* rendering of it, per locale.
|
|
77
|
+
*/
|
|
78
|
+
const referenceCodec = {
|
|
79
|
+
project(key, raw) {
|
|
80
|
+
if (typeof raw === "string")
|
|
81
|
+
return { [key]: raw };
|
|
82
|
+
const ref = isObj(raw) ? raw : undefined;
|
|
83
|
+
return { [key]: str(ref?.href ?? ref?.slug ?? "") };
|
|
84
|
+
},
|
|
85
|
+
merge(key, props, before) {
|
|
86
|
+
if (!(key in props))
|
|
87
|
+
return null;
|
|
88
|
+
const current = typeof before === "string" ? before : str(isObj(before) ? (before.href ?? before.slug) : "");
|
|
89
|
+
if (str(props[key]) === current)
|
|
90
|
+
return null;
|
|
91
|
+
return {
|
|
92
|
+
warning: `"${key}" points at a document in the CMS, not at a URL. Its address is derived from ` +
|
|
93
|
+
`that document — move or rename it in Sanity instead.`
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
fieldKind: "reference"
|
|
97
|
+
};
|
|
98
|
+
const richTextCodec = {
|
|
99
|
+
project(key, raw) {
|
|
100
|
+
return { [key]: fromPortableText(Array.isArray(raw) ? raw : []) };
|
|
101
|
+
},
|
|
102
|
+
merge(key, props, before) {
|
|
103
|
+
if (!(key in props))
|
|
104
|
+
return null;
|
|
105
|
+
/*
|
|
106
|
+
* Key-preserving on purpose. Portable Text blocks carry a `_key` that
|
|
107
|
+
* Sanity uses to address them, and a converter that mints fresh keys turns
|
|
108
|
+
* every edit into a full replacement of the array — which is a patch that
|
|
109
|
+
* conflicts with every other editor's concurrent patch, rather than one
|
|
110
|
+
* that merges with it.
|
|
111
|
+
*/
|
|
112
|
+
const nextBlocks = toPortableText(props[key], { previous: Array.isArray(before) ? before : [] });
|
|
113
|
+
if (JSON.stringify(nextBlocks) === JSON.stringify(before ?? []))
|
|
114
|
+
return null;
|
|
115
|
+
const bothEmpty = nextBlocks.length === 0 && (!Array.isArray(before) || before.length === 0);
|
|
116
|
+
if (bothEmpty)
|
|
117
|
+
return null;
|
|
118
|
+
return { value: nextBlocks };
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
const imageListCodec = () => ({
|
|
122
|
+
project(key, raw) {
|
|
123
|
+
return {
|
|
124
|
+
[key]: (Array.isArray(raw) ? raw : []).map((img) => ({
|
|
125
|
+
image: imageUrl(img),
|
|
126
|
+
alt: imageAlt(img),
|
|
127
|
+
_key: isObj(img) ? img._key : undefined
|
|
128
|
+
}))
|
|
129
|
+
};
|
|
130
|
+
},
|
|
131
|
+
merge(key, props, before) {
|
|
132
|
+
if (!(key in props))
|
|
133
|
+
return null;
|
|
134
|
+
const items = Array.isArray(props[key]) ? props[key] : [];
|
|
135
|
+
const sourceItems = (Array.isArray(before) ? before : []);
|
|
136
|
+
const byKey = new Map(sourceItems.filter(isObj).map((row) => [row._key, row]));
|
|
137
|
+
const merged = items.map((item, i) => {
|
|
138
|
+
const src = (item._key !== undefined ? byKey.get(item._key) : undefined) ?? sourceItems[i] ?? {};
|
|
139
|
+
const alt = str(item.alt);
|
|
140
|
+
if (!isObj(src) || alt === imageAlt(src))
|
|
141
|
+
return src;
|
|
142
|
+
return { ...src, alt };
|
|
143
|
+
});
|
|
144
|
+
if (JSON.stringify(merged) === JSON.stringify(sourceItems))
|
|
145
|
+
return null;
|
|
146
|
+
return { value: merged };
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
/**
|
|
150
|
+
* The locale lens for a site whose translatable fields are `{de, fr, en}`.
|
|
151
|
+
*
|
|
152
|
+
* One Sanity document becomes one Avocado page *per language*, because an
|
|
153
|
+
* Avocado prop is one value and this content model has three. `/preise` and
|
|
154
|
+
* `/fr/prix` are two pages backed by the same document.
|
|
155
|
+
*/
|
|
156
|
+
export function sanityLocale(defaultLang, languages) {
|
|
157
|
+
return {
|
|
158
|
+
default: defaultLang,
|
|
159
|
+
languages,
|
|
160
|
+
path: (key, lang) => [key, lang]
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The default naming is the one a tri-lingual Sanity site on Next 15 arrived
|
|
165
|
+
* at, and it is not a style choice: the asset picker recognises a field as an
|
|
166
|
+
* image by the `*imageUrl` / `*Image` name pattern, so an image field `hero`
|
|
167
|
+
* has to project to `heroUrl` for the picker to appear at all.
|
|
168
|
+
*/
|
|
169
|
+
export function sanityPrimitives(options) {
|
|
170
|
+
const imageNaming = options?.imageNaming ?? suffixNaming("Url", "Alt");
|
|
171
|
+
return {
|
|
172
|
+
imageNaming,
|
|
173
|
+
rowIdKey: "_key",
|
|
174
|
+
rowTypeKey: "_type",
|
|
175
|
+
newRowId: () => typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
176
|
+
? crypto.randomUUID().replace(/-/g, "").slice(0, 12)
|
|
177
|
+
: Math.random().toString(36).slice(2, 14),
|
|
178
|
+
codecs: {
|
|
179
|
+
image: imageCodec(imageNaming),
|
|
180
|
+
file: fileCodec,
|
|
181
|
+
link: referenceCodec,
|
|
182
|
+
reference: referenceCodec,
|
|
183
|
+
richtext: richTextCodec,
|
|
184
|
+
imageList: imageListCodec()
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { CodecContext, FieldCodec } from "./types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Unchanged is not written, and empty is not written over absent.
|
|
4
|
+
*
|
|
5
|
+
* The second rule is the one that is easy to miss and expensive to discover.
|
|
6
|
+
* Writing `""` into a slot that had no value for this language looks like a
|
|
7
|
+
* no-op — the page rendered the fallback before and renders it after — and it
|
|
8
|
+
* is a real diff in the document, so a publish that touched one field reports
|
|
9
|
+
* every page as modified.
|
|
10
|
+
*/
|
|
11
|
+
export declare function changed(next: unknown, before: unknown): boolean;
|
|
12
|
+
export declare const textCodec: FieldCodec;
|
|
13
|
+
export declare const numberCodec: FieldCodec;
|
|
14
|
+
export declare const booleanCodec: FieldCodec;
|
|
15
|
+
/**
|
|
16
|
+
* A closed list, and a value outside it is refused rather than stored.
|
|
17
|
+
*
|
|
18
|
+
* The panel offers only the declared options, so an out-of-range value can only
|
|
19
|
+
* arrive from a planner — and storing one produces a page that renders its
|
|
20
|
+
* fallback branch with no error anywhere, which is the failure this whole file
|
|
21
|
+
* exists to stop being silent.
|
|
22
|
+
*/
|
|
23
|
+
export declare const enumCodec: FieldCodec;
|
|
24
|
+
export declare const stringListCodec: FieldCodec;
|
|
25
|
+
export declare const SCALAR_CODECS: Record<string, FieldCodec>;
|
|
26
|
+
export type { CodecContext };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The kinds no CMS shapes differently.
|
|
3
|
+
*
|
|
4
|
+
* A string is a string in Storyblok, Sanity, Contentful and Strapi; so is a
|
|
5
|
+
* number, a boolean, an enum value and a heading level. These carry no
|
|
6
|
+
* per-CMS answer, so a primitive pack that supplied them would be four copies
|
|
7
|
+
* of the same eight lines — and the eighth line, the one that decides whether a
|
|
8
|
+
* value counts as changed, is the one it is worst to get subtly different
|
|
9
|
+
* between two integrations.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Unchanged is not written, and empty is not written over absent.
|
|
13
|
+
*
|
|
14
|
+
* The second rule is the one that is easy to miss and expensive to discover.
|
|
15
|
+
* Writing `""` into a slot that had no value for this language looks like a
|
|
16
|
+
* no-op — the page rendered the fallback before and renders it after — and it
|
|
17
|
+
* is a real diff in the document, so a publish that touched one field reports
|
|
18
|
+
* every page as modified.
|
|
19
|
+
*/
|
|
20
|
+
export function changed(next, before) {
|
|
21
|
+
if (next === before)
|
|
22
|
+
return false;
|
|
23
|
+
const nextEmpty = next === "" || next === undefined || next === null;
|
|
24
|
+
const beforeEmpty = before === "" || before === undefined || before === null;
|
|
25
|
+
return !(nextEmpty && beforeEmpty);
|
|
26
|
+
}
|
|
27
|
+
function scalar(read, write) {
|
|
28
|
+
return {
|
|
29
|
+
project(key, raw) {
|
|
30
|
+
return { [key]: read(raw) };
|
|
31
|
+
},
|
|
32
|
+
merge(key, props, before) {
|
|
33
|
+
if (!(key in props))
|
|
34
|
+
return null;
|
|
35
|
+
const next = write(props[key]);
|
|
36
|
+
return changed(next, read(before)) ? { value: next } : null;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const asText = (raw) => (raw == null ? "" : String(raw));
|
|
41
|
+
export const textCodec = scalar(asText, asText);
|
|
42
|
+
export const numberCodec = scalar((raw) => (typeof raw === "number" ? raw : Number(raw ?? 0) || 0), (next) => (typeof next === "number" ? next : Number(next ?? 0) || 0));
|
|
43
|
+
export const booleanCodec = scalar((raw) => raw === true, (next) => next === true);
|
|
44
|
+
/**
|
|
45
|
+
* A closed list, and a value outside it is refused rather than stored.
|
|
46
|
+
*
|
|
47
|
+
* The panel offers only the declared options, so an out-of-range value can only
|
|
48
|
+
* arrive from a planner — and storing one produces a page that renders its
|
|
49
|
+
* fallback branch with no error anywhere, which is the failure this whole file
|
|
50
|
+
* exists to stop being silent.
|
|
51
|
+
*/
|
|
52
|
+
export const enumCodec = {
|
|
53
|
+
project(key, raw) {
|
|
54
|
+
return { [key]: asText(raw) };
|
|
55
|
+
},
|
|
56
|
+
merge(key, props, before, ctx) {
|
|
57
|
+
if (!(key in props))
|
|
58
|
+
return null;
|
|
59
|
+
const next = asText(props[key]);
|
|
60
|
+
if (!changed(next, asText(before)))
|
|
61
|
+
return null;
|
|
62
|
+
const options = ctx.spec.kind === "enum" ? ctx.spec.options : [];
|
|
63
|
+
if (next !== "" && !options.includes(next)) {
|
|
64
|
+
return { warning: `"${next}" is not one of ${options.join(", ")}` };
|
|
65
|
+
}
|
|
66
|
+
return { value: next };
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
export const stringListCodec = {
|
|
70
|
+
project(key, raw) {
|
|
71
|
+
return { [key]: (Array.isArray(raw) ? raw : []).map(asText) };
|
|
72
|
+
},
|
|
73
|
+
merge(key, props, before) {
|
|
74
|
+
if (!(key in props))
|
|
75
|
+
return null;
|
|
76
|
+
const next = (Array.isArray(props[key]) ? props[key] : []).map(asText);
|
|
77
|
+
const prev = (Array.isArray(before) ? before : []).map(asText);
|
|
78
|
+
if (next.length === prev.length && next.every((v, i) => v === prev[i]))
|
|
79
|
+
return null;
|
|
80
|
+
if (next.length === 0 && prev.length === 0)
|
|
81
|
+
return null;
|
|
82
|
+
return { value: next };
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
export const SCALAR_CODECS = {
|
|
86
|
+
text: textCodec,
|
|
87
|
+
number: numberCodec,
|
|
88
|
+
boolean: booleanCodec,
|
|
89
|
+
enum: enumCodec,
|
|
90
|
+
headingLevel: textCodec,
|
|
91
|
+
stringList: stringListCodec
|
|
92
|
+
};
|