Internationalization (i18n) guide
August 23, 2026 · View on GitHub
Every rule's title/description, and every occurrence's summary/hint, is localized via a key-based dictionary lookup — not hard-coded per language.
Current locale coverage
| Locale | File | Keys | Values still in English |
|---|---|---|---|
en (English) | src/i18n/en.json | 666 | — (the canonical/fallback set) |
fr (French) | src/i18n/fr.json | 666 | 1 |
de (German) | src/i18n/de.json | 666 | 0 |
es (Spanish) | src/i18n/es.json | 666 | 0 |
Locale files are plain JSON: a flat map of key to translated string, in the same key order as en.json. Nothing else lives in them, so contributing a language means editing text and never touching code.
Every locale carries every key en.json has. Keeping it that way is the job of npm run i18n:sync: run it after any change to en.json and it rewrites every non-English locale file to match — adding keys that are new, dropping keys en.json no longer has, and leaving existing translations alone. A key it adds is seeded with the English text, which counts as untranslated until someone replaces it.
Forget to run it and the build fails: tests/i18n-sync.test.js and tests/i18n/i18n-locale-completeness.test.js reject a missing key, an orphaned key, or any file that i18n:sync would rewrite. npm run i18n:check reports the same thing without touching the files, and npm run i18n:report prints per-locale coverage.
Selecting a locale
runDomRulesInPage(url, null, { locale: 'fr' }, null);
Default is 'en' if omitted. Any string is accepted; an unrecognized locale is never an error.
How a locale is chosen
Two things happen, in this order. Getting them mixed up is the usual source of confusion, so they are described separately.
Step 1 — pick a dictionary (once per scan)
- Use the dictionary matching your code.
defindsde.json. Case doesn't matter —pt-br,pt-BRandPT-BRall findpt-BR.json. - Otherwise, drop everything after the first
-and try that.de-DEfindsde.json; so doesde-AT. - Otherwise, use English.
So you only need a de-DE.json if German in Austria and Germany should actually read differently. Ship de.json and every German variant is covered.
Step 2 — resolve each string (per string)
Within the chosen dictionary, every string is looked up on its own:
- Take the key's value from the chosen dictionary.
- If that key is missing, take the English one.
- If English is missing it too — which shouldn't happen for a built-in key, but can for a hand-rolled one — use the literal text the rule itself carries.
The result is never a blank, an undefined, or a thrown error. A half-finished translation renders in your language where it exists and in English everywhere else. See t() in scripts/build-core.js for the exact implementation.
Knowing which locale you actually got
Graceful fallback has one drawback: ask for a language the build doesn't carry and you get fluent English back, with nothing in the strings to say so. Every result therefore reports the resolution once, at the top:
"engine": {
"tag": "a11ycore",
"schemaVersion": "1.0.0",
"locale": { "requested": "ja", "resolved": "en", "reason": "unknown-locale" }
}
requested is what you asked for (after trimming; en if you passed nothing or a non-string), resolved is the dictionary that was used, and reason is one of:
reason | Meaning |
|---|---|
ok | You got exactly what you asked for, and that dictionary carries every key. |
primary-subtag | Your code carried a subtag with no dictionary of its own, so its base language was used — you asked for de-DE and got de. Normal and expected; nothing to fix. A difference in case alone is not this: DE reports ok. |
dictionary-not-loaded | The project ships that language, but this copy of the engine doesn't carry it and none was supplied. In practice: the standalone browser bundle without its locale side file. |
unknown-locale | The project has no such translation at all, so English was used. ja and pt-BR both land here today. |
partial-dictionary | The dictionary was used but is missing some keys, so those individual strings fell back to English. |
Treat the list as open — a later release can add a value, so match on the ones you care about and let the rest fall through a default.
If you need a particular language, requested !== resolved is the condition to check in CI. Note it is also true for the harmless primary-subtag case, so gate on reason === 'unknown-locale' || reason === 'dictionary-not-loaded' if a base-language match is good enough for you. See OUTPUT_SCHEMA.md for the field's place in the result and API_STABILITY.md for what is guaranteed about it.
Where the dictionaries live
Which languages are available depends on how you load the engine.
| How you load it | What you get |
|---|---|
require('@surea11y/core') | Every locale, built in. Nothing to configure. |
| A binding (Playwright, Cypress, …) | Every locale, built in. Nothing to configure. |
surea11y.browser.js in a <script> tag | English. Load surea11y.i18n.<locale>.js after it for anything else. |
The bundle is split because it travels over the network to every page that uses it, and no page needs all four languages. Keeping English inline and the rest optional took about 280 KB off the download and stops it growing as languages are added. Nothing else changes: the Node package and the bindings are unaffected.
<script src="surea11y.browser.js"></script>
<script src="surea11y.i18n.de.js"></script>
Ask for a language whose file you didn't load and you get English, with engine.locale.reason set to dictionary-not-loaded — different from unknown-locale, which means the project has no such translation at all.
Supplying a dictionary yourself
engineOptions.messages accepts { [locale]: { key: value } } and takes precedence over anything built in or loaded from a side file. Useful for overriding a handful of strings, or for a language you maintain privately:
runDomRulesInPage(url, null, {
locale: 'de',
messages: { de: { img_altPresent_title: 'Eigener Text' } }
}, null);
Keys you don't supply fall back normally, so a partial override is fine.
Where keys are used
Two independent key namespaces, both resolved the same way:
- Rule-level:
meta.i18n.titleKey/meta.i18n.descriptionKey— resolve a rule'stitle/descriptionon everychecksResults[]entry. - Occurrence-level:
i18n.summaryKey/i18n.hintKey, withi18n.paramsfor{{placeholder}}interpolation — resolve an occurrence'ssummary/hint. SeeOUTPUT_SCHEMA.md.
Both are included in the result alongside the already-resolved text, so you can re-render in a different locale from a saved result without re-scanning — see the i18n field's presence in OUTPUT_SCHEMA.md.
Adding your language
You do not need to know how the engine works, and you will not write any JavaScript beyond editing quoted strings. A translation is one data file.
1. Get the repository running
git clone https://github.com/SureA11y/core.git
cd core
npm install
2. Create the file
npm run i18n:new pt-BR
That writes src/i18n/pt-BR.json containing every key en.json has, in the same order, each seeded with the English text as a placeholder. Use the shortest code that identifies the language — pt, nl, pl. A file named pt.json serves everyone who asks for pt, pt-BR or pt-PT, because a code with a subtag falls back to its base language. Name it pt-BR.json and only people who ask for exactly that get it; pt speakers elsewhere fall through to English.
Add a regional file only when the wording genuinely has to differ, and add it alongside the base language rather than instead of it.
The command refuses to overwrite a file that already exists. To pick up work on an existing locale, edit it directly.
3. Translate the values
Each entry is "key": "text". Change only the text on the right:
"img_altPresent_title": "<img> must have an alt attribute",
becomes
"img_altPresent_title": "<img> precisa ter um atributo alt",
Four things to leave alone:
- The keys.
img_altPresent_titleis an identifier the engine looks up. Renaming one breaks the lookup. {{placeholder}}tokens —{{ratio}},{{element}},{{role}}and friends are substituted with real values at scan time. Keep them spelled exactly as in English. You may move them within the sentence if your grammar needs it.{{#name}}…{{/name}}and{{^name}}…{{/name}}blocks — conditional sections, shown or hidden depending on the finding. Translate the text inside them; keep the markers.- Code identifiers —
<img>,aria-label,role="dialog",alt="", CSS property names. These are things the reader will look for in their own source, so they stay in the original. A double quote inside a value has to stay escaped as\", which is the one piece of JSON syntax you need.
Write for someone fixing the page, not for a specialist: say what is wrong and what to do about it. Where your language has established accessibility vocabulary (a national WCAG translation, a government standard), follow it rather than inventing terms.
If a string is genuinely identical in your language, leave it. It is counted as untranslated but behaves correctly.
4. Check your progress
npm run i18n:report
Prints, per locale, how many values differ from English, plus any missing or orphaned keys. Anything still matching the English text is reported as untranslated — that is a progress signal, not an error.
You do not need every string on day one. Per-string fallback means an unfinished locale renders in your language where you have translated it and in English everywhere else, which is exactly how fr, de and es started. Ship what you have.
5. Before opening the pull request
npm run i18n:sync # confirms your file matches en.json key-for-key
npm test
npm run build also emits surea11y.i18n.<locale>.js for the standalone browser bundle — generated, so there is nothing for you to write.
Then open a pull request touching src/i18n/<locale>.json, plus one row in the coverage table at the top of this file. Tell us which language and, if you use one, which national terminology standard you followed — that helps whoever reviews it later.
Once a locale is in the repository it is maintained with the rest of the engine: when a new rule adds strings, npm run i18n:sync seeds them in your file in English and npm run i18n:report shows them as outstanding.
Maintaining the locales
When you add, rename or remove a key in src/i18n/en.json:
npm run i18n:sync
Every other locale is rewritten to match — new keys seeded in English, removed keys dropped, existing translations untouched. The command is idempotent and prints what it changed per locale.
| Command | Does |
|---|---|
npm run i18n:new <locale> | Create a new locale file from en.json. Refuses to overwrite. |
npm run i18n:sync | Bring every locale file back in line with en.json. Add -- <locale> to restrict it to one. |
npm run i18n:check | Same comparison, writes nothing, exits non-zero on drift. |
npm run i18n:report | Per-locale translation coverage. |
npm test fails if a locale file has drifted, so an added key cannot reach main without every locale carrying it.