Smartling automation based on Uniform Workflow

Uniform enables you to set up an automated flow for content editors to manage translations.

Do You Need a Workflow to Manage Smartling Translations?

Automation via Uniform Workflow gives you much better control over when exactly the translation is sent to Smartling, locking content while translating, limiting permissions, adding review steps, etc.

However, it is not required, as you can use a simple on-demand flow, which you can find here.

If you've determined that you do, in fact, need workflow automation, this tutorial will guide you through the process of building one.

You need to go through the Smartling Integration documentation and follow both the integration installation and configuring webhook sections.

Our workflow automation code will include another webhook handler similar to the Smartling Webhook handler.

You can find the final code in the examples repository.

Please repeat the locale mapping you've configured on the Smartling Integration settings page inside your webhook handler app. The Uniform source locale code should go into UNIFORM_SOURCE_LOCALE, and UNIFORM_TO_SMARTLING_LOCALE_MAPPING should be a JSON with target Uniform => Smartling locale codes mapping.

You can read about workflow here and set up any kind of flow that suits you. In this tutorial, the workflow will focus on translations, but you can reuse concepts and code from here.

We will create a simple 3-stage workflow with the following stages:

  • Editing - Initial stage which is open for Uniform content editors and is basically the only stage where content can be manually changed.
  • Ready for translation - Second stage where content is still not published but it is locked from editing and should automatically be sent to Smartling for translation.
  • Translated - Last stage where Uniform has received translations from Smartling and it is automatically published.
workflow-setup
Workflow setup for translations automation.

You need to copy these three Public IDs into your webhook handler .env file:

  • Workflow Public ID into WORKFLOW_ID
  • Ready for translation stage Public ID into WORKFLOW_LOCKED_FOR_TRANSLATION_STAGE_ID
  • Translated stage Public ID into WORKFLOW_TRANSLATED_STAGE_ID

Now we to send Uniform content that was moved to Ready for translation stage to smartling automatically. For this purpose we will use Uniform Webhook event

create-workflow-webhook
Creation Uniform workflow webhook

After you've created webhook please copy Signing Secret and paste it into SVIX_SECRET env variable

You can read more about webhooks here

It is recommended to keep Uniform webhook handler together with Smartling webhook handler, as they share some code and some env variables.

Please install required dependencies: npm i smartling-api-sdk-nodejs @uniformdev/canvas @uniformdev/webhooks uuid svix @uniformdev/tms-sdk

  • smartling-api-sdk-nodejs - Smartling SDK to create job/files and authorize for translation
  • @uniformdev/canvas - for fetching uniform content that was moved to Ready for translation stage
  • @uniformdev/webhooks (optional) contains TS types for handling webhook payload
  • svix - for verification of webhook payload (read more here)
  • @uniformdev/tms-sdk - sdk for preparing Uniform content for translations

Following code examples are based on NextJS API handlers

import type { NextApiRequest, NextApiResponse } from 'next'; import { Webhook } from "svix"; const uniformSvixSecret = process.env.SVIX_SECRET; export default async function handler(req: NextApiRequest, res: NextApiResponse) { const headers = req.headers as Record<string, string>; const wh = new Webhook(uniformSvixSecret); try { wh.verify(JSON.stringify(req.body), headers); console.log('payload verified') } catch (err) { console.error(err); res .status(400) .send( JSON.stringify({ reason: "webhook payload was not verified", err }) ); return; } ... }
import { SmartlingApiClientBuilder, ... } from 'smartling-api-sdk-nodejs'; const smartlingUserId = process.env.SMARTLING_USER_ID; const smartlingUserSecret = process.env.SMARTLING_USER_SECRET; const uniformToSmartlingLanguageMap = process.env.UNIFORM_TO_SMARTLING_LOCALE_MAPPING; export default async function handler(req: NextApiRequest, res: NextApiResponse) { ... const uniformMappedLocales = JSON.parse(uniformToSmartlingLanguageMap) as Record<string, string>; const apiBuilder = new SmartlingApiClientBuilder() .setBaseSmartlingApiUrl('https://api.smartling.com') .authWithUserIdAndUserSecret(smartlingUserId, smartlingUserSecret); ... }
import { WorkflowTransitionPayload } from '@uniformdev/webhooks'; const smartlingUserId = process.env.SMARTLING_USER_ID; const smartlingUserSecret = process.env.SMARTLING_USER_SECRET; export default async function handler(req: NextApiRequest, res: NextApiResponse) { ... // You can find full payload structure under Uniform Project -> Settings -> Webhooks => Event Catalog const payloadObject: WorkflowTransitionPayload = req.body; if (payloadObject.newStage.stageId === uniformWorkflowStageIdReadyForTranslations) { const { entity, translationEntityType } = await resolveUniformEntityFromWebhook(payloadObject); if (!entity || !translationEntityType) { console.log(`skip: can not find Uniform entity: ${translationEntityType} (${payloadObject.entity.id})`); res.status(400).json('error: can not find Uniform entity'); return; } ... } ... } const resolveUniformEntityFromWebhook = async ( payload: WorkflowTransitionPayload ): Promise<{ translationEntityType: TranslationPayload['metadata']['entityType']; entity?: RootComponentInstance | EntryData; }> => { if (payload.entity.type === 'component') { const canvasClient = new CanvasClient({ apiKey: uniformApiKey, projectId: payload.project.id, apiHost: uniformApiHost, bypassCache: true, }); const compositionOrPattern = await getCompositionForTranslation({ canvasClient, compositionId: payload.entity.id, releaseId: payload.entity.releaseId, state: CANVAS_DRAFT_STATE, }); if (compositionOrPattern) { return { entity: compositionOrPattern.composition, translationEntityType: compositionOrPattern.pattern ? 'componentPattern' : 'composition', }; } return { translationEntityType: 'composition' }; } else { const contentClient = new ContentClient({ apiKey: uniformApiKey, projectId: payload.project.id, apiHost: uniformApiHost, bypassCache: true, }); // NOTE: entityType === "entry" for both entry and entry pattern const entry = await getEntryForTranslation({ contentClient, entryId: payload.entity.id, releaseId: payload.entity.releaseId, state: CANVAS_DRAFT_STATE, pattern: false, }); if (entry) { return { entity: entry.entry, translationEntityType: 'entry', }; } const entryPattern = await getEntryForTranslation({ contentClient, entryId: payload.entity.id, releaseId: payload.entity.releaseId, state: CANVAS_DRAFT_STATE, pattern: true, }); if (entryPattern) { return { entity: entryPattern.entry, translationEntityType: 'entryPattern', }; } return { translationEntityType: 'entry' }; } };
import { WorkflowTransitionPayload } from '@uniformdev/webhooks'; const uniformSourceLocale = process.env.UNIFORM_SOURCE_LOCALE export default async function handler(req: NextApiRequest, res: NextApiResponse) { ... const translationPayloads: { payload: TranslationPayload; fileUri: string; targetLocale: string }[] = []; for (const uniformLanguage of Object.keys(uniformMappedLocales)) { if (uniformLanguage === uniformSourceLocale) { continue; } const { payload: translationPayload, errorKind, errorText, } = collectTranslationPayload({ uniformProjectId: payloadObject.project.id, uniformSourceLocale: uniformSourceLocale, uniformTargetLocale: uniformLanguage, uniformReleaseId: payloadObject.entity.releaseId, targetLang: uniformMappedLocales[uniformLanguage], entity: entity, entityType: translationEntityType, }); if (translationPayload) { const hasContentToTranslate = Object.keys(translationPayload.components).length > 0; if (hasContentToTranslate) { console.log(`translation for ${uniformLanguage} is ready`); translationPayloads.push({ payload: translationPayload, fileUri: `${getJobNamePrefix({ entityId: payloadObject.entity.id, entityType: payloadObject.entity.type, slug: payloadObject.entity.name, projectId: payloadObject.project.id, })}__${uniformLanguage}__${new Date().toISOString()}.json`, targetLocale: uniformMappedLocales[uniformLanguage], }); } else { console.warn(`nothing to translate for ${uniformMappedLocales[uniformLanguage]} locale`); } } else { console.error(`error: ${errorKind} - ${errorText}`); } } ... }

We are using Smartling Batch V2 flow

import { SmartlingJobsApi, SmartlingJobBatchesApi, CreateJobParameters, CreateBatchParameters, UploadBatchFileParameters, } from 'smartling-api-sdk-nodejs'; // This be Smarting webhook url, that you've configured in Smartling Integration Settings before const uniformSmartlingWebhookCallbackUrl = process.env.SMARTLING_WEBHOOK_URL; export default async function handler(req: NextApiRequest, res: NextApiResponse) { ... const jobClient = apiBuilder.build(SmartlingJobsApi); const params = new CreateJobParameters({ jobName: `${getJobNamePrefix({ entityId: payloadObject.entity.id, entityType: payloadObject.entity.type, slug: payloadObject.entity.name, projectId: payloadObject.project.id, })}__${new Date().toISOString()}`, targetLocaleIds: Object.values(uniformMappedLocales), callbackUrl: uniformSmartlingWebhookCallbackUrl, callbackMethod: 'GET', }); const createdJob = await jobClient.createJob(smartlingProjectId, params); console.log(`Translation job was created: ${createdJob.translationJobUid}`); if (!createdJob) { throw 'Failed to create job'; } const jobBatchesClient = apiBuilder.build(SmartlingJobBatchesApi); const batchParams = new CreateBatchParameters({ translationJobUid: createdJob.translationJobUid, authorize: 'true', }); for (const { fileUri } of translationPayloads) { batchParams.addFileUri(fileUri); } const batch = await jobBatchesClient.createBatch(smartlingProjectId, batchParams); console.log(`Batch was created: ${batch.batchUid}`); if (!batch) { throw 'Failed to create batch'; } const filesUploadClient = apiBuilder.build(SmartlingJobBatchesApi); for (const { payload, fileUri, targetLocale } of translationPayloads) { const filePath = path.join(os.tmpdir(), `translation-${v4()}.json`); fs.writeFileSync(filePath, JSON.stringify({ ...smartlingJsonPayloadSettings, ...payload })); const fileUploadParameters = new UploadBatchFileParameters({ fileUri, fileType: 'json', file: fs.createReadStream(filePath), authorize: 'true', localeIdsToAuthorize: [targetLocale], }); const success = await filesUploadClient.uploadBatchFile( smartlingProjectId, batch.batchUid, fileUploadParameters ); if (success) { console.log(`File ${fileUri} was uploaded`); } } res.status(200).json('ok'); } // If you want to see status of jobs in Smartling Integration UI, you have to keep this job name prefix format export const getJobNamePrefix = ({ entityId, entityType, slug, projectId, }: { entityId: string; entityType: string; slug: string; projectId: string; }) => { return `${slug}-${entityType}-${entityId}-${projectId}`; }; const smartlingJsonPayloadSettings = { smartling: { translate_paths: [ { path: '*/locales/target', key: '{*}/locales/target', }, { path: '*/locales/target/xml', key: '{*}/locales/target/xml', }, ], placeholder_format_custom: ['\\$\\{[^\\}]+\\}'], variants_enabled: 'true', }, };

Make sure your Smartling webhook is workflow aware

You can find it inside ensureWorkflowStage function here

It is resposible for automated transition from Ready for translation to Translated