+ {{ $e.name }} +
+ +{{ $e.desc }}
+ + {{ with $e.normtags }} + + {{ end }} + + {{ with $links }} +diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index e102164..8dc1669 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Hugo uses: peaceiris/actions-hugo@v3 with: - hugo-version: "latest" + hugo-version: "0.164.0" # keep in step with netlify.toml, so previews match production extended: true - name: Build diff --git a/assets/main.scss b/assets/main.scss index 4a72eb1..a906e3d 100644 --- a/assets/main.scss +++ b/assets/main.scss @@ -1027,9 +1027,370 @@ 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; + } + } + + // Packages maintained by the core team sort to the front and carry a wash of colour + .eco-card--core { + background-color: rgba($scviyellow, 0.08); + } + + .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-badge--lang { + color: $tiletext; + background-color: $tilebg2; + } + .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; + } + &.is-active { + color: white; + background-color: $tiletext; + } + } + + .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/config.toml b/config.toml index ba8f340..7e77399 100644 --- a/config.toml +++ b/config.toml @@ -11,3 +11,8 @@ images = ["img/scverse-social-card.png"] [markup.goldmark.renderer] unsafe= true + +# The ecosystem registry rebuilds this site when packages.json changes, so a cached copy defeats the trigger. +# Hugo caches remote resources forever by default, and on Netlify that cache survives between builds. +[caches.getresource] +maxAge = 0 diff --git a/content/packages/_index.md b/content/packages/_index.md index 4451a9f..f170caa 100644 --- a/content/packages/_index.md +++ b/content/packages/_index.md @@ -7,7 +7,7 @@ aliases = ["/projects/"] datastructures = "Data structures are the foundational building block for all scverse packages. Building upon common data structures ensures interoperability." datastructures_post = "In addition to these packages, we define standards on how to represent certain data types in these data structures. For now, such a specification is available for [Adaptive Immune Receptor Repertoire (AIRR) data](https://scirpy.scverse.org/en/latest/data-structure.html#storing-airr-rearrangement-data-in-anndata)." frameworks = "Frameworks provide essential algorithms and plotting functions for specific analysis steps, building on our data structures." - ecosystem = "Many popular packages rely on scverse functionality. For instance, they take advantage of established data format standards such as AnnData and MuData, or are designed to be integrated into the workflow of analysis frameworks. Here, we list ecosystem packages following development best practices (continuous testing, documented, available through standard distribution tools).\n\n *This listing is a work in progress. See [scverse/ecosystem-packages](https://github.com/scverse/ecosystem-packages) for inclusion criteria, and to submit more packages.*" + ecosystem = "Many popular packages rely on scverse functionality. For instance, they take advantage of established data format standards such as AnnData and MuData, or are designed to be integrated into the workflow of analysis frameworks. Here, we list ecosystem packages following development best practices (continuous testing, documented, available through standard distribution tools).\n\n *See [scverse/ecosystem-packages](https://github.com/scverse/ecosystem-packages) for inclusion criteria, and to submit more packages.*" [[datastructures]] name = "anndata" diff --git a/layouts/packages/list.html b/layouts/packages/list.html index 16779d7..65a92ce 100644 --- a/layouts/packages/list.html +++ b/layouts/packages/list.html @@ -65,41 +65,250 @@
{{ $sections.ecosystem | markdownify }}
-| Package | -Description | -
|---|---|
| - {{ $lib.name }} - | -{{ $lib.description | markdownify }} | -
{{ $e.desc }}
+ + {{ with $e.normtags }} + + {{ end }} + + {{ with $links }} ++ No packages match your filters. + +
{{ end }} diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..d16d066 --- /dev/null +++ b/netlify.toml @@ -0,0 +1,4 @@ +# 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" diff --git a/static/main.js b/static/main.js index 799fc1d..2574140 100644 --- a/static/main.js +++ b/static/main.js @@ -1,16 +1,83 @@ -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, one active category, one active tag. +// Categories and tags both come from the controlled vocabulary in the registry schema. +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 activeCategory = ''; + let activeTag = ''; + + const apply = () => { + const terms = input.value.toLowerCase().split(/\s+/).filter(Boolean); + let shown = 0; + cards.forEach(card => { + const visible = + terms.every(term => card.dataset.search.includes(term)) && + (!activeCategory || card.dataset.category === activeCategory) && + (!activeTag || card.dataset.tags.includes(`|${activeTag}|`)); + card.hidden = !visible; + if (visible) shown += 1; + }); + counter.textContent = shown; + empty.hidden = shown !== 0; + clearButton.hidden = !terms.length && !activeCategory && !activeTag; + root.querySelectorAll('.eco-tag').forEach(tag => { + tag.classList.toggle('is-active', tag.dataset.tag === activeTag); + }); }; - trs.forEach(toggleTrs); -} + + const syncChips = () => { + chipRow.querySelectorAll('.eco-chip').forEach(chip => { + const isActive = chip.dataset.category === activeCategory; + chip.classList.toggle('is-active', isActive); + chip.setAttribute('aria-pressed', String(isActive)); + }); + }; + + const reset = () => { + input.value = ''; + activeCategory = ''; + 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) return; + activeCategory = activeCategory === chip.dataset.category ? '' : chip.dataset.category; + syncChips(); + apply(); + }); + + // Tags are not in the chip row — there are too many — so they filter from the cards themselves. + grid.addEventListener('click', event => { + const tag = event.target.closest('.eco-tag'); + if (!tag) return; + event.preventDefault(); + activeTag = activeTag === tag.dataset.tag ? '' : tag.dataset.tag; + apply(); + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + root.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'start' }); + }); +}; // 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. @@ -19,7 +86,16 @@ const initTableOfContents = () => { const body = document.querySelector('.with-toc-body'); if (!toc || !body) return; - const headings = [...body.querySelectorAll('h2, h3')].filter(heading => heading.textContent.trim()); + // Headings inside a nested