このプラグインは、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-プラグイン-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({
// ... 既存の設定
plugins: [
phrasePlugin(PHRASE_CONFIG),
// ... その他のプラグイン
],
})// sanity.config.(js|ts)
import {
phrasePlugin,
documentInternationalizationAdapter,
} from 'sanity-プラグイン-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] デフォルトテンプレート',
},
],
/**
* @optional
* ユーザー権限に応じてPhraseダッシュボードを表示または非表示にする場合。
*
* 現在のユーザーとドキュメントを含むコンテキストを受け取り、ブール値を返す必要があります。
*/
isPhraseDashboardHidden: (context) =>
!(context.currentUser.roles || []).some((r) => r.name === 'admin'),
})
export default defineConfig({
// ...
plugins: [
// ...
phrasePlugin(PHRASE_CONFIG),
],
})
スキーマインジェクション
どのドキュメントタイプを翻訳可能にするかをプラグインに指示するには、sanity.config.tsファイルのinjectPhraseIntoSchema関数にドキュメントタイプの配列を渡します:
// sanity.config.ts
import { injectPhraseIntoSchema } from 'sanity-plugin-phrase'
// 翻訳可能なスキーマタイプの一覧。通常はインデックスファイルからエクスポートされます
// Sanityスキーマの場所はどこでも構いません
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)),
},
plugins: [
// ...
phrasePlugin({
// ここに設定オプションを記述します
}),
],
})
PTDをドキュメントリストから除外する
PTD(Phrase翻訳ドキュメント)は一時的なドキュメントであり、Sanity Studio内の通常のドキュメントリストには表示されるべきではありません。NOT_PTD定数は、この目的のためのGROQフィルタを提供します:
// sanity.config.ts
import { NOT_PTD } from 'sanity-plugin-phrase/utils'
export default defineConfig({
// ... その他の設定
plugins: [
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をドキュメントリストから除外する
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({
ドキュメント: {
unstable_languageFilter: (prev, ctx) => {
const { schemaType, documentId } = ctx
// 実際のドキュメントに対してのみ翻訳メニューを表示し、PTD には表示しない
return TRANSLATABLE_TYPES.includes(schemaType) &&
documentId &&
!isPtdId(documentId)
? [...prev, DocumentInternationalizationMenu]
: prev
},
},
})
このプラグインには Phrase UI 上の設定はありませんが、Phrase がウェブフック通知をバックエンド API エンドポイントに送信するように設定する必要があります。これにより、翻訳の進捗に合わせてリアルタイムで更新が可能になります。
ウェブフックを作成
以下の設定で ウェブフック を作成します:
-
URL
オプションで設定された、プラグインの API エンドポイントへの URL。
-
イベント:
-
ジョブ
-
ジョブが削除されました
-
Job assigned
-
ジョブの納期が変更されました
-
ジョブの訳文が更新されました
-
-
プロジェクト
-
プロジェクトが削除されました
-
プロジェクトの納期が変更されました
-
-
その他
-
一括翻訳が完成しました
-
-
これにより、プラグインが Phrase プロジェクトの変更を確実に通知され、Sanity データを同期し続けることができます。
プロジェクト テンプレートのセットアップ
ワークフローと Team の要件に必要なプロパティを使用して、Phrase プロジェクト テンプレート を設定します。新しい翻訳を注文する際、1つ以上のテンプレートを選択肢として提供できます。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 Studioと通信するために使用するエンドポイントです。これは、PhraseのAPIに対して認証を行い、Sanity StudioからのWebhookやユーザーリクエストを受信するために使用されます。
SanityプロジェクトにカスタムAPIエンドポイントを作成し、これらのリクエストを処理します。これを行う最も簡単な方法の1つは、NextJS、Remix、SvelteKit、Nuxtなどのフロントエンドフレームワークを介してサーバーレス関数を使用することです。
import {createRequestHandler} from を介してRequest-Responseパターンでハンドラーを構成するか、import {createInternalHandler} from を介して内部ハンドラーを直接使用してアクセスします。Studioとエンドポイントのオリジンが異なるため、CORSリクエストが正しく処理されるようにしてください。
NextJSのappディレクトリは、バックエンドハンドラーをReactクライアントコンポーネントとして誤って解析するため、現在サポートされていません。
この例では、Next.js Pages Routerを使用して、構成された apiEndpoint パス /api/phrase にルートハンドラーを作成する方法を示します。
// 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'
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('アクセス-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('アクセス-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 adapters
Sanityには国際化に対する規定のアプローチはなく、実装方法は多数あります。このプラグインはアダプターパターンを使用して、コンテンツの構造や翻訳方法に基づいた構成を可能にします。
現在利用可能なアダプターは のみで、これはSanityの公式 document-internationalization プラグイン (バージョン ^2.0.0) で使用されているものです。特定のアダプターが必要な場合は問題を作成するか、カスタムアダプターの実装例としてこの リポジトリの package/src/adapters/document-internationalization.ts を参照してください。
カスタムデータトランスフォーマー
Phraseに送信する前にデータを変換する必要がある場合は、 オプションを使用してください。これは、データの構造を変更する場合や、特定のフィールドを翻訳対象から除外する場合に便利です。
各データトランスフォーマーは、Phraseに送信する前にデータをエンコードし、受信したデータをSanityに保存する前に変換するためにデコードする必要があります。複数のトランスフォーマーをスタックして順番に実行できます。
このプラグインにはトランスフォーマーを個別にテストする方法がないため、開発が複雑になる可能性があります。Sanityデータセットから実際の訳文ドキュメントを.JSONとして保存し、それらを各エンコード/デコード関数のテストデータとして使用してください。
Phraseが字幕のコンテンツをより適切にセグメント化できるように、JSONエンコードされたVTTファイルをHTMLに変更する例:
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(
encoded: EncodedSubtitles,
): StoredSubtitleNode[]
declare function encodeSubtitles(nodes: StoredSubtitleNode[]): EncodedSubtitles
エディタのアクセスを制限する
どのエディタがPhraseダッシュボードにアクセスできるかは、iオプションを実装することで制限できます。この関数は、Sanityのフィールドのhiddenプロパティに渡されるものと同等です。現在のユーザーとドキュメントを含むコンテキストを受け取り、ブール値を返す必要があります。
Phraseダッシュボードへのアクセスを管理者ロールを持つユーザーに制限する例:
const PHRASE_CONFIG = definePhraseOptions({
// ...
isPhraseDashboardHidden: (context) => {
const isAdmin = (context.currentUser.roles || []).some(
(r) => r.name === 'admin',
)
// 管理者でない場合は非表示
return !isAdmin
},
})