@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.
Files changed (41) hide show
  1. package/dist/cli/register-notice.d.ts +20 -0
  2. package/dist/cli/register-notice.js +34 -0
  3. package/dist/cli/register-notice.test.d.ts +1 -0
  4. package/dist/cli/register-notice.test.js +21 -0
  5. package/dist/cli/register.js +7 -2
  6. package/dist/draft.d.ts +1 -0
  7. package/dist/draft.js +3 -0
  8. package/dist/editor-render.d.ts +43 -0
  9. package/dist/editor-render.js +71 -0
  10. package/dist/editor-render.test.d.ts +1 -0
  11. package/dist/editor-render.test.js +44 -0
  12. package/dist/editor.d.ts +1 -1
  13. package/dist/editor.js +1 -1
  14. package/dist/lens/create-lens.d.ts +48 -0
  15. package/dist/lens/create-lens.js +349 -0
  16. package/dist/lens/index.d.ts +7 -0
  17. package/dist/lens/index.js +25 -0
  18. package/dist/lens/lens.test.d.ts +1 -0
  19. package/dist/lens/lens.test.js +221 -0
  20. package/dist/lens/register.d.ts +34 -0
  21. package/dist/lens/register.js +148 -0
  22. package/dist/lens/register.test.d.ts +1 -0
  23. package/dist/lens/register.test.js +81 -0
  24. package/dist/lens/sanity.d.ts +28 -0
  25. package/dist/lens/sanity.js +187 -0
  26. package/dist/lens/scalar-codecs.d.ts +26 -0
  27. package/dist/lens/scalar-codecs.js +92 -0
  28. package/dist/lens/storyblok.d.ts +28 -0
  29. package/dist/lens/storyblok.js +232 -0
  30. package/dist/lens/types.d.ts +243 -0
  31. package/dist/lens/types.js +28 -0
  32. package/dist/markers.d.ts +68 -0
  33. package/dist/markers.js +62 -0
  34. package/dist/markers.test.d.ts +1 -0
  35. package/dist/markers.test.js +35 -0
  36. package/dist/middleware.d.ts +1 -0
  37. package/dist/middleware.js +6 -0
  38. package/dist/proxy.d.ts +26 -0
  39. package/dist/proxy.js +88 -26
  40. package/dist/proxy.test.js +61 -2
  41. package/package.json +21 -5
@@ -0,0 +1,349 @@
1
+ /*
2
+ * The derivation: one field table, read as props and written back.
3
+ *
4
+ * `project` is the getter and `merge` is the setter, and the discipline of the
5
+ * whole thing is that they are inverses — a projection fed back through the
6
+ * merge must produce no change at all. That is not a property you reason out
7
+ * from the shapes; it is one you run against real content, which is why
8
+ * `roundTrip` below is part of the API rather than a script each integration
9
+ * writes for itself.
10
+ *
11
+ * Two rules govern every write, and both came out of running a projection
12
+ * through its own inverse over a real dataset rather than out of thinking about
13
+ * it:
14
+ *
15
+ * 1. **Unchanged means untouched.** A CMS with per-language fallback resolves
16
+ * a missing translation to the default language, which is correct on screen
17
+ * and a lie in storage. Merging a projection back wholesale materialises
18
+ * every one of those fallbacks as a real translation — dozens per publish,
19
+ * each identical to what the page already showed, so nothing looks wrong.
20
+ * Compare against the *projection* of the source, never against a rebuilt
21
+ * object: key order and synthetic row ids both make a string comparison
22
+ * report changes nobody made.
23
+ * 2. **Empty means absent.** Writing `""` into a slot that had no value for
24
+ * this language is a no-op on screen and a diff in the document.
25
+ *
26
+ * A field the table does not declare is invisible to Avocado and untouched by
27
+ * it: it does not reach the planner, does not appear in the panel, and survives
28
+ * every publish, because the merge patches the source document rather than
29
+ * replacing it. That is the lever for scope — declare what an editor should be
30
+ * able to change and leave the layout and behaviour switches out.
31
+ */
32
+ import { SCALAR_CODECS } from "./scalar-codecs.js";
33
+ function isRecord(v) {
34
+ return v != null && typeof v === "object" && !Array.isArray(v);
35
+ }
36
+ function humanise(key) {
37
+ return key
38
+ .replace(/[_-]+/g, " ")
39
+ .replace(/([a-z\d])([A-Z])/g, "$1 $2")
40
+ .replace(/^./, (c) => c.toUpperCase());
41
+ }
42
+ export function createLens(options) {
43
+ const { table, locale, primitives } = options;
44
+ const codecs = { ...SCALAR_CODECS, ...primitives.codecs };
45
+ function codecFor(spec) {
46
+ if (spec.kind === "list")
47
+ return listCodec;
48
+ const codec = codecs[spec.kind];
49
+ if (!codec) {
50
+ throw new Error(`No codec for field kind "${spec.kind}". The CMS primitive pack must supply one, ` +
51
+ `or the table should use a kind it does supply.`);
52
+ }
53
+ return codec;
54
+ }
55
+ function at(rec, path) {
56
+ if (path.length === 1)
57
+ return rec[path[0]];
58
+ const container = rec[path[0]];
59
+ return isRecord(container) ? container[path[1]] : undefined;
60
+ }
61
+ /**
62
+ * Where this language's value lives.
63
+ *
64
+ * Three cases, and the third is the one that cost a rewrite. A localised
65
+ * field is wherever `locale.path` says — for *every* language including the
66
+ * default, because a CMS that localises into an object under the key has no
67
+ * bare value at all, and short-circuiting the default to `doc[key]` reads
68
+ * that object back as `[object Object]`.
69
+ *
70
+ * A field with one value for every language is at the bare key, which is
71
+ * **not** the same as the default language's path: those coincide in a CMS
72
+ * that localises into a suffixed sibling and diverge in one that localises
73
+ * into an object. A field the CMS has stopped marking translatable is the
74
+ * same case — its old translations are still in the document and the delivery
75
+ * API ignores them, so this must too, or the publisher believes a field
76
+ * changed on every publish.
77
+ */
78
+ function slot(key, lang, spec, type) {
79
+ if (spec.localized === false)
80
+ return [key];
81
+ if (locale.translatable && !locale.translatable(type, key, lang))
82
+ return [key];
83
+ return locale.path(key, lang);
84
+ }
85
+ function isBlank(v) {
86
+ return v === undefined || v === null || v === "";
87
+ }
88
+ /**
89
+ * Read `key`'s value for `lang`, falling back to the default language.
90
+ *
91
+ * The default language is the fallback when the localised slot is absent *or*
92
+ * empty — an empty string in a translation slot is how a CMS spells "not
93
+ * translated yet", and reading it literally blanks the page.
94
+ */
95
+ function readField(rec, key, lang, spec, type) {
96
+ const target = slot(key, lang, spec, type);
97
+ const value = at(rec, target);
98
+ if (!isBlank(value))
99
+ return value;
100
+ const fallbackTarget = locale.path(key, locale.default);
101
+ if (target.length === fallbackTarget.length && target.every((seg, i) => seg === fallbackTarget[i]))
102
+ return value;
103
+ if (spec.localized === false)
104
+ return value;
105
+ const fallback = at(rec, fallbackTarget);
106
+ return isBlank(fallback) ? value : fallback;
107
+ }
108
+ function writeField(rec, key, lang, spec, type, value) {
109
+ const target = slot(key, lang, spec, type);
110
+ if (target.length === 1) {
111
+ rec[target[0]] = value;
112
+ return;
113
+ }
114
+ const container = isRecord(rec[target[0]]) ? { ...rec[target[0]] } : {};
115
+ container[target[1]] = value;
116
+ rec[target[0]] = container;
117
+ }
118
+ function specFor(type) {
119
+ return table[type];
120
+ }
121
+ /**
122
+ * A list of child rows, matched by identity rather than by position.
123
+ *
124
+ * Position is the wrong key and it fails quietly: reorder a list, or delete
125
+ * the second of five rows, and every row after the change is merged onto the
126
+ * wrong source — the edit lands, the page looks plausible, and four rows have
127
+ * quietly swapped their untouched fields. Matching on the CMS's own row id is
128
+ * what keeps a list edit a merge.
129
+ *
130
+ * **A row Avocado never saw stays where it was.** The editor's order is taken
131
+ * for the rows it knows; anything else is re-inserted at its original index.
132
+ * A list can hold types the table does not declare, and dropping them is how
133
+ * an integration deletes content it was never asked about.
134
+ *
135
+ * Built here rather than in a primitive pack because the walk is the same
136
+ * everywhere — what differs is the key a row carries its identity and type
137
+ * under, and both are already declared.
138
+ */
139
+ const listCodec = {
140
+ project(key, raw, ctx) {
141
+ const rows = Array.isArray(raw) ? raw : [];
142
+ const spec = ctx.spec;
143
+ if (spec.kind !== "list")
144
+ return { [key]: [] };
145
+ if ("itemFields" in spec) {
146
+ // Rows described inline: no type of their own, so project field by field.
147
+ const inline = spec.itemFields;
148
+ return {
149
+ [key]: rows.filter(isRecord).map((row) => {
150
+ const out = {};
151
+ if (primitives.rowIdKey in row)
152
+ out[primitives.rowIdKey] = row[primitives.rowIdKey];
153
+ for (const [itemKey, itemSpec] of Object.entries(inline)) {
154
+ const itemCtx = { spec: itemSpec, lang: ctx.lang, type: ctx.type, where: `${ctx.where}[] > ${itemKey}` };
155
+ // A row's fields are localised exactly as a block's are — the
156
+ // per-locale container sits on the row, not on the page.
157
+ const raw = readField(row, itemKey, ctx.lang, itemSpec, ctx.type);
158
+ Object.assign(out, codecFor(itemSpec).project(itemKey, raw, itemCtx));
159
+ }
160
+ return out;
161
+ })
162
+ };
163
+ }
164
+ const typeKey = primitives.rowTypeKey;
165
+ return {
166
+ [key]: rows
167
+ .filter((row) => isRecord(row) && Boolean(typeKey) && specFor(String(row[typeKey])) !== undefined)
168
+ .map((row) => ({
169
+ [typeKey]: row[typeKey],
170
+ [primitives.rowIdKey]: row[primitives.rowIdKey],
171
+ ...project(row, String(row[typeKey]), ctx.lang)
172
+ }))
173
+ };
174
+ },
175
+ merge(key, props, before, ctx) {
176
+ if (!(key in props))
177
+ return null;
178
+ const spec = ctx.spec;
179
+ if (spec.kind !== "list")
180
+ return null;
181
+ const items = Array.isArray(props[key]) ? props[key].filter(isRecord) : [];
182
+ const sourceRows = (Array.isArray(before) ? before : []).filter(isRecord);
183
+ const byId = new Map(sourceRows.map((row) => [row[primitives.rowIdKey], row]));
184
+ const seen = new Set();
185
+ const merged = [];
186
+ for (const item of items) {
187
+ const rowId = item[primitives.rowIdKey];
188
+ const source = rowId === undefined ? undefined : byId.get(rowId);
189
+ if (source)
190
+ seen.add(rowId);
191
+ if ("itemFields" in spec) {
192
+ const base = source ? { ...source } : { [primitives.rowIdKey]: primitives.newRowId() };
193
+ let rowChanged = !source;
194
+ for (const [itemKey, itemSpec] of Object.entries(spec.itemFields)) {
195
+ const itemCtx = { spec: itemSpec, lang: ctx.lang, type: ctx.type, where: `${ctx.where}[] > ${itemKey}` };
196
+ const before = source ? readField(source, itemKey, ctx.lang, itemSpec, ctx.type) : undefined;
197
+ const outcome = codecFor(itemSpec).merge(itemKey, item, before, itemCtx);
198
+ if (!outcome || "warning" in outcome)
199
+ continue;
200
+ writeField(base, itemKey, ctx.lang, itemSpec, ctx.type, outcome.value);
201
+ rowChanged = true;
202
+ }
203
+ void rowChanged;
204
+ merged.push(base);
205
+ continue;
206
+ }
207
+ const typeKey = primitives.rowTypeKey;
208
+ const rowType = String(item[typeKey] ?? (source ? source[typeKey] : ""));
209
+ if (!rowType || !specFor(rowType)) {
210
+ /*
211
+ * A row that names no type is a row no CMS storing rows as documents
212
+ * can construct, and guessing one writes content nobody asked for.
213
+ * The op validator rejects this at the moment of the mistake; a row
214
+ * that still reaches here is dropped from the merge rather than
215
+ * fabricated, and the source list keeps whatever it had.
216
+ */
217
+ if (source)
218
+ merged.push(source);
219
+ continue;
220
+ }
221
+ const result = merge(source ?? { [typeKey]: rowType, [primitives.rowIdKey]: primitives.newRowId() }, item, rowType, ctx.lang, `${ctx.where}[]`);
222
+ merged.push(result.doc);
223
+ }
224
+ // Rows the table does not describe keep their place rather than vanishing.
225
+ for (const [index, row] of sourceRows.entries()) {
226
+ const rowId = row[primitives.rowIdKey];
227
+ if (seen.has(rowId))
228
+ continue;
229
+ const typeKey = primitives.rowTypeKey;
230
+ const described = typeKey ? specFor(String(row[typeKey])) !== undefined : true;
231
+ if (described)
232
+ continue;
233
+ merged.splice(Math.min(index, merged.length), 0, row);
234
+ }
235
+ /*
236
+ * Compared structurally, and deliberately not by counting rows.
237
+ *
238
+ * The projection omits every row whose type the table does not describe,
239
+ * so a list of five that Avocado can edit two of arrives back with two —
240
+ * and a length check reads that as three deletions on a list nobody
241
+ * touched. Comparing the rebuilt list to the source catches a real
242
+ * reorder, add and delete, and says nothing about a list that only looks
243
+ * shorter from Avocado's side.
244
+ */
245
+ return JSON.stringify(merged) === JSON.stringify(sourceRows) ? null : { value: merged };
246
+ }
247
+ };
248
+ /**
249
+ * One CMS document as Avocado props, in one language.
250
+ *
251
+ * A type the table does not declare projects to nothing, rather than to a
252
+ * partial guess — an undeclared type is one nobody said Avocado may edit.
253
+ */
254
+ function project(doc, type, lang, opts) {
255
+ const blockSpec = specFor(type);
256
+ if (!blockSpec)
257
+ return {};
258
+ const props = {};
259
+ for (const [key, spec] of Object.entries(blockSpec.fields)) {
260
+ const raw = opts?.resolved ? doc[key] : readField(doc, key, lang, spec, type);
261
+ const ctx = { spec, lang, type, where: `${type} > ${key}` };
262
+ Object.assign(props, codecFor(spec).project(key, raw, ctx));
263
+ }
264
+ return props;
265
+ }
266
+ /**
267
+ * Edited props back onto their source document, for one language.
268
+ *
269
+ * The source is the live CMS document, not a snapshot Avocado holds, so every
270
+ * field the table does not declare survives untouched by construction — and
271
+ * that is the difference between a publish that patches and a publish that
272
+ * silently deletes forty fields this integration never learned about.
273
+ */
274
+ function merge(source, props, type, lang, where = type, opts) {
275
+ const blockSpec = specFor(type);
276
+ if (!blockSpec)
277
+ return { doc: source, changed: false, warnings: [] };
278
+ /*
279
+ * The language the *page* is in, which is not always the language being
280
+ * written: a live preview merges into the bare keys of an already-resolved
281
+ * document — writing as if it were the default language — while the page it
282
+ * draws may be another. Translatability is a question about the page;
283
+ * where to store the value is a question about the write. Conflating them
284
+ * lets the preview show an edit the publish then refuses, which is worse
285
+ * than refusing it in both places.
286
+ */
287
+ const checkLang = opts?.checkLang ?? lang;
288
+ const out = { ...source };
289
+ const warnings = [];
290
+ let changed = false;
291
+ for (const [key, spec] of Object.entries(blockSpec.fields)) {
292
+ const before = readField(source, key, lang, spec, type);
293
+ const ctx = { spec, lang, type, where: `${where} > ${key}` };
294
+ const outcome = codecFor(spec).merge(key, props, before, ctx);
295
+ if (!outcome)
296
+ continue;
297
+ if ("warning" in outcome) {
298
+ warnings.push({ where: ctx.where, reason: outcome.warning });
299
+ continue;
300
+ }
301
+ /*
302
+ * Asked here, about a real change, and deliberately not earlier. The
303
+ * first version of this re-projected the source and compared JSON before
304
+ * deciding — and reported fields nobody had touched as refused
305
+ * translations, because a rebuilt object's key order does not match the
306
+ * source's and because list rows carry a synthetic id. Both are
307
+ * invisible differences that a string comparison calls a change.
308
+ */
309
+ if (checkLang !== locale.default &&
310
+ spec.localized !== false &&
311
+ locale.translatable &&
312
+ !locale.translatable(type, key, checkLang)) {
313
+ warnings.push({
314
+ where: ctx.where,
315
+ reason: `"${key}" is not translatable on ${type} — it has one value for every language, ` +
316
+ `so this ${String(checkLang).toUpperCase()} edit cannot be stored. ` +
317
+ `Edit it on the ${String(locale.default).toUpperCase()} page.`
318
+ });
319
+ continue;
320
+ }
321
+ writeField(out, key, lang, spec, type, outcome.value);
322
+ changed = true;
323
+ }
324
+ return { doc: out, changed, warnings };
325
+ }
326
+ /**
327
+ * Project, merge the projection straight back, and report what moved.
328
+ *
329
+ * Nothing should. A non-empty result is the signal that a codec is not the
330
+ * inverse of itself for some value in this dataset — the class of defect that
331
+ * is invisible in the editor, harmless in the preview, and shows up as a
332
+ * publish wanting to rewrite documents nobody opened.
333
+ *
334
+ * Run it over real content, not fixtures. Every rule in this file exists
335
+ * because a fixture round-tripped and a dataset did not.
336
+ */
337
+ function roundTrip(doc, type, lang, opts) {
338
+ const props = project(doc, type, lang, opts);
339
+ const result = merge(doc, props, type, lang);
340
+ const fields = Object.keys(specFor(type)?.fields ?? {}).filter((key) => {
341
+ const spec = specFor(type).fields[key];
342
+ const before = readField(doc, key, lang, spec, type);
343
+ const after = readField(result.doc, key, lang, spec, type);
344
+ return JSON.stringify(before ?? null) !== JSON.stringify(after ?? null);
345
+ });
346
+ return { clean: !result.changed && fields.length === 0, fields, warnings: result.warnings };
347
+ }
348
+ return { project, merge, roundTrip, table, locale, primitives, label: humanise };
349
+ }
@@ -0,0 +1,7 @@
1
+ export { createLens } from "./create-lens.ts";
2
+ export type { Lens, LensOptions, MergeResult, MergeWarning, ProjectOptions } from "./create-lens.ts";
3
+ export { registerFieldTable, topLevelTypes } from "./register.ts";
4
+ export type { RegisterOptions } from "./register.ts";
5
+ export { suffixNaming } from "./types.ts";
6
+ export type { BlockSpec, CodecContext, FieldCodec, FieldSpec, FieldTable, ImageNaming, LocaleLens, MergeOutcome, Primitives } from "./types.ts";
7
+ export { changed } from "./scalar-codecs.ts";
@@ -0,0 +1,25 @@
1
+ /*
2
+ * The field table, and the four things derived from it.
3
+ *
4
+ * ```ts
5
+ * import { createLens, registerFieldTable } from "@avocadostudio-ai/site-sdk/lens"
6
+ * import { storyblokPrimitives } from "@avocadostudio-ai/site-sdk/lens/storyblok"
7
+ *
8
+ * const primitives = storyblokPrimitives()
9
+ *
10
+ * registerFieldTable(TABLE, { primitives }) // schemas + panel metadata
11
+ * export const lens = createLens({ // projection + merge
12
+ * table: TABLE,
13
+ * locale: { default: "de", languages: ["de", "en", "fr"], path: (k, l) => [`${k}__i18n__${l}`] },
14
+ * primitives,
15
+ * })
16
+ * ```
17
+ *
18
+ * The two are separate on purpose: registration writes to a global registry and
19
+ * `createLens` returns a value, so folding one into the other would make an
20
+ * object you cannot build twice and make the order of two imports matter.
21
+ */
22
+ export { createLens } from "./create-lens.js";
23
+ export { registerFieldTable, topLevelTypes } from "./register.js";
24
+ export { suffixNaming } from "./types.js";
25
+ export { changed } from "./scalar-codecs.js";
@@ -0,0 +1 @@
1
+ export {};
@@ -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[];