gt-sanity 2.0.7 → 2.0.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gt-sanity",
3
- "version": "2.0.7",
3
+ "version": "2.0.8",
4
4
  "description": "General Translation integration with Sanity",
5
5
  "keywords": [
6
6
  "sanity",
@@ -52,7 +52,7 @@
52
52
  "jsonpointer": "^5.0.1",
53
53
  "@sanity/document-internationalization": "^6.0.6",
54
54
  "lodash.merge": "^4.6.2",
55
- "generaltranslation": "8.2.6"
55
+ "generaltranslation": "8.2.7"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@portabletext/types": "^2.0.15",
@@ -3,6 +3,7 @@ import { libraryDefaultLocale } from 'generaltranslation/internal';
3
3
  import type { Secrets } from '../types';
4
4
  import type {
5
5
  TranslateDocumentFilter,
6
+ DedupeFields,
6
7
  IgnoreFields,
7
8
  SkipFields,
8
9
  } from './types';
@@ -25,6 +26,7 @@ export class GTConfig {
25
26
  singletons: string[];
26
27
  singletonMapping: (sourceDocumentId: string, locale: string) => string;
27
28
  ignoreFields: IgnoreFields[];
29
+ dedupeFields: DedupeFields[];
28
30
  skipFields: SkipFields[];
29
31
  translateDocuments: TranslateDocumentFilter[];
30
32
  additionalStopTypes: string[];
@@ -41,6 +43,7 @@ export class GTConfig {
41
43
  singletons: string[],
42
44
  singletonMapping: (sourceDocumentId: string, locale: string) => string,
43
45
  ignoreFields: IgnoreFields[],
46
+ dedupeFields: DedupeFields[],
44
47
  skipFields: SkipFields[],
45
48
  translateDocuments: TranslateDocumentFilter[],
46
49
  additionalStopTypes: string[] = [],
@@ -55,6 +58,7 @@ export class GTConfig {
55
58
  this.singletons = singletons;
56
59
  this.singletonMapping = singletonMapping;
57
60
  this.ignoreFields = ignoreFields;
61
+ this.dedupeFields = dedupeFields;
58
62
  this.skipFields = skipFields;
59
63
  this.translateDocuments = translateDocuments;
60
64
  this.additionalStopTypes = additionalStopTypes;
@@ -76,6 +80,7 @@ export class GTConfig {
76
80
  [],
77
81
  [],
78
82
  [],
83
+ [],
79
84
  {},
80
85
  {},
81
86
  []
@@ -92,6 +97,7 @@ export class GTConfig {
92
97
  singletons: string[],
93
98
  singletonMapping: (sourceDocumentId: string, locale: string) => string,
94
99
  ignoreFields: IgnoreFields[],
100
+ dedupeFields: DedupeFields[],
95
101
  skipFields: SkipFields[],
96
102
  translateDocuments: TranslateDocumentFilter[],
97
103
  additionalStopTypes: string[] = [],
@@ -106,6 +112,7 @@ export class GTConfig {
106
112
  this.singletons = singletons;
107
113
  this.singletonMapping = singletonMapping;
108
114
  this.ignoreFields = ignoreFields;
115
+ this.dedupeFields = dedupeFields;
109
116
  this.skipFields = skipFields;
110
117
  this.translateDocuments = translateDocuments;
111
118
  this.additionalStopTypes = additionalStopTypes;
@@ -137,6 +144,9 @@ export class GTConfig {
137
144
  getIgnoreFields() {
138
145
  return this.ignoreFields;
139
146
  }
147
+ getDedupeFields() {
148
+ return this.dedupeFields;
149
+ }
140
150
  getSkipFields() {
141
151
  return this.skipFields;
142
152
  }
@@ -1,12 +1,13 @@
1
- export type IgnoreFields = {
1
+ export type FieldMatcher = {
2
2
  documentId?: string;
3
3
  fields?: { property: string; type?: string }[];
4
4
  };
5
5
 
6
- export type SkipFields = {
7
- documentId?: string;
8
- fields?: { property: string; type?: string }[];
9
- };
6
+ export type IgnoreFields = FieldMatcher;
7
+
8
+ export type DedupeFields = FieldMatcher;
9
+
10
+ export type SkipFields = FieldMatcher;
10
11
 
11
12
  export type TranslateDocumentFilter = {
12
13
  documentId?: string;
@@ -99,7 +99,8 @@ export const documentLevelPatch = async (
99
99
  baseDoc,
100
100
  merged,
101
101
  translatedFields,
102
- client
102
+ client,
103
+ i18nDoc
103
104
  );
104
105
  }
105
106
  //otherwise, create a new document
@@ -35,7 +35,9 @@ export async function createI18nDocAndPatchMetadata(
35
35
  sourceDocument,
36
36
  rest,
37
37
  pluginConfig.getIgnoreFields(),
38
- pluginConfig.getSkipFields()
38
+ pluginConfig.getSkipFields(),
39
+ pluginConfig.getDedupeFields(),
40
+ localeId
39
41
  );
40
42
 
41
43
  // Check if this is a singleton document and apply singleton mapping
@@ -0,0 +1,52 @@
1
+ import { afterEach, describe, expect, test, vi } from 'vitest';
2
+ import { pluginConfig } from '../../../adapter/core';
3
+ import { patchI18nDoc } from './patchI18nDoc';
4
+
5
+ describe('patchI18nDoc', () => {
6
+ afterEach(() => {
7
+ vi.restoreAllMocks();
8
+ });
9
+
10
+ test('preserves existing translated dedupe fields during re-import', async () => {
11
+ vi.spyOn(pluginConfig, 'getIgnoreFields').mockReturnValue([]);
12
+ vi.spyOn(pluginConfig, 'getSkipFields').mockReturnValue([]);
13
+ vi.spyOn(pluginConfig, 'getDedupeFields').mockReturnValue([
14
+ { fields: [{ property: '$.slug', type: 'slug' }] },
15
+ ]);
16
+
17
+ const commit = vi.fn().mockResolvedValue({});
18
+ const patch = vi.fn().mockReturnValue({ commit });
19
+ const client = { patch } as any;
20
+
21
+ await patchI18nDoc(
22
+ 'doc-1',
23
+ 'drafts.doc-1-es',
24
+ {
25
+ _id: 'doc-1',
26
+ _type: 'article',
27
+ title: 'About',
28
+ slug: { _type: 'slug', current: 'about' },
29
+ },
30
+ {
31
+ _id: 'doc-1',
32
+ _type: 'article',
33
+ title: 'Acerca de',
34
+ },
35
+ { title: 'Acerca de' },
36
+ client,
37
+ {
38
+ _id: 'drafts.doc-1-es',
39
+ _type: 'article',
40
+ title: 'Acerca de',
41
+ slug: { _type: 'slug', current: 'custom-spanish-slug' },
42
+ }
43
+ );
44
+
45
+ expect(patch).toHaveBeenCalledWith('drafts.doc-1-es', {
46
+ set: expect.objectContaining({
47
+ title: 'Acerca de',
48
+ slug: { _type: 'slug', current: 'custom-spanish-slug' },
49
+ }),
50
+ });
51
+ });
52
+ });
@@ -1,7 +1,12 @@
1
1
  // adapted from https://github.com/sanity-io/sanity-translations-tab. See LICENSE.md for more details.
2
2
 
3
3
  import { SanityClient, SanityDocumentLike } from 'sanity';
4
- import { applyDocuments } from '../../../utils/applyDocuments';
4
+ import JSONPointer from 'jsonpointer';
5
+ import {
6
+ applyDocuments,
7
+ deleteMatchingFields,
8
+ forEachMatchingField,
9
+ } from '../../../utils/applyDocuments';
5
10
  import { pluginConfig } from '../../../adapter/core';
6
11
 
7
12
  const SYSTEM_FIELDS = ['_id', '_rev', '_updatedAt', 'language'];
@@ -14,7 +19,8 @@ export async function patchI18nDoc(
14
19
  sourceDocument: SanityDocumentLike,
15
20
  mergedDocument: SanityDocumentLike,
16
21
  translatedFields: Record<string, any>,
17
- client: SanityClient
22
+ client: SanityClient,
23
+ existingDocument?: SanityDocumentLike
18
24
  ): Promise<void> {
19
25
  const cleanedMerge: Record<string, any> = {};
20
26
  Object.entries(mergedDocument).forEach(([key, value]) => {
@@ -43,6 +49,18 @@ export async function patchI18nDoc(
43
49
  pluginConfig.getIgnoreFields(),
44
50
  pluginConfig.getSkipFields()
45
51
  );
52
+ const dedupeFields = pluginConfig.getDedupeFields();
53
+ deleteMatchingFields(sourceDocumentId, appliedDocument, dedupeFields);
54
+ if (existingDocument) {
55
+ forEachMatchingField(
56
+ sourceDocumentId,
57
+ existingDocument,
58
+ dedupeFields,
59
+ (result) => {
60
+ JSONPointer.set(appliedDocument, result.pointer, result.value);
61
+ }
62
+ );
63
+ }
46
64
  const newDocument = await client
47
65
  .patch(i18nDocId, { set: appliedDocument })
48
66
  .commit();
package/src/index.ts CHANGED
@@ -61,6 +61,7 @@ import { gt, pluginConfig } from './adapter/core';
61
61
  import { libraryDefaultLocale } from 'generaltranslation/internal';
62
62
  import { getLocaleProperties } from 'generaltranslation';
63
63
  import type {
64
+ DedupeFields,
64
65
  IgnoreFields,
65
66
  SkipFields,
66
67
  TranslateDocumentFilter,
@@ -83,6 +84,7 @@ export type GTPluginConfig = Omit<
83
84
  // By default, the translated singleton document is is `${sourceDocumentId}-${locale}`
84
85
  singletonMapping?: (sourceDocumentId: string, locale: string) => string;
85
86
  ignoreFields?: IgnoreFields[];
87
+ dedupeFields?: DedupeFields[];
86
88
  skipFields?: SkipFields[];
87
89
  languageField?: string;
88
90
  translateDocuments?: TranslateDocumentFilter[] | string[];
@@ -122,6 +124,7 @@ export const gtPlugin = definePlugin<GTPluginConfig>(
122
124
  singletons,
123
125
  singletonMapping,
124
126
  ignoreFields,
127
+ dedupeFields,
125
128
  skipFields,
126
129
  translateDocuments,
127
130
  secretsNamespace = SECRETS_NAMESPACE,
@@ -153,6 +156,7 @@ export const gtPlugin = definePlugin<GTPluginConfig>(
153
156
  singletonMapping ||
154
157
  ((sourceDocumentId, locale) => `${sourceDocumentId}-${locale}`),
155
158
  ignoreFields || [],
159
+ dedupeFields || [],
156
160
  skipFields || [],
157
161
  normalizedTranslateDocuments || [],
158
162
  additionalStopTypes,
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test, vi } from 'vitest';
2
- import { forEachMatchingField } from '../applyDocuments';
2
+ import { applyDocuments, forEachMatchingField } from '../applyDocuments';
3
3
  import type { IgnoreFields } from '../../adapter/types';
4
4
 
5
5
  describe('forEachMatchingField', () => {
@@ -169,3 +169,73 @@ describe('forEachMatchingField', () => {
169
169
  expect(cb.mock.calls[0][0].pointer).toBe('/metadata/seo/title');
170
170
  });
171
171
  });
172
+
173
+ describe('applyDocuments dedupe fields', () => {
174
+ test('restores slug fields with a locale suffix', () => {
175
+ const sourceDoc = {
176
+ _id: 'doc-1',
177
+ title: 'Hello',
178
+ slug: { _type: 'slug', current: 'hello-world' },
179
+ };
180
+ const translatedDoc = {
181
+ _id: 'doc-1-es',
182
+ title: 'Hola',
183
+ };
184
+
185
+ const result = applyDocuments(
186
+ 'doc-1',
187
+ sourceDoc,
188
+ translatedDoc,
189
+ [],
190
+ [],
191
+ [{ fields: [{ property: '$.slug' }] }],
192
+ 'es'
193
+ );
194
+
195
+ expect(result).toEqual({
196
+ _id: 'doc-1-es',
197
+ title: 'Hola',
198
+ slug: { _type: 'slug', current: 'hello-world-es' },
199
+ });
200
+ });
201
+
202
+ test('dedupes string fields directly when the JSONPath targets a string', () => {
203
+ const sourceDoc = {
204
+ _id: 'doc-1',
205
+ title: 'Hello',
206
+ slug: { _type: 'slug', current: 'hello-world' },
207
+ };
208
+
209
+ const result = applyDocuments(
210
+ 'doc-1',
211
+ sourceDoc,
212
+ { title: 'Bonjour' },
213
+ [],
214
+ [],
215
+ [{ fields: [{ property: '$.slug.current' }] }],
216
+ 'fr-CA'
217
+ );
218
+
219
+ expect(result.slug.current).toBe('hello-world-fr-ca');
220
+ });
221
+
222
+ test('does not append the same locale suffix twice', () => {
223
+ const sourceDoc = {
224
+ _id: 'doc-1',
225
+ title: 'Hello',
226
+ slug: { _type: 'slug', current: 'hello-world-fr' },
227
+ };
228
+
229
+ const result = applyDocuments(
230
+ 'doc-1',
231
+ sourceDoc,
232
+ { title: 'Bonjour' },
233
+ [],
234
+ [],
235
+ [{ fields: [{ property: '$.slug' }] }],
236
+ 'fr'
237
+ );
238
+
239
+ expect(result.slug.current).toBe('hello-world-fr');
240
+ });
241
+ });
@@ -1,11 +1,11 @@
1
1
  import { JSONPath } from 'jsonpath-plus';
2
2
  import JSONPointer from 'jsonpointer';
3
- import type { IgnoreFields, SkipFields } from '../adapter/types';
3
+ import type { DedupeFields, FieldMatcher, SkipFields } from '../adapter/types';
4
4
 
5
5
  export function forEachMatchingField(
6
6
  documentId: string,
7
7
  document: Record<string, any>,
8
- fields: IgnoreFields[],
8
+ fields: FieldMatcher[],
9
9
  callback: (result: {
10
10
  pointer: string;
11
11
  value: any;
@@ -67,7 +67,7 @@ export function forEachMatchingField(
67
67
  export function deleteMatchingFields(
68
68
  documentId: string,
69
69
  document: Record<string, any>,
70
- fields: IgnoreFields[]
70
+ fields: FieldMatcher[]
71
71
  ): void {
72
72
  const arrayRemovals: Array<{ parent: any[]; index: number }> = [];
73
73
 
@@ -93,8 +93,10 @@ export function applyDocuments(
93
93
  documentId: string,
94
94
  sourceDocument: Record<string, any>,
95
95
  targetDocument: Record<string, any>,
96
- ignore: IgnoreFields[],
97
- skip: SkipFields[] = []
96
+ ignore: FieldMatcher[],
97
+ skip: SkipFields[] = [],
98
+ dedupe: DedupeFields[] = [],
99
+ localeId?: string
98
100
  ) {
99
101
  // Deep copy both documents so mutations (e.g. skip-field deletions) never affect the originals
100
102
  const mergedDocument = JSON.parse(JSON.stringify(sourceDocument));
@@ -110,8 +112,57 @@ export function applyDocuments(
110
112
  JSONPointer.set(mergedDocument, result.pointer, result.value);
111
113
  });
112
114
 
115
+ // Restore de-duped fields from the source document with a deterministic locale suffix.
116
+ forEachMatchingField(documentId, sourceDocument, dedupe, (result) => {
117
+ JSONPointer.set(
118
+ mergedDocument,
119
+ result.pointer,
120
+ dedupeFieldValue(result.value, localeId)
121
+ );
122
+ });
123
+
113
124
  // Remove skip fields from merged document
114
125
  deleteMatchingFields(documentId, mergedDocument, skip);
115
126
 
116
127
  return mergedDocument;
117
128
  }
129
+
130
+ function dedupeFieldValue(value: any, localeId?: string): any {
131
+ if (!localeId) return value;
132
+
133
+ if (typeof value === 'string') {
134
+ return appendLocaleSuffix(value, localeId);
135
+ }
136
+
137
+ if (
138
+ value &&
139
+ typeof value === 'object' &&
140
+ !Array.isArray(value) &&
141
+ typeof value.current === 'string'
142
+ ) {
143
+ return {
144
+ ...value,
145
+ current: appendLocaleSuffix(value.current, localeId),
146
+ };
147
+ }
148
+
149
+ return value;
150
+ }
151
+
152
+ function appendLocaleSuffix(value: string, localeId: string): string {
153
+ if (!value) return value;
154
+
155
+ const suffix = createLocaleSuffix(localeId);
156
+ if (!suffix || value.endsWith(suffix)) return value;
157
+
158
+ return `${value}${suffix}`;
159
+ }
160
+
161
+ function createLocaleSuffix(localeId: string): string {
162
+ const normalized = localeId
163
+ .toLowerCase()
164
+ .replace(/[^a-z0-9]+/g, '-')
165
+ .replace(/^-|-$/g, '');
166
+
167
+ return normalized ? `-${normalized}` : '';
168
+ }
@@ -10,7 +10,7 @@ import { PortableTextHtmlComponents } from '@portabletext/to-html';
10
10
  import { pluginConfig } from '../adapter/core';
11
11
  import merge from 'lodash.merge';
12
12
  import { deleteMatchingFields } from './applyDocuments';
13
- import type { IgnoreFields } from '../adapter/types';
13
+ import type { FieldMatcher } from '../adapter/types';
14
14
 
15
15
  export function deserializeDocument(document: string) {
16
16
  const deserializers = merge(
@@ -45,7 +45,8 @@ export function serializeDocument(
45
45
 
46
46
  const docToSerialize = stripIgnoredFields(
47
47
  document,
48
- pluginConfig.getIgnoreFields()
48
+ pluginConfig.getIgnoreFields(),
49
+ pluginConfig.getDedupeFields()
49
50
  );
50
51
 
51
52
  const serialized = BaseDocumentSerializer(schema).serializeDocument(
@@ -60,16 +61,18 @@ export function serializeDocument(
60
61
 
61
62
  function stripIgnoredFields(
62
63
  document: SanityDocument,
63
- ignoreFields: IgnoreFields[]
64
+ ignoreFields: FieldMatcher[],
65
+ dedupeFields: FieldMatcher[]
64
66
  ): SanityDocument {
65
- if (ignoreFields.length === 0) return document;
67
+ const fieldsToStrip = [...ignoreFields, ...dedupeFields];
68
+ if (fieldsToStrip.length === 0) return document;
66
69
 
67
70
  const strippedDoc = JSON.parse(JSON.stringify(document)) as SanityDocument;
68
71
 
69
72
  deleteMatchingFields(
70
73
  document._id.replace('drafts.', ''),
71
74
  strippedDoc,
72
- ignoreFields
75
+ fieldsToStrip
73
76
  );
74
77
 
75
78
  return strippedDoc;