The German product page reads well. Then a visitor submits the contact form with the email field empty and gets "This field is required" sitting under a German label. The order confirmation that follows has a German subject line, an English footer, and a button that says "View order".
None of that shows up in a page-by-page review, because a rendered page is assembled from a lot of places at once: theme templates, plugin templates, rows in the database, JavaScript bundles, and — for anything that leaves by mail — a renderer that never runs in a browser. Translating the pages you can see leaves the rest untouched, and the rest is exactly what appears when something goes wrong, which is when a customer is most likely to notice.
The surfaces, and where each string actually lives
| Surface | Where the string lives | How to catch it |
|---|---|---|
| Theme and plugin templates |
.po / .mo files, or literal text that was never wrapped in a translation call |
extract with wp i18n make-pot, then grep templates for bare text |
| Menu labels | database rows for menu items; one menu per language unless the theme registers separate locations per locale | switch language, read the menu, check each item's lang attribute |
| Widgets, customiser values, block content | database, usually stored once in the source language | search the target-language HTML for the source string |
| Form labels, placeholders, help text | the form plugin's per-form settings in the database | open the form in the target language and read every field |
| Validation and error messages | browser constraint validation, the client-side validator's catalogue, and the server handler | submit invalid input in each language and read the response |
| Cart and checkout copy | template files, settings, and JS-rendered fragments | complete a purchase end to end in the target locale |
| Transactional email | template files plus settings text, rendered server-side at send time | capture the raw message for every template |
title, meta description, og: tags, alt, aria-label, placeholder
|
templates and database | crawl both locales and compare attributes |
| Structured data, sitemaps, feeds | generated from the same source strings | view source, check inLanguage and the URLs |
| PDFs — invoices, packing slips | template files, and font files that may not carry the glyphs | generate one in the target language and look at it |
Start from the catalogue, not the page
Every string that is wrapped for translation ends up in a .pot file. Generate it and compare against the target language's .po:
wp i18n make-pot . languages/site.pot --exclude=node_modules,vendor,dist
msgattrib --untranslated languages/de_DE.po
msgattrib --only-fuzzy languages/de_DE.po
Fuzzy entries are worth reading. A fuzzy match is not counted as translated in the admin, and whether it reaches the front end depends on how the files are compiled.
The catalogue covers only the strings that were wrapped. Bare text inside a template never appears in it:
grep -rn --include='*.php' -E '>[^<>{}]*[A-Za-z]{4,}[^<>{}]*<' \
wp-content/themes/mytheme \
| grep -v -E '__\(|_e\(|esc_html__\(|esc_html_e\(|_x\(|_n\('
This is a heuristic and it will produce noise — text inside comments, inline SVG, printf fragments that are assembled elsewhere. Read what it returns instead of piping it into a fixer.
Then check the rendered page against the source page
A string can be translated in the .po file and still render in the source language, because something else overwrote it: a cached fragment, a JS bundle that ships its own copy of the text, a database value that took precedence.
The check that catches this: take the strings that appear on the source-language page and look for them on the target-language page. Anything that shows up unchanged is either untranslated or not being loaded. That is a script, not a review:
// npm i playwright && npx playwright install chromium
// node find-missing.mjs https://example.com/de/ https://example.com/en/
import { chromium } from 'playwright';
async function harvest(page, url) {
await page.goto(url, { waitUntil: 'networkidle' });
return page.evaluate(() => {
const found = [];
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
let node;
while ((node = walker.nextNode())) {
const t = node.nodeValue.replace(/\s+/g, ' ').trim();
if (t) found.push({ kind: 'text', value: t });
}
for (const el of document.querySelectorAll('[placeholder],[aria-label],[alt],[title]')) {
for (const attr of ['placeholder', 'aria-label', 'alt', 'title']) {
const v = el.getAttribute(attr);
if (v && v.trim()) found.push({ kind: attr, value: v.replace(/\s+/g, ' ').trim() });
}
}
return found;
});
}
const [tgtUrl, srcUrl] = process.argv.slice(2);
const browser = await chromium.launch();
const page = await browser.newPage();
const target = await harvest(page, tgtUrl);
const source = await harvest(page, srcUrl);
await browser.close();
const sourceValues = new Set(source.map(x => x.value));
const seen = new Set();
for (const item of target) {
if (!sourceValues.has(item.value)) continue;
if (!/[A-Za-z]{3}/.test(item.value)) continue;
const key = item.kind + '|' + item.value;
if (seen.has(key)) continue;
seen.add(key);
console.log(`${item.kind.padEnd(12)} ${item.value}`);
}
Brand names, product codes, numbers and words that are genuinely the same in both languages will come back as suspects. So will anything that only ever existed on one locale's page, because it has no counterpart to compare against.
Run it over a URL list rather than the home page alone: a product, a category, the cart, checkout, a search with no results, a 404. Empty states and error pages are where templates nobody reviewed turn up.
Email is a separate application
Each transactional message is rendered on the server, and the locale at render time is whatever is active when the mail is built. When the trigger is an admin action or a scheduled job, that is frequently the admin's language rather than the recipient's.
Capture raw messages instead of reading them in one inbox. Point the site's mail transport at a local catcher, then trigger each template once with a test account whose language is the target one. Read the subject, the preheader, every heading, the button label, the footer, and the plain-text alternative — that part is often generated from a separate template and drifts out of sync with the HTML one.
In WordPress the locale used during rendering can be switched with switch_to_locale(), and mail-building code varies in whether it does that around the render. Testing with a real recipient account tells you what actually happened; reading the template files only tells you what is in them.
Validation messages come from more than one layer
<input required> produces a message from the browser, in the browser's UI language, not the site's. A visitor with an English browser reading a German page gets an English bubble, and no translation file is involved.
If that matters, set the message yourself:
const email = document.querySelector('#email');
email.addEventListener('invalid', () => {
email.setCustomValidity(
email.validity.valueMissing ? t('field_required') : t('field_invalid_email')
);
});
email.addEventListener('input', () => email.setCustomValidity(''));
t() stands for whatever your catalogue lookup is called. After that, the server-side handler is a different set again: post the form with an empty required field and with a malformed value in each language, and read the JSON or the re-rendered page that comes back.
Keep it from drifting
Put the catalogue diff in CI so a release can't quietly add untranslated text to a translated page:
wp i18n make-pot . /tmp/now.pot --exclude=node_modules,vendor,dist
diff \
<(grep -o '^msgid ".*"' /tmp/now.pot | sort -u) \
<(grep -o '^msgid ".*"' languages/site.pot | sort -u)
A non-zero exit means the catalogue changed. Whether that is a problem depends on whether the new strings were translated, which is what msgattrib --untranslated answers.
While the crawler is already running, assert <html lang> and the hreflang pairs on every page. A document whose lang says en while the body is German gets hyphenated, read aloud and indexed as English.
If the site runs on WordPress and you would rather not assemble the translation layer yourself, AumLang is a free plugin on wordpress.org that handles AI translation, hreflang and WooCommerce product-page translation. Whatever you end up using, the checks above still apply — where a string lives decides whether any tool can reach it.
Top comments (0)