Paraglide JS + Svelte 5 ベストプラクティス は何をしますか?
ロケールは、リアクティブなアプリケーション状態ではなく、コンパイラによって解決されるリクエスト/ビルド状態として扱います。ロケールの切り替えは Paraglide の全ドキュメントナビゲーションに任せ、それ以外のすべては Svelte 5 のルーンに任せます。 Paraglide JS は SvelteKit の公式 i18n 統合です。messages/{locale}.json をツリーシェイカブルで完全に型付けされた ESM 関数にコンパイルし、ランタイムライブラリと比較して i18n バンドルを最大約 70% 削減します(公開ベンチマークでは、5 ロケール / 200 メッセージで約 47 KB 対約 205 KB)。非同期ウォーターフォールはなく、Svelte 5 コンポーネントに i18n プロバイダーを配線する必要もありません。
1. クイックリファレンス決定表
| 決定事項 | 推奨デフォルト | 理由 |
|---|---|---|
| インストール方法 | 新しい SvelteKit アプリで npx sv add paraglide="languageTags:en,de" | Vite プラグイン、フック、app.html 属性、.gitignore を 1 ステップでスキャフォールディング |
| パッケージ | @inlang/paraglide-js v2.x | v1 アダプター @inlang/paraglide-js-adapter-sveltekit は非推奨。コアパッケージを直接使用 |
| 戦略(SSR + ローカライズされた URL) | ["url", "cookie", "baseLocale"] | URL は SEO の信頼できる情報源。Cookie はサーバーから見える形でユーザー選択を保持。baseLocale は常に最後に解決 |
| 戦略(保存されたオーバーライドを追加) | ["localStorage", "cookie", "preferredLanguage", "url", "baseLocale"] | localStorage だけでは最初の SSR リクエストからは見えません。Cookie がサーバー/クライアントのギャップを埋める |
| ロケール切り替え動作 | デフォルトの setLocale(locale)(全ドキュメントナビゲーション) | フレームワークのリアクティビティでは <html lang>、dir、<title>、ローカライズされた URL は再レンダリングされません。ナビゲーションが行います |
| AsyncLocalStorage | 有効のまま(デフォルト) | Vercel Edge と Cloudflare Workers(Node 互換)がサポート。無効にすると、同時リクエスト間でロケールがリークするリスクがあります |
| TS 宣言 | emitTsDeclarations: true(TypeScript ≥ 5.6) | 開発中にキーが変更されたときに、Svelte + TS 言語サーバーを同期し続けます |
| メッセージキー | フラットで安定した、人間が読めるランダムキー(calm_green_otter) | 公式推奨。ネストされたキーも機能しますが、フラットキーが推奨されます |
| プリレンダリング | export const prerender = true + ロケールごとの entries() | SSG では、すべてのロケールが個別の URL ファイルに解決される必要があります |
| ロケールのリアクティビティ | デフォルトではコンポーネント内ではなし | getLocale() はリクエストスコープ。ロケールごとに新しいドキュメントがレンダリングされるため、ルーンがそれを追跡する必要はありません |
2. セットアップと設定(Paraglide 2.x + SvelteKit + Svelte 5)
2.1 最速のパス
npx sv create my-app
npx sv add paraglide="languageTags:en,de,demo:yes"
Svelte CLI アドオンは、inlang プロジェクト、Vite プラグイン、SvelteKit handle/reroute フック、app.html の %lang%/%dir%、オプションのデモページを設定します。
2.2 手動セットアップ(完全なボイラープレート)
プロジェクトをインストールしてスキャフォールディングした後、4 つのファイルを配線します。
vite.config.ts — 明示的な戦略を持つプラグイン:
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { paraglideVitePlugin } from '@inlang/paraglide-js';
export default defineConfig({
plugins: [
sveltekit(),
paraglideVitePlugin({
project: './project.inlang',
outdir: './src/lib/paraglide',
emitTsDeclarations: true,
strategy: ['url', 'cookie', 'baseLocale'],
}),
],
});
emitTsDeclarations: true はメッセージを編集するたびに .d.ts を再生成し、Svelte と TypeScript サーバーの同期を維持します。TypeScript 5.6+ が必要です(高速な JSDoc 推論パスの場合は false に設定)。
src/app.html — ハードコードされた lang を置き換える:
<!doctype html>
<html lang="%lang%" dir="%dir%">
src/hooks.server.ts — リクエストスコープのロケールを持つサーバーミドルウェア:
import type { Handle } from '@sveltejs/kit';
import { paraglideMiddleware } from '$lib/paraglide/server';
import { getTextDirection } from '$lib/paraglide/runtime';
const paraglideHandle: Handle = ({ event, resolve }) =>
paraglideMiddleware(event.request, ({ request: localizedRequest, locale }) => {
event.request = localizedRequest;
return resolve(event, {
transformPageChunk: ({ html }) =>
html.replace('%lang%', locale).replace('%dir%', getTextDirection(locale)),
});
});
export const handle: Handle = paraglideHandle;
ミドルウェアはロケールを検出し(戦略の順序に従う)、URL が検出されたロケールと一致しない場合にドキュメントリクエストをリダイレクトし、/de/about → /about を SvelteKit が認識する前にローカライズ解除し、リクエストを AsyncLocalStorage でラップして、getLocale() が同時リクエストごとに正しいロケールを返すようにします。
src/hooks.ts — ルーティング境界(hooks.server.ts ではなく hooks.ts である必要があります):
import type { Reroute } from '@sveltejs/kit';
import { deLocalizeUrl } from '$lib/paraglide/runtime';
export const reroute: Reroute = (request) => deLocalizeUrl(request.url).pathname;
// project.inlang/paraglide.config.ts
import { defineConfig } from '@inlang/paraglide-js';
export default defineConfig({
outdir: './src/lib/paraglide',
strategy: ['url', 'cookie', 'baseLocale'],
urlPatterns: [
{ pattern: '/', localized: [['en', '/']] },
{
pattern: '/:path(.*)?',
localized: [
['en', '/en/:path(.*)?'],
['de', '/de/:path(.*)?'],
],
},
],
});
優先順位: CLI フラグ / プラグインオプション > 設定ファイル > 組み込みデフォルト。ウォッチモードは設定をホットリロードします。
3. コア使用パターン(メッセージ、ロケール、ルーティング)
3.1 メッセージ
import { m } from '$lib/paraglide/messages';
import type { LocalizedString } from '$lib/paraglide/runtime';
m.hello_world(); // "Hello World!"
m.greeting({ name: 'Ada' }); // "Hello Ada!" — パラメータは型付けされています
m.greeting({ name: 'Ada' }, { locale: 'de' }); // 1 回の呼び出しでロケールを強制
const title: LocalizedString = m.welcome_title(); // ブランドセーフな文字列型
メッセージは messages/{locale}.json にあります。ロケールは project.inlang/settings.json(baseLocale + locales)で宣言されます。フラットで安定したキーを優先します。ネストされたキーは m['user.profile.title']() を介してサポートされています。動的なキーアクセスはツリーシェイキングを妨げるため、動的 ID を明示的なメッセージ参照にマッピングしてください。
3.2 ロケールの取得と設定
import { getLocale, locales, setLocale, getTextDirection } from '$lib/paraglide/runtime';
getLocale(); // 例: "de" — サーバー上ではリクエストスコープ
getTextDirection(); // 現在のロケールの "ltr" | "rtl"
setLocale('de'); // ローカライズされた URL への全ドキュメントナビゲーション
setLocale() は設定された戦略を更新し、その後 全ドキュメントナビゲーション を実行します。url 戦略がアクティブな場合はローカライズされた URL へ、そうでない場合はリロードします。これは意図的です。<html lang>、dir、<title>、メタデータ、およびローカライズされた URL はドキュメントレベルの状態であり、フレームワークのリアクティビティでは更新できません。
setLocale(locale, { reload: false }) エスケープハッチは、完全にクライアントレンダリングされたサーフェス(埋め込みウィジェット、拡張機能ページ)でのみ存在し、復元不可能なインメモリ状態を保持する必要がある場合に使用します。通常のロケールピッカー、SSR/SSG ドキュメント、または戦略に url が含まれるルートでは絶対に使用しないでください。古い URL とシェルがそのまま残り、getLocale() が古いロケールを解決し続けることになります。
3.3 ローカライズされた URL とリンク
import { localizeHref, deLocalizeUrl, localizeUrl } from '$lib/paraglide/runtime';
localizeHref('/blog'); // 現在のロケールでは "/de/blog"
localizeHref('/blog', { locale: 'de' }); // 明示的
localizeUrl('https://example.com/about', { locale: 'de' }).href; // 完全な URL 形式
URL パターンは標準の URLPattern 構文を使用します。順序が重要です。特定のパターンをワイルカードの前に配置し、urlpattern.com でテストしてください。
3.4 Svelte 5 ロケールスイッチャー(慣用的)
<script lang="ts">
import { page } from '$app/state'; // Svelte 5: オブジェクト、ストアではない
import { resolve } from '$app/paths';
import { locales, localizeHref } from '$lib/paraglide/runtime';
</script>
<nav aria-label="言語">
{#each locales as locale (locale)}
<a
href={resolve(localizeHref(page.url.pathname, { locale }))}
data-sveltekit-reload
aria-current={page.params.locale === locale ? 'page' : undefined}
>
{locale}
</a>
{/each}
</nav>
ここでは 2 つの Svelte 5 の詳細が重要です。$app/state は page をリアクティブオブジェクトとして公開します($page ストアと $app/stores はレガシーパターンです)。また、クロスロケールリンクの data-sveltekit-reload は、ローカライズされた URL、サーバーレンダリングされたドキュメント、およびランタイムロケールの同期を維持します。これはプリレンダリングされたクローリングにも必要です。
4. Svelte 5 スニペットを使用したリッチテキスト(ParaglideMessage)
マークアップにより、翻訳者は構造を制御し、アプリはレンダリングを制御できます。タグ名はアプリ定義です。何も自動的に HTML にマッピングされるわけではありません。
// messages/en.json
{
"cta": "{#link to=|/docs|}ドキュメントを読む{/link}",
"welcome": "{#b}こんにちは {name}{/b}{#icon/}"
}
<script lang="ts">
import { ParaglideMessage } from '@inlang/paraglide-js-svelte';
import { m } from '$lib/paraglide/messages';
const name = $props().name; // または任意の $state
</script>
<ParaglideMessage message={m.welcome} inputs={{ name }}>
{#snippet b({ children })}
<strong>{@render children?.()}</strong>
{/snippet}
{#snippet icon({ attributes })}
<Icon variant={attributes?.variant} />
{/snippet}
</ParaglideMessage>
<ParaglideMessage> は、マークアップが存在する場合は message.parts() を使用し、プレーンテキストの場合は message() にフォールバックします。{#snippet tag({ children, options, attributes })} 構文は、まさに Svelte 5 のスニペットパターンです。スロットも <slot> レガシーもありません。リテラル構文 to=|/docs|(パイプ区切り)に注意してください。また、m.welcome() 単独では、マークアップが除去されたプレーンテキストを返します。
5. Svelte 5 ルーン統合: リアクティブなもの(とそうでないもの)
重要なメンタルモデル: Paraglide はロケールをドキュメント/リクエスト状態として所有し、Svelte 5 ルーンは UI 状態を所有します。 ロケールの変更は新しいドキュメントを提供するため、getLocale() を追跡するためにルーンが必要になることはほとんどありません。実際に発生するパターン:
単一ドキュメント内のロケール依存の派生値(例: reload: false を使用するクライアント専用サーフェス、または複数のロケールを明示的にレンダリングするプレビューペイン):
<script lang="ts">
import { getLocale, locales } from '$lib/paraglide/runtime';
import { m } from '$lib/paraglide/messages';
let { locale = getLocale() } = $props(); // 型付けされた props
// $derived は `locale` prop が変更されたときに再評価されます — Paraglide の内部ロケールが変更されたときではありません(ナビゲーションなしでは変更されません):
const greeting = $derived(m.greeting({ name: 'Ada' }, { locale }));
const isRtl = $derived(getTextDirection(locale) === 'rtl');
</script>
グローバル状態を読み取る代わりに、強制されたロケールを下位に渡す ことがルーンフレンドリーなパターンです。各メッセージ呼び出しの { locale } オプションにより、コンポーネントは props の純粋関数になります。これはまさに Svelte 5 が期待する状態の流れ方です。$effect は本当の副作用(例: プレビューロケールの永続化)のために予約してください。$derived に決して代入せず、$effect 内から $state に書き込んでロケールを「プッシュ」しないでください。代わりに props を渡してください。
グローバルクライアント状態(例: アプリ内翻訳ツールの「最後にプレビューされたロケール」):
// src/lib/locale-preview.svelte.ts — .svelte.ts ファイルはモジュールスコープでのルーンをサポート
import { getLocale } from '$lib/paraglide/runtime';
export const preview = $state({ locale: getLocale() ?? 'en' });
$state をモジュールレベルで使用する場合、.svelte.ts ファイル内のモジュールレベルの $state は、すべてのインポーター間で共有されます。アプリ全体のシングルトンには問題ありませんが、これが本番ロケールの動作方法(戦略チェーン + ドキュメントナビゲーション)ではないことを覚えておいてください。
6. SSR、ハイドレーション、SSG
6.1 SSR ルール
- 1 つの
handleチェーンの順序:paraglideHandleを最初に(またはロケールでキー付けされたevent.localsを必要とする認証ハンドラの前に)配置し、ダウンストリームフックがローカライズ解除されたリクエストを認識できるようにします。 - TLS 終端プロキシの背後では、
effectiveRequestUrlを渡して、URL マッチングとリダイレクトがブラウザ側のオリジンを使用するようにします。 AsyncLocalStorageはオンのままにします。これにより、getLocale()が同時リクエストとストリーミング SSR のもとで安全になります。disableAsyncLocalStorageフォールバックは、node:async_hooksを持たず、かつ リクエストごとの分離が保証されているランタイムのみを対象としています。それ以外の場所で使用すると、ユーザー間でロケールがリークする可能性があります。
6.2 ハイドレーションの正確性
サーバーとクライアントは、同じ戦略順序からロケールを解決する必要があります。典型的な不一致: サーバーは URL から解決するが、クライアント戦略は最初に localStorage を読み取り、これは SSR 時には存在しません。修正方法は、永続化された選択が最初の応答を形作る必要がある場合に、サーバーから見える cookie 戦略を含めることです。
6.3 SSG(adapter-static / prerender)
プリレンダリングはミドルウェアを実行しません。ビルド中にページごとにロケールを設定します:
// src/routes/[locale]/+layout.ts
import { setLocale, assertIsLocale } from '$lib/paraglide/runtime';
import type { LayoutLoad } from './$types';
export const prerender = true;
export function load({ params }: { params: { locale: string } }) {
setLocale(assertIsLocale(params.locale));
}
// src/routes/[locale]/+page.ts
import { locales } from '$lib/paraglide/runtime';
export const prerender = true;
export function entries() {
return locales.map((locale) => ({ locale }));
}
SSG では、すべての ロケールが URL プレフィックスを持つ必要があります(urlPatterns を設定して /en/… と /de/… の両方が存在するようにします)。そうしないと、SvelteKit はロケールごとに個別のファイルを出力できません。静的アダプターの SPA フォールバックでは、ロケールプレフィックス付きアセットの 404 を回避するために kit.paths.relative = false を設定し、サイトマップには generateStaticLocalizedUrls() を使用します。
7. パフォーマンスとツリーシェイキング
- メッセージ関数を直接呼び出します。
m.hello()は 1 つの小さな ESM 関数にコンパイルされます。バンドラーは未使用のメッセージとその翻訳を自動的に削除します。バンドルサイズはカタログが成長しても一定です(ベンチマーク: プロジェクトが 200 でも 1,000 のメッセージを持っていても 47 KB)。 - 動的なキーアクセスを避けます。
m[dynamicKey]()は、すべてのメッセージがインポートされる原因となるため、ツリーシェイキングを無効にします。代わりに、動的 ID を静的なメッセージ参照にマッピングします。 @inlang/paraglide-jsのバージョンを 2.x に保ちます。 v1 は大幅に遅く、バンドルサイズも大きくなります。- メッセージ関数はデフォルトでメモ化されています。 同じ引数で複数回呼び出すと、キャッシュされた結果が返されます。
8. テスト
8.1 ユニットテスト
// src/lib/paraglide/messages.test.ts
import { describe, it, expect } from 'vitest';
import { m } from './messages';
describe('メッセージ', () => {
it('hello_world が正しい文字列を返す', () => {
expect(m.hello_world()).toBe('Hello World!');
});
it('greeting がパラメータを受け入れる', () => {
expect(m.greeting({ name: 'Ada' })).toBe('Hello Ada!');
});
it('greeting がロケールオーバーライドを受け入れる', () => {
expect(m.greeting({ name: 'Ada' }, { locale: 'de' })).toBe('Hallo Ada!');
});
});
8.2 統合テスト
// src/routes/+page.test.ts
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/svelte';
import Page from './+page.svelte';
describe('ホームページ', () => {
it('ローカライズされたメッセージをレンダリングする', () => {
const { getByText } = render(Page);
expect(getByText('Hello World!')).toBeDefined();
});
});
8.3 E2E テスト
// tests/i18n.spec.ts
import { test, expect } from '@playwright/test';
test('ロケール切り替えが正しく機能する', async ({ page }) => {
await page.goto('/');
await page.click('text=DE');
await expect(page).toHaveURL('/de');
await expect(page.locator('html')).toHaveAttribute('lang', 'de');
await expect(page.getByText('Hallo Welt!')).toBeVisible();
});
9. トラブルシューティング
9.1 よくある問題
| 問題 | 原因 | 解決策 |
|---|---|---|
getLocale() がサーバーとクライアントで異なる値を返す | 戦略の順序の不一致 | cookie 戦略を追加して、サーバーがクライアントの選択を認識できるようにする |
setLocale() がナビゲーションをトリガーしない | reload: false の誤用 | 通常のロケールスイッチャーでは reload を省略する |
| SSG がロケールごとにファイルを生成しない | urlPatterns にロケールプレフィックスがない | すべてのロケールに URL プレフィックスを設定する |
| メッセージ関数が TypeScript エラーをスローする | 古い .d.ts ファイル | emitTsDeclarations: true を有効にするか、手動で outdir を削除して再構築する |
| Paraglide ミドルウェアがリクエストをリダイレクトし続ける | 設定ミスまたは戦略の競合 | 戦略の順序を確認し、localStorage と cookie の値が一致していることを確認する |
9.2 デバッグ
// 現在のロケールと戦略状態をログに記録する
console.log('Current locale:', getLocale());
console.log('All locales:', locales);
console.log('Text direction:', getTextDirection());