svelte/svelte5手抜き国際化 の履歴(No.7)

更新


プログラミング/svelte

概要:英語版と日本語版を同時に開発したい

どうせ個人での開発なのでロケールファイルを切り出したりせず手抜きでやりたい

国際化とは言いつつ、さしあたり日本語版と英語版を作れればいい。

一応、多言語への対応の余地を残しておく。

実現したい機能

  • /some/path/to/file ならブラウザの設定を読んで自動で表示言語を選択
  • /ja/some/path/to/file や /en/some/path/to/file を読めば指定の言語で表示
  • 言語切替ボタン

を実現したい

ロケールの選択

パスにオプションパラメータ locale を含める

3つの URL で実質的に同じ内容を表示することになるので1つにまとめたい

  • /some/path/to/file
  • /ja/some/path/to/file
  • /en/some/path/to/file

これには、

src/routes/[[locale=locales]]/some/path/to/file

というパスにファイルを置けばよい。

  • [ [locale=locales] ] でオプショナルなパラメータを指定する ← https://learn.svelte.jp/tutorial/optional-params
  • lib/params/locales.ts の export const match: ParamMatcher で locale として渡せる文字列を限定する

lib/params/locales.ts

LANG: ts
import type { ParamMatcher } from '@sveltejs/kit';

/**
 * ロケール一覧
 * 最初のものがデフォルトになり、訳が存在しないときにも使われる
 */
 export const locales = ['en', 'ja'] as const;

/**
 * param が locales に含まれるか判定する
 */ 
export const match: ParamMatcher = (param) => {
  return (locales as readonly string[]).includes(param);
};

type Locale および i18n オブジェクトを用意

ロケールの変更をリアクティブに画面表示に反映するため svelte5 の $state や $derived を使う。

そのため src/lib/i18n.svelte.ts の拡張子は .ts ではなく .svelte.ts となっている。

  • src/params/locales.ts で定義した locales を再 export する
  • locales から type Locale を作成して export する ("en" | "ja" のような型になる)
  • i18n.locales (Locale[] 型) にブラウザのロケール設定や params.locale の値を反映して優先順位順に並べたロケールを保持する(リアクティブな $state)
  • i18n.locale は最も優先度の高いロケール(リアクティブな $derived(i18n.locales[0]))
  • i18n.setupLocales により i18n.locales を設定する
    • 現在の順位から param に指定されたものの順位を上げるよう働く
      • 初期値は src/params/locales.ts の locales
    • param.acceptLanguage はリクエストヘッダーの Accept-Language を指定する
    • param.locale は params.locale を指定する
    • param.locales は優先順位を直接指定したい場合に指定する

src/lib/i18n.svelte.ts

LANG: ts
import { locales } from '$params/locales';
import { arrayEqual, arrayUniq } from './utils';

export { locales } from '$params/locales'; // export しておく

/**
 * 選択可能なロケールを表す型
 */
// ['en', 'ja'] のような指定から 'en' | 'ja' のような型を得る
export type Locale = (typeof locales)[number];

class T_i18n {
  /**
   * ロケール優先順を設定する
   * @param acceptLanguage リクエストヘッダーの Accept-Language
   * @param locale 最優先する locale
   * @param locales 優先順位順に並べた locale
   */
  setupLocales(param: { acceptLanguage?: string | null; locale?: Locale; locales?: Locale[] } = {}) {
    const specified = (param.locales ?? []) as Locale[];
    if (param.locale) specified.unshift(param.locale);

    const acceptable = (param.acceptLanguage ?? '').split(',')
      .map((s) => s.toLowerCase().replace(/.*?([a-z]{2}).*/, '$1')) // 始めに現れる英文字2文字に置き換え
      .filter((s) => (locales as readonly string[]).includes(s)) as Locale[]; // Locale のみ残す

    // uniq で重複を除くことで優先順位順になる
    const newLocales = arrayUniq([
      ...specified, // 直接指定された
      ...acceptable, // ブラウザ設定
      ...this.locales, // 現在の設定
      ...locales, // アプリ提供順
    ]);

    // 不要なリアクティブ更新を誘起しないよう変更があった時のみ代入する
    if (!arrayEqual(this.locales, newLocales)) {
      this.locales = newLocales;
    }
  }

  /**
   * 優先順位順に並べ替えた対応ロケール一覧 (リアクティブ)
   */
  locales: Locale[] = $state([...locales]);

  /**
   * 現在の最優先ロケール設定 (リアクティブ)
   */
  locale = $derived(this.locales[0]);
}

export const i18n = new T_i18n();

hooks.server.ts で event.params.locale を参照する

サーバーサイドレンダリング(SSR)時や API 処理(メール送信等)で正しいロケールを選択できるよう、 hooks.server.ts でロケールを読み取り locals 経由で +layout.server.ts へ通知、そして PageData 経由で +layout.ts へ送り、実行環境がブラウザであれば i18n.setupLocales を再度呼び出す。

+server.ts の呼び出し時には +layout.ts は呼び出されないので、 hooks.server.ts でも i18n.setupLocales しておく必要がある。

src/hooks.server.ts

LANG: ts
import { type Handle, redirect } from '@sveltejs/kit';
import { i18n, type Locale } from '$lib/i18n.svelte';

export const handle: Handle = async ({ event, resolve }) => {
  i18n.setupLocales({ // ロケール優先順位を設定
    locale: event.params.locale as Locale,
    acceptLanguage: event.request.headers.get('Accept-Language'),
  });
  event.locals.locales = i18n.locales; // +layout.server.ts へ渡す

  const response = await resolve(event);

  return response;
};

src/app.d.ts

LANG: ts
declare global {
  namespace App {
    interface Locals {
      locales: import('$lib/i18n.svelte').Locale[];
    }
    interface PageData {
      locales: import('$lib/i18n.svelte').Locale[];
    }
  }
}

src/routes/+layout.server.ts

LANG: ts
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async () => {
  // hooks.server.ts から event.locals に渡された locales を
  // page.data.locales にそのまま受け渡す
  return { locales: event.locals.locales};
};

src/routes/+layout.svelte

LANG: html
<script lang="ts">
  import { browser } from '$app/environment';
  import { page } from '$app/stores';
  import { i18n, type Locale } from '$lib/i18n.svelte';

  const { children, data } = $props();

  if(browser) { // ブラウザ上であれば locales を初期化する
    i18n.setupLocales({
      locales: data.locales,
      locale: $page.params.locale as Locale | undefined,
    });
  }
</script>

{@render children()}

+layout.svelte で $page.params.locale を読み取る

path に locale が指定されると $page.params.locale にその値が入るので i18n.locale にその値を入れる ($page は rune で書き換えられずこれからも store のままなんだろうか?)

path に locale が指定されなければ defaultLocale に設定しなおす(これを忘れると最後の設定が残ってしまう)

この i18n.locale は $state になっているため変更はリアクティブに各コンポーネントに反映される

src/routes/[[locale=locales]]/+layout.svelte
LANG: html
<script lang="ts">
  import { page } from '$app/stores';
  import { i18n, type Locale } from '$lib/i18n.svelte';

  if ($page.params.locale) {
    i18n.locale = $page.params.locale as Locale;
  } else {
    i18n.locale = i18n.defaultLocale;
  }

  const { children } = $props();
</script>

{@render children()}

デフォルトロケールをブラウザ設定から読み取る

URL でロケールを指定されないときはブラウザ設定からユーザーが選択したロケールを読み取る

rune を使うのでファイル名は i18n.svelte.ts としている

src/lib/i18n.svelte.ts

LANG: ts
import { browser } from '$app/environment';
import { locales } from '$params/locales';

export { locales } from '$params/locales';  // export しておく
...

/**
 * 選択可能なロケールを表す型 ('en' | 'ja' の形)
 */
// ['en', 'ja'] のような指定から 'en' | 'ja' のような型を得る
export type Locale = (typeof locales)[number];
...

class T_i18n {
  /**
   * ブラウザからロケール指定を読み出す
   * サーバーで使うことは想定していないのでブラウザでなければ
   * デフォルト(最初の)ロケールに設定する
   */
  get defaultLocale() {
    return !browser ? locales[0] : getDefaultLocale(getLanguagesOnBrowser());
  }

  /**
   * 現在のロケール設定 (リアクティブ)
   */
  locale = $state(this.defaultLocale);
  ...
};

export const i18n = new T_i18n();

/**
 * 対応するロケールのうちブラウザ設定で最も優先順位の高いものを返す
 * Accept-Languages を , で split して渡す
 */
function getDefaultLocale(languages: readonly string[]) {
  // 対応する言語のうち最も優先順位の高いものを使う
  return [...languages, locales[0]] // default locale as the last resort
    .map((s) => s.toLowerCase().replace(/[^A-Za-z].*$/, '')) // 英文字以外が現れたら以降を削除
    .find((s) => (locales as readonly string[]).includes(s)) as Locale;
}

/**
 * ブラウザが受け付ける言語リストを取得する
 * window を参照可能であることを確認してから呼び出されると想定
 * https://qiita.com/shogo82148/items/548a6c9904eb19269f8c より
 */
function getLanguagesOnBrowser() {
  const navigator = window.navigator as {
    languages?: readonly string[];
    language?: string;
    userLanguage?: string;
    browserLanguage?: string;
  };

  if (navigator.languages) {
    return navigator.languages;
  }

  const language = navigator.language ?? navigator['userLanguage'] ?? navigator['browserLanguage'];

  if (language) {
    return [language];
  } else {
    return [];
  }
}

ロケールを使った翻訳機能の実現

以上で i18n.locale に適切なロケールが設定されているので、 この値を使って適切な言語で文面を表示するための機能を実現する。

ロケール限定表示タグ

+page.svelte

LANG: html
<script lang="ts">
  import { I18n, L } from '$lib/i18n.svelte';
</script>

<I18n>
  <L en>An English text!</L>
  <L ja>その日本語訳!</L>
</I18n>

と書くことでロケール設定に従ってどちらか一方が表示される。

適切な翻訳が与えられなかった場合(英語が選択されているのに日本語版しか提供されない場合など)は、提供された翻訳の中からロケール優先順位(現在はブラウザではなく locales に与えられた順番)の最も高いものが表示される。

どのロケールの翻訳が与えられているかを I18n タグが把握するために set/getContext を使っている。

L タグが addLocale で翻訳の与えられたロケールを I18n タグに知らせ、適切な翻訳が与えられていないことを I18n が判断した際には i18n.locale ではなく与えられた翻訳から選んだ候補を forcedLocale で L タグに知らせその言語で表示する。

変更をリアクティブにするため forcedLocale は $state 指定されている。

src/lib/i18n.svelte.ts

LANG: ts
...
export { default as I18n } from './components/i18n.svelte'; // 名前を付けて export
export { default as L } from './components/i18n-l.svelte';  // 名前を付けて export
...

src/lib/components/i18n-l.svelte

LANG: html
<script lang="ts">
  import { i18n, type Locale } from '$lib/i18n.svelte';
  import { objectKeys } from '$lib/utils';
  import { getContext, type Snippet } from 'svelte';

  // <L ja> のようにして与えられたロケール選択 (ja=true) を locales で受ける
  // 実は <L ja en> のように複数与えることもできて、このときは ja, en どちらでも表示されるようになる
  let { children, ...locales }: { children: Snippet } & { [loc in Locale]?: boolean } = $props();

  // 親コンポーネント I18n とやり取りする
  // addLocale(loc: Locale): void でどのロケールの翻訳が与えられているかを教える
  // forcedLocale: Locale | undefined が与えられていればその言語で表示する
  let parent = getContext<{addLocale: (loc: Locale)=> void, forcedLocale: Locale | undefined}>('I18n');
 
  // 翻訳候補を登録する
  $effect.pre(() => {
    objectKeys(locales).forEach((loc) => parent.addLocale(loc));
  });
</script>

<!-- 親から強制されればそれに、そうでなければ i18n.locale に合わせる -->
{#if parent.forcedLocale ? locales[parent.forcedLocale] : locales[i18n.locale]}
  {@render children()}
{/if}

src/lib/components/i18n.svelte

LANG: html
<script lang="ts">
  import { setContext, type Snippet } from 'svelte';
  import { i18n, locales, type Locale } from '$lib/i18n.svelte';

  const { children }: { children: Snippet} = $props();

  // ロケールを強制する際に使う
  let forcedLocale : Locale | undefined = $state(undefined);

  // 子要素の <L> タグとやり取りをするためにコンテキストを設定
  // $state 変数はそのままでは渡せないのでゲッターを渡す
  setContext('I18n', {
    addLocale,
    get forcedLocale() { return forcedLocale; },
  })

  // 与えられた翻訳選択肢を集める
  const givenLocales = [] as Locale[];

  // 子コンポーネントによるコールバック
  function addLocale(loc: Locale) {
    if(!givenLocales.includes(loc)) {
      givenLocales.push(loc);
    }
  }

  // 現在のロケールに合う候補がない場合
  // 与えられた翻訳の中から最も優先順位の高いものを使う
  // TODO: 本来はブラウザ設定で優先順位の高いものを使うべき
  $effect(()=>{
    if(!givenLocales.includes(i18n.locale)) {
      const first = locales.find((loc)=> givenLocales.includes(loc));
      if(forcedLocale != first) {
        forcedLocale = first; // reactive
      }
    }
  })
</script>

{@render children()}

src/lib/utils.ts

LANG:ts
...

/**
 * マッピング型のキーを型安全な形で取り出す関数
 * 参照: https://zenn.dev/ossamoon/articles/694a601ee62526
 *
 * @example
 * ```
 * for (const key of objectKeys(data)) {
 *   // key: keyof typeof data になっている
 * ```
 */
export function objectKeys<T extends { [key: string]: unknown }>(obj: T) {
  return Object.keys(obj) as (keyof T)[];
}

文字列単位の翻訳

LANG: html
<script lang="ts">
  import { i18n } from '$lib/i18n.svelte';
  const { l } = i18n;
  const n = 10;
</script>

<p>{ l('An English text!<>その日本語訳!') }</p>

<p>{ l`Number #{n}<>#{n}番目` }</p>

のように <> で区切って前後に英語と日本語を両方書いておき、 i18n.l という関数に渡すとロケール設定に従ってどちらかを表示する。

この i18n.l は $state でリアクティブになっているため i18n.locale の変更に従って表示が更新される。

i18n.l`${variable}` のようにバッククオートでパラメータを埋め込むことも可能。

2言語以上の場合も locales の順に訳語を並べればよい。

その場合順番を覚えておくのが面倒なので、それと文字列が長くなりすぎて表記しづらいので、

LANG: ts
const message: string = i18n.s({
  en: 'Hello',
  ja: 'こんにちは',
  fr: 'bonjour',
});

のようにも書けるようにしてある。

LANG: ts
const message: string = i18n.s([
  'Hello',
  'こんにちは',
  'bonjour',
]);

LANG: ts
const message: string = i18n.s(
  'Hello',
  'こんにちは',
  'bonjour',
);

という形式も可。

これらは単に長い文字列を + で繋げて書くのとあまり変わらないけれど、 多分 prettier 的にこちらの方がいい感じになるはず?あと、いざというときに後からデリミタを変えやすい。

LANG: ts
const message: string = [
  'Hello',
  'こんにちは',
  'bonjour',
].join('<>');

とはほぼ変わらないけど。

こうやって作った国際化文字列同士を連結してしまうと訳が分からなくなってしまうので i18n.cat も作った。

i18n.cat('A<>い','B<>ろ','C<>は') // returns `ABC<>いろは`

国際化文字列の中に国際化文字列を埋め込むときに困るので i18n.ln という関数を作り、 国際化文字列から n 番目を簡単に取り出せるようにした。

LANG: ts
const { ln } = i18n;
const inner = 'abc<>あいう';
const outer = `"${ln(0, inner)}"<>「${ln(1, inner)}」`; // '"abc"<>「あいう」'

この ln はサーバーからメールを送る際の言語を決めるのに使えそう。その場合には、

  • フォームの POST に隠しパラメータを含める <input type="hidden" name="locale" value="{locales.indexOf($locale)}" />
  • action にて ln(form.data.locale, i18nSubject) のようにして使う

のようにする。

上に示したコード部分も含めてここにソースコードを与える。

src/lib/i18n.svelte.ts

LANG:ts
/**
 * # 基本的な使い方
 *
 * ```
 * import { I18n, L, i18n } from '$lib/i18n.svelte';
 * const { l } = i18n;
 *
 * // ロケールを指定する(初期値はブラウザ環境に合わせて選ばれる)
 * i18n.locale = 'en';
 *
 * // locale の値によって <> の前後どちらかを出力する
 * let lang = l('Japanese<>日本語');
 *
 * // パラメータを埋め込んでもいい
 * let str = l`Number ${n}<>${n}番目`;
 *
 * // l の呼び出しは i18n.locale の変更に対してリアクティブになる
 * <h1>{ l`Number ${n}<>${n}番目` }</h1>
 *
 * // 国際化文字列の連結
 * i18nCat('A<>い','B<>ろ','C<>は') // returns `ABC<>いろは`
 *
 * // 国際化文字列への国際化文字列の埋め込み
 * const inner = 'abc<>あいう';
 * const outer = `"${ln(0, inner)}"<>「${ln(1, inner)}」`; // '"abc"<>「あいう」'
 *
 * // 文字列ではなくタグで指定する場合はこう
 * <I18n><L en>English Version</L><L ja>日本語版</L></I18n>
 * ```
 */

import { browser } from '$app/environment';
import { locales } from '$params/locales';

export { locales } from '$params/locales';  // export しておく
export { default as I18n } from './components/i18n.svelte';
export { default as L } from './components/i18n-l.svelte';

/**
 * 国際化文字列の各翻訳語を区切るデリミタ
 */
export const delim = '<>';

/**
 * 選択可能なロケールを表す型
 */
// ['en', 'ja'] のような指定から 'en' | 'ja' のような型を得る
export type Locale = (typeof locales)[number];

class T_i18n {
  /**
   * ブラウザからロケール指定を読み出す
   * サーバーで使うことは想定していないのでブラウザでなければ
   * デフォルト(最初の)ロケールに設定する
   */
  get defaultLocale() {
    return !browser ? locales[0] : getDefaultLocale(getLanguagesOnBrowser());
  }

  /**
   * 現在のロケール設定 (リアクティブ)
   */
  locale = $state(this.defaultLocale);

  /**
   * <> で区切られた文字列から現在の指定ロケールに対応するものを選んで出力する (リアクティブ)
   * localize の l のつもり。
   */
  l = $state((strings: TemplateStringsArray | string, ...args: { toString: () => string }[]) => {
    // 埋め込み変数を埋め込む
    const raw = typeof strings == 'string' ? [strings] : strings.raw;
    let joined = raw.length == 0 ? '' : raw[0];
    for (let i = 1; i < raw.length; i++) {
      joined += args[i - 1].toString() + raw[i];
    }

    // <> で分けて指定のロケール番号のものを出力する
    // 対応する訳が見つからなければ番号0のものを出力
    // TODO: 本来ならブラウザの優先順位を尊重すべきだ
    const splitted = joined.split(delim, locales.length);
    const localeIndex = Math.min(splitted.length - 1, locales.indexOf(this.locale));
    return splitted[localeIndex] || splitted[0];
  });

  /**
   * locales の順に delim で繋げて返す
   * 引数は配列を渡してもマッピングで渡してもいい
   */
  s(translation: string[] | { [loc in Locale]: string }) {
    if (locales.findIndex((loc) => Object.hasOwn(translation, loc)) >= 0) {
      // マッピング形式と仮定する
      return locales
        .map((loc) => (translation as { [loc in Locale]: string })[loc as Locale] || '')
        .join(delim);
    } else {
      // 配列形式
      return (translation as string[]).join(delim);
    }
  }

  /**
   * 国際化文字列を複数繋げるための関数
   */
  cat(...args: string[]) {
    // それぞれの文字列をロケール毎に分ける
    const list = [] as string[][];
    args.forEach((arg) => {
      list.push(arg.split(delim));
    });

    // 最大ロケール数を求め、ロケール毎に処理する
    const result = [] as string[];
    for (let i = 0; i < list.reduce((n, item) => Math.max(n, item.length), 0); i++) {
      // ロケール毎に文字列を繋げる
      result.push(list.reduce((cat, item) => cat + (i < item.length ? item[i] : ''), ''));
    }
    return result.join(delim);
  }

  /**
   * 国際化文字列から n 番目の翻訳を取り出す
   * 国際化文字列内に国際化文字列を埋め込むときに使うことを想定している
   *
   * @example
   * ```
   * const inner = 'abc<>あいう';
   * const outer = `${ln(0, inner)}<>${ln(1, inner)}`; // 'abc<>あいう'
   * ```
   */
  ln(n: number, s: string) {
    const list = s.split(delim);
    return n < list.length ? list[n] : list[0];
  }
}

export const i18n = new T_i18n();

/**
 * 対応するロケールのうちブラウザ設定で最も優先順位の高いものを返す
 * Accept-Languages を , で split して渡す
 */
function getDefaultLocale(languages: readonly string[]) {
  // 対応する言語のうち最も優先順位の高いものを使う
  return [...languages, locales[0]] // default locale as the last resort
    .map((s) => s.toLowerCase().replace(/[^A-Za-z].*$/, '')) // 英文字以外が現れたら以降を削除
    .find((s) => (locales as readonly string[]).includes(s)) as Locale;
}

/**
 * ブラウザが受け付ける言語リストを取得する
 * window を参照可能であることを確認してから呼び出されると想定
 * https://qiita.com/shogo82148/items/548a6c9904eb19269f8c より
 */
function getLanguagesOnBrowser() {
  const navigator = window.navigator as {
    languages?: readonly string[];
    language?: string;
    userLanguage?: string;
    browserLanguage?: string;
  };

  if (navigator.languages) {
    return navigator.languages;
  }

  const language = navigator.language ?? navigator['userLanguage'] ?? navigator['browserLanguage'];

  if (language) {
    return [language];
  } else {
    return [];
  }
}

言語切替ボタン

i18n.locale を設定し /ja あるいは /en 付きのアドレスへ飛べばいい。

これでしばらくやってみよう

やってみて困ったことがあれば直す。

コメント・質問





Counter: 231 (from 2010/06/03), today: 7, yesterday: 4