EmDash는 Astro의 기본 제공 i18n 라우팅과 통합되어 다국어 콘텐츠 관리를 제공합니다. URL 라우팅과 로케일 감지는 Astro가 처리하고, 번역된 콘텐츠의 저장과 조회는 EmDash가 처리합니다.
각 번역은 고유한 슬러그, 상태, 리비전 기록을 가진 완전히 독립적인 콘텐츠 항목입니다. 게시물의 프랑스어 버전이 초안 상태여도 영어 버전은 게시될 수 있습니다.
로케일 구성
Astro 설정에 i18n 블록을 추가하여 i18n을 활성화합니다. EmDash는 이 동일한 설정에서 로케일 목록, 기본 로케일, 폴백 체인을 읽습니다.
import { defineConfig } from "astro/config";
import emdash, { local } from "emdash/astro";
import { sqlite } from "emdash/db";
export default defineConfig({
i18n: {
defaultLocale: "en",
locales: ["en", "fr", "es"],
fallback: { fr: "en", es: "en" },
},
integrations: [
emdash({
database: sqlite({ url: "file:./data.db" }),
storage: local({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
}),
}),
],
});
Astro 설정에 i18n이 없으면 모든 i18n 기능이 비활성화되고, EmDash는 단일 언어 CMS로 동작합니다.
번역의 작동 방식
EmDash는 로케일당 한 행 모델을 사용합니다. 각 번역은 데이터베이스에서 고유한 ID, 슬러그, 상태를 가진 별도의 행이며, 공유된 translation_group 식별자를 통해 다른 번역과 연결됩니다. 세 개의 번역이 있는 posts 테이블은 다음과 같습니다.
ec_posts:
id | slug | locale | translation_group | status
---------|-------------|--------|-------------------|----------
01ABC... | my-post | en | 01ABC... | published
01DEF... | mon-article | fr | 01ABC... | draft
01GHI... | mi-entrada | es | 01ABC... | published
이 설계에는 다음과 같은 의미가 있습니다.
- 로케일별 슬러그 —
/blog/my-post와/fr/blog/mon-article이 자연스럽게 동작합니다 - 로케일별 게시 — 프랑스어는 초안으로 두고 영어 버전을 게시할 수 있습니다
- 로케일별 리비전 — 각 번역이 고유한 리비전 기록을 가집니다
- 단일 로케일 쿼리 — 목록 쿼리는 하나의 로케일에 대한 항목만 반환합니다
슬러그, 항목 ID, 데이터베이스 ID
항목에는 용도가 다른 두 가지 식별자가 있습니다.
entry.id는 항목의 슬러그입니다. 공개 URL을 만들 때 사용하세요.entry.data.id는 데이터베이스 ID입니다. API 작업과,getTranslations()및getEntryTerms()를 포함하여 저장된 콘텐츠 행을 참조하는 헬퍼에 사용하세요.
각 로케일은 별도의 행이므로 번역마다 데이터베이스 ID가 다릅니다. 공유된 translation_group은 해당 행들이 같은 콘텐츠의 번역임을 기록합니다. 번역을 만들면 EmDash가 그 그룹을 관리하며, 템플릿에는 보통 그룹 내 임의의 행의 데이터베이스 ID만 있으면 됩니다.
번역된 콘텐츠 쿼리
단일 항목
다국어 라우트에서는 Astro.currentLocale을 getEmDashEntry에 전달하세요. Astro는 라우터가 선택한 로케일을 알지만, 여러 로케일에 존재할 수 있는 슬러그를 구분하려면 EmDash에 명시적인 값이 필요합니다. 컬렉션 쿼리에서도 마찬가지로 하세요.
---
import { getEmDashEntry } from "emdash";
const { slug } = Astro.params;
const { entry: post, error } = await getEmDashEntry("posts", slug, {
locale: Astro.currentLocale,
});
if (!post) return Astro.rewrite("/404");
---
<article>
<h1>{post.data.title}</h1>
</article>
폴백 체인
요청한 로케일에 일치하는 게시된 항목이 없으면 getEmDashEntry는 Astro 설정의 폴백 체인을 따라갑니다. 미리보기 또는 비주얼 편집 모드에서는 같은 조회가 초안을 반환할 수 있습니다. fallback: { fr: "en" }인 경우:
- 요청한 로케일(
fr)을 시도합니다 - 폴백 로케일(
en)을 시도합니다 - 기본 로케일이 아직 체인에 없다면 기본 로케일을 시도합니다
폴백은 단일 항목 쿼리에만 적용됩니다. 목록 쿼리는 요청한 로케일의 항목만 반환합니다.
각 폴백 조회는 동일한 id 인수를 사용합니다. 예를 들어 슬러그 about에 대한 요청은 프랑스어에서 슬러그가 마찬가지로 about인 영어 항목으로 폴백될 수 있습니다. 반면 a-propos에 대한 요청은 슬러그가 about인 영어 항목을 찾을 수 없습니다. 두 행은 서로 다른 공개 식별자를 사용하기 때문입니다. 슬러그가 서로 다른 로케일 변형을 찾아 연결하려면 getTranslations()를 사용하세요.
메뉴
메뉴는 로케일별로 관리됩니다. 같은 name(예: "primary")이 여러 로케일에 존재할 수 있으며, 모두 공유된 translation_group으로 연결됩니다. 메뉴 항목은 참조하는 콘텐츠를 현재 로케일의 버전으로 해석합니다.
다음 컴포넌트는 현재 로케일의 기본 메뉴를 가져옵니다.
---
import { getMenu } from "emdash";
const menu = await getMenu("primary", { locale: Astro.currentLocale });
---
<nav aria-label="Primary">
<ul>
{menu?.items.map((item) => (
<li><a href={item.url}>{item.label}</a></li>
))}
</ul>
</nav>
기존 메뉴의 번역은 관리자의 Menus 목록에서 만듭니다. 항목은 reference_id(참조된 콘텐츠의 translation_group을 저장합니다)가 그대로 유지된 채 복제되므로, 새 메뉴의 링크는 로케일별로 올바른 콘텐츠를 자동으로 가리킵니다.
택소노미 (카테고리, 태그)
용어는 로케일별로 관리됩니다. 각 로케일이 고유한 정의를 가질 수 있으므로 label과 labelSingular도 번역할 수 있으며, hierarchical과 collections는 모든 로케일이 공유합니다(택소노미와 용어 번역 참조). 피벗 content_taxonomies.taxonomy_id에는 용어의 translation_group이 저장되므로, 한 번의 할당이 콘텐츠의 모든 로케일에 걸쳐 적용됩니다.
다음 예시는 현재 로케일의 카테고리와 게시물의 용어를 가져옵니다.
---
import { getTaxonomyTerms, getEntryTerms } from "emdash";
const categories = await getTaxonomyTerms("category", {
locale: Astro.currentLocale,
});
const terms = await getEntryTerms("posts", post.data.id, undefined, {
locale: Astro.currentLocale,
});
---
콘텐츠를 번역하면 원본의 용어 할당이 자동으로 상속됩니다. 용어 자체를 한 번만 번역하면, 해당 용어를 사용하는 모든 게시물이 읽을 때 올바른 로케일로 해석됩니다.
택소노미 로케일 불일치 수정
관리자가 사이트 매니페스트를 로드할 때, 택소노미 정의나 용어가 사이트에 구성된 i18n.locales에 없는 로케일을 사용하면 EmDash가 서버 로그에 경고를 남깁니다. i18n 설정이 없으면 en이 유효한 로케일입니다. EmDash는 기존 콘텐츠가 어떤 구성된 로케일을 의도했는지 추론할 수 없으므로 이 행들은 변경되지 않은 채 남습니다.
데이터베이스를 백업한 다음, 경고에 명시된 해당 행을 확인합니다.
SELECT id, name, locale FROM _emdash_taxonomy_defs ORDER BY name, locale;
SELECT id, name, slug, locale FROM taxonomies ORDER BY name, slug, locale;
각 행에 의도된 로케일을 확인한 후 id로 업데이트합니다.
UPDATE _emdash_taxonomy_defs SET locale = 'ja' WHERE id = '<definition-id>';
UPDATE taxonomies SET locale = 'ja' WHERE id = '<term-id>';
i18n.locales에 있는 대소문자를 그대로 사용하세요. 업데이트하기 전에 같은 택소노미 이름과 대상 로케일, 또는 같은 용어 이름, 슬러그, 대상 로케일을 가진 행이 있는지 확인하세요. 이러한 조합은 고유해야 합니다. 대상 행이 이미 존재한다면 로케일을 일괄 업데이트하지 말고 번역을 조정하세요. EmDash를 다시 시작하고 경고가 더 이상 나타나지 않는지 확인합니다.
컬렉션 목록
로케일로 컬렉션을 필터링합니다.
---
import { getEmDashCollection } from "emdash";
const { entries: posts } = await getEmDashCollection("posts", {
locale: Astro.currentLocale,
status: "published",
});
---
<ul>
{posts.map((post) => (
<li><a href={`/${post.id}`}>{post.data.title}</a></li>
))}
</ul>
언어 전환기 구축하기
getTranslations를 사용하여 현재 항목의 기존 번역으로 연결되는 언어 전환기를 만듭니다.
---
import { getTranslations } from "emdash";
import { getRelativeLocaleUrl } from "astro:i18n";
interface Props {
collection: string;
entryId: string;
}
const { collection, entryId } = Astro.props;
const { translations } = await getTranslations(collection, entryId);
const publishedTranslations = translations.filter(
(translation): translation is typeof translation & { slug: string } =>
translation.status === "published" && translation.slug !== null
);
---
<nav aria-label="Language">
<ul>
{publishedTranslations.map((translation) => (
<li>
<a
href={getRelativeLocaleUrl(translation.locale, `/blog/${translation.slug}`)}
aria-current={translation.locale === Astro.currentLocale ? "page" : undefined}
>
{translation.locale.toUpperCase()}
</a>
</li>
))}
</ul>
</nav>
getTranslations 함수는 같은 번역 그룹에 속한 모든 로케일 변형을 반환합니다.
const { translationGroup, translations } = await getTranslations("posts", post.data.id);
// translations: [
// { locale: "en", id: "01ABC...", slug: "my-post", status: "published" },
// { locale: "fr", id: "01DEF...", slug: "mon-article", status: "draft" },
// ]
관리자에서 번역 관리
콘텐츠 목록
i18n이 활성화되면 콘텐츠 목록에 다음이 표시됩니다.
- 각 항목의 로케일을 보여 주는 로케일 열
- 로케일 간에 전환하는 툴바의 로케일 필터
번역 생성
편집기에서 아무 콘텐츠 항목이나 엽니다. 사이드바에 구성된 모든 로케일을 나열하는 Translations 패널이 표시됩니다. 각 로케일에 대해:
- 번역이 없는 로케일에는 **“Translate”**가 표시됩니다. 클릭하여 번역을 만듭니다
- 이미 번역이 있는 로케일에는 **“Edit”**가 표시됩니다. 클릭하여 해당 번역으로 이동합니다
- 현재 로케일에는 체크 표시가 붙습니다
번역을 만들면 새 항목은 원본 로케일의 데이터로 미리 채워지고 기본 슬러그 {source-slug}-{locale}이 지정됩니다. 필요에 따라 슬러그와 콘텐츠를 조정한 다음 저장하세요.
로케일별 게시
각 번역은 고유한 상태를 가집니다. 번역을 각각 독립적으로 게시하거나, 게시 취소하거나, 예약할 수 있습니다. 영어 버전이 라이브 상태여도 프랑스어 버전은 초안으로 둘 수 있습니다.
콘텐츠 API 사용
locale 매개변수
콘텐츠 API 라우트에는 인증된 세션 또는 베어러 토큰이 필요합니다. 목록 라우트는 선택적인 locale 쿼리 매개변수를 받습니다. 경로에 슬러그를 사용할 때는 단일 항목 라우트도 이 매개변수를 받습니다. 데이터베이스 ID는 전역적으로 고유하므로 로케일로 구분할 필요가 없습니다.
GET /_emdash/api/content/posts?locale=fr
GET /_emdash/api/content/posts/my-post?locale=fr
목록 요청에서 locale을 생략하면 구성된 기본 로케일이 사용됩니다.
API를 통한 번역 생성
콘텐츠 생성 엔드포인트에 locale과 translationOf를 전달하여 번역을 만듭니다.
POST /_emdash/api/content/posts
Content-Type: application/json
X-EmDash-Request: 1
{
"locale": "fr",
"translationOf": "01ABC...",
"slug": "mon-article",
"data": {
"title": "Mon Article"
}
}
translationOf는 entry.data.id와 같은 원본 행의 데이터베이스 ID입니다. 새 항목은 원본 항목의 translation_group을 공유하며 초안으로 시작합니다.
번역 나열
특정 항목의 모든 번역을 가져옵니다.
GET /_emdash/api/content/posts/01ABC.../translations
번역 그룹 ID와, ID, 슬러그, 상태가 포함된 로케일 변형 배열을 반환합니다.
CLI 사용
CLI를 인증한 후에는 콘텐츠 명령에서 --locale 플래그를 사용할 수 있습니다.
# List French posts
emdash content list posts --locale fr
# Get a specific entry in French
emdash content get posts my-post --locale fr
# Create a French translation as a draft
emdash content create posts \
--locale fr \
--translation-of 01ABC... \
--slug mon-article \
--data '{"title":"Mon article"}' \
--draft
content create는 --data, --file, --stdin 중 하나로 입력을 받아야 합니다. --draft를 전달하지 않으면 생성 직후 게시됩니다.
다국어 콘텐츠 시딩
시드 파일은 locale과 translationOf를 사용하여 번역을 표현합니다.
{
"content": {
"posts": [
{
"id": "welcome",
"slug": "welcome",
"locale": "en",
"status": "published",
"data": { "title": "Welcome" }
},
{
"id": "welcome-fr",
"slug": "bienvenue",
"locale": "fr",
"translationOf": "welcome",
"status": "draft",
"data": { "title": "Bienvenue" }
}
]
}
}
translationOf 참조가 올바르게 해석되도록, 원본 로케일 항목은 시드 파일에서 번역보다 먼저 나와야 합니다.
번역 가능한 필드 선택
각 필드에는 translatable 설정이 있습니다(기본값: true). 번역을 만들 때:
- 번역 가능한 필드는 편집을 위해 원본 로케일에서 미리 채워집니다
- 번역 불가능한 필드는 복사되며 그룹 내 모든 번역 간에 동기화된 상태로 유지됩니다
리비전이 있는 컬렉션에서는 항목을 게시하면 해당 항목에서 변경된 번역 불가능한 값이 다른 번역으로 복사되며, 초안을 저장하면 그 항목만 변경됩니다. 다른 번역에 이러한 값 중 하나를 변경한 보류 중인 초안이 있다면, 그 초안은 자신의 값을 유지하고, 해당 번역을 게시하면 그 값이 그룹의 나머지 번역으로 복사됩니다.
status, published_at, author_id 같은 시스템 필드는 항상 로케일별로 관리되며 동기화되지 않습니다.
참조 필드는 동기화되는 것이 아니라 공유됩니다. EmDash는 링크를 번역 그룹을 키로 하여 저장하므로, 항목의 모든 번역이 하나의 선택을 갖고, 어느 로케일에서 편집하든 모든 번역에 반영됩니다.
로케일 URL 구축
EmDash는 로케일을 저장하고, 공개 라우팅은 Astro가 처리합니다. 지원되는 EmDash 구성에서는 기본 로케일에 접두사를 붙이지 않습니다.
# prefix-other-locales (Astro default)
/blog/my-post → en (default locale, no prefix)
/fr/blog/mon-article → fr
astro:i18n의 getRelativeLocaleUrl을 사용하여 올바른 접두사와 사용자 지정 로케일 경로 매핑을 추가하세요. 기본 로케일 접두사는 활성화하지 마세요. 로케일 구성에서 설명했듯이, 그 라우팅 전략은 주입된 관리자 페이지가 로드되지 않게 합니다.
사이트맵
/sitemap-{collection}.xml의 컬렉션별 사이트맵은 로케일을 인식합니다. 라우팅이 가능하고 SEO가 활성화된 컬렉션의 게시된 항목을 포함합니다. 삭제된 항목, 슬러그가 없는 항목, noindex로 표시된 항목은 제외됩니다. 포함된 각 번역은 각각 고유한 <url> 항목이 됩니다. EmDash는 컬렉션의 urlPattern으로 경로를 만든 다음, Astro의 로케일 접두사와 사용자 지정 로케일 path 매핑을 적용합니다.
형제 번역은 xhtml:link alternate로 서로 연결되므로, 검색 엔진이 각 사용자에게 올바른 언어를 제공할 수 있습니다.
<url>
<loc>https://example.com/blog/hello</loc>
<lastmod>2026-05-28T16:33:15.461Z</lastmod>
<xhtml:link rel="alternate" hreflang="en" href="https://example.com/blog/hello" />
<xhtml:link rel="alternate" hreflang="fr" href="https://example.com/fr/blog/bonjour" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/blog/hello" />
</url>
형제 번역은 translation_group으로 묶이므로, 게시된 로케일 변형은 게시되고 색인 가능한 다른 모든 변형에 alternate로 나타납니다. i18n.locales에 없는 로케일은 Astro에 해당 라우트가 없으므로 생략됩니다. 로케일이 하나뿐인 사이트는 xhtml 네임스페이스가 없는 일반 사이트맵을 생성합니다.
페이지 헤드에 hreflang 링크 추가
같은 alternate는 모든 콘텐츠 페이지의 <head>에도 있어야 합니다. 레이아웃에서 <EmDashHead>를 사용한다면 이 작업이 자동으로 이루어집니다. i18n이 활성화되어 있고 페이지 컨텍스트에 content가 포함되어 있으면, 게시된 형제 번역마다 하나의 <link rel="alternate">를 출력하며, Google이 권장하는 자기 참조 링크도 포함하고, 거기에 x-default가 더해집니다.
<link rel="alternate" hreflang="en" href="https://example.com/blog/hello" />
<link rel="alternate" hreflang="fr" href="https://example.com/fr/blog/bonjour" />
<link rel="alternate" hreflang="x-default" href="https://example.com/blog/hello" />
직접 작성한 head에서는 getHreflangAlternates로 alternate를 해석하세요.
---
import { getEmDashEntry, getHreflangAlternates } from "emdash";
const { entry, error } = await getEmDashEntry("posts", Astro.params.slug, {
locale: Astro.currentLocale,
});
if (error) return new Response("Server error", { status: 500 });
if (!entry) return Astro.rewrite("/404");
const alternates = await getHreflangAlternates("posts", entry.data.id, {
siteUrl: Astro.url.origin,
});
---
<head>
{alternates.map((a) => <link rel="alternate" hreflang={a.hreflang} href={a.href} />)}
</head>
동작은 사이트맵과 동일합니다.
- **
x-default**는 기본 로케일 변형을 가리킵니다. 기본 로케일에 게시된 번역이 없으면 라우팅 가능한 첫 번째 변형으로 폴백하므로, 이 집합에x-default가 빠지는 일은 없습니다. - 게시되지 않은 형제 번역은 제외됩니다 — 초안 번역은 alternate에 노출되지 않습니다.
noindex형제 번역은 제외됩니다. 현재 항목이noindex이면 alternate가 반환되지 않습니다.- 라우팅할 수 없는 로케일은 제외됩니다. 로케일이 구성된
i18n.locales에 없는 행은 제공할 수 없으며, 검색 엔진을 404로 연결하는 것은 링크가 없는 것보다 나쁩니다. - 번역되지 않은 항목도 i18n이 활성화되어 있으면 사이트맵과 마찬가지로 자기 참조 alternate와
x-default를 가집니다. - i18n이 비활성화되어 있으면 결과가 비어 있고 쿼리도 실행되지 않습니다.
URL은 컬렉션의 urlPattern으로 만들어지고 Astro i18n 설정을 통해 현지화됩니다. getHreflangAlternates()에는 절대 사이트 URL이 필요합니다. 호출에 전달된 siteUrl 또는 사이트 설정의 URL을 사용하며, 둘 다 없으면 hreflang 링크는 절대 URL이어야 하므로 빈 배열을 반환합니다.
다국어 콘텐츠 가져오기
관리자 마이그레이션 도구로 WordPress 콘텐츠를 가져오세요. 콘텐츠 가져오기와 WordPress에서 마이그레이션을 참조하세요. WXR 내보내기에는 WPML이나 Polylang이 추가하는 로케일 및 번역 그룹 구조가 포함되어 있지 않으므로, 가져온 콘텐츠는 기본 로케일에 들어갑니다.
가져온 콘텐츠로 번역을 만들려면, 번역된 항목을 초안으로 만들고 원본 데이터베이스 ID에 연결하세요.
emdash content create posts \
--locale fr \
--translation-of 01ABC... \
--slug mon-article \
--data '{"title":"Mon article"}' \
--draft
이는 시드 파일에서 사용하는 것과 동일한 --locale 및 --translation-of 관계이며, 가져오기가 끝난 후에 적용합니다.
다음 단계
- 콘텐츠 쿼리 — 전체 쿼리 API 참조
- 콘텐츠 작업 — 관리자 콘텐츠 관리
- Astro i18n 라우팅 — Astro의 라우팅 설정