@smartcat/sanity-plugin 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +294 -0
- package/dist/_chunks-cjs/constants.cjs +13 -0
- package/dist/_chunks-cjs/constants.cjs.map +1 -0
- package/dist/_chunks-cjs/index.cjs +174 -0
- package/dist/_chunks-cjs/index.cjs.map +1 -0
- package/dist/_chunks-cjs/index2.cjs +338 -0
- package/dist/_chunks-cjs/index2.cjs.map +1 -0
- package/dist/_chunks-cjs/workflow.cjs +16 -0
- package/dist/_chunks-cjs/workflow.cjs.map +1 -0
- package/dist/_chunks-es/constants.js +14 -0
- package/dist/_chunks-es/constants.js.map +1 -0
- package/dist/_chunks-es/index.js +175 -0
- package/dist/_chunks-es/index.js.map +1 -0
- package/dist/_chunks-es/index2.js +340 -0
- package/dist/_chunks-es/index2.js.map +1 -0
- package/dist/_chunks-es/workflow.js +17 -0
- package/dist/_chunks-es/workflow.js.map +1 -0
- package/dist/export.cjs +160 -0
- package/dist/export.cjs.map +1 -0
- package/dist/export.d.cts +162 -0
- package/dist/export.d.ts +162 -0
- package/dist/export.js +162 -0
- package/dist/export.js.map +1 -0
- package/dist/import.cjs +169 -0
- package/dist/import.cjs.map +1 -0
- package/dist/import.d.cts +155 -0
- package/dist/import.d.ts +155 -0
- package/dist/import.js +169 -0
- package/dist/import.js.map +1 -0
- package/dist/index.cjs +2172 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +122 -0
- package/dist/index.d.ts +122 -0
- package/dist/index.js +2179 -0
- package/dist/index.js.map +1 -0
- package/dist/locjson.cjs +12 -0
- package/dist/locjson.cjs.map +1 -0
- package/dist/locjson.d.cts +291 -0
- package/dist/locjson.d.ts +291 -0
- package/dist/locjson.js +12 -0
- package/dist/locjson.js.map +1 -0
- package/dist/progress.cjs +16 -0
- package/dist/progress.cjs.map +1 -0
- package/dist/progress.d.cts +277 -0
- package/dist/progress.d.ts +277 -0
- package/dist/progress.js +16 -0
- package/dist/progress.js.map +1 -0
- package/dist/smartcat.cjs +287 -0
- package/dist/smartcat.cjs.map +1 -0
- package/dist/smartcat.d.cts +234 -0
- package/dist/smartcat.d.ts +234 -0
- package/dist/smartcat.js +287 -0
- package/dist/smartcat.js.map +1 -0
- package/dist/templates.cjs +12 -0
- package/dist/templates.cjs.map +1 -0
- package/dist/templates.d.cts +52 -0
- package/dist/templates.d.ts +52 -0
- package/dist/templates.js +12 -0
- package/dist/templates.js.map +1 -0
- package/package.json +101 -15
- package/src/actions/AddToProjectAction.tsx +274 -0
- package/src/export/export.test.ts +537 -0
- package/src/export/index.ts +393 -0
- package/src/form/TranslationStatusInput.tsx +212 -0
- package/src/import/import.test.ts +346 -0
- package/src/import/index.ts +418 -0
- package/src/index.ts +63 -0
- package/src/lib/constants.ts +23 -0
- package/src/lib/documentTitle.test.ts +43 -0
- package/src/lib/documentTitle.ts +30 -0
- package/src/lib/languageMap.test.ts +56 -0
- package/src/lib/languageMap.ts +71 -0
- package/src/lib/linkedDocuments.test.ts +56 -0
- package/src/lib/linkedDocuments.ts +100 -0
- package/src/lib/locjson/deserialize.ts +60 -0
- package/src/lib/locjson/fields.ts +355 -0
- package/src/lib/locjson/filename.ts +34 -0
- package/src/lib/locjson/index.ts +10 -0
- package/src/lib/locjson/locjson.test.ts +615 -0
- package/src/lib/locjson/paths.test.ts +41 -0
- package/src/lib/locjson/paths.ts +73 -0
- package/src/lib/locjson/portableText.ts +157 -0
- package/src/lib/locjson/serialize.ts +124 -0
- package/src/lib/locjson/types.ts +106 -0
- package/src/lib/log.ts +33 -0
- package/src/lib/projectItems.ts +31 -0
- package/src/lib/resolveConfig.test.ts +62 -0
- package/src/lib/resolveConfig.ts +54 -0
- package/src/lib/workflow.test.ts +37 -0
- package/src/lib/workflow.ts +48 -0
- package/src/lib/zip.fixtures.ts +75 -0
- package/src/lib/zip.test.ts +110 -0
- package/src/lib/zip.ts +164 -0
- package/src/progress/core.ts +409 -0
- package/src/progress/index.ts +7 -0
- package/src/progress/progress.test.ts +290 -0
- package/src/schema/settings.ts +55 -0
- package/src/schema/translatableOptions.ts +25 -0
- package/src/schema/translationProject.ts +268 -0
- package/src/smartcat/client.test.ts +222 -0
- package/src/smartcat/client.ts +351 -0
- package/src/smartcat/index.ts +10 -0
- package/src/smartcat/types.ts +99 -0
- package/src/templates/core.ts +55 -0
- package/src/templates/index.ts +5 -0
- package/src/templates/templates.test.ts +50 -0
- package/src/tool/Dashboard.tsx +46 -0
- package/src/tool/ItemProgress.tsx +176 -0
- package/src/tool/LogPanel.tsx +207 -0
- package/src/tool/ProjectView.tsx +1004 -0
- package/src/tool/ProjectsView.tsx +208 -0
- package/src/tool/StatusBadge.tsx +33 -0
- package/src/tool/StudioInit.tsx +42 -0
- package/src/tool/WorkflowSelect.tsx +41 -0
- package/src/tool/data.ts +86 -0
- package/src/tool/index.ts +19 -0
- package/src/tool/processing.test.ts +698 -0
- package/src/tool/processing.ts +839 -0
- package/src/tool/useTemplates.ts +84 -0
- package/src/tool/useWorkflowSelection.ts +65 -0
- package/src/types.ts +77 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from './types'
|
|
2
|
+
export {getTranslatableFields, inferTranslatableFields, orderFieldsBy} from './fields'
|
|
3
|
+
export {buildLocjsonFilename} from './filename'
|
|
4
|
+
export {serializeToLocjson, type SerializeOptions, type SerializableDocument} from './serialize'
|
|
5
|
+
export {
|
|
6
|
+
deserializeFromLocjson,
|
|
7
|
+
type DeserializeOptions,
|
|
8
|
+
type DeserializedDocument,
|
|
9
|
+
} from './deserialize'
|
|
10
|
+
export {portableTextToHtml, htmlToPortableText, type PortableTextBlock, type ParseHtml} from './portableText'
|
|
@@ -0,0 +1,615 @@
|
|
|
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
|
+
// @sanity/table shape: rows of plain-string cells, embedded in Portable Text.
|
|
26
|
+
{name: 'tableRow', type: 'object', fields: [{name: 'cells', type: 'array', of: [{type: 'string'}]}]},
|
|
27
|
+
{name: 'table', type: 'object', fields: [{name: 'rows', type: 'array', of: [{type: 'tableRow'}]}]},
|
|
28
|
+
{
|
|
29
|
+
name: 'page',
|
|
30
|
+
type: 'document',
|
|
31
|
+
fields: [
|
|
32
|
+
{name: 'language', type: 'string', options: {documentInternationalization: {exclude: true}}},
|
|
33
|
+
{name: 'title', type: 'string'},
|
|
34
|
+
{name: 'excerpt', type: 'text'},
|
|
35
|
+
{name: 'views', type: 'number'},
|
|
36
|
+
{name: 'featured', type: 'boolean'},
|
|
37
|
+
{name: 'tags', type: 'array', of: [{type: 'string'}]},
|
|
38
|
+
{name: 'seo', type: 'object', fields: [{name: 'metaTitle', type: 'string'}]},
|
|
39
|
+
{name: 'body', type: 'array', of: [{type: 'block'}, {type: 'table'}]},
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const pageType = schema.get('page')
|
|
46
|
+
const bodyType = pageType.fields.find((f: {name: string}) => f.name === 'body').type
|
|
47
|
+
|
|
48
|
+
const parseHtml = (html: string) => new JSDOM(html).window.document
|
|
49
|
+
const getBlockContentType = () => bodyType
|
|
50
|
+
|
|
51
|
+
function makeBlock(text: string, marks: string[] = []) {
|
|
52
|
+
return {
|
|
53
|
+
_type: 'block',
|
|
54
|
+
_key: 'b1',
|
|
55
|
+
style: 'normal',
|
|
56
|
+
markDefs: [],
|
|
57
|
+
children: [{_type: 'span', _key: 's1', text, marks}],
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function makeTable(rows: string[][]) {
|
|
62
|
+
return {
|
|
63
|
+
_type: 'table',
|
|
64
|
+
_key: 't1',
|
|
65
|
+
rows: rows.map((cells, i) => ({_type: 'tableRow', _key: `row${i}`, cells})),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('getTranslatableFields', () => {
|
|
70
|
+
it('selects string/text/portable-text and excludes the rest', () => {
|
|
71
|
+
const fields = getTranslatableFields(pageType)
|
|
72
|
+
const byPath = Object.fromEntries(fields.map((f) => [f.path, f.kind]))
|
|
73
|
+
|
|
74
|
+
expect(byPath).toEqual({title: 'plain', excerpt: 'plain', body: 'portableText'})
|
|
75
|
+
// excluded: the language field (marked via document-internationalization's
|
|
76
|
+
// field option), primitives, plain array, object
|
|
77
|
+
expect(byPath.language).toBeUndefined()
|
|
78
|
+
expect(byPath.views).toBeUndefined()
|
|
79
|
+
expect(byPath.featured).toBeUndefined()
|
|
80
|
+
expect(byPath.tags).toBeUndefined()
|
|
81
|
+
expect(byPath.seo).toBeUndefined()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('excludes fields marked non-translatable by document-internationalization', () => {
|
|
85
|
+
const s = Schema.compile({
|
|
86
|
+
name: 'opt',
|
|
87
|
+
types: [
|
|
88
|
+
{
|
|
89
|
+
name: 'thing',
|
|
90
|
+
type: 'document',
|
|
91
|
+
fields: [
|
|
92
|
+
{name: 'title', type: 'string'},
|
|
93
|
+
{name: 'sku', type: 'string', options: {documentInternationalization: {exclude: true}}},
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
})
|
|
98
|
+
const fields = getTranslatableFields(s.get('thing'))
|
|
99
|
+
expect(fields.map((f) => f.path)).toEqual(['title']) // sku skipped via the i18n option
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('never selects fields on Sanity system types (e.g. sanity.imageAsset)', () => {
|
|
103
|
+
// Built-in asset docs are referenced by image/file fields and carry plain
|
|
104
|
+
// string fields (originalFilename, path, url) that must never be translated.
|
|
105
|
+
const imageAsset = {
|
|
106
|
+
jsonType: 'object',
|
|
107
|
+
name: 'sanity.imageAsset',
|
|
108
|
+
fields: [
|
|
109
|
+
{name: 'originalFilename', type: {jsonType: 'string', name: 'string'}},
|
|
110
|
+
{name: 'path', type: {jsonType: 'string', name: 'string'}},
|
|
111
|
+
{name: 'title', type: {jsonType: 'string', name: 'string'}},
|
|
112
|
+
],
|
|
113
|
+
}
|
|
114
|
+
expect(getTranslatableFields(imageAsset)).toEqual([])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('honors a custom exclude list (e.g. the configured language field)', () => {
|
|
118
|
+
const fields = getTranslatableFields(pageType, {exclude: ['title']})
|
|
119
|
+
expect(fields.map((f) => f.path)).toEqual(['excerpt', 'body'])
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('opts custom-named types into translation via smartcatTranslation.include', () => {
|
|
123
|
+
const s = Schema.compile({
|
|
124
|
+
name: 'inc',
|
|
125
|
+
types: [
|
|
126
|
+
{name: 'headingString', type: 'string', options: {smartcatTranslation: {include: true}}},
|
|
127
|
+
{name: 'richText', type: 'array', of: [{type: 'block'}], options: {smartcatTranslation: {include: true}}},
|
|
128
|
+
{
|
|
129
|
+
name: 'thing',
|
|
130
|
+
type: 'document',
|
|
131
|
+
fields: [
|
|
132
|
+
{name: 'heading', type: 'headingString'}, // custom string alias → plain
|
|
133
|
+
{name: 'tagline', type: 'string', options: {smartcatTranslation: {include: true}}}, // per-field
|
|
134
|
+
{name: 'content', type: 'richText'}, // custom PT alias → portableText
|
|
135
|
+
{name: 'plain', type: 'string'}, // still works via the default allowlist
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
})
|
|
140
|
+
const fields = getTranslatableFields(s.get('thing'))
|
|
141
|
+
expect(Object.fromEntries(fields.map((f) => [f.path, f.kind]))).toEqual({
|
|
142
|
+
heading: 'plain',
|
|
143
|
+
tagline: 'plain',
|
|
144
|
+
content: 'portableText',
|
|
145
|
+
plain: 'plain',
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('lets include override a document-internationalization exclude', () => {
|
|
150
|
+
const s = Schema.compile({
|
|
151
|
+
name: 'override',
|
|
152
|
+
types: [
|
|
153
|
+
{
|
|
154
|
+
name: 'thing',
|
|
155
|
+
type: 'document',
|
|
156
|
+
fields: [
|
|
157
|
+
{
|
|
158
|
+
name: 'note',
|
|
159
|
+
type: 'string',
|
|
160
|
+
options: {documentInternationalization: {exclude: true}, smartcatTranslation: {include: true}},
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
})
|
|
166
|
+
expect(getTranslatableFields(s.get('thing')).map((f) => f.path)).toEqual(['note'])
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('warns and skips when an included field has no translatable representation', () => {
|
|
170
|
+
const s = Schema.compile({
|
|
171
|
+
name: 'badinc',
|
|
172
|
+
types: [
|
|
173
|
+
{
|
|
174
|
+
name: 'thing',
|
|
175
|
+
type: 'document',
|
|
176
|
+
fields: [{name: 'count', type: 'number', options: {smartcatTranslation: {include: true}}}],
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
})
|
|
180
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
181
|
+
const fields = getTranslatableFields(s.get('thing'))
|
|
182
|
+
expect(fields).toEqual([])
|
|
183
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('count'))
|
|
184
|
+
warn.mockRestore()
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
describe('portableTextToHtml', () => {
|
|
189
|
+
it('renders unknown/custom block types as nothing, not a placeholder', () => {
|
|
190
|
+
// A custom block with no to-html component. The default behavior emits a
|
|
191
|
+
// hidden `<div style="display:none">Unknown block type "…"</div>`, whose
|
|
192
|
+
// English text would be translated into every locale and which Smartcat
|
|
193
|
+
// rejects as unparseable HTML. It must be dropped entirely instead.
|
|
194
|
+
const blocks = [
|
|
195
|
+
makeBlock('Delivery info'),
|
|
196
|
+
{_type: 'heroBanner', _key: 'h1', heading: 'Save 20%'},
|
|
197
|
+
]
|
|
198
|
+
const html = portableTextToHtml(blocks)
|
|
199
|
+
expect(html).not.toContain('Unknown block type')
|
|
200
|
+
expect(html).not.toContain('display:none')
|
|
201
|
+
expect(html).toContain('Delivery info')
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('renders a table block to an HTML <table>, escaping cell text', () => {
|
|
205
|
+
const html = portableTextToHtml([makeTable([['Region', 'Price'], ['EU', '<10 & up']])])
|
|
206
|
+
expect(html).toContain('<table><tbody>')
|
|
207
|
+
expect(html).toContain('<tr><td>Region</td><td>Price</td></tr>')
|
|
208
|
+
// cells are HTML-escaped so markup-looking text survives Smartcat's parser
|
|
209
|
+
expect(html).toContain('<td><10 & up</td>')
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
describe('findUnserializableBlockTypes', () => {
|
|
214
|
+
const table = makeTable([['A', 'B']])
|
|
215
|
+
|
|
216
|
+
it('flags custom top-level block types', () => {
|
|
217
|
+
const embed = {_type: 'heroBanner', _key: 'h1', heading: 'x'}
|
|
218
|
+
expect(findUnserializableBlockTypes([makeBlock('text'), embed])).toEqual(['heroBanner'])
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('does NOT flag a table block (it round-trips through HTML)', () => {
|
|
222
|
+
expect(findUnserializableBlockTypes([makeBlock('text'), table])).toEqual([])
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('flags custom inline children (non-span)', () => {
|
|
226
|
+
const withInline = {
|
|
227
|
+
_type: 'block',
|
|
228
|
+
_key: 'b1',
|
|
229
|
+
children: [
|
|
230
|
+
{_type: 'span', _key: 's1', text: 'see '},
|
|
231
|
+
{_type: 'stockTicker', _key: 'i1', symbol: 'HUEL'},
|
|
232
|
+
],
|
|
233
|
+
}
|
|
234
|
+
expect(findUnserializableBlockTypes([withInline])).toEqual(['stockTicker'])
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('returns empty for standard blocks/spans', () => {
|
|
238
|
+
expect(findUnserializableBlockTypes([makeBlock('a'), makeBlock('b')])).toEqual([])
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
describe('inferTranslatableFields', () => {
|
|
243
|
+
it('never selects fields on Sanity system types (e.g. sanity.imageAsset)', () => {
|
|
244
|
+
const asset = {
|
|
245
|
+
_type: 'sanity.imageAsset',
|
|
246
|
+
originalFilename: '01-Front Shot - es-ES.jpg',
|
|
247
|
+
path: 'images/xyz/production/abc-1468x1468.jpg',
|
|
248
|
+
url: 'https://cdn.sanity.io/images/xyz/production/abc-1468x1468.jpg',
|
|
249
|
+
}
|
|
250
|
+
expect(inferTranslatableFields(asset)).toEqual([])
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
describe('orderFieldsBy', () => {
|
|
255
|
+
it('reorders data-inferred fields to match schema order (title before body)', () => {
|
|
256
|
+
// Simulate Content Lake returning keys alphabetically (body before title).
|
|
257
|
+
const inferred = inferTranslatableFields({body: [makeBlock('x')], title: 'T'})
|
|
258
|
+
expect(inferred.map((f) => f.path)).toEqual(['body', 'title'])
|
|
259
|
+
|
|
260
|
+
const ordered = orderFieldsBy(inferred, ['title', 'body'])
|
|
261
|
+
expect(ordered.map((f) => f.path)).toEqual(['title', 'body'])
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('puts unlisted fields last, preserving their order', () => {
|
|
265
|
+
const fields = inferTranslatableFields({title: 'T', body: [makeBlock('x')], summary: 'S'})
|
|
266
|
+
const ordered = orderFieldsBy(fields, ['title'])
|
|
267
|
+
expect(ordered[0].path).toBe('title')
|
|
268
|
+
expect(ordered.slice(1).map((f) => f.path).sort()).toEqual(['body', 'summary'])
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('returns fields unchanged when no order is given', () => {
|
|
272
|
+
const fields = inferTranslatableFields({title: 'T'})
|
|
273
|
+
expect(orderFieldsBy(fields, undefined)).toBe(fields)
|
|
274
|
+
})
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
describe('serializeToLocjson', () => {
|
|
278
|
+
const doc = {
|
|
279
|
+
_id: '3f14f092-3475-42fc-a9f4-abf710d71ea8',
|
|
280
|
+
_type: 'page',
|
|
281
|
+
_rev: 'rev-1',
|
|
282
|
+
language: 'en',
|
|
283
|
+
title: 'About Us',
|
|
284
|
+
excerpt: 'Line one\nLine two',
|
|
285
|
+
body: [makeBlock('We build delightful things.')],
|
|
286
|
+
}
|
|
287
|
+
const fields = getTranslatableFields(pageType)
|
|
288
|
+
const locjson = serializeToLocjson(doc, fields, {sourceLanguage: 'en', fieldLanguageKey: 'language'})
|
|
289
|
+
|
|
290
|
+
it('writes file-level x-sanity metadata', () => {
|
|
291
|
+
expect(locjson.properties['x-sanity']).toEqual({
|
|
292
|
+
documentId: doc._id,
|
|
293
|
+
documentType: 'page',
|
|
294
|
+
rev: 'rev-1',
|
|
295
|
+
sourceLanguage: 'en',
|
|
296
|
+
})
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('emits one unit per non-empty translatable field, keyed by field path', () => {
|
|
300
|
+
expect(locjson.units.map((u) => u.key)).toEqual(['title', 'excerpt', 'body'])
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('splits plain text into line segments (keeping newlines) and marks html units', () => {
|
|
304
|
+
const excerpt = locjson.units.find((u) => u.key === 'excerpt')!
|
|
305
|
+
expect(excerpt.source).toEqual(['Line one\n', 'Line two'])
|
|
306
|
+
expect(excerpt.source.join('')).toBe('Line one\nLine two')
|
|
307
|
+
expect(excerpt.properties?.['x-smartcat-format']).toBeUndefined()
|
|
308
|
+
|
|
309
|
+
const body = locjson.units.find((u) => u.key === 'body')!
|
|
310
|
+
expect(body.properties?.['x-smartcat-format']).toBe('html')
|
|
311
|
+
expect(body.source[0]).toContain('We build delightful things.')
|
|
312
|
+
expect(body.properties?.['x-sanity']).toEqual({fieldPath: 'body'})
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('skips a Portable Text field containing an unserializable block, and reports it', () => {
|
|
316
|
+
const skipped: {fieldPath: string; types: string[]}[] = []
|
|
317
|
+
const result = serializeToLocjson(
|
|
318
|
+
{
|
|
319
|
+
_id: 'x',
|
|
320
|
+
_type: 'page',
|
|
321
|
+
title: 'Delivery',
|
|
322
|
+
body: [makeBlock('How much is delivery?'), {_type: 'heroBanner', _key: 'h1', heading: 'x'}],
|
|
323
|
+
},
|
|
324
|
+
fields,
|
|
325
|
+
{
|
|
326
|
+
sourceLanguage: 'en',
|
|
327
|
+
fieldLanguageKey: 'language',
|
|
328
|
+
onSkippedField: ({fieldPath, types}) => skipped.push({fieldPath, types}),
|
|
329
|
+
},
|
|
330
|
+
)
|
|
331
|
+
// title still serialized; body left out entirely (not partially)
|
|
332
|
+
expect(result.units.map((u) => u.key)).toEqual(['title'])
|
|
333
|
+
expect(skipped).toEqual([{fieldPath: 'body', types: ['heroBanner']}])
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it('serializes a body containing a table (renders it into the HTML unit)', () => {
|
|
337
|
+
const result = serializeToLocjson(
|
|
338
|
+
{_id: 'x', _type: 'page', title: '', body: [makeTable([['Region', 'Price']])]},
|
|
339
|
+
fields,
|
|
340
|
+
{sourceLanguage: 'en', fieldLanguageKey: 'language'},
|
|
341
|
+
)
|
|
342
|
+
const body = result.units.find((u) => u.key === 'body')!
|
|
343
|
+
expect(body.properties?.['x-smartcat-format']).toBe('html')
|
|
344
|
+
expect(body.source[0]).toContain('<td>Region</td>')
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('skips empty fields', () => {
|
|
348
|
+
const empty = serializeToLocjson(
|
|
349
|
+
{_id: 'x', _type: 'page', title: '', body: []},
|
|
350
|
+
fields,
|
|
351
|
+
{sourceLanguage: 'en', fieldLanguageKey: 'language'},
|
|
352
|
+
)
|
|
353
|
+
expect(empty.units).toHaveLength(0)
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it('skips whitespace-only plain fields', () => {
|
|
357
|
+
const blank = serializeToLocjson(
|
|
358
|
+
{_id: 'x', _type: 'page', title: ' ', excerpt: '\n\n', body: []},
|
|
359
|
+
fields,
|
|
360
|
+
{sourceLanguage: 'en', fieldLanguageKey: 'language'},
|
|
361
|
+
)
|
|
362
|
+
expect(blank.units).toHaveLength(0)
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
it('skips Portable Text that renders to markup but carries no visible text', () => {
|
|
366
|
+
// An "emptied" rich-text field: the paragraph/list structure survives but the
|
|
367
|
+
// spans hold no text, so toHTML yields <p></p> / <br/> — non-empty markup that
|
|
368
|
+
// must NOT become a unit.
|
|
369
|
+
for (const body of [
|
|
370
|
+
[makeBlock('')], // <p></p>
|
|
371
|
+
[makeBlock(' ')], // <p> </p>
|
|
372
|
+
[makeBlock('\n')], // <p><br/></p>
|
|
373
|
+
[{_type: 'block', _key: 'b1', style: 'normal', markDefs: [], children: []}], // <p></p>
|
|
374
|
+
]) {
|
|
375
|
+
const result = serializeToLocjson(
|
|
376
|
+
{_id: 'x', _type: 'page', title: '', body},
|
|
377
|
+
fields,
|
|
378
|
+
{sourceLanguage: 'en', fieldLanguageKey: 'language'},
|
|
379
|
+
)
|
|
380
|
+
expect(result.units).toHaveLength(0)
|
|
381
|
+
}
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
it('keeps a field whose text is surrounded by whitespace (preserving it in source)', () => {
|
|
385
|
+
const result = serializeToLocjson(
|
|
386
|
+
{_id: 'x', _type: 'page', title: ' Hello ', body: []},
|
|
387
|
+
fields,
|
|
388
|
+
{sourceLanguage: 'en', fieldLanguageKey: 'language'},
|
|
389
|
+
)
|
|
390
|
+
expect(result.units.map((u) => u.key)).toEqual(['title'])
|
|
391
|
+
expect(result.units[0].source.join('')).toBe(' Hello ')
|
|
392
|
+
})
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
describe('round-trip', () => {
|
|
396
|
+
const doc = {
|
|
397
|
+
_id: 'abc12345-0000',
|
|
398
|
+
_type: 'page',
|
|
399
|
+
_rev: 'rev-1',
|
|
400
|
+
language: 'en',
|
|
401
|
+
title: 'About Us',
|
|
402
|
+
excerpt: 'Line one\nLine two',
|
|
403
|
+
body: [makeBlock('We build delightful things.', ['strong'])],
|
|
404
|
+
}
|
|
405
|
+
const fields = getTranslatableFields(pageType)
|
|
406
|
+
|
|
407
|
+
it('deserialize(serialize(doc)) preserves translatable field values', () => {
|
|
408
|
+
const locjson = serializeToLocjson(doc, fields, {sourceLanguage: 'en', fieldLanguageKey: 'language'})
|
|
409
|
+
// Simulate "no-op translation": copy source into target.
|
|
410
|
+
const translated: LocJSON = {
|
|
411
|
+
...locjson,
|
|
412
|
+
units: locjson.units.map((u) => ({...u, target: [...u.source]})),
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const result = deserializeFromLocjson(translated, {parseHtml, getBlockContentType})
|
|
416
|
+
|
|
417
|
+
expect(result.documentId).toBe(doc._id)
|
|
418
|
+
expect(result.documentType).toBe('page')
|
|
419
|
+
expect(result.fields.title).toBe('About Us')
|
|
420
|
+
expect(result.fields.excerpt).toBe('Line one\nLine two')
|
|
421
|
+
|
|
422
|
+
const body = result.fields.body as Array<Record<string, unknown>>
|
|
423
|
+
expect(Array.isArray(body)).toBe(true)
|
|
424
|
+
const spans = (body[0].children as Array<{text: string; marks: string[]}>)
|
|
425
|
+
expect(spans[0].text).toBe('We build delightful things.')
|
|
426
|
+
expect(spans[0].marks).toContain('strong')
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
it('uses target text when present (actual translation)', () => {
|
|
430
|
+
const locjson = serializeToLocjson(doc, fields, {sourceLanguage: 'en', fieldLanguageKey: 'language'})
|
|
431
|
+
const translated: LocJSON = {
|
|
432
|
+
...locjson,
|
|
433
|
+
units: locjson.units.map((u) =>
|
|
434
|
+
u.key === 'title' ? {...u, target: ['À propos']} : {...u, target: [...u.source]},
|
|
435
|
+
),
|
|
436
|
+
}
|
|
437
|
+
const result = deserializeFromLocjson(translated, {parseHtml, getBlockContentType})
|
|
438
|
+
expect(result.fields.title).toBe('À propos')
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
it('round-trips a table block, translating its cells', () => {
|
|
442
|
+
const tableDoc = {
|
|
443
|
+
_id: 'abc12345-0000',
|
|
444
|
+
_type: 'page',
|
|
445
|
+
title: 'Pricing',
|
|
446
|
+
body: [makeBlock('Our plans:'), makeTable([['Region', 'Price'], ['EU', '€10']])],
|
|
447
|
+
}
|
|
448
|
+
const locjson = serializeToLocjson(tableDoc, fields, {sourceLanguage: 'en', fieldLanguageKey: 'language'})
|
|
449
|
+
// Translate the header cells by rewriting the HTML unit's target.
|
|
450
|
+
const translated: LocJSON = {
|
|
451
|
+
...locjson,
|
|
452
|
+
units: locjson.units.map((u) =>
|
|
453
|
+
u.key === 'body'
|
|
454
|
+
? {...u, target: [u.source[0].replace('Region', 'Région').replace('Price', 'Prix')]}
|
|
455
|
+
: {...u, target: [...u.source]},
|
|
456
|
+
),
|
|
457
|
+
}
|
|
458
|
+
const result = deserializeFromLocjson(translated, {parseHtml, getBlockContentType})
|
|
459
|
+
const body = result.fields.body as Array<Record<string, unknown>>
|
|
460
|
+
const table = body.find((b) => b._type === 'table')!
|
|
461
|
+
const rows = table.rows as Array<{_type: string; _key: string; cells: string[]}>
|
|
462
|
+
expect(rows.map((r) => r.cells)).toEqual([
|
|
463
|
+
['Région', 'Prix'],
|
|
464
|
+
['EU', '€10'],
|
|
465
|
+
])
|
|
466
|
+
// rows keep the tableRow type and carry a _key (required for array members)
|
|
467
|
+
expect(rows.every((r) => r._type === 'tableRow' && typeof r._key === 'string')).toBe(true)
|
|
468
|
+
})
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
// --- Field-level localization (sanity-plugin-internationalized-array) ---------
|
|
472
|
+
|
|
473
|
+
const faqSchema = Schema.compile({
|
|
474
|
+
name: 'faqTest',
|
|
475
|
+
types: [
|
|
476
|
+
{name: 'internationalizedArrayStringValue', type: 'object', fields: [{name: 'value', type: 'string'}]},
|
|
477
|
+
{name: 'internationalizedArrayString', type: 'array', of: [{type: 'internationalizedArrayStringValue'}]},
|
|
478
|
+
{
|
|
479
|
+
name: 'internationalizedArraySimpleBlockContentValue',
|
|
480
|
+
type: 'object',
|
|
481
|
+
fields: [{name: 'value', type: 'array', of: [{type: 'block'}]}],
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
name: 'internationalizedArraySimpleBlockContent',
|
|
485
|
+
type: 'array',
|
|
486
|
+
of: [{type: 'internationalizedArraySimpleBlockContentValue'}],
|
|
487
|
+
},
|
|
488
|
+
{
|
|
489
|
+
name: 'faq',
|
|
490
|
+
type: 'document',
|
|
491
|
+
fields: [
|
|
492
|
+
{name: 'question', type: 'internationalizedArrayString'},
|
|
493
|
+
{name: 'answer', type: 'internationalizedArraySimpleBlockContent'},
|
|
494
|
+
{name: 'note', type: 'string'}, // plain top-level field — skipped in field mode
|
|
495
|
+
],
|
|
496
|
+
},
|
|
497
|
+
],
|
|
498
|
+
})
|
|
499
|
+
const faqType = faqSchema.get('faq')
|
|
500
|
+
const answerBlockType = innerValueType(faqType.fields.find((f: {name: string}) => f.name === 'answer').type)
|
|
501
|
+
|
|
502
|
+
const member = (language: string, value: unknown, type = 'internationalizedArrayStringValue') => ({
|
|
503
|
+
_key: `${language}-key`,
|
|
504
|
+
_type: type,
|
|
505
|
+
language,
|
|
506
|
+
value,
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
describe('field-level localization', () => {
|
|
510
|
+
it('localizationMode is field for an internationalized-array type, document otherwise', () => {
|
|
511
|
+
expect(localizationMode(faqType)).toBe('field')
|
|
512
|
+
expect(localizationMode(pageType)).toBe('document')
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
it('selects only the i18n-array fields and skips plain top-level fields (homogeneity rule)', () => {
|
|
516
|
+
const fields = getTranslatableFields(faqType)
|
|
517
|
+
expect(fields.map((f) => [f.path, f.kind, f.localization])).toEqual([
|
|
518
|
+
['question', 'plain', 'field'],
|
|
519
|
+
['answer', 'portableText', 'field'],
|
|
520
|
+
])
|
|
521
|
+
expect(fields.find((f) => f.path === 'note')).toBeUndefined()
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
it('serializes the source-language member value of each field', () => {
|
|
525
|
+
const doc = {
|
|
526
|
+
_id: 'faq-1',
|
|
527
|
+
_type: 'faq',
|
|
528
|
+
question: [member('en', 'How?'), member('fr', 'Comment ?')],
|
|
529
|
+
answer: [
|
|
530
|
+
member('en', [makeBlock('Like this.')], 'internationalizedArraySimpleBlockContentValue'),
|
|
531
|
+
],
|
|
532
|
+
}
|
|
533
|
+
const locjson = serializeToLocjson(doc, getTranslatableFields(faqType), {sourceLanguage: 'en', fieldLanguageKey: 'language'})
|
|
534
|
+
const question = locjson.units.find((u) => u.key === 'question')!
|
|
535
|
+
expect(question.source.join('')).toBe('How?')
|
|
536
|
+
const answer = locjson.units.find((u) => u.key === 'answer')!
|
|
537
|
+
expect(answer.properties?.['x-smartcat-format']).toBe('html')
|
|
538
|
+
expect(answer.source[0]).toContain('Like this.')
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
it('reads the source member by _key when fieldLanguageKey is "_key" (v4)', () => {
|
|
542
|
+
const doc = {
|
|
543
|
+
_id: 'faq-1',
|
|
544
|
+
_type: 'faq',
|
|
545
|
+
question: [
|
|
546
|
+
{_key: 'en', _type: 'internationalizedArrayStringValue', value: 'How?'},
|
|
547
|
+
{_key: 'fr', _type: 'internationalizedArrayStringValue', value: 'Comment ?'},
|
|
548
|
+
],
|
|
549
|
+
}
|
|
550
|
+
const locjson = serializeToLocjson(doc, getTranslatableFields(faqType), {
|
|
551
|
+
sourceLanguage: 'en',
|
|
552
|
+
fieldLanguageKey: '_key',
|
|
553
|
+
})
|
|
554
|
+
expect(locjson.units.find((u) => u.key === 'question')!.source.join('')).toBe('How?')
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
it('does not match a _key-keyed member under the "language" key (no fallback)', () => {
|
|
558
|
+
const doc = {
|
|
559
|
+
_id: 'faq-1',
|
|
560
|
+
_type: 'faq',
|
|
561
|
+
question: [{_key: 'en', _type: 'internationalizedArrayStringValue', value: 'How?'}],
|
|
562
|
+
}
|
|
563
|
+
const locjson = serializeToLocjson(doc, getTranslatableFields(faqType), {sourceLanguage: 'en', fieldLanguageKey: 'language'})
|
|
564
|
+
expect(locjson.units.find((u) => u.key === 'question')).toBeUndefined()
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
it('round-trips field-level values (LocJSON carries no localization marker)', () => {
|
|
568
|
+
const doc = {
|
|
569
|
+
_id: 'faq-1',
|
|
570
|
+
_type: 'faq',
|
|
571
|
+
question: [member('en', 'How?')],
|
|
572
|
+
answer: [member('en', [makeBlock('Like this.')], 'internationalizedArraySimpleBlockContentValue')],
|
|
573
|
+
}
|
|
574
|
+
const locjson = serializeToLocjson(doc, getTranslatableFields(faqType), {sourceLanguage: 'en', fieldLanguageKey: 'language'})
|
|
575
|
+
expect(locjson.properties['x-sanity']).not.toHaveProperty('localization')
|
|
576
|
+
|
|
577
|
+
const translated: LocJSON = {
|
|
578
|
+
...locjson,
|
|
579
|
+
units: locjson.units.map((u) =>
|
|
580
|
+
u.key === 'question' ? {...u, target: ['Comment ?']} : {...u, target: [...u.source]},
|
|
581
|
+
),
|
|
582
|
+
}
|
|
583
|
+
const result = deserializeFromLocjson(translated, {
|
|
584
|
+
parseHtml,
|
|
585
|
+
getBlockContentType: (path) => (path === 'answer' ? answerBlockType : undefined),
|
|
586
|
+
})
|
|
587
|
+
expect(result.fields.question).toBe('Comment ?')
|
|
588
|
+
const body = result.fields.answer as Array<Record<string, unknown>>
|
|
589
|
+
expect((body[0].children as Array<{text: string}>)[0].text).toBe('Like this.')
|
|
590
|
+
})
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
describe('buildLocjsonFilename', () => {
|
|
594
|
+
it('builds "<type>/<title>-<idPrefix>.locjson"', () => {
|
|
595
|
+
expect(buildLocjsonFilename('page', '3f14f092-3475-42fc-a9f4-abf710d71ea8', 'About Us')).toBe(
|
|
596
|
+
'page/About Us-3f14f092.locjson',
|
|
597
|
+
)
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
it('strips drafts. prefix and sanitizes the title', () => {
|
|
601
|
+
expect(buildLocjsonFilename('blogPost', 'drafts.7c7b0950-aaaa', 'Why We Love / Content')).toBe(
|
|
602
|
+
'blogPost/Why We Love Content-7c7b0950.locjson',
|
|
603
|
+
)
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
it('falls back when title is missing', () => {
|
|
607
|
+
expect(buildLocjsonFilename('page', 'abc12345-0000')).toBe('page/Untitled-abc12345.locjson')
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
it('appends a -draft marker when the source was a draft', () => {
|
|
611
|
+
expect(buildLocjsonFilename('page', '3f14f092-3475', 'About Us', {draft: true})).toBe(
|
|
612
|
+
'page/About Us-3f14f092-draft.locjson',
|
|
613
|
+
)
|
|
614
|
+
})
|
|
615
|
+
})
|
|
@@ -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
|
+
})
|