Skip to content

Mark the language of every value a page renders - #91

Merged
namedgraph merged 8 commits into
masterfrom
rf-language-marking
Sep 2, 2026
Merged

Mark the language of every value a page renders#91
namedgraph merged 8 commits into
masterfrom
rf-language-marking

Conversation

@namedgraph

Copy link
Copy Markdown
Member

Follow-up to #89/#90. Those made label lookup negotiate over the reader's ordered
language preferences; this branch makes the rendered page say which language it
ended up in, and stops the negotiation from silently discarding the values it did
not pick.

What changes

$ac:langs is a function, not a parameter. ac:langs() is declared once in
functions.xsl and answered differently by Saxon and Saxon-JS via use-when, so
the server reads Accept-Language and the browser reads navigator.languages
without either side re-declaring the state. $ldt:lang and its LDT.java term go
with it — the last of the language state that lived in two places.

The page declares the language it is written in, rather than the one that was
asked for, and publishes the shortest tag the bundle justifies rather than the key
it happens to be stored under.

Every rendered value carries its own language. A label chosen from one of
several languages is marked with the tag it was chosen in — on the anchor and the
span that emit it, read off the literal ac:label mode picked, with no caller
passing it in. A label computed from a URI has no literal behind it and inherits.
An untagged literal gets lang="", HTML's spelling of RDF's absent tag; a typed
value is not prose and inherits, so a number or a date is read out in whatever
language the reader is in. This is WCAG 3.1.2, and it keeps the extracted RDFa
faithful.

A table cell holds every value its property has. A cell used to render the one
value matching the reader's language, or the first in document order when none
matched, and drop the rest — so a reader whose language the data lacks saw an
arbitrary value and everyone else saw one value with no sign the others existed.
The header fixes the column count, so the values now share the one cell the first
one opens, ordered by ac:lang-rank().

…and lays them out instead of merely emitting them. The first cut wrapped each
value in a bare span with nothing between them, which reads fine for a title in
three languages and not at all for eight object references: they ran together into
one unbroken line, wrapping mid-token. Each value is now a block of its own inside
a container the cell holds — a td cannot bound its own height, so the container
is what a skin caps, and a property with fifty values scrolls rather than setting
the row height for the whole table. Nothing is dropped from the DOM.

The layout is a mode, xhtml:TableDataCellValue, so a skin overrides a cell's
values the way it already overrides a property list's. It matches the statement
rather than the nodes beneath it, so an XHTML literal stays one value however many
elements it is written with.

The language badge is written once. It existed inline in the property list and
was about to be copied into the table cell; it moves to an ac:lang-tag mode over
@xml:lang, applied from wherever values are laid out, so the two cannot drift.
The generic layer answers it with nothing, the Bootstrap layer with its label, and
a downstream skin with whatever it uses.

Verification

No automated coverage for rendering; checked by eye against LinkedDataHub's
Northwind Traders demo — a container table with a high-arity object property, a
resource description with multi-language titles, under both the server render and
the Saxon-JS one, which share these stylesheets.

🤖 Generated with Claude Code

https://claude.ai/code/session_016YxhcqMPK1kZR1pxxJqoge

namedgraph and others added 8 commits September 2, 2026 10:00
…r language.

A representation negotiated by language needs Accept-Language in its Vary and in
its entity tag, or a shared cache hands one reader the other's rendering and a
conditional request answers 304 across languages. Both fixes are in the Core
snapshot; the release this depended on predates them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
…hat was asked for.

Asking for a language does not make the page that language. The root lang came
from the reader's top preference with no reference to the content, so
Accept-Language: de produced lang="de" over a page whose every word was English,
and es produced lang="es" over the same page. That is a WCAG 3.1.1 failure
generated from a request header.

So the two facts are separated. $ac:langs stays the reader's ordered preference
and keeps driving collation and label selection; $ac:contentLang is what the
representation was composed in, and only that reaches <html lang>. A writer
supplies it as the highest-ranked accepted language whose translations it
actually ships.

It defaults to en rather than to $ac:lang. The rule is "the first accepted
language the UI provides, else en", and Web-Client's own strings are English
only, so for a client supplying no value the negotiation can only ever answer
en. Defaulting to $ac:lang would return a value the rule cannot produce, and
would leave the defect as the out-of-the-box behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
…ge it is in.

A cell rendered the value matching the reader's language, or the first in
document order when none matched, and discarded the rest. So a reader whose
language the data lacks was shown an arbitrary one, every other reader was shown
a single value with no sign the others existed, and neither could tell which
language they were looking at.

The header fixes the column count, so the values share one cell rather than each
claiming a column: the first value of a property opens it and its siblings are
folded in, ordered by ac:lang-rank so the reader's language leads and the rest
follow instead of disappearing. Each carries its own lang, since a cell now
holds several at once and the document's language is wrong for all but one of
them. An untagged literal gets lang="", HTML's spelling of the absent RDF tag; a
typed value is not prose and inherits, so a number or a date is read out in
whatever language the reader is in.

Object links get the same treatment from the other end: the anchor already
applies the label mode itself, so it can read the tag off the literal that mode
chose without any caller passing it one. A label computed from a URI has no
literal behind it and inherits, which is what you want for a fragment or a local
name.

The ldt:lang templates go with them. They were the same suppression, commented
out, over a parameter that was never declared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
…owser answer it differently without declaring it twice.

$ac:langs was a parameter with a default in Web-Client and an override in
LinkedDataHub's client.xsl, because the browser's list can only be read where
ixsl is allowed. Two declarations of one value is the shape that lets them
drift, and they had: the client override expressed a normalisation the server
default did not, so the same page ranked languages differently depending on
which engine drew it.

ac:langs() replaces it, with the same use-when split the browser-dependent
functions already use - except that the two bodies live on opposite sides of the
repository boundary, since Web-Client carries no ixsl references. This body
reads the parameter the writer supplies and exists only under SAXON; the SaxonJS
one lives in LinkedDataHub. Exactly one is present in any compilation, which is
also what lets this one keep reading a parameter that does not exist in the
client-side stylesheet at all.

Normalising inside the function is what makes the two engines agree: primary
subtags, because fn:lang() prefix-matches and 'es' therefore reaches a label
tagged es-ES while 'es-ES' would not reach one tagged es; deduped, because a
browser sending es-ES,es yields that subtag twice; and 'en' when nothing was
expressed, which is the floor the server-side negotiation already applied.

$ac:lang goes with it. Nothing ever supplied it - there is no vocabulary term
and no writer passes one - so it was a derived alias for the first accepted
language, declared once per engine and carrying the same drift risk. Its 36 call
sites say ac:langs()[1] now, which is what the value always was: the collation
sites read as "sort in the reader's top preference", and the date formatting
keeps the language argument it needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
…ry other label already does.

The anchor for an object resource wrapped its label in xsl:value-of, which
atomizes the literal to a string and throws the language tag away before
anything downstream can read it. So a Lithuanian reader saw "Gatvių tipai" on a
page composed in English with nothing saying which language it was in, and a
German reader - whose language the data does not have - saw a fallback label in
a third language, equally unmarked.

Binding the label as nodes keeps the literal, and its @xml:lang goes on the
anchor. A label built as a computed string, from a fragment identifier or a
decoded path segment, has no literal behind it and inherits, which is what you
want for those.

This is the same idiom the subject anchors in xhtml:Anchor mode already use.
They were marked earlier and this template was missed: it renders the links
inside a property value rather than the subject heading, so it never went
through that mode at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
Folding every value of a property into the one cell its header allots stopped
the cell from hiding values, but left them wrapped in bare spans with nothing
between them: eight order items in an OrderedItem cell read as one unbroken run
of UUIDs, wrapping mid-token, with no way to tell where one link ended and the
next began.

So the cell lays them out rather than merely emitting them. Each value is a
block of its own, and the blocks live in a container the cell holds, because a
td cannot bound its own height and a property with fifty values must not set the
row height for the whole table - the skin caps the container and the surplus
scrolls, with every value still in the DOM.

The layout is a mode, xhtml:TableDataCellValue, so a skin overrides a cell's
values the way it already overrides a property list's. It matches the statement,
not the nodes beneath it: an XHTML literal is one value however many elements it
is written with, and the xsl:choose over the value's language becomes two
templates, which is where that choice belongs.

The language badge was written inline in the property list and would have been
written a second time here. It moves to an ac:lang-tag mode over @xml:lang,
applied from wherever values are laid out, so the two places cannot drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016YxhcqMPK1kZR1pxxJqoge
@namedgraph
namedgraph merged commit 4fc84ee into master Sep 2, 2026
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.

1 participant