509318396 ­ ­ ­ ­ ­ ­ ­ ­ ­ ­ ­ ­ ­ ­ ­ ­ ­ ­ # 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 <x> table · heading · tab title · menu item · status value shown next to a <x> · option in the <x> dropdown · placeholder text in the <x> field · tooltip · accessible label (screen readers) · notice title · text in the <x> dialog · label of the <x> 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 ``, 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.