Skip to content
loclizr

A Vite and Svelte 5 app whose markup follows setLocale, with a test that proves it. There is no loclizr/svelte entry: an eleven-line module over subscribe and getLocale is the whole binding.

  1. Terminal window
    npm create vite@latest app -- --template svelte-ts
    cd app
    npm i
    npm i loclizr
    npx loclizr init

    init wrote locales/en.json with the Quickstart messages. Add German:

    locales/de.json
    {
    "nav": {
    "home": "Startseite",
    "cart": "Warenkorb"
    },
    "cart": {
    "greeting": "Hallo {name}, dein Warenkorb ist bereit",
    "items": "{count, plural, =0 {Dein Warenkorb ist leer} one {# Artikel in deinem Warenkorb} other {# Artikel in deinem Warenkorb}}"
    }
    }
  2. package.json
    "scripts": {
    "predev": "loclizr build --no-fail",
    "dev": "vite",
    "prebuild": "loclizr build",
    "build": "vite build",
    "preview": "vite preview",
    "pretypecheck": "loclizr build",
    "typecheck": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json",
    "pretest": "loclizr build",
    "test": "vitest run"
    }

    The template calls its type check check; renaming it typecheck lets pretypecheck run first. pretest generates src/loclizr before the mount test in the last step.

  3. src/locale.svelte.ts
    import { createSubscriber } from 'svelte/reactivity'
    import { getLocale, subscribe, type AppLocale } from './loclizr/messages'
    const track = createSubscriber(subscribe)
    export const locale = {
    get current(): AppLocale {
    track()
    return getLocale()
    },
    }

    createSubscriber wants (update) => cleanup, which is the shape of loclizr’s subscribe, so it passes straight in. Any $derived, effect or markup that reads locale.current reruns on a switch.

  4. src/Switcher.svelte
    <script lang="ts">
    import * as m from './loclizr/messages'
    import { locale } from './locale.svelte'
    </script>
    <div>
    {#each m.locales as tag (tag)}
    <button type="button" aria-pressed={tag === locale.current} onclick={() => m.setLocale(tag)}>
    {tag}
    </button>
    {/each}
    </div>
  5. src/App.svelte
    <script lang="ts">
    import * as m from './loclizr/messages'
    import { locale } from './locale.svelte'
    import Cart from './Cart.svelte'
    import Switcher from './Switcher.svelte'
    </script>
    <Switcher />
    {#key locale.current}
    <h1>{m.cart_greeting({ name: 'Ada' })}</h1>
    {/key}
    <Cart />
    src/Cart.svelte
    <script lang="ts">
    import * as m from './loclizr/messages'
    import { locale } from './locale.svelte'
    let count = $state(3)
    const items = $derived(m.cart_items({ count }, { locale: locale.current }))
    </script>
    <p>{items}</p>
    <button type="button" onclick={() => count++}>+1</button>

    A bare {m.nav_cart()} never reruns: the message reads the store, and the store is not a rune. Pick one of the two forms:

    Form On a switch
    {#key locale.current} remounts the block; component state inside it resets
    { locale: locale.current } per call reruns that expression in place; state survives
  6. Terminal window
    npm i -D vitest jsdom
    vite.config.ts
    import { svelte } from '@sveltejs/vite-plugin-svelte'
    import { defineConfig } from 'vitest/config'
    export default defineConfig({
    plugins: [svelte()],
    resolve: process.env.VITEST ? { conditions: ['browser'] } : undefined,
    test: { environment: 'jsdom' },
    })

    Without the browser condition vitest loads Svelte’s server build and mount throws lifecycle_function_unavailable.

    src/App.test.ts
    import { afterEach, expect, test } from 'vitest'
    import { flushSync, mount, unmount } from 'svelte'
    import * as m from './loclizr/messages'
    import App from './App.svelte'
    let app: ReturnType<typeof mount> | undefined
    afterEach(() => {
    if (app) unmount(app)
    m.setLocale('en')
    flushSync()
    })
    test('a switch re-renders the heading and keeps the cart count', () => {
    app = mount(App, { target: document.body })
    document.querySelector<HTMLButtonElement>('p + button')?.click()
    flushSync()
    expect(document.querySelector('h1')?.textContent).toBe('Hi Ada, your cart is ready')
    expect(document.querySelector('p')?.textContent).toBe('4 items in your cart')
    m.setLocale('de')
    flushSync()
    expect(document.querySelector('h1')?.textContent).toBe('Hallo Ada, dein Warenkorb ist bereit')
    expect(document.querySelector('p')?.textContent).toBe('4 Artikel in deinem Warenkorb')
    })

    setLocale writes the cookie in jsdom too, so afterEach switches back; then run npm test:

    npm test
    Test Files 1 passed (1)
    Tests 1 passed (1)
src/locale-store.ts
import { readable } from 'svelte/store'
import { getLocale, subscribe } from './loclizr/messages'
export const localeStore = readable(getLocale(), (set) => {
set(getLocale())
return subscribe(() => set(getLocale()))
})

The store contract calls a subscriber at once, which loclizr’s subscribe does not, so readable supplies the first value and set(getLocale()) refreshes it whenever the first subscriber arrives. Components then read $localeStore.

SvelteKit server rendering is out of scope here; Server rendering has the request-scoped shape. Language switching covers persistence and the per-call override, and Runtime API lists every export.