@stackbit/cms-sanity 0.2.45-develop.1 → 0.2.45-staging.1

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 (34) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/sanity-content-source.d.ts +21 -10
  6. package/dist/sanity-content-source.d.ts.map +1 -1
  7. package/dist/sanity-content-source.js +74 -242
  8. package/dist/sanity-content-source.js.map +1 -1
  9. package/dist/sanity-document-converter.d.ts +11 -9
  10. package/dist/sanity-document-converter.d.ts.map +1 -1
  11. package/dist/sanity-document-converter.js +262 -205
  12. package/dist/sanity-document-converter.js.map +1 -1
  13. package/dist/sanity-operation-converter.d.ts +60 -0
  14. package/dist/sanity-operation-converter.d.ts.map +1 -0
  15. package/dist/sanity-operation-converter.js +664 -0
  16. package/dist/sanity-operation-converter.js.map +1 -0
  17. package/dist/sanity-schema-converter.d.ts +35 -3
  18. package/dist/sanity-schema-converter.d.ts.map +1 -1
  19. package/dist/sanity-schema-converter.js +290 -43
  20. package/dist/sanity-schema-converter.js.map +1 -1
  21. package/dist/sanity-schema-fetcher.d.ts +3 -3
  22. package/dist/sanity-schema-fetcher.d.ts.map +1 -1
  23. package/dist/utils.d.ts +53 -0
  24. package/dist/utils.d.ts.map +1 -1
  25. package/dist/utils.js +93 -1
  26. package/dist/utils.js.map +1 -1
  27. package/package.json +6 -5
  28. package/src/index.ts +1 -1
  29. package/src/sanity-content-source.ts +109 -317
  30. package/src/sanity-document-converter.ts +332 -231
  31. package/src/sanity-operation-converter.ts +785 -0
  32. package/src/sanity-schema-converter.ts +424 -70
  33. package/src/sanity-schema-fetcher.ts +3 -3
  34. package/src/utils.ts +98 -0
@@ -0,0 +1,785 @@
1
+ import _ from 'lodash';
2
+ import { v4 as uuid } from 'uuid';
3
+ import tinycolor from 'tinycolor2';
4
+ import type { PatchOperations, SanityDocument } from '@sanity/client';
5
+ import type * as StackbitTypes from '@stackbit/types';
6
+ import type { ModelContext, ModelWithContext } from './sanity-schema-converter';
7
+ import { getItemTypeForListItem, isLocalizedModelField, getSanityAliasFieldType, resolvedFieldType } from './utils';
8
+ import type { GetModelByName } from './sanity-document-converter';
9
+
10
+ export function convertUpdateOperation({
11
+ operation,
12
+ ...rest
13
+ }: {
14
+ operation: StackbitTypes.UpdateOperation;
15
+ sanityDocument: SanityDocument;
16
+ getModelByName: GetModelByName;
17
+ model: ModelWithContext;
18
+ }): PatchOperations {
19
+ switch (operation.opType) {
20
+ case 'set':
21
+ return Operations.set({ operation, ...rest });
22
+ case 'unset':
23
+ return Operations.unset({ operation, ...rest });
24
+ case 'insert':
25
+ return Operations.insert({ operation, ...rest });
26
+ case 'remove':
27
+ return Operations.remove({ operation, ...rest });
28
+ case 'reorder':
29
+ return Operations.reorder({ operation, ...rest });
30
+ }
31
+ }
32
+
33
+ export const Operations: {
34
+ [Type in StackbitTypes.UpdateOperation as Type['opType']]: ({
35
+ sanityDocument,
36
+ operation,
37
+ getModelByName
38
+ }: {
39
+ sanityDocument: SanityDocument;
40
+ operation: Type;
41
+ getModelByName: GetModelByName;
42
+ model: StackbitTypes.Model<ModelContext>;
43
+ }) => PatchOperations;
44
+ } = {
45
+ set: ({ operation, sanityDocument, getModelByName, model }) => {
46
+ const { field, fieldPath, modelField, locale } = operation;
47
+ const { patchFieldPath, modelFieldPath, localizedLeaf, isInList } = getPatchPathAndModelFieldPaths({
48
+ fieldPath,
49
+ sanityDocument,
50
+ getModelByName,
51
+ addValueToI18NLeafs: true,
52
+ allowUndefinedI18NLeafArrays: true,
53
+ locale
54
+ });
55
+ let value = mapUpdateOperationFieldToSanityValue({
56
+ updateOperationField: field,
57
+ getModelByName,
58
+ modelField,
59
+ rootModel: model,
60
+ modelFieldPath,
61
+ locale,
62
+ isInList
63
+ });
64
+ if (isLocalizedModelField(modelField) && localizedLeaf !== 'value') {
65
+ value = localizedValue({
66
+ value,
67
+ model,
68
+ modelFieldPath,
69
+ locale
70
+ });
71
+ if (localizedLeaf === 'newItem') {
72
+ return {
73
+ insert: {
74
+ after: `${patchFieldPath}[-1]`,
75
+ items: [value]
76
+ }
77
+ };
78
+ } else if (localizedLeaf === 'undefinedArray') {
79
+ return { set: { [patchFieldPath]: [value] } };
80
+ }
81
+ }
82
+ return { set: { [patchFieldPath]: value } };
83
+ },
84
+ unset: ({ operation, sanityDocument, getModelByName }) => {
85
+ const { fieldPath, locale } = operation;
86
+ const { patchFieldPath } = getPatchPathAndModelFieldPaths({
87
+ fieldPath,
88
+ sanityDocument,
89
+ getModelByName,
90
+ locale
91
+ });
92
+ return { unset: [patchFieldPath] };
93
+ },
94
+ insert: ({ operation, sanityDocument, getModelByName, model }) => {
95
+ const { item, fieldPath, modelField, index, locale } = operation;
96
+ const listItemModelField = (modelField as StackbitTypes.FieldList).items ?? { type: 'string' };
97
+ const { patchFieldPath, modelFieldPath, currentValue, localizedLeaf } = getPatchPathAndModelFieldPaths({
98
+ fieldPath,
99
+ sanityDocument,
100
+ getModelByName,
101
+ addValueToI18NLeafs: true,
102
+ allowUndefinedI18NLeafArrays: true,
103
+ locale
104
+ });
105
+ let value = mapUpdateOperationFieldToSanityValue({
106
+ updateOperationField: item,
107
+ getModelByName,
108
+ modelField: listItemModelField,
109
+ rootModel: model,
110
+ modelFieldPath: modelFieldPath.concat('items'),
111
+ locale,
112
+ isInList: true
113
+ });
114
+ // In the case of a localized array field, the field will contain an
115
+ // array of objects with localized arrays:
116
+ // [
117
+ // {
118
+ // _key: 'en',
119
+ // _type: 'internationalizedArrayLocalizedArray',
120
+ // value: ['en value 1', 'en value 2', 'en value 3']
121
+ // },
122
+ // {
123
+ // _key: 'es',
124
+ // _type: 'internationalizedArrayLocalizedArray',
125
+ // value: ['es value 1', 'es value 2', 'es value 3']
126
+ // }
127
+ // ]
128
+ if (isLocalizedModelField(modelField) && localizedLeaf !== 'value') {
129
+ // When there is no array for a given locale, create a new localized
130
+ // array with a single value and insert it into the localized array:
131
+ // {
132
+ // _key: 'es',
133
+ // _type: 'internationalizedArrayLocalizedArray',
134
+ // value: [value]
135
+ // }
136
+ value = localizedValue({
137
+ value: [value],
138
+ model,
139
+ modelFieldPath,
140
+ locale
141
+ });
142
+ if (localizedLeaf === 'newItem') {
143
+ return {
144
+ insert: {
145
+ after: `${patchFieldPath}[-1]`,
146
+ items: [value]
147
+ }
148
+ };
149
+ } else if (localizedLeaf === 'undefinedArray') {
150
+ return { set: { [patchFieldPath]: [value] } };
151
+ }
152
+ }
153
+ if (!currentValue) {
154
+ return { set: { [patchFieldPath]: [value] } };
155
+ } else if (_.isNil(index) || index >= currentValue.length) {
156
+ return {
157
+ insert: {
158
+ after: `${patchFieldPath}[-1]`,
159
+ items: [value]
160
+ }
161
+ };
162
+ }
163
+ return {
164
+ insert: {
165
+ before: `${patchFieldPath}[${index}]`,
166
+ items: [value]
167
+ }
168
+ };
169
+ },
170
+ remove: ({ sanityDocument, operation, getModelByName }) => {
171
+ const { fieldPath, index, locale } = operation;
172
+ const { patchFieldPath } = getPatchPathAndModelFieldPaths({
173
+ fieldPath: fieldPath.concat(index),
174
+ sanityDocument,
175
+ getModelByName,
176
+ locale
177
+ });
178
+ return {
179
+ unset: [patchFieldPath]
180
+ };
181
+ },
182
+ reorder: ({ sanityDocument, operation, getModelByName }) => {
183
+ const { fieldPath, order, locale } = operation;
184
+ const { patchFieldPath, currentValue } = getPatchPathAndModelFieldPaths({
185
+ fieldPath,
186
+ sanityDocument,
187
+ getModelByName,
188
+ addValueToI18NLeafs: true,
189
+ locale
190
+ });
191
+ const newEntryArr = order.map((newIndex) => currentValue[newIndex]);
192
+ return { set: { [patchFieldPath]: newEntryArr } };
193
+ }
194
+ };
195
+
196
+ export function mapUpdateOperationFieldToSanityValue({
197
+ updateOperationField,
198
+ getModelByName,
199
+ modelField,
200
+ rootModel,
201
+ modelFieldPath,
202
+ locale,
203
+ isInList
204
+ }: {
205
+ updateOperationField: StackbitTypes.UpdateOperationField;
206
+ getModelByName: GetModelByName;
207
+ modelField: StackbitTypes.FieldSpecificProps;
208
+ rootModel: StackbitTypes.Model<ModelContext>;
209
+ modelFieldPath: string[];
210
+ locale: string | undefined;
211
+ isInList?: boolean;
212
+ }): any {
213
+ switch (updateOperationField.type) {
214
+ case 'string':
215
+ case 'url':
216
+ case 'text':
217
+ case 'markdown':
218
+ case 'html':
219
+ case 'boolean':
220
+ case 'date':
221
+ case 'datetime':
222
+ case 'enum':
223
+ case 'style':
224
+ case 'json':
225
+ case 'richText':
226
+ case 'file': {
227
+ return updateOperationField.value;
228
+ }
229
+ case 'number': {
230
+ return Number(updateOperationField.value);
231
+ }
232
+ case 'slug': {
233
+ return {
234
+ _type: getSanityAliasFieldType({
235
+ resolvedType: 'slug',
236
+ model: rootModel,
237
+ modelFieldPath
238
+ }),
239
+ current: updateOperationField.value
240
+ };
241
+ }
242
+ case 'color': {
243
+ const color = tinycolor(updateOperationField.value);
244
+ return {
245
+ _type: getSanityAliasFieldType({
246
+ resolvedType: 'color',
247
+ model: rootModel,
248
+ modelFieldPath
249
+ }),
250
+ hex: color.toHexString(),
251
+ alpha: color.getAlpha(),
252
+ hsl: {
253
+ _type: 'hslaColor',
254
+ ...color.toHsl()
255
+ },
256
+ hsv: {
257
+ _type: 'hsvaColor',
258
+ ...color.toHsv()
259
+ },
260
+ rgb: {
261
+ _type: 'rgbaColor',
262
+ ...color.toRgb()
263
+ }
264
+ };
265
+ }
266
+ case 'image': {
267
+ const value = updateOperationField?.value;
268
+ if (modelField.type === 'image') {
269
+ if (modelField.source === 'cloudinary' || modelField.source === 'aprimo') {
270
+ const type = modelField.source === 'cloudinary' ? 'cloudinary.asset' : 'aprimo.cdnasset';
271
+ return addKeyIfInList(
272
+ {
273
+ _type: type,
274
+ ...value
275
+ },
276
+ isInList
277
+ );
278
+ } else if (modelField.source === 'bynder') {
279
+ let imageValue = value;
280
+ if (imageValue?.__typename) {
281
+ imageValue = _.omitBy(
282
+ {
283
+ id: value.id,
284
+ name: value.name,
285
+ databaseId: value.databaseId,
286
+ type: value.type,
287
+ previewUrl: value.type === 'VIDEO' ? value.previewUrls[0] : value.files.webImage.url,
288
+ previewImg: value.files.webImage.url,
289
+ datUrl: value.files.transformBaseUrl?.url,
290
+ videoUrl: value.type === 'VIDEO' ? value.files.original?.url : null,
291
+ description: value.description,
292
+ aspectRatio: value.height / value.width
293
+ },
294
+ _.isUndefined
295
+ );
296
+ }
297
+ return addKeyIfInList(
298
+ {
299
+ _type: 'bynder.asset',
300
+ ...imageValue
301
+ },
302
+ isInList
303
+ );
304
+ }
305
+ }
306
+ // TODO: there is a bug right now because documentField is inferred from the model which is an "image", not reference
307
+ return addKeyIfInList(linkForAssetId(value), isInList);
308
+ }
309
+ case 'object': {
310
+ if (modelField.type !== 'object') {
311
+ throw new Error(`Operation field type 'object' does not match model field type '${modelField.type}'.`);
312
+ }
313
+ // Sanity array fields may consist of anonymous 'object' with names.
314
+ // When creating such objects, the '_type' should be set to their
315
+ // name to identify them among other 'object' types.
316
+ const fieldAlias = rootModel.context?.fieldAliasMap?.[modelFieldPath.join('.')] ?? [];
317
+ const typeName = fieldAlias?.find((alias) => alias.resolvedTypeName === 'object')?.origTypeName;
318
+ const object = addKeyIfInList(
319
+ {
320
+ ...(typeName ? { _type: typeName } : null)
321
+ },
322
+ isInList
323
+ );
324
+ return _.reduce(
325
+ updateOperationField.fields,
326
+ (result, childUpdateOperationField, fieldName) => {
327
+ const childModelField = _.find(modelField.fields, (field) => field.name === fieldName);
328
+ if (!childModelField) {
329
+ throw new Error(
330
+ `No model field found for field '${fieldName}' in model '${rootModel.name}' at field path ${modelFieldPath.join('.')}.`
331
+ );
332
+ }
333
+ const childModelFieldPath = modelFieldPath.concat(fieldName);
334
+ const value = mapUpdateOperationFieldToSanityValue({
335
+ updateOperationField: childUpdateOperationField,
336
+ getModelByName,
337
+ modelField: childModelField,
338
+ rootModel,
339
+ modelFieldPath: childModelFieldPath,
340
+ locale
341
+ });
342
+ if (isLocalizedModelField(childModelField)) {
343
+ _.set(result, fieldName, [
344
+ localizedValue({
345
+ value,
346
+ model: rootModel,
347
+ modelFieldPath: childModelFieldPath,
348
+ locale
349
+ })
350
+ ]);
351
+ } else {
352
+ _.set(result, fieldName, value);
353
+ }
354
+ return result;
355
+ },
356
+ object
357
+ );
358
+ }
359
+ case 'model': {
360
+ if (modelField.type !== 'model') {
361
+ throw new Error(`Operation field type 'model' does not match model field type '${modelField.type}'.`);
362
+ }
363
+ const modelName = updateOperationField.modelName;
364
+ const childModel = getModelByName(modelName);
365
+ if (!childModel) {
366
+ throw new Error(`No model '${modelName}' was found for field at '${modelFieldPath.join('.')}' in model '${rootModel.name}'.`);
367
+ }
368
+ const object = addKeyIfInList(
369
+ {
370
+ _type: getSanityAliasFieldType({
371
+ resolvedType: modelName,
372
+ model: rootModel,
373
+ modelFieldPath
374
+ })
375
+ },
376
+ isInList
377
+ );
378
+ return _.reduce(
379
+ updateOperationField.fields,
380
+ (result, childUpdateOperationField, fieldName) => {
381
+ const childModelField = _.find(childModel?.fields, (field) => field.name === fieldName);
382
+ if (!childModelField) {
383
+ throw new Error(`No model field found for field '${fieldName}' in model '${childModel.name}'.`);
384
+ }
385
+ const childModelFieldPath = [fieldName];
386
+ const value = mapUpdateOperationFieldToSanityValue({
387
+ updateOperationField: childUpdateOperationField,
388
+ getModelByName,
389
+ modelField: childModelField,
390
+ rootModel: childModel,
391
+ modelFieldPath: childModelFieldPath,
392
+ locale
393
+ });
394
+ if (isLocalizedModelField(childModelField)) {
395
+ _.set(result, fieldName, [
396
+ localizedValue({
397
+ value,
398
+ model: rootModel,
399
+ modelFieldPath: childModelFieldPath,
400
+ locale
401
+ })
402
+ ]);
403
+ } else {
404
+ _.set(result, fieldName, value);
405
+ }
406
+ return result;
407
+ },
408
+ object
409
+ );
410
+ }
411
+ case 'reference': {
412
+ const value =
413
+ updateOperationField.refType === 'document'
414
+ ? {
415
+ _ref: updateOperationField.refId,
416
+ _type: getSanityAliasFieldType({
417
+ resolvedType: 'reference',
418
+ model: rootModel,
419
+ modelFieldPath
420
+ }),
421
+ // TODO: this is a bug!
422
+ // The _weak is not always `true`. Lookup the referenced document
423
+ // by id, and the original model field's `weak` value.
424
+ // If the referenced document was published (status === 'published'
425
+ // or status === 'modified'), then the _weak value should be set to
426
+ // the `weak` value defined in Sanity model field's.
427
+ // Otherwise, if the document was not published (status === 'added'),
428
+ // then the _weak value should be set to `true` and the
429
+ // _strengthenOnPublish.weak should be set to the `weak` value
430
+ // defined in the model field's.
431
+ _weak: true,
432
+ // TODO: Lookup the referenced document by id, and the original model
433
+ // field's `weak` value. If the referenced document was never published
434
+ // (status === 'added'), then add the _strengthenOnPublish object and
435
+ // set its `weak` property to the model field's `weak` value.
436
+ // When publishing objects with reference fields having the
437
+ // _strengthenOnPublish object, update the field's _weak with that of
438
+ // _strengthenOnPublish.weak.
439
+ // For more info: https://www.sanity.io/blog/obvious-features-aren-t-obviously-made#2c38c9f38060
440
+ _strengthenOnPublish: {
441
+ // type: <type for referenced item>,
442
+ // weak: <the _weak value of the original field>
443
+ }
444
+ }
445
+ : linkForAssetId(updateOperationField.refId);
446
+ return addKeyIfInList(value, isInList);
447
+ }
448
+ case 'cross-reference': {
449
+ throw new Error('Sanity crossDatasetReference fields not supported.');
450
+ }
451
+ case 'list': {
452
+ if (modelField.type !== 'list') {
453
+ throw new Error(`Operation field type 'list' does not match model field type '${modelField.type}'.`);
454
+ }
455
+ return updateOperationField.items.map((item, index) => {
456
+ let listItemModelField = modelField.items;
457
+ if (_.isArray(modelField.items)) {
458
+ const itemModel = (modelField.items as StackbitTypes.FieldListItems[]).find((listItemsModel) => listItemsModel.type === item.type);
459
+ if (!itemModel) {
460
+ throw new Error(
461
+ `No list item model found for item type '${item.type}' in model '${rootModel.name}' at field path ${modelFieldPath.join('.')}.`
462
+ );
463
+ }
464
+ listItemModelField = itemModel;
465
+ }
466
+ return mapUpdateOperationFieldToSanityValue({
467
+ updateOperationField: item,
468
+ getModelByName,
469
+ modelField: listItemModelField,
470
+ rootModel,
471
+ modelFieldPath: modelFieldPath.concat('items'),
472
+ locale,
473
+ isInList: true
474
+ });
475
+ });
476
+ }
477
+ default: {
478
+ const _exhaustiveCheck: never = updateOperationField;
479
+ return _exhaustiveCheck;
480
+ }
481
+ }
482
+ }
483
+
484
+ function addKeyIfInList(object: Record<string, any>, isInList?: boolean) {
485
+ if (isInList) {
486
+ _.set(object, '_key', uuid());
487
+ }
488
+ return object;
489
+ }
490
+
491
+ /**
492
+ * In Sanity, a localized field is represented by an array of objects containing the localized field values.
493
+ * Each object has three properties:
494
+ * - `_key` holds the field's locale
495
+ * - `_type` holds the type of the localized value
496
+ * - `value` holds the localized value.
497
+ *
498
+ * Note: the `_type` is not the regular type such as `string` or `object`,
499
+ * but a special localized type generated by the Sanity Internationalized Array plugin.
500
+ * The map between the localized fields and these types is stored in the model's context.
501
+ * title: [
502
+ * {
503
+ * _key: 'us',
504
+ * _type: 'internationalizedArrayStringValue',
505
+ * value: 'hello',
506
+ * }, {
507
+ * _key: 'es',
508
+ * _type: 'internationalizedArrayStringValue',
509
+ * value: 'hola',
510
+ * }
511
+ * ]
512
+ */
513
+ export function localizedValue({ value, model, modelFieldPath, locale }: { value: any; model: ModelWithContext; modelFieldPath: string[]; locale?: string }) {
514
+ const localizedFieldModelNameMap = model.context?.localizedFieldsModelMap?.[modelFieldPath.join('.')];
515
+ if (!localizedFieldModelNameMap) {
516
+ throw Error(`Internationalized array model for localized field at path ${modelFieldPath.join('.')} of model ${model.name} not found.`);
517
+ }
518
+ if (!locale) {
519
+ throw Error(`No locale provided for localized field at path ${modelFieldPath.join('.')} of model ${model.name}.`);
520
+ }
521
+ return {
522
+ _key: locale,
523
+ _type: localizedFieldModelNameMap.arrayValueModelName,
524
+ value: value
525
+ };
526
+ }
527
+
528
+ function linkForAssetId(assetId?: string): any {
529
+ return {
530
+ _type: 'image',
531
+ asset: {
532
+ _ref: assetId,
533
+ _type: 'reference'
534
+ }
535
+ };
536
+ }
537
+
538
+ /**
539
+ * Receives a `fieldPath` of a target field in a `sanityDocument` and returns
540
+ * an object with following properties:
541
+ *
542
+ * - `model`: The closest ancestor model of the target field.
543
+ * - `modelFieldPath`: The field path of the target field from the `model`.
544
+ * The model's field path doesn't identify a specific document field,
545
+ * therefore it does not include list indexes.
546
+ * - `patchFieldPath`: A Sanity specific field path for patching fields.
547
+ * For array items, the field path will include [_key="..."] when possible.
548
+ * For localized fields, which represented by arrays in Sanity, the path
549
+ * will point to the localized array item using the `_key`:
550
+ * `sections[_key=="es"].value[3].title`
551
+ * - `localizedLeaf`: A string specifying the leaf value in the `patchFieldPath`
552
+ * when the target field is localized:
553
+ * - `value`: The `patchFieldPath` points to the localized value:
554
+ * `sections[_key=="es"].value
555
+ * - `newItem`: The `patchFieldPath` points to the existing localized array,
556
+ * which does not include an item for the provided locale.
557
+ * Returned only when `allowUndefinedI18NLeafArrays` set to `true`.
558
+ * - `undefinedArray`: The `patchFieldPath` points to undefined localized array.
559
+ * Returned only when `allowUndefinedI18NLeafArrays` set to `true`.
560
+ * - `currentValue`: The value of the target field. If the target field is
561
+ * localized but doesn't have a value for the provided locale,
562
+ * the `currentValue` will be undefined.
563
+ */
564
+ function getPatchPathAndModelFieldPaths({
565
+ fieldPath,
566
+ sanityDocument,
567
+ getModelByName,
568
+ addValueToI18NLeafs,
569
+ allowUndefinedI18NLeafArrays,
570
+ locale
571
+ }: {
572
+ fieldPath: StackbitTypes.FieldPath;
573
+ sanityDocument: SanityDocument;
574
+ getModelByName: (modelName: string) => ModelWithContext | undefined;
575
+ addValueToI18NLeafs?: boolean;
576
+ allowUndefinedI18NLeafArrays?: boolean;
577
+ locale?: string;
578
+ }): {
579
+ model: ModelWithContext;
580
+ modelFieldPath: string[];
581
+ patchFieldPath: string;
582
+ localizedLeaf?: 'value' | 'newItem' | 'undefinedArray';
583
+ currentValue: any;
584
+ isInList: boolean;
585
+ } {
586
+ function iterateObject({
587
+ object,
588
+ model,
589
+ rootModel,
590
+ modelFieldPath,
591
+ fieldPath,
592
+ first = false
593
+ }: {
594
+ object: Record<string, any>;
595
+ model: StackbitTypes.Model | StackbitTypes.FieldObjectProps;
596
+ rootModel: ModelWithContext;
597
+ modelFieldPath: string[];
598
+ fieldPath: StackbitTypes.FieldPath;
599
+ first?: boolean;
600
+ }): {
601
+ model: ModelWithContext;
602
+ modelFieldPath: string[];
603
+ patchFieldPath: string;
604
+ localizedLeaf?: 'value' | 'newItem' | 'undefinedArray';
605
+ currentValue: any;
606
+ isInList: boolean;
607
+ } {
608
+ const [fieldName, ...fieldPathTail] = fieldPath as [string, ...StackbitTypes.FieldPath];
609
+ if (typeof fieldName === 'undefined') {
610
+ throw new Error('The fieldPath cannot be empty.');
611
+ }
612
+
613
+ const modelField = (model.fields ?? []).find((field) => field.name === fieldName);
614
+ if (!modelField) {
615
+ throw new Error(`Model field for field '${fieldName}' not found.`);
616
+ }
617
+
618
+ let patchFieldPath = '';
619
+ if (/\W/.test(fieldName)) {
620
+ // field name is a string with non-alphanumeric characters
621
+ patchFieldPath += `['${fieldName}']`;
622
+ } else {
623
+ if (!first) {
624
+ patchFieldPath += '.';
625
+ }
626
+ patchFieldPath += fieldName;
627
+ }
628
+
629
+ let value = object[fieldName];
630
+ let localizedLeaf: 'value' | 'newItem' | 'undefinedArray' | undefined;
631
+ if (modelField?.localized) {
632
+ if (Array.isArray(value)) {
633
+ const localizedItem = value.find((item) => item._key === locale);
634
+ if (localizedItem) {
635
+ patchFieldPath += `[_key=="${locale}"]`;
636
+ if (fieldPathTail.length === 0) {
637
+ localizedLeaf = 'value';
638
+ if (addValueToI18NLeafs) {
639
+ patchFieldPath += '.value';
640
+ }
641
+ } else {
642
+ patchFieldPath += '.value';
643
+ }
644
+ value = localizedItem.value;
645
+ } else if (fieldPathTail.length === 0 && allowUndefinedI18NLeafArrays) {
646
+ localizedLeaf = 'newItem';
647
+ value = undefined;
648
+ } else {
649
+ throw new Error(`The localized field '${fieldName}' has no value for locale '${locale}'.`);
650
+ }
651
+ } else if (fieldPathTail.length === 0 && allowUndefinedI18NLeafArrays) {
652
+ localizedLeaf = 'undefinedArray';
653
+ value = undefined;
654
+ } else {
655
+ throw new Error(`The localized field '${fieldName}' has no localization array defined for locale '${locale}'.`);
656
+ }
657
+ }
658
+
659
+ const result = iterateField({
660
+ value,
661
+ modelField,
662
+ fieldPath: fieldPathTail,
663
+ rootModel,
664
+ modelFieldPath: modelFieldPath.concat(fieldName),
665
+ isInList: false
666
+ });
667
+
668
+ return {
669
+ model: result.model,
670
+ modelFieldPath: result.modelFieldPath,
671
+ patchFieldPath: patchFieldPath + result.patchFieldPath,
672
+ localizedLeaf: localizedLeaf ?? result.localizedLeaf,
673
+ currentValue: result.currentValue,
674
+ isInList: result.isInList
675
+ };
676
+ }
677
+
678
+ function iterateField({
679
+ value,
680
+ modelField,
681
+ fieldPath,
682
+ rootModel,
683
+ modelFieldPath,
684
+ isInList
685
+ }: {
686
+ value: any;
687
+ modelField: StackbitTypes.FieldSpecificProps;
688
+ fieldPath: StackbitTypes.FieldPath;
689
+ rootModel: ModelWithContext;
690
+ modelFieldPath: string[];
691
+ isInList: boolean;
692
+ }): {
693
+ model: ModelWithContext;
694
+ modelFieldPath: string[];
695
+ patchFieldPath: string;
696
+ localizedLeaf?: 'value' | 'newItem' | 'undefinedArray';
697
+ currentValue: any;
698
+ isInList: boolean;
699
+ } {
700
+ if (fieldPath.length === 0) {
701
+ return {
702
+ patchFieldPath: '',
703
+ currentValue: value,
704
+ model: rootModel,
705
+ modelFieldPath,
706
+ isInList: isInList
707
+ };
708
+ }
709
+ if (typeof value === 'undefined') {
710
+ throw new Error(`Field path has more items [${fieldPath.join('.')}], but value is undefined.`);
711
+ }
712
+ if (modelField?.type === 'object') {
713
+ return iterateObject({
714
+ object: value,
715
+ model: modelField,
716
+ rootModel,
717
+ modelFieldPath,
718
+ fieldPath
719
+ });
720
+ } else if (modelField?.type === 'model') {
721
+ const modelName = resolvedFieldType({
722
+ sanityFieldType: value._type,
723
+ model: rootModel,
724
+ modelFieldPath
725
+ });
726
+ const model = getModelByName(modelName);
727
+ if (!model) {
728
+ throw new Error(`Model '${modelName}' not found.`);
729
+ }
730
+ return iterateObject({
731
+ object: value,
732
+ model,
733
+ rootModel: model,
734
+ modelFieldPath: [],
735
+ fieldPath
736
+ });
737
+ } else if (modelField?.type === 'list') {
738
+ const [itemIndex, ...fieldPathTail] = fieldPath as [number, ...StackbitTypes.FieldPath];
739
+ const listItem = value[itemIndex];
740
+ const itemModel = getItemTypeForListItem(listItem, modelField);
741
+ if (!itemModel) {
742
+ throw new Error('Could not resolve type of a list item.');
743
+ }
744
+ const result = iterateField({
745
+ value: listItem,
746
+ modelField: itemModel,
747
+ rootModel,
748
+ modelFieldPath: modelFieldPath.concat('items'),
749
+ fieldPath: fieldPathTail,
750
+ isInList: true
751
+ });
752
+
753
+ // try to use Sanity _key as explicit accessor
754
+ const key = listItem?._key;
755
+ const patchFieldPath = key ? `[_key=="${key}"]` : `[${Number(itemIndex)}]`;
756
+
757
+ return {
758
+ // model fieldPath doesn't include array indexes.
759
+ // return fieldPath with field names only, no list indexes
760
+ model: result.model,
761
+ modelFieldPath: result.modelFieldPath,
762
+ // fieldPath for Sanity patches should always include array indexes.
763
+ patchFieldPath: patchFieldPath + result.patchFieldPath,
764
+ currentValue: result.currentValue,
765
+ isInList: result.isInList
766
+ };
767
+ } else {
768
+ throw new Error(`Field path has more items [${fieldPath.join('.')}], but no objects/arrays left to iterate.`);
769
+ }
770
+ }
771
+
772
+ const modelName = sanityDocument._type;
773
+ const model = getModelByName(modelName);
774
+ if (!model) {
775
+ throw new Error(`Model '${modelName}' not found.`);
776
+ }
777
+ return iterateObject({
778
+ object: sanityDocument,
779
+ model,
780
+ rootModel: model,
781
+ modelFieldPath: [],
782
+ fieldPath,
783
+ first: true
784
+ });
785
+ }