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.
- package/dist/index.cjs +1220 -763
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +113 -1
- package/dist/index.d.ts +113 -1
- package/dist/index.js +1211 -754
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/adapter/core.ts +27 -2
- package/src/adapter/types.ts +9 -0
- package/src/components/TranslationsProvider.tsx +92 -6
- package/src/components/page/TranslationsTable.tsx +5 -3
- package/src/components/shared/SingleDocumentView.tsx +5 -3
- package/src/components/tab/TranslationView.tsx +24 -14
- package/src/configuration/baseDocumentLevelConfig/documentLevelPatch.ts +1 -1
- package/src/configuration/baseFieldLevelConfig.ts +1 -1
- package/src/configuration/internationalizedArrayConfig/internationalizedArrayPatch.ts +59 -0
- package/src/index.ts +114 -58
- package/src/schema/InternationalizedArrayInput.tsx +278 -0
- package/src/schema/__tests__/createInternationalizedArrayTypes.test.ts +169 -0
- package/src/schema/createInternationalizedArrayTypes.ts +209 -0
- package/src/schema/types.ts +84 -0
- package/src/serialization/__tests__/BaseDocumentDeserializer/baseDeserialization.test.ts +3 -3
- package/src/serialization/__tests__/BaseDocumentMerger/baseMerge.test.ts +1 -1
- package/src/serialization/__tests__/BaseDocumentMerger/documentLevelMerge.test.ts +1 -1
- package/src/serialization/__tests__/BaseDocumentMerger/fieldLevelMerge.test.ts +1 -1
- package/src/serialization/__tests__/BaseDocumentSerializer/baseSerialization.test.ts +2 -2
- package/src/serialization/__tests__/BaseDocumentSerializer/documentInlineMarks.test.ts +4 -2
- package/src/serialization/__tests__/global.setup.ts +6 -0
- package/src/serialization/__tests__/helpers.ts +2 -1
- package/src/serialization/internationalizedArray/__tests__/internationalizedArray.test.ts +265 -0
- package/src/serialization/internationalizedArray/__tests__/serializeRoundTrip.test.ts +83 -0
- package/src/serialization/internationalizedArray/collapse.ts +52 -0
- package/src/serialization/internationalizedArray/detect.ts +81 -0
- package/src/serialization/internationalizedArray/merge.ts +149 -0
- package/src/serialization/types.ts +5 -1
- package/src/translation/__tests__/strategy.test.ts +47 -0
- package/src/translation/importDocument.ts +9 -5
- package/src/translation/strategy.ts +92 -0
- package/src/translation/uploadFiles.ts +1 -1
- package/src/types.ts +1 -1
- package/src/utils/__tests__/batchProcessor.test.ts +61 -2
- package/src/utils/batchProcessor.ts +11 -6
- package/src/utils/serialize.ts +24 -7
- 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 '../../
|
|
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 '../../
|
|
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 '../../
|
|
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 '../../
|
|
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 '../../
|
|
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
|
|
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
|
|
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
|
+
});
|