Dev.to WebDev 🛠 Dev 👁 0 📖 7 min read

165 HTML errors across our 76 pages, and deleting one attribute removes 152 of them

I have never once run my own site through a validator. Forty-one days of pages, 76 of them, and I had checked colour contrast, spelling, structured data and link graphs without ever asking whether the markup underneath w

I have never once run my own site through a validator. Forty-one days of pages, 76 of them, and I had checked colour contrast, spelling, structured data and link graphs without ever asking whether the markup underneath was legal.

So I fed all 76 to the Nu checker and counted. The loop is about ten lines:

html = urllib.request.urlopen(url, timeout=60).read()
req = urllib.request.Request(
    "https://validator.w3.org/nu/?out=json",
    data=html,
    headers={"Content-Type": "text/html; charset=utf-8"},
)
messages = json.loads(urllib.request.urlopen(req, timeout=120).read())["messages"]

One request a second so I am not rude to a free public service. Six minutes later:

pages:            76
errors:          165   (every page has at least one)
warnings:          0
info:          3,391

Three numbers, three completely different stories.

Nakodo is the app, and the pages are its public side: guides, niche pages, calculators, the pricing page, the lot. The URL list came from our sitemap.

The 3,391 notices are all one sentence

Trailing slash on void elements has no effect and interacts badly
with unquoted attribute values.

Every single one of the 3,391. That is <meta charSet="utf-8"/> and <link rel="stylesheet" ... /> and every <br/>, in the output of React's server renderer, which closes void elements in the XML style. In HTML it is meaningless but harmless, which is exactly why the checker calls it info rather than a warning.

There is nothing for me to do here and nothing to feel bad about. I note it because a first validator run on any React or Vue site produces thousands of these, and if you sort by count rather than severity you will spend your afternoon on the one message that is not yours to fix.

The 165 errors are three mistakes

155  The aria-label attribute must not be specified on any span element
       unless the element has a role value of ...
  9  The aria-label attribute must not be specified on any p element
       unless the element has a role value of ...
  1  The heading h3 (with computed level 3) follows the heading h1,
       skipping 1 heading level

Seventy-two pages have exactly two errors. Four pages have five. The distribution gave it away before I read a single extract: two per page means something in the layout, on every page.

152 of them are our logo, twice per page

// Lowercase wordmark in Besley. The full stop is the field mark: an ochre spot
// with an ink outline.
export function Wordmark({ className }: { className?: string }) {
  return (
    <span
      className={cn("relative inline-block font-display text-[22px] ...", className)}
      aria-label={BRAND.name}
    >
      <span aria-hidden="true">
        nakodo
        <span className="ml-[0.06em] inline-block size-[0.24em] rounded-full border border-mark-outline bg-mark" />
      </span>
    </span>
  );
}

The reasoning at the time was sound. The wordmark is the word "nakodo" plus a drawn dot that happens to be a span with a border radius. I did not want a screen reader to read the dot, so the visible layer is aria-hidden and the name is supplied by aria-label on the wrapper.

ARIA in HTML says no. A plain span has no role, and naming is prohibited on a generic element, so aria-label on it is invalid markup. The header and the footer each render one, which is 76 pages times two, which is 152.

The part that decided the fix is what I found in the accessibility tree. In Chrome, the name computes anyway:

{"role":"generic","name":"Nakodo","nameFrom":[{"attr":"aria-label","value":"Nakodo"}]}

Chromium honours an author name on a generic element even though the specification prohibits it. So the attribute is invalid, and it also works, which is the worst of the four possible combinations because it means nothing will ever tell you off except a validator you were not running.

Then I checked what wraps it:

[{"inLink":true,"linkLabel":"Nakodo home","href":"/","innerHidden":"true","text":"nakodo"},
 {"inLink":true,"linkLabel":"Nakodo home","href":"/","innerHidden":"true","text":"nakodo"}]

Both wordmarks on every public page sit inside <a aria-label="Nakodo home">. A link's own aria-label beats its contents, so the accessible name of that link is "Nakodo home" whether or not the span inside it is labelled. The attribute causing 92 per cent of our HTML errors has no effect on any user, on any page of the public site. Deleting it takes 165 errors to 13 and changes nothing else.

It is not quite a one-line delete, because the app behind the login renders <Logo href={null} /> in the sidebar, and there the span's label is the only name in the subtree. That one wants role="img" with the label, which is valid, deliberate and says what the thing is: a picture of a word. You can see the public version in the header of any page on the site; the invalid attribute is in the DOM until I ship the change.

Three more are the same mistake in a third component

That accounts for 152 of the 155 span errors. The other three are all on the home page, in the chip that shows a creator's fit as a number:

<span
  className={cn("inline-flex items-center justify-center gap-2 rounded-sm font-semibold tabular-nums", ...)}
  title={...}
  aria-label={`Fit score ${score}, ${bin.name}`}
>

A span, a label, no role. Three example chips on the landing page, three errors. Unlike the logo, this one is doing real work: the chip is a bare number next to a word, and the label is what turns "78" into something a screen reader can say usefully (the three on the home page read "Fit score 91, Top", "Fit score 78, Strong" and "Fit score 30, Low"). So this is the case for role="img" again rather than a deletion, and it is the same one-attribute fix in a third file.

Nine more are the same mistake in a different tag

Three of our pages are calculators, and each has three rows:

<p className="hidden pb-2 text-right text-sm tabular-nums sm:block" aria-label={`Video ${i + 1} engagement rate`}>

Same prohibition, same reason, on a p this time. Three rows times three calculators is nine. These are the per-row results on the engagement rate calculator, where the number needs a name because the visible column header is somewhere else on a narrow screen. That is a real problem with a real solution, and aria-label on a paragraph is not it: the fix is a th relationship or a visually hidden label inside the element, so the name comes from content rather than from an attribute the element is not allowed to carry.

Worth noticing: those nine errors are on exactly the pages where I was thinking hardest about screen readers. The markup that is wrong is the markup I wrote while trying to be careful. Nothing in the careless parts of the site produced an error at all.

And one heading, on one page

/pricing: The heading h3 ... follows the heading h1, skipping 1 heading level
extract: </p></div><h3 class="font-sans text-lg font-semibold">Free</

Our plan cards are a shared component:

<h3 className="font-sans text-lg font-semibold">{plan.name}</h3>

On the home page that component appears inside a section that already has an h2, so the levels run 1, 2, 3 and everything is fine. On the pricing page the cards come straight after the h1, so the levels run 1, 3. Same component, same markup, correct on one page and an error on the other, because a heading level is a fact about position in a document and a component does not know where it is.

There are three honest ways out. Put an h2 above the cards on the pricing page, which is a content change and probably the right one, since a group of plans deserves a name. Take a level prop. Or thread a heading level down through context, which is correct in general and which I am not going to build for one card.

One of 76 pages. I would not have found it by reading, because when you read a page you see sizes, not levels, and our h3 is deliberately smaller than the body text around it.

What I took from it

Zero warnings and 3,391 notices is the shape of a modern framework site. The signal is entirely in the errors, and the errors came to 165 from three distinct causes. Finding three real defects in 76 pages in six minutes is the best return on an afternoon I have had in a while.

All three are the same kind of defect. Markup that is invalid because of where it sits rather than what it says: a label on an element that cannot be labelled, a heading at a level that is only wrong in one context. No typos, no unclosed tags. Those classes of bug were solved for me by JSX, and the ones left over are the ones a compiler cannot see because they are about meaning.

The validator disagreed with me most where I had tried hardest. Every error in that list is a place where I was adding accessibility information. That is not an argument for adding less of it. It is an argument for checking that the way you added it is a way the platform supports, because an attribute that is ignored is worse than no attribute: it leaves you believing the job is done.

If you have never run your own site through it, the URL is validator.w3.org/nu and your sitemap already has the list of pages. Sort by message, not by page. The count will be large and the number of actual mistakes will be small.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.