@plott/webkit-i18n0.4.0
다국어 — 로케일 집합 하나(코드·BCP 47 태그·hreflang)에서 협상(Accept-Language·쿠키·?lang)·경로 접두·hreflang 대체 주소를 파생하고, 자리표시자·복수형(CLDR)·메시지 트리 검사·글자 방향(dirOf)을 준다. ICU 메시지(select·서수·통화·날짜·태그)는 /icu, Next proxy·서버 액션·화면 연동은 /next, 클라이언트는 /react
설치
pnpm add @plott/webkit-i18n@0.4.0진입점(exports)
| import | 타입 | 파일 |
|---|---|---|
@plott/webkit-i18n | ./dist/index.d.ts | ./dist/index.js |
@plott/webkit-i18n/icu | ./dist/icu.d.ts | ./dist/icu.js |
@plott/webkit-i18n/next | ./dist/next/index.d.ts | ./dist/next/index.js |
@plott/webkit-i18n/react | ./dist/react/index.d.ts | ./dist/react/index.js |
Next.js(App Router) 앱의 다국어 패키지입니다. 읽는 사람은 로케일 경로(/en/search)·언어 협상·번역 메시지를 붙이는 프론트엔드 개발자입니다. 숫자·날짜 형식은 @plott/webkit-format 이 합니다.
로케일 표 하나(코드·BCP 47 태그)를 적으면 언어 협상, 경로 접두, hreflang 대체 주소, 언어 이름, 메시지 검사가 거기서 나옵니다. 메시지는 앱이 갖는 평범한 객체(t.nav.home)이고, 자리표시자는 {name} 입니다.
런타임 의존성은 intl-messageformat(ICU 메시지 포맷 파서·서식, BSD-3-Clause) 하나이고 /icu 와 checkMessages({ syntax: "icu" }) 만 씁니다. {name} 자리표시자만 쓰면 이 라이브러리를 부르지 않습니다. /next 는 peer next >=16.4.1 <17, /react 는 peer react ^19.3 이 필요합니다.
진입점
| import | 어디서 | 주요 export |
|---|---|---|
@plott/webkit-i18n | 어디서나(proxy·서버·브라우저) | defineLocales, decideLocale, alternatesOf, fill·ParamsOf, plural·selectPlural·count, checkMessages, dirOf, LOCALE_HEADER·LOCALE_COOKIE |
@plott/webkit-i18n/icu | 서버·클라이언트 어디서나(React 를 import 하지 않음) | createIcu(t·rich), IcuError |
@plott/webkit-i18n/next | proxy·서버 액션·라우트 핸들러·메타데이터 | createLocaleProxy, localeParam, getRequestLocale, localeStaticParams, localeAlternates, localeSitemap |
@plott/webkit-i18n/react | 클라이언트 컴포넌트 | I18nProvider, useLocale, useMessages, fillNodes, rememberLocale |
붙이기
locales.ts 는 서버·클라이언트·proxy 가 같이 쓰며 next/* 를 import 하지 않습니다. locales 의 코드에서 태그를 만들고(zh-hant → zh-Hant), prefix: "as-needed" 는 기본 로케일만 접두 없이 둡니다(/search, /en/search).
import { defineLocales } from "@plott/webkit-i18n";
export const locales = defineLocales({ locales: ["ko", "en", "zh-hans", "zh-hant", "ja"], defaultLocale: "ko", prefix: "always",});export type Locale = (typeof locales.locales)[number];locales 는 세 모양을 받습니다. 태그가 코드와 다르면 { ko: "ko-KR", en: "en" }, hreflang·별칭까지 적으려면 { "zh-hant": { tag: "zh-Hant", hreflang: "zh-Hant", aliases: ["zh-Hans"] } } 입니다.
proxy.ts 는 인증 proxy 와 함께 씁니다. l.response 가 있으면 언어 협상·대소문자·태그 접두(/zh-TW) 보내기를 위한 응답이므로 그대로 돌려줍니다. l.next(headers) 는 주소의 로케일을 x-request-locale(태그) 헤더로 싣습니다.
import { createLocaleProxy } from "@plott/webkit-i18n/next";const locale = createLocaleProxy(locales);
export async function proxy(req: NextRequest) { const l = locale(req); if (l.response) return l.response; return auth.proxy(req, (headers) => l.next(headers));}app/[lang]/layout.tsx 에서 localeParam 은 우리 로케일이 아니면 404 로 보냅니다.
import { lang } from "next/root-params";import { localeParam, localeStaticParams } from "@plott/webkit-i18n/next";import { I18nProvider } from "@plott/webkit-i18n/react";
export const generateStaticParams = () => localeStaticParams(locales);
export default async function Layout({ children }) { const locale = localeParam(locales, await lang()); return ( <html lang={locales.tagOf(locale)}> <body> <I18nProvider locale={locale} tag={locales.tagOf(locale)} messages={messages[locale]}>{children}</I18nProvider> </body> </html> );}서버 액션은 root params 를 읽지 못하므로 getRequestLocale 이 proxy 가 실은 헤더에서 읽습니다.
import type { LocaleSet } from "@plott/webkit-i18n";import { getRequestLocale } from "@plott/webkit-i18n/next";
declare const locales: LocaleSet<"ko" | "en" | "zh-hans" | "zh-hant" | "ja">;
export const locale = await getRequestLocale(locales);업스트림 API 에는 @plott/webkit-api/next 의 locale({ from: LOCALE_HEADER }) 플러그인이 같은 헤더(x-request-locale, LOCALE_HEADER)를 읽어 Accept-Language: zh-Hant 처럼 태그로 넘깁니다.
메시지
messages/ko.ts 가 기준 트리이고, en.ts 는 export const en: Messages = … 로 같은 모양을 지킵니다.
import { plural } from "@plott/webkit-i18n";export const ko = { nav: { hello: "{name}님, 안녕하세요" }, unit: { nights: plural({ other: "{count}박" }) },};export type Messages = typeof ko;fill 은 글자만 끼우고, fillNodes 는 노드(굵게·링크)를 끼웁니다. count(tag, nights, t.unit.nights) 는 로케일에 맞는 복수형을 고릅니다(ko 는 3박, en 이면 3 nights).
import { count, fill, type Plural } from "@plott/webkit-i18n";import { useMessages } from "@plott/webkit-i18n/react";
type Messages = { nav: { hello: string }; unit: { nights: Plural } };declare const name: string;declare const tag: string;declare const nights: number;
const t = useMessages<Messages>();fill(t.nav.hello, { name });count(tag, nights, t.unit.nights);fillNodes(t.price.total, { amount: <b>{f.currency(n)}</b> });메시지 트리 검사는 앱의 테스트(test/i18n.test.ts)에 둡니다.
import { checkMessages, type LocaleSet } from "@plott/webkit-i18n";import { expect } from "vitest";
type Tree = { [key: string]: string | Tree };declare const locales: LocaleSet<"ko" | "en" | "ja">;declare const ko: Tree;declare const en: Tree;declare const ja: Tree;
expect(checkMessages({ locales, base: "ko", trees: { ko, en, ja }, emptyAllowed: ["en:legal.koreanOnlyNotice"] })).toEqual([]);빠진 키, 남는 키, 빈 값, 원문과 다른 자리표시자, 그 언어에 필요한 복수 범주(en 의 one, ru 의 few·many)가 빠졌거나 쓰이지 않는 범주가 있는 것, 낡은 emptyAllowed 를 잡습니다.
fill 은 문장이 리터럴이면 타입이 자리표시자 이름을 읽어, 값이 빠지면 컴파일에서 막습니다(ParamsOf<S>). 런타임은 같습니다.
import { fill } from "@plott/webkit-i18n";
declare const name: string;declare const count: number;declare const t: { cart: { summary: string } };
// @ts-expect-errorfill("{name}님 {count}개", { name });fill(t.cart.summary, { name, count });첫 호출은 count 가 없어서 타입 오류입니다. 둘째처럼 문장이 넓은 string 이면 아무 객체나 받으며, 이름 검사는 checkMessages 가 합니다.
글자 방향
<html lang={locales.tagOf(locale)} dir={locales.dirOf(locale)}>import { dirOf } from "@plott/webkit-i18n";
dirOf("ku-Arab");dirOf("ku");아랍어·히브리어·페르시아어·우르두어는 "rtl" 입니다. dirOf("ku-Arab") 는 "rtl", dirOf("ku") 는 "ltr" 입니다(쿠르드어의 기본 표기는 라틴).
Intl.Locale.getTextInfo 는 쓰지 않습니다(엔진마다 있고 없어 서버와 브라우저가 다를 수 있습니다). 표기 체계(아랍·히브리·타나·시리아·응코·아들람·로힝야)로 정하고, 표기 체계를 알 수 없는 언어만 작은 언어 표를 봅니다. 태그에 표기 체계를 적었으면 그것이 이깁니다(az-Arab rtl, ku-Latn ltr). 틀린 태그는 "ltr" 입니다.
ICU 메시지 — @plott/webkit-i18n/icu
성별(select), 서수(selectordinal), 숫자·통화({n, number, ::currency/KRW}), 날짜({d, date, short}), 굵게·링크(<b>…</b>)가 필요한 곳에서 씁니다. 단순 자리표시자는 fill 로 충분합니다.
import type { LocaleSet } from "@plott/webkit-i18n";import { createIcu, type IcuMessageTree } from "@plott/webkit-i18n/icu";
type Locale = "ko" | "en";declare const locales: LocaleSet<Locale>;declare const locale: Locale;declare const messages: Record<Locale, IcuMessageTree>;
const icu = createIcu({ locale: locales.tagOf(locale), messages: messages[locale], timeZone: "Asia/Seoul",});
icu.t("cart.total", { price: 51000 });icu.t("rank", { n: 3 });icu.rich("terms", { link: (chunks) => <a href="/terms">{chunks}</a> });| 옵션 | 뜻 |
|---|---|
locale | BCP 47 태그 |
messages | 점 경로(nav.home)로 찾는 일반 객체 |
timeZone | 필수이며 기본값이 없습니다 |
formats | 이름 붙인 서식. 예: { number: { won: { style: "currency", currency: "KRW" } } } 를 주면 {n, number, won} 을 씁니다 |
onError | (error, { key, locale }) => string. 예: (error) => { report(error); return ""; } |
| 호출 | 결과 |
|---|---|
icu.t("cart.total", { price: 51000 }) | ₩51,000 |
icu.t("rank", { n: 3 }) | 메시지가 {n, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} 이면 3rd |
icu.rich("terms", …) | 메시지가 <link>이용약관</link>에 동의합니다 이면 [<a>이용약관</a>, "에 동의합니다"] |
rich 는 글자와 콜백 결과가 이어진 배열을 돌려줍니다. React 를 import 하지 않으므로 React 에서는 Children.toArray(icu.rich(...)) 로 key 를 붙여 그리거나, 콜백이 key 를 가진 요소를 돌려줍니다. 이웃한 글자는 한 조각으로 합쳐집니다.
메시지 안의 작은따옴표는 ICU 의 이스케이프 문자입니다('{' 는 글자 {). don't 처럼 뒤에 { 가 오지 않으면 그대로 글자입니다.
ICU 로 쓰는 메시지 트리의 검사는 syntax: "icu" 를 줍니다.
import { checkMessages, type LocaleSet } from "@plott/webkit-i18n";import { expect } from "vitest";
type Tree = { [key: string]: string | Tree };declare const locales: LocaleSet<"ko" | "en" | "ru">;declare const ko: Tree;declare const en: Tree;declare const ru: Tree;
expect(checkMessages({ locales, base: "ko", trees: { ko, en, ru }, syntax: "icu" })).toEqual([]);문법 오류(icu-parse), 로케일끼리 인자 이름·쓰임이 다른 것(icu-arguments), select·plural 가지가 모자라거나 모르는 가지가 있는 것(icu-branches: 그 언어에 필요한 복수 범주, 원문의 =0, 원문의 select 값)을 잡습니다. 원문의 {n} 을 번역이 {n, plural, …} 로 풀어 쓰는 것은 허용합니다(ko 에는 복수형이 없고 en 에는 있습니다).
기본으로 지키는 것
- 주소의 로케일이 화면의 언어입니다. 쿠키는 «접두 없는 주소로 왔을 때 어디로 보낼까» 만 정합니다. 쿠키로 화면 언어를 정하면 모든 문구가 요청마다 쿠키에 달려 미리 그린 화면(정적 셸)이 생기지 않습니다.
- 접두 없는 주소의 언어는
?lang→ 쿠키(NEXT_LOCALE) →Accept-Language→ 기본 순으로 고릅니다. 표기 체계는 CLDR 로 채웁니다(zh-TW·zh-HK→ 번체,zh·zh-CN→ 간체). 손으로 적은 «TW 면 번체» 표가 없습니다.- q 값은 RFC 9110 모양(0~1, 소수 셋째 자리까지,
Q=도 받음)만 받습니다.q=0,q=1.5,q=0.5abc인 항목은 버립니다. 헤더는 앞쪽 4096자·64항목만 봅니다. - 같은 언어인데 표기 체계만 다른 로케일(간체 사용자 → 번체
zh-hant)은 기본으로 받습니다(ICU LocaleMatcher 와 같습니다). 받지 않으려면scriptFallback: false, 일부만 받으려면 그 로케일에aliases를 적습니다.
- q 값은 RFC 9110 모양(0~1, 소수 셋째 자리까지,
?lang=으로 고른 언어는 쿠키로 기억합니다. 쿠키가 다른 언어를 가리켜도 다음 접두 없는 주소에서 그 선택을 뒤집지 않습니다. 끄려면createLocaleProxy(locales, { rememberQuery: false }). 쿠키 속성의 기본은path앱 basePath,maxAge365일,sameSitelax,securehttps 일 때이며cookieOptions: { path, maxAge, sameSite, secure, domain }으로 바꿉니다(undefined값은 기본을 지우지 않습니다).- 보내기는 늘 같은 출처의 경로입니다.
//evil.example·/\evil.example·/ko//evil.example처럼 앞에 슬래시·역슬래시가 겹친 요청 경로는/하나로 접어 앱 경로로 읽습니다. 프로토콜 상대 주소(//host)를 그대로Location에 넣으면 다른 사이트로 가는 오픈 리다이렉트가 됩니다. proxy 는 응답을 만들기 직전에 출처를 한 번 더 확인합니다. - 보내기는 307 이고 캐시하지 않습니다(
private, no-store). 협상한 보내기에는Vary: Cookie, Accept-Language를 붙입니다. 308·301 은 브라우저·CDN 이 기억해 접두 규칙을 바꿀 수 없게 됩니다.as-needed의 접두 없는 화면 응답에도Vary를 붙입니다 — 같은 주소가 쿠키에 따라 화면이 되기도, 보내기가 되기도 합니다. - hreflang 이 겹치는 로케일 표는 만들 때 막습니다(
en-US·en-GB가 둘 다en). 한쪽에hreflang: "en-GB"를 적습니다. - 언어 태그 모양의 접두(
/zh-TW/…,/ko-KR)는 우리 코드로 보냅니다. 언어(+표기 체계)(+지역) 모양만 봅니다 —/en-guide같은 앱 경로를 언어 주소로 빼앗지 않습니다. - proxy 는 클라이언트가 보낸
x-request-locale을 덮어씁니다. - 런타임에 기준 언어로 메우지 않습니다. 빈 번역을 한국어로 채우면 번역이 안 된 화면이 조용히 섞여 나갑니다. 빈 값은
checkMessages가 막고, 정말 대응하는 말이 없는 키만emptyAllowed에 적습니다. 원문이 복수형인데 번역이 문장이면(또는 반대)shape로 알립니다. - basePath 앱:
getRequestLocale(locales, { basePath: "/shop" })로 Referer 경로에서 basePath 를 떼고 읽습니다.localeAlternates·localeSitemap의origin에는 basePath 까지 적습니다(https://www.example.com/shop). - 채우지 못한 자리표시자는 화면에 나가지 않습니다.
fill은 개발·테스트에서 던지고 운영에서 빈 글자로 둡니다. - 번역 문장에 HTML 을 넣지 않습니다. 서버 값을 끼운 번역 문장을
dangerouslySetInnerHTML로 그리면 XSS 입니다. 노드는fillNodes로 끼웁니다. createIcu의 날짜는 지정한 시간대로 그립니다(timeZone필수, 기본값 없음). 실행 환경의 시간대로 그리면 서버(UTC)와 브라우저(KST)가{d, date}에 다른 날짜를 냅니다. 스타일이 없는{d, date}와 스켈레톤(::yyyyMMdd)도 같은 시간대를 씁니다.- ICU 값은 글자로만 들어가고 태그는 앱이 준 콜백으로만 감쌉니다. 값에
<img onerror=…>나<b>가 들어 있어도 해석하지 않고 글자로 남습니다. 메시지에 있는 태그에 콜백이 없으면 오류입니다(t로 태그가 든 메시지를 부를 때도).innerHTML을 쓰지 않습니다. - ICU 실패는
fill과 같은 규칙입니다. 없는 키·없는 값·문법 오류·콜백 없는 태그·모양이 틀린 값(plural 에 글자, 인자에 함수·객체·Invalid Date)은 개발·테스트에서IcuError(code로 구분)를 던지고 운영에서는 메시지 전체가 빈 글자입니다.onError로 보고하고 대체 글자를 줄 수 있습니다. - ICU 는 실제로 고른 가지의 인자만 요구합니다.
{n, plural, =0 {없음} other {{name} 외 #명}}에{ n: 0 }만 줘도 «없음» 입니다. 고른 가지 안의 인자와 태그는 여전히 필수입니다. - ICU 인자의 종류를 검사합니다.
{n, number}에 숫자가 아닌 글자("abc")를 주면 조용한NaN이 아니라invalid-value입니다("1234"처럼Number()가 유한한 수로 읽는 글자는 받습니다).{d, date}·{t, time}은Date와 유한한 epoch 밀리초만 받고 ISO 글자("2026-11-03T…")는invalid-value입니다 — 이 패키지는 시간 패키지에 기대지 않으므로 호출하는 쪽이Date로 바꿔 넘깁니다. - 같은 이름을 인자와 태그로 함께 쓰지 못합니다.
{b} <b>x</b>는rich가 값 하나로 둘을 채울 수 없어, 컴파일 때IcuError(parse)입니다. fill의 자리표시자 이름은 글자·숫자·밑줄이고, 가장 안쪽{name}이 맞습니다.{한글}·{名前}이 채워지고{{a}}는a를 채웁니다. 타입(ParamsOf)도 같은 규칙으로 읽습니다. 타입은 글자 종류를 가를 수 없어 ASCII 공백·구두점이 든 이름({a b})만 자리표시자에서 뺍니다.placeholdersOf·checkMessages는 런타임과 같은 정규식을 씁니다.dirOf는ar_EG같은 밑줄 꼴도 읽습니다.- 메시지 키와 값의 키는 입력이 아닙니다.
Object.hasOwn으로만 따라가고(__proto__·constructor·toString은 키로도 값으로도 안 됩니다), 값은 상속 키 없는 객체로 옮깁니다. 컴파일한 메시지는 (로케일, 키)마다 한 번만 만들고, 없는 키는 캐시하지 않아 입력 키가 표를 키우지 않습니다. - 기본 로케일을 숨기지 않습니다.
I18nProvider밖에서useLocale·useMessages를 부르면 던집니다. 앱이 값을 정해 줘야 하는 훅(useLocale,@plott/webkit-format/react의useFormat)은 공급자 밖에서 던지고, 던지는 일이 앱 코드에 번지면 안 되는 계측 훅(@plott/webkit-analytics의useAnalytics)은 아무 일도 하지 않습니다. - hreflang 에 지역을 붙이지 않습니다(
ko,zh-Hant). 한 주소가 그 언어를 쓰는 모든 지역을 대표합니다.x-default는 접두 없는 주소입니다. - 언어 이름은 그 언어로 씁니다(
한국어,English). 영어 화면에서 «Korean» 이라 적으면 그 언어를 쓰는 사람이 자기 언어를 못 찾습니다.
그 밖
- 클라이언트 컴포넌트에서 메시지 트리 모듈을 값으로 import 하지 마세요(
import type만). 모든 로케일의 트리가 번들에 실립니다. 루트 레이아웃이 한 로케일만I18nProvider로 내려줍니다. - 키 경로와
{name}자리표시자는 ICU 메시지 포맷(next-intl 등)과 같은 모양이라, 나중에 라이브러리로 옮길 때 키를 바꾸지 않아도 됩니다. next-intl 도 Next 16.3 부터next/root-params로 정적 렌더를 지원합니다(https://next-intl.dev/docs/routing/setup). - 언어 전환 버튼은
locales.swapLocale(pathname, next)주소로 이동하고, 다음 방문을 위해rememberLocale(next)로 쿠키를 남깁니다.