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.

—lang flag skips the per-language queue. Adding a language is an extension, not a refactor.The distinction that makes multilingual output tractable
Producing a document in multiple languages from one source requires a constraint that is easy to state and easy to violate: 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 - if a fact file contains “January” alongside the date, or if 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.
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:
- 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 is 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:
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:
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:
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:
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:
- Adding an
es:block totraduction.yamlwith all required translation keys - Running
gen_calendar.py --lang es - Adding a thin
es/planning.texwrapper (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. The build failure for a missing translation key is a feature, not a bug - 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
- Facts and display strings must never share a file. A fact appears identically in every edition; a display string varies by language. Mixing them couples the two concerns and multilingual maintenance grows with every violation.
- The data layer holds facts only.
events.yaml,leads.yaml, andvenues.yamlcarry names, dates, prices, and venues - nothing that changes when the language changes. - The language layer holds strings only.
traduction.yamlcarries month names, day abbreviations, and labels under language keys - no dates, prices, or proper names. - The generator is the only place they combine. Run with
--lang, it pairs a fact with its label and emits a language-specific edition, not a multilingual file with embedded switching. - Adding a language is an extension, not a refactor. Spanish needed an
es:block, a generator run, and a five-line wrapper - data, template, and generator untouched. - A missing translation key fails the build. Same logic as an unknown director: an incomplete document should not build silently into a PDF that looks complete but isn’t.
- Name the constraint and enforce it. The split is easy to violate incrementally; making violations cause build failures keeps the friction honest and the discipline intact.
External sources
- Facts vs. display strings (i18n/l10n)
- babel: LaTeX multilingual typesetting
- gettext: the classic strings-separation pattern
Related posts
Follow on LinkedIn for more
Articles on docs-as-code, DITA XML, YAML, and AI-assisted documentation.