Skip to content

Refuse markup that does not parse, and four things a browser hides [#32] - #148

Merged
iderex merged 1 commit into
mainfrom
refuse-markup-that-does-not-parse
Aug 11, 2026
Merged

Refuse markup that does not parse, and four things a browser hides [#32]#148
iderex merged 1 commit into
mainfrom
refuse-markup-that-does-not-parse

Conversation

@iderex

@iderex iderex commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

What was wrong

Nothing read the markup this repository produces. The gate decided what a grep
can decide about a page, which is whether a particular string is there, and a
string being there says nothing about whether the document holds together:

go run . invariants
invariants: 17 rule(s), in order: page-declares-its-language, page-carries-a-title, page-carries-the-affiliation-notice, page-fetches-no-script, image-carries-its-own-dimensions, output-carries-no-unfinished-marker, output-references-no-domain-outside-the-allowlist, output-carries-no-expired-reporting-route, tracked-text-names-no-tool, workflow-step-carries-no-version-literal, design-tokens-live-in-exactly-one-file, test-opens-no-window, test-needs-no-display-server, test-binds-only-loopback, test-writes-no-certificate-store, test-asks-for-no-elevation, test-needs-nothing-outside-the-toolchain

Run 2026-08-12 on 195020a. A browser recovers from broken markup, so a
template can produce an unclosed element for months while every page renders and
nothing says a word about it.

What this does

Reads every produced page strictly. Every element is closed, in the order it was
opened, or the page is refused.

That is stricter than HTML, which lets a paragraph or a list item end where the
next one begins, and it is deliberate. This repository writes every byte of its
own markup out of templates, so an optional end tag left out is a choice
somebody made once, and a rule that has to model a browser's recovery is a rule
nobody can read.

Four more properties come off the same walk, because the walk is where they are
cheap and because each one is a page that renders perfectly:

page-parses                          an element never closed, an end tag that closes something else, a tag that never ends
page-uses-no-identifier-twice        one identifier on two elements, or an empty one
page-skips-no-heading-level          a level that rises by more than one
page-image-carries-alternative-text  an image with no alt attribute
page-names-every-control             a control with no accessible name

Five rows and not one, because the repairs differ. A page that does not parse is
a template somebody has to fix before anything on it can be judged; a heading
that skips is a page that parses.

A page that does not parse is reported once rather than five times. Everything
after a broken tag is a guess about what the author meant, and a page buried
under guesses is one nobody reads the first line of.

The population is what the build produces, which today is one page. The rows are
landed while that is true because retrofitting a strict read across seventeen
generated pages later is the expensive version of this, and because the plugin
pages this issue names as its dependency are what they are for.

Closes

Closes #32.

Its done-when in order: every produced page parses, which the run above says; a
deliberately unclosed element reds the check, a duplicate id reds it, and an
image without alt reds it, all three below; and the whole leg runs with no
browser installed, which is what the package is written to make true.

The two properties named in the body beside those three, a heading level that
skips and a control with no label, are rows as well.

What was run

go run . ci
gate: 6 legs, in order: format, vet, test, build, links, 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, 31 file(s)
  vet: ok
  test: ok, 15 test file(s)
  build: ok, 2 file(s)
  links: every reference that stays inside this site resolves to a file the build wrote
  invariants: ok, 22 rule(s) decided, 1 owed and not decided
6 of 6 legs ran. None was skipped.

Twenty-two decided against seventeen. The one still owed is
image-dimensions-match-the-file, waiting on the first image the build writes,
which is #69.

git diff --name-only origin/main...HEAD
internal/invariant/invariant.go
internal/invariant/invariant_test.go
internal/markup/markup.go
internal/markup/markup_test.go

Both run 2026-08-12.

The guards, deleted and watched go red

One at a time, with the rest of the tree left alone. Each shows the package's
own case and the row that reads it:

the well-formedness refusal removed
  --- FAIL: TestAnElementThatIsNeverClosedIsRefusedAndTheLineIsNamed
      a page with an unclosed element was not refused
  --- FAIL: TestCrossedElementsAndAStrayEndTagAreRefused
      elements that cross was not refused
  --- FAIL: TestABrokenPageIsReportedOnceRatherThanGuessedAt

the duplicate-identifier refusal removed
  --- FAIL: TestADuplicateIdentifierIsRefusedAndBothLinesAreNamed
  --- FAIL: TestEveryRowRefusesItsOwnViolationAndPassesTheNeighbour
      row page-uses-no-identifier-twice passed its own violation

the heading refusal removed
  --- FAIL: TestAHeadingLevelThatSkipsIsRefusedAndOneThatFallsBackIsNot
  --- FAIL: TestEveryRowRefusesItsOwnViolationAndPassesTheNeighbour
      row page-skips-no-heading-level passed its own violation

the alternative-text refusal removed
  --- FAIL: TestAnImageWithNoAlternativeTextIsRefusedAndAnEmptyOneIsNot
  --- FAIL: TestEveryRowRefusesItsOwnViolationAndPassesTheNeighbour
      row page-image-carries-alternative-text passed its own violation

the control-name refusal removed
  --- FAIL: TestAControlWithNothingNamingItIsRefused
  --- FAIL: TestEveryRowRefusesItsOwnViolationAndPassesTheNeighbour
      row page-names-every-control passed its own violation

The near miss worth the effort here is the other direction. A strict reader that
refuses a well-formed page is worse than no reader, because the repair for it is
somebody loosening the rule, and the shapes a template writes every day are
exactly the ones a hand-written reader gets wrong. So the suite carries a page
that breaks nothing and seven shapes that must be left alone: a void element
written with a slash and without one, an attribute with no value, values in
double quotes, single quotes and none, a comment carrying a tag inside it, a
stylesheet carrying a < inside it, and a heading falling back to a level
already used.

One of those found a defect in this change before it landed. Raw-text elements
were stepped over without being put on the stack, so </title> was read as an
end tag closing <head>, and the first run over the real page refused it:

dist/index.html: line 6: </title> closes an element that is not open; the innermost open element is <head>, opened on line 3

The repair is that a raw-text element goes on the stack like any other and the
walk steps over what is inside it to its end tag.

No test was skipped. Nothing here needs a display, a browser, elevation or the
network, which is the property this issue asks for in its last clause.

The means

Go, in a package of its own, with no dependency added. The alternative worth
naming is a real HTML parser from outside the toolchain, and it is the wrong
tool twice over: a conforming parser implements the recovery this rule exists to
refuse, so it would accept the unclosed element and report a tree it repaired,
and it would be the first dependency in a repository whose bill of materials
says it has none.

The rows live in the invariant table, decided by the same verb as every other
row and reported under the same check name, so a contributor meets them by
running one verb rather than by remembering a second one.

Who read it

Nobody else has read this. 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.

A browser recovers from broken markup, so a template can produce an unclosed
element for months while every page still renders. Nothing in a review, a build
or a reader's visit says a word about it, and the first thing that notices is a
screen reader announcing a heading that swallowed the rest of the document.

Every produced page is now read strictly: every element closed, in the order it
was opened, or the page is refused. That is stricter than HTML, which lets a
paragraph end where the next one begins, and it is deliberate. This repository
writes every byte of its own markup out of templates, so an end tag left out is
a choice somebody made once, and a rule that has to model a browser's recovery
is a rule nobody can read.

Four properties are read off the same walk, because the walk is where they are
cheap and because each is a page that renders perfectly. A duplicate identifier
means a reference reaches whichever element the reader's software picked. A
heading level that skips leaves somebody moving by heading unable to tell what
is under what. An image with no alternative text is a hole in the page for
anybody who cannot see it. A control with nothing naming it asks for a field to
be filled in without saying what goes in it.

They are five rows and not one. The repairs are different: a page that does not
parse is a template somebody has to fix before anything on it can be judged, and
a heading that skips is a page that parses.

A page that does not parse is reported once rather than four times. Everything
after a broken tag is a guess about what the author meant, and a page buried
under guesses is one nobody reads the first line of.

No browser and no network. The page is read as bytes, so this stays inside a
suite that runs on a machine with no display, and what only a render can decide
is left to the run that has one.

Signed-off-by: Nils Lehnen <[email protected]>
@iderex
iderex merged commit 21e13dd into main Aug 11, 2026
17 checks passed
@iderex
iderex deleted the refuse-markup-that-does-not-parse branch August 11, 2026 22:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Refuse markup that does not parse

1 participant