Validate messages.json before shipping
A single error in any messages.json file — a missing comma, a wrong placeholder name, a key that exists in English but not in German — can silently break your extension for an entire locale. None of these errors throw at runtime; Chrome and Firefox just return empty strings or fall back silently. Here is how to catch every class of error before your users do.
Why validation is easy to skip — and why you shouldn’t
Most i18n bugs in browser extensions are silent. The extension loads, the UI renders, and everything looks fine — until a German-speaking user opens the popup and sees an empty button label. Or a Japanese user gets an English string where the translated one should appear. Or the Chrome Web Store rejects your upload because a __MSG_key__ in your manifest references a key that doesn’t exist in your default_locale file.
There are four distinct categories of messages.json errors, each with different failure modes:
JSON syntax errors
fails loudlyTrailing commas, unescaped quotes, or missing braces prevent the entire locale from loading.
Missing required fields
silent failureEvery message entry must have a message field. Missing it causes getMessage() to return an empty string.
Missing keys across locales
silent failureA key in your default locale that is absent from a translated locale silently falls back to English.
Malformed placeholder syntax
silent failureA renamed or dropped $PLACEHOLDER$ token causes the substitution to disappear entirely from the UI.
1. JSON syntax errors
This is the only class of error that fails loudly. messages.json must be valid JSON — no trailing commas, no comments, no single-quoted strings. A syntax error in any locale file prevents the entire locale from loading. Chrome and Firefox silently fall back to default_locale when a locale file is broken, so the user gets English strings with no indication that something is wrong.
Common syntax mistakes:
// ✗ Trailing comma after the last key
{
"appName": {
"message": "My Extension"
}, ← remove this comma
}
// ✗ Unescaped double quote inside a string
{
"tooltip": {
"message": "Click \"here\" to continue" ← must escape with backslash
}
}
// ✗ Single-quoted strings (not valid JSON)
{
'appName': {
'message': 'My Extension' ← JSON requires double quotes
}
}How to catch them:
# Node.js — parse each locale file
node -e "JSON.parse(require('fs').readFileSync('_locales/de/messages.json','utf8'))"
# Shell — check all locale files at once
for f in _locales/*/messages.json; do
node -e "JSON.parse(require('fs').readFileSync('$f','utf8'))" && echo "OK: $f" || echo "ERROR: $f"
done2. Missing required fields
Each entry in messages.json must have a message field. If the field is absent, getMessage() returns an empty string — not an error — for every call using that key.
// ✗ Missing message field — getMessage("appName") returns ""
{
"appName": {
"description": "The extension name"
}
}
// ✓ Correct
{
"appName": {
"message": "My Extension",
"description": "The extension name"
}
}Validate all entries have a message field with a script:
// validate-messages.mjs
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
const localesDir = "_locales";
let hasErrors = false;
for (const locale of readdirSync(localesDir)) {
const filePath = join(localesDir, locale, "messages.json");
const messages = JSON.parse(readFileSync(filePath, "utf8"));
for (const [key, value] of Object.entries(messages)) {
if (!value.message && value.message !== "") {
console.error(`[${locale}] "${key}" is missing the "message" field`);
hasErrors = true;
}
}
}
if (hasErrors) process.exit(1);
else console.log("All message fields present ✓");3. Missing keys across locales
This is the most common production bug in localized extensions. Your source file (the default_locale messages.json) has 40 keys. A translated file has 37. The 3 missing keys silently fall back to English. No error, no warning — your German users just see 3 English strings.
The reverse also matters: extra keys in a translated file that don’t exist in the source are dead weight — they can never be reached and make the file harder to maintain.
// validate-keys.mjs — check all locales against the default
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
const DEFAULT_LOCALE = "en"; // change to your default_locale value
const localesDir = "_locales";
const sourceFile = join(localesDir, DEFAULT_LOCALE, "messages.json");
const sourceKeys = new Set(Object.keys(JSON.parse(readFileSync(sourceFile, "utf8"))));
let hasErrors = false;
for (const locale of readdirSync(localesDir)) {
if (locale === DEFAULT_LOCALE) continue;
const filePath = join(localesDir, locale, "messages.json");
const translatedKeys = new Set(Object.keys(JSON.parse(readFileSync(filePath, "utf8"))));
// Keys in source but missing from translation
for (const key of sourceKeys) {
if (!translatedKeys.has(key)) {
console.error(`[${locale}] missing key: "${key}"`);
hasErrors = true;
}
}
// Keys in translation not in source (orphaned)
for (const key of translatedKeys) {
if (!sourceKeys.has(key)) {
console.warn(`[${locale}] extra key (not in source): "${key}"`);
}
}
}
if (hasErrors) process.exit(1);
else console.log("All locale keys match ✓");4. Malformed placeholder syntax
Placeholders are the trickiest part of messages.json to validate. The format requires that every $NAME$ token in the message string has a matching lowercase key in the placeholders object, and each placeholder must have a content field. See messages.json format explained for the full placeholder reference.
The four placeholder mistakes that cause silent failures:
Token in message with no matching placeholder definition
$USER$ appears in message but there is no user key in placeholders, Chrome outputs the literal string $USER$ in the UI.Placeholder definition with no matching token in message
Placeholder renamed by a translator
$USER$ to $BENUTZER$ in the German file. The browser now looks for a benutzer key, finds nothing, and outputs the literal token.Missing content field in a placeholder definition
placeholders must have a content field specifying the positional argument ($1) or a literal value. Without it, the token always resolves to an empty string.Script to validate placeholder consistency:
// validate-placeholders.mjs
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
const localesDir = "_locales";
const TOKEN_RE = /\$([A-Z0-9_]+)\$/g;
let hasErrors = false;
for (const locale of readdirSync(localesDir)) {
const filePath = join(localesDir, locale, "messages.json");
const messages = JSON.parse(readFileSync(filePath, "utf8"));
for (const [key, entry] of Object.entries(messages)) {
if (!entry.message) continue;
const tokensInMessage = [...entry.message.matchAll(TOKEN_RE)].map(m => m[1].toLowerCase());
const definedPlaceholders = Object.keys(entry.placeholders ?? {});
for (const token of tokensInMessage) {
if (!definedPlaceholders.includes(token)) {
console.error(`[${locale}] "${key}": token $${token.toUpperCase()}$ has no placeholder definition`);
hasErrors = true;
}
}
for (const placeholder of definedPlaceholders) {
if (!tokensInMessage.includes(placeholder)) {
console.warn(`[${locale}] "${key}": placeholder "${placeholder}" defined but not used in message`);
}
}
for (const [name, def] of Object.entries(entry.placeholders ?? {})) {
if (!def.content) {
console.error(`[${locale}] "${key}": placeholder "${name}" is missing the "content" field`);
hasErrors = true;
}
}
}
}
if (hasErrors) process.exit(1);
else console.log("All placeholders valid ✓");5. Manifest errors caused by messages.json
Your manifest.json can reference keys from messages.json via the __MSG_key__ syntax. If any referenced key is missing from your default_locale file, the Chrome Web Store will reject the upload. See default_locale rules and common errors for the full error list.
__MSG_appName__ in manifest but no appName key in default_locale
Chrome Web Store rejects upload: key not found in catalog
_locales/ exists but default_locale missing from manifest
Extension fails to load: "default_locale is required"
default_locale value uses a hyphen (e.g. en-US)
Extension fails to load: "Invalid locale code"
default_locale folder absent from _locales/
Extension fails to load: "Catalog file is missing"
Check that all __MSG_key__ references in the manifest exist in your source file:
// validate-manifest-keys.mjs
import { readFileSync } from "node:fs";
import { join } from "node:path";
const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const defaultLocale = manifest.default_locale;
if (!defaultLocale) {
console.log("No default_locale — skipping manifest key check");
process.exit(0);
}
const sourceFile = join("_locales", defaultLocale, "messages.json");
const sourceKeys = new Set(Object.keys(JSON.parse(readFileSync(sourceFile, "utf8"))));
const manifestStr = JSON.stringify(manifest);
const MSG_RE = /__MSG_([A-Za-z0-9_@]+)__/g;
let hasErrors = false;
for (const [, key] of manifestStr.matchAll(MSG_RE)) {
if (!sourceKeys.has(key)) {
console.error(`manifest.json: __MSG_${key}__ not found in ${defaultLocale}/messages.json`);
hasErrors = true;
}
}
if (hasErrors) process.exit(1);
else console.log("All manifest __MSG_ references are valid ✓");6. Duplicate keys
JSON parsers handle duplicate keys by keeping the last value and silently discarding earlier ones. A file with a duplicate key is valid JSON and will parse without error — but the first definition is invisible at runtime.
// ✗ Duplicate key — only "Something Else" is ever returned
{
"appName": { "message": "My Extension" },
"appName": { "message": "Something Else" }
}
// Check for duplicates in all locale files
// validate-duplicates.mjs
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
function findDuplicateKeys(jsonStr) {
const seen = new Set();
const duplicates = [];
for (const [, key] of jsonStr.matchAll(/"([^"]+)"\s*:/g)) {
if (seen.has(key)) duplicates.push(key);
seen.add(key);
}
return duplicates;
}
let hasErrors = false;
for (const locale of readdirSync("_locales")) {
const filePath = join("_locales", locale, "messages.json");
const content = readFileSync(filePath, "utf8");
const dups = findDuplicateKeys(content);
if (dups.length > 0) {
console.error(`[${locale}] duplicate keys: ${dups.join(", ")}`);
hasErrors = true;
}
}
if (hasErrors) process.exit(1);
else console.log("No duplicate keys ✓");Putting it all together: a complete validation pipeline
Run all checks in sequence as part of your build. Add a prebuild script so any CI pipeline or pre-publish hook catches them automatically:
// package.json
{
"scripts": {
"validate:json": "node validate-messages.mjs",
"validate:keys": "node validate-keys.mjs",
"validate:placeholders": "node validate-placeholders.mjs",
"validate:manifest": "node validate-manifest-keys.mjs",
"validate:duplicates": "node validate-duplicates.mjs",
"validate": "npm run validate:json && npm run validate:keys && npm run validate:placeholders && npm run validate:manifest && npm run validate:duplicates",
"prebuild": "npm run validate"
}
}prebuild set, running npm run build automatically validates all locale files first. A failed validation exits non-zero and stops the build before anything is packaged or uploaded.Quick validation checklist before every release
| Check | Failure mode |
|---|---|
| All locale files parse as valid JSON | Locale silently falls back to default_locale |
| Every entry has a "message" field | getMessage() returns empty string |
| All keys in source exist in every translated file | Missing keys fall back to source language |
| No extra keys in translated files not in source | Dead strings — unreachable, maintenance burden |
| Every $TOKEN$ in message has a matching placeholder definition | Token rendered literally in the UI |
| Every placeholder definition has a content field | Substitution always resolves to empty string |
| Placeholder tokens unchanged in translated files | Token rendered literally; substitution lost |
| No duplicate keys in any locale file | First definition silently overwritten |
| All __MSG_key__ in manifest exist in default_locale file | Web Store upload rejected |
| default_locale value matches a folder in _locales/ | Extension fails to load: "Catalog file is missing" |
Let LocalePack validate for you
LocalePack validates your source messages.json on upload — catching JSON errors, missing message fields, and malformed placeholders before translation begins. The output ZIP preserves every $PLACEHOLDER$ token exactly, so translated files pass placeholder validation automatically. Upload once, pay once, download a ready _locales ZIP.