Claude Code Plugins

Community-maintained marketplace

Feedback

composable-svelte-i18n

@jonathanbelolo/composable-svelte
0
0

Internationalization (i18n) system for Composable Svelte. Use when implementing multi-language support, translations, date/number formatting, locale detection, or localizing applications. Covers translation loading, ICU MessageFormat, formatters (dates/numbers/currency), locale detection, SSR integration, and the i18n reducer.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name composable-svelte-i18n
description Internationalization (i18n) system for Composable Svelte. Use when implementing multi-language support, translations, date/number formatting, locale detection, or localizing applications. Covers translation loading, ICU MessageFormat, formatters (dates/numbers/currency), locale detection, SSR integration, and the i18n reducer.

Composable Svelte Internationalization (i18n)

Complete i18n solution integrated with the Composable Architecture. Handles translations, formatting (dates, numbers, currency), locale detection, and works seamlessly with SSR/SSG.


CRITICAL RULES

Rule 1: i18n State Lives in Store

Principle: i18n state is part of your application state, integrated via the i18n reducer.

✅ CORRECT - i18n in Store State

import { createInitialI18nState, i18nReducer } from '@composable-svelte/core/i18n';

interface AppState {
  todos: Todo[];
  i18n: I18nState;  // ✅ i18n integrated with app state
}

type AppAction =
  | { type: 'addTodo'; text: string }
  | I18nAction;  // ✅ i18n actions part of app actions

const appReducer: Reducer<AppState, AppAction> = (state, action, deps) => {
  // Handle i18n actions
  if (typeof action.type === 'string' && action.type.startsWith('i18n/')) {
    const [newI18nState, i18nEffect] = i18nReducer(state.i18n, action as any, deps);
    return [
      { ...state, i18n: newI18nState },
      i18nEffect as Effect<AppAction>
    ];
  }

  // Handle other actions...
  return [state, Effect.none()];
};

❌ WRONG - Separate i18n Store

// ❌ Don't create a separate store for i18n
const i18nStore = createStore({
  initialState: i18nState,
  reducer: i18nReducer
});

const appStore = createStore({
  initialState: appState,
  reducer: appReducer
});

WHY: Single source of truth. i18n changes should flow through the same reducer pipeline as other state changes.


Rule 2: Use Framework Formatters, Not Manual Formatting

Principle: The framework provides locale-aware formatters. Never manually format dates, numbers, or currency.

✅ CORRECT - Framework Formatters

<script lang="ts">
  import { createTranslator, createFormatters } from '@composable-svelte/core/i18n';

  const t = $derived(createTranslator($store.i18n, 'common'));
  const formatters = $derived(createFormatters($store.i18n));
</script>

<div>
  <p>{t('welcome', { name: 'Alice' })}</p>
  <p>{formatters.date(post.date)}</p>
  <p>{formatters.number(1234.56)}</p>
  <p>{formatters.currency(99.99, 'USD')}</p>
</div>

❌ WRONG - Manual Formatting

<!-- ❌ Don't manually format dates -->
<p>{new Date(post.date).toLocaleDateString()}</p>

<!-- ❌ Don't manually format numbers -->
<p>{price.toFixed(2)}</p>

<!-- ❌ Don't manually construct currency -->
<p>${price}</p>

WHY: Manual formatting doesn't respect locale conventions. Formatters automatically handle regional differences (e.g., "January 5, 2025" vs "5 janvier 2025", "1,234.56" vs "1 234,56").


CORE CONCEPTS

1. Translation System

Use createTranslator() for accessing translations. It handles interpolation, ICU MessageFormat, and fallback chains automatically.

<script lang="ts">
  import { createTranslator } from '@composable-svelte/core/i18n';

  const t = $derived(createTranslator($store.i18n, 'common'));
</script>

<div>
  <!-- Simple translation -->
  <h1>{t('app.title')}</h1>

  <!-- With interpolation -->
  <p>{t('welcome', { name: 'Alice' })}</p>

  <!-- ICU MessageFormat (pluralization) -->
  <p>{t('items', { count: 5 })}</p>
  <!-- Translation: "{count, plural, one {# item} other {# items}}" -->
  <!-- Output: "5 items" -->
</div>

Translation File Example (locales/en/common.json):

{
  "app.title": "My Application",
  "welcome": "Welcome, {name}!",
  "items": "{count, plural, one {# item} other {# items}}",
  "greeting": "{gender, select, male {Hello Mr. {name}} female {Hello Ms. {name}} other {Hello {name}}}"
}

2. Formatters (Dates, Numbers, Currency)

Use createFormatters() to get locale-bound formatters. All formatters automatically use the current locale.

<script lang="ts">
  import { createFormatters, DateFormats, NumberFormats } from '@composable-svelte/core/i18n';

  const formatters = $derived(createFormatters($store.i18n));
</script>

<div>
  <!-- Date formatting -->
  <p>{formatters.date(post.date)}</p>
  <!-- en: "January 5, 2025" -->
  <!-- fr: "5 janvier 2025" -->
  <!-- es: "5 de enero de 2025" -->

  <!-- Date with custom options -->
  <p>{formatters.date(post.date, DateFormats.short)}</p>
  <!-- en: "1/5/25" -->
  <!-- fr: "05/01/2025" -->

  <!-- Number formatting -->
  <p>{formatters.number(1234.56)}</p>
  <!-- en: "1,234.56" -->
  <!-- fr: "1 234,56" -->
  <!-- de: "1.234,56" -->

  <!-- Currency formatting -->
  <p>{formatters.currency(99.99, 'USD')}</p>
  <!-- en-US: "$99.99" -->
  <!-- fr: "99,99 $US" -->
  <!-- de: "99,99 $" -->

  <!-- Relative time -->
  <p>{formatters.relativeTime(yesterday)}</p>
  <!-- en: "yesterday" -->
  <!-- fr: "hier" -->
  <!-- es: "ayer" -->
</div>

Available DateFormats:

  • DateFormats.short - "1/5/25"
  • DateFormats.medium - "Jan 5, 2025"
  • DateFormats.long - "January 5, 2025" (default)
  • DateFormats.full - "Monday, January 5, 2025"

3. Locale Detection

The framework provides three locale detectors for different environments.

Browser (Client-Side)

import { createBrowserLocaleDetector } from '@composable-svelte/core/i18n';

const localeDetector = createBrowserLocaleDetector({
  supportedLocales: ['en', 'fr', 'es'],
  defaultLocale: 'en',
  cookieName: 'locale',  // Optional: persist preference
  storageKey: 'user_locale'  // Optional: localStorage key
});

const locale = localeDetector.detect();
// Checks: 1. localStorage, 2. cookie, 3. navigator.language, 4. default

SSR (Server-Side Rendering)

import { createSSRLocaleDetector } from '@composable-svelte/core/i18n';

// In your server handler
const localeDetector = createSSRLocaleDetector({
  supportedLocales: ['en', 'fr', 'es'],
  defaultLocale: 'en',
  url: request.url,  // For ?lang=fr query param
  cookies: request.headers.get('cookie') ?? '',
  acceptLanguage: request.headers.get('accept-language') ?? ''
});

const locale = localeDetector.detect();
// Checks: 1. URL (?lang=fr), 2. cookie, 3. Accept-Language, 4. default

SSG (Static Site Generation)

import { createStaticLocaleDetector } from '@composable-svelte/core/i18n';

// For pre-rendering specific locale
const localeDetector = createStaticLocaleDetector('fr', ['en', 'fr', 'es']);
const locale = localeDetector.detect();  // Always returns 'fr'

4. Translation Loading

Three strategies for loading translations based on your use case.

Fetch Loader (Client-Side, Lazy Loading)

import { FetchTranslationLoader } from '@composable-svelte/core/i18n';

const translationLoader = new FetchTranslationLoader({
  baseUrl: '/locales',  // Fetches from /locales/{locale}/{namespace}.json
  supportedLocales: ['en', 'fr', 'es']
});

File Structure:

public/
  locales/
    en/
      common.json
      products.json
    fr/
      common.json
      products.json

Bundled Loader (SSR, Build-Time)

import { BundledTranslationLoader } from '@composable-svelte/core/i18n';

// Import translations at build time
import enCommon from '../locales/en/common.json';
import frCommon from '../locales/fr/common.json';

const translationLoader = new BundledTranslationLoader({
  bundles: {
    'en': { common: enCommon },
    'fr': { common: frCommon }
  }
});

Glob Loader (Vite-Specific, Auto-Discovery)

import { createGlobLoader } from '@composable-svelte/core/i18n';

const translationLoader = createGlobLoader(
  import.meta.glob('/src/locales/*/*.json')
);

COMPLETE SETUP EXAMPLES

Client-Only Application

// src/store.ts
import { createStore } from '@composable-svelte/core';
import {
  createInitialI18nState,
  i18nReducer,
  createBrowserLocaleDetector,
  FetchTranslationLoader,
  browserDOM
} from '@composable-svelte/core/i18n';

// Detect locale
const localeDetector = createBrowserLocaleDetector({
  supportedLocales: ['en', 'fr', 'es'],
  defaultLocale: 'en',
  storageKey: 'app_locale'
});

const initialLocale = localeDetector.detect();

// Initialize i18n state
const i18nState = createInitialI18nState(initialLocale, ['en', 'fr', 'es'], 'en');

const initialState: AppState = {
  todos: [],
  i18n: i18nState
};

// Create store with i18n dependencies
const store = createStore({
  initialState,
  reducer: appReducer,
  dependencies: {
    translationLoader: new FetchTranslationLoader({
      baseUrl: '/locales',
      supportedLocales: ['en', 'fr', 'es']
    }),
    localeDetector,
    storage: localStorage,
    dom: browserDOM
  }
});

// Load initial translations
store.dispatch({
  type: 'i18n/loadNamespace',
  namespace: 'common'
});
<!-- src/App.svelte -->
<script lang="ts">
  import { createTranslator, createFormatters } from '@composable-svelte/core/i18n';

  const t = $derived(createTranslator($store.i18n, 'common'));
  const formatters = $derived(createFormatters($store.i18n));

  function switchLanguage(locale: string) {
    store.dispatch({
      type: 'i18n/setLocale',
      locale,
      preloadNamespaces: ['common']
    });
  }
</script>

<div>
  <h1>{t('app.title')}</h1>

  <select value={$store.i18n.currentLocale} onchange={(e) => switchLanguage(e.currentTarget.value)}>
    <option value="en">English</option>
    <option value="fr">Français</option>
    <option value="es">Español</option>
  </select>

  <p>{t('welcome', { name: 'User' })}</p>
  <p>{formatters.date(new Date())}</p>
</div>

SSR Application

Server Setup:

// src/server/index.ts
import { renderToHTML } from '@composable-svelte/core/ssr';
import {
  createInitialI18nState,
  BundledTranslationLoader,
  createSSRLocaleDetector,
  serverDOM
} from '@composable-svelte/core/i18n';

// Import translations
import enCommon from '../locales/en/common.json';
import frCommon from '../locales/fr/common.json';

app.get('*', async (request, reply) => {
  // Detect locale from request
  const localeDetector = createSSRLocaleDetector({
    supportedLocales: ['en', 'fr'],
    defaultLocale: 'en',
    url: request.url,
    cookies: request.headers.cookie ?? '',
    acceptLanguage: request.headers['accept-language'] ?? ''
  });

  const locale = localeDetector.detect();

  // Initialize i18n for this request
  const i18nState = createInitialI18nState(locale, ['en', 'fr'], 'en');

  const initialState: AppState = {
    todos: [],
    i18n: i18nState
  };

  // Render with i18n dependencies
  const html = await renderToHTML(App, {
    initialState,
    reducer: appReducer,
    dependencies: {
      translationLoader: new BundledTranslationLoader({
        bundles: {
          'en': { common: enCommon },
          'fr': { common: frCommon }
        }
      }),
      localeDetector,
      storage: new Map(),  // SSR-safe storage
      dom: serverDOM
    }
  });

  reply.type('text/html').send(html);
});

Client Hydration:

// src/client/index.ts
import { hydrateStore } from '@composable-svelte/core/ssr';
import {
  FetchTranslationLoader,
  createBrowserLocaleDetector,
  browserDOM
} from '@composable-svelte/core/i18n';

const store = hydrateStore(
  document.getElementById('__COMPOSABLE_SVELTE_STATE__')!.textContent,
  {
    reducer: appReducer,
    dependencies: {
      translationLoader: new FetchTranslationLoader({
        baseUrl: '/locales',
        supportedLocales: ['en', 'fr']
      }),
      localeDetector: createBrowserLocaleDetector({
        supportedLocales: ['en', 'fr'],
        defaultLocale: 'en'
      }),
      storage: localStorage,
      dom: browserDOM
    }
  }
);

ICU MessageFormat

The framework automatically detects and compiles ICU MessageFormat syntax for advanced translations.

Pluralization

{
  "items": "{count, plural, =0 {No items} one {# item} other {# items}}"
}
t('items', { count: 0 });   // "No items"
t('items', { count: 1 });   // "1 item"
t('items', { count: 5 });   // "5 items"

Selection (Gender, etc.)

{
  "greeting": "{gender, select, male {Hello Mr. {name}} female {Hello Ms. {name}} other {Hello {name}}}"
}
t('greeting', { gender: 'male', name: 'Smith' });    // "Hello Mr. Smith"
t('greeting', { gender: 'female', name: 'Johnson' }); // "Hello Ms. Johnson"
t('greeting', { gender: 'other', name: 'Taylor' });  // "Hello Taylor"

Nested Plurals

{
  "cart": "{itemCount, plural, one {You have # item} other {You have # items}} in {cartCount, plural, one {# cart} other {# carts}}"
}
t('cart', { itemCount: 1, cartCount: 1 });  // "You have 1 item in 1 cart"
t('cart', { itemCount: 5, cartCount: 2 });  // "You have 5 items in 2 carts"

COMMON PATTERNS

Pattern 1: Language Switcher

<script lang="ts">
  const availableLocales = $derived($store.i18n.availableLocales);
  const currentLocale = $derived($store.i18n.currentLocale);

  const languageNames: Record<string, string> = {
    en: 'English',
    fr: 'Français',
    es: 'Español'
  };

  function switchLanguage(locale: string) {
    store.dispatch({
      type: 'i18n/setLocale',
      locale,
      preloadNamespaces: ['common']  // Load common translations immediately
    });
  }
</script>

<div class="language-switcher">
  {#each availableLocales as locale}
    <button
      class:active={locale === currentLocale}
      onclick={() => switchLanguage(locale)}
    >
      {languageNames[locale]}
    </button>
  {/each}
</div>

Pattern 2: Lazy Loading Namespaces

Load translations on-demand to reduce initial bundle size.

// Load translations when user navigates to products page
function navigateToProducts() {
  store.dispatch({
    type: 'i18n/loadNamespace',
    namespace: 'products'
  });

  // Navigate after translations loaded (via effect)
  store.dispatch({
    type: 'navigate',
    destination: { type: 'products' }
  });
}

In Reducer:

case 'i18n/namespaceLoaded': {
  // Translation loaded, safe to navigate
  if (action.namespace === 'products') {
    return [state, Effect.none()];
  }
  return [state, Effect.none()];
}

Pattern 3: Formatted Lists

<script lang="ts">
  const authors = ['Alice', 'Bob', 'Carol'];

  // The framework uses Intl.ListFormat automatically
  const formattedAuthors = $derived(
    new Intl.ListFormat($store.i18n.currentLocale, {
      style: 'long',
      type: 'conjunction'
    }).format(authors)
  );
</script>

<p>Authors: {formattedAuthors}</p>
<!-- en: "Authors: Alice, Bob, and Carol" -->
<!-- fr: "Authors: Alice, Bob et Carol" -->
<!-- es: "Authors: Alice, Bob y Carol" -->

BEST PRACTICES

1. Organize Translation Files by Namespace

locales/
  en/
    common.json         # UI labels, buttons, navigation
    products.json       # Product-related translations
    errors.json         # Error messages
    validation.json     # Form validation messages
  fr/
    common.json
    products.json
    ...

Benefits:

  • Lazy load translations per feature
  • Easier maintenance
  • Smaller initial bundle

2. Use Descriptive Keys

{
  "nav.home": "Home",
  "nav.products": "Products",
  "products.addToCart": "Add to Cart",
  "products.outOfStock": "Out of Stock",
  "validation.required": "This field is required"
}

WHY: Namespaced keys make it clear where translations are used and prevent conflicts.

3. Provide Context in Plurals

{
  "cart.items": "{count, plural, =0 {Your cart is empty} one {# item in cart} other {# items in cart}}"
}

WHY: The =0 case lets you provide a more natural message than "0 items".

4. Keep Formatters as Derived Values

<script lang="ts">
  // ✅ CORRECT - Reactive to locale changes
  const formatters = $derived(createFormatters($store.i18n));

  // ❌ WRONG - Won't update when locale changes
  const formatters = createFormatters($store.i18n);
</script>

5. Test with Multiple Locales

import { TestStore } from '@composable-svelte/core/test';

test('displays localized date', async () => {
  const store = new TestStore({
    initialState: {
      post: { date: '2025-01-05' },
      i18n: createInitialI18nState('fr', ['en', 'fr'])
    },
    reducer: appReducer,
    dependencies: { /* ... */ }
  });

  const formatters = createFormatters(store.state.i18n);
  expect(formatters.date(store.state.post.date)).toBe('5 janvier 2025');
});

COMMON GOTCHAS

Gotcha 1: Not Awaiting Translation Load

// ❌ WRONG - Translations not loaded yet
store.dispatch({ type: 'i18n/setLocale', locale: 'fr' });
const t = createTranslator(store.state.i18n, 'common');  // May not have French translations!

// ✅ CORRECT - Wait for load or preload
store.dispatch({
  type: 'i18n/setLocale',
  locale: 'fr',
  preloadNamespaces: ['common']  // Loads before completing
});

Gotcha 2: Forgetting to Handle i18n Actions in Reducer

// ❌ WRONG - i18n actions not handled
const appReducer: Reducer<AppState, AppAction> = (state, action, deps) => {
  switch (action.type) {
    case 'addTodo':
      return [/* ... */];
    // Missing i18n handling!
  }
};

// ✅ CORRECT - Handle i18n actions
const appReducer: Reducer<AppState, AppAction> = (state, action, deps) => {
  // Handle i18n actions first
  if (typeof action.type === 'string' && action.type.startsWith('i18n/')) {
    const [newI18nState, i18nEffect] = i18nReducer(state.i18n, action as any, deps);
    return [
      { ...state, i18n: newI18nState },
      i18nEffect as Effect<AppAction>
    ];
  }

  switch (action.type) {
    case 'addTodo':
      return [/* ... */];
  }
};

Gotcha 3: Using Wrong Storage on Server

// ❌ WRONG - localStorage doesn't exist on server
const deps = {
  storage: localStorage,  // Crashes in SSR!
  dom: serverDOM
};

// ✅ CORRECT - Use SSR-safe storage
const deps = {
  storage: new Map(),  // Or mock storage
  dom: serverDOM
};

Gotcha 4: Not Binding Formatters to Locale

// ❌ WRONG - Formatters created once, won't update
const formatters = createFormatters($store.i18n);

// ✅ CORRECT - Derived, updates when locale changes
const formatters = $derived(createFormatters($store.i18n));

PERFORMANCE TIPS

1. Cache Formatters Per Locale

The framework already caches Intl.DateTimeFormat and Intl.NumberFormat instances. Creating formatters is cheap.

2. Lazy Load Namespaces

// Load translations only when needed
store.dispatch({
  type: 'i18n/loadNamespace',
  namespace: 'products'
});

3. Use Bundled Loader for SSR

Bundled translations are faster than fetch on every request:

const translationLoader = new BundledTranslationLoader({
  bundles: {
    'en': { common: enCommon },
    'fr': { common: frCommon }
  }
});

4. Preload Critical Namespaces

store.dispatch({
  type: 'i18n/setLocale',
  locale: 'fr',
  preloadNamespaces: ['common', 'navigation']  // Load multiple at once
});

SSR/SSG INTEGRATION

For complete SSR/SSG patterns, see the composable-svelte-ssr skill. Key points:

SSR

  • Server: Use BundledTranslationLoader, createSSRLocaleDetector, serverDOM
  • Client: Use FetchTranslationLoader, createBrowserLocaleDetector, browserDOM
  • State serializes automatically via renderToHTML

SSG (Static Sites)

  • Use createStaticLocaleDetector with fixed locale
  • Generate separate HTML files per locale (/en/, /fr/, /es/)
  • Language switcher uses links to pre-generated pages

Example: See examples/ssr-server/src/build/ssg.ts for complete SSG setup.


TYPESCRIPT SUPPORT

All types are fully typed and exported:

import type {
  I18nState,
  I18nAction,
  I18nDependencies,
  Translator,
  BoundFormatters,
  TranslationLoader,
  LocaleDetector
} from '@composable-svelte/core/i18n';

TESTING

Use TestStore to test translations and locale switching:

import { TestStore } from '@composable-svelte/core/test';
import { createTranslator } from '@composable-svelte/core/i18n';

test('switches locale', async () => {
  const store = new TestStore({
    initialState: {
      i18n: createInitialI18nState('en', ['en', 'fr'])
    },
    reducer: appReducer,
    dependencies: mockI18nDependencies
  });

  await store.send({ type: 'i18n/setLocale', locale: 'fr', preloadNamespaces: ['common'] });
  await store.receive({ type: 'i18n/namespaceLoaded', namespace: 'common' }, (state) => {
    expect(state.i18n.currentLocale).toBe('fr');
  });
});

SUMMARY

Core Principles:

  1. i18n state lives in your app store via i18nReducer
  2. Use createTranslator() for translations, createFormatters() for dates/numbers/currency
  3. Framework handles all locale-specific formatting automatically
  4. ICU MessageFormat for pluralization and selection
  5. Three loaders: Fetch (client), Bundled (SSR), Glob (Vite)
  6. Three detectors: Browser, SSR, Static (SSG)

Most Common Pattern:

<script lang="ts">
  import { createTranslator, createFormatters } from '@composable-svelte/core/i18n';

  const t = $derived(createTranslator($store.i18n, 'common'));
  const formatters = $derived(createFormatters($store.i18n));
</script>

<div>
  <h1>{t('app.title')}</h1>
  <p>{t('welcome', { name: user.name })}</p>
  <time>{formatters.date(post.date)}</time>
  <span>{formatters.currency(product.price, 'USD')}</span>
</div>

The i18n system is fully integrated with the Composable Architecture, providing a complete, type-safe, and performant solution for building multi-language applications.