Introductie

De internationalisering (i18n) van een statische site kan in eerste instantie complex lijken, maar JBake gecombineerd met Thymeleaf biedt elegante oplossingen om een meertalige site te maken. In dit artikel laat ik zien hoe ik de i18n op mijn blog heb ingesteld, waarbij ik zowel templating als het beheer van artikelen in meerdere talen behandel.

Diagram van gebruikscases (Use Case)

@startuml
left to right direction
skinparam packageStyle rectangle

actor "Auteur" as author
actor "Lezer FR" as reader_fr
actor "Lezer EN" as reader_en

rectangle "Meertalige JBake-site" {
  usecase "Artikel schrijven FR" as UC1
  usecase "Artikel schrijven EN" as UC2
  usecase "i18n-berichten configureren" as UC3
  usecase "Site genereren" as UC4
  usecase "Artikel lezen FR" as UC5
  usecase "Artikel lezen EN" as UC6
  usecase "Taal wijzigen" as UC7
  usecase "Beschikbare vertalingen bekijken" as UC8
}

author --> UC1
author --> UC2
author --> UC3
author --> UC4

reader_fr --> UC5
reader_fr --> UC7
reader_fr --> UC8

reader_en --> UC6
reader_en --> UC7
reader_en --> UC8

UC1 ..> UC4 : <<include>>
UC2 ..> UC4 : <<include>>
UC3 ..> UC4 : <<include>>
UC5 ..> UC8 : <<extend>>
UC6 ..> UC8 : <<extend>>
@enduml

Internationalisatie-architectuur

Onze aanpak berust op twee pijlers :

  1. De i18n van templating: gebruik van Thymeleaf-berichtbestanden voor de interface-elementen

  2. De i18n van de inhoudorganisatie van artikelen per taal in een speciale mapstructuur

Structuurdiagram (Organisatie van de bestanden)

@startuml
@startsalt
{
{T
+ projet-jbake
++ content
+++ blog
++++ 2024
+++++ fr
++++++ article1.adoc
++++++ article2.adoc
+++++ en
++++++ article1.adoc
++++++ article2.adoc
++++ 2025
+++++ fr
++++++ guide-i18n.adoc
+++++ en
++++++ i18n-guide.adoc
++ templates
+++ post.html
+++ index.html
+++ index_en.html
+++ messages.properties
+++ messages_fr.properties
+++ messages_en.properties
++ assets
+++ css
+++ js
+++ img
++ jbake.properties
++ output
+++ fr
++++ blog
+++++ 2025
++++++ guide-i18n.html
+++ en
++++ blog
+++++ 2025
++++++ i18n-guide.html
}
}
@endsalt
@enduml

Waarom deze benadering?

Deze scheiding maakt het mogelijk:

  • Behoud een consistentie in de interface, ongeacht de taal.

  • Beheren de inhoud en de vertalingen van artikelen onafhankelijk

  • Het toevoegen van nieuwe talen vergemakkelijken zonder grote refactoring

  • Toestaan artikelen die alleen beschikbaar zijn in bepaalde talen

Componentendiagram

@startuml
skinparam componentStyle rectangle

package "Bronnen" {
  folder "content/blog/2025/" {
    folder "fr/" as content_fr {
      [article1.adoc]
      [article2.adoc]
    }
    folder "en/" as content_en {
      [article1.adoc] as article1_en
      [article2.adoc] as article2_en
    }
  }

  folder "templates/" {
    [post.html]
    [index.html]
    [index_en.html]
  }

  folder "messages/" {
    [messages_fr.properties]
    [messages_en.properties]
  }

  [jbake.properties]
}

package "JBake Engine" {
  [Parser AsciiDoc] as parser
  [Template Engine\nThymeleaf] as thymeleaf
  [Generator] as generator
  [I18n Resolver] as i18n
}

package "Site Gegenereerd" {
  folder "fr/" {
    folder "blog/2025/" as blog_fr {
      [article1.html]
      [article2.html]
    }
  }

  folder "en/" {
    folder "blog/2025/" as blog_en {
      [article1.html] as article1_html_en
      [article2.html] as article2_html_en
    }
  }
}

content_fr --> parser
content_en --> parser
parser --> generator

templates --> thymeleaf
messages --> i18n
i18n --> thymeleaf
thymeleaf --> generator
jbake.properties --> generator

generator --> blog_fr
generator --> blog_en
@enduml

I18n van templating met Thymeleaf

Structuur van de berichtenbestanden

De eerste stap bestaat erin de properties-bestanden voor elke ondersteunde taal aan te maken :

src/jbake/templates/
├── messages.properties        # Fallback par défaut
├── messages_fr.properties     # Français
├── messages_en.properties     # Anglais
└── messages_de.properties     # Allemand

Inhoud van de berichtenbestanden

Hier is een voorbeeld van een bestand`messages_fr.properties`:

# Navigation
nav.home=Accueil
nav.blog=Blog
nav.about=À propos
nav.contact=Contact

# Articles
article.readmore=Lire la suite
article.published=Publié le
article.tags=Étiquettes
article.also.available=Également disponible en

# Interface
site.title=Mon Blog Technique
site.description=Partage de connaissances et expériences
footer.copyright=© 2025 Tous droits réservés
search.placeholder=Rechercher un article...

En zijn Engelse equivalent`messages_en.properties`:

# Navigation
nav.home=Home
nav.blog=Blog
nav.about=About
nav.contact=Contact

# Articles
article.readmore=Read more
article.published=Published on
article.tags=Tags
article.also.available=Also available in

# Interface
site.title=My Tech Blog
site.description=Sharing knowledge and experiences
footer.copyright=© 2025 All rights reserved
search.placeholder=Search articles...

Gebruik in de sjablonen

Gebruik in uw Thymeleaf-sjablonen de syntaxis`#{}`om toegang te krijgen tot de berichten :

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title th:text="#{site.title}">Mon Blog</title>
    <meta name="description" th:content="#{site.description}" />
</head>
<body>
    <nav>
        <a th:href="@{/}" th:text="#{nav.home}">Accueil</a>
        <a th:href="@{/blog/}" th:text="#{nav.blog}">Blog</a>
        <a th:href="@{/about.html}" th:text="#{nav.about}">À propos</a>
    </nav>

    <footer>
        <p th:text="#{footer.copyright}">Copyright</p>
    </footer>
</body>
</html>

Sequentiediagram - i18n-oplossing

@startuml
actor Auteur
participant JBake
participant "AsciiDoc\nParser" as parser
participant "Thymeleaf\nEngine" as thymeleaf
participant "I18n\nResolver" as i18n
database "messages_*.properties" as messages

Auteur -> JBake: jbake -b
activate JBake

JBake -> parser: Lire article.fr.adoc
activate parser
parser --> JBake: Contenu + métadonnées\n(lang=fr, article-id=xyz)
deactivate parser

JBake -> parser: Lire article.en.adoc
activate parser
parser --> JBake: Contenu + métadonnées\n(lang=en, article-id=xyz)
deactivate parser

JBake -> thymeleaf: Générer page FR
activate thymeleaf

thymeleaf -> i18n: Résoudre #{nav.home}
activate i18n
i18n -> messages: Lire messages_fr.properties
messages --> i18n: "Accueil"
i18n --> thymeleaf: "Accueil"
deactivate i18n

thymeleaf -> JBake: Rechercher traductions\n(article-id=xyz, lang!=fr)
JBake --> thymeleaf: article.en.html trouvé

thymeleaf --> JBake: /fr/blog/article.html
deactivate thymeleaf

JBake -> thymeleaf: Générer page EN
activate thymeleaf

thymeleaf -> i18n: Résoudre #{nav.home}
activate i18n
i18n -> messages: Lire messages_en.properties
messages --> i18n: "Home"
i18n --> thymeleaf: "Home"
deactivate i18n

thymeleaf -> JBake: Rechercher traductions\n(article-id=xyz, lang!=en)
JBake --> thymeleaf: article.fr.html trouvé

thymeleaf --> JBake: /en/blog/article.html
deactivate thymeleaf

JBake --> Auteur: Site généré
deactivate JBake
@enduml

Configuratie van de locale in JBake

In jouw bestand`jbake.properties`, stel de standaardlocale in :

# Locale par défaut
thymeleaf.locale=fr

# Encodage
template.encoding=UTF-8

I18n van de artikelen : organisatie per mappen

Stroomdiagram (Flow) - Generatie

@startuml
start

:Lire jbake.properties;
:Charger configuration i18n;

partition "Voor elk AsciiDoc-bestand" {
  :Lire métadonnées\n(:jbake-lang:, :jbake-article-id:);
  :Parser contenu AsciiDoc;
  :Stocker en mémoire avec langue;
}

partition "Voor elk template" {
  :Charger template Thymeleaf;
  :Identifier langue cible;
  :Charger messages_{lang}.properties;

  partition "Inhoudsfiltering" {
    :Filtrer articles par langue;
    :Grouper traductions\npar article-id;
  }

  :Appliquer template avec i18n;
  :Générer HTML dans /{lang}/;
}

:Copier assets statiques;
:Générer sitemap multilingue;
:Générer flux RSS par langue;

stop
@enduml

Mapstructuur

In plaats van het gebruik van suffixen in bestandsnamen, heb ik gekozen voor een organisatie op basis van mappen die meer duidelijkheid en onderhoudbaarheid biedt:

content/blog/
├── 2024/
│   ├── fr/
│   │   ├── introduction-jbake.adoc
│   │   ├── guide-thymeleaf.adoc
│   │   └── astuces-asciidoc.adoc
│   └── en/
│       ├── introduction-jbake.adoc
│       ├── thymeleaf-guide.adoc
│       └── asciidoc-tips.adoc
└── 2025/
    ├── fr/
    │   └── internationalisation-jbake.adoc
    └── en/
        └── jbake-internationalization.adoc

Voordelen van deze aanpak

Deze structuur biedt meerdere voordelen:

  • Duidelijke scheiding: elke taal heeft zijn eigen ruimte

  • Flexibele naamgeving: bestanden kunnen verschillende namen hebben volgens de taal

  • Schaalbaarheid: gemakkelijk een nieuwe taal toe te voegen

  • Natuurlijke organisatie: volgt de tijdelijke logica van JBake

Metadata van de artikelen

Elk artikel moet metadata bevatten om de koppeling tussen vertalingen mogelijk te maken. Hier is een voorbeeld:

Franse versie(2025/fr/internationalisation-jbake.adoc) :

= Internationalisation d'un site statique JBake
:jbake-type: post
:jbake-status: published
:jbake-date: 2025-10-20
:jbake-lang: nl
:jbake-article-id: jbake-i18n-thymeleaf
:jbake-tags: jbake, thymeleaf, i18n
:jbake-description: Guide pour mettre en place l'i18n avec JBake

Engelse versie(2025/en/jbake-internationalization.adoc) :

= Internationalizing a JBake Static Site
:jbake-type: post
:jbake-status: published
:jbake-date: 2025-10-20
:jbake-lang: nl
:jbake-article-id: jbake-i18n-thymeleaf
:jbake-tags: jbake, thymeleaf, i18n
:jbake-description: Guide to implement i18n with JBake

LET OP: Het attribuut`:jbake-article-id:`Het is cruciaal: het maakt het mogelijk om de verschillende vertalingen van hetzelfde artikel te koppelen.

Configuratie van de URLs

in`jbake.properties`, configureer het URL-patroon om de taal op te nemen :

# Pattern d'URL avec langue
post.permalink.pattern=:lang/blog/:year/:name.html

# Langue par défaut
site.default.lang=fr

Dit genereert URL’s van het type :

  • /fr/blog/2025/internationalisation-jbake.html

  • /en/blog/2025/jbake-internationalization.html

Sjablonen voor de meertalige weergave

Artikel sjabloon met taalselector

Maak een sjabloon`post.html`die de beschikbare vertalingen toont:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title th:text="${content.title}">Article</title>
</head>
<body>
    <article>
        <header>
            <h1 th:text="${content.title}">Titre</h1>

            <div class="article-meta">
                <time th:text="${#dates.format(content.date, 'dd MMMM yyyy')}"
                      th:attr="datetime=${#dates.format(content.date, 'yyyy-MM-dd')}">
                    Date
                </time>

                <!-- Sélecteur de traductions -->
                <div class="translations" th:if="${content['article-id']}">
                    <span th:text="#{article.also.available}">Aussi disponible en :</span>
                    <ul class="language-list">
                        <li th:each="post : ${published_posts}"
                            th:if="${post['article-id'] == content['article-id'] and post.lang != content.lang}">
                            <a th:href="${post.uri}"
                               th:text="${post.lang.toUpperCase()}">
                                LANG
                            </a>
                        </li>
                    </ul>
                </div>
            </div>
        </header>

        <div class="content" th:utext="${content.body}">
            Contenu de l'article
        </div>

        <footer class="article-footer">
            <div class="tags" th:if="${content.tags}">
                <span th:text="#{article.tags}">Étiquettes :</span>
                <span th:each="tag : ${content.tags}">
                    <a th:href="@{/tags/{tag}.html(tag=${tag})}"
                       th:text="${tag}">tag</a>
                </span>
            </div>
        </footer>
    </article>
</body>
</html>

Index gefilterd op taal

Creëer indexsjablonen voor elke taal:

index.html(index Frans) :

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title th:text="#{site.title}">Mon Blog</title>
</head>
<body>
    <main>
        <h1 th:text="#{nav.blog}">Blog</h1>

        <div class="articles-list">
            <article th:each="post : ${published_posts}"
                     th:if="${post.lang == 'fr'}">
                <h2>
                    <a th:href="${post.uri}" th:text="${post.title}">Titre</a>
                </h2>
                <time th:text="${#dates.format(post.date, 'dd MMMM yyyy')}">
                    Date
                </time>
                <p th:text="${post.description}">Description</p>
                <a th:href="${post.uri}" th:text="#{article.readmore}">
                    Lire la suite
                </a>
            </article>
        </div>
    </main>
</body>
</html>

index_en.html( Engelse index) :

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title th:text="#{site.title}">My Blog</title>
</head>
<body>
    <main>
        <h1 th:text="#{nav.blog}">Blog</h1>

        <div class="articles-list">
            <article th:each="post : ${published_posts}"
                     th:if="${post.lang == 'en'}">
                <h2>
                    <a th:href="${post.uri}" th:text="${post.title}">Title</a>
                </h2>
                <time th:text="${#dates.format(post.date, 'dd MMMM yyyy')}">
                    Date
                </time>
                <p th:text="${post.description}">Description</p>
                <a th:href="${post.uri}" th:text="#{article.readmore}">
                    Read more
                </a>
            </article>
        </div>
    </main>
</body>
</html>

Navigatie tussen talen

globale taalkiezer

Stroomdiagram - Gebruiker lezen

@startuml
start

:Utilisateur accède au site;

if (Langue préférée ?) then (FR)
  :Afficher /index.html;
  :Lister articles FR;
else (EN)
  :Afficher /en/index.html;
  :Lister articles EN;
endif

:Utilisateur clique sur article;

:Afficher article\navec métadonnées;

if (Traductions disponibles ?) then (oui)
  :Afficher sélecteur\nde traductions;

  if (Changement de langue ?) then (oui)
    :Rediriger vers\ntraduction;
    :Afficher article\ndans nouvelle langue;
  else (non)
    :Continuer lecture;
  endif
else (non)
  :Continuer lecture;
endif

stop
@enduml

Voeg een taalselector toe aan uw hoofdtemplate :

<nav class="language-switcher">
    <a href="/index.html"
       th:classappend="${content.lang == 'fr'} ? 'active'"
       title="Français">
        🇫🇷 FR
    </a>
    <a href="/en/index.html"
       th:classappend="${content.lang == 'en'} ? 'active'"
       title="English">
        🇬🇧 EN
    </a>
</nav>

CSS-stijl voor de selector

.language-switcher {
    display: flex;
    gap: 1rem;
    padding: 0.5rem;
    background: #f5f5f5;
    border-radius: 4px;
}

.language-switcher a {
    padding: 0.5rem 1rem;
    text-decoration: none;
    color: #333;
    border-radius: 4px;
    transition: background 0.2s;
}

.language-switcher a:hover {
    background: #e0e0e0;
}

.language-switcher a.active {
    background: #007bff;
    color: white;
}

.translations {
    margin: 1rem 0;
    padding: 1rem;
    background: #f8f9fa;
    border-left: 4px solid #007bff;
}

.language-list {
    display: inline-flex;
    gap: 0.5rem;
    list-style: none;
    padding: 0;
    margin: 0;
}

.language-list li::after {
    content: "•";
    margin-left: 0.5rem;
}

.language-list li:last-child::after {
    content: "";
}

RSS-feed per taal

Om aparte RSS-feeds per taal te hebben, maak aparte sjablonen aan :

feed.xml(Franse flux) :

<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:th="http://www.thymeleaf.org">
    <channel>
        <title th:text="#{site.title}">Mon Blog</title>
        <link th:text="${config.site_host}">http://example.com</link>
        <description th:text="#{site.description}">Description</description>
        <language>fr</language>

        <item th:each="post : ${published_posts}"
              th:if="${post.lang == 'fr'}">
            <title th:text="${post.title}">Titre</title>
            <link th:text="${config.site_host + post.uri}">Lien</link>
            <pubDate th:text="${#dates.format(post.date, 'EEE, dd MMM yyyy HH:mm:ss Z')}">
                Date
            </pubDate>
            <description th:text="${post.description}">Description</description>
        </item>
    </channel>
</rss>

Goede praktijken en tips

1. Consistentie van artikelidentificatoren

Zorg ervoor dat`:jbake-article-id:`is identiek voor alle vertalingen van hetzelfde artikel. Gebruik een consistent formaat :

  • Voorkeur voor Engelse identificatoren voor universaliteit

  • Gebruik streepjes om de woorden te scheiden

  • Vermijd speciale tekens

2. Consistente datums

Alle vertalingen van een artikel moeten dezelfde publicatiedatum hebben (:jbake-date:). Dit vergemakkelijkt het sorteren en de chronologische weergave.

3. Meertalige tags

Voor de tags, je hebt twee opties :

Optie 1 : Universele tags in het Engels

:jbake-tags: java, spring-boot, microservices

Optie 2 : Vertaalde tags met mapping

# Version française
:jbake-tags: java, spring-boot, microservices

# Version anglaise
:jbake-tags: java, spring-boot, microservices

4. Beheer van niet-vertaalde artikelen

Het is niet verplicht om alle artikelen te vertalen. Als een artikel alleen in één taal bestaat, wordt het eenvoudigweg niet weergegeven in de lijsten van de andere taal.

5. Meertalige sitemap

Genereer een sitemap die alle talen omvat :

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
        xmlns:xhtml="http://www.w3.org/1999/xhtml"
        xmlns:th="http://www.thymeleaf.org">
    <url th:each="post : ${published_posts}">
        <loc th:text="${config.site_host + post.uri}">URL</loc>
        <lastmod th:text="${#dates.format(post.date, 'yyyy-MM-dd')}">Date</lastmod>

        <!-- Liens alternatifs pour les traductions -->
        <xhtml:link th:each="translation : ${published_posts}"
                    th:if="${translation['article-id'] == post['article-id'] and translation.lang != post.lang}"
                    rel="alternate"
                    th:attr="hreflang=${translation.lang},href=${config.site_host + translation.uri}" />
    </url>
</urlset>

Conclusie

De internationalisatie van een JBake-site met Thymeleaf is een robuuste en onderhoudbare aanpak. Door i18n van templating (via berichtbestanden) en i18n van inhoud (via de organisatie in mappen) te scheiden, krijgt u een flexibel systeem dat gemakkelijk kan evolueren.

Belangrijkste punten om te onthouden:

  • Berichtbestanden Thymeleafvoor de gebruikersinterface

  • Organisatie per dossiers(jaar/taal) voor de artikelen

  • Artikel-IDsom de vertalingen te koppelen

  • Gespecialiseerde sjablonenvoor elke taal

  • Expliciete URL’sinclusief de taalcode

Implementatiediagram

@startuml
node "Ontwikkelmachine" {
  artifact "Bronnen" {
    folder "content/"
    folder "templates/"
    folder "assets/"
  }

  component "JBake CLI" as jbake

  Sources --> jbake : jbake -b
}

node "Buildserver\n(CI/CD)" {
  component "GitHub Actions\nof GitLab CI" as ci

  jbake --> ci : push
}

cloud "CDN / Hosting" {
  node "Statische Webserver" {
    artifact "Gegenereerde site" {
      folder "/fr/blog/"
      folder "/en/blog/"
      folder "/assets/"
    }
  }
}

ci --> "Gegenereerde site" : déploiement

actor "Lezers" as users

users --> "Statische Webserver" : HTTPS
@enduml

Met deze architectuur kunt u eenvoudig beginnen met twee talen en er andere aan toevoegen zonder grote refactorings. Alles blijft volledig statisch en presterend, trouw aan de filosofie van JBake.

Goede meertalige ontwikkeling! 🌍

Gerelateerde artikelen