@smartcat/sanity-plugin 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +294 -0
  2. package/dist/_chunks-cjs/constants.cjs +13 -0
  3. package/dist/_chunks-cjs/constants.cjs.map +1 -0
  4. package/dist/_chunks-cjs/index.cjs +168 -0
  5. package/dist/_chunks-cjs/index.cjs.map +1 -0
  6. package/dist/_chunks-cjs/index2.cjs +311 -0
  7. package/dist/_chunks-cjs/index2.cjs.map +1 -0
  8. package/dist/_chunks-cjs/workflow.cjs +16 -0
  9. package/dist/_chunks-cjs/workflow.cjs.map +1 -0
  10. package/dist/_chunks-es/constants.js +14 -0
  11. package/dist/_chunks-es/constants.js.map +1 -0
  12. package/dist/_chunks-es/index.js +169 -0
  13. package/dist/_chunks-es/index.js.map +1 -0
  14. package/dist/_chunks-es/index2.js +313 -0
  15. package/dist/_chunks-es/index2.js.map +1 -0
  16. package/dist/_chunks-es/workflow.js +17 -0
  17. package/dist/_chunks-es/workflow.js.map +1 -0
  18. package/dist/export.cjs +160 -0
  19. package/dist/export.cjs.map +1 -0
  20. package/dist/export.d.cts +162 -0
  21. package/dist/export.d.ts +162 -0
  22. package/dist/export.js +162 -0
  23. package/dist/export.js.map +1 -0
  24. package/dist/import.cjs +169 -0
  25. package/dist/import.cjs.map +1 -0
  26. package/dist/import.d.cts +155 -0
  27. package/dist/import.d.ts +155 -0
  28. package/dist/import.js +169 -0
  29. package/dist/import.js.map +1 -0
  30. package/dist/index.cjs +2103 -0
  31. package/dist/index.cjs.map +1 -0
  32. package/dist/index.d.cts +122 -0
  33. package/dist/index.d.ts +122 -0
  34. package/dist/index.js +2110 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/locjson.cjs +12 -0
  37. package/dist/locjson.cjs.map +1 -0
  38. package/dist/locjson.d.cts +287 -0
  39. package/dist/locjson.d.ts +287 -0
  40. package/dist/locjson.js +12 -0
  41. package/dist/locjson.js.map +1 -0
  42. package/dist/progress.cjs +16 -0
  43. package/dist/progress.cjs.map +1 -0
  44. package/dist/progress.d.cts +262 -0
  45. package/dist/progress.d.ts +262 -0
  46. package/dist/progress.js +16 -0
  47. package/dist/progress.js.map +1 -0
  48. package/dist/smartcat.cjs +287 -0
  49. package/dist/smartcat.cjs.map +1 -0
  50. package/dist/smartcat.d.cts +234 -0
  51. package/dist/smartcat.d.ts +234 -0
  52. package/dist/smartcat.js +287 -0
  53. package/dist/smartcat.js.map +1 -0
  54. package/dist/templates.cjs +12 -0
  55. package/dist/templates.cjs.map +1 -0
  56. package/dist/templates.d.cts +52 -0
  57. package/dist/templates.d.ts +52 -0
  58. package/dist/templates.js +12 -0
  59. package/dist/templates.js.map +1 -0
  60. package/package.json +101 -15
  61. package/src/actions/AddToProjectAction.tsx +258 -0
  62. package/src/export/export.test.ts +537 -0
  63. package/src/export/index.ts +393 -0
  64. package/src/form/TranslationStatusInput.tsx +212 -0
  65. package/src/import/import.test.ts +346 -0
  66. package/src/import/index.ts +418 -0
  67. package/src/index.ts +63 -0
  68. package/src/lib/constants.ts +23 -0
  69. package/src/lib/documentTitle.test.ts +43 -0
  70. package/src/lib/documentTitle.ts +30 -0
  71. package/src/lib/languageMap.test.ts +56 -0
  72. package/src/lib/languageMap.ts +71 -0
  73. package/src/lib/linkedDocuments.test.ts +56 -0
  74. package/src/lib/linkedDocuments.ts +100 -0
  75. package/src/lib/locjson/deserialize.ts +60 -0
  76. package/src/lib/locjson/fields.ts +355 -0
  77. package/src/lib/locjson/filename.ts +27 -0
  78. package/src/lib/locjson/index.ts +10 -0
  79. package/src/lib/locjson/locjson.test.ts +545 -0
  80. package/src/lib/locjson/paths.test.ts +41 -0
  81. package/src/lib/locjson/paths.ts +73 -0
  82. package/src/lib/locjson/portableText.ts +98 -0
  83. package/src/lib/locjson/serialize.ts +124 -0
  84. package/src/lib/locjson/types.ts +106 -0
  85. package/src/lib/log.ts +33 -0
  86. package/src/lib/projectItems.ts +17 -0
  87. package/src/lib/resolveConfig.test.ts +62 -0
  88. package/src/lib/resolveConfig.ts +47 -0
  89. package/src/lib/workflow.test.ts +37 -0
  90. package/src/lib/workflow.ts +48 -0
  91. package/src/lib/zip.fixtures.ts +75 -0
  92. package/src/lib/zip.test.ts +110 -0
  93. package/src/lib/zip.ts +164 -0
  94. package/src/progress/core.ts +387 -0
  95. package/src/progress/index.ts +7 -0
  96. package/src/progress/progress.test.ts +268 -0
  97. package/src/schema/settings.ts +55 -0
  98. package/src/schema/translatableOptions.ts +25 -0
  99. package/src/schema/translationProject.ts +264 -0
  100. package/src/smartcat/client.test.ts +222 -0
  101. package/src/smartcat/client.ts +351 -0
  102. package/src/smartcat/index.ts +10 -0
  103. package/src/smartcat/types.ts +99 -0
  104. package/src/templates/core.ts +55 -0
  105. package/src/templates/index.ts +5 -0
  106. package/src/templates/templates.test.ts +50 -0
  107. package/src/tool/Dashboard.tsx +44 -0
  108. package/src/tool/ItemProgress.tsx +176 -0
  109. package/src/tool/LogPanel.tsx +207 -0
  110. package/src/tool/ProjectView.tsx +975 -0
  111. package/src/tool/ProjectsView.tsx +208 -0
  112. package/src/tool/StatusBadge.tsx +33 -0
  113. package/src/tool/StudioInit.tsx +42 -0
  114. package/src/tool/WorkflowSelect.tsx +41 -0
  115. package/src/tool/data.ts +79 -0
  116. package/src/tool/index.ts +19 -0
  117. package/src/tool/processing.test.ts +623 -0
  118. package/src/tool/processing.ts +766 -0
  119. package/src/tool/useTemplates.ts +84 -0
  120. package/src/tool/useWorkflowSelection.ts +65 -0
  121. package/src/types.ts +77 -0
@@ -0,0 +1,418 @@
1
+ import type {SmartcatDocument, SmartcatProject} from '../smartcat/types'
2
+ import {buildProgress, hasConfirmedContent, itemsFromProject, type DocProgress} from '../progress/core'
3
+ import {itemDocDeref} from '../lib/projectItems'
4
+ import type {SmartcatLanguageMapping} from '../lib/languageMap'
5
+ import type {LogLine} from '../lib/log'
6
+
7
+ /** The Smartcat document's full path (folder + filename), extension stripped. */
8
+ function smartcatDocPath(scDoc: SmartcatDocument | undefined): string {
9
+ const raw = scDoc?.fullPath || scDoc?.name || scDoc?.filename || ''
10
+ return raw.replace(/\.[^./]+$/, '')
11
+ }
12
+
13
+ /** Minimal structural subset of @sanity/client used by the import download. */
14
+ export interface SanityLikeClient {
15
+ fetch<T = unknown>(query: string, params?: Record<string, unknown>): Promise<T>
16
+ patch(id: string): {
17
+ set(attrs: Record<string, unknown>): {commit(): Promise<unknown>}
18
+ }
19
+ }
20
+
21
+ /** Structural subset of SmartcatClient the import download needs. */
22
+ export interface SmartcatImportClient {
23
+ getProject(projectId: string): Promise<SmartcatProject>
24
+ exportDocument(documentId: string): Promise<string>
25
+ /**
26
+ * Batch export: one Smartcat task for many documents (ZIP result). Optional
27
+ * so custom/legacy clients keep working — without it every document falls
28
+ * back to a single {@link exportDocument} round-trip.
29
+ */
30
+ exportDocumentsBatch?(documentIds: string[]): Promise<{filename: string; content: string}[]>
31
+ }
32
+
33
+ export interface RunImportDownloadOptions {
34
+ sanity: SanityLikeClient
35
+ smartcat: SmartcatImportClient
36
+ /** `_id` of the `smartcat.translationProject` to import. */
37
+ projectId: string
38
+ now?: () => string
39
+ /** Documents per Smartcat batch-export task. */
40
+ batchSize?: number
41
+ /**
42
+ * Soft cap on the total LocJSON bytes stored in the project's `inbox` per
43
+ * run. The inbox lives on the Sanity project document, which has a practical
44
+ * size limit — targets beyond the cap are reported as `remaining` and picked
45
+ * up by the next import run.
46
+ */
47
+ maxInboxBytes?: number
48
+ /** How many batch-export tasks run against Smartcat concurrently. */
49
+ concurrency?: number
50
+ /**
51
+ * Soft time budget for the download phase, in ms. Once exceeded, batches not
52
+ * yet started are deferred as `remaining` and the run exits cleanly — so the
53
+ * Function always finishes well before its hard platform timeout instead of
54
+ * being killed mid-run (which would leave the project stuck in "importing").
55
+ */
56
+ timeBudgetMs?: number
57
+ /** Clock override for tests (ms). */
58
+ nowMs?: () => number
59
+ /**
60
+ * Receives every log line as it is emitted (in addition to the functionLog
61
+ * stored on the project document) — the Function forwards these to console
62
+ * so `sanity functions logs` shows the run without reading the document.
63
+ */
64
+ onLog?: (line: LogLine) => void
65
+ }
66
+
67
+ export interface RunImportDownloadResult {
68
+ /** Number of complete targets downloaded into the inbox. */
69
+ downloaded: number
70
+ /** Number of targets skipped because they are not yet fully translated. */
71
+ skipped: number
72
+ /** Targets ready to download but deferred to the next run (inbox size cap). */
73
+ remaining: number
74
+ /** Targets already imported in a previous run and therefore not re-downloaded. */
75
+ alreadyImported: number
76
+ }
77
+
78
+ const DEFAULT_BATCH_SIZE = 50
79
+ /** Keeps the project document comfortably below Sanity's document size limits. */
80
+ const DEFAULT_MAX_INBOX_BYTES = 1_500_000
81
+ /**
82
+ * Parallel Smartcat export tasks. High enough to divide the download time ~4×,
83
+ * low enough not to crowd the account's export-task queue.
84
+ */
85
+ const DEFAULT_CONCURRENCY = 4
86
+ /** Exit-early margin against the 15-minute Sanity Function timeout. */
87
+ const DEFAULT_TIME_BUDGET_MS = 10 * 60_000
88
+
89
+ const PROJECT_QUERY = `*[_id == $id][0]{
90
+ _id,
91
+ smartcatProjectId,
92
+ smartcatLanguages[]{sanityId, smartcatLanguage},
93
+ "items": items[]{ "doc": ${itemDocDeref('{_id, title}')} },
94
+ progress[]{_key, sourceDocId, title, targets[]{_key, language, smartcatDocumentId, stages, complete, imported, syncedAt}}
95
+ }`
96
+
97
+ interface ProjectData {
98
+ _id: string
99
+ smartcatProjectId?: string
100
+ smartcatLanguages?: SmartcatLanguageMapping[]
101
+ items?: {doc: {_id: string; title?: string} | null}[]
102
+ progress?: DocProgress[]
103
+ }
104
+
105
+ interface InboxItem {
106
+ _key: string
107
+ sourceDocId: string
108
+ targetLanguage: string
109
+ smartcatDocumentId: string
110
+ locjson: string
111
+ }
112
+
113
+ /** A (document × language) target selected for download this run. */
114
+ interface ReadyTarget {
115
+ key: string
116
+ sourceDocId: string
117
+ language: string
118
+ smartcatDocumentId: string
119
+ label: string
120
+ }
121
+
122
+ function stripDraft(id: string): string {
123
+ return id.replace(/^drafts\./, '')
124
+ }
125
+
126
+ /** The Sanity source-document id a LocJSON carries in its `x-sanity` metadata. */
127
+ function sanityDocIdOf(locjson: string): string | undefined {
128
+ try {
129
+ const parsed = JSON.parse(locjson) as {properties?: {'x-sanity'?: {documentId?: string}}}
130
+ return parsed.properties?.['x-sanity']?.documentId
131
+ } catch {
132
+ return undefined
133
+ }
134
+ }
135
+
136
+ interface BatchDownloadResult {
137
+ files: {target: ReadyTarget; locjson: string}[]
138
+ /** Targets fetched one-by-one because the batched call failed or its archive missed them. */
139
+ fallbackCount: number
140
+ /** The batched export call itself failed (the error line is already emitted). */
141
+ batchFailed: boolean
142
+ }
143
+
144
+ /**
145
+ * Downloads a batch of same-language targets. Uses one batched export task
146
+ * (ZIP) when the client supports it, correlating each returned file back to
147
+ * its target via the LocJSON's own `x-sanity.documentId`. Targets the archive
148
+ * didn't cover — and the whole batch when the batched call fails — fall back
149
+ * to per-document exports, so a batch problem degrades to the old (slow but
150
+ * proven) path instead of failing the run. Every degradation is logged loudly:
151
+ * a silent fallback looks exactly like "batching deployed but nothing faster".
152
+ */
153
+ async function downloadBatch(
154
+ smartcat: SmartcatImportClient,
155
+ batch: ReadyTarget[],
156
+ emit: (line: LogLine) => void,
157
+ ): Promise<BatchDownloadResult> {
158
+ const contentByTarget = new Map<ReadyTarget, string>()
159
+ const batchAttempted = Boolean(smartcat.exportDocumentsBatch && batch.length > 1)
160
+ let batchFailed = false
161
+
162
+ if (batchAttempted) {
163
+ try {
164
+ const files = await smartcat.exportDocumentsBatch!(batch.map((t) => t.smartcatDocumentId))
165
+ const targetByDocId = new Map(batch.map((t) => [stripDraft(t.sourceDocId), t]))
166
+ for (const file of files) {
167
+ const docId = sanityDocIdOf(file.content)
168
+ const target = docId ? targetByDocId.get(stripDraft(docId)) : undefined
169
+ if (target && !contentByTarget.has(target)) contentByTarget.set(target, file.content)
170
+ }
171
+ if (contentByTarget.size < batch.length) {
172
+ emit({
173
+ level: 'info',
174
+ message: `${batch.length - contentByTarget.size} of ${batch.length} file(s) missing from the batch archive — fetching them per-document`,
175
+ })
176
+ }
177
+ } catch (err) {
178
+ batchFailed = true
179
+ emit({
180
+ level: 'error',
181
+ message: `Batch export of ${batch.length} document(s) failed — falling back to per-document export: ${err instanceof Error ? err.message : String(err)}`,
182
+ })
183
+ }
184
+ }
185
+
186
+ const files: {target: ReadyTarget; locjson: string}[] = []
187
+ let fallbackCount = 0
188
+ for (const target of batch) {
189
+ let locjson = contentByTarget.get(target)
190
+ if (locjson === undefined) {
191
+ locjson = await smartcat.exportDocument(target.smartcatDocumentId)
192
+ if (batchAttempted) fallbackCount++
193
+ }
194
+ files.push({target, locjson})
195
+ }
196
+ return {files, fallbackCount, batchFailed}
197
+ }
198
+
199
+ /**
200
+ * Thin import step run by a Sanity Function: refreshes per-stage progress from
201
+ * Smartcat, then downloads the ready targets into the project's `inbox` and
202
+ * sets status to `downloaded`. The browser turns the inbox into locale
203
+ * variants and decides final completion.
204
+ *
205
+ * Downloads happen in **batches** (one Smartcat export task per ~50 documents,
206
+ * returned as a ZIP), so run time no longer scales with per-document polling —
207
+ * large projects fit comfortably inside the Function timeout. The inbox is
208
+ * size-capped per run; overflow targets are reported as `remaining` and the
209
+ * next import run picks them up (already-imported targets are skipped).
210
+ *
211
+ * Targets with nothing confirmed are skipped, so no untranslated variants are
212
+ * created. Does no content processing, so it depends only on the Smartcat client.
213
+ */
214
+ export async function runImportDownload(
215
+ options: RunImportDownloadOptions,
216
+ ): Promise<RunImportDownloadResult> {
217
+ const {
218
+ sanity,
219
+ smartcat,
220
+ projectId,
221
+ now = () => new Date().toISOString(),
222
+ batchSize = DEFAULT_BATCH_SIZE,
223
+ maxInboxBytes = DEFAULT_MAX_INBOX_BYTES,
224
+ concurrency = DEFAULT_CONCURRENCY,
225
+ timeBudgetMs = DEFAULT_TIME_BUDGET_MS,
226
+ nowMs = Date.now,
227
+ onLog,
228
+ } = options
229
+ const log: LogLine[] = []
230
+ const emit = (line: LogLine) => {
231
+ log.push(line)
232
+ onLog?.(line)
233
+ }
234
+
235
+ const project = await sanity.fetch<ProjectData | null>(PROJECT_QUERY, {id: projectId})
236
+ if (!project) throw new Error(`Translation project ${projectId} not found`)
237
+
238
+ try {
239
+ if (!project.smartcatProjectId) {
240
+ throw new Error('Project has no smartcatProjectId — export it first')
241
+ }
242
+
243
+ const timestamp = now()
244
+ const scProject = await smartcat.getProject(project.smartcatProjectId)
245
+ emit({level: 'info', message: 'Checking Smartcat for completed translations'})
246
+ // Look up each target's Smartcat document by its id, for full-path logging.
247
+ const scDocById = new Map((scProject.documents ?? []).filter((d) => d.id).map((d) => [d.id, d]))
248
+
249
+ // Rebuild progress from Smartcat + the project's items, so the dashboard
250
+ // reflects the latest state even for targets we don't import this round.
251
+ const progress = buildProgress(
252
+ itemsFromProject(project),
253
+ scProject,
254
+ project.progress,
255
+ timestamp,
256
+ project.smartcatLanguages,
257
+ )
258
+
259
+ // Collect every target that has confirmed content (at any stage); targets
260
+ // with nothing confirmed are skipped. Targets already imported in an
261
+ // earlier run are not re-downloaded, so capped runs make forward progress.
262
+ const ready: ReadyTarget[] = []
263
+ let skipped = 0
264
+ let alreadyImported = 0
265
+ for (const docProgress of progress) {
266
+ for (const target of docProgress.targets) {
267
+ const id = target.smartcatDocumentId
268
+ // Prefer the Smartcat full path; fall back to the resolved title / source id.
269
+ const label =
270
+ (id && smartcatDocPath(scDocById.get(id))) || docProgress.title || docProgress.sourceDocId
271
+ if (!id || !hasConfirmedContent(target.stages)) {
272
+ if (id && !target.imported) {
273
+ skipped++
274
+ emit({level: 'skip', message: `${label} (Smartcat ID: ${id}) → ${target.language}: not ready, skipped`})
275
+ }
276
+ continue
277
+ }
278
+ if (target.imported) {
279
+ alreadyImported++
280
+ continue
281
+ }
282
+ ready.push({
283
+ key: target._key,
284
+ sourceDocId: docProgress.sourceDocId,
285
+ language: target.language,
286
+ smartcatDocumentId: id,
287
+ label,
288
+ })
289
+ }
290
+ }
291
+
292
+ // Download in batches: one Smartcat export task per batch (ZIP result)
293
+ // instead of a request+poll round-trip per document. Batches are grouped
294
+ // by language so each source document appears at most once per archive.
295
+ const inbox: InboxItem[] = []
296
+ let inboxBytes = 0
297
+ let remaining = 0
298
+ const byLanguage = new Map<string, ReadyTarget[]>()
299
+ for (const target of ready) {
300
+ const group = byLanguage.get(target.language) ?? []
301
+ group.push(target)
302
+ byLanguage.set(target.language, group)
303
+ }
304
+
305
+ const batches: ReadyTarget[][] = []
306
+ for (const group of byLanguage.values()) {
307
+ for (let i = 0; i < group.length; i += batchSize) {
308
+ batches.push(group.slice(i, i + batchSize))
309
+ }
310
+ }
311
+
312
+ // Worker pool: up to `concurrency` batch-export tasks in flight at once —
313
+ // Smartcat assembles the archives in parallel, dividing the download time.
314
+ // Each worker re-checks the caps before claiming the next batch:
315
+ // - inbox byte cap: the inbox lives on the project document, which must
316
+ // stay below Sanity's document size limit;
317
+ // - time budget: exit cleanly before the platform kills the Function, so a
318
+ // slow Smartcat day degrades to "continue in the next run", never to a
319
+ // project stuck in "importing".
320
+ const effectiveConcurrency = Math.max(1, Math.min(concurrency, batches.length))
321
+ if (batches.length > 0) {
322
+ emit({
323
+ level: 'info',
324
+ message: `Downloading ${ready.length} target(s) in ${batches.length} batch(es) of up to ${batchSize}, ${effectiveConcurrency} in parallel`,
325
+ })
326
+ }
327
+ const startedAtMs = nowMs()
328
+ let deferredByBytes = 0
329
+ let deferredByTime = 0
330
+ let fallbackDownloads = 0
331
+ let failedBatches = 0
332
+ let nextBatch = 0
333
+ const worker = async () => {
334
+ while (nextBatch < batches.length) {
335
+ const index = nextBatch++
336
+ const batch = batches[index]
337
+ if (inboxBytes >= maxInboxBytes) {
338
+ deferredByBytes += batch.length
339
+ continue
340
+ }
341
+ if (nowMs() - startedAtMs >= timeBudgetMs) {
342
+ deferredByTime += batch.length
343
+ continue
344
+ }
345
+ const batchStartMs = nowMs()
346
+ const {files, fallbackCount, batchFailed} = await downloadBatch(smartcat, batch, emit)
347
+ fallbackDownloads += fallbackCount
348
+ if (batchFailed) failedBatches++
349
+ for (const {target, locjson} of files) {
350
+ inbox.push({
351
+ _key: target.key,
352
+ sourceDocId: target.sourceDocId,
353
+ targetLanguage: target.language,
354
+ smartcatDocumentId: target.smartcatDocumentId,
355
+ locjson,
356
+ })
357
+ inboxBytes += locjson.length
358
+ }
359
+ emit({
360
+ level: 'success',
361
+ message:
362
+ `Batch ${index + 1}/${batches.length} (${batch[0].language}): ${files.length} doc(s) in ` +
363
+ `${((nowMs() - batchStartMs) / 1000).toFixed(1)}s` +
364
+ `${fallbackCount ? ` — ${fallbackCount} via per-document fallback` : ''}`,
365
+ })
366
+ }
367
+ }
368
+ await Promise.all(Array.from({length: effectiveConcurrency}, worker))
369
+ remaining = deferredByBytes + deferredByTime
370
+
371
+ // One line that answers "where did the time go and why was anything left
372
+ // behind" — the silent-degradation postmortems start here.
373
+ if (ready.length > 0) {
374
+ emit({
375
+ level: failedBatches || fallbackDownloads ? 'error' : 'info',
376
+ message:
377
+ `Download phase: ${((nowMs() - startedAtMs) / 1000).toFixed(1)}s — ` +
378
+ `${batches.length} batch(es), ${failedBatches} failed, ${fallbackDownloads} per-document fallback download(s); ` +
379
+ `deferred ${deferredByBytes} (inbox size cap) + ${deferredByTime} (time budget)`,
380
+ })
381
+ }
382
+
383
+ emit({
384
+ level: 'info',
385
+ message:
386
+ `Downloaded ${inbox.length} target(s)` +
387
+ `${skipped ? `, skipped ${skipped} not ready` : ''}` +
388
+ `${alreadyImported ? `, ${alreadyImported} already imported` : ''}` +
389
+ `${remaining ? `, ${remaining} deferred — run Import again to continue` : ''}`,
390
+ })
391
+
392
+ await sanity
393
+ .patch(projectId)
394
+ .set({
395
+ inbox,
396
+ progress,
397
+ progressSyncedAt: timestamp,
398
+ status: 'downloaded',
399
+ // Deferred-target count for the dashboard: after applying this batch it
400
+ // auto-continues the import instead of asking for another click.
401
+ importRemaining: remaining,
402
+ lastError: null,
403
+ functionLog: JSON.stringify(log),
404
+ })
405
+ .commit()
406
+
407
+ return {downloaded: inbox.length, skipped, remaining, alreadyImported}
408
+ } catch (err) {
409
+ const message = err instanceof Error ? err.message : String(err)
410
+ emit({level: 'error', message})
411
+ await sanity
412
+ .patch(projectId)
413
+ .set({status: 'error', lastError: message, lastImportAt: now(), functionLog: JSON.stringify(log)})
414
+ .commit()
415
+ .catch(() => {})
416
+ throw err
417
+ }
418
+ }
package/src/index.ts ADDED
@@ -0,0 +1,63 @@
1
+ import {definePlugin} from 'sanity'
2
+ import {createTranslationProjectType} from './schema/translationProject'
3
+ import {createSettingsType} from './schema/settings'
4
+ import {createAddToProjectAction} from './actions/AddToProjectAction'
5
+ import {createTranslationStatusInput} from './form/TranslationStatusInput'
6
+ import {createTranslationsTool} from './tool'
7
+ import {StudioInit} from './tool/StudioInit'
8
+ import {resolveConfig} from './lib/resolveConfig'
9
+ import type {SmartcatPluginConfig} from './types'
10
+
11
+ export type {SmartcatPluginConfig, SmartcatLanguage, TranslationProjectStatus} from './types'
12
+ // Re-exported so the `declare module 'sanity'` augmentation (adding
13
+ // `options.smartcatTranslation`) is loaded for consumers of the plugin types.
14
+ export type {SmartcatTranslationSchemaOptions} from './schema/translatableOptions'
15
+ export {TRANSLATION_PROJECT_TYPE} from './lib/constants'
16
+
17
+ /**
18
+ * Smartcat translation plugin for Sanity Studio.
19
+ *
20
+ * - Adds a `smartcat.translationProject` document type (a named set of items).
21
+ * - Adds an "Add to translation project" document action on the configured
22
+ * `translatableTypes`.
23
+ * - Shows a status line on those types when the document belongs to one or more
24
+ * translation projects.
25
+ *
26
+ * @public
27
+ */
28
+ export const smartcatTranslation = definePlugin<SmartcatPluginConfig>((config) => {
29
+ // Resolve all config defaults once, here at the boundary; everything below
30
+ // receives concrete values.
31
+ const resolved = resolveConfig(config)
32
+ const projectType = createTranslationProjectType()
33
+ const settingsType = createSettingsType()
34
+ const addToProjectAction = createAddToProjectAction(resolved)
35
+ const translationStatusInput = createTranslationStatusInput(resolved)
36
+ const translationsTool = createTranslationsTool(resolved)
37
+
38
+ // The plugin's customizations apply to translatable types (all types by
39
+ // default), but never to the plugin's own `smartcat.*` documents.
40
+ const isTranslatable = resolved.isTranslatableType
41
+
42
+ return {
43
+ name: 'sanity-plugin-smartcat-translation',
44
+ schema: {
45
+ types: [projectType, settingsType],
46
+ },
47
+ studio: {
48
+ components: {
49
+ // Bootstraps the template cache on first Studio load.
50
+ layout: StudioInit,
51
+ },
52
+ },
53
+ tools: [translationsTool],
54
+ document: {
55
+ actions: (prev, context) => (isTranslatable(context.schemaType) ? [...prev, addToProjectAction] : prev),
56
+ },
57
+ form: {
58
+ components: {
59
+ input: translationStatusInput,
60
+ },
61
+ },
62
+ }
63
+ })
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Document type for a translation project (a named set of items to translate).
3
+ *
4
+ * @public
5
+ */
6
+ export const TRANSLATION_PROJECT_TYPE = 'smartcat.translationProject'
7
+
8
+ /** Singleton document holding workspace-wide Smartcat settings (template cache). */
9
+ export const SETTINGS_TYPE = 'smartcat.settings'
10
+
11
+ /** Fixed `_id` of the settings singleton. */
12
+ export const SETTINGS_DOC_ID = 'smartcat.settings'
13
+
14
+ /** Sanity API version used by the plugin's client calls. */
15
+ export const API_VERSION = '2025-02-01'
16
+
17
+ /** Resolves the configured source language, failing if it's not set. */
18
+ export function requireSourceLanguage(sourceLanguage: string | undefined): string {
19
+ if (!sourceLanguage) {
20
+ throw new Error('Smartcat plugin: `sourceLanguage` is not configured')
21
+ }
22
+ return sourceLanguage
23
+ }
@@ -0,0 +1,43 @@
1
+ import {describe, expect, it} from 'vitest'
2
+ import {Schema} from '@sanity/schema'
3
+ import {resolveDocumentTitle} from './documentTitle'
4
+
5
+ // A `page` whose title is a field-level internationalized array and whose preview
6
+ // selects a plain-string `internalName` — the shape that crashed React #31.
7
+ const schema = Schema.compile({
8
+ name: 'test',
9
+ types: [
10
+ {name: 'internationalizedArrayStringValue', type: 'object', fields: [{name: 'value', type: 'string'}]},
11
+ {name: 'internationalizedArrayString', type: 'array', of: [{type: 'internationalizedArrayStringValue'}]},
12
+ {
13
+ name: 'page',
14
+ type: 'document',
15
+ fields: [
16
+ {name: 'internalName', type: 'string'},
17
+ {name: 'title', type: 'internationalizedArrayString'},
18
+ ],
19
+ preview: {select: {title: 'internalName'}},
20
+ },
21
+ ],
22
+ })
23
+
24
+ const localizedTitle = [
25
+ {_key: 'en', _type: 'internationalizedArrayStringValue', language: 'en', value: 'About us'},
26
+ ]
27
+
28
+ describe('resolveDocumentTitle', () => {
29
+ it('returns the schema preview title, never the localized array', () => {
30
+ const title = resolveDocumentTitle(schema, {_type: 'page', internalName: 'About', title: localizedTitle})
31
+ expect(title).toBe('About')
32
+ expect(typeof title).toBe('string')
33
+ })
34
+
35
+ it('returns undefined (not the array object) when no string title resolves', () => {
36
+ const title = resolveDocumentTitle(schema, {_type: 'page', title: localizedTitle})
37
+ expect(title).toBeUndefined()
38
+ })
39
+
40
+ it('returns undefined for an unknown type', () => {
41
+ expect(resolveDocumentTitle(schema, {_type: 'nope'})).toBeUndefined()
42
+ })
43
+ })
@@ -0,0 +1,30 @@
1
+ import {prepareForPreview} from 'sanity'
2
+
3
+ /** Fallback label for a document whose schema preview yields no title. */
4
+ export const UNTITLED = 'Untitled'
5
+
6
+ /** Minimal schema shape: just enough to look a type up by name. */
7
+ export interface TitleSchema {
8
+ get(typeName: string): unknown
9
+ }
10
+
11
+ /**
12
+ * Document display title, resolved the Sanity-native way — by running the schema
13
+ * type's `preview.prepare` (same as the Studio list/structure tree) — so types
14
+ * without a literal `title` field (e.g. field-level i18n) get a real label. Always
15
+ * returns a string or `undefined`, never a raw field value (an internationalized
16
+ * array is an object that crashes React #31 if rendered as a child).
17
+ */
18
+ export function resolveDocumentTitle(
19
+ schema: TitleSchema,
20
+ doc: Record<string, unknown> & {_type: string},
21
+ ): string | undefined {
22
+ const type = schema.get(doc._type)
23
+ if (!type) return undefined
24
+ try {
25
+ const preview = prepareForPreview(doc as never, type as never)
26
+ return typeof preview?.title === 'string' ? preview.title : undefined
27
+ } catch {
28
+ return undefined
29
+ }
30
+ }
@@ -0,0 +1,56 @@
1
+ import {describe, expect, it} from 'vitest'
2
+ import {
3
+ buildLanguageMappings,
4
+ findDuplicateSmartcatLanguages,
5
+ toSanityLanguage,
6
+ toSmartcatLanguage,
7
+ } from './languageMap'
8
+
9
+ const LANGUAGES = [
10
+ {id: 'en', title: 'English'},
11
+ {id: 'fr', title: 'French'},
12
+ {id: 'PL-PL', title: 'Polish', smartcatLanguage: 'pl'},
13
+ ]
14
+
15
+ describe('buildLanguageMappings', () => {
16
+ it('resolves each id to its smartcatLanguage, defaulting to the id', () => {
17
+ expect(buildLanguageMappings(LANGUAGES, ['en', 'PL-PL'])).toEqual([
18
+ {sanityId: 'en', smartcatLanguage: 'en'},
19
+ {sanityId: 'PL-PL', smartcatLanguage: 'pl'},
20
+ ])
21
+ })
22
+
23
+ it('falls back to the id for languages not in the config', () => {
24
+ expect(buildLanguageMappings(LANGUAGES, ['de'])).toEqual([{sanityId: 'de', smartcatLanguage: 'de'}])
25
+ })
26
+ })
27
+
28
+ describe('toSmartcatLanguage / toSanityLanguage', () => {
29
+ const mappings = buildLanguageMappings(LANGUAGES, ['en', 'PL-PL'])
30
+
31
+ it('maps forward and inverse', () => {
32
+ expect(toSmartcatLanguage(mappings, 'PL-PL')).toBe('pl')
33
+ expect(toSanityLanguage(mappings, 'pl')).toBe('PL-PL')
34
+ })
35
+
36
+ it('is identity for unmapped values or no mapping', () => {
37
+ expect(toSmartcatLanguage(mappings, 'de')).toBe('de')
38
+ expect(toSanityLanguage(mappings, 'de')).toBe('de')
39
+ expect(toSmartcatLanguage(undefined, 'fr')).toBe('fr')
40
+ expect(toSanityLanguage(undefined, 'fr')).toBe('fr')
41
+ })
42
+ })
43
+
44
+ describe('findDuplicateSmartcatLanguages', () => {
45
+ it('returns nothing when all codes are distinct', () => {
46
+ expect(findDuplicateSmartcatLanguages(buildLanguageMappings(LANGUAGES, ['en', 'fr', 'PL-PL']))).toEqual([])
47
+ })
48
+
49
+ it('groups Sanity ids that collapse to the same code', () => {
50
+ const dups = findDuplicateSmartcatLanguages([
51
+ {sanityId: 'PL-PL', smartcatLanguage: 'pl'},
52
+ {sanityId: 'pl', smartcatLanguage: 'pl'},
53
+ ])
54
+ expect(dups).toEqual([{smartcatLanguage: 'pl', sanityIds: ['PL-PL', 'pl']}])
55
+ })
56
+ })