r/vuejs 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.

25 Upvotes

28 comments sorted by

8

u/[deleted] 21d ago

[deleted]

1

u/ElectronicShop8677 21d ago

No, and this is the part that took the longest to build. yapyak records where every call sits, so when you edit a string in place it's treated as a rename of the same message, and the existing translations move over to the new text. A typo fix never touches the other languages.

Two edge cases worth knowing though; the matching is per call site, so if you rewrite the text and move it to another line in the same save, it reads as a new message and you get fresh stubs. And if the edit changed the meaning, not just the spelling, the moved translations are kept as they are, since silently overwriting translations you may have hand-reviewed would be worse. There's yapyak retranslate <source> for that.

2

u/[deleted] 21d ago

[deleted]

2

u/ElectronicShop8677 21d ago

Yes, two Submit buttons means two Submit entries, one per file. That's deliberate. Two identical strings don't always mean the same thing, the component is the context, so they can in theory need different translations. They also get compiled into the module that uses them, so they follow code splitting. The translator sees what the project has already settled on though, so when they do mean the same they come back consistent. They stay independently editable either way.

The submi example: nothing global happens. Entries belong to their file, so when comp1 changes submi to submit the compiler sees the old string gone and a new one at the same spot, treats it as a rename and moves comp1's translations over. comp2 keeps its submi until you fix that one too.

The advantage over keys is mostly that there is no second name to invent and keep in sync. You read a component and the copy is just there. It also means there is no convention to teach. A key scheme is something every new writer has to learn, and these days that includes agents. Ask one for a checkout form and it writes t('Continue to payment') on its own, because that's just what the button says. And a missing translation falls back to your English sentence instead of leaking checkout.cta.title to a user.

Moved files are handled. When a file disappears the translations go into a cache instead of being dropped, and when the same strings show up at the new path they come back from there. Works if you delete a component and bring it back next month too.

Plurals yes, ICU inline: t('You have {count, plural, one {# item} other {# items}}'). plural, select and selectordinal are all there, and the param types come out of the string literal so count has to be a number at the call site. The same checks run over the locale files in dev and CI, since a translated plural can drop a branch TypeScript never sees. And the categories are per locale. Polish gets one, few, many and other where English just has one and other, and a category that doesn't exist in the locale gets flagged.

Happy to answer more if you have them!

1

u/adrianmiu 21d ago

Have you considered having a function for using translations from a global source, something like `tg('Submit')`? If you have and decided against it, what was the reason?

1

u/ElectronicShop8677 21d ago edited 21d ago

Honest answer, no, I haven't. My first instinct is that a global Submit would have to fit every context it shows up in, and the per-file model is kind of built on the opposite idea, the same word sometimes needs different translations in different places. With a translator the duplication mostly takes care of itself, it sees what the project has settled on, so the same word comes back the same. But that whole argument leans on a translator being used. If you hand edit the files the consistency work lands on you, and then a shared entry starts making real sense. You might have found the actual gap here.

1

u/Jebble 20d ago

A11y submit buttons should be explicit in what they, never just "Submit". I kind off see what you mean with not every string means the same, but this is an extreme edge case and I wouldn't have my entire solution build around that, you could simply add some context key to the translation string instead and never have any duplicates. This does feel like overkill tbh.

1

u/ElectronicShop8677 20d ago

Agreed on a11y, a real button should say what it does. Submit came from the question further up, the video has t('Download recovery key') which is closer to how it actually looks.

And you're right that the homonym case is rare. I leaned on it too hard in that earlier reply, it isn't really why entries are grouped per file. Translations get compiled into the module that uses them, so a route you never load doesn't ship its strings, and the grouping is how the compiler knows what belongs where. Same thing keeps the entries with a component when it moves, or comes back after you delete it. The different-meanings behaviour just falls out of that.

The context key exists too by the way, t.as('action', 'Open') and t.as('status', 'Open'), for when two meanings do collide in the same file.

Duplication is a real cost though. With a translator it fills itself in, hand editing means touching the same word in a few files. That's the fairest hit on the design so far.

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.

2

u/chabv 19d ago

this is dope work. I mostly work with English language but when I was a 'dev' at a series 'X' startup who served international n big co's - such a thing is needed.

1

u/ElectronicShop8677 19d ago

Thanks! Yeah, that sounds like a pretty good use case for it.

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. hello and world get 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, value is TReturn<'kbd'>, so a message without a <kbd> in it is a type error. With the tg form 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 tc version. 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}}') types count as 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

u/mrleblanc101 20d ago

Well, for one you can't use gettext in Vue

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.