| | | | | linguist.page@gmail.com

The shape of the problem

A Hugo site with lessons in Arabic, Spanish, French, and German sounds like four folders of translated content. In practice it’s one lesson structure — Read, Listen, Vocabulary, Practice — repeated four times, where each repetition needs its own text, its own audio references, and its own copywork boxes, but the skeleton around all of it (headings, front matter fields, CSS classes like .cc-container and .original-text) has to stay identical.

The failure mode with four folders maintained by hand isn’t translation quality — it’s drift. A template class gets renamed in the French folder during a fix and never makes it back to German. A front matter field gets added for Arabic lessons and the other three tracks silently fall out of sync. None of this shows up until a template render hook breaks on one language and works fine on the other three.

Why templating beats hand-editing here

The instinct is to treat each language folder as its own content, since the words are genuinely different. But the shape isn’t — every lesson has the same five sections in the same order with the same HTML scaffolding. That’s a strong signal that the shape belongs in code and the words belong in data.

The pipeline separates these two concerns:

content/
  lessons/
    lesson-01/
      data.yaml       # language-agnostic structure + per-language text
      generate.py     # renders 4 markdown files from data.yaml
      ar.md           # generated
      es.md           # generated
      fr.md           # generated
      de.md           # generated

data.yaml holds the lesson once, with language-specific fields nested:

lesson_id: "lesson-01"
weight: 1
sections:
  - type: read
    text:
      ar: "بِسْمِ اللَّهِ الرَّحْمَٰنِ الرَّحِيمِ"
      es: "En el nombre de Dios, el Clemente, el Misericordioso"
      fr: "Au nom de Dieu, le Tout Miséricordieux"
      de: "Im Namen Gottes, des Allerbarmers"
  - type: vocabulary
    items:
      - ar: "بِسْمِ"
        transliteration: "bismi"
        meaning:
          es: "en el nombre de"
          fr: "au nom de"
          de: "im Namen von"

The generator script

The Python script’s job is narrow on purpose: read the YAML, walk the sections, and emit one markdown file per language using a shared Jinja2 template per section type.

import yaml
from jinja2 import Environment, FileSystemLoader

env = Environment(loader=FileSystemLoader("templates"))

def generate_lesson(data_path, languages=("ar", "es", "fr", "de")):
    with open(data_path) as f:
        lesson = yaml.safe_load(f)

    for lang in languages:
        output = []
        front_matter = {
            "title": lesson.get("title", {}).get(lang, ""),
            "weight": lesson["weight"],
            "lesson_id": lesson["lesson_id"],
        }
        output.append("---\n" + yaml.dump(front_matter, allow_unicode=True) + "---\n")

        for section in lesson["sections"]:
            template = env.get_template(f"{section['type']}.md.j2")
            output.append(template.render(section=section, lang=lang))

        out_path = f"content/lessons/{lesson['lesson_id']}/{lang}.md"
        with open(out_path, "w", encoding="utf-8") as f:
            f.write("\n".join(output))

A section template stays deliberately simple — it only needs to know how to pull the right language key out of a dict:

{# vocabulary.md.j2 #}
## Vocabulary

<div class="cc-container">
{% for item in section.items %}
  <div class="original-text">{{ item.ar }} <span class="translit">({{ item.transliteration }})</span></div>
  <div class="input-area" data-answer="{{ item.meaning[lang] }}"></div>
{% endfor %}
</div>

Because .cc-container and .original-text are emitted from one template file rather than typed four times, a class rename or a copywork format change happens in exactly one place and propagates to all four languages the next time the generator runs.

What this doesn’t solve, on purpose

This pipeline doesn’t attempt to auto-translate anything — the Spanish, French, and German text in data.yaml is still written and reviewed by hand. Automating translation would trade a structural-drift problem for a much worse accuracy problem. The pipeline’s job is strictly to guarantee that once the four translations exist, they render through identical scaffolding — not to generate the translations themselves.

It also doesn’t try to be a general-purpose CMS. There’s no admin UI, no database — data.yaml files are just checked into the same git repo as the rest of the Hugo site, and generate.py runs as a pre-build step. For a solo-maintained site, that’s a feature: the entire pipeline is inspectable in a text editor, and there’s no separate system to keep running.

The tradeoff worth naming

The generation step does add a layer of indirection: a lesson typo now sometimes means editing YAML instead of Markdown directly, and anyone extending the section types needs to touch both a Jinja2 template and the generator’s section-type dispatch. For four languages and a growing lesson count, that indirection pays for itself. For a two-language site with a handful of lessons, hand-editing four Markdown files directly would likely still be less overhead than building and maintaining this pipeline — the point at which templating wins is a function of how many parallel tracks and how often the shared scaffolding changes, not a universal rule.