DEV Community

Zugo
Zugo

Posted on

Two h1 tags on 500 pages: the bug that lives between two correct halves

Our blog grew from 84 posts to 1,360 in a day. Then I read one live page instead of the source, counted tags, and found two <h1> elements on 500 of them. Both halves of the code were correct. Their sum was not, and no test could see it.

Where the second heading came from

The post template prints the title from frontmatter:

<h1>${esc(fm.title)}</h1>
Enter fullscreen mode Exit fullscreen mode

Every article starts with its own heading, because that is what a markdown document looks like:

# How to build a CRM without code
Enter fullscreen mode Exit fullscreen mode

marked turns that into another <h1>. Nothing in either file is wrong. A markdown document that opens with a level-one heading is correct markdown. A template that prints the page title as the page heading is a correct template. The defect exists only in the output, which is the one artifact nobody was testing.

Why the tests were green

The suite had guards for the corpus and guards for the renderer:

Guard What it read Would it catch two h1?
corpus checker the .md files no, markdown was valid
template tests the component no, template emits one
build exit code no, build succeeded

Every one of them passed, forever, while a thousand pages shipped a broken heading hierarchy. Search engines get to pick which heading is the page title in that situation, and screen readers announce a structure that does not exist.

The fix is one line, in one place

Not in 500 files. The markdown is not at fault, so editing it would be fixing the wrong thing.

const html = marked.parse(body)
  .replace(/<h1(\s[^>]*)?>/g, "<h2$1>")
  .replace(/<\/h1>/g, "</h2>");
Enter fullscreen mode Exit fullscreen mode

Demoted rather than deleted: if the body heading ever diverges from the frontmatter title, the text stays on the page instead of vanishing silently.

The part worth stealing

The gate runs on the finished page, not on the inputs:

const h1s = (page.match(/<h1[\s>]/g) ?? []).length;
if (h1s !== 1) fail(`${slug}: ${h1s} h1 tags, expected exactly one`);
Enter fullscreen mode Exit fullscreen mode

It is three lines and it catches a whole class. I verified it by mutation: remove the demotion and the build fails on the first article, which is the only evidence that a green gate means anything.

Counting things that must be unique is cheap and it works for more than headings. canonical, title, og:url all have the same property: exactly one, always, and a duplicate is invisible until someone looks at the output.

The general shape

When both sides of a seam look correct and the result is wrong, check the output, not the inputs. I hit the same shape twice more the same week: a checker that read the source and passed while the built HTML was broken, and a guard that fired on correct text because it matched a substring instead of a structure.

If your site generator has a template and a content directory, you probably have a version of this. It costs one command to find out:

grep -c '<h1' dist/some/page/index.html
Enter fullscreen mode Exit fullscreen mode

I write about this kind of thing while building Zugo, which turns a written description into a working game, site or app. The blog has more of the measured-not-guessed variety, for example what AI builders cannot do and whether AI-built sites are accessible.

Top comments (0)