From 87c7564cb32f23db1c3f250de5a950306df00509 Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:17:59 +0200 Subject: [PATCH] feat: render the docs sidebar once per section, version, and language The sidebar tree is now rendered once per (section, version, language, variant) via partialCached in the new uncached wrapper page/sidebar-cached.html, used by both call sites (page/sidebar.html and docs/all.html). The wrapper canonicalizes the page context to the (versioned) section root, passes the base URL (mod-utils GetBaseURL) and the error-path filename as explicit arguments, and leaves Mode B (level-min >= 2) uncached. The renderer (assets/sidebar.html) no longer reads per-page state: the server stops emitting the active class and the expanded collapse trail, always renders the canonical collapsed state, and the current-group anchor swap is removed (the group title always renders its link). Sidebar-internal icons render as inline SVG through the per-call mode argument of mod-fontawesome v6.1.0, including the external-link cue threaded through the new icon-mode argument of assets/link.html, so no per-page sprite registration happens inside the cached subtree. Active-item highlighting, the expanded trail (aria-current, aria-expanded), and the current-group toggle UX move to the new critical script sidebar-active.js, applied per sidebar instance before first paint. Without JavaScript every sidebar group renders expanded via a no-js fallback rule; critical/js-detect.js swaps the marker class. Requires mod-fontawesome v6.1.0 and mod-utils v6.6.0; the FontAwesome webfonts snapshot bump (upstream 2026-07-15) rides along with the module update. Co-Authored-By: Claude Fable 5 --- assets/js/critical/js-detect.js | 7 ++ assets/js/critical/sidebar-active.js | 97 ++++++++++++++++ assets/scss/components/_sidebar.scss | 8 ++ data/structures/link.yml | 10 ++ data/structures/sidebar.yml | 18 +++ eslint.config.mjs | 2 +- exampleSite/hinode.work.sum | 4 + layouts/_partials/assets/link.html | 4 +- layouts/_partials/assets/sidebar.html | 125 +++++++++++---------- layouts/_partials/page/sidebar-cached.html | 51 +++++++++ layouts/_partials/page/sidebar.html | 2 +- layouts/docs/all.html | 2 +- 12 files changed, 268 insertions(+), 62 deletions(-) create mode 100644 assets/js/critical/js-detect.js create mode 100644 assets/js/critical/sidebar-active.js create mode 100644 layouts/_partials/page/sidebar-cached.html diff --git a/assets/js/critical/js-detect.js b/assets/js/critical/js-detect.js new file mode 100644 index 000000000..c20a3bc03 --- /dev/null +++ b/assets/js/critical/js-detect.js @@ -0,0 +1,7 @@ +// Swap the `no-js` marker class on the root element for `js` as early as possible, so +// stylesheets can key no-JS fallbacks (such as the sidebar collapse trail) off `no-js`. +(function () { + var el = document.documentElement + el.classList.remove('no-js') + el.classList.add('js') +}()) diff --git a/assets/js/critical/sidebar-active.js b/assets/js/critical/sidebar-active.js new file mode 100644 index 000000000..42129a3e1 --- /dev/null +++ b/assets/js/critical/sidebar-active.js @@ -0,0 +1,97 @@ +// Client-side sidebar active-item highlighting. The sidebar HTML is rendered once per +// (section, version, language, variant) and cached, so the server no longer emits per-page +// `active` classes or an expanded collapse trail; this script applies them from the current +// location. It runs in the critical bundle so the highlight lands before first paint. +(function () { + 'use strict' + + function normalize (path) { + try { path = decodeURI(path) } catch { /* keep the raw path */ } + if (path.length > 1) path = path.replace(/\/+$/, '') + return path || '/' + } + + function findBestMatch (nav, pathname) { + var best = null + var bestLength = -1 + var bestExact = false + nav.querySelectorAll('a[href]').forEach(function (link) { + var href = link.getAttribute('href') + if (!href || href.charAt(0) === '#') return + var url + try { url = new URL(href, window.location.origin) } catch { return } + if (url.origin !== window.location.origin) return + var path = normalize(url.pathname) + if (path === pathname) { + if (!bestExact) { + best = link + bestLength = path.length + bestExact = true + } + } else if (!bestExact && link.getAttribute('data-sidebar-match') === 'prefix') { + var prefix = path === '/' ? '/' : path + '/' + if (pathname.indexOf(prefix) === 0 && path.length > bestLength) { + best = link + bestLength = path.length + } + } + }) + return { link: best, exact: bestExact } + } + + function initNav (nav, pathname) { + var match = findBestMatch(nav, pathname) + var link = match.link + if (!link) return + + nav.classList.add('sidebar-no-transition') + link.classList.add('active') + link.setAttribute('aria-current', match.exact ? 'page' : 'true') + + // Expand the collapse trail: the matched group's own collapse (when the link is a group + // title) plus every collapse ancestor within this nav instance. + var own = null + var group = link.closest('.sidebar-item-group') + if (group && group.parentElement) { + own = group.parentElement.querySelector(':scope > .collapse') + } + var trail = own ? [own] : [] + var ancestor = link.closest('.collapse') + while (ancestor && nav.contains(ancestor)) { + trail.push(ancestor) + ancestor = ancestor.parentElement && ancestor.parentElement.closest('.collapse') + } + trail.forEach(function (el) { + el.classList.add('show') + if (el.id) { + nav.querySelectorAll('[data-bs-target="#' + el.id + '"]').forEach(function (toggle) { + toggle.setAttribute('aria-expanded', 'true') + }) + } + }) + nav.offsetHeight // force reflow before re-enabling transitions + nav.classList.remove('sidebar-no-transition') + + // Clicking the current group's title toggles its collapse instead of re-navigating. + if (own && match.exact) { + link.addEventListener('click', function (event) { + if (typeof bootstrap === 'undefined' || !bootstrap.Collapse) return + event.preventDefault() + bootstrap.Collapse.getOrCreateInstance(own).toggle() + }) + } + } + + function init () { + var pathname = normalize(window.location.pathname) + document.querySelectorAll('[data-sidebar-nav]').forEach(function (nav) { + initNav(nav, pathname) + }) + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init) + } else { + init() + } +}()) diff --git a/assets/scss/components/_sidebar.scss b/assets/scss/components/_sidebar.scss index a846618e3..b303648e2 100644 --- a/assets/scss/components/_sidebar.scss +++ b/assets/scss/components/_sidebar.scss @@ -198,6 +198,14 @@ html.sidebar-pre-collapsed .sidebar-collapsible .sidebar-brand { } } +// No-JS fallback: the collapse trail and active highlight are applied client-side by +// sidebar-active.js, and Bootstrap's collapse toggles require JavaScript. Without JavaScript +// (the `no-js` class set in baseof.html is swapped for `js` by critical/js-detect.js) render +// every sidebar group expanded instead. +.no-js .sidebar .collapse { + display: block; +} + // Collapsible sidebar .sidebar-no-transition, .sidebar-no-transition * { diff --git a/data/structures/link.yml b/data/structures/link.yml index dfe359b60..f29494357 100644 --- a/data/structures/link.yml +++ b/data/structures/link.yml @@ -30,6 +30,16 @@ arguments: optional: true comment: Skip internal link validation (use for dynamic content). release: v2.0.0 + icon-mode: + type: string + optional: true + group: partial + release: v3.3.0 + comment: >- + Per-call rendering mode of the external-link cue icon (one of `symbols`, + `svg`, or `webfonts`), passed through to the icon partial. Cached + contexts such as the sidebar pass `svg` to avoid per-page sprite + registration; defaults to the globally configured icon mode. # deprecated arguments destination: type: string diff --git a/data/structures/sidebar.yml b/data/structures/sidebar.yml index 6844f4dc6..337f6b2bc 100644 --- a/data/structures/sidebar.yml +++ b/data/structures/sidebar.yml @@ -20,6 +20,24 @@ arguments: comment: >- Version of the sidebar navigation, used to define the base URL of generated links, together with the page's section. + baseURL: + type: string + optional: true + group: partial + release: v3.3.0 + comment: >- + Base URL of the site for the current language, used to resolve relative + menu links. Pass it explicitly (see `page/sidebar-cached.html`) so the + renderer performs no per-page Scratch reads; defaults to `/`. + filename: + type: string + optional: true + group: partial + release: v3.3.0 + comment: >- + Path of the data file that defines the sidebar menu, used in error + messages only. Passed explicitly by `page/sidebar-cached.html` so the + renderer performs no per-page Scratch reads. auto-generate: type: bool optional: true diff --git a/eslint.config.mjs b/eslint.config.mjs index 7851542a9..ab6639403 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -28,7 +28,7 @@ export default [ } }, { - files: ['assets/js/sidebar-toggle.js'], + files: ['assets/js/sidebar-toggle.js', 'assets/js/critical/sidebar-active.js'], languageOptions: { globals: { bootstrap: 'readonly' diff --git a/exampleSite/hinode.work.sum b/exampleSite/hinode.work.sum index 769f6f2ed..be83ecbca 100644 --- a/exampleSite/hinode.work.sum +++ b/exampleSite/hinode.work.sum @@ -1,3 +1,5 @@ +github.com/FortAwesome/Font-Awesome v0.0.0-20260715180930-14c65a3747d0 h1:4+bB2ojsMTsQroHtQr1FWy2gxFzpBn5hISldeRfxF+o= +github.com/FortAwesome/Font-Awesome v0.0.0-20260715180930-14c65a3747d0/go.mod h1:IUgezN/MFpCDIlFezw3L8j83oeiIuYoj28Miwr/KUYo= github.com/cloudcannon/bookshop/hugo/v3 v3.17.1 h1:weTVWBamjQHMIp/oYTFsPwRzzhWrZA6JO43QnxI1kxw= github.com/cloudcannon/bookshop/hugo/v3 v3.17.1/go.mod h1:s7mIonDhtsLcn10ZKuVXyqd6BDHI8vT1WQhZw8rPfY8= github.com/cloudcannon/bookshop/hugo/v3 v3.18.2 h1:j3XUvvuCv/7SfGKzd7gzb3WEgs1DurqTRDe7gdMAAvU= @@ -14,6 +16,8 @@ github.com/gethinode/mod-fontawesome/v2 v2.1.2 h1:v1aHhbLLwe/05zRHnx9qGqh6b3toDz github.com/gethinode/mod-fontawesome/v2 v2.1.2/go.mod h1:zukv88wXqquEvTJJ9mWWk8Ia+9INnA41wYqusf2RcHA= github.com/gethinode/mod-fontawesome/v3 v3.1.3 h1:xlwmdgulIV/IMj5I1/fH4tPM1/AU0OYZ4vepQiJbOYA= github.com/gethinode/mod-fontawesome/v3 v3.1.3/go.mod h1:+E6jSVNv7h6oXw4Wm1XRq8HBEB2WrvxCPkp6u4vY5xo= +github.com/gethinode/mod-fontawesome/v6 v6.1.0 h1:hvhBAHuICZYAiQdmcd+qtLRoiJAIgU7/5sEbs1GoVW4= +github.com/gethinode/mod-fontawesome/v6 v6.1.0/go.mod h1:875OBk5te+KBJmr0PdSkFcduYnsKCuHWfcaqChh9NGo= github.com/gethinode/mod-utils/v4 v4.12.0 h1:5sSfYIxZCeQbXLoZdS//rl6thwLwtXuvM0ujaWKyPmc= github.com/gethinode/mod-utils/v4 v4.12.0/go.mod h1:bYmvRdAo4ICy5MpSGafDvO4p5bTDpsDKFCPL3bH0mN4= github.com/gethinode/mod-utils/v6 v6.5.0/go.mod h1:E5tO9w3VKaidJpu1nI8zAKmh0bddFHOIIQnudAaXQTs= diff --git a/layouts/_partials/assets/link.html b/layouts/_partials/assets/link.html index 8b8d1a68b..ee1d0fd61 100644 --- a/layouts/_partials/assets/link.html +++ b/layouts/_partials/assets/link.html @@ -79,7 +79,9 @@ {{- end -}} {{- if $cue -}} - {{- $suffix := partial "assets/icon.html" (dict "page" $args.page "icon" $externalLink "class" "fa-2xs" "spacing" false) -}} + {{- $iconArgs := dict "page" $args.page "icon" $externalLink "class" "fa-2xs" "spacing" false -}} + {{- with $args.iconMode }}{{ $iconArgs = merge $iconArgs (dict "mode" .) }}{{ end -}} + {{- $suffix := partial "assets/icon.html" $iconArgs -}} {{- $text = printf "%s %s" $text $suffix | safeHTML -}} {{- end -}} {{- else -}} diff --git a/layouts/_partials/assets/sidebar.html b/layouts/_partials/assets/sidebar.html index 698c55c8e..27a622feb 100644 --- a/layouts/_partials/assets/sidebar.html +++ b/layouts/_partials/assets/sidebar.html @@ -51,20 +51,19 @@ {{- $href = partial "utilities/URLJoin.html" (dict "base" $baseURL "path" $href) -}} {{- end -}} {{- if and $href (not (hasSuffix $href "/")) -}}{{- $href = printf "%s/" $href -}}{{- end -}} - {{- $active := and $href (strings.HasPrefix $page.RelPermalink $href) -}} {{- $groupTitle := $group.title -}} {{- if and site.Params.main.titleCase (not $page.Params.exact) -}} {{- $groupTitle = title $groupTitle -}} {{- end -}}
  • - + {{ with $href }}href="{{ . }}" data-sidebar-match="prefix"{{ end }}> {{- with $pre -}} {{- if hasPrefix . "
  • @@ -410,22 +415,24 @@ {{- range $index, $item := . -}} {{- if $item.pages -}} {{ partial "inline/sidebar/group.html" (dict - "page" $page - "index" $index - "level" 1 - "baseURL" $baseURL - "group" $item - "menu" $activeGroup.pages + "page" $page + "index" $index + "level" 1 + "baseURL" $baseURL + "group" $item + "menu" $activeGroup.pages + "filename" $args.filename ) }} {{- else -}} {{ partial "inline/sidebar/item.html" (dict - "page" $page - "level" $level - "baseURL" $baseURL - "title" $item.title - "href" $item.link - "menu" $activeGroup.pages - "pre" $item.pre + "page" $page + "level" $level + "baseURL" $baseURL + "title" $item.title + "href" $item.link + "menu" $activeGroup.pages + "pre" $item.pre + "filename" $args.filename ) }} {{- end -}} {{- end -}} @@ -437,23 +444,25 @@ {{- range $index, $item := $args.menu -}} {{- if $item.pages }} {{ partial "inline/sidebar/group.html" (dict - "page" $page - "index" $index - "level" (add $level 1) - "baseURL" $baseURL - "group" $item - "menu" $args.menu + "page" $page + "index" $index + "level" (add $level 1) + "baseURL" $baseURL + "group" $item + "menu" $args.menu + "filename" $args.filename ) }} {{- else }} {{ partial "inline/sidebar/item.html" (dict - "page" $page - "level" $level - "baseURL" $baseURL - "title" $item.title - "href" $item.link - "menu" $args.menu - "pre" $item.pre + "page" $page + "level" $level + "baseURL" $baseURL + "title" $item.title + "href" $item.link + "menu" $args.menu + "pre" $item.pre + "filename" $args.filename ) }} {{- end }} diff --git a/layouts/_partials/page/sidebar-cached.html b/layouts/_partials/page/sidebar-cached.html new file mode 100644 index 000000000..bdec1f510 --- /dev/null +++ b/layouts/_partials/page/sidebar-cached.html @@ -0,0 +1,51 @@ +{{/* + Copyright © 2022 - 2026 The Hinode Team / Mark Dumay. All rights reserved. + Use of this source code is governed by The MIT License (MIT) that can be found in the LICENSE file. + Visit gethinode.com/license for more details. +*/}} + +{{/* + Uncached wrapper around the sidebar renderer that renders the sidebar tree once per + (section, version, language, variant) using partialCached. The rendered sidebar markup is + page-independent by contract: active-item highlighting and the expanded group trail are + applied client-side by assets/js/critical/sidebar-active.js. The wrapper: + + 1. computes the cache key tuple from the real page context, + 2. canonicalizes the page context to the section root (the versioned section root when + applicable), so the cached output does not depend on which page happens to render first, + 3. passes the base URL and the sidebar data filename as explicit arguments, so the cached + subtree performs no per-page Scratch reads, + 4. keeps Mode B (level-min >= 2) uncached: that mode selects a subtree by the current + page's URL and is structurally page-dependent. + + The raw renderer (assets/sidebar.html) stays cache-free and remains directly callable by + site overrides. +*/}} + +{{- $args := . -}} +{{- $page := $args.page -}} +{{- $section := $page.Section -}} +{{- $version := $args.version | default "" -}} +{{- $lang := site.Language.Lang -}} +{{- $levelMin := index $args "level-min" | default 0 -}} +{{- $levelMax := index $args "level-max" | default 0 -}} +{{- $collapsible := $args.collapsible | default false -}} +{{- $autoGenerate := index $args "auto-generate" | default false -}} + +{{/* The base URL depends on site state only; the filename is set per menu by GetMenu */}} +{{- $baseURL := partialCached "utilities/GetBaseURL.html" site -}} +{{- $filename := $page.Scratch.Get "sidebarFilename" | default "" -}} + +{{- if ge $levelMin 2 -}} + {{/* Mode B renders a page-dependent subtree; call the renderer uncached */}} + {{- partial "assets/sidebar.html" (merge $args (dict "baseURL" $baseURL "filename" $filename)) -}} +{{- else -}} + {{/* Canonicalize the page context to the (versioned) section root */}} + {{- $root := $page.FirstSection | default $page -}} + {{- with $version -}} + {{- with site.GetPage (path.Join $section .) }}{{ $root = . }}{{ end -}} + {{- end -}} + {{- $ctx := merge $args (dict "page" $root "baseURL" $baseURL "filename" $filename) -}} + {{- $variant := printf "%v-%v-%v-%v" $levelMin $levelMax $collapsible $autoGenerate -}} + {{- partialCached "assets/sidebar.html" $ctx $section $version $lang $variant -}} +{{- end -}} diff --git a/layouts/_partials/page/sidebar.html b/layouts/_partials/page/sidebar.html index 4bbd44121..4a1ef7171 100644 --- a/layouts/_partials/page/sidebar.html +++ b/layouts/_partials/page/sidebar.html @@ -1,5 +1,5 @@ {{- $menu := .Scratch.Get "sidebar" -}} {{- $version := .Scratch.Get "version" -}} {{ if $menu }} - {{ partial "assets/sidebar.html" (dict "page" . "menu" $menu "version" $version) }} + {{ partial "page/sidebar-cached.html" (dict "page" . "menu" $menu "version" $version) }} {{ end -}} diff --git a/layouts/docs/all.html b/layouts/docs/all.html index 5a5585ba8..61f65a2ab 100644 --- a/layouts/docs/all.html +++ b/layouts/docs/all.html @@ -9,7 +9,7 @@ {{- $menu := .Scratch.Get "sidebar" -}} {{- if not (reflect.IsSlice $menu) }}{{ $none := dict }}{{ $menu = $none.missing }}{{ end }} {{- $version := .Scratch.Get "version" -}} - {{- $sidebar := partial "assets/sidebar.html" (dict "page" . "menu" $menu "version" $version "auto-generate" true) -}} + {{- $sidebar := partial "page/sidebar-cached.html" (dict "page" . "menu" $menu "version" $version "auto-generate" true) -}} {{/* Render the offcanvas sidebar */}} {{- partial "page/sidebar-offcanvas.html" (dict "section" $.Section "raw" $sidebar) -}}