@smartcat/sanity-plugin 1.2.1 → 1.4.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/dist/_chunks-cjs/index.cjs +18 -5
- package/dist/_chunks-cjs/index.cjs.map +1 -1
- package/dist/_chunks-cjs/index2.cjs +8 -6
- package/dist/_chunks-cjs/index2.cjs.map +1 -1
- package/dist/_chunks-es/index.js +18 -5
- package/dist/_chunks-es/index.js.map +1 -1
- package/dist/_chunks-es/index2.js +8 -6
- package/dist/_chunks-es/index2.js.map +1 -1
- package/dist/export.cjs +21 -4
- package/dist/export.cjs.map +1 -1
- package/dist/export.d.cts +2 -0
- package/dist/export.d.ts +2 -0
- package/dist/export.js +22 -5
- package/dist/export.js.map +1 -1
- package/dist/index.cjs +477 -124
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +482 -129
- package/dist/index.js.map +1 -1
- package/dist/locjson.d.cts +15 -0
- package/dist/locjson.d.ts +15 -0
- package/dist/progress.d.cts +0 -19
- package/dist/progress.d.ts +0 -19
- package/dist/smartcat.cjs +14 -2
- package/dist/smartcat.cjs.map +1 -1
- package/dist/smartcat.d.cts +10 -1
- package/dist/smartcat.d.ts +10 -1
- package/dist/smartcat.js +14 -2
- package/dist/smartcat.js.map +1 -1
- package/package.json +1 -1
- package/src/actions/AddToProjectAction.tsx +23 -2
- package/src/export/export.test.ts +42 -5
- package/src/export/index.ts +63 -8
- package/src/lib/locjson/filename.ts +7 -2
- package/src/lib/locjson/locjson.test.ts +64 -0
- package/src/lib/locjson/serialize.ts +38 -2
- package/src/progress/core.ts +44 -3
- package/src/progress/progress.test.ts +40 -0
- package/src/schema/translationProject.ts +19 -0
- package/src/smartcat/client.ts +15 -1
- package/src/tool/AddItemsSearch.tsx +462 -0
- package/src/tool/DocTitle.tsx +23 -0
- package/src/tool/ProjectView.tsx +29 -187
- package/src/tool/data.ts +7 -0
- package/src/tool/itemActions.tsx +216 -0
- package/src/tool/processing.test.ts +11 -1
- package/src/tool/processing.ts +116 -4
package/src/tool/ProjectView.tsx
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
import {useCallback, useEffect, useRef, useState} from 'react'
|
|
2
|
-
import {useClient, useRelativeTime, useSchema
|
|
2
|
+
import {useClient, useRelativeTime, useSchema} from 'sanity'
|
|
3
3
|
import {applyImportedTranslations, prepareExport, type PendingDeletion} from './processing'
|
|
4
|
-
import {
|
|
5
|
-
import {serializeToLocjson} from '../lib/locjson/serialize'
|
|
6
|
-
import {buildLocjsonFilename} from '../lib/locjson/filename'
|
|
7
|
-
import {resolveDocumentTitle} from '../lib/documentTitle'
|
|
4
|
+
import {localizationMode} from '../lib/locjson/fields'
|
|
8
5
|
import type {LocalizationMode} from '../lib/locjson/types'
|
|
9
|
-
import {useIntentLink} from 'sanity/router'
|
|
10
6
|
import {
|
|
11
7
|
Badge,
|
|
12
8
|
Box,
|
|
@@ -25,18 +21,18 @@ import {
|
|
|
25
21
|
} from '@sanity/ui'
|
|
26
22
|
import {
|
|
27
23
|
ArrowLeftIcon,
|
|
28
|
-
LaunchIcon,
|
|
29
24
|
TrashIcon,
|
|
30
25
|
PublishIcon,
|
|
31
26
|
CloseIcon,
|
|
32
27
|
DownloadIcon,
|
|
33
28
|
SyncIcon,
|
|
34
29
|
RemoveCircleIcon,
|
|
35
|
-
JsonIcon,
|
|
36
30
|
} from '@sanity/icons'
|
|
37
31
|
import styled, {css, keyframes} from 'styled-components'
|
|
38
32
|
import {API_VERSION} from '../lib/constants'
|
|
39
|
-
import {
|
|
33
|
+
import {DocTitle} from './DocTitle'
|
|
34
|
+
import {AddItemsSearch} from './AddItemsSearch'
|
|
35
|
+
import {OpenInStudioButton, ViewLocjsonButton} from './itemActions'
|
|
40
36
|
import {PROJECT_DETAIL_QUERY, type ProjectDetail, type ProjectItemRef} from './data'
|
|
41
37
|
import {StatusBadge, statusLabel} from './StatusBadge'
|
|
42
38
|
import {ItemProgress, languageTitle} from './ItemProgress'
|
|
@@ -67,26 +63,6 @@ function languageMappingLabel(language: SmartcatLanguage): string {
|
|
|
67
63
|
return code === language.id ? language.id : `${language.id} → ${code}`
|
|
68
64
|
}
|
|
69
65
|
|
|
70
|
-
/**
|
|
71
|
-
* Resolve an item's title the way the Studio does — by running the schema type's
|
|
72
|
-
* `preview.prepare` — so types without a literal `title` field (e.g. field-level
|
|
73
|
-
* localized docs) show the same label as in the structure tree, not 'Untitled'.
|
|
74
|
-
*/
|
|
75
|
-
function DocTitle({doc}: {doc: ProjectItemRef['doc']}) {
|
|
76
|
-
const schema = useSchema()
|
|
77
|
-
const schemaType = doc ? schema.get(doc._type) : undefined
|
|
78
|
-
const preview = useValuePreview({
|
|
79
|
-
enabled: Boolean(doc && schemaType),
|
|
80
|
-
schemaType,
|
|
81
|
-
value: doc ? {_id: doc._id, _type: doc._type} : undefined,
|
|
82
|
-
})
|
|
83
|
-
// String-only: never fall back to the raw projected value, which is an
|
|
84
|
-
// internationalized-array object for field-level i18n types (renders as React #31).
|
|
85
|
-
const title = typeof preview.value?.title === 'string' ? preview.value.title : undefined
|
|
86
|
-
// Placeholder while the async preview resolves, so a real title isn't briefly mislabeled.
|
|
87
|
-
return <>{title ?? (preview.isLoading ? '…' : UNTITLED)}</>
|
|
88
|
-
}
|
|
89
|
-
|
|
90
66
|
/**
|
|
91
67
|
* Statuses during which a Function is actively running, so the project shouldn't
|
|
92
68
|
* be re-sent until it settles. `translating`/`sent`/`completed` are resting
|
|
@@ -124,159 +100,6 @@ function LastUpdate({date}: {date: string}) {
|
|
|
124
100
|
)
|
|
125
101
|
}
|
|
126
102
|
|
|
127
|
-
/** Opens the document in the Studio. */
|
|
128
|
-
function OpenInStudioButton({id, type}: {id: string; type: string}) {
|
|
129
|
-
const {onClick, href} = useIntentLink({intent: 'edit', params: {id, type}})
|
|
130
|
-
return (
|
|
131
|
-
<Button
|
|
132
|
-
as="a"
|
|
133
|
-
href={href}
|
|
134
|
-
onClick={onClick}
|
|
135
|
-
mode="bleed"
|
|
136
|
-
icon={LaunchIcon}
|
|
137
|
-
text="Open in Studio"
|
|
138
|
-
fontSize={0}
|
|
139
|
-
padding={2}
|
|
140
|
-
/>
|
|
141
|
-
)
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** Read-only, monospace, full-height code view for the LocJSON modal. */
|
|
145
|
-
const CodeArea = styled.textarea`
|
|
146
|
-
width: 100%;
|
|
147
|
-
height: 70vh;
|
|
148
|
-
resize: none;
|
|
149
|
-
border: none;
|
|
150
|
-
outline: none;
|
|
151
|
-
padding: 0;
|
|
152
|
-
background: transparent;
|
|
153
|
-
color: inherit;
|
|
154
|
-
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
155
|
-
font-size: 12px;
|
|
156
|
-
line-height: 1.5;
|
|
157
|
-
white-space: pre;
|
|
158
|
-
tab-size: 2;
|
|
159
|
-
`
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Builds the LocJSON the export would produce for an item — same field selection
|
|
163
|
-
* and serialize options as {@link prepareExport} — and shows it (pretty-printed)
|
|
164
|
-
* in a read-only modal, for inspecting exactly what gets sent to Smartcat.
|
|
165
|
-
*/
|
|
166
|
-
function ViewLocjsonButton(props: {
|
|
167
|
-
docId: string
|
|
168
|
-
type: string
|
|
169
|
-
/** Whether the export will translate the published doc (else prefer the draft). */
|
|
170
|
-
sourceIsPublished: boolean | null
|
|
171
|
-
client: {fetch: (query: string, params?: Record<string, unknown>) => Promise<unknown>}
|
|
172
|
-
schema: {get: (typeName: string) => unknown}
|
|
173
|
-
sourceLanguage: string
|
|
174
|
-
documentI18nLanguageField: string
|
|
175
|
-
fieldI18nLanguageField: string
|
|
176
|
-
}) {
|
|
177
|
-
const {docId, type, sourceIsPublished, client, schema, sourceLanguage, documentI18nLanguageField, fieldI18nLanguageField} = props
|
|
178
|
-
const [open, setOpen] = useState(false)
|
|
179
|
-
const [content, setContent] = useState<string | null>(null)
|
|
180
|
-
const [filename, setFilename] = useState<string | null>(null)
|
|
181
|
-
const [error, setError] = useState<string | null>(null)
|
|
182
|
-
|
|
183
|
-
const build = useCallback(async () => {
|
|
184
|
-
setOpen(true)
|
|
185
|
-
setContent(null)
|
|
186
|
-
setFilename(null)
|
|
187
|
-
setError(null)
|
|
188
|
-
try {
|
|
189
|
-
// Resolve the same version the export will send: the draft (fallback to
|
|
190
|
-
// published) unless the item was added from the published perspective.
|
|
191
|
-
const raw = (await client.fetch(
|
|
192
|
-
sourceIsPublished
|
|
193
|
-
? '*[_id == $id][0]'
|
|
194
|
-
: 'coalesce(*[_id == "drafts." + $id][0], *[_id == $id][0])',
|
|
195
|
-
{id: docId},
|
|
196
|
-
)) as (Record<string, unknown> & {_id: string}) | null
|
|
197
|
-
if (!raw) throw new Error(`Document not found: ${docId}`)
|
|
198
|
-
const isDraft = typeof raw._id === 'string' && raw._id.startsWith('drafts.')
|
|
199
|
-
// Serialize under the canonical (bare) id, matching the export.
|
|
200
|
-
const doc = isDraft ? {...raw, _id: docId} : raw
|
|
201
|
-
const fields = getTranslatableFields(schema.get(type), {exclude: [documentI18nLanguageField]})
|
|
202
|
-
const locjson = serializeToLocjson(doc as never, fields, {
|
|
203
|
-
sourceLanguage,
|
|
204
|
-
fieldLanguageKey: fieldI18nLanguageField,
|
|
205
|
-
})
|
|
206
|
-
// Same name the export sends to Smartcat; the last path segment is the
|
|
207
|
-
// file's basename (the Smartcat "folder/" prefix can't be a local filename).
|
|
208
|
-
const smartcatName = buildLocjsonFilename(type, docId, resolveDocumentTitle(schema, doc as never), {draft: isDraft})
|
|
209
|
-
setFilename(smartcatName.split('/').pop() || smartcatName)
|
|
210
|
-
setContent(JSON.stringify(locjson, null, 2))
|
|
211
|
-
} catch (err) {
|
|
212
|
-
setError(err instanceof Error ? err.message : String(err))
|
|
213
|
-
}
|
|
214
|
-
}, [client, schema, docId, type, sourceIsPublished, sourceLanguage, documentI18nLanguageField, fieldI18nLanguageField])
|
|
215
|
-
|
|
216
|
-
const download = useCallback(() => {
|
|
217
|
-
if (content == null || !filename) return
|
|
218
|
-
const url = URL.createObjectURL(new Blob([content], {type: 'application/json'}))
|
|
219
|
-
const anchor = document.createElement('a')
|
|
220
|
-
anchor.href = url
|
|
221
|
-
anchor.download = filename
|
|
222
|
-
anchor.click()
|
|
223
|
-
URL.revokeObjectURL(url)
|
|
224
|
-
}, [content, filename])
|
|
225
|
-
|
|
226
|
-
return (
|
|
227
|
-
<>
|
|
228
|
-
<Button
|
|
229
|
-
mode="bleed"
|
|
230
|
-
icon={JsonIcon}
|
|
231
|
-
title="View LocJSON"
|
|
232
|
-
fontSize={0}
|
|
233
|
-
padding={2}
|
|
234
|
-
onClick={build}
|
|
235
|
-
/>
|
|
236
|
-
{open && (
|
|
237
|
-
<Dialog
|
|
238
|
-
id={`locjson-${docId}`}
|
|
239
|
-
header={
|
|
240
|
-
<Flex align="center" gap={3}>
|
|
241
|
-
<Text weight="semibold">LocJSON · {type}</Text>
|
|
242
|
-
<Button
|
|
243
|
-
icon={DownloadIcon}
|
|
244
|
-
text="Download"
|
|
245
|
-
mode="ghost"
|
|
246
|
-
fontSize={1}
|
|
247
|
-
padding={2}
|
|
248
|
-
disabled={content == null}
|
|
249
|
-
onClick={download}
|
|
250
|
-
/>
|
|
251
|
-
</Flex>
|
|
252
|
-
}
|
|
253
|
-
onClose={() => setOpen(false)}
|
|
254
|
-
width={3}
|
|
255
|
-
>
|
|
256
|
-
<Box padding={4}>
|
|
257
|
-
{error ? (
|
|
258
|
-
<Text size={1} muted>
|
|
259
|
-
{error}
|
|
260
|
-
</Text>
|
|
261
|
-
) : content === null ? (
|
|
262
|
-
<Flex align="center" justify="center" padding={5}>
|
|
263
|
-
<Spinner muted />
|
|
264
|
-
</Flex>
|
|
265
|
-
) : (
|
|
266
|
-
<CodeArea
|
|
267
|
-
readOnly
|
|
268
|
-
spellCheck={false}
|
|
269
|
-
value={content}
|
|
270
|
-
onFocus={(e) => e.currentTarget.select()}
|
|
271
|
-
/>
|
|
272
|
-
)}
|
|
273
|
-
</Box>
|
|
274
|
-
</Dialog>
|
|
275
|
-
)}
|
|
276
|
-
</>
|
|
277
|
-
)
|
|
278
|
-
}
|
|
279
|
-
|
|
280
103
|
export function ProjectView({
|
|
281
104
|
projectId,
|
|
282
105
|
onBack,
|
|
@@ -912,6 +735,18 @@ export function ProjectView({
|
|
|
912
735
|
{modeByType.get(item.doc._type) === 'field' ? 'fields' : 'document'}
|
|
913
736
|
</Badge>
|
|
914
737
|
)}
|
|
738
|
+
<Badge
|
|
739
|
+
tone={item.sendExistingTranslations ? 'primary' : 'default'}
|
|
740
|
+
fontSize={0}
|
|
741
|
+
paddingX={2}
|
|
742
|
+
title={
|
|
743
|
+
item.sendExistingTranslations
|
|
744
|
+
? 'Existing translations are uploaded to Smartcat (one bilingual file per language) and marked confirmed'
|
|
745
|
+
: 'Only source text is sent; Smartcat translates from scratch'
|
|
746
|
+
}
|
|
747
|
+
>
|
|
748
|
+
{item.sendExistingTranslations ? 'source + translations' : 'source only'}
|
|
749
|
+
</Badge>
|
|
915
750
|
{item.doc && <OpenInStudioButton id={item.doc._id} type={item.doc._type} />}
|
|
916
751
|
{item.doc && (
|
|
917
752
|
<ViewLocjsonButton
|
|
@@ -923,6 +758,8 @@ export function ProjectView({
|
|
|
923
758
|
sourceLanguage={sourceLanguage}
|
|
924
759
|
documentI18nLanguageField={documentI18nLanguageField}
|
|
925
760
|
fieldI18nLanguageField={fieldI18nLanguageField}
|
|
761
|
+
targetLanguages={targets}
|
|
762
|
+
languages={languages}
|
|
926
763
|
/>
|
|
927
764
|
)}
|
|
928
765
|
</Flex>
|
|
@@ -970,11 +807,16 @@ export function ProjectView({
|
|
|
970
807
|
</Stack>
|
|
971
808
|
)}
|
|
972
809
|
|
|
973
|
-
<
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
810
|
+
<AddItemsSearch
|
|
811
|
+
projectId={projectId}
|
|
812
|
+
translatableTypes={translatableTypes}
|
|
813
|
+
existingDocIds={new Set(items.map((i) => i.docId))}
|
|
814
|
+
sourceLanguage={sourceLanguage}
|
|
815
|
+
languages={languages}
|
|
816
|
+
targetLanguages={targets}
|
|
817
|
+
documentI18nLanguageField={documentI18nLanguageField}
|
|
818
|
+
fieldI18nLanguageField={fieldI18nLanguageField}
|
|
819
|
+
/>
|
|
978
820
|
</Stack>
|
|
979
821
|
|
|
980
822
|
{confirmDelete && (
|
package/src/tool/data.ts
CHANGED
|
@@ -24,6 +24,12 @@ export interface ProjectItemRef {
|
|
|
24
24
|
* and which version the export serializes. Null on legacy items (pre-flag).
|
|
25
25
|
*/
|
|
26
26
|
sourceIsPublished: boolean | null
|
|
27
|
+
/**
|
|
28
|
+
* Whether the export sends this item's existing translations (one bilingual
|
|
29
|
+
* file per target language) or only its source text. Null/false on legacy
|
|
30
|
+
* items (pre-flag) ⇒ source-only, today's behaviour.
|
|
31
|
+
*/
|
|
32
|
+
sendExistingTranslations: boolean | null
|
|
27
33
|
doc: {_id: string; _type: string} | null
|
|
28
34
|
/** Locale variants linked via translation.metadata: language -> variant doc id. */
|
|
29
35
|
translations: {language: string; id: string}[] | null
|
|
@@ -73,6 +79,7 @@ export const PROJECT_DETAIL_QUERY = `*[_id == $id][0]{
|
|
|
73
79
|
_key,
|
|
74
80
|
"docId": ${ITEM_ID},
|
|
75
81
|
sourceIsPublished,
|
|
82
|
+
sendExistingTranslations,
|
|
76
83
|
"doc": ${itemDocDeref('{_id, _type}')},
|
|
77
84
|
"translations": *[_type == "translation.metadata" && references(${ITEM_ID_FROM_SUBQUERY})][0]
|
|
78
85
|
.translations[]{language, "id": value._ref}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import {useCallback, useState} from 'react'
|
|
2
|
+
import {useIntentLink} from 'sanity/router'
|
|
3
|
+
import {Box, Button, Dialog, Flex, Menu, MenuButton, MenuItem, Spinner, Text} from '@sanity/ui'
|
|
4
|
+
import {LaunchIcon, JsonIcon, ChevronDownIcon, DownloadIcon} from '@sanity/icons'
|
|
5
|
+
import styled from 'styled-components'
|
|
6
|
+
import {getTranslatableFields, localizationMode} from '../lib/locjson/fields'
|
|
7
|
+
import {serializeToLocjson} from '../lib/locjson/serialize'
|
|
8
|
+
import {buildLocjsonFilename} from '../lib/locjson/filename'
|
|
9
|
+
import {resolveDocumentTitle} from '../lib/documentTitle'
|
|
10
|
+
import {languageTitle} from './ItemProgress'
|
|
11
|
+
import type {SmartcatLanguage} from '../types'
|
|
12
|
+
|
|
13
|
+
/** Opens the document in the Studio. */
|
|
14
|
+
export function OpenInStudioButton({id, type}: {id: string; type: string}) {
|
|
15
|
+
const {onClick, href} = useIntentLink({intent: 'edit', params: {id, type}})
|
|
16
|
+
return (
|
|
17
|
+
<Button
|
|
18
|
+
as="a"
|
|
19
|
+
href={href}
|
|
20
|
+
onClick={onClick}
|
|
21
|
+
mode="bleed"
|
|
22
|
+
icon={LaunchIcon}
|
|
23
|
+
text="Open in Studio"
|
|
24
|
+
fontSize={0}
|
|
25
|
+
padding={2}
|
|
26
|
+
/>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Read-only, monospace, full-height code view for the LocJSON modal. */
|
|
31
|
+
const CodeArea = styled.textarea`
|
|
32
|
+
width: 100%;
|
|
33
|
+
height: 70vh;
|
|
34
|
+
resize: none;
|
|
35
|
+
border: none;
|
|
36
|
+
outline: none;
|
|
37
|
+
padding: 0;
|
|
38
|
+
background: transparent;
|
|
39
|
+
color: inherit;
|
|
40
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
41
|
+
font-size: 12px;
|
|
42
|
+
line-height: 1.5;
|
|
43
|
+
white-space: pre;
|
|
44
|
+
tab-size: 2;
|
|
45
|
+
`
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Target-locale siblings of a document-level item, via translation.metadata. The
|
|
49
|
+
* member's locale lives in its language field (v5+, uuid `_key`) or in `_key`
|
|
50
|
+
* itself (v4.x) — `coalesce` reads whichever applies, per {@link fieldLanguageKey}.
|
|
51
|
+
*/
|
|
52
|
+
function siblingsQuery(fieldLanguageKey: string): string {
|
|
53
|
+
return `*[_type == "translation.metadata" && references($id)][0].translations[]{"language": coalesce(${fieldLanguageKey}, _key), "doc": value->}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Dropdown that builds the LocJSON the export would produce for an item — same
|
|
58
|
+
* field selection and serialize options as {@link prepareExport} — and shows it
|
|
59
|
+
* (pretty-printed) in a read-only modal. Offers "Source only" (what a plain
|
|
60
|
+
* export sends) plus "Source + <language>" for each target language: the
|
|
61
|
+
* bilingual file the send-existing flow would upload for that language, so you
|
|
62
|
+
* can inspect exactly what goes to Smartcat per language.
|
|
63
|
+
*/
|
|
64
|
+
export function ViewLocjsonButton(props: {
|
|
65
|
+
docId: string
|
|
66
|
+
type: string
|
|
67
|
+
/** Whether the export will translate the published doc (else prefer the draft). */
|
|
68
|
+
sourceIsPublished: boolean | null
|
|
69
|
+
client: {fetch: (query: string, params?: Record<string, unknown>) => Promise<unknown>}
|
|
70
|
+
schema: {get: (typeName: string) => unknown}
|
|
71
|
+
sourceLanguage: string
|
|
72
|
+
documentI18nLanguageField: string
|
|
73
|
+
fieldI18nLanguageField: string
|
|
74
|
+
/** Project target languages (Sanity locale ids). */
|
|
75
|
+
targetLanguages: string[]
|
|
76
|
+
/** Configured languages (for titles + Smartcat codes). */
|
|
77
|
+
languages: SmartcatLanguage[]
|
|
78
|
+
}) {
|
|
79
|
+
const {docId, type, sourceIsPublished, client, schema, sourceLanguage, documentI18nLanguageField, fieldI18nLanguageField, targetLanguages, languages} = props
|
|
80
|
+
const [open, setOpen] = useState(false)
|
|
81
|
+
const [content, setContent] = useState<string | null>(null)
|
|
82
|
+
const [filename, setFilename] = useState<string | null>(null)
|
|
83
|
+
const [title, setTitle] = useState<string>('Source only')
|
|
84
|
+
const [error, setError] = useState<string | null>(null)
|
|
85
|
+
|
|
86
|
+
// Build the LocJSON for one variant: `targetLanguage` undefined ⇒ source-only
|
|
87
|
+
// (a plain export), otherwise the bilingual file for that Sanity locale.
|
|
88
|
+
const build = useCallback(
|
|
89
|
+
async (targetLanguage?: string) => {
|
|
90
|
+
setOpen(true)
|
|
91
|
+
setContent(null)
|
|
92
|
+
setFilename(null)
|
|
93
|
+
setError(null)
|
|
94
|
+
setTitle(targetLanguage ? `Source + ${languageTitle(languages, targetLanguage)}` : 'Source only')
|
|
95
|
+
try {
|
|
96
|
+
// Resolve the same version the export will send: the draft (fallback to
|
|
97
|
+
// published) unless the item was added from the published perspective.
|
|
98
|
+
const raw = (await client.fetch(
|
|
99
|
+
sourceIsPublished
|
|
100
|
+
? '*[_id == $id][0]'
|
|
101
|
+
: 'coalesce(*[_id == "drafts." + $id][0], *[_id == $id][0])',
|
|
102
|
+
{id: docId},
|
|
103
|
+
)) as (Record<string, unknown> & {_id: string}) | null
|
|
104
|
+
if (!raw) throw new Error(`Document not found: ${docId}`)
|
|
105
|
+
const isDraft = typeof raw._id === 'string' && raw._id.startsWith('drafts.')
|
|
106
|
+
// Serialize under the canonical (bare) id, matching the export.
|
|
107
|
+
const doc = isDraft ? {...raw, _id: docId} : raw
|
|
108
|
+
const fields = getTranslatableFields(schema.get(type), {exclude: [documentI18nLanguageField]})
|
|
109
|
+
|
|
110
|
+
// For a bilingual variant, resolve the existing translation the same way
|
|
111
|
+
// the export does: field-level from the inline member, document-level from
|
|
112
|
+
// the target-locale sibling document.
|
|
113
|
+
let target: {language: string; doc?: Record<string, unknown> & {_id: string; _type: string}} | undefined
|
|
114
|
+
if (targetLanguage) {
|
|
115
|
+
if (localizationMode(schema.get(type)) === 'document') {
|
|
116
|
+
const variants = (await client.fetch(siblingsQuery(fieldI18nLanguageField), {id: docId})) as
|
|
117
|
+
| {language: string; doc: (Record<string, unknown> & {_id: string; _type: string}) | null}[]
|
|
118
|
+
| null
|
|
119
|
+
const sibling = (variants ?? []).find((v) => v.language === targetLanguage)?.doc ?? undefined
|
|
120
|
+
target = {language: targetLanguage, doc: sibling}
|
|
121
|
+
} else {
|
|
122
|
+
target = {language: targetLanguage}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const locjson = serializeToLocjson(doc as never, fields, {
|
|
127
|
+
sourceLanguage,
|
|
128
|
+
fieldLanguageKey: fieldI18nLanguageField,
|
|
129
|
+
target,
|
|
130
|
+
})
|
|
131
|
+
// Same name the export sends to Smartcat; the last path segment is the
|
|
132
|
+
// file's basename (the Smartcat "folder/" prefix can't be a local filename).
|
|
133
|
+
const smartcatCode = targetLanguage
|
|
134
|
+
? languages.find((l) => l.id === targetLanguage)?.smartcatLanguage || targetLanguage
|
|
135
|
+
: undefined
|
|
136
|
+
const smartcatName = buildLocjsonFilename(type, docId, resolveDocumentTitle(schema, doc as never), {
|
|
137
|
+
draft: isDraft,
|
|
138
|
+
language: smartcatCode,
|
|
139
|
+
})
|
|
140
|
+
setFilename(smartcatName.split('/').pop() || smartcatName)
|
|
141
|
+
setContent(JSON.stringify(locjson, null, 2))
|
|
142
|
+
} catch (err) {
|
|
143
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
[client, schema, docId, type, sourceIsPublished, sourceLanguage, documentI18nLanguageField, fieldI18nLanguageField, languages],
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
const download = useCallback(() => {
|
|
150
|
+
if (content == null || !filename) return
|
|
151
|
+
const url = URL.createObjectURL(new Blob([content], {type: 'application/json'}))
|
|
152
|
+
const anchor = document.createElement('a')
|
|
153
|
+
anchor.href = url
|
|
154
|
+
anchor.download = filename
|
|
155
|
+
anchor.click()
|
|
156
|
+
URL.revokeObjectURL(url)
|
|
157
|
+
}, [content, filename])
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<>
|
|
161
|
+
<MenuButton
|
|
162
|
+
id={`locjson-menu-${docId}`}
|
|
163
|
+
button={<Button mode="bleed" icon={JsonIcon} iconRight={ChevronDownIcon} title="View LocJSON" fontSize={0} padding={2} />}
|
|
164
|
+
menu={
|
|
165
|
+
<Menu>
|
|
166
|
+
<MenuItem text="Source only" onClick={() => build()} />
|
|
167
|
+
{targetLanguages.map((lang) => (
|
|
168
|
+
<MenuItem key={lang} text={`Source + ${languageTitle(languages, lang)}`} onClick={() => build(lang)} />
|
|
169
|
+
))}
|
|
170
|
+
</Menu>
|
|
171
|
+
}
|
|
172
|
+
popover={{portal: true}}
|
|
173
|
+
/>
|
|
174
|
+
{open && (
|
|
175
|
+
<Dialog
|
|
176
|
+
id={`locjson-${docId}`}
|
|
177
|
+
header={
|
|
178
|
+
<Flex align="center" gap={3}>
|
|
179
|
+
<Text weight="semibold">LocJSON · {type} · {title}</Text>
|
|
180
|
+
<Button
|
|
181
|
+
icon={DownloadIcon}
|
|
182
|
+
text="Download"
|
|
183
|
+
mode="ghost"
|
|
184
|
+
fontSize={1}
|
|
185
|
+
padding={2}
|
|
186
|
+
disabled={content == null}
|
|
187
|
+
onClick={download}
|
|
188
|
+
/>
|
|
189
|
+
</Flex>
|
|
190
|
+
}
|
|
191
|
+
onClose={() => setOpen(false)}
|
|
192
|
+
width={3}
|
|
193
|
+
>
|
|
194
|
+
<Box padding={4}>
|
|
195
|
+
{error ? (
|
|
196
|
+
<Text size={1} muted>
|
|
197
|
+
{error}
|
|
198
|
+
</Text>
|
|
199
|
+
) : content === null ? (
|
|
200
|
+
<Flex align="center" justify="center" padding={5}>
|
|
201
|
+
<Spinner muted />
|
|
202
|
+
</Flex>
|
|
203
|
+
) : (
|
|
204
|
+
<CodeArea
|
|
205
|
+
readOnly
|
|
206
|
+
spellCheck={false}
|
|
207
|
+
value={content}
|
|
208
|
+
onFocus={(e) => e.currentTarget.select()}
|
|
209
|
+
/>
|
|
210
|
+
)}
|
|
211
|
+
</Box>
|
|
212
|
+
</Dialog>
|
|
213
|
+
)}
|
|
214
|
+
</>
|
|
215
|
+
)
|
|
216
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {beforeAll, describe, expect, it} from 'vitest'
|
|
2
2
|
import {Schema} from '@sanity/schema'
|
|
3
3
|
import {JSDOM} from 'jsdom'
|
|
4
|
-
import {applyImportedTranslations, prepareExport, type ProcessingClient} from './processing'
|
|
4
|
+
import {applyImportedTranslations, prepareExport, siblingsQuery, type ProcessingClient} from './processing'
|
|
5
5
|
|
|
6
6
|
// processing.ts uses the browser's global DOMParser; provide one via jsdom.
|
|
7
7
|
beforeAll(() => {
|
|
@@ -696,3 +696,13 @@ describe('nested + array-member field-level translation', () => {
|
|
|
696
696
|
])
|
|
697
697
|
})
|
|
698
698
|
})
|
|
699
|
+
|
|
700
|
+
describe('siblingsQuery', () => {
|
|
701
|
+
// The v5+ translation.metadata member stores its locale in a language field
|
|
702
|
+
// with a uuid `_key`; reading only `_key` (the v4.x layout) missed every v5+
|
|
703
|
+
// sibling, so send-existing attached no target for document-level items.
|
|
704
|
+
it('reads the locale from the configured field, falling back to `_key` (v4.x)', () => {
|
|
705
|
+
expect(siblingsQuery('language')).toContain('"language": coalesce(language, _key)')
|
|
706
|
+
expect(siblingsQuery('_key')).toContain('"language": coalesce(_key, _key)')
|
|
707
|
+
})
|
|
708
|
+
})
|