gt-sanity 2.0.21 → 2.1.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 (44) hide show
  1. package/dist/index.cjs +1220 -763
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +113 -1
  4. package/dist/index.d.ts +113 -1
  5. package/dist/index.js +1211 -754
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/adapter/core.ts +27 -2
  9. package/src/adapter/types.ts +9 -0
  10. package/src/components/TranslationsProvider.tsx +92 -6
  11. package/src/components/page/TranslationsTable.tsx +5 -3
  12. package/src/components/shared/SingleDocumentView.tsx +5 -3
  13. package/src/components/tab/TranslationView.tsx +24 -14
  14. package/src/configuration/baseDocumentLevelConfig/documentLevelPatch.ts +1 -1
  15. package/src/configuration/baseFieldLevelConfig.ts +1 -1
  16. package/src/configuration/internationalizedArrayConfig/internationalizedArrayPatch.ts +59 -0
  17. package/src/index.ts +114 -58
  18. package/src/schema/InternationalizedArrayInput.tsx +278 -0
  19. package/src/schema/__tests__/createInternationalizedArrayTypes.test.ts +169 -0
  20. package/src/schema/createInternationalizedArrayTypes.ts +209 -0
  21. package/src/schema/types.ts +84 -0
  22. package/src/serialization/__tests__/BaseDocumentDeserializer/baseDeserialization.test.ts +3 -3
  23. package/src/serialization/__tests__/BaseDocumentMerger/baseMerge.test.ts +1 -1
  24. package/src/serialization/__tests__/BaseDocumentMerger/documentLevelMerge.test.ts +1 -1
  25. package/src/serialization/__tests__/BaseDocumentMerger/fieldLevelMerge.test.ts +1 -1
  26. package/src/serialization/__tests__/BaseDocumentSerializer/baseSerialization.test.ts +2 -2
  27. package/src/serialization/__tests__/BaseDocumentSerializer/documentInlineMarks.test.ts +4 -2
  28. package/src/serialization/__tests__/global.setup.ts +6 -0
  29. package/src/serialization/__tests__/helpers.ts +2 -1
  30. package/src/serialization/internationalizedArray/__tests__/internationalizedArray.test.ts +265 -0
  31. package/src/serialization/internationalizedArray/__tests__/serializeRoundTrip.test.ts +83 -0
  32. package/src/serialization/internationalizedArray/collapse.ts +52 -0
  33. package/src/serialization/internationalizedArray/detect.ts +81 -0
  34. package/src/serialization/internationalizedArray/merge.ts +149 -0
  35. package/src/serialization/types.ts +5 -1
  36. package/src/translation/__tests__/strategy.test.ts +47 -0
  37. package/src/translation/importDocument.ts +9 -5
  38. package/src/translation/strategy.ts +92 -0
  39. package/src/translation/uploadFiles.ts +1 -1
  40. package/src/types.ts +1 -1
  41. package/src/utils/__tests__/batchProcessor.test.ts +61 -2
  42. package/src/utils/batchProcessor.ts +11 -6
  43. package/src/utils/serialize.ts +24 -7
  44. package/src/serialization/index.ts +0 -16
@@ -0,0 +1,209 @@
1
+ import { getLocaleProperties } from 'generaltranslation';
2
+ import { FieldProps, SchemaTypeDefinition } from 'sanity';
3
+ import {
4
+ InternationalizedArrayInput,
5
+ InternationalizedValueItem,
6
+ } from './InternationalizedArrayInput';
7
+ import {
8
+ FieldLevelFieldType,
9
+ FieldLevelUIComponents,
10
+ GTFieldLevelLocalizationConfig,
11
+ } from './types';
12
+
13
+ const DEFAULT_TYPE_PREFIX = 'internationalizedArray';
14
+
15
+ export type CreateInternationalizedArrayTypesOptions = {
16
+ sourceLocale: string;
17
+ locales: string[];
18
+ fieldTypes: FieldLevelFieldType[];
19
+ languageTitles?: Record<string, string>;
20
+ getLanguageTitle?: (locale: string) => string;
21
+ typePrefix?: string;
22
+ includeCompatibilityTypes?: boolean;
23
+ components?: FieldLevelUIComponents;
24
+ };
25
+
26
+ /**
27
+ * Resolve the components to attach to generated types. Each slot can be a
28
+ * custom component, `false` (detach — Sanity's default rendering), or
29
+ * undefined (GT's default). The `field` level-reset wrapper only makes sense
30
+ * alongside GT's inline input, so its default follows the resolved `input`.
31
+ */
32
+ function resolveComponents(overrides: FieldLevelUIComponents | undefined): {
33
+ input?: unknown;
34
+ item?: unknown;
35
+ field?: unknown;
36
+ } {
37
+ const input =
38
+ overrides?.input === false
39
+ ? undefined
40
+ : (overrides?.input ?? InternationalizedArrayInput);
41
+ const item =
42
+ overrides?.item === false
43
+ ? undefined
44
+ : (overrides?.item ?? InternationalizedValueItem);
45
+ const defaultField =
46
+ input === InternationalizedArrayInput
47
+ ? // Reset the field level so inline per-locale inputs don't inherit
48
+ // nested-object indentation.
49
+ (fieldProps: FieldProps) =>
50
+ fieldProps.renderDefault({ ...fieldProps, level: 0 })
51
+ : undefined;
52
+ const field =
53
+ overrides?.field === false ? undefined : (overrides?.field ?? defaultField);
54
+ return { input, item, field };
55
+ }
56
+
57
+ function capitalize(value: string): string {
58
+ return value.charAt(0).toUpperCase() + value.slice(1);
59
+ }
60
+
61
+ function fieldTypeName(fieldType: FieldLevelFieldType): string {
62
+ return typeof fieldType === 'string' ? fieldType : fieldType.name;
63
+ }
64
+
65
+ /** Build the generated `value` field definition for a localizable field type. */
66
+ function valueField(fieldType: FieldLevelFieldType): Record<string, unknown> {
67
+ if (fieldType === 'string' || fieldType === 'text') {
68
+ return { name: 'value', type: fieldType, title: 'Value' };
69
+ }
70
+ if (fieldType === 'block') {
71
+ return {
72
+ name: 'value',
73
+ type: 'array',
74
+ title: 'Value',
75
+ of: [{ type: 'block' }],
76
+ };
77
+ }
78
+ // Custom object form: pass the user definition through under `value`.
79
+ const { name: _name, ...rest } = fieldType;
80
+ return { name: 'value', title: 'Value', ...rest };
81
+ }
82
+
83
+ function makeLanguageTitle(
84
+ options: CreateInternationalizedArrayTypesOptions
85
+ ): (locale: string) => string {
86
+ const { languageTitles, getLanguageTitle, sourceLocale } = options;
87
+ return (locale: string) =>
88
+ getLanguageTitle?.(locale) ??
89
+ languageTitles?.[locale] ??
90
+ getLocaleProperties(locale, sourceLocale).name ??
91
+ locale;
92
+ }
93
+
94
+ function buildTypesForPrefix(
95
+ prefix: string,
96
+ options: CreateInternationalizedArrayTypesOptions
97
+ ): SchemaTypeDefinition[] {
98
+ const { sourceLocale, locales, fieldTypes } = options;
99
+ const languageTitle = makeLanguageTitle(options);
100
+ const components = resolveComponents(options.components);
101
+
102
+ return fieldTypes.map((fieldType) => {
103
+ const typeName = `${prefix}${capitalize(fieldTypeName(fieldType))}`;
104
+ const valueTypeName = `${typeName}Value`;
105
+
106
+ // Locale identity comes from gtPlugin (sourceLocale + locales); surfaced
107
+ // on both the array type (per-locale add buttons) and the value object
108
+ // (inline item label + source-locale remove guard).
109
+ const gtInternationalizedArray = {
110
+ sourceLocale,
111
+ locales,
112
+ titles: Object.fromEntries(
113
+ [sourceLocale, ...locales].map((locale) => [
114
+ locale,
115
+ languageTitle(locale),
116
+ ])
117
+ ),
118
+ };
119
+
120
+ const valueObject = {
121
+ type: 'object',
122
+ name: valueTypeName,
123
+ ...(components.item ? { components: { item: components.item } } : {}),
124
+ fields: [
125
+ {
126
+ name: 'language',
127
+ type: 'string',
128
+ title: 'Language',
129
+ readOnly: true,
130
+ // GT's inline item shows the locale as the value field's label;
131
+ // with a custom or detached item, keep the field visible so the
132
+ // locale is still discoverable in default/dialog rendering.
133
+ hidden: components.item === InternationalizedValueItem,
134
+ },
135
+ valueField(fieldType),
136
+ ],
137
+ options: { gtInternationalizedArray },
138
+ preview: {
139
+ select: { language: 'language', value: 'value' },
140
+ prepare(selection: { language?: string; value?: unknown }) {
141
+ const localeLabel = selection.language
142
+ ? languageTitle(selection.language)
143
+ : 'Unknown';
144
+ return {
145
+ title:
146
+ typeof selection.value === 'string'
147
+ ? selection.value
148
+ : localeLabel,
149
+ subtitle: localeLabel,
150
+ };
151
+ },
152
+ },
153
+ };
154
+
155
+ const arrayComponents = {
156
+ ...(components.input ? { input: components.input } : {}),
157
+ ...(components.field ? { field: components.field } : {}),
158
+ };
159
+
160
+ return {
161
+ name: typeName,
162
+ type: 'array',
163
+ ...(Object.keys(arrayComponents).length
164
+ ? { components: arrayComponents }
165
+ : {}),
166
+ of: [valueObject],
167
+ options: { gtInternationalizedArray },
168
+ } as unknown as SchemaTypeDefinition;
169
+ });
170
+ }
171
+
172
+ /**
173
+ * Generate `internationalizedArray*` schema types from gtPlugin locale config.
174
+ *
175
+ * Each generated type is an array of `{ _key, _type, language, value }` objects
176
+ * matching `sanity-plugin-internationalized-array`, giving zero-migration
177
+ * interop with existing internationalized-array data.
178
+ *
179
+ * When `typePrefix` is customized and `includeCompatibilityTypes` is true, the
180
+ * standard `internationalizedArray*` names are also generated so existing
181
+ * content keeps resolving.
182
+ */
183
+ export function createInternationalizedArrayTypes(
184
+ options: CreateInternationalizedArrayTypesOptions
185
+ ): SchemaTypeDefinition[] {
186
+ const prefix = options.typePrefix ?? DEFAULT_TYPE_PREFIX;
187
+ const includeCompatibilityTypes = options.includeCompatibilityTypes ?? true;
188
+
189
+ const types = buildTypesForPrefix(prefix, options);
190
+
191
+ if (includeCompatibilityTypes && prefix !== DEFAULT_TYPE_PREFIX) {
192
+ types.push(...buildTypesForPrefix(DEFAULT_TYPE_PREFIX, options));
193
+ }
194
+
195
+ return types;
196
+ }
197
+
198
+ export function resolveFieldLevelConfig(
199
+ config: GTFieldLevelLocalizationConfig | undefined
200
+ ): Required<Pick<GTFieldLevelLocalizationConfig, 'enabled' | 'fieldTypes'>> &
201
+ GTFieldLevelLocalizationConfig {
202
+ return {
203
+ enabled: false,
204
+ fieldTypes: ['string', 'text'],
205
+ typePrefix: DEFAULT_TYPE_PREFIX,
206
+ includeCompatibilityTypes: true,
207
+ ...config,
208
+ };
209
+ }
@@ -0,0 +1,84 @@
1
+ import type { ComponentType } from 'react';
2
+ import type {
3
+ ArrayOfObjectsInputProps,
4
+ FieldProps,
5
+ ObjectItemProps,
6
+ } from 'sanity';
7
+
8
+ /**
9
+ * A localizable field type. Built-in shortcuts (`string`, `text`, `block`) map
10
+ * to generated `value` field definitions; an object form supplies a custom
11
+ * `value` field definition verbatim.
12
+ */
13
+ export type FieldLevelFieldType =
14
+ | 'string'
15
+ | 'text'
16
+ | 'block'
17
+ | {
18
+ /** Suffix used in the generated type name, e.g. `seo` → `internationalizedArraySeo`. */
19
+ name: string;
20
+ /** Sanity type of the generated `value` field (e.g. an object type name). */
21
+ type: string;
22
+ title?: string;
23
+ of?: unknown[];
24
+ fields?: unknown[];
25
+ options?: Record<string, unknown>;
26
+ };
27
+
28
+ /**
29
+ * Studio component overrides for generated `internationalizedArray*` types.
30
+ *
31
+ * Each slot defaults to GT's inline UI (per-locale labeled inputs with
32
+ * remove buttons and add-locale buttons). Pass a component to replace a
33
+ * slot, or `false` to detach GT's component and fall back to Sanity's
34
+ * default rendering. Translation is unaffected either way — it operates on
35
+ * the stored `{_key, language, value}` data, not on the components.
36
+ */
37
+ export type FieldLevelUIComponents = {
38
+ /** Input for the generated array types. Default: GT's inline input. */
39
+ input?: ComponentType<ArrayOfObjectsInputProps> | false;
40
+ /** Item for the generated `*Value` objects. Default: GT's inline item. */
41
+ item?: ComponentType<ObjectItemProps> | false;
42
+ /**
43
+ * Field wrapper for the generated array types. Defaults to a wrapper that
44
+ * resets the field level (removes nested-object indentation) — but only
45
+ * while GT's default input is in use; with a custom or disabled `input`,
46
+ * this defaults to Sanity's standard field rendering.
47
+ */
48
+ field?: ComponentType<FieldProps> | false;
49
+ };
50
+
51
+ /**
52
+ * Field-level localization options, mirroring the useful parts of
53
+ * `sanity-plugin-internationalized-array` while keeping `sourceLocale` /
54
+ * `locales` from `gtPlugin()` as the only source of locale identity.
55
+ */
56
+ export type GTFieldLevelLocalizationConfig = {
57
+ /** Generate `internationalizedArray*` schema types + input components. Default `false`. */
58
+ enabled?: boolean;
59
+ /** Which field types to generate. Default `['string', 'text']`. */
60
+ fieldTypes?: FieldLevelFieldType[];
61
+ /** Per-locale display labels shown in the Studio input. */
62
+ languageTitles?: Record<string, string>;
63
+ /** Compute a display label for a locale (overrides `languageTitles`). */
64
+ getLanguageTitle?: (locale: string) => string;
65
+ /** Prefix for generated type names. Default `'internationalizedArray'`. */
66
+ typePrefix?: string;
67
+ /**
68
+ * When `typePrefix` is customized, also generate `internationalizedArray*`
69
+ * aliases for interop with existing data. Default `true`.
70
+ */
71
+ includeCompatibilityTypes?: boolean;
72
+ /**
73
+ * Override or detach the Studio components attached to generated types.
74
+ * Use this to keep your own UI (or Sanity's default) while GT generates
75
+ * the schema types and handles translation.
76
+ */
77
+ components?: FieldLevelUIComponents;
78
+
79
+ // Deferred past v1 (need a richer custom input first):
80
+ // buttonLocations, buttonAddAll, languageDisplay, select.
81
+ //
82
+ // Item field names are fixed (not configurable) in v1: locale in `language`,
83
+ // data in `value`, random `_key` — matching the reference plugin.
84
+ };
@@ -6,12 +6,12 @@ import {
6
6
  PortableTextTextBlock,
7
7
  } from 'sanity';
8
8
  import { beforeEach, expect, test, vi } from 'vitest';
9
+ import { BaseDocumentDeserializer } from '../../deserialize/BaseDocumentDeserializer';
10
+ import { BaseDocumentSerializer } from '../../serialize/index';
9
11
  import {
10
- BaseDocumentDeserializer,
11
- BaseDocumentSerializer,
12
12
  customBlockDeserializers,
13
13
  defaultStopTypes,
14
- } from '../../index';
14
+ } from '../../BaseSerializationConfig';
15
15
  import {
16
16
  annotationAndInlineBlocks,
17
17
  documentLevelArticle,
@@ -1,6 +1,6 @@
1
1
  import { PortableTextBlock } from 'sanity';
2
2
  import { expect, test } from 'vitest';
3
- import { BaseDocumentMerger } from '../../index';
3
+ import { BaseDocumentMerger } from '../../BaseDocumentMerger';
4
4
  import { getNewDocument } from './utils';
5
5
  import documentLevelArticle from '../__fixtures__/documentLevelArticle.json';
6
6
 
@@ -1,5 +1,5 @@
1
1
  import { expect, test } from 'vitest';
2
- import { BaseDocumentMerger } from '../../index';
2
+ import { BaseDocumentMerger } from '../../BaseDocumentMerger';
3
3
  import { getNewDocument, getNewObject } from './utils';
4
4
  import documentLevelArticle from '../__fixtures__/documentLevelArticle.json';
5
5
 
@@ -1,5 +1,5 @@
1
1
  import { expect, test } from 'vitest';
2
- import { BaseDocumentMerger } from '../../index';
2
+ import { BaseDocumentMerger } from '../../BaseDocumentMerger';
3
3
  import { getDeserialized } from '../helpers';
4
4
  import { getNewFieldLevelDocument, getNewObject } from './utils';
5
5
  import clone from 'just-clone';
@@ -1,10 +1,10 @@
1
1
  import { PortableTextBlock } from 'sanity';
2
2
  import { describe, expect, test, vi } from 'vitest';
3
+ import { BaseDocumentSerializer } from '../../serialize/index';
3
4
  import {
4
- BaseDocumentSerializer,
5
5
  customSerializers,
6
6
  defaultStopTypes,
7
- } from '../../index';
7
+ } from '../../BaseSerializationConfig';
8
8
  import {
9
9
  addedCustomSerializers,
10
10
  createCustomInnerHTML,
@@ -2,9 +2,11 @@ import { SanityDocument } from 'sanity';
2
2
  import { expect, test } from 'vitest';
3
3
  import { getDeserialized } from '../helpers';
4
4
  import { docWithInlineMarks, findByClass, getHTMLNode, schema } from './utils';
5
- import { attachGTData, BaseDocumentSerializer, customSerializers } from '../..';
5
+ import { attachGTData } from '../../data';
6
+ import { BaseDocumentSerializer } from '../../serialize/index';
7
+ import { customSerializers } from '../../BaseSerializationConfig';
6
8
  import { TranslationLevel } from '../../types';
7
- import { defaultStopTypes } from '../..';
9
+ import { defaultStopTypes } from '../../BaseSerializationConfig';
8
10
  import merge from 'lodash.merge';
9
11
  import { PortableTextHtmlComponents } from '@portabletext/to-html';
10
12
 
@@ -5,6 +5,12 @@ import {
5
5
  } from 'sanity';
6
6
  import { vi } from 'vitest';
7
7
 
8
+ // jsdom doesn't provide window.CSS; sanity's form components call
9
+ // CSS.supports at module scope when imported.
10
+ if (typeof globalThis.CSS === 'undefined') {
11
+ (globalThis as { CSS?: unknown }).CSS = { supports: () => false };
12
+ }
13
+
8
14
  let mockTestKey = 0;
9
15
 
10
16
  vi.mock('@portabletext/block-tools', async () => {
@@ -1,4 +1,5 @@
1
- import { BaseDocumentSerializer, BaseDocumentDeserializer } from '../index';
1
+ import { BaseDocumentSerializer } from '../serialize/index';
2
+ import { BaseDocumentDeserializer } from '../deserialize/BaseDocumentDeserializer';
2
3
  import {
3
4
  customSerializers,
4
5
  customDeserializers,
@@ -0,0 +1,265 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import {
3
+ findLocaleItem,
4
+ isInternationalizedArrayField,
5
+ isInternationalizedArrayItem,
6
+ } from '../detect';
7
+ import { collapseToSourceLocale } from '../collapse';
8
+ import { mergeInternationalizedArrays } from '../merge';
9
+ import { pluginConfig } from '../../../adapter/core';
10
+
11
+ const stringItem = (language: string, value: string) => ({
12
+ _key: `key-${language}`,
13
+ _type: 'internationalizedArrayStringValue',
14
+ language,
15
+ value,
16
+ });
17
+
18
+ describe('detect', () => {
19
+ test('recognises an internationalized array item by shape', () => {
20
+ expect(isInternationalizedArrayItem(stringItem('en', 'Hello'))).toBe(true);
21
+ });
22
+
23
+ test('rejects plain objects and ordinary array entries', () => {
24
+ expect(isInternationalizedArrayItem({ _type: 'block', children: [] })).toBe(
25
+ false
26
+ );
27
+ expect(isInternationalizedArrayItem({ language: 'en', value: 'x' })).toBe(
28
+ false
29
+ );
30
+ });
31
+
32
+ test('rejects user-defined *Value types that only share the shape', () => {
33
+ expect(
34
+ isInternationalizedArrayItem({
35
+ _key: 'k',
36
+ _type: 'priceValue',
37
+ language: 'USD',
38
+ value: 9.99,
39
+ })
40
+ ).toBe(false);
41
+ });
42
+
43
+ test('recognises items generated with a custom typePrefix', () => {
44
+ const item = {
45
+ _key: 'k',
46
+ _type: 'myI18nStringValue',
47
+ language: 'en',
48
+ value: 'Hello',
49
+ };
50
+ expect(isInternationalizedArrayItem(item)).toBe(false);
51
+
52
+ const originalPrefix = pluginConfig.fieldLevelTypePrefix;
53
+ pluginConfig.fieldLevelTypePrefix = 'myI18n';
54
+ try {
55
+ expect(isInternationalizedArrayItem(item)).toBe(true);
56
+ } finally {
57
+ pluginConfig.fieldLevelTypePrefix = originalPrefix;
58
+ }
59
+ });
60
+
61
+ test('recognises a full internationalized array field', () => {
62
+ const field = [stringItem('en', 'Hello'), stringItem('es', 'Hola')];
63
+ expect(isInternationalizedArrayField(field)).toBe(true);
64
+ });
65
+
66
+ test('a mixed/ordinary array is not an internationalized array field', () => {
67
+ expect(isInternationalizedArrayField([{ _type: 'block' }])).toBe(false);
68
+ expect(isInternationalizedArrayField([])).toBe(false);
69
+ });
70
+
71
+ test('findLocaleItem matches on exact language id, not _key', () => {
72
+ const field = [stringItem('en', 'Hello'), stringItem('fr-CA', 'Bonjour')];
73
+ expect(findLocaleItem(field, 'fr-CA')?.value).toBe('Bonjour');
74
+ expect(findLocaleItem(field, 'key-en')).toBeUndefined();
75
+ });
76
+ });
77
+
78
+ describe('collapseToSourceLocale', () => {
79
+ test('exports only the source-locale value for a string field', () => {
80
+ const doc = {
81
+ _id: 'a',
82
+ _type: 'post',
83
+ title: [stringItem('en', 'Hello'), stringItem('es', 'Hola')],
84
+ };
85
+ expect(collapseToSourceLocale(doc, 'en')).toEqual({
86
+ _id: 'a',
87
+ _type: 'post',
88
+ title: 'Hello',
89
+ });
90
+ });
91
+
92
+ test('skips a field with no source-locale item', () => {
93
+ const doc = {
94
+ _type: 'post',
95
+ title: [stringItem('es', 'Hola'), stringItem('fr', 'Bonjour')],
96
+ };
97
+ expect(collapseToSourceLocale(doc, 'en')).toEqual({ _type: 'post' });
98
+ });
99
+
100
+ test('handles hyphenated source locale ids', () => {
101
+ const doc = {
102
+ _type: 'post',
103
+ title: [stringItem('en-US', 'Hello'), stringItem('es', 'Hola')],
104
+ };
105
+ expect(collapseToSourceLocale(doc, 'en-US')).toEqual({
106
+ _type: 'post',
107
+ title: 'Hello',
108
+ });
109
+ });
110
+
111
+ test('preserves Portable Text source values', () => {
112
+ const blocks = [{ _type: 'block', _key: 'b1', children: [] }];
113
+ const doc = {
114
+ _type: 'post',
115
+ body: [
116
+ {
117
+ _key: 'k',
118
+ _type: 'internationalizedArrayBlockValue',
119
+ language: 'en',
120
+ value: blocks,
121
+ },
122
+ ],
123
+ };
124
+ expect(collapseToSourceLocale(doc, 'en')).toEqual({
125
+ _type: 'post',
126
+ body: blocks,
127
+ });
128
+ });
129
+
130
+ test('leaves ordinary (non-localized) arrays untouched', () => {
131
+ const doc = { _type: 'post', tags: ['a', 'b'] };
132
+ expect(collapseToSourceLocale(doc, 'en')).toEqual(doc);
133
+ });
134
+
135
+ test('handles an internationalized array field nested inside an object', () => {
136
+ const doc = {
137
+ _type: 'post',
138
+ seo: {
139
+ _type: 'seo',
140
+ heading: [stringItem('en', 'Hi'), stringItem('es', 'Hola')],
141
+ },
142
+ };
143
+ expect(collapseToSourceLocale(doc, 'en')).toEqual({
144
+ _type: 'post',
145
+ seo: { _type: 'seo', heading: 'Hi' },
146
+ });
147
+ });
148
+ });
149
+
150
+ describe('mergeInternationalizedArrays', () => {
151
+ test('updates an existing target-locale item, preserving its _key', () => {
152
+ const baseDoc = {
153
+ _id: 'a',
154
+ _type: 'post',
155
+ title: [stringItem('en', 'Hello'), stringItem('es', 'OLD')],
156
+ };
157
+ const changes = mergeInternationalizedArrays(
158
+ baseDoc,
159
+ { _type: 'post', title: 'Hola' },
160
+ 'es',
161
+ 'en'
162
+ );
163
+ expect(changes.title).toEqual([
164
+ stringItem('en', 'Hello'),
165
+ stringItem('es', 'Hola'),
166
+ ]);
167
+ });
168
+
169
+ test('inserts a missing target-locale item with a fresh random _key', () => {
170
+ const baseDoc = {
171
+ _type: 'post',
172
+ title: [stringItem('en', 'Hello')],
173
+ };
174
+ const changes = mergeInternationalizedArrays(
175
+ baseDoc,
176
+ { _type: 'post', title: 'Bonjour' },
177
+ 'fr',
178
+ 'en'
179
+ );
180
+ const title = changes.title as Array<Record<string, unknown>>;
181
+ expect(title).toHaveLength(2);
182
+ const inserted = title[1];
183
+ expect(inserted.language).toBe('fr');
184
+ expect(inserted.value).toBe('Bonjour');
185
+ expect(inserted._type).toBe('internationalizedArrayStringValue');
186
+ expect(typeof inserted._key).toBe('string');
187
+ expect(inserted._key).not.toBe('key-en');
188
+ });
189
+
190
+ test('preserves the source-locale item', () => {
191
+ const baseDoc = {
192
+ _type: 'post',
193
+ title: [stringItem('en', 'Hello')],
194
+ };
195
+ const changes = mergeInternationalizedArrays(
196
+ baseDoc,
197
+ { _type: 'post', title: 'Hola' },
198
+ 'es',
199
+ 'en'
200
+ );
201
+ const title = changes.title as Array<Record<string, unknown>>;
202
+ expect(title.find((i) => i.language === 'en')?.value).toBe('Hello');
203
+ });
204
+
205
+ test('leaves non-translatable sibling fields out of the patch', () => {
206
+ const baseDoc = {
207
+ _type: 'post',
208
+ title: [stringItem('en', 'Hello')],
209
+ slug: { current: 'hello' },
210
+ };
211
+ const changes = mergeInternationalizedArrays(
212
+ baseDoc,
213
+ { _type: 'post', title: 'Hola', slug: { current: 'hola' } },
214
+ 'es',
215
+ 'en'
216
+ );
217
+ expect(Object.keys(changes)).toEqual(['title']);
218
+ });
219
+
220
+ test('handles hyphenated target locale ids', () => {
221
+ const baseDoc = {
222
+ _type: 'post',
223
+ title: [stringItem('en', 'Hello')],
224
+ };
225
+ const changes = mergeInternationalizedArrays(
226
+ baseDoc,
227
+ { _type: 'post', title: 'Bonjour' },
228
+ 'fr-CA',
229
+ 'en'
230
+ );
231
+ const title = changes.title as Array<Record<string, unknown>>;
232
+ expect(title.find((i) => i.language === 'fr-CA')?.value).toBe('Bonjour');
233
+ });
234
+
235
+ test('upserts an internationalized array nested inside an object field', () => {
236
+ const baseDoc = {
237
+ _type: 'post',
238
+ seo: {
239
+ _type: 'seo',
240
+ _key: undefined,
241
+ heading: [stringItem('en', 'Hi')],
242
+ },
243
+ };
244
+ const changes = mergeInternationalizedArrays(
245
+ baseDoc,
246
+ { _type: 'post', seo: { _type: 'seo', heading: 'Hola' } },
247
+ 'es',
248
+ 'en'
249
+ );
250
+ const seo = changes.seo as { heading: Array<Record<string, unknown>> };
251
+ expect(seo.heading.find((i) => i.language === 'es')?.value).toBe('Hola');
252
+ expect(seo.heading.find((i) => i.language === 'en')?.value).toBe('Hi');
253
+ });
254
+
255
+ test('returns no changes when there is no localized content', () => {
256
+ const baseDoc = { _type: 'post', title: 'plain' };
257
+ const changes = mergeInternationalizedArrays(
258
+ baseDoc,
259
+ { _type: 'post', title: 'plain-es' },
260
+ 'es',
261
+ 'en'
262
+ );
263
+ expect(changes).toEqual({});
264
+ });
265
+ });