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.
- It requires maintenance of an external dependency, which may vary between bumping a version to receive bug fixes and getting distracted by rewriting locales to cater to a newly introduced format change.
- It adds weight to a bundle. Mind that it depends on a format (with client-side apps being affected the most) and a count of utilised features (the more features used the more increase in size is justified, it would have its counterpart in a custom solution).
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"