mirror of
https://github.com/kevinveenbirkenbach/homepage.veen.world.git
synced 2026-08-24 05:04:33 +00:00
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:
100
app/app.py
100
app/app.py
@@ -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"),
|
||||
|
||||
228
app/cypress/e2e/i18n.spec.js
Normal file
228
app/cypress/e2e/i18n.spec.js
Normal 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
11
app/i18n/ui/ar.yaml
Normal 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
11
app/i18n/ui/bn.yaml
Normal 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
11
app/i18n/ui/cs.yaml
Normal 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
11
app/i18n/ui/da.yaml
Normal 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
11
app/i18n/ui/de.yaml
Normal 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
11
app/i18n/ui/el.yaml
Normal 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
11
app/i18n/ui/es.yaml
Normal 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
11
app/i18n/ui/fa.yaml
Normal 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
11
app/i18n/ui/fi.yaml
Normal 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
11
app/i18n/ui/fr.yaml
Normal 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
11
app/i18n/ui/he.yaml
Normal 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
11
app/i18n/ui/hi.yaml
Normal 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
11
app/i18n/ui/hu.yaml
Normal 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
11
app/i18n/ui/id.yaml
Normal 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
11
app/i18n/ui/it.yaml
Normal 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
11
app/i18n/ui/ja.yaml
Normal 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
11
app/i18n/ui/ko.yaml
Normal 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
11
app/i18n/ui/nl.yaml
Normal 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
11
app/i18n/ui/pl.yaml
Normal 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
11
app/i18n/ui/pt.yaml
Normal 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
11
app/i18n/ui/ro.yaml
Normal 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
11
app/i18n/ui/ru.yaml
Normal 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
11
app/i18n/ui/sv.yaml
Normal 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
11
app/i18n/ui/th.yaml
Normal 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
11
app/i18n/ui/tr.yaml
Normal 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: Aç
|
||||
Open Link: Bağlantıyı aç
|
||||
Options: Seçenekler
|
||||
Warning: Uyarı
|
||||
11
app/i18n/ui/uk.yaml
Normal file
11
app/i18n/ui/uk.yaml
Normal 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
11
app/i18n/ui/ur.yaml
Normal 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
11
app/i18n/ui/vi.yaml
Normal 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
11
app/i18n/ui/zh.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
Alternatives: 替代方案
|
||||
Close: 关闭
|
||||
Copy: 复制
|
||||
Identifier copied to clipboard!: 标识符已复制到剪贴板!
|
||||
Imprint: 法律声明
|
||||
Information: 信息
|
||||
Language: 语言
|
||||
Open: 打开
|
||||
Open Link: 打开链接
|
||||
Options: 选项
|
||||
Warning: 警告
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
163
app/utils/i18n.py
Normal 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}
|
||||
Reference in New Issue
Block a user