From c6158106cc2c48543b2786d1a0f0da84011cc520 Mon Sep 17 00:00:00 2001 From: Nils Lehnen <30603423+iderex@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:21:34 +0200 Subject: [PATCH] Refuse a produced page that declares a language the site does not publish in [#186] The row read whether the lang attribute was there and held anything, so a page announcing a language this site does not publish in walked through it. That is the sharper of the two failures: a page declaring nothing leaves a screen reader guessing, and a page declaring the wrong language stops it guessing and sends it to the wrong answer with nothing to signal that. What the site publishes in is now declared once, in the invariant table, with the record it was taken from cited beside it. The comparison is on the primary subtag and folds case, so en-GB and EN pass, which is the record's own reading. The guard was proved by deleting the new arm and running the suite, which reddened the three cases written for it and left the neighbour green. Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com> --- internal/invariant/invariant.go | 46 +++++++++++- internal/invariant/invariant_test.go | 102 +++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 3 deletions(-) diff --git a/internal/invariant/invariant.go b/internal/invariant/invariant.go index 51bdb64..65e27e4 100644 --- a/internal/invariant/invariant.go +++ b/internal/invariant/invariant.go @@ -273,6 +273,28 @@ const affiliationNotice = "Flowfin is not affiliated with the Jellyfin project." // reaches anything else is a link that skips less than it looks like it does. const contentElement = "main" +// publishedLanguages is what this site publishes in, held as the primary subtag +// of a language tag. A page announcing anything else is a page in the wrong +// language, and the row below is where that is decided. +// +// It is a second copy of an answer taken elsewhere. The record is +// decisions/site-language.md in Flowfin/hub, which decided that the published +// pages are English and stated the same three-part obligation this row carries. +// Nothing here compares the two: no leg of the gate reaches the network, and the +// two verbs that do are outside it for that reason. So the day that record moves +// is a day this line is wrong and no run says so, and it is moved by hand with +// the record. +// +// A set rather than a single value, so an answer that later publishes a second +// language is a change to what this holds rather than to the row that reads it. +// +// The comparison is on the primary subtag, which is the record's own reading: a +// tag that is more specific about the same language is a page in that language, +// so en-GB passes. Refusing it would push whoever writes the next page towards +// the least specific tag available, which is the wrong direction for a rule that +// exists so a reader's software picks the right voice. +var publishedLanguages = []string{"en"} + // Rules is the table. The order is the order a run reports them in. // // It is handed the numbers a client is held to, because one row compares against @@ -290,8 +312,8 @@ func Rules(numbers []tokens.Number) []Rule { { ID: "page-declares-its-language", Subject: ProducedPages, - Reason: "a page with no language is read aloud in whichever one the reader's software guessed, and the guess is wrong for exactly the readers who depend on it", - Refuses: "a produced page with no html element, or one whose lang attribute is missing or empty", + Reason: "a page with no language is read aloud in whichever one the reader's software guessed, and the guess is wrong for exactly the readers who depend on it; a page declaring a language this site does not publish in stops the guessing and sends the same reader to the wrong answer with nothing on the page to signal it", + Refuses: "a produced page with no html element, one whose lang attribute is missing or empty, or one whose lang names a language this site does not publish in", decide: decideLang, }, { @@ -1295,12 +1317,30 @@ func decideLang(body []byte) []string { if m == nil { return []string{"the html element carries no lang attribute"} } - if strings.TrimSpace(string(m[1])) == "" { + tag := strings.TrimSpace(string(m[1])) + if tag == "" { return []string{"the lang attribute on the html element is empty"} } + if !published(tag) { + return []string{fmt.Sprintf("the html element declares lang=%q, and this site publishes in %s", + tag, strings.Join(publishedLanguages, ", "))} + } return nil } +// published says whether a language tag names one of the languages this site +// publishes in. It reads the primary subtag and folds case, because a tag is +// case-insensitive and a page written EN is the same page as one written en. +func published(tag string) bool { + primary, _, _ := strings.Cut(tag, "-") + for _, l := range publishedLanguages { + if strings.EqualFold(primary, l) { + return true + } + } + return false +} + func decideTitle(body []byte) []string { m := titleElement.FindSubmatch(body) if m == nil { diff --git a/internal/invariant/invariant_test.go b/internal/invariant/invariant_test.go index d0215bc..013b584 100644 --- a/internal/invariant/invariant_test.go +++ b/internal/invariant/invariant_test.go @@ -719,6 +719,80 @@ func TestAViolationRedsExactlyOneRow(t *testing.T) { } } +// The other half of the same row, and the half a presence check misses. The +// attribute is there, it holds a real tag, and it is the wrong one, which is +// what a page left behind in the language this site used to be written in looks +// like. The refusal has to carry the value, because a message saying only that +// something about the language was wrong sends the next reader back to the page +// to find out which of the three things it was. +func TestTheLanguageRowRefusesALanguageTheSiteDoesNotPublishIn(t *testing.T) { + page := []byte(strings.Replace(cleanPage, ``, ``, 1)) + + got := decideLang(page) + if len(got) != 1 { + t.Fatalf("the row reported %d violation(s) for a page declaring de: %v", len(got), got) + } + if !strings.Contains(got[0], `"de"`) { + t.Errorf("the refusal reads %q, which does not name the value it found", got[0]) + } + for _, l := range publishedLanguages { + if !strings.Contains(got[0], l) { + t.Errorf("the refusal reads %q, which does not say the site publishes in %s", got[0], l) + } + } +} + +// What the row leaves alone, and it is the direction that costs a reader if it +// is wrong. A tag more specific about the same language is a page in that +// language, and a row refusing it would be a row somebody satisfies by writing +// the vaguest tag available. The case is spelled out of the declared set rather +// than typed, so a set that later publishes a second language does not leave +// this case passing against a language nobody publishes in. +func TestTheLanguageRowLeavesAMoreSpecificTagAlone(t *testing.T) { + for _, l := range publishedLanguages { + for name, tag := range map[string]string{ + "the tag itself": l, + "a region of it": l + "-GB", + "the tag in capitals": strings.ToUpper(l), + "a region of it, spaced ": " " + l + "-419 ", + } { + page := []byte(strings.Replace(cleanPage, ``, + ``, 1)) + if got := decideLang(page); len(got) != 0 { + t.Errorf("the row refused %s (%q): %v", name, tag, got) + } + } + } +} + +// The wrong language reds this row and moves no other, in either direction. A +// page that is otherwise clean carries every other row's subject, so a fixture +// that tripped a second row would mean a red run naming a repair the page does +// not need. +func TestALanguageTheSiteDoesNotPublishInRedsExactlyOneRow(t *testing.T) { + page := []byte(strings.Replace(cleanPage, ``, ``, 1)) + + var refused []string + for _, r := range Rules(fixtureNumbers) { + if len(r.decide(page)) > 0 { + refused = append(refused, r.ID) + } + } + if len(refused) != 1 || refused[0] != "page-declares-its-language" { + t.Errorf("a page declaring de refused %v, want only page-declares-its-language", refused) + } + + // The same page with the published tag back, so this case says the row + // moved on the value rather than on anything else the replacement did. + clean := []byte(strings.Replace(cleanPage, ``, + ``, 1)) + for _, r := range Rules(fixtureNumbers) { + if got := r.decide(clean); len(got) > 0 { + t.Errorf("%s refused the same page carrying %s: %v", r.ID, publishedLanguages[0], got) + } + } +} + // The four shapes a reference to somebody else's domain arrives in, each named // in #37 and each refused with the address written out. A message that said only // that the page reached another origin would leave the next person grepping the @@ -937,6 +1011,34 @@ func TestRunRefusesATemplateThatDroppedTheLanguage(t *testing.T) { } } +// The same mistake made in the value rather than in the attribute, which is the +// shape a page left behind in another language arrives in. It is put in the +// template because that is where a page property is lost, and every page the +// build produced is named rather than the one somebody happened to open. +func TestRunRefusesATemplateDeclaringALanguageTheSiteDoesNotPublishIn(t *testing.T) { + root := tree(t, strings.Replace(goodTemplate, ``, ``, 1)) + + var log bytes.Buffer + err := Run(root, &log) + if err == nil { + t.Fatalf("Run accepted a template declaring a language this site does not publish in:\n%s", log.String()) + } + for _, want := range []string{ + "page-declares-its-language: REFUSED, 2 violation(s)", + `dist/index.html: the html element declares lang="de"`, + `dist/privacy/index.html: the html element declares lang="de"`, + "it refuses", + "because", + } { + if !strings.Contains(log.String(), want) { + t.Errorf("the run does not say %q; it said:\n%s", want, log.String()) + } + } + if !strings.Contains(err.Error(), "1 rule(s) refused") { + t.Errorf("the error reads %q, which does not say how many rules refused", err) + } +} + // The declaration lives in the head every page is rendered through, so losing // it there loses it from every page at once. The run is what shows that, and // what makes it a statement about the frame is that both produced pages are