locale-match/ devslab

// @devslab/locale-match

간체 중국어 독자에게
번체를 주지 않습니다

Accept-Languagenavigator.languages 매칭에 교체 가능한 스크립트 가드. 의존성 0, Node · Bun · Deno · Cloudflare Workers · 브라우저 어디서나. React · Vue · Nuxt 바인딩 포함.

직접 해보기 문서 npm i @devslab/locale-match

the problem

zh는 가장 중요한 것에 침묵합니다

대부분의 언어는 태그만으로 결정됩니다. ko는 한국어, de는 독일어 — 더 알 것이 없습니다. 중국어는 예외이고, 그것도 아주 큰 예외입니다. zh간체인지 번체인지 말하지 않는데, 양쪽 다 수억 명의 독자가 있습니다.

거의 모두가 쓰는 코드는 이렇게 틀립니다:

const base = tag.split('-')[0];       // 'zh-CN' → 'zh'
if (base === 'zh') return 'zh-TW';    // ...간체 독자가 번체를 받는다

그리고 그 뒤에 두 번째 함정이 있습니다. zh-CN을 거부해도 본토 브라우저는 이렇게 보냅니다:

Accept-Language: zh-CN,zh;q=0.9

방금 거부한 태그 바로 뒤의 맨 zh가 그것을 구제해서, 페이지는 여전히 번체로 돌아옵니다. 프로덕션에서 실제로 측정한 현상이고, 이 라이브러리는 그 두 사고에서 나왔습니다.

playground

헤더를 넣어 보세요

이 페이지에 실제로 로드된 라이브러리가 아래에서 그대로 돕니다. 결과뿐 아니라 왜 그렇게 됐는지를 태그별로 보여줍니다.

결과

    the rule

    두 층 규칙

    가드는 하나의 기본 언어에 대해 태그를 세 값으로 분류하고, 그 태그가 어디서 왔는지에 따라 다르게 적용합니다.

    태그의 출처zh의 의미
    선언된 단일 값
    ?lang= · 쿠키 · 저장된 설정
    수용 — 중국어를 달라고 했고 우리에겐 중국어가 있다
    순위 목록의 한 항목
    Accept-Language · navigator.languages
    거부 — 순위 목록에서는 모호한 항목이 정확한 항목을 구제한다

    중국어 가드는 당신이 쓴 supported 목록에서 자동으로 유도됩니다. 번체만 있으면 번체 가드가, 간체만 있으면 간체 가드가 설치되고, 양쪽 다 서비스하거나 중국어가 없으면 보호할 대상이 없으므로 가드도 없습니다. 방문자에 대한 추측이 아니라 당신이 직접 쓴 목록을 읽는 것입니다.

    usage

    쓰는 법

    @devslab/locale-match

    import { createLocaleResolver } from '@devslab/locale-match';
    
    const locales = createLocaleResolver({
      supported: ['ko', 'en', 'ja', 'zh-HK', 'zh-TW', 'fr'],
      fallback: 'ko',
    });
    
    // 서버 (Workers · Node · Request가 있는 어디든)
    const { locale } = locales.resolve({
      query: url.searchParams.get('lang'),
      cookieHeader: request.headers.get('cookie'),
      acceptLanguage: request.headers.get('accept-language'),
    });
    
    // 브라우저: ?lang= → localStorage → navigator.languages
    const { locale } = locales.resolveInBrowser();

    @devslab/locale-match-react

    <LocaleProvider supported={LOCALES} fallback="ko" initial={serverLocale}>
      <App />
    </LocaleProvider>
    
    const { locale, setLocale, supported, isFallback } = useLocale();

    감지는 렌더 중이 아니라 마운트 직후 이펙트에서 실행됩니다. 서버가 그린 HTML과 첫 클라이언트 렌더가 어긋나면 하이드레이션 불일치이기 때문입니다. 서버에서 확정한 값을 initial로 넘기면 그 한 프레임도 없습니다.

    @devslab/locale-match-vue · -nuxt

    // Vue 3
    app.use(createLocalePlugin({ supported: LOCALES, fallback: 'ko' }));
    
    // Nuxt — SSR에서 확정하므로 언어 깜빡임이 없습니다
    export default defineNuxtConfig({
      modules: ['@devslab/locale-match-nuxt'],
      localeMatch: { supported: LOCALES, fallback: 'ko' },
    });

    빌드 스텝 없이

    <script src="https://unpkg.com/@devslab/locale-match/dist/index.global.js"></script>
    <script>
      const locales = LocaleMatch.createLocaleResolver({ supported: [...], fallback: 'ko' });
      document.documentElement.lang = locales.resolveInBrowser().locale;
    </script>

    이 페이지가 바로 그렇게 동작합니다.

    extending

    다른 언어 가드 추가하기

    중국어는 거의 모두가 겪는 케이스라 내장했습니다. 하지만 메커니즘은 중국어 전용이 아닙니다 — 문자로 독자가 갈리는 언어라면 무엇이든 같은 방식으로 쓸 수 있습니다.

    import { defineScriptGuard } from '@devslab/locale-match';
    
    const serbianLatin = defineScriptGuard({
      language: 'sr',                        // 기본 서브태그, 소문자
      supported: /^sr-(latn|latin)\b/,       // 소문자화된 태그에 테스트됨
      unsupported: /^sr-(cyrl|cyrillic)\b/,
    });

    지역까지 덮으세요 — 사람들은 sr-Cyrl이 아니라 sr-RS라고 씁니다. 맨 태그는 분류하지 마세요 — 두 층 규칙이 처리합니다. 패턴을 고정하세요. 자세한 이유와 체크리스트는 README에 있고, 충분히 조사된 가드를 추가하는 PR을 환영합니다.

    why not something else

    기존 라이브러리를 쓰면 안 되나요?

    써도 됩니다. 이게 필요 없을 수도 있습니다.

    @formatjs/intl-localematcher 표준 알고리즘을 제대로 구현합니다. lookup은 옆걸음을 하지 않아 중국어 버그를 피하지만, 동시에 pt-PTpt-BR 대신 영어를 받습니다. best fit은 문자를 가로질러 매칭할 수 있습니다.
    i18next-browser-languagedetector 감지 순서를 제공합니다. 다만 load: 'languageOnly'는 지역을 잘라내서 이 라이브러리가 막으려는 바로 그 버그를 다시 만듭니다.
    next-intl · vue-i18n 완전한 i18n 프레임워크입니다. 이미 쓰고 있다면 그대로 두고, 협상 단계에만 이걸 쓰세요.

    이 라이브러리가 더하는 것은 어느 쪽도 결정해 주지 않는 부분입니다: 당신이 어떤 문자를 서비스하는가, 그리고 반대쪽을 요청한 독자를 어떻게 할 것인가.