국제화 (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이 자연스럽게 작동
  • 로케일별 게시 — 프랑스어를 초안으로 유지하면서 영어 버전 게시
  • 로케일별 리비전 — 각 번역이 자체 리비전 이력을 가짐
  • 단일 로케일 쿼리 — 목록 쿼리가 하나의 로케일에 대한 항목만 반환

번역된 콘텐츠 쿼리

단일 항목

getEmDashEntrylocale을 전달하여 특정 번역을 가져옵니다. 생략하면 요청의 현재 로케일(Astro의 i18n 미들웨어에서 설정)이 기본값입니다.

---
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>

폴백 체인

요청된 로케일에 콘텐츠가 없으면 EmDash는 Astro 구성에 정의된 폴백 체인을 따릅니다. fallback: { fr: "en" }의 경우:

  1. 요청된 로케일(fr) 시도
  2. 폴백 로케일(en) 시도
  3. 기본 로케일 시도

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

메뉴

메뉴는 로케일별입니다 — 같은 name(예: "primary")이 여러 로케일에 존재할 수 있으며, 모두 공유 translation_group을 통해 연결됩니다. 메뉴 항목은 활성 로케일의 참조된 콘텐츠 버전에 대해 콘텐츠 참조를 해석합니다.

다음 컴포넌트는 활성 로케일의 기본 메뉴를 가져옵니다:

---
import { getMenu } from "emdash";

const menu = await getMenu("primary", { locale: Astro.currentLocale });
---

<nav aria-label="기본">
  <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.id, undefined, {
  locale: Astro.currentLocale,
});
---

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

컬렉션 목록

로케일로 컬렉션 필터링:

---
import { getEmDashCollection } from "emdash";

const { entries: posts } = await getEmDashCollection("posts", {
  locale: Astro.currentLocale,
  status: "published",
});
---

<ul>
  {posts.map((post) => (
    <li><a href={`/${post.data.slug}`}>{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);
---

<nav aria-label="언어">
  <ul>
    {translations.map((t) => (
      <li>
        <a
          href={getRelativeLocaleUrl(t.locale, `/blog/${t.slug}`)}
          aria-current={t.locale === Astro.currentLocale ? "page" : undefined}
        >
          {t.locale.toUpperCase()}
        </a>
      </li>
    ))}
  </ul>
</nav>

getTranslations 함수는 같은 번역 그룹의 모든 로케일 변형을 반환합니다:

const { translationGroup, translations } = await getTranslations("posts", post.entry.id);
// translations: [
//   { locale: "en", id: "01ABC...", slug: "my-post", status: "published" },
//   { locale: "fr", id: "01DEF...", slug: "mon-article", status: "draft" },
// ]

관리자에서 번역 관리

콘텐츠 목록

i18n이 활성화되면 콘텐츠 목록에 다음이 표시됩니다:

  • 각 항목의 로케일을 표시하는 로케일 열
  • 로케일 간 전환을 위한 도구 모음의 로케일 필터

번역 생성

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

  • “번역” 은 번역이 없는 로케일에 표시 — 클릭하여 생성
  • “편집” 은 기존 번역이 있는 로케일에 표시 — 클릭하여 이동
  • 현재 로케일은 체크 표시로 표시됨

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

로케일별 게시

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

콘텐츠 API

로케일 매개변수

모든 콘텐츠 API 라우트는 선택적 locale 쿼리 매개변수를 수락합니다:

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

생략하면 구성된 기본 로케일이 기본값입니다.

API를 통한 번역 생성

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

POST /_emdash/api/content/posts
Content-Type: application/json

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

새 항목은 소스 항목의 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...

다국어 콘텐츠 시딩

시드 파일은 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는 로케일 URL을 관리하지 않습니다 — Astro가 라우팅을 처리합니다. 일반적인 패턴:

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

# prefix-always
/en/blog/my-post       → en
/fr/blog/mon-article   → fr

라우팅 모드에 관계없이 올바른 URL을 구축하려면 astro:i18ngetRelativeLocaleUrl을 사용하세요.

사이트맵

/sitemap-{collection}.xml의 컬렉션별 사이트맵은 로케일을 인식합니다. i18n이 활성화되면 각 번역이 자체 <url> 항목으로 출력되며, 로케일 접두사는 Astro의 getRelativeLocaleUrl을 통해 해석됩니다. prefixDefaultLocale 설정과 사용자 정의 로케일 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으로 그룹화되므로 나중에 추가된 행(기존 게시물의 새 로케일 변형)이 다른 모든 변형에서 자동으로 대체로 나타납니다. 단일 로케일 사이트는 xhtml 네임스페이스 없이 일반 사이트맵을 생성합니다.

페이지 head의 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" />

수동으로 만든 head의 경우 getHreflangAlternates로 대체를 해석합니다:

---
import { getEmDashEntry, getHreflangAlternates } from "emdash";

const { entry } = await getEmDashEntry("posts", Astro.params.slug);
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가 빠지지 않습니다.
  • 미게시 형제는 제외됩니다 — 초안 번역이 대체에 노출되지 않습니다.
  • 라우팅 불가능한 로케일은 삭제됩니다. 구성된 i18n.locales에 없는 로케일의 행은 서비스할 수 없으며, 검색 엔진을 404로 연결하는 것은 링크가 없는 것보다 나쁩니다.
  • 번역되지 않은 항목도 i18n이 활성화되면 사이트맵을 미러링하여 자기 참조 대체와 x-default를 받습니다.
  • i18n 비활성화 시 결과는 비어 있고 쿼리가 실행되지 않습니다.

URL은 컬렉션의 urlPattern에서 구축되고 Astro의 i18n 라우팅 구성(prefixDefaultLocale, 사용자 정의 로케일 path 매핑)을 통해 현지화되므로 head와 사이트맵이 항상 일치합니다.

다국어 콘텐츠 가져오기

관리자 마이그레이션 도구를 통해 WordPress 콘텐츠를 가져옵니다 — 콘텐츠 가져오기WordPress에서 마이그레이션을 참조하세요. WXR 내보내기는 WPML이나 Polylang이 추가하는 로케일 및 번역 그룹 구조를 포함하지 않으므로 가져온 콘텐츠는 기본 로케일에 배치됩니다.

가져온 콘텐츠에서 번역을 만들려면 번역 항목을 생성하고 원본에 연결합니다:

emdash content create posts --locale fr --translation-of 01ABC...

이것은 위의 다국어 콘텐츠 시딩에서 보여준 것과 동일한 --locale / --translation-of 워크플로우로, 가져오기 완료 후 적용됩니다.

다음 단계