@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,545 @@
1
+ import {describe, expect, it, vi} from 'vitest'
2
+ import {Schema} from '@sanity/schema'
3
+ import {JSDOM} from 'jsdom'
4
+ import {
5
+ getTranslatableFields,
6
+ inferTranslatableFields,
7
+ innerValueType,
8
+ localizationMode,
9
+ orderFieldsBy,
10
+ } from './fields'
11
+ import {findUnserializableBlockTypes, portableTextToHtml} from './portableText'
12
+ import {serializeToLocjson} from './serialize'
13
+ import {deserializeFromLocjson} from './deserialize'
14
+ import {buildLocjsonFilename} from './filename'
15
+ import type {LocJSON} from './types'
16
+
17
+ // A schema resembling the studio's page/blogPost, with non-translatable fields
18
+ // mixed in to exercise selection. (Object-based built-ins like slug/image/
19
+ // reference aren't registered in a bare @sanity/schema compile, so non-text
20
+ // fields are represented here with primitives, a plain array, and an object —
21
+ // all of which must be excluded just the same.)
22
+ const schema = Schema.compile({
23
+ name: 'test',
24
+ types: [
25
+ {
26
+ name: 'page',
27
+ type: 'document',
28
+ fields: [
29
+ {name: 'language', type: 'string', options: {documentInternationalization: {exclude: true}}},
30
+ {name: 'title', type: 'string'},
31
+ {name: 'excerpt', type: 'text'},
32
+ {name: 'views', type: 'number'},
33
+ {name: 'featured', type: 'boolean'},
34
+ {name: 'tags', type: 'array', of: [{type: 'string'}]},
35
+ {name: 'seo', type: 'object', fields: [{name: 'metaTitle', type: 'string'}]},
36
+ {name: 'body', type: 'array', of: [{type: 'block'}]},
37
+ ],
38
+ },
39
+ ],
40
+ })
41
+
42
+ const pageType = schema.get('page')
43
+ const bodyType = pageType.fields.find((f: {name: string}) => f.name === 'body').type
44
+
45
+ const parseHtml = (html: string) => new JSDOM(html).window.document
46
+ const getBlockContentType = () => bodyType
47
+
48
+ function makeBlock(text: string, marks: string[] = []) {
49
+ return {
50
+ _type: 'block',
51
+ _key: 'b1',
52
+ style: 'normal',
53
+ markDefs: [],
54
+ children: [{_type: 'span', _key: 's1', text, marks}],
55
+ }
56
+ }
57
+
58
+ describe('getTranslatableFields', () => {
59
+ it('selects string/text/portable-text and excludes the rest', () => {
60
+ const fields = getTranslatableFields(pageType)
61
+ const byPath = Object.fromEntries(fields.map((f) => [f.path, f.kind]))
62
+
63
+ expect(byPath).toEqual({title: 'plain', excerpt: 'plain', body: 'portableText'})
64
+ // excluded: the language field (marked via document-internationalization's
65
+ // field option), primitives, plain array, object
66
+ expect(byPath.language).toBeUndefined()
67
+ expect(byPath.views).toBeUndefined()
68
+ expect(byPath.featured).toBeUndefined()
69
+ expect(byPath.tags).toBeUndefined()
70
+ expect(byPath.seo).toBeUndefined()
71
+ })
72
+
73
+ it('excludes fields marked non-translatable by document-internationalization', () => {
74
+ const s = Schema.compile({
75
+ name: 'opt',
76
+ types: [
77
+ {
78
+ name: 'thing',
79
+ type: 'document',
80
+ fields: [
81
+ {name: 'title', type: 'string'},
82
+ {name: 'sku', type: 'string', options: {documentInternationalization: {exclude: true}}},
83
+ ],
84
+ },
85
+ ],
86
+ })
87
+ const fields = getTranslatableFields(s.get('thing'))
88
+ expect(fields.map((f) => f.path)).toEqual(['title']) // sku skipped via the i18n option
89
+ })
90
+
91
+ it('never selects fields on Sanity system types (e.g. sanity.imageAsset)', () => {
92
+ // Built-in asset docs are referenced by image/file fields and carry plain
93
+ // string fields (originalFilename, path, url) that must never be translated.
94
+ const imageAsset = {
95
+ jsonType: 'object',
96
+ name: 'sanity.imageAsset',
97
+ fields: [
98
+ {name: 'originalFilename', type: {jsonType: 'string', name: 'string'}},
99
+ {name: 'path', type: {jsonType: 'string', name: 'string'}},
100
+ {name: 'title', type: {jsonType: 'string', name: 'string'}},
101
+ ],
102
+ }
103
+ expect(getTranslatableFields(imageAsset)).toEqual([])
104
+ })
105
+
106
+ it('honors a custom exclude list (e.g. the configured language field)', () => {
107
+ const fields = getTranslatableFields(pageType, {exclude: ['title']})
108
+ expect(fields.map((f) => f.path)).toEqual(['excerpt', 'body'])
109
+ })
110
+
111
+ it('opts custom-named types into translation via smartcatTranslation.include', () => {
112
+ const s = Schema.compile({
113
+ name: 'inc',
114
+ types: [
115
+ {name: 'headingString', type: 'string', options: {smartcatTranslation: {include: true}}},
116
+ {name: 'richText', type: 'array', of: [{type: 'block'}], options: {smartcatTranslation: {include: true}}},
117
+ {
118
+ name: 'thing',
119
+ type: 'document',
120
+ fields: [
121
+ {name: 'heading', type: 'headingString'}, // custom string alias → plain
122
+ {name: 'tagline', type: 'string', options: {smartcatTranslation: {include: true}}}, // per-field
123
+ {name: 'content', type: 'richText'}, // custom PT alias → portableText
124
+ {name: 'plain', type: 'string'}, // still works via the default allowlist
125
+ ],
126
+ },
127
+ ],
128
+ })
129
+ const fields = getTranslatableFields(s.get('thing'))
130
+ expect(Object.fromEntries(fields.map((f) => [f.path, f.kind]))).toEqual({
131
+ heading: 'plain',
132
+ tagline: 'plain',
133
+ content: 'portableText',
134
+ plain: 'plain',
135
+ })
136
+ })
137
+
138
+ it('lets include override a document-internationalization exclude', () => {
139
+ const s = Schema.compile({
140
+ name: 'override',
141
+ types: [
142
+ {
143
+ name: 'thing',
144
+ type: 'document',
145
+ fields: [
146
+ {
147
+ name: 'note',
148
+ type: 'string',
149
+ options: {documentInternationalization: {exclude: true}, smartcatTranslation: {include: true}},
150
+ },
151
+ ],
152
+ },
153
+ ],
154
+ })
155
+ expect(getTranslatableFields(s.get('thing')).map((f) => f.path)).toEqual(['note'])
156
+ })
157
+
158
+ it('warns and skips when an included field has no translatable representation', () => {
159
+ const s = Schema.compile({
160
+ name: 'badinc',
161
+ types: [
162
+ {
163
+ name: 'thing',
164
+ type: 'document',
165
+ fields: [{name: 'count', type: 'number', options: {smartcatTranslation: {include: true}}}],
166
+ },
167
+ ],
168
+ })
169
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
170
+ const fields = getTranslatableFields(s.get('thing'))
171
+ expect(fields).toEqual([])
172
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('count'))
173
+ warn.mockRestore()
174
+ })
175
+ })
176
+
177
+ describe('portableTextToHtml', () => {
178
+ it('renders unknown/custom block types as nothing, not a placeholder', () => {
179
+ // A custom `table` block (no to-html component). The default behavior emits a
180
+ // hidden `<div style="display:none">Unknown block type "table"…</div>`, whose
181
+ // English text would be translated into every locale and which Smartcat
182
+ // rejects as unparseable HTML. It must be dropped entirely instead.
183
+ const blocks = [
184
+ makeBlock('Delivery info'),
185
+ {_type: 'table', _key: 't1', rows: [{_key: 'r1', _type: 'tableRow', cells: ['Region', 'Price']}]},
186
+ ]
187
+ const html = portableTextToHtml(blocks)
188
+ expect(html).not.toContain('Unknown block type')
189
+ expect(html).not.toContain('display:none')
190
+ expect(html).toContain('Delivery info')
191
+ })
192
+ })
193
+
194
+ describe('findUnserializableBlockTypes', () => {
195
+ const table = {_type: 'table', _key: 't1', rows: [{_key: 'r1', _type: 'tableRow', cells: ['A', 'B']}]}
196
+
197
+ it('flags custom top-level block types', () => {
198
+ expect(findUnserializableBlockTypes([makeBlock('text'), table])).toEqual(['table'])
199
+ })
200
+
201
+ it('flags custom inline children (non-span)', () => {
202
+ const withInline = {
203
+ _type: 'block',
204
+ _key: 'b1',
205
+ children: [
206
+ {_type: 'span', _key: 's1', text: 'see '},
207
+ {_type: 'stockTicker', _key: 'i1', symbol: 'HUEL'},
208
+ ],
209
+ }
210
+ expect(findUnserializableBlockTypes([withInline])).toEqual(['stockTicker'])
211
+ })
212
+
213
+ it('returns empty for standard blocks/spans', () => {
214
+ expect(findUnserializableBlockTypes([makeBlock('a'), makeBlock('b')])).toEqual([])
215
+ })
216
+ })
217
+
218
+ describe('inferTranslatableFields', () => {
219
+ it('never selects fields on Sanity system types (e.g. sanity.imageAsset)', () => {
220
+ const asset = {
221
+ _type: 'sanity.imageAsset',
222
+ originalFilename: '01-Front Shot - es-ES.jpg',
223
+ path: 'images/xyz/production/abc-1468x1468.jpg',
224
+ url: 'https://cdn.sanity.io/images/xyz/production/abc-1468x1468.jpg',
225
+ }
226
+ expect(inferTranslatableFields(asset)).toEqual([])
227
+ })
228
+ })
229
+
230
+ describe('orderFieldsBy', () => {
231
+ it('reorders data-inferred fields to match schema order (title before body)', () => {
232
+ // Simulate Content Lake returning keys alphabetically (body before title).
233
+ const inferred = inferTranslatableFields({body: [makeBlock('x')], title: 'T'})
234
+ expect(inferred.map((f) => f.path)).toEqual(['body', 'title'])
235
+
236
+ const ordered = orderFieldsBy(inferred, ['title', 'body'])
237
+ expect(ordered.map((f) => f.path)).toEqual(['title', 'body'])
238
+ })
239
+
240
+ it('puts unlisted fields last, preserving their order', () => {
241
+ const fields = inferTranslatableFields({title: 'T', body: [makeBlock('x')], summary: 'S'})
242
+ const ordered = orderFieldsBy(fields, ['title'])
243
+ expect(ordered[0].path).toBe('title')
244
+ expect(ordered.slice(1).map((f) => f.path).sort()).toEqual(['body', 'summary'])
245
+ })
246
+
247
+ it('returns fields unchanged when no order is given', () => {
248
+ const fields = inferTranslatableFields({title: 'T'})
249
+ expect(orderFieldsBy(fields, undefined)).toBe(fields)
250
+ })
251
+ })
252
+
253
+ describe('serializeToLocjson', () => {
254
+ const doc = {
255
+ _id: '3f14f092-3475-42fc-a9f4-abf710d71ea8',
256
+ _type: 'page',
257
+ _rev: 'rev-1',
258
+ language: 'en',
259
+ title: 'About Us',
260
+ excerpt: 'Line one\nLine two',
261
+ body: [makeBlock('We build delightful things.')],
262
+ }
263
+ const fields = getTranslatableFields(pageType)
264
+ const locjson = serializeToLocjson(doc, fields, {sourceLanguage: 'en', fieldLanguageKey: 'language'})
265
+
266
+ it('writes file-level x-sanity metadata', () => {
267
+ expect(locjson.properties['x-sanity']).toEqual({
268
+ documentId: doc._id,
269
+ documentType: 'page',
270
+ rev: 'rev-1',
271
+ sourceLanguage: 'en',
272
+ })
273
+ })
274
+
275
+ it('emits one unit per non-empty translatable field, keyed by field path', () => {
276
+ expect(locjson.units.map((u) => u.key)).toEqual(['title', 'excerpt', 'body'])
277
+ })
278
+
279
+ it('splits plain text into line segments (keeping newlines) and marks html units', () => {
280
+ const excerpt = locjson.units.find((u) => u.key === 'excerpt')!
281
+ expect(excerpt.source).toEqual(['Line one\n', 'Line two'])
282
+ expect(excerpt.source.join('')).toBe('Line one\nLine two')
283
+ expect(excerpt.properties?.['x-smartcat-format']).toBeUndefined()
284
+
285
+ const body = locjson.units.find((u) => u.key === 'body')!
286
+ expect(body.properties?.['x-smartcat-format']).toBe('html')
287
+ expect(body.source[0]).toContain('We build delightful things.')
288
+ expect(body.properties?.['x-sanity']).toEqual({fieldPath: 'body'})
289
+ })
290
+
291
+ it('skips a Portable Text field containing an unserializable block, and reports it', () => {
292
+ const skipped: {fieldPath: string; types: string[]}[] = []
293
+ const result = serializeToLocjson(
294
+ {
295
+ _id: 'x',
296
+ _type: 'page',
297
+ title: 'Delivery',
298
+ body: [makeBlock('How much is delivery?'), {_type: 'table', _key: 't1', rows: []}],
299
+ },
300
+ fields,
301
+ {
302
+ sourceLanguage: 'en',
303
+ fieldLanguageKey: 'language',
304
+ onSkippedField: ({fieldPath, types}) => skipped.push({fieldPath, types}),
305
+ },
306
+ )
307
+ // title still serialized; body left out entirely (not partially)
308
+ expect(result.units.map((u) => u.key)).toEqual(['title'])
309
+ expect(skipped).toEqual([{fieldPath: 'body', types: ['table']}])
310
+ })
311
+
312
+ it('skips empty fields', () => {
313
+ const empty = serializeToLocjson(
314
+ {_id: 'x', _type: 'page', title: '', body: []},
315
+ fields,
316
+ {sourceLanguage: 'en', fieldLanguageKey: 'language'},
317
+ )
318
+ expect(empty.units).toHaveLength(0)
319
+ })
320
+
321
+ it('skips whitespace-only plain fields', () => {
322
+ const blank = serializeToLocjson(
323
+ {_id: 'x', _type: 'page', title: ' ', excerpt: '\n\n', body: []},
324
+ fields,
325
+ {sourceLanguage: 'en', fieldLanguageKey: 'language'},
326
+ )
327
+ expect(blank.units).toHaveLength(0)
328
+ })
329
+
330
+ it('skips Portable Text that renders to markup but carries no visible text', () => {
331
+ // An "emptied" rich-text field: the paragraph/list structure survives but the
332
+ // spans hold no text, so toHTML yields <p></p> / <br/> — non-empty markup that
333
+ // must NOT become a unit.
334
+ for (const body of [
335
+ [makeBlock('')], // <p></p>
336
+ [makeBlock(' ')], // <p>&nbsp; </p>
337
+ [makeBlock('\n')], // <p><br/></p>
338
+ [{_type: 'block', _key: 'b1', style: 'normal', markDefs: [], children: []}], // <p></p>
339
+ ]) {
340
+ const result = serializeToLocjson(
341
+ {_id: 'x', _type: 'page', title: '', body},
342
+ fields,
343
+ {sourceLanguage: 'en', fieldLanguageKey: 'language'},
344
+ )
345
+ expect(result.units).toHaveLength(0)
346
+ }
347
+ })
348
+
349
+ it('keeps a field whose text is surrounded by whitespace (preserving it in source)', () => {
350
+ const result = serializeToLocjson(
351
+ {_id: 'x', _type: 'page', title: ' Hello ', body: []},
352
+ fields,
353
+ {sourceLanguage: 'en', fieldLanguageKey: 'language'},
354
+ )
355
+ expect(result.units.map((u) => u.key)).toEqual(['title'])
356
+ expect(result.units[0].source.join('')).toBe(' Hello ')
357
+ })
358
+ })
359
+
360
+ describe('round-trip', () => {
361
+ const doc = {
362
+ _id: 'abc12345-0000',
363
+ _type: 'page',
364
+ _rev: 'rev-1',
365
+ language: 'en',
366
+ title: 'About Us',
367
+ excerpt: 'Line one\nLine two',
368
+ body: [makeBlock('We build delightful things.', ['strong'])],
369
+ }
370
+ const fields = getTranslatableFields(pageType)
371
+
372
+ it('deserialize(serialize(doc)) preserves translatable field values', () => {
373
+ const locjson = serializeToLocjson(doc, fields, {sourceLanguage: 'en', fieldLanguageKey: 'language'})
374
+ // Simulate "no-op translation": copy source into target.
375
+ const translated: LocJSON = {
376
+ ...locjson,
377
+ units: locjson.units.map((u) => ({...u, target: [...u.source]})),
378
+ }
379
+
380
+ const result = deserializeFromLocjson(translated, {parseHtml, getBlockContentType})
381
+
382
+ expect(result.documentId).toBe(doc._id)
383
+ expect(result.documentType).toBe('page')
384
+ expect(result.fields.title).toBe('About Us')
385
+ expect(result.fields.excerpt).toBe('Line one\nLine two')
386
+
387
+ const body = result.fields.body as Array<Record<string, unknown>>
388
+ expect(Array.isArray(body)).toBe(true)
389
+ const spans = (body[0].children as Array<{text: string; marks: string[]}>)
390
+ expect(spans[0].text).toBe('We build delightful things.')
391
+ expect(spans[0].marks).toContain('strong')
392
+ })
393
+
394
+ it('uses target text when present (actual translation)', () => {
395
+ const locjson = serializeToLocjson(doc, fields, {sourceLanguage: 'en', fieldLanguageKey: 'language'})
396
+ const translated: LocJSON = {
397
+ ...locjson,
398
+ units: locjson.units.map((u) =>
399
+ u.key === 'title' ? {...u, target: ['À propos']} : {...u, target: [...u.source]},
400
+ ),
401
+ }
402
+ const result = deserializeFromLocjson(translated, {parseHtml, getBlockContentType})
403
+ expect(result.fields.title).toBe('À propos')
404
+ })
405
+ })
406
+
407
+ // --- Field-level localization (sanity-plugin-internationalized-array) ---------
408
+
409
+ const faqSchema = Schema.compile({
410
+ name: 'faqTest',
411
+ types: [
412
+ {name: 'internationalizedArrayStringValue', type: 'object', fields: [{name: 'value', type: 'string'}]},
413
+ {name: 'internationalizedArrayString', type: 'array', of: [{type: 'internationalizedArrayStringValue'}]},
414
+ {
415
+ name: 'internationalizedArraySimpleBlockContentValue',
416
+ type: 'object',
417
+ fields: [{name: 'value', type: 'array', of: [{type: 'block'}]}],
418
+ },
419
+ {
420
+ name: 'internationalizedArraySimpleBlockContent',
421
+ type: 'array',
422
+ of: [{type: 'internationalizedArraySimpleBlockContentValue'}],
423
+ },
424
+ {
425
+ name: 'faq',
426
+ type: 'document',
427
+ fields: [
428
+ {name: 'question', type: 'internationalizedArrayString'},
429
+ {name: 'answer', type: 'internationalizedArraySimpleBlockContent'},
430
+ {name: 'note', type: 'string'}, // plain top-level field — skipped in field mode
431
+ ],
432
+ },
433
+ ],
434
+ })
435
+ const faqType = faqSchema.get('faq')
436
+ const answerBlockType = innerValueType(faqType.fields.find((f: {name: string}) => f.name === 'answer').type)
437
+
438
+ const member = (language: string, value: unknown, type = 'internationalizedArrayStringValue') => ({
439
+ _key: `${language}-key`,
440
+ _type: type,
441
+ language,
442
+ value,
443
+ })
444
+
445
+ describe('field-level localization', () => {
446
+ it('localizationMode is field for an internationalized-array type, document otherwise', () => {
447
+ expect(localizationMode(faqType)).toBe('field')
448
+ expect(localizationMode(pageType)).toBe('document')
449
+ })
450
+
451
+ it('selects only the i18n-array fields and skips plain top-level fields (homogeneity rule)', () => {
452
+ const fields = getTranslatableFields(faqType)
453
+ expect(fields.map((f) => [f.path, f.kind, f.localization])).toEqual([
454
+ ['question', 'plain', 'field'],
455
+ ['answer', 'portableText', 'field'],
456
+ ])
457
+ expect(fields.find((f) => f.path === 'note')).toBeUndefined()
458
+ })
459
+
460
+ it('serializes the source-language member value of each field', () => {
461
+ const doc = {
462
+ _id: 'faq-1',
463
+ _type: 'faq',
464
+ question: [member('en', 'How?'), member('fr', 'Comment ?')],
465
+ answer: [
466
+ member('en', [makeBlock('Like this.')], 'internationalizedArraySimpleBlockContentValue'),
467
+ ],
468
+ }
469
+ const locjson = serializeToLocjson(doc, getTranslatableFields(faqType), {sourceLanguage: 'en', fieldLanguageKey: 'language'})
470
+ const question = locjson.units.find((u) => u.key === 'question')!
471
+ expect(question.source.join('')).toBe('How?')
472
+ const answer = locjson.units.find((u) => u.key === 'answer')!
473
+ expect(answer.properties?.['x-smartcat-format']).toBe('html')
474
+ expect(answer.source[0]).toContain('Like this.')
475
+ })
476
+
477
+ it('reads the source member by _key when fieldLanguageKey is "_key" (v4)', () => {
478
+ const doc = {
479
+ _id: 'faq-1',
480
+ _type: 'faq',
481
+ question: [
482
+ {_key: 'en', _type: 'internationalizedArrayStringValue', value: 'How?'},
483
+ {_key: 'fr', _type: 'internationalizedArrayStringValue', value: 'Comment ?'},
484
+ ],
485
+ }
486
+ const locjson = serializeToLocjson(doc, getTranslatableFields(faqType), {
487
+ sourceLanguage: 'en',
488
+ fieldLanguageKey: '_key',
489
+ })
490
+ expect(locjson.units.find((u) => u.key === 'question')!.source.join('')).toBe('How?')
491
+ })
492
+
493
+ it('does not match a _key-keyed member under the "language" key (no fallback)', () => {
494
+ const doc = {
495
+ _id: 'faq-1',
496
+ _type: 'faq',
497
+ question: [{_key: 'en', _type: 'internationalizedArrayStringValue', value: 'How?'}],
498
+ }
499
+ const locjson = serializeToLocjson(doc, getTranslatableFields(faqType), {sourceLanguage: 'en', fieldLanguageKey: 'language'})
500
+ expect(locjson.units.find((u) => u.key === 'question')).toBeUndefined()
501
+ })
502
+
503
+ it('round-trips field-level values (LocJSON carries no localization marker)', () => {
504
+ const doc = {
505
+ _id: 'faq-1',
506
+ _type: 'faq',
507
+ question: [member('en', 'How?')],
508
+ answer: [member('en', [makeBlock('Like this.')], 'internationalizedArraySimpleBlockContentValue')],
509
+ }
510
+ const locjson = serializeToLocjson(doc, getTranslatableFields(faqType), {sourceLanguage: 'en', fieldLanguageKey: 'language'})
511
+ expect(locjson.properties['x-sanity']).not.toHaveProperty('localization')
512
+
513
+ const translated: LocJSON = {
514
+ ...locjson,
515
+ units: locjson.units.map((u) =>
516
+ u.key === 'question' ? {...u, target: ['Comment ?']} : {...u, target: [...u.source]},
517
+ ),
518
+ }
519
+ const result = deserializeFromLocjson(translated, {
520
+ parseHtml,
521
+ getBlockContentType: (path) => (path === 'answer' ? answerBlockType : undefined),
522
+ })
523
+ expect(result.fields.question).toBe('Comment ?')
524
+ const body = result.fields.answer as Array<Record<string, unknown>>
525
+ expect((body[0].children as Array<{text: string}>)[0].text).toBe('Like this.')
526
+ })
527
+ })
528
+
529
+ describe('buildLocjsonFilename', () => {
530
+ it('builds "<type>/<title>-<idPrefix>.locjson"', () => {
531
+ expect(buildLocjsonFilename('page', '3f14f092-3475-42fc-a9f4-abf710d71ea8', 'About Us')).toBe(
532
+ 'page/About Us-3f14f092.locjson',
533
+ )
534
+ })
535
+
536
+ it('strips drafts. prefix and sanitizes the title', () => {
537
+ expect(buildLocjsonFilename('blogPost', 'drafts.7c7b0950-aaaa', 'Why We Love / Content')).toBe(
538
+ 'blogPost/Why We Love Content-7c7b0950.locjson',
539
+ )
540
+ })
541
+
542
+ it('falls back when title is missing', () => {
543
+ expect(buildLocjsonFilename('page', 'abc12345-0000')).toBe('page/Untitled-abc12345.locjson')
544
+ })
545
+ })
@@ -0,0 +1,41 @@
1
+ import {describe, expect, it} from 'vitest'
2
+ import {getAtPath, parsePath, setAtPath} from './paths'
3
+
4
+ describe('parsePath', () => {
5
+ it('parses fields, array-by-key, and array-by-index', () => {
6
+ expect(parsePath('benefits.items[b1].value')).toEqual([{field: 'benefits'}, {field: 'items'}, {key: 'b1'}, {field: 'value'}])
7
+ expect(parsePath('tabs[2]')).toEqual([{field: 'tabs'}, {index: 2}])
8
+ expect(parsePath('')).toEqual([])
9
+ })
10
+ })
11
+
12
+ const value = [
13
+ {_key: 'b1', key: 'protein', value: 'High in protein'},
14
+ {_key: 'b2', key: 'fibre', value: 'Rich in fibre'},
15
+ ]
16
+
17
+ describe('getAtPath', () => {
18
+ it('reads by _key and index', () => {
19
+ expect(getAtPath(value, '[b1].value')).toBe('High in protein')
20
+ expect(getAtPath(value, '[1].key')).toBe('fibre')
21
+ expect(getAtPath(value, '[missing].value')).toBeUndefined()
22
+ })
23
+ })
24
+
25
+ describe('setAtPath', () => {
26
+ it('replaces a leaf immutably, preserving siblings and _keys', () => {
27
+ const next = setAtPath(value, '[b1].value', 'PROTEINE')
28
+ expect(next[0]).toEqual({_key: 'b1', key: 'protein', value: 'PROTEINE'})
29
+ expect(next[1]).toEqual(value[1])
30
+ expect(value[0].value).toBe('High in protein') // original untouched
31
+ })
32
+
33
+ it('empty path replaces the whole value', () => {
34
+ expect(setAtPath('old', '', 'new')).toBe('new')
35
+ })
36
+
37
+ it('is a no-op when an intermediate segment is missing', () => {
38
+ const next = setAtPath(value, '[nope].value', 'x')
39
+ expect(next).toEqual(value)
40
+ })
41
+ })
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Tiny path grammar for addressing translatable leaves nested inside a field's
3
+ * value: `.field` for object properties, `[key]` for array members by `_key`,
4
+ * `[n]` for array members by index. Used to expand array/object member values
5
+ * into per-leaf LocJSON units and to overlay translations back onto a cloned
6
+ * source value.
7
+ *
8
+ * e.g. `items[b1].value`, `tabs[2]`, `` (empty = the value itself).
9
+ */
10
+ export type PathSeg = {field: string} | {key: string} | {index: number}
11
+
12
+ export function parsePath(path: string): PathSeg[] {
13
+ const segs: PathSeg[] = []
14
+ const re = /\.?([^.[\]]+)|\[([^\]]+)\]/g
15
+ let m: RegExpExecArray | null
16
+ while ((m = re.exec(path)) !== null) {
17
+ if (m[1] !== undefined) segs.push({field: m[1]})
18
+ else if (/^\d+$/.test(m[2])) segs.push({index: Number(m[2])})
19
+ else segs.push({key: m[2]})
20
+ }
21
+ return segs
22
+ }
23
+
24
+ /** Append a segment to a base path string, producing a valid path. */
25
+ export function joinPath(base: string, seg: PathSeg): string {
26
+ if ('field' in seg) return base ? `${base}.${seg.field}` : seg.field
27
+ if ('index' in seg) return `${base}[${seg.index}]`
28
+ return `${base}[${seg.key}]`
29
+ }
30
+
31
+ function child(container: unknown, seg: PathSeg): unknown {
32
+ if (container == null || typeof container !== 'object') return undefined
33
+ if ('field' in seg) return (container as Record<string, unknown>)[seg.field]
34
+ if ('index' in seg) return Array.isArray(container) ? container[seg.index] : undefined
35
+ return Array.isArray(container)
36
+ ? container.find((m) => m && typeof m === 'object' && (m as Record<string, unknown>)._key === seg.key)
37
+ : undefined
38
+ }
39
+
40
+ /** Read the value at a path from a root value (undefined if any segment misses). */
41
+ export function getAtPath(root: unknown, path: string): unknown {
42
+ let cur = root
43
+ for (const seg of parsePath(path)) {
44
+ cur = child(cur, seg)
45
+ if (cur === undefined) return undefined
46
+ }
47
+ return cur
48
+ }
49
+
50
+ /**
51
+ * Return a deep clone of `root` with the leaf at `path` replaced by `value`.
52
+ * An empty path replaces the whole value. Missing intermediate segments are a
53
+ * no-op (the clone is returned unchanged), so a template that lacks a leaf can't
54
+ * be corrupted.
55
+ */
56
+ export function setAtPath<T>(root: T, path: string, value: unknown): T {
57
+ const segs = parsePath(path)
58
+ if (segs.length === 0) return value as T
59
+ const clone = structuredClone(root)
60
+ let cur: unknown = clone
61
+ for (let i = 0; i < segs.length - 1; i++) {
62
+ cur = child(cur, segs[i])
63
+ if (cur == null || typeof cur !== 'object') return clone
64
+ }
65
+ const last = segs[segs.length - 1]
66
+ if ('field' in last) (cur as Record<string, unknown>)[last.field] = value
67
+ else if ('index' in last && Array.isArray(cur)) cur[last.index] = value
68
+ else if ('key' in last && Array.isArray(cur)) {
69
+ const idx = cur.findIndex((m) => m && typeof m === 'object' && (m as Record<string, unknown>)._key === last.key)
70
+ if (idx >= 0) cur[idx] = value
71
+ }
72
+ return clone
73
+ }