Extracting hard-coded interface text means moving every string that a person reads or hears out of the component and into a message catalogue, then putting a lookup call where the string used to be. The hard part is not the mechanics. It is deciding, string by string, what a reader actually sees, keeping whole sentences together so a translator can reorder them, and leaving alone everything that code depends on. This guide walks through those three decisions, a naming scheme for message keys, and a check that fails when a string was missed.

Why the split matters

The W3C describes internationalization as designing a product so that it can be localized easily later, and among its examples are two relevant habits: being careful when strings are concatenated, and keeping code from depending on the values of interface strings (W3C Internationalization). Both habits show up in a typical component. Text is typed straight into markup, a sentence is built from pieces, and a few lines further down the same component compares a string to decide what to render. Extraction pulls the first kind out and has to leave the second kind exactly where it is.

What a person sees: extract it

Go through the file and write down each text node, string literal and attribute value. A string is a message when a sighted reader or a screen reader user meets it. In practice that covers:

  • Visible text between tags: headings, body copy, buttons, menu entries, grid cells, dropdown choices, figure captions. Even one word counts, so "Save" is a message.
  • alt on images. MDN describes the attribute as the text that stands in for the image: screen readers speak it, and browsers show it when the picture fails to load (MDN, img element). An empty alt is a deliberate statement that the image is decoration, so an empty value is not a message.
  • aria-label. It defines a string that names an element, and MDN notes that when it is present it takes precedence over an image's alt, an iframe's title or a form label as the accessible name (MDN, aria-label). An icon-only close button with aria-label="Close" has its only readable meaning in that attribute, so it must be translated.
  • title, placeholder and the label carried by a submit input. Same reasoning: they are words on screen or in a tooltip.
  • Page-level text in HTML: the <title> of the document and the text inside the meta description tag.
  • Literals that code defines and a template prints later: the label field of a tab list, each side of a conditional expression, an error sentence parked in state. Lift the literal from the line that declares it, not from the line that renders it.

What stays: leave it alone

Everything that code, styles, tests or the network read must keep its exact bytes:

  • styling and wiring: className, element ids, htmlFor, form field name and type, role, link rel, target, list key, inline style values;
  • locations of every kind: href, src, action, mailto links, module import paths;
  • hooks for tests and scripts: data- attributes and test ids. A tab's caption is a message, while the identifier that selects the tab is not;
  • property names, enum members, status codes, and any literal that a condition or switch inspects, plus regular expressions;
  • whatever goes to a logger or the console, and error codes raised by throw;
  • embedded script and style blocks, and comments;
  • strings that hold no letters, such as a bullet or a divider, and values that arrive from data, like a product name.

The dangerous direction is turning an internal string into a message. A status value such as "pending" that gets replaced by a lookup call now varies with the locale, and the if that compares against it silently stops matching in every language except the first. If a string is compared anywhere, it is a code value, and the text shown to the reader is a separate message that maps from it.

One more rule belongs here: a string wrapped in a lookup call already has a key. Skip it in the new extraction, and never give its key to a different text.

Whole sentences, not pieces

Word order differs between languages, so a translator needs the complete sentence. Three habits break this, and each one looks harmless in English.

A sentence cut around a link or bold part. Take a paragraph that says "Click", then a link reading "here", then "to continue". Extracted as three messages, it forces every language to keep that order and that number of parts. Keep one message and let the markup sit inside it. FormatJS supports this with tags inside the message, and its documentation presents the purpose as keeping each sentence whole instead of chopping it into fragments (FormatJS ICU syntax).

A sentence assembled by joining text with a variable. A name, a number or a date placed inside a sentence is a named placeholder in curly braces, not a join. The translator can then move the placeholder to wherever the target language wants it, and the number or date is formatted by the locale instead of being baked into the text.

A count. "You have 3 items" cannot be solved by adding an "s". The Unicode CLDR defines six plural categories, named by the tags zero, one, two, few, many and other, requires the last, and says that some languages have two forms, some have a single form and some have several (Unicode CLDR plural rules). Its sample bug report lists draft Ukrainian rules in which one form serves 1, 21, 31 and so on, a second serves the ranges that start at 2 and at 22 and stop at 4 and 24, and everything else, including 5 through 20, takes a third. An English-only count === 1 ? "item" : "items" cannot express that. The ICU form can:

{count, plural,
  one {# file selected}
  other {# files selected}
}

FormatJS documents the # token as the placeholder for the numeric value, formatted as a number, and requires an other branch (FormatJS ICU syntax). The English source needs only those two branches; translators add whichever others their language requires. For a deeper look at those rules, see our guide to plural messages and CLDR rules.

Naming message keys

A key is an identifier, not a sentence. Writers and translators will edit the sentence, while the key has to survive those edits. A scheme that holds up:

  • dot-separated lowercase words with the screen or component first and the job the text does last, such as cart.addbutton.label or checkout.error.cardrejected;
  • no sentence text in the key, so that a copy edit never forces a rename;
  • one key per distinct text, reused at every place the same text appears, and never one key for two different texts, because the second one will need a different translation sooner or later;
  • no collision with keys already in the catalogue.

Pick one separator style and keep it. Mixed styles are the quickest way to end up with two keys for the same thing.

A check that can fail: the pseudo-locale

Reading the diff proves little. A string that was never found is invisible in the diff. The better check renders the whole page in a fake language whose output is obviously not English, then looks for any readable English left on screen.

The FormatJS command line can compile messages into pseudo-locales; its documentation lists several, including en-XA, which wraps accented look-alike letters in square brackets, and en-XB, which reverses the text, and notes that the option requires the AST output (FormatJS CLI). With such a locale active:

  1. Open each screen and look for text that is still plain English. Anything not accented or bracketed was never extracted.
  2. Look at tooltips and the accessible names too: a screen reader or the browser's accessibility tree shows aria-label and alt values that a visual scan misses.
  3. Watch for broken layouts. Accented and padded text is longer, so a button that clips it will clip German too.
  4. Search the rewritten source for each original string. None may remain outside the catalogue.
  5. Count the lookup calls in the file and the keys in the catalogue: every key is used, and every place a string was removed has a call.

To confirm the check works, plant a string that you know was missed and make sure the pseudo-locale view shows it. If it does not stand out, the check is not testing anything.

When this does not apply

Our scope is text in component and template files of web interfaces. It leaves out content kept in a CMS or a data store, emails assembled on a server, strings in native mobile apps, or text embedded in images, none of which a source rewrite reaches. Frameworks differ in how they spell the lookup call and in how they support inline markup, so check your library's documentation before copying the shapes above. The pseudo-locales named here come from one library's tooling; others have their own. Every example is an illustration built from the cited documentation and from familiar patterns; nothing was measured. A codebase with unusual conventions, such as copy built in a server action or loaded from a CMS at runtime, will have strings that this process does not find.

One tool for the rewrite

For the mechanical part, our i18n-strings-extract skill handles one JSX, TSX, HTML or Vue file at a time and answers with one JSON object: a map of messages plus the full rewritten source. Each message matches the wording on screen character for character. A sentence holding an inline link or emphasis becomes one message carrying its markup, concatenations and interpolations become named placeholders, and a sentence about a quantity becomes an ICU plural. Styling hooks, locations, test hooks, logging, enum values and comments are untouched, as is any text already wrapped in a translation call. A file with nothing visible to extract comes back with an empty map and unchanged source.

Read this post as Markdown: /blog/hard-coded-ui-text-extraction.md · Atom feed.