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