Skip to content

Render lang and dir on a page's <html> tag from the page locale - #1870

Open
dashorst wants to merge 5 commits into
masterfrom
html-lang-dir-1848
Open

dashorst wants to merge 5 commits into
masterfrom
html-lang-dir-1848

Conversation

@dashorst

@dashorst dashorst commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Pages now render lang and dir on their <html> tag from the page locale: dir="rtl" when that language is written right to left, dir="ltr" otherwise. Screen readers pick their pronunciation from lang (WCAG 2.2 SC 3.1.1, level A). Until now an application had to bind <html> to a TransparentWebMarkupContainer to get it.

WebPage#onHtmlTag(ComponentTag) receives a mutable copy of the <html> tag on every full render:

  • lang: set to getLocale().toLanguageTag(), unless the markup has lang or xml:lang, or the locale has no language (Locale.ROOT).
  • dir: set to rtl when Session.isRtlLanguage (WICKET-6943) recognises the language and to ltr otherwise, unless the markup has its own dir or declares its own lang. Writing ltr explicitly follows solomax's review and MDN's recommendation; 4f19938 explains why.
  • Opting out: put dir="ltr" on <html> in the markup, or call tag.remove("dir") after super.onHtmlTag(tag) in the base page.
  • Extending: override the method to add document-wide attributes, such as a theme class or data-theme.

The commit messages carry the design decisions: why there is no setting, no IOnComponentTagListener call, no contributor mechanism and no hook for <head> or <body>. It also explains why <html> attributes can't change on an Ajax request. The draft migration-guide wording is there too.

Where to look

  • Core:

    • WebPage: renderNext and onHtmlTag.
    • HtmlTagHandler (new, page markup only): keeps each <html> tag as a tag with id _html_ and the new ComponentTag.RENDERED_BY_PAGE flag. No component is created.
    • MarkupContainer and TagUtils: queueing ignores the flagged tag, and findTagMarkup keeps looking inside it, as it does for <head>. Without this, components inside <html> are no longer found or dequeued.
    • HtmlTagTest: 21 tests covering locale changes between renders, markup lang/xml:lang/dir in any case, Locale.ROOT, RTL and the Latin-script exception, the opt-out, a bound <html wicket:id>, markup inheritance, queueing and an Ajax update.
  • Mechanical:

    • 235 expected-output files in wicket-core-tests and wicket-extensions, each changed only on its <html> line (lang and dir added).
    • Two Java tests with a hard-coded <html>.
  • Small test fixes:

  • Migration guide: migration-to-12.md, now on master, gets a section on the change. The branch was merged with master to resolve the conflict on that file; the merge keeps both sections.

Risk

  • Every page's output changes. Pages without lang in their markup get lang and dir. Tests that compare rendered pages with expected files need updating.
  • The <html> tag is now written from its parsed attributes, like a component tag. Values get double quotes, a valueless xmlns:wicket becomes xmlns:wicket="", and a tag spread over several lines is written on one.
  • RTL layout changes. An application with RTL translations and an LTR-only stylesheet sees its layout mirror until it opts out.

Verification

  • mvn clean verify -Pjs-test passed in two parts: the first run stopped at one extensions expected file, and the run resumed from wicket-extensions passed after fixing it.
  • CI passes on every pushed commit. With explicit dir, wicket-core-tests (2571) and wicket-extensions (141) pass locally.
  • wicket-core-tests and wicket-extensions also pass with the JVM locale set to nl_NL.

Closes #1848

Generated-by: Claude Code (Claude Opus 5.5)

A page had no lang attribute on its <html> element unless the developer
wrote one in the markup or bound the tag to a component. WCAG 2.2
success criterion 3.1.1 (level A) requires the language of every page to
be determinable by software; screen readers pick their pronunciation
from it. A lang written in the markup goes wrong as soon as the
application is localized, and the workaround - a
TransparentWebMarkupContainer bound to <html> with an AttributeModifier -
had to be known and repeated by every application.

WebPage#onHtmlTag(ComponentTag) is called with a mutable copy of the
<html> tag whenever the page renders. The default sets lang to
getLocale().toLanguageTag(), and dir="rtl" when Session.isRtlLanguage
says the language is written right to left. It changes nothing when the
markup has lang or xml:lang already, because the page then declares its
own language, or when the locale has no language (Locale.ROOT). A dir
in the markup is kept. Pages override the method to add attributes that
belong to the document as a whole, such as a theme class or a
data-theme.

How it works: the markup of a page keeps its <html> tags (each one, so
markup choosing between them with conditional comments is covered) as
tags with the reserved id "_html_" and the new
ComponentTag.RENDERED_BY_PAGE flag instead of merging them into raw
markup. No component is created. WebPage#renderNext writes the flagged
tag after the hook; MarkupContainer writes it unchanged for a Page that
is not a WebPage. Component queueing ignores the tag and
TagUtils.findTagMarkup keeps looking inside it, as it does for <head>;
without both, every component inside <html> would no longer be found.
The attribute cannot be added while parsing: MarkupCache shares the
parsed markup of a file between locales, so the first locale to load a
page would win.

Design decisions:

- dir="rtl" is rendered by default for right-to-left languages, using
  the detection Session has had since WICKET-6943. It mirrors the page,
  so an application that translated its strings into Arabic or Hebrew
  but kept a left-to-right stylesheet sees its layout flip. Opting out
  is cheap, which is why it is on by default: dir="ltr" in the markup,
  or tag.remove("dir") after super.onHtmlTag in the base page. dir="ltr"
  is never rendered; it is the browser's default.
- No setting switches either attribute off; overriding onHtmlTag in the
  base page is one method.
- IOnComponentTagListeners are not called for the <html> tag. Existing
  listeners would start receiving the page and an <html> tag, and one
  that writes a test id or a path onto every tag would change the
  output of applications that asked for none of this.
- There is no contributor mechanism through which components deeper in
  the page add <html> attributes, like IHeaderContributor does for
  <head>. The <html> tag is only rendered with the whole page: Ajax
  replaces components by markup id, the DOM refuses outerHTML on the
  document element, and adding the page to an AjaxRequestTarget
  redirects to a full render. A contribution made during an Ajax
  request would silently do nothing. State that a page reads in
  onHtmlTag (a theme in the session) serves full renders; an Ajax
  request changes the attribute with JavaScript, as the Javadoc shows.
  A contributor or an application-level listener can be layered on
  later without breaking the hook.
- Only <html> gets a hook. It carries what belongs to the document as a
  whole and could only be reached through a transparent container;
  <head> already has a component, and <body> takes a wicket:id without
  the problems <html> has.

What applications see: every page without lang in its markup renders
<html lang="...">, and <html lang="..." dir="rtl"> in a right-to-left
locale, after this change. An <html wicket:id="..."> bound to
a component is left to that component, so the old workaround keeps
working. The <html> tag is now written from its parsed attributes, the
way component tags are: attribute values get double quotes, a valueless
attribute such as xmlns:wicket is written as xmlns:wicket="", and a tag
spread over several lines is written on one. Tests that compare rendered
pages with expected files have to update those files; this commit does
so for wicket-core-tests, where WicketTestCase renders with Locale.US.

Migration guide wording:

  Pages render lang and dir on <html>. WebPage now renders
  lang="<language tag of the page locale>" on the <html> tag of every
  page whose markup does not have lang or xml:lang, and dir="rtl" when
  that language is written right to left and the markup has no dir.
  Expected-output files of tests that render pages change accordingly.
  If your stylesheets do not support right-to-left layout, put
  dir="ltr" on <html> in your markup, or override
  WebPage#onHtmlTag(ComponentTag) in your base page and call
  tag.remove("dir") after super. To switch both attributes off,
  override it without calling super. If you bound <html> to a
  TransparentWebMarkupContainer only to set lang, you can remove it.

GitHub issue #1848

Generated-by: Claude Code (Claude Opus 5.5)
@codecov-commenter

codecov-commenter commented Nov 8, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 62.75%. Comparing base (7d4b720) to head (fab7cc7).
⚠️ Report is 6 commits behind head on master.

Additional details and impacted files
@@             Coverage Diff              @@
##             master    #1870      +/-   ##
============================================
+ Coverage     62.72%   62.75%   +0.02%     
- Complexity    11512    11534      +22     
============================================
  Files          1254     1255       +1     
  Lines         48665    48698      +33     
  Branches       6845     6855      +10     
============================================
+ Hits          30526    30559      +33     
  Misses        15463    15463              
  Partials       2676     2676              
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Codecov reported five changed lines without full coverage in #1870.
A test now renders a Page that is not a WebPage, which writes its
<html> tag unchanged through MarkupContainer#renderNext, and one renders
page markup whose <html> tag has a namespace, which HtmlTagHandler
leaves alone. The null check on the locale in WebPage#onHtmlTag goes:
getLocale() falls back to the session locale and is never null.

GitHub issue #1848

Generated-by: Claude Code (Claude Opus 5.5)
The first version of this change wrote dir only for right-to-left
languages and left it off otherwise, because left to right is the
browser's default and the attribute would be on every page without
changing how it renders. In review, solomax argued for writing it in
both directions. MDN recommends setting dir explicitly on the root
element even for documents in a single script, and stylesheets that
select on [dir="ltr"] only match when the attribute is there.

WebPage#onHtmlTag now writes dir="ltr" or dir="rtl" next to lang. A dir
in the markup is still kept, and markup that declares its own lang
still gets neither. The expected-output files gain dir="ltr" after
lang.

migration-to-12.md describes the change for applications. The file is
also created by #1865; whichever pull request is merged second keeps
both sections.

GitHub issue #1848

Generated-by: Claude Code (Claude Opus 5.5)
Both master and this branch create migration-to-12.md; the merge keeps both sections.

@papegaaij papegaaij 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.

This will go in with a merge commit, per AGENTS.md; 4f19938 already explains the switch to writing dir="ltr", so the history reads fine without squashing.

*/
protected void onHtmlTag(final ComponentTag tag)
{
if (tag.getAttribute("lang") != null || tag.getAttribute("xml:lang") != null)

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.

HTML attribute names are case-insensitive, but getAttribute is a plain map lookup. <html LANG="en" DIR="ltr"> doesn't count as having lang, and renders as <html LANG="en" DIR="ltr" lang="en-US" dir="ltr">. Browsers take the first one, but validators and accessibility checkers such as axe flag the duplicates. The same applies to XML:LANG and to the dir check below.

ValueMap.getKey(String) already does a case-insensitive lookup:

ValueMap attributes = tag.getAttributes();
if (attributes.getKey("lang") != null || attributes.getKey("xml:lang") != null)
{
	return;
}
...
if (attributes.getKey("dir") == null)

A case in HtmlTagTest with upper-case attributes would cover it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, thanks. fab7cc7 checks lang, xml:lang and dir with getKey now, as you suggested. HtmlTagTest has three new cases with upper-case attributes: LANG/DIR, XML:LANG and DIR alone. Before the fix they produced exactly the duplicates you described.

HTML attribute names are case-insensitive, but WebPage#onHtmlTag looked
them up with getAttribute, a case-sensitive map lookup. Markup with
<html LANG="en" DIR="ltr"> counted as having neither, and rendered as
<html LANG="en" DIR="ltr" lang="en-US" dir="ltr">: browsers use the
first of each, but validators and accessibility checkers such as axe
flag the duplicates. The same went for XML:LANG.

The checks now use IValueMap#getKey, which ignores case, as papegaaij
pointed out in review.

GitHub issue #1848

Generated-by: Claude Code (Claude Opus 5.5)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Render the lang attribute of a page's <html> element from the page locale

4 participants