Skip to content

Run the documented translator adapters, and rewrite the prose round them - #36

Merged
jakejackson1 merged 1 commit into
mainfrom
docs/deslop-i18n
Aug 21, 2026
Merged

Run the documented translator adapters, and rewrite the prose round them#36
jakejackson1 merged 1 commit into
mainfrom
docs/deslop-i18n

Conversation

@jakejackson1

Copy link
Copy Markdown
Member

Follow-up to #35. Nothing ran the translator adapters the README told a caller to write. Wiring one up against the real library found a bug, so this adds the checks, the per-library pages, and a prose pass over what was left.

A bug, found by running the documented code

Symfony's PoFileLoader reads a .po into one catalogue entry per msgid — including entries still carrying an empty msgstr. trans() then answers '' for anything not yet translated:

before:  untranslated msgid rendered as ""      ← blank message, on the failure path
after:   untranslated msgid rendered as "Unknown error"

Gettext defines an empty msgstr as untranslated. Symfony's loader does not apply that rule, so Translation::translate() now does — for every loader, not just that one.

Anyone with a partly-translated catalogue would have shown blank error messages to whoever submitted the file.

The checks

tools/translator-readme/, built the way tools/psr7-readme/ already is: its own pinned manifest so the library still depends on nothing, and a verify.php that reads each snippet out of the documentation at run time rather than copying it. Reword an adapter and the extraction fails by name.

The libraries are Composer dependencies installed in CI on PHP 7.3 and 8.5 — one pinned set resolves on both; illuminate/translation 8.x is the newest that still installs on 7.3.

Each adapter is checked for three things: a translated msgid comes back translated, an untranslated one falls back to English rather than an empty string, and a value still interpolates.

The catalogue is built by running the documented commandsmsginit, msgfmt --check, msgmerge, msgattrib — not approximated in PHP, so the pipeline a reader follows is the one under test. Symfony and php-gettext consume a real GNU message catalog.

WordPress

wp i18n make-pot runs before WordPress loads, so the extraction half needs wp-cli but no WordPress and no database. wordpress.sh builds a plugin tree from git archive — what it holds is what a consumer's vendor/ holds — runs the documented make-pot --merge, checks the library's msgids and the plugin's own survived, then compiles and reads the result back.

It also asserts the negative the WordPress page turns on: that a catalogue left inside vendor/ is still invisible to make-pot. That claim would otherwise rot silently.

The runtime half that actually goes wrong is pinned in PHP instead. marker-import.php imports this library's __() marker and calls both __() and \__() — the situation the docs warn about, where a missing backslash makes the translator a silent no-op.

Mutation-tested, not just green

mutation result
empty-string fallback removed Symfony: an untranslated msgid rendered as ""
Symfony snippet reworded The Symfony adapter is no longer in the documentation
expected msgid absent the library's msgids are missing from the merged catalogue

One mutation I ran was invalid: emptying i18n/upload.pot in the working tree does not fail wordpress.sh, because it builds from git archive HEAD. That is the script working as designed, but it means working-tree edits cannot mutation-test it.

I also confirmed msgfmt --check earns the place the docs give it — a translation dropping a %1$s gives a format specification for argument 1 doesn't exist in 'msgstr', because xgettext marks those entries #, php-format.

Documentation

One page per library under docs/translation/, each with its adapter, the commands to build its catalogue, and its gotchas — Symfony's empty-msgstr behaviour, Laravel's printf placeholders not being :name, php-gettext's two-package split, and the whole wordpress.org limitation including why --include proves nothing.

The README links rather than repeats. That matters for the check as much as for length: with a snippet in two places, verify.php would only exercise whichever copy it found first, so a stale one could pass.

docs is added to package.yml's expected top level, so the pages ship.

Prose

The docblocks and docs added in #35 were written at length rather than for clarity — the same fact twice in different words, asides longer than their clause, sentences narrating why a design is good rather than what it does. i18n.php carried twenty lines of docblock over a function whose body is return $text;. UPGRADE.md described six reworded messages in a paragraph where a before/after table is what somebody upgrading wants.

Verification

551 tests, PHPStan level 9, PSR-12, parallel-lint, the PSR-7 bridge, the translator adapters and the WordPress pipeline all pass locally. No message id moves and i18n/upload.pot regenerates byte for byte.

The wp-cli download and apt-get gettext steps have only run on my machine — this PR's own CI run is the first real test of the new workflow.

🤖 Generated with Claude Code

@jakejackson1
jakejackson1 force-pushed the docs/deslop-i18n branch 27 times, most recently from 7de8ed5 to b342f06 Compare August 21, 2026 04:22
Three things, all downstream of the same discovery: nothing ran the
adapters the README told a caller to write.

Wiring the Symfony one up against the real library turned up a bug.
Symfony's PoFileLoader reads a `.po` into one catalogue entry per msgid,
including entries still carrying an empty `msgstr`, so `trans()` answers
`''` for anything not yet translated. Every untranslated message
rendered blank rather than falling back to English — on the failure
path, in front of whoever submitted the file. Gettext defines an empty
`msgstr` as untranslated; Symfony's loader does not apply that rule, so
`Translation::translate()` now does, for every loader rather than that
one.

tools/translator-readme/ is the arrangement tools/psr7-readme/ already
uses: its own pinned manifest, so the library still depends on nothing,
and a verify.php that reads each snippet out of the documentation at run
time rather than copying it. Reword an adapter and the extraction fails
by name. The libraries are installed by Composer in CI on both ends of
the supported range — illuminate/translation 8.x is the newest that
still installs on 7.3, and one pinned set resolves on both.

Each adapter is checked for the three things it has to hold: a
translated msgid comes back translated, an untranslated one falls back
to English rather than to an empty string, and a value still
interpolates. The catalogue is built by running the commands the pages
document — msginit, msgfmt, msgmerge, msgattrib — rather than
approximating them in PHP, so the pipeline a reader follows is the one
that gets tested.

WordPress cannot be run without WordPress, but its extraction half can:
`wp i18n make-pot` runs before WordPress loads. wordpress.sh builds a
plugin tree from `git archive`, so what it holds is what a consumer's
vendor/ holds. It runs the documented `make-pot --merge`, compiles the
result, runs the strings-file generator the wordpress.org section
documents, and asserts the negative that section turns on — that a
catalogue left inside vendor/ is still invisible to make-pot. The
runtime half that actually goes wrong is pinned in PHP instead:
marker-import.php imports the marker and calls both `__()` and `\__()`,
which is the situation the docs warn about.

Each library now has a page under docs/translation/ carrying its
adapter, the commands to build its catalogue, and what to watch for. The
README links to them rather than repeating the snippets, so each exists
once and the check cannot pass against a stale copy.

CLAUDE.md gains a section on writing comments and documentation, so the
same edits stop having to be asked for.

The prose those pages were cut from was written at length rather than
for clarity: the same fact stated twice in different words, asides
longer than the clause carrying them, and sentences narrating why a
design is good rather than what it does. i18n.php carried twenty lines
of docblock over a function whose body is `return $text;`. UPGRADE.md
described six reworded messages in a paragraph where a before/after
table is what somebody upgrading wants.

No message id moves and the catalogue regenerates byte for byte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jakejackson1
jakejackson1 merged commit 215f613 into main Aug 21, 2026
34 checks passed
@jakejackson1
jakejackson1 deleted the docs/deslop-i18n branch August 21, 2026 04:29
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