Integraciones

Sanity (TMS)

El contenido se traduce automáticamente del inglés por Phrase Language AI.

El plugin proporciona acceso al contenido traducido de Phrase desde dentro del estudio de Sanity.

Solo se admiten traducciones a nivel de documento. No se admiten traducciones a nivel de campo.

Características:

  • Vistas previas en tiempo real

    Las traducciones se mantienen sincronizadas para permitir que los lingüistas y traductores vean los cambios en las vistas previas en tiempo real.

  • Re-traducciones inteligentes

    El plugin compara qué contenido ha cambiado desde la última traducción y solo envía esos cambios a Phrase.

  • Traducción automática de referencias

    Al emitir traducciones, los editores pueden elegir traducir también los documentos referenciados por el actual y el plugin los vinculará automáticamente por idioma objetivo.

  • Esquemas flexibles

    No importa la estructura, el plugin se adapta a ella y asegura que el contenido traducido final esté de acuerdo con los esquemas de Sanity.

  • Flujos de trabajo de Phrase

    Los flujos de trabajo de traducción en Phrase permanecen iguales; no se requiere reentrenar o reconfigurar operaciones.

Instalación del Plugin de Sanity

La instalación se realiza en la línea de comandos.

Se asume que un generador de sitios web y Sanity Studio ya están configurados. Si no, utiliza una de las plantillas de inicio proporcionadas por Sanity.

Instalación

Navega al proyecto que contiene la instancia de Sanity Studio e instala el plugin:

npm install sanity-plugin-phrase

# o pnpm, yarn, bun

Variables de entorno

Antes de configurar el plugin, deben establecerse las siguientes variables de entorno. Crea un archivo `.env` (o `.env.local` para Next.js) en la raíz del proyecto.

Los ejemplos a continuación se dan para NextJS. Para otros frameworks, consulta su documentación específica y ten en cuenta que el prefijo `NEXT_PUBLIC_` puede necesitar ser eliminado para variables públicas.

Importante

Las variables del lado del servidor (SANITY_WRITE_TOKEN, PHRASE_USER_NAME, PHRASE_PASSWORD) nunca deben ser expuestas al cliente. En Next.js, solo las variables con el prefijo NEXT_PUBLIC_ son expuestas al navegador.

# URL base de tu sitio (utilizada para enlaces de vista previa)
NEXT_PUBLIC_BASE_URL="http://localhost:3000"

# URL donde se ubicará el controlador del backend del plugin
NEXT_PUBLIC_PHRASE_PLUGIN_API_ENDPOINT="http://localhost:3000/api/phrase"

# Región del centro de datos de Phrase ('eu' o 'us')
NEXT_PUBLIC_PHRASE_REGION="eu"

# Configuración del proyecto de Sanity
NEXT_PUBLIC_SANITY_PROJECT_ID="your-project-id"
NEXT_PUBLIC_SANITY_DATASET="production"

# Token de API de Sanity con permisos de escritura (solo del lado del servidor)
SANITY_WRITE_TOKEN=""

# Credenciales de Phrase (solo del lado del servidor)
# Nota: La API de Phrase espera solo la parte del nombre de usuario, NO la dirección de correo electrónico completa
PHRASE_USER_NAME="phraseUsername"
PHRASE_PASSWORD="secretPassword"

Configuración del plugin

El plugin se añade a sanity.config.ts con las opciones de configuración requeridas:

// sanity.config.ts
importar { defineConfig } from 'sanity'
importar {
  phrasePlugin,
  definePhraseOptions,
  documentInternationalizationAdapter,
} from 'sanity-plugin-phrase'

const PHRASE_CONFIG = definePhraseOptions({
  // Requerido: adaptador i18n para la internacionalización de documentos
  i18nAdapter: documentInternationalizationAdapter(),

  // Requerido: Tipos de documentos que pueden ser traducidos
  translatableTypes: ['page', 'post', 'article'],

  // Requerido: Idioma de origen (idioma principal)
  // Esto debe coincidir con el idioma definido en la plantilla de tu proyecto Phrase
  // Requerido: Idiomas de destino a los que los usuarios pueden traducir

  // Utiliza los mismos códigos que tus documentos de Sanity
  // Esta lista debe coincidir con los idiomas definidos en la plantilla del proyecto Phrase
  idiomasDeDestinoSoportados: ['es', 'fr', 'de', 'pt'],
  // Requerido: La URL de tu endpoint API de backend

  apiEndpoint: process.env.NEXT_PUBLIC_PHRASE_PLUGIN_API_ENDPOINT!,
  // Requerido: Región del centro de datos de Phrase ('eu' o 'us')

  regionPhrase: process.env.NEXT_PUBLIC_PHRASE_REGION como 'eu' | 'us',
  // Requerido: Plantillas de proyecto Phrase disponibles para editores

  plantillasPhrase: [
  uidPlantilla: 'YOUR_TEMPLATE_UID_HERE',
    {
      etiqueta: 'Plantilla de Traducción Predeterminada',
      // Requerido: Generar URLs de vista previa para lingüistas
    },
  ],

  obtenerVistaPreviaDocumento: (doc, clienteSanity) => {
  const idPublicado = doc._id.replace('drafts.', '')
    return `${process.env.NEXT_PUBLIC_BASE_URL}/api/draft?id=${idPublicado}`
    return `${process.env.NEXT_PUBLIC_BASE_URL}/api/draft?id=${publishedId}`
  },

  // Profundidad máxima para traducir documentos referenciados (predeterminado: 3)

  profundidadMaximaReferencias: 3,
  

  // Permitir la traducción de documentos borrador (predeterminado: falso)
  traducirBorradores: falso,

  // Transformadores de datos personalizados para tipos de contenido especiales
  transformadoresDeDatos: [],

  // Configuración de registro para depuración
  registrador: {
    nivelMinimoDeRegistro: 'info', // 'debug' | 'info' | 'warning' | 'error' | 'fatal'
  },

  // Ocultar el tablero de Phrase según los roles de usuario
  estaOcultoElTableroDePhrase: (contexto) =>
    !(context.currentUser.roles || []).some((r) => r.name === 'admin'),
})

exportar por defecto definirConfiguracion({
  // ... tu configuración existente
  plugins: [
    phrasePlugin(PHRASE_CONFIG),
    // ... otros plugins
  ],
})// sanity.config.(js|ts)
importar {
  phrasePlugin,
  documentInternationalizationAdapter,
} from 'sanity-plugin-phrase'

const PHRASE_CONFIG = definePhraseOptions({
  /**
   * El adaptador i18n que se utilizará para este plugin.
   * Será responsable de obtener y modificar documentos para cada idioma objetivo.
   *
   * Ver más abajo para más información sobre adaptadores.
   */
  i18nAdapter: documentInternationalizationAdapter(),

  /**
   * Tipos de esquema de Sanity que el plugin puede traducir
   */
  tiposTranslables: ['página', 'publicación', 'curso', 'lección', 'definición']

  /**
   * Código de idioma de todos los idiomas a los que los usuarios pueden traducir.
   * Debe ser el mismo que el almacenado en sus documentos de Sanity y utilizado por su front-end. El plugin lo traducirá automáticamente al formato de Phrase.
   */
  idiomasObjetivoSoportados: ['cz', 'es', 'pt', 'fr', 'de', 'it', 'nl', 'pl', 'ru'],

  /**
   * Código de idioma de la lengua fuente que será traducida.
   * Debe ser el mismo que el almacenado en sus documentos de Sanity y utilizado por su front-end. El plugin lo traducirá automáticamente al formato de Phrase.
   */
  // Requerido: Idiomas de destino a los que los usuarios pueden traducir

  /**
   * Según lo definido por la configuración de la cuenta de Phrase.
   * Ya sea `eu` o `us`
   */
  regiónFrase: 'us|eu',

  /**
   * La URL de su API de backend del plugin configurado.
   *
   * **Nota:** siga los pasos para configurar el endpoint, descritos a continuación
   */
  apiEndpoint: 'https://my-site.com/api/phrase',

  /**
   * Utilizado para redirigir a los lingüistas desde el tablero de Phrase a la vista previa del front-end de sus traducciones.
   */
  obtenerVistaPreviaDocumento: async (doc, clienteSanity) => {
    return `${process.env.NEXT_PUBLIC_BASE_URL}/api/draft?id=${idPublicado}`
    return `${process.env.NEXT_PUBLIC_FRONT_END_URL}/api/draft?publishedId=${publishedId}`
  },

  /**
   * Plantillas de proyecto de Phrase que sus editores pueden usar al solicitar traducciones.
   *
   * **Nota:** siga los pasos para establecer plantillas, descritos a continuación
   */
  uidPlantilla: 'YOUR_TEMPLATE_UID_HERE',
    {
      templateUid: '1jYg0Pc1d8kAHUyM0tgdmt',
      etiqueta: '[Sanity.io] Plantilla predeterminada',
    },
  ],

  /**
   * @opcional
   * En caso de que desees mostrar u ocultar el tablero de frases según los privilegios del usuario.
   *
   * Recibe un contexto con el usuario y el documento actuales y debe devolver un booleano.
   */
  estaOcultoElTableroDePhrase: (contexto) =>
    !(context.currentUser.roles || []).some((r) => r.name === 'admin'),
})

exportar por defecto definirConfiguracion({
  // ...
  plugins: [
    // ...
    phrasePlugin(PHRASE_CONFIG),
  ],
})

Configuración del Plugin de Sanity

Inyección de esquema

Para indicar al plugin qué tipos de documentos pueden ser traducidos, pasa un array de tipos de documentos a la función injectPhraseIntoSchema en el archivo sanity.config.ts:

// sanity.config.ts
import { injectPhraseIntoSchema } from 'sanity-plugin-phrase'

// Lista de tipos de esquema traducibles. Generalmente exportado desde un archivo índice
// donde sea que tengas tu esquema de Sanity
const SCHEMAS_TRADUCIBLES = ['pagina', 'publicación', 'curso', 'lección', 'definición']

exportar por defecto definirConfiguracion({
  schema: {
    tipos: injectPhraseIntoSchema(SCHEMAS_TRADUCIBLES, PHRASE_CONFIG),
    plantillas: (prev) =>
      prev.filter((plantilla) => !SCHEMAS_TRADUCIBLES.includes(plantilla.id)),
  },
  plugins: [
    // ...
    phrasePlugin({
      // Tus opciones de configuración aquí
    }),
  ],
})

Excluyendo PTDs de listas de documentos

Los PTDs (Documentos de Traducción de Frases) son documentos temporales que no deberían aparecer en listas de documentos normales dentro de Sanity Studio. La constante NOT_PTD proporciona un filtro GROQ para este propósito:

// sanity.config.ts
import { NOT_PTD } from 'sanity-plugin-phrase/utils'

exportar por defecto definirConfiguracion({
  // ... otra configuración
  plugins: [
    structureTool({
      estructura: (S) =>
        S.lista()
          .title('Content')
          .items([
            S.listItem()
              .title('Posts')
              .schemaType('post')
              .child(
                S.documentList()
                  .title('Posts')
                  .filter(`_type == "post" && ${NOT_PTD}`),
              ),
            // ... otros elementos
          ]),
    }),
  ],
})

Ocultando el menú de traducción de PTDs

Al usar el plugin de internacionalización de documentos, el menú de traducción debe estar oculto para los PTDs. La isPtdId utilidad identifica documentos PTD:

importar { isPtdId } de 'sanity-plugin-phrase/utils'
importar { MenuDeInternacionalizaciónDeDocumentos } de '@sanity/document-internationalization'

// Usa el mismo array que translatableTypes de tu PHRASE_CONFIG
const TIPOS_TRADUCIBLES = ['página', 'publicación', 'artículo']

exportar por defecto definirConfiguracion({
  documento: {
    unstable_languageFilter: (prev, ctx) => {
      const { tipoDeEsquema, idDocumento } = ctx

      // Solo mostrar el menú de traducción para documentos reales, no PTDs
      return TRANSLATABLE_TYPES.includes(schemaType) &&
        documentId &&
        !isPtdId(documentId)
        ? [...prev, DocumentInternationalizationMenu]
        : prev
    },
  },
})

Configuración de Phrase

El plugin no tiene configuración en la interfaz de Phrase, pero Phrase debe estar configurado para enviar notificaciones de webhook al punto final de la API del backend. Esto permite actualizaciones en tiempo real a medida que avanzan las traducciones.

Crear un webhook

Crear un webhook con estas configuraciones:

  • URL

    La URL al punto final de la API del plugin, según lo configurado en la opción apiEndpoint.

  • Eventos:

    • Trabajos

      • Trabajo eliminado

      • Trabajo asignado

      • La fecha de entrega del trabajo ha cambiado

      • Meta del trabajo actualizada

    • Proyectos

      • Proyecto eliminado

      • La fecha de entrega del Proyecto ha cambiado

    • Otro

      • Pre-traducción terminada

Esto asegura que el plugin sea notificado de cualquier cambio en los proyectos de Phrase y pueda mantener los datos de Sanity sincronizados.

Configurar plantilla(s) de proyectos

Configurar plantilla de proyecto(s) de Phrase con las propiedades requeridas para los flujos de trabajo y los requisitos del equipo. Se puede ofrecer una o más plantillas para elegir al solicitar una nueva traducción. Las plantillas de proyecto de Phrase deben tener configuraciones específicas de importación JSON para que el plugin funcione correctamente. Estas configuraciones controlan qué campos se envían a los traductores y cuáles se preservan como metadatos.

Importación de archivo JSON

Usar regex para excluir claves específicas:

(^|.*\/)
(_createdAt|_id|_rev|_type|_updatedAt|_ref|_key|_sanityRev|_sanityContext|_strengthenOnPublish|phraseMetadata|_spanMeta|_blockMeta|_diff|marks|YOUR_IGNORED_KEYS_HERE|
(_createdAt|_id|_rev|_type|_updatedAt|_ref|_key|_sanityRev|_sanityContext|_strengthenOnPublish|phraseMetadata|_spanMeta|_blockMeta|_diff|marks|YOUR_IGNORED_KEYS_HERE)
/.*)

Esta expresión incluye claves duplicadas a propósito para asegurar que sean ignoradas por el analizador RegEx de Phrase. Asegúrate de que estén correctamente duplicadas.

  • Excluir datos específicos de localización, como el idioma de un documento dado si se utiliza @sanity/document-internationalization.

  • Incluir cualquier clave específica del proyecto que no requiera traducción, como un slug para contenido que utilice la misma ruta en todos los idiomas. Reemplazar YOUR_IGNORED_KEYS_HERE con una lista de claves separadas por tuberías para ignorar.

  • Context note: /_sanityContext

Idioma fuente

Actualmente, este plugin opera bajo la suposición de tener un único idioma fuente. Las plantilla(s) del proyecto deben tener la misma fuente que la configurada en el sourceLanguage del plugin.

Idiomas de destino

Asegúrate de que los idiomas elegidos en Phrase estén sincronizados con lo que está en la configuración del plugin.

apiEndpoint de Sanity

Este es el endpoint que el plugin utiliza para comunicarse con el Sanity Studio. Se utiliza para autenticar en la API de Phrase, recibir webhooks y solicitudes de usuario desde el Sanity Studio.

Crea un endpoint API personalizado en el proyecto de Sanity para manejar estas solicitudes. Una de las formas más fáciles de hacer esto es usar funciones sin servidor a través de frameworks de front-end como NextJS, Remix, SvelteKit o Nuxt.

Accede a configurar el manejador con un patrón de Solicitud-Respuesta a través de import {createRequestHandler} from sanity-plugin-phrase/backend o usa el manejador interno directamente a través de import {createInternalHandler} from sanity-plugin-phrase/backend. Asegúrate de que las solicitudes CORS se manejen correctamente si el estudio y el endpoint tienen orígenes diferentes.

El directorio de la aplicación de NextJS no es actualmente compatible ya que analiza incorrectamente el manejador de backend como un componente cliente de React.

Este ejemplo demuestra cómo crear un Manejador de Rutas en la ruta configurada apiEndpoint en /api/phrase usando el Router de Páginas de Next.js:

// app/api/phrase/route.ts
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from 'next'
import { PHRASE_CONFIG } from 'phraseConfig'
import { createInternalHandler } from 'sanity-plugin-phrase/backend'

import { writeToken } from '~/lib/sanity.api'
import { client } from '~/lib/sanity.client'

export const maxDuration = 60
export const dynamic = 'force-dynamic'

const phraseHandler = createInternalHandler({
  phraseCredentials: {
    userName: process.env.PHRASE_USER_NAME || '',
    password: process.env.PHRASE_PASSWORD || '',
  },
  sanityClient: client.withConfig({ token: writeToken }),
  pluginOptions: PHRASE_CONFIG,
})

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse,
) {
  res.setHeader('Access-Control-Allow-Origin', '*')
  res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS')
  res.setHeader('Access-Control-Allow-Headers', '*')

  if (req.method?.toUpperCase() === 'OPTIONS') {
    res.status(200).json({})
    return
  }

  if (
    !req.method ||
    (req.method.toUpperCase() !== 'POST' && req.method.toUpperCase() !== 'GET')
  ) {
    res.status(405).json({ error: 'Method not allowed' })
    return
  }

  const phraseRes = await phraseHandler(
    req.method.toUpperCase() === 'POST' ? req.body : req.query,
  )
  const resBody = await phraseRes.json().catch(() => {})

  Array.from(phraseRes.headers.entries()).forEach((value: [string, any]) => {
    res.setHeader(value[0], value[1])
  })
  res.status(phraseRes.status).json(resBody)
}// src/pages/api/phrase.ts
// Next.js API route: https://nextjs.org/docs/pages/building-your-application/routing/api-routes
import type { NextApiRequest, NextApiResponse } from 'next'
import { PHRASE_CONFIG } from 'phraseConfig'
import { createInternalHandler } from 'sanity-plugin-phrase/backend'
import { writeToken } from '~/lib/sanity.api'
import { client } from '~/lib/sanity.client'

const phraseHandler = createInternalHandler({
  phraseCredentials: {
    userName: process.env.PHRASE_USER_NAME || '',
    password: process.env.PHRASE_PASSWORD || '',
  },
  sanityClient: client.withConfig({ token: writeToken }),
  pluginOptions: PHRASE_CONFIG,
})

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse,
) {
  res.setHeader('Access-Control-Allow-Origin', '*')
  res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS')
  res.setHeader('Access-Control-Allow-Headers', '*')

  if (req.method?.toUpperCase() === 'OPTIONS') {
    res.status(200).json({})
    return
  }

  if (
    !req.method ||
    (req.method.toUpperCase() !== 'POST' && req.method.toUpperCase() !== 'GET')
  ) {
    res.status(405).json({ error: 'Method not allowed' })
    return
  }

  const phraseRes = await phraseHandler(
    req.method.toUpperCase() === 'POST' ? req.body : req.query,
  )
  const resBody = await phraseRes.json().catch(() => {})

  Array.from(phraseRes.headers.entries()).forEach((value) => {
    res.setHeader(value[0], value[1])
  })
  res.status(phraseRes.status).json(resBody)
}

adaptadores de i18n

Sanity no tiene un enfoque prescriptivo para la internacionalización, y hay muchas maneras de implementarlo. Este plugin utiliza un patrón de adaptador para permitir la configuración basada en cómo está estructurado el contenido y cómo debe ser traducido.

Actualmente, el único adaptador disponible es documentInternationalizationAdapter, que es el que utiliza el plugin oficial de document-internationalization (version ^2.0.0). Presenta un problema si se requiere un adaptador específico o consulta este repositorio's package/src/adapters/document-internationalization.ts para un ejemplo sobre cómo implementar uno personalizado.

Transformadores de datos personalizados

Si se requiere transformar datos antes de enviarlos a Phrase, utiliza la dataTransformers opción. Esto es útil si se cambia la estructura de los datos, o si se excluyen ciertos campos de ser traducidos.

Cada transformador de datos necesita codificar los datos antes de enviarlos a Phrase; y decodificarlos al recibirlos de vuelta para transformarlos antes de guardarlos en Sanity. Se pueden apilar múltiples transformadores y ejecutarlos secuencialmente.

El plugin no ofrece forma de probar transformadores de manera aislada, por lo que el desarrollo puede ser complejo. Guarda documentos de destino reales del conjunto de datos de Sanity en .JSON y úsalos como datos de prueba para cada función de codificación/decodificación.

Ejemplo de modificación de archivos VTT codificados en JSON a HTML para que Phrase pueda segmentar mejor el contenido de los subtítulos:

import { DataTransformer } from 'sanity-plugin-phrase'

const vttJsonTransformer: DataTransformer = {
  codificar: {
    array(arr) {
      // Verificar si el array contiene nodos de subtítulos VTT
      if (
        arr.every(
          (elemento) =>
            typeof elemento === 'object' &&
            !!elemento &&
            '_type' in elemento &&
            typeof elemento._type === 'string' &&
            item._type.startsWith('vtt.'),
        )
      ) {
        return encodeSubtitles(arr as StoredSubtitleNode[])
      }
      return undefined // Retornar undefined para omitir la transformación
    },
  },
  decodificar: {
    object(obj) {
      if (!!obj && '_type' in obj && obj._type === 'encodedSubtitles') {
        return decodeSubtitles(obj as EncodedSubtitles)
      }
      return undefined
    },
  },
}

exportar const PHRASE_CONFIG = definePhraseOptions({
  // ...
  dataTransformers: [vttJsonTransformer],
})export const PHRASE_CONFIG = definePhraseOptions({
  // ...
  dataTransformers: [vttJsonTransformer],
})

const vttJsonTransformer: DataTransformer = {
  codificar: {
    array(arr) {
      if (
        arr.every(
          (elemento) =>
            typeof elemento === 'object' &&
            !!elemento &&
            '_type' in elemento &&
            typeof elemento._type === 'string' &&
            item._type.startsWith('vtt.'),
        )
      ) {
        return encodeSubtitles(arr as StoredSubtitleNode[])
      }

      return undefined
    },
  },
  decodificar: {
    object(obj) {
      if (!!obj && '_type' in obj && obj._type === 'encodedSubtitles') {
        return decodeSubtitles(obj as EncodedSubtitles)
      }

      return undefined
    },
  },
}

// Omitiendo implementación
// Consulte /demo-nextjs/src/utils/vttJsonTransformer.ts para el código fuente completo
declare function decodeSubtitles(
  encoded: EncodedSubtitles,
): StoredSubtitleNode[]
declare function encodeSubtitles(nodes: StoredSubtitleNode[]): EncodedSubtitles

Limitando el acceso del editor

Los editores que pueden acceder al tablero de Phrase pueden ser limitados implementando la opción isPhraseDashboardHidden. Esta función es equivalente a la que se pasa a la propiedad hidden de un campo en Sanity. Recibe un contexto con el usuario y el documento actuales y debe devolver un booleano.

Ejemplo de limitación de acceso al tablero de Phrase para usuarios con el rol de admin:

const PHRASE_CONFIG = definePhraseOptions({
  // ...
  isPhraseDashboardHidden: (context) => {
    const isAdmin = (context.currentUser.roles || []).some(
      (r) => r.name === 'admin',
    )
    // Ocultar si no es un admin
    return !isAdmin
  },
})
¿Fue útil este artículo?

Sorry about that! In what way was it not helpful?

The article didn’t address my problem.
I couldn’t understand the article.
The feature doesn’t do what I need.
Other reason.

Note that feedback is provided anonymously so we aren't able to reply to questions.
If you'd like to ask a question, submit a request to our Support team.
Thank you for your feedback.