Skip to content

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:

  1. Enable Language and Content Translation, and add the languages the house serves.
  2. 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.repository before 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.