@avocadostudio-ai/site-sdk 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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 +37 -1
- package/dist/markers.js +32 -2
- package/dist/markers.test.d.ts +1 -0
- package/dist/markers.test.js +35 -0
- package/dist/proxy.js +17 -1
- package/package.json +21 -5
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { createLens } from "./create-lens.js";
|
|
4
|
+
import { storyblokPrimitives, storyblokLocale } from "./storyblok.js";
|
|
5
|
+
import { sanityPrimitives, sanityLocale } from "./sanity.js";
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Storyblok: a suffixed sibling key per language
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
const TABLE = {
|
|
10
|
+
hero_section: {
|
|
11
|
+
displayName: "Hero",
|
|
12
|
+
topLevel: true,
|
|
13
|
+
fields: {
|
|
14
|
+
title: { kind: "text" },
|
|
15
|
+
background_image: { kind: "image" },
|
|
16
|
+
cta_link: { kind: "link" },
|
|
17
|
+
anchor: { kind: "text", localized: false },
|
|
18
|
+
buttons: { kind: "list", of: ["button"] }
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
button: {
|
|
22
|
+
displayName: "Button",
|
|
23
|
+
topLevel: false,
|
|
24
|
+
fields: {
|
|
25
|
+
label: { kind: "text" },
|
|
26
|
+
variant: { kind: "enum", options: ["solid", "outline"] }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const sbLocale = storyblokLocale("de", ["de", "fr"]);
|
|
31
|
+
const sb = createLens({ table: TABLE, locale: sbLocale, primitives: storyblokPrimitives() });
|
|
32
|
+
function story() {
|
|
33
|
+
return {
|
|
34
|
+
component: "hero_section",
|
|
35
|
+
_uid: "u_hero",
|
|
36
|
+
title: "Willkommen",
|
|
37
|
+
title__i18n__fr: "Bienvenue",
|
|
38
|
+
background_image: { fieldtype: "asset", id: 9, filename: "https://a.storyblok.com/f/1/a.jpg", alt: "Halle" },
|
|
39
|
+
cta_link: { fieldtype: "multilink", linktype: "url", url: "https://book.example.com" },
|
|
40
|
+
anchor: "top",
|
|
41
|
+
// Declared by nobody. It has to survive every write below.
|
|
42
|
+
layout_variant: "wide",
|
|
43
|
+
buttons: [
|
|
44
|
+
{ component: "button", _uid: "u_b1", label: "Buchen", label__i18n__fr: "Réserver", variant: "solid" },
|
|
45
|
+
{ component: "spacer", _uid: "u_x1", height: 24 }
|
|
46
|
+
]
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
test("a field the table does not declare is not projected", () => {
|
|
50
|
+
const props = sb.project(story(), "hero_section", "de");
|
|
51
|
+
assert.equal("layout_variant" in props, false);
|
|
52
|
+
assert.equal(props.title, "Willkommen");
|
|
53
|
+
});
|
|
54
|
+
test("an image projects to two props, a URL and its alt", () => {
|
|
55
|
+
const props = sb.project(story(), "hero_section", "de");
|
|
56
|
+
assert.equal(props.background_image, "https://a.storyblok.com/f/1/a.jpg");
|
|
57
|
+
assert.equal(props.background_image_alt, "Halle");
|
|
58
|
+
});
|
|
59
|
+
test("a language reads its own slot, and falls back to the default when absent", () => {
|
|
60
|
+
assert.equal(sb.project(story(), "hero_section", "fr").title, "Bienvenue");
|
|
61
|
+
const noFrench = { ...story(), title__i18n__fr: undefined };
|
|
62
|
+
assert.equal(sb.project(noFrench, "hero_section", "fr").title, "Willkommen");
|
|
63
|
+
});
|
|
64
|
+
/*
|
|
65
|
+
* The rule the whole file turns on. French has no value for `anchor` — it is
|
|
66
|
+
* declared `localized: false` — and no value for the image alt, so the
|
|
67
|
+
* projection carries German strings sitting in a French page. Merging that
|
|
68
|
+
* projection back must write none of them: each one would become a real,
|
|
69
|
+
* fabricated French translation, invisible on screen and permanent in the
|
|
70
|
+
* document.
|
|
71
|
+
*/
|
|
72
|
+
test("a projection merged straight back changes nothing, in either language", () => {
|
|
73
|
+
for (const lang of ["de", "fr"]) {
|
|
74
|
+
const result = sb.roundTrip(story(), "hero_section", lang);
|
|
75
|
+
assert.equal(result.clean, true, `${lang}: ${result.fields.join(", ")}`);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
test("a real edit is written into the language being edited, and only there", () => {
|
|
79
|
+
const props = sb.project(story(), "hero_section", "fr");
|
|
80
|
+
const { doc, changed } = sb.merge(story(), { ...props, title: "Salut" }, "hero_section", "fr");
|
|
81
|
+
assert.equal(changed, true);
|
|
82
|
+
assert.equal(doc.title__i18n__fr, "Salut");
|
|
83
|
+
assert.equal(doc.title, "Willkommen", "the German value must not move");
|
|
84
|
+
});
|
|
85
|
+
test("a field nobody declared survives a write", () => {
|
|
86
|
+
const props = sb.project(story(), "hero_section", "de");
|
|
87
|
+
const { doc } = sb.merge(story(), { ...props, title: "Neu" }, "hero_section", "de");
|
|
88
|
+
assert.equal(doc.layout_variant, "wide");
|
|
89
|
+
});
|
|
90
|
+
test("a non-localised field writes to the bare key whatever page is edited", () => {
|
|
91
|
+
const props = sb.project(story(), "hero_section", "fr");
|
|
92
|
+
const { doc } = sb.merge(story(), { ...props, anchor: "hero" }, "hero_section", "fr");
|
|
93
|
+
assert.equal(doc.anchor, "hero");
|
|
94
|
+
assert.equal("anchor__i18n__fr" in doc, false);
|
|
95
|
+
});
|
|
96
|
+
/*
|
|
97
|
+
* A story link is a reference rendered per-locale, so its href cannot be
|
|
98
|
+
* written back — but an external URL is stored as itself and stays editable,
|
|
99
|
+
* which is the case that matters (booking and shop links).
|
|
100
|
+
*/
|
|
101
|
+
test("an external link is writable and a story link is refused with a reason", () => {
|
|
102
|
+
const props = sb.project(story(), "hero_section", "de");
|
|
103
|
+
const external = sb.merge(story(), { ...props, cta_link: "https://shop.example.com" }, "hero_section", "de");
|
|
104
|
+
assert.equal(external.doc.cta_link.url, "https://shop.example.com");
|
|
105
|
+
assert.equal(external.warnings.length, 0);
|
|
106
|
+
const withStoryLink = { ...story(), cta_link: { fieldtype: "multilink", linktype: "story", cached_url: "faq" } };
|
|
107
|
+
const refused = sb.merge(withStoryLink, { ...sb.project(withStoryLink, "hero_section", "de"), cta_link: "/anything" }, "hero_section", "de");
|
|
108
|
+
assert.equal(refused.changed, false);
|
|
109
|
+
assert.match(refused.warnings[0].reason, /points at a page in the CMS/);
|
|
110
|
+
});
|
|
111
|
+
test("a child row is matched by its own id, not by position", () => {
|
|
112
|
+
const props = sb.project(story(), "hero_section", "de");
|
|
113
|
+
const buttons = props.buttons;
|
|
114
|
+
assert.equal(buttons.length, 1, "an undeclared child type is not projected");
|
|
115
|
+
assert.equal(buttons[0]._uid, "u_b1");
|
|
116
|
+
const reordered = { ...props, buttons: [{ ...buttons[0], label: "Jetzt buchen" }] };
|
|
117
|
+
const { doc } = sb.merge(story(), reordered, "hero_section", "de");
|
|
118
|
+
const merged = doc.buttons;
|
|
119
|
+
assert.equal(merged.find((b) => b._uid === "u_b1").label, "Jetzt buchen");
|
|
120
|
+
});
|
|
121
|
+
/*
|
|
122
|
+
* A list can hold types the table does not describe, and dropping them is how
|
|
123
|
+
* an integration deletes content it was never asked about.
|
|
124
|
+
*/
|
|
125
|
+
test("a child row of an undeclared type keeps its place through a list edit", () => {
|
|
126
|
+
const props = sb.project(story(), "hero_section", "de");
|
|
127
|
+
const buttons = props.buttons;
|
|
128
|
+
const { doc } = sb.merge(story(), { ...props, buttons: [{ ...buttons[0], label: "X" }] }, "hero_section", "de");
|
|
129
|
+
const merged = doc.buttons;
|
|
130
|
+
assert.ok(merged.some((b) => b._uid === "u_x1"), "the spacer must survive");
|
|
131
|
+
});
|
|
132
|
+
test("a value outside a closed list is refused rather than stored", () => {
|
|
133
|
+
const source = { component: "button", _uid: "u_b1", label: "Buchen", variant: "solid" };
|
|
134
|
+
const result = sb.merge(source, { label: "Buchen", variant: "ghost" }, "button", "de");
|
|
135
|
+
assert.equal(result.changed, false);
|
|
136
|
+
assert.match(result.warnings[0].reason, /not one of solid, outline/);
|
|
137
|
+
});
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Sanity: a per-locale object under the key itself
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
/*
|
|
142
|
+
* The same table, the same lens, a CMS that disagrees in every particular — a
|
|
143
|
+
* per-locale object rather than a suffixed key, a reference rather than a flat
|
|
144
|
+
* asset, `_key` rather than `_uid`. If anything above `Primitives` had turned
|
|
145
|
+
* out to be Storyblok-shaped, it would show up here.
|
|
146
|
+
*/
|
|
147
|
+
const SANITY_TABLE = {
|
|
148
|
+
heroSplit: {
|
|
149
|
+
displayName: "Hero — split",
|
|
150
|
+
fields: {
|
|
151
|
+
heading: { kind: "text" },
|
|
152
|
+
// Sanity localises only what the schema marks translatable, and an image
|
|
153
|
+
// is not: it is one asset reference for every language, at the bare key
|
|
154
|
+
// rather than inside a `{de, fr}` container. Declaring that is the
|
|
155
|
+
// difference between reading the image and reading nothing.
|
|
156
|
+
hero: { kind: "image", localized: false },
|
|
157
|
+
// The list *container* is one array for every language; what is
|
|
158
|
+
// translated are the fields on its rows. Storyblok and Sanity differ here
|
|
159
|
+
// and the table is where the difference is stated.
|
|
160
|
+
buttons: {
|
|
161
|
+
kind: "list",
|
|
162
|
+
localized: false,
|
|
163
|
+
itemFields: { label: { kind: "text" }, variant: { kind: "enum", options: ["solid", "outline"] } }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
const sanity = createLens({
|
|
169
|
+
table: SANITY_TABLE,
|
|
170
|
+
locale: sanityLocale("de", ["de", "fr"]),
|
|
171
|
+
primitives: sanityPrimitives()
|
|
172
|
+
});
|
|
173
|
+
function sanityDoc() {
|
|
174
|
+
return {
|
|
175
|
+
_type: "heroSplit",
|
|
176
|
+
_key: "k_hero",
|
|
177
|
+
heading: { de: "Willkommen", fr: "Bienvenue" },
|
|
178
|
+
hero: { _type: "image", asset: { _ref: "image-abc", url: "https://cdn.sanity.io/a.jpg" }, alt: "Halle" },
|
|
179
|
+
buttons: [{ _key: "k_b1", label: { de: "Buchen", fr: "Réserver" }, variant: "solid" }]
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
test("the same lens reads a per-locale object", () => {
|
|
183
|
+
assert.equal(sanity.project(sanityDoc(), "heroSplit", "de").heading, "Willkommen");
|
|
184
|
+
assert.equal(sanity.project(sanityDoc(), "heroSplit", "fr").heading, "Bienvenue");
|
|
185
|
+
});
|
|
186
|
+
test("an image names its props the way the asset picker expects", () => {
|
|
187
|
+
const props = sanity.project(sanityDoc(), "heroSplit", "de");
|
|
188
|
+
assert.equal(props.heroUrl, "https://cdn.sanity.io/a.jpg");
|
|
189
|
+
assert.equal(props.heroAlt, "Halle");
|
|
190
|
+
});
|
|
191
|
+
test("a Sanity projection merged back changes nothing either", () => {
|
|
192
|
+
for (const lang of ["de", "fr"]) {
|
|
193
|
+
const result = sanity.roundTrip(sanityDoc(), "heroSplit", lang);
|
|
194
|
+
assert.equal(result.clean, true, `${lang}: ${result.fields.join(", ")}`);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
test("an edit lands in its own locale slot and leaves the other alone", () => {
|
|
198
|
+
const props = sanity.project(sanityDoc(), "heroSplit", "fr");
|
|
199
|
+
const { doc } = sanity.merge(sanityDoc(), { ...props, heading: "Salut" }, "heroSplit", "fr");
|
|
200
|
+
assert.deepEqual(doc.heading, { de: "Willkommen", fr: "Salut" });
|
|
201
|
+
});
|
|
202
|
+
/*
|
|
203
|
+
* The URL is a render of `asset._ref`, so writing it back would replace a
|
|
204
|
+
* reference with a string. The alt text is content and stays editable — the
|
|
205
|
+
* asymmetry is the point.
|
|
206
|
+
*/
|
|
207
|
+
test("an image's alt is writable and its resolved URL is not", () => {
|
|
208
|
+
const props = sanity.project(sanityDoc(), "heroSplit", "de");
|
|
209
|
+
const { doc } = sanity.merge(sanityDoc(), { ...props, heroAlt: "Die Halle", heroUrl: "https://evil.example/x.jpg" }, "heroSplit", "de");
|
|
210
|
+
const image = doc.hero;
|
|
211
|
+
assert.equal(image.alt, "Die Halle");
|
|
212
|
+
assert.deepEqual(image.asset, { _ref: "image-abc", url: "https://cdn.sanity.io/a.jpg" });
|
|
213
|
+
});
|
|
214
|
+
test("an inline-described row merges by _key", () => {
|
|
215
|
+
const props = sanity.project(sanityDoc(), "heroSplit", "de");
|
|
216
|
+
const buttons = props.buttons;
|
|
217
|
+
assert.equal(buttons[0]._key, "k_b1");
|
|
218
|
+
const { doc } = sanity.merge(sanityDoc(), { ...props, buttons: [{ ...buttons[0], label: "Jetzt" }] }, "heroSplit", "de");
|
|
219
|
+
const merged = doc.buttons;
|
|
220
|
+
assert.deepEqual(merged[0].label, { de: "Jetzt", fr: "Réserver" });
|
|
221
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { FieldTable, Primitives } from "./types.ts";
|
|
2
|
+
export type RegisterOptions = {
|
|
3
|
+
/**
|
|
4
|
+
* The CMS pack, read only for its image prop naming.
|
|
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
|
+
primitives?: Pick<Primitives, "imageNaming">;
|
|
12
|
+
/**
|
|
13
|
+
* `false` to leave Avocado's own built-in block types in the picker.
|
|
14
|
+
*
|
|
15
|
+
* The default narrows the catalogue to the table, because a site that cannot
|
|
16
|
+
* render `FeatureGrid` should not be offered it — an editor who adds one gets
|
|
17
|
+
* a block that renders as nothing, and the only trace is a warning in a log.
|
|
18
|
+
*/
|
|
19
|
+
narrowCatalogue?: boolean;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Register every type in the table as an Avocado block type.
|
|
23
|
+
*
|
|
24
|
+
* Adding a field to a block becomes one line in one file: the schema, the
|
|
25
|
+
* panel, the projection and the merge all read the same declaration.
|
|
26
|
+
*/
|
|
27
|
+
export declare function registerFieldTable(table: FieldTable, options?: RegisterOptions): void;
|
|
28
|
+
/**
|
|
29
|
+
* The types a page body may hold, which is not every type in the table.
|
|
30
|
+
*
|
|
31
|
+
* A row type — a card, a button — is declared so the panel can draw it and the
|
|
32
|
+
* merge can construct it, and must never appear in the block picker.
|
|
33
|
+
*/
|
|
34
|
+
export declare function topLevelTypes(table: FieldTable): string[];
|
|
@@ -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;
|