@smartcat/sanity-plugin 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +294 -0
  2. package/dist/_chunks-cjs/constants.cjs +13 -0
  3. package/dist/_chunks-cjs/constants.cjs.map +1 -0
  4. package/dist/_chunks-cjs/index.cjs +168 -0
  5. package/dist/_chunks-cjs/index.cjs.map +1 -0
  6. package/dist/_chunks-cjs/index2.cjs +311 -0
  7. package/dist/_chunks-cjs/index2.cjs.map +1 -0
  8. package/dist/_chunks-cjs/workflow.cjs +16 -0
  9. package/dist/_chunks-cjs/workflow.cjs.map +1 -0
  10. package/dist/_chunks-es/constants.js +14 -0
  11. package/dist/_chunks-es/constants.js.map +1 -0
  12. package/dist/_chunks-es/index.js +169 -0
  13. package/dist/_chunks-es/index.js.map +1 -0
  14. package/dist/_chunks-es/index2.js +313 -0
  15. package/dist/_chunks-es/index2.js.map +1 -0
  16. package/dist/_chunks-es/workflow.js +17 -0
  17. package/dist/_chunks-es/workflow.js.map +1 -0
  18. package/dist/export.cjs +160 -0
  19. package/dist/export.cjs.map +1 -0
  20. package/dist/export.d.cts +162 -0
  21. package/dist/export.d.ts +162 -0
  22. package/dist/export.js +162 -0
  23. package/dist/export.js.map +1 -0
  24. package/dist/import.cjs +169 -0
  25. package/dist/import.cjs.map +1 -0
  26. package/dist/import.d.cts +155 -0
  27. package/dist/import.d.ts +155 -0
  28. package/dist/import.js +169 -0
  29. package/dist/import.js.map +1 -0
  30. package/dist/index.cjs +2103 -0
  31. package/dist/index.cjs.map +1 -0
  32. package/dist/index.d.cts +122 -0
  33. package/dist/index.d.ts +122 -0
  34. package/dist/index.js +2110 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/locjson.cjs +12 -0
  37. package/dist/locjson.cjs.map +1 -0
  38. package/dist/locjson.d.cts +287 -0
  39. package/dist/locjson.d.ts +287 -0
  40. package/dist/locjson.js +12 -0
  41. package/dist/locjson.js.map +1 -0
  42. package/dist/progress.cjs +16 -0
  43. package/dist/progress.cjs.map +1 -0
  44. package/dist/progress.d.cts +262 -0
  45. package/dist/progress.d.ts +262 -0
  46. package/dist/progress.js +16 -0
  47. package/dist/progress.js.map +1 -0
  48. package/dist/smartcat.cjs +287 -0
  49. package/dist/smartcat.cjs.map +1 -0
  50. package/dist/smartcat.d.cts +234 -0
  51. package/dist/smartcat.d.ts +234 -0
  52. package/dist/smartcat.js +287 -0
  53. package/dist/smartcat.js.map +1 -0
  54. package/dist/templates.cjs +12 -0
  55. package/dist/templates.cjs.map +1 -0
  56. package/dist/templates.d.cts +52 -0
  57. package/dist/templates.d.ts +52 -0
  58. package/dist/templates.js +12 -0
  59. package/dist/templates.js.map +1 -0
  60. package/package.json +101 -15
  61. package/src/actions/AddToProjectAction.tsx +258 -0
  62. package/src/export/export.test.ts +537 -0
  63. package/src/export/index.ts +393 -0
  64. package/src/form/TranslationStatusInput.tsx +212 -0
  65. package/src/import/import.test.ts +346 -0
  66. package/src/import/index.ts +418 -0
  67. package/src/index.ts +63 -0
  68. package/src/lib/constants.ts +23 -0
  69. package/src/lib/documentTitle.test.ts +43 -0
  70. package/src/lib/documentTitle.ts +30 -0
  71. package/src/lib/languageMap.test.ts +56 -0
  72. package/src/lib/languageMap.ts +71 -0
  73. package/src/lib/linkedDocuments.test.ts +56 -0
  74. package/src/lib/linkedDocuments.ts +100 -0
  75. package/src/lib/locjson/deserialize.ts +60 -0
  76. package/src/lib/locjson/fields.ts +355 -0
  77. package/src/lib/locjson/filename.ts +27 -0
  78. package/src/lib/locjson/index.ts +10 -0
  79. package/src/lib/locjson/locjson.test.ts +545 -0
  80. package/src/lib/locjson/paths.test.ts +41 -0
  81. package/src/lib/locjson/paths.ts +73 -0
  82. package/src/lib/locjson/portableText.ts +98 -0
  83. package/src/lib/locjson/serialize.ts +124 -0
  84. package/src/lib/locjson/types.ts +106 -0
  85. package/src/lib/log.ts +33 -0
  86. package/src/lib/projectItems.ts +17 -0
  87. package/src/lib/resolveConfig.test.ts +62 -0
  88. package/src/lib/resolveConfig.ts +47 -0
  89. package/src/lib/workflow.test.ts +37 -0
  90. package/src/lib/workflow.ts +48 -0
  91. package/src/lib/zip.fixtures.ts +75 -0
  92. package/src/lib/zip.test.ts +110 -0
  93. package/src/lib/zip.ts +164 -0
  94. package/src/progress/core.ts +387 -0
  95. package/src/progress/index.ts +7 -0
  96. package/src/progress/progress.test.ts +268 -0
  97. package/src/schema/settings.ts +55 -0
  98. package/src/schema/translatableOptions.ts +25 -0
  99. package/src/schema/translationProject.ts +264 -0
  100. package/src/smartcat/client.test.ts +222 -0
  101. package/src/smartcat/client.ts +351 -0
  102. package/src/smartcat/index.ts +10 -0
  103. package/src/smartcat/types.ts +99 -0
  104. package/src/templates/core.ts +55 -0
  105. package/src/templates/index.ts +5 -0
  106. package/src/templates/templates.test.ts +50 -0
  107. package/src/tool/Dashboard.tsx +44 -0
  108. package/src/tool/ItemProgress.tsx +176 -0
  109. package/src/tool/LogPanel.tsx +207 -0
  110. package/src/tool/ProjectView.tsx +975 -0
  111. package/src/tool/ProjectsView.tsx +208 -0
  112. package/src/tool/StatusBadge.tsx +33 -0
  113. package/src/tool/StudioInit.tsx +42 -0
  114. package/src/tool/WorkflowSelect.tsx +41 -0
  115. package/src/tool/data.ts +79 -0
  116. package/src/tool/index.ts +19 -0
  117. package/src/tool/processing.test.ts +623 -0
  118. package/src/tool/processing.ts +766 -0
  119. package/src/tool/useTemplates.ts +84 -0
  120. package/src/tool/useWorkflowSelection.ts +65 -0
  121. package/src/types.ts +77 -0
@@ -0,0 +1,623 @@
1
+ import {beforeAll, describe, expect, it} from 'vitest'
2
+ import {Schema} from '@sanity/schema'
3
+ import {JSDOM} from 'jsdom'
4
+ import {applyImportedTranslations, prepareExport, type ProcessingClient} from './processing'
5
+
6
+ // processing.ts uses the browser's global DOMParser; provide one via jsdom.
7
+ beforeAll(() => {
8
+ ;(globalThis as {DOMParser?: unknown}).DOMParser = new JSDOM().window.DOMParser
9
+ })
10
+
11
+ const schema = Schema.compile({
12
+ name: 'test',
13
+ types: [
14
+ {
15
+ type: 'document',
16
+ name: 'page',
17
+ fields: [
18
+ {name: 'language', type: 'string', options: {documentInternationalization: {exclude: true}}},
19
+ {name: 'title', type: 'string'},
20
+ {name: 'body', type: 'array', of: [{type: 'block'}]},
21
+ ],
22
+ },
23
+ ],
24
+ })
25
+
26
+ const faqSchema = Schema.compile({
27
+ name: 'faqTest',
28
+ types: [
29
+ {name: 'internationalizedArrayStringValue', type: 'object', fields: [{name: 'value', type: 'string'}]},
30
+ {name: 'internationalizedArrayString', type: 'array', of: [{type: 'internationalizedArrayStringValue'}]},
31
+ {
32
+ name: 'internationalizedArraySimpleBlockContentValue',
33
+ type: 'object',
34
+ fields: [{name: 'value', type: 'array', of: [{type: 'block'}]}],
35
+ },
36
+ {
37
+ name: 'internationalizedArraySimpleBlockContent',
38
+ type: 'array',
39
+ of: [{type: 'internationalizedArraySimpleBlockContentValue'}],
40
+ },
41
+ {
42
+ name: 'faq',
43
+ type: 'document',
44
+ fields: [
45
+ {name: 'question', type: 'internationalizedArrayString'},
46
+ {name: 'answer', type: 'internationalizedArraySimpleBlockContent'},
47
+ {name: 'metadata', type: 'internationalizedArrayString'},
48
+ ],
49
+ },
50
+ ],
51
+ })
52
+
53
+ const block = (text: string) => ({
54
+ _type: 'block',
55
+ _key: 'b1',
56
+ style: 'normal',
57
+ markDefs: [],
58
+ children: [{_type: 'span', _key: 's1', text, marks: []}],
59
+ })
60
+
61
+ const sourceDoc = {
62
+ _id: 'page-1',
63
+ _type: 'page',
64
+ _rev: 'r1',
65
+ language: 'en',
66
+ title: 'About Us',
67
+ body: [block('We build delightful things.')],
68
+ }
69
+
70
+ /** Routing fake client with a record of creates/patches. */
71
+ function makeClient(opts: {project: unknown; source?: unknown; draft?: unknown}) {
72
+ const created: Record<string, unknown>[] = []
73
+ const ensured: Record<string, unknown>[] = []
74
+ const patches: Record<string, unknown>[] = []
75
+ let metadata: {_id: string; translations: unknown[]} | null = null
76
+ const client: ProcessingClient = {
77
+ fetch: (async (query: string, params?: Record<string, unknown>) => {
78
+ if (query.includes('translation.metadata')) return metadata
79
+ if (query.includes('items[]') || query.includes('inbox[]')) return opts.project
80
+ // A draft is served when provided (its id carries the `drafts.` prefix).
81
+ if (opts.draft && params?.id === (opts.draft as {_id?: string})?._id) return opts.draft
82
+ if (params?.id === (opts.source as {_id?: string})?._id) return opts.source
83
+ return null
84
+ }) as never,
85
+ create: async (doc) => {
86
+ const _id = (doc._id as string) ?? (doc._type === 'translation.metadata' ? 'meta-1' : 'variant-1')
87
+ created.push(doc)
88
+ if (doc._type === 'translation.metadata') metadata = {_id, translations: doc.translations as unknown[]}
89
+ return {_id}
90
+ },
91
+ // Draft seeding; tracked apart from real creates so `created` reflects only
92
+ // new variant/metadata documents.
93
+ createIfNotExists: async (doc) => {
94
+ ensured.push(doc)
95
+ return {_id: doc._id as string}
96
+ },
97
+ patch: (id: string) => {
98
+ const attrs: Record<string, unknown> = {__patchTarget: id}
99
+ const p = {
100
+ set: (a: Record<string, unknown>) => (Object.assign(attrs, a), p),
101
+ setIfMissing: () => p,
102
+ insert: () => p,
103
+ unset: () => p,
104
+ commit: async () => (patches.push(attrs), {}),
105
+ }
106
+ return p
107
+ },
108
+ }
109
+ return {client, created, ensured, patches, getMetadata: () => metadata}
110
+ }
111
+
112
+ const LANGUAGES = [
113
+ {id: 'en', title: 'English'},
114
+ {id: 'fr', title: 'French'},
115
+ {id: 'PL-PL', title: 'Polish', smartcatLanguage: 'pl'},
116
+ ]
117
+
118
+ describe('prepareExport', () => {
119
+ it('serializes items into outbox in schema order, with status queued', async () => {
120
+ const {client, patches} = makeClient({project: {sourceLanguage: 'en', items: [{doc: sourceDoc}]}})
121
+
122
+ const {prepared} = await prepareExport({
123
+ client,
124
+ schema,
125
+ projectId: 'p1',
126
+ targetLanguages: ['fr'],
127
+ sourceLanguage: 'en',
128
+ languages: LANGUAGES,
129
+ fieldLanguageKey: 'language',
130
+ })
131
+
132
+ expect(prepared).toBe(1)
133
+ const set = patches.at(-1)!
134
+ expect(set.status).toBe('queued')
135
+ expect(set.targetLanguages).toEqual(['fr'])
136
+
137
+ const outbox = set.outbox as {filename: string; locjson: string}[]
138
+ expect(outbox[0].filename).toBe('page/About Us-page-1.locjson')
139
+ const locjson = JSON.parse(outbox[0].locjson)
140
+ expect(locjson.units.map((u: {key: string}) => u.key)).toEqual(['title', 'body']) // schema order
141
+ expect(locjson.properties['x-sanity'].documentId).toBe('page-1')
142
+ })
143
+
144
+ const table = {_type: 'table', _key: 't1', rows: [{_key: 'r1', _type: 'tableRow', cells: ['A', 'B']}]}
145
+
146
+ it('leaves out a field with an unserializable block but still sends the rest of the document', async () => {
147
+ const doc = {_id: 'page-2', _type: 'page', language: 'en', title: 'About Us', body: [block('Intro.'), table]}
148
+ const {client, patches} = makeClient({project: {sourceLanguage: 'en', items: [{doc}]}})
149
+ const logs: {level: string; message: string}[] = []
150
+
151
+ const {prepared} = await prepareExport({
152
+ client, schema, projectId: 'p1', targetLanguages: ['fr'], sourceLanguage: 'en',
153
+ languages: LANGUAGES, fieldLanguageKey: 'language', onLog: (l) => logs.push(l),
154
+ })
155
+
156
+ expect(prepared).toBe(1)
157
+ const outbox = patches.at(-1)!.outbox as {locjson: string}[]
158
+ const locjson = JSON.parse(outbox[0].locjson)
159
+ expect(locjson.units.map((u: {key: string}) => u.key)).toEqual(['title']) // body left out
160
+ expect(logs.some((l) => l.level === 'error' && /Body: not translated.*table/.test(l.message))).toBe(true)
161
+ })
162
+
163
+ it('does not emit a zero-unit LocJSON when the only content is in an unserializable field', async () => {
164
+ // A single-field document whose one field can't be serialized: it must not be
165
+ // sent at all (no empty LocJSON), and the whole document is flagged.
166
+ const doc = {_id: 'page-3', _type: 'page', language: 'en', title: '', body: [table]}
167
+ const {client, patches} = makeClient({project: {sourceLanguage: 'en', items: [{doc}]}})
168
+ const logs: {level: string; message: string}[] = []
169
+
170
+ const {prepared} = await prepareExport({
171
+ client, schema, projectId: 'p1', targetLanguages: ['fr'], sourceLanguage: 'en',
172
+ languages: LANGUAGES, fieldLanguageKey: 'language', onLog: (l) => logs.push(l),
173
+ })
174
+
175
+ expect(prepared).toBe(0)
176
+ expect(patches.at(-1)!.outbox).toEqual([]) // nothing sent — no empty LocJSON
177
+ expect(logs.some((l) => l.level === 'error' && /not sent — its only translatable content/.test(l.message))).toBe(true)
178
+ })
179
+
180
+ it('stamps the id → Smartcat-code mapping for source + targets onto the project', async () => {
181
+ const {client, patches} = makeClient({project: {sourceLanguage: 'en', items: [{doc: sourceDoc}]}})
182
+
183
+ await prepareExport({
184
+ client,
185
+ schema,
186
+ projectId: 'p1',
187
+ targetLanguages: ['fr', 'PL-PL'],
188
+ sourceLanguage: 'en',
189
+ languages: LANGUAGES,
190
+ fieldLanguageKey: 'language',
191
+ })
192
+
193
+ expect(patches.at(-1)!.smartcatLanguages).toEqual([
194
+ {sanityId: 'en', smartcatLanguage: 'en'},
195
+ {sanityId: 'fr', smartcatLanguage: 'fr'},
196
+ {sanityId: 'PL-PL', smartcatLanguage: 'pl'},
197
+ ])
198
+ })
199
+
200
+ it('rejects targets that collapse to the same Smartcat code', async () => {
201
+ const {client} = makeClient({project: {sourceLanguage: 'en', items: [{doc: sourceDoc}]}})
202
+
203
+ await expect(
204
+ prepareExport({
205
+ client,
206
+ schema,
207
+ projectId: 'p1',
208
+ targetLanguages: ['PL-PL', 'pl'],
209
+ sourceLanguage: 'en',
210
+ languages: [...LANGUAGES, {id: 'pl', title: 'Polish (plain)', smartcatLanguage: 'pl'}],
211
+ fieldLanguageKey: 'language',
212
+ }),
213
+ ).rejects.toThrow(/same Smartcat language/i)
214
+ })
215
+ })
216
+
217
+ describe('applyImportedTranslations', () => {
218
+ it('turns inbox LocJSON into a locale variant + metadata, status completed', async () => {
219
+ // Produce LocJSON via prepareExport, then feed it back as a translated inbox item.
220
+ const exp = makeClient({project: {sourceLanguage: 'en', items: [{doc: sourceDoc}]}})
221
+ await prepareExport({client: exp.client, schema, projectId: 'p1', targetLanguages: ['fr'], sourceLanguage: 'en', languages: LANGUAGES, fieldLanguageKey: 'language'})
222
+ const outbox = exp.patches.at(-1)!.outbox as {locjson: string}[]
223
+ const locjson = JSON.parse(outbox[0].locjson)
224
+ // Simulate translation: copy source → target.
225
+ locjson.units.forEach((u: {source: string[]; target: string[]}) => (u.target = [...u.source]))
226
+
227
+ const {client, created, patches, getMetadata} = makeClient({
228
+ project: {
229
+ sourceLanguage: 'en',
230
+ inbox: [{sourceDocId: 'page-1', targetLanguage: 'fr', locjson: JSON.stringify(locjson)}],
231
+ // Single complete target → importing it should complete the project.
232
+ progress: [
233
+ {
234
+ _key: 'd1',
235
+ sourceDocId: 'page-1',
236
+ targets: [{_key: 'page-1__fr', language: 'fr', stages: [{name: 'Translation', percent: 100}], complete: true, imported: false}],
237
+ },
238
+ ],
239
+ },
240
+ source: sourceDoc,
241
+ })
242
+
243
+ const {imported, status} = await applyImportedTranslations({client, schema, projectId: 'p1', documentLanguageField: 'language', fieldLanguageKey: 'language'})
244
+ expect(imported).toBe(1)
245
+ expect(status).toBe('completed')
246
+
247
+ const variant = created.find((d) => d._type === 'page')!
248
+ expect(variant.language).toBe('fr')
249
+ expect(variant.title).toBe('About Us')
250
+ // The new variant is created as a draft so it accumulates for review.
251
+ expect(variant._id).toMatch(/^drafts\./)
252
+ const body = variant.body as Array<Record<string, unknown>>
253
+ expect(body[0]._key).toBeTruthy()
254
+
255
+ expect((getMetadata()!.translations as {language: string}[]).map((t) => t.language)).toEqual(['en', 'fr'])
256
+ expect(patches.at(-1)!.status).toBe('completed')
257
+ })
258
+
259
+ it('field-level: patches the same document inline, no variant or metadata', async () => {
260
+ const faqDoc = {
261
+ _id: 'faq-1',
262
+ _type: 'faq',
263
+ question: [{_key: 'q-en', _type: 'internationalizedArrayStringValue', language: 'en', value: 'How?'}],
264
+ answer: [
265
+ {
266
+ _key: 'a-en',
267
+ _type: 'internationalizedArraySimpleBlockContentValue',
268
+ language: 'en',
269
+ value: [block('Like this.')],
270
+ },
271
+ ],
272
+ }
273
+
274
+ // Produce LocJSON via prepareExport, then feed it back as a translated inbox item.
275
+ const exp = makeClient({project: {sourceLanguage: 'en', items: [{doc: faqDoc}]}})
276
+ await prepareExport({client: exp.client, schema: faqSchema, projectId: 'p1', targetLanguages: ['fr'], sourceLanguage: 'en', languages: LANGUAGES, fieldLanguageKey: 'language'})
277
+ const outbox = exp.patches.at(-1)!.outbox as {locjson: string}[]
278
+ const locjson = JSON.parse(outbox[0].locjson)
279
+ locjson.units.forEach((u: {key: string; source: string[]; target: string[]}) => {
280
+ u.target = u.key === 'question' ? ['Comment ?'] : [...u.source]
281
+ })
282
+
283
+ const {client, created, ensured, patches} = makeClient({
284
+ project: {
285
+ sourceLanguage: 'en',
286
+ inbox: [{sourceDocId: 'faq-1', targetLanguage: 'fr', locjson: JSON.stringify(locjson)}],
287
+ progress: [
288
+ {
289
+ _key: 'd1',
290
+ sourceDocId: 'faq-1',
291
+ targets: [{_key: 'faq-1__fr', language: 'fr', stages: [{name: 'Translation', percent: 100}], complete: true, imported: false}],
292
+ },
293
+ ],
294
+ },
295
+ source: faqDoc,
296
+ })
297
+
298
+ const {imported, status} = await applyImportedTranslations({client, schema: faqSchema, projectId: 'p1', documentLanguageField: 'language', fieldLanguageKey: 'language'})
299
+ expect(imported).toBe(1)
300
+ expect(status).toBe('completed')
301
+
302
+ // No locale variant document and no translation.metadata were created.
303
+ expect(created).toHaveLength(0)
304
+
305
+ // The write targets the document's DRAFT, seeded from the published version
306
+ // (no draft existed), so the published doc is left untouched.
307
+ expect(ensured.map((d) => d._id)).toContain('drafts.faq-1')
308
+ const docPatch = patches.find((p) => 'question' in p)!
309
+ expect(docPatch.__patchTarget).toBe('drafts.faq-1')
310
+ const question = docPatch.question as {language: string; value: unknown; _key: string}[]
311
+ expect(question.map((m) => m.language)).toEqual(['en', 'fr'])
312
+ expect(question.find((m) => m.language === 'fr')!.value).toBe('Comment ?')
313
+ expect(question.find((m) => m.language === 'en')!._key).toBe('q-en')
314
+
315
+ const answer = docPatch.answer as {_key: string; language: string; value: Array<Record<string, unknown>>}[]
316
+ const fr = answer.find((m) => m.language === 'fr')!
317
+ expect(fr._key).toBeTruthy()
318
+ expect((fr.value[0].children as Array<{text: string}>)[0].text).toBe('Like this.')
319
+ })
320
+
321
+ it('field-level: skips a member whose translated value is unchanged', async () => {
322
+ // The doc already has a French member equal to what the import would write,
323
+ // so no document patch should be made.
324
+ const faqDoc = {
325
+ _id: 'faq-1',
326
+ _type: 'faq',
327
+ question: [
328
+ {_key: 'q-en', _type: 'internationalizedArrayStringValue', language: 'en', value: 'How?'},
329
+ {_key: 'q-fr', _type: 'internationalizedArrayStringValue', language: 'fr', value: 'How?'},
330
+ ],
331
+ }
332
+
333
+ const exp = makeClient({project: {sourceLanguage: 'en', items: [{doc: faqDoc}]}})
334
+ await prepareExport({client: exp.client, schema: faqSchema, projectId: 'p1', targetLanguages: ['fr'], sourceLanguage: 'en', languages: LANGUAGES, fieldLanguageKey: 'language'})
335
+ const outbox = exp.patches.at(-1)!.outbox as {locjson: string}[]
336
+ const locjson = JSON.parse(outbox[0].locjson)
337
+ // No actual translation: target mirrors source, so the value is identical.
338
+ locjson.units.forEach((u: {source: string[]; target: string[]}) => (u.target = [...u.source]))
339
+
340
+ const {client, patches} = makeClient({
341
+ project: {
342
+ sourceLanguage: 'en',
343
+ inbox: [{sourceDocId: 'faq-1', targetLanguage: 'fr', locjson: JSON.stringify(locjson)}],
344
+ progress: [
345
+ {_key: 'd1', sourceDocId: 'faq-1', targets: [{_key: 'faq-1__fr', language: 'fr', stages: [{name: 'Translation', percent: 100}], complete: true, imported: false}]},
346
+ ],
347
+ },
348
+ source: faqDoc,
349
+ })
350
+
351
+ const {imported} = await applyImportedTranslations({client, schema: faqSchema, projectId: 'p1', documentLanguageField: 'language', fieldLanguageKey: 'language'})
352
+ expect(imported).toBe(1)
353
+ // Nothing changed, so the source document was never patched.
354
+ expect(patches.find((p) => 'question' in p)).toBeUndefined()
355
+ })
356
+
357
+ it('field-level: accumulates onto an existing draft without re-seeding it', async () => {
358
+ const published = {
359
+ _id: 'faq-1',
360
+ _type: 'faq',
361
+ question: [{_key: 'q-en', _type: 'internationalizedArrayStringValue', language: 'en', value: 'How?'}],
362
+ }
363
+ // The editor already has a draft with an in-progress edit to the English value.
364
+ const draft = {
365
+ _id: 'drafts.faq-1',
366
+ _type: 'faq',
367
+ question: [{_key: 'q-en', _type: 'internationalizedArrayStringValue', language: 'en', value: 'How (edited)?'}],
368
+ }
369
+
370
+ const exp = makeClient({project: {sourceLanguage: 'en', items: [{doc: published}]}})
371
+ await prepareExport({client: exp.client, schema: faqSchema, projectId: 'p1', targetLanguages: ['fr'], sourceLanguage: 'en', languages: LANGUAGES, fieldLanguageKey: 'language'})
372
+ const outbox = exp.patches.at(-1)!.outbox as {locjson: string}[]
373
+ const locjson = JSON.parse(outbox[0].locjson)
374
+ locjson.units.forEach((u: {key: string; target: string[]}) => (u.target = ['Comment ?']))
375
+
376
+ const {client, ensured, patches} = makeClient({
377
+ project: {
378
+ sourceLanguage: 'en',
379
+ inbox: [{sourceDocId: 'faq-1', targetLanguage: 'fr', locjson: JSON.stringify(locjson)}],
380
+ progress: [
381
+ {_key: 'd1', sourceDocId: 'faq-1', targets: [{_key: 'faq-1__fr', language: 'fr', stages: [{name: 'Translation', percent: 100}], complete: true, imported: false}]},
382
+ ],
383
+ },
384
+ source: published,
385
+ draft,
386
+ })
387
+
388
+ await applyImportedTranslations({client, schema: faqSchema, projectId: 'p1', documentLanguageField: 'language', fieldLanguageKey: 'language'})
389
+
390
+ // A draft already existed, so it is NOT re-seeded from published…
391
+ expect(ensured).toHaveLength(0)
392
+ // …and the French member is appended onto the draft's array (preserving the
393
+ // editor's in-progress English edit).
394
+ const docPatch = patches.find((p) => 'question' in p)!
395
+ expect(docPatch.__patchTarget).toBe('drafts.faq-1')
396
+ const question = docPatch.question as {language: string; value: string}[]
397
+ expect(question.find((m) => m.language === 'en')!.value).toBe('How (edited)?')
398
+ expect(question.find((m) => m.language === 'fr')!.value).toBe('Comment ?')
399
+ })
400
+ })
401
+
402
+ describe('i18n language conventions', () => {
403
+ it('field-level v4: matches and writes members by _key, with no language field', async () => {
404
+ const faqDoc = {
405
+ _id: 'faq-1',
406
+ _type: 'faq',
407
+ question: [{_key: 'en', _type: 'internationalizedArrayStringValue', value: 'How?'}],
408
+ answer: [{_key: 'en', _type: 'internationalizedArraySimpleBlockContentValue', value: [block('Like this.')]}],
409
+ }
410
+
411
+ // Export reads the source member by _key.
412
+ const exp = makeClient({project: {sourceLanguage: 'en', items: [{doc: faqDoc}]}})
413
+ await prepareExport({
414
+ client: exp.client,
415
+ schema: faqSchema,
416
+ projectId: 'p1',
417
+ targetLanguages: ['fr'],
418
+ sourceLanguage: 'en',
419
+ languages: LANGUAGES,
420
+ fieldLanguageKey: '_key',
421
+ })
422
+ const outbox = exp.patches.at(-1)!.outbox as {locjson: string}[]
423
+ const locjson = JSON.parse(outbox[0].locjson)
424
+ expect(locjson.units.map((u: {key: string}) => u.key)).toEqual(['question', 'answer'])
425
+ locjson.units.forEach((u: {key: string; source: string[]; target: string[]}) => {
426
+ u.target = u.key === 'question' ? ['Comment ?'] : [...u.source]
427
+ })
428
+
429
+ const {client, patches} = makeClient({
430
+ project: {
431
+ sourceLanguage: 'en',
432
+ inbox: [{sourceDocId: 'faq-1', targetLanguage: 'fr', locjson: JSON.stringify(locjson)}],
433
+ progress: [
434
+ {
435
+ _key: 'd1',
436
+ sourceDocId: 'faq-1',
437
+ targets: [{_key: 'faq-1__fr', language: 'fr', stages: [{name: 'Translation', percent: 100}], complete: true, imported: false}],
438
+ },
439
+ ],
440
+ },
441
+ source: faqDoc,
442
+ })
443
+ await applyImportedTranslations({client, schema: faqSchema, projectId: 'p1', documentLanguageField: 'language', fieldLanguageKey: '_key'})
444
+
445
+ const docPatch = patches.find((p) => 'question' in p)!
446
+ const question = docPatch.question as {_key: string; language?: string; value: unknown}[]
447
+ expect(question.map((m) => m._key)).toEqual(['en', 'fr']) // fr keyed by _key, en preserved
448
+ expect(question.find((m) => m._key === 'fr')!.value).toBe('Comment ?')
449
+ expect(question.every((m) => m.language === undefined)).toBe(true) // no language field written
450
+ })
451
+
452
+ it('document-level: creates the variant using a custom languageField', async () => {
453
+ const exp = makeClient({project: {sourceLanguage: 'en', items: [{doc: sourceDoc}]}})
454
+ await prepareExport({client: exp.client, schema, projectId: 'p1', targetLanguages: ['fr'], sourceLanguage: 'en', languages: LANGUAGES, fieldLanguageKey: 'language'})
455
+ const outbox = exp.patches.at(-1)!.outbox as {locjson: string}[]
456
+ const locjson = JSON.parse(outbox[0].locjson)
457
+ locjson.units.forEach((u: {source: string[]; target: string[]}) => (u.target = [...u.source]))
458
+
459
+ const {client, created} = makeClient({
460
+ project: {
461
+ sourceLanguage: 'en',
462
+ inbox: [{sourceDocId: 'page-1', targetLanguage: 'fr', locjson: JSON.stringify(locjson)}],
463
+ progress: [
464
+ {
465
+ _key: 'd1',
466
+ sourceDocId: 'page-1',
467
+ targets: [{_key: 'page-1__fr', language: 'fr', stages: [{name: 'Translation', percent: 100}], complete: true, imported: false}],
468
+ },
469
+ ],
470
+ },
471
+ source: sourceDoc,
472
+ })
473
+ await applyImportedTranslations({client, schema, projectId: 'p1', documentLanguageField: 'lang', fieldLanguageKey: 'language'})
474
+
475
+ const variant = created.find((d) => d._type === 'page')!
476
+ expect(variant.lang).toBe('fr')
477
+ })
478
+ })
479
+
480
+ describe('JSON field validation on import', () => {
481
+ // A faq with a `metadata` field whose source is well-formed JSON.
482
+ const faqWithJson = {
483
+ _id: 'faq-1',
484
+ _type: 'faq',
485
+ question: [{_key: 'en', _type: 'internationalizedArrayStringValue', language: 'en', value: 'How?'}],
486
+ metadata: [
487
+ {
488
+ _key: 'en',
489
+ _type: 'internationalizedArrayStringValue',
490
+ language: 'en',
491
+ value: '{"id":"m1","description":"Hello there, friend."}',
492
+ },
493
+ ],
494
+ }
495
+
496
+ /** Build an inbox LocJSON for faqWithJson with a custom metadata translation. */
497
+ async function inboxWith(metadataTarget: string) {
498
+ const exp = makeClient({project: {sourceLanguage: 'en', items: [{doc: faqWithJson}]}})
499
+ await prepareExport({client: exp.client, schema: faqSchema, projectId: 'p1', targetLanguages: ['fr'], sourceLanguage: 'en', languages: LANGUAGES, fieldLanguageKey: 'language'})
500
+ const outbox = exp.patches.at(-1)!.outbox as {locjson: string}[]
501
+ const locjson = JSON.parse(outbox[0].locjson)
502
+ locjson.units.forEach((u: {key: string; source: string[]; target: string[]}) => {
503
+ u.target = u.key === 'metadata' ? [metadataTarget] : ['Comment ?']
504
+ })
505
+ return JSON.stringify(locjson)
506
+ }
507
+
508
+ function project(locjson: string) {
509
+ return {
510
+ sourceLanguage: 'en',
511
+ inbox: [{sourceDocId: 'faq-1', targetLanguage: 'fr', locjson}],
512
+ progress: [
513
+ {_key: 'd1', sourceDocId: 'faq-1', targets: [{_key: 'faq-1__fr', language: 'fr', stages: [{name: 'Translation', percent: 100}], complete: true, imported: false}]},
514
+ ],
515
+ }
516
+ }
517
+
518
+ it('skips a field whose source is JSON but whose translation is malformed, logging the parse error', async () => {
519
+ // Missing closing brace → invalid JSON.
520
+ const {client, patches} = makeClient({project: project(await inboxWith('{"id":"m1","description":"Bonjour."')), source: faqWithJson})
521
+ const logs: {level: string; message: string; value?: string; error?: string}[] = []
522
+
523
+ await applyImportedTranslations({client, schema: faqSchema, projectId: 'p1', documentLanguageField: 'language', fieldLanguageKey: 'language', onLog: (l) => logs.push(l)})
524
+
525
+ // The malformed metadata is not written; the valid `question` still is.
526
+ const docPatch = patches.find((p) => 'question' in p || 'metadata' in p)!
527
+ expect('metadata' in docPatch).toBe(false)
528
+ expect('question' in docPatch).toBe(true)
529
+
530
+ // Short message; the raw attempted value (after) and the parse error are
531
+ // carried separately for the expandable boxes.
532
+ const err = logs.find((l) => l.level === 'error' && /metadata/.test(l.message))!
533
+ expect(err).toBeTruthy()
534
+ expect(err.message).toMatch(/not valid JSON — skipped$/)
535
+ expect(err.value).toContain('description') // raw attempted JSON
536
+ expect(err.error).toBeTruthy() // JSON parse error in its own box
537
+ })
538
+
539
+ it('imports a field whose JSON translation is well-formed', async () => {
540
+ const {client, patches} = makeClient({
541
+ project: project(await inboxWith('{"id":"m1","description":"Bonjour, mon ami."}')),
542
+ source: faqWithJson,
543
+ })
544
+
545
+ await applyImportedTranslations({client, schema: faqSchema, projectId: 'p1', documentLanguageField: 'language', fieldLanguageKey: 'language'})
546
+
547
+ const docPatch = patches.find((p) => 'metadata' in p)!
548
+ const metadata = docPatch.metadata as {language: string; value: string}[]
549
+ expect(metadata.find((m) => m.language === 'fr')!.value).toBe('{"id":"m1","description":"Bonjour, mon ami."}')
550
+ })
551
+ })
552
+
553
+ // A HUEL-shaped block with translatable content nested inside a plain object AND
554
+ // inside an internationalized-array whose member value is an array of objects.
555
+ const scienceSchema = Schema.compile({
556
+ name: 'sci',
557
+ types: [
558
+ {name: 'internationalizedArrayStringValue', type: 'object', fields: [{name: 'value', type: 'string'}]},
559
+ {name: 'internationalizedArrayString', type: 'array', of: [{type: 'internationalizedArrayStringValue'}]},
560
+ {name: 'benefitItem', type: 'object', fields: [{name: 'key', type: 'string'}, {name: 'value', type: 'string'}]},
561
+ {name: 'internationalizedArrayBenefitsValue', type: 'object', fields: [{name: 'value', type: 'array', of: [{type: 'benefitItem'}]}]},
562
+ {name: 'internationalizedArrayBenefits', type: 'array', of: [{type: 'internationalizedArrayBenefitsValue'}]},
563
+ {name: 'benefits', type: 'object', fields: [{name: 'items', type: 'internationalizedArrayBenefits'}]},
564
+ {name: 'homePageScienceBlock', type: 'document', fields: [
565
+ {name: 'heading', type: 'internationalizedArrayString'},
566
+ {name: 'benefits', type: 'benefits'},
567
+ ]},
568
+ ],
569
+ })
570
+
571
+ describe('nested + array-member field-level translation', () => {
572
+ const sciDoc = {
573
+ _id: 'sci-1',
574
+ _type: 'homePageScienceBlock',
575
+ _rev: 'r1',
576
+ heading: [{_key: 'en', _type: 'internationalizedArrayStringValue', language: 'en', value: 'The science'}],
577
+ benefits: {
578
+ items: [
579
+ {_key: 'en', _type: 'internationalizedArrayBenefitsValue', language: 'en', value: [
580
+ {_key: 'b1', _type: 'benefitItem', key: 'protein', value: 'High in protein'},
581
+ {_key: 'b2', _type: 'benefitItem', key: 'fibre', value: 'Rich in fibre'},
582
+ ]},
583
+ ],
584
+ },
585
+ }
586
+
587
+ it('serializes nested benefit copy and imports it back into a fr member, preserving structure', async () => {
588
+ const exp = makeClient({project: {sourceLanguage: 'en', items: [{doc: sciDoc}]}})
589
+ await prepareExport({client: exp.client, schema: scienceSchema, projectId: 'p1', targetLanguages: ['fr'], sourceLanguage: 'en', languages: LANGUAGES, fieldLanguageKey: 'language'})
590
+ const outbox = exp.patches.at(-1)!.outbox as {locjson: string}[]
591
+ const locjson = JSON.parse(outbox[0].locjson)
592
+
593
+ // The nested benefit values are serialized (previously dropped entirely).
594
+ const keys = locjson.units.map((u: {key: string}) => u.key).sort()
595
+ expect(keys).toContain('benefits.items[b1].value')
596
+ expect(keys).toContain('benefits.items[b2].value')
597
+
598
+ // Translate only the `value` leaves (uppercase); leave `key`/`heading` as source
599
+ // to show non-translated leaves and structure survive the round-trip.
600
+ locjson.units.forEach((u: {key: string; source: string[]; target: string[]}) => {
601
+ u.target = u.key.endsWith('.value') ? u.source.map((s) => s.toUpperCase()) : [...u.source]
602
+ })
603
+
604
+ const {client, patches} = makeClient({
605
+ project: {sourceLanguage: 'en', inbox: [{sourceDocId: 'sci-1', targetLanguage: 'fr', locjson: JSON.stringify(locjson)}], progress: [
606
+ {_key: 'd1', sourceDocId: 'sci-1', targets: [{_key: 'sci-1__fr', language: 'fr', stages: [{name: 'Translation', percent: 100}], complete: true, imported: false}]},
607
+ ]},
608
+ source: sciDoc,
609
+ })
610
+ const {imported} = await applyImportedTranslations({client, schema: scienceSchema, projectId: 'p1', documentLanguageField: 'language', fieldLanguageKey: 'language'})
611
+ expect(imported).toBe(1)
612
+
613
+ // The draft patch adds a fr member to benefits.items with translated values,
614
+ // preserving inner _keys and the non-text `key` field.
615
+ const docPatch = patches.find((p) => 'benefits.items' in p)!
616
+ const members = docPatch['benefits.items'] as {language: string; value: {_key: string; key: string; value: string}[]}[]
617
+ const fr = members.find((m) => m.language === 'fr')!
618
+ expect(fr.value).toEqual([
619
+ {_key: 'b1', _type: 'benefitItem', key: 'protein', value: 'HIGH IN PROTEIN'},
620
+ {_key: 'b2', _type: 'benefitItem', key: 'fibre', value: 'RICH IN FIBRE'},
621
+ ])
622
+ })
623
+ })