국제화 (i18n)

이 페이지

EmDash는 Astro의 내장 i18n 라우팅과 통합하여 다국어 콘텐츠 관리를 제공합니다. Astro가 URL 라우팅과 로케일 감지를 처리하고, 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입니다. getTranslations()getEntryTerms()를 포함하여 저장된 콘텐츠 행을 참조하는 API 작업과 헬퍼에 사용합니다.

번역은 각 로케일이 별도의 행이므로 서로 다른 데이터베이스 ID를 가집니다. 공유된 translation_group은 행들이 동일한 콘텐츠의 번역임을 기록합니다. EmDash는 번역을 생성할 때 그 그룹을 관리합니다. 템플릿은 보통 그룹 내 임의의 행의 데이터베이스 ID만 필요합니다.

번역된 콘텐츠 쿼리

단일 항목

다국어 라우트에서 getEmDashEntryAstro.currentLocale을 전달합니다. 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.redirect("/404");
---

<article>
  <h1>{post.data.title}</h1>
</article>

폴백 체인

요청된 로케일에 일치하는 게시된 항목이 없으면, getEmDashEntry는 Astro 설정의 폴백 체인을 따릅니다. 미리보기 또는 비주얼 편집 모드에서는 동일한 조회가 초안을 반환할 수 있습니다. fallback: { fr: "en" }이 주어지면:

  1. 요청된 로케일 시도 (fr)
  2. 폴백 로케일 시도 (en)
  3. 체인에 아직 없는 경우 기본 로케일 시도

폴백은 단일 항목 쿼리에만 적용됩니다. 목록 쿼리는 요청된 로케일의 항목만 반환합니다.

각 폴백 조회는 동일한 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>

관리자의 메뉴 목록에서 기존 메뉴의 번역을 만듭니다 — 항목은 reference_id가 그대로(참조된 콘텐츠의 translation_group을 저장) 복제되므로 새 메뉴의 링크는 자동으로 올바른 로케일별 콘텐츠를 가리킵니다.

택소노미 (카테고리, 태그)

용어는 로케일별입니다. 정의(_emdash_taxonomy_defs)도 로케일별이므로 label / labelSingular도 번역할 수 있습니다. 피벗 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,
});
---

콘텐츠를 번역하면 소스의 용어 할당이 자동으로 상속됩니다 — 용어 자체를 한 번만 번역하면 되고, 그것을 사용하는 모든 게시물이 읽기 시점에 올바른 로케일로 해결됩니다.

택소노미 로케일 불일치 수정

관리자가 사이트 매니페스트를 로드할 때, EmDash는 택소노미 정의나 용어가 사이트의 구성된 i18n.locales에 없는 로케일을 사용하는 경우 서버 로그에서 경고합니다. 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이 활성화되면 콘텐츠 목록은 다음을 표시합니다:

  • 각 항목의 로케일을 표시하는 로케일 열
  • 로케일 간 전환을 위한 툴바의 로케일 필터

번역 생성

편집기에서 아무 콘텐츠 항목을 엽니다. 사이드바에 구성된 모든 로케일을 나열하는 번역 패널이 표시됩니다. 각 로케일에 대해:

  • **“Translate”**가 번역이 없는 로케일에 표시됩니다 — 클릭하여 생성
  • **“Edit”**가 기존 번역이 있는 로케일에 표시됩니다 — 클릭하여 이동
  • 현재 로케일은 체크 표시로 표시됩니다

번역을 생성할 때 새 항목은 소스 로케일의 데이터로 미리 채워지고 {소스-슬러그}-{로케일}의 기본 슬러그가 할당됩니다. 필요에 따라 슬러그와 콘텐츠를 조정한 다음 저장합니다.

로케일별 게시

각 번역은 고유한 상태를 가집니다. 번역을 독립적으로 게시, 게시 취소 또는 예약합니다. 프랑스어 버전은 초안이고 영어 버전은 라이브일 수 있습니다.

콘텐츠 API 사용

locale 매개변수

콘텐츠 API 라우트는 인증된 세션 또는 베어러 토큰이 필요합니다. 목록 라우트는 선택적 locale 쿼리 매개변수를 받습니다. 단일 항목 라우트도 경로가 슬러그를 사용할 때 받습니다. 데이터베이스 ID는 전역적으로 고유하며 로케일 모호성 해소가 필요하지 않습니다.

GET /_emdash/api/content/posts?locale=fr
GET /_emdash/api/content/posts/my-post?locale=fr

목록 요청이 locale을 생략하면 구성된 기본 로케일을 사용합니다.

API를 통한 번역 생성

콘텐츠 생성 엔드포인트에 localetranslationOf를 전달하여 번역을 생성합니다:

POST /_emdash/api/content/posts
Content-Type: application/json
X-EmDash-Request: 1

{
  "locale": "fr",
  "translationOf": "01ABC...",
  "slug": "mon-article",
  "data": {
    "title": "Mon Article"
  }
}

translationOfentry.data.id와 같은 소스 행의 데이터베이스 ID입니다. 새 항목은 소스 항목의 translation_group을 공유하고 초안으로 시작됩니다.

번역 나열

주어진 항목의 모든 번역을 검색합니다:

GET /_emdash/api/content/posts/01ABC.../translations

번역 그룹 ID와 각각의 ID, 슬러그, 상태를 가진 로케일 변형 배열을 반환합니다.

CLI 사용

CLI를 인증한 후 콘텐츠 명령에 --locale 플래그를 사용합니다:

# 프랑스어 게시물 나열
emdash content list posts --locale fr

# 특정 항목을 프랑스어로 가져오기
emdash content get posts my-post --locale fr

# 프랑스어 번역을 초안으로 생성
emdash content create posts \
  --locale fr \
  --translation-of 01ABC... \
  --slug mon-article \
  --data '{"title":"Mon article"}' \
  --draft

content create--data, --file 또는 --stdin의 입력이 필요합니다. --draft를 전달하지 않으면 생성 후 게시합니다.

다국어 콘텐츠 시딩

시드 파일은 localetranslationOf를 사용하여 번역을 표현합니다:

{
  "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와 같은 시스템 필드는 항상 로케일별이며 동기화되지 않습니다.

로케일 URL 구축

EmDash는 로케일을 저장하고, Astro는 공개 라우팅을 처리합니다. 지원되는 EmDash 설정은 기본 로케일을 접두사 없이 유지합니다:

# prefix-other-locales (Astro 기본값)
/blog/my-post          → en (기본 로케일, 접두사 없음)
/fr/blog/mon-article   → fr

astro:i18ngetRelativeLocaleUrl을 사용하여 올바른 접두사와 사용자 정의 로케일 경로 매핑을 추가합니다. 기본 로케일 접두사를 활성화하지 마세요. 로케일 구성에서 설명한 대로 해당 라우팅 전략은 주입된 관리자 페이지의 로딩을 방지합니다.

사이트맵

/sitemap-{collection}.xml의 컬렉션별 사이트맵은 로케일을 인식합니다. 라우팅 가능한 SEO 활성화 컬렉션의 게시된 항목을 포함합니다. 삭제된 항목, 슬러그가 없는 항목, noindex로 표시된 항목은 제외됩니다. 포함된 각 번역은 자체 <url> 항목이 됩니다. EmDash는 컬렉션의 urlPattern에서 경로를 구축한 다음 Astro의 로케일 접두사와 사용자 정의 로케일 path 매핑을 적용합니다.

번역 형제는 xhtml:link 대체로 교차 연결되어 검색 엔진이 각 사용자에게 올바른 언어를 제공할 수 있습니다:

<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으로 그룹화되므로 게시된 로케일 변형은 다른 모든 게시된 인덱스 가능한 변형의 대체로 나타납니다. i18n.locales에 없는 로케일은 Astro에 라우트가 없으므로 생략됩니다. 단일 로케일 사이트는 xhtml 네임스페이스 없이 일반 사이트맵을 생성합니다.

페이지 헤드에 hreflang 링크 추가

동일한 대체는 모든 콘텐츠 페이지의 <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" />

직접 작성한 헤드의 경우 getHreflangAlternates로 대체를 해결합니다:

---
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.redirect("/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가 없는 경우는 없습니다.
  • 게시되지 않은 형제는 제외됩니다 — 초안 번역은 대체에 누출되지 않습니다.
  • noindex 형제는 제외됩니다. 현재 항목이 noindex이면 대체가 반환되지 않습니다.
  • 라우팅 불가능한 로케일은 삭제됩니다. 구성된 i18n.locales에 없는 로케일의 행은 제공할 수 없으며, 검색 엔진을 404에 연결하는 것은 링크가 없는 것보다 나쁩니다.
  • 번역되지 않은 항목도 i18n이 활성화된 경우 사이트맵을 반영하여 자기 참조 대체와 x-default를 얻습니다.
  • i18n이 비활성화된 경우 결과는 비어 있고 쿼리가 실행되지 않습니다.

URL은 컬렉션의 urlPattern에서 구축되고 Astro i18n 설정을 통해 로컬라이즈됩니다. getHreflangAlternates()는 절대 사이트 URL이 필요합니다. 호출의 siteUrl 또는 사이트 설정 URL을 사용합니다. 둘 다 없으면 hreflang 링크가 절대적이어야 하므로 빈 배열을 반환합니다.

다국어 콘텐츠 가져오기

관리자 마이그레이션 도구를 통해 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 관계이며, 가져오기가 완료된 후 적용됩니다.

다음 단계