gt-sanity 2.0.21 → 2.1.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/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,83 @@
|
|
|
1
|
+
import { Schema } from '@sanity/schema';
|
|
2
|
+
import { SanityDocument } from 'sanity';
|
|
3
|
+
import { describe, expect, test } from 'vitest';
|
|
4
|
+
import { createInternationalizedArrayTypes } from '../../../schema/createInternationalizedArrayTypes';
|
|
5
|
+
import {
|
|
6
|
+
deserializeDocument,
|
|
7
|
+
serializeDocument,
|
|
8
|
+
} from '../../../utils/serialize';
|
|
9
|
+
|
|
10
|
+
const generatedTypes = createInternationalizedArrayTypes({
|
|
11
|
+
sourceLocale: 'en',
|
|
12
|
+
locales: ['es'],
|
|
13
|
+
fieldTypes: ['string', 'text'],
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const postType = {
|
|
17
|
+
name: 'post',
|
|
18
|
+
title: 'Post',
|
|
19
|
+
type: 'document',
|
|
20
|
+
fields: [
|
|
21
|
+
{ name: 'title', title: 'Title', type: 'internationalizedArrayString' },
|
|
22
|
+
{
|
|
23
|
+
name: 'description',
|
|
24
|
+
title: 'Description',
|
|
25
|
+
type: 'internationalizedArrayText',
|
|
26
|
+
},
|
|
27
|
+
{ name: 'tags', title: 'Tags', type: 'array', of: [{ type: 'string' }] },
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const schema: InstanceType<typeof Schema> = new Schema({
|
|
32
|
+
name: 'test',
|
|
33
|
+
types: [...generatedTypes, postType] as never,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const item = (type: string, language: string, value: unknown) => ({
|
|
37
|
+
_key: `key-${language}`,
|
|
38
|
+
_type: type,
|
|
39
|
+
language,
|
|
40
|
+
value,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const doc = {
|
|
44
|
+
_id: 'drafts.post-1',
|
|
45
|
+
_type: 'post',
|
|
46
|
+
_rev: 'rev-1',
|
|
47
|
+
title: [
|
|
48
|
+
item('internationalizedArrayStringValue', 'en', 'Hello'),
|
|
49
|
+
item('internationalizedArrayStringValue', 'es', 'Hola'),
|
|
50
|
+
],
|
|
51
|
+
description: [item('internationalizedArrayTextValue', 'en', 'A description')],
|
|
52
|
+
tags: ['alpha', 'beta'],
|
|
53
|
+
} as unknown as SanityDocument;
|
|
54
|
+
|
|
55
|
+
describe('internationalized array serialize round-trip', () => {
|
|
56
|
+
const serialized = serializeDocument(
|
|
57
|
+
doc,
|
|
58
|
+
schema,
|
|
59
|
+
'en',
|
|
60
|
+
'internationalizedArray'
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
test('exports only the source-locale value', () => {
|
|
64
|
+
expect(serialized.content).toContain('Hello');
|
|
65
|
+
expect(serialized.content).not.toContain('Hola');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('carries the document _type so import can pick the strategy', () => {
|
|
69
|
+
expect(serialized.content).toContain('content="post"');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('round-trips source values back through the deserializer', () => {
|
|
73
|
+
const deserialized = deserializeDocument(serialized.content);
|
|
74
|
+
expect(deserialized.title).toBe('Hello');
|
|
75
|
+
expect(deserialized.description).toBe('A description');
|
|
76
|
+
expect(deserialized._type).toBe('post');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('passes non-localized arrays through unchanged', () => {
|
|
80
|
+
const deserialized = deserializeDocument(serialized.content);
|
|
81
|
+
expect(deserialized.tags).toEqual(['alpha', 'beta']);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {
|
|
2
|
+
findLocaleItem,
|
|
3
|
+
isInternationalizedArrayField,
|
|
4
|
+
isRecord,
|
|
5
|
+
} from './detect';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Collapse every internationalized-array field in a value down to just its
|
|
9
|
+
* source-locale `value`, recursing into nested objects/arrays.
|
|
10
|
+
*
|
|
11
|
+
* This runs *before* the generic HTML serializer so that the per-locale item
|
|
12
|
+
* arrays never reach `serializeArray` (which would otherwise emit every
|
|
13
|
+
* locale). After collapsing, an `internationalizedArrayString` field is a plain
|
|
14
|
+
* string, an `internationalizedArrayText` is a plain string, Portable Text is a
|
|
15
|
+
* plain `block` array, and a custom value object is a plain object — all shapes
|
|
16
|
+
* the existing document-level serializer already round-trips.
|
|
17
|
+
*
|
|
18
|
+
* Returns `undefined` when a field has no source-locale entry, so the caller
|
|
19
|
+
* drops it (nothing to translate) rather than emitting an empty field.
|
|
20
|
+
*/
|
|
21
|
+
export function collapseToSourceLocale(
|
|
22
|
+
value: unknown,
|
|
23
|
+
sourceLocale: string
|
|
24
|
+
): unknown {
|
|
25
|
+
if (isInternationalizedArrayField(value)) {
|
|
26
|
+
const sourceItem = findLocaleItem(value, sourceLocale);
|
|
27
|
+
if (!sourceItem) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
// Recurse: the source value may itself contain nested localized fields.
|
|
31
|
+
return collapseToSourceLocale(sourceItem.value, sourceLocale);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (Array.isArray(value)) {
|
|
35
|
+
return value
|
|
36
|
+
.map((item) => collapseToSourceLocale(item, sourceLocale))
|
|
37
|
+
.filter((item) => item !== undefined);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (isRecord(value)) {
|
|
41
|
+
const collapsed: Record<string, unknown> = {};
|
|
42
|
+
for (const key of Object.keys(value)) {
|
|
43
|
+
const collapsedValue = collapseToSourceLocale(value[key], sourceLocale);
|
|
44
|
+
if (collapsedValue !== undefined) {
|
|
45
|
+
collapsed[key] = collapsedValue;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return collapsed;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Helpers for recognising the `sanity-plugin-internationalized-array` storage
|
|
2
|
+
// shape, where a localized field is stored as an array of per-locale items:
|
|
3
|
+
//
|
|
4
|
+
// title: [
|
|
5
|
+
// { _key: 'abc', _type: 'internationalizedArrayStringValue', language: 'en', value: 'Hello' },
|
|
6
|
+
// { _key: 'def', _type: 'internationalizedArrayStringValue', language: 'es', value: 'Hola' },
|
|
7
|
+
// ]
|
|
8
|
+
//
|
|
9
|
+
// Detection is shape-based (not schema-based) so the serializer/merger do not
|
|
10
|
+
// have to thread the schema through every call, but it is anchored on the
|
|
11
|
+
// value `_type` naming convention (`<typePrefix>…Value`) so user-defined types
|
|
12
|
+
// that merely share the `{ language, value }` shape are not misdetected.
|
|
13
|
+
|
|
14
|
+
import { pluginConfig } from '../../adapter/core';
|
|
15
|
+
|
|
16
|
+
// The reference-plugin prefix. Always recognized (even with a custom
|
|
17
|
+
// `typePrefix`) because compatibility types with this prefix are generated by
|
|
18
|
+
// default and pre-existing upstream data uses it.
|
|
19
|
+
const DEFAULT_TYPE_PREFIX = 'internationalizedArray';
|
|
20
|
+
|
|
21
|
+
function hasRecognizedTypePrefix(type: string): boolean {
|
|
22
|
+
return (
|
|
23
|
+
type.startsWith(DEFAULT_TYPE_PREFIX) ||
|
|
24
|
+
type.startsWith(pluginConfig.getFieldLevelTypePrefix())
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
29
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
30
|
+
|
|
31
|
+
export type InternationalizedArrayItem = {
|
|
32
|
+
_key?: string;
|
|
33
|
+
_type: string;
|
|
34
|
+
language: string;
|
|
35
|
+
value: unknown;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A single `{ _key, _type, language, value }` entry. The `_type` of the
|
|
40
|
+
* generated value objects is always `<typePrefix><FieldType>Value`
|
|
41
|
+
* (e.g. `internationalizedArrayStringValue`), so both the prefix and the
|
|
42
|
+
* `Value` suffix are required — a bare `endsWith('Value')` check would
|
|
43
|
+
* misdetect user-defined types like `priceValue` that happen to carry
|
|
44
|
+
* `language`/`value` keys.
|
|
45
|
+
*/
|
|
46
|
+
export function isInternationalizedArrayItem(
|
|
47
|
+
item: unknown
|
|
48
|
+
): item is InternationalizedArrayItem {
|
|
49
|
+
return (
|
|
50
|
+
isRecord(item) &&
|
|
51
|
+
typeof item._type === 'string' &&
|
|
52
|
+
hasRecognizedTypePrefix(item._type) &&
|
|
53
|
+
item._type.endsWith('Value') &&
|
|
54
|
+
typeof item.language === 'string' &&
|
|
55
|
+
'value' in item
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A non-empty array where every element is an internationalized-array item.
|
|
61
|
+
*/
|
|
62
|
+
export function isInternationalizedArrayField(
|
|
63
|
+
value: unknown
|
|
64
|
+
): value is InternationalizedArrayItem[] {
|
|
65
|
+
return (
|
|
66
|
+
Array.isArray(value) &&
|
|
67
|
+
value.length > 0 &&
|
|
68
|
+
value.every(isInternationalizedArrayItem)
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Returns the item for a given locale, matching on the exact `language` id
|
|
74
|
+
* (hyphens preserved, e.g. `fr-CA`). Never matches on `_key`, which is random.
|
|
75
|
+
*/
|
|
76
|
+
export function findLocaleItem(
|
|
77
|
+
field: InternationalizedArrayItem[],
|
|
78
|
+
locale: string
|
|
79
|
+
): InternationalizedArrayItem | undefined {
|
|
80
|
+
return field.find((item) => item.language === locale);
|
|
81
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { randomKey } from '../../utils/randomKey';
|
|
2
|
+
import {
|
|
3
|
+
findLocaleItem,
|
|
4
|
+
InternationalizedArrayItem,
|
|
5
|
+
isInternationalizedArrayField,
|
|
6
|
+
isRecord,
|
|
7
|
+
} from './detect';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Upsert the translated value for `targetLocale` into an internationalized
|
|
11
|
+
* array, preserving every other locale item and its (random) `_key`.
|
|
12
|
+
*
|
|
13
|
+
* Sanity mutations can only address array items by `_key`, and `_key` is random
|
|
14
|
+
* here, so we resolve the target item by its `language` field and reuse its
|
|
15
|
+
* existing `_key`; if absent we append a fresh random `_key`.
|
|
16
|
+
*/
|
|
17
|
+
function upsertLocaleItem(
|
|
18
|
+
baseArray: InternationalizedArrayItem[],
|
|
19
|
+
translatedValue: unknown,
|
|
20
|
+
targetLocale: string,
|
|
21
|
+
sourceLocale: string
|
|
22
|
+
): InternationalizedArrayItem[] {
|
|
23
|
+
const itemType = (findLocaleItem(baseArray, sourceLocale) ?? baseArray[0])
|
|
24
|
+
._type;
|
|
25
|
+
|
|
26
|
+
const updated = baseArray.map((item) =>
|
|
27
|
+
item.language === targetLocale
|
|
28
|
+
? { ...item, value: translatedValue }
|
|
29
|
+
: { ...item }
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
if (!findLocaleItem(updated, targetLocale)) {
|
|
33
|
+
updated.push({
|
|
34
|
+
_key: randomKey(),
|
|
35
|
+
_type: itemType,
|
|
36
|
+
language: targetLocale,
|
|
37
|
+
value: translatedValue,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return updated;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Merge a single base value with its translated counterpart, returning the new
|
|
46
|
+
* value to write, or `undefined` when nothing localized changed at this level.
|
|
47
|
+
* Recurses through plain objects (by key) and plain arrays (by `_key`) so
|
|
48
|
+
* internationalized-array fields nested inside other content are also handled.
|
|
49
|
+
*/
|
|
50
|
+
function mergeValue(
|
|
51
|
+
baseValue: unknown,
|
|
52
|
+
translatedValue: unknown,
|
|
53
|
+
targetLocale: string,
|
|
54
|
+
sourceLocale: string
|
|
55
|
+
): unknown {
|
|
56
|
+
if (isInternationalizedArrayField(baseValue)) {
|
|
57
|
+
return upsertLocaleItem(
|
|
58
|
+
baseValue,
|
|
59
|
+
translatedValue,
|
|
60
|
+
targetLocale,
|
|
61
|
+
sourceLocale
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (Array.isArray(baseValue) && Array.isArray(translatedValue)) {
|
|
66
|
+
let changed = false;
|
|
67
|
+
const merged = baseValue.map((item) => {
|
|
68
|
+
if (!isRecord(item) || typeof item._key !== 'string') {
|
|
69
|
+
return item;
|
|
70
|
+
}
|
|
71
|
+
const translatedItem = translatedValue.find(
|
|
72
|
+
(candidate) => isRecord(candidate) && candidate._key === item._key
|
|
73
|
+
);
|
|
74
|
+
if (!translatedItem) {
|
|
75
|
+
return item;
|
|
76
|
+
}
|
|
77
|
+
const mergedItem = mergeValue(
|
|
78
|
+
item,
|
|
79
|
+
translatedItem,
|
|
80
|
+
targetLocale,
|
|
81
|
+
sourceLocale
|
|
82
|
+
);
|
|
83
|
+
if (mergedItem !== undefined) {
|
|
84
|
+
changed = true;
|
|
85
|
+
return mergedItem;
|
|
86
|
+
}
|
|
87
|
+
return item;
|
|
88
|
+
});
|
|
89
|
+
return changed ? merged : undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (isRecord(baseValue) && isRecord(translatedValue)) {
|
|
93
|
+
let changed = false;
|
|
94
|
+
const merged: Record<string, unknown> = { ...baseValue };
|
|
95
|
+
for (const key of Object.keys(translatedValue)) {
|
|
96
|
+
if (key.startsWith('_') || !(key in baseValue)) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const mergedChild = mergeValue(
|
|
100
|
+
baseValue[key],
|
|
101
|
+
translatedValue[key],
|
|
102
|
+
targetLocale,
|
|
103
|
+
sourceLocale
|
|
104
|
+
);
|
|
105
|
+
if (mergedChild !== undefined) {
|
|
106
|
+
merged[key] = mergedChild;
|
|
107
|
+
changed = true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return changed ? merged : undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Compare a base Sanity document against a deserialized translation and produce
|
|
118
|
+
* a patch object of top-level fields whose internationalized-array content
|
|
119
|
+
* changed. Each entry is the full merged top-level value (with the target
|
|
120
|
+
* locale upserted), suitable for `client.patch(id).set(changes)`.
|
|
121
|
+
*
|
|
122
|
+
* Only top-level fields that actually changed are returned, so unrelated
|
|
123
|
+
* content and other locales are left untouched.
|
|
124
|
+
*/
|
|
125
|
+
export function mergeInternationalizedArrays(
|
|
126
|
+
baseDoc: Record<string, unknown>,
|
|
127
|
+
translatedFields: Record<string, unknown>,
|
|
128
|
+
targetLocale: string,
|
|
129
|
+
sourceLocale: string
|
|
130
|
+
): Record<string, unknown> {
|
|
131
|
+
const changes: Record<string, unknown> = {};
|
|
132
|
+
|
|
133
|
+
for (const key of Object.keys(translatedFields)) {
|
|
134
|
+
if (key.startsWith('_') || !(key in baseDoc)) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const merged = mergeValue(
|
|
138
|
+
baseDoc[key],
|
|
139
|
+
translatedFields[key],
|
|
140
|
+
targetLocale,
|
|
141
|
+
sourceLocale
|
|
142
|
+
);
|
|
143
|
+
if (merged !== undefined) {
|
|
144
|
+
changes[key] = merged;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return changes;
|
|
149
|
+
}
|
|
@@ -14,7 +14,11 @@ export type SerializedDocument = {
|
|
|
14
14
|
content: string;
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
// 'document' – translate the whole document (creates per-locale documents).
|
|
18
|
+
// 'field' – legacy object-keyed model (e.g. `title: { en, es_ES }`).
|
|
19
|
+
// 'internationalizedArray' – sanity-plugin-internationalized-array shape:
|
|
20
|
+
// `title: [{ _key, _type, language, value }]`, localized in place.
|
|
21
|
+
export type TranslationLevel = 'document' | 'field' | 'internationalizedArray';
|
|
18
22
|
|
|
19
23
|
export interface Deserializer {
|
|
20
24
|
deserializeDocument: <
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'vitest';
|
|
2
|
+
import { pluginConfig } from '../../adapter/core';
|
|
3
|
+
import {
|
|
4
|
+
getTranslationStrategy,
|
|
5
|
+
getTranslationStrategyForType,
|
|
6
|
+
} from '../strategy';
|
|
7
|
+
import type { SanityDocument } from 'sanity';
|
|
8
|
+
|
|
9
|
+
const doc = (type: string) =>
|
|
10
|
+
({ _id: 'a', _type: type, _rev: 'r' }) as unknown as SanityDocument;
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
pluginConfig.translationLevel = 'document';
|
|
14
|
+
pluginConfig.fieldLevelDocuments = [];
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('getTranslationStrategy', () => {
|
|
18
|
+
test("'document' mode always uses the document-level strategy", () => {
|
|
19
|
+
pluginConfig.translationLevel = 'document';
|
|
20
|
+
expect(getTranslationStrategyForType('post').level).toBe('document');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("'internationalizedArray' mode always uses the array strategy", () => {
|
|
24
|
+
pluginConfig.translationLevel = 'internationalizedArray';
|
|
25
|
+
expect(getTranslationStrategy(doc('post')).level).toBe(
|
|
26
|
+
'internationalizedArray'
|
|
27
|
+
);
|
|
28
|
+
expect(getTranslationStrategy(doc('page')).level).toBe(
|
|
29
|
+
'internationalizedArray'
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("'mixed' mode routes by document type", () => {
|
|
34
|
+
pluginConfig.translationLevel = 'mixed';
|
|
35
|
+
pluginConfig.fieldLevelDocuments = [{ type: 'post' }];
|
|
36
|
+
expect(getTranslationStrategyForType('post').level).toBe(
|
|
37
|
+
'internationalizedArray'
|
|
38
|
+
);
|
|
39
|
+
expect(getTranslationStrategyForType('page').level).toBe('document');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('an unknown/undefined type falls back to document-level', () => {
|
|
43
|
+
pluginConfig.translationLevel = 'mixed';
|
|
44
|
+
pluginConfig.fieldLevelDocuments = [{ type: 'post' }];
|
|
45
|
+
expect(getTranslationStrategyForType(undefined).level).toBe('document');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { pluginConfig } from '../adapter/core';
|
|
2
|
-
import { documentLevelPatch } from '../configuration/baseDocumentLevelConfig/documentLevelPatch';
|
|
3
1
|
import type { GTFile, TranslationFunctionContext } from '../types';
|
|
4
2
|
import { deserializeDocument } from '../utils/serialize';
|
|
3
|
+
import { getTranslationStrategyForType } from './strategy';
|
|
5
4
|
|
|
6
5
|
export async function importDocument(
|
|
7
6
|
docInfo: GTFile,
|
|
@@ -12,12 +11,17 @@ export async function importDocument(
|
|
|
12
11
|
) {
|
|
13
12
|
const { client } = context;
|
|
14
13
|
const deserialized = deserializeDocument(document);
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
// The serialized HTML carries the document `_type` as a <meta> tag, so the
|
|
15
|
+
// deserialized doc tells us which strategy to import with. versionId is not
|
|
16
|
+
// used for the patch since the _rev travels inside the deserialized HTML.
|
|
17
|
+
const strategy = getTranslationStrategyForType(
|
|
18
|
+
deserialized._type as string | undefined
|
|
19
|
+
);
|
|
20
|
+
return strategy.patch(
|
|
21
|
+
docInfo,
|
|
17
22
|
deserialized,
|
|
18
23
|
localeId,
|
|
19
24
|
client,
|
|
20
|
-
pluginConfig.getLanguageField(),
|
|
21
25
|
mergeWithTargetLocale
|
|
22
26
|
);
|
|
23
27
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { SanityClient, SanityDocument, Schema } from 'sanity';
|
|
2
|
+
import { pluginConfig } from '../adapter/core';
|
|
3
|
+
import { documentLevelPatch } from '../configuration/baseDocumentLevelConfig/documentLevelPatch';
|
|
4
|
+
import { internationalizedArrayPatch } from '../configuration/internationalizedArrayConfig/internationalizedArrayPatch';
|
|
5
|
+
import type {
|
|
6
|
+
SerializedDocument,
|
|
7
|
+
TranslationLevel,
|
|
8
|
+
} from '../serialization/types';
|
|
9
|
+
import type { GTFile } from '../types';
|
|
10
|
+
import { serializeDocument } from '../utils/serialize';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A translation strategy bundles the serialize/patch pair for a given level.
|
|
14
|
+
* The shared GT upload/enqueue/status/download workflow is identical across
|
|
15
|
+
* strategies; only how a document is turned into the GT HTML file and how the
|
|
16
|
+
* translated file is written back to Sanity differs.
|
|
17
|
+
*/
|
|
18
|
+
export type SanityTranslationAdapter = {
|
|
19
|
+
level: TranslationLevel;
|
|
20
|
+
serialize: (
|
|
21
|
+
document: SanityDocument,
|
|
22
|
+
schema: Schema,
|
|
23
|
+
baseLanguage: string
|
|
24
|
+
) => SerializedDocument;
|
|
25
|
+
patch: (
|
|
26
|
+
docInfo: GTFile,
|
|
27
|
+
deserialized: SanityDocument,
|
|
28
|
+
localeId: string,
|
|
29
|
+
client: SanityClient,
|
|
30
|
+
mergeWithTargetLocale?: boolean
|
|
31
|
+
) => Promise<void>;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const documentAdapter: SanityTranslationAdapter = {
|
|
35
|
+
level: 'document',
|
|
36
|
+
serialize: (document, schema, baseLanguage) =>
|
|
37
|
+
serializeDocument(document, schema, baseLanguage, 'document'),
|
|
38
|
+
patch: (docInfo, deserialized, localeId, client, mergeWithTargetLocale) =>
|
|
39
|
+
documentLevelPatch(
|
|
40
|
+
docInfo,
|
|
41
|
+
deserialized,
|
|
42
|
+
localeId,
|
|
43
|
+
client,
|
|
44
|
+
pluginConfig.getLanguageField(),
|
|
45
|
+
mergeWithTargetLocale
|
|
46
|
+
),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const internationalizedArrayAdapter: SanityTranslationAdapter = {
|
|
50
|
+
level: 'internationalizedArray',
|
|
51
|
+
serialize: (document, schema, baseLanguage) =>
|
|
52
|
+
serializeDocument(document, schema, baseLanguage, 'internationalizedArray'),
|
|
53
|
+
patch: (docInfo, deserialized, localeId, client) =>
|
|
54
|
+
internationalizedArrayPatch(docInfo, deserialized, localeId, client),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function matchesFieldLevel(type: string | undefined): boolean {
|
|
58
|
+
if (!type) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
return pluginConfig
|
|
62
|
+
.getFieldLevelDocuments()
|
|
63
|
+
.some((filter) => filter.type === type);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve which strategy a document type uses:
|
|
68
|
+
* - `document` → always document-level (default; unchanged).
|
|
69
|
+
* - `internationalizedArray` → always the array strategy.
|
|
70
|
+
* - `mixed` → array strategy for `fieldLevelDocuments`, else
|
|
71
|
+
* document-level.
|
|
72
|
+
*/
|
|
73
|
+
export function getTranslationStrategyForType(
|
|
74
|
+
type: string | undefined
|
|
75
|
+
): SanityTranslationAdapter {
|
|
76
|
+
const level = pluginConfig.getTranslationLevel();
|
|
77
|
+
if (level === 'internationalizedArray') {
|
|
78
|
+
return internationalizedArrayAdapter;
|
|
79
|
+
}
|
|
80
|
+
if (level === 'mixed') {
|
|
81
|
+
return matchesFieldLevel(type)
|
|
82
|
+
? internationalizedArrayAdapter
|
|
83
|
+
: documentAdapter;
|
|
84
|
+
}
|
|
85
|
+
return documentAdapter;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function getTranslationStrategy(
|
|
89
|
+
document: SanityDocument
|
|
90
|
+
): SanityTranslationAdapter {
|
|
91
|
+
return getTranslationStrategyForType(document._type);
|
|
92
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { GTFile, Secrets } from '../types';
|
|
2
2
|
import { gt, overrideConfig } from '../adapter/core';
|
|
3
3
|
import { libraryDefaultLocale } from 'generaltranslation/internal';
|
|
4
|
-
import type { SerializedDocument } from '../serialization';
|
|
4
|
+
import type { SerializedDocument } from '../serialization/types';
|
|
5
5
|
|
|
6
6
|
// note: this function is used to create a new translation task
|
|
7
7
|
// uploads files & calls the getTranslationTask function
|
package/src/types.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// adapted from https://github.com/sanity-io/sanity-translations-tab. See LICENSE.md for more details.
|
|
2
2
|
|
|
3
3
|
import { SanityClient, Schema, TypedObject } from 'sanity';
|
|
4
|
-
import type { SerializedDocument } from './serialization';
|
|
4
|
+
import type { SerializedDocument } from './serialization/types';
|
|
5
5
|
import { PortableTextTypeComponent } from '@portabletext/to-html';
|
|
6
6
|
import type { DeserializerRule } from '@portabletext/block-tools';
|
|
7
7
|
|
|
@@ -1,5 +1,24 @@
|
|
|
1
|
-
import { describe, expect, test } from 'vitest';
|
|
2
|
-
import {
|
|
1
|
+
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
ImportBatchItem,
|
|
4
|
+
processBatch,
|
|
5
|
+
processImportBatch,
|
|
6
|
+
} from '../batchProcessor';
|
|
7
|
+
import { pluginConfig } from '../../adapter/core';
|
|
8
|
+
|
|
9
|
+
const importCounters = vi.hoisted(() => ({ active: 0, maxActive: 0 }));
|
|
10
|
+
|
|
11
|
+
vi.mock('../../translation/importDocument', () => ({
|
|
12
|
+
importDocument: vi.fn(async () => {
|
|
13
|
+
importCounters.active++;
|
|
14
|
+
importCounters.maxActive = Math.max(
|
|
15
|
+
importCounters.maxActive,
|
|
16
|
+
importCounters.active
|
|
17
|
+
);
|
|
18
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
19
|
+
importCounters.active--;
|
|
20
|
+
}),
|
|
21
|
+
}));
|
|
3
22
|
|
|
4
23
|
describe('processBatch', () => {
|
|
5
24
|
test('serializes items with the same concurrency key', async () => {
|
|
@@ -42,3 +61,43 @@ describe('processBatch', () => {
|
|
|
42
61
|
expect(maxActive).toBe(2);
|
|
43
62
|
});
|
|
44
63
|
});
|
|
64
|
+
|
|
65
|
+
describe('processImportBatch', () => {
|
|
66
|
+
const originalLevel = pluginConfig.translationLevel;
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
pluginConfig.translationLevel = originalLevel;
|
|
70
|
+
importCounters.active = 0;
|
|
71
|
+
importCounters.maxActive = 0;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const makeItem = (documentId: string, locale: string): ImportBatchItem => ({
|
|
75
|
+
docInfo: { documentId, versionId: 'rev-1' },
|
|
76
|
+
locale,
|
|
77
|
+
data: '',
|
|
78
|
+
translationContext: {} as ImportBatchItem['translationContext'],
|
|
79
|
+
key: `${documentId}:${locale}`,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('imports locales of the same document in parallel at document level', async () => {
|
|
83
|
+
pluginConfig.translationLevel = 'document';
|
|
84
|
+
|
|
85
|
+
await processImportBatch([
|
|
86
|
+
makeItem('article-1', 'es'),
|
|
87
|
+
makeItem('article-1', 'fr'),
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
expect(importCounters.maxActive).toBe(2);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('serializes locales of the same document for in-place strategies', async () => {
|
|
94
|
+
pluginConfig.translationLevel = 'internationalizedArray';
|
|
95
|
+
|
|
96
|
+
await processImportBatch([
|
|
97
|
+
makeItem('article-1', 'es'),
|
|
98
|
+
makeItem('article-1', 'fr'),
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
expect(importCounters.maxActive).toBe(1);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { GTFile, TranslationFunctionContext } from '../types';
|
|
2
|
+
import { pluginConfig } from '../adapter/core';
|
|
2
3
|
import { importDocument } from '../translation/importDocument';
|
|
3
|
-
import {
|
|
4
|
+
import { getPublishedId } from './documentIds';
|
|
4
5
|
|
|
5
6
|
export interface BatchProcessorOptions<T = unknown, R = unknown> {
|
|
6
7
|
batchSize?: number;
|
|
@@ -126,12 +127,16 @@ export async function processImportBatch(
|
|
|
126
127
|
},
|
|
127
128
|
{
|
|
128
129
|
...options,
|
|
130
|
+
// Serialize all locales of the same document when imports may patch the
|
|
131
|
+
// source document in place: internationalized-array imports do a
|
|
132
|
+
// read-merge-set, so concurrent locale imports would clobber each other
|
|
133
|
+
// (last write wins). Document-level imports write to separate per-locale
|
|
134
|
+
// documents and stay parallel. In 'mixed' mode the per-document strategy
|
|
135
|
+
// is only known after deserialization, so serialize conservatively.
|
|
129
136
|
getConcurrencyKey: (item: ImportBatchItem) =>
|
|
130
|
-
|
|
131
|
-
undefined
|
|
132
|
-
item.docInfo.documentId,
|
|
133
|
-
item.locale
|
|
134
|
-
),
|
|
137
|
+
pluginConfig.getTranslationLevel() === 'document'
|
|
138
|
+
? undefined
|
|
139
|
+
: getPublishedId(item.docInfo.documentId),
|
|
135
140
|
onItemSuccess: (item: ImportBatchItem, key: string) => {
|
|
136
141
|
successfulImports.push(key);
|
|
137
142
|
options.onItemSuccess?.(item, key);
|