@avocadostudio-ai/site-sdk 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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;