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

更新


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

export type Locale = (typeof locales)[number];

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

これで URL で指定したロケールを $page.params.locale として読めるようになった。

LANG: ts
import { page } from '$app/stores';

$page.params.locale // returns 'en' | 'ja' | undefined

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

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

import { locale } from 'src/params/locales';

として得られる locale: Writable<"ja" | "en"> が現在のロケール設定になる。

src/params/locales.ts

LANG: ts
import { writable } from 'svelte/store';
import { browser } from '$app/environment';

...

// ブラウザ上であれば設定からロケールを読み取る
const defaultLocale = (!browser ? 'en' : getDefaultLocale(getLanguagesOnBrowser()));

/**
 * サーバー上では使わない
 */
export const locale = writable(defaultLocale);

/**
 * 対応するロケールのうちブラウザ設定で最も優先順位の高いものを返す
 * 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) => match(s)) as Locale;
}

function getLanguagesOnBrowser() {
  // https://qiita.com/shogo82148/items/548a6c9904eb19269f8c
  // ブラウザが受け付けている言語リストをブラウザ上で取得
  // ここでは window を参照可能
  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 [];
  }
}

レイアウトファイルで使用するロケールを設定

  • locale は Writable なので、.svelte ファイル内で値を読む際には $locale とする
  • トップレベルのレイアウトで children を {#key $locale} により囲んでいるため、$locale に変更があった際にはすべてが再レンダリングされる
  • URL に locale パラメータが含まれていればそれを locale に設定する
src/routes/[[locale=locales]]/+layout.svelte
LANG: html
<script lang="ts">
  import '../../app.css';
  import { locale, type Locale } from '$params/locales';
  import { page } from '$app/stores';

  if ($page.params.locale) {
    locale.set($page.params.locale as Locale);
  }
  const { children } = $props();
</script>

{#key $locale}
  {@render children()}
{/key}

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

ロケール限定表示タグ

LANG: html
<script lang="ts">
  import { JA, EN, I18n } from '$lib/translate';
</script>

<I18n>
<EN>An English text!</EN>
<JA>その日本語訳!</JA>
</I18n>

と書いた時にロケールに従ってどちらか一方が表示されるようにする。

実は現状では外側の <I18n> は必要ないのだけれど、ロケールを追加した場合などに翻訳を追加するところを探したり、翻訳が足りていない場合にデフォルトロケールで表示するような機能を追加する際にあった方が便利なので無駄に入れるようにしている。

src/lib/translate.ts

LANG: ts
export { default as I18n } from './components/translate-i18n.svelte';
export { default as JA } from './components/translate-ja.svelte';
export { default as EN } from './components/translate-en.svelte';

src/lib/components/translate-i18n.svelte

<script lang="ts">
  // 現状では何もせず子供を表示するだけのタグになっている
  import type { Snippet } from 'svelte';
  let { children }: { children: Snippet } = $props();
</script>

{@render children()}

src/lib/components/translate-ja.svelte

LANG: html
<script lang="ts">
  import { locale } from '$lib';
  import type { Snippet } from 'svelte';
  let { children }: { children: Snippet } = $props();
</script>

{#if $locale == 'ja'}{@render children()}{/if}

src/lib/components/translate-en.svelte

LANG: html
<script lang="ts">
  import { locale } from '$lib';
  import type { Snippet } from 'svelte';
  let { children }: { children: Snippet } = $props();
</script>

{#if $locale == 'en'}{@render children()}{/if}

文字列単位の翻訳

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

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

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

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

t とバッククオートでパラメータを埋め込むことも可能。

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

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

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

のようにも書けるようにしておく。

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

という形式も可。

これは単に長い文字列を書くのとあまり変わらないけど、 多分 prettier 的にこちらの方がいい感じになるはず?

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

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

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

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

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

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

src/lib/translate.ts

LANG:ts
/**
 * # 基本的な使い方
 *
 * ```
 * import { l, J, E, I18n } from '$lib/translate';
 *
 * # locale の値によって <> の前後どちらかを出力する
 * let lang = l('Japanese<>日本語');
 *
 * # パラメータを埋め込んでもいい
 * let str = l`Number ${n}<>${n}番目`;
 *
 * # 国際化文字列の連結
 * i18nCat('A<>い','B<>ろ','C<>は') // returns `ABC<>いろは`
 *
 * # 国際化文字列への国際化文字列の埋め込み
 * const inner = 'abc<>あいう';
 * const outer = `"${ln(0, inner)}"<>「${ln(1, inner)}」`; // '"abc"<>「あいう」'
 *
 * # 文字列ではなくタグで指定する場合はこう
 * <I18n><E>English Version</E><J>日本語版</J></I18n>
 * ```
 */
export { default as I18n } from './components/translate-i18n.svelte';
export { default as J } from './components/translate-ja.svelte';
export { default as E } from './components/translate-en.svelte';
import { locale, locales, type Locale } from '$params/locales';
import { get } from 'svelte/store';

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

/**
 * locales の順に delim で繋げて返す
 * 引数は配列を渡してもマッピングで渡してもいい
 */
export function i18n(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);
  }
}

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

/**
 * <> で区切られた文字列から現在の指定ロケールに対応するものを選んで出力する
 * localize の l のつもり。
 */
export function 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];
  }

  // <> で分けて指定のロケール番号のものを出力する
  // 対応する訳が見つからなければ番号0のものを出力
  const splitted = joined.split(delim, locales.length);
  const localeIndex = Math.min(splitted.length - 1, locales.indexOf(get(locale)));
  return splitted[localeIndex] || splitted[0];
}

関数 l は locale の切り替えに対応するため常に .svelte ファイル内で呼び出すようにする。

サーバー側ではロケール情報を保持していないので呼んだらダメ。

ユニットテスト

src/lib/translate.test.ts

LANG:ts
import { describe, it, expect } from 'vitest';
import { delim, i18n, i18nCat, l } from './translate';
import { locale } from '$params/locales';

describe('translate.delim', () => {
  it('デリミタは <>', () => {
    expect(delim).toBe('<>');
  });
});

describe('translate.l', () => {
  it('ロケールに従って文字列を選択する', () => {
    locale.set('en');
    expect(l`E<>J`).toBe('E');
    locale.set('ja');
    expect(l`E<>J`).toBe('J');
  });

  it('変数を埋め込める', () => {
    locale.set('en');
    expect(l`E${1}<>J${2}`).toBe('E1');
    locale.set('ja');
    expect(l`E${1}<>J${2}`).toBe('J2');
  });
});

describe('translate.ln', () => {
  it('数字で文字列を選択する', () => {
    expect(ln(0, 'E<>J')).toBe('E');
    expect(ln(1, 'E<>J')).toBe('J');
  });
  it('埋め込むのに使える', () => {
    const inner = 'abc<>あいう';
    expect(`"${ln(0,inner)}"<>「${ln(1,inner)}」`).toBe('"abc"<>「あいう」');
  });
});

describe('i18n', () => {
  it('配列から国際化文字列を作成する', () => {
    expect(i18n(['E', 'J'])).toBe(`E<>J`);
  });
  it('マッピングから国際化文字列を作成する', () => {
    expect(i18n({ en: 'E', ja: 'J' })).toBe(`E<>J`);
  });
});

describe('i18nCat', () => {
  it('国際化文字列を連結する', () => {
    expect(i18nCat('A<>い', 'B<>ろ', 'C<>は')).toBe(`ABC<>いろは`);
  });
});

言語切替ボタン

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

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

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

コメント・質問





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