Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions data/structures/section-title.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
67 changes: 51 additions & 16 deletions layouts/_partials/assets/section-title.html
Original file line number Diff line number Diff line change
Expand Up @@ -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)) " " }}
<h1 id="{{ anchorize .title }}" {{ if ne $title $label }}aria-label="{{ $label }}"{{ end -}}
class="{{ $headingStyle }}-{{ .size }}{{ with .color }} text-{{ . }}{{ end }} pt-1">
{{ .title | $page.RenderString | safeHTML }}
</h1>
{{ else }}
<div id="{{ anchorize .title }}" class="{{ $headingStyle }}-{{ .size }}{{ with .color }} text-{{ . }}{{ end }} pt-1">
{{ .title | $page.RenderString | safeHTML }}
</div>
{{ 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</%s>" $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
Expand Down
7 changes: 5 additions & 2 deletions layouts/_partials/page/articles.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,18 @@
{{ 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
"title" .Title
"content" .Description
"align" "start"
)
"use-title" true
"action" $sectionAction
"heading-level" 1
"heading-class" ""
"action" $sectionAction
) }}

{{/* Init the card styling */}}
Expand Down
3 changes: 2 additions & 1 deletion layouts/docs/all.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
<div class="col-12 col-{{ $breakpoint.current }}-9 col-{{ $breakpoint.next }}-8 mb-5">
{{/* 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"
Expand Down
3 changes: 2 additions & 1 deletion layouts/list.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@

<!-- Render the defined content blocks, using the default articles element as fallback -->
{{ 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" . }}
<div class="container-xxl {{ partial "utilities/PaddingClasses.html" (dict "padding" $padding "axis" "y") }} px-xxl-0 {{ partial "utilities/PaddingClasses.html" (dict "padding" $padding "axis" "x") }} py-0 my-auto h-100">
Expand Down
10 changes: 9 additions & 1 deletion layouts/single.html
Original file line number Diff line number Diff line change
Expand Up @@ -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) */}}
Expand Down
18 changes: 18 additions & 0 deletions tests/templates/hugo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
71 changes: 71 additions & 0 deletions tests/templates/layouts/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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" `<div id="probe-nil" class="display-4 text-body pt-1">`)
(dict "case" "use-title maps onto level 1 with unchanged classes"
"args" (dict "heading" (dict "title" "Probe Legacy") "use-title" true)
"want" `<h1 id="probe-legacy" class="display-4 text-body pt-1">`)
(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" `<h1 id="probe-legacy-fs" class="fs-4 text-body pt-1">`)
(dict "case" "heading-level renders its tag with the margin reset"
"args" (dict "heading" (dict "title" "Probe Level Two") "heading-level" 2)
"want" `<h2 id="probe-level-two" class="display-4 text-body pt-1 mt-0 mb-0">`)
(dict "case" "heading-level six is the last valid level"
"args" (dict "heading" (dict "title" "Probe Level Six") "heading-level" 6)
"want" `<h6 id="probe-level-six" class="display-4 text-body pt-1 mt-0 mb-0">`)
(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" `<h2 id="probe-level-fs" class="fs-4 text-body pt-1 mt-0 mb-0 fw-normal lh-base">`)
(dict "case" "explicit heading-level beats use-title"
"args" (dict "heading" (dict "title" "Probe Both") "heading-level" 3 "use-title" true)
"want" `<h3 id="probe-both" class="display-4 text-body pt-1 mt-0 mb-0">`)
(dict "case" "explicit level zero keeps the div"
"args" (dict "heading" (dict "title" "Probe Zero") "heading-level" 0)
"want" `<div id="probe-zero" class="display-4 text-body pt-1">`)
(dict "case" "level above six warns and falls back to the div"
"args" (dict "heading" (dict "title" "Probe Nine") "heading-level" 9)
"want" `<div id="probe-nine" class="display-4 text-body pt-1">`)
(dict "case" "negative level warns and falls back to the div"
"args" (dict "heading" (dict "title" "Probe Minus") "heading-level" -1)
"want" `<div id="probe-minus" class="display-4 text-body pt-1">`)
(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" `<h2 id="probe-bare" class="display-4 text-body pt-1">`)
(dict "case" "heading-class replaces the reset"
"args" (dict "heading" (dict "title" "Probe Classy") "heading-level" 2 "heading-class" "my-4")
"want" `<h2 id="probe-classy" class="display-4 text-body pt-1 my-4">`)
-}}
{{- 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 }}