Rehoboth Builds

chrome.i18n formats nothing, and getUILanguage() is not the locale your strings came from

Translating an extension is not the hard part. The hard part is that all four of the ways it goes wrong are silent: no exception, no console warning, and nothing visibly broken in the language you happen to read. You find them by shipping, or by writing an assertion that fails when you break it on purpose.

8 September 2026 · from adding three more locale files to a shipped extension

1. $ is a placeholder sigil, and the damage is per-file

In messages.json, $1$9 are positional substitutions and $name$ is a named one. A literal dollar has to be written $$. That much is documented. What is not obvious is the shape of getting it wrong: the string still loads, the extension still runs, and the only thing that changes is that the price is no longer the price you wrote.

Now put that in a project with a dozen locale files. Whoever reviews the change reads the English one, where the dollar is escaped, and approves it. The Korean file with the missing $ is a file nobody in the room reads. This is why we do not hold it by review: an assertion compares the number of $$ in every locale file against the English one, and a file that disagrees fails the build. It costs one line and it is checkable by someone who does not speak the language.

2. getUILanguage() answers a different question

chrome.i18n.getUILanguage() returns the browser's UI language. The strings you actually got back came from whichever _locales folder Chrome matched — and when there is no match, that is your default_locale. Those two are the same value only when you happen to have a file for that language.

It matters because getUILanguage() is exactly what you reach for when setting document.documentElement.lang. A user whose browser is in a language you do not ship gets English text inside an element declaring itself to be that language — wrong for a screen reader, wrong for a hyphenation engine, wrong for anything downstream that trusts lang.

The fix does not need an API. Put the tag inside each message file — one non-user-visible key whose value is that file's own BCP-47 tag — and read it back through getMessage. Whatever comes back is by construction the locale of the strings on screen, because it travelled with them.

3. For direction, do not keep your own list of RTL languages

The same trap has a built-in answer here, so use it: chrome.i18n.getMessage('@@bidi_dir') returns ltr or rtl for the locale Chrome actually selected — the same source as your strings, so the layout cannot end up left-to-right while the text is Arabic.

The tempting alternative is a hard-coded set like {ar, he, fa}. It is wrong twice: it misses Urdu, Pashto, Sindhi, Sorani Kurdish, Yiddish and Dhivehi, and it is keyed off a locale value that, per the previous section, may not be the one your strings came from.

4. The folder name is Chrome's, and a typo is not an error

Locale directories are Chrome's identifiers, not the BCP-47 tag you would write in HTML: the separator is an underscore, so it is zh_CN and pt_BR, not zh-CN. And a folder Chrome does not recognise — kr where you meant ko — produces no error at load and no warning at runtime. It is simply never matched, so every user of that language silently receives default_locale instead. A translation you paid for can be absent from the product for months with every check still green.

5. Nothing is formatted for you

chrome.i18n is string substitution. It does not know that a price is a price. If you keep 4.99 byte-identical across your files for consistency, it reads as wrong to a German, French, Italian or Russian user, who writes 4,99. That is a per-locale editorial decision that has to be made in the file, by hand, and it is invisible to any test that only checks that keys exist.

What a test can check is that the set of keys in every file matches the English one exactly — missing keys fall back one at a time and produce a half-translated panel — and that the tag in section 2 equals the folder name it sits in.

How we know these assertions work

Every failure on this page is silent, which means a green test proves nothing on its own: an assertion that never fails and an assertion that cannot fail look identical from the outside. So for each one we break the thing on purpose and confirm precisely the expected check goes red — drop one $ from a price and the escape-count check fails; delete a key and the key-set check fails; write kr for Korean and the tag check fails — then restore the files and confirm they are byte-for-byte what they were. The checks run over every locale file in both of our extensions with no browser involved, which is why they run on every change rather than only before a release.

Where this came from

All of it came out of Highlight Reader, a Chrome extension that reads a page aloud and highlights each sentence as it speaks. It is free on the Chrome Web Store, needs no account, and uses the voices Chrome already ships. To be exact about what you would be installing: the version on the store today carries ten locale files; the three that prompted this page are written and tested but are not on the store yet. A paid add-on — word-level highlighting and higher-quality voices — is described here, and it is an add-on, not the extension.