# One source, three languages: the strict facts-vs-display-strings split

I write software documentation for a living, where one rule earns its keep over and over: keep each kind of content in its own source, and resolve them together only at build time. The same discipline applies to a planning poster that has to ship in French, English, and Spanish from a single source. The dates, prices, director names, and venues are identical in every edition; only the labels around them change. The question is where each kind of value lives, and what stops the two kinds from leaking into each other.

The answer here is architectural: facts go in `events.yaml`, `leads.yaml`, and `venues.yaml`; display strings go in `traduction.yaml`; and the generator pairs them at build time under a `--lang` flag. The rest of this post is what enforcing that split looks like in practice, and why the friction it creates is the point.

<figure>
  ![At airport immigration, a Swiss traveller and a Chinese traveller wait in the ](https://redaction-technique.org/images/blog/one-source-three-languages-fast-lane.webp)
  <figcaption>One source, every edition: a single fact base resolved under a <code>--lang</code> flag skips the per-language queue. Adding a language is an extension, not a refactor.</figcaption>
</figure>

## The distinction that makes multilingual output tractable

Producing a document in multiple languages from one source requires a constraint that's easy to state and easy to violate.

> **The core constraint:** Facts and display strings must never live in the same file.

A fact is a value that appears identically in all language editions: a director's name, a date, a price, a bank account number, an immersion venue. A display string is a label that varies by language: "led by" / "sous la direction de" / "dirigido por", "January" / "Janvier" / "Enero", "Registration" / "Inscriptions" / "Inscripciones".

If both live together (a fact file contains "January" alongside the date, say, or a translation file contains an immersion price alongside the label for it), the separation is already broken. A new language edition requires editing the fact file. A fact correction requires reviewing the translation file. The two concerns are coupled, and multilingual maintenance becomes complex in proportion to how many violations accumulate.

The planning poster enforces the constraint architecturally: facts files contain no strings, strings files contain no facts, and the generator enforces the distinction at build time.

<div class="not-prose grid gap-4 sm:grid-cols-3 my-6">
  <ConceptCard title="Data layer" badge="Facts">events.yaml, leads.yaml, venues.yaml: dates, prices, proper names, venues. Nothing that changes when the language changes.</ConceptCard>
  <ConceptCard title="Language layer" badge="Strings">traduction.yaml: month names, day abbreviations, and labels under language keys. No dates, prices, or proper names.</ConceptCard>
  <ConceptCard title="Generator" badge="--lang">Combines a fact with its label at build time and emits a language-specific edition, not a multilingual file with embedded switching.</ConceptCard>
</div>

## The data layer: facts only

Three YAML files hold all factual information for the poster. None of them contains a single language-specific string.

`events.yaml` holds immersion events for the year:

```yaml
- title: Southern Session
  date: 2025-10-04
  to: 2025-10-05
  type: immersion
  venue: Central Hall
  lead:
    - Sarah Bennett
  prix:
    normal: 110
    reduit: 85
```

The `title` field here is a name, not a label: it's the immersion's proper name and appears as-is in all language editions. The `type` field is a key used by the generator for calendar coloring; it never appears as text in the output. The prices are numbers. The director's name is a proper name. Nothing in this entry changes when the language changes.

`leads.yaml` holds director reference data: full name, status (lead/teacher), gender. All facts. No labels.

`venues.yaml` holds venue reference data: name, type, country, address, contact. All facts. No labels.

## The language layer: display strings only

`traduction.yaml` holds every string that varies by language, under language keys:

```yaml
fr:
  mois:
    septembre: Septembre
    octobre: Octobre
    novembre: Novembre
    # ... through août
  jours:
    lundi: L
    mardi: M
    mercredi: M
    jeudi: J
    vendredi: V
    samedi: S
    dimanche: D
  labels:
    dirige_par: "dirigé par"
    prix_normal: "tarif normal"
    prix_reduit: "tarif réduit"
    inscription: "Inscriptions"

en:
  mois:
    septembre: September
    octobre: October
    # ...
  jours:
    lundi: M
    mardi: T
    # ...
  labels:
    dirige_par: "led by"
    prix_normal: "full rate"
    prix_reduit: "reduced rate"
    inscription: "Registration"

es:
  mois:
    septembre: Septiembre
    # ...
  labels:
    dirige_par: "dirigido por"
    prix_normal: "tarifa normal"
    prix_reduit: "tarifa reducida"
    inscription: "Inscripciones"
```

Nothing in this file is factual. No dates. No prices. No proper names. Nothing that a fact correction would need to touch.

## How the generator resolves them

The generator runs with a `--lang` flag:

```bash
python gen_calendar.py --lang fr   # French edition
python gen_calendar.py --lang en   # English edition
python gen_calendar.py --lang es   # Spanish edition
```

Inside `EventLoader`, the constructor loads the translation table for the active language:

```python
def __init__(self, lang: str):
    self.lang = lang
    with open("traduction.yaml") as f:
        all_translations = yaml.safe_load(f)
    self.translations = all_translations[lang]
```

When the generator builds an event description, it combines fact and label:

```python
def format_event_block(self, event: dict) -> str:
    director = ", ".join(event["lead"])
    led_by   = self.translations["labels"]["dirige_par"]
    price    = event["prix"]["normal"]
    rate     = self.translations["labels"]["prix_normal"]
    # ...
    return f"{director}\\\\{led_by}\\\\{price}\\,\\texteuro{{}}"
```

The fact (director name, price) comes from the data layer. The label ("led by", "tarif normal") comes from the language layer. Neither layer contains the other's content. The generator is the only place where both are combined, and it produces language-specific output, not a multilingual file with embedded language switching.

Facts and display strings never share a file; the generator is the only place they combine. A missing translation key fails the build rather than printing a blank label into a press file.

## What this means for adding a language

Adding a Spanish edition to a system that previously produced only French and English required:

The `es/planning.tex` wrapper is five lines: set `\babellang{spanish}` and `\input{../planning-shared}`. The data files were untouched. The template was untouched. The generator was untouched. The only changes were in the language layer and the thin wrapper: exactly the layers that should change when adding a language.

Contrast this with a system where facts and labels are mixed in the same file. Adding Spanish would require reviewing every data file for embedded French labels, extracting them, creating a separate translation, and updating the references. A refactor rather than an extension.

## The constraint in practice

The discipline creates friction. A new fact added to `events.yaml` doesn't automatically have a display label; the label must be added to `traduction.yaml` in all active languages before the build works. A new label added in French must immediately have English and Spanish equivalents, or the English and Spanish builds will fail when the generator tries to look up the key.

This is intentional.

> **A feature, not a bug:** the build failure for a missing translation key is the same logic that makes an unknown director halt the build, applied to a different class of incompleteness.

It enforces the constraint at build time: a multilingual document with a missing translation in one language is an incomplete document, and incomplete documents should not build silently into PDFs that look complete but aren't.

The alternative (silently falling back to the source language, or to an empty string, for missing translations) produces PDFs that contain untranslated fragments or blank labels. These look like errors to a reader who receives the Spanish edition with French labels in it. A build failure that says "missing key `prix_normal` in `es` translations" is harder to ignore and easier to fix than a printed poster with untranslated text.

## The broader principle

The facts-vs-display-strings split is a specific application of a broader separation-of-concerns architecture. It's worth treating as a named constraint rather than a consequence of good organization, because it's easy to violate incrementally (one hardcoded string here, one translated label mixed into a data file there) and the violations compound silently until adding a new language requires significant refactoring rather than a YAML addition.

Name the constraint. Enforce it at build time. Make violations cause build failures rather than subtle output errors. The discipline is worth the friction.

## Summing up

## External sources

- [Facts vs. display strings (i18n/l10n)](https://en.wikipedia.org/wiki/Internationalization_and_localization)
- [babel: LaTeX multilingual typesetting](https://ctan.org/pkg/babel)
- [gettext: the classic strings-separation pattern](https://www.gnu.org/software/gettext/)

<small>*Hero image: ["Trilingual sign"](https://www.flickr.com/photos/logatfer/6949205556) by [David Boté Estrada](https://www.flickr.com/photos/logatfer/), licensed under [CC BY-SA 2.0](https://creativecommons.org/licenses/by-sa/2.0/).*</small>

---

Source: https://redaction-technique.org/one-source-three-languages
