@sanity/assist 1.2.13 → 1.2.15-lang.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 (36) hide show
  1. package/README.md +392 -6
  2. package/dist/index.d.ts +170 -3
  3. package/dist/index.esm.js +1986 -111
  4. package/dist/index.esm.js.map +1 -1
  5. package/dist/index.js +1980 -105
  6. package/dist/index.js.map +1 -1
  7. package/package.json +15 -14
  8. package/src/_lib/form/DocumentForm.tsx +1 -1
  9. package/src/assistDocument/components/instruction/InstructionInput.tsx +5 -4
  10. package/src/assistDocument/components/instruction/InstructionOutputField.tsx +45 -0
  11. package/src/assistDocument/components/instruction/InstructionOutputInput.tsx +205 -0
  12. package/src/assistDocument/hooks/useStudioAssistDocument.ts +5 -32
  13. package/src/assistFormComponents/AssistField.tsx +5 -4
  14. package/src/assistFormComponents/AssistFormBlock.tsx +2 -3
  15. package/src/assistFormComponents/validation/listItem.tsx +2 -2
  16. package/src/assistInspector/FieldAutocomplete.tsx +1 -0
  17. package/src/assistInspector/InstructionTaskHistoryButton.tsx +2 -3
  18. package/src/assistInspector/helpers.ts +7 -9
  19. package/src/assistLayout/AssistLayout.tsx +9 -6
  20. package/src/fieldActions/assistFieldActions.tsx +14 -8
  21. package/src/fieldActions/translateActions.tsx +118 -0
  22. package/src/helpers/assistSupported.ts +1 -1
  23. package/src/node_modules/.vitest/results.json +1 -0
  24. package/src/plugin.tsx +12 -2
  25. package/src/presence/AssistAvatar.tsx +1 -1
  26. package/src/schemas/assistDocumentSchema.tsx +39 -0
  27. package/src/schemas/serialize/serializeSchema.test.ts +15 -2
  28. package/src/schemas/serialize/serializeSchema.ts +8 -7
  29. package/src/schemas/typeDefExtensions.ts +12 -1
  30. package/src/translate/FieldTranslationProvider.tsx +254 -0
  31. package/src/translate/getLanguageParams.ts +26 -0
  32. package/src/translate/paths.test.ts +87 -0
  33. package/src/translate/paths.ts +151 -0
  34. package/src/translate/types.ts +159 -0
  35. package/src/types.ts +21 -2
  36. package/src/useApiClient.ts +63 -0
@@ -0,0 +1,87 @@
1
+ import {describe, expect, test} from 'vitest'
2
+ import {Schema} from '@sanity/schema'
3
+ import {defineType, ObjectSchemaType, pathToString, SanityDocumentLike, typed} from 'sanity'
4
+ import {
5
+ defaultLanguageOutputs,
6
+ getDocumentMembersFlat,
7
+ getTranslationMap,
8
+ TranslationMap,
9
+ } from './paths'
10
+
11
+ describe('paths', () => {
12
+ test('should return internationalizedArrayString paths and find translation mappings', () => {
13
+ const docSchema: ObjectSchemaType = Schema.compile({
14
+ name: 'test',
15
+ types: [
16
+ defineType({
17
+ type: 'document',
18
+ name: 'article',
19
+ fields: [
20
+ {type: 'string', name: 'title'},
21
+ {
22
+ type: 'object',
23
+ name: 'localeTitle',
24
+ fields: [
25
+ {type: 'string', name: 'en'},
26
+ {type: 'string', name: 'no'},
27
+ ],
28
+ },
29
+ {
30
+ type: 'array',
31
+ name: 'translations',
32
+ of: [
33
+ {
34
+ type: 'object',
35
+ name: 'internationalizedArrayString',
36
+ fields: [{type: 'string', name: 'value'}],
37
+ },
38
+ ],
39
+ },
40
+ ],
41
+ }),
42
+ ],
43
+ }).get('article')
44
+
45
+ const doc: SanityDocumentLike = {
46
+ _id: 'na',
47
+ _type: 'article',
48
+ title: 'some title',
49
+ translations: [
50
+ {
51
+ _type: 'internationalizedArrayString',
52
+ _key: 'en',
53
+ value: 'some string',
54
+ },
55
+ {
56
+ _type: 'internationalizedArrayString',
57
+ _key: 'nb',
58
+ },
59
+ ],
60
+ }
61
+
62
+ const members = getDocumentMembersFlat(doc, docSchema)
63
+ expect(members.map((p) => pathToString(p.path))).toEqual([
64
+ 'title',
65
+ 'localeTitle',
66
+ 'localeTitle.en',
67
+ 'localeTitle.no',
68
+ 'translations',
69
+ 'translations[_key=="en"]',
70
+ 'translations[_key=="en"].value',
71
+ 'translations[_key=="nb"]',
72
+ 'translations[_key=="nb"].value',
73
+ ])
74
+
75
+ const transMap = getTranslationMap(docSchema, members, 'en', ['nb'], defaultLanguageOutputs)
76
+
77
+ expect(transMap).toEqual(
78
+ typed<TranslationMap[]>([
79
+ {
80
+ inputLanguageId: 'en',
81
+ inputPath: ['translations', {_key: 'en'}],
82
+ outputs: [{id: 'nb', outputPath: ['translations', {_key: 'nb'}]}],
83
+ },
84
+ ])
85
+ )
86
+ })
87
+ })
@@ -0,0 +1,151 @@
1
+ import {
2
+ isDocumentSchemaType,
3
+ isKeySegment,
4
+ ObjectSchemaType,
5
+ Path,
6
+ pathToString,
7
+ SanityDocumentLike,
8
+ } from 'sanity'
9
+ import {extractWithPath} from '@sanity/mutator'
10
+ import {DocumentMember, TranslationOutputsFunction, TranslationOutput} from './types'
11
+
12
+ export interface TranslationMap {
13
+ inputLanguageId: string
14
+ inputPath: Path
15
+ outputs: TranslationOutput[]
16
+ }
17
+
18
+ const MAX_DEPTH = 6
19
+
20
+ export function getDocumentMembersFlat(doc: SanityDocumentLike, schemaType: ObjectSchemaType) {
21
+ if (!isDocumentSchemaType(schemaType)) {
22
+ console.error(`Schema type is not a document`)
23
+ return []
24
+ }
25
+
26
+ return extractPaths(doc, schemaType, [], MAX_DEPTH)
27
+ }
28
+
29
+ function extractPaths(
30
+ doc: SanityDocumentLike,
31
+ schemaType: ObjectSchemaType,
32
+ path: Path,
33
+ maxDepth: number
34
+ ): DocumentMember[] {
35
+ if (path.length >= maxDepth) {
36
+ return []
37
+ }
38
+
39
+ return schemaType.fields.reduce<DocumentMember[]>((acc, field) => {
40
+ const fieldPath = [...path, field.name]
41
+ const fieldSchema = field.type
42
+ const thisFieldWithPath: DocumentMember = {
43
+ path: fieldPath,
44
+ name: field.name,
45
+ schemaType: fieldSchema,
46
+ }
47
+
48
+ if (fieldSchema.jsonType === 'object') {
49
+ const innerFields = extractPaths(doc, fieldSchema, fieldPath, maxDepth)
50
+
51
+ return [...acc, thisFieldWithPath, ...innerFields]
52
+ } else if (
53
+ fieldSchema.jsonType === 'array' &&
54
+ fieldSchema.of.length &&
55
+ fieldSchema.of.some((item) => 'fields' in item)
56
+ ) {
57
+ const {value: arrayValue} = extractWithPath(pathToString(fieldPath), doc)[0] ?? {}
58
+
59
+ let arrayPaths: DocumentMember[] = []
60
+ if ((arrayValue as any)?.length) {
61
+ for (const item of arrayValue as any[]) {
62
+ const arrayItemSchema = fieldSchema.of.find((t) => t.name === item._type)
63
+ if (item._key && arrayItemSchema) {
64
+ const itemPath = [...fieldPath, {_key: item._key}]
65
+ const innerFields = extractPaths(
66
+ doc,
67
+ arrayItemSchema as ObjectSchemaType,
68
+ itemPath,
69
+ maxDepth
70
+ )
71
+ arrayPaths = [
72
+ ...arrayPaths,
73
+ {path: itemPath, name: item._key, schemaType: arrayItemSchema},
74
+ ...innerFields,
75
+ ]
76
+ }
77
+ }
78
+ }
79
+
80
+ return [...acc, thisFieldWithPath, ...arrayPaths]
81
+ }
82
+
83
+ return [...acc, thisFieldWithPath]
84
+ }, [])
85
+ }
86
+
87
+ export const defaultLanguageOutputs: TranslationOutputsFunction = function (
88
+ member,
89
+ enclosingType,
90
+ translateFromLanguageId,
91
+ translateToLanguageIds
92
+ ) {
93
+ if (
94
+ member.schemaType.jsonType === 'object' &&
95
+ member.schemaType.name.startsWith('internationalizedArray')
96
+ ) {
97
+ const pathEnd = member.path.slice(-1)
98
+
99
+ const language = isKeySegment(pathEnd[0]) ? pathEnd[0]._key : null
100
+ return language === translateFromLanguageId
101
+ ? translateToLanguageIds.map((translateToId) => ({
102
+ id: translateToId,
103
+ outputPath: [...member.path.slice(0, -1), {_key: translateToId}],
104
+ }))
105
+ : undefined
106
+ }
107
+
108
+ if (enclosingType.jsonType === 'object' && enclosingType.name.startsWith('locale')) {
109
+ return translateFromLanguageId === member.name
110
+ ? translateToLanguageIds.map((translateToId) => ({
111
+ id: translateToId,
112
+ outputPath: [...member.path.slice(0, -1), translateToId],
113
+ }))
114
+ : undefined
115
+ }
116
+
117
+ return undefined
118
+ }
119
+
120
+ export function getTranslationMap(
121
+ documentSchema: ObjectSchemaType,
122
+ documentMembers: DocumentMember[],
123
+ translateFromLanguageId: string,
124
+ outputLanguageIds: string[],
125
+ langFn: TranslationOutputsFunction
126
+ ): TranslationMap[] {
127
+ const translationMaps: TranslationMap[] = []
128
+ for (const member of documentMembers) {
129
+ const parentPath = member.path.slice(0, -1)
130
+ const enclosingType =
131
+ documentMembers.find((m) => pathToString(m.path) === pathToString(parentPath))?.schemaType ??
132
+ documentSchema
133
+
134
+ const translations = langFn(
135
+ member,
136
+ enclosingType,
137
+ translateFromLanguageId,
138
+ outputLanguageIds
139
+ )?.filter((translation) => translation.id !== translateFromLanguageId)
140
+
141
+ if (translations) {
142
+ translationMaps.push({
143
+ inputLanguageId: translateFromLanguageId,
144
+ inputPath: member.path,
145
+ outputs: translations,
146
+ })
147
+ }
148
+ }
149
+
150
+ return translationMaps
151
+ }
@@ -0,0 +1,159 @@
1
+ import {Path, SanityClient, SchemaType} from 'sanity'
2
+
3
+ export interface Language {
4
+ id: string
5
+ title?: string
6
+ }
7
+
8
+ export interface DocumentMember {
9
+ schemaType: SchemaType
10
+ path: Path
11
+ name: string
12
+ }
13
+
14
+ export interface TranslationOutput {
15
+ /** Language id */
16
+ id: string
17
+ outputPath: Path
18
+ }
19
+
20
+ export type TranslationOutputsFunction = (
21
+ documentMember: DocumentMember,
22
+ enclosingType: SchemaType,
23
+ translateFromLanguageId: string,
24
+ translateToLanguageIds: string[]
25
+ ) => TranslationOutput[] | undefined
26
+
27
+ export type LanguageCallback = (
28
+ client: SanityClient,
29
+ selectedLanguageParams: Record<string, unknown>
30
+ ) => Promise<Language[]>
31
+
32
+ export interface FieldTranslationConfig {
33
+ /**
34
+ * `documentTypes` should be an array of strings where each entry must match a name from your document schemas.
35
+ *
36
+ * If defined, matching document will get a "Translate fields" instruction added.
37
+ **/
38
+ documentTypes?: string[]
39
+
40
+ /**
41
+ *
42
+ * Used for display strings in the Studio, and to determine languages for field level translations
43
+ *
44
+ * If the studio is using the sanity-plugin-internationalized-array plugin, this
45
+ * should be set to the same configuration.
46
+ */
47
+ languages: Language[] | LanguageCallback
48
+
49
+ /**
50
+ * API version for client passed to LanguageCallback for languages
51
+ * https://www.sanity.io/docs/api-versioning
52
+ * @defaultValue '2022-11-27'
53
+ */
54
+ apiVersion?: string
55
+
56
+ /**
57
+ * Specify fields that should be available in the languages callback:
58
+ * ```tsx
59
+ * {
60
+ * select: {
61
+ * markets: 'markets'
62
+ * },
63
+ * languages: (client, {markets}) =>
64
+ * client.fetch('*[_type == "language" && market in $markets]{id,title}', {markets})
65
+ * }
66
+ * ```
67
+ *
68
+ * If the studio is using the sanity-plugin-internationalized-array plugin, this
69
+ * should be set to the same configuration.
70
+ */
71
+ selectLanguageParams?: Record<string, string>
72
+
73
+ /**
74
+ * `translationOutputs` is used when the "Translate fields" instruction is started by a Studio user.
75
+ *
76
+ * It determines the relationships between document paths: Given a document path and a language, into which
77
+ * sibling paths should translations be output.
78
+
79
+ *
80
+ * `translationOutputs` is invoked once per path in the document (limited to a depth of 6), with the following:
81
+ *
82
+ * * `documentMember` - the field or array item for a given path; contains the path and its schemaType,
83
+ * * `enclosingType` - the schema type of parent holding the member
84
+ * * `translateFromLanguageId` - the languageId for the language the user want to translate from
85
+ * * `translateToLanguageIds` - all languageIds the user can translate to
86
+ *
87
+ * The function should return a `TranslationOutput[]` array that contains all the paths where translations from
88
+ * documentMember language (translateFromLanguageId) should be output.
89
+ *
90
+ * The function should return `undefined` for all documentMembers that should not be directly translated,
91
+ * or are nested fields under a translated path.
92
+ *
93
+ * ## Default function
94
+ *
95
+ * The default function for `translationOutputs` is configured to be automatically compatible with sanity-plugin-internationalized-array
96
+ * and object types prefixed with "locale".
97
+ *
98
+ * See <link to source for defaultTranslationOutputs> implementation details.
99
+ *
100
+ * ## Example
101
+ * A document has the following document members:
102
+ * * `{path: 'localeObject.en', schemaType: ObjectSchemaType}`
103
+ * * `{path: 'localeObject.en.title', schemaType: StringSchemaType}`
104
+ * * `{path: 'localeObject.de', schemaType: ObjectSchemaType}`,
105
+ * * `{path: 'localeObject.de.title', schemaType: StringSchemaType}`
106
+ *
107
+ * `translationOutputs` for invoked with `translateFromLanguageId` `en`,
108
+ * should only return [{id: 'de', outputPath: 'localeObject.de'}] for the `'localeObject.en'` path,
109
+ * and undefined for all the other members.
110
+ *
111
+ * ### Example implementation
112
+ * ```ts
113
+ * function translationOutputs(member, enclosingType, translateFromLanguageId, translateToLanguageIds)
114
+ * if (enclosingType.jsonType === 'object' && enclosingType.name.startsWith('locale') && translateFromLanguageId === member.name) {
115
+ * return translateToLanguageIds.map((translateToId) => ({
116
+ * id: translateToId,
117
+ * outputPath: [...member.path.slice(0, -1), translateToId],
118
+ * }))
119
+ * }
120
+ * return undefined
121
+ * }
122
+ * ```
123
+ **/
124
+ translationOutputs?: TranslationOutputsFunction
125
+ }
126
+
127
+ export interface DocumentTranslationConfig {
128
+ /**
129
+ * Path to language field in documents. Can be a hidden field.
130
+ * For instance: 'config.language'
131
+ *
132
+ * For projects that use the `@sanity/document-internationalization` plugin,
133
+ * this should be the same as `languageField` config for that plugin.
134
+ *
135
+ * Default: 'language'
136
+ */
137
+ languageField: string
138
+
139
+ /**
140
+ * `documentTypes` should be an array of strings where each entry must match a name from your document schemas.
141
+ *
142
+ * If defined, this property will add a translate instruction to these document types.
143
+ * If undefined, the instruction will be added to all documents with aiAssistance enabled and a field matching `documentLanguageField` config.
144
+ *
145
+ * Documents with translation support will get a "Translate document>" instruction added.
146
+ **/
147
+ documentTypes?: string[]
148
+ }
149
+
150
+ export interface TranslationConfig {
151
+ /**
152
+ * Config for document types with fields in multiple languages in the same document.
153
+ */
154
+ field?: FieldTranslationConfig
155
+ /**
156
+ * Config for document types with a single language field that determines the language for the whole document.
157
+ */
158
+ document?: DocumentTranslationConfig
159
+ }
package/src/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import {SanityDocument, ValidationMarker} from 'sanity'
1
+ import {SanityDocument} from 'sanity'
2
2
  import {PortableTextBlock, PortableTextMarkDefinition, PortableTextSpan} from '@portabletext/types'
3
3
 
4
4
  //id prefixes
@@ -26,6 +26,9 @@ export const fieldPresenceTypeName = 'sanity.assist.instructionTask.presence' as
26
26
  export const assistSerializedTypeName = 'sanity.assist.serialized.type' as const
27
27
  export const assistSerializedFieldTypeName = 'sanity.assist.serialized.field' as const
28
28
 
29
+ export const outputFieldTypeName = 'sanity.assist.output.field' as const
30
+ export const outputTypeTypeName = 'sanity.assist.output.type' as const
31
+
29
32
  //url params
30
33
  export const inspectParam = 'inspect' as const
31
34
  export const fieldPathParam = 'pathKey' as const
@@ -149,10 +152,10 @@ export interface StudioInstruction {
149
152
  userId?: string
150
153
  title?: string
151
154
  placeholder?: string
155
+ output?: (OutputFieldItem | OutputTypeItem)[]
152
156
 
153
157
  //added after query / synthetic fields
154
158
  tasks?: InstructionTask[]
155
- validation?: ValidationMarker[]
156
159
  }
157
160
 
158
161
  export interface AssistTasksStatus {
@@ -166,3 +169,19 @@ export interface AssistInspectorRouteParams {
166
169
  [fieldPathParam]?: string
167
170
  [instructionParam]?: string
168
171
  }
172
+
173
+ export interface OutputFieldItem {
174
+ _type: typeof outputFieldTypeName
175
+ _key: string
176
+ //path relative to the field the instruction is for (same as _key)
177
+ relativePath?: string
178
+ }
179
+
180
+ export interface OutputTypeItem {
181
+ _type: typeof outputTypeTypeName
182
+ _key: string
183
+ /* array item type name */
184
+ type?: string
185
+ //path relative to the array-field the instruction is for, can be empty string (the array itself, same as _key)
186
+ relativePath?: string
187
+ }
@@ -3,6 +3,7 @@ import {useCallback, useMemo, useState} from 'react'
3
3
  import {serializeSchema} from './schemas/serialize/serializeSchema'
4
4
  import {useToast} from '@sanity/ui'
5
5
  import {SanityClient} from '@sanity/client'
6
+ import {TranslationMap} from './translate/paths'
6
7
 
7
8
  export interface UserTextInstance {
8
9
  blockKey: string
@@ -35,6 +36,68 @@ export function useApiClient(customApiClient?: (defaultClient: SanityClient) =>
35
36
  )
36
37
  }
37
38
 
39
+ export function useTranslate(apiClient: SanityClient) {
40
+ const [loading, setLoading] = useState(false)
41
+ const user = useCurrentUser()
42
+ const schema = useSchema()
43
+ const types = useMemo(() => serializeSchema(schema, {leanFormat: true}), [schema])
44
+ const toast = useToast()
45
+
46
+ const translate = useCallback(
47
+ ({
48
+ documentId,
49
+ languagePath,
50
+ fieldLanguageMap,
51
+ }: {
52
+ documentId: string
53
+ languagePath?: string
54
+ fieldLanguageMap?: TranslationMap[]
55
+ }) => {
56
+ setLoading(true)
57
+
58
+ return apiClient
59
+ .request({
60
+ method: 'POST',
61
+ url: `/assist/tasks/translate/${apiClient.config().dataset}?projectId=${
62
+ apiClient.config().projectId
63
+ }`,
64
+ body: {
65
+ documentId,
66
+ types,
67
+ languagePath,
68
+ fieldLanguageMap,
69
+ userId: user?.id,
70
+ },
71
+ })
72
+ .catch((e) => {
73
+ toast.push({
74
+ status: 'error',
75
+ title: 'Translate failed',
76
+ description: e.message,
77
+ })
78
+ setLoading(false)
79
+ throw e
80
+ })
81
+ .finally(() => {
82
+ // adding some artificial delay here
83
+ // server responds with 201 then proceeds; we dont need to allow spamming the button
84
+ setTimeout(() => {
85
+ setLoading(false)
86
+ }, 2000)
87
+ })
88
+ },
89
+ [setLoading, apiClient, toast, user, types]
90
+ )
91
+
92
+ return useMemo(
93
+ () => ({
94
+ translate,
95
+ loading,
96
+ }),
97
+ [translate, loading]
98
+ )
99
+ }
100
+
38
101
  export function useGenerateCaption(apiClient: SanityClient) {
39
102
  const [loading, setLoading] = useState(false)
40
103
  const user = useCurrentUser()