Internationalizing plugins
This article provides a guide for plugin authors to add localization capabilities to their Sanity Studio plugins.
Looking for Studio localization docs?
This article is aimed at plugin authors who wish to add localization capabilities to their plugins. If you're aim is rather to enable a new language in your studio UI, visit this article.
The v3.23.0
release of Sanity Studio includes the tools maintainers need to internationalize their Studio code, as well as a range of tools for plugin developers to enable i18n in their plugins. This article will introduce you to the core concepts and tooling with examples to get you started.
Before proceeding, make sure you are running the latest version of Sanity Studio. Your Studio needs to be v3.23.0
or later to work with the internationalization (i18n) APIs.
Gotcha
Minimum Sanity peer dependency
Since there is no easy way to make these changes backward compatible, your plugin will now have to bind to a minimum version of Sanity where i18n is introduced. As this may be considered a breaking change, consider implementing semantic versioning if not already in use.
- Locale - “English (US)”, or “Norwegian (Bokmål)”. Has an ID (
en-US
,nb-NO
), a title and an icon. In most cases it should also have one or more resource bundles defined (strings). - Locale namespace - “studio”, “desk”, “vision” etc. This makes it simpler to use the translation in a plugin (no need to prefix all strings), and allows for dynamic loading of namespaces when needed.
- Resource bundles - represents the strings available for a locale/namespace combination. The “resources” (strings) can be defined statically or as an async function, allowing for dynamic imports. Studio only loads/merges resources for a namespace/locale when used. (not thrilled about the name, but i18next calls strings “resources”, and PoC used “bundle” terminology - suggestions welcome).
Start by creating a “resource bundle”, and define a namespace for your plugin using the defineLocaleResourceBundle
helper function. A resource bundle is an object specifying a namespace and locale for a localization, as well as pointing to where the files containing your localized strings – or resources – can be found. By convention the namespace should be the same as the name of your plugin, e.g., @sanity/vision
or @sanity/google-maps-input
. The locale should be specified following the BCP-47 naming convention extended to include both language – e.g. en
for English – and area – i.e. US
for USA. See the list of available locales for reference.
import {defineLocaleResourceBundle} from 'sanity'
export const googleMapsInputResourceBundle = defineLocaleResourceBundle({
locale: 'en-US',
namespace: '@sanity/google-maps-input',
resources: () => import('./resources'),
})
If you aim to add support for multiple locales, you should export an array of these bundles with different locale
properties.
The resources
key is a function that resolves to an object of resources, which are the files containing your translated strings - this allows for only loading the namespace and locale combination if the user has chosen the given locale and is currently using the plugin that depends on it.
The resources
file should have a default export that exposes an object of key-value pairs. The keys should be identical for each supported locale, while the translated strings are kept in values. E.g.:
export default {
/** --- PUBLISH ACTION --- */
/** Tooltip when action is disabled because the studio is not ready.*/
'action.publish.disabled.not-ready': 'Operation not ready',
/** Label for action when there are pending changes.*/
'action.publish.draft.label': 'Publish',
/** Label for the "Publish" document action while publish is being executed.*/
'action.publish.running.label': 'Publishing…',
/** Label for the "Publish" document action when there are no changes.*/
'action.publish.published.label': 'Published',
/** Label for the button to rev up the engine of your Tardis.*/
'action.time-travel': 'Allons-y!'
}
While you are technically free to name your keys however you like, we do have a few recommendations to keep everything consistent and easy to reason about:
- Keys should be in lowercase and kebab-case - no camelCase!
- Keys are namespaced, so you don’t need to worry about conflicts outside of your plugin.
- Put a comment on top of each key explaining what it does/what it means. Helps translating the key, both for humans and/or an AI.
- Use separate keys for aria labels, suffixed with
-aria-label
.
In your definePlugin
call, include an i18n.bundles
property that points to your resource bundles:
import {definePlugin} from 'sanity'
import {mySwedishBundle, myNorwegianBundle} from './i18n'
export const myPlugin = definePlugin(() => ({
// ...rest of plugin config
i18n: {
bundles: [mySwedishBundle, myNorwegianBundle],
},
}))
The workhorse of the i18n toolkit is the useTranslation
hook. Initialize it with your plugin namespace and use it to retrieve localized string values in your plugin user interface.
import {useTranslation} from 'sanity'
import {travelInTimeAndSpace} from 'tardis'
function MyComponent() {
// Argument is the locale namespace - if omitted, it will use the `studio`
// namespace, which is probably not what you want. Specifying a namespace
// might become a requirement in the future. For now it's just good practice.
const {t} = useTranslation('myPlugin')
return <button onClick={travelInTimeAndSpace}>{t('action.time-travel')}</button>
}
Interpolation is how you dynamically integrate variable data, like perhaps a username or file type, into translated strings. Surrounding a term with a double set of curly braces will mark it as a {{placeholder}}
for interpolation. You’d then supply the term to be interpolated as a second argument to the useTranslation
function.
// resources.ts
export default {
// ...
'greetings.my-name-is': 'My name is {{userName}}'
}
// MyComponent.tsx
function MyComponent() {
const {t} = useTranslation('myPlugin')
return <div>{t('greetings.my-name-is', {userName: 'Kokos'})}</div>
}
This object can take any number of key:value-pairs, allowing for quite complex string-crafting.
Protip
Note that values passed for interpolation should generally never be language-specific terms. For instance, sending Image
or File
to a message that reads {{assetType}} not found
will not work in other languages, i.e. Kunne ikke finne Image
is not valid Norwegian. See the section below titled Context for more info.
That second argument object to the useTranslation
hook is no one-trick-pony. In addition to accepting interpolation variables, passing a count
parameter to the t
function will allow it to be pluralized:
// resources.ts
export default {
'search.result-summary_zero': 'Nothing matched your query',
'search.result-summary_one': 'Found one match for your query',
'search.result-summary_other': 'Found {{count}} matches for your query',
}
// MyComponent.tsx
function MyComponent() {
const {t} = useTranslation('myPlugin')
// ⬇ returns 'Found 4 matches for your query
return <div>{t('search.result-summary', {count: 4})}</div>
}
Note that the underscore works as a delimiter for matching terms. We’ll see the same pattern when working with variants in the next section. For more information, consult the [i18next documentation](https://www.i18next.com/translation-function/plurals).
Similarly to the count feature, the context feature allows you to create several variants of a translated term. Pass a string to the context
parameter, and it will be matched against the underscore-delimited suffix of the keys:
// resources.ts
export default {
// ...
'error.asset-not-found_image': 'Image not found',
'error.asset-not-found_file': 'File not found',
// Fallback in case context cannot be found:
'error.asset-not-found': 'Asset not found',
}
// MyComponent.tsx
function MyComponent(props) {
const {t} = useTranslation('myPlugin')
// ⬇ returns either 'file' or 'image' depending on the assets type
const assetType = props.document._type === 'sanity.imageAsset' ? 'image' : 'file'1
return <div>{t('error.asset-not-found', {context: assetType})}</div>
}
In certain cases, you may need to use a component as part of the string. For instance, formatting/highlighting a part to indicate that it is user input, or showing a localized timestamp with an aria-label and a computer-readable ISO-timestamp attached to it.
For this, you can use the Translate
component. Note that it is heavier to execute and should therefore only be used when necessary.
// resources.ts
export default {
'event-listing.summary': '{{host}} is organizing <Emphasis>{{name}}</Emphasis> at <Location/>, <Time />'
}
// EventListing.tsx
import {useRelativeTime, Translate} from 'sanity'
function EventListing(event) {
const {t} = useTranslation('myPlugin')
const time = useRelativeTime(event.isoTime)
return (
<Translate
t={t}
i18nKey="event-listing.summary"
values={{host: event.host, name: event.name}}
components={{
Time: () => <time dateTime={event.isoTime}>{time}</time>,
Location: () => <a href={event.location.url}>{event.location.name}</a>,
Emphasis: ({children}) => <em>{children}</em>
}}
/>
)
}
Gotcha
The tags in components
can only receive a single prop: children
- all other props should be passed to the Translate-component when defining it. This helps minimize the parsing logic, as well as keeping it safer in terms of injections.
The available and chosen languages are currently resolved as part of the workspace matcher, which means in some areas of the Studio user interface, i18n is not yet supported.
When you need to use i18n outside of React, such as in validation or structure definitions, we pass an i18n
”source” through context, which has a t
function available for use. If you encounter areas that do not have access to this context where you would need it, please let us know so we can find a suitable workaround or find a way to pass it down.
defineField({
type: 'string',
name: 'Model',
validation: (Rule) =>
Rule.custom((value, context) => {
if (!value || !value.startsWith('Model ')) {
return context.i18n.t('some-namespace:some-error-message', {
modelValue: value,
})
}
return true
}),
})
It’s worth noting that you can also add localized strings directly in the schema if you know what languages should be supported at authoring time.
import {defineType, defineField} from 'sanity'
export const gallery = defineType({
name: 'gallery',
type: 'document',
fields: [
defineField({
name: 'photos',
type: 'array',
of: [{type: 'image'}],
// The parameters here have to be documented
validation: Rule => Rule.required().min(4).max(100).error({
'en-US': 'Needs at least {{min}}, at most {{max}} items',
'no-NB': 'Kan ikke ha flere enn {{max}}, og ikke færre enn {{min}}'
})
}),
]
})
We are working on exposing more hooks, and some of these might change names. Currently they are
useTranslation
- described above in some detailuseCurrentLocale
- returns aLocale
, which contains the localeid
andtitle
. Useful if you want to send this as part of a request to a server or something to localize the response. Also, for passing into theIntl.X
APIsuseListFormat
- provides cached access toIntl.ListFormat
instances based on the passed options and the current locale.useNumberFormat
- provides cached access toIntl.NumberFormat
instances based on the passed options and the current locale.useFormattedDuration
- not strictly an i18n API, but is localized. Give it a duration in milliseconds and it will format it with localized units.
If you want to see which parts of the user interface are localized and which are not, you can run the Studio with SANITY_STUDIO_DEBUG_I18N=triangles npm run dev
. In this mode all strings that have been localized will be framed by little ◤ “Triangles” ◢, and your console will have all sorts of useful information logged to help you locate missing or incorrect translations.