From 286c113c2d687589441275d1864fff45e8a814be Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Tue, 11 Aug 2026 10:44:01 +0200 Subject: [PATCH 1/6] Redesign the ecosystem package registry as a filterable card grid The registry was a two-column table of name and description, which ignored most of what packages.json actually carries. Each package is now a card showing its logo (or a monogram tile), version, license, description, tags, and a link row built from the registry data: repository, docs, tutorials, PyPI/conda/CRAN/Bioconductor and the first publication DOI. Entries in the core-* categories get a badge so they are distinguishable from community submissions. Filtering is search over name, description and tags plus a row of the twelve most common tags. Registry tags are free-form, so variants such as "single-cell", "single cell" and "singlecell" are folded together through an alias map before they are counted. Tags on a card filter by that tag as well, injecting a chip when it is not one of the top twelve. Along the way: - Wrap the remote fetch in `try`, so a registry outage degrades to a link instead of failing the build. - Match the search query as a substring instead of compiling user input into a RegExp, which threw on input such as `(`. - Collapse duplicate outbound links; many entries point documentation_home and tutorials_home at the same URL. - Drop the

wrapping markdownify output, which nested paragraphs. --- assets/main.scss | 354 ++++++++++++++++++++++++++++++++++++- layouts/packages/list.html | 318 +++++++++++++++++++++++++++++---- static/main.js | 101 +++++++++-- 3 files changed, 720 insertions(+), 53 deletions(-) diff --git a/assets/main.scss b/assets/main.scss index 8a3ee6e..cb376ee 100644 --- a/assets/main.scss +++ b/assets/main.scss @@ -1053,9 +1053,357 @@ body { } } -#eco-table tbody { - // TODO: I think this shouldn't be neccesary - border-top: 0px; +// Specific to /packages#ecosystem — the community package registry + +$eco-radius: 0.75rem; +$eco-hues: + $anndataorange, $scanpycerise, $mudatagreen, $spatialdatablue, $squidpyviolet, + $scirpypurple, $scviyellow, $muonaquamarine; + +#ecosystem-packages { + margin: 2rem 0 4rem; + + .eco-sprite { + display: none; + } + + svg { + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; + } + + .eco-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 1.5rem; + } + + .eco-search { + position: relative; + display: flex; + align-items: center; + flex: 1 1 22rem; + + .eco-search-icon { + position: absolute; + left: 1.1rem; + width: 1.1rem; + height: 1.1rem; + color: $greydesc; + pointer-events: none; + } + + input { + width: 100%; + height: 3.25rem; + padding: 0 2.9rem; + font-size: 1rem; + color: $tiletext; + background-color: $backtickbg; + border: 1px solid $overline; + border-radius: 2rem; + outline: none; + transition: all 200ms ease-in-out; + text-overflow: ellipsis; + + &::placeholder { + color: $greydesc; + } + &:focus { + background-color: white; + border-color: $greydesc; + box-shadow: 0 0 0 0.25rem rgba(0, 0, 0, 0.04); + } + // The native affordance duplicates our own clear button + &::-webkit-search-cancel-button { + display: none; + } + } + } + + .eco-search-clear { + position: absolute; + right: 0.9rem; + display: flex; + align-items: center; + justify-content: center; + width: 1.6rem; + height: 1.6rem; + padding: 0; + border: 0; + border-radius: 50%; + color: $tiletext2; + background-color: $tilebg; + cursor: pointer; + transition: all 200ms ease-in-out; + + svg { + width: 0.7rem; + height: 0.7rem; + } + &:hover { + color: white; + background-color: $tiletext; + } + } + + .eco-count { + margin: 0; + font-size: 0.95rem; + color: $greydesc; + white-space: nowrap; + } + + .eco-chips { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin: 1.25rem 0 2rem; + } + + .eco-chip { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.35rem 0.85rem; + font-size: 0.85rem; + color: $tiletext2; + background-color: white; + border: 1px solid $overline; + border-radius: 2rem; + cursor: pointer; + transition: all 200ms ease-in-out; + + &:hover { + color: $tiletext3; + border-color: $tilebg2; + background-color: $tilebg4; + } + &.is-active { + color: white; + background-color: $tiletext; + border-color: $tiletext; + } + .eco-chip-count { + font-size: 0.75rem; + opacity: 0.55; + } + } + + .eco-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(19rem, 1fr)); + gap: 1.25rem; + } + + .eco-card { + position: relative; + display: flex; + flex-direction: column; + gap: 0.8rem; + padding: 1.35rem; + background-color: white; + border: 1px solid $overline; + border-radius: $eco-radius; + transition: all 200ms ease-in-out; + + &:hover { + border-color: $tilebg2; + box-shadow: 0 0.6rem 1.5rem rgba(0, 0, 0, 0.06); + transform: translateY(-2px); + } + &[hidden] { + display: none; + } + } + + .eco-card-head { + display: flex; + align-items: center; + gap: 0.85rem; + } + + .eco-logo { + display: grid; + place-items: center; + flex: 0 0 auto; + width: 2.75rem; + height: 2.75rem; + overflow: hidden; + border-radius: 0.6rem; + background-color: $tilebg4; + + img { + width: 100%; + height: 100%; + padding: 0.3rem; + object-fit: contain; + } + } + + .eco-logo--mono { + font-size: 1.3rem; + font-weight: 800; + color: $tiletext; + + @each $hue in $eco-hues { + &[data-hue="#{index($eco-hues, $hue) - 1}"] { + background-color: rgba($hue, 0.18); + } + } + } + + .eco-card-heading { + min-width: 0; + } + + // Element selector included to beat the generic `#page-content .post h3` margins + .eco-card h3.eco-card-name { + margin: 0; + font-size: 1.15rem; + font-weight: 800; + line-height: 1.3; + overflow-wrap: anywhere; + + a { + color: black; + text-decoration: none; + + // Make the whole card clickable without nesting interactive elements + &::after { + content: ""; + position: absolute; + inset: 0; + border-radius: $eco-radius; + } + } + } + + .eco-card-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; + margin: 0.25rem 0 0; + font-size: 0.75rem; + color: $greydesc; + + .eco-badge { + padding: 0.05rem 0.35rem; + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: white; + background-color: $tiletext; + border-radius: 0.25rem; + } + .eco-version { + font-family: "JetBrains Mono", Menlo, monospace; + } + .eco-version + .eco-license::before { + content: "·"; + margin-right: 0.4rem; + } + } + + .eco-card-desc { + display: -webkit-box; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + overflow: hidden; + margin: 0; + font-size: 0.9rem; + line-height: 1.55; + color: $tiletext2; + } + + .eco-card-tags { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin: 0; + padding: 0; + list-style: none; + } + + .eco-tag { + position: relative; + z-index: 1; + padding: 0.2rem 0.5rem; + font-size: 0.72rem; + color: $greydesc; + background-color: $tilebg4; + border: 0; + border-radius: 0.35rem; + cursor: pointer; + transition: all 200ms ease-in-out; + + &:hover { + color: $tiletext3; + background-color: $tilebg; + } + } + + .eco-card-links { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-top: auto; + padding-top: 0.3rem; + + a { + position: relative; + z-index: 1; + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.3rem 0.6rem; + font-size: 0.78rem; + color: $tiletext2; + text-decoration: none; + background-color: $tilebg4; + border-radius: 0.4rem; + transition: all 200ms ease-in-out; + + svg { + width: 0.85rem; + height: 0.85rem; + } + &:hover { + color: white; + background-color: $tiletext; + } + } + } + + .eco-empty { + padding: 4rem 0; + text-align: center; + color: $greydesc; + + .eco-empty-reset { + padding: 0; + color: $tiletext; + background: none; + border: 0; + text-decoration: underline; + cursor: pointer; + } + } + + @media (max-width: 50rem) { + .eco-grid { + grid-template-columns: 1fr; + } + .eco-count { + order: -1; + } + } } // Specific to /tutorials diff --git a/layouts/packages/list.html b/layouts/packages/list.html index c913803..d0baded 100644 --- a/layouts/packages/list.html +++ b/layouts/packages/list.html @@ -62,42 +62,294 @@

Frameworks

{{ 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 }} - - - - + {{ $sections.ecosystem | markdownify }} + + {{ $registryBase := "https://scverse.org/ecosystem-packages/" }} + {{ $data := false }} + {{ with try (resources.GetRemote (printf "%spackages.json" $registryBase)) }} + {{ with .Value }} + {{ $data = . }} + {{ end }} + {{ end }} + {{ if $data }} + {{ $pkgs := transform.Unmarshal $data.Content }} + + {{/* Author-provided tags are free-form, so fold the obvious spelling variants together. */}} + {{ $aliases := dict + "single-cell" "single cell" + "singlecell" "single cell" + "single cell analysis" "single cell" + "scrnaseq" "scrna-seq" + "scrna seq" "scrna-seq" + "single cell rna-seq" "scrna-seq" + "single-cell rna-seq" "scrna-seq" + "rnaseq" "rna-seq" + "rna seq" "rna-seq" + "rna-sequencing" "rna-seq" + "spatial" "spatial omics" + "spatial-omics" "spatial omics" + "spatial transcriptomics" "spatial omics" + "spatial-transcriptomics" "spatial omics" + "spatialomics" "spatial omics" + "multi omics" "multi-omics" + "multi_omics" "multi-omics" + "multiomics" "multi-omics" + "multi-modal" "multimodal" + "deep-learning" "deep learning" + "deeplearning" "deep learning" + "machine-learning" "machine learning" + "differential-expression" "differential expression" + "differential gene expression" "differential expression" + "data structure" "data structures" + "optimal-transport" "optimal transport" + "cell-cell-communication" "cell-cell communication" + "cell cell communication" "cell-cell communication" + "gene regulatory networks" "gene networks" + "gene network inference" "gene networks" + "grn" "gene networks" + "rna velocity" "rna-velocity" + "trajectory-inference" "trajectory inference" + "trajectory-generation" "trajectory inference" + "pseudotime" "trajectory inference" + }} + + {{/* Normalise tags once, then reuse for the chip row, the cards and the search index. */}} + {{ $items := slice }} + {{ $tagCounts := dict }} + {{ range $pkgs }} + {{ $tags := slice }} + {{ range (.tags | default slice) }} + {{ $t := trim (. | lower | replaceRE `\s+` " ") " " }} + {{ $t = index $aliases $t | default $t }} + {{ if and $t (not (in $tags $t)) }} + {{ $tags = $tags | append $t }} + {{ $tagCounts = merge $tagCounts (dict $t (add (index $tagCounts $t | default 0) 1)) }} + {{ end }} + {{ end }} + {{/* Registry descriptions are plain text with the odd bit of markdown and hard line breaks. */}} + {{ $desc := trim (.description | markdownify | plainify | htmlUnescape | replaceRE `\s+` " ") " " }} + {{ $search := lower (printf "%s %s %s" .name $desc (delimit $tags " ")) }} + {{ $items = $items | append (merge . (dict + "sortkey" (lower .name) + "normtags" $tags + "desc" $desc + "search" $search + )) + }} + {{ end }} + {{ $items = sort $items "sortkey" }} + + {{/* Chip row: the tags that are common enough to be worth a shortcut. */}} + {{ $tagList := slice }} + {{ range $tag, $count := $tagCounts }} + {{ if ge $count 3 }} + {{ $tagList = $tagList | append (dict "tag" $tag "count" $count) }} + {{ end }} + {{ end }} + {{ $topTags := sort (first 12 (sort $tagList "count" "desc")) "tag" }} + + +
+ + +
+ +

+ {{ len $items }} of {{ len $items }} packages +

+
+ +
+ + {{ range $topTags }} + + {{ end }} +
+ +
+ {{ range $i, $e := $items }} + {{ $isCore := hasPrefix ($e.category | default "") "core" }} + {{ $home := $e.project_home }} + {{ $isGitHub := in ($home | default "") "github.com" }} + + {{/* Collect the outbound links once so the footer stays consistent across cards. */}} + {{ $candidates := slice }} + {{ with $home }} + {{ $candidates = $candidates | append (dict + "icon" (cond $isGitHub "eco-i-code" "eco-i-link") + "label" (cond $isGitHub "GitHub" "Website") + "url" . + ) + }} + {{ end }} + {{ with $e.documentation_home }} + {{ $candidates = $candidates | append (dict "icon" "eco-i-book" "label" "Docs" "url" .) }} {{ end }} -
-
PackageDescription
+ {{ with $e.tutorials_home }} + {{ $candidates = $candidates | append (dict "icon" "eco-i-cap" "label" "Tutorials" "url" .) }} + {{ end }} + {{ $install := $e.install | default dict }} + {{ with $install.pypi }} + {{ $candidates = $candidates | append (dict + "icon" "eco-i-package" "label" "PyPI" "url" (printf "https://pypi.org/project/%s/" .) + ) + }} + {{ end }} + {{ with $install.conda }} + {{ $parts := split . "::" }} + {{ $url := printf "https://anaconda.org/search?q=%s" (urlquery .) }} + {{ if eq (len $parts) 2 }} + {{ $url = printf "https://anaconda.org/%s/%s" (index $parts 0) (index $parts 1) }} + {{ end }} + {{ $candidates = $candidates | append (dict "icon" "eco-i-package" "label" "conda" "url" $url) }} + {{ end }} + {{ with $install.cran }} + {{ $candidates = $candidates | append (dict + "icon" "eco-i-package" "label" "CRAN" "url" (printf "https://cran.r-project.org/package=%s" .) + ) + }} + {{ end }} + {{ with $install.bioconductor }} + {{ $candidates = $candidates | append (dict + "icon" "eco-i-package" "label" "Bioconductor" "url" (printf "https://bioconductor.org/packages/%s" .) + ) + }} + {{ end }} + {{ with $e.publications }} + {{ $doi := index . 0 }} + {{ $url := cond (hasPrefix $doi "http") $doi (printf "https://doi.org/%s" $doi) }} + {{ $candidates = $candidates | append (dict "icon" "eco-i-paper" "label" "Paper" "url" $url) }} + {{ end }} + + {{/* Registries often point several fields at the same URL — show it once. */}} + {{ $links := slice }} + {{ $seen := slice }} + {{ range $candidates }} + {{ $url := trim .url "/" }} + {{ if not (in $seen $url) }} + {{ $seen = $seen | append $url }} + {{ $links = $links | append . }} + {{ end }} + {{ end }} + + +
+
+ {{ with $e.logo }} + + {{ else }} + + {{ end }} +
+

+ {{ $e.name }} +

+

+ {{ if $isCore }}core{{ end }} + {{ with $e.version }}{{ . }}{{ end }} + {{ with $e.license }}{{ . }}{{ end }} +

+
+
+ +

{{ $e.desc }}

+ + {{ with $e.normtags }} +
    + {{ range first 4 . }} +
  • + +
  • + {{ end }} +
+ {{ end }} + + {{ with $links }} + + {{ end }} +
+ {{ end }} +
+ + + {{ else }} +

+ The package registry could not be loaded. Browse it directly on + GitHub. +

{{ end }} diff --git a/static/main.js b/static/main.js index 5eb34b9..738c89a 100644 --- a/static/main.js +++ b/static/main.js @@ -1,16 +1,87 @@ -const filterPackages = () => { - const trs = document.querySelectorAll('.eco-table-row'); - const filter = document.querySelector('#eco-filter').value; - const regex = new RegExp(filter, 'i'); - const tdFound = td => regex.test(td.innerHTML); - const pkgFound = childrenArr => childrenArr.some(tdFound); - const toggleTrs = ({ style, children }) => { - style.display = pkgFound([ - ...children - ]) ? '' : 'none' ; +// Ecosystem package registry: free-text search combined with a single active tag filter. +const initEcosystemRegistry = () => { + const root = document.querySelector('#ecosystem-packages'); + if (!root) return; + + const input = root.querySelector('#eco-filter'); + const chipRow = root.querySelector('#eco-chips'); + const grid = root.querySelector('#eco-grid'); + const counter = root.querySelector('#eco-count'); + const empty = root.querySelector('#eco-empty'); + const clearButton = root.querySelector('#eco-clear'); + const cards = Array.from(grid.querySelectorAll('.eco-card')); + let activeTag = ''; + + const apply = () => { + const terms = input.value.toLowerCase().split(/\s+/).filter(Boolean); + let shown = 0; + cards.forEach(card => { + const matchesQuery = terms.every(term => card.dataset.search.includes(term)); + const matchesTag = !activeTag || card.dataset.tags.includes(`|${activeTag}|`); + const visible = matchesQuery && matchesTag; + card.hidden = !visible; + if (visible) shown += 1; + }); + counter.textContent = shown; + empty.hidden = shown !== 0; + clearButton.hidden = !terms.length && !activeTag; + }; + + // A tag picked from a card may not be in the pre-rendered chip row, so add it on demand. + const syncChips = () => { + if (activeTag && !chipRow.querySelector(`.eco-chip[data-tag="${CSS.escape(activeTag)}"]`)) { + const chip = document.createElement('button'); + chip.type = 'button'; + chip.className = 'eco-chip eco-chip--custom'; + chip.dataset.tag = activeTag; + chip.textContent = activeTag; + chipRow.insertBefore(chip, chipRow.firstElementChild.nextSibling); + } + chipRow.querySelectorAll('.eco-chip').forEach(chip => { + const isActive = chip.dataset.tag === activeTag; + chip.classList.toggle('is-active', isActive); + chip.setAttribute('aria-pressed', String(isActive)); + if (!isActive && chip.classList.contains('eco-chip--custom')) chip.remove(); + }); + }; + + const setTag = tag => { + activeTag = activeTag === tag ? '' : tag; + syncChips(); + apply(); }; - trs.forEach(toggleTrs); -} + + const reset = () => { + input.value = ''; + activeTag = ''; + syncChips(); + apply(); + }; + + input.addEventListener('input', apply); + input.addEventListener('keydown', event => { + if (event.key === 'Escape') reset(); + }); + clearButton.addEventListener('click', () => { + reset(); + input.focus(); + }); + empty.querySelector('.eco-empty-reset').addEventListener('click', reset); + + chipRow.addEventListener('click', event => { + const chip = event.target.closest('.eco-chip'); + if (chip) setTag(chip.dataset.tag); + }); + + grid.addEventListener('click', event => { + const tag = event.target.closest('.eco-tag'); + if (!tag) return; + event.preventDefault(); + setTag(tag.dataset.tag); + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + root.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'start' }); + }); +}; const filterTutorials = () => { const trs = document.querySelectorAll('.tutorial-item'); @@ -247,11 +318,7 @@ const initInteractiveViz = () => { // Initialize everything when DOM is loaded document.addEventListener('DOMContentLoaded', function() { - // Set up event listeners for filters if they exist - const ecoFilter = document.querySelector('#eco-filter') - if (ecoFilter) { - ecoFilter.addEventListener('input', filterPackages) - } + initEcosystemRegistry() const tutorialFilter = document.querySelector('#tutorial-filter') if (tutorialFilter) { From df9b1b3e942c015a565ea612d9068397ac393b6f Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Tue, 11 Aug 2026 10:48:29 +0200 Subject: [PATCH 2/6] Pin Netlify deploy previews to the Hugo version production builds with Deploy previews built with Netlify's default Hugo 0.97.2, from 2022, while production builds with `latest`, currently 0.164.0. Previews therefore did not reflect what gets published, and the `try` guard around the registry fetch failed to parse on the older version. Pin previews to 0.164.0, and drop `try`, which needs Hugo 0.141+, in favour of the plain nil check the template used before. The fallback markup stays, so an unreachable registry still renders a link rather than an empty section. --- layouts/packages/list.html | 8 ++------ netlify.toml | 5 +++++ 2 files changed, 7 insertions(+), 6 deletions(-) create mode 100644 netlify.toml diff --git a/layouts/packages/list.html b/layouts/packages/list.html index d0baded..27d59d8 100644 --- a/layouts/packages/list.html +++ b/layouts/packages/list.html @@ -67,12 +67,8 @@

Ecosystem packages maintained by scverse community

{{ $sections.ecosystem | markdownify }} {{ $registryBase := "https://scverse.org/ecosystem-packages/" }} - {{ $data := false }} - {{ with try (resources.GetRemote (printf "%spackages.json" $registryBase)) }} - {{ with .Value }} - {{ $data = . }} - {{ end }} - {{ end }} + {{/* Netlify previews build with Hugo 0.97, so no `try` here — it needs 0.141+. */}} + {{ $data := resources.GetRemote (printf "%spackages.json" $registryBase) }} {{ if $data }} {{ $pkgs := transform.Unmarshal $data.Content }} diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..b83ddc5 --- /dev/null +++ b/netlify.toml @@ -0,0 +1,5 @@ +# Deploy previews used to build with Netlify's default Hugo (0.97.2, from 2022), so they did +# not reflect what gets published: .github/workflows/gh-pages.yml builds with `latest`, which +# currently resolves to 0.164.0. Pin previews to that, and bump both together from here on. +[build.environment] +HUGO_VERSION = "0.164.0" From 166fecc6657b8abd8b2f71fff18219f335be7443 Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Tue, 11 Aug 2026 10:51:50 +0200 Subject: [PATCH 3/6] Restore the `try` guard around the registry fetch Now that deploy previews build with the same Hugo as production, the template can use `try` again: an unreachable registry falls back to a link instead of failing the build. Verified by pointing the fetch at a 404 and confirming the page still builds with the fallback markup. Note the resulting minimum Hugo version in the README. --- README.md | 3 +++ layouts/packages/list.html | 7 +++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4460cee..414110f 100644 --- a/README.md +++ b/README.md @@ -26,3 +26,6 @@ To build the website (with hot reload), [install Hugo](https://gohugo.io/getting ```sh hugo server --buildFuture -D ``` + +Use the extended version, 0.141 or newer. +Deploy previews are pinned to a specific version in [`netlify.toml`](/netlify.toml); keep it in step with the one [the deploy workflow](/.github/workflows/gh-pages.yml) uses. diff --git a/layouts/packages/list.html b/layouts/packages/list.html index 27d59d8..4ebaf61 100644 --- a/layouts/packages/list.html +++ b/layouts/packages/list.html @@ -67,8 +67,11 @@

Ecosystem packages maintained by scverse community

{{ $sections.ecosystem | markdownify }} {{ $registryBase := "https://scverse.org/ecosystem-packages/" }} - {{/* Netlify previews build with Hugo 0.97, so no `try` here — it needs 0.141+. */}} - {{ $data := resources.GetRemote (printf "%spackages.json" $registryBase) }} + {{/* An unreachable registry should not fail the build, so swallow the error and fall back. */}} + {{ $data := false }} + {{ with try (resources.GetRemote (printf "%spackages.json" $registryBase)) }} + {{ $data = .Value }} + {{ end }} {{ if $data }} {{ $pkgs := transform.Unmarshal $data.Content }} From fe44400e6c72fda82e84c85ce87262a4667cc20a Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Tue, 11 Aug 2026 10:53:57 +0200 Subject: [PATCH 4/6] Drop the Hugo version note from the README --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index 414110f..4460cee 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,3 @@ To build the website (with hot reload), [install Hugo](https://gohugo.io/getting ```sh hugo server --buildFuture -D ``` - -Use the extended version, 0.141 or newer. -Deploy previews are pinned to a specific version in [`netlify.toml`](/netlify.toml); keep it in step with the one [the deploy workflow](/.github/workflows/gh-pages.yml) uses. From d8ea9ad8ca379a8675c9652cdb4f095ee8940ec5 Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Tue, 11 Aug 2026 11:27:55 +0200 Subject: [PATCH 5/6] Revert the registry fetch to failing the build Wrapping the fetch in `try` traded a loud failure for a silent one: a transient outage would have published the page with all 118 packages replaced by a fallback line, on a green build, until whenever the next deploy happened. Failing the build keeps the last good page live, since the deploy step in gh-pages.yml only runs after a successful build, and puts a red X in front of someone who can act on it. Degrading gracefully is worth revisiting on its own, backed by a committed snapshot of packages.json so an outage means stale data rather than an empty section, but it does not belong in a redesign. --- layouts/packages/list.html | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/layouts/packages/list.html b/layouts/packages/list.html index 4ebaf61..4f529d6 100644 --- a/layouts/packages/list.html +++ b/layouts/packages/list.html @@ -67,13 +67,9 @@

Ecosystem packages maintained by scverse community

{{ $sections.ecosystem | markdownify }} {{ $registryBase := "https://scverse.org/ecosystem-packages/" }} - {{/* An unreachable registry should not fail the build, so swallow the error and fall back. */}} - {{ $data := false }} - {{ with try (resources.GetRemote (printf "%spackages.json" $registryBase)) }} - {{ $data = .Value }} - {{ end }} - {{ if $data }} - {{ $pkgs := transform.Unmarshal $data.Content }} + {{ $data := resources.GetRemote (printf "%spackages.json" $registryBase) }} + {{ $pkgs := transform.Unmarshal $data.Content }} + {{ if $pkgs }} {{/* Author-provided tags are free-form, so fold the obvious spelling variants together. */}} {{ $aliases := dict @@ -344,11 +340,6 @@

- {{ else }} -

- The package registry could not be loaded. Browse it directly on - GitHub. -

{{ end }} From 3b96ce454bfb7b8d52749de9c1b9822634c335f3 Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Thu, 13 Aug 2026 10:28:49 +0200 Subject: [PATCH 6/6] Read the registry's controlled vocabulary instead of normalising tags scverse/ecosystem-packages#392 replaces free-form tags with a controlled vocabulary, a primary category and a language, so the normalisation this template was doing can go: the alias map, the tag counting, the `>= 3` threshold and the top-12 slice are all deleted. The chip row is now the categories actually in use, biggest first, which is stable across registry updates rather than shifting whenever a package is added. Tags filter from the cards, since 45 of them would not fit in a chip row, and non-Python packages get a language badge. Needs scverse/ecosystem-packages#392 merged and redeployed first. Until then the live packages.json has no primary_category, and the chip row renders empty. --- assets/main.scss | 8 ++++ layouts/packages/list.html | 93 ++++++++++++-------------------------- static/main.js | 44 ++++++++---------- 3 files changed, 58 insertions(+), 87 deletions(-) diff --git a/assets/main.scss b/assets/main.scss index 7b80211..90fe366 100644 --- a/assets/main.scss +++ b/assets/main.scss @@ -1276,6 +1276,10 @@ $eco-hues: background-color: $tiletext; border-radius: 0.25rem; } + .eco-badge--lang { + color: $tiletext; + background-color: $tilebg2; + } .eco-version { font-family: "JetBrains Mono", Menlo, monospace; } @@ -1321,6 +1325,10 @@ $eco-hues: color: $tiletext3; background-color: $tilebg; } + &.is-active { + color: white; + background-color: $tiletext; + } } .eco-card-links { diff --git a/layouts/packages/list.html b/layouts/packages/list.html index 4f529d6..969c1fc 100644 --- a/layouts/packages/list.html +++ b/layouts/packages/list.html @@ -71,64 +71,25 @@

Ecosystem packages maintained by scverse community

{{ $pkgs := transform.Unmarshal $data.Content }} {{ if $pkgs }} - {{/* Author-provided tags are free-form, so fold the obvious spelling variants together. */}} - {{ $aliases := dict - "single-cell" "single cell" - "singlecell" "single cell" - "single cell analysis" "single cell" - "scrnaseq" "scrna-seq" - "scrna seq" "scrna-seq" - "single cell rna-seq" "scrna-seq" - "single-cell rna-seq" "scrna-seq" - "rnaseq" "rna-seq" - "rna seq" "rna-seq" - "rna-sequencing" "rna-seq" - "spatial" "spatial omics" - "spatial-omics" "spatial omics" - "spatial transcriptomics" "spatial omics" - "spatial-transcriptomics" "spatial omics" - "spatialomics" "spatial omics" - "multi omics" "multi-omics" - "multi_omics" "multi-omics" - "multiomics" "multi-omics" - "multi-modal" "multimodal" - "deep-learning" "deep learning" - "deeplearning" "deep learning" - "machine-learning" "machine learning" - "differential-expression" "differential expression" - "differential gene expression" "differential expression" - "data structure" "data structures" - "optimal-transport" "optimal transport" - "cell-cell-communication" "cell-cell communication" - "cell cell communication" "cell-cell communication" - "gene regulatory networks" "gene networks" - "gene network inference" "gene networks" - "grn" "gene networks" - "rna velocity" "rna-velocity" - "trajectory-inference" "trajectory inference" - "trajectory-generation" "trajectory inference" - "pseudotime" "trajectory inference" - }} - - {{/* Normalise tags once, then reuse for the chip row, the cards and the search index. */}} + {{/* Categories and tags are a controlled vocabulary in the registry schema, so no + normalisation here: https://github.com/scverse/ecosystem-packages/blob/main/scripts/src/ecosystem_scripts/schema.json + */}} {{ $items := slice }} - {{ $tagCounts := dict }} + {{ $categories := slice }} {{ range $pkgs }} - {{ $tags := slice }} - {{ range (.tags | default slice) }} - {{ $t := trim (. | lower | replaceRE `\s+` " ") " " }} - {{ $t = index $aliases $t | default $t }} - {{ if and $t (not (in $tags $t)) }} - {{ $tags = $tags | append $t }} - {{ $tagCounts = merge $tagCounts (dict $t (add (index $tagCounts $t | default 0) 1)) }} - {{ end }} + {{ $tags := .tags | default slice }} + {{ $category := .primary_category | default "" }} + {{ if and $category (not (in $categories $category)) }} + {{ $categories = $categories | append $category }} {{ end }} {{/* Registry descriptions are plain text with the odd bit of markdown and hard line breaks. */}} {{ $desc := trim (.description | markdownify | plainify | htmlUnescape | replaceRE `\s+` " ") " " }} - {{ $search := lower (printf "%s %s %s" .name $desc (delimit $tags " ")) }} + {{ $search := lower (printf "%s %s %s %s %s" .name $desc (delimit $tags " ") $category .language) }} {{ $items = $items | append (merge . (dict "sortkey" (lower .name) "normtags" $tags + "category" $category + "language" .language "desc" $desc "search" $search )) @@ -136,14 +97,12 @@

Ecosystem packages maintained by scverse community

{{ end }} {{ $items = sort $items "sortkey" }} - {{/* Chip row: the tags that are common enough to be worth a shortcut. */}} - {{ $tagList := slice }} - {{ range $tag, $count := $tagCounts }} - {{ if ge $count 3 }} - {{ $tagList = $tagList | append (dict "tag" $tag "count" $count) }} - {{ end }} + {{/* Chip row: every category in the schema that is actually in use, biggest first. */}} + {{ $categoryCounts := slice }} + {{ range $categories }} + {{ $categoryCounts = $categoryCounts | append (dict "name" . "count" (len (where $items "category" .))) }} {{ end }} - {{ $topTags := sort (first 12 (sort $tagList "count" "desc")) "tag" }} + {{ $categoryCounts = sort $categoryCounts "count" "desc" }}
@@ -210,10 +169,10 @@

Ecosystem packages maintained by scverse community

- - {{ range $topTags }} - + {{ range $categoryCounts }} + {{ end }} @@ -286,7 +245,12 @@

Ecosystem packages maintained by scverse community

{{ end }} -
+
{{ with $e.logo }}