# Writing texts people can translate

Every text this plugin shows is translated by people who never see the code.
They are given the template — the msgid, the gettext context and the
`#. translators:` comment — and nothing else. No screenshot, no file name, no
surrounding call.

So the comment is not a courtesy. It is the whole specification the translator
works from, and a text without one is translated by guesswork. Amir's ruling
that started this (2026-09-03): *"the translation comments are not a ceremony.
Without meaningful comments, PTC will translate things wrong."*

This page is the review rule. It is the same rule wpml-core carries in its own
`docs/i18n.md`, and it is enforced by the two gates listed at the end.

## Every short text says what it is and where it is

One or two English words are ambiguous almost every time. "Rate" is an exchange
rate here and a rating elsewhere; "Order" is a purchase in one place and a
sequence in another. So every short text names its **role** and **where it is
shown**:

```php
/* translators: Column heading for a currency's exchange rate on the multicurrency screen. */
__( 'Rate', 'woocommerce-multilingual' )
```

Roles to use, in these words: button label · link text · column heading in the
&lt;x&gt; table · heading · tab title · menu item · status value shown next to a
&lt;x&gt; · option in the &lt;x&gt; dropdown · placeholder text in the &lt;x&gt;
field · tooltip · accessible label (screen readers) · notice title · text in the
&lt;x&gt; dialog · label of the &lt;x&gt; checkbox · email subject · email body
line.

A word that could be read two ways in English also gets its **meaning and its
grammatical form** — "Show", "Update", "Cancel", "Processing", "Custom":

```php
/* translators: Button label that closes a dialog and discards the changes. Verb, imperative: the action, not the state "cancelled". */
```

## Every placeholder is named, in order

Name what fills each one, as the reader of the screen would describe it — never
a variable name, never a function name, never a class name:

```php
/* translators: Line on the Status screen counting untranslated products in one language. %1$d: how many are missing, %2$s: the language name. */
```

A placeholder carrying markup is described as markup: `%1$s: opening link tag,
%2$s: closing link tag`. Where the same placeholder appears twice in the text,
say so once.

**Two or more placeholders in one text must be numbered** — `%1$s`, `%2$s`, not
`%s … %s` — in the msgid and in every `sprintf` argument list. printf fills
unnumbered placeholders in order, so a translator whose language puts them the
other way round cannot write a correct sentence. This is a gate, not a
preference. (`%1s` is not a number: printf reads the digit as a field width. It
has to be `%1$s`.) Numbering also has to start at 1 and be contiguous — a msgid
using `%2$s` and `%3$s` with two `sprintf` arguments is a fatal error on PHP 8,
and this lane found one.

## A fragment says what sentence it belongs to

When a sentence is built at run time from parts, or when it starts with a
pronoun, the comment carries the whole sentence and says what the pronoun is:

```php
/* translators: Last line of the admin notice about a caching plugin; "this" is the wrong currency being shown to shoppers. */
```

## A gettext context, only for a real difference

Split a msgid with `_x()` / `_nx()` only when the two uses would need
**different words in an inflected language** — an imperative verb against a noun,
a singular against a plural, an adjective against a noun. A heading and a link
with the same noun phrase, or a button and a menu item with the same imperative,
are **one** entry with **one** comment that names both places.

## Where the comment goes

- `/* translators: … */` on the line immediately before the gettext call. In a
  template where the call sits inside a one-line `<?php … ?>`, the comment goes
  inside the tag before the call — a comment on its own line there prints on the
  page. A comment attaches to **every** gettext call on the line that follows
  it, so on a line carrying two different msgids each call gets its own inline
  comment.
- One line, under about 200 characters, unless a placeholder list needs more.
- WordPress English: sentence case, a full stop at the end.
- The same msgid gets the **same** comment at every one of its call sites.
  `make-pot` merges identical comments and concatenates different ones, so two
  wordings on one msgid hand the translator both — and `pot-check.sh` fails.

## The text domain has to be this plugin's own

A mistyped or missing text domain keeps the string out of the template: it is
never translated, and nothing says so. This plugin's domain is
`woocommerce-multilingual`.

Four foreign domains are allowed, and only where the call deliberately reuses
another plugin's msgid verbatim so the shop owner reads the wording that plugin
already ships: `woocommerce`, `woocommerce-bookings`, `woo_ce`, `sitepress`.
Those strings are translated through that plugin's own template, not ours, so
they carry no translators comment here. Anything else — including an empty
domain — is an error on the line that wrote it.

Never pass a variable or a built-up string to `__()`. Nothing reaches the
template, and the gate cannot see it either.

## Regenerating the template

```
composer run i18n-update-pot
```

Run it in the CI image, never on a Windows host, and check the mount: `make-pot`
takes the plugin slug from the directory the plugin sits in, so the tree has to
be mounted as `woocommerce-multilingual`. Mounted under any other name it writes
a wrong `Report-Msgid-Bugs-To` header and silently drops entries.

## The gates

| Gate | What it reads | Where it runs |
|---|---|---|
| `WordPress.WP.I18n` sniffs, raised to errors in `phpcs.xml` | the PHP a merge request changed | the `standards` job |
| `build/quality/pot-check.sh` | the committed `.pot` — the whole of what a translator is given | the `Translation template` job |

The first speaks only about lines somebody just wrote. The second speaks about
the output, every run, which is what catches a comment that was written and does
not arrive: inside the call's brackets, or attached to the wrong call on a
shared line.
