Label keys
How Shipeasy Translations names, hashes, chunks, describes, and deletes the strings your code references.
A label key is a stable identifier for a translatable string. It is what your code references; the value (the actual translation) varies per profile. Keys are the contract between your codebase and every locale you ship.
Shipeasy Translations's job is to make keys feel invisible: you write English in code, the codemod hashes it, the AI translates it, and the runtime resolves it back. But understanding what a key actually is — and how it survives renames, refactors, and re-scans — is what stops your localization from drifting six months in.
In every example on this page, t(...) is shorthand for i18n.t(...) from @shipeasy/sdk/client
(or /server for SSR). The codemod always emits the full i18n.t(...) form with the import
added.
Schema
checkout.cta_label) or auto-derived from the source value's hash plus the file path namespace.{{name}} ICU MessageFormat syntax.Identity: explicit keys vs hashed keys
There are two ways a key gets its identifier, and you can mix both in one project:
Explicit key→
t("checkout.cta_label", "Pay") — the key is checkout.cta_label.
Editing the English value does not break translations. Recommended for buttons, headings, and
any copy that will iterate.
Hashed key→
t("Pay") — the key is the source value itself, hashed to a stable id. Edit the
English and you implicitly create a new key (the old one is marked stale). Best for high-volume
marketing copy.
The codemod defaults to hashed keys for raw JSX strings (<h1>Welcome back</h1>) and explicit keys for anything inside a clearly labelled CTA component. You can flip the default with --keys=explicit or --keys=hashed.
Naming conventions
Explicit keys are forever-ish — they are referenced from code, drafted, translated, reviewed, published. Renaming has a cost: every translator's context resets. Pick well the first time.
- Namespace by surface:
checkout.cta_label,checkout.summary_total. - Snake_case the leaf:
cta_label, notctaLabelorcta-label. - Describe role, not content:
header.cta_primaryages better thanheader.start_free_trial(the copy will change; the role won't). - Don't embed the language:
welcome_enis wrong;welcomeis right. - Don't embed the locale into the key: one key is shared across every profile; each profile supplies its own translation. Don't duplicate a key per locale.
A loose convention that scales well: <surface>.<component>.<role>. So checkout.summary.cta_primary, settings.billing.heading, auth.signup.subtitle.
Discovery: codemod vs scan
The CLI discovers keys two ways:
Codemod→
Walks the AST, finds string literals in user-visible positions (JSX text, JSX attribute values
for known props, existing i18n.t / t calls), and replaces them with
i18n.t("key", "value"). Aggressive and high-quality on JSX.
Scan→
Walks any text file, lists candidate strings, and reports them. You wrap them by hand. Use for Vue SFCs, Svelte, server templates, plain HTML, and email templates.
Either way, the key set in your source profile should match the keys actually referenced in code. Use shipeasy i18n validate in CI to enforce that — it fails the build if a t("…") call has no matching key in the profile.
shipeasy i18n scan ./src --rewrite --profile en
shipeasy i18n validate --profile en ./srcThe codemod skips strings it cannot safely rewrite — anything with embedded JSX, dynamic interpolation in a non-template position, or suspicious-looking content (URLs, emails, all-numeric). Skipped candidates land in i18n-codemod-review.json at the project root with file, line, and reason.
Placeholders and pluralization
The runtime supports ICU MessageFormat for both interpolation and plurals.
t("greeting", { name: "Vadim" });
// value: "Hello {{name}}!"
// → "Hello Vadim!"t("notifications", { count: 3 });
// value: "{{count, plural, one {# notification} other {# notifications}}}"
// → "3 notifications"t("invite_status", { gender: "f", name: "Anna" });
// value: "{{gender, select, m {He} f {She} other {They}}} invited {{name}}."
// → "She invited Anna."Pluralization rules follow CLDR — Shipeasy Translations preserves the structure across translation. The AI is instructed to fill out every plural category required by the target locale (Russian needs one / few / many / other; Arabic needs zero / one / two / few / many / other). Categories the source locale does not use are filled by analogy.
Always spot-check plural keys in low-resource locales after AI translation. Claude is good at this but not perfect — wrong forms slip through and can survive review if no one on the team reads the locale.
Rich text and HTML
For mixed text + markup (a sentence with a bold word, an inline link), wrap it as a fragment:
<T id="legal.tos_consent">
By continuing you agree to our <Link href="/tos">Terms</Link> and{" "}
<Link href="/privacy">Privacy Policy</Link>.
</T>The codemod produces a value with placeholders for each component:
By continuing you agree to our {{0}}Terms{{/0}} and {{1}}Privacy Policy{{/1}}.Translators see the placeholders as visible tokens; the runtime swaps them back into React elements. This keeps translation safe (no HTML injection) and re-renderable on the client without re-parsing.
Descriptions matter for the AI
When the AI translates a key, it sees:
- The key (
checkout.cta_label). - The source value (
Pay). - The description, if you've added one (
CTA on the checkout summary page; verb, must be short). - Up to 8 "context keys" — neighbouring keys from the same chunk or namespace.
- Project-level tone and glossary.
A good description doubles translation quality on ambiguous strings. Add one for any key whose meaning is not obvious from its source value. Common cases that need a description:
- Single words (
Pay,Match,Order) — disambiguate verb vs noun. - Branded UI labels (
Spaces,Workflows) — mark do-not-translate. - Length-constrained strings (
Save,Done) — tell the AI the budget. - Idioms (
You're all set!) — explain the intent.
shipeasy i18n keys describe checkout.cta_label \
--description "CTA button on the checkout summary page; verb, must be ≤12 chars in target locales" \
--max-length 12You can also set descriptions inline in code with a comment that the codemod picks up:
{
/* i18n: CTA button, verb, ≤12 chars */
}
<button>{t("checkout.cta_label", "Pay")}</button>;Chunking for performance
By default every key in a profile is served in one bundle. For large apps (≥5k keys), split into chunks so the loader fetches only what each route needs.
const labels = await loadLabels({ profile: "en", chunk: "checkout" });You define a chunk via:
- The
chunkfield on each key (set when pushing). - Or by directory in the codemod config:
{
"i18n": {
"chunks": {
"checkout": ["src/checkout/**"],
"settings": ["src/settings/**"],
"marketing": ["src/(marketing)/**"],
"shared": ["src/components/**"]
}
}
}The loader fetches chunks active for the current page; navigations fetch additional chunks lazily. This keeps the initial bundle tiny and warm.
KV storage layout per published version:
i18n/v1/{project}/{profile}/v{n}/index.json — chunk → hash manifest, ~2 KB
i18n/v1/{project}/{profile}/v{n}/shared.json — chunk bundle
i18n/v1/{project}/{profile}/v{n}/checkout.json
i18n/v1/{project}/{profile}/v{n}/settings.json
i18n/v1/{project}/{profile}/v{n}/marketing.jsonThe index is cached aggressively at the edge; chunk URLs are content-hashed in the index, so a single-key edit in checkout purges only checkout.json for the new version. Other chunks stay warm.
Under ~5,000 keys, a single bundle is usually faster end-to-end (one request, smaller HTTP overhead). Chunk when the bundle exceeds ~80 KB gzipped or when route-level lazy loading actually pays off.
Stale keys and re-translation
Shipeasy Translations detects when a source value changes (the hash changes) and marks every target translation as stale. Stale keys still serve their old value at the loader — nothing breaks — but they show up in the dashboard with a yellow flag and are eligible for re-translation.
shipeasy i18n translate --profile fr --ai --stale-onlyThe AI run sees the previous source value, the new source value, and the previous translation, and proposes an updated value. Most edits are tiny ("Sign in" → "Sign in →") and the AI keeps the existing translation; meaningful edits get a new draft.
Deleting a key
Deletion is destructive — it removes the key from every profile, every active draft, every version going forward (old published versions still contain the key, since they are immutable).
shipeasy i18n keys delete checkout.old_cta_label --profile enIn practice you should:
- Remove the
t("checkout.old_cta_label")call from code first. - Run
shipeasy i18n validateto confirm no references remain. - Then delete from the dashboard or CLI.
The dashboard's Unused keys tab uses the last_seen_in_code timestamp from your latest validate run to surface candidates. By default keys not seen in code for 30 days appear there; you can sweep them with:
shipeasy i18n keys prune --not-seen-in 30d --dry-runDrop --dry-run once you trust the list.
The CLI's delete hard-removes; the dashboard offers a Mark deprecated action
that keeps the key in the source profile but hides it from translation drafts. Deprecated keys are
excluded from coverage stats. Use this when you are mid-migration and old code still references
the key.
Translate them — safely.
Every translation lands as a draft, never directly in a profile. Review side-by-side, edit inline, then merge.