25th December 2022 5 min read

Translating Sanity content with Smartcat

Burak Tarım

In this article, we will show you how you can add localization to your Sanity CMS and how you can translate documents using Smartcat.

Smartcat allows us to upload an HTML file and automatically translate it to almost any language you want. You can learn more about Smartcat from their website.

We used this logic in our client Exakthealth. You can read more about our work here.

To implement localization and add the ability to import the translations we will follow these steps;

1. Create an empty project with Sanity

2. Install and setup the localization plugin

3. Create the document actions

4. Use Smartcat to translate

In this article, we will use Sanity Studio v3 with typescript. You can read more about creating a Sanity project here.

Use this command to create the Sanity project.

sh
1npm create sanity@latest -- --template clean --create-project "Sanity Project" --dataset production

After that, we need to create a dummy blog schema to translate.

Go under the ./schemas folder and create a filed called blog.ts. We will add `i18n` fields for localization.

typescript
1export default {2  type: 'document',3  title: 'Blog',4  name: 'blog',5  i18n: true,6  initialValue: {7    __i18n_lang: 'en',8  },9  fields: [10    {11      title: 'Title',12      type: 'string',13      name: 'title',14    },15    {16      title: 'Body',17      name: 'body',18      type: 'array',19      of: [{type: 'block'}],20    },21  ],22}23

And add this schema file to the schemaTypes array under the ./schemas/index.ts file. After that, it should look like this;

typescript
1import blog from './blog'2
3export const schemaTypes = [blog]4

We will use a plugin called Document Internationalization to handle localization stuff. You can read about it here.

Install the plugin with this command

typescript
1npm i @sanity/document-internationalization

Now we can update the sanity.config.ts file for this plugin. And we need to update the desk structure to show only default language content in the document lists. After that, the config file should look like this.

typescript
1import {defineConfig} from 'sanity'2import {deskTool} from 'sanity/desk'3import {visionTool} from '@sanity/vision'4import {schemaTypes} from './schemas'5import {6  getFilteredDocumentTypeListItems,7  withDocumentI18nPlugin,8} from '@sanity/document-internationalization'9
10export default defineConfig({11  name: 'default',12  title: 'Sanity Project',13
14  projectId: 'projectId',15  dataset: 'production',16
17  plugins: withDocumentI18nPlugin(18    [19      deskTool({20        structure: (S, {schema}) => {21          const docTypeListItems = getFilteredDocumentTypeListItems({22            S,23            schema,24            config: {25              languages: ['en', 'de', 'fr'],26            },27          })28
29          return S.list()30            .title('Content')31            .items([32              //anything else33              ...docTypeListItems,34              //anything else35            ])36        },37      }),38      visionTool(),39    ],40    {41      languages: ['en', 'de', 'fr'], // langauges you want to add42    }43  ),44
45  schema: {46    types: schemaTypes,47  },48})49

Now you should be able to see the dropdown top of the document.

We will use the document actions and document actions API to export and import the document for translation. You can read more about document actions and document actions API.

We are going to create two document actions, the first one is for exporting the document, and the second one is for importing the translation.

3.1. Export document action

Under your project create a file in the ./actions folder called exportTranslation.tsx. This actions function is pretty simple, all we are going to do is convert the document to HTML and let the user to download it.

typescript
1import {toHTML} from '@portabletext/to-html'2
3export default function ExportTranslation(props: any) {4  const document = props.draft ?? props.published5
6  const getHtml = () => {7    return `<div data-field="title">${document.title}</div>8    <div data-field="body">${toHTML(document.body)}</div>`9  }10
11  return {12    label: 'Export for translation',13    onHandle: () => {14      const html = getHtml()15
16      downloadHTML(`${document.title}-${document.__i18n_lang}`, html)17      props.onComplete()18    },19  }20}21
22function downloadHTML(fileName: string, html: string) {23  var element = document.createElement('a')24  element.setAttribute('href', 'data:text/html;charset=utf-8, ' + encodeURIComponent(html))25  element.setAttribute('download', `${fileName}.html`)26  document.body.appendChild(element)27  element.click()28  document.body.removeChild(element)29}30

We will add this file to the document actions list under the sanity.config.ts file.

typescript
1export default defineConfig({2  name: 'default',3  title: 'Sanity Project',4
5  projectId: 'projectId',6  dataset: 'production',7
8  plugins,9  document: {actions: [ExportTranslation]}, // Here it is10  schema: {11    types: schemaTypes,12  },13})

And if you open the document actions dropdown, you can see our Export for translation action. When you click it, it should download the HTML file.

3.2 Creating import for translation action

In this action, we will read the translated HTML file and patch the document.

First, create a file called importTranslation.tsx under the ./actions folder. Add it basic action code with dialog modal type.

typescript
1import React from 'react'2
3export default function ImportTranslation(props: any) {4  const [modalOpen, setModalOpen] = React.useState(false)5  const [html, setHtml] = React.useState<null | string>(null)6
7  return {8    label: 'Import Translation',9    onHandle: () => {10      setModalOpen(true)11    },12    dialog: modalOpen && {13      type: 'modal',14      onClose: props.onComplete,15      content: <></>,16    },17  }18}19

Now we are going to create a modal context. It will contain a file input and an apply button.

When the user selects a file, we are going to read it and update the "html,setHtml" state. html state's initial value is null so apply button should only show up when the html state is set.

typescript
1<div>2    <input3      type="file"4      multiple={false}5      onChange={(e) => {6        if (e.target.files) {7          const file = e.target.files[0]8          var reader = new FileReader()9          reader.readAsText(file, 'UTF-8')10          reader.onload = (evt) => {11            setHtml(evt.target?.result as string)12          }13          reader.onerror = () => {14            window.alert('Ops! Something went wrong')15          }16        }17      }}18    />19    {html && (20      <button>21        Apply22      </button>23    )}24  </div>

Now we can create the handleApply function to parse HTML and patch the document.

To parse the body, we are going to use @sanity/block-tools. It is pretty easy to use, you can read the docs for more info.

typescript
1// Import the packages2import {htmlToBlocks} from '@sanity/block-tools'3import Schema from '@sanity/schema'4import blog from '../schemas/blog'5
6//Create the compiled schema7
8const defaultSchema = Schema.compile({name: 'blog', types: [blog]})9
10// Create the block content type, we are going to use it for converting html to document body.11
12const blockContentType = defaultSchema13  .get('blog')14  .fields.find((field: any) => field.name === 'body').type
typescript
1const handleApply = () => {2    if (!html) return // return if html is still null3    const contentHTML = document.createElement('div')4    contentHTML.innerHTML = html5    const title = contentHTML.querySelector('[data-field=title]')?.innerHTML || ''6
7    const bodyHtml = contentHTML.querySelector('[data-field=body]')?.innerHTML || ''8
9    const body = htmlToBlocks(bodyHtml, blockContentType)10
11  }

Thanks to this function, we can convert the translated HTML to our blog document.

Now it's time to use Sanity hooks to patch the document. We will use useDocumentOperation to make it. You can read more about Sanity hooks.

typescript
1// Import the package2import {useDocumentOperation} from 'sanity'3
4//Get patch function from the hook5
6const {patch} = useDocumentOperation(props.id, props.type)7
8//Call patch execute to update the document.9
10patch.execute([{set: {title, body}}], props.draft || props.published)11  

Now the final import for translation action should look like this.

typescript
1import {htmlToBlocks} from '@sanity/block-tools'2import Schema from '@sanity/schema'3import React from 'react'4import blog from '../schemas/blog'5import {useDocumentOperation} from 'sanity'6
7const defaultSchema = Schema.compile({name: 'blog', types: [blog]})8
9const blockContentType = defaultSchema10  .get('blog')11  .fields.find((field: any) => field.name === 'body').type12
13export default function ImportTranslation(props: any) {14  const {patch} = useDocumentOperation(props.id, props.type)15  const [modalOpen, setModalOpen] = React.useState(false)16  const [html, setHtml] = React.useState<null | string>(null)17
18  const handleApply = () => {19    if (!html) return // return if html is still null20    const contentHTML = document.createElement('div')21    contentHTML.innerHTML = html22    const title = contentHTML.querySelector('[data-field=title]')?.innerHTML || ''23
24    const bodyHtml = contentHTML.querySelector('[data-field=body]')?.innerHTML || ''25
26    const body = htmlToBlocks(bodyHtml, blockContentType)27
28    patch.execute([{set: {title, body}}], props.draft || props.published)29  }30
31  return {32    label: 'Import Translation',33    onHandle: () => {34      setModalOpen(true)35    },36    dialog: modalOpen && {37      type: 'modal',38      onClose: props.onComplete,39      content: (40        <div>41          <input42            type="file"43            multiple={false}44            onChange={(e) => {45              if (e.target.files) {46                const file = e.target.files[0]47                console.log(file)48                var reader = new FileReader()49                reader.readAsText(file, 'UTF-8')50                reader.onload = (evt) => {51                  setHtml(evt.target?.result as string)52                }53                reader.onerror = () => {54                  window.alert('Ops! Something went wrong')55                }56              }57            }}58          />59          {html && (60            <button61              onClick={() => {62                handleApply()63              }}64            >65              Apply66            </button>67          )}68        </div>69      ),70    },71  }72}73

We can add this to the document actions array in the Sanity config.

Since we want to only export default language and import others, we need to add a conditional check based on the documentId. And the actions config should look like this.

typescript
1document: {2    actions: (prev, context) => {3      const defaultLang = !context.documentId?.includes('_i18n_')4      return defaultLang ? [...prev, exportTranslation] : [...prev, importTranslation]5    },6  },

Now you can export the default language, translate it with Smartcat and import it back!