본문으로 건너뛰기
언어

@plott/webkit-i18n0.4.0

다국어 — 로케일 집합 하나(코드·BCP 47 태그·hreflang)에서 협상(Accept-Language·쿠키·?lang)·경로 접두·hreflang 대체 주소를 파생하고, 자리표시자·복수형(CLDR)·메시지 트리 검사·글자 방향(dirOf)을 준다. ICU 메시지(select·서수·통화·날짜·태그)는 /icu, Next proxy·서버 액션·화면 연동은 /next, 클라이언트는 /react

설치

pnpm
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/nextproxy·서버 액션·라우트 핸들러·메타데이터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).

ts
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(태그) 헤더로 싣습니다.

ts
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 로 보냅니다.

tsx
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 가 실은 헤더에서 읽습니다.

ts
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 = … 로 같은 모양을 지킵니다.

ts
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).

ts
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);
tsx
fillNodes(t.price.total, { amount: <b>{f.currency(n)}</b> });

메시지 트리 검사는 앱의 테스트(test/i18n.test.ts)에 둡니다.

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>). 런타임은 같습니다.

ts
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 가 합니다.

글자 방향

tsx
<html lang={locales.tagOf(locale)} dir={locales.dirOf(locale)}>
ts
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 로 충분합니다.

ts
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 });
tsx
icu.rich("terms", { link: (chunks) => <a href="/terms">{chunks}</a> });
옵션뜻
localeBCP 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" 를 줍니다.

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" | "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 를 적습니다.
  • ?lang= 으로 고른 언어는 쿠키로 기억합니다. 쿠키가 다른 언어를 가리켜도 다음 접두 없는 주소에서 그 선택을 뒤집지 않습니다. 끄려면 createLocaleProxy(locales, { rememberQuery: false }). 쿠키 속성의 기본은 path 앱 basePath, maxAge 365일, sameSite lax, secure https 일 때이며 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) 로 쿠키를 남깁니다.