Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 43 additions & 3 deletions internal/invariant/invariant.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
},
{
Expand Down Expand Up @@ -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 {
Expand Down
102 changes: 102 additions & 0 deletions internal/invariant/invariant_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<html lang="en">`, `<html lang="de">`, 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, `<html lang="en">`,
`<html lang="`+tag+`">`, 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, `<html lang="en">`, `<html lang="de">`, 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, `<html lang="en">`,
`<html lang="`+publishedLanguages[0]+`">`, 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
Expand Down Expand Up @@ -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, `<html lang="en">`, `<html lang="de">`, 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
Expand Down
Loading