yapyak — an i18n compiler for SvelteKit that never asks you to name a key again #16683
qwuide
started this conversation in
Show and tell
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Hi all,
I've been building an i18n compiler for Vite apps. SvelteKit is what I'll mainly be using it with myself, and Svelte has ended up being one of the nicest fits for how it works, so I thought this was a good place to show it.
The basic idea is that the source string is the key.
Here's
src/routes/settings/security/+page.svelte:Save the file and it appears in
locales/de.json, grouped by where it came from:{ "src/routes/settings/security/+page.svelte": { "Download recovery key": "" } }If you haven't configured a translator, that's it. It's just an empty stub waiting for you.
If you have, a second or two later it becomes:
{ "src/routes/settings/security/+page.svelte": { "Download recovery key": "Wiederherstellungsschlüssel herunterladen" } }The running page picks it up immediately. Nothing remounts, and the button is now German.
More importantly, the button is now
Wiederherstellungsschlüssel herunterladen, which is roughly twice as wide as the space I had designed for it. I found that out about two seconds after writing the button instead of several weeks later.That's also the extraction step. There isn't a separate extraction command or a naming step. I don't have to decide that this particular string should forever be known as
routes.settings.security.downloadRecoveryKeyButton.labelor whatever. The string I wrote is the thing I'm translating, and its location in the source tree gives it structure.Counts, plurals, dates and the rest go in the same string using ICU:
The parameter types come directly from the string literal, so nothing has to be regenerated when you change it. Miss an argument and TypeScript knows. Misspell an ICU keyword and it knows that too. Numbers and dates that aren't inside a sentence use
format.numberand its siblings instead —Intl, with the active locale already filled in.Saving also validates the locale files, so if a translation loses
{count}, breaks an ICU expression or otherwise drifts away from the source, it fails there instead of making it into the app. The same check runs in CI:Every error it can raise has a code and a page explaining it; I think I'm up to 47 now, which wasn't exactly the part of the project I expected to spend this much time on.
Most of the behaviour lives in
yapyak.config.ts:The translator talks straight to the provider with your API key, so there's no yapyak service in between. There are translators for Anthropic, OpenAI, Gemini and Ollama, and the Ollama one runs locally if you'd rather nothing left the machine at all. Writing your own means implementing a small interface. Skipping it is a real option too, not a degraded mode: the stubs sit there until you fill them in.
voiceis there because I don't want a settings screen slowly turning into marketing copy when the model gets enthusiastic.glossarypins the words that are yours, soWorkspacedoesn't quietly becomeArbeitsbereich.The compiler also knows more about a string than a translation service normally does. This is what goes out for something as ambiguous as
Open:[ { "source": "Open", "component": "FileMenu", "element": "button", "examples": [ { "source": "Save", "translation": "Spara" } ] } ]How much of that goes is your call. The default is the component and the element, which is usually enough to get
Öppnafor a button andÖppenfor a status badge. Same word in English, two different ones in Swedish. Turn it up and the surrounding source goes too. Turn it down to nothing and the model sees the string alone, which is the setting to reach for if sending source anywhere is a conversation you'd rather not have.The
examplesare translations already sitting in your locale files, picked by how close they are to the string being translated. So the model can see that this app already saysSpara, andSave changescomes back matching it instead of drifting off on its own. If both senses ofOpenlive in the same file they share one entry, andt.as('action', 'Open')splits them.For SvelteKit, the server setup is one line in
src/hooks.server.ts:Then in
src/app.html:The locale is request-scoped on the server, and the client starts with the same locale the server rendered. Persistence can be a cookie, the URL or local storage, and the first two are the ones that survive SSR.
<html lang>is handled during rendering. The main thing I cared about here was making locale state behave like normal SvelteKit state rather than something global that happens to work until two requests arrive at once.On the client,
@yapyak/svelteexposeslocaleas an object backed by$state, so changing language is just a rune assignment:bind:works on it like it would on any other piece of state. There's nothing to fetch when you switch; the translations used by a module were compiled into it, and only the parts reading the locale react.Rich text uses snippets:
The required snippets are inferred from the string too. Remove
linkwhile<link>is still in the string and TypeScript complains.yapyak does most of its work at compile time, so a fixed-locale build can go all the way:
becomes
Nothing left to switch, so nothing ships. A normal multi-locale build keeps around 5 KB gzipped and code-splits the rest along your routes. Most of the heavy lifting is left to
Intl.The same holds before you've added any languages at all. With only your source language on disk there's nothing to pick between, so every
t()compiles back to the string you wrote. You can wrap your strings today and not pay for it until you actually want a second language.There are bindings for React, Vue and Astro as well, but they're separate integrations rather than a generic wrapper pretending every framework has the same reactivity model. Svelte's implementation uses Svelte's own primitives, which is partly why I've enjoyed working on this one.
It's early, MIT licensed, and very much at the point where I'd rather find the awkward cases now than design around imaginary ones for six months. I built it for a product I'm working on, and that's the whole business plan. There's no hosted tier behind it, and no follow-up post where I get to the pricing.
yapyak.dev has the docs, and there are five runnable Svelte examples in the repo, three plain Svelte and two on SvelteKit.
I've tested the per-request locale handling about as hard as one person reasonably can, which isn't the same thing as thousands of real SvelteKit apps doing strange things in production. If you've dealt with i18n in SvelteKit for a while, especially SSR, routing, locale persistence or larger apps, I'd love to know what you'd try to break first.
yapyak is young enough that good feedback still changes things, and the Svelte side is the one I most want to get right. That's mostly why I'm posting here.
All reactions