feat(i18n): serve every page in 30 languages

The interface ships translated; page content stays English until a
LibreTranslate instance fills app/i18n/content/ through make i18n. A string
without a catalogue entry falls back to its English source, so a half-filled
catalogue degrades instead of breaking.

Translation runs after ConfigurationResolver.resolve_links(), on a copy.
resolve_links matches by the `name` field, so translating it beforehand
would break every `link:` reference in the configuration.

negotiate() normalises to the primary subtag itself. Werkzeug's best_match
returns an exact match before it considers a primary-tag fallback, so the
Chrome default `de-DE,en;q=0.8` resolves to English there. "/" carries
Vary: Accept-Language, without which a shared cache pins the first
visitor's language for everyone.

The route rule lists the known codes as a converter argument. A bare
"/<lang>/" answers /robots.txt and /favicon.ico with a permanently
cacheable 308 to their trailing-slash form.

Templates gain lang, dir, the RTL stylesheet, a canonical URL and 30
hreflang alternates. Those are the first external URLs in this app:
ProxyFix takes the scheme from X-Forwarded-Proto so they do not claim
http:// behind a TLS-terminating proxy, X-Forwarded-Host stays untrusted
because nginx passes a client-supplied one through, and TRUSTED_HOSTS lets
Flask reject a forged Host outright.

Flask only autoescapes .html/.htm/.xml/.xhtml/.svg, so every *.html.j2
template interpolated configuration raw. Enabling it changes two lines of
the shipped page, both an apostrophe.

read_catalog degrades an unreadable catalogue to English rather than
serving a 500, and drops non-string entries that would otherwise render as
"42". i18n_sync writes atomically, never overwrites an existing entry,
refuses to touch a catalogue it could not parse, and leaves the file alone
when a run translated nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 00:02:35 +02:00
parent a42930a699
commit 2a35b2910a
46 changed files with 1915 additions and 37 deletions

View File

@@ -3,10 +3,12 @@ import os
import requests
import yaml
from flask import Flask, current_app, render_template, url_for
from flask import Flask, current_app, make_response, render_template, request, url_for
from markupsafe import Markup
from werkzeug.middleware.proxy_fix import ProxyFix
try:
from app.utils import i18n
from app.utils.asset_resolver import asset_src, resolve_asset_cache
from app.utils.cache_manager import CacheManager
from app.utils.compute_card_classes import compute_card_classes
@@ -17,6 +19,10 @@ except ImportError: # pragma: no cover - supports running from the app/ directo
from utils.compute_card_classes import compute_card_classes
from utils.configuration_resolver import ConfigurationResolver
from utils import i18n
TRANSLATED_SECTIONS = ("cards", "company", "navigation", "platform")
logging.basicConfig(level=logging.DEBUG)
FLASK_ENV = os.getenv("FLASK_ENV", "production")
@@ -42,6 +48,8 @@ def load_config(app):
resolver = ConfigurationResolver(config)
resolver.resolve_links()
app.config.update(resolver.get_config())
app.config["TRANSLATED_CONFIG"] = {}
i18n.clear_catalogs()
def cache_icons_and_logos(app):
@@ -60,6 +68,23 @@ def cache_icons_and_logos(app):
# Initialize Flask app
app = Flask(__name__)
app.jinja_options = {**app.jinja_options, "autoescape": True}
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1)
def trusted_hosts(raw):
"""Parse a comma-separated host list, or None when nothing is configured.
Args:
raw: the ``TRUSTED_HOSTS`` value, possibly empty.
"""
hosts = [host.strip() for host in raw.split(",") if host.strip()]
return hosts or None
app.config["TRUSTED_HOSTS"] = trusted_hosts(os.getenv("TRUSTED_HOSTS", ""))
# Load configuration and cache assets on startup
load_config(app)
cache_icons_and_logos(app)
@@ -91,36 +116,79 @@ def reload_config_in_dev():
cache_icons_and_logos(app)
@app.route("/")
def index():
"""Render the main index page."""
cards = app.config["cards"]
lg_classes, md_classes = compute_card_classes(cards)
apod_bg = None
def translated_config(lang):
"""Return the configuration sections translated into ``lang``, memoized.
The memo is dropped by ``load_config``, so a development reload picks up
edited content on the next request.
"""
memo = app.config["TRANSLATED_CONFIG"]
if lang not in memo:
source = {section: app.config[section] for section in TRANSLATED_SECTIONS}
memo[lang] = i18n.translate_tree(source, lang)
return memo[lang]
def apod_background():
"""Return today's NASA APOD image URL, or None when unavailable."""
api_key = app.config.get("NASA_API_KEY")
if api_key:
if not api_key:
return None
try:
resp = requests.get(
"https://api.nasa.gov/planetary/apod",
params={"api_key": api_key},
timeout=10,
)
if resp.ok:
data = resp.json()
if data.get("media_type") == "image":
apod_bg = data.get("url")
except requests.RequestException:
logging.warning("APOD lookup failed", exc_info=True)
return None
if not resp.ok:
return None
data = resp.json()
return data.get("url") if data.get("media_type") == "image" else None
def render_index(lang):
"""Render the index page in ``lang``."""
config = translated_config(lang)
cards = config["cards"]
lg_classes, md_classes = compute_card_classes(cards)
return render_template(
"pages/index.html.j2",
cards=cards,
company=app.config["company"],
navigation=app.config["navigation"],
platform=app.config["platform"],
company=config["company"],
navigation=config["navigation"],
platform=config["platform"],
lg_classes=lg_classes,
md_classes=md_classes,
apod_bg=apod_bg,
apod_bg=apod_background(),
lang=lang,
lang_dir=i18n.direction(lang),
languages=i18n.LANGUAGES,
ui_strings=i18n.ui_strings(lang),
t=lambda source: i18n.catalog(lang).get(source, source),
)
@app.route("/")
def index():
"""Render the index page in the language the browser asks for."""
response = make_response(render_index(i18n.negotiate(request.accept_languages)))
response.headers["Vary"] = "Accept-Language"
return response
@app.route(f"/<any({','.join(i18n.LANGUAGES)}):lang>/")
def localized_index(lang):
"""Render the index page in an explicitly requested language."""
return render_index(lang)
if __name__ == "__main__":
app.run(
debug=(FLASK_ENV == "development"),

View File

@@ -0,0 +1,228 @@
// cypress/e2e/i18n.spec.js
const GERMAN_BROWSER = { headers: { 'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8' } };
describe('Language negotiation', () => {
it('serves English to an English browser', () => {
cy.visit('/', { headers: { 'Accept-Language': 'en-US,en;q=0.9' } });
cy.get('html').should('have.attr', 'lang', 'en');
cy.get('footer.footer a.iframe-link').should('contain.text', 'Imprint');
});
it('serves German to a German browser', () => {
cy.visit('/', GERMAN_BROWSER);
cy.get('html').should('have.attr', 'lang', 'de');
cy.get('footer.footer a.iframe-link').should('contain.text', 'Impressum');
});
it('falls back to English for an unsupported browser language', () => {
cy.visit('/', { headers: { 'Accept-Language': 'xx-XX' } });
cy.get('html').should('have.attr', 'lang', 'en');
});
it('lets the URL prefix override the browser language', () => {
cy.visit('/fr/', GERMAN_BROWSER);
cy.get('html').should('have.attr', 'lang', 'fr');
cy.get('footer.footer a.iframe-link').should('contain.text', 'Mentions légales');
});
it('rejects an unsupported language code', () => {
cy.request({ url: '/xx/', failOnStatusCode: false })
.its('status')
.should('eq', 404);
});
it('does not turn unrelated single-segment paths into redirects', () => {
['/robots.txt', '/favicon.ico', '/sitemap.xml'].forEach(path => {
cy.request({ url: path, failOnStatusCode: false, followRedirect: false })
.its('status')
.should('eq', 404);
});
});
it('marks the negotiated route as varying by language', () => {
cy.request('/')
.its('headers.vary')
.should('contain', 'Accept-Language');
});
});
describe('Language switcher', () => {
beforeEach(() => {
cy.viewport(1280, 720);
cy.visit('/en/');
});
it('names the active language and offers all thirty', () => {
cy.get('#navbarDropdownLanguage')
.should('have.attr', 'data-bs-toggle', 'dropdown')
.and('contain.text', 'English');
cy.get('#navbarDropdownLanguage')
.parent('.nav-item')
.find('> .dropdown-menu a.dropdown-item')
.should('have.length', 30);
});
it('marks the active language', () => {
cy.get('#navbarDropdownLanguage').click();
cy.get('.dropdown-menu a.dropdown-item.active[hreflang="en"]').should('exist');
});
it('navigates to the chosen language', () => {
cy.get('#navbarDropdownLanguage').click();
cy.get('.dropdown-menu a.dropdown-item[hreflang="de"]')
.should('have.text', 'Deutsch')
.click();
cy.url().should('match', /\/de\/$/);
cy.get('html').should('have.attr', 'lang', 'de');
});
});
describe('Translated interface strings', () => {
it('translates the strings rendered by the templates', () => {
cy.visit('/de/');
cy.get('#dynamicCopyButton').should('have.text', 'Kopieren');
cy.get('#dynamicChildrenSection h6').should('have.text', 'Optionen:');
cy.get('#dynamicAlternativesSection h6').should('have.text', 'Alternativen:');
cy.get('.modal-footer button').should('have.text', 'Schließen');
});
it('exposes the catalogue to client-side code', () => {
cy.visit('/de/');
cy.window().its('I18N').should('deep.include', {
Open: 'Öffnen',
'Open Link': 'Link öffnen',
'Identifier copied to clipboard!': 'Kennung in die Zwischenablage kopiert!',
});
});
it('leaves the catalogue untranslated in the source language', () => {
cy.visit('/en/');
cy.window().its('I18N').should('deep.include', { Open: 'Open' });
});
});
describe('Strings translated by modal.js', () => {
const item = {
name: 'Test Item',
identifier: 'ABC123',
icon: { class: 'fa fa-test' },
alternatives: [
{ name: 'Alt One', identifier: 'ALT1', icon: { class: 'fa fa-alt1' } },
],
};
beforeEach(() => {
cy.visit('/de/');
cy.window().then(win => {
cy.stub(win.navigator.clipboard, 'writeText').resolves();
cy.stub(win, 'alert');
});
});
it('translates the button of a list entry', () => {
cy.window().invoke('openDynamicPopup', item);
cy.get('#dynamicAlternativesList button').should('have.text', 'Öffnen');
});
it('translates the link label when the entry has no description', () => {
cy.window().invoke('openDynamicPopup', {
...item,
url: 'https://example.com',
description: null,
});
cy.get('#dynamicModalLinkHref').should('have.text', 'Link öffnen');
});
it('translates the clipboard confirmation', () => {
cy.window().invoke('openDynamicPopup', item);
cy.get('#dynamicCopyButton').click();
cy.window()
.its('alert')
.should('have.been.calledWith', 'Kennung in die Zwischenablage kopiert!');
});
});
describe('Right-to-left languages', () => {
it('flips the document and loads the RTL stylesheet', () => {
cy.visit('/ar/');
cy.get('html').should('have.attr', 'dir', 'rtl');
cy.get('link[href*="bootstrap.rtl.min.css"]').should('exist');
cy.get('link[href*="vendor/bootstrap/css/bootstrap.min.css"]').should('not.exist');
cy.get('body').should('have.css', 'direction', 'rtl');
});
it('keeps left-to-right languages on the default stylesheet', () => {
cy.visit('/en/');
cy.get('html').should('have.attr', 'dir', 'ltr');
cy.get('link[href*="bootstrap.rtl.min.css"]').should('not.exist');
});
it('actually serves the RTL stylesheet it links to', () => {
cy.visit('/ar/');
cy.get('link[href*="bootstrap.rtl.min.css"]')
.should('have.attr', 'href')
.then(href => {
cy.request(href).its('status').should('eq', 200);
});
});
});
describe('Translated interface details', () => {
it('translates the strings only a screen reader sees', () => {
cy.visit('/de/');
cy.get('#dynamicModal .btn-close').should('have.attr', 'aria-label', 'Schließen');
});
it('translates the alert headings', () => {
cy.visit('/fr/');
cy.get('#dynamicModalWarning h5').should('contain.text', 'Avertissement');
cy.get('#dynamicModalInfo h5').should('contain.text', 'Informations');
});
it('translates the language switcher tooltip', () => {
cy.visit('/de/');
cy.get('#navbarDropdownLanguage').should('have.attr', 'title', 'Sprache');
});
it('tags each switcher entry with its own language', () => {
cy.visit('/en/');
cy.get('.dropdown-menu a.dropdown-item[hreflang="ja"]').should(
'have.attr',
'lang',
'ja',
);
});
it('offers the switcher in the header only', () => {
cy.viewport(1280, 720);
cy.visit('/en/');
cy.get('#navbarNavheader #navbarDropdownLanguage').should('exist');
cy.get('#navbarNavfooter #navbarDropdownLanguage').should('not.exist');
});
});
describe('Search engine metadata', () => {
beforeEach(() => {
cy.visit('/de/');
});
it('declares an alternate for every language plus a default', () => {
cy.get('link[rel="alternate"][hreflang]').should('have.length', 31);
cy.get('link[rel="alternate"][hreflang="x-default"]').should('exist');
cy.get('link[rel="alternate"][hreflang="ja"]')
.should('have.attr', 'href')
.and('match', /\/ja\/$/);
});
it('points the canonical URL at the language actually served', () => {
cy.get('link[rel="canonical"]').should('have.attr', 'href').and('match', /\/de\/$/);
});
});

11
app/i18n/ui/ar.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: بدائل
Close: إغلاق
Copy: نسخ
Identifier copied to clipboard!: تم نسخ المعرّف إلى الحافظة!
Imprint: معلومات قانونية
Information: معلومات
Language: اللغة
Open: فتح
Open Link: فتح الرابط
Options: خيارات
Warning: تحذير

11
app/i18n/ui/bn.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: অন্যান্য বিকল্প
Close: বন্ধ করুন
Copy: অনুলিপি
Identifier copied to clipboard!: শনাক্তকারী ক্লিপবোর্ডে অনুলিপি করা হয়েছে!
Imprint: আইনি তথ্য
Information: তথ্য
Language: ভাষা
Open: খুলুন
Open Link: লিঙ্ক খুলুন
Options: বিকল্প
Warning: সতর্কতা

11
app/i18n/ui/cs.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternativy
Close: Zavřít
Copy: Kopírovat
Identifier copied to clipboard!: Identifikátor zkopírován do schránky!
Imprint: Tiráž
Information: Informace
Language: Jazyk
Open: Otevřít
Open Link: Otevřít odkaz
Options: Možnosti
Warning: Upozornění

11
app/i18n/ui/da.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternativer
Close: Luk
Copy: Kopiér
Identifier copied to clipboard!: Identifikator kopieret til udklipsholder!
Imprint: Juridisk information
Information: Information
Language: Sprog
Open: Åbn
Open Link: Åbn link
Options: Valgmuligheder
Warning: Advarsel

11
app/i18n/ui/de.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternativen
Close: Schließen
Copy: Kopieren
Identifier copied to clipboard!: Kennung in die Zwischenablage kopiert!
Imprint: Impressum
Information: Information
Language: Sprache
Open: Öffnen
Open Link: Link öffnen
Options: Optionen
Warning: Warnung

11
app/i18n/ui/el.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Εναλλακτικές
Close: Κλείσιμο
Copy: Αντιγραφή
Identifier copied to clipboard!: Το αναγνωριστικό αντιγράφηκε στο πρόχειρο!
Imprint: Νομικές πληροφορίες
Information: Πληροφορίες
Language: Γλώσσα
Open: Άνοιγμα
Open Link: Άνοιγμα συνδέσμου
Options: Επιλογές
Warning: Προειδοποίηση

11
app/i18n/ui/es.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternativas
Close: Cerrar
Copy: Copiar
Identifier copied to clipboard!: ¡Identificador copiado al portapapeles!
Imprint: Aviso legal
Information: Información
Language: Idioma
Open: Abrir
Open Link: Abrir enlace
Options: Opciones
Warning: Advertencia

11
app/i18n/ui/fa.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: جایگزین‌ها
Close: بستن
Copy: کپی
Identifier copied to clipboard!: شناسه در کلیپ‌بورد کپی شد!
Imprint: اطلاعات حقوقی
Information: اطلاعات
Language: زبان
Open: باز کردن
Open Link: باز کردن پیوند
Options: گزینه‌ها
Warning: هشدار

11
app/i18n/ui/fi.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Vaihtoehdot
Close: Sulje
Copy: Kopioi
Identifier copied to clipboard!: Tunniste kopioitu leikepöydälle!
Imprint: Oikeudelliset tiedot
Information: Tiedot
Language: Kieli
Open: Avaa
Open Link: Avaa linkki
Options: Valinnat
Warning: Varoitus

11
app/i18n/ui/fr.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternatives
Close: Fermer
Copy: Copier
Identifier copied to clipboard!: Identifiant copié dans le presse-papiers !
Imprint: Mentions légales
Information: Informations
Language: Langue
Open: Ouvrir
Open Link: Ouvrir le lien
Options: Options
Warning: Avertissement

11
app/i18n/ui/he.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: חלופות
Close: סגור
Copy: העתק
Identifier copied to clipboard!: המזהה הועתק ללוח!
Imprint: מידע משפטי
Information: מידע
Language: שפה
Open: פתח
Open Link: פתח קישור
Options: אפשרויות
Warning: אזהרה

11
app/i18n/ui/hi.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: अन्य विकल्प
Close: बंद करें
Copy: कॉपी करें
Identifier copied to clipboard!: पहचानकर्ता क्लिपबोर्ड पर कॉपी हो गया!
Imprint: कानूनी सूचना
Information: जानकारी
Language: भाषा
Open: खोलें
Open Link: लिंक खोलें
Options: विकल्प
Warning: चेतावनी

11
app/i18n/ui/hu.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternatívák
Close: Bezárás
Copy: Másolás
Identifier copied to clipboard!: Az azonosító a vágólapra másolva!
Imprint: Impresszum
Information: Információ
Language: Nyelv
Open: Megnyitás
Open Link: Hivatkozás megnyitása
Options: Lehetőségek
Warning: Figyelmeztetés

11
app/i18n/ui/id.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternatif
Close: Tutup
Copy: Salin
Identifier copied to clipboard!: Pengenal disalin ke papan klip!
Imprint: Informasi hukum
Information: Informasi
Language: Bahasa
Open: Buka
Open Link: Buka tautan
Options: Opsi
Warning: Peringatan

11
app/i18n/ui/it.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternative
Close: Chiudi
Copy: Copia
Identifier copied to clipboard!: Identificatore copiato negli appunti!
Imprint: Note legali
Information: Informazioni
Language: Lingua
Open: Apri
Open Link: Apri collegamento
Options: Opzioni
Warning: Avviso

11
app/i18n/ui/ja.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: 代替
Close: 閉じる
Copy: コピー
Identifier copied to clipboard!: 識別子をクリップボードにコピーしました!
Imprint: 法的情報
Information: 情報
Language: 言語
Open: 開く
Open Link: リンクを開く
Options: オプション
Warning: 警告

11
app/i18n/ui/ko.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: 대안
Close: 닫기
Copy: 복사
Identifier copied to clipboard!: 식별자를 클립보드에 복사했습니다!
Imprint: 법적 고지
Information: 정보
Language: 언어
Open: 열기
Open Link: 링크 열기
Options: 옵션
Warning: 경고

11
app/i18n/ui/nl.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternatieven
Close: Sluiten
Copy: Kopiëren
Identifier copied to clipboard!: Identificatie gekopieerd naar klembord!
Imprint: Colofon
Information: Informatie
Language: Taal
Open: Openen
Open Link: Link openen
Options: Opties
Warning: Waarschuwing

11
app/i18n/ui/pl.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternatywy
Close: Zamknij
Copy: Kopiuj
Identifier copied to clipboard!: Identyfikator skopiowany do schowka!
Imprint: Nota prawna
Information: Informacja
Language: Język
Open: Otwórz
Open Link: Otwórz link
Options: Opcje
Warning: Ostrzeżenie

11
app/i18n/ui/pt.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternativas
Close: Fechar
Copy: Copiar
Identifier copied to clipboard!: Identificador copiado para a área de transferência!
Imprint: Aviso legal
Information: Informação
Language: Idioma
Open: Abrir
Open Link: Abrir link
Options: Opções
Warning: Aviso

11
app/i18n/ui/ro.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternative
Close: Închide
Copy: Copiază
Identifier copied to clipboard!: Identificator copiat în clipboard!
Imprint: Notă legală
Information: Informații
Language: Limbă
Open: Deschide
Open Link: Deschide linkul
Options: Opțiuni
Warning: Avertisment

11
app/i18n/ui/ru.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Альтернативы
Close: Закрыть
Copy: Копировать
Identifier copied to clipboard!: Идентификатор скопирован в буфер обмена!
Imprint: Выходные данные
Information: Информация
Language: Язык
Open: Открыть
Open Link: Открыть ссылку
Options: Варианты
Warning: Предупреждение

11
app/i18n/ui/sv.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternativ
Close: Stäng
Copy: Kopiera
Identifier copied to clipboard!: Identifierare kopierad till urklipp!
Imprint: Juridisk information
Information: Information
Language: Språk
Open: Öppna
Open Link: Öppna länk
Options: Val
Warning: Varning

11
app/i18n/ui/th.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: ทางเลือกอื่น
Close: ปิด
Copy: คัดลอก
Identifier copied to clipboard!: คัดลอกตัวระบุไปยังคลิปบอร์ดแล้ว!
Imprint: ข้อมูลทางกฎหมาย
Information: ข้อมูล
Language: ภาษา
Open: เปิด
Open Link: เปิดลิงก์
Options: ตัวเลือก
Warning: คำเตือน

11
app/i18n/ui/tr.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternatifler
Close: Kapat
Copy: Kopyala
Identifier copied to clipboard!: Tanımlayıcı panoya kopyalandı!
Imprint: Künye
Information: Bilgi
Language: Dil
Open:
Open Link: Bağlantıyı
Options: Seçenekler
Warning: Uyarı

11
app/i18n/ui/uk.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Альтернативи
Close: Закрити
Copy: Копіювати
Identifier copied to clipboard!: Ідентифікатор скопійовано в буфер обміну!
Imprint: Вихідні дані
Information: Інформація
Language: Мова
Open: Відкрити
Open Link: Відкрити посилання
Options: Варіанти
Warning: Попередження

11
app/i18n/ui/ur.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: متبادل
Close: بند کریں
Copy: نقل کریں
Identifier copied to clipboard!: شناخت کنندہ کلپ بورڈ پر نقل ہو گیا!
Imprint: قانونی معلومات
Information: معلومات
Language: زبان
Open: کھولیں
Open Link: لنک کھولیں
Options: اختیارات
Warning: انتباہ

11
app/i18n/ui/vi.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Lựa chọn thay thế
Close: Đóng
Copy: Sao chép
Identifier copied to clipboard!: Đã sao chép mã định danh vào bộ nhớ tạm!
Imprint: Thông tin pháp lý
Information: Thông tin
Language: Ngôn ngữ
Open: Mở
Open Link: Mở liên kết
Options: Tùy chọn
Warning: Cảnh báo

11
app/i18n/ui/zh.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: 替代方案
Close: 关闭
Copy: 复制
Identifier copied to clipboard!: 标识符已复制到剪贴板!
Imprint: 法律声明
Information: 信息
Language: 语言
Open: 打开
Open Link: 打开链接
Options: 选项
Warning: 警告

View File

@@ -29,6 +29,10 @@ copyFile(
path.join(NM, 'bootstrap', 'dist', 'css', 'bootstrap.min.css'),
path.join(VENDOR, 'bootstrap', 'css', 'bootstrap.min.css')
);
copyFile(
path.join(NM, 'bootstrap', 'dist', 'css', 'bootstrap.rtl.min.css'),
path.join(VENDOR, 'bootstrap', 'css', 'bootstrap.rtl.min.css')
);
copyFile(
path.join(NM, 'bootstrap', 'dist', 'js', 'bootstrap.bundle.min.js'),
path.join(VENDOR, 'bootstrap', 'js', 'bootstrap.bundle.min.js')

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="{{ lang }}" dir="{{ lang_dir }}">
<head>
<title>{{platform.titel}}</title>
<meta charset="utf-8" >
@@ -8,8 +8,13 @@
type="image/x-icon"
href="{{ asset_src(platform.favicon) }}"
>
<link rel="canonical" href="{{ url_for('localized_index', lang=lang, _external=True) }}">
{% for code in languages %}
<link rel="alternate" hreflang="{{ code }}" href="{{ url_for('localized_index', lang=code, _external=True) }}">
{% endfor %}
<link rel="alternate" hreflang="x-default" href="{{ url_for('index', _external=True) }}">
<!-- Bootstrap CSS only -->
<link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap' ~ ('.rtl' if lang_dir == 'rtl' else '') ~ '.min.css') }}" rel="stylesheet">
<!-- Bootstrap JavaScript Bundle with Popper -->
<script src="{{ url_for('static', filename='vendor/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
<!-- Bootstrap Icons -->
@@ -60,12 +65,13 @@
<p itemprop="name">{{ company.titel }} <br />
{{ company.subtitel }}</p>
<span><i class="fa-solid fa-location-dot"></i> {{ company.address.values() | join(", ") }}</span>
<p><a href="{{company.imprint_url}}" class="iframe-link"><i class="fa-solid fa-scale-balanced"></i> Imprint</a></p>
<p><a href="{{company.imprint_url}}" class="iframe-link"><i class="fa-solid fa-scale-balanced"></i> {{ t('Imprint') }}</a></p>
</div>
</footer>
</div>
<!-- Include modal -->
{% include "moduls/modal.html.j2" %}
<script>window.I18N = {{ ui_strings | tojson }};</script>
{% for name in [
'modal',
'navigation',

View File

@@ -16,14 +16,14 @@
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="dynamicModalLabel"></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ t('Close') }}"></button>
</div>
<div class="modal-body">
<!-- Warning box with Markdown -->
{{ alert_box('dynamicModalWarning', 'alert-warning', 'fa-solid fa-triangle-exclamation', 'Warning', 'dynamicModalWarningText') }}
{{ alert_box('dynamicModalWarning', 'alert-warning', 'fa-solid fa-triangle-exclamation', t('Warning'), 'dynamicModalWarningText') }}
<!-- Info box with Markdown -->
{{ alert_box('dynamicModalInfo', 'alert-info', 'fa-solid fa-circle-info', 'Information', 'dynamicModalInfoText') }}
{{ alert_box('dynamicModalInfo', 'alert-info', 'fa-solid fa-circle-info', t('Information'), 'dynamicModalInfoText') }}
<!-- Description text -->
<div id="dynamicDescriptionText" class="mt-2 d-none"></div>
@@ -31,7 +31,7 @@
<!-- Input box for Identifier -->
<div id="dynamicIdentifierBox" class="input-group mt-2 d-none">
<input type="text" id="dynamicModalContent" class="form-control" readonly>
<button class="btn btn-outline-secondary" type="button" id="dynamicCopyButton">Copy</button>
<button class="btn btn-outline-secondary" type="button" id="dynamicCopyButton">{{ t('Copy') }}</button>
</div>
<!-- Link -->
@@ -40,13 +40,13 @@
</div>
<!-- Options -->
{{ list_section('dynamicChildrenSection', 'Options', 'dynamicChildrenList') }}
{{ list_section('dynamicChildrenSection', t('Options'), 'dynamicChildrenList') }}
<!-- Alternatives -->
{{ list_section('dynamicAlternativesSection', 'Alternatives', 'dynamicAlternativesList') }}
{{ list_section('dynamicAlternativesSection', t('Alternatives'), 'dynamicAlternativesList') }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" onclick="closeAllModals()">Close</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" onclick="closeAllModals()">{{ t('Close') }}</button>
</div>
</div>
</div>

View File

@@ -98,6 +98,23 @@
</li>
{% endif %}
{% endfor %}
{% if menu_type == "header" %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle btn btn-light" id="navbarDropdownLanguage" role="button" data-bs-toggle="dropdown" data-bs-display="dynamic" aria-expanded="false" title="{{ t('Language') }}">
<i class="fa-solid fa-language"></i> {{ languages[lang] }}
</a>
<ul class="dropdown-menu">
{% for code, endonym in languages.items() %}
<li>
<a class="dropdown-item {% if code == lang %}active{% endif %}"
href="{{ url_for('localized_index', lang=code) }}"
hreflang="{{ code }}"
lang="{{ code }}">{{ endonym }}</a>
</li>
{% endfor %}
</ul>
</li>
{% endif %}
</ul>
</div>
</nav>

163
app/utils/i18n.py Normal file
View File

@@ -0,0 +1,163 @@
"""Language negotiation and translation of the resolved configuration tree.
Translation is catalogue-driven: a string is replaced only when the target
language's catalogue holds an entry for the exact English source string.
Anything unknown falls through to English, so a partially filled catalogue
degrades instead of breaking.
"""
import logging
from pathlib import Path
import yaml
I18N_DIR = Path(__file__).resolve().parent.parent / "i18n"
UI_DIR = I18N_DIR / "ui"
CONTENT_DIR = I18N_DIR / "content"
SOURCE_LANGUAGE = "en"
LANGUAGES = {
"en": "English",
"zh": "中文",
"hi": "हिन्दी",
"es": "Español",
"fr": "Français",
"ar": "العربية",
"bn": "বাংলা",
"pt": "Português",
"ru": "Русский",
"ur": "اردو",
"id": "Bahasa Indonesia",
"de": "Deutsch",
"ja": "日本語",
"tr": "Türkçe",
"ko": "한국어",
"vi": "Tiếng Việt",
"it": "Italiano",
"th": "ไทย",
"pl": "Polski",
"nl": "Nederlands",
"uk": "Українська",
"fa": "فارسی",
"ro": "Română",
"el": "Ελληνικά",
"cs": "Čeština",
"sv": "Svenska",
"hu": "Magyar",
"he": "עברית",
"da": "Dansk",
"fi": "Suomi",
}
RTL_LANGUAGES = frozenset({"ar", "fa", "he", "ur"})
AUTOFILL_KEYS = frozenset({"description", "text", "warning", "info", "subtitel"})
TRANSLATABLE_KEYS = AUTOFILL_KEYS | frozenset({"name", "title"})
UI_STRINGS = (
"Alternatives",
"Close",
"Copy",
"Identifier copied to clipboard!",
"Imprint",
"Information",
"Language",
"Open",
"Open Link",
"Options",
"Warning",
)
_catalogs: dict[str, dict[str, str]] = {}
def direction(code):
"""Return the writing direction of ``code`` as an HTML ``dir`` value."""
return "rtl" if code in RTL_LANGUAGES else "ltr"
def read_catalog(path):
"""Return the catalogue at ``path``, or an empty one if it is unusable.
Catalogues are hand-edited and machine-written, so a stray character must
degrade that language to English rather than take every page down with a
parse error. Non-string entries are dropped for the same reason: they would
otherwise reach the templates and render as ``42`` or ``null``.
"""
if not path.exists():
return {}
try:
loaded = yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, yaml.YAMLError):
logging.warning("Ignoring unreadable translation catalogue: %s", path)
return {}
if not isinstance(loaded, dict):
logging.warning(
"Ignoring translation catalogue that is not a mapping: %s", path
)
return {}
return {
key: value
for key, value in loaded.items()
if isinstance(key, str) and isinstance(value, str)
}
def clear_catalogs():
"""Drop the memoized catalogues so edited files are picked up."""
_catalogs.clear()
def catalog(code):
"""Return the merged UI and content catalogue for ``code``."""
if code not in _catalogs:
_catalogs[code] = {
**read_catalog(UI_DIR / f"{code}.yaml"),
**read_catalog(CONTENT_DIR / f"{code}.yaml"),
}
return _catalogs[code]
def negotiate(accepted, default=SOURCE_LANGUAGE):
"""Pick the best supported language from ``Accept-Language`` pairs.
Args:
accepted: iterable of ``(tag, quality)`` as produced by
``flask.request.accept_languages``.
default: language returned when no tag is supported.
Werkzeug's own ``best_match`` returns an exact match before it considers
primary-tag fallbacks, so ``de-DE,en;q=0.8`` resolves to English. Matching
on the primary subtag up front avoids that.
"""
best, best_quality = default, 0.0
for tag, quality in accepted:
code = tag.replace("_", "-").split("-")[0].lower()
if code in LANGUAGES and quality > best_quality:
best, best_quality = code, quality
return best
def translate_tree(node, code, key=None):
"""Return a copy of ``node`` with translatable leaves swapped for ``code``.
Args:
node: the resolved configuration tree, or any subtree of it.
code: target language code.
key: the mapping key ``node`` was reached through.
"""
if isinstance(node, dict):
return {name: translate_tree(value, code, name) for name, value in node.items()}
if isinstance(node, list):
return [translate_tree(item, code, key) for item in node]
if isinstance(node, str) and key in TRANSLATABLE_KEYS:
return catalog(code).get(node, node)
return node
def ui_strings(code):
"""Return the interface strings for ``code``, keyed by their English source."""
entries = catalog(code)
return {source: entries.get(source, source) for source in UI_STRINGS}