Skip to content
Metric VaultHelp Center
Open app

Localization system

Four separate translation systems share one generated dictionary; this is what each one covers, the four-command pipeline, and exactly what the extractor does and does not pick up.

Last updated 2026-08-06

Summary#

The product ships in six languages: en, es, fr, pt, de and ru. The choice lives in localStorage('mvLang') and is shared across every same-origin page.

Behind that single setting are four separate translation systems. They exist because the surfaces they serve are genuinely different: a static marketing page, a 7 MB single-document app, tool results that change on every run, and two pages that ship their translations inline. Three of the four read one generated dictionary, one file per language (js/mv-i18n-dict.<lang>.js), produced by a four-command pipeline.

The single most common mistake is treating dictionary coverage as proof that text swaps on screen. It is not. A string can be in the dictionary and still render in English because no runtime pass ever visits it, and a string can be visible on screen and never reach the dictionary at all. Both halves have to be checked.

Purpose#

Localization is binding from the moment a string is written, not audited later. That rule is in CLAUDE.md because the alternative was tried: features shipped, translations were deferred, and the cleanup cost more than the features. If a user can see it, it goes through the translation system when you write it.

The architecture is shaped by the constraint that runs through the whole repository: there is no build step. There is no i18n library, no message catalogue format, no compile-time extraction of tagged strings. So the pipeline is a regex scanner over the shipped files, and the dictionary is a 6.4 MB generated JavaScript file that assigns one global.

That has a real consequence for how you write UI. The scanner reads what is already there rather than what you declared, so ordinary English markup is picked up for free, and anything that hides a string from a regex is invisible to it.

Architecture#

text
  SOURCE                      PIPELINE                       RUNTIME
  ─────────────────────────   ────────────────────────────   ─────────────────────
  19 HTML files           →   tests/i18n-extract.mjs     →   js/mv-i18n-runtime.js
  17 JS files                 i18n/strings.en.json           (every page but
                              ↓                               dashboard.html)
                              tests/i18n-generate.mjs
                              i18n/dict.{es,fr,pt,de,ru}      dashboard.html inline
                              ↓                               runtime
                              --emit
                              js/mv-i18n-dict.<lang>.js  →   window.MV_I18N[lang]
                              ↓                               (loaded lazily)
                              tests/i18n-verify.mjs

  tool results            →   POST /api/translate        →   mvTranslateResults
  (built at run time)         Workers AI / DeepL, D1 cache    (dashboard only)

  login.html, legal/*     →   own inline dictionaries    →   own switchers

The four systems#

#SystemWhere it runsDictionary
1Shared runtimejs/mv-i18n-runtime.js, injected into every customer page except the exclusionswindow.MV_I18N
2Dashboard inline runtimeAn inline block in dashboard.html, marked data-legacy="mv-lang-i18n"window.MV_I18N, plus a small hardcoded TR fallback table
3Dynamic result translationmvTranslateResults in dashboard.htmlNone. Calls POST /api/translate
4Per-page systemslogin.html and the four legal/* pagesTheir own inline objects

They are mutually exclusive by design. mv-i18n-runtime.js returns immediately if window.__mvI18nRuntime or window.mvApplyLang is already defined, so it can never double-run alongside the dashboard's own runtime.

login.html reads localStorage('mvLang') || localStorage('language') || 'en', and the legal pages do the same, keeping the older language key readable once so a mid-session user does not lose their choice.

Why the dashboard has its own runtime#

The shared runtime is a text pass, an attribute pass and a MutationObserver. The dashboard needs three things it does not have:

  • A curated selector list whose elements get their own text node swapped by applyEl, so a label sitting next to an inline SVG survives translation.
  • A characterData observer branch. A run button flipping Analyze to Analyzing… through textContent fires no childList mutation, so a node-added observer never sees it.
  • Two passes that share elements, each owning exactly one text node. applyEl claims an element by stamping __mvEnRaw on it; the async result translator claims one by stamping data-mvt-en. Neither claims the element: both only ever read and write its FIRST non-empty text child, so the full-content pass skips that one node and translates the element's other text nodes as usual. Rejecting the whole element instead is what stranded the tail of any label split by an inline tag — IDEA <svg/>PUBLISH-READY translated its head and left PUBLISH-READY in English with a dictionary entry sitting unused. mvtOwns() and applyElOwns() are that rule, one per pass.

What must never happen is capturing a translator-painted value as the "English original": that is the sticky-Spanish bug, where switching back to English restored Spanish. The order the passes run in is what keeps it safe — applyEl runs first, so by the time the full-content pass arrives the owned node already holds a translation and is skipped, while every sibling text node is still English and safe to capture.

Components#

tests/i18n-extract.mjs, the scanner#

A deliberate regex scanner rather than a DOM or AST parse, because there is no build step and no node_modules. It writes i18n/strings.en.json, a sorted JSON array. --report prints per-file counts and writes nothing; --list <file> dumps one file's yield.

Its inputs are three lists you must maintain:

ListContentsRule
HTML_FILES19 pages: 404, blog, blog-post, dashboard, index, login, pricing and all twelve free-tools pagesAdd a new page here or its strings never enter the dictionary
JS_FILES17 scripts that build UI, including mv-blog, mv-charts, mv-export, mv-guide-rules, mv-library, mv-modal, mv-psi, rec-panel, tool-explainer, mv-site-chrome, mv-helpers, chat-widget.js, pwa-install.jsAdd a new component script here. rec-panel.js and tool-explainer.js were both missed this way
ALREADY_LOCALISEDlegal.html and the four legal/* pagesThey ship translations side by side in [data-lang] blocks; scanning them would re-extract translated prose

admin.html is excluded on purpose: it is operator-only and English by design. js/tool-samples.js is excluded because it is fabricated sample tool output, which stays English like real data. Do not add a data file to JS_FILES.

Two further constants matter. KEEP_CODE is 74 metric, format and language codes such as SEO, CTR, SERP, JSON, PDF and H1; keeping them out of the dictionary is how they stay English at runtime, because the runtime only swaps dictionary hits. Real uppercase labels such as FREE, REFRESH and ACTIVE are deliberately not on that list, so they do translate. SEED adds seven badge words that live as data values in the navigation array and no scanner can reach: WORKFLOW, LIVE, BETA, DEPRECATED, SOON, PRO and Confirm.

LOOSE_PAGES is a second, permissive pass over the seventeen near-entirely static pages (index, pricing, blog, blog-post, 404 and the twelve free-tools pages). The shared runtime translates every visible text node on those, including fragments the strict pass rejects, so a permissive scan is correct there. It is never used on dashboard or app HTML, where loose capture would sweep up JavaScript-built markup and data values.

What the extractor WILL pick up#

  • Markup text, matched after an opening or a closing tag. That matters: the icon-then-label pattern <span><svg…></svg>Opportunities This Week</span> puts the label after </svg>, and requiring an opening tag missed hundreds of dashboard labels.
  • Text inside template literals in <script> blocks. Most dashboard result labels exist only there.
  • Six text attributes: placeholder, title, aria-label, alt, data-tip, data-tooltip.
  • JavaScript prose literals: quoted strings containing a space and either a capital letter or sentence punctuation. Fragments containing < or > are skipped as markup.
  • Message-helper arguments, including single words like "Saved." and "Analyzing…". The recognized helpers are mvToast, mvCaToast, mvKmToast, gaToast, _toast, toast, mvAlert, mvConfirm, mvPrompt, alertMsg and setStage, plus assignments to .textContent, .placeholder and .title and setAttribute calls naming one of the six attributes.
  • HTML entities, decoded first. The browser decodes every entity, so the dictionary key must be the decoded form. A partial decoder once stored Start free &rarr; while the runtime saw Start free →, and the string stayed English forever.

What the extractor will NOT pick up#

  • Anything shorter than 2 or longer than 400 characters.
  • Interpolated strings. Anything containing ${, <% or {{ is rejected; a string with a template hole can never be a fixed key.
  • Concatenation seams. n + ' items' yields a fragment that cannot be hardcoded per language, because word order moves the variable. Reword it, use a {placeholder} token, or let the result translator handle it.
  • Anything containing a backtick, { or }.
  • URLs, emails, asset filenames, bare domains, kebab-case and snake_case identifiers, CSS selectors, at-rules and bare entities.
  • JavaScript operators, function calls such as foo(bar, attribute assignments and statement tails. Prose parentheticals survive because they have a space before the bracket.
  • Markdown export headers beginning # or ## , which live in CSV and Markdown output rather than in the UI.
  • Attributes other than the six listed.
  • Text that no runtime pass ever visits. This is the gap that keeps recurring.
Note

Note: HTML comments are blanked before scanning, but <style> blocks are not stripped. A template literal containing the string "<style>" once made a non-greedy match run to a </style> about 90 KB away and delete the entire Site Audit renderer, roughly thirty labels, from the scan.

tests/i18n-generate.mjs, the translator#

SettingValue
Languageses Spanish, fr French, pt Portuguese (Brazil), de German, ru Russian
Modelclaude-haiku-4-5-20251001, overridable with MV_I18N_MODEL
Batch40 strings per request, 6 requests in flight
API keyANTHROPIC_API_KEY from the environment, otherwise parsed from .dev.vars
Retry429 and 5xx back off exponentially, up to 4 attempts

It is resumable by design: it writes i18n/dict.<lang>.json after every batch and skips keys already present, because a run this size will be interrupted. Results are matched by position, so a length mismatch would silently shift every translation onto the wrong key; instead the batch is split in half recursively so one pathological string isolates rather than dropping its whole batch.

KEEP_ENGLISH is empty. As of the owner decision on 2026-08-05, feature, tool and section names are translated, and the prompt's acronym rule keeps SERP, AI, GBP, CRM and the rest English inside a translated name. Before that, names were pinned English without an explicit list, and the model guessed per string: it translated "Backlinks" and left "Link Prospector" alone, producing a half-translated navigation.

The prompt is explicit about the things that break a UI: translate as UI copy rather than prose, keep labels short because a label that grows by half breaks its layout, preserve any {placeholder} token exactly including the braces, never translate Metric Vault or third-party brand names, keep an ALL-CAPS badge ALL-CAPS, and use the formal register consistently.

--emit writes one js/mv-i18n-dict.<lang>.js per language, each assigning window.MV_I18N[lang] into the shared object, as a single statement with a do-not-edit banner. Because there is no build step, that generated file is the runtime source of truth. Never edit it by hand.

tests/i18n-wire.mjs, page wiring#

Idempotently inserts two things into every candidate page: the <div class="mv-langw" data-mv-lang></div> switcher mount after <div class="mv-nav-right">, and <script defer src="/js/mv-i18n-runtime.js"> before the last </body>. --check exits 1 if any page still needs wiring.

Excluded: dashboard.html (its own richer runtime), the legal pages (own inline i18n), admin.html (English by design), login.html (its own complete data-i18n system on the same mvLang key, so double-running would fight it), plus ext-preview.html.

Data flow#

The pipeline, in order#

text
node tests/i18n-extract.mjs
node tests/i18n-generate.mjs        # needs ANTHROPIC_API_KEY in .dev.vars; resumable
node tests/i18n-generate.mjs --emit
node tests/i18n-verify.mjs          # must print "all current UI strings are covered"

Run all four whenever any user-visible string changed. i18n-verify.mjs re-runs the extractor's own scan and reports, per language, covered/current (pct), missing-from-baseline and layout-risk(long), the last being strings of eight characters or more whose translation is more than 1.8 times longer. It exits 1 if any language has an uncovered baseline string or the UI has new untranslated strings.

Important

Important: coverage of the dictionary is not the same as text swapping on screen. CLAUDE.md therefore asks for a second check: a permissive loose scan diffed against i18n/dict.es.json. No visible English should remain except data, meaning user content, URLs, IDs, domains and raw external-API values.

Note

Note: as recorded on 2026-08-06, i18n-verify.mjs reports 19,642 current UI strings against a 12,313-string baseline, with 7,339 new and untranslated, and every language at 10,946 of 19,642. The pipeline needs a full re-run.

At runtime#

English users never download the dictionary. Both runtimes load /js/mv-i18n-dict.<lang>.js lazily, only the language in use and only when a non-English language is first applied, and queue callbacks while it loads. A file is only ever requested for one of the six shipped codes. In the dashboard, mvApplyLang called with a missing or unknown language applies the stored one, and code that re-applies after writing English text passes window.mvCurLang(). Before 2026-09-14 two such calls passed nothing and every dashboard load requested mv-i18n-dict.undefined.js; tests/i18n-dict-lang.mjs holds it. Because it is 6.4 MB, a user can pick a different language mid-download, so the deferred callback re-checks the current language before applying and abandons a stale apply.

The text pass walks text nodes, skipping SCRIPT, STYLE, TEXTAREA, CODE, PRE, OPTION and NOSCRIPT, and anything inside input, textarea, select, [translate="no"], [data-i18n-skip] or [data-mvt-skip]. Nodes must be 2 to 400 characters and contain a letter. The English original is stored on the node, which is what makes switching back to English exact rather than a reverse translation. The attribute pass does the same for the six attributes, storing each original on the element.

Dynamic content is handled by a MutationObserver on document.body, debounced at 250 ms in the shared runtime and 300 ms in the dashboard, and it no-ops entirely while the language is English.

window.mvSetLang(lang) writes localStorage('mvLang'), applies, and dispatches a document-level mvLangChange event carrying { lang }. That event is the hook the legal pages and js/mv-site-chrome.js listen on. See Centralized nav and footer for why the navbar relabels the language pill to a two-letter code.

Tool results#

Tool results change on every run, so their labels cannot be pre-translated, and many are sentences assembled by concatenation. mvTranslateResults sends the whole rendered sentence to POST /api/translate so machine translation sees full context and word order comes out right.

  • It covers a fixed selector list including .mvr-section-t, .mvr-kpi-label, .mvr-tier, .mvr-empty, .mvr-insight, .mvr-note, .mv-toast, .mv-loading and .mv-error. Building results from those classes is what gets them translated. See Building a result renderer.
  • Domains, table cells, keywords and raw values are never translated.
  • Emails, URLs and bare domains are masked to {0}, {1} before translation and restored afterwards. Numbers are deliberately left in place, because the translation model drops a leading {0} and leaving numbers preserves localized formatting.
  • Restore is all-or-nothing: if a placeholder came back missing or duplicated, the English original is kept rather than a mangled half-sentence.
  • Translations are cached in memory keyed by language and masked template, so every domain pair reuses one translation, and cached again server-side in D1.
  • Requests are batched 60 templates at a time and painted progressively, so the UI translates in visible waves behind a Translating… indicator.

An earlier plan to rewrite roughly 1,000 concatenation sites into mvT('a {n} b', {n}) was deliberately abandoned: the result path never used the offline dictionary anyway, and 1,000 hand edits to a 40,000-line file with no local test path was too large a regression surface.

Failure modes#

FailureSymptomCauseFix
A new page ships untranslatedEverything is English on that page onlyIt is not in HTML_FILES, or i18n-wire.mjs never ran on itAdd it to HTML_FILES, run node tests/i18n-wire.mjs, re-run the pipeline
A new component's strings never translateOne panel stays EnglishIts file is not in JS_FILESAdd it, then re-run the pipeline
A tooltip or placeholder stays EnglishText around it translatesAn attribute outside the six the passes handleUse one of the six, or move the text into a text node
A label with a value stays English12 items unchangedA concatenation seam, which the extractor rejectsRoute it through one of the dynamic-MT classes, or reword it
The interface reverts to Spanish after switching to EnglishSticky languageThe English original was captured from translator-painted textmvtOwns() and applyElOwns() keep each pass off the one node the other owns. Narrow those checks, never widen them back to the whole element
Half a sentence translates…created as a draft, never live. keeps its tail in EnglishThe element is in SELS and its text is split by an inline tag, so applyEl only ever saw the first fragmentFixed: the full-content pass now skips only the owned node. If it returns, something widened applyElOwns back to a whole-element check
An acronym gets translatedSERP becomes something elseIt is missing from KEEP_CODEAdd it, then re-run
A translated label breaks its layoutWrapping or clippingGerman and Russian run longi18n-verify.mjs reports layout-risk(long) for anything over 1.8 times the source
i18n-generate.mjs throws on startNo keyANTHROPIC_API_KEY absent from both the environment and .dev.varsSet it. See Environment variables and secrets
/api/translate returns 503Results stay English, nothing blanks outNo provider: neither the Workers AI binding nor DEEPL_API_KEYThe endpoint fails open by returning the source text, which is the intended degradation
A bare string in mv-site-chrome.js gets translatedA media query or selector appears in the dictionaryThe extractor treats bare string literals as UI copyThis is why the 860px breakpoint in that file is compared as a number, not a "(max-width: 860px)" string

Two documentation gaps are worth knowing. docs/I18N.md is referenced by CLAUDE.md and twice inside tests/i18n-extract.mjs, and it does not exist; this page and the header comments in the four scripts are the surviving documentation. And the four legal/* pages ship English, Spanish, French, Portuguese and German side by side, so their known gap is Russian.

See also

Was this article helpful?