集成

Sanity (TMS)

文本由 Phrase Language AI 从英语机器翻译而得。

该插件提供从 Sanity studio 内部访问 Phrase 内容的功能。

仅支持文档级翻译。不支持字段级翻译。

功能:

  • 实时预览

    翻译保持同步,以便语言专家和译员能够实时查看预览更改。

  • 智能重新翻译

    插件会对比自上次翻译以来哪些内容发生了变化,并仅将这些更改发送到 Phrase。

  • 自动引用翻译

    在发布翻译时,编辑可以选择同时翻译当前文档所引用的文档,插件将根据译文语言自动链接它们。

  • 灵活的架构

    无论结构如何,插件都能适应它,并确保最终的内容符合 Sanity 架构。

  • Phrase 工作流

    Phrase 中的翻译工作流保持不变;无需重新培训或重新配置操作。

Sanity 插件安装

安装在命令行执行。

假设网站生成器和 Sanity Studio 已经配置完成。如果不是,请使用 Sanity 提供的 Starter 模板 之一。

安装

导航到包含 Sanity Studio 实例的项目,并安装插件:

npm install sanity-plugin-phrase

# 或 pnpm, yarn, bun

环境变量

在配置插件之前,必须设置以下环境变量。在项目根目录中创建 `.env` 文件(或用于 Next.js 的 `.env.local`)。

以下示例适用于 NextJS。对于其他框架,请参考其特定文档,并注意可能需要删除公共变量的 `NEXT_PUBLIC_` 前缀。

重要

服务器端变量(SANITY_WRITE_TOKENPHRASE_USER_NAMEPHRASE_PASSWORD)绝不应暴露给客户。在 Next.js 中,只有以 NEXT_PUBLIC_ 为前缀的变量才会暴露给浏览器。

# 站点的基本 URL(用于预览链接)
NEXT_PUBLIC_BASE_URL="http://localhost:3000"

# 插件服务器后端处理程序所在的 URL
NEXT_PUBLIC_PHRASE_PLUGIN_API_ENDPOINT="http://localhost:3000/api/phrase"

# Phrase 数据中心区域('eu' 或 'us')
NEXT_PUBLIC_PHRASE_REGION="eu"

# Sanity 项目配置
NEXT_PUBLIC_SANITY_PROJECT_ID="your-project-id"
NEXT_PUBLIC_SANITY_DATASET="production"

# 具有写入权限的 Sanity API 令牌(仅限服务器端)
SANITY_WRITE_TOKEN=""

# Phrase 凭据(仅限服务器端)
# 注释:Phrase API 仅需要用户名部分,而不是完整的电子邮件地址
PHRASE_USER_NAME="phraseUsername"
PHRASE_PASSWORD="secretPassword"

插件配置

该插件通过所需的配置选项添加到 sanity.config.ts 中:

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

const PHRASE_CONFIG = definePhraseOptions({
  // 必需:用于文档国际化的 i18n 适配器
  i18nAdapter: documentInternationalizationAdapter(),

  // 必需:可翻译的文档类型
  translatableTypes: ['page', 'post', 'article'],

  // 必需:原文/源语(主要语言)
  // 这必须匹配您的 Phrase 项目模板中定义的语言
  sourceLang: 'en',

  // 必需:用户可翻译到的译文语言
  // 使用与您的 Sanity 文档相同的代码
  // 此列表必须匹配您的 Phrase 项目模板中定义的语言
  supportedTargetLangs: ['es', 'fr', 'de', 'pt'],

  // 必需:您的后端 API 端点 URL
  apiEndpoint: process.env.NEXT_PUBLIC_PHRASE_PLUGIN_API_ENDPOINT!,

  // 必需:Phrase 数据中心区域('eu' 或 'us')
  phraseRegion: process.env.NEXT_PUBLIC_PHRASE_REGION as 'eu' | 'us',

  // 必需:供编辑使用的 Phrase 项目模板
  phraseTemplates: [
    {
      templateUid: 'YOUR_TEMPLATE_UID_HERE',
      label: '默认翻译模板',
    },
  ],

  // 必需:为语言专家生成预览 URL
  getDocumentPreview: (doc, sanityClient) => {
    const publishedId = doc._id.replace('drafts.', '')
    return `${process.env.NEXT_PUBLIC_BASE_URL}/api/draft?id=${publishedId}`
  },

  // 可选设置

  // 翻译引用文档的最大深度(默认:3)
  maxReferencesDepth: 3,

  // 允许翻译草稿文档(默认:false)
  translateDrafts: false,

  // 用于特殊内容类型的自定义数据转换器
  dataTransformers: [],

  // 用于调试的日志记录配置
  logger: {
    minimumLogLevel: 'info', // 'debug' | 'info' | 'warning' | 'error' | 'fatal'
  },

  // 根据用户角色隐藏 Phrase 仪表盘
  isPhraseDashboardHidden: (context) =>
    !(context.currentUser.roles || []).some((r) => r.name === 'admin'),
})

export default defineConfig({
  // ... 您现有的配置
  插件: [
    phrasePlugin(PHRASE_CONFIG),
    // ... 其他插件
  ],
})// sanity.config.(js|ts)
import {
  phrasePlugin,
  documentInternationalizationAdapter,
} from 'sanity-plugin-phrase'

const PHRASE_CONFIG = definePhraseOptions({
  /**
   * 此插件要使用的 i18n 适配器。
   * 它将负责为每种译文语言获取和修改文档。
   *
   * 有关适配器的更多信息,请参见下文。
   */
  i18nAdapter: documentInternationalizationAdapter(),

  /**
   * 插件可以翻译的 Sanity 模式类型
   */
  translatableTypes: ['page', 'post', 'course', 'lesson', 'definition'],

  /**
   * 用户可以翻译成的所有语言的语言代码。
   * 应与存储在您的 Sanity 文档中并由您的前端使用的语言代码相同。插件将自动将其转换为 Phrase 的格式。
   */
  supportedTargetLangs: ['cz', 'es', 'pt', 'fr', 'de', 'it', 'nl', 'pl', 'ru'],

  /**
   * 将被翻译的原文/源语的语言代码。
   * 应与存储在您的 Sanity 文档中并由您的前端使用的语言代码相同。插件将自动将其转换为 Phrase 的格式。
   */
  sourceLang: 'en',

  /**
   * 由您的 Phrase 账户的设置定义
   * `eu` 或 `us`
   */
  phraseRegion: 'us|eu',

  /**
   * 指向您配置的插件后端 API 的 URL。
   *
   * **注释:** 按照下文概述的步骤设置端点
   */
  apiEndpoint: 'https://my-site.com/api/phrase',

  /**
   * 用于将译员从 Phrase 仪表盘重定向到其译文的前端预览。
   */
  getDocumentPreview: async (doc, sanityClient) => {
    const publishedId = doc._id.replace('drafts.', '')
    return `${process.env.NEXT_PUBLIC_FRONT_END_URL}/api/draft?publishedId=${publishedId}`
  },

  /**
   * 您的编辑在请求翻译时可以使用的 Phrase 项目模板。
   *
   * **注释:** 按照下述设置模板的步骤操作
   */
  phraseTemplates: [
    {
      templateUid: '1jYg0Pc1d8kAHUyM0tgdmt',
      label: '[Sanity.io] 默认模板',
    },
  ],

  /**
   * @可选项
   * 如果您想根据用户权限显示或隐藏 Phrase 仪表盘。
   *
   * 接收包含当前用户和文档的上下文,并且必须返回一个布尔值。
   */
  isPhraseDashboardHidden: (context) =>
    !(context.currentUser.roles || []).some((r) => r.name === 'admin'),
})

export default defineConfig({
  // ...
  插件: [
    // ...
    phrasePlugin(PHRASE_CONFIG),
  ],
})

Phrase 插件配置

Schema 注入

为了告诉插件哪些文档类型可以被翻译,请将文档类型数组传递给 sanity.config.ts 文件中的 injectPhraseIntoSchema 函数:

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

// 可译的 schema 类型列表。通常从索引文件导出
// 无论您的 Sanity Schema 位于何处
const TRANSLATABLE_SCHEMAS = ['page', 'post', 'course', 'lesson', 'definition']

export default defineConfig({
  schema: {
    types: injectPhraseIntoSchema(TRANSLATABLE_SCHEMAS, PHRASE_CONFIG),
    templates: (prev) =>
      prev.filter((template) => !TRANSLATABLE_SCHEMAS.includes(template.id)),
  },
  插件: [
    // ...
    phrasePlugin({
      // Your configuration options here
    }),
  ],
})

从文档列表中排除 PTD

PTD (Phrase Translation Documents) 是临时文档,不应出现在 Sanity Studio 内的常规文档列表中。NOT_PTD 常量为此提供了 GROQ 筛选:

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

export default defineConfig({
  // ... other config
  插件: [
    structureTool({
      structure: (S) =>
        S.list()
          .title('Content')
          .items([
            S.listItem()
              .title('Posts')
              .schemaType('post')
              .child(
                S.documentList()
                  .title('Posts')
                  .filter(`_type == "post" && ${NOT_PTD}`),
              ),
            // ... other items
          ]),
    }),
  ],
})

隐藏 PTD 的 Phrase 菜单

使用 document-internationalization 插件时,应针对 PTD 隐藏 Phrase 菜单。isPtdId 工具用于识别 PTD 文档:

import { isPtdId } from 'sanity-plugin-phrase/utils'
import { DocumentInternationalizationMenu } from '@sanity/document-internationalization'

// 使用与 PHRASE_CONFIG 中 translatableTypes 相同的数组
const TRANSLATABLE_TYPES = ['page', 'post', 'article']

export default defineConfig({
  document: {
    unstable_languageFilter: (prev, ctx) => {
      const { schemaType, documentId } = ctx

      // 仅为真实文档显示翻译菜单,而非 PTD
      return TRANSLATABLE_TYPES.includes(schemaType) &&
        documentId &&
        !isPtdId(documentId)
        ? [...prev, DocumentInternationalizationMenu]
        : prev
    },
  },
})

Phrase 配置

该插件在 Phrase UI 中没有配置,但必须配置 Phrase 以将 webhook 通知发送到后端 API 端点。这使得在翻译进程中能够实现实时更新。

创建 webhook

创建带有以下设置的 webhook

  • URL

    插件 API 端点的 URL,如 apiEndpoint 选项中所配置。

  • 活动:

    • 招贤纳士

      • 工作已删除

      • Job assigned

      • 工作截止日期已更改

      • 工作译文已更新

    • 项目

      • 项目已删除

      • 项目截止日期已更改

    • 其他

      • 预翻译完成

这确保了插件能够收到 Phrase 项目任何更改的通知,并能保持 Sanity 数据同步。

设置项目模板

配置 Phrase 项目模板,使其具备工作流和 Team 要求所需的属性。订购新翻译时,可以提供一个或多个模板供选择。Phrase 项目模板必须具有特定的 JSON 导入设置,插件才能正常运行。这些设置控制哪些字段被发送给译员,哪些字段作为元数据保留。

JSON 文件导入

使用正则表达式排除特定的键:

(^|.*\/)
(_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)
/.*)

此表达式特意包含了重复的键,以确保它们被 Phrase 的 RegEx 解析器略过。确保它们被正确复制。

  • 排除特定于本地化的数据,例如使用 @sanity/document-internationalization 时给定文档的语言。

  • 包含任何不需要翻译的项目特定键,例如在所有语言中使用相同路径的内容的 slug。将 YOUR_IGNORED_KEYS_HERE 替换为以管道符分隔的要略过的键列表。

  • 上下文注释:/_sanityContext

原文/源语语言

目前,此插件基于拥有单一原文/源语语言的假设运行。项目模板必须与插件 sourceLanguage 中配置的原文/源语相同。

译文语言

确保 Phrase 中选择的语言与插件配置中的语言保持同步。

Sanity API 端点

这是插件用于与 Sanity Studio 通信的端点。它用于向 Phrase 的 API 进行身份验证,并接收来自 Sanity Studio 的 Webhook 和用户请求。

在 Sanity 项目中创建一个自定义 API 端点来处理这些请求。最简单的方法之一是使用 NextJS、Remix、SvelteKit 或 Nuxt 等前端框架提供的无服务器函数。

访问并通过 import {createRequestHandler} from sanity-plugin-phrase/backend 使用请求-响应模式配置处理程序,或通过 import {createInternalHandler} from sanity-plugin-phrase/backend 直接使用内部处理程序。确保正确处理 CORS 请求,因为 Studio 和端点具有不同的来源。

目前不支持 NextJS 的 app 目录,因为它错误地将后端处理程序解析为 React 客户组件。

此示例演示了如何在配置的 API 路径 /API/Phrase 处使用 下一个.js Pages Router 创建路由处理程序:

// app/api/phrase/route.ts
// 下一个.js API 路由支持: https://nextjs.org/docs/api-routes/introduction
导入 类型 { 下一个ApiRequest, 下一个ApiResponse } from '下一个'
导入 { Phrase_CONFIG } from 'PhraseConfig'
导入 { createInternalHandler } from 'sanity-插件-Phrase/backend'

import { writeToken } from '~/lib/sanity.api'
导入 { 客户 } from '~/lib/sanity.客户'

导出 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 }),
  插件Options: Phrase_CONFIG,
})

导出 default async function handler(
  req: 下一个ApiRequest,
  res: 下一个ApiResponse,
) {
  res.setHeader('Access-Control-Allow-Origin', '*')
  res.setHeader('访问-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 路由:https://nextjs.org/docs/pages/building-your-application/routing/api-routes
导入 类型 { 下一个ApiRequest, 下一个ApiResponse } from '下一个'
导入 { Phrase_CONFIG } from 'PhraseConfig'
导入 { createInternalHandler } from 'sanity-插件-Phrase/backend'
import { writeToken } from '~/lib/sanity.api'
导入 { 客户 } from '~/lib/sanity.客户'

const PhraseHandler = createInternalHandler({
  PhraseCredentials: {
    userName: process.env.PHRASE_USER_NAME || '',
    password: process.env.Phrase_PASSWORD || '',
  },
  sanityClient: client.withConfig({ token: writeToken }),
  插件Options: Phrase_CONFIG,
})

导出 default async function handler(
  req: 下一个ApiRequest,
  res: 下一个ApiResponse,
) {
  res.setHeader('Access-Control-Allow-Origin', '*')
  res.setHeader('访问-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)
}

i18n 适配器

Sanity 没有针对国际化的规定性方法,并且有多种实现方式。此插件使用适配器模式,允许根据内容结构及其应如何翻译进行配置。

目前,唯一可用的适配器是 documentInternationalizationAdapter,这是 Sanity 官方 document-internationalization 插件(版本 ^2.0.0)所使用的适配器。如果需要特定适配器,请提交一个问题,或参考此 存储库的 package/src/adapters/document-internationalization.ts 以获取如何实现自定义适配器的示例。

自定义数据转换器

如果需要在将数据发送到 Phrase 之前对其进行转换,请使用 dataTransformers 选项。如果需要更改数据结构,或者需要排除某些字段不进行翻译,这非常有用。

每个数据转换器都需要在将数据发送到 Phrase 之前对其进行“编码”;并在接收回数据时对其进行“解码”,以便在保存到 Sanity 之前对其进行转换。多个转换器可以堆叠并按顺序运行。

该插件无法单独测试转换器,因此开发过程可能会很复杂。将真实的译文文档从 Sanity 数据集保存为 .JSON 文件,并将其用作每个编码/解码函数的测试数据。

将 JSON 编码的 VTT 文件修改为 HTML 的示例,以便 Phrase 可以更好地对字幕内容进行句段划分:

import { DataTransformer } from 'sanity-插件-Phrase'

const vttJsonTransformer: DataTransformer = {
  encode: {
    array(arr) {
      // 检查数组是否包含 VTT 字幕节点
      if (
        arr.every(
          (条目) =>
            typeof item === 'object' &&
            !!item &&
            '_类型' in 条目 &&
            typeof item._type === 'string' &&
            item._type.startsWith('vtt.'),
        )
      ) {
        return encodeSubtitles(arr as StoredSubtitleNode[])
      }
      return undefined // 返回 undefined 以跳过转换
    },
  },
  decode: {
    object(obj) {
      if (!!obj && '_type' in obj && obj._type === 'encodedSubtitles') {
        return decodeSubtitles(obj as EncodedSubtitles)
      }
      return undefined
    },
  },
}

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

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

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

      return undefined
    },
  },
}

// 跳过实现
// 请参阅 /demo-nextjs/src/utils/vttJsonTransformer.ts 获取完整的 原文/源语 代码
declare function decodeSubtitles(
  encoded: EncodedSubtitles,
): StoredSubtitleNode[]
declare function encodeSubtitles(nodes: StoredSubtitleNode[]): EncodedSubtitles

限制编辑器访问

可以通过实现 isPhraseDashboardHidden 选项来限制哪些编辑器可以访问 Phrase 仪表盘。此函数等同于传递给 Sanity 中字段 hidden 属性的函数。它接收一个包含当前用户和文档的上下文,并且必须返回一个布尔值。

限制用户访问 Phrase 仪表盘至仅限拥有 admin 角色的示例:

const PHRASE_CONFIG = definePhraseOptions({
  // ...
  isPhraseDashboardHidden: (context) => {
    const isAdmin = (context.currentUser.roles || []).some(
      (r) => r.name === 'admin',
    )
    // 如果不是管理员则隐藏
    return !isAdmin
  },
})
这篇文章有帮助吗?

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.