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.
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),
],
})
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
},
},
})
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 .
-
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_HEREcon 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.
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 backend o usa el manejador interno directamente a través de import {createInternalHandler} from . 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 , 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 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 i. 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
},
})