Skip to content

feat(sidebar): data-driven sidebar with section scoping, merge modes, and state persistence - #1006

Draft
imfing wants to merge 62 commits into
mainfrom
feat/sidebar-update
Draft

feat(sidebar): data-driven sidebar with section scoping, merge modes, and state persistence#1006
imfing wants to merge 62 commits into
mainfrom
feat/sidebar-update

Conversation

@imfing

@imfing imfing commented Jun 14, 2026

Copy link
Copy Markdown
Owner

Summary

Reworks the sidebar into a modular, data-driven system. The monolithic
layouts/_partials/sidebar.html (319 lines) is replaced by a pipeline of
small, cached partials under layouts/_partials/components/sidebar/, and a new
optional data layer lets authors define sidebar structure explicitly instead of
relying solely on the auto-generated content tree.

What's new

Data-driven sidebar

  • Define structure in a sidebar data file under data/<lang>/ (localized) or
    data/ (language-independent). Any data format Hugo supports works — YAML,
    JSON, or TOML (e.g. sidebar.yaml, sidebar.json, sidebar.toml); examples
    below use YAML.
  • Section scoping: drop files under data/<lang>/sidebar/<section>.*
    mirroring the content tree; lookup walks from the deepest matching section
    upward, so a more specific file wins. Sections without a file fall back to
    auto-discovery.
  • Merge modes per node: none (explicit items only, default) or deep
    (pin explicit items, then append remaining auto-generated children).
  • Node types: search (inline sidebar search input) and separator
    (label; children are lifted to the parent level).
  • Group headings: link-less nodes render as non-clickable labels, with a
    collapsible toggle when they have children — a way to expose folders that
    have no _index.md.

Front matter additions

  • sidebar.icon: per-page icon from Hextra's icon set (data-file icon wins on
    conflict).
  • sidebar.separator: true: promote a top-level section to a separator label,
    lifting its children to the top level (URLs/breadcrumbs unchanged).
  • sidebar.sort: weight (default) or title, configurable globally
    (params.sidebar.sort) or per-section, with per-section taking precedence.
  • linkTitle: use a different label in the sidebar/breadcrumb than title.
  • params.sidebar.maxDepth to cap tree depth.

Behavior & UX

  • Expand/collapse state and scroll position persist across navigation via
    sessionStorage.
  • Animated collapse/expand that respects prefers-reduced-motion.
  • Mobile tree builder reuses the same node pipeline; mobile TOC injection for
    the active page.
  • <noscript> fallback and semantic CSS class layer for the rendered tree.

Implementation notes

  • New partials: build-tree, build-auto-nodes, build-data-tree,
    build-mobile-tree, source-data, normalize-node, sorted-items,
    is-excluded, render-tree/-item/-footer/-toc, collapsible-button,
    icon.
  • All tree builders use partialCached keyed by language/section/scope for
    performance.
  • Accessibility: ARIA attributes on collapsibles, keyboard operability,
    preserved focus indicators (per WCAG AA).

Breaking changes

  • The menu.sidebar config is removed in favor of data files (the example
    site's docs/hugo.yaml is updated accordingly).

Docs & tests

  • Expanded guide/configuration.md and guide/organize-files.md (data schema,
    node properties table, merge modes, sort order, scoping, separators, icons).
  • New Playwright E2E coverage in tests/sidebar.spec.ts backed by
    docs/content/docs/sidebar-lab/ fixtures and a sidebar data file under
    data/en/sidebar/, plus a root-level data-sidebar integration test.

Verification

  • hugo build of the example site is clean (no warnings).
  • Full sidebar E2E suite passes (17 tests).

imfing added 30 commits May 6, 2026 09:36
Remove all define blocks. Both auto and data paths produce identical
normalized node structures consumed by a single render pipeline.
Hugo does not support return-with-value in recursive partial calls.
Restructure: data tree is normalized iteratively (unrolled 3 levels),
auto tree children are discovered inline during rendering.
Swap inline Tailwind utility classes for the semantic
hextra-sidebar-* classes defined in a3a462e, completing the
class refactor in the top-level orchestrator template.
The build-* partials return node arrays that depend only on
(navRoot, language) and site.Menus.main — never on the current
page. Wrap both top-level orchestrator calls with partialCached
so the tree data is computed once per (lang, navRoot) instead
of once per page. Per-page state (active link, open ancestor)
remains handled by the render-* partials and stays uncached.

Measured on docs site (122 pages, 4 languages):
- build-mobile-tree: 62.96ms -> 8.03ms cumulative (-87%)
- build-auto-nodes: 724 -> 33 invocations (-95%)
- Total sidebar build layer: ~80ms -> ~22ms (-72%)
Savings scale with page count, so larger sites benefit more.
Extract recursive auto-tree discovery into a dedicated cached
partial. Previously the same logic was duplicated across the
desktop, mobile, and data-tree builders, and render-tree.html
fell back to inline discovery for nodes with empty children.

Both build-tree and build-mobile-tree now call build-auto-nodes
for their auto-discovery needs, and build-data-tree uses it to
resolve children for merge: deep nodes. Caching is keyed by
section RelPermalink so each subtree is computed once per build.
Replace inline Tailwind utility classes in the render-* partials
with the semantic hextra-sidebar-* classes defined in a3a462e.
Covers separator items, child list containers, item wrappers,
TOC links, and collapsible button focus styles. Drops the
conditional hx:flex hx:flex-col on nested <li> elements — the
parent .hextra-sidebar-children rule now provides this layout
for every depth uniformly.
Wrap sidebar.css in @layer components and add the missing
container-level rules (mobile/desktop list, scroll wrapper,
search wrapper, placeholder, switches, theme-toggle, guide-list,
toc-link) used by the orchestrator and render partials.

Regenerate compiled/main.css and docs/hugo_stats.json so the
Tailwind pipeline picks up the new class names.
Hugo's default security policy blocks Node-based asset pipelines
(PostCSS, Tailwind) unless allow-listed. Disable the node
permission gate in dev.toml so the local dev server can run
PostCSS without per-command exec policies.
Replace fragile Tailwind utility class selectors (e.g.
hx\:max-md\:hidden) with the stable hextra-sidebar-desktop-list
and hextra-sidebar-mobile-list semantic classes. The tests no
longer break when utility classes are reshuffled in the layout.
…mobile

Three regressions surfaced by review of the data-driven sidebar:

1. build-tree.html always merged data nodes into the desktop tree
   as soon as a data file existed, so customizations leaked into
   unrelated sections (e.g. a data file scoped to /docs/sidebar-lab/
   prepended that node on /docs/getting-started/). Restore the
   $matchFound gate from main: the data tree is only active when
   the current page sits under one of its declared roots. The match
   is computed once in sidebar.html and threaded through both the
   desktop and mobile builders, with the boolean joined into the
   partialCached variant keys so cache buckets stay correct.

2. build-mobile-tree.html expanded each main-menu section with
   build-auto-nodes, bypassing data rules entirely. Mobile users
   on /docs/sidebar-lab/manual-parent/ saw the auto child even
   though merge:none should hide it. Mobile now reuses build-tree
   for section expansion so merge modes, ordering, and titles are
   applied consistently across viewports.

3. When site.Menus.main was empty (or filtered down to icon-only
   entries), the mobile tree returned an empty list, dropping all
   docs navigation. Re-add the auto-tree fallback by deferring to
   build-tree on the resolved navRoot when no usable menu entries
   were produced.

build-tree.html also filters data roots to descendants of $context
so the new mobile reuse can pass arbitrary section roots without
pulling in unrelated data entries.
Switch the data-driven sidebar to Hugo's language subfolder convention
(`data/<lang>/sidebar.yaml`) so it aligns with the existing termbase
layout and Hugo's recommended pattern for localized data. The legacy
`data/sidebar.<lang>.yaml` form is no longer recognized; `data/sidebar.yaml`
remains as the language-agnostic fallback.
Add an `icon` property to the sidebar data schema so authors can render
a Hextra icon (from data/icons.yaml) before a node title. The icon is
wrapped together with the title in an inner flex span so the parent
link's justify-between layout does not separate them.
Previously, when a page matched a data sidebar root, the data nodes were
placed first and the remaining auto-generated sections were appended
after, which forced data sections to the top regardless of weight. Walk
the auto tree instead and substitute data versions in place by link, so
data-driven sections inherit their natural weight-ordered position.
The data tree was only consulted when the current page sat under a
data-defined root, so cosmetic properties such as `icon` disappeared on
every other page. Compute the data tree unconditionally and, when not
matched, overlay only the icon onto matching auto-nodes by link; the
structural overrides (items, merge, open) remain gated on the match so
data-defined navigation does not leak into unrelated sections.
Read `sidebar.icon` from page front matter when building auto-tree
nodes, merge:deep auto-appended children, and the mobile menu tree.
Data-driven nodes also fall back to the linked page's front-matter
icon when their own `icon` is omitted, so authors can attach an icon
to a page without listing it in the data file. An explicit `icon` in
the data file still takes precedence.
Use the new front-matter `sidebar.icon` to give Getting Started,
Guide, and Advanced distinct icons in the sidebar. Mirrored across
all four language variants.
Replace the abrupt h-0/h-auto toggle with a grid-template-rows
0fr↔1fr transition so nested sections expand smoothly, matching the
mobile sidebar's cubic-bezier easing. Guard the transition behind
prefers-reduced-motion: no-preference and disable the chevron
rotation when reduce is set.
# Conflicts:
#	assets/css/compiled/main.css
imfing added 18 commits May 25, 2026 23:25
# Conflicts:
#	assets/css/compiled/main.css
#	assets/css/components/sidebar.css
#	assets/js/core/banner.js
#	assets/js/flexsearch.js
#	docs/content/docs/guide/configuration.md
#	docs/hugo_stats.json
#	layouts/_partials/sidebar.html
A section (or data node) with `sidebar.separator: true` renders as a
top-level separator label and its children are lifted alongside it,
matching mkdocs-style grouping. Works in both auto-discovered and
data-driven sidebars; the data path also lifts children from
`merge: deep` auto-discovery. Separator labels now render
`sidebar.icon` when set.
The navbar trigger is mobile-icon-only and pill at md+ with a fixed
12rem width. Dropped into the sidebar, it left a near-empty button
on mobile and didn't fill the sidebar width on desktop.

Add specificity-scoped overrides keyed off .hextra-sidebar-search-item
to always show the label + kbd hint and stretch to full width. Rules
live in search.css (unlayered) because sidebar.css is wrapped in
@layer components and would otherwise lose the cascade.
Remove the site.Params.sidebar.cache toggle and the dual rendering
paths. The sidebar now always renders statically via partialCached,
with active state, ancestor expansion, TOC injection, and open-state
persistence handled client-side.

Also inline the single-use render-children wrapper into render-tree,
collapsing the render-tree -> render-children -> render-tree
indirection into a single recursive partial.
… fallback

- Extract collapsible-button, sorted-items, and utils/sidebar-link partials
  to deduplicate logic across the tree-building and rendering partials
- Support configurable sidebar.maxDepth (defaults to 4) in place of the
  hardcoded depth limit
- Add a <noscript> fallback that expands all sections and hides the inert
  toggles for no-JS users
- Delegate the mobile in-page link dismissal to the sidebar container so
  dynamically injected TOC links are covered
- Restore saved scroll position before scrolling the active item into view
- Add level/section cache keys to partialCached calls for correct caching
- Filter data-file nodes with `exclude: true` (was silently ignored)
- Mark the active link with aria-current="page" in both the inline and
  external init scripts (WCAG)
- Normalize trailing slashes when matching the active link, restoring the
  server-side behavior dropped in the rewrite
- Drop the page-dependent mobile $groupOpen that was baked into a
  pageURL-agnostic partialCached result; keep groups closed and let JS open
  the active ancestors
- Remove the now-dead pageURL param threaded through build-tree/
  build-mobile-tree/sidebar
- Precompute the sidebar link class so conditionals no longer leak literal
  newlines into the rendered class attribute
- Reuse utils/sidebar-link.html in render-footer instead of duplicating the
  link normalization
- Guard the scroll sessionStorage access against sandboxed/private contexts
Drop the string form of sidebar.separator in favor of the boolean-only
contract, thread allowSeparator through the tree builders so lifting only
happens at the top level, and lift separator children in the mobile tree
to match desktop. Top-level data/sidebar.yaml lists now drive the sidebar
for root-level docs sites, covered by a new integration test.
@imfing
imfing temporarily deployed to accessibility June 14, 2026 16:25 — with GitHub Actions Inactive
@netlify

netlify Bot commented Jun 14, 2026

Copy link
Copy Markdown

Deploy Preview for hugo-hextra ready!

Name Link
🔨 Latest commit e7ba73b
🔍 Latest deploy log https://app.netlify.com/projects/hugo-hextra/deploys/6a766b01017d0800085a1923
😎 Deploy Preview https://deploy-preview-1006--hugo-hextra.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@imfing

imfing commented Jun 30, 2026

Copy link
Copy Markdown
Owner Author

Need to do some regression tests and ensure the semantics support different use cases

Resolve data file from the section context instead of the viewed page
so multi-root sites don't cross-contaminate sidebar entries.  Guard on
pre-filter sidebarData length so an all-excluded data file stays
authoritative (returns empty) instead of silently falling back to the
auto tree.  Clear the body scroll lock and menu state in
syncAriaHidden when the viewport transitions to desktop width.
@imfing
imfing temporarily deployed to accessibility July 11, 2026 12:02 — with GitHub Actions Inactive
# Conflicts:
#	assets/css/compiled/main.css
#	layouts/_partials/sidebar.html
@imfing
imfing temporarily deployed to accessibility July 22, 2026 00:05 — with GitHub Actions Inactive
@imfing
imfing temporarily deployed to accessibility August 7, 2026 23:27 — with GitHub Actions Inactive
@imfing
imfing deployed to accessibility August 7, 2026 23:32 — with GitHub Actions Active
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant