@smartcat/sanity-plugin 1.0.1 → 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/dist/_chunks-cjs/index.cjs +10 -4
- package/dist/_chunks-cjs/index.cjs.map +1 -1
- package/dist/_chunks-cjs/index2.cjs +34 -7
- package/dist/_chunks-cjs/index2.cjs.map +1 -1
- package/dist/_chunks-es/index.js +10 -4
- package/dist/_chunks-es/index.js.map +1 -1
- package/dist/_chunks-es/index2.js +35 -8
- package/dist/_chunks-es/index2.js.map +1 -1
- package/dist/import.cjs +1 -1
- package/dist/import.cjs.map +1 -1
- package/dist/import.js +2 -2
- package/dist/import.js.map +1 -1
- package/dist/index.cjs +96 -27
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +100 -31
- package/dist/index.js.map +1 -1
- package/dist/locjson.d.cts +5 -1
- package/dist/locjson.d.ts +5 -1
- package/dist/progress.d.cts +24 -9
- package/dist/progress.d.ts +24 -9
- package/package.json +1 -1
- package/src/actions/AddToProjectAction.tsx +21 -5
- package/src/import/index.ts +3 -3
- package/src/lib/locjson/filename.ts +10 -3
- package/src/lib/locjson/locjson.test.ts +78 -8
- package/src/lib/locjson/portableText.ts +71 -12
- package/src/lib/projectItems.ts +14 -0
- package/src/lib/resolveConfig.ts +7 -0
- package/src/progress/core.ts +37 -15
- package/src/progress/progress.test.ts +22 -0
- package/src/schema/translationProject.ts +4 -0
- package/src/tool/Dashboard.tsx +3 -1
- package/src/tool/ProjectView.tsx +35 -6
- package/src/tool/data.ts +7 -0
- package/src/tool/processing.test.ts +79 -4
- package/src/tool/processing.ts +84 -11
package/src/progress/core.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type {SmartcatProject, SmartcatDocument, RequestLogger} from '../smartcat/types'
|
|
2
2
|
import {toSanityLanguage, type SmartcatLanguageMapping} from '../lib/languageMap'
|
|
3
|
-
import {
|
|
3
|
+
import {ITEM_ID, itemDocDerefRespectingDraft} from '../lib/projectItems'
|
|
4
4
|
import type {LogLine} from '../lib/log'
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -121,11 +121,14 @@ export interface ProjectItem {
|
|
|
121
121
|
|
|
122
122
|
/**
|
|
123
123
|
* Extracts the 8-char source-id prefix Smartcat preserves in a document's
|
|
124
|
-
* filename (built as `<title>-<idPrefix>.locjson
|
|
124
|
+
* filename (built as `<title>-<idPrefix>.locjson`, or `<title>-<idPrefix>-draft.locjson`
|
|
125
|
+
* when the content came from the document's draft). Used to correlate a Smartcat
|
|
125
126
|
* document back to a Sanity source document.
|
|
126
127
|
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
128
|
+
* A trailing `-draft` marker is stripped first, so a draft-sourced file resolves
|
|
129
|
+
* to the same id as its published counterpart. The prefix is then always the
|
|
130
|
+
* final 8 characters before the (marker and) extension, so take the tail rather
|
|
131
|
+
* than splitting on a dash — the title or the id prefix itself can
|
|
129
132
|
* contain dashes (e.g. a custom id like `demo-pdpA` → prefix `demo-pdp`), which
|
|
130
133
|
* would make a last-dash split return the wrong fragment.
|
|
131
134
|
*
|
|
@@ -139,7 +142,12 @@ export function sourceIdPrefix(doc: SmartcatDocument): string {
|
|
|
139
142
|
const base = doc.name || doc.filename || doc.fullPath || ''
|
|
140
143
|
const leaf = base.split('/').pop() ?? ''
|
|
141
144
|
const noExt = leaf.replace(/\.locjson$/i, '')
|
|
142
|
-
|
|
145
|
+
// Draft-sourced files carry a trailing `-draft` marker (informational only, so
|
|
146
|
+
// a project never holds both a document's draft and its published version).
|
|
147
|
+
// Strip it before taking the tail so a draft file correlates to the same source
|
|
148
|
+
// id as its published counterpart — the id prefix is what identifies the item.
|
|
149
|
+
const noDraft = noExt.replace(/-draft$/, '')
|
|
150
|
+
return noDraft.slice(-8)
|
|
143
151
|
}
|
|
144
152
|
|
|
145
153
|
/**
|
|
@@ -292,7 +300,7 @@ const PROGRESS_QUERY = `*[_id == $id][0]{
|
|
|
292
300
|
status,
|
|
293
301
|
smartcatProjectId,
|
|
294
302
|
smartcatLanguages[]{sanityId, smartcatLanguage},
|
|
295
|
-
"items": items[]{ "
|
|
303
|
+
"items": items[]{ "_id": ${ITEM_ID}, "title": ${itemDocDerefRespectingDraft('.title')} },
|
|
296
304
|
progress[]{_key, sourceDocId, title, targets[]{_key, language, smartcatDocumentId, stages, complete, imported, syncedAt}}
|
|
297
305
|
}`
|
|
298
306
|
|
|
@@ -301,18 +309,32 @@ interface ProgressProjectData {
|
|
|
301
309
|
status?: string
|
|
302
310
|
smartcatProjectId?: string
|
|
303
311
|
smartcatLanguages?: SmartcatLanguageMapping[]
|
|
304
|
-
items?:
|
|
312
|
+
items?: ProjectItemRef[]
|
|
305
313
|
progress?: DocProgress[]
|
|
306
314
|
}
|
|
307
315
|
|
|
308
|
-
/**
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
+
/** A project item as read for correlation: its stored id (always present) plus a
|
|
317
|
+
* best-effort title. `doc` is the legacy shape — some callers still project it. */
|
|
318
|
+
interface ProjectItemRef {
|
|
319
|
+
_id?: string
|
|
320
|
+
title?: string
|
|
321
|
+
doc?: {_id?: string; title?: string} | null
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Resolves a project's items into the lightweight shape we correlate on. The id
|
|
326
|
+
* is the item's stored `docId` — NOT a dereferenced document — so an item whose
|
|
327
|
+
* document exists only as a draft (never published) still correlates instead of
|
|
328
|
+
* being silently dropped. Tolerates the legacy `{doc}` projection.
|
|
329
|
+
*/
|
|
330
|
+
export function itemsFromProject(project: {items?: ProjectItemRef[]}): ProjectItem[] {
|
|
331
|
+
const result: ProjectItem[] = []
|
|
332
|
+
for (const item of project.items ?? []) {
|
|
333
|
+
const _id = item?._id ?? item?.doc?._id
|
|
334
|
+
if (!_id) continue
|
|
335
|
+
result.push({_id, title: item?.title ?? item?.doc?.title ?? undefined})
|
|
336
|
+
}
|
|
337
|
+
return result
|
|
316
338
|
}
|
|
317
339
|
|
|
318
340
|
/**
|
|
@@ -58,6 +58,18 @@ describe('stage helpers', () => {
|
|
|
58
58
|
expect(sourceIdPrefix({id: 'g_1', filename: 'aiAssistantModal.resetChat-11AKrV8G.locjson'})).toBe('11AKrV8G')
|
|
59
59
|
expect(sourceIdPrefix({id: 'g_1', name: 'v2.5 Landing Page-ab12cd34'})).toBe('ab12cd34')
|
|
60
60
|
})
|
|
61
|
+
|
|
62
|
+
it('correlates a draft-sourced file to the same id as its published counterpart', () => {
|
|
63
|
+
// Draft files carry a trailing `-draft` marker; it must be stripped so both
|
|
64
|
+
// resolve to the same source id (a project holds one version, never both).
|
|
65
|
+
const published = {id: 'g_1', filename: 'page/About Us-f18f0d41.locjson'}
|
|
66
|
+
const draft = {id: 'g_2', filename: 'page/About Us-f18f0d41-draft.locjson'}
|
|
67
|
+
expect(sourceIdPrefix(published)).toBe('f18f0d41')
|
|
68
|
+
expect(sourceIdPrefix(draft)).toBe('f18f0d41')
|
|
69
|
+
expect(sourceIdPrefix(draft)).toBe(sourceIdPrefix(published))
|
|
70
|
+
// Marker stripped from the name form (no extension) too.
|
|
71
|
+
expect(sourceIdPrefix({id: 'g_3', name: 'About Us-f18f0d41-draft'})).toBe('f18f0d41')
|
|
72
|
+
})
|
|
61
73
|
})
|
|
62
74
|
|
|
63
75
|
const scProject: SmartcatProject = {
|
|
@@ -131,6 +143,16 @@ describe('buildProgress', () => {
|
|
|
131
143
|
it('resolves items from a project shape', () => {
|
|
132
144
|
expect(itemsFromProject({items: [{doc: {_id: 'a'}}, {doc: null}]})).toEqual([{_id: 'a'}])
|
|
133
145
|
})
|
|
146
|
+
|
|
147
|
+
it('resolves items by stored id, so a draft-only item still correlates', () => {
|
|
148
|
+
// Current query projects the item's stored `docId` as `_id` (no deref), so an
|
|
149
|
+
// item whose document exists only as a draft is not dropped.
|
|
150
|
+
expect(itemsFromProject({items: [{_id: 'draftOnlyId', title: 'Draft Page'}]})).toEqual([
|
|
151
|
+
{_id: 'draftOnlyId', title: 'Draft Page'},
|
|
152
|
+
])
|
|
153
|
+
// Items with no id at all (deleted/unresolvable) are still dropped.
|
|
154
|
+
expect(itemsFromProject({items: [{doc: null}, {}]})).toEqual([])
|
|
155
|
+
})
|
|
134
156
|
})
|
|
135
157
|
|
|
136
158
|
describe('summarizeCompletion', () => {
|
|
@@ -97,6 +97,10 @@ export function createTranslationProjectType(): SchemaTypeDefinition {
|
|
|
97
97
|
fields: [
|
|
98
98
|
defineField({name: 'docId', title: 'Document ID', type: 'string'}),
|
|
99
99
|
defineField({name: 'docType', title: 'Document type', type: 'string'}),
|
|
100
|
+
// Set when the item was added from the published perspective, so the
|
|
101
|
+
// export translates the published document. Absent/false (incl. all
|
|
102
|
+
// legacy items) prefers the draft — see itemDocDerefRespectingDraft.
|
|
103
|
+
defineField({name: 'sourceIsPublished', title: 'Source is published', type: 'boolean'}),
|
|
100
104
|
],
|
|
101
105
|
preview: {select: {title: 'docId', subtitle: 'docType'}},
|
|
102
106
|
}),
|
package/src/tool/Dashboard.tsx
CHANGED
|
@@ -11,7 +11,8 @@ import type {ResolvedSmartcatConfig} from '../lib/resolveConfig'
|
|
|
11
11
|
* view, so the selected project is reflected in the URL (deep-linkable).
|
|
12
12
|
*/
|
|
13
13
|
export function createDashboardComponent(config: ResolvedSmartcatConfig) {
|
|
14
|
-
const {sourceLanguage, languages, documentI18nLanguageField, fieldI18nLanguageField} =
|
|
14
|
+
const {sourceLanguage, languages, translatableTypes, documentI18nLanguageField, fieldI18nLanguageField} =
|
|
15
|
+
config
|
|
15
16
|
|
|
16
17
|
return function Dashboard() {
|
|
17
18
|
const router = useRouter()
|
|
@@ -31,6 +32,7 @@ export function createDashboardComponent(config: ResolvedSmartcatConfig) {
|
|
|
31
32
|
onBack={goBack}
|
|
32
33
|
languages={languages}
|
|
33
34
|
sourceLanguage={sourceLanguage}
|
|
35
|
+
translatableTypes={translatableTypes}
|
|
34
36
|
documentI18nLanguageField={documentI18nLanguageField}
|
|
35
37
|
fieldI18nLanguageField={fieldI18nLanguageField}
|
|
36
38
|
/>
|
package/src/tool/ProjectView.tsx
CHANGED
|
@@ -50,6 +50,8 @@ interface ProjectViewProps {
|
|
|
50
50
|
onBack: () => void
|
|
51
51
|
languages: SmartcatLanguage[]
|
|
52
52
|
sourceLanguage: string
|
|
53
|
+
/** Configured translatable types (undefined = all), shown in the export log. */
|
|
54
|
+
translatableTypes?: string[]
|
|
53
55
|
/** document-internationalization `languageField` (document locale field name). */
|
|
54
56
|
documentI18nLanguageField: string
|
|
55
57
|
/** internationalized-array locale key: field name (`'language'`) or `'_key'`. */
|
|
@@ -164,13 +166,15 @@ const CodeArea = styled.textarea`
|
|
|
164
166
|
function ViewLocjsonButton(props: {
|
|
165
167
|
docId: string
|
|
166
168
|
type: string
|
|
169
|
+
/** Whether the export will translate the published doc (else prefer the draft). */
|
|
170
|
+
sourceIsPublished: boolean | null
|
|
167
171
|
client: {fetch: (query: string, params?: Record<string, unknown>) => Promise<unknown>}
|
|
168
172
|
schema: {get: (typeName: string) => unknown}
|
|
169
173
|
sourceLanguage: string
|
|
170
174
|
documentI18nLanguageField: string
|
|
171
175
|
fieldI18nLanguageField: string
|
|
172
176
|
}) {
|
|
173
|
-
const {docId, type, client, schema, sourceLanguage, documentI18nLanguageField, fieldI18nLanguageField} = props
|
|
177
|
+
const {docId, type, sourceIsPublished, client, schema, sourceLanguage, documentI18nLanguageField, fieldI18nLanguageField} = props
|
|
174
178
|
const [open, setOpen] = useState(false)
|
|
175
179
|
const [content, setContent] = useState<string | null>(null)
|
|
176
180
|
const [filename, setFilename] = useState<string | null>(null)
|
|
@@ -182,8 +186,18 @@ function ViewLocjsonButton(props: {
|
|
|
182
186
|
setFilename(null)
|
|
183
187
|
setError(null)
|
|
184
188
|
try {
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
187
201
|
const fields = getTranslatableFields(schema.get(type), {exclude: [documentI18nLanguageField]})
|
|
188
202
|
const locjson = serializeToLocjson(doc as never, fields, {
|
|
189
203
|
sourceLanguage,
|
|
@@ -191,13 +205,13 @@ function ViewLocjsonButton(props: {
|
|
|
191
205
|
})
|
|
192
206
|
// Same name the export sends to Smartcat; the last path segment is the
|
|
193
207
|
// file's basename (the Smartcat "folder/" prefix can't be a local filename).
|
|
194
|
-
const smartcatName = buildLocjsonFilename(type, docId, resolveDocumentTitle(schema, doc as never))
|
|
208
|
+
const smartcatName = buildLocjsonFilename(type, docId, resolveDocumentTitle(schema, doc as never), {draft: isDraft})
|
|
195
209
|
setFilename(smartcatName.split('/').pop() || smartcatName)
|
|
196
210
|
setContent(JSON.stringify(locjson, null, 2))
|
|
197
211
|
} catch (err) {
|
|
198
212
|
setError(err instanceof Error ? err.message : String(err))
|
|
199
213
|
}
|
|
200
|
-
}, [client, schema, docId, type, sourceLanguage, documentI18nLanguageField, fieldI18nLanguageField])
|
|
214
|
+
}, [client, schema, docId, type, sourceIsPublished, sourceLanguage, documentI18nLanguageField, fieldI18nLanguageField])
|
|
201
215
|
|
|
202
216
|
const download = useCallback(() => {
|
|
203
217
|
if (content == null || !filename) return
|
|
@@ -268,6 +282,7 @@ export function ProjectView({
|
|
|
268
282
|
onBack,
|
|
269
283
|
languages,
|
|
270
284
|
sourceLanguage,
|
|
285
|
+
translatableTypes,
|
|
271
286
|
documentI18nLanguageField,
|
|
272
287
|
fieldI18nLanguageField,
|
|
273
288
|
}: ProjectViewProps) {
|
|
@@ -588,6 +603,7 @@ export function ProjectView({
|
|
|
588
603
|
targetLanguages: targets,
|
|
589
604
|
sourceLanguage,
|
|
590
605
|
languages,
|
|
606
|
+
translatableTypes,
|
|
591
607
|
exclude: [documentI18nLanguageField],
|
|
592
608
|
fieldLanguageKey: fieldI18nLanguageField,
|
|
593
609
|
deletions,
|
|
@@ -865,16 +881,28 @@ export function ProjectView({
|
|
|
865
881
|
<Stack space={3}>
|
|
866
882
|
<Flex align="center" gap={3}>
|
|
867
883
|
<Flex align="center" gap={2}>
|
|
884
|
+
<Badge
|
|
885
|
+
tone={item.sourceIsPublished ? 'positive' : 'caution'}
|
|
886
|
+
paddingX={2}
|
|
887
|
+
title={
|
|
888
|
+
item.sourceIsPublished
|
|
889
|
+
? 'The published version is the translation source'
|
|
890
|
+
: 'The draft version is the translation source'
|
|
891
|
+
}
|
|
892
|
+
>
|
|
893
|
+
{item.sourceIsPublished ? 'Published' : 'Draft'}
|
|
894
|
+
</Badge>
|
|
868
895
|
<Text weight="medium">
|
|
869
896
|
<span style={isMarked ? {textDecoration: 'line-through'} : undefined}>
|
|
870
897
|
<DocTitle doc={item.doc} />
|
|
871
898
|
</span>
|
|
872
899
|
</Text>
|
|
873
|
-
{item.doc?._type && <Badge tone="primary">{item.doc._type}</Badge>}
|
|
900
|
+
{item.doc?._type && <Badge tone="primary" paddingX={2}>{item.doc._type}</Badge>}
|
|
874
901
|
{item.doc?._type && modeByType.has(item.doc._type) && (
|
|
875
902
|
<Badge
|
|
876
903
|
tone="default"
|
|
877
904
|
fontSize={0}
|
|
905
|
+
paddingX={2}
|
|
878
906
|
title={
|
|
879
907
|
modeByType.get(item.doc._type) === 'field'
|
|
880
908
|
? 'This item is translated in field mode; translations will be stored as language variants of each field'
|
|
@@ -889,6 +917,7 @@ export function ProjectView({
|
|
|
889
917
|
<ViewLocjsonButton
|
|
890
918
|
docId={item.doc._id}
|
|
891
919
|
type={item.doc._type}
|
|
920
|
+
sourceIsPublished={item.sourceIsPublished}
|
|
892
921
|
client={client}
|
|
893
922
|
schema={schema}
|
|
894
923
|
sourceLanguage={sourceLanguage}
|
package/src/tool/data.ts
CHANGED
|
@@ -18,6 +18,12 @@ export interface ProjectItemRef {
|
|
|
18
18
|
_key: string
|
|
19
19
|
/** Published id of the added document, normalized across old (`_ref`) and new (`docId`) items. */
|
|
20
20
|
docId: string
|
|
21
|
+
/**
|
|
22
|
+
* Whether the published version is the translation source (vs the draft),
|
|
23
|
+
* decided and stored when the item was added. Drives the Draft/Published pill
|
|
24
|
+
* and which version the export serializes. Null on legacy items (pre-flag).
|
|
25
|
+
*/
|
|
26
|
+
sourceIsPublished: boolean | null
|
|
21
27
|
doc: {_id: string; _type: string} | null
|
|
22
28
|
/** Locale variants linked via translation.metadata: language -> variant doc id. */
|
|
23
29
|
translations: {language: string; id: string}[] | null
|
|
@@ -66,6 +72,7 @@ export const PROJECT_DETAIL_QUERY = `*[_id == $id][0]{
|
|
|
66
72
|
items[]{
|
|
67
73
|
_key,
|
|
68
74
|
"docId": ${ITEM_ID},
|
|
75
|
+
sourceIsPublished,
|
|
69
76
|
"doc": ${itemDocDeref('{_id, _type}')},
|
|
70
77
|
"translations": *[_type == "translation.metadata" && references(${ITEM_ID_FROM_SUBQUERY})][0]
|
|
71
78
|
.translations[]{language, "id": value._ref}
|
|
@@ -141,10 +141,85 @@ describe('prepareExport', () => {
|
|
|
141
141
|
expect(locjson.properties['x-sanity'].documentId).toBe('page-1')
|
|
142
142
|
})
|
|
143
143
|
|
|
144
|
-
|
|
144
|
+
it('logs a config header with source language, languages, and translatable types', async () => {
|
|
145
|
+
const {client} = makeClient({project: {sourceLanguage: 'en', items: [{doc: sourceDoc}]}})
|
|
146
|
+
const logs: {level: string; message: string}[] = []
|
|
147
|
+
await prepareExport({
|
|
148
|
+
client, schema, projectId: 'p1', targetLanguages: ['fr'], sourceLanguage: 'en',
|
|
149
|
+
languages: LANGUAGES, translatableTypes: ['page', 'faq'], fieldLanguageKey: 'language',
|
|
150
|
+
onLog: (l) => logs.push(l),
|
|
151
|
+
})
|
|
152
|
+
const messages = logs.map((l) => l.message)
|
|
153
|
+
expect(messages).toContain('Config — source language: en')
|
|
154
|
+
expect(messages).toContain('translatable types: page, faq')
|
|
155
|
+
expect(messages.some((m) => /Project — source: en → targets: fr/.test(m))).toBe(true)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('reports "all types" in the config header when translatableTypes is omitted', async () => {
|
|
159
|
+
const {client} = makeClient({project: {sourceLanguage: 'en', items: [{doc: sourceDoc}]}})
|
|
160
|
+
const logs: {message: string}[] = []
|
|
161
|
+
await prepareExport({
|
|
162
|
+
client, schema, projectId: 'p1', targetLanguages: ['fr'], sourceLanguage: 'en',
|
|
163
|
+
languages: LANGUAGES, fieldLanguageKey: 'language', onLog: (l) => logs.push(l),
|
|
164
|
+
})
|
|
165
|
+
expect(logs.map((l) => l.message)).toContain('translatable types: all types')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('explains a source-language mismatch on a field-level field by naming the locales present', async () => {
|
|
169
|
+
// Field members exist only for en-GB, but the project source is 'en'.
|
|
170
|
+
const faqDoc = {
|
|
171
|
+
_id: 'faq-mismatch',
|
|
172
|
+
_type: 'faq',
|
|
173
|
+
question: [{_key: 'q', _type: 'internationalizedArrayStringValue', language: 'en-GB', value: 'How?'}],
|
|
174
|
+
}
|
|
175
|
+
const {client} = makeClient({project: {sourceLanguage: 'en', items: [{doc: faqDoc}]}})
|
|
176
|
+
const logs: {level: string; message: string}[] = []
|
|
177
|
+
await prepareExport({
|
|
178
|
+
client, schema: faqSchema, projectId: 'p1', targetLanguages: ['fr'], sourceLanguage: 'en',
|
|
179
|
+
languages: LANGUAGES, fieldLanguageKey: 'language', onLog: (l) => logs.push(l),
|
|
180
|
+
})
|
|
181
|
+
expect(logs.some((l) => /Question:.*no 'en' content \(I see only: en-GB\)/.test(l.message))).toBe(true)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('translates draft content under the canonical (bare) id and marks the file -draft', async () => {
|
|
185
|
+
// The item resolves to the draft (drafts.page-9); the export must keep the
|
|
186
|
+
// bare id as identity but serialize the draft's content and flag the file.
|
|
187
|
+
const draftDoc = {_id: 'drafts.page-9', _type: 'page', language: 'en', title: 'Draft Title', body: [block('Draft body.')]}
|
|
188
|
+
const {client, patches} = makeClient({
|
|
189
|
+
project: {sourceLanguage: 'en', items: [{docId: 'page-9', docType: 'page', doc: draftDoc}]},
|
|
190
|
+
})
|
|
191
|
+
const logs: {message: string}[] = []
|
|
192
|
+
const {prepared} = await prepareExport({
|
|
193
|
+
client, schema, projectId: 'p1', targetLanguages: ['fr'], sourceLanguage: 'en',
|
|
194
|
+
languages: LANGUAGES, fieldLanguageKey: 'language', onLog: (l) => logs.push(l),
|
|
195
|
+
})
|
|
196
|
+
expect(prepared).toBe(1)
|
|
197
|
+
const outbox = patches.at(-1)!.outbox as {sourceDocId: string; filename: string; locjson: string}[]
|
|
198
|
+
expect(outbox[0].sourceDocId).toBe('page-9') // bare, not drafts.page-9
|
|
199
|
+
expect(JSON.parse(outbox[0].locjson).properties['x-sanity'].documentId).toBe('page-9')
|
|
200
|
+
expect(outbox[0].filename).toMatch(/-draft\.locjson$/)
|
|
201
|
+
expect(logs.some((l) => /Draft Title.*\(draft\)/.test(l.message))).toBe(true)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('reports a project item that resolves to no document instead of dropping it silently', async () => {
|
|
205
|
+
const {client, patches} = makeClient({
|
|
206
|
+
project: {sourceLanguage: 'en', items: [{docId: 'missing-1', docType: 'page', doc: null}]},
|
|
207
|
+
})
|
|
208
|
+
const logs: {level: string; message: string}[] = []
|
|
209
|
+
const {prepared} = await prepareExport({
|
|
210
|
+
client, schema, projectId: 'p1', targetLanguages: ['fr'], sourceLanguage: 'en',
|
|
211
|
+
languages: LANGUAGES, fieldLanguageKey: 'language', onLog: (l) => logs.push(l),
|
|
212
|
+
})
|
|
213
|
+
expect(prepared).toBe(0)
|
|
214
|
+
expect(patches.at(-1)!.outbox).toEqual([])
|
|
215
|
+
expect(logs.some((l) => l.level === 'error' && /missing-1.*no document found/.test(l.message))).toBe(true)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// A custom block with no HTML representation (unlike `table`, which round-trips).
|
|
219
|
+
const heroBanner = {_type: 'heroBanner', _key: 'h1', heading: 'Save 20%'}
|
|
145
220
|
|
|
146
221
|
it('leaves out a field with an unserializable block but still sends the rest of the document', async () => {
|
|
147
|
-
const doc = {_id: 'page-2', _type: 'page', language: 'en', title: 'About Us', body: [block('Intro.'),
|
|
222
|
+
const doc = {_id: 'page-2', _type: 'page', language: 'en', title: 'About Us', body: [block('Intro.'), heroBanner]}
|
|
148
223
|
const {client, patches} = makeClient({project: {sourceLanguage: 'en', items: [{doc}]}})
|
|
149
224
|
const logs: {level: string; message: string}[] = []
|
|
150
225
|
|
|
@@ -157,13 +232,13 @@ describe('prepareExport', () => {
|
|
|
157
232
|
const outbox = patches.at(-1)!.outbox as {locjson: string}[]
|
|
158
233
|
const locjson = JSON.parse(outbox[0].locjson)
|
|
159
234
|
expect(locjson.units.map((u: {key: string}) => u.key)).toEqual(['title']) // body left out
|
|
160
|
-
expect(logs.some((l) => l.level === 'error' && /Body: not translated.*
|
|
235
|
+
expect(logs.some((l) => l.level === 'error' && /Body: not translated.*heroBanner/.test(l.message))).toBe(true)
|
|
161
236
|
})
|
|
162
237
|
|
|
163
238
|
it('does not emit a zero-unit LocJSON when the only content is in an unserializable field', async () => {
|
|
164
239
|
// A single-field document whose one field can't be serialized: it must not be
|
|
165
240
|
// sent at all (no empty LocJSON), and the whole document is flagged.
|
|
166
|
-
const doc = {_id: 'page-3', _type: 'page', language: 'en', title: '', body: [
|
|
241
|
+
const doc = {_id: 'page-3', _type: 'page', language: 'en', title: '', body: [heroBanner]}
|
|
167
242
|
const {client, patches} = makeClient({project: {sourceLanguage: 'en', items: [{doc}]}})
|
|
168
243
|
const logs: {level: string; message: string}[] = []
|
|
169
244
|
|
package/src/tool/processing.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {uuid} from '@sanity/uuid'
|
|
2
2
|
import {resolveDocumentTitle} from '../lib/documentTitle'
|
|
3
|
-
import {
|
|
3
|
+
import {ITEM_ID, itemDocDerefRespectingDraft} from '../lib/projectItems'
|
|
4
4
|
import {
|
|
5
5
|
enumerateLeaves,
|
|
6
6
|
getTranslatableFields,
|
|
@@ -68,6 +68,26 @@ function errorMessage(err: unknown): string {
|
|
|
68
68
|
return err instanceof Error ? err.message : String(err)
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* For a field-level (internationalized-array) field, the member locales that
|
|
73
|
+
* actually carry content — so a "no source content" skip can say *which* locales
|
|
74
|
+
* exist ("I see only: en-GB") when the source-language member is simply missing,
|
|
75
|
+
* turning a silent source-language mismatch into a self-explaining log line.
|
|
76
|
+
*/
|
|
77
|
+
function memberLocalesWithContent(members: unknown, languageKey: string): string[] {
|
|
78
|
+
if (!Array.isArray(members)) return []
|
|
79
|
+
const locales: string[] = []
|
|
80
|
+
for (const member of members) {
|
|
81
|
+
if (!member || typeof member !== 'object') continue
|
|
82
|
+
const locale = (member as Record<string, unknown>)[languageKey]
|
|
83
|
+
const value = (member as {value?: unknown}).value
|
|
84
|
+
const hasContent =
|
|
85
|
+
typeof value === 'string' ? value.trim().length > 0 : Array.isArray(value) ? value.length > 0 : value != null
|
|
86
|
+
if (typeof locale === 'string' && hasContent) locales.push(locale)
|
|
87
|
+
}
|
|
88
|
+
return locales
|
|
89
|
+
}
|
|
90
|
+
|
|
71
91
|
/** Top-level, non-system schema field names for a type (for "not translatable" reporting). */
|
|
72
92
|
function schemaFieldNames(schema: ProcessingSchema, type: string): string[] {
|
|
73
93
|
const compiled = schema.get(type) as {fields?: {name?: string}[]} | undefined
|
|
@@ -106,6 +126,8 @@ export interface PrepareExportArgs {
|
|
|
106
126
|
sourceLanguage: string
|
|
107
127
|
/** Configured languages, used to resolve each id → its Smartcat code. */
|
|
108
128
|
languages: SmartcatLanguage[]
|
|
129
|
+
/** Configured translatable types (undefined = all), logged in the config header. */
|
|
130
|
+
translatableTypes?: string[]
|
|
109
131
|
exclude?: string[]
|
|
110
132
|
/** How internationalized-array members identify their locale (field name or `'_key'`). */
|
|
111
133
|
fieldLanguageKey: string
|
|
@@ -115,7 +137,14 @@ export interface PrepareExportArgs {
|
|
|
115
137
|
onLog?: LogFn
|
|
116
138
|
}
|
|
117
139
|
|
|
118
|
-
const ITEMS_QUERY = `*[_id == $id][0]{
|
|
140
|
+
const ITEMS_QUERY = `*[_id == $id][0]{
|
|
141
|
+
sourceLanguage,
|
|
142
|
+
items[]{
|
|
143
|
+
"docId": ${ITEM_ID},
|
|
144
|
+
"docType": docType,
|
|
145
|
+
"doc": ${itemDocDerefRespectingDraft()}
|
|
146
|
+
}
|
|
147
|
+
}`
|
|
119
148
|
|
|
120
149
|
export async function prepareExport({
|
|
121
150
|
client,
|
|
@@ -124,6 +153,7 @@ export async function prepareExport({
|
|
|
124
153
|
targetLanguages,
|
|
125
154
|
sourceLanguage,
|
|
126
155
|
languages,
|
|
156
|
+
translatableTypes,
|
|
127
157
|
exclude,
|
|
128
158
|
fieldLanguageKey,
|
|
129
159
|
deletions = [],
|
|
@@ -132,7 +162,11 @@ export async function prepareExport({
|
|
|
132
162
|
const log: LogFn = onLog ?? (() => {})
|
|
133
163
|
const project = await client.fetch<{
|
|
134
164
|
sourceLanguage?: string
|
|
135
|
-
items?: {
|
|
165
|
+
items?: {
|
|
166
|
+
docId?: string
|
|
167
|
+
docType?: string
|
|
168
|
+
doc: (Record<string, unknown> & {_id: string; _type: string}) | null
|
|
169
|
+
}[]
|
|
136
170
|
} | null>(ITEMS_QUERY, {id: projectId})
|
|
137
171
|
|
|
138
172
|
const source = project?.sourceLanguage || sourceLanguage
|
|
@@ -150,10 +184,16 @@ export async function prepareExport({
|
|
|
150
184
|
}
|
|
151
185
|
// Don't re-upload documents that are being deleted this round.
|
|
152
186
|
const deletedDocIds = new Set(deletions.map((d) => d.sourceDocId))
|
|
153
|
-
|
|
154
|
-
|
|
187
|
+
// Resolve each project item to its (draft-or-published) document. An item that
|
|
188
|
+
// derefs to nothing — deleted, or expected published but never published — is
|
|
189
|
+
// reported below rather than silently dropped.
|
|
190
|
+
const items = (project?.items ?? []).filter(
|
|
191
|
+
(item) => !(item.docId && deletedDocIds.has(item.docId)),
|
|
192
|
+
)
|
|
193
|
+
const unresolved = items.filter((item) => !item.doc)
|
|
194
|
+
const docs = items
|
|
195
|
+
.map((item) => item.doc)
|
|
155
196
|
.filter((d): d is Record<string, unknown> & {_id: string; _type: string} => Boolean(d))
|
|
156
|
-
.filter((d) => !deletedDocIds.has(d._id))
|
|
157
197
|
|
|
158
198
|
// The translatable field list is per-type, so compute it once per type even
|
|
159
199
|
// when a project holds many documents of the same type.
|
|
@@ -167,12 +207,36 @@ export async function prepareExport({
|
|
|
167
207
|
return fields
|
|
168
208
|
}
|
|
169
209
|
|
|
170
|
-
|
|
210
|
+
// Config header — surfaces the resolved plugin config and this project's
|
|
211
|
+
// languages up front, so a "nothing serialized" run can be traced to a
|
|
212
|
+
// source-language or translatable-types mismatch straight from the log.
|
|
213
|
+
const configuredLanguages = languages.map((l) => l.id).join(', ') || '(none)'
|
|
214
|
+
const configuredTypes =
|
|
215
|
+
translatableTypes === undefined ? 'all types' : translatableTypes.join(', ') || '(none)'
|
|
216
|
+
log({level: 'info', message: `Config — source language: ${sourceLanguage}`})
|
|
217
|
+
log({level: 'info', indent: true, message: `languages: ${configuredLanguages}`})
|
|
218
|
+
log({level: 'info', indent: true, message: `translatable types: ${configuredTypes}`})
|
|
219
|
+
log({level: 'info', message: `Project — source: ${source} → targets: ${targetLanguages.join(', ') || '(no targets)'}`})
|
|
220
|
+
log({level: 'info', message: `Export — ${docs.length} document(s)`})
|
|
221
|
+
|
|
222
|
+
// Items that resolved to no document at all: don't drop them silently — the
|
|
223
|
+
// usual cause is a document that was never published (or was deleted).
|
|
224
|
+
for (const item of unresolved) {
|
|
225
|
+
log({
|
|
226
|
+
level: 'error',
|
|
227
|
+
message: `${item.docId ?? 'unknown document'} (${item.docType ?? 'unknown type'}): no document found — it may be unpublished or deleted; skipped`,
|
|
228
|
+
})
|
|
229
|
+
}
|
|
171
230
|
|
|
172
231
|
const outbox: {_key: string; sourceDocId: string; title?: string; filename: string; locjson: string}[] = []
|
|
173
|
-
for (const
|
|
232
|
+
for (const rawDoc of docs) {
|
|
233
|
+
// Serialize under the canonical (bare) id so identity, metadata and progress
|
|
234
|
+
// stay stable, even when the *content* came from the draft. Mark draft-sourced
|
|
235
|
+
// files so they're recognizable in Smartcat.
|
|
236
|
+
const isDraft = rawDoc._id.startsWith('drafts.')
|
|
237
|
+
const doc = isDraft ? {...rawDoc, _id: publishedId(rawDoc._id)} : rawDoc
|
|
174
238
|
const name = resolveDocumentTitle(schema, doc)
|
|
175
|
-
log({level: 'info', message: `${name || doc._id} · ${doc._type}`})
|
|
239
|
+
log({level: 'info', message: `${name || doc._id} · ${doc._type}${isDraft ? ' (draft)' : ''}`})
|
|
176
240
|
try {
|
|
177
241
|
const fields = fieldsFor(doc._type)
|
|
178
242
|
// Fields skipped because they hold a block type that can't be serialized
|
|
@@ -198,7 +262,16 @@ export async function prepareExport({
|
|
|
198
262
|
message: `${field.title || field.path}: not translated — contains block type(s) that can't be translated: ${skippedTypes.join(', ')}. The whole field was left untranslated to avoid losing this content.`,
|
|
199
263
|
})
|
|
200
264
|
} else {
|
|
201
|
-
|
|
265
|
+
// Field-level with members in other locales but not the source is a
|
|
266
|
+
// source-language mismatch, not an empty field — name the locales seen.
|
|
267
|
+
const otherLocales =
|
|
268
|
+
field.localization === 'field'
|
|
269
|
+
? memberLocalesWithContent(getAtPath(doc, field.path), fieldLanguageKey).filter((l) => l !== source)
|
|
270
|
+
: []
|
|
271
|
+
const reason = otherLocales.length
|
|
272
|
+
? `no '${source}' content (I see only: ${otherLocales.join(', ')})`
|
|
273
|
+
: 'no source content'
|
|
274
|
+
log({level: 'skip', indent: true, message: `${field.title || field.path}: skipped — ${reason}`})
|
|
202
275
|
}
|
|
203
276
|
}
|
|
204
277
|
// Surface the rest of the type's fields too, so a missing translation is
|
|
@@ -228,7 +301,7 @@ export async function prepareExport({
|
|
|
228
301
|
_key: uuid(),
|
|
229
302
|
sourceDocId: doc._id,
|
|
230
303
|
title: name,
|
|
231
|
-
filename: buildLocjsonFilename(doc._type, doc._id, name),
|
|
304
|
+
filename: buildLocjsonFilename(doc._type, doc._id, name, {draft: isDraft}),
|
|
232
305
|
locjson: JSON.stringify(locjson),
|
|
233
306
|
})
|
|
234
307
|
} catch (err) {
|