Translating Sanity content with Smartcat

Table of contents

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

1. Create an empty project with Sanity

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.

npm 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.

export default {
type: 'document',
title: 'Blog',
name: 'blog',
i18n: true,
initialValue: {
__i18n_lang: 'en',
},
fields: [
{
title: 'Title',
type: 'string',
name: 'title',
},
{
title: 'Body',
name: 'body',
type: 'array',
of: [{type: 'block'}],
},
],
}

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

import blog from './blog'
export const schemaTypes = [blog]

2. Install and setup the localization plugin

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

Install the plugin with this command

npm 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.

import {defineConfig} from 'sanity'
import {deskTool} from 'sanity/desk'
import {visionTool} from '@sanity/vision'
import {schemaTypes} from './schemas'
import {
getFilteredDocumentTypeListItems,
withDocumentI18nPlugin,
} from '@sanity/document-internationalization'
export default defineConfig({
name: 'default',
title: 'Sanity Project',
projectId: 'projectId',
dataset: 'production',
plugins: withDocumentI18nPlugin(
[
deskTool({
structure: (S, {schema}) => {
const docTypeListItems = getFilteredDocumentTypeListItems({
S,
schema,
config: {
languages: ['en', 'de', 'fr'],
},
})
return S.list()
.title('Content')
.items([
//anything else
...docTypeListItems,
//anything else
])
},
}),
visionTool(),
],
{
languages: ['en', 'de', 'fr'], // langauges you want to add
}
),
schema: {
types: schemaTypes,
},
})

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

3. Create the document actions

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.

import {toHTML} from '@portabletext/to-html'
export default function ExportTranslation(props: any) {
const document = props.draft ?? props.published
const getHtml = () => {
return `<div data-field="title">${document.title}</div>
<div data-field="body">${toHTML(document.body)}</div>`
}
return {
label: 'Export for translation',
onHandle: () => {
const html = getHtml()
downloadHTML(`${document.title}-${document.__i18n_lang}`, html)
props.onComplete()
},
}
}
function downloadHTML(fileName: string, html: string) {
var element = document.createElement('a')
element.setAttribute('href', 'data:text/html;charset=utf-8, ' + encodeURIComponent(html))
element.setAttribute('download', `${fileName}.html`)
document.body.appendChild(element)
element.click()
document.body.removeChild(element)
}

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

export default defineConfig({
name: 'default',
title: 'Sanity Project',
projectId: 'projectId',
dataset: 'production',
plugins,
document: {actions: [ExportTranslation]}, // Here it is
schema: {
types: schemaTypes,
},
})

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.

import React from 'react'
export default function ImportTranslation(props: any) {
const [modalOpen, setModalOpen] = React.useState(false)
const [html, setHtml] = React.useState<null | string>(null)
return {
label: 'Import Translation',
onHandle: () => {
setModalOpen(true)
},
dialog: modalOpen && {
type: 'modal',
onClose: props.onComplete,
content: <></>,
},
}
}

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.

<div>
<input
type="file"
multiple={false}
onChange={(e) => {
if (e.target.files) {
const file = e.target.files[0]
var reader = new FileReader()
reader.readAsText(file, 'UTF-8')
reader.onload = (evt) => {
setHtml(evt.target?.result as string)
}
reader.onerror = () => {
window.alert('Ops! Something went wrong')
}
}
}}
/>
{html && (
<button>
Apply
</button>
)}
</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.

// Import the packages
import {htmlToBlocks} from '@sanity/block-tools'
import Schema from '@sanity/schema'
import blog from '../schemas/blog'
//Create the compiled schema
const defaultSchema = Schema.compile({name: 'blog', types: [blog]})
// Create the block content type, we are going to use it for converting html to document body.
const blockContentType = defaultSchema
.get('blog')
.fields.find((field: any) => field.name === 'body').type
const handleApply = () => {
if (!html) return // return if html is still null
const contentHTML = document.createElement('div')
contentHTML.innerHTML = html
const title = contentHTML.querySelector('[data-field=title]')?.innerHTML || ''
const bodyHtml = contentHTML.querySelector('[data-field=body]')?.innerHTML || ''
const body = htmlToBlocks(bodyHtml, blockContentType)
}

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.

// Import the package
import {useDocumentOperation} from 'sanity'
//Get patch function from the hook
const {patch} = useDocumentOperation(props.id, props.type)
//Call patch execute to update the document.
patch.execute([{set: {title, body}}], props.draft || props.published)

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

import {htmlToBlocks} from '@sanity/block-tools'
import Schema from '@sanity/schema'
import React from 'react'
import blog from '../schemas/blog'
import {useDocumentOperation} from 'sanity'
const defaultSchema = Schema.compile({name: 'blog', types: [blog]})
const blockContentType = defaultSchema
.get('blog')
.fields.find((field: any) => field.name === 'body').type
export default function ImportTranslation(props: any) {
const {patch} = useDocumentOperation(props.id, props.type)
const [modalOpen, setModalOpen] = React.useState(false)
const [html, setHtml] = React.useState<null | string>(null)
const handleApply = () => {
if (!html) return // return if html is still null
const contentHTML = document.createElement('div')
contentHTML.innerHTML = html
const title = contentHTML.querySelector('[data-field=title]')?.innerHTML || ''
const bodyHtml = contentHTML.querySelector('[data-field=body]')?.innerHTML || ''
const body = htmlToBlocks(bodyHtml, blockContentType)
patch.execute([{set: {title, body}}], props.draft || props.published)
}
return {
label: 'Import Translation',
onHandle: () => {
setModalOpen(true)
},
dialog: modalOpen && {
type: 'modal',
onClose: props.onComplete,
content: (
<div>
<input
type="file"
multiple={false}
onChange={(e) => {
if (e.target.files) {
const file = e.target.files[0]
console.log(file)
var reader = new FileReader()
reader.readAsText(file, 'UTF-8')
reader.onload = (evt) => {
setHtml(evt.target?.result as string)
}
reader.onerror = () => {
window.alert('Ops! Something went wrong')
}
}
}}
/>
{html && (
<button
onClick={() => {
handleApply()
}}
>
Apply
</button>
)}
</div>
),
},
}
}

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.

document: {
actions: (prev, context) => {
const defaultLang = !context.documentId?.includes('_i18n_')
return defaultLang ? [...prev, exportTranslation] : [...prev, importTranslation]
},
},

4. Use Smartcat to translate

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

[@portabletext/react] Unknown block type "video", specify a component for it in the `components.types` prop

Recent articles

SEO best practices on Sanity

When we build a website with Sanity, we configure SEO best practices to rank higher on search engine result pages.
Omar Benseddik's photo
Omar Benseddik
2022-09-05 · 7 min

Translating Shopify stores with Sanity

At Tinloof, we have an internal library that does a lot of heavy lifting when it comes to building fast Remix websites that have their content managed from Sanity. A while ago, we...
Seif Ghezala's photo
Seif Ghezala
2023-01-31 · 4 min