Close

The Smallest Possible i18n: One JavaScript File, No Framework, No Build Step

johnsonfarmsusJohnsonFarms.us wrote 2 days ago • 8 min read • Like

The Smallest Possible i18n: One JavaScript File, No Framework, No Build Step

I have a static HTML site with no React, no Vue, no bundler, no build step. cp is my deploy tool. I wanted to translate the whole thing into Japanese, Chinese, and French. The finished site lives at saturacorp.com, and this post walks through the i18n approach I used to build it. The translation file and runtime are visible via view-source on any page, so you can follow along with real examples as you read.

Every i18n tutorial I could find assumed a framework. react-intl, vue-i18n, Next.js's next-i18next, formatjs, they all want either a component model or a build step, usually both. The pattern is always the same: annotate your markup with abstract keys, store translations in JSON files keyed by those names, and let the build or runtime swap the strings.

That didn't fit saturacorp.com. The whole appeal of the site was that the HTML stayed human-readable. Annotating every heading and paragraph with data-i18n="home.businesses.heading" would have polluted the markup with framework apparatus before any visitor saw the page.

So I took a different path. I used CSS selectors as translation keys. This post is about what that looks like, why it worked for the site, and the specific kinds of bugs it produces that a key-based system would never have. The point isn't "here's a new way to do i18n," it's "here's an approach with a very specific shape of constraint where it fits, and a very specific failure mode that should keep you from using it almost anywhere else."

What the standard approach looks like

In a key-based i18n system, the homepage's "Group Companies" heading on saturacorp.com would look like this in the markup:

<h2 data-i18n="home.groupCompanies.heading">Group Companies & Affiliates</h2>

And the translation file:

{
  "en": { "home.groupCompanies.heading": "Group Companies & Affiliates" },
  "ja": { "home.groupCompanies.heading": "グループ会社・関連法人" }
}

A runtime, or a build step, finds all elements with data-i18n attributes, reads the key, looks it up in the active language's dictionary, and replaces the content. This is what every mainstream i18n library does, with framework-specific variations on the markup syntax.

What I did instead

The dictionary on saturacorp.com is keyed by CSS selectors that target the rendered DOM, not by abstract names that the HTML knows about. The entries look like this, pulled directly from the live site's saturacorp-language.js:

{
  "ja": {
    "#group-companies h2": "グループ会社・関連法人",
    ".ref-aff-chaos": "人間・AI協調システムおよび製品開発に向けたベンチャー・テクノロジー・スタジオ。",
    "#privacy > p:nth-of-type(2)": "技術的開示事項として一点..."
  }
}

A roughly 30-line runtime walks each entry, runs document.querySelectorAll(selector), and rewrites the matched elements' content. The HTML itself is completely untouched. No data-i18n attributes anywhere, no template syntax, no key references. View-source on saturacorp.com from any page and you'll see ordinary semantic markup with nothing translation-aware in it. The HTML doesn't know it's being translated at all.

That's the unusual part. I haven't seen a major published i18n library that uses this approach as its primary mechanism. The closest cousins are not mainstream i18n libraries but userscript translation tools, the Greasemonkey and Tampermonkey scripts that translate sites the user doesn't control. When you can't modify the HTML, you have to target what's already there with selectors. A few small experimental libraries have explored CSS-selector i18n, but none have become standard. The pattern is "known but not popular."

Why it works for this site

The constraints of saturacorp.com happened to align with the approach's strengths:

The bug that taught me the real downside

About 200 entries in, I added a translation for the about-page's "Group Companies" section heading on saturacorp.com. The about page at saturacorp.com/about.html has this structure:

<article id="group-companies" class="legal-section">
  <h3>4. Group Companies</h3>
  <p>A curated selection of Group entities is presented in...</p>
</article>

So I added this to the Japanese dictionary:

"#group-companies h3": "4. グループ会社 <em>Group Companies</em>"

The translation worked correctly on the about page. I checked. Then I switched to Japanese on the homepage and noticed something odd.

The homepage also has an element with id="group-companies". It's the "Group Companies & Affiliates" grid at the bottom of saturacorp.com. Its structure looks completely different from the about page:

<section id="group-companies">
  <header class="section-head">
    <h2>Group Companies & Affiliates</h2>
  </header>
  <ul class="affiliates-grid">
    <li>
      <h3>Harnessing Chaos LLC</h3>
      <p>Venture technology studio for human-AI orchestrated systems...</p>
    </li>
    <li><h3>Johnson Farms US</h3>...</li>
  </ul>
</section>

The first <h3> inside #group-companies on the homepage is the first affiliate card's title, "Harnessing Chaos LLC." The selector #group-companies h3 silently matched it. In Japanese mode, the first card's title now read "4. グループ会社 Group Companies," a numbered section heading sitting inside an unrelated card grid. The card's description text below it stayed correct, which made the bug almost worse. Only the title was broken, so it read as a mysterious editorial error rather than a translation glitch.

This is the canonical CSS-selector-i18n failure mode. The selector worked fine in the context I tested it in. It silently misfired in a different context where the same selector ancestry happened to exist. A key-based i18n system would never have that collision. Abstract keys are inherently uncoupled from DOM structure. CSS selectors are inherently coupled to it.

The fix was easy. Tighten the selector to article#group-companies > h3 so it only matched the about-page's <article> element and not the homepage's <section>. But the existence of the bug taught me to audit for these collisions every time I added a translation targeting a generic ancestor-and-tag pair. Selectors like #some-id h2 or .some-class > p, any selector that doesn't have a deeply specific ancestry chain, become a candidate for silent collision.

The other trade-offs you need to be honest about

Beyond the collision issue, the CSS-selector approach has costs that key-based systems don't:

When the trade-offs make sense

Looking at that list, the approach fits a specific shape of project surprisingly well, which is the shape saturacorp.com has:

It does not fit:

The full runtime, conceptually

The entire i18n machinery for saturacorp.com fits in roughly 70 lines of vanilla JavaScript. The full source is at saturacorp.com/saturacorp-language.js if you want to read it directly. The shape of it:

That's the whole machinery. Markup stays clean, deploy stays as cp, the dictionary stays human-curated. The trade-off I made: selector brittleness in exchange for a simplicity ceiling that no build-tool-based system can match.

Closing

For most real products, use i18next or whatever your framework's i18n library is. The patterns are mature, the tooling is real, and the trade-offs are well understood.

For a small, hand-curated, static-HTML site where the constraint of "no build step" is itself a feature, the CSS-selector approach is genuinely better. Clean markup, zero dependencies, debuggable in DevTools, retroactively applicable. It will not scale, and you should not let it scale. Within those bounds, it's the smallest possible thing that works.

Live demo at saturacorp.com. Toggle EN, JP, CN, FR in the masthead. URLs like saturacorp.com/jp auto-load the corresponding language for share links. View-source on any page, and the markup is the markup.

Like

Discussions