Skip to content
Development

Teaching an app to speak in a language it doesn't know yet

By Victor Da Luz
sveltei18nrustdev-loggreenhouse

Greenhouse has had an i18n issue sitting in the backlog for a while: eventually add Spanish and Portuguese, since I can review both languages myself. A spike back in early July scoped it out, found roughly 170 translatable strings, and recommended splitting the work into two pieces: fix an architectural issue with how stage names get stored first, then do the actual translation pass later, once the app is something more than a local build I run myself. This week I picked the translation piece back up, and it turned out to be a good lesson in scoping work down to something you can actually finish in one sitting.

The string count kept climbing

Before touching any code, I re-inventoried the app’s translatable surface, since the original spike was a few weeks old and a lot had shipped since. The number went from roughly 170 strings to roughly 230: four new screens (a harvest view, a kanban board, a vault-damaged recovery screen, a rules dialog) that didn’t exist yet when the spike ran. That kind of drift is exactly why I don’t trust an old estimate without re-checking it.

The bigger scoping question was what “add Spanish and Portuguese” actually means as one unit of work. Extracting every hardcoded string into a translation system is mechanical and fully checkable by a machine: build succeeds, tests still pass, nothing changed visually. Writing the actual Spanish and Portuguese is not mechanical. Words like “Budding” and “Leafing” don’t have clean equivalents in either language, and I want to review those word choices properly, not rubber-stamp a machine draft.

So I split it three ways instead of doing it all in one issue: extraction and tooling now, with English as the only shipped language; the Rust-side error messages later, since they’re built as pre-formatted strings deep in the backend and need a real architecture change, not just a translation file; and the actual Spanish/Portuguese content later still, as its own issue where I can sit down with the words and take them seriously.

Parallelizing the boring part

Once scoped down to “extract every string, keep the English exactly the same,” the work was mechanical but spread across nineteen files. I split it into one task per file and ran them in parallel, each with the same instructions: find every user-facing string, give it a name, replace it with a function call, don’t touch the rendered text, and never use {@html} even if it would make a sentence easier to build, since a couple of these dialogs render user-typed project names and notes, and dropping a template literal into raw HTML is exactly the kind of shortcut that turns into a real injection bug later.

That constraint mattered more than I expected. One dialog logs a touch with a sentence like “Logs a touch on your project name and sinks it to the bottom of your worklist,” with the project name in bold. The tempting shortcut is one string with the bold baked in via raw HTML. Instead, the bold tag stays as real markup in the template, and the sentence around it splits into a “before” piece and an “after” piece, each its own translatable string. A little clunkier to write, considerably safer to ship.

A silent failure that looked like success

The properly aggravating part came during verification, after all nineteen files were done and merged. The build succeeded. No errors. And yet every single translated string in the compiled app was undefined.

It turned out my hand-written project config was missing one setting the translation plugin needs to know where to find the translation files. Without it, the plugin doesn’t error, it just quietly decides there are no messages to compile. A clean build with zero translated content looks exactly like a clean build with fully translated content, right up until you actually read the compiled output. I only caught it because I went looking at the generated file directly instead of trusting the green checkmark. Once fixed, a second issue showed up immediately: the plural syntax I’d used for “5 items” versus “1 item” wasn’t the syntax this particular plugin actually expects, it wanted its own declarative form instead of the standard shorthand. That one at least had the decency to fail loudly.

Lesson

A build passing is not the same as a build doing what you think it’s doing. “Zero errors” only tells you nothing broke, not that anything worked, and the two claims are further apart than they feel in the moment. Also: when a piece of work naturally contains a part you can verify by machine and a part you can only verify by judgment, that’s usually a sign it’s actually two issues wearing one label.

Related reading