Skip to content

feat(a11y): give content blocks a heading level - #2116

Merged
markdumay merged 5 commits into
gethinode:mainfrom
LeonarddeR:feat/blocks-heading-levels
Aug 4, 2026
Merged

feat(a11y): give content blocks a heading level#2116
markdumay merged 5 commits into
gethinode:mainfrom
LeonarddeR:feat/blocks-heading-levels

Conversation

@LeonarddeR

@LeonarddeR LeonarddeR commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Phase 2 of #1519. Phase 1 (#2091) gave every template-rendered page an h1 and listed seven
pages it could not reach. This handles those seven.

Companion pull request: gethinode/mod-blocks#193, "forward a heading level to section titles".

Neither repository changes what a page looks like on its own. This one adds the capability;
mod-blocks#193 is what asks for it, so the seven pages below only gain their headings once both
have landed and the module dependency is bumped. The two are safe to merge in either order —
without mod-blocks#193 nothing passes a level and every block keeps rendering a <div>, and
without this pull request mod-blocks passes an argument that the section title partial ignores.

What is wrong today

A page assembled from content blocks has no headings at all. Not a missing h1 — nothing. In
the example site that is the home page in all three languages, the team page in two, and the
two documentation landing pages: seven pages, zero headings between them.

Three things combine to cause it.

The template that renders a list page checks whether the page has content blocks. When it does,
it renders those blocks and stops; the page header, which carries the h1, is never reached.

The only thing that can produce a heading for a block is the shared section title partial. It
took a boolean called use-title, which defaults to off and, when switched on, could produce
only an h1.

Nothing switched it on. Twelve Bookshop wrappers render a section title and none of them passed
the boolean, so every block title on every site rendered as a <div>.

Why a boolean cannot fix it

A page carries anywhere from one to a dozen section titles. Turning them all into headings with
a boolean would produce a dozen h1 elements. The titles need a level, and something has to
decide what that level is.

That decision belongs to the page template, because it depends on the layout rather than on the
block. A list page renders blocks instead of a header, so its first block owns the h1. A
single page renders its header whenever it carries content, so its blocks must start at h2.

The change

A heading level replaces the boolean. assets/section-title.html takes heading-level, an
integer from 1 to 6, where 0 keeps the <div>. use-title still maps onto level 1, so existing
callers behave exactly as before, and a caller that passes neither keeps the <div> this partial
has always rendered. Out-of-range values warn and fall back to a <div>.

Go's template engine rejects a dynamic element name, so the element is assembled as a string and
marked safe — the same approach assets/toc-parse-content.html already uses for its anchors.

The page templates pass the level down. list.html and docs/all.html render blocks in
place of the header, so their blocks start at 1. single.html renders its header whenever the
page carries content, so its blocks start at 2 in that case and at 1 otherwise.

The level is an argument, not a running tally. An earlier attempt handed the h1 to
whichever title rendered first and tracked that on the page. It does not work, and the build says
so: Hugo decides for itself when a page's content is rendered, and the example-bookshop
shortcode composes live component previews while the content is being built — before the header
template runs. A documentation preview therefore took the page's h1, and the real page title
became a second one. Passing the level as an argument removes the ordering question entirely.

Keeping the layout identical

assets/scss/layouts/_type.scss gives every heading a top margin, and Bootstrap adds a bottom
margin, a heavier weight and a tighter line height. A <div> has none of that, so promoting a
title in place would visibly push every hero down the page.

The partial resets those on a title promoted through heading-level, so it occupies the same
box the <div> did at every level. A heading-class argument replaces the reset — an empty
value means no extra classes — and a title that arrives through use-title keeps the styling
its h1 has always had. The list page header is the one existing caller that already rendered
an h1; it passes the empty value and keeps its markup and spacing untouched.

Verification

Every figure below comes from building hinode's example site with this branch and mod-blocks#193
resolved through a Hugo workspace, compared against a build of both repositories at their
unmodified state.

Document structure. A script over the built example site, run against both a pre-change and a
post-change build of all three languages. Before: 7 of 111 pages had no h1. After: every page
has exactly one h1, no page has two, and no page skips a level. Comparing the two heading
outlines page by page, exactly those 7 changed and the other 104 are identical.

Rendered geometry. Both builds were served locally and measured through Claude in Chrome at a
matched 1280×900 viewport. Every text-bearing element inside <main> was recorded with its
position, size, font size, weight, line height and margins. Across the nine pages that could
change — the seven promoted pages plus the two kinds of list page — zero elements differ, and
every page reports an identical scroll height. Screenshots of the home page before and after are
indistinguishable.

Generated markup. Comparing every built HTML file between the two builds: 95 of 122 files
are byte-identical, and the style sheet is unchanged, so no asset fingerprint moves. All 27 that
differ fall into three groups — the seven heading promotions, the list page titles whose element
is recomposed with identical attributes, and the generated argument tables on the documentation
pages gaining a heading-level row.

Template tests. Twelve assertions in tests/templates pin the partial's behaviour: nil
keeps the <div>, use-title maps onto level 1 with unchanged classes under both heading
styles, an explicit level beats use-title, out-of-range levels fall back to the <div>, each
level renders its tag, and heading-class replaces the reset — including the empty value, which
means no extra classes.

Build health. pnpm lint and pnpm test pass. The example site builds with no errors and no
new warnings. The theme also builds standalone, without mod-blocks.

Notes

404.html and tags/list.html render no page content, so no section title can appear on them and
they are left alone.

Card titles remain a <p>. They are the other open item on the issue's checklist and need their
own change, because assets/card.html also backs the card shortcode, whose output lands in the
page content. The mobile table of contents scans that content for headings while the desktop one
reads the Markdown structure, and the scanner raises a build error for any heading without an id —
which cards do not have. Those two heading sources need to agree first.

LeonarddeR and others added 2 commits August 3, 2026 13:56
Section titles rendered a plain div unless the caller passed `use-title`, and that boolean
could only ever produce an h1. A page assembled from content blocks carries many section
titles, so a boolean cannot express its outline.

Replace it with `heading-level`, an integer from 1 to 6 where 0 keeps the div. `use-title`
still maps onto level 1, so existing callers are unaffected, and a caller that asks for
neither keeps the div this partial has always rendered.

Go's template engine rejects a dynamic element name, so the element is assembled as a string
and marked safe, the way `toc-parse-content.html` already composes its anchors.

A heading carries margins the div does not, and under the `fs` heading style it also picks
up Bootstrap's heading weight and line height. Reset those on the element so a promoted
title occupies exactly the box it did before, and add `heading-class` for callers that want
their own spacing. The list page header is the one existing caller that already rendered an
h1, so it opts back in to the element margin through `section-title-lead`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A list page built from content blocks never reaches the page header, so nothing supplied its
h1 and the whole document had no headings at all. That covered the home page, the team page
and the two docs landing pages of the example site.

Tell `page/blocks.html` which level its blocks start at. A list page and a docs landing page
render blocks in place of the header, so their blocks start at 1; a single page renders its
header whenever it carries content, so its blocks start at 2 in that case and at 1 otherwise.

The level is passed as an argument rather than tracked while rendering because Hugo decides
for itself when a page's content is rendered: the `example-bookshop` shortcode composes
components while the content is being built, which is before the header template runs. An
approach that handed out the h1 to whichever title rendered first therefore gave it to a
documentation preview and left the real page title as a second h1.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@netlify

netlify Bot commented Aug 3, 2026

Copy link
Copy Markdown

Deploy Preview for gethinode-demo ready!

Name Link
🔨 Latest commit 9d19fb8
🔍 Latest deploy log https://app.netlify.com/projects/gethinode-demo/deploys/6a71b91413b77f0008e501a9
😎 Deploy Preview https://deploy-preview-2116--gethinode-demo.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@markdumay

Copy link
Copy Markdown
Collaborator

Review notes

I verified the behavioural claims by building the example site on this branch and on its merge-base, and by probing assets/section-title.html directly with each argument combination.

What holds up

  • "Safe to merge in either order" is correct. Released mod-blocks page/blocks.html reads only .page/.embed from a dict and ignores unknown keys, and hinode's own blocks.html is a comment-only placeholder. Diffing a full example-site build before/after, the only rendered change is six list-page h1s gaining section-title-lead; everything else is whitespace. No new build warnings.
  • Nil handling is right. InitArgs omits unset arguments from the map entirely, so eq $level nil correctly distinguishes "not passed" from level 0.
  • Out-of-range handling works — level 9 and −1 both warn with a suppressible warn-invalid-heading-level id and fall back to a <div>.
  • release: v3.19.0 is the right next minor, and the manual htmlEscape on aria-label is correct now that the attribute is assembled by hand.

1. use-title callers do not behave exactly as before

The description states that use-title maps onto level 1 "so existing callers behave exactly as before". That isn't what happens. Because use-title: true now routes through the same reset path as a new-style heading, it picks up classes it never had:

args before after
use-title: true <h1 class="display-4 text-body pt-1"> <h1 class="display-4 text-body pt-1 mt-0 mb-0">
use-title: true, heading-style: fs <h1 class="fs-4 text-body pt-1"> <h1 class="fs-4 text-body pt-1 mt-0 mb-0 fw-normal lh-base">

Those are !important Bootstrap utilities, so the heading loses margin-top: $spacer * 2.5 from assets/scss/layouts/_type.scss, Bootstrap's margin-bottom: .5rem, and — with heading-style: fs — its heading weight and line height.

The live caller is mod-blocks layouts/partials/assets/hero.html:126, and hero.yml does declare use-title, so any hero block with use_title: true is affected. (hinode's own assets/timeline.html:146 forwards $args.useTitle, but timeline.yml never declares it, so it is inert there.)

The example site sets use-title: true nowhere, which is why the geometry verification came back clean.

Suggested fix: apply $reset only when the level arrived via heading-level, leaving the use-title path on the element's native styling.

2. single.html puts block headings ahead of the page h1 in document order

Blocks render outside the {{ if (gt (len .RawContent) 0) }} wrapper that contains .Render "header". Building a single page with both content_blocks and body content confirms the block's section title emits before <h1 id="probe-single-page">. Once mod-blocks#193 lands, that page's outline becomes h2 (block) → h1 (page header) → h2 (markdown).

Both stated criteria — "exactly one h1" and "no page skips a level" — pass on such a page, and the example site has no single pages using content_blocks, so neither the checker nor the fixtures cover this branch. It is the trickiest logic in the change and the one path with no evidence behind it. Worth either adding a fixture or stating explicitly that blocks-plus-content on a single page is unsupported.

3. heading-class has no "no classes" value

$args.headingClass | default $reset treats "" as unset, so a caller cannot opt out of the reset — it can only substitute something. That is why section-title-lead has to exist, and it restates $spacer * 2.5 / $spacer * 0.5, duplicating values that the h1 rule in _type.scss and Bootstrap's reboot already provide. If the h1 margin changes, the two drift silently.

Consider {{ if isset $args "headingClass" }} so that "" means "no extra classes", which removes the need for the class entirely.

4. No tests, though the harness exists

tests/templates is a Hugo-site-as-test-harness with an established assertion pattern (errorf → non-zero exit), already wired through test:templates. The new branching is worth pinning: nil → div, use-title → 1, explicit level wins over use-title, out-of-range → div, and tag composition per level. All five were confirmable in a single build, so they are cheap to encode.

Nits

  • heading-class is silently ignored when the level is 0; the structure comment doesn't mention it.
  • use-title isn't marked deprecated in data/structures/section-title.yml despite being fully superseded.

Verdict

The direction is right and the compatibility analysis with mod-blocks is sound. Point 1 is a real regression against an explicit claim in the description and should be addressed before merge; point 2 deserves at least an answer. The rest is polish.

markdumay and others added 2 commits August 4, 2026 08:39
- Apply the margin reset only when the level arrives via heading-level,
  so use-title callers keep the classes their h1 has always rendered
- Treat an empty heading-class as "no extra classes" via isset, letting
  the list page header return to its native h1 styling; this removes
  the section-title-lead class and its SCSS rule
- Document that heading-class only applies when a level renders a
  heading
- Pin the level resolution and class handling with twelve assertions in
  the template test harness, mounting the partial and the vendored
  mod-utils chain it initializes through

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@LeonarddeR

Copy link
Copy Markdown
Contributor Author

Fixes for points 1, 3, 4 and the first nit are in bbfb989.

1. use-title callers do not behave exactly as before

Confirmed and fixed. The margin reset now applies only when the level arrived through
heading-level. A caller that passes use-title gets the exact classes the h1 rendered
before this change, with both heading styles:

args rendered
use-title: true <h1 class="display-4 text-body pt-1">
use-title: true, heading-style: fs <h1 class="fs-4 text-body pt-1">

Both rows from your table are pinned by new template tests (see point 4).

2. Block headings ahead of the page h1 on single pages

The position of the blocks is not new: single.html rendered them ahead of the content
grid before this change, in the same spot. What is new is only that their titles can now
carry a level. The document order follows the visual order — blocks appear above the page
content, so their headings come first. The level assignment keeps the page at exactly one
h1 and no skipped level, and an h2 that precedes the h1 is a valid outline; it reads
oddly when navigating by headings, but it reflects what the page actually shows.

Moving the blocks below the header is not an option, because the header lives inside the
content grid and blocks render full-width outside it.

I documented the behaviour in the single.html comment. A fixture cannot live in
tests/templates: hinode's page/blocks.html is a placeholder, so a page with
content_blocks renders nothing there. It can live in the example site, but only becomes
meaningful once the mod-blocks dependency is bumped past gethinode/mod-blocks#193 — I can
add a single page with both blocks and body content in that bump.

3. heading-class has no "no classes" value

Fixed as suggested. The partial now checks isset, so an empty heading-class means "no
extra classes" and leaves the element on its own styling. The list page header passes the
empty string, which returns it to the exact markup it rendered before this branch, and
section-title-lead is gone — both the class and the SCSS rule that restated the h1
margins.

4. No tests

Added to tests/templates. The harness now mounts assets/section-title.html, its
structure file, and the vendored mod-utils partials and argument data it initializes
through. Twelve cases assert the opening tag of the title element:

  • nil level keeps the div; explicit level 0 keeps the div
  • use-title maps onto level 1, with unchanged classes under both heading styles
  • an explicit heading-level beats use-title
  • levels 2 and 6 render their tag with the margin reset; the fs style adds the weight
    and line-height reset
  • level 9 and level −1 warn and fall back to the div
  • an empty heading-class yields no extra classes; a non-empty one replaces the reset

Nits

  • heading-class at level 0: the structure comment now states the classes apply only when
    a heading level renders one.
  • Deprecating use-title has to wait for mod-blocks. The argument validator warns whenever
    a caller provides a value, and released mod-blocks always does: contact.html passes a
    literal false, and hero.html forwards its own defaulted false. Marking it deprecated
    today would warn on every page with a hero or contact block. Once mod-blocks stops passing
    the argument, hinode can mark it deprecated.

Verification of the fixes

Rebuilt the example site before and after the review fixes. After normalizing the style
sheet fingerprint, exactly six files differ — the six list pages whose h1 loses
section-title-lead — and the style sheet itself loses that one rule. pnpm lint and
pnpm run test:templates pass.

@markdumay

Copy link
Copy Markdown
Collaborator

Re-verified bbfb989e against the merge-base. All four points check out.

Point 1. $levelled correctly scopes the reset. Probing the combinations directly:

args rendered
use-title: true <h1 class="display-4 text-body pt-1">
use-title: true, heading-style: fs <h1 class="fs-4 text-body pt-1">
heading-level: 1 <h1 class="display-4 text-body pt-1 mt-0 mb-0">
heading-level: 2, heading-style: fs <h2 class="fs-4 text-body pt-1 mt-0 mb-0 fw-normal lh-base">

The first two rows are the pre-branch markup exactly.

Point 3. Worth noting the part that could have gone wrong quietly: isset only works here because InitArgs drops nil but keeps an explicitly passed empty string. Had "" been dropped, the list page header would have silently picked up mt-0 mb-0 — the same regression, relocated. It doesn't; heading-class: "" yields no extra classes.

Point 4. The assertions bite. Mutating {{- else if $levelled -}} back to {{- else -}} — reintroducing the exact bug — makes test:templates exit 1 with two section-title mismatch failures. I also checked the new _vendor mounts against CI, since _vendor is gitignored and the reusable workflow has no vendoring step: the build command starts with build:cache, whose prebuild runs mod:vendor, so the ordering holds. (One papercut: a bare pnpm test on a fresh clone fails with partial "utilities/InitArgs.html" not found, because test doesn't chain prebuild. Not worth blocking on, but a contributor hitting it won't guess the cause quickly.)

Net effect. Comparing heading markup across all 122 example-site pages against the merge-base, the only page that differs is en/table-demo, and that comes from the unrelated feat(table) commit this branch sits on. So against main the rendered change is now nil, not the six files you measured — you were comparing to the pre-fix state of this branch.

Point 2. Your reasoning is right and I withdraw the objection. DOM order tracking visual order is the correct priority; putting the h1 first would break the correspondence between reading order and what the page shows, which is the worse outcome. I confirmed the fixture constraint too — page/blocks.html is a comment-only placeholder in this repo, so a content_blocks page renders nothing under tests/templates. Adding the example-site page at the mod-blocks bump is the right place for it.

The deprecation note also holds: released mod-blocks always supplies the argument (contact.html passes a literal false, hero.html forwards its defaulted one, and hero.yml declares it), so deprecating today would warn on every page carrying a hero or contact block.

Nothing further from me.

@markdumay
markdumay enabled auto-merge August 4, 2026 10:08
@markdumay
markdumay merged commit 4cdccc0 into gethinode:main Aug 4, 2026
17 checks passed
@markdumay

Copy link
Copy Markdown
Collaborator

🎉 This PR is included in version 3.19.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants