@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,148 @@
|
|
|
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 } 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
|
+
function schemaForField(spec) {
|
|
36
|
+
switch (spec.kind) {
|
|
37
|
+
case "richtext":
|
|
38
|
+
return richTextSchema().optional();
|
|
39
|
+
case "boolean":
|
|
40
|
+
return z.boolean().optional();
|
|
41
|
+
case "number":
|
|
42
|
+
return z.number().optional();
|
|
43
|
+
case "enum":
|
|
44
|
+
// Not `z.enum`: content that predates an option being removed still holds
|
|
45
|
+
// the old value, and a schema that rejects it makes the block uneditable
|
|
46
|
+
// in the one surface that can still fix it. The panel offers the declared
|
|
47
|
+
// options; the merge refuses an undeclared write.
|
|
48
|
+
return z.string().optional();
|
|
49
|
+
case "stringList":
|
|
50
|
+
return z.array(z.string()).optional();
|
|
51
|
+
case "imageList":
|
|
52
|
+
return z.array(z.record(z.string(), z.any())).optional();
|
|
53
|
+
case "list":
|
|
54
|
+
return z.array(z.record(z.string(), z.any())).optional();
|
|
55
|
+
default:
|
|
56
|
+
return z.string().optional();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function metaForField(key, spec) {
|
|
60
|
+
const meta = {
|
|
61
|
+
kind: spec.kind === "stringList" || spec.kind === "imageList" || spec.kind === "list" ? "text" : spec.kind,
|
|
62
|
+
label: spec.label ?? humanise(key)
|
|
63
|
+
};
|
|
64
|
+
if (spec.internal)
|
|
65
|
+
meta.internal = true;
|
|
66
|
+
if (spec.kind === "text" && spec.multiline)
|
|
67
|
+
meta.multiline = true;
|
|
68
|
+
if (spec.kind === "text" && spec.inlineEditable !== undefined)
|
|
69
|
+
meta.inlineEditable = spec.inlineEditable;
|
|
70
|
+
if (spec.kind === "richtext" && spec.inline)
|
|
71
|
+
meta.inline = true;
|
|
72
|
+
if (spec.kind === "enum")
|
|
73
|
+
meta.options = [...spec.options];
|
|
74
|
+
if (spec.kind === "image" && spec.imageSpec)
|
|
75
|
+
meta.imageSpec = spec.imageSpec;
|
|
76
|
+
return meta;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Register every type in the table as an Avocado block type.
|
|
80
|
+
*
|
|
81
|
+
* Adding a field to a block becomes one line in one file: the schema, the
|
|
82
|
+
* panel, the projection and the merge all read the same declaration.
|
|
83
|
+
*/
|
|
84
|
+
export function registerFieldTable(table, options) {
|
|
85
|
+
const naming = options?.primitives?.imageNaming ?? suffixNaming("", "_alt");
|
|
86
|
+
for (const [type, blockSpec] of Object.entries(table)) {
|
|
87
|
+
const shape = {};
|
|
88
|
+
const fields = {};
|
|
89
|
+
const listFields = {};
|
|
90
|
+
for (const [key, spec] of Object.entries(blockSpec.fields)) {
|
|
91
|
+
shape[key] = schemaForField(spec);
|
|
92
|
+
if (spec.kind === "list" || spec.kind === "imageList") {
|
|
93
|
+
const itemFields = {};
|
|
94
|
+
if (spec.kind === "imageList") {
|
|
95
|
+
itemFields.image = { kind: "image", label: "Image" };
|
|
96
|
+
itemFields.alt = { kind: "imageAlt", label: "Alt text" };
|
|
97
|
+
}
|
|
98
|
+
else if ("itemFields" in spec) {
|
|
99
|
+
for (const [itemKey, itemSpec] of Object.entries(spec.itemFields)) {
|
|
100
|
+
if (itemSpec.kind === "image") {
|
|
101
|
+
itemFields[naming.url(itemKey)] = metaForField(itemKey, itemSpec);
|
|
102
|
+
itemFields[naming.alt(itemKey)] = { kind: "imageAlt", label: `${itemSpec.label ?? humanise(itemKey)} alt` };
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
itemFields[itemKey] = metaForField(itemKey, itemSpec);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
listFields[key] = { label: spec.label ?? humanise(key), itemFields };
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (spec.kind === "image") {
|
|
112
|
+
// An image is two props, and `key` may not be either of them: a pack
|
|
113
|
+
// that names them `heroUrl`/`heroAlt` must not also leave a bare `hero`
|
|
114
|
+
// in the schema, or the panel draws a third control that writes nowhere.
|
|
115
|
+
delete shape[key];
|
|
116
|
+
shape[naming.url(key)] = z.string().optional();
|
|
117
|
+
shape[naming.alt(key)] = z.string().optional();
|
|
118
|
+
fields[naming.url(key)] = metaForField(key, spec);
|
|
119
|
+
fields[naming.alt(key)] = { kind: "imageAlt", label: `${spec.label ?? humanise(key)} alt` };
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
fields[key] = metaForField(key, spec);
|
|
123
|
+
}
|
|
124
|
+
const meta = {
|
|
125
|
+
displayName: blockSpec.displayName,
|
|
126
|
+
fields,
|
|
127
|
+
...(blockSpec.category ? { category: blockSpec.category } : {}),
|
|
128
|
+
...(Object.keys(listFields).length > 0 ? { listFields } : {})
|
|
129
|
+
};
|
|
130
|
+
registerBlock(type, { schema: z.object(shape), meta });
|
|
131
|
+
}
|
|
132
|
+
if (options?.narrowCatalogue !== false) {
|
|
133
|
+
declareBlockCatalogue(Object.entries(table)
|
|
134
|
+
.filter(([, spec]) => spec.topLevel !== false)
|
|
135
|
+
.map(([type]) => type));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The types a page body may hold, which is not every type in the table.
|
|
140
|
+
*
|
|
141
|
+
* A row type — a card, a button — is declared so the panel can draw it and the
|
|
142
|
+
* merge can construct it, and must never appear in the block picker.
|
|
143
|
+
*/
|
|
144
|
+
export function topLevelTypes(table) {
|
|
145
|
+
return Object.entries(table)
|
|
146
|
+
.filter(([, spec]) => spec.topLevel !== false)
|
|
147
|
+
.map(([type]) => type);
|
|
148
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { getBlockMeta, blockSchemas, catalogueBlockTypes } from "@avocadostudio-ai/shared";
|
|
4
|
+
import { registerFieldTable } from "./register.js";
|
|
5
|
+
import { storyblokPrimitives } from "./storyblok.js";
|
|
6
|
+
import { sanityPrimitives } from "./sanity.js";
|
|
7
|
+
const TABLE = {
|
|
8
|
+
lens_hero: {
|
|
9
|
+
displayName: "Hero",
|
|
10
|
+
topLevel: true,
|
|
11
|
+
category: "content",
|
|
12
|
+
fields: {
|
|
13
|
+
title: { kind: "text", multiline: true },
|
|
14
|
+
body: { kind: "richtext" },
|
|
15
|
+
background: { kind: "image", label: "Background" },
|
|
16
|
+
alignment: { kind: "enum", options: ["left", "center"] },
|
|
17
|
+
uid: { kind: "text", internal: true },
|
|
18
|
+
cards: { kind: "list", itemFields: { heading: { kind: "text" }, photo: { kind: "image" } } }
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
lens_card: { displayName: "Card", topLevel: false, fields: { heading: { kind: "text" } } }
|
|
22
|
+
};
|
|
23
|
+
registerFieldTable(TABLE, { primitives: storyblokPrimitives(), narrowCatalogue: false });
|
|
24
|
+
test("every type in the table becomes a registered block type", () => {
|
|
25
|
+
assert.ok(blockSchemas.lens_hero, "without a schema every AI edit is Unknown block type");
|
|
26
|
+
assert.equal(getBlockMeta("lens_hero")?.displayName, "Hero");
|
|
27
|
+
assert.equal(getBlockMeta("lens_hero")?.category, "content");
|
|
28
|
+
});
|
|
29
|
+
/*
|
|
30
|
+
* `kind` has no JSON Schema spelling, so a table that produced only a schema
|
|
31
|
+
* would give a rich-text body the same one-line input as a headline.
|
|
32
|
+
*/
|
|
33
|
+
test("the panel metadata carries what the schema cannot say", () => {
|
|
34
|
+
const fields = getBlockMeta("lens_hero").fields;
|
|
35
|
+
assert.equal(fields.title.kind, "text");
|
|
36
|
+
assert.equal(fields.title.multiline, true);
|
|
37
|
+
assert.equal(fields.body.kind, "richtext");
|
|
38
|
+
assert.deepEqual(fields.alignment.options, ["left", "center"]);
|
|
39
|
+
});
|
|
40
|
+
test("an internal prop is declared internal rather than left undeclared", () => {
|
|
41
|
+
assert.equal(getBlockMeta("lens_hero").fields.uid.internal, true);
|
|
42
|
+
});
|
|
43
|
+
/*
|
|
44
|
+
* The projection and the panel have to agree about what an image field's two
|
|
45
|
+
* props are called, and the agreement is `imageNaming` on the pack rather than
|
|
46
|
+
* two constants in two files.
|
|
47
|
+
*/
|
|
48
|
+
test("an image registers the two props its pack projects", () => {
|
|
49
|
+
const fields = getBlockMeta("lens_hero").fields;
|
|
50
|
+
assert.equal(fields.background.kind, "image");
|
|
51
|
+
assert.equal(fields.background_alt.kind, "imageAlt");
|
|
52
|
+
assert.ok(blockSchemas.lens_hero.shape.background, "the URL prop must be in the schema");
|
|
53
|
+
assert.ok(blockSchemas.lens_hero.shape.background_alt, "and so must the alt");
|
|
54
|
+
});
|
|
55
|
+
test("a list becomes a list field, with metadata for its rows", () => {
|
|
56
|
+
const listFields = getBlockMeta("lens_hero").listFields;
|
|
57
|
+
assert.equal(listFields.cards.itemFields.heading.kind, "text");
|
|
58
|
+
assert.equal(listFields.cards.itemFields.photo.kind, "image");
|
|
59
|
+
assert.equal(listFields.cards.itemFields.photo_alt.kind, "imageAlt");
|
|
60
|
+
assert.equal("cards" in getBlockMeta("lens_hero").fields, false, "a list is not a scalar field");
|
|
61
|
+
});
|
|
62
|
+
test("the other pack names the same image field differently, and the panel follows", () => {
|
|
63
|
+
registerFieldTable({ sanity_hero: TABLE.lens_hero }, {
|
|
64
|
+
primitives: sanityPrimitives(),
|
|
65
|
+
narrowCatalogue: false
|
|
66
|
+
});
|
|
67
|
+
const fields = getBlockMeta("sanity_hero").fields;
|
|
68
|
+
assert.equal(fields.backgroundUrl.kind, "image");
|
|
69
|
+
assert.equal(fields.backgroundAlt.kind, "imageAlt");
|
|
70
|
+
assert.equal("background_alt" in fields, false);
|
|
71
|
+
});
|
|
72
|
+
/*
|
|
73
|
+
* A site that cannot render `FeatureGrid` should not be offered it: an editor
|
|
74
|
+
* who adds one gets a block that draws as nothing, with a warning in a log as
|
|
75
|
+
* the only trace.
|
|
76
|
+
*/
|
|
77
|
+
test("the catalogue narrows to the table's top-level types", () => {
|
|
78
|
+
registerFieldTable(TABLE);
|
|
79
|
+
const types = catalogueBlockTypes();
|
|
80
|
+
assert.deepEqual([...types].sort(), ["lens_hero"]);
|
|
81
|
+
});
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { changed } from "./scalar-codecs.ts";
|
|
2
|
+
import type { ImageNaming, LocaleLens, Primitives } from "./types.ts";
|
|
3
|
+
export declare function assetUrl(asset: unknown): string;
|
|
4
|
+
export declare function assetAlt(asset: unknown): string;
|
|
5
|
+
export declare function linkHref(link: unknown): string;
|
|
6
|
+
/** A link that points at a story is a reference, whatever it renders as. */
|
|
7
|
+
export declare function isStoryLink(link: unknown): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* The locale lens for Storyblok's field-level i18n.
|
|
10
|
+
*
|
|
11
|
+
* The default language lives in the bare key and every other language in
|
|
12
|
+
* `<key>__i18n__<lang>` beside it. Supplied rather than left to the integrator
|
|
13
|
+
* because the conditional is one line and getting it wrong is silent: a lens
|
|
14
|
+
* that reads `title__i18n__de` finds nothing, falls back to `title`, and looks
|
|
15
|
+
* correct until somebody edits the German page.
|
|
16
|
+
*/
|
|
17
|
+
export declare function storyblokLocale<L extends string>(defaultLang: L, languages: readonly L[]): LocaleLens<L>;
|
|
18
|
+
/**
|
|
19
|
+
* Storyblok's answers, ready to hand to `createLens` and `registerFieldTable`.
|
|
20
|
+
*
|
|
21
|
+
* The default naming is the one a tri-lingual Storyblok site on Next 16
|
|
22
|
+
* arrived at: an image field `background_image` projects to
|
|
23
|
+
* `background_image` and `background_image_alt`.
|
|
24
|
+
*/
|
|
25
|
+
export declare function storyblokPrimitives(options?: {
|
|
26
|
+
imageNaming?: ImageNaming;
|
|
27
|
+
}): Primitives;
|
|
28
|
+
export { changed };
|