@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.
@@ -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 {itemDocDeref} from '../lib/projectItems'
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`). Used to correlate a Smartcat
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
- * The prefix is always the final 8 characters before the extension, so take the
128
- * tail rather than splitting on a dash the title or the id prefix itself can
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
- return noExt.slice(-8)
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[]{ "doc": ${itemDocDeref('{_id}')} },
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?: {doc: {_id: string} | null}[]
312
+ items?: ProjectItemRef[]
305
313
  progress?: DocProgress[]
306
314
  }
307
315
 
308
- /** Resolves a project's referenced items into the lightweight shape we correlate on. */
309
- export function itemsFromProject(project: {
310
- items?: {doc: {_id: string} | null}[]
311
- }): ProjectItem[] {
312
- return (project.items ?? [])
313
- .map((i) => i?.doc)
314
- .filter((d): d is {_id: string} => Boolean(d))
315
- .map((d) => ({_id: d._id}))
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
  }),
@@ -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} = config
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
  />
@@ -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
- const doc = await client.fetch('*[_id == $id][0]', {id: docId})
186
- if (!doc) throw new Error(`Document not found: ${docId}`)
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
- const table = {_type: 'table', _key: 't1', rows: [{_key: 'r1', _type: 'tableRow', cells: ['A', 'B']}]}
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.'), table]}
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.*table/.test(l.message))).toBe(true)
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: [table]}
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
 
@@ -1,6 +1,6 @@
1
1
  import {uuid} from '@sanity/uuid'
2
2
  import {resolveDocumentTitle} from '../lib/documentTitle'
3
- import {itemDocDeref} from '../lib/projectItems'
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]{ sourceLanguage, items[]{ "doc": ${itemDocDeref()} } }`
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?: {doc: (Record<string, unknown> & {_id: string; _type: string}) | null}[]
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
- const docs = (project?.items ?? [])
154
- .map((i) => i?.doc)
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
- log({level: 'info', message: `Export ${docs.length} document(s) ${targetLanguages.join(', ') || '(no targets)'}`})
210
+ // Config headersurfaces 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 doc of docs) {
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
- log({level: 'skip', indent: true, message: `${field.title || field.path}: skipped no source content`})
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) {