feat(a11y): give content blocks a heading level - #2116
Conversation
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>
✅ Deploy Preview for gethinode-demo ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
Review notesI verified the behavioural claims by building the example site on this branch and on its merge-base, and by probing What holds up
1.
|
| 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-classis silently ignored when the level is 0; the structure comment doesn't mention it.use-titleisn't marked deprecated indata/structures/section-title.ymldespite 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.
- 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>
|
Fixes for points 1, 3, 4 and the first nit are in bbfb989. 1.
|
| 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-titlemaps onto level 1, with unchanged classes under both heading styles- an explicit
heading-levelbeatsuse-title - levels 2 and 6 render their tag with the margin reset; the
fsstyle adds the weight
and line-height reset - level 9 and level −1 warn and fall back to the div
- an empty
heading-classyields no extra classes; a non-empty one replaces the reset
Nits
heading-classat level 0: the structure comment now states the classes apply only when
a heading level renders one.- Deprecating
use-titlehas to wait for mod-blocks. The argument validator warns whenever
a caller provides a value, and released mod-blocks always does:contact.htmlpasses a
literalfalse, andhero.htmlforwards its own defaultedfalse. 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.
|
Re-verified Point 1.
The first two rows are the pre-branch markup exactly. Point 3. Worth noting the part that could have gone wrong quietly: Point 4. The assertions bite. Mutating Net effect. Comparing heading markup across all 122 example-site pages against the merge-base, the only page that differs is Point 2. Your reasoning is right and I withdraw the objection. DOM order tracking visual order is the correct priority; putting the The deprecation note also holds: released mod-blocks always supplies the argument ( Nothing further from me. |
|
🎉 This PR is included in version 3.19.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
Phase 2 of #1519. Phase 1 (#2091) gave every template-rendered page an
h1and listed sevenpages 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>, andwithout 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. Inthe 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 produceonly 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
h1elements. The titles need a level, and something has todecide 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. Asingle 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.htmltakesheading-level, aninteger from 1 to 6, where 0 keeps the
<div>.use-titlestill maps onto level 1, so existingcallers behave exactly as before, and a caller that passes neither keeps the
<div>this partialhas 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.htmlalready uses for its anchors.The page templates pass the level down.
list.htmlanddocs/all.htmlrender blocks inplace of the header, so their blocks start at 1.
single.htmlrenders its header whenever thepage 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
h1towhichever 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-bookshopshortcode 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 titlebecame a second one. Passing the level as an argument removes the ordering question entirely.
Keeping the layout identical
assets/scss/layouts/_type.scssgives every heading a top margin, and Bootstrap adds a bottommargin, a heavier weight and a tighter line height. A
<div>has none of that, so promoting atitle 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 samebox the
<div>did at every level. Aheading-classargument replaces the reset — an emptyvalue means no extra classes — and a title that arrives through
use-titlekeeps the stylingits
h1has always had. The list page header is the one existing caller that already renderedan
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 pagehas exactly one
h1, no page has two, and no page skips a level. Comparing the two headingoutlines 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 itsposition, 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-levelrow.Template tests. Twelve assertions in
tests/templatespin the partial's behaviour: nilkeeps the
<div>,use-titlemaps onto level 1 with unchanged classes under both headingstyles, an explicit level beats
use-title, out-of-range levels fall back to the<div>, eachlevel renders its tag, and
heading-classreplaces the reset — including the empty value, whichmeans no extra classes.
Build health.
pnpm lintandpnpm testpass. The example site builds with no errors and nonew warnings. The theme also builds standalone, without mod-blocks.
Notes
404.htmlandtags/list.htmlrender no page content, so no section title can appear on them andthey are left alone.
Card titles remain a
<p>. They are the other open item on the issue's checklist and need theirown change, because
assets/card.htmlalso backs thecardshortcode, whose output lands in thepage 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.