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.
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,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
- export type TranslationLevel = 'document' | 'field';
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
- return documentLevelPatch(
16
- docInfo, // versionId is not used here, since we just use the _rev id in the deserialized HTML itself
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 { processBatch } from '../batchProcessor';
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 { createStableTranslationKey } from './documentIds';
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
- createStableTranslationKey(
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);