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
18 changes: 11 additions & 7 deletions content/index.txt
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions data/clients.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"intent": "one genuinely native client per platform, sharing a core between them",
"availability": "none-released"
}
16 changes: 16 additions & 0 deletions internal/invariant/invariant.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
39 changes: 38 additions & 1 deletion internal/invariant/invariant_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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)
}
}
141 changes: 141 additions & 0 deletions internal/site/clients.go
Original file line number Diff line number Diff line change
@@ -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:]...)
}
Loading
Loading