이 plugin은 Sanity 스튜디오 내에서 Phrase 콘텐츠에 대한 액세스를 제공합니다.
문서 수준의 번역만 지원됩니다. 필드 수준의 번역은 지원되지 않습니다.
기능:
-
실시간 미리 보기
번역은 동기화 상태로 유지되어 언어 전문가와 번역가가 실시간으로 미리 보기 변경 사항을 확인할 수 있습니다.
-
스마트 재번역
plugin은 마지막 번역 이후 변경된 콘텐츠를 비교하여 해당 변경 사항만 Phrase로 전송합니다.
-
자동 참조 번역
번역을 요청할 때 편집자는 현재 문서가 참조하는 문서도 함께 번역하도록 선택할 수 있으며, plugin은 대상 언어별로 자동으로 연결합니다.
-
유연한 스키마
구조에 관계없이 plugin은 이에 적응하며 최종 콘텐츠가 Sanity 스키마에 따라 번역되도록 보장합니다.
-
Phrase 워크플로우
Phrase의 번역 워크플로우는 동일하게 유지되므로, 운영을 재교육하거나 재구성할 필요가 없습니다.
설치는 명령줄에서 수행됩니다.
웹사이트 생성기와 Sanity Studio가 이미 구성되어 있다고 가정합니다. 그렇지 않은 경우 Sanity에서 제공하는 Starter 템플릿 중 하나를 사용하십시오.
설치
Sanity Studio 인스턴스가 포함된 프로젝트로 이동하여 plugin을 설치하십시오:
npm install sanity-plugin-phrase # 또는 pnpm, yarn, bun
환경 변수
plugin을 구성하기 전에 다음 환경 변수를 설정해야 합니다. 프로젝트 루트에 `.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"
# plugin 백엔드 핸들러가 위치할 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"
plugin 구성
plugin이(가) 필수 구성 옵션과 함께 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({
// ... 기존 설정
plugins: [
phrasePlugin(PHRASE_CONFIG),
// ... 기타 plugin
],
})// sanity.config.(js|ts)
import {
phrasePlugin,
documentInternationalizationAdapter,
} from 'sanity-plugin-phrase'
const PHRASE_CONFIG = definePhraseOptions({
/**
* 이 plugin에 사용할 i18n 어댑터입니다.
* 각 대상 언어에 대한 문서를 가져오고 수정하는 역할을 합니다.
*
* 어댑터에 대한 자세한 내용은 아래를 참조하십시오.
*/
i18nAdapter: documentInternationalizationAdapter(),
/**
* plugin이 번역할 수 있는 Sanity 스키마 유형
*/
translatableTypes: ['page', 'post', 'course', 'lesson', 'definition'],
/**
* 사용자가 번역할 수 있는 모든 언어의 언어 코드입니다.
* Sanity 문서에 저장되고 프런트엔드에서 사용하는 것과 동일해야 합니다. plugin은 이를 자동으로 Phrase 형식으로 번역합니다.
*/
supportedTargetLangs: ['cz', 'es', 'pt', 'fr', 'de', 'it', 'nl', 'pl', 'ru'],
/**
* 번역될 소스 언어의 언어 코드입니다.
* Sanity 문서에 저장되고 프런트엔드에서 사용하는 것과 동일해야 합니다. plugin은 이를 자동으로 Phrase 형식으로 번역합니다.
*/
sourceLang: 'en',
/**
* Phrase 계정 설정에 정의된 대로
* `eu` 또는 `us`
*/
phraseRegion: 'us|eu',
/**
* 구성된 plugin 백엔드 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] Default 템플릿',
},
],
/**
* @optional
* 사용자 권한에 따라 Phrase 대시보드를 표시하거나 숨기기 원하는 경우.
*
* 현재 사용자 및 문서가 포함된 컨텍스트를 수신하며 불리언 값을 반환해야 합니다.
*/
isPhraseDashboardHidden: (context) =>
!(context.currentUser.roles || []).some((r) => r.name === 'admin'),
})
export default defineConfig({
// ...
plugins: [
// ...
phrasePlugin(PHRASE_CONFIG),
],
})
스키마 주입
plugin에 번역 가능한 문서 유형을 알리려면 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 Translation Documents)는 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에서 번역 메뉴 숨기기
문서 국제화 plugin을 사용할 때, PTD에서는 번역 메뉴를 숨겨야 합니다. isPtdId 유틸리티는 PTD 문서를 식별합니다:
import { isPtdId } from 'sanity-plugin-phrase/utils'
import { DocumentInternationalizationMenu } from '@sanity/document-internationalization'
// PHRASE_CONFIG의 번역 가능 유형과 동일한 배열을 사용하십시오
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
},
},
})
이 plugin은 Phrase UI에서 별도의 설정이 없으나, Phrase가 백엔드 API 엔드포인트로 웹 후크 알림을 보내도록 설정해야 합니다. 이를 통해 번역 진행 상황에 따른 실시간 업데이트가 가능합니다.
웹 후크 생성
다음 설정으로 웹 후크를 생성합니다:
-
URL
옵션에 설정된 plugin의 API 엔드포인트 URL입니다.
-
이벤트:
-
작업
-
작업 삭제됨
-
Job assigned
-
작업 만기일 변경됨
-
작업 대상 업데이트됨
-
-
프로젝트
-
프로젝트 삭제됨
-
프로젝트 만기일 변경됨
-
-
기타
-
사전 번역 완료됨
-
-
이를 통해 plugin이 Phrase 프로젝트의 모든 변경 사항을 알림받고 Sanity 데이터를 동기화 상태로 유지할 수 있습니다.
프로젝트 템플릿 설정
워크플로우 및 Team 요구 사항에 필요한 속성으로 Phrase 프로젝트 템플릿을 구성하십시오. 새 번역을 주문할 때 선택할 수 있는 하나 이상의 템플릿이 제공될 수 있습니다. Phrase 프로젝트 템플릿에는 plugin이 올바르게 작동하기 위한 특정 JSON 가져오기 설정이 있어야 합니다. 이 설정은 번역가에게 보낼 필드와 메타데이터로 보존할 필드를 제어합니다.
JSON 파일 가져오기
regex를 사용하여 특정 키 제외:
(^|.*\/) (_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
소스 언어
현재 이 plugin은 단일 소스 언어가 있다는 가정하에 작동합니다. 프로젝트 템플릿은 plugin의 sourceLanguage에 구성된 것과 동일한 소스를 가져야 합니다.
대상 언어
Phrase에서 선택한 언어가 plugin 구성에 있는 언어와 동기화되었는지 확인하십시오.
이것은 plugin이 Sanity Studio와 통신하기 위해 사용하는 엔드포인트입니다. 이것은 Phrase의 API를 인증하고, Sanity Studio로부터 웹훅 및 사용자 요청을 수신하는 데 사용됩니다.
Sanity 프로젝트에서 이러한 요청을 처리할 사용자 지정 API 엔드포인트를 생성하십시오. 이를 수행하는 가장 쉬운 방법 중 하나는 NextJS, Remix, SvelteKit 또는 Nuxt와 같은 프런트엔드 프레임워크를 통해 서버리스 함수를 사용하는 것입니다.
import {createRequestHandler} from 을(를) 통해 Request-Response 패턴으로 핸들러를 구성하거나 import {createInternalHandler} from 을(를) 통해 내부 핸들러를 직접 사용하십시오. 액세스. Studio와 엔드포인트의 출처가 다른 경우 CORS 요청이 올바르게 처리되도록 하십시오.
NextJS의 앱 디렉터리는 백엔드 핸들러를 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('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: '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('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: '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는 국제화에 대한 규정된 접근 방식이 없으며, 이를 구현하는 방법은 다양합니다. 이 plugin은 콘텐츠가 구조화된 방식과 번역되어야 하는 방식에 따라 구성을 허용하기 위해 어댑터 패턴을 사용합니다.
현재 사용 가능한 유일한 어댑터는 이며, 이는 Sanity의 공식 document-internationalization plugin(버전 ^2.0.0)에서 사용하는 것입니다. 특정 어댑터가 필요한 경우 문제를 제기하거나 사용자 지정 어댑터를 구현하는 방법에 대한 예시는 이 repository's package/src/adapters/document-internationalization.ts를 참조하십시오.
사용자 지정 데이터 변환기
Phrase로 데이터를 보내기 전에 변환이 필요한 경우 옵션을 사용하십시오. 이는 데이터 구조를 변경하거나 특정 필드를 번역 대상에서 제외할 때 유용합니다.
각 데이터 변환기는 데이터를 Phrase로 보내기 전에 encode해야 하며, Sanity에 저장하기 전에 변환하려면 다시 받을 때 decode해야 합니다. 여러 변환기를 쌓아서 순차적으로 실행할 수 있습니다.
이 plugin은 변환기를 개별적으로 테스트할 방법을 제공하지 않으므로 개발이 복잡할 수 있습니다. Sanity 데이터셋에서 실제 대상 문서를 .JSON으로 저장하고 각 encode/decode 함수의 테스트 데이터로 사용하십시오.
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
},
})