集成

Sanity (TMS)

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

该插件提供对在Sanity工作室内翻译的Phrase内容的访问。

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

特点:

  • 实时预览

    翻译保持同步,以便语言学家和翻译人员可以实时查看预览更改。

  • 智能重新翻译

    该插件比较自上次翻译以来内容的变化,仅将这些变化发送到Phrase。

  • 自动引用翻译

    在发出翻译时,编辑可以选择翻译当前文档引用的文档,插件将自动按目标语言链接它们。

  • 灵活的架构

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

  • Phrase工作流程

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

Sanity插件安装

安装在命令行中进行。

假设网站生成器和Sanity Studio已经配置好。如果没有,请使用Sanity提供的starter templates之一。

安装

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

npm install sanity-plugin-phrase

# 或 pnpm, yarn, bun

环境变量

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

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

重要

服务器端变量 (SANITY_WRITE_TOKEN, PHRASE_USER_NAME, PHRASE_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
导入 { defineConfig } 来自 'sanity'
导入 {
  phrasePlugin,
  definePhraseOptions,
  documentInternationalizationAdapter,
} 来自 '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 作为 'eu' | 'us',

  // 必需:可供编辑者使用的 Phrase 项目模板
  短语模板: [
    {
      模板Uid: 'YOUR_TEMPLATE_UID_HERE',
      标签: '默认翻译模板',
    },
  ],

  // 必需: 为语言学家生成预览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)
  翻译草稿: false,

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

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

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

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

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

  /**
   * 插件可以翻译的 Sanity 模式类型
   */
  可翻译类型: ['页面', '帖子', '课程', '课时', '定义'],

  /**
   * 用户可以翻译的所有语言的语言代码。
   * 应与存储在您的 Sanity 文档中并由您的前端使用的语言代码相同。插件将自动将其翻译为 Phrase 的格式。
   */
  支持的目标语言: ['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 项目模板,供您的编辑在请求翻译时使用。
   *
   * **注释:** 请按照下面概述的步骤设置模板
   */
  短语模板: [
    {
      templateUid: '1jYg0Pc1d8kAHUyM0tgdmt',
      label: '[Sanity.io] 默认模板',
    },
  ],

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

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

Sanity 插件配置

模式注入

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

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

// 可翻译模式类型的列表。通常从索引文件导出
// 无论您将 Sanity 模式放在哪里
const TRANSLATABLE_SCHEMAS = ['page', 'post', 'course', 'lesson', 'definition']

export default defineConfig({
  schema: {
    types: injectPhraseIntoSchema(可译的_SCHEMAS, Phrase_CONFIG),
    templates: (prev) =>
      prev.filter((template) => !TRANSLATABLE_SCHEMAS.includes(template.id)),
  },
  插件: [
    // ...
    phrasePlugin({
      // 在这里配置您的选项
    }),
  ],
})

从文档列表中排除PTD

PTD(短语翻译文档)是临时文档,不应出现在Sanity Studio中的正常文档列表中。NOT_PTD常量提供了一个GROQ筛选器,用于此目的:

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

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

从PTD中隐藏翻译菜单

使用文档国际化插件时,翻译菜单应从PTD中隐藏。isPtdId工具识别PTD文档:

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

// 使用与您的PHRASE_CONFIG中的translatableTypes相同的数组
const 可译的类型 = ['页面', '帖子', '文章']

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

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

短语配置

该插件在短语UI中没有配置,但必须配置短语以向后端API端点发送webhook通知。这使得翻译进程中的实时更新成为可能。

创建一个webhook

使用以下设置创建一个 webhook:

  • URL

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

  • 活动:

    • 工作

      • 工作已删除

      • 工作分配

      • 工作截止日期已更改

      • 工作译文已更新

    • 项目

      • 项目已删除

      • 项目截止日期已更改

    • 其他

      • 预翻译完成

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

设置项目模板

使用所需的属性配置Phrase 项目模板以满足工作流程和团队需求。在订购新翻译时,可以提供一个或多个模板供选择。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的正则表达式解析器忽略。确保它们被正确重复。

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

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

  • 上下文注释:/_sanityContext

源语言

目前,此插件假设只有一种源语言。项目模板必须与插件中配置的sourceLanguage相同。

目标语言

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

Sanity apiEndpoint

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

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

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

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

此示例演示如何在配置的apiEndpoint路径/api/phrase上创建路由处理程序,使用Next.js页面路由:

// app/api/phrase/route.ts
// Next.js API路由支持: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'
导入 { 客户 } 从 '~/lib/sanity.client'

导出 常量 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,
})

导出 默认 异步 函数 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({})
    返回
  }

  如果 (
    !req.method ||
    (req.method.toUpperCase() !== 'POST' && req.method.toUpperCase() !== 'GET')
  ) {
    res.status(405).json({ error: '不允许的方法' })
    返回
  }

  常量 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
import type { NextApiRequest, NextApiResponse } from 'next'
import { PHRASE_CONFIG } from 'phraseConfig'
import { createInternalHandler } from 'sanity-plugin-phrase/backend'
import { writeToken } from '~/lib/sanity.api'
导入 { 客户 } 从 '~/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,
})

导出 默认 异步 函数 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({})
    返回
  }

  如果 (
    !req.method ||
    (req.method.toUpperCase() !== 'POST' && req.method.toUpperCase() !== 'GET')
  ) {
    res.status(405).json({ error: '不允许的方法' })
    返回
  }

  常量 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 plugin(版本 ^2.0.0)使用的适配器。如果需要特定适配器,请提交问题,或参考此 repository's package/src/adapters/document-internationalization.ts 以获取如何实现自定义适配器的示例。

自定义数据转换器

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

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

该插件没有提供在隔离环境中测试转换器的方法,因此开发可能会很复杂。将 Sanity 数据集中的真实目标文档保存为 .JSON,并将其用作每个编码/解码函数的测试数据。

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

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

const vttJsonTransformer: DataTransformer = {
  encode: {
    array(arr) {
      // 检查数组是否包含 VTT 字幕节点
      如果 (
        arr.every(
          (item) =>
            typeof item === 'object' &&
            !!item &&
            '_type' in item &&
            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) {
      如果 (
        arr.every(
          (item) =>
            typeof item === 'object' &&
            !!item &&
            '_type' in item &&
            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 选项来限制哪些编辑器可以访问短语仪表盘。此函数等同于传递给 Sanity 中字段的 hidden 属性的函数。它接收一个包含当前用户和文档的上下文,并且必须返回一个布尔值。

限制对短语仪表盘的访问仅限于具有管理员角色的用户的示例:

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.