Belar

Micro i18n for Svelte


There comes a time when hard-coded text becomes limiting, or worse, a burden. Duplicates scattered across a code base and inconsistent copy updates are good signs that input labels should have their own place in a source code, and a translation strategy is needed.

Solutions exist

There are tools that solve this problem, libraries like i18next, Polyglot.js, and svelte-i18n, provide feature-rich, internationalisation tooling. However, language detection, caching, interpolation, nesting, pluralisation, formatting, and much more, come at a cost.

l10n as a base for i18n

Note: Code examples use the default directory structure from SvelteKit.

If a prototype could use a basic internationalisation mechanism, but it’s too early for plurals, detailed formatting and so on, Svelte’s store has all that’s need to put together a good base for future i18n support.

Locales will be held in JSON files e.g. en.json, es.json, in a format compatible with existing solutions (mentioned earlier). The module will utilise Svelte’s writeable and derived stores to load and propagate translations based on a chosen locale.

The basic solution looks like this:

// $lib/i18n/locales/en.json
{
	“greeting”: “hello”
}
// $lib/i18n/locales/es.json
{
	“greeting”: ”ola”
}
// $lib/i18n/i18n.ts

import { derived, writable, type Writable } from "svelte/store";

type Locale = Record<string, string>;

const LOCALE_DEFAULT = "en";
const TRANSLATION_FALLBACK = "";

const locale = writable(LOCALE_DEFAULT);

const translations = derived<Writable<string>, Locale | null>(
	locale,
	($locale, set) => {
		getTranslations($locale)
			.then(({ default: translations }) => set(translations))
			.catch((error) => {
				console.error("Failed to load translations:", error);

				set(null);
			});
	},
	null
);

export const t = derived(
	translations,
	($translations) => (key: string) =>
		(!!$translations && translate($translations, key)) || TRANSLATION_FALLBACK
);

function translate(translations: Locale, key: string) {
	return translations[key] || TRANSLATION_FALLBACK;
}

function getTranslations(locale: string) {
	return import(`./locales/${locale}.json`) as Promise<{
		default: Locale;
	}>;
}

Translation can be accessed through a t store’s value, by a key.

<script lang="ts">
	import { t } from "$lib/i18n/i18n.js";
</script>

<p>{ $t('greeting') }</p>

Caveats

SvelteKit’s static site generation (SSG) pre-render missing translations

In a setup using adapter-static, with prerender and ssr enabled, the first page won’t include translations coming from the t stores. The reason is dynamic import of a language file, which is asynchronous and is not ready for server side rendering (SSR).

Digging through documentations and repositories, I couldn’t find an official solution, but below are possible workarounds to the problem.

Pre-bundle the default language file

File with the translations for a default language could be bundled-in, with traditional import, and accessed directly, instead of the dynamically imported one.

import defaultLocale from "$lib/i18n/locales/en.json";

const translations = derived<Writable<string>, Locale | null>(
	locale,
	($locale, set) => {
		if ($locale === LOCALE_DEFAULT) {
			set(defaultLocale);
			return;
		}

		getTranslations($locale)
			.then(({ default: translations }) => set(translations))
			.catch((error) => {
				console.error("Failed to load translations:", error);

				set(null);
			});
	},
	null
);

However, that means extra weight for users who will use a different language than the default one. In that case, it would be better for a solution to reside in the build process.

Warm-up in the build process

As mentioned, only the first page is affected, so if the dynamic import could be triggered early in the build process, it would make the translations available for the SSR.

The import must happen within a context of an application, so its evaluation is reused. For that, a “dummy” page can be used.

// svelte.config.js

import adapter from "@sveltejs/adapter-static";
import preprocess from "svelte-preprocess";

/** @type {import('@sveltejs/kit').Config} */
const config = {
	// Consult https://github.com/sveltejs/svelte-preprocess
	// for more information about preprocessors
	preprocess: preprocess(),
	kit: {
		prerender: {
			entries: ["/_warmup", "/"],
		},
	},
};

export default config;
<!-- src/routes/_warmup/+page.svelte -->

<script lang="ts">
	import { t } from "$lib/i18n/i18n.js";
</script>

<p>{$t('greeting')}</p>

To keep a build clean, the dummy page can be removed after the build process. For example, removal can be included in the build command:

// package.json

"build": "vite build && rm build/_warmup.html"