svelte/svelte5手抜き国際化

更新


プログラミング/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 '$lib/utils';

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

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

/**
 * 優先順位順に並べた locale
 */
let i18nLocales: Locale[] = $state([...locales]);

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

    if(param.url) { // param.url にロケール指定が含まれていれば param.locales の先頭に追加
      const m = (new URL(param.url)).pathname.match(new RegExp(`/(${locales.join('|')})(/|$)`));
      if(m) param.locales.unshift(m[1] as Locale);
    }

    if (param.locale) param.locales.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([
      ...param.locales, // 直接指定された
      ...acceptable, // ブラウザ設定
      ...i18nLocales, // 現在の設定
      ...locales, // アプリ提供順
    ]);

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

  /**
   * 優先順位順に並べ替えた対応ロケール一覧 (リアクティブ)
   */
  get locales() { return i18nLocales };

  /**
   * 現在の最優先ロケール設定 (リアクティブ)
   */
  get locale() { return i18nLocales[0] };
 ...
 
}

export const i18n = new T_i18n();

src/lib/utils.ts

LANG:ts
/**
 * 重複する要素を除いた配列を作って返す
 */
export function arrayUniq<T>(array: T[]) {
  const result: T[] = [];
  array.forEach((item) => {
    if (!result.includes(item)) {
      result.push(item);
    }
  });
  return result;
}

/**
 * 配列同士を比較する(メンバー同士の浅い比較)
 */
export function arrayEqual<T>(a: T[], b: T[]) {
  return a.length == b.length && !a.find((item, i) => item != b[i]);
}

src/hooks.server.ts で i18n.locales を設定する

action 処理やAPI 処理でもメール送信等の際に正しいロケールを選択できるよう hooks.server.ts で i18n.locales を設定する。

src/hooks.server.ts

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

export const handle: Handle = async ({ event, resolve }) => {
  i18n.setupLocales({ // ロケール優先順位を設定
    url: event.request.url, // ここでは event.params.locale は利用不可
    acceptLanguage: event.request.headers.get('Accept-Language'),
  });

  const response = await resolve(event);

  return response;
};

src/routes/+layout.svelte で i18n.locales を設定する

hooks.server.ts で行った設定はクライアント側に伝わらないため、browser 環境であればルートフォルダの +layout.svelte でも同様に設定を行う。

+layout.svelte ではリクエストヘッダーの Accept-Language にアクセスできないため、+layout.svelte.ts から PageData へ acceptLanguage を渡してそれを使う。

また $page.params.locale の変化のたびに i18n.setupLocales が呼び出されるよう subscribe する。

src/app.d.ts

LANG: ts
declare global {
  namespace App {
    interface PageData {
      acceptLanguage?: string;
    }
  }
}

src/routes/+layout.server.ts

LANG: ts
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async () => {
  return { 
    acceptLanguage: event.request.headers.get('Accept-Language'),
  };
};

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) {
    i18n.setupLocales({
      acceptLanguage: data.acceptLanguage
    })
  }

  // ページ切替のたび
  page.subscribe(($page)=>
    i18n.setupLocales({
      locale: $page.params.locale as Locale | undefined,
    })
  );
</script>

{@render children()}

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

以上で i18n.locales にロケール優先順位が設定されているので、 この値を使って適切な言語で文面を表示する機能を提供する。

ロケール選択表示タグ

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

と書くことでロケール設定に従って複数の翻訳のうち最も優先順位が高いものが表示される。

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

どのロケールの翻訳が与えられているかを I18n タグが把握するために、また、最終的にどの翻訳が採用されたかを L タグが把握するために、set/getContext を使っている。

L タグが localeIsGiven で翻訳の与えられたロケールを I18n タグに知らせ、I18n が最も適切なロケールを選択して localeSelected で L タグに知らせる。

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

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

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

  /** I18n コンテキストで受け渡されるデータ型 */
  export class I18nContext {
    /** 与えられた翻訳選択肢を子コンポーネントがここに登録する */
    localesGiven: { [loc in Locale]?: boolean } = $state({});
    /** 与えられた翻訳のうち i18n.locales の優先順位が最も高いものを返す */
    get localeSelected() {
      return i18n.locales.find((l) => this.localesGiven[l]) ?? i18n.locale;
    }
  }
</script>

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

  /**
   * Example:
   * ```
   *  <script lang='ts'>
   *    import I18n from 'I18n.svelte';
   *    import L from 'I18n-L.svelte';
   *  </ script>
   *
   *  <I18n>
   *    <L ja>こんにちは</L>
   *    <L en>Hello</L>
   *  </I18n>
   * ```
   */

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

  // 子要素の <L> タグとやり取りをするためにコンテキストを設定
  setContext('I18n', new I18nContext());
</script>

{@render children()}

src/lib/components/I18n-l.svelte

LANG:ts
<script lang="ts">
  import { getContext, type Snippet } from 'svelte';
  import type { I18nContext } from './I18n.svelte';

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

  // 親コンポーネント <I18n> とやり取りする
  // localesGiven により <L> から <I18n> へどのロケールの翻訳が与えられているかを教える
  // localeSelected で <I18n> から <L> へ表示言語を指定する
  let I18n = getContext<I18nContext>('I18n');

  // 翻訳候補を登録する
  Object.assign(I18n.localesGiven, locales);
</script>

<!-- 指定された言語に対応していれば表示 -->
{#if locales[I18n.localeSelected]}
  {@render children()}
{/if}

文字列単位の翻訳

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> <!-- 本来はこれで動くのだけれど svelte5 のバグで今は動かない -->

<p>{ l(`Number #{n}<>#{n}番目`) }</p> <!-- 現状ではこちらが安全 -->

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

この i18n.l はリアクティブな i18n.locales を参照しているのでその変更に従って表示が更新されることになる。

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

LANG:ts
const { l } = i18n;

のように取り出して、

{l`${variable}`}

のように使うと現状ではリアクティブにならなくなってしまう。

これは svelte5 のバグなのでいずれ解消するはずだが、
https://github.com/sveltejs/svelte/issues/12687

{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.delim);

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

こうやって作った国際化文字列同士を普通に + で連結してしまうと壊れてしまうので 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"<>「あいう」'

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

src/lib/i18n.svelte.ts

LANG:ts
/**
 * # 基本的な使い方
 *
 * ```
 * import { I18n, L, i18n } from '$lib/i18n.svelte';
 * const { l } = i18n;
 *
 * // ロケール優先順位を指定する(初期値はブラウザ環境に合わせて選ばれる)
 * i18n.setupLocale({locale: 'en'});
 * i18n.setupLocale({locales: ['ja','en']});
 * i18n.setupLocale({acceptLanguage: 'en-EN, ja-JP'});
 * i18n.setupLocale({url: '/ja/path/to/file'});
 *
 * // locale の値によって <> で区切った locale 番号番目を出力する
 * let lang = l('Japanese<>日本語');
 *
 * // パラメータを埋め込んでもいいのだが、この形は現状では svelte5 
 * // のバグでリアクティブでなくなってしまう
 * let str = l`Number ${n}<>${n}番目`;
 *
 * // i18n. を略さず書く、あるいは ( ) で囲めば大丈夫
 * let str1 = i18n.l`Number ${n}<>${n}番目`;
 * let str2 = 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 { locales } from '$params/locales';
import { arrayEqual, arrayUniq } from './utils';

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

/**
 * 優先順位順に並べた locale
 */
let i18nLocales: Locale[] = $state([...locales]);

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

    if(param.url) { // param.url にロケール指定が含まれていれば param.locales の先頭に追加
      const m = (new URL(param.url)).pathname.match(new RegExp(`/(${locales.join('|')})(/|$)`));
      if(m) param.locales.unshift(m[1] as Locale);
    }

    if (param.locale) param.locales.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([
      ...param.locales, // 直接指定された
      ...acceptable, // ブラウザ設定
      ...i18nLocales, // 現在の設定
      ...locales, // アプリ提供順
    ]);

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

  /**
   * 優先順位順に並べ替えた対応ロケール一覧 (リアクティブ)
   */
  get locales() { return i18nLocales };

  /**
   * 現在の最優先ロケール設定 (リアクティブ)
   */
  get locale() { return i18nLocales[0] };

  /**
   * <> で区切られた文字列から現在の指定ロケールに対応するものを選んで出力する (リアクティブ)
   * localize の l のつもり。
   */

  readonly l = (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];
    }

    // <> で分けて最も優先順位の高いものを出力する
    const splitted = joined.split(delim, this.locales.length);
    const index = this.locales // 優先順位順のロケールを
      .map((loc) => locales.indexOf(loc)) // ロケール番号に直し
      .find((i) => i < splitted.length)!; // 翻訳が与えられた最初の番号を返す
    return splitted[index];
  };

  /**
   * locales の順に delim で繋げて返す
   * 引数は配列を渡してもマッピングで渡してもいい
   */
  readonly s = (...translations: string[] | [string[]] | [{ [loc in Locale]: string }]) => {
    let translation: string[] | { [loc in Locale]: string };
    if (translations.length == 0 || typeof translations[0] == 'string') {
      translation = translations as string[];
    } else {
      translation = translations[0];
    }

    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);
    }
  }

  /**
   * 国際化文字列を複数繋げるための関数
   */
  readonly 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<>あいう'
   * ```
   */
  readonly ln = (n: number, s: string) => {
    const list = s.split(delim);
    return n < list.length ? list[n] : list[0];
  }
}

export const i18n = new T_i18n();

言語切替ボタン

単に /ja あるいは /en 付きのアドレスへ飛べばいい。

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

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

コメント・質問





Counter: 296 (from 2010/06/03), today: 5, yesterday: 3