Skip to content

Produce one page per roster row, all through the one frame [#28] - #190

Merged
iderex merged 2 commits into
mainfrom
enhancement/one-page-per-roster-row
Aug 25, 2026
Merged

Produce one page per roster row, all through the one frame [#28]#190
iderex merged 2 commits into
mainfrom
enhancement/one-page-per-roster-row

Conversation

@iderex

@iderex iderex commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

What was wrong

The landing page listed twelve plugins and the build produced a page for none
of them, so a reader who found a row in the table had nowhere to go but the
repository:

git ls-tree -r --name-only b2b47bd | grep -c '^internal/site/pluginpages' ; echo "exit=$?"
0
exit=1

Run 2026-08-25. b2b47bd is the mainline this branch was cut from.

What this waited on was #26, the landing page, which is closed as completed:

gh issue view 26 --repo Flowfin/site --json state,stateReason --jq '"\(.state) \(.stateReason)"'
CLOSED COMPLETED

Run 2026-08-25. The other number go run . blockers reads out of this issue's
body is #80, and this issue's own text says of it that it is a forward
reference rather than a dependency: #80 lands the prose these pages will render,
and this issue owns the frame, the addresses and the escaping.

What this does

internal/site/pluginpages.go writes one page per roster row, at the address
decisions/0008-the-url-shape.md gives them, rendered through the frame every
other page is rendered through. Each page says what the plugin does, what state
it is in and what that state means, and where its repository is, and it offers
two ways onward: the repository, and the table it came from.

A plugin in the shell state gets the same page as any other. Hiding it would
make the site disagree with the list it is generated from, and the reader
arriving at a shell is the one most likely to have been sent by the table and
least likely to know what they are looking at.

The rows the table renders are the rows the pages come from. There is one read
behind both, so a page with no row and a row with no page are the same read seen
twice rather than two things that can disagree.

The table's first cell now links to the plugin's page rather than to its
repository. A cell offering both is a cell where a reader has to guess which of
the two links is the one they want, and the repository is on the page the cell
leads to.

What this does not do. The prose that makes a plugin page worth opening is
#79's decision and lands in #80; what is on these pages today is the sentence
the roster carries and what the state word means. The computed shipping state is
#22, so no page here says a plugin ships. The install page is #27 and this
change adds no address the plan did not already have: decisions/0008 counts
these twelve, and decisions/0003's count of eighteen is taken against that
list, so neither record needs correcting.

Closes

Closes #28.

What was run

The gate, at the commit being pushed, 78ad89c:

go run . ci
gate: 7 legs, in order: format, vet, test, build, links, sitemap, invariants
  needs-network was not asked for. Asking costs a request to the public name from whatever machine runs it, and a verdict that moves when somebody else's service does rather than when this tree changes. Ask with: go run ./harness/needs-network
  format: ok, 60 file(s)
  vet: ok
  test: ok, 29 test file(s)
  build: ok, 20 file(s)
  links: every reference that stays inside this site resolves to a file the build wrote
  sitemap: every page the build wrote is listed once, and every entry has a page behind it
  invariants: ok, 39 rule(s) decided, 1 owed and not decided
7 of 7 legs ran. None was skipped.

No test was skipped, and none needed a display or elevation.

Exactly one page per roster row, and none without one

go run . build > /dev/null
diff <(jq -r '.[].id' data/roster.json | sort | tr -d '\r') \
     <(ls dist/plugins | sort | tr -d '\r') ; echo "exit=$?"
exit=0
ls dist/plugins | wc -l
12

Both run 2026-08-25 at 78ad89c. Empty diff in both directions, which is the
part a containment check would miss: a build that also wrote a page for
something the roster does not carry passes every row has a page and fails
this.

No count in the frame and none in the check

git grep -c -E 'plugins/|12|twelve' -- templates/page.html.tmpl ; echo "exit=$?"
exit=1

Run 2026-08-25 at 78ad89c. The frame carries neither the container these pages
sit in nor any number of them; it renders the rows it is handed. The invariant
row this milestone added refuses a tree carrying no roster and reads no count
either.

Inside the budget in #6

go run . invariants | grep -E '^  (page-fits-the-markup-budget|page-fits-the-stylesheet-budget|page-fetches-no-script|page-downloads-no-web-font|page-references-everything-from-the-site-root)'
  page-fetches-no-script: ok, 17 file(s) of every page the build produced
  page-fits-the-markup-budget: ok, 17 file(s) of every page the build produced
  page-fits-the-stylesheet-budget: 17 file(s) of every page the build produced carried no inlined stylesheets, so this rule decided nothing
  page-downloads-no-web-font: ok, 17 file(s) of every page the build produced
  page-references-everything-from-the-site-root: ok, 106 references in 17 file(s) of every page the build produced

wc -c dist/plugins/*/index.html | tail -1
25377 total

Both run 2026-08-25 at 78ad89c. Twelve pages, 25377 bytes between them, the
largest of them an order of magnitude inside the document line. The two lines of
that budget that are properties of a render rather than of the bytes, the
largest contentful paint and the layout shift, are decided nowhere in this
repository and are #35; this change moves neither. The stylesheet line still
decides nothing while no produced page carries one.

The guards bite

The writer made to write nothing, which is what losing the pages looks like from
a table that still links them:

go test ./internal/site/
--- FAIL: TestOnePageIsProducedPerRosterRowAndNoneWithoutOne (0.02s)
--- FAIL: TestARowAddedToTheRosterProducesAPageWithNoOtherEdit (0.02s)
--- FAIL: TestAPluginPageSaysWhatItIsWhatStateItIsInAndWhereItLives (0.01s)
--- FAIL: TestAShellGetsAPageLikeAnyOther (0.01s)
--- FAIL: TestARowCarryingMarkupRendersAsTextOnItsPage (0.02s)
FAIL

Run 2026-08-25, and the edit reverted afterwards.

What the suite asserts that the runs above do not

A row added to the roster produces a page with nothing else edited, at the
address the record gives it. A sentence carrying markup renders as text on the
page as well as in the table, with data that tries rather than with the data
this tree happens to hold. A shell's page carries the same number of paragraphs
as any other, which is the clause about not hiding it stated as a comparison
rather than as an intention. And a tree with no roster produces no plugin page
and does not leave an empty container behind for the host to answer at.

What a server check caught that this machine's gate did not

The first head of this branch was refused by the static analysis workflow, and
the refusal is recorded here rather than removed with the commit that repaired
it. The rule is about a value out of the roster reaching a filesystem path, and
the case it names is real rather than theoretical: .. is a legal suffix of a
repository name, it survived every rule the parser had, and joined into the
container these pages go in it resolves out of that container and over a page
the build already wrote.

Ran 5 rules on 31 files: 2 findings.
  internal/site/plugins.go
  tools.semgrep.roster-data-reaches-a-filesystem-path
    110: Href:       "/" + path.Join(PluginsDir, e.ID) + "/",
    115: Produced:   path.Join(PluginsDir, e.ID, indexDocument),

The repair is where that rule's own message says it belongs, in the parser
beside the other roster rules: an identifier is lower case words joined by
hyphens, which is the shape the schema already describes and which makes it one
segment by construction. The value is reduced at the join as well. The rule is
proved by switching it off:

go test ./internal/roster/ -run 'TestAnIdentifierThatIsNotOneSegment'
--- FAIL: TestAnIdentifierThatIsNotOneSegmentOfAnAddressIsRefused (0.00s)
    roster_test.go:254: the identifier ".." parsed into 1 row(s) rather than being refused
    roster_test.go:254: the identifier "." parsed into 1 row(s) rather than being refused
    roster_test.go:254: the identifier "A" parsed into 1 row(s) rather than being refused

and the case beside it takes the five real identifiers of this project through
the same rule and asserts none of them is refused, because a rule that refused
one of those is a rule somebody widens the first time it is in the way.

That leg is not part of go run . ci, which is why the gate on this machine
was green over it, and that gap is a property of the leg list rather than of
this change.

What went red on the way, and it was not about this issue

Seven cases in the invariant suite asserted REFUSED, 2 violation(s) over the
pages its fixture tree produces, and three more asserted 2 file(s) and 2 declarations. The fixture tree gained a roster with #26 and produces three
pages now, so ten cases about frames, colour schemes and motion went red naming
a repair none of them needed. Those counts are read off a build of the fixture
tree rather than typed, so the next page added there reds nothing that is not
about it. That is a repair to the suite in the same change that surfaced it,
and it is disclosed here rather than folded into the description above.

The means

Go, the build verb and the frame this repository already carries. The page
struct, the address derivation and the description register were all already
here and are reused rather than duplicated; what is new is the writer and the
two words a state declares. Every value out of the roster reaches the page
through html/template, which is the same engine the prose takes, so escaping
is a property of the path rather than of a filter somebody remembers to apply.
Nothing is added to the module graph.

The alternative refused was a template per plugin. It is what a site of twelve
pages is usually built as, and it is the one shape in which a thirteenth plugin
is a new file and a page can quietly say something the roster does not.

Who read it

Nobody but me. The ruleset requires no approving review, so this is merged by
whoever opened it, and the evidence above stands in place of a second reader.

The site listed twelve plugins and had a page for none of them, so a reader who
found a row in the table had nowhere to go but the repository.

One page per row now, at the address decisions/0008-the-url-shape.md gives
them, rendered through the frame every other page is rendered through. Each one
says what the plugin does, what state it is in and what that state means, and
where its repository is. A plugin in the shell state gets the same page as any
other: hiding it would make the site disagree with the list it is generated
from, and the reader arriving at a shell is the one least likely to know what
they are looking at.

Nothing about a plugin is typed twice and nothing learns how many there are.
The rows the table renders are the rows the pages come from, so a page with no
row and a row with no page are the same read seen twice rather than two things
that can disagree. Neither the frame nor any check carries a count, and a
thirteenth plugin is a row in the roster.

The table's first cell now links to the plugin's page rather than to its
repository. A cell offering both is a cell where a reader has to guess which of
the two links is the one they want, and the repository is on the page the cell
leads to.

Seven cases in the invariant suite asserted a violation count of two over the
pages the fixture tree produces. The tree gained a roster and produces three,
and those counts are now read off a build of the fixture rather than typed, so
the next page added there reds nothing that is not about it.

Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com>
Comment thread internal/site/plugins.go Fixed
Comment thread internal/site/plugins.go Fixed
@iderex iderex added the enhancement New feature or request label Aug 25, 2026
@iderex iderex self-assigned this Aug 25, 2026
@iderex iderex added this to the The pages milestone Aug 25, 2026
The static analysis rule about roster data reaching a filesystem path refused
the two lines that join an identifier into the address of a plugin page and into
the path that page is written to. The rule is right and the case it names is
real: `..` is a legal suffix of a repository name, it survived every rule the
parser had, and joined into the container the plugin pages go in it resolves out
of that container and over a page the build already wrote.

    Ran 5 rules on 31 files: 2 findings.
      internal/site/plugins.go
      tools.semgrep.roster-data-reaches-a-filesystem-path
        110: Href:       "/" + path.Join(PluginsDir, e.ID) + "/",
        115: Produced:   path.Join(PluginsDir, e.ID, indexDocument),

Run by the static analysis workflow on the head before this one.

The repair is where that rule's own message says it belongs, in the parser
beside the other roster rules: an identifier is lower case words joined by
hyphens, which is the shape the schema already describes and which makes it one
segment by construction. The value is reduced at the join as well, because the
line that puts a value out of a data file into a path is where a reader decides
whether a row can choose where the build writes.

Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com>
@iderex
iderex merged commit 184e34c into main Aug 25, 2026
17 checks passed
@iderex
iderex deleted the enhancement/one-page-per-roster-row branch August 25, 2026 01:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

One page per plugin, generated from the roster

2 participants