From ef1c8ff09abfcb3cdddc2220e567753ea779b1a9 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Sat, 22 Aug 2026 01:08:28 +0200 Subject: [PATCH] feat(i18n): offer every ISO 639-1 language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The table was thirty languages typed by hand. It is now generated: utils/generate_languages.py takes the 184 alpha-2 codes from pycountry, the display names from CLDR through babel, and the writing direction from CLDR character order. 159 languages carry their endonym; the remaining 25 have no CLDR entry and carry their English ISO name. That corrects the right-to-left set, which had four entries and needs ten — dv, ks, ps, sd, ug and yi were simply missed. Only 29 languages ship an interface catalogue, so the other 155 render in English until one is filled. make i18n-ui fills app/i18n/ui/ for them, and make i18n now covers the interface strings as well; neither asks for a string a shipped catalogue already answers, so hand-written entries stay. 184 entries do not fit on a screen, so the language menu scrolls inside itself. overscroll-behavior keeps the page behind it from moving once the list reaches its end. babel and pycountry are dev dependencies: the generator needs them, the application does not. Co-Authored-By: Claude Opus 5 (1M context) --- Makefile | 9 ++ README.md | 23 +-- app/cypress/e2e/i18n.spec.js | 21 ++- app/static/css/default.css | 6 + app/templates/moduls/navigation.html.j2 | 2 +- app/utils/i18n.py | 39 +---- app/utils/languages.py | 196 ++++++++++++++++++++++++ pyproject.toml | 2 + tests/unit/test_i18n.py | 37 +++-- tests/unit/test_i18n_sync.py | 21 ++- utils/generate_languages.py | 83 ++++++++++ utils/i18n_sync.py | 50 ++++-- 12 files changed, 413 insertions(+), 76 deletions(-) create mode 100644 app/utils/languages.py create mode 100644 utils/generate_languages.py diff --git a/Makefile b/Makefile index 666efed..28badb2 100644 --- a/Makefile +++ b/Makefile @@ -144,6 +144,15 @@ i18n: env config --url "$$LIBRETRANSLATE_URL" \ --api-key "$$LIBRETRANSLATE_API_KEY" +.PHONY: i18n-ui +i18n-ui: env + # Fill missing interface translations in app/i18n/ui/ via LibreTranslate. + @$(call _require_env,LIBRETRANSLATE_URL); \ + $(PYTHON) utils/i18n_sync.py \ + --catalog ui \ + --url "$$LIBRETRANSLATE_URL" \ + --api-key "$$LIBRETRANSLATE_API_KEY" + .PHONY: lint-actions lint-actions: # Lint GitHub Actions workflows. diff --git a/README.md b/README.md index e418c7a..8102533 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,8 @@ A lightweight, Docker-powered portfolio/landing-page generator—fully customiza Auto-cache assets for lightning-fast loading. - **Responsive Design** Built on Bootstrap; looks great on desktop, tablet & mobile. -- **30 Languages** - Browser-negotiated, RTL-aware, with machine translation for your own content. +- **184 Languages** + Every ISO 639-1 code, browser-negotiated, RTL-aware, with machine translation for your own content. - **YAML-Driven** All content & structure defined in a simple `config.yaml`. - **CLI Control** @@ -143,16 +143,19 @@ company: ## 🌍 Languages -The interface ships in 30 languages. `/` serves the best match for the visitor's +Every ISO 639-1 language — all 184 two-letter codes — has a URL, a display +name in its own script and a writing direction. The interface ships translated +for 29 of them; the rest fall back to English string by string until a +catalogue is filled. `/` serves the best match for the visitor's `Accept-Language` header, `//` forces one, and a switcher in the navbar -lists them all. Right-to-left languages (`ar`, `fa`, `he`, `ur`) get -`dir="rtl"` and Bootstrap's RTL stylesheet automatically. +lists them all. The ten right-to-left languages get `dir="rtl"` and Bootstrap's RTL +stylesheet automatically. Translations live in two catalogues, both keyed by the English source string: | Path | Tracked | Holds | | --- | --- | --- | -| `app/i18n/ui/.yaml` | yes | Interface strings, shipped complete for all 29 non-English languages. English is the source and has no file. | +| `app/i18n/ui/.yaml` | yes | Interface strings. Shipped for 29 languages; English is the source and has no file. | | `app/i18n/content/.yaml` | no | Your `config.yaml` prose, generated per deployment. | A string with no catalogue entry falls back to English, so a half-filled @@ -165,8 +168,10 @@ instance — set `LIBRETRANSLATE_URL` in `.env`, then: make i18n ``` -Existing entries are never overwritten, so corrections you make by hand survive -later runs. Only prose (`description`, `text`, `warning`, `info`, `subtitel`) is +This fills the interface strings of the languages that ship no catalogue as +well. Existing entries are never overwritten, and a string the shipped +catalogue already covers is never requested, so corrections you make by hand +survive later runs. Only prose (`description`, `text`, `warning`, `info`, `subtitel`) is filled automatically; `name` and `title` are left to you, because a machine cannot tell the menu label "Pictures" from the brand "Mastodon". Write those into the content catalogue yourself when you want them translated. @@ -179,7 +184,7 @@ into the content catalogue yourself when you want them translated. * Secure with SSL/TLS. * Swap to a production database if needed. -Because every page carries a canonical URL and 30 `hreflang` alternates, two +Because every page carries a canonical URL and 184 `hreflang` alternates, two details of the proxy setup now matter: * **Set `TRUSTED_HOSTS`** in `.env` to your public hostname(s), comma-separated. diff --git a/app/cypress/e2e/i18n.spec.js b/app/cypress/e2e/i18n.spec.js index c3fb7f5..1296fbb 100644 --- a/app/cypress/e2e/i18n.spec.js +++ b/app/cypress/e2e/i18n.spec.js @@ -53,7 +53,7 @@ describe('Language switcher', () => { cy.visit('/en/'); }); - it('names the active language and offers all thirty', () => { + it('names the active language and offers every ISO 639-1 code', () => { cy.get('#navbarDropdownLanguage') .should('have.attr', 'data-bs-toggle', 'dropdown') .and('contain.text', 'English'); @@ -61,7 +61,7 @@ describe('Language switcher', () => { cy.get('#navbarDropdownLanguage') .parent('.nav-item') .find('> .dropdown-menu a.dropdown-item') - .should('have.length', 30); + .should('have.length', 184); }); it('marks the active language', () => { @@ -200,6 +200,21 @@ describe('Translated interface details', () => { ); }); + it('scrolls inside the language menu instead of past the page', () => { + cy.viewport(1280, 720); + cy.visit('/en/'); + cy.get('#navbarDropdownLanguage').click(); + + cy.get('.dropdown-menu.language-menu').should($menu => { + const menu = $menu[0]; + expect(menu.scrollHeight, 'taller than it shows').to.be.greaterThan( + menu.clientHeight, + ); + expect(menu.getBoundingClientRect().height).to.be.lessThan(720); + expect(getComputedStyle(menu).overflowY).to.eq('auto'); + }); + }); + it('offers the switcher in the header only', () => { cy.viewport(1280, 720); cy.visit('/en/'); @@ -215,7 +230,7 @@ describe('Search engine metadata', () => { }); 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]').should('have.length', 185); cy.get('link[rel="alternate"][hreflang="x-default"]').should('exist'); cy.get('link[rel="alternate"][hreflang="ja"]') .should('have.attr', 'href') diff --git a/app/static/css/default.css b/app/static/css/default.css index dfe0f62..a2dc31f 100644 --- a/app/static/css/default.css +++ b/app/static/css/default.css @@ -120,6 +120,12 @@ div#navbarNavfooter .navbar-nav { flex-wrap: nowrap; } +.dropdown-menu.language-menu { + max-height: 60vh; + overflow-y: auto; + overscroll-behavior: contain; +} + main, footer, header, nav { position: relative; box-shadow: diff --git a/app/templates/moduls/navigation.html.j2 b/app/templates/moduls/navigation.html.j2 index eb8dae6..f215126 100644 --- a/app/templates/moduls/navigation.html.j2 +++ b/app/templates/moduls/navigation.html.j2 @@ -103,7 +103,7 @@ -