---
name: fluent-migration
description: >
Use this skill when a patch or local changes rename, restructure, move, or
replace Fluent (.ftl) strings - or migrate legacy .properties strings to Fluent
- and you need a migration recipe in python/l10n/fluent_migrations so existing
translations carry over. You read the diff, classify each changed string, write
the recipe by hand, and validate it with the in-tree `./mach
fluent-migration-test`. Triggers: "write/generate a fluent migration", "migrate
these strings", renamed/bumped l10n IDs (foo -> foo2), moving a value to/from an
attribute, adding or removing an attribute on a message, moving a string between
files, .properties -> Fluent. Also covers changes that must NOT be migrated.
---
## What this does
A **migration recipe** is a Python module in `python/l10n/fluent_migrations/`,
shipped in the same patch. It tells l10n tooling to copy existing translations
over to a renamed, moved, or restructured string, so locales don't fall back to
English. It only helps when the English text can be **reused** - identical, or
differing only in capitalization. If the wording really changed, the string gets
translated fresh instead (give it a new id and leave it out of the recipe).
You build the recipe by hand: read the diff, decide per string whether it's
migratable, write the `add_transforms` blocks, then let `./mach
fluent-migration-test` check them authoritatively against real l10n. There is no
generator - the test is the source of truth.
**Two hard rules - never break them when writing a migration file:**
- **No partial migrations.** Migration is all-or-nothing per message. Every
translatable part of the target message (its value and *each* attribute) must
be rebuildable from reused source content via `COPY`/`COPY_PATTERN`. If any part
is new or changed - a new attribute, a removed attribute, a changed value, or an
attribute with reworded text - you can't migrate *just* the reused parts: either
the **whole message** is migratable (every part the new message *keeps*, rebuilt
from reused source under a new id) or it stays out of the recipe entirely and
translates fresh. Whether every kept part has a reusable source decides which:
an attribute with genuinely new wording has none, so the whole message stays out;
but a new attribute that *reuses* an existing source string's text (e.g. adding
`.aria-label = Go back` to a message whose old `.title` was already `Go back`)
makes the **whole** message migratable from reused content under a new id - copy
the kept `.title` and the added `.aria-label` both from the old `.title`. Removing
an attribute is likewise migratable under a new id: copy each surviving part from
its old counterpart and just don't reference the dropped one. Either way the id
must be bumped first - see the added/removed-attribute rows in step 2.
Bumping the id does **not** by itself rescue a message: a message that changed one
attribute (say `.style = ...45em` -> `...32em`) still needs a new id *and* still
stays out of the recipe, because migrating its unchanged `.title` while the
`.style` changed is exactly a partial migration. Never suggest "give it a new id
and migrate the reused part" - the new id and migratability are separate
questions, and a message only becomes migratable when *every* part is reused.
- **No hardcoded strings.** Never put a literal in the recipe to fill in content
the migration can't produce from a source string (for example, copying `.title`
via `COPY_PATTERN` while hardcoding a changed `.style = ...`). Mixing copied
translations with hardcoded literals is just a partial migration in disguise.
(Some landed patches do this as a deliberate human exception - but it's an
exception, never something this skill should produce or suggest.)
## Workflow
### 1. Get the diff of the string changes
These commands work on uncommitted changes too. Look only at `.ftl` /
`.properties` files:
```bash
# working tree (local-only change):
git diff -- '*.ftl' '*.properties'
# committed change (single commit):
git diff 'HEAD^!' -- '*.ftl' '*.properties'
# committed change spanning several commits ..:
git diff ~1 -- '*.ftl' '*.properties'
```
To confirm a string's text is reused, read its *old* value from the pre-change
file. `` is the commit *before* the change (e.g. `HEAD^`, or `~1`):
```bash
git show :browser/locales/en-US/browser/preferences/containers.ftl
```
### 2. Classify each changed string
Decide, per message, what the recipe should do. This table is the core of the
job:
**First, a mandatory mechanical step - do this for _every_ added id before you
classify anything.** Naming-based comparison (`foo` vs `foo-1`) is NOT sufficient
and is the classic way to wrongly stamp a migratable string "fresh": a new id
often reuses the exact text of a *differently named* message (e.g. a new
`...feature-introduction-title-1` whose value matches an unrelated
`unauthenticated-vpn-title`). So for each added message, extract its literal value
(and each attribute's literal value) and grep that exact text across the changed
`.ftl`/`.properties` file(s) themselves to find any byte-identical (or
capitalization-only) source there. Limit the search to the files touched by the
diff - do not branch out into other files. Run this search uniformly for all added
ids, including long prose strings; never skip a string because it "looks novel" or
decide by intuition which ones "might" collide. Only after this search comes up
empty for a part may you classify that part as translated-fresh. A convenient
sweep: pull every added (`^\+`) message value from the diff and search each one's
text within the changed files, rather than hand-picking a subset.
| What changed | Migrate? | How |
| --- | --- | --- |
| id renamed/bumped, text identical (`foo` -> `foo2`) | Yes | `COPY_PATTERN(from_path, "foo")` for the value; `"foo.attr"` for each attribute |
| capitalization-only difference | Yes (still reusable) | same as above; the test flags it `WARNING` - confirm only the casing changed. **But a new id + migration is optional here** - the casing can instead be changed in place under the *same id*, with no recipe at all |
| wording genuinely changed | **No** | new id, translated fresh; leave out of the recipe entirely |
| moved to another file, text unchanged | Yes | `target` = new file, `from_path` = old file; a pure move may even keep its id |
| value <-> attribute restructure, **all** text reused, **with a new id or new file** | Yes | `COPY_PATTERN` each reused piece from the *old* id |
| value <-> attribute restructure keeping the **same id in the same file** | **No - flag it** | the message changed, so this is a cardinal-rule violation: the dev must bump the id first. A recipe entry here is a self-migration the test rejects (no-op) |
| attribute **added or removed** (e.g. add `.aria-label`, drop `.accesskey`), **id kept** | **No - flag it** | adding *or* removing a part *changes* the message, so it needs a new id; keeping the id is a cardinal-rule violation. Tell the dev to bump the id, then migrate per the next row. (Migrating under the same id would also be a self-migration the test rejects.) |
| attribute added or removed **under a new id**, and every part of the new message reuses an existing source string's text | Yes | `COPY_PATTERN` *every* part of the new message from a reused source. **Removed** attribute: copy each surviving part from its old counterpart (the dropped one simply isn't referenced). **Added** attribute: copy the carried-over parts from their old counterparts and the added attribute from whatever source shares its text - often another attribute of the old message (e.g. both `.title` and a new `.aria-label` from the old `back-nav-button-title.title`). Confirm cross-message context if any borrowed text comes from a different message |
| restructure that adds/changes any text (new `.description`, changed `.style`, ...) with no reusable source for some part | **No** (no partial) | leave the whole message out |
| brand-new id, but **every** part reuses an existing source string's text (cross-message reuse) | Yes | `COPY_PATTERN(from_path, "")` (or `.attr`) for each part; confirm the source context matches - see cross-message note below |
| legacy `.properties` key -> Fluent | Yes | `COPY` / `REPLACE` / `PLURALS` / `CONCAT` (see below) |
Cardinal rule: **a changed string must get a new identifier** (unique, with a
meaning that stays stable across files) - otherwise locales keep showing the old
translation next to the new English. "Changed" means any non-capitalization change
to the value *or to any attribute* - including non-prose attributes like `.style`,
`.accesskey`, or `.key`, **including adding a brand-new attribute or removing an
existing one** (the message gains or loses a part, so it changed - e.g. adding
`.aria-label` to a message that only had `.title`, or dropping a `.accesskey`),
**and including structural changes that move text between
the value and an attribute even when the text itself is reused** (dropping the
value and adding `.label`, promoting a `.label` to the value, etc.). A message whose
only edit is `.style = ...45em` -> `...32em` still needs a new id; so does one that
turns `foo = Add an item` into `foo =\n .label = Add an item`, and so does one
that merely gains or loses an attribute. Reused text makes
such a change *migratable* (from the old id), but it does **not** exempt it
from needing a new id. When you see a same-id restructure in a diff, flag it: the fix is for
the dev to bump the id, after which it migrates cleanly. The only exception to the
new-id rule is an *unchanged* cross-file move, which keeps its id. A brand-new id (no predecessor of its own) is usually
translated from scratch - *but not always*: if **every** translatable part of it
reuses the exact text of an existing source string, it is migratable via
cross-message reuse (copy each part with `COPY_PATTERN` from that other source),
subject to the no-partial rule and the cross-message context check below. Only a
new string for which some part has no reusable source is left out entirely. Do not
reflexively dismiss a new id as "translated fresh" - first check whether its text
already exists elsewhere.
If text matches several candidate source strings (AMBIGUOUS), pick the source by
hand. For legacy `.properties`, you can also scaffold with `properties-to-ftl`
(https://github.com/mozilla/properties-to-ftl).
When a target message draws its parts from a **different source message** - one or
more (e.g. a restructure whose value comes from one string and whose
`.title`/`.header` come from others, or a brand-new id whose `.title`/`.aria-label`
reuse some existing string's value), the test only proves the *English text*
matches - it cannot tell you the borrowed translation belongs in the new context.
A translation that is correct in its original message may be wrong once reused
elsewhere. For every such cross-message reuse, surface it to the user and have
them **independently confirm the source string's context matches the target's**
before relying on the migration; if the contexts don't line up, leave that part
(and therefore the whole message - no partial) out and let it translate fresh.
### 3. Write the recipe file
Path: `python/l10n/fluent_migrations/bug__.py`. The docstring must
contain the bug number and the literal `part {index}`. Use one `add_transforms`
block per `(target, from_path)` pair. See "Recipe shape" below for the template.
The bug number comes from the work you're doing - look in the relevant commit
message (`git log`, e.g. `git log -1 --format=%s`, or the bug reference on the
commit you diffed) and the conversation/task. If you can't find it, **use a
numeric placeholder** - `bug_0000000_.py` with `Bug 0000000` in the
docstring - rather than stalling; it keeps the test passing. Then tell the user
plainly that the bug number is a placeholder they must replace (rename the file
and update the docstring to match) before landing.
### 4. Validate with the in-tree test (authoritative)
```bash
./mach fluent-migration-test python/l10n/fluent_migrations/bug__.py
```
It checks out the pre-change strings, runs the recipe, and exits non-zero on any
error. Read the **"Fluent migration test summary"** (the diff above it is just a
visual aid), which sorts every finding into three levels:
- **ERROR** (must fix; the test exits non-zero): a recipe string that wasn't
migrated, a migrated message differing from the reference by more than
capitalization, a same-id/same-file ("migrated from itself") migration, a
non-normalized reference path, a recipe that couldn't be inspected or loaded
("Could not inspect declared targets"), or a bad bug number / a commit missing
`part {index}`.
- **WARNING** (surface every one): a migrated message differing only in
capitalization (confirm only the casing changed - then it's fine); a migrated
message "not present in the reference" (the target id doesn't exist in the new
en-US, usually a wrong or mistyped target id - fix it); or "No migration applied"
(the recipe produced no changes at all - almost always a mistake - fix it).
- **INFO**: strings that differ but aren't in the recipe. Surface these to the
user for review rather than silently ignoring them: each changed string needs
the cardinal-rule treatment (a **new id**, counting *every* attribute - see step
2). Flagging that a string "needs a new id" is **not** an invitation to then
migrate its unchanged parts: if any part changed, the whole message stays out (no
partial - see the hard rules). A genuinely new string with no reusable source for
some part, and quarantined strings, are fine to ignore - but a brand-new string
whose *every* part matches an existing source string's text should be migrated via
cross-message reuse (step 2), not ignored.
Relay **every** ERROR and WARNING line the summary prints to the user - never drop
a finding just because it isn't described above. Use the summary to correct
yourself too: an ERROR on a string you left out means it *was* fully reusable - add
it; an ERROR on a string you included means the text wasn't reusable - remove it,
and re-check against the two hard rules (you may be attempting a partial or
hardcoded migration).
## Recipe shape & hand-writing
Template - `COPY_PATTERN` for FTL sources, `COPY` for `.properties` keys:
```python
# Any copyright is dedicated to the Public Domain.
# http://creativecommons.org/publicdomain/zero/1.0/
from fluent.migrate.helpers import transforms_from
def migrate(ctx):
"""Bug - , part {index}."""
source = "browser/browser/preferences/containers.ftl"
target = "toolkit/toolkit/global/contextual-identity.ftl"
ctx.add_transforms(
target,
target,
transforms_from(
"""
user-context-color-blue =
.label = {COPY_PATTERN(from_path, "containers-color-blue.label")}
""",
from_path=source,
),
)
```
- Recipe paths drop `locales/en-US/` (`browser/locales/en-US/browser/foo.ftl` ->
`browser/browser/foo.ftl`). `from_path` is the *old* file, `target` is the new
file; use one `add_transforms` block per (target, `from_path`) pair.
- Inside the `transforms_from` string, always reference `from_path` (the keyword
passed to `transforms_from`), regardless of the local variable's name.
- `COPY_PATTERN`: `"id"` copies the value, `"id.attr"` copies an attribute. List
every attribute you migrate - and per the no-partial rule, migrate all of a
message's translatable parts or none.
- `.properties` -> Fluent uses **`COPY`** with the flat key. For placeholders,
brand, plurals, or markup, drop down to the raw AST:
```python
import fluent.syntax.ast as FTL
from fluent.migrate.transforms import COPY, REPLACE, PLURALS, REPLACE_IN_TEXT, CONCAT
from fluent.migrate.helpers import VARIABLE_REFERENCE, TERM_REFERENCE, MESSAGE_REFERENCE
```
- `%S`/`%1$S`/brand -> `REPLACE(path, key, {"%1$S": VARIABLE_REFERENCE("name"),
"Firefox": TERM_REFERENCE("brand-short-name")})` (`normalize_printf=True` is
the default for `.properties`).
- `a;b` plural with `#1` -> `PLURALS(path, key, VARIABLE_REFERENCE("count"),
lambda t: REPLACE_IN_TEXT(t, {"#1": VARIABLE_REFERENCE("count")}))`.
- markup / joined strings -> `CONCAT(...)`; never add your own spaces/punctuation.
- FTL->FTL transforms (strip `…`, remove a ``, rename a `{ $var }`) need a
custom `TransformPattern` subclass. Never bake English literals into a template -
that is the no-hardcoding rule.
Authoritative docs: `intl/l10n/docs/migrations/{overview,fluent,legacy,testing}.rst`.
For recent examples grep `python/l10n/fluent_migrations/` (pruned each cycle).