From f7ed46d55cafb23526249ccc0b4d6c14f73c27ae Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Mon, 3 Aug 2026 13:56:51 +0200 Subject: [PATCH 1/3] feat(a11y): add a heading level to section titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- assets/scss/layouts/_type.scss | 7 +++ data/structures/section-title.yml | 16 ++++++ layouts/_partials/assets/section-title.html | 60 +++++++++++++++------ layouts/_partials/page/articles.html | 5 +- 4 files changed, 70 insertions(+), 18 deletions(-) diff --git a/assets/scss/layouts/_type.scss b/assets/scss/layouts/_type.scss index ba3bf565c..cf6d1a300 100644 --- a/assets/scss/layouts/_type.scss +++ b/assets/scss/layouts/_type.scss @@ -34,3 +34,10 @@ h6 { .display-1, .display-2, .display-3, .display-4, .display-5, .display-6 { scroll-margin-top: var(--navbar-offset); } + +// Section titles reset their element margins so a heading occupies the same box as the div it +// replaces. A list page's own title opts back in to the spacing the h1 rule above provides. +.section-title-lead { + margin-top: $spacer * 2.5; + margin-bottom: $spacer * 0.5; +} diff --git a/data/structures/section-title.yml b/data/structures/section-title.yml index 6d1ef5e6c..43787b5ed 100644 --- a/data/structures/section-title.yml +++ b/data/structures/section-title.yml @@ -12,6 +12,22 @@ arguments: class: size: use-title: + heading-level: + type: int + optional: true + release: v3.19.0 + comment: >- + Heading level of the title, from 1 (h1) to 6 (h6). Defaults to 0, which + renders a div instead of a heading. Content blocks receive a level from + the page that renders them, so the first titled block of a page without a + page header becomes its h1. + heading-class: + type: string + optional: true + release: v3.19.0 + comment: >- + Classes for the heading element. Replaces the margin reset that otherwise + keeps a heading aligned with the div it succeeds. justify: link-type: use-section: diff --git a/layouts/_partials/assets/section-title.html b/layouts/_partials/assets/section-title.html index 6af5f2ec1..416649abf 100644 --- a/layouts/_partials/assets/section-title.html +++ b/layouts/_partials/assets/section-title.html @@ -43,32 +43,60 @@ {{- if and (not $preheading) $args.useSection }}{{ $preheading = $page.CurrentSection.Name }}{{ end -}} {{- $justify := cond (eq $args.justify "start") "" (cond (eq $args.justify "end") "me-0" "mx-auto") -}} +{{/* Read the heading level. An explicit heading-level wins, the legacy use-title boolean maps onto + level 1, and a caller that asks for neither keeps the div this partial has always rendered. */}} +{{- $level := $args.headingLevel -}} +{{- if eq $level nil }}{{ $level = cond $args.useTitle 1 0 }}{{ end -}} +{{- if or (lt $level 0) (gt $level 6) -}} + {{- partial "utilities/LogWarn.html" (dict + "partial" "assets/section-title.html" + "warnid" "warn-invalid-heading-level" + "msg" (printf "Heading level %d is out of range, falling back to a div" $level) + "details" (slice "Use a level between 1 and 6, or 0 to render a div") + "file" $page.File + ) -}} + {{- $level = 0 -}} +{{- end -}} + +{{/* A heading carries margins that the div it replaces does not, and with the "fs" heading style + it also picks up Bootstrap's heading weight and line height. Reset those so the element + occupies the same box at every level, unless the caller supplies its own classes. */}} +{{- $reset := "mt-0 mb-0" -}} +{{- if eq $headingStyle "fs" }}{{ $reset = printf "%s fw-normal lh-base" $reset }}{{ end -}} +{{- $headingClass := $args.headingClass | default $reset -}} + {{ $preheading = partial "utilities/TitleCase.html" (dict "page" $page "text" $preheading) }} {{ $title = partial "utilities/TitleCase.html" (dict "page" $page "text" $title) }} +{{/* Composes the title element. The tag varies with the heading level, and Go's template engine + rejects a dynamic element name, so the markup is assembled as a string and marked safe. */}} {{ define "_partials/assets/section-title-header.html" }} - {{- $page := .page }} - {{ $headingStyle := .headingStyle }} - - {{ if (index . "use-title") }} - {{ $title := .title | $page.RenderString }} - {{ $label := trim (replaceRE "\r\n?|\n" " " ($title | plainify)) " " }} -

- {{ .title | $page.RenderString | safeHTML }} -

- {{ else }} -
- {{ .title | $page.RenderString | safeHTML }} -
- {{ end }} + {{- $page := .page -}} + {{- $level := .level -}} + {{- $tag := cond (eq $level 0) "div" (printf "h%d" $level) -}} + + {{- $title := .title | $page.RenderString -}} + {{- $label := trim (replaceRE "\r\n?|\n" " " ($title | plainify)) " " -}} + + {{- $class := printf "%s-%v" .headingStyle .size -}} + {{- with .color }}{{ $class = printf "%s text-%s" $class . }}{{ end -}} + {{- $class = printf "%s pt-1" $class -}} + {{- if ne $level 0 }}{{ with .headingClass }}{{ $class = printf "%s %s" $class . }}{{ end }}{{ end -}} + + {{- $attrs := printf `id="%s" class="%s"` (anchorize .title) $class -}} + {{- if and (ne $level 0) (ne $title $label) -}} + {{- $attrs = printf `id="%s" aria-label="%s" class="%s"` (anchorize .title) (htmlEscape $label) $class -}} + {{- end -}} + + {{- printf "<%s %s>\n%s\n" $tag $attrs $title $tag | safeHTML -}} {{ end }} {{ $header := "" }} {{ if $title }} {{ $header = partial "assets/section-title-header.html" (dict "page" $page - "use-title" $args.useTitle + "level" $level + "headingClass" $headingClass "title" $title "headingStyle" $headingStyle "color" $args.color diff --git a/layouts/_partials/page/articles.html b/layouts/_partials/page/articles.html index 53162982f..944477921 100644 --- a/layouts/_partials/page/articles.html +++ b/layouts/_partials/page/articles.html @@ -24,8 +24,9 @@ "content" .Description "align" "start" ) - "use-title" true - "action" $sectionAction + "heading-level" 1 + "heading-class" "section-title-lead" + "action" $sectionAction ) }} {{/* Init the card styling */}} From bb5635d42f3570857898267958686647681ff5f0 Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Mon, 3 Aug 2026 13:57:17 +0200 Subject: [PATCH 2/3] feat(a11y): let content blocks own the page heading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- layouts/docs/all.html | 3 ++- layouts/list.html | 3 ++- layouts/single.html | 8 +++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/layouts/docs/all.html b/layouts/docs/all.html index 8aa88dd0e..dea61cc1b 100644 --- a/layouts/docs/all.html +++ b/layouts/docs/all.html @@ -23,7 +23,8 @@
{{/* Render the defined content blocks, using the default articles element as fallback for list pages */}} {{ if .Params.content_blocks }} - {{- partial "page/blocks.html" (dict "page" . "embed" true) -}} + {{/* Blocks replace the page header here, so they own the h1 */}} + {{- partial "page/blocks.html" (dict "page" . "embed" true "heading-level" 1) -}} {{ else if eq .Kind "section" }} {{ $.Scratch.Set "articlesParams" (dict "sort" "weight" diff --git a/layouts/list.html b/layouts/list.html index da0088008..d62151bab 100644 --- a/layouts/list.html +++ b/layouts/list.html @@ -5,7 +5,8 @@ {{ if .Params.content_blocks }} - {{- partial "page/blocks.html" . -}} + {{/* Blocks replace the page header here, so they own the h1 */}} + {{- partial "page/blocks.html" (dict "page" . "heading-level" 1) -}} {{ else }} {{ with partial "page/articles.html" . }}
diff --git a/layouts/single.html b/layouts/single.html index 8888a21ef..3c3c26865 100644 --- a/layouts/single.html +++ b/layouts/single.html @@ -5,7 +5,13 @@ {{/* Render the offcanvas sidebar and content blocks */}} {{- partial "page/sidebar-offcanvas.html" (dict "section" $.Section "raw" $sidebar) -}} - {{- partial "page/blocks.html" . -}} + + {{/* The header below renders only for a page that carries content, and it owns the h1 when it + does. Tell the blocks which level to start at so the page has exactly one. */}} + {{- partial "page/blocks.html" (dict + "page" . + "heading-level" (cond (gt (len .RawContent) 0) 2 1) + ) -}} {{/* Render the single page content using responsive columns */}} {{/* Column 1: sidebar navigation, visible in medium viewports and above (uses offcanvas on small devices) */}} From bbfb989eff806168f24725e06e840be4dd0b95a7 Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Tue, 4 Aug 2026 10:16:48 +0200 Subject: [PATCH 3/3] fix(components): scope the section title reset to heading-level callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- assets/scss/layouts/_type.scss | 7 -- data/structures/section-title.yml | 6 +- layouts/_partials/assets/section-title.html | 21 ++++-- layouts/_partials/page/articles.html | 4 +- layouts/single.html | 4 +- tests/templates/hugo.toml | 18 ++++++ tests/templates/layouts/index.html | 71 +++++++++++++++++++++ 7 files changed, 113 insertions(+), 18 deletions(-) diff --git a/assets/scss/layouts/_type.scss b/assets/scss/layouts/_type.scss index cf6d1a300..ba3bf565c 100644 --- a/assets/scss/layouts/_type.scss +++ b/assets/scss/layouts/_type.scss @@ -34,10 +34,3 @@ h6 { .display-1, .display-2, .display-3, .display-4, .display-5, .display-6 { scroll-margin-top: var(--navbar-offset); } - -// Section titles reset their element margins so a heading occupies the same box as the div it -// replaces. A list page's own title opts back in to the spacing the h1 rule above provides. -.section-title-lead { - margin-top: $spacer * 2.5; - margin-bottom: $spacer * 0.5; -} diff --git a/data/structures/section-title.yml b/data/structures/section-title.yml index 43787b5ed..2140613aa 100644 --- a/data/structures/section-title.yml +++ b/data/structures/section-title.yml @@ -26,8 +26,10 @@ arguments: optional: true release: v3.19.0 comment: >- - Classes for the heading element. Replaces the margin reset that otherwise - keeps a heading aligned with the div it succeeds. + Classes for the heading element, applied only when a heading level renders + one. Replaces the margin reset that otherwise keeps a title raised by + heading-level aligned with the div it succeeds; pass an empty string to + leave the element on its own styling. justify: link-type: use-section: diff --git a/layouts/_partials/assets/section-title.html b/layouts/_partials/assets/section-title.html index 416649abf..a331d1990 100644 --- a/layouts/_partials/assets/section-title.html +++ b/layouts/_partials/assets/section-title.html @@ -46,7 +46,8 @@ {{/* Read the heading level. An explicit heading-level wins, the legacy use-title boolean maps onto level 1, and a caller that asks for neither keeps the div this partial has always rendered. */}} {{- $level := $args.headingLevel -}} -{{- if eq $level nil }}{{ $level = cond $args.useTitle 1 0 }}{{ end -}} +{{- $levelled := ne $level nil -}} +{{- if not $levelled }}{{ $level = cond $args.useTitle 1 0 }}{{ end -}} {{- if or (lt $level 0) (gt $level 6) -}} {{- partial "utilities/LogWarn.html" (dict "partial" "assets/section-title.html" @@ -58,12 +59,18 @@ {{- $level = 0 -}} {{- end -}} -{{/* A heading carries margins that the div it replaces does not, and with the "fs" heading style - it also picks up Bootstrap's heading weight and line height. Reset those so the element - occupies the same box at every level, unless the caller supplies its own classes. */}} -{{- $reset := "mt-0 mb-0" -}} -{{- if eq $headingStyle "fs" }}{{ $reset = printf "%s fw-normal lh-base" $reset }}{{ end -}} -{{- $headingClass := $args.headingClass | default $reset -}} +{{/* Classes for the heading element. A caller-supplied heading-class always wins, including an + empty string, which leaves the element on its own styling. Without one, a heading-level title + resets the margins that a heading carries and the div it replaces does not, plus the weight + and line height that the "fs" heading style leaves to Bootstrap; a use-title title keeps the + styling the h1 has always had. */}} +{{- $headingClass := "" -}} +{{- if isset $args "headingClass" -}} + {{- $headingClass = $args.headingClass -}} +{{- else if $levelled -}} + {{- $headingClass = "mt-0 mb-0" -}} + {{- if eq $headingStyle "fs" }}{{ $headingClass = printf "%s fw-normal lh-base" $headingClass }}{{ end -}} +{{- end -}} {{ $preheading = partial "utilities/TitleCase.html" (dict "page" $page "text" $preheading) }} {{ $title = partial "utilities/TitleCase.html" (dict "page" $page "text" $title) }} diff --git a/layouts/_partials/page/articles.html b/layouts/_partials/page/articles.html index 944477921..ff4a4d37a 100644 --- a/layouts/_partials/page/articles.html +++ b/layouts/_partials/page/articles.html @@ -17,6 +17,8 @@ {{ if .Site.Params.navigation.breadcrumb }} {{ partial "assets/breadcrumb.html" (dict "page" .) }} {{ end }} +{{/* The empty heading-class keeps this title on the h1 element's own spacing, rather than the + reset that aligns a promoted block title with the div it replaces. */}} {{ partial "assets/section-title.html" (dict "page" . "heading" (dict @@ -25,7 +27,7 @@ "align" "start" ) "heading-level" 1 - "heading-class" "section-title-lead" + "heading-class" "" "action" $sectionAction ) }} diff --git a/layouts/single.html b/layouts/single.html index 3c3c26865..c985d4fe4 100644 --- a/layouts/single.html +++ b/layouts/single.html @@ -7,7 +7,9 @@ {{- partial "page/sidebar-offcanvas.html" (dict "section" $.Section "raw" $sidebar) -}} {{/* The header below renders only for a page that carries content, and it owns the h1 when it - does. Tell the blocks which level to start at so the page has exactly one. */}} + does, so blocks start at 2 there and at 1 otherwise. Blocks render ahead of that header, + so a page carrying both leads with its block titles at level 2 and reaches its h1 below + them. */}} {{- partial "page/blocks.html" (dict "page" . "heading-level" (cond (gt (len .RawContent) 0) 2 1) diff --git a/tests/templates/hugo.toml b/tests/templates/hugo.toml index 014ff081d..8c4ee1ebb 100644 --- a/tests/templates/hugo.toml +++ b/tests/templates/hugo.toml @@ -36,3 +36,21 @@ disableKinds = ['taxonomy', 'term', 'RSS', 'sitemap', 'robotsTXT', '404'] [[module.mounts]] source = '../../layouts/_partials/utilities/GetIncludeTOC.html' target = 'layouts/_partials/utilities/GetIncludeTOC.html' + +# assets/section-title.html initializes its arguments through mod-utils (InitArgs and the +# structure data it reads), so those come from the vendored module — `mod:vendor` populates +# `_vendor` and CI runs it before `test:templates`. The two `data/structures` mounts merge: +# the theme file provides the section-title structure, the module directory provides the +# global argument and type definitions it inherits from. +[[module.mounts]] + source = '../../layouts/_partials/assets/section-title.html' + target = 'layouts/_partials/assets/section-title.html' +[[module.mounts]] + source = '../../_vendor/github.com/gethinode/mod-utils/v6/layouts/_partials/utilities' + target = 'layouts/_partials/utilities' +[[module.mounts]] + source = '../../data/structures/section-title.yml' + target = 'data/structures/section-title.yml' +[[module.mounts]] + source = '../../_vendor/github.com/gethinode/mod-utils/v6/data/structures' + target = 'data/structures' diff --git a/tests/templates/layouts/index.html b/tests/templates/layouts/index.html index b19d7369e..1a9ed2344 100644 --- a/tests/templates/layouts/index.html +++ b/tests/templates/layouts/index.html @@ -156,3 +156,74 @@ {{- end }} TOC FAILURES: {{ $tocFail }} + +{{- /* + Assertions for assets/section-title.html. + + Each case renders the partial and compares the opening tag of the title element — + matched by its id attribute, which the surrounding wrapper div lacks. The harness + leaves site.Params.style unset, so every case builds on the partial's own defaults: + heading style "display", size 4, color "body". + + The cases pin the level resolution (nil keeps the div, use-title maps onto level 1, + an explicit heading-level beats use-title, out-of-range falls back to the div), the + tag composition per level, and the class handling: a heading-level title receives the + margin reset — plus the weight and line-height reset under the "fs" heading style — + while a use-title title keeps the classes the h1 has always had, and a caller-supplied + heading-class always wins, including the empty string. +*/ -}} +{{- $stFail := 0 -}} +{{- $stPage := site.GetPage "/blog/without-exact" -}} +{{- $stCases := slice + (dict "case" "nil level keeps the div" + "args" (dict "heading" (dict "title" "Probe Nil")) + "want" `
`) + (dict "case" "use-title maps onto level 1 with unchanged classes" + "args" (dict "heading" (dict "title" "Probe Legacy") "use-title" true) + "want" `

`) + (dict "case" "use-title with fs style keeps its weight and line height" + "args" (dict "heading" (dict "title" "Probe Legacy Fs") "use-title" true "heading-style" "fs") + "want" `

`) + (dict "case" "heading-level renders its tag with the margin reset" + "args" (dict "heading" (dict "title" "Probe Level Two") "heading-level" 2) + "want" `

`) + (dict "case" "heading-level six is the last valid level" + "args" (dict "heading" (dict "title" "Probe Level Six") "heading-level" 6) + "want" `

`) + (dict "case" "heading-level with fs style also resets weight and line height" + "args" (dict "heading" (dict "title" "Probe Level Fs") "heading-level" 2 "heading-style" "fs") + "want" `

`) + (dict "case" "explicit heading-level beats use-title" + "args" (dict "heading" (dict "title" "Probe Both") "heading-level" 3 "use-title" true) + "want" `

`) + (dict "case" "explicit level zero keeps the div" + "args" (dict "heading" (dict "title" "Probe Zero") "heading-level" 0) + "want" `
`) + (dict "case" "level above six warns and falls back to the div" + "args" (dict "heading" (dict "title" "Probe Nine") "heading-level" 9) + "want" `
`) + (dict "case" "negative level warns and falls back to the div" + "args" (dict "heading" (dict "title" "Probe Minus") "heading-level" -1) + "want" `
`) + (dict "case" "empty heading-class leaves the element on its own styling" + "args" (dict "heading" (dict "title" "Probe Bare") "heading-level" 2 "heading-class" "") + "want" `

`) + (dict "case" "heading-class replaces the reset" + "args" (dict "heading" (dict "title" "Probe Classy") "heading-level" 2 "heading-class" "my-4") + "want" `

`) +-}} +{{- range $stCases -}} +{{- $stGot := partial "assets/section-title.html" (merge (dict "page" $stPage) .args) -}} +{{- $stEl := index (findRE `<(?:h[1-6]|div) id="[^>]*>` $stGot 1) 0 -}} +{{- if eq $stEl .want }} +PASS {{ .case }} +{{- else }} +FAIL {{ .case }} + want={{ .want }} + got ={{ $stEl }} +{{- $stFail = add $stFail 1 -}} +{{- errorf "section-title mismatch: case=%q want=%q got=%q" .case .want $stEl -}} +{{- end -}} +{{- end }} + +SECTION TITLE FAILURES: {{ $stFail }}