@avocadostudio-ai/site-sdk 0.14.0 → 0.16.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/lens/create-lens.js +90 -16
- package/dist/lens/index.d.ts +1 -1
- package/dist/lens/index.js +1 -1
- package/dist/lens/register.d.ts +12 -0
- package/dist/lens/register.js +88 -8
- package/dist/lens/sanity.d.ts +18 -1
- package/dist/lens/sanity.js +46 -12
- package/dist/lens/types.d.ts +13 -1
- package/package.json +6 -6
package/dist/lens/create-lens.js
CHANGED
|
@@ -183,19 +183,62 @@ export function createLens(options) {
|
|
|
183
183
|
const byId = new Map(sourceRows.map((row) => [row[primitives.rowIdKey], row]));
|
|
184
184
|
const seen = new Set();
|
|
185
185
|
const merged = [];
|
|
186
|
-
|
|
186
|
+
/*
|
|
187
|
+
* A list whose source rows carry no identity at all.
|
|
188
|
+
*
|
|
189
|
+
* It is not a rare shape: a CMS query written before Avocado existed
|
|
190
|
+
* selects the fields a template renders, and a row's `_key` is not one of
|
|
191
|
+
* them. Matching then finds nothing, and the old code read "matches
|
|
192
|
+
* nothing" as "every row is new" — so a projection merged straight back
|
|
193
|
+
* appended a second copy of every row and kept the originals. Two buttons
|
|
194
|
+
* became four on a no-op, and the fabricated rows were built from the
|
|
195
|
+
* projection, so they carried one language and none of the fields the
|
|
196
|
+
* table does not declare.
|
|
197
|
+
*
|
|
198
|
+
* Position is the only identity left, and using it is better than the
|
|
199
|
+
* alternatives: refusing makes the list uneditable, and fabricating makes
|
|
200
|
+
* it grow. It is also *wrong* for a reorder, silently — the rows swap
|
|
201
|
+
* their undeclared fields — so this writes and warns rather than writing
|
|
202
|
+
* quietly. The remedy is one line in the integrator's query, and the
|
|
203
|
+
* warning names it.
|
|
204
|
+
*/
|
|
205
|
+
const keyless = sourceRows.length > 0 && sourceRows.every((row) => row[primitives.rowIdKey] === undefined);
|
|
206
|
+
let usedPositionalMatch = false;
|
|
207
|
+
const rowWarnings = [];
|
|
208
|
+
for (const [index, item] of items.entries()) {
|
|
187
209
|
const rowId = item[primitives.rowIdKey];
|
|
188
|
-
|
|
210
|
+
let source = rowId === undefined ? undefined : byId.get(rowId);
|
|
189
211
|
if (source)
|
|
190
212
|
seen.add(rowId);
|
|
213
|
+
if (!source && keyless && index < sourceRows.length) {
|
|
214
|
+
source = sourceRows[index];
|
|
215
|
+
usedPositionalMatch = true;
|
|
216
|
+
}
|
|
191
217
|
if ("itemFields" in spec) {
|
|
192
|
-
|
|
218
|
+
/*
|
|
219
|
+
* A row matched by position keeps the identity it had, which is none.
|
|
220
|
+
* Stamping a fresh `rowIdKey` onto it would be this codec inventing a
|
|
221
|
+
* field the document never had — a schema change smuggled in by an
|
|
222
|
+
* edit to a label.
|
|
223
|
+
*/
|
|
224
|
+
const base = source
|
|
225
|
+
? { ...source }
|
|
226
|
+
: keyless
|
|
227
|
+
? {}
|
|
228
|
+
: { [primitives.rowIdKey]: primitives.newRowId() };
|
|
193
229
|
let rowChanged = !source;
|
|
194
230
|
for (const [itemKey, itemSpec] of Object.entries(spec.itemFields)) {
|
|
195
231
|
const itemCtx = { spec: itemSpec, lang: ctx.lang, type: ctx.type, where: `${ctx.where}[] > ${itemKey}` };
|
|
196
232
|
const before = source ? readField(source, itemKey, ctx.lang, itemSpec, ctx.type) : undefined;
|
|
197
233
|
const outcome = codecFor(itemSpec).merge(itemKey, item, before, itemCtx);
|
|
198
|
-
if (!outcome
|
|
234
|
+
if (!outcome)
|
|
235
|
+
continue;
|
|
236
|
+
// A row's own hazards travel up with the list's. A nested list is
|
|
237
|
+
// the case that matters: its rows are keyless far more often than a
|
|
238
|
+
// top-level list's, and it is a level deeper than anyone looks.
|
|
239
|
+
if (outcome.warning)
|
|
240
|
+
rowWarnings.push(outcome.warning);
|
|
241
|
+
if (!("value" in outcome))
|
|
199
242
|
continue;
|
|
200
243
|
writeField(base, itemKey, ctx.lang, itemSpec, ctx.type, outcome.value);
|
|
201
244
|
rowChanged = true;
|
|
@@ -221,16 +264,27 @@ export function createLens(options) {
|
|
|
221
264
|
const result = merge(source ?? { [typeKey]: rowType, [primitives.rowIdKey]: primitives.newRowId() }, item, rowType, ctx.lang, `${ctx.where}[]`);
|
|
222
265
|
merged.push(result.doc);
|
|
223
266
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
267
|
+
/*
|
|
268
|
+
* Rows the table does not describe keep their place rather than vanishing.
|
|
269
|
+
*
|
|
270
|
+
* Only `of` lists can hold such a row. An `itemFields` list describes
|
|
271
|
+
* every row it has by construction, so there is nothing here to rescue —
|
|
272
|
+
* and running this pass over one was the second half of the duplication
|
|
273
|
+
* bug: `specFor(String(undefined))` is undefined, every row read as
|
|
274
|
+
* undescribed, and each got re-inserted beside the copy the loop above
|
|
275
|
+
* had just built.
|
|
276
|
+
*/
|
|
277
|
+
if (!("itemFields" in spec)) {
|
|
278
|
+
for (const [index, row] of sourceRows.entries()) {
|
|
279
|
+
const rowId = row[primitives.rowIdKey];
|
|
280
|
+
if (seen.has(rowId))
|
|
281
|
+
continue;
|
|
282
|
+
const typeKey = primitives.rowTypeKey;
|
|
283
|
+
const described = typeKey ? specFor(String(row[typeKey])) !== undefined : true;
|
|
284
|
+
if (described)
|
|
285
|
+
continue;
|
|
286
|
+
merged.splice(Math.min(index, merged.length), 0, row);
|
|
287
|
+
}
|
|
234
288
|
}
|
|
235
289
|
/*
|
|
236
290
|
* Compared structurally, and deliberately not by counting rows.
|
|
@@ -242,7 +296,19 @@ export function createLens(options) {
|
|
|
242
296
|
* reorder, add and delete, and says nothing about a list that only looks
|
|
243
297
|
* shorter from Avocado's side.
|
|
244
298
|
*/
|
|
245
|
-
|
|
299
|
+
if (JSON.stringify(merged) === JSON.stringify(sourceRows))
|
|
300
|
+
return null;
|
|
301
|
+
const hazards = usedPositionalMatch
|
|
302
|
+
? [
|
|
303
|
+
`"${key}" holds rows with no "${primitives.rowIdKey}", so they were matched to the stored rows by ` +
|
|
304
|
+
`position. An edit lands correctly, but a reorder or a deletion moves every row's undeclared fields ` +
|
|
305
|
+
`onto its neighbour. Select "${primitives.rowIdKey}" for this list in the query that reads it.`,
|
|
306
|
+
...rowWarnings
|
|
307
|
+
]
|
|
308
|
+
: rowWarnings;
|
|
309
|
+
if (hazards.length === 0)
|
|
310
|
+
return { value: merged };
|
|
311
|
+
return { value: merged, warning: [...new Set(hazards)].join(" ") };
|
|
246
312
|
}
|
|
247
313
|
};
|
|
248
314
|
/**
|
|
@@ -294,10 +360,18 @@ export function createLens(options) {
|
|
|
294
360
|
const outcome = codecFor(spec).merge(key, props, before, ctx);
|
|
295
361
|
if (!outcome)
|
|
296
362
|
continue;
|
|
297
|
-
if ("
|
|
363
|
+
if (!("value" in outcome)) {
|
|
298
364
|
warnings.push({ where: ctx.where, reason: outcome.warning });
|
|
299
365
|
continue;
|
|
300
366
|
}
|
|
367
|
+
/*
|
|
368
|
+
* Reported whether or not the write goes on to happen. A codec that
|
|
369
|
+
* returns both has stored something *and* found a hazard in how it had to
|
|
370
|
+
* store it; swallowing the second half was how a list that doubled its
|
|
371
|
+
* rows came back `changed: true, warnings: []`.
|
|
372
|
+
*/
|
|
373
|
+
if (outcome.warning)
|
|
374
|
+
warnings.push({ where: ctx.where, reason: outcome.warning });
|
|
301
375
|
/*
|
|
302
376
|
* Asked here, about a real change, and deliberately not earlier. The
|
|
303
377
|
* first version of this re-projected the source and compared JSON before
|
package/dist/lens/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { createLens } from "./create-lens.ts";
|
|
2
2
|
export type { Lens, LensOptions, MergeResult, MergeWarning, ProjectOptions } from "./create-lens.ts";
|
|
3
|
-
export { registerFieldTable, topLevelTypes } from "./register.ts";
|
|
3
|
+
export { registerFieldTable, registerLens, topLevelTypes } from "./register.ts";
|
|
4
4
|
export type { RegisterOptions } from "./register.ts";
|
|
5
5
|
export { suffixNaming } from "./types.ts";
|
|
6
6
|
export type { BlockSpec, CodecContext, FieldCodec, FieldSpec, FieldTable, ImageNaming, LocaleLens, MergeOutcome, Primitives } from "./types.ts";
|
package/dist/lens/index.js
CHANGED
|
@@ -20,6 +20,6 @@
|
|
|
20
20
|
* object you cannot build twice and make the order of two imports matter.
|
|
21
21
|
*/
|
|
22
22
|
export { createLens } from "./create-lens.js";
|
|
23
|
-
export { registerFieldTable, topLevelTypes } from "./register.js";
|
|
23
|
+
export { registerFieldTable, registerLens, topLevelTypes } from "./register.js";
|
|
24
24
|
export { suffixNaming } from "./types.js";
|
|
25
25
|
export { changed } from "./scalar-codecs.js";
|
package/dist/lens/register.d.ts
CHANGED
|
@@ -23,6 +23,18 @@ export type RegisterOptions = {
|
|
|
23
23
|
narrowCatalogue?: boolean;
|
|
24
24
|
};
|
|
25
25
|
export declare function registerFieldTable(table: FieldTable, options?: RegisterOptions): void;
|
|
26
|
+
/**
|
|
27
|
+
* Register a lens's own table, with the lens's own primitives.
|
|
28
|
+
*
|
|
29
|
+
* The two-argument call is two declarations that have to agree; this is one
|
|
30
|
+
* that cannot disagree with itself. Prefer it. `registerFieldTable` remains for
|
|
31
|
+
* a table that has no lens — a site that registers blocks without projecting a
|
|
32
|
+
* CMS through them.
|
|
33
|
+
*/
|
|
34
|
+
export declare function registerLens(lens: {
|
|
35
|
+
table: FieldTable;
|
|
36
|
+
primitives: Pick<Primitives, "imageNaming"> & Partial<Pick<Primitives, "rowIdKey" | "rowTypeKey">>;
|
|
37
|
+
}, options?: Omit<RegisterOptions, "primitives">): void;
|
|
26
38
|
/**
|
|
27
39
|
* The types a page body may hold, which is not every type in the table.
|
|
28
40
|
*
|
package/dist/lens/register.js
CHANGED
|
@@ -177,12 +177,25 @@ function metaForField(key, spec) {
|
|
|
177
177
|
meta.imageSpec = spec.imageSpec;
|
|
178
178
|
return meta;
|
|
179
179
|
}
|
|
180
|
-
/**
|
|
180
|
+
/**
|
|
181
|
+
* The same expansion as `shapeForFields`, as panel metadata rather than schema.
|
|
182
|
+
*
|
|
183
|
+
* Lists come back separately. `metaForField` renders a `list` as `kind: "text"`,
|
|
184
|
+
* which is harmless for a block's own list — that one travels `listFields` and
|
|
185
|
+
* the kind is never read — and destructive for a row's, which had no second
|
|
186
|
+
* channel to travel and so arrived at the panel as a text input pointed at an
|
|
187
|
+
* array of objects. Now it has one.
|
|
188
|
+
*/
|
|
181
189
|
function metaForFields(fields, ctx) {
|
|
182
190
|
const out = {};
|
|
191
|
+
const lists = {};
|
|
183
192
|
if (ctx.rowIdKey)
|
|
184
193
|
out[ctx.rowIdKey] = { kind: "text", label: humanise(ctx.rowIdKey), internal: true };
|
|
185
194
|
for (const [key, spec] of Object.entries(fields)) {
|
|
195
|
+
if (spec.kind === "list") {
|
|
196
|
+
lists[key] = listMetaFor(key, spec, ctx);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
186
199
|
if (spec.kind === "image") {
|
|
187
200
|
out[ctx.naming.url(key)] = metaForField(key, spec);
|
|
188
201
|
out[ctx.naming.alt(key)] = { kind: "imageAlt", label: `${spec.label ?? humanise(key)} alt` };
|
|
@@ -190,7 +203,7 @@ function metaForFields(fields, ctx) {
|
|
|
190
203
|
}
|
|
191
204
|
out[key] = metaForField(key, spec);
|
|
192
205
|
}
|
|
193
|
-
return out;
|
|
206
|
+
return { fields: out, listFields: lists };
|
|
194
207
|
}
|
|
195
208
|
/**
|
|
196
209
|
* A list's panel metadata.
|
|
@@ -204,24 +217,40 @@ function metaForFields(fields, ctx) {
|
|
|
204
217
|
*/
|
|
205
218
|
function listMetaFor(key, spec, ctx) {
|
|
206
219
|
const label = spec.label ?? humanise(key);
|
|
207
|
-
if ("itemFields" in spec)
|
|
208
|
-
|
|
220
|
+
if ("itemFields" in spec) {
|
|
221
|
+
const row = metaForFields(spec.itemFields, ctx);
|
|
222
|
+
return {
|
|
223
|
+
label,
|
|
224
|
+
itemFields: row.fields,
|
|
225
|
+
...(Object.keys(row.listFields).length > 0 ? { itemListFields: row.listFields } : {})
|
|
226
|
+
};
|
|
227
|
+
}
|
|
209
228
|
const types = rowTypesFor(spec.of, ctx);
|
|
210
229
|
if (types.length === 0)
|
|
211
230
|
return { label, itemFields: {} };
|
|
212
231
|
const itemFieldsByType = {};
|
|
213
232
|
const merged = {};
|
|
233
|
+
const mergedLists = {};
|
|
214
234
|
for (const type of types) {
|
|
215
|
-
const
|
|
235
|
+
const row = metaForFields(ctx.table[type].fields, {
|
|
216
236
|
...ctx,
|
|
217
237
|
visiting: new Set([...ctx.visiting, type])
|
|
218
238
|
});
|
|
219
|
-
itemFieldsByType[type] = fields;
|
|
220
|
-
for (const [fieldKey, meta] of Object.entries(fields))
|
|
239
|
+
itemFieldsByType[type] = row.fields;
|
|
240
|
+
for (const [fieldKey, meta] of Object.entries(row.fields))
|
|
221
241
|
if (!(fieldKey in merged))
|
|
222
242
|
merged[fieldKey] = meta;
|
|
243
|
+
for (const [fieldKey, meta] of Object.entries(row.listFields))
|
|
244
|
+
if (!(fieldKey in mergedLists))
|
|
245
|
+
mergedLists[fieldKey] = meta;
|
|
223
246
|
}
|
|
224
|
-
return {
|
|
247
|
+
return {
|
|
248
|
+
label,
|
|
249
|
+
itemFields: merged,
|
|
250
|
+
discriminator: ctx.rowTypeKey,
|
|
251
|
+
itemFieldsByType,
|
|
252
|
+
...(Object.keys(mergedLists).length > 0 ? { itemListFields: mergedLists } : {})
|
|
253
|
+
};
|
|
225
254
|
}
|
|
226
255
|
/**
|
|
227
256
|
* Register every type in the table as an Avocado block type.
|
|
@@ -262,7 +291,47 @@ function assertDrawable(type, key, spec) {
|
|
|
262
291
|
* no `FieldKind` spelling.
|
|
263
292
|
*/
|
|
264
293
|
const KNOWN_KINDS = [...FIELD_KINDS, "list"];
|
|
294
|
+
/** Does this field set declare an image anywhere, including inside a row? */
|
|
295
|
+
function declaresAnImage(fields, seen = new Set()) {
|
|
296
|
+
for (const spec of Object.values(fields)) {
|
|
297
|
+
if (spec.kind === "image" || spec.kind === "imageList")
|
|
298
|
+
return true;
|
|
299
|
+
if (spec.kind === "list" && "itemFields" in spec && declaresAnImage(spec.itemFields, seen))
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Refuse a table with images when nobody said what an image's props are called.
|
|
306
|
+
*
|
|
307
|
+
* `registerFieldTable` defaulted to `suffixNaming("", "_alt")` — Storyblok's
|
|
308
|
+
* answer — for every CMS, while `sanityPrimitives()` defaults to
|
|
309
|
+
* `suffixNaming("Url", "Alt")`. Same table, same package, two calls, and the
|
|
310
|
+
* defaults disagree: the panel drew `image` and `image_alt`, the projection
|
|
311
|
+
* emitted `imageUrl` and `imageAlt`, and every image on the site was both
|
|
312
|
+
* invisible and uneditable. Nothing errored, because neither half is wrong on
|
|
313
|
+
* its own.
|
|
314
|
+
*
|
|
315
|
+
* `RegisterOptions` has documented this exact symptom for as long as the
|
|
316
|
+
* option has existed, which is the point: an integration that never reads that
|
|
317
|
+
* doc gets the broken pairing by default, and the only evidence is an asset
|
|
318
|
+
* picker that never appears. So the default is withdrawn where it can do harm.
|
|
319
|
+
* A table with no image is unaffected.
|
|
320
|
+
*/
|
|
321
|
+
function assertImageNamingDeclared(table, options) {
|
|
322
|
+
if (options?.primitives?.imageNaming)
|
|
323
|
+
return;
|
|
324
|
+
for (const [type, blockSpec] of Object.entries(table)) {
|
|
325
|
+
if (!declaresAnImage(blockSpec.fields))
|
|
326
|
+
continue;
|
|
327
|
+
throw new Error(`registerFieldTable: ${type} declares an image, but no image naming was given. The panel and the ` +
|
|
328
|
+
`projection have to agree on what an image field's two props are called, and their defaults do not ` +
|
|
329
|
+
`match. Pass the same pack you gave createLens — registerFieldTable(table, { primitives: ` +
|
|
330
|
+
`sanityPrimitives() }) — or call registerLens(lens), which cannot get this wrong.`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
265
333
|
export function registerFieldTable(table, options) {
|
|
334
|
+
assertImageNamingDeclared(table, options);
|
|
266
335
|
const naming = options?.primitives?.imageNaming ?? suffixNaming("", "_alt");
|
|
267
336
|
const ctx = {
|
|
268
337
|
naming,
|
|
@@ -309,6 +378,17 @@ export function registerFieldTable(table, options) {
|
|
|
309
378
|
.map(([type]) => type));
|
|
310
379
|
}
|
|
311
380
|
}
|
|
381
|
+
/**
|
|
382
|
+
* Register a lens's own table, with the lens's own primitives.
|
|
383
|
+
*
|
|
384
|
+
* The two-argument call is two declarations that have to agree; this is one
|
|
385
|
+
* that cannot disagree with itself. Prefer it. `registerFieldTable` remains for
|
|
386
|
+
* a table that has no lens — a site that registers blocks without projecting a
|
|
387
|
+
* CMS through them.
|
|
388
|
+
*/
|
|
389
|
+
export function registerLens(lens, options) {
|
|
390
|
+
registerFieldTable(lens.table, { ...options, primitives: lens.primitives });
|
|
391
|
+
}
|
|
312
392
|
/**
|
|
313
393
|
* The types a page body may hold, which is not every type in the table.
|
|
314
394
|
*
|
package/dist/lens/sanity.d.ts
CHANGED
|
@@ -8,7 +8,24 @@ import type { ImageNaming, LocaleLens, Primitives } from "./types.ts";
|
|
|
8
8
|
* precisely the overwrite a field-level publish diff exists to prevent.
|
|
9
9
|
*/
|
|
10
10
|
export declare function imageUrl(image: unknown): string;
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* An image's alt text, in one language.
|
|
13
|
+
*
|
|
14
|
+
* Alt text is content, so a field-level-i18n CMS localises it like any other
|
|
15
|
+
* string: a tri-lingual Sanity site models it as `localeString`, i.e. `{de,fr,en}` on
|
|
16
|
+
* the image object. This used to be `String(image.alt)`, which rendered every
|
|
17
|
+
* such alt as the literal `"[object Object]"` in the property panel — and then
|
|
18
|
+
* an editor who corrected that garbage wrote a flat string over the container,
|
|
19
|
+
* deleting the other two languages with no warning.
|
|
20
|
+
*
|
|
21
|
+
* The container sits *inside* a field the table declares `localized: false`,
|
|
22
|
+
* because the asset reference really is shared across languages. So no
|
|
23
|
+
* declaration could have expressed this and none is asked for: an object here
|
|
24
|
+
* is read as a locale container, always. The one thing never done again is
|
|
25
|
+
* coercing it — an unreadable alt projects as empty, which is true, rather than
|
|
26
|
+
* as a string that looks like text and is not.
|
|
27
|
+
*/
|
|
28
|
+
export declare function imageAlt(image: unknown, lang?: string): string;
|
|
12
29
|
/**
|
|
13
30
|
* The locale lens for a site whose translatable fields are `{de, fr, en}`.
|
|
14
31
|
*
|
package/dist/lens/sanity.js
CHANGED
|
@@ -34,8 +34,33 @@ export function imageUrl(image) {
|
|
|
34
34
|
const asset = isObj(img.asset) ? img.asset : undefined;
|
|
35
35
|
return str(asset?.url);
|
|
36
36
|
}
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
/**
|
|
38
|
+
* An image's alt text, in one language.
|
|
39
|
+
*
|
|
40
|
+
* Alt text is content, so a field-level-i18n CMS localises it like any other
|
|
41
|
+
* string: a tri-lingual Sanity site models it as `localeString`, i.e. `{de,fr,en}` on
|
|
42
|
+
* the image object. This used to be `String(image.alt)`, which rendered every
|
|
43
|
+
* such alt as the literal `"[object Object]"` in the property panel — and then
|
|
44
|
+
* an editor who corrected that garbage wrote a flat string over the container,
|
|
45
|
+
* deleting the other two languages with no warning.
|
|
46
|
+
*
|
|
47
|
+
* The container sits *inside* a field the table declares `localized: false`,
|
|
48
|
+
* because the asset reference really is shared across languages. So no
|
|
49
|
+
* declaration could have expressed this and none is asked for: an object here
|
|
50
|
+
* is read as a locale container, always. The one thing never done again is
|
|
51
|
+
* coercing it — an unreadable alt projects as empty, which is true, rather than
|
|
52
|
+
* as a string that looks like text and is not.
|
|
53
|
+
*/
|
|
54
|
+
export function imageAlt(image, lang) {
|
|
55
|
+
if (!isObj(image))
|
|
56
|
+
return "";
|
|
57
|
+
const alt = image.alt;
|
|
58
|
+
if (typeof alt === "string")
|
|
59
|
+
return alt;
|
|
60
|
+
if (!isObj(alt))
|
|
61
|
+
return "";
|
|
62
|
+
const value = lang === undefined ? undefined : alt[lang];
|
|
63
|
+
return typeof value === "string" ? value : "";
|
|
39
64
|
}
|
|
40
65
|
/**
|
|
41
66
|
* An image whose alt text is editable and whose asset is not.
|
|
@@ -45,19 +70,26 @@ export function imageAlt(image) {
|
|
|
45
70
|
* repoint a reference at something the CMS has no record of.
|
|
46
71
|
*/
|
|
47
72
|
const imageCodec = (naming) => ({
|
|
48
|
-
project(key, raw) {
|
|
49
|
-
return { [naming.url(key)]: imageUrl(raw), [naming.alt(key)]: imageAlt(raw) };
|
|
73
|
+
project(key, raw, ctx) {
|
|
74
|
+
return { [naming.url(key)]: imageUrl(raw), [naming.alt(key)]: imageAlt(raw, ctx.lang) };
|
|
50
75
|
},
|
|
51
|
-
merge(key, props, before) {
|
|
76
|
+
merge(key, props, before, ctx) {
|
|
52
77
|
const altKey = naming.alt(key);
|
|
53
78
|
if (!(altKey in props))
|
|
54
79
|
return null;
|
|
55
80
|
const nextAlt = str(props[altKey]);
|
|
56
|
-
if (!changed(nextAlt, imageAlt(before)))
|
|
81
|
+
if (!changed(nextAlt, imageAlt(before, ctx.lang)))
|
|
57
82
|
return null;
|
|
58
83
|
if (!isObj(before))
|
|
59
84
|
return null;
|
|
60
|
-
|
|
85
|
+
/*
|
|
86
|
+
* Written back into the language's slot, never over the container. The
|
|
87
|
+
* shape the source had is the shape it keeps: a localised alt stays
|
|
88
|
+
* localised and the untouched languages ride along, exactly as every other
|
|
89
|
+
* localised field in this lens behaves.
|
|
90
|
+
*/
|
|
91
|
+
const alt = isObj(before.alt) ? { ...before.alt, [ctx.lang]: nextAlt } : nextAlt;
|
|
92
|
+
return { value: { ...before, alt } };
|
|
61
93
|
}
|
|
62
94
|
});
|
|
63
95
|
const fileCodec = {
|
|
@@ -119,16 +151,16 @@ const richTextCodec = {
|
|
|
119
151
|
}
|
|
120
152
|
};
|
|
121
153
|
const imageListCodec = () => ({
|
|
122
|
-
project(key, raw) {
|
|
154
|
+
project(key, raw, ctx) {
|
|
123
155
|
return {
|
|
124
156
|
[key]: (Array.isArray(raw) ? raw : []).map((img) => ({
|
|
125
157
|
image: imageUrl(img),
|
|
126
|
-
alt: imageAlt(img),
|
|
158
|
+
alt: imageAlt(img, ctx.lang),
|
|
127
159
|
_key: isObj(img) ? img._key : undefined
|
|
128
160
|
}))
|
|
129
161
|
};
|
|
130
162
|
},
|
|
131
|
-
merge(key, props, before) {
|
|
163
|
+
merge(key, props, before, ctx) {
|
|
132
164
|
if (!(key in props))
|
|
133
165
|
return null;
|
|
134
166
|
const items = Array.isArray(props[key]) ? props[key] : [];
|
|
@@ -137,9 +169,11 @@ const imageListCodec = () => ({
|
|
|
137
169
|
const merged = items.map((item, i) => {
|
|
138
170
|
const src = (item._key !== undefined ? byKey.get(item._key) : undefined) ?? sourceItems[i] ?? {};
|
|
139
171
|
const alt = str(item.alt);
|
|
140
|
-
if (!isObj(src) || alt === imageAlt(src))
|
|
172
|
+
if (!isObj(src) || alt === imageAlt(src, ctx.lang))
|
|
141
173
|
return src;
|
|
142
|
-
|
|
174
|
+
// Same rule as the single image: into the language's slot, never over the
|
|
175
|
+
// container that holds the other languages.
|
|
176
|
+
return { ...src, alt: isObj(src.alt) ? { ...src.alt, [ctx.lang]: alt } : alt };
|
|
143
177
|
});
|
|
144
178
|
if (JSON.stringify(merged) === JSON.stringify(sourceItems))
|
|
145
179
|
return null;
|
package/dist/lens/types.d.ts
CHANGED
|
@@ -212,8 +212,20 @@ export type CodecContext = {
|
|
|
212
212
|
* showed, so nothing looks wrong. A codec returns a value only when the value
|
|
213
213
|
* really differs from what the source holds.
|
|
214
214
|
*/
|
|
215
|
-
export type MergeOutcome = null
|
|
215
|
+
export type MergeOutcome = null
|
|
216
|
+
/**
|
|
217
|
+
* A value to store, and optionally a reason the integrator is owed anyway.
|
|
218
|
+
*
|
|
219
|
+
* A merge that writes is not the same question as a merge that is safe. A
|
|
220
|
+
* list whose source rows carry no `rowIdKey` can only be matched by position,
|
|
221
|
+
* which is the right answer often enough to be worth doing and wrong in a way
|
|
222
|
+
* nobody can see — so it writes *and* says so. Without this the only way to
|
|
223
|
+
* report a hazard was to refuse the edit, so the codec that knew stayed
|
|
224
|
+
* silent and the corruption shipped.
|
|
225
|
+
*/
|
|
226
|
+
| {
|
|
216
227
|
value: unknown;
|
|
228
|
+
warning?: string;
|
|
217
229
|
}
|
|
218
230
|
/** The edit cannot be stored, and the editor is owed the reason. */
|
|
219
231
|
| {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avocadostudio-ai/site-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -147,17 +147,17 @@
|
|
|
147
147
|
],
|
|
148
148
|
"dependencies": {
|
|
149
149
|
"zod": "^4.3.6",
|
|
150
|
-
"@avocadostudio-ai/blocks": "^0.
|
|
151
|
-
"@avocadostudio-ai/
|
|
152
|
-
"@avocadostudio-ai/preview-adapter": "^0.
|
|
153
|
-
"@avocadostudio-ai/
|
|
150
|
+
"@avocadostudio-ai/blocks": "^0.16.0",
|
|
151
|
+
"@avocadostudio-ai/richtext": "^0.16.0",
|
|
152
|
+
"@avocadostudio-ai/preview-adapter": "^0.16.0",
|
|
153
|
+
"@avocadostudio-ai/shared": "^0.16.0"
|
|
154
154
|
},
|
|
155
155
|
"peerDependencies": {
|
|
156
156
|
"next": ">=15.0.0",
|
|
157
157
|
"react": ">=19.0.0",
|
|
158
158
|
"react-dom": ">=19.0.0",
|
|
159
159
|
"better-sqlite3": ">=12.0.0",
|
|
160
|
-
"@avocadostudio-ai/orchestrator-core": "^0.
|
|
160
|
+
"@avocadostudio-ai/orchestrator-core": "^0.16.0"
|
|
161
161
|
},
|
|
162
162
|
"peerDependenciesMeta": {
|
|
163
163
|
"@avocadostudio-ai/orchestrator-core": {
|