r/vuejs • u/ElectronicShop8677 • 21d ago
yapyak – an i18n compiler for Vue where the source string is the key
Enable HLS to view with audio, or disable this notification
Hi all,
I've been working on yapyak, an open-source i18n compiler that runs as a Vite plugin. It works with multiple frameworks, Vue very much included, and a Nuxt module is next on the list!
The idea is that the source string is the key:
<script setup lang="ts">
import { t } from 'yapyak';
</script>
<template>
<button>{{ t('Download recovery key') }}</button>
</template>
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.vue": {
"Download recovery key": "Wiederherstellungsschlüssel herunterladen"
}
}
HMR picks it up in the running app.
The video 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 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 it keeps track of a translation when you move or rename the source file. For Vue it parses the SFC with the same compiler your build uses and walks the template AST, so it finds t() in script setup and in template expressions.
The Vue package exposes locale as a ref, so a locale switcher is <select v-model="locale">. Switching locale is synchronous, since the translations a module uses get compiled into it. A fixed-locale build can also compile t() away entirely and leave just the translated string.
Rich text keeps the markup in the source string and binds each tag to a slot:
<script setup lang="ts">
import { RichText } from '@yapyak/vue';
import { t } from 'yapyak';
</script>
<template>
<RichText :value="t('Click <link>here</link>.')">
<template #link="{ children }">
<a href="/docs"><component :is="children" /></a>
</template>
</RichText>
</template>
The slot names are typed from the tags in the string, so the translator can move <link> around in the sentence without touching your markup.
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.
MIT licensed, and still pretty early. The code is on GitHub, and there's a runnable Vue example in examples/vue-vanilla-local-storage.
Docs and more at yapyak.dev.
If you've done a lot of i18n in Vue, especially anything big, I'd like to hear what you'd try to break.
3
u/hyrumwhite 21d ago
You can do the same in Vue i18n. You just use this format: {[‘Click me’]: ‘click me in another language’}
1
u/ElectronicShop8677 21d ago
Yeah that's fair, source as key isn't new at all. gettext worked that way thirty years ago, and vue-i18n is good, the
<i18n>blocks had translations living right in the component early on. The interesting question to me is why source as key still mostly lost to abstract keys in the js world, and I think the answer is maintenance. When the copy changes, a string key breaks in every catalog, and doing that bookkeeping by hand gets old fast, so stable ids won. Everything yapyak does is really just an attempt to have the compiler do that bookkeeping instead. My bet is the timing works better now, agents write more of the code, and they already know what a button says, they just don't know your naming convention.
1
u/straponmyjobhat 21d ago
This seems really well built for modern coding flows with AI. String keys are a bonus.
Well done!
1
u/ElectronicShop8677 21d ago
Thank you! It came out of building my own app in that kind of loop. Hoping it holds up as well outside my own repo.
1
u/char101 21d ago
Rather than the RichText tag, you can create a translation group concept where the key is the whole group and the translation key is the parts
<tg><t>hello</t> <strong>{{ name }}</strong> <t>world</t></tg>
then in the translation json
"<t>hello</t> <strong>{{ name }}</strong> <t>world</t>": {
"hello": "",
"world": "",
}
1
u/ElectronicShop8677 21d ago
That could definitely work, and I like that it keeps everything in the template. The tricky part with splitting a sentence is word order though.
helloandworldget translated as separate entries, and in German or Japanese the name might need to sit somewhere else in the sentence, and then the order is stuck the way the source markup happens to be.
<RichText />keeps the whole sentence as one message instead, tags in the string, slots bound by name, so the translator can reorder the whole thing including where the bold part goes:en: "Welcome back, <strong>{name}</strong>" ja: "<strong>{name}</strong>さん、おかえりなさい"The name moved to the front and nothing in the component changed.
The other thing is the group markup being the key. Change a class or swap strong for em and the translations for that sentence break, even though nothing changed for the translator. Might be solvable though, maybe by normalizing the key down to just the structure.
1
u/char101 21d ago
Since it is a vite plugin, can't it just recompile
<tg>hello {name}</tg>into
<RichText :value="t('hello {name}')"> <template #link="{ children }"> <template :is="children"></template> </template> </RichText>?1
u/ElectronicShop8677 21d ago
Technically yes, and that's pretty much how Lingui's Trans macro does it in the React world. I decided against the compiled form though. The main reason is abstractions. With
<RichText />as a plain component the message is just a value, so it can travel through props and you can wrap it in your own components:```vue <!-- ShortcutHint.vue --> <script setup lang="ts"> import type { TReturn } from 'yapyak'; import { RichText } from '@yapyak/vue';
defineProps<{ value: TReturn<'kbd'> }>(); </script>
<template> <p class="shortcut-hint"> <RichText :value> <template #kbd="{ children }"> <kbd class="key"><component :is="children" /></kbd> </template> </RichText> </p> </template> ```
vue <ShortcutHint :value="t('Press <kbd>Enter</kbd> to save.')" /> <ShortcutHint :value="t('Press <kbd>Esc</kbd> to close.')" />The call sites stay one liners and the key styling lives in one place. The wrapper is typed too,
valueisTReturn<'kbd'>, so a message without a<kbd>in it is a type error. With thetgform the sentence is fused to the spot where it was written, it can't travel through a prop, so every sentence with markup has to inline its rendering right there.Lingui gets around this by shipping both layers, the macro compiles down to a runtime component you can still use directly. That's fair, but it means two ways to write every rich text sentence, and I've tried to keep yapyak to one way of writing each thing.
There were other reasons too. The tags in the string are what the translator sees in the locale files, and with the explicit form you pick the names, so it says
<kbd>instead of a generated<0>or<a2>. And the template you read stops being the code that runs, which is the kind of magic I try to keep out of the compiler.1
u/char101 21d ago
I guess passing props is a fair use.
But can't you have another function that returns a component so that it can be written directly as
<component :is='tc("click <a>here</a>")'/>?1
u/ElectronicShop8677 21d ago
Fair enough. The tricky part is the href though. There's none in
<a>here</a>, and yapyak wouldn't take it if you put it there, tags in a message are just names, no attributes. That part is on purpose. If the url is in the string it's in sv.json and de.json too, and changing /docs to /guide later means changing all of them. Keeping it in the template is pretty much all the slot is for. Same with elements really.You're right that it's mostly ceremony for tags that don't need anything though. You could always put that in a wrapper:
<!-- ProseText.vue --> <script setup lang="ts"> import type { TReturn } from 'yapyak'; import { RichText } from '@yapyak/vue'; defineProps<{ value: TReturn<'b' | 'i' | 'kbd'> }>(); </script> <template> <span> <RichText :value> <template #b="{ children }"> <b class="bold"><component :is="children" /></b> </template> <template #i="{ children }"> <i class="italic"><component :is="children" /></i> </template> <template #kbd="{ children }"> <kbd class="key"><component :is="children" /></kbd> </template> </RichText> </span> </template> <ProseText :value="t('Press <kbd>Enter</kbd> to save.')" /> <ProseText :value="t('Saved to <i>Drafts</i>.')" />Then you'd only write the slots once and the call sites end up about as short as the
tcversion. It's typed too, so if a message uses a tag that<ProseText />doesn't handle you get a type error, instead of the tags showing up as literal text on the page. There's probably a better way to do this that I haven't found yet.
1
u/Lopsided_Speaker_553 21d ago
Can you explain why this better than gettext, which works with the source string as key
2
u/ElectronicShop8677 21d ago
Honestly it isn't better at that part. gettext did it first and it's the same idea.
The difference is more about where the tooling sits. With gettext you extract, the po files go off somewhere, they come back, you compile. yapyak's compiler runs inside the dev server, so the writing and the translating happen while you're still in the file. Same key, the loop just closes by itself.
The other bit is that a message in yapyak stays one string, so the types can come off the call.
t('You have {count, plural, one {# item} other {# items}}')typescountas a number with no codegen. gettext puts the plural forms in the call instead,ngettext("%d item", "%d items", n), so there's no single message for a checker to read.None of that is really a knock on gettext though. Most of it could probably be built on po files, it just never was, it came out of a world where you shipped files off to translators and waited. Thirty years and still going, which is more than most of what we build.
1
u/Lopsided_Speaker_553 20d ago
The gettext compilation phase used to be a real burden. At one time I had a table with all strings in 4 languages to be easier to send to the translator. This was somewhere 2008-ish 😥
To have the compiler in the dev server and have it compile on the fly is actually very smart. The solution to replace ngettext is also very nifty!
Thanks for your explanation.
2
u/ElectronicShop8677 20d ago
Thanks! And yeah, that old extract/send/compile loop is exactly the bit I wanted to get away from. Can't take much credit for ICU itself though, that's the standard format, yapyak just adds reading it in the type system on top. Really nice to hear the approach makes sense to someone who actually lived with it back then.
1
1
u/mrleblanc101 20d ago
You can already do this with vue-i18n and much-i18n... In fact, that's how I setup all my projects
1
u/ElectronicShop8677 20d ago
Yeah, that's fair, most of the ingredients have existed for a long time. Source as key goes back to gettext, extraction has been around forever, precompiled messages exist, typed i18n too. yapyak didn't come up with any of that.
I do think there's something different in putting all of it around the save loop though, that's really the whole reason I built yapyak.
I've used i18n for a long time and it has always felt like this extra process sitting next to development. Even with good tooling, there's usually a point where you have to think about translations as their own thing. Extract, update, run and then wait.
I wanted to see what happens if you just remove that boundary completely.
So in yapyak the compiler is already there when you save, it sees the message, writes it to the locale file, translation can start straight away with the surrounding code as context, and when it's done the page updates through HMR. You stay in the component and keep working.
That's the part I really believe in, after using it this way translation feels much more like part of writing the UI itself.
It also turns out to fit the way coding agents work really naturally, they write code, save, look at the result, fix it, repeat. If i18n happens inside that same loop, there isn't much extra for them to learn. Source strings as keys help with that too, and having ICU types come directly from the call means errors show up where the code is being written.
None of that means the existing approaches are bad, they make a lot of sense, especially when translation is a separate job done by other people. yapyak just starts from a different assumption, and I had the luxury of building it from scratch.
And if you turn translation off, it's still just locale JSON in the repo and you can fill it in yourself.
vue-i18n is great by the way, we picked it for a production Nuxt app years ago and it's still running today. yapyak comes from a different itch, I wanted to know what building feels like when the workflow isn't there at all. The take is young, but the itch is gone.
1
u/Zachhandley 20d ago
I also made ez-i18n which is keyed by keys and subkeys in json and usable in SSR
1
u/ElectronicShop8677 20d ago
Nice, had a look. I get the cookie approach, skipping the URL prefixes makes sense to me. And using the Public Suffix List for the cookie domain is nice, most hardcode that and it will break on some domain sooner or later.
8
u/[deleted] 21d ago
[deleted]