---
name: hardcoded-string-extraction
source: https://app.decimal.ai/s/hardcoded-string-extraction@1/SKILL.md
source_sha256: 4254555ffed6
---

# Pull user-facing strings out of source into a translation function

You are handed a source file. Find every literal that an end user reads on screen, replace
it with a call to the translation function `t("key")`, and add a `key → original text` line
to a message catalog. Leave every other literal exactly as it is. The whole task is one
binary decision made over and over: **does a human read this string in the running UI?**
If yes, extract it. If no, it stays a plain literal.

Getting the boundary right matters more than getting many strings. Wrapping a log line, a
test id, or an enum value in `t()` is a bug — it changes program behavior or ships noise to
translators. Missing an `aria-label` leaves a blind user with untranslated text. Precision on
the boundary is the whole job.

## Extract — a person reads this rendered on screen

- **Element text** — the words between JSX or HTML tags: `<h1>Account settings</h1>`,
  `<button>Save changes</button>`, `<p>Your trial ends soon.</p>`.
- **These four attributes only**, because assistive tech or the browser reads them to a user:
  `alt`, `title`, `aria-label` (and `aria-description`), and `placeholder`.
- **Human-readable messages built for display** — the text of an error, toast, banner,
  snackbar, confirmation dialog, or empty-state message that is rendered to the user.
- **User-facing option and label text** — the visible label of a select option, menu item,
  tab, or column header (the label the person sees, never its underlying value).

## Skip — leave the literal exactly as written

- **Logs and developer output** — anything inside `console.log/warn/error`, `logger.*`, a
  `debug()` call, or a comment. It never reaches the UI.
- **Test and automation hooks** — `data-testid`, `data-cy`, `data-test`, and any `id` used
  for selection.
- **Keys and lookup strings** — object keys, `Map`/`Record` keys, the first argument to
  `t()` itself, dispatch/action `type` strings, GraphQL field names, event names.
- **Enum, discriminant, and status values** — a literal being compared or switched on
  (`status === "pending"`, `case "archived":`, `variant="primary"`). It is an identifier,
  not prose, even though it reads like a word.
- **Technical attribute values** — `type`, `role`, `name`, `href`, `src`, `htmlFor`,
  `className`, `class`, `rel`, `target`, `method`, `autoComplete`, `inputMode`, and the like.
- **URLs, paths, routes, emails, and file names** — `"/api/v1/users"`, `"https://…"`,
  `"config.yaml"`.
- **Anything already wrapped** — a string already inside a `t(...)` / `<Trans>` / `i18n.*`
  call is done; do not double-wrap it.

## The two rules that decide the hard cases

1. **A string that reads like a word can still be an identifier.** `"pending"` in
   `status === "pending"` is compared by the code, so it is frozen; `"Pending"` shown in
   `<span>{label}</span>` is read by a user, so it is extracted. Ask what the string is
   *used for*, not what it looks like.
2. **The attribute allowlist is exact.** Only `alt`, `title`, `aria-label`,
   `aria-description`, and `placeholder` carry user-facing text. Every other attribute value
   — even one that happens to be English words — stays a literal. When unsure, an attribute
   is skipped unless it is on the allowlist.

## How to rewrite an extracted string

- Replace the literal with `t("key")`. Inside JSX element text, wrap it: `<h1>{t("settings.title")}</h1>`.
- Give each string a stable, dotted key derived from where it lives (`settings.title`,
  `login.error.badPassword`) — never derived from the English words, so the key survives a
  wording change.
- When the string contains a runtime value, use a named placeholder rather than string
  concatenation: `t("cart.count", { n })` for a catalog entry `"You have {n} items"`, not
  `"You have " + n + " items"`.
- Add each `key: "original text"` line to the message catalog. Do not translate the text —
  the catalog holds the source-language original.

## Worked

```jsx
// before
<button data-testid="save-btn" aria-label="Save account settings" onClick={save}>
  Save
</button>
// after — element text and aria-label extracted; data-testid frozen
<button data-testid="save-btn" aria-label={t("account.save.aria")} onClick={save}>
  {t("account.save.label")}
</button>
// catalog:  account.save.aria: "Save account settings"   account.save.label: "Save"
```

```js
// before
if (status === "error") {
  console.error("payment failed for order");
  toast("We couldn't process your payment.");
}
// after — only the toast is user-facing
if (status === "error") {              // "error" is a compared value → frozen
  console.error("payment failed for order");   // log → frozen
  toast(t("checkout.paymentFailed"));  // shown to the user → extracted
}
// catalog:  checkout.paymentFailed: "We couldn't process your payment."
```
