集成

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
从'sanity'导入{ defineConfig }
从{导入
  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项目模板
  phraseTemplates: [
    {
      模板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)
  translateDrafts: false,

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

  // 调试的日志配置
  记录器: {
    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 架构类型
   */
  translatableTypes: ['页面', '帖子', '课程', '课', '定义'],

  /**
   * 用户可以翻译到的所有语言的语言代码。
   * 应与存储在您的 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}`
  },

  /**
   * 短语项目模板,您的编辑可以在请求翻译时使用。
   *
   * **注释:** 请按照下面概述的步骤设置模板
   */
  phraseTemplates: [
    {
      模板Uid:'1jYg0Pc1d8kAHUyM0tgdmt',
      标签: '[Sanity.io] 默认模板',
    },
  ],

  /**
   * @可选
   * 如果您想根据用户权限显示或隐藏短语仪表盘。
   *
   * 接收当前用户和文档的上下文,并必须返回一个布尔值。
   */
  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 可译模式 = ['页面', '帖子', '课程', '课', '定义']

export default defineConfig({
  模式: {
    类型: injectPhraseIntoSchema(可译模式, 短语配置),
    模板: (prev) =>
      prev.filter((template) => !TRANSLATABLE_SCHEMAS.includes(template.id)),
  },
  插件: [
    // ...
    phrasePlugin({
      // 在这里输入您的配置选项
    }),
  ],
})

排除文档列表中的PTD

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

// sanity.config.ts
导入 { NOT_PTD } 来自 'sanity-plugin-phrase/utils'

export default defineConfig({
  // ... 其他配置
  插件: [
    structureTool({
      结构:(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文档:

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

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

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

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

短语配置

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

创建一个webhook

使用以下设置创建一个webhook

  • URL

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

  • 活动:

    • 招贤纳士

      • 工作已删除

      • Job assigned

      • 工作截止日期已更改

      • 工作译文已更新

    • 项目

      • 项目已删除

      • 项目截止日期已更改

    • 其他

      • 预翻译完成

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

设置项目模板(s)

使用工作流和团队需求所需的属性配置短语项目模板(s)。在订购新翻译时,可以提供一个或多个模板供选择。短语项目模板必须具有特定的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)
/.*)

该表达式故意包含重复的键,以确保它们被短语的正则表达式解析器忽略。确保它们被正确复制。

  • 排除特定于本地化的数据,例如在使用@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
导入类型 { NextApiRequest, NextApiResponse } 来自 'next'
导入 { PHRASE_CONFIG } 来自 'phraseConfig'
导入 { createInternalHandler } 来自 'sanity-plugin-phrase/backend'

导入 { writeToken } 来自 '~/lib/sanity.api'
导入 { client } 来自 '~/lib/sanity.client'

导出常量 maxDuration = 60
导出常量 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({})
    return
  }

  if (
    !req.method ||
    (req.method.toUpperCase() !== 'POST' && req.method.toUpperCase() !== 'GET')
  ) {
    res.status(405).json({ error:'不允许的方法' })
    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
导入类型 { NextApiRequest, NextApiResponse } 来自 'next'
导入 { PHRASE_CONFIG } 来自 'phraseConfig'
导入 { createInternalHandler } 来自 'sanity-plugin-phrase/backend'
导入 { writeToken } 来自 '~/lib/sanity.api'
导入 { client } 来自 '~/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({})
    return
  }

  if (
    !req.method ||
    (req.method.toUpperCase() !== 'POST' && req.method.toUpperCase() !== 'GET')
  ) {
    res.status(405).json({ error:'不允许的方法' })
    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 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 字幕节点
      if (
        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) {
      if (
        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(
  编码:EncodedSubtitles,
):StoredSubtitleNode[]
声明函数 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.