From d2dcf2e801ffc48114e446735e003d81d0ee5624 Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:49:46 +0200 Subject: [PATCH 1/2] fix(link): resolve a link to the site root as the site root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stripping the base URL's path from the destination leaves nothing behind for a link to the site's own home, and `path.Clean` renders that empty string as ".". Only the empty form was caught by the emptiness guard, so "." survived as a relative page reference meaning "the page being rendered" and the lookup that follows resolved it to the caller. The sidebar is where this surfaces. Its markup is cached per section against the section root, so a menu entry linking to "/" rendered as a link to the section root, then matched the current page in the client-side active pass and took the highlight from the entry that owns it. Add assertions for assets/link.html to the template test site, covering the root case plus the absolute, section and anchored links either side of it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) --- layouts/_partials/assets/link.html | 15 ++++++++- tests/templates/hugo.toml | 11 +++++++ tests/templates/layouts/index.html | 53 ++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/layouts/_partials/assets/link.html b/layouts/_partials/assets/link.html index 8860b99f..3ccd3081 100644 --- a/layouts/_partials/assets/link.html +++ b/layouts/_partials/assets/link.html @@ -51,7 +51,20 @@ {{- else -}} {{- $destination = (strings.TrimPrefix (strings.TrimSuffix "/" site.BaseURL) $destination) -}} {{- end -}} - {{- if not $destination }}{{ $destination = "/" }}{{ end -}} + {{/* A destination that normalizes away is the site root. + + Stripping the base URL's path leaves nothing behind for a link to the site's own + home - "/" on a site rooted at "/", "/docs/" on one rooted at "/docs/" - and + `path.Clean` renders that empty string as ".". Both mean the same place, but only + the empty one is caught by an emptiness test: "." survives as a *relative* page + reference meaning "the page being rendered", so the lookup below resolves it to + the caller and the link quietly points at whatever page happened to render it. + + The sidebar is where that surfaces: its markup is cached per section against the + section root, so a menu entry linking to "/" comes out as a link to the section + root, then matches the current page in the client-side active pass and takes the + highlight from the entry that owns it. */}} + {{- if or (not $destination) (eq $destination ".") }}{{ $destination = "/" }}{{ end -}} {{/* cue and tab carry no schema default, so an absent value is nil and an explicit false is a real choice. "default" treats false as empty and would diff --git a/tests/templates/hugo.toml b/tests/templates/hugo.toml index 8c4ee1eb..7ea08248 100644 --- a/tests/templates/hugo.toml +++ b/tests/templates/hugo.toml @@ -54,3 +54,14 @@ disableKinds = ['taxonomy', 'term', 'RSS', 'sitemap', 'robotsTXT', '404'] [[module.mounts]] source = '../../_vendor/github.com/gethinode/mod-utils/v6/data/structures' target = 'data/structures' + +# assets/link.html resolves its destination against the site and the calling page, so the +# assertions need the real partial and its argument structure. Its icon call site is only +# reached for external links with a cue, which none of the cases exercise, so no icon +# partial is mounted. +[[module.mounts]] + source = '../../layouts/_partials/assets/link.html' + target = 'layouts/_partials/assets/link.html' +[[module.mounts]] + source = '../../data/structures/link.yml' + target = 'data/structures/link.yml' diff --git a/tests/templates/layouts/index.html b/tests/templates/layouts/index.html index 1a9ed234..fc173b3c 100644 --- a/tests/templates/layouts/index.html +++ b/tests/templates/layouts/index.html @@ -227,3 +227,56 @@ {{- end }} SECTION TITLE FAILURES: {{ $stFail }} + +{{- /* + Assertions for assets/link.html. + + Every case renders the partial from a page that is not the site home and compares the + emitted href, because the destination is normalized against both the site's base URL + and the calling page - and those two are what a link to the site root falls between. + + The root case is the one with teeth. Normalization strips the base URL's path from the + destination, so on a site rooted at "/" the string "/" is left empty, and `path.Clean` + turns an empty path into ".". A "." is a valid relative page reference meaning *this + page*, so the lookup that follows resolves it to whichever page is rendering and the + link silently points at the caller instead of the home page. The sidebar makes that + visible: its markup is cached per section against the section root, so a menu entry + linking to "/" renders as a link to the section root, matches the current page in the + active-item pass, and steals the highlight from the entry that should own it. + + The remaining cases hold the paths either side of it: a normal absolute link, a link + to a section, and an anchored one all still resolve to their own targets. The anchored + case keeps no trailing slash because the harness leaves main.internalLinks.pretty + unset, which is the partial's documented behaviour rather than an artefact. +*/ -}} +{{- $linkFail := 0 -}} +{{- $linkPage := site.GetPage "/blog/without-exact" -}} +{{- $linkCases := slice + (dict "case" "site root resolves to the site root, not the calling page" + "args" (dict "href" "/" "text" "Home") + "want" `href="/"`) + (dict "case" "absolute link to another page is left alone" + "args" (dict "href" "/blog/toc-override/" "text" "Toc") + "want" `href="/blog/toc-override/"`) + (dict "case" "absolute link to a section is left alone" + "args" (dict "href" "/blog/" "text" "Blog") + "want" `href="/blog/"`) + (dict "case" "anchor on an absolute link is preserved" + "args" (dict "href" "/blog/toc-override/#intro" "text" "Intro") + "want" `href="/blog/toc-override#intro"`) +-}} +{{- range $linkCases -}} +{{- $linkGot := partial "assets/link.html" (merge (dict "page" $linkPage "exact" true) .args) -}} +{{- $linkHref := index (findRE `href="[^"]*"` $linkGot 1) 0 -}} +{{- if eq $linkHref .want }} +PASS {{ .case }} +{{- else }} +FAIL {{ .case }} + want={{ .want }} + got ={{ $linkHref }} +{{- $linkFail = add $linkFail 1 -}} +{{- errorf "link mismatch: case=%q want=%q got=%q" .case .want $linkHref -}} +{{- end -}} +{{- end }} + +LINK FAILURES: {{ $linkFail }} From e40e261804d641fbde914d293576f497afe231bf Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:50:19 +0200 Subject: [PATCH 2/2] fix(sidebar): align labels of entries that carry no icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A menu is free to mix entries that lead with an icon and entries that do not, and the ones without then started their label at the padding edge while their neighbours started an icon-width further right, breaking the column of labels. Resolve per sibling list whether anything in it carries an icon, and mark the rows that do not with `sidebar-item-icon-indent`, which reserves the same column. Scoped to the list rather than the whole sidebar: a nested list of icon-less entries is aligned already, and reserving a column there would indent it away from the label it hangs under - the shape of every menu that puts icons on its top level only. The class comes from the template rather than a CSS `:has()` derivation, for the reason already documented for `sidebar-nav-icon-indent`: the collapse trail is expanded client side, and a selector re-resolved as a subtree becomes visible can paint the wrong value first. Add assertions covering a mixed list, a list without icons, a list where every row has one, and nested lists in both directions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) --- assets/scss/components/_sidebar.scss | 32 ++++++ layouts/_partials/assets/sidebar.html | 137 ++++++++++++++++++-------- tests/templates/hugo.toml | 23 +++++ tests/templates/layouts/index.html | 108 ++++++++++++++++++++ 4 files changed, 257 insertions(+), 43 deletions(-) diff --git a/assets/scss/components/_sidebar.scss b/assets/scss/components/_sidebar.scss index 5e5863a6..7e5da3db 100644 --- a/assets/scss/components/_sidebar.scss +++ b/assets/scss/components/_sidebar.scss @@ -181,6 +181,38 @@ $sidebar-item-icon-column: calc(1.25em + #{$spacer * 0.25}); } } +// Hold the icon column open on rows that have no icon. +// +// A menu is free to mix the two - a "Home" entry among icon-bearing sections is +// the common case - and the row without an icon then starts its label at the +// padding edge while every neighbour starts one icon-width further right. The +// labels no longer form a column, and the odd row reads as misplaced rather +// than as merely unadorned. +// +// So such a row reserves the same column instead. `assets/sidebar.html` sets +// the class on a row that has no icon in a list where something else does, so +// the reservation follows the list it has to line up with: a list that uses no +// icons at all is untouched, and neither is a nested list of its own. +// +// This is the width alone - no `text-indent` to go with it, unlike the hanging +// indent above. There is no icon to pull the first line back past, and the +// wrapped lines of a row whose text starts at the padding edge already line up +// underneath it. Applies in both the collapsible and the plain sidebar, since +// what it corrects is the resting layout the two share; in icon-only mode the +// row has nothing left to show and the reserved column costs nothing. +// +// `!important` for the same reason the block above needs it: the base +// `.sidebar-item` padding carries one. +.sidebar-item.sidebar-item-icon-indent { + padding-left: calc(0.5rem + #{$sidebar-item-icon-column}) !important; +} + +// Group headers again sit on the wrapper's padding, so they reserve the column +// alone. They are not `.sidebar-item`, so this needs its own selector. +.sidebar-item-group > div > a.sidebar-item-icon-indent { + padding-left: $sidebar-item-icon-column; +} + .btn-toggle-group { padding: 0.25rem 0.5rem; font-weight: 600; diff --git a/layouts/_partials/assets/sidebar.html b/layouts/_partials/assets/sidebar.html index 7fb29a66..b1917cad 100644 --- a/layouts/_partials/assets/sidebar.html +++ b/layouts/_partials/assets/sidebar.html @@ -79,11 +79,34 @@ {{- $args = merge $args (dict "menu" $menu) -}} {{- end -}} +{{/* + Does any entry in this list carry a leading icon? + + Icons are per entry, but alignment is a property of the list: as soon as one row leads + with one, every other row's label in that list starts an icon-width further left and the + column of labels breaks. The rows that need to reserve that column are therefore the ones + without an icon in a list that has them, which only the list knows - hence a single + lookup per sibling list, passed down to the rows as `iconColumn`. + + Scoped to the list rather than the whole sidebar on purpose: a nested list whose entries + are all icon-less is aligned already, and reserving a column there would indent it away + from the label it hangs under, changing every existing site that renders icons only on + its top level. +*/ -}} +{{- define "_partials/inline/sidebar/has-icons.html" -}} + {{- $found := false -}} + {{- range . -}} + {{- if .pre }}{{ $found = true }}{{ end -}} + {{- end -}} + {{- return $found -}} +{{- end -}} + {{- define "_partials/inline/sidebar/group-link.html" -}} {{- $page := .page -}} {{- $group := .group -}} {{- $baseURL := .baseURL -}} {{- $pre := $group.pre -}} + {{- $iconColumn := .iconColumn | default false -}} {{- $collapsible := .collapsible | default false -}} {{- $href := $group.link | default "" -}} {{- if and $href (not (hasPrefix $href "/")) -}} @@ -92,8 +115,10 @@ {{- if and $href (not (hasSuffix $href "/")) -}}{{- $href = printf "%s/" $href -}}{{- end -}} {{- $groupTitle := $group.title -}} {{- $groupTitle = partial "utilities/TitleCase.html" (dict "page" $page "text" $groupTitle) -}} + {{- $class := "sidebar-item text-decoration-none rounded w-100" -}} + {{- if and $iconColumn (not $pre) -}}{{- $class = printf "%s sidebar-item-icon-indent" $class -}}{{- end -}}
  • - {{- with $pre -}} @@ -120,8 +145,12 @@ {{- $group := .group -}} {{- $data := .menu -}} {{- $pre := $group.pre -}} + {{- $iconColumn := .iconColumn | default false -}} {{- $filename := .filename -}} + {{- /* Resolved for this group's own children, who are a list of their own */ -}} + {{- $childIconColumn := partial "inline/sidebar/has-icons.html" $group.pages -}} + {{- $doc_slug := partial "utilities/URLJoin.html" (dict "base" $baseURL "path" ($group.title | urlize)) -}} {{- $href := or $group.link $doc_slug -}} {{- /* Handle absolute paths from hierarchical menu (starting with /) */ -}} @@ -146,7 +175,12 @@ {{ if not $ref }} {{ $dest = "" }} {{ end }} - @@ -183,14 +217,15 @@ nested collapse element IDs stay unique across sibling branches at the same depth. */}} {{ partial "inline/sidebar/group.html" (dict - "page" $page - "index" (printf "%v-%v" $index $childIndex) - "level" (add $level 1) - "baseURL" $href - "group" $item - "menu" $data - "pre" $item.pre - "filename" $filename + "page" $page + "index" (printf "%v-%v" $index $childIndex) + "level" (add $level 1) + "baseURL" $href + "group" $item + "menu" $data + "pre" $item.pre + "iconColumn" $childIconColumn + "filename" $filename ) }} {{- else -}} @@ -202,6 +237,7 @@ "href" $item.link "menu" $data "pre" $item.pre + "iconColumn" $childIconColumn "filename" $filename ) }} @@ -219,8 +255,10 @@ {{- $title := .title -}} {{- $data := .menu -}} {{- $pre := .pre -}} + {{- $iconColumn := .iconColumn | default false -}} {{- $collapsible := .collapsible | default false -}} {{- $filename := .filename -}} + {{- $iconIndent := cond (and $iconColumn (not $pre)) " sidebar-item-icon-indent" "" -}} {{- $title = partial "utilities/TitleCase.html" (dict "page" $page "text" $title) -}} {{- $titleText := $title -}} {{- if $collapsible -}} @@ -253,7 +291,7 @@