Add i18n support for the NiceGUI website (focused)#5848
Add i18n support for the NiceGUI website (focused)#5848evnchn wants to merge 1 commit intozauberzeug:mainfrom
Conversation
- Add website/i18n.py with ContextVar-based translation function t() - Add translation files for de, ja, ko, zh with full coverage - Register language-prefixed routes (e.g. /de/, /zh/documentation) - Add language selector dropdown to header - Wrap user-facing strings in t() across main_page, header, search, star, style, examples_page, and documentation intro - Add pre-commit hook (check_translations.py) to validate that all t() keys have entries in every translation file - Rewrite all internal links (not just /documentation) with language prefix in translated text TODO: Add <html lang> attribute and <link rel="alternate" hreflang> meta tags for SEO (needs coordination with SEO strategy). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Thanks, @evnchn! |
|
Oh, if splitting the strings is to handle indentation, we should improve the |
| if prefix and '](' in text: | ||
| text = text.replace('](/', f']({prefix}/') |
There was a problem hiding this comment.
This feels like it could go wrong easily.
I believe it's to handle Markdown links ([Example](example)), but I can see it going wrong unpredictably.
At the same time, I don't know of a better way to implement it. Maybe some RegEx?
There was a problem hiding this comment.
[Example](example) seems like a good idea actually. What's wrong with it? Let's rewrite all links to not use relative-to-/ and we should be good to go.
There was a problem hiding this comment.
A few scenarios where this str.replace("](/") approach could break:
1. Markdown images → broken assets
If a translated string contains an image reference:

It becomes  → 404, since static assets don't have language-prefixed routes.
2. Links to non-translated paths (static, API, etc.)
[Download the PDF](/static/nicegui-cheatsheet.pdf)
→ ](/de/static/nicegui-cheatsheet.pdf) → 404.
3. Double-prefixing on repeated calls
If t() is ever called on already-translated text, links get double-prefixed: ](/de/docs) → ](/de/de/docs).
A regex that excludes known static prefixes (e.g. /static, /assets, /_) would be safer, or handling link rewriting at the route/template level instead of string-munging the translations.
There was a problem hiding this comment.
This is what my AI agent had to say about this.
I won't say this is certainly going to break, but it feels fragile in my opinion, and would need special care to work correctly.
| with ui.button(icon='language').props('flat color=white round').classes('max-[470px]:hidden'): | ||
| with ui.menu().classes('bg-primary text-white'): | ||
| for lang, name in SUPPORTED_LANGUAGES.items(): | ||
| ui.menu_item(name, on_click=lambda lang=lang: switch_language(lang)) |
There was a problem hiding this comment.
Lambda inside a loop gives me flashbacks to bugs I introduced, where the value of the variable is always the last iteration of the loop, or something like that.
Not sure if this is the case here, but I'd suggest testing it thoroughly.
There was a problem hiding this comment.
I think the bug I coded went something like this:
my_list = ["foo", "bar"]
my_dict = {}
for my_item in my_list:
def print_item() -> None:
print(my_item)
my_dict[my_item] = print_item
my_dict["foo"]() # bar
my_dict["bar"]() # barSo again, double-check and be careful.
There was a problem hiding this comment.
Hence the lang=lang. Without it, the last lang is used.
| SUPPORTED_LANGUAGES: dict[str, str] = { | ||
| 'en': 'English', | ||
| 'de': 'Deutsch', | ||
| 'ja': '日本語', | ||
| 'ko': '한국어', | ||
| 'zh': '中文', | ||
| } |
There was a problem hiding this comment.
This could be automated by scanning the list of translations inside the website/translations directory.
That way, to add a new language, one just has to add a <lang>.json to that folder, without needing to touch this i18n.py file at all, making diffs simpler and avoiding conflicts.
That's more or less what I'm doing in my own web app.
There was a problem hiding this comment.
For this, you could either use a lib (like Babel) to go from lang abbreviation (de) to lang name (Deutsch), or you could add a field to the JSON files themselves for the language name, something like "__LANG_NAME": "Deutsch".
There was a problem hiding this comment.
The only language that would need to be "hard-coded" to the list would then be English itself, as it doesn't have a JSON file.
You're both probably already aware, but Python comes with a |
Yes, but the behavior is slightly different. For |
Now that you mentioned it, I finally noticed there's even a comment on the link I sent about this behavior. I suppose it's fair to want to avoid the |
|
Does Google (and other search engines) not use the language header to probe pages for multi language content? As in, if I had a website with Because, as far as I can tell, the main reason you're going with I personally find it nicer for a website to have the same route (always |
|
To answer my own question — no, Google does not use the Google's official guidance is explicit: use different URLs for different languages (subdirectories, subdomains, or separate domains), and link them together with Without distinct URLs:
So the |
|
If rewriting the structure is so difficult, can we do /XXXXXX?lang=zh instead? |
|
Query params (
If SEO is a goal here (and for the NiceGUI website it probably should be), |
|
By the way, I'm personally not opposed to Also, I know it's likely obvious, but I'm alternating between me and my AI agent answering. If it gets annoying, just let me know. |
|
Thanks for the work on this, @evnchn — the architecture here (ContextVar-based Since the website has been completely redesigned in the meantime (the monolithic Here's roughly what I have in mind: Scope: Static strings on landing page, header, footer, imprint, examples page, and documentation overview. Documentation content and code examples stay English for now. Decisions:
Incorporating feedback from this PR:
Thank you to @MicaelJarniac for the thorough review comments that helped shape this direction. Of course, @evnchn, if you'd prefer to rework this PR yourself against the new website structure, that's also welcome — happy to discuss either way! |
|
Whatever it is, it's not in 3.10. I have said:
I'm kinda open to perf / accessibility (we already did the keyboard nav), but i18n and SEO is definitely not it. Also the GoogleBot would need its chill time to acclimatize to the redesigned NiceGUI homepage anyways. |

Motivation
Adds internationalization (i18n) infrastructure to the NiceGUI website, starting with the main landing page and core UI — the highest-value surface area for non-English speakers.
Driven by community interest: #4025 (comment)
This is the focused follow-up to #5825, which served as a proof-of-concept and drew valuable early feedback. This PR is deliberately scoped down to address the key concern raised by @falkoschindler: the original was too large (+8 200 lines) to review in good conscience. This version is 15 files, ~670 insertions.
Implementation
Core i18n module (
website/i18n.py):t(english)function that returns the translated string for the current language, falling back to Englishset_language(lang)/get_language()usingcontextvars.ContextVarfor async-safe, per-request language state/documentation/…→/zh/documentation/…)website/translations/Scope:
main_page.py,header.py,search.py,star.py,style.py,examples_page.py, and the documentation intro — the most user-visible surface area, deliberately excluding the full API reference docsLanguage-prefixed routes (
main.py):/de/,/zh/documentation/…)Translation files (
website/translations/*.json): de, ja, ko, zh — 70 strings each, covering the complete translated surfaceValidation tooling (
website/check_translations.py+ pre-commit hook):t()call keys via ASTWhat is intentionally excluded (vs #5825):
doc.text()prose)i18n_bootstrap.py,i18n_salvage.py, etc.)website/translate.csvsingle-file approach (replaced by per-language JSON)<html lang>/<link rel="alternate" hreflang>SEO tags (can follow separately, see TODO in commit)Progress