@avocadostudio-ai/site-sdk 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/register-notice.d.ts +20 -0
- package/dist/cli/register-notice.js +34 -0
- package/dist/cli/register-notice.test.d.ts +1 -0
- package/dist/cli/register-notice.test.js +21 -0
- package/dist/cli/register.js +7 -2
- package/dist/draft.d.ts +1 -0
- package/dist/draft.js +3 -0
- package/dist/editor-render.d.ts +43 -0
- package/dist/editor-render.js +71 -0
- package/dist/editor-render.test.d.ts +1 -0
- package/dist/editor-render.test.js +44 -0
- package/dist/editor.d.ts +1 -1
- package/dist/editor.js +1 -1
- package/dist/lens/create-lens.d.ts +48 -0
- package/dist/lens/create-lens.js +349 -0
- package/dist/lens/index.d.ts +7 -0
- package/dist/lens/index.js +25 -0
- package/dist/lens/lens.test.d.ts +1 -0
- package/dist/lens/lens.test.js +221 -0
- package/dist/lens/register.d.ts +34 -0
- package/dist/lens/register.js +148 -0
- package/dist/lens/register.test.d.ts +1 -0
- package/dist/lens/register.test.js +81 -0
- package/dist/lens/sanity.d.ts +28 -0
- package/dist/lens/sanity.js +187 -0
- package/dist/lens/scalar-codecs.d.ts +26 -0
- package/dist/lens/scalar-codecs.js +92 -0
- package/dist/lens/storyblok.d.ts +28 -0
- package/dist/lens/storyblok.js +232 -0
- package/dist/lens/types.d.ts +243 -0
- package/dist/lens/types.js +28 -0
- package/dist/markers.d.ts +68 -0
- package/dist/markers.js +62 -0
- package/dist/markers.test.d.ts +1 -0
- package/dist/markers.test.js +35 -0
- package/dist/middleware.d.ts +1 -0
- package/dist/middleware.js +6 -0
- package/dist/proxy.d.ts +26 -0
- package/dist/proxy.js +88 -26
- package/dist/proxy.test.js +61 -2
- package/package.json +21 -5
|
@@ -0,0 +1,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[];
|