@smartcat/sanity-plugin 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +174 -0
  5. package/dist/_chunks-cjs/index.cjs.map +1 -0
  6. package/dist/_chunks-cjs/index2.cjs +338 -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 +175 -0
  13. package/dist/_chunks-es/index.js.map +1 -0
  14. package/dist/_chunks-es/index2.js +340 -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 +2172 -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 +2179 -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 +291 -0
  39. package/dist/locjson.d.ts +291 -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 +277 -0
  45. package/dist/progress.d.ts +277 -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 +274 -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 +34 -0
  78. package/src/lib/locjson/index.ts +10 -0
  79. package/src/lib/locjson/locjson.test.ts +615 -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 +157 -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 +31 -0
  87. package/src/lib/resolveConfig.test.ts +62 -0
  88. package/src/lib/resolveConfig.ts +54 -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 +409 -0
  95. package/src/progress/index.ts +7 -0
  96. package/src/progress/progress.test.ts +290 -0
  97. package/src/schema/settings.ts +55 -0
  98. package/src/schema/translatableOptions.ts +25 -0
  99. package/src/schema/translationProject.ts +268 -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 +46 -0
  108. package/src/tool/ItemProgress.tsx +176 -0
  109. package/src/tool/LogPanel.tsx +207 -0
  110. package/src/tool/ProjectView.tsx +1004 -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 +86 -0
  116. package/src/tool/index.ts +19 -0
  117. package/src/tool/processing.test.ts +698 -0
  118. package/src/tool/processing.ts +839 -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,208 @@
1
+ import {useCallback, useEffect, useState} from 'react'
2
+ import {useClient, useRelativeTime} from 'sanity'
3
+ import {
4
+ Box,
5
+ Button,
6
+ Card,
7
+ Dialog,
8
+ Flex,
9
+ Heading,
10
+ Label,
11
+ Spinner,
12
+ Stack,
13
+ Text,
14
+ TextInput,
15
+ useToast,
16
+ } from '@sanity/ui'
17
+ import {AddIcon, TranslateIcon} from '@sanity/icons'
18
+ import {API_VERSION, TRANSLATION_PROJECT_TYPE} from '../lib/constants'
19
+ import {PROJECTS_QUERY, type ProjectListItem} from './data'
20
+ import {StatusBadge} from './StatusBadge'
21
+ import {WorkflowSelect} from './WorkflowSelect'
22
+ import {useTemplates} from './useTemplates'
23
+ import {useWorkflowSelection} from './useWorkflowSelection'
24
+
25
+ interface ProjectsViewProps {
26
+ sourceLanguage: string
27
+ onOpenProject: (id: string) => void
28
+ }
29
+
30
+ /** A single project row. Extracted so `useRelativeTime` can run per row. */
31
+ function ProjectRow({project: p, onOpen}: {project: ProjectListItem; onOpen: (id: string) => void}) {
32
+ const createdAgo = useRelativeTime(p._createdAt, {useTemporalPhrase: true})
33
+ return (
34
+ <Card
35
+ padding={3}
36
+ radius={2}
37
+ shadow={1}
38
+ as="button"
39
+ onClick={() => onOpen(p._id)}
40
+ style={{cursor: 'pointer', textAlign: 'left', width: '100%'}}
41
+ >
42
+ <Flex align="center" gap={3}>
43
+ <Box style={{width: 110}}>
44
+ <Text size={1} muted title={new Date(p._createdAt).toLocaleString()}>
45
+ {createdAgo}
46
+ </Text>
47
+ </Box>
48
+ <Box style={{width: 100}}>
49
+ <StatusBadge status={p.status} />
50
+ </Box>
51
+ <Box>
52
+ <TranslateIcon />
53
+ </Box>
54
+ <Box flex={1}>
55
+ <Text weight="semibold">{p.name}</Text>
56
+ </Box>
57
+ <Text size={1} muted>
58
+ {p.count} item{p.count === 1 ? '' : 's'}
59
+ </Text>
60
+ </Flex>
61
+ </Card>
62
+ )
63
+ }
64
+
65
+ export function ProjectsView({sourceLanguage, onOpenProject}: ProjectsViewProps) {
66
+ const client = useClient({apiVersion: API_VERSION})
67
+ const toast = useToast()
68
+ const [projects, setProjects] = useState<ProjectListItem[] | null>(null)
69
+ const [creating, setCreating] = useState(false)
70
+ const [newName, setNewName] = useState('')
71
+ const [busy, setBusy] = useState(false)
72
+ const {templates, loaded, requestRefresh} = useTemplates()
73
+ const [workflow, setWorkflow] = useWorkflowSelection(creating, templates, loaded)
74
+
75
+ // Refresh the template list each time the create dialog opens.
76
+ useEffect(() => {
77
+ if (creating) requestRefresh()
78
+ }, [creating, requestRefresh])
79
+
80
+ const load = useCallback(() => {
81
+ return client
82
+ .fetch<ProjectListItem[]>(PROJECTS_QUERY)
83
+ .then(setProjects)
84
+ .catch((err) => toast.push({status: 'error', title: 'Failed to load projects', description: String(err)}))
85
+ }, [client, toast])
86
+
87
+ // Initial load + live updates.
88
+ useEffect(() => {
89
+ load()
90
+ const sub = client
91
+ .listen(PROJECTS_QUERY, {}, {visibility: 'query'})
92
+ .subscribe({next: () => load(), error: () => {}})
93
+ return () => sub.unsubscribe()
94
+ }, [client, load])
95
+
96
+ const handleCreate = useCallback(async () => {
97
+ const name = newName.trim()
98
+ if (!name) return
99
+ setBusy(true)
100
+ try {
101
+ const created = await client.create({
102
+ _type: TRANSLATION_PROJECT_TYPE,
103
+ name,
104
+ status: 'draft',
105
+ sourceLanguage,
106
+ workflow,
107
+ items: [],
108
+ })
109
+ toast.push({status: 'success', title: `Created “${name}”`})
110
+ setCreating(false)
111
+ setNewName('')
112
+ onOpenProject(created._id)
113
+ } catch (err) {
114
+ toast.push({status: 'error', title: 'Failed to create project', description: String(err)})
115
+ } finally {
116
+ setBusy(false)
117
+ }
118
+ }, [newName, workflow, client, sourceLanguage, toast, onOpenProject])
119
+
120
+ return (
121
+ <Stack space={4}>
122
+ <Flex align="center" justify="space-between">
123
+ <Heading size={2}>Translation projects</Heading>
124
+ <Button icon={AddIcon} text="New project" tone="primary" onClick={() => setCreating(true)} />
125
+ </Flex>
126
+
127
+ {projects === null ? (
128
+ <Flex align="center" justify="center" padding={5}>
129
+ <Spinner muted />
130
+ </Flex>
131
+ ) : projects.length === 0 ? (
132
+ <Card padding={5} radius={2} tone="transparent" border>
133
+ <Stack space={3}>
134
+ <Text muted align="center">
135
+ No translation projects yet.
136
+ </Text>
137
+ <Flex justify="center">
138
+ <Button icon={AddIcon} text="Create your first project" onClick={() => setCreating(true)} />
139
+ </Flex>
140
+ </Stack>
141
+ </Card>
142
+ ) : (
143
+ <Stack space={2}>
144
+ {projects.map((p) => (
145
+ <ProjectRow key={p._id} project={p} onOpen={onOpenProject} />
146
+ ))}
147
+ </Stack>
148
+ )}
149
+
150
+ {creating && (
151
+ <Dialog
152
+ id="create-project"
153
+ header="New translation project"
154
+ onClose={() => {
155
+ setCreating(false)
156
+ setNewName('')
157
+ }}
158
+ width={1}
159
+ >
160
+ <Box padding={4}>
161
+ <Stack space={4}>
162
+ <Stack space={3}>
163
+ <Label size={1}>Project name</Label>
164
+ <TextInput
165
+ value={newName}
166
+ placeholder="e.g. Spring campaign"
167
+ autoFocus
168
+ onChange={(e) => setNewName(e.currentTarget.value)}
169
+ onKeyDown={(e) => {
170
+ if (e.key === 'Enter') handleCreate()
171
+ }}
172
+ disabled={busy}
173
+ />
174
+ </Stack>
175
+ <Stack space={3}>
176
+ <Label size={1}>Workflow</Label>
177
+ <WorkflowSelect
178
+ value={workflow}
179
+ onChange={setWorkflow}
180
+ templates={templates}
181
+ disabled={busy}
182
+ />
183
+ </Stack>
184
+ <Flex justify="flex-end" gap={2}>
185
+ <Button
186
+ mode="bleed"
187
+ text="Cancel"
188
+ onClick={() => {
189
+ setCreating(false)
190
+ setNewName('')
191
+ }}
192
+ disabled={busy}
193
+ />
194
+ <Button
195
+ tone="primary"
196
+ text="Create"
197
+ onClick={handleCreate}
198
+ loading={busy}
199
+ disabled={!newName.trim() || !workflow}
200
+ />
201
+ </Flex>
202
+ </Stack>
203
+ </Box>
204
+ </Dialog>
205
+ )}
206
+ </Stack>
207
+ )
208
+ }
@@ -0,0 +1,33 @@
1
+ import {Badge, type BadgeTone} from '@sanity/ui'
2
+ import type {TranslationProjectStatus} from '../types'
3
+ import {STATUS_OPTIONS} from '../schema/translationProject'
4
+
5
+ // Badge color is a UI-only concern (no schema equivalent), so it lives here.
6
+ const TONES: Record<TranslationProjectStatus, BadgeTone> = {
7
+ draft: 'default',
8
+ queued: 'caution',
9
+ sent: 'primary',
10
+ translating: 'primary',
11
+ importing: 'caution',
12
+ downloaded: 'primary',
13
+ completed: 'positive',
14
+ error: 'critical',
15
+ }
16
+
17
+ // Display titles come from the schema's STATUS_OPTIONS — the single source.
18
+ const LABELS: Record<string, string> = Object.fromEntries(
19
+ STATUS_OPTIONS.map((o) => [o.value, o.title]),
20
+ )
21
+
22
+ /** Display label for a project status (from the schema's status titles). */
23
+ export function statusLabel(status: TranslationProjectStatus): string {
24
+ return LABELS[status] ?? status
25
+ }
26
+
27
+ export function StatusBadge({status}: {status: TranslationProjectStatus}) {
28
+ return (
29
+ <Badge tone={TONES[status] ?? 'default'} fontSize={0} padding={2} radius={2}>
30
+ {statusLabel(status)}
31
+ </Badge>
32
+ )
33
+ }
@@ -0,0 +1,42 @@
1
+ import {useEffect, useRef} from 'react'
2
+ import {useClient, type LayoutProps} from 'sanity'
3
+ import {API_VERSION, SETTINGS_DOC_ID, SETTINGS_TYPE} from '../lib/constants'
4
+ import {isRefreshInFlight} from './useTemplates'
5
+
6
+ interface SettingsDoc {
7
+ templatesRequestedAt?: string
8
+ templatesSyncedAt?: string
9
+ }
10
+
11
+ const INIT_QUERY = `*[_id == $id][0]{templatesRequestedAt, templatesSyncedAt}`
12
+
13
+ /**
14
+ * Studio layout wrapper. On first load it bootstraps the template cache *only if
15
+ * it has never been initialized* — i.e. no `templatesSyncedAt` and no request
16
+ * already in flight. Routine freshness is handled by the create dialogs; this
17
+ * just seeds an empty cache the very first time the Studio runs.
18
+ */
19
+ export function StudioInit(props: LayoutProps) {
20
+ const client = useClient({apiVersion: API_VERSION})
21
+ const didRun = useRef(false)
22
+
23
+ useEffect(() => {
24
+ if (didRun.current) return
25
+ didRun.current = true
26
+ client
27
+ .fetch<SettingsDoc | null>(INIT_QUERY, {id: SETTINGS_DOC_ID})
28
+ .then((s) => {
29
+ // Skip if already initialized, or a fresh request is still in flight.
30
+ // A stale request (older than the window) falls through and re-bumps.
31
+ if (s?.templatesSyncedAt || isRefreshInFlight(s)) return
32
+ return client
33
+ .transaction()
34
+ .createIfNotExists({_id: SETTINGS_DOC_ID, _type: SETTINGS_TYPE})
35
+ .patch(SETTINGS_DOC_ID, (p) => p.set({templatesRequestedAt: new Date().toISOString()}))
36
+ .commit({visibility: 'async'})
37
+ })
38
+ .catch(() => {})
39
+ }, [client])
40
+
41
+ return props.renderDefault(props)
42
+ }
@@ -0,0 +1,41 @@
1
+ import {Select} from '@sanity/ui'
2
+ import type {SmartcatTemplate} from '../smartcat/types'
3
+ import {STANDARD_WORKFLOW_OPTIONS, TEMPLATE_PREFIX} from '../lib/workflow'
4
+
5
+ interface WorkflowSelectProps {
6
+ value: string
7
+ onChange: (value: string) => void
8
+ templates: SmartcatTemplate[]
9
+ disabled?: boolean
10
+ }
11
+
12
+ /**
13
+ * Workflow picker for new projects: cached Smartcat templates grouped under
14
+ * "Templates", followed by the standard presets under "Standard workflows".
15
+ * Templates are expected pre-sorted by name (see `useTemplates`).
16
+ */
17
+ export function WorkflowSelect({value, onChange, templates, disabled}: WorkflowSelectProps) {
18
+ return (
19
+ <Select value={value} onChange={(e) => onChange(e.currentTarget.value)} disabled={disabled}>
20
+ <option value="" disabled>
21
+ Select a workflow…
22
+ </option>
23
+ {templates.length > 0 && (
24
+ <optgroup label="Templates">
25
+ {templates.map((t) => (
26
+ <option key={t.id} value={`${TEMPLATE_PREFIX}${t.id}`}>
27
+ {t.name}
28
+ </option>
29
+ ))}
30
+ </optgroup>
31
+ )}
32
+ <optgroup label="Standard workflows">
33
+ {STANDARD_WORKFLOW_OPTIONS.map((o) => (
34
+ <option key={o.value} value={o.value}>
35
+ {o.label}
36
+ </option>
37
+ ))}
38
+ </optgroup>
39
+ </Select>
40
+ )
41
+ }
@@ -0,0 +1,86 @@
1
+ import {TRANSLATION_PROJECT_TYPE} from '../lib/constants'
2
+ import {ITEM_ID, ITEM_ID_FROM_SUBQUERY, itemDocDeref} from '../lib/projectItems'
3
+ import type {TranslationProjectStatus} from '../types'
4
+ import type {DocProgress} from '../progress/core'
5
+
6
+ /** A row in the projects list. */
7
+ export interface ProjectListItem {
8
+ _id: string
9
+ name: string
10
+ status: TranslationProjectStatus
11
+ count: number
12
+ _createdAt: string
13
+ _updatedAt: string
14
+ }
15
+
16
+ /** A resolved item inside a project. */
17
+ export interface ProjectItemRef {
18
+ _key: string
19
+ /** Published id of the added document, normalized across old (`_ref`) and new (`docId`) items. */
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
27
+ doc: {_id: string; _type: string} | null
28
+ /** Locale variants linked via translation.metadata: language -> variant doc id. */
29
+ translations: {language: string; id: string}[] | null
30
+ }
31
+
32
+ /** A single project with its resolved items. */
33
+ export interface ProjectDetail {
34
+ _id: string
35
+ name: string
36
+ status: TranslationProjectStatus
37
+ sourceLanguage: string | null
38
+ targetLanguages: string[] | null
39
+ smartcatProjectId: string | null
40
+ smartcatProjectUrl: string | null
41
+ lastError: string | null
42
+ /** JSON-encoded LogLine[] from the most recent Function run. */
43
+ functionLog: string | null
44
+ /** Targets the last download run deferred (size/time caps) — drives auto-continue. */
45
+ importRemaining: number | null
46
+ items: ProjectItemRef[] | null
47
+ progress: DocProgress[] | null
48
+ progressSyncedAt: string | null
49
+ }
50
+
51
+ export const PROJECTS_QUERY = `*[_type == "${TRANSLATION_PROJECT_TYPE}"] | order(_createdAt desc){
52
+ _id,
53
+ name,
54
+ status,
55
+ _createdAt,
56
+ _updatedAt,
57
+ "count": count(items)
58
+ }`
59
+
60
+ export const PROJECT_DETAIL_QUERY = `*[_id == $id][0]{
61
+ _id,
62
+ name,
63
+ status,
64
+ sourceLanguage,
65
+ targetLanguages,
66
+ smartcatProjectId,
67
+ smartcatProjectUrl,
68
+ lastError,
69
+ functionLog,
70
+ importRemaining,
71
+ progressSyncedAt,
72
+ items[]{
73
+ _key,
74
+ "docId": ${ITEM_ID},
75
+ sourceIsPublished,
76
+ "doc": ${itemDocDeref('{_id, _type}')},
77
+ "translations": *[_type == "translation.metadata" && references(${ITEM_ID_FROM_SUBQUERY})][0]
78
+ .translations[]{language, "id": value._ref}
79
+ },
80
+ progress[]{
81
+ _key,
82
+ sourceDocId,
83
+ title,
84
+ targets[]{_key, language, smartcatDocumentId, stages, complete, imported, syncedAt}
85
+ }
86
+ }`
@@ -0,0 +1,19 @@
1
+ import {route} from 'sanity/router'
2
+ import {TranslateIcon} from '@sanity/icons'
3
+ import type {Tool} from 'sanity'
4
+ import {createDashboardComponent} from './Dashboard'
5
+ import type {ResolvedSmartcatConfig} from '../lib/resolveConfig'
6
+
7
+ /** Tool name; also the URL segment used to deep-link into the tool. */
8
+ export const TRANSLATION_TOOL_NAME = 'translation-projects'
9
+
10
+ /** Builds the "Translations" Studio tool (top-nav dashboard). */
11
+ export function createTranslationsTool(config: ResolvedSmartcatConfig): Tool {
12
+ return {
13
+ name: TRANSLATION_TOOL_NAME,
14
+ title: 'Translation Projects',
15
+ icon: TranslateIcon,
16
+ component: createDashboardComponent(config),
17
+ router: route.create('/', [route.create('/project/:projectId')]),
18
+ }
19
+ }