diff --git a/data/structures/section-title.yml b/data/structures/section-title.yml index 6d1ef5e6..2140613a 100644 --- a/data/structures/section-title.yml +++ b/data/structures/section-title.yml @@ -12,6 +12,24 @@ 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, 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 6af5f2ec..a331d199 100644 --- a/layouts/_partials/assets/section-title.html +++ b/layouts/_partials/assets/section-title.html @@ -43,32 +43,67 @@ {{- 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 -}} +{{- $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" + "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 -}} + +{{/* 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) }} +{{/* 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 53162982..ff4a4d37 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 @@ -24,8 +26,9 @@ "content" .Description "align" "start" ) - "use-title" true - "action" $sectionAction + "heading-level" 1 + "heading-class" "" + "action" $sectionAction ) }} {{/* Init the card styling */}} diff --git a/layouts/docs/all.html b/layouts/docs/all.html index 8aa88dd0..dea61cc1 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 da008800..d62151ba 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 8888a21e..c985d4fe 100644 --- a/layouts/single.html +++ b/layouts/single.html @@ -5,7 +5,15 @@ {{/* 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, 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) + ) -}} {{/* Render the single page content using responsive columns */}} {{/* Column 1: sidebar navigation, visible in medium viewports and above (uses offcanvas on small devices) */}} diff --git a/tests/templates/hugo.toml b/tests/templates/hugo.toml index 014ff081..8c4ee1eb 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 b19d7369..1a9ed234 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 }}