플러그인은 Sanity 스튜디오 내에서 번역된 Phrase 콘텐츠에 대한 액세스를 제공합니다.
문서 수준 번역만 지원됩니다. 필드 수준 번역은 지원되지 않습니다.
기능:
-
실시간 미리 보기
번역은 동기화되어 언어학자와 번역가가 실시간으로 미리 보기 변경 사항을 볼 수 있도록 합니다.
-
스마트 재번역
플러그인은 마지막 번역 이후 변경된 콘텐츠를 비교하고 해당 변경 사항만 Phrase에 전송합니다.
-
자동 참조 번역
번역을 발행할 때, 편집자는 현재 문서에 의해 참조된 문서도 번역하도록 선택할 수 있으며, 플러그인은 대상 언어에 따라 자동으로 링크합니다.
-
유연한 스키마
구조에 관계없이 플러그인은 이를 조정하고 최종 번역된 콘텐츠가 Sanity 스키마에 따라 이루어지도록 보장합니다.
-
Phrase 워크플로우
Phrase의 번역 워크플로우는 동일하게 유지되며, 재교육이나 재구성이 필요하지 않습니다.
설치는 명령줄에서 수행됩니다.
웹사이트 생성기와 Sanity 스튜디오가 이미 구성되어 있다고 가정합니다. 아니면, Sanity에서 제공하는 스타터 템플릿 중 하나를 사용하세요.
설치
Sanity 스튜디오 인스턴스가 포함된 프로젝트로 이동하여 플러그인을 설치하세요:
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
가져오기 { defineConfig } from 'sanity'
가져오기 {
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',
라벨: '기본 번역 템플릿',
},
],
// 필수: 언어학자를 위한 미리 보기 URL 생성
getDocumentPreview: (문서, 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)
가져오기 {
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}`
},
/**
* 문구 프로젝트 템플릿은 번역 요청 시 편집자가 사용할 수 있습니다.
*
* **참고:** 아래에 설명된 템플릿 설정 단계를 따르십시오.
*/
phraseTemplates: [
{
templateUid: '1jYg0Pc1d8kAHUyM0tgdmt',
라벨: '[Sanity.io] 기본 템플릿',
},
],
/**
* @선택적
* 사용자 권한에 따라 Phrase 대시보드를 표시하거나 숨기고 싶을 경우.
*
* 현재 사용자와 문서가 포함된 컨텍스트를 수신하고 불리언을 반환해야 합니다.
*/
isPhraseDashboardHidden: (context) =>
!(context.currentUser.roles || []).some((r) => r.name === 'admin'),
})
export default defineConfig({
// ...
플러그인: [
// ...
phrasePlugin(PHRASE_CONFIG),
],
})
스키마 주입
어떤 문서 유형이 번역될 수 있는지 플러그인에 알리기 위해, 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({
스키마: {
types: injectPhraseIntoSchema(TRANSLATABLE_SCHEMAS, PHRASE_CONFIG),
템플릿: (prev) =>
prev.filter((template) => !TRANSLATABLE_SCHEMAS.includes(template.id)),
},
플러그인: [
// ...
phrasePlugin({
// 여기에 구성 옵션을 입력하십시오
),
],
})
문서 목록에서 PTD 제외하기
PTD(구문 번역 문서)는 Sanity Studio의 일반 문서 목록에 나타나지 않아야 하는 임시 문서입니다. NOT_PTD 상수는 이 목적을 위한 GROQ 필터를 제공합니다:
// sanity.config.ts
가져오기 { NOT_PTD } from '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 } from 'sanity-plugin-phrase/utils'
가져오기 { 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 데이터를 동기화할 수 있도록 보장합니다.
프로젝트 템플릿 설정
워크플로우 및 팀 요구 사항에 필요한 속성으로 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의 RegEx 파서에 의해 무시되도록 의도적으로 중복된 키를 포함합니다. 정확하게 중복되었는지 확인하십시오.
-
주어진 문서의 언어 와 같은 지역화 특정 데이터를 제외하십시오.
@sanity/document-internationalization를 사용하는 경우. -
모든 언어에서 동일한 경로를 사용하는 콘텐츠의 slug 와 같이 번역이 필요하지 않은 프로젝트 특정 키를 포함하십시오.
YOUR_IGNORED_KEYS_HERE를 무시할 키의 파이프 구분 목록으로 바꾸십시오. -
컨텍스트 메모:
/_sanityContext
소스 언어
현재 이 플러그인은 단일 소스 언어를 가정하여 작동합니다. 프로젝트 템플릿은 플러그인의 sourceLanguage에 구성된 것과 동일한 소스를 가져야 합니다.
대상 언어
Phrase에서 선택한 언어가 플러그인 구성에 있는 것과 동기화되어 있는지 확인하십시오.
이것은 플러그인이 Sanity Studio와 통신하는 데 사용하는 엔드포인트입니다. Phrase의 API에 인증하고, 웹훅 및 Sanity 스튜디오에서 사용자 요청을 수신하는 데 사용됩니다.
이 요청을 처리하기 위해 Sanity 프로젝트에서 사용자 지정 API 엔드포인트를 생성하십시오. 이 작업을 수행하는 가장 쉬운 방법 중 하나는 NextJS, Remix, SvelteKit 또는 Nuxt와 같은 프론트엔드 프레임워크를 통해 서버리스 기능을 사용하는 것입니다.
import {createRequestHandler} from backend를 통해 요청-응답 패턴으로 핸들러를 구성하거나 import {createInternalHandler} from 를 통해 내부 핸들러를 직접 사용할 수 있습니다. CORS 요청이 올바르게 처리되도록 확인하십시오. 스튜디오와 엔드포인트가 서로 다른 출처를 가지고 있습니다.
NextJS의 앱 디렉토리는 백엔드 핸들러를 React 클라이언트 구성 요소로 잘못 파싱하므로 현재 지원되지 않습니다.
이 예제는 구성된 apiEndpoint 경로에서 /api/phrase를 사용하여 Route Handler를 생성하는 방법을 보여줍니다:
// app/api/phrase/route.ts
// Next.js API 경로 지원: https://nextjs.org/docs/api-routes/introduction
가져오기 유형 { NextApiRequest, NextApiResponse } from 'next'
가져오기 { PHRASE_CONFIG } from 'phraseConfig'
가져오기 { createInternalHandler } from 'sanity-plugin-phrase/backend'
import { writeToken } from '~/lib/sanity.api'
가져오기 { client } from '~/lib/sanity.client'
내보내기 const maxDuration = 60
내보내기 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,
})
내보내기 기본 비동기 함수 핸들러(
req: NextApiRequest,
res: 다음ApiResponse,
) {
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 route: https://nextjs.org/docs/pages/building-your-application/routing/api-routes
가져오기 유형 { NextApiRequest, NextApiResponse } from 'next'
가져오기 { PHRASE_CONFIG } from 'phraseConfig'
가져오기 { createInternalHandler } from 'sanity-plugin-phrase/backend'
import { writeToken } from '~/lib/sanity.api'
가져오기 { 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,
})
내보내기 기본 비동기 함수 핸들러(
req: NextApiRequest,
res: 다음ApiResponse,
) {
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는 국제화에 대한 규정된 접근 방식을 가지고 있지 않으며, 이를 구현하는 방법은 다양합니다. 이 플러그인은 콘텐츠가 구성되는 방식과 번역되어야 하는 방식에 따라 구성할 수 있도록 어댑터 패턴을 사용합니다.
현재 사용 가능한 유일한 어댑터는 이며, 이는 Sanity의 공식 document-internationalization plugin (버전 ^2.0.0)에서 사용됩니다. 특정 어댑터가 필요한 경우 문제를 제기하거나 리포지토리의 package/src/adapters/document-internationalization.ts를 참조하여 사용자 지정 어댑터를 구현하는 방법에 대한 예를 확인하십시오.
사용자 지정 데이터 변환기
데이터를 Phrase로 전송하기 전에 변환해야 하는 경우 옵션을 사용하십시오. 데이터 구조를 변경하거나 특정 필드를 번역에서 제외하는 경우에 유용합니다.
각 데이터 변환기는 Phrase로 전송하기 전에 데이터를 인코딩해야 하며, Sanity에 저장하기 전에 변환하기 위해 다시 수신할 때 디코딩해야 합니다. 여러 변환기를 쌓아서 순차적으로 실행할 수 있습니다.
플러그인은 변환기를 독립적으로 테스트할 수 있는 방법을 제공하지 않으므로 개발이 복잡할 수 있습니다. Sanity 데이터 세트에서 실제 대상 문서를 .JSON으로 저장하고 각 인코드/디코드 기능의 테스트 데이터로 사용하십시오.
Phrase가 자막의 콘텐츠를 더 잘 세그먼트할 수 있도록 JSON 인코딩된 VTT 파일을 HTML로 수정하는 예:
가져오기 { 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
},
})