diff --git a/content/index.txt b/content/index.txt index 36899e3..c03d85a 100644 --- a/content/index.txt +++ b/content/index.txt @@ -1,13 +1,17 @@ Flowfin -description: Flowfin is a set of Jellyfin plugins and native clients, and this - is the page that says what each of them is and what state it is in. +description: Flowfin is a set of Jellyfin plugins, and this is the page that + says what each of them is and what state it is in. -Flowfin is a set of Jellyfin plugins and native clients. This page is what the -build produces before the real pages exist, so that the verb that produces it, -the directory it lands in and the exit code it returns are all working and can -be added to rather than invented later. +Flowfin is a set of Jellyfin plugins for a server somebody else runs. + +This page is what the build produces before the real pages exist, so that the +verb that produces it, the directory it lands in and the exit code it returns +are all working and can be added to rather than invented later. The first line of this file is the title and the blocks below it are paragraphs. That is the placeholder's shape and not the site's; the pages -replace it with something that knows about a roster. +replace it with something that knows about a roster. What this file may not +carry is a claim about whether something is available: that is a value the +build reads, so the page cannot go on saying it after it has stopped being +true. diff --git a/data/clients.json b/data/clients.json new file mode 100644 index 0000000..c6cf96a --- /dev/null +++ b/data/clients.json @@ -0,0 +1,4 @@ +{ + "intent": "one genuinely native client per platform, sharing a core between them", + "availability": "none-released" +} diff --git a/internal/invariant/invariant.go b/internal/invariant/invariant.go index b79690a..78971a8 100644 --- a/internal/invariant/invariant.go +++ b/internal/invariant/invariant.go @@ -1018,6 +1018,17 @@ func gather(root string) (map[string][]file, error) { if len(tracked.tokenCopies) == 0 { return nil, fmt.Errorf("%s is not tracked in this tree, and the row about where a token value is read from is a row about there being exactly one such file", tokens.File) } + // The claim about the clients is refused here for a different reason + // than the two above, and it is the reason the file exists. A tree that + // lost it still builds, and the landing page it produces is a page that + // leads with what this project is and says nothing about the clients at + // all. That is not a row deciding nothing; it is the page silently going + // back to the shape the value was introduced to end, and no reading of + // the produced bytes tells a sentence that was never composed from one + // that was never asked for. + if len(tracked.clientClaims) == 0 { + return nil, fmt.Errorf("%s is not tracked in this tree, so the landing page says nothing about the clients and reads as a page about a project that has none", site.ClientsFile) + } return map[string][]file{ ProducedPages: pages, @@ -1041,6 +1052,7 @@ type tracked struct { buildInputs []file tokenCopies []file securitySources []file + clientClaims []file } // workflowDir is where a workflow has to live for the server to run it, so it @@ -1097,6 +1109,10 @@ func trackedText(root string) (tracked, error) { found.securitySources = append(found.securitySources, f) continue } + if name == site.ClientsFile { + found.clientClaims = append(found.clientClaims, f) + continue + } if strings.HasPrefix(name, site.TemplatesDir+"/") || strings.HasPrefix(name, site.ContentDir+"/") { found.buildInputs = append(found.buildInputs, f) } diff --git a/internal/invariant/invariant_test.go b/internal/invariant/invariant_test.go index f2b83a9..01dff66 100644 --- a/internal/invariant/invariant_test.go +++ b/internal/invariant/invariant_test.go @@ -694,8 +694,13 @@ func tree(t *testing.T, template string) string { mk("content") mk(filepath.Dir(filepath.FromSlash(tokens.File))) wr(filepath.Join("templates", "page.html.tmpl"), template) + // The landing page's prose carries no paragraph of its own. The one + // paragraph on it is the sentence the build composes from the claim + // about the clients written below, so a fixture that lost that claim + // produces a landing page with nothing on it rather than one that merely + // says less. wr(filepath.Join("content", "index.txt"), - "A title\n\ndescription: What the first fixture page is.\n\nOne paragraph.\n") + "A title\n\ndescription: What the first fixture page is.\n") // The second page, and it is here for the frame rather than for // anything the privacy register decides. A property of the one file // every page is rendered through is a statement about all of them, and a @@ -719,6 +724,11 @@ func tree(t *testing.T, template string) string { // than on a change. wr(filepath.FromSlash(security.File), `{"route":"https://example.invalid/report", "policy":"https://example.invalid/policy","confirmed":"2099-01-01"}`) + // What the landing page says about the clients. It is a value rather + // than a paragraph, so a fixture with no such file is a fixture whose + // landing page has quietly stopped making the statement, which is what + // the refusal below is about. + wr(filepath.FromSlash(site.ClientsFile), `{"intent":"a client per platform","availability":"none-released"}`) git(t, root, "init", "-q") git(t, root, "add", "-A") @@ -1442,3 +1452,30 @@ func TestANarrowedRowFindsNothingWhenThePageIsNotThere(t *testing.T) { t.Errorf("the narrowing returned %d file(s) for a page the build did not write", len(got)) } } + +// A tree that lost the claim about the clients is refused before any row is +// decided, and the refusal names the file. The landing page still builds +// without it, and what it builds is a page leading with what this project is +// and saying nothing about the clients at all, which reads as a project that +// has none. No reading of the produced bytes separates a sentence that was +// never composed from one nobody ever asked for, so the absence is refused here +// rather than left to a row over the output. +func TestRunRefusesATreeThatLostTheClaimAboutTheClients(t *testing.T) { + root := tree(t, goodTemplate) + if err := os.Remove(filepath.Join(root, filepath.FromSlash(site.ClientsFile))); err != nil { + t.Fatalf("removing %s: %v", site.ClientsFile, err) + } + git(t, root, "rm", "-q", "--cached", filepath.FromSlash(site.ClientsFile)) + + var log bytes.Buffer + err := Run(root, &log) + if err == nil { + t.Fatalf("Run accepted a tree with no claim about the clients:\n%s", log.String()) + } + if !strings.Contains(err.Error(), site.ClientsFile) { + t.Errorf("the refusal reads %q, which does not name the file that was missing", err) + } + if !strings.Contains(err.Error(), "says nothing about the clients") { + t.Errorf("the refusal reads %q, which does not say what the tree lost", err) + } +} diff --git a/internal/site/clients.go b/internal/site/clients.go new file mode 100644 index 0000000..e567c17 --- /dev/null +++ b/internal/site/clients.go @@ -0,0 +1,141 @@ +// What the landing page says about the clients, and why it is a value rather +// than a paragraph. +// +// The first thing this project says about itself is about clients, and no +// client is released. A reader who arrives because of that sentence has nothing +// to download and, until now, nothing on the page telling them so. The repair +// is not a better paragraph. A claim about whether something is available that +// lives in prose is a claim somebody has to remember to change, and the day it +// stops being true is the day nobody is reading that paragraph. +// +// So the claim is a value in the tree and the sentence is composed from it. +// Changing what the page says is changing the value, which is one line in one +// file, and there is no wording anywhere that can go on saying the old thing +// after the value has moved. +// +// The vocabulary is deliberately not the roster's. The three state words there +// say something about a plugin repository and are computed from what that +// repository has published; a client is not a roster row today and borrowing a +// word that means something else would be the drift this milestone exists +// against. What replaces this file is the day a client becomes something a +// person can install: it stops being a sentence here and becomes a row, with +// its kind named, in the file the pages are generated from. +package site + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// ClientsFile is what the claim is read from. +const ClientsFile = "data/clients.json" + +// The two things the file may say, and there is no third. A closed set is what +// makes the sentence composable at all: a free-text claim would be prose again, +// one file further down. +const ( + // NoneReleased is that nothing a person can install exists yet. + NoneReleased = "none-released" + // Released is that at least one does, and then the file has to say + // where a reader gets it. + Released = "released" +) + +// clients is what the tree says. Intent is what the clients are meant to be, +// in the words a reader reads, and it is data rather than template text for the +// same reason the availability is: a description of software that does not +// exist yet is the sentence most likely to go quietly out of date. +type clients struct { + Intent string `json:"intent"` + Availability string `json:"availability"` + // Where a reader gets one. It carries something only when something is + // released, because a route to software nobody has published is an + // address that answers with nothing. + Where string `json:"where"` +} + +var clientFields = map[string]bool{"intent": true, "availability": true, "where": true} + +// readClients reads the claim and refuses everything it does not understand. +// Every refusal is a shape that renders as a finished sentence, which is the +// class of mistake this file exists to catch rather than a syntax error. +func readClients(name string) (clients, error) { + body, err := os.ReadFile(filepath.Clean(name)) + if err != nil { + return clients{}, err + } + + var raw map[string]json.RawMessage + if err := json.Unmarshal(body, &raw); err != nil { + return clients{}, fmt.Errorf("%s is not the object this file has to be: %w", ClientsFile, err) + } + for field := range raw { + if !clientFields[field] { + return clients{}, fmt.Errorf("%s carries the field %q, which is not a field of this file", ClientsFile, field) + } + } + var c clients + if err := json.Unmarshal(body, &c); err != nil { + return clients{}, fmt.Errorf("%s does not carry the fields this file has to carry: %w", ClientsFile, err) + } + + if strings.TrimSpace(c.Intent) == "" { + return clients{}, fmt.Errorf( + "%s says nothing about what the clients are, and a page that leads with them and describes none of them is the page this file exists to prevent", + ClientsFile) + } + switch c.Availability { + case NoneReleased: + if strings.TrimSpace(c.Where) != "" { + return clients{}, fmt.Errorf( + "%s says %s and still names %q as where to get one, which no reader will see and which whoever wrote it has stopped looking at", + ClientsFile, NoneReleased, c.Where) + } + case Released: + if strings.TrimSpace(c.Where) == "" { + return clients{}, fmt.Errorf( + "%s says %s and names nowhere to get one, which is a page telling a reader something exists and leaving them to look for it", + ClientsFile, Released) + } + case "": + return clients{}, fmt.Errorf( + "%s declares no availability, and %s and %s are the only two this file may declare", + ClientsFile, NoneReleased, Released) + default: + return clients{}, fmt.Errorf( + "%s declares the availability %q, and %s and %s are the only two this file may declare", + ClientsFile, c.Availability, NoneReleased, Released) + } + return c, nil +} + +// sentence is what the page says, composed from the value. Both halves come out +// of the file, so there is no wording here that survives the value changing +// underneath it. +func (c clients) sentence() string { + if c.Availability == Released { + return fmt.Sprintf( + "The clients this project means to build are %s. At least one of them is released, and %s is where a reader gets it.", + c.Intent, c.Where) + } + return fmt.Sprintf( + "The clients this project means to build are %s. None of them is released, so there is nothing here to download and no page describing one as though there were.", + c.Intent) +} + +// sayWhatTheClientsAre puts the sentence on the page, directly after the +// paragraph that says what this project is. That position is the point: a +// reader who reads far enough to learn that clients are part of the plan has +// already read the line telling them none exists, rather than finding it after +// going to look for a download. +func sayWhatTheClientsAre(paragraphs []string, said string) []string { + if len(paragraphs) == 0 { + return []string{said} + } + out := make([]string, 0, len(paragraphs)+1) + out = append(out, paragraphs[0], said) + return append(out, paragraphs[1:]...) +} diff --git a/internal/site/clients_test.go b/internal/site/clients_test.go new file mode 100644 index 0000000..ff64c62 --- /dev/null +++ b/internal/site/clients_test.go @@ -0,0 +1,197 @@ +// The suite over what the landing page says about the clients. +// +// The case that matters most is the first one below, and it is written the way +// it is on purpose. It does not assert that the page carries a particular +// sentence, because a case doing that would pass just as well over a sentence +// typed into the template. It changes the value in the tree and asserts that +// exactly that changes on the page, which is the property this whole file +// exists for: setting the value is what changes the page. +package site + +import ( + "bytes" + "io" + "path/filepath" + "strings" + "testing" +) + +// clientsIn writes the claim into a fixture tree and returns the landing page +// the build produced from it. +func clientsIn(t *testing.T, body string) string { + t.Helper() + + root := tree(t, "Fixture title\n\ndescription: What this fixture page is, in one sentence.\n\nWhat this project is.\n") + mkdir(t, filepath.Join(root, filepath.Dir(filepath.FromSlash(ClientsFile)))) + write(t, filepath.Join(root, filepath.FromSlash(ClientsFile)), body) + + if _, err := Build(root, OutputDir, io.Discard); err != nil { + t.Fatalf("Build: %v", err) + } + return read(t, filepath.Join(root, OutputDir, IndexPath)) +} + +// refusedClaim builds a tree carrying the claim and returns the reason the +// build gave for refusing it. +func refusedClaim(t *testing.T, body string) string { + t.Helper() + + root := tree(t, "Fixture title\n\ndescription: What this fixture page is, in one sentence.\n\nWhat this project is.\n") + mkdir(t, filepath.Join(root, filepath.Dir(filepath.FromSlash(ClientsFile)))) + write(t, filepath.Join(root, filepath.FromSlash(ClientsFile)), body) + + _, err := Build(root, OutputDir, io.Discard) + if err == nil { + t.Fatalf("Build accepted %s", body) + } + if !strings.Contains(err.Error(), ClientsFile) { + t.Errorf("the refusal reads %q, which does not name the file that was wrong", err) + } + return err.Error() +} + +// The sentence comes out of the value and not out of any wording the build +// carries. Flipping the value flips what the page says, and the page that said +// nothing is released stops saying it. +func TestTheClientSentenceFollowsTheValueAndNotTheTemplate(t *testing.T) { + none := clientsIn(t, `{"intent":"a client per platform","availability":"none-released"}`) + if !strings.Contains(none, "None of them is released") { + t.Errorf("the page does not say that none is released; it is:\n%s", none) + } + if !strings.Contains(none, "a client per platform") { + t.Errorf("the page does not say what the clients are; it is:\n%s", none) + } + + released := clientsIn(t, `{"intent":"a client per platform","availability":"released","where":"the releases of this project"}`) + if strings.Contains(released, "None of them is released") { + t.Errorf("the value moved and the page went on saying nothing is released; it is:\n%s", released) + } + if !strings.Contains(released, "the releases of this project") { + t.Errorf("the page does not say where a reader gets one; it is:\n%s", released) + } +} + +// The sentence sits directly after the paragraph saying what this project is, +// rather than at the end of the page. A reader who learns that clients are part +// of the plan learns in the same breath that none exists, instead of finding it +// after going to look for a download. +func TestTheClientSentenceFollowsWhatThisProjectIs(t *testing.T) { + got := clientsIn(t, `{"intent":"a client per platform","availability":"none-released"}`) + + // The fixture template renders one element per paragraph and nothing + // else, so the order of these is the order of the paragraphs. + paragraphs := paragraphsOf(got) + if len(paragraphs) != 2 { + t.Fatalf("the page carries %d paragraph(s) and the fixture plus the sentence is two:\n%s", len(paragraphs), got) + } + if !strings.Contains(paragraphs[0], "What this project is.") { + t.Errorf("the first paragraph is not the one saying what this project is: %q", paragraphs[0]) + } + if !strings.Contains(paragraphs[1], "a client per platform") { + t.Errorf("the paragraph after it is not the one about the clients: %q", paragraphs[1]) + } +} + +// paragraphsOf returns what a fixture page renders between its paragraph tags, +// in the order they appear. +func paragraphsOf(page string) []string { + var out []string + for _, after := range strings.Split(page, "

")[1:] { + end := strings.Index(after, "

") + if end < 0 { + continue + } + out = append(out, after[:end]) + } + return out +} + +// Text out of the value is escaped on the way into the page by the path every +// other value takes, so a claim containing a bracket cannot become markup. +func TestTheClientSentenceRendersMarkupInTheValueAsText(t *testing.T) { + got := clientsIn(t, `{"intent":"a client per platform","availability":"none-released"}`) + + if strings.Contains(got, "") { + t.Errorf("the value reached the page as markup; the page is:\n%s", got) + } + if !strings.Contains(got, "<script>") { + t.Errorf("the value does not appear as text; the page is:\n%s", got) + } +} + +// A tree with no claim in it is reported rather than passed over, so a run that +// said nothing about the clients cannot be read as one that had nothing to say. +func TestBuildSaysWhenThereIsNoClaimAboutTheClients(t *testing.T) { + root := tree(t, "Fixture title\n\ndescription: What this fixture page is, in one sentence.\n\nWhat this project is.\n") + + var absent bytes.Buffer + if _, err := Build(root, OutputDir, &absent); err != nil { + t.Fatalf("Build refused a tree with no claim about the clients: %v", err) + } + if !strings.Contains(absent.String(), "no "+ClientsFile+" in the tree") { + t.Errorf("the build passed over an absent claim in silence:\n%s", absent.String()) + } + + mkdir(t, filepath.Join(root, filepath.Dir(filepath.FromSlash(ClientsFile)))) + write(t, filepath.Join(root, filepath.FromSlash(ClientsFile)), + `{"intent":"a client per platform","availability":"none-released"}`) + + var present bytes.Buffer + if _, err := Build(root, OutputDir, &present); err != nil { + t.Fatalf("Build refused a tree carrying a claim about the clients: %v", err) + } + if !strings.Contains(present.String(), "read "+ClientsFile+" ("+NoneReleased+")") { + t.Errorf("the build does not say what it read:\n%s", present.String()) + } +} + +// Every shape below renders as a finished sentence rather than as a broken +// page, which is why each one is refused by name instead of being left to a +// reader to notice. +func TestBuildRefusesAClaimAboutTheClientsItWillNotStandBehind(t *testing.T) { + for _, c := range []struct { + name, body, says string + }{ + { + name: "a field this file does not have", + body: `{"intent":"a client per platform","availability":"none-released","state":"shell"}`, + says: `"state"`, + }, + { + name: "nothing about what the clients are", + body: `{"intent":" ","availability":"none-released"}`, + says: "says nothing about what the clients are", + }, + { + name: "no availability at all", + body: `{"intent":"a client per platform"}`, + says: "declares no availability", + }, + { + name: "an availability outside the two", + body: `{"intent":"a client per platform","availability":"build-up"}`, + says: `declares the availability "build-up"`, + }, + { + name: "nothing released and somewhere to get one", + body: `{"intent":"a client per platform","availability":"none-released","where":"the releases"}`, + says: "no reader will see", + }, + { + name: "something released and nowhere to get it", + body: `{"intent":"a client per platform","availability":"released"}`, + says: "names nowhere to get one", + }, + { + name: "not the object this file has to be", + body: `["a client per platform"]`, + says: "is not the object this file has to be", + }, + } { + t.Run(c.name, func(t *testing.T) { + if got := refusedClaim(t, c.body); !strings.Contains(got, c.says) { + t.Errorf("the refusal reads %q, and does not say %q", got, c.says) + } + }) + } +} diff --git a/internal/site/site.go b/internal/site/site.go index 4473d64..70ed3ef 100644 --- a/internal/site/site.go +++ b/internal/site/site.go @@ -122,6 +122,28 @@ func Build(root, outDir string, log io.Writer) ([]string, error) { return nil, err } + // What the page says about the clients, before anything is written, so a + // tree carrying a claim the build will not accept fails before it has + // produced half a site. + // + // An absent file is reported rather than passed over, the way every other + // source this build reads is, and the sentence is then simply not on the + // page. What refuses a tree that lost the file is the invariant over the + // tree, because a landing page that quietly stops saying what the clients + // are is a rule about this repository rather than about a fixture + // somebody built a page in. + clientsPath := filepath.Join(root, filepath.FromSlash(ClientsFile)) + if _, err := os.Stat(clientsPath); os.IsNotExist(err) { + fmt.Fprintf(log, "no %s in the tree, so the page says nothing about the clients\n", ClientsFile) + } else { + c, err := readClients(clientsPath) + if err != nil { + return nil, fmt.Errorf("reading what the clients are: %w", err) + } + p.Paragraphs = sayWhatTheClientsAre(p.Paragraphs, c.sentence()) + fmt.Fprintf(log, "read %s (%s)\n", ClientsFile, c.Availability) + } + out := outDir if !filepath.IsAbs(out) { out = filepath.Join(root, out)