Shipping WebGPU translation via Babulfish
In the first version, I just wanted live translation on my resume. I had no grand library plan. I wanted a button, a big irresponsible model download warning, and a way to make my site speak Spanish without paying for server inference.
That version worked. It also had the exact shape of a one-off feature.
The code knew about my sidebar. It knew about my essay markdown wrappers. It knew which company names should never be translated. It knew how I wanted section headers to pulse when translation landed. It knew a bunch of very specific things that were perfectly reasonable inside one app and didn't make sense inside a public package.
Then the obvious thing happened: I wanted to use it somewhere else.
Not a huge product. Just the same basic trick: local-only translation, running in the browser, without sending someone's writing through a server I control. That felt useful enough to extract.
It was also exactly the kind of extraction that can go wrong quietly. You move the code into packages/whatever, fix the imports, publish it, and now you have a library that... really, isn't a library. Technically reusable. Actually a torn out piece of code with lots of cruft.
I did not want that.
The first split was pretty easy. Model loading did not belong to the site. Neither did download progress, WebGPU vs WASM selection, the translation state machine, abort handling, DOM walking, phased translation, restoring original content, or translating repeated labels once and fanning the result out to every matching node.
The site-specific stuff was just as obvious. My selectors. My animation classes. My button. My punctuation cleanup. My list of names to preserve. The fact that my essays keep original inline markdown around in data-md.
That split became babulfish. The name is a small joke. "Babul" is another name for the acacia or gum arabic tree, which survives dry conditions. The library survives dry token budgets. I am pretty pleased with myself for coming up with that one!
The package structure is intentionally decomposed:
@babulfish/coreowns the engine, the DOM translator, and the public contract.@babulfish/reactis a thin React binding over the same core.@babulfish/stylescarries the CSS contract.babulfishexists as a permanent unscoped alias because package naming on the internet is still a little stupid.
@babulfish/core had to be useful without knowing anything about my website. React could get a nicer wrapper, but it could not be the center of the design. Styles could exist, but the engine could not require my taste in buttons. The unscoped alias could sit there and make npm a little less annoying.
The rule was simple: if a React app, a plain DOM app, and a custom element could all drive the same engine, I had probably found a real boundary. If they could not, I was just filing down my website until it looked like a package.
The hardest part was still the DOM.
Model loading is a solved enough problem now. The nasty part is translating real page content without wrecking it.
A normal page is not one big string. It is text nodes, links, bold spans, code, headings, tooltips, repeated labels, and whatever little crimes your renderer committed on the way to the browser. If you translate raw text nodes one by one, you get nonsense. I already learned that lesson in the site version. A sentence split across inline tags turns into soup fast. Many languages switch adjectives and nouns. This meant, creatively inlined links would need to survive this, too.
The extraction had to preserve the things that made the original feature decent. linkedBy handles cases like data-section-title, where the same label appears in more than one place and should translate identically. richText handles authored source strings like data-md, where the right thing to translate is the original markdown, not the rendered fragments. structuredText handles inline-rich prose that lives directly in the DOM and still needs to be treated as one logical unit. Hooks and output transforms give the library flexibility to meet my use-case or any other, with a clearly defined edge.
The funny thing is that once you do that work, the original app gets simpler almost immediately.
The current site-side translation setup is mostly configuration:
export const SITE_TRANSLATOR_CONFIG = {
engine: {
device: "webgpu",
},
dom: {
roots: ["[data-translate-root='sidebar']", "main"],
phases: ["[data-translate-root='sidebar']", "main h2", "main"],
linkedBy: {
selector: "[data-section-title]",
keyAttribute: "data-section-title",
},
richText: {
selector: "[data-md]",
sourceAttribute: "data-md",
render: renderInlineMarkdownToHtml,
},
structuredText: {
selector: "p, li, figcaption, h1, h2, h3, h4, h5, h6",
},
translateAttributes: ["title"],
outputTransform: normalizeDomOutput,
},
}That is much better than the original pile.
The site now mostly says what it wants. Use WebGPU. Translate the sidebar first. Treat markdown-backed spans as source text. Link repeated section titles. Normalize the output afterward. The library handles the annoying machinery, and the site keeps its taste.
That boundary also made the agent work better. Instead of asking it to wade through one large translation feature with site concerns braided through it, I could point it at a contract and have it simply bolt stuff together.
I still did not trust the extraction until I had tested boundaries.
So the repo now has three demos: a React demo, a zero-framework DOM demo, and a web component demo with isolated Shadow roots sharing one engine. They originally existed so anyone can easily cargo-cult and go. They also help catch a different kind of fake portability. These days, I'd rather not read all my agent-written code. So, I prefer to litigate interfaces and use tests/demo apps to get the rest of the way there. If the code there looks sane and each of them can exist with fairly neat code, then the seams are in the right places.
Each demo ends up validating a boundary. I came away liking the extraction more than I expected
Partly because the site got quieter. Partly because I now have a reusable thing I can point at new experiments. It was a proving ground for the generality of continuous-refactoring (article, repo). Mostly because the original feature had already earned its keep before I tried to name the abstraction.
That matters. It is easy to invent a library in a vacuum and then wait for reality to agree. Reality tends to take its time.
I also have never shipped a library or tool to any official package repository. However pedestrian it may be for others, it was cool to do that. A first for me.
So, that is babulfish.
It started as "make my site cool!"
Then it became a globe button, a DOM translator, and a pile of lessons about inline content. Then it turned into a package set with an actual contract, shared tests, and multiple integration surfaces.
If you want to try it, start with @babulfish/react on npm. If you want the lower-level bits or just want to read the code, the whole thing is open source at github.com/bigH/babulfish.