Internationalisation (i18n)
Introduction
MintyFlaskThemes uses a YAML-based internationalisation system that separates site-wide text content from template logic. This approach provides a clean, maintainable way to manage translatable strings, site configuration, and multi-language support without requiring complex translation frameworks.
Why YAML-Based i18n?
Advantages:
- Simple to maintain: Plain text YAML files that non-developers can edit
- Version control friendly: Easy to track changes and manage translations
- No compilation step: Changes take effect immediately
- Framework agnostic: Works with any Flask setup
- Hierarchical organisation: Natural structure for grouped content
When to use:
- Site-wide labels and text
- Navigation menu items
- Form labels and button text
- Error messages and notifications
- SEO metadata (titles, descriptions)
- Multi-language site content
The i18n System Architecture
How It Works
The i18n system follows a simple three-step process:
- YAML Configuration: Text content stored in YAML files
- Flask Integration: YAML loaded and passed to templates
- Template Access: Jinja2 templates access translations with fallbacks
# Flask loads YAML
i18n_data = load_yaml('config/i18n/en.yaml')
# Passed to template context
render_template('page.html', i18n_yaml=i18n_data)
{# Template accesses with fallback #}
{{ i18n_yaml.site_name or "Default Site Name" }}
File Structure
Organise i18n files by language and purpose:
project/
├── config/
│ └── i18n/
│ ├── en.yaml # English (default)
│ ├── nl.yaml # Dutch
│ ├── fr.yaml # French
│ └── common/
│ ├── navigation.yaml
│ ├── forms.yaml
│ └── errors.yaml
YAML Configuration Files
Basic Structure
Create a primary language file with all translatable strings:
# config/i18n/en.yaml
# Site Identity
site_name: "Minty Fresh Flask"
tagline: "A minty fresh publishing platform"
description: "Discover fresh perspectives and insightful articles"
keywords: "blog, flask, python, web development, articles"
# Site Configuration
logo_url: "/static/images/logo.png"
language: "en"
locale: "en_GB"
# Navigation
nav:
home: "Home"
about: "About"
blog: "Blog"
contact: "Contact"
# Common Actions
actions:
read_more: "Read more"
learn_more: "Learn more"
get_started: "Get started"
subscribe: "Subscribe"
search: "Search"
# Blog-Specific
blog:
recent_posts: "Recent Posts"
featured_post: "Featured Post"
all_posts: "All Posts"
categories: "Categories"
tags: "Tags"
archives: "Archives"
no_posts: "No posts available"
read_article: "Read article"
# Forms
forms:
name_label: "Name"
name_placeholder: "Enter your name"
email_label: "Email"
email_placeholder: "Enter your email"
message_label: "Message"
message_placeholder: "Your message"
submit: "Submit"
required: "required"
# Messages
messages:
success: "Success! Your message has been sent."
error: "An error occurred. Please try again."
loading: "Loading..."
# Footer
footer:
copyright: "© 2024 Minty Fresh Flask. All rights reserved."
privacy: "Privacy Policy"
terms: "Terms of Service"
Hierarchical Organisation
Group related strings under nested keys:
blog:
titles:
home: "Blog Home"
category: "Category: {category}"
tag: "Posts tagged with {tag}"
archive: "Archive: {year}"
empty_states:
no_posts:
title: "No posts available"
message: "Check back soon for new content"
no_category:
title: "No articles in this category"
message: "This category doesn't contain any published articles yet"
metadata:
reading_time: "{minutes} min read"
published_on: "Published on {date}"
by_author: "by {author}"
Multiple Language Files
Create corresponding files for each language:
# config/i18n/nl.yaml (Dutch)
site_name: "Minty Fresh Flask"
tagline: "Een minty fresh publicatieplatform"
description: "Ontdek frisse perspectieven en inzichtelijke artikelen"
nav:
home: "Home"
about: "Over ons"
blog: "Blog"
contact: "Contact"
actions:
read_more: "Lees meer"
learn_more: "Meer informatie"
get_started: "Aan de slag"
blog:
recent_posts: "Recente berichten"
featured_post: "Uitgelicht bericht"
all_posts: "Alle berichten"
Template Integration
Basic Usage Pattern
Access i18n strings in templates with fallback values:
{# Simple value access #}
<h1>{{ i18n_yaml.site_name or "Default Site Name" }}</h1>
{# Nested key access #}
<a href="{{ url_for('blog_bp.blog_index') }}">
{{ i18n_yaml.blog.all_posts or "All Posts" }}
</a>
{# Multiple fallback levels #}
<p>{{ i18n_yaml.description or i18n_yaml.tagline or "Welcome" }}</p>
Navigation Menus
Build dynamic navigation from i18n configuration:
{# templates/components/navigation.html #}
<nav aria-label="Main navigation">
<ul>
<li>
<a href="{{ url_for('home_bp.home') }}">
{{ i18n_yaml.nav.home or "Home" }}
</a>
</li>
<li>
<a href="{{ url_for('blog_bp.blog_index') }}">
{{ i18n_yaml.nav.blog or "Blog" }}
</a>
</li>
<li>
<a href="{{ url_for('main_bp.about') }}">
{{ i18n_yaml.nav.about or "About" }}
</a>
</li>
<li>
<a href="{{ url_for('main_bp.contact') }}">
{{ i18n_yaml.nav.contact or "Contact" }}
</a>
</li>
</ul>
</nav>
Dynamic Content Substitution
Use Jinja2 filters for string interpolation:
{# Reading time with variable substitution #}
{% set reading_time_text = i18n_yaml.blog.metadata.reading_time or "{minutes} min read" %}
<span>{{ reading_time_text.replace('{minutes}', minutes|string) }}</span>
{# Or use format filter #}
<span>{{ (i18n_yaml.blog.metadata.reading_time or "{minutes} min read").format(minutes=minutes) }}</span>
{# Category title with substitution #}
<h1>{{ (i18n_yaml.blog.titles.category or "Category: {category}").format(category=category) }}</h1>
SEO Metadata Integration
Use i18n for comprehensive SEO support:
{# templates/pages/homepage.html #}
{% block page_meta %}
<title>{{ i18n_yaml.site_name or "Minty Fresh Flask" }} - {{ i18n_yaml.tagline or "A Fresh Publishing Platform" }}</title>
<meta name="description" content="{{ i18n_yaml.description or 'Discover fresh perspectives and insightful articles' }}">
<meta name="keywords" content="{{ i18n_yaml.keywords or 'blog, flask, python' }}">
{# Language and locale #}
<html lang="{{ i18n_yaml.language or 'en' }}">
<meta property="og:locale" content="{{ i18n_yaml.locale or 'en_GB' }}">
{# Open Graph tags #}
<meta property="og:site_name" content="{{ i18n_yaml.site_name }}">
<meta property="og:title" content="{{ i18n_yaml.site_name }}">
<meta property="og:description" content="{{ i18n_yaml.description }}">
{% endblock %}
Form Labels and Validation
Build accessible forms with i18n labels:
{# templates/components/contact-form.html #}
<form>
<div class="form-group">
<label for="name">
{{ i18n_yaml.forms.name_label or "Name" }}
{% if required %}
<span aria-label="{{ i18n_yaml.forms.required or 'required' }}">*</span>
{% endif %}
</label>
<input
type="text"
id="name"
placeholder="{{ i18n_yaml.forms.name_placeholder or 'Enter your name' }}"
required>
</div>
<div class="form-group">
<label for="email">
{{ i18n_yaml.forms.email_label or "Email" }}*
</label>
<input
type="email"
id="email"
placeholder="{{ i18n_yaml.forms.email_placeholder or 'Enter your email' }}"
required>
</div>
<button type="submit">
{{ i18n_yaml.forms.submit or "Submit" }}
</button>
</form>
Flask Integration
Loading YAML Files
Create a helper function to load i18n configuration:
# app/utils/i18n.py
import yaml
from pathlib import Path
from flask import current_app
def load_i18n(language='en'):
"""
Load internationalisation strings from YAML file.
Args:
language: Language code (e.g., 'en', 'nl', 'fr')
Returns:
Dictionary of i18n strings
"""
i18n_path = Path(current_app.config['I18N_DIR']) / f'{language}.yaml'
try:
with open(i18n_path, 'r', encoding='utf-8') as file:
return yaml.safe_load(file) or {}
except FileNotFoundError:
current_app.logger.warning(f"i18n file not found: {i18n_path}")
return {}
except yaml.YAMLError as e:
current_app.logger.error(f"Error parsing i18n file: {e}")
return {}
Application Configuration
Configure i18n in your Flask application:
# config.py
class Config:
# i18n Configuration
I18N_DIR = 'config/i18n'
DEFAULT_LANGUAGE = 'en'
SUPPORTED_LANGUAGES = ['en', 'nl', 'fr']
Context Processor
Make i18n data available to all templates:
# app/__init__.py
from app.utils.i18n import load_i18n
def create_app():
app = Flask(__name__)
@app.context_processor
def inject_i18n():
"""Inject i18n data into all templates."""
language = get_current_language() # Implement language detection
return {
'i18n_yaml': load_i18n(language),
'current_language': language,
'supported_languages': app.config['SUPPORTED_LANGUAGES']
}
return app
Language Detection
Implement language detection based on user preference:
# app/utils/i18n.py
from flask import request, session
def get_current_language():
"""
Determine current language from session, cookie, or browser.
Returns:
Language code string
"""
# 1. Check session
if 'language' in session:
return session['language']
# 2. Check cookie
language = request.cookies.get('language')
if language:
return language
# 3. Check browser accept-language header
return request.accept_languages.best_match(
current_app.config['SUPPORTED_LANGUAGES'],
default=current_app.config['DEFAULT_LANGUAGE']
)
def set_language(language):
"""Set user's language preference."""
session['language'] = language
Language Switcher Route
Create an endpoint for users to switch languages:
# app/routes/main.py
@main_bp.route('/set-language/<language>')
def set_language(language):
"""Handle language switching."""
from app.utils.i18n import set_language as set_lang
if language in current_app.config['SUPPORTED_LANGUAGES']:
set_lang(language)
# Create response and set cookie
response = make_response(redirect(request.referrer or url_for('home_bp.home')))
response.set_cookie('language', language, max_age=60*60*24*365) # 1 year
return response
flash('Language not supported', 'error')
return redirect(request.referrer or url_for('home_bp.home'))
Multi-Language Implementation
Language Switcher Component
Build a language switcher for users:
{# templates/components/language-switcher.html #}
<div class="language-switcher" role="navigation" aria-label="Language selection">
<button class="language-button"
aria-haspopup="true"
aria-expanded="false">
{{ current_language|upper }}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<ul class="language-menu" role="menu">
{% for lang in supported_languages %}
<li role="none">
<a href="{{ url_for('main_bp.set_language', language=lang) }}"
role="menuitem"
class="language-option {% if lang == current_language %}active{% endif %}"
hreflang="{{ lang }}">
{{ lang|upper }}
</a>
</li>
{% endfor %}
</ul>
</div>
Language-Specific URLs
Implement language-specific URL patterns:
# URL structure: /en/about, /nl/about, /fr/about
@main_bp.route('/<language>/about')
def about(language):
"""About page with language parameter."""
from app.utils.i18n import set_language
if language not in current_app.config['SUPPORTED_LANGUAGES']:
abort(404)
set_language(language)
return render_template('about.html')
Alternate Language Links (SEO)
Add alternate language links for SEO:
{# SEO: Indicate available translations #}
{% for lang in supported_languages %}
<link rel="alternate"
hreflang="{{ lang }}"
href="{{ url_for(request.endpoint, language=lang, _external=True) }}">
{% endfor %}
{# Default language #}
<link rel="alternate"
hreflang="x-default"
href="{{ url_for(request.endpoint, language=config.DEFAULT_LANGUAGE, _external=True) }}">
Best Practices
Fallback Strategy
Always provide sensible fallbacks:
{# ✓ Good: Multiple fallback levels #}
{{ i18n_yaml.blog.titles.category or i18n_yaml.blog.category or "Category" }}
{# ✓ Good: Context-appropriate defaults #}
{{ i18n_yaml.actions.read_more or "Read more" }}
{# ✗ Bad: No fallback #}
{{ i18n_yaml.blog.some_key }} {# May display empty string #}
{# ✗ Bad: Generic fallback #}
{{ i18n_yaml.blog.read_article or "Click here" }} {# Too vague #}
Key Naming Conventions
Use clear, hierarchical naming:
# ✓ Good: Clear hierarchy and purpose
blog:
actions:
read_more: "Read more"
view_all: "View all posts"
titles:
home: "Blog Home"
category: "Category: {category}"
# ✗ Bad: Flat structure, unclear purpose
blog_read_more: "Read more"
blog_title_1: "Blog Home"
thing: "Category: {category}"
Maintainability Guidelines
Keep it organised:
# Group by feature/section
navigation:
main: {}
footer: {}
blog:
titles: {}
actions: {}
messages: {}
Use comments for context:
# Blog Section
# These strings appear in blog templates and components
blog:
# Post listing page
recent_posts: "Recent Posts" # Homepage section heading
all_posts: "All Posts" # Navigation link and page title
# Empty states
no_posts: "No posts available" # Shown when no posts exist
Consistent formatting:
# ✓ Good: Consistent sentence case
actions:
read_more: "Read more"
learn_more: "Learn more"
get_started: "Get started"
# ✗ Bad: Inconsistent capitalisation
actions:
read_more: "Read More"
learn_more: "Learn more"
get_started: "GET STARTED"
String Interpolation Guidelines
Design strings for easy substitution:
# ✓ Good: Clear placeholder names
blog:
published_info: "Published on {date} by {author}"
reading_time: "{minutes} min read"
comment_count: "{count} comments"
# ✗ Bad: Unclear placeholders
blog:
info: "Posted {1} by {2}" # What is 1 and 2?
time: "{x} min" # What is x?
Template usage:
{# Clear and readable #}
{{ i18n_yaml.blog.published_info.format(date=post.date, author=post.author) }}
{{ i18n_yaml.blog.reading_time.format(minutes=5) }}
Common Patterns
Pattern: Pluralisation Handling
Handle singular and plural forms:
# Option 1: Separate keys
comments:
single: "1 comment"
plural: "{count} comments"
# Option 2: Full sentence variants
blog:
post_count_single: "There is 1 post in this category"
post_count_plural: "There are {count} posts in this category"
Template implementation:
{% if count == 1 %}
{{ i18n_yaml.comments.single or "1 comment" }}
{% else %}
{{ (i18n_yaml.comments.plural or "{count} comments").format(count=count) }}
{% endif %}
Pattern: Date and Time Formatting
Localise date formats:
formats:
date_short: "%d/%m/%Y" # UK: 24/11/2024
date_long: "%d %B %Y" # UK: 24 November 2024
time_short: "%H:%M" # 24-hour format
datetime: "%d/%m/%Y %H:%M"
{# Use format from i18n #}
{{ post.date.strftime(i18n_yaml.formats.date_long or "%d %B %Y") }}
Pattern: Conditional Content
Show/hide content based on language:
{# Language-specific content #}
{% if current_language == 'nl' %}
<p>{{ i18n_yaml.special_notice_nl }}</p>
{% elif current_language == 'fr' %}
<p>{{ i18n_yaml.special_notice_fr }}</p>
{% endif %}
{# Or use i18n key that exists only in some languages #}
{% if i18n_yaml.get('regional_promotion') %}
<div class="promotion">{{ i18n_yaml.regional_promotion }}</div>
{% endif %}
Pattern: Rich Text Content
Store rich text in i18n files:
about:
hero_title: "About Us"
hero_subtitle: "Building better web experiences"
intro: |
We are a team of passionate developers creating
modern web applications with Flask and Python.
Our mission is to make web development accessible
and enjoyable for everyone.
features:
- title: "Open Source"
description: "All our projects are open source and community-driven"
- title: "Modern Stack"
description: "Using the latest Python and web technologies"
Template usage with preserved formatting:
<div class="about-content">
{{ i18n_yaml.about.intro|safe }}
<ul>
{% for feature in i18n_yaml.about.features %}
<li>
<strong>{{ feature.title }}</strong>
<p>{{ feature.description }}</p>
</li>
{% endfor %}
</ul>
</div>
Troubleshooting
Problem: Keys Not Found
Symptoms:
- Empty strings in templates
- Missing translations
Solution:
{# Debug: Check if key exists #}
{% if i18n_yaml.get('blog', {}).get('title') %}
{{ i18n_yaml.blog.title }}
{% else %}
<span style="color: red;">MISSING: blog.title</span>
{% endif %}
{# Production: Always use fallbacks #}
{{ i18n_yaml.blog.title or "Default Title" }}
Problem: YAML Parsing Errors
Symptoms:
- Empty i18n_yaml dictionary
- Site using all fallback values
Diagnosis: Check Flask logs for YAML parsing errors.
Common YAML errors:
# ✗ Bad: Inconsistent indentation
nav:
home: "Home"
about: "About" # Extra space
# ✓ Good: Consistent indentation (2 spaces)
nav:
home: "Home"
about: "About"
# ✗ Bad: Unescaped special characters
message: "It's a great day!" # Apostrophe breaks parsing
# ✓ Good: Quoted strings with special characters
message: "It's a great day!" # or 'It''s a great day!'
Problem: Language Not Switching
Symptoms:
- Language switcher doesn't work
- Site stays in default language
Diagnosis:
# Debug in Flask route
@main_bp.route('/debug-language')
def debug():
return {
'session_language': session.get('language'),
'cookie_language': request.cookies.get('language'),
'current_language': get_current_language(),
'i18n_loaded': bool(load_i18n(get_current_language()))
}
Solutions:
- Verify language code is supported
- Check session is configured correctly
- Ensure cookies are being set
- Confirm YAML files exist for all languages
Problem: String Interpolation Not Working
Symptoms:
- Placeholders like
{count}appearing literally - Formatted strings not showing variables
Solution:
{# ✗ Wrong: Missing .format() #}
{{ i18n_yaml.reading_time }}
{# ✓ Correct: Use .format() #}
{{ i18n_yaml.reading_time.format(minutes=5) }}
{# ✓ Alternative: Use replace() #}
{{ i18n_yaml.reading_time.replace('{minutes}', minutes|string) }}
Checklist for Implementation
Setup Checklist
- Create i18n directory structure
- Create default language file (en.yaml)
- Configure Flask app with i18n settings
- Implement YAML loading function
- Create context processor for i18n data
- Test i18n_yaml available in templates
Content Checklist
- Extract hardcoded strings from templates
- Organise strings in hierarchical YAML structure
- Add descriptive comments to YAML files
- Implement fallback values in all templates
- Test all pages with empty i18n file
Multi-Language Checklist
- Create language files for each supported language
- Implement language detection logic
- Build language switcher component
- Add alternate language links for SEO
- Test language switching functionality
- Verify all translations are complete
Quality Checklist
- All strings have sensible fallbacks
- YAML files validated (no syntax errors)
- Consistent naming conventions used
- String interpolation works correctly
- Pluralisation handled appropriately
- Date/time formats localised
Additional Resources
Related Documentation
- Theme Customisation: Learn how to customise themes with i18n
- Template Development: Advanced Jinja2 patterns with i18n
- Configuration Reference: Complete configuration options
Example Implementations
config/i18n/en.yaml: Default English translationstemplates/components/language-switcher.html: Language switcher componentapp/utils/i18n.py: i18n utility functions
External Resources
- YAML Syntax: Learn YAML formatting rules
- Flask-Babel: For more advanced i18n needs
- Jinja2 Documentation: Template filters and functions
Conclusion
The YAML-based internationalisation system in MintyFlaskThemes provides a straightforward, maintainable approach to managing site-wide content and multi-language support.
Key takeaways:
- YAML files store all translatable content: Simple, editable, version-controllable
- Templates use fallback pattern: Robust against missing translations
- Flask integration is minimal: Context processor makes i18n data available everywhere
- Hierarchical organisation: Logical grouping makes content easy to find
- Scalable approach: Start simple, add languages as needed
This system works well for small to medium sites. For large-scale applications with hundreds of translations and complex workflows, consider dedicated i18n frameworks like Flask-Babel.