Skip to content

Add lang attribute to <html> tag in page template - #5811

Merged
falkoschindler merged 6 commits into
zauberzeug:mainfrom
evnchn:html-lang-attribute
Jun 20, 2026
Merged

Add lang attribute to <html> tag in page template#5811
falkoschindler merged 6 commits into
zauberzeug:mainfrom
evnchn:html-lang-attribute

Conversation

@evnchn

@evnchn evnchn commented Feb 19, 2026

Copy link
Copy Markdown
Collaborator

Motivation

NiceGUI's page template doesn't declare a lang attribute on the <html> tag — but Quasar's lang plugin adds lang="en-US" (and dir="ltr") to the live DOM as soon as JavaScript boots, regardless of the app's actual content language. So today, non-JS consumers (search engine crawlers, reader modes) get no language information, while screen readers, spell checkers and CSS hyphenation are told "English" even for apps whose content is in another language.

This PR makes the language declaration explicit and opt-in, improving accessibility and SEO for apps that configure their language, without wrongly declaring English for those that don't.

Split out from #5767 per review feedback — this is a library-wide template change affecting all NiceGUI users.

Implementation

  • ui.run(language=...) and run_with now default to None, meaning "undeclared". Quasar elements fall back to 'en-US' as before.
  • When a language is configured, the page template renders <html lang="..."> with the full BCP 47 tag (e.g. zh-CN, pt-BR), and Quasar keeps it in sync client-side.
  • When no language is configured, the server omits the attribute and passes Quasar's lang: {noHtmlAttrs: true} config so Quasar doesn't add lang/dir to the DOM either.
  • ui.page(language=None) opts out per page, even when a global language is set.

Progress

  • I chose a meaningful title that completes the sentence: "If applied, this PR will..."
  • The implementation is complete.
  • This PR addresses no security issue.
  • Pytests have been added.
  • Documentation has been added.

Sets the HTML `lang` attribute based on the NiceGUI language setting,
improving accessibility and SEO. The language code is split on '-' to
use just the primary subtag (e.g. 'en' from 'en-US').

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@evnchn evnchn added the feature Type/scope: New or intentionally changed behavior label Feb 19, 2026
@falkoschindler falkoschindler added the review Status: PR is open and needs review label Feb 19, 2026
@falkoschindler
falkoschindler self-requested a review February 19, 2026 09:57

@falkoschindler falkoschindler left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, @evnchn!
One concern about the current approach:

split('-')[0] discards meaningful region/script information for several languages in the Language type. For example:

  • zh-CN (Simplified Chinese) and zh-TW (Traditional Chinese) both become just zh - screen readers use the lang attribute to select the correct voice/pronunciation, and these variants are meaningfully different
  • pt-BR (Brazilian Portuguese) and pt (European Portuguese) both become pt

Almost all Quasar language codes are already valid BCP 47 tags. The simplest fix would be to pass the full value directly:

<html lang="{{ language }}">

This preserves zh-CN, pt-BR, ko-KR, etc. where the distinction matters for accessibility.

@falkoschindler falkoschindler added this to the 3.8 milestone Feb 19, 2026
Comment thread nicegui/templates/index.html Outdated
@evnchn

evnchn commented Feb 19, 2026

Copy link
Copy Markdown
Collaborator Author

I agree with you.

@falkoschindler

Copy link
Copy Markdown
Contributor

One thing to consider: the language parameter primarily controls Quasar's UI components (date pickers, button labels, etc.) and defaults to en-US. Many developers may never have changed it, even if their app content is in another language.

Before this change, the <html> tag has no lang attribute, so browsers infer the page language from content (usually correctly). After this change, every NiceGUI app that didn't explicitly set language would declare lang="en" — which tells browsers to stop guessing and trust the declared value. This affects spell checking (wrong dictionary for inputs), screen readers (wrong voice/pronunciation), and CSS hyphens: auto (wrong hyphenation rules).

So for a developer with e.g. a German-language app who never touched the language setting, this change would actively tell browsers "this page is English," which could be worse than the current behavior of saying nothing.

Could this be made opt-in instead (e.g. only emit lang when the user explicitly sets it), or is the expectation that developers should always configure language to match their content?

@evnchn

evnchn commented Feb 19, 2026

Copy link
Copy Markdown
Collaborator Author

Hmm fair. Not only that, considering cases where the user doesn't know what the language is (perhaps user-provided content dominates), the possibility of language=None will persist into future NiceGUI versions, not just a stopgap until 4.0

@falkoschindler falkoschindler added analysis Status: Requires team/community input and removed review Status: PR is open and needs review labels Feb 19, 2026
@evnchn

evnchn commented Feb 19, 2026

Copy link
Copy Markdown
Collaborator Author

@falkoschindler do you think evnchn#85 is ready to be brought upstream?

@falkoschindler

Copy link
Copy Markdown
Contributor

@evnchn Looks like you closed evnchn#85 already.
Let's postpone this discussion to 3.10 to focus on 3.8.

@falkoschindler falkoschindler modified the milestones: 3.8, 3.10 Feb 19, 2026
@evnchn

evnchn commented Feb 19, 2026

Copy link
Copy Markdown
Collaborator Author

@falkoschindler I closed it to bake stuff into 1 commit and ask Claude Code to review (as usual)

It is alive at https://github.com/evnchn/nicegui/tree/copilot/add-lang-attribute-html, but sure let's focus on 3.8 first.

@evnchn

evnchn commented Mar 29, 2026

Copy link
Copy Markdown
Collaborator Author

We have agreed to tackle i18n not in 3.10. I'm not sure if it will be 3.11 though (since if further work is required for the NiceGUI docs design, doing the i18n later dodges merge conflicts)

falkoschindler and others added 2 commits June 12, 2026 16:54
`language` now defaults to `None`, meaning "undeclared": Quasar elements
fall back to 'en-US' as before, but neither the server-rendered HTML nor
Quasar's lang plugin (suppressed via its `noHtmlAttrs` config) add a
`lang` attribute to the `html` tag. This way apps with default settings
no longer declare `lang="en-US"` even though their content may be in
another language, which would mislead screen readers, spell checkers and
hyphenation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@falkoschindler

Copy link
Copy Markdown
Contributor

I didn't want to let this PR wait for a broader i18n effort any longer, so I implemented the opt-in approach we discussed above and merged in the latest main:

  • ui.run(language=...) (and run_with) now defaults to None, meaning "undeclared". Quasar elements still fall back to 'en-US' as before, but the lang attribute is only added to the html tag when a language is explicitly configured.
  • ui.page(language=None) can opt out per page even when a global language is set.

While testing I discovered that the server-side template is only half the story: Quasar's lang plugin sets lang (and dir) on document.documentElement whenever a language pack is set — which NiceGUI's boot script always does. So today every NiceGUI app already gets lang="en-US" in the live DOM after JavaScript boots, regardless of the template. To make "undeclared" actually mean undeclared, the response now also passes Quasar's lang: {noHtmlAttrs: true} config when no language is configured.

This resolves my concern from February: apps with default settings no longer declare English to screen readers, spell checkers and hyphenation, while apps that do configure a language get the full BCP 47 tag both server-side and client-side. And we keep the door open for inferring the language per request (e.g. from Accept-Language) in a future i18n effort.

@falkoschindler falkoschindler removed the analysis Status: Requires team/community input label Jun 12, 2026
@falkoschindler falkoschindler modified the milestones: Next, 3.14 Jun 12, 2026
@falkoschindler falkoschindler added the review Status: PR is open and needs review label Jun 12, 2026
Suppressing the lang attribute via Quasar's noHtmlAttrs also drops the dir
attribute, which left direction-dependent components like the carousel arrows
mispositioned (both arrows stacked on the left, intercepting each other's
clicks and failing test_carousel). Re-add dir="ltr" in the template for the
no-language case, where the en-US fallback is always left-to-right.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@evnchn

evnchn commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator Author

Posted by Claude Code on @evnchn's behalf.

TL;DR:

Reviewed your opt-in implementation end to end — good to merge from my side.

The one decision worth recording: the dir="ltr" default for the undeclared case is the right call —

  • we genuinely can't claim the content is English when language is unset, so omitting lang is correct;
  • but dir="ltr" is a safe default to keep — the vast majority of undeclared content is LTR, and RTL authors configure their language deliberately, at which point Quasar drives the correct direction.

One optional, non-blocking follow-up below. Over to you for the final approve/merge.

What I verified (not just read)
  • No shared-state mutation. The lang:{noHtmlAttrs} branch re-binds quasar_config to a new merged dict ({**quasar_config, 'lang': {...}}); the configured path serializes core.app.config.quasar_config by reference without editing it. No in-place mutation of the shared config.
  • BCP 47 validity. Parsed all 69 codes in nicegui/language.py — every one is a well-formed tag (de-CH, az-Latn, pt-BR, …), none non-standard. The full-tag approach (replacing the original split('-')[0]) can't emit an invalid lang.
  • No injection surface. html_lang comes from the Language Literal (developer-set via ui.run/@ui.page), never request input — lang="{{ html_lang }}" is trusted (and Jinja autoescapes regardless).
  • Test exercises the client-side half, not just the template. test_html_lang_attribute reads find_by_tag('html').get_attribute('lang') via a real browser (Screen), i.e. the post-JS DOM — so assert lang == '' for the undeclared case would fail if noHtmlAttrs weren't suppressing Quasar's en-US re-injection. It tests the exact failure mode you found.
  • CI green: pre-commit, mypy, pylint, and the 3.10 pytest gate all pass on the current head.
Optional follow-up — release note for the default flip

language now defaults to None instead of 'en-US'. That's an intentional, defensible change (say-nothing beats maybe-wrong), but it is a behavior change for the silent majority: apps that never set language previously got lang="en-US" injected client-side and now get no lang at all. Worth one release-note line so authors who want the English declaration know to set language='en-US'.

Happy to add it here or as a trivial follow-up — your preference.

Over to you for the approve/merge, or I'm glad to merge if you'd rather. Thanks again for unblocking this and catching the Quasar client-side half — that was the part the template alone never covered.

The existing assertions only inspected the live DOM after Quasar's lang
plugin runs. Add raw-HTML checks via httpx so the test also covers the
server-rendered tag (the SEO / no-JS payload this feature targets) and
catches server-template regressions that the client-side sync would mask.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@falkoschindler falkoschindler left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough review, @evnchn!

I pushed one more commit (3150478) that extends test_html_lang_attribute with raw-HTML assertions via httpx, so we now also pin the server-rendered <html> tag — not just the post-JS DOM you pointed at. The two halves are complementary: the httpx checks catch a server-template regression (which Quasar's client-side sync would otherwise mask), while the existing find_by_tag checks guard the noHtmlAttrs suppression you flagged.

Approving — good to merge!

@falkoschindler
falkoschindler enabled auto-merge June 20, 2026 13:17
@falkoschindler
falkoschindler added this pull request to the merge queue Jun 20, 2026
Merged via the queue into zauberzeug:main with commit 8822a85 Jun 20, 2026
12 of 14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Type/scope: New or intentionally changed behavior review Status: PR is open and needs review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants