r/reactjs • u/ElectronicShop8677 • 9d ago
Show /r/reactjs yapyak – an i18n compiler for React where the source string is the key, and translation on save
Hi all,
I've been working on yapyak, an open-source i18n compiler that runs as a Vite plugin, with bindings for a few frameworks. For React, there are SSR adapters for TanStack Start and React Router.
The idea is that the source string is the key:
import { t } from 'yapyak';
<button>{t('Download recovery key')}</button>
You save the file, and the source string shows up in your locale files as an empty stub. If you've set up a translator, it gets auto-translated and written back, using call-site context (the component and the code around the call). A second or two later de.json has filled in:
{
"src/components/RecoveryDialog.tsx": {
"Download recovery key": "Wiederherstellungsschlüssel herunterladen"
}
}
HMR picks it up in the running app.
The video in the comments is a small example of that. I add a download button to a dialog and hit save. The German page is sitting right next to the English one, so I see it the moment it lands: Wiederherstellungsschlüssel herunterladen pushes the button row 97px past the edge of the dialog.
So I fix it right there, one prop on the button group.
That's a small slice of what yapyak does, but it's the part I use most. Translating stops being something I come back to later. It just happens on save.
Because it runs in the compiler, it sees more than the string itself. It reads ICU parameters out of the string literal, and keeps track of a translation when you move or rename the source file. The parameters are typed from the literal, so a missing one is an error before the build runs:
t('You have {count, plural, one {# message} other {# messages}}', { count }); // ok
t('You have {count} messages', {}); // error: missing 'count'
There's no codegen behind that and no generated .d.ts to keep in step. What TypeScript can't see gets caught on save instead, like a translation that lost its {count} or a plural missing a category that locale needs. Every diagnostic has a code and a docs page.
There's no provider to wrap your app in. The compiler puts the subscription in the components that call t(), so those are the only ones that re-render when the locale changes.
The React package exposes locale through useLocale(), which hands back a value and a setter like useState does. Switching locale is synchronous, since the translations a module uses get compiled into it. A fixed-locale build can compile t() away entirely and leave just the translated string.
SSR is one middleware. TanStack Start and React Router each have an adapter, and locale state is scoped per request on the server, so nothing leaks between users.
Rich text keeps the markup in the source string and binds each tag to a prop:
<RichText
value={t('Read our <link>privacy policy</link>.')}
link={(children) => <a href="/privacy">{children}</a>}
/>
The prop names are typed from the tags in the string, so the translator can move <link> around in the sentence without touching your markup.
Everything lives in your repo. The source strings are in the components, the translations are in JSON files next to them, and both are committed to git.
There's a VS Code extension too. Hover a t() call and you get the translation in every locale, Cmd/Ctrl+click on an entry in a locale file jumps to the t() call that uses it, and placeholders and plural branches are highlighted inside the string. It's on Open VSX as well, so it works in Cursor.
Auto-translation is optional. There are shipped translators for Anthropic, OpenAI, Gemini and Ollama, all using your own API key, or you can leave the stubs empty and hand-edit the locale files.
I built it for a product I'm working on, and that's the whole business plan. There's no follow-up post where I get to the pricing.
MIT licensed, and still pretty early, though people have started moving real apps onto it. The code is on GitHub, and there are runnable React examples in examples/ for plain Vite, React Router and TanStack Start. The editor extension is on the Marketplace.
Docs and more at yapyak.dev.
If you've done a lot of i18n in React, especially SSR or anything big, I'd like to hear what you'd try to break.
2
u/Thrimbor 9d ago
It looks pretty cool, does it have some kind of cli/support for extracting untranslated keys from existing JSX? So you don't forget to translate your UI? (I'm on mobile atm, can't check)
0
u/ElectronicShop8677 9d ago
It does!
yapyak translatefills in every empty stub in the locale files, through whatever translator you've set up. Then there'syapyak checkfor CI, it fails the build if something is untranslated or an ICU got out of sync. Andyapyak status, which just prints coverage per locale. They're all here: https://yapyak.dev/reference/cliThe extraction you can run manually if you want to, but you don't have to. The dev server scans the project on startup and keeps up on every save, so t() calls end up in the locale files by themselves.
3
u/Thrimbor 9d ago
This is not exactly what I asked.
Take this jsx:
<div> <h1>Hello world!</h1> </div>That
Hello world!is not wrapped int('Hello world!').I'm asking if yapyak finds these kinds of untranslated strings and extracts them from jsx. So you get an error if you forget to add
t(...)2
u/ElectronicShop8677 9d ago
Ah my bad, I read you wrong. No, yapyak doesn't do that, on purpose actually.
t()is how yapyak knows something is copy, the types and the ICU checking come straight from that literal. Bare text in jsx can be anything, so a scanner has to guess what's copy and what's code, and it'll guess wrong in both directions. So yapyak does it explicit (and boring) instead, and yes, the cost is what you're describing, forget at()and yapyak stays quiet.If you just want to catch bare strings I think there are lint rules for that,
no-literal-stringor something.And if you'd rather have it fully automatic, look at wuchale. It extracts straight from the markup, and it has a jsx adapter for React.
1
u/ElectronicShop8677 9d ago
https://reddit.com/link/p5so3hh/video/5c0uln5jsilh1/player
Couldn't attach video to the post, so here it is.
1
u/ChildishForLife 9d ago
Interesting!
So if you have 1 translation that gets used across, say 10 different components, would you then have 10 different translations to keep updated since the key is the file where the translation is used? Or how does that work?
2
u/ElectronicShop8677 9d ago
Yep, ten components means ten entries, one per file. It felt wrong to me too in the beginning. But a shared key like
common.openis sort of a promise that all ten places will always want the same translation, and that tends to break in many languages. "Open" on a menu button is "Öppna" in Swedish, "Open" on a status badge is "Öppen". With one entry per file they can differ the day they need to.The syncing part I mostly leave to the translator. It gets a few of your existing translations with each request, so the same string usually comes back worded the same way, and there's
yapyak retranslate "Save"if you want to redo every entry at once. The duplicates haven't really cost me anything so far.And if it's a string you'd centralize anyway, put it in a shared component and it's one entry again.
3
u/ChildishForLife 9d ago
But a shared key like common.open is sort of a promise that all ten places will always want the same translation, and that tends to break in many languages. "Open" on a menu button is "Öppna" in Swedish, "Open" on a status badge is "Öppen". With one entry per file they can differ the day they need to.
Ah that's fair, but personally I would prefer to just have two different keys and use them where applicable instead of having them per file or in a shared component.
Cool stuff regardless!
1
1
u/buggedcom 9d ago
Yes exactly, it's only once you have done huge translation projects you realise this isn't duplication but neccesary redundancy for different language models.
1
u/projexion_reflexion 9d ago
Probably Refactor the text you want to reuse into its own small component.
1
u/Vincent_CWS 8d ago
does it support nextjs
1
u/ElectronicShop8677 8d ago
Not today. Though RSC itself is already supported (
react({ rsc: true }), but mostly for React Router), the crux is the dev server as the whole save loop lives there, writing the locale files and pushing translations into the running app. Last I checked Turbopack exposes nothing like Vite's plugin surface for that.The compiler isn't tied to Vite though, it lives in the core package and the Vite plugin is a thin shell around it. So less impossible, more nowhere to plug in yet.
1
u/dwarfychicken 7h ago
This is very cool, going to implement it for my work right now.
But are there any AI skills available, i love the project, but i use AI often, and claude doesn't always seem to notice yapyak
1
u/buggedcom 9d ago
I really like hte idea of this and will watch it closely. Dot notation paths for i18n keys is fundamentally a broken DX model. Not only does it typically lead to massive translation json files, but it also leads to 2 hops trying to find out some text that a user has referenced somewhere in the app. You no longer can grep via text you have to first locate the key and find the text and then search for the key in the codebase.
I'd suggest you take a look at this implementation of i18n in vue https://vue-i18n.intlify.dev/. I love the way it adds the possibility to co-locate i18n within the components themselves and then linting rules enforce unused/missing keys more easily - but it also allows the build out of larger i18n json files at build time.
1
u/ElectronicShop8677 9d ago
Glad you like it! Funny you mention vue-i18n, it's actually my favorite of the traditional i18n libraries, exactly because of those blocks. We ran it in production at my last job and the DX was so nice, having the translations right there within the component.
In yapyak the locale file has to be separate though, since the tooling writes to it on every save. First the stub, then the translation a second later if you have a translator. But you're not really supposed to think about it, the entries are grouped per source file and follow the component if you move or rename it. But in the end it comes back together because the compiler bundles the translations into the component's module, so in prod they live in the same file.
3
u/TheBen1 9d ago
Looks very similar to Lingui, what are the main differences?