Skip to content

feat(translation): translate the labels of post types and taxonomies on init - #602

Open
ogorzalka wants to merge 12 commits into
developfrom
feat/i18n-wiring
Open

ogorzalka wants to merge 12 commits into
developfrom
feat/i18n-wiring

Conversation

@ogorzalka

@ogorzalka ogorzalka commented Nov 12, 2026 •

Copy link
Copy Markdown
Member

What

The names and labels of post types and taxonomies could not be translated: singular: 'Book' is a literal, and the default labels were computed when Laravel boots, before init, from that untranslated literal. They are now built and translated in the init callback, where the text domains are loaded.

  • One call point. TranslatingEntityRegistry decorates the registry of pollora/entity at the three places the framework creates it, and resolves the arguments just before register_post_type() / register_taxonomy().
  • Translatable and TranslatablePlural (from pollora/i18n) written in an attribute argument, at any depth, are resolved there. This is how a text inside an array argument (admin columns, templates…) is marked: PHP does not allow __() in an attribute argument.
  • Default labels (Add New %s, Edit %s…) are built in init from the translated singular and plural. Their templates are literal __('…', 'pollora') calls, so wp i18n make-pot collects them, and the framework's own catalogue supplies them whatever textDomain the entity declares.
  • Explicit texts (#[Labels], Label, Description, TitlePlaceholder, FeaturedImage, admin column titles) go through __($text, $textDomain); a text with no translation comes back unchanged. A test reads resources/i18n.php of pollora/attributes and fails when a field declared as translatable is not translated, or the other way round.
  • No shortcut. __() is always called, so every gettext filter (Polylang, WPML…) applies.
  • The pollora catalogue is now loaded on init. It used to be loaded between wp and template_redirect, so on the front end the shipped .mo never reached these labels, and REST requests never loaded it at all.
  • Catalogue: five templates were in no file (No %s found in Trash, %s Archives, %s Attributes, Insert into %s, Uploaded to this %s). They are added to the .pot and translated in the seven shipped languages, in gender-neutral forms. A native speaker's review is welcome.
  • Commands: pollora:i18n:extract, update and compile wrap the ones of pollora/i18n; the console is the only place that loads them.

Behaviour that changes

  • With no catalogue the registered labels are byte for byte the same as before (compared on a full dump of $wp_post_types and $wp_taxonomies, in en_US and in a locale with no catalogue, and on the edge cases: withArgs(), configuring(), the factories, partial #[Labels]).
  • With the shipped catalogue, about forty labels of three entities go from English to the language of the site (fr_FR: Ajouter un(e) …, Tous les …). The catalogue is the existing one; its wording is gender-neutral.
  • REST requests now load the pollora catalogue (two attempts, a 4.7 KB .mo in fr_FR).

Performance

Measured on the front end of a test site, 40 requests alternated between the base and this branch: no class or file added beyond the decorator and the resolver (+2 classes, +2 files), no Tooling class loaded on a web request, request time within the noise of the measurement, in en_US and in fr_FR. translate() calls in en_US go from 0 to 55 (a __() is about 2 µs). Admin without a session and wp eval are unchanged; the admin with a session was not measured.

Checks

95 targeted tests and PHPStan pass on a clean install resolved from Packagist (pollora/i18n 0.1.1, pollora/attributes 0.1.2), where the test that guards the translated fields runs instead of being skipped. The thirty tests that fail locally in BootstrapEnvironmentTest, DatabaseConfigTest and ViteManagerTest fail the same way on develop.

Known gap, not in this PR: Ability, BlockBinding, CapabilitySet, Meta and Role declare translatable fields that are consumed elsewhere.

ogorzalka and others added 12 commits November 12, 2026 16:44
…omies on init

Every post type and taxonomy reaches WordPress through the entity
registration service, from the callback of the init action. The factories
and the configuring() support now hand it a TranslatingEntityRegistry, which
calls Translator::resolve() on the arguments and names just before the
adapter registers them, with the entity's text_domain (else pollora) as the
default domain. Arguments without a Translatable are passed on unchanged.

Adds the pollora:i18n:extract, :update and :compile commands (the commands
of pollora/i18n, without their short aliases), registered in the console
only.

Requires pollora/i18n, added to composer.json in the next commit.
…ies on init

The discovery used to build the labels of a post type or a taxonomy while
Laravel boots, before `init`, with the singular and plural names as written
in the attribute. The domains of themes and plugins were not loaded yet, and
WordPress's translation functions were not available, so the templates
("Add New %s") were never looked up in a catalogue and the names never were.

The discovery now hands over the raw names and asks for the default labels
with TranslatingEntityRegistry::defaultLabels(). The registry, which runs in
the callback of `init`, translates the singular and plural names in the text
domain of the entity (`textDomain`, else `pollora`), translates each template,
then gives it the translated name. Labels written by the class (#[Labels],
withArgs(), configuring(), factory arguments) still win over the generated
ones, and their Translatable texts are resolved.

Without a catalogue the labels registered are the ones registered before. A
domain with no catalogue and no `gettext` filter is not asked for the names.
…take the label templates from pollora

The texts a post type or taxonomy attribute writes in the registration
arguments (#[Labels], Label, Description, TitlePlaceholder, FeaturedImage and
the titles of the admin columns) are looked up on init in the text domain of the
entity (`textDomain`, else `pollora`). A text with no translation comes back as
written, so a label written directly in the language of the site is unchanged.
The default labels already built, and the Translatable already resolved, are
not looked up again. Nothing is looked up in a domain that has no catalogue and
no `gettext` filter.

The generic templates of the default labels ("Add New %s", "All %s"...) belong
to the framework: they are always looked up in the `pollora` domain, whatever
the `textDomain` of the entity, and given the singular or plural name
translated in the domain of the entity.

The `pollora` catalogue was loaded by the service provider once WordPress was
up, which on a web request is after `init`, so the templates found no catalogue.
The registry now loads it, once per locale, before building the default labels,
and the provider reuses that call.

A test reads the fields the attributes declare translatable and checks that each
of them is translated on registration, and that nothing undeclared is.
…ollora catalogue

The templates of the default labels of post types and taxonomies are looked up
in the pollora domain. Five were in no catalogue: "No %s found in Trash",
"%s Archives", "%s Attributes", "Insert into %s" and "Uploaded to this %s".
They are added to the template and, untranslated, to each language file; the
references of the other templates now point at their current place.
…tes up through __()

The check that skipped the lookup when a domain had no catalogue and nothing was
on the `gettext` filter cost about as much as the lookup it saved, and it left
out the `gettext_{domain}` and `gettext_with_context_{domain}` filters. The
singular and plural names, the explicit labels, `label`, `description`,
`title_placeholder`, `featured_image` and the titles of the admin columns now
all go through __(). The catalogue of the framework is still loaded on init.
…d no translation

No %s found in Trash, %s Archives, %s Attributes, Insert into %s and Uploaded to this %s, in the seven languages the framework ships. Gender-neutral forms, as the other templates of the catalogue.
pollora/i18n resolves the Translatable markers and runs the extraction commands; pollora/attributes 0.1.2 ships resources/i18n.php, which the test that guards the translated fields reads.
…ature

The stand-in took two parameters, so Rector, which only knew that declaration, read the third argument the e2e fixture passes as an extra one and wanted it removed. It takes the optional locale WordPress has, and the closure of the catalogue helper declares its return type.
…covery tests, and isolate the provider locale tests
@ogorzalka

Copy link
Copy Markdown
Member Author

CI was red on this branch because of three interactions with tests merged into develop since, none in the feature itself:

  • The discovery tests added with the coverage work read args['labels']['name'] and ['singular_name']; the default labels are now built on init, so at discovery the arguments carry the TranslatingEntityRegistry::DEFAULT_LABELS marker ([kind, singular, plural]). The four assertions now check the marker.
  • determine_locale() and load_textdomain() were declared as global stand-ins by two test files; a global function is declared once, so the one loaded first (the providers test) won and the recorder of DefaultLabelsTest saw no call. Both files now use one shared stand-in (tests/Unit/wordpress-i18n-stand-ins.php).
  • loadFrameworkTextDomain() remembers the locales it has tried for the whole process, so the provider tests, which used fr_FR and de_DE, depended on the order of the other tests. They now use it_IT and nl_NL, which no other test uses, and forget the catalogue before each test.

Full suite on this branch merged with develop: only the 6 template tests that already fail on macOS; the files involved pass in 8 random orders. No change to src/.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant