• Official Extension
Automatic Translation

README

ApostropheCMS logo

Automatic Translation

Scale your content globally with AI-powered translation for ApostropheCMS. Transform your localization workflow with automatic translation that handles pages, pieces, and complex content structures. Supporting DeepL, Google Cloud Translation, Azure Translator, the AI provider your project already configured, and custom translation providers with intelligent retry mechanisms and customizable field mapping.

Why Automatic Translation?

  • 🌍 Global Content, Zero Friction: Transform your content into multiple languages instantly during the localization process
  • 🎯 Production-Ready Quality: Support for professional translation providers including DeepL, Google Cloud, Azure Translator, and custom providers
  • 🤖 Translate With The AI You Already Have: Reuse the provider configured for the Apostrophe core AI engine — one configuration, no second API key, whole-document context
  • ⚡ Smart Field Handling: Automatically translates text fields, rich content, and widgets while preserving formatting
  • 🔧 Developer Friendly: Extensive customization options for field mapping, widget handling, and custom field types
  • 📋 Translation Workflow: Streamlined process for submitting content for translation and reviewing AI-generated results before publishing

Installation

Note: This module requires an ApostropheCMS Pro license. Don't have Pro yet? Create an account on Apostrophe Workspaces or contact us and get started with ApostropheCMS Pro to access this and other Pro extensions.

bash
npm install @apostrophecms-pro/automatic-translation

Requirements: this module is built on the Apostrophe core schema content extraction (apos.schema.extract) — update apostrophe to the latest version.

Quick Start Guide

Choose your preferred translation provider and follow the setup instructions below. All providers offer professional-grade translation quality with different strengths and pricing models.

AI Translation

Translate through the AI provider your project has already configured. Apostrophe's core AI engine (apos.ai) is configured once, for every AI feature — this provider reuses it, so translation needs no AI client, key or model of its own.

It also translates differently from the dedicated translation services below: a language model receives the whole document in one request, so it can disambiguate short strings against their neighbours and keep terminology and tone consistent from field to field.

Requirements: a core carrying the AI engine — update apostrophe to the latest version — and at least one provider configured for the @apostrophecms/ai module. Every project locale is supported — a language model translates any language pair.

Environment variable setup (recommended): each AI adapter reads its own variable — APOS_ANTHROPIC_KEY here, and the environment wins over a key configured in code.

bash
export APOS_ANTHROPIC_KEY=your-key-here npm start

Configuration:

javascript
import apostrophe from 'apostrophe'; ​ apostrophe({ root: import.meta, shortName: 'my-project', modules: { // The core AI engine, configured once for every AI feature '@apostrophecms/ai': { options: { provider: 'anthropic', providers: { anthropic: {} } } }, '@apostrophecms-pro/automatic-translation': { options: { provider: 'llm' } }, // Nothing to configure: it inherits the AI configuration above '@apostrophecms-pro/automatic-translation-llm': {} } });

The provider appears as AI in the localization dialog. For the full AI configuration surface — the other adapters, effort levels and model routing — see the @apostrophecms/ai module in Apostrophe core.

Optional settings

javascript
'@apostrophecms-pro/automatic-translation-llm': { options: { // Translation is cheap work, so bias the routing down effort: 'low' } }
Option Default Description
effort the @apostrophecms/ai module's own default level — its effort.default option, or medium when that is unset Which routing level to resolve. Translation is cheap work, so low is usually the right bias.
provider, model whatever the core AI routing table resolves for the effort level above Pin an exact target, bypassing the routing table. Configure both or neither.
chunkTokens half of the routed model's declared maxOutputTokens, or 4000 when the model declares none How much text one request may carry, in estimated tokens. A document holding more is translated in several requests and the answers merged.
batchAttempts 2 — one batch, and one more for whatever it left out How many times one set of fields may be asked for when the model answers with entries missing. 1 goes straight to the per-field fallback below.

How a batch stays aligned

Sending a whole document in one request is cheaper and better, and the one risk that comes with it — fields coming back misaligned — is closed rather than hoped away:

  • every field is sent with a stable id, its position in the extracted content;
  • the model answers in id/text pairs, and the result is rebuilt strictly by id in the original order, so a reordered, repeated or dropped answer is detected and never written to the wrong field;
  • empty fields are never sent and pass through untranslated;
  • a field that does not come back is asked for again in a smaller request, then on its own; a field that survives even that fails the translation rather than being silently left in its source language.

DeepL Setup

Get your API key from your DeepL Account. Copy the key from the "Authentication Key for DeepL API" section.

Environment variable setup (recommended):

bash
export APOS_DEEPL_API_SECRET=your-key-here npm start

Configuration:

javascript
import apostrophe from 'apostrophe'; ​ apostrophe({ root: import.meta, shortName: 'my-project', modules: { '@apostrophecms-pro/automatic-translation': { options: { provider: 'deepl' } }, '@apostrophecms-pro/automatic-translation-deepl': {} } });

Alternative configuration with API key in code:

javascript
import apostrophe from 'apostrophe'; ​ apostrophe({ root: import.meta, shortName: 'my-project', modules: { '@apostrophecms-pro/automatic-translation': { options: { provider: 'deepl' } }, '@apostrophecms-pro/automatic-translation-deepl': { options: { apiSecret: 'your-key-here' } } } });

Language Mapping for DeepL

Your project locales might not match exactly with DeepL's supported languages. Configure mappings as needed:

javascript
'@apostrophecms-pro/automatic-translation-deepl': { options: { // Map project locales to DeepL source languages (no country codes supported) sourcesMapping: { 'en-US': 'en', }, // Map project locales to DeepL target languages targetsMapping: { 'en-GB': 'en-US', // DeepL doesn't support 'en' for targets 'pt': 'pt-PT' // Default mapping provided } } }

Note: DeepL sources only support languages without country codes. The module removes country codes by default, so you might not need source mapping. Some default target mappings are provided for common locales like en and pt to avoid errors.

For supported languages, check the DeepL documentation.

Translation Provider Setup

Google Cloud Translation

Requires a Google Cloud project with Translation API enabled and a service account key. Complete setup guide.

Environment variables (recommended):

bash
export APOS_GOOGLE_TRANSLATE_PROJECT_ID=your-project-id export APOS_GOOGLE_TRANSLATE_KEY_FILENAME=/path/to/keyfile.json npm start

Configuration:

javascript
import apostrophe from 'apostrophe'; ​ apostrophe({ root: import.meta, shortName: 'my-project', modules: { '@apostrophecms-pro/automatic-translation': { options: { provider: 'google' } }, '@apostrophecms-pro/automatic-translation-google': {} } });

Alternative configuration with credentials in code:

javascript
import apostrophe from 'apostrophe'; ​ apostrophe({ root: import.meta, shortName: 'my-project', modules: { '@apostrophecms-pro/automatic-translation': { options: { provider: 'google' } }, '@apostrophecms-pro/automatic-translation-google': { options: { projectId: 'your-project-id', keyFilename: '/path/to/keyfile.json' } } } });

Azure AI Translation

Get your API key from Azure AI Services. Follow the Azure documentation to create a Translator resource and obtain your authentication key.

Environment variable (recommended):

bash
export APOS_AZURE_API_SECRET=your-key-here npm start

Configuration:

javascript
import apostrophe from 'apostrophe'; ​ apostrophe({ root: import.meta, shortName: 'my-project', modules: { '@apostrophecms-pro/automatic-translation': { options: { provider: 'azure' } }, '@apostrophecms-pro/automatic-translation-azure': {} } });

Alternative configuration with API key in code:

javascript
import apostrophe from 'apostrophe'; ​ apostrophe({ root: import.meta, shortName: 'my-project', modules: { '@apostrophecms-pro/automatic-translation': { options: { provider: 'azure' } }, '@apostrophecms-pro/automatic-translation-azure': { options: { apiSecret: 'your-key-here' } } } });

Language Mapping for Azure

Your project locales might not match exactly with Azure's supported languages. Configure mappings as needed:

javascript
'@apostrophecms-pro/automatic-translation-azure': { options: { // Map project locales to Azure language codes languageMapping: { 'en-US': 'en', 'pt': 'pt-PT', // Default provided by module 'pt-BR': 'pt' // Default provided by module } } }

Important: Setting a custom languageMapping completely overrides the default mappings. If you need the defaults (like pt: 'pt-PT'), you must include them explicitly in your custom mapping.

Advanced Azure Configuration

Optional settings for specific Azure service configurations:

javascript
'@apostrophecms-pro/automatic-translation-azure': { options: { region: 'eastus', // Required for multi-service or regional resources baseUrl: 'https://api.cognitive.microsofttranslator.com', // Custom domain translateOptions: { // Additional query parameters for Azure Translation API // See Azure documentation for available options category: 'general', profanityAction: 'NoAction' } } }

How It Works

When configured, the module adds a "Translate text content" option to the localization dialog. It will show a new Translate text content option under the Automatic translation settings section in the localization dialog. This intelligently translates:

  • All string and slug fields throughout your content structure
  • Rich text widgets with proper HTML preservation
  • Custom widgets and nested content structures
  • Area widgets while maintaining layout and formatting
  • The html widget is excluded by default (its extractable option carries the notranslate tag). You can enable it in your project by overriding the option in the widget module definition: extractable: true (see the "Content Extraction Policy" section below).

Localization Wizard

Only languages supported by both your project and the selected translation provider will be available for translation. It will skip the translation when a language is not supported, and show a message about it.

Configuration Options

Module-Level Settings

Configure retry behavior and rate limiting:

javascript
'@apostrophecms-pro/automatic-translation': { options: { provider: 'deepl', retryBaseDelayMs: 1000, // Default initial retry delay (1 second) retryMaxAttempts: 5 // Default maximum retry attempts } }

Retry Mechanism: The module automatically handles rate limiting from translation providers using exponential backoff. When a provider responds with a 429 (Too Many Requests) error, the module will retry with increasing delays calculated as:

plaintext
delay = lastDelay * 2 * (1 + random(0, 1))

Where lastDelay is the delay of the previous attempt with an initial value retryBaseDelayMs and random(0, 1) is a random number between 0 and 1. The retry attempts will be stopped when the retryMaxAttempts is reached. This ensures reliable translation even during high-traffic periods or when hitting API rate limits.

Library mode

Setting enabled: false on the main module turns the bundle into an extraction library:

javascript
'@apostrophecms-pro/automatic-translation': { options: { enabled: false } }

In library mode no translation provider is registered, no provider is required, and the admin UI additions (the translation indicator and field metadata component) are not installed. Other modules — such as @apostrophecms-pro/import-export-translation — keep using the extraction API: extractText, applyFieldText, the translation type registry and the convertText/convertTranslationText conversions all work normally.

Things that still happen in library mode, by design:

  • The module still requires an Apostrophe core providing apos.schema.extract and refuses to boot without one.
  • The legacy translate flag mapping still runs at startup, adjusting field type definitions, schemas and widget options to the extractable policy — extraction behaves identically whether translation is enabled or not.
  • The bundled widget improvements (such as the html widget's translation opt-out) stay active.

The optional automatic-translation-llm provider module is inert in library mode: it does not register a provider and does not require a configured AI stack, so a project using this bundle only as a library can have it installed without configuring AI.

Modules integrating with the extraction API should check the extractionVersion property (an integer, currently 1) on this module rather than feature-sniffing internal methods; it is bumped when the extraction contract changes.

Content Extraction Policy

Translation is driven by the Apostrophe core schema content extraction (apos.schema.extract) and its extractable field policy. Every field, field type, widget module and per-area widget configuration can declare extractable:

  • true — the default: extract with the type's tags (text-carrying fields such as string are tagged text)
  • false — never extract, for any consumer of extraction
  • [ 'tag', ... ] — extract, adding the listed tags to the type's tags

Note that true adds no tags of its own — it restores the default behavior, where every tag comes from the field types (string carries text). In practice:

  • To exclude content from translation only, add the notranslate tag — at the field, field type, widget module or area widget configuration level.
  • To hide content from every consumer, use extractable: false.
  • To undo a widget module opt-out (such as the html widget's), override the module option with extractable: true. Do it at the module level — the per-area configuration only adds tags, it never removes them.
  • A custom field type is translatable when its items carry the text tag: extend string (the tag is inherited) or declare extractable: [ 'text' ] with an extractor.

Declarations combine rather than override: a field's tags add to its type's tags, and a per-area widget configuration adds to the widget module's option. extractable: false at any level wins. The one thing a field cannot do is force extraction of a type that does not extract — give the type an extractor (or extend a text-carrying type) instead.

This module translates the extracted items tagged text, excluding those carrying the special notranslate marker tag. That distinction matters: extraction serves more consumers than translation (for example AI features building document context). To keep a field out of translation while leaving it visible to the other consumers, tag it notranslate. Reserve extractable: false for content that should be invisible to every consumer.

Field-Level Control

Exclude specific fields from translation:

javascript
// modules/my-module/index.js export default { fields: { add: { internalCode: { type: 'string', label: 'Internal Reference Code', extractable: [ 'notranslate' ] // Skip translation }, description: { type: 'string', label: 'Product Description' // Will be translated by default } } } };

A field-level extractable array adds to the type's tags — [ 'notranslate' ] on a string field resolves to [ 'text', 'notranslate' ], so the field stays a text item but is excluded from translation.

Widget-Level Control

Control translation for entire widget types:

javascript
// modules/my-widget/index.js export default { extend: '@apostrophecms/widget-type', options: { label: 'My Custom Widget', extractable: [ 'notranslate' ] // Skip all fields in this widget } };

Or disable specific widget instances in areas:

javascript
// modules/my-page/index.js export default { fields: { add: { main: { type: 'area', options: { widgets: { 'code-block': { extractable: [ 'notranslate' ] // Don't translate code examples }, 'rich-text': {} // Will be translated } } } } } };

Note the per-area form is a flat property of the widget configuration, not nested under options.

Migrating from the legacy translate flags

Earlier versions of this module used translate: true/false flags at four levels. The flags are supported but deprecated: at startup they are automatically mapped onto the extractable policy, and a single deprecation warning is logged per boot in development. No project edit is forced, but migrating to extractable is highly recommended — it is the stable configuration, and what every new feature builds on.

The mapping is:

Legacy Current
translate: true extractable: [ 'text' ]
translate: false extractable: [ 'notranslate' ]

translate: false deliberately maps to the notranslate marker and not to extractable: false: the flag opts out of translation only, so the field stays visible to other extraction consumers (for example AI features building document context). Use extractable: false if content must be hidden from those too.

Field level — before:

javascript
internalCode: { type: 'string', translate: false }

After:

javascript
internalCode: { type: 'string', extractable: [ 'notranslate' ] }

Widget module level — before:

javascript
export default { extend: '@apostrophecms/widget-type', options: { translate: false } };

After:

javascript
export default { extend: '@apostrophecms/widget-type', options: { extractable: [ 'notranslate' ] } };

Area widget configuration — before (the legacy nested form):

javascript
main: { type: 'area', options: { widgets: { 'code-block': { options: { translate: false } } } } }

After (flat form):

javascript
main: { type: 'area', options: { widgets: { 'code-block': { extractable: [ 'notranslate' ] } } } }

Custom field type — before:

javascript
self.apos.schema.addFieldType({ name: 'myCustomField', translate: true, // ... });

After (extend a text-carrying type, or declare the tags and an extractor — see "Custom field types" below):

javascript
self.apos.schema.addFieldType({ name: 'myCustomField', extend: 'string', // ... });

Migration notes:

  • A translate: true type translates all of its fields — no field-level flag is needed. Exclude individual fields with extractable: [ 'notranslate' ].
  • The same goes for a custom type extending string or another text-carrying type: it is translatable out of the box, no flags needed. Tag the type or individual fields notranslate to exclude them.
  • Upgrade check: on older versions, some fields of custom types silently never translated even when the type was enabled. After updating they translate. Review your custom types and tag any field that must stay untranslated notranslate.
  • A legacy flag merges into an explicit extractable on the same declaration: translate: false adds the notranslate marker to the tags ([ 'seo' ] becomes [ 'seo', 'notranslate' ]), and a type's translate: true adds text. An explicit extractable: false wins over any legacy flag.
  • A widget configured with legacy translate: true cannot re-enable a widget whose extractable option carries notranslate (such as the html widget) — the startup check logs a warning; override the extractable option instead.

Migrating the legacy extraction hooks

The legacy hooks — a widget's own getTranslationText method, and a getText registered through addTranslationType — are deprecated but fully functional: legacy signatures, verbatim items with custom properties and the recursive extractText call all keep working, with a once-per-type deprecation warning. Nothing forces a migration, but migrating is highly recommended — the core extract method is the stable path, and what every new feature builds on.

Your widget Migration work
No getTranslationText override None. The legacy default and the core default extract are the same schema walk.
An override synthesizing content and/or recursing A mechanical transform — see below.
Custom metaPath or extra item properties The same transform; custom properties survive onto the field meta. Or keep the legacy hook until ready.

Before:

javascript
methods(self) { return { getTranslationText(value, schemaPath = []) { const at = self.apos.modules['@apostrophecms-pro/automatic-translation']; const valuePath = [ { _id: value._id } ]; return [ { text: value.tagline, valuePath: valuePath.concat('tagline'), schemaPath, type: at.getWidgetType(self) } ].concat(at.extractText(self.schema, value, [], valuePath, schemaPath)); } }; }

After:

javascript
methods(self) { return { extract(req, widget, options) { return [ { text: widget.tagline, path: `${options.path}.tagline`, tags: [ 'text' ] }, ...self.apos.schema.extract(req, self.schema, widget, options) ]; } }; }

The two new obligations are tags: [ 'text' ] and the explicit path — without them the item is invisible to translation, or refused when the translation is applied (both warned about). type is stamped automatically; convertTranslationText is untouched.

User Interface Customization

Custom Labels and Disclaimers

There are two keys available for altering and localizing the text for the translation checkbox:

  • automaticTranslationCheckboxHelp used to display additional details or acknowledgement after the Translate text content checkbox label
  • automaticTranslationDisclaimer used to display a disclaimer to your users before the Translate text content checkbox

Create modules/@apostrophecms/i18n/i18n/en.json to customize user-facing text:

json
{ "automaticTranslationCheckboxHelp": "By clicking the below checkbox, you acknowledge that translations are generated by AI and may require review.", "automaticTranslationDisclaimer": "Translations are produced by AI. They can be inaccurate, omit nuance, or introduce formatting issues. Review all translated content before you publish." }

Change the "Translated with AI" indicator in modules/@apostrophecms-pro/automatic-translation/i18n/en.json:

json
{ "fieldMeta": "AI Translated ✨" }

Advanced Development

Custom field types

If you have custom field types that contain text, and you want to translate them, make them extractable. The simplest way is to extend a text-carrying core type — the extraction behavior (including the text tag) is inherited:

javascript
// in modules/my-module/index.js module.exports = { init(self) { self.apos.schema.addFieldType({ name: 'myCustomField', // Inherits the string extraction and its `text` tag extend: 'string', vueComponent: 'MyCustomField' }); } };

A type that does not extend a text type declares its own extractable tags and an extract method. The extractor receives (req, field, value, path) — path is the walk context with the field's value and schema dot paths — and returns an array of partial items; the core walk fills in any missing path, type and resolved tags:

javascript
// in modules/my-module/index.js module.exports = { init(self) { self.apos.schema.addFieldType({ name: 'myCustomField', extractable: [ 'text' ], // Let's assume the field value contains two text properties extract(req, field, value, path) { if (!value) { return []; } return [ { path: `${path.value}.text1`, text: value.text1 }, { path: `${path.value}.text2`, text: value.text2 } ]; }, convert(req, field, data, object) { // ... your logic here }, vueComponent: 'MyCustomField' }); } };

See the extract method documentation in the core @apostrophecms/schema module for the full extractor contract (item shapes, tags, containers).

When the translated text needs a custom conversion before it is written back to the document (formatting, entity decoding and the changed status), register a translation type carrying a convertText method with this module. Types without one get the default conversion (HTML entity unescaping plus a changed comparison):

javascript
// in modules/my-module/index.js module.exports = { handlers(self) { return { 'apostrophe:modulesRegistered': { addTranslationType() { self.apos.modules['@apostrophecms-pro/automatic-translation'] .addTranslationType({ name: 'myCustomField', convertText(translated, meta) { return { ...meta, translated, changed: meta.text.trim() !== translated.trim() }; } }); } } }; } };

A translation type registered with its own getText method (the legacy extraction path) is deprecated but still consulted: the method is called with its legacy signature (field, value, valuePath, schemaPath) and its items pass through verbatim — custom properties included. Its fields translate like any others; only the notranslate tag (or a legacy translate: false flag) excludes one. A once-per-type deprecation warning is logged. Migrating to a field type extract method and the extractable policy as shown above is highly recommended; convertText stays as it is.

Learn more about the translation and field metadata in the dedicated section below.

In your Vue component, you can pass the meta information so that the "Translated" indicator can be displayed:

vue
<template> <!-- Add :meta="fieldMeta" prop here --> <AposInputWrapper :modifiers="modifiers" :field="field" :error="effectiveError" :uid="uid" :display-options="displayOptions" :meta="fieldMeta" > <!-- ... your input component here --> </AposInputWrapper> </template> ​ <script> import AposInputMixin from 'Modules/@apostrophecms/schema/mixins/AposInputMixin'; export default { name: 'AposInputString', // the mixin will handle the meta information mixins: [ AposInputMixin ], // ... your component logic here } </script>

Advanced widget support

By default a widget extracts through its field schema. A widget that needs different handling overrides the core extract(req, widget, options) widget method — the same mechanism the core rich text widget uses to extract its content as a single item. See the @apostrophecms/widget-type and @apostrophecms/rich-text-widget modules in apostrophe core for the contract and a working example:

javascript
// in modules/my-widget/index.js module.exports = { extend: '@apostrophecms/widget-type', options: { label: 'My Widget' }, methods(self) { return { extract(req, widget, options) { // Return the widget's items; `options.path` carries the // walk position to anchor item paths on return [ { path: `${options.path}.myText`, text: widget.myText, tags: [ 'text' ] } ]; }, convertTranslationText(translated, meta) { // ... how a translated item is written back, see the next section } }; } };

convertTranslationText remains this module's hook for converting a translated widget item before it is applied — the rich text widget uses it to apply HTML-preserving conversion. It is only consulted for items whose type is the widget form (widget:my-widget), which a custom extract sets on its items when it wants widget-level conversion.

Legacy note: the former getTranslationText widget extraction hook is deprecated but still consulted — a widget implementing it keeps extracting exactly as before, with a once-per-type deprecation warning. See "Migrating the legacy extraction hooks" above for the (small) transform to the core extract override shown here.

When contributing content the widget's schema does not own, always give the item an explicit path and its tags. Without a path the item defaults to the whole widget object, which nothing can safely write a translation back to — extraction logs a structured widget-extract-pathless-item warning and the apply step refuses the item. Without tags: [ 'text' ] the translation query never sees it.

Translation meta and field meta explained

First, let's clarify the terms.

Translation meta, in the context of this module, is the information extracted from a document in the form of a list of objects, each containing information about:

  • the path to a text value in the document
  • the type of field that owns the text
  • any additional information that might be useful for the translation provider, the current field type or the UI

Keep in mind that one schema field can have multiple text values, and each of them will be translated separately, and exported as a separate meta object.

Field meta is field-specific information that is added to the document. This is a core feature that allows the storage of additional information about a schema field. This feature is currently used to store the "changed" status of the field after translation, the "original" text and the document value path to the text.

How it works?

  1. The document translation meta is extracted from the document through the core schema content extraction (apos.schema.extract, items tagged text minus the notranslate marker) and mapped to the per-field meta shape explained below by the extractText method.
  2. The translation provider translates the text and returns the translated text.
  3. The convertText method is called on every translated text. This allows the field type to perform any formatting on the translated text (e.g. escape HTML entities when required) but also to add additional meta information. For example, we add changed status by comparing the original and translated text.
  4. The translated text replaces the original text in the document.
  5. The translation meta per field is stored as a field meta in the document.
  6. The UI can use the field meta to display the "Translated" indicator.

Basic example

Let's look closer at the translation meta item format. A typical text field produces an item similar to this:

js
{ valuePath: [ 'myField' ], schemaPath: [ 'myField' ], text: 'Original text', type: 'string', label: 'My Field' }

valuePath is an array of path components that lead to the text value in the document, something we call pathComponents. schemaPath is an array of parent field names that lead to the field in the schema (widget positions appear as widget:name components). This is useful when you need to extract additional meta data from the schema fields for a given translation meta item. You can use the eachSchemaField(schemaPath, schema, callback) method to walk through the schema fields and call a callback on each of them (see below for more details). label is the label of the field the text was extracted from, present when the field has one — providers that translate with context use it to disambiguate short strings. Additional arbitrary properties may be present, depending on the field type.

The next step according to our logic is to call the convertText method for each of the items when the translation is done. The convertText method will receive the translated text and the meta object. Here is an example of how the corresponding convertText method can look like:

js
convertText(translated, meta) { const converted = he.decode(translated || ''); ​ return { ...meta, translated: converted, changed: meta.text.trim() !== converted.trim() }; }

The same meta object that was produced by the extraction is passed to the convertText method. We add the translated property to the meta object after converting the translated text.

We use the he package to unescape any HTML entities that might have been encoded by the translation provider. We also add the changed status to the meta object. This status is used by the UI to display the "Translated" indicator. Any additional properties that are added to the meta object will be stored later as field meta in the document.

After the conversion, our meta object will look like this:

js
{ valuePath: [ 'myField' ], text: 'Original text', type: 'string', translated: 'Translated text', changed: true }

The internal bundle engine will first use this to replace the translated text in the document. It will set doc.myField to Translated text. Then, it will store some of the properties as field meta in the document, using code similar to the following:

js
self.apos.schema.setMeta(doc, '@apostrophecms/automatic-translation', 'myField', 'data', { valuePath: 'myField', text: 'Original text', type: 'string', changed: true });

The payload (last argument) will be stored in a key, data for the path (path components) myField under namespace @apostrophecms/automatic-translation in the document.

Private meta properties

It's possible to pass useful data from the extraction to convertText while keeping those private, not saved as field meta in the document. The built-in slug handling is the working example: the extraction transforms /parent-path/my-slug to the translatable text my slug and keeps the raw value on the item as original:

js
{ valuePath: [ 'slug' ], schemaPath: [ 'slug' ], text: 'my slug', original: '/parent-path/my-slug', type: 'slug' }

The slug convertText then rebuilds a valid slug from the translated text:

js
convertText(translated, meta) { const { original, ...rest } = meta; // Logic that will transform `my translated slug` // to `/parent-path/my-translated-slug` const slug = transformTextToSlug(translated, original); ​ // Omit the `original` property from the meta object return { ...rest, translated: slug, changed: original.trim() !== slug.trim() }; }

The original property is introduced by the extraction and is passed to the convertText method after successful translation, where it is omitted from the returned meta object. It is not saved as metadata in the document — it's a private property used internally to deliver data between the extraction (before translation) and convertText (after translation).

Unique value path

The previous example works well for simple, root-level document properties. What about widgets and array items? The problem with these is that their position in the document may change. To address this, we need to use a unique value path. Luckily, this is already handled well by the Apostrophe core. Every array or area item has a unique _id property. A unique path to any such item is possible by using @{_id} as a starting path component. Here is an example of a rich text widget value:

js
{ _id: 'uniqueId', content: 'Original text', // ... other properties }

Its path components would be:

js
[ '@uniqueId', 'content' ]

The string version of the path above is @uniqueId.content. The rich text widget's translation meta item and its convertTranslationText conversion look like this:

js
// The extracted item { valuePath: [ { _id: 'uniqueId' }, 'content' ], metaPath: [ { _id: 'uniqueId' } ], schemaPath: [ 'main', 'widget:@apostrophecms/rich-text' ], text: '<p>Original text</p>', type: 'widget:@apostrophecms/rich-text' }
js
convertTranslationText(translated, meta) { // No need to transform the text, as it's already HTML return { ...meta, translated, changed: meta.text.trim() !== translated.trim() }; }

You might have noticed that we have introduced a new metaPath property. Most of the time the path components passed to the apos.schema.setMeta() are the same as our valuePath that represents the path components to the document value. However, in the case of more complex value structures, this won't be the case anymore. When available, metaPath will be used to store the field meta in the document:

js
self.apos.schema.setMeta(doc, '@apostrophecms/automatic-translation', '@uniqueId', 'data', { valuePath: '@uniqueId.content', text: 'Original text', type: 'string', changed: true });

Here we have widgetValue.content property, containing the text we want to translate. The content property is an internal implementation detail and it should not be used when adding the field meta. We can use metaPath in order to provide the value as required by the apos.schema.setMeta() method.

Meta only fields and the metaOnly property

Let's look at another scenario - array fields. The array field does not directly participate in translation, its schema fields do. At the same time, we need a way to add field meta for the array field so that the UI can see that this array field has "changed" status. The extraction handles that with a separate, structural meta object carrying the metaOnly property — a container marker emitted after the container's content:

js
{ metaOnly: true, valuePath: [ 'myArray' ], schemaPath: [ 'myArray' ], type: 'array' }

A metaOnly object has no text and is NOT sent to the translation provider. It is only used to store field meta in the document after the translation is performed. The convertText method of its type is called for it: the translated argument is the container value retrieved from the document using the valuePath (the engine uses apos.util.get(valuePath) under the hood). The array convertText adds the changed status:

js
convertText(translated, meta) { if (!Array.isArray(translated)) { return meta; } return { ...meta, changed: translated.length > 0 }; }

The engine then stores the meta object as field meta in the document:

js
self.apos.schema.setMeta(doc, '@apostrophecms/automatic-translation', 'myArray', 'data', { valuePath: 'myArray', type: 'array', changed: true });

The schemaPath property and the eachSchemaField method

The schemaPath property is an array of parent field names that lead to the current field in the schema. It is useful for external modules or project level code that extracts document text manually via the extractText(req, schema, values) method (the old req-less extractText(schema, values) signature still works but is deprecated and logs a warning). The eachSchemaField(schemaPath, schema, callback) method can be used to walk through the schema fields and call a callback on each of them, allowing you to extract additional meta data from the schema fields for a given translation meta item. The callback function is called with two arguments: field and context. The field object is the schema field definition or a widget module. The context.isWidget property is a boolean indicating if the field is a widget object.

Here is an example of how to use it:

js
// As already mentioned, the `schemaPath` property is an array of parent field names that lead to the current field in the schema. You can grab it from the translation meta object. const schemaPath = ['main', 'widget:@apostrophecms/rich-text']; const fieldLabels = []; self.apos.modules['@apostrophecms-pro/automatic-translation'] .eachSchemaField(schemaPath, schema, (field, { isWidget }) => { // Extract additional meta data from the schema fields // for a given translation meta item. In this case, we extract the field labels. // Keep in mind in a real world you might want to use `req.t(label)` to translate it. fieldLabels.push( isWidget ? field.options.label : field.label ) });

Custom Translation Providers

Creating your own translation provider is also supported.

  1. Create a folder modules/my-provider in your project. Add a file index.js with the following content:
javascript
module.exports = { init(self, options) { self.apos.modules['@apostrophecms-pro/automatic-translation'] .registerProvider(self, { name: 'my-provider', label: 'My Provider' }); }, ​ methods(self) { return { async translate(req, data, sourceLanguage, targetLanguage, options) { // Get the text to translate from the `source` language code // to the `target` language code const texts = data.fields.map(m => m.text); ​ // Your translation logic here. Array of translated text (string) is expected // with the exact same order (index) as the input text. // Your provider should support HTML text. The internal engine expects HTML support and // unescapes HTML entities for non-HTML text fields after successful translation. const translatedTextArray = self.translateTextWithMyProvider( texts, sourceLanguage, targetLanguage ); ​ // Finally, return the translated text in this format. // The `state` can be `translated` or `failed`. // If it's `failed`, fields should be empty. It's a good practice to offer // a structured logs (self.logError(...)) for the failed translations. return { state: 'translated', fields: translatedTextArray }; }, ​ async getSupportedLanguages(req, sourceLanguages, targetLanguages) { // `sourceLanguages` and `targetLanguages` are optional arrays of language codes. // If they are provided, the method should return information about whether // each language is supported as a source or a target respectively. // if a requested language is not supported, then the supported flag will be false. ​ // The expected return format is: return { source: [ { code: 'en', supported: true }, { code: 'fr', supported: true }, { code: 'zh', supported: false } ], target: [ { code: 'en', supported: true }, { code: 'fr', supported: true }, { code: 'zh', supported: true } ] }; } }; } };
  1. Add your logic to the code above and configure the module in your app.js file:
javascript
require('apostrophe')({ shortName: 'my-project', modules: { '@apostrophecms-pro/automatic-translation': { options: { provider: 'my-provider' } }, 'my-provider': {} } });

Maximize your global content strategy with these complementary ApostropheCMS Pro modules:

🔍 SEO Assistant

AI-powered meta title and description generation that works with your translated content to optimize search rankings across all languages.

📄 Import/Export Translations

Seamlessly integrate with professional translation services by exporting content for human review and importing polished translations back into your CMS.

🎨 Palette Design Tools

Customize your multilingual sites with visual design tools that maintain brand consistency across all language versions.

Create an account on Apostrophe Workspaces or contact us to learn more about Pro features and build a complete global content management solution.


🏢 Managing Multiple International Sites?

Scaling content across multiple countries and languages? Consider ApostropheCMS Assembly for enterprise multisite management:

✨ Assembly Global Features

  • 🌍 Multi-Region Deployment: Deploy sites across different geographic regions for optimal performance
  • 🏗️ Centralized Translation Management: Coordinate translation workflows across your entire site network
  • 🚀 Shared Content Libraries: Reuse translated assets and content across multiple international sites
  • ⚙️ Per-Site Language Configuration: Different language sets and translation providers per site
  • 📊 Global Analytics: Track content performance and translation effectiveness across regions
  • 🎨 Regional Customization: Adapt designs and content for local markets while maintaining brand consistency

Perfect for international organizations, global brands, or agencies managing content across multiple markets and languages.

Learn more about Assembly or contact our team to discuss your global content strategy.


Made with ❤️ by the ApostropheCMS team. Need multilingual content at scale? Contact us about Pro translation features.