From 2e1c89a567b442bb0994797cad582fe254330ca5 Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Fri, 14 Aug 2026 14:04:48 +0200 Subject: [PATCH] Add a table of contents to the packages and tutorials pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both pages are long enough that finding a section means scrolling and hoping. A sticky list of their headings now sits to the left, with the section you are in marked. The headings come from templates rather than markdown, so Hugo's .TableOfContents cannot see them and the list is built from the rendered page. That also covers the tutorial categories, which are generated from content and carry no ids of their own — those get one derived from the heading text so the links have somewhere to point. The column only appears above 1200px, where there is room for it beside the content; below that the pages are exactly as they were. --- assets/main.scss | 83 ++++++++++++++++ layouts/learn/list.html | 103 ++++++++++---------- layouts/packages/list.html | 187 +++++++++++++++++++------------------ layouts/partials/toc.html | 7 ++ static/main.js | 52 +++++++++++ 5 files changed, 292 insertions(+), 140 deletions(-) create mode 100644 layouts/partials/toc.html diff --git a/assets/main.scss b/assets/main.scss index 7bdb074..4a72eb1 100644 --- a/assets/main.scss +++ b/assets/main.scss @@ -1138,6 +1138,89 @@ body { } } +// Table of contents sidebar, used by the long pages (see partials/toc.html) + +.with-toc, +.with-toc-body { + display: flex; + flex-direction: column; + min-width: 0; +} + +.toc { + display: none; +} + +// Only worth a column when there is room for one beside the content +@media (min-width: 75rem) { + #page-content:has(.with-toc) { + max-width: $maxwidthwide; + width: $contentwidthwide; + } + + .with-toc { + display: grid; + grid-template-columns: 13rem minmax(0, 1fr); + gap: 3.5rem; + align-items: start; + } + + .toc { + display: block; + position: sticky; + top: 2rem; + max-height: calc(100vh - 4rem); + overflow-y: auto; + // The body column starts with the h1, so drop the list to roughly its baseline + margin-top: 4.5rem; + + &[hidden] { + display: none; + } + + .toc-title { + margin: 0 0 0.6rem 0.75rem; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: $greydesc; + } + + ul { + margin: 0; + padding: 0; + list-style: none; + border-left: 1px solid $overline; + } + + li a { + display: block; + padding: 0.35rem 0.75rem; + margin-left: -1px; + border-left: 2px solid transparent; + font-size: 0.9rem; + line-height: 1.35; + color: $greydesc; + text-decoration: none; + transition: all 200ms ease-in-out; + + &:hover { + color: black; + } + &.is-current { + color: black; + border-left-color: $tiletext; + } + } + + .toc-h3 a { + padding-left: 1.5rem; + font-size: 0.85rem; + } + } +} + // Specific to /people #people-content .team .team-list { diff --git a/layouts/learn/list.html b/layouts/learn/list.html index e7cea9a..eff578b 100644 --- a/layouts/learn/list.html +++ b/layouts/learn/list.html @@ -1,64 +1,69 @@ {{ define "main" }} -

{{ .Title }}

-
-
- {{ if .Params.tutorials }} - {{ .Params.tutorials_intro | markdownify }} +
+ {{ partial "toc.html" . }} +
+

{{ .Title }}

+
+
+ {{ if .Params.tutorials }} + {{ .Params.tutorials_intro | markdownify }} - {{ $jsonURL := "https://scverse.org/scverse-tutorials/tutorials.json" }} - {{ $remoteData := resources.GetRemote $jsonURL }} - {{ $content := transform.Unmarshal $remoteData.Content }} - {{ if $content }} + {{ $jsonURL := "https://scverse.org/scverse-tutorials/tutorials.json" }} + {{ $remoteData := resources.GetRemote $jsonURL }} + {{ $content := transform.Unmarshal $remoteData.Content }} + {{ if $content }} -

Tutorials

+

Tutorials

-
- +
+ - {{ range $cat := $content.categories }} - {{ range $catname, $catmore := $cat }} - {{ $ts := where $content.tutorials "primary_category" $catname }} - {{ if (gt ($ts | len) 0) }} -

{{ $catname }}

-

{{ $catmore.description }}

+ {{ range $cat := $content.categories }} + {{ range $catname, $catmore := $cat }} + {{ $ts := where $content.tutorials "primary_category" $catname }} + {{ if (gt ($ts | len) 0) }} +

{{ $catname }}

+

{{ $catmore.description }}

- - {{ end }} - - {{ end }} + {{ end }} +
+
-
+ {{ end }} diff --git a/layouts/packages/list.html b/layouts/packages/list.html index c913803..16779d7 100644 --- a/layouts/packages/list.html +++ b/layouts/packages/list.html @@ -1,104 +1,109 @@ {{ define "main" }} -

{{ .Title }}

-
- {{ $sections := index .Params.sections 0 }} -
- {{ if .Params.packages }} -

Packages maintained by core team

- {{ $sections.core_packages | markdownify }} - {{ if .Params.datastructures }} -

Data structures

- {{ $sections.datastructures | markdownify }} -
- {{ $datastructures := .Params.datastructures }} - {{ range $i, $e := $datastructures }} -
-
- Logo for {{ .name }} -
-
-
-
- {{ .name }} - {{ .details | markdownify }} +
+ {{ partial "toc.html" . }} +
+

{{ .Title }}

+
+ {{ $sections := index .Params.sections 0 }} +
+ {{ if .Params.packages }} +

Packages maintained by core team

+ {{ $sections.core_packages | markdownify }} + {{ if .Params.datastructures }} +

Data structures

+ {{ $sections.datastructures | markdownify }} +
+ {{ $datastructures := .Params.datastructures }} + {{ range $i, $e := $datastructures }} +
+
+ Logo for {{ .name }} +
+
+
+
+ {{ .name }} + {{ .details | markdownify }} +
+
+
- -
+ {{ end }}
{{ end }} -
- {{ end }} -

Analysis task-specific extensions

-

{{ $sections.datastructures_post | markdownify }}

-

Frameworks

- {{ $sections.frameworks | markdownify }} -
- {{ $packages := .Params.packages }} - {{ range $i, $e := $packages }} -
-
- Logo for {{ .name }} -
-
-
-
- {{ .name }} - {{ .details | markdownify }} +

Analysis task-specific extensions

+

{{ $sections.datastructures_post | markdownify }}

+

Frameworks

+ {{ $sections.frameworks | markdownify }} +
+ {{ $packages := .Params.packages }} + {{ range $i, $e := $packages }} +
+
+ Logo for {{ .name }} +
+
+
+
+ {{ .name }} + {{ .details | markdownify }} +
+
+
- -
+ {{ end }}
{{ end }} -
- {{ end }} - {{ $jsonURL := "https://scverse.org/ecosystem-packages/packages.json" }} - {{ $remoteData := resources.GetRemote $jsonURL }} - {{ $pkgs := transform.Unmarshal $remoteData.Content }} -

Ecosystem packages maintained by scverse community

-
-

{{ $sections.ecosystem | markdownify }}

-
- {{ if $pkgs }} -
- - - - - - - - - - {{ range $lib := $pkgs }} - - - - - {{ end }} - -
PackageDescription
+ {{ $jsonURL := "https://scverse.org/ecosystem-packages/packages.json" }} + {{ $remoteData := resources.GetRemote $jsonURL }} + {{ $pkgs := transform.Unmarshal $remoteData.Content }} +

Ecosystem packages maintained by scverse community

+
+

{{ $sections.ecosystem | markdownify }}

+
+ {{ if $pkgs }} +
+ + + + + + + + + + {{ range $lib := $pkgs }} + + + + + {{ end }} + +
PackageDescription
+
+ {{ end }}
- {{ end }} +
- + {{ end }} diff --git a/layouts/partials/toc.html b/layouts/partials/toc.html new file mode 100644 index 0000000..821384d --- /dev/null +++ b/layouts/partials/toc.html @@ -0,0 +1,7 @@ +{{/* The headings on these pages are rendered by templates rather than by markdown, so Hugo's + .TableOfContents cannot see them. initTableOfContents in main.js fills this in and unhides it. +*/}} + diff --git a/static/main.js b/static/main.js index 5eb34b9..799fc1d 100644 --- a/static/main.js +++ b/static/main.js @@ -12,6 +12,56 @@ const filterPackages = () => { trs.forEach(toggleTrs); } +// Table of contents for long pages. These headings come from templates rather than markdown, so +// Hugo's .TableOfContents cannot see them and the list is built from the rendered page instead. +const initTableOfContents = () => { + const toc = document.querySelector('.toc'); + const body = document.querySelector('.with-toc-body'); + if (!toc || !body) return; + + const headings = [...body.querySelectorAll('h2, h3')].filter(heading => heading.textContent.trim()); + if (headings.length < 3) return; // too short to be worth a sidebar + + const list = toc.querySelector('ul'); + const used = new Set(); + const links = new Map(); + + headings.forEach(heading => { + if (!heading.id) { + const slug = heading.textContent.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + let id = slug; + for (let n = 2; used.has(id) || document.getElementById(id); n += 1) id = `${slug}-${n}`; + heading.id = id; + } + used.add(heading.id); + + const link = document.createElement('a'); + link.href = `#${heading.id}`; + link.textContent = heading.textContent.trim(); + + const item = document.createElement('li'); + item.className = `toc-${heading.tagName.toLowerCase()}`; + item.append(link); + list.append(item); + links.set(heading, link); + }); + + toc.hidden = false; + + // Highlight the last heading scrolled past. A handful of rect reads per scroll is cheap enough + // to do directly, and avoids a throttle that can wedge if its callback never runs. + const markCurrent = () => { + let current = headings[0]; + for (const heading of headings) { + if (heading.getBoundingClientRect().top > 120) break; + current = heading; + } + links.forEach((link, heading) => link.classList.toggle('is-current', heading === current)); + }; + window.addEventListener('scroll', markCurrent, { passive: true }); + markCurrent(); +}; + const filterTutorials = () => { const trs = document.querySelectorAll('.tutorial-item'); const filter = document.querySelector('#tutorial-filter').value; @@ -258,6 +308,8 @@ document.addEventListener('DOMContentLoaded', function() { tutorialFilter.addEventListener('input', filterTutorials) } + initTableOfContents() + // Initialize interactive visualization if on home page initInteractiveViz() })