Translation¶
A multilingual house serves the same season in several languages. The rule the engine follows is one line: a string a visitor reads has to be translatable. Everything else about a booking is a requirement rather than prose, and a requirement reads the same in every language.
That splits the model cleanly in two, and the split is what the rest of this page describes.
What a house translates¶
These entity types carry an authored label a booker reads, so each stores it per language:
| Type | What is translated |
|---|---|
yoyaku_resource |
The name of what is on offer, which also heads every slot title the engine composes |
yoyaku_slot |
The slot title, where a house types one ("Premiere", "Signed performance") |
yoyaku_resource_tariff |
The tariff label a booker chooses between ("Adult", "Reduced") |
yoyaku_tariff_class |
The heading over a set of tariffs ("Full price") |
yoyaku_allotment |
The name of a set-aside, shown on a ticket for a booking that came out of it |
yoyaku_tariff_preset |
The name of a reusable tariff set, an operator vocabulary |
yoyaku_venue |
The name of the hall itself, the where of a booking page and of a ticket |
yoyaku_section |
The name of an area of a venue ("Orchestre") |
yoyaku_grade |
The name of a place category, shown beside the offers it covers |
yoyaku_configuration, yoyaku_pinning |
Operator vocabulary for a hall's layouts |
Only the label is translated. Everything else on those rows is one requirement: the times, the capacities and quotas, the machine keys, the weights, the references, the two flags a slot is published by. A house editing a quota in French means it to hold in English too, so the value is shared across the translations rather than copied into each.
What a house does not translate¶
- Records. A booking, a transaction, a hold, a place, a pinned place: these document what happened and carry no authored prose.
- The slot tariff (
yoyaku_slot_tariff), which is an allocation with no label of its own. What a booker reads there is the resource tariff it provisions. - Interface strings shipped by the module, which are translated the ordinary
way through
translations/yoyaku.fr.po, not per entity. - Dates and times, which are formatted through Drupal's date formatter and therefore already follow the language they are shown in.
Switching it on¶
The engine's entities are headless: they have no page of their own, only admin
screens. Core hangs its translation screens off an entity type's canonical link,
so the engine gives each translatable type one on its edit form, the way core's
own menu_link_content does. Nothing of this appears until the site has the
translation UI:
- Enable Language and Content Translation, and add the languages the house serves.
- Nothing else. Each translatable type is already offered for translation, and each carries a Translate tab on its edit screen beside an Edit tab, with the translation forms under that screen's path.
Step 2 is the engine's, not the operator's: it ships that answer as optional
config, which Drupal installs only when content_translation is there. A site
without it gets none of the settings and is not asked about them.
The types are the ones listed under What a house translates above, and on each of them the label is the translatable field.
To translate fewer of them, clear the ones the house does not translate at Configuration → Regional and language → Content language and translation. Clearing a type leaves any translation already written in place; it stops the screen offering to write more.
A site with one language does none of this and sees no change: a type with no second language has one row, which is the row it always had.
How a translation reaches a booker¶
Reading a label in the visitor's language is a separate thing from storing it per language, and the engine keeps the two apart on purpose.
- The engine answers in one language. Availability, pricing and the hold path read the default translation, because what is left is not a per-language question and an answer that varied by language could not be shared between visitors.
- The surfaces translate what they show. The booking page, the plan, the
cart card, the ticket and the notifications resolve each entity through
entity.repositorybefore reading a label off it, which is what applies the visitor's language. - A composed label follows its own language.
BookingSlot::label()falls back to the resource name and the start time when a house typed no slot title, and it takes the resource's translation of the slot's own language, so a French slot is never headed by an English resource name. - Mail names its language. A notification is built from the queue that
sweeps the order, whose request language is the sweep's rather than the
booker's, so
TransactionSummary::build()takes a langcode and the order tokens pass the one the replacement was asked for. - Anything held is held per language. The month availability feed and the plan's two cached halves all carry the language in their key, so one language's slot titles are never served to another.
Keeping the shipped catalog complete¶
The interface strings live in translations/yoyaku.fr.po, and the rule is that
a change ships its translations in the same commit as the string it adds.
That rule is checked rather than remembered. Every pipeline runs:
php scripts/check-translations.php
which extracts the module's translatable strings with potx, the extractor localize.drupal.org itself runs, and fails when one of them has no entry in the catalog.
The job is advisory, at the same level as cspell and phpstan: a string waiting for its French does not refuse a merge. That makes it a job to read rather than a badge to trust. Run it yourself before pushing, too: it needs no database and takes a second.
potx rather than a search for t( because knowing what counts is the hard
half: label: in config/schema counts and the same key in config/install
does not, .info.yml name and description count, and a plural is one entry
here and two in gettext. A regex scan of this module once reported everything
present and was wrong by ninety-four strings.
What is deliberately not translated lives in translations/untranslated.txt,
one string per line with the reason it is there. Two kinds qualify, and nothing
else should be added:
- Log messages. potx extracts them, because a logger call looks like any other call with a sentence in it, but a watchdog entry is read by whoever is fixing the site and stays in English. The same doctrine the engine applies to a refusal: what went wrong and what the visitor is told are two different sentences.
- Words core already answers, such as the breadcrumb's "Home", which core already renders in French. Translating them here would duplicate core's catalog rather than add anything.
One blind spot, upstream and known: potx does not extract a JavaScript call with
a trailing comma after its last argument, Drupal.t('…',), which is what
Prettier emits on a wrapped call. The check is therefore strong over PHP and
YAML and leaky over JavaScript.
How the French catalog is written¶
The completeness check counts entries; it says nothing about whether a sentence reads as French. That is a reading, and these are the answers it settled so the next one starts from them rather than from scratch.
Typography, decided once for the whole catalog. The apostrophe is U+2019 throughout, and the space before a colon, semicolon, exclamation or question mark is a non-breaking space, U+00A0, as is the space inside a pair of French quotation marks. U+00A0 rather than the narrow U+202F because three quarters of the sites are colons, which is the mark French typography gives a word space rather than a fine one, and because one character for all four means one grep finds every exception rather than four. Both are checkable in one line:
grep -c "^msgstr.*'" translations/yoyaku.fr.po # a typewriter apostrophe: 0
grep -c "^msgstr.* [;:!?]" translations/yoyaku.fr.po # a breaking space: 0
One English word, one French word. A msgid that says the same thing twice has to say it the same way twice, and where two of the engine's words are close the French has to keep them apart:
| English | French | Not |
|---|---|---|
| slot | créneau |
séance, which is the event sense the engine retired |
| section | section |
zone, which is what an area is |
| area | zone |
espace, secteur |
| place grade | grade de place |
catégorie |
| tenant | tenant |
locataire |
| booking channel, front | canal |
espace |
| workflow | workflow, as Orchestra's own catalog writes it |
flux de travail |
| settlement | clôture |
règlement, which is a payment |
| booker | réservant |
visiteur, who is anybody at all |
| operator | opérateur |
exploitant |
| row | rang |
rangée |
| parked | en attente, as Orchestra writes it |
positionné |
A term that turns a sentence circular is dropped for that sentence. Pinned
to this allotment is not contingentées pour ce contingent; it is
réservées à ce contingent. An area with no setting follows its section
cannot have both nouns as zone, or it says a zone follows itself.
Read the finished sentence aloud. What no check can find is the calque: a sentence that parses, passes every job and still reads as translated rather than written. It is found by reading the msgstr against its msgid and asking whether a French speaker would have written that sentence.
Migration (pre-1.0, no update hooks)¶
Making an entity type translatable moves its columns out of the base table into
<base>_field_data, which holds one row per language. Yoyaku ships no update
hooks before 1.0, so a site carrying data from before this change reinstalls
rather than migrating: the base tables yoyaku_resource, yoyaku_slot and
yoyaku_resource_tariff keep only their id, uuid and langcode, and everything
else has moved.
The one thing to know when reading the schema afterwards: the slot's
(resource, start) unique key moved with its columns and gained the langcode,
because a slot has one row per language and neither column is translatable. The
guarantee is unchanged, since a second slot at the same resource and start
collides in the first row it writes.
Writing a query over a translated type¶
A query over one of these types joins the data table, which has a row per language, and core adds no language condition of its own. So a query that filters, sorts or counts adds one:
$ids = $storage->getQuery()
->accessCheck(FALSE)
->condition('default_langcode', 1)
->condition('resource', $resource->id())
->sort('weight')
->execute();
Without it a slot published in one language reads as published, and a list sorted on a label is ordered by whichever language's row the database met first while the screen shows another. The display language is applied where the entity is rendered, never in the query.