该插件提供从 Sanity studio 内部访问 Phrase 内容的功能。
仅支持文档级翻译。不支持字段级翻译。
功能:
-
实时预览
翻译保持同步,以便语言专家和译员能够实时查看预览更改。
-
智能重新翻译
插件会对比自上次翻译以来哪些内容发生了变化,并仅将这些更改发送到 Phrase。
-
自动引用翻译
在发布翻译时,编辑可以选择同时翻译当前文档所引用的文档,插件将根据译文语言自动链接它们。
-
灵活的架构
无论结构如何,插件都能适应它,并确保最终的内容符合 Sanity 架构。
-
Phrase 工作流
Phrase 中的翻译工作流保持不变;无需重新培训或重新配置操作。
安装在命令行执行。
假设网站生成器和 Sanity Studio 已经配置完成。如果不是,请使用 Sanity 提供的 Starter 模板 之一。
安装
导航到包含 Sanity Studio 实例的项目,并安装插件:
npm install sanity-plugin-phrase # 或 pnpm, yarn, bun
环境变量
在配置插件之前,必须设置以下环境变量。在项目根目录中创建 `.env` 文件(或用于 Next.js 的 `.env.local`)。
以下示例适用于 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
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),
],
})
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 UI 中没有配置,但必须配置 Phrase 以将 webhook 通知发送到后端 API 端点。这使得在翻译进程中能够实现实时更新。
创建 webhook
创建带有以下设置的 webhook:
-
URL
插件 API 端点的 URL,如 选项中所配置。
-
活动:
-
招贤纳士
-
工作已删除
-
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 Studio 通信的端点。它用于向 Phrase 的 API 进行身份验证,并接收来自 Sanity Studio 的 Webhook 和用户请求。
在 Sanity 项目中创建一个自定义 API 端点来处理这些请求。最简单的方法之一是使用 NextJS、Remix、SvelteKit 或 Nuxt 等前端框架提供的无服务器函数。
访问并通过 import {createRequestHandler} from 使用请求-响应模式配置处理程序,或通过 import {createInternalHandler} from 直接使用内部处理程序。确保正确处理 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 没有针对国际化的规定性方法,并且有多种实现方式。此插件使用适配器模式,允许根据内容结构及其应如何翻译进行配置。
目前,唯一可用的适配器是 ,这是 Sanity 官方 document-internationalization 插件(版本 ^2.0.0)所使用的适配器。如果需要特定适配器,请提交一个问题,或参考此 存储库的 package/src/adapters/document-internationalization.ts 以获取如何实现自定义适配器的示例。
自定义数据转换器
如果需要在将数据发送到 Phrase 之前对其进行转换,请使用 选项。如果需要更改数据结构,或者需要排除某些字段不进行翻译,这非常有用。
每个数据转换器都需要在将数据发送到 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
限制编辑器访问
可以通过实现 i 选项来限制哪些编辑器可以访问 Phrase 仪表盘。此函数等同于传递给 Sanity 中字段 hidden 属性的函数。它接收一个包含当前用户和文档的上下文,并且必须返回一个布尔值。
限制用户访问 Phrase 仪表盘至仅限拥有 admin 角色的示例:
const PHRASE_CONFIG = definePhraseOptions({
// ...
isPhraseDashboardHidden: (context) => {
const isAdmin = (context.currentUser.roles || []).some(
(r) => r.name === 'admin',
)
// 如果不是管理员则隐藏
return !isAdmin
},
})