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.