On section and subcategory pages, every row repeated its use case and current page as tags (and as the label under the name on phones), right under a heading that already said the same thing. They are now hidden in editorial order and shown again when a column sort flattens the groups.
Co-Authored-By: Claude <noreply@anthropic.com>
With tags now pointing at section anchors instead of filtering in place, subcategory pages (74 clicks and 19.7K impressions in the last 3 months) had no other internal links pointing to them.
Co-Authored-By: Claude <noreply@anthropic.com>
Clicking a category or group tag filtered the homepage table in place while rewriting the address bar to the category URL, so one URL rendered two different pages and readers never reached category pages with their intros and guides; category pages also showed a redundant self-referential filter bar.
Co-Authored-By: Claude <noreply@anthropic.com>
Category pages sorted rows by downloads, which buried editorial leads (the Django ORM showed as row 7 of 7, tkinter as 14 of 14) against CONTRIBUTING.md's "position is the marker"; the full intro in the hero pushed the table 2.5 screens down on a phone; and every page repeated the same "Search every project in one place" H2.
Co-Authored-By: Claude <noreply@anthropic.com>
Category page titles read "ORM Python Libraries", but people search "python orm", so the title word order didn't match the query.
Co-Authored-By: Claude <noreply@anthropic.com>
Category pages carried no text of their own beyond the README one-line description, and most meta descriptions fell back to a generic "Explore N curated Python projects" line, which correlated with weak search rankings for category queries. This adds optional per-category intro markdown files rendered under the H1, with the first paragraph used as the meta description and links opening in a new tab, starting with the ORM category.
Co-Authored-By: Claude <noreply@anthropic.com>
Renamed or dissolved category slugs (e.g. /categories/web-servers/rpc/, /categories/code-analysis/code-linters/) returned 404, Search Console lists 7 of them, and each audit re-home was dropping the old URL's ranking.
Co-Authored-By: Claude <noreply@anthropic.com>
The uv-audit bug had no automated guard: pypi_name_overrides.json is
a manual registry, so a wrong-package mapping is only caught if
someone already suspects it.
Two broader checks were measured against the real list and rejected.
Checking that PyPI metadata links back to the entry's GitHub repo
would not have caught uv-audit, since that package declares no
home_page or project_urls, landing it in a 26-entry bucket of
packages that simply don't declare a repo (numba, selenium, pyglet,
etc.), plus 10 benign cases of orgs moving or splitting bindings.
Flagging display-name/repo-name mismatches yields 46 hits, all
legitimate python-X-repo-to-X-package pairs, with uv-build sitting
among them despite being a real Astral package with the identical
shape to uv-audit.
What discriminates is the bundled marker itself: a "(part of X)"
entry ships inside something else and has no package of its own, so
the sweep must never query it. This test walks the real README and
requires a null override for every bundled entry whose normalized
name is PyPI-shaped. Verified it fails with exactly the uv-audit
message when that override is removed, and passes with it restored,
across the three current bundled entries with no false positives. It
runs offline, fitting the existing network-less CI.
Co-Authored-By: Claude <noreply@anthropic.com>
azure-sdk-for-python and google-cloud-python were rendering "Not on
PyPI", which is misleading. Both do ship on PyPI, just as many
per-service packages (azure-identity, azure-storage-blob,
google-cloud-storage, etc.) rather than under the repo name.
pypi_name_overrides.json already recorded that distinction in its
reason field; those two entries now carry an optional "badge" value
that build.py reads into the PyPI Downloads column. The other sixteen
no-count entries (cpython, renpy, agent skill repos, etc.) keep
"Not on PyPI" since that remains accurate for them.
Co-Authored-By: Claude <noreply@anthropic.com>
django.db.models, geodjango, httpx.URL and uv audit were rendering
"Not on PyPI" alongside eighteen genuinely standalone projects that
simply are not packaged on PyPI, conflating two different reasons for
a missing download count.
These four entries now carry a "(part of X)" description prefix in
README.md, mirroring the existing "(Python standard library)"
convention. build.py reads that prefix into a bundled flag that both
templates render as a "Bundled" badge.
The prefix approach was chosen over a separate data file so README.md
stays the single source of content truth, and over inferring from the
entry name because geodjango is neither dotted nor spaced and would
have been missed. Redundant tail wording was trimmed from the
httpx.URL, geodjango and uv audit descriptions now that the prefix
names the parent.
The new entry format is documented in CONTRIBUTING.md and the
vocabulary in CONTEXT.md.
Co-Authored-By: Claude <noreply@anthropic.com>
Standard-library entries rendered "Not on PyPI" in the PyPI Downloads
column, which read like missing data rather than a deliberate category
— the build already forces downloads to None for them so they never
pick up a same-named PyPI backport. They now render a "Stdlib" badge
instead, while genuine non-PyPI entries keep "Not on PyPI".
The filter tag is renamed to match, so the source-type value, the row
filter tag, and the synthetic category heading all read Stdlib now.
The literal "Built-in" strings scattered through build.py are routed
through the existing BUILTIN_FILTER constant so the label lives in one
place. The category page slug stays "built-in" so the public URL
/categories/built-in/ does not break.
Co-Authored-By: Claude <noreply@anthropic.com>
Entries now end with "(PyPI downloads/month: N, GitHub stars: M)" where
known, replacing the stars-only note, since download counts are the
list's stated primary evidence signal. annotate_entries_with_stars is
renamed to annotate_entries_with_stats and looks downloads up by the
first link's display name, skipping category-index bullets (which link
into the site itself) and Built-in entries (which would otherwise hit
same-named PyPI backports like logging or asyncio).
The intro now mirrors the README subtitle verbatim with the
project/category totals on their own line below, and the "opinionated
catalog" wording is gone since the shortlist ADR is literally titled
"shortlist, not a catalog".
Co-Authored-By: Claude <noreply@anthropic.com>
Entries with a download count now sort first (descending), with
stars, then Built-in, then name as fallback tiers for entries that
lack a count. main.js mirrors this in its default activeSort, clean
URL check, and third-click reset target. Sorting by stars remains one
header click away.
Co-Authored-By: Claude <noreply@anthropic.com>
Sourced from website/data/pypi_downloads.tsv the same way
github_stars.json feeds the stars column. The new sortable column
sits between GitHub Stars and Last Commit on the homepage and
category pages, formatted with thousands separators like stars, with
an em dash when no PyPI data exists. Rows are matched by normalized
README display name; Built-in entries never show counts since
same-named PyPI packages are stdlib backports (e.g. the asyncio
package).
Below 960px the column hides and the count moves into the expand
row, mirroring the existing Last Commit treatment. main.js gains the
downloads sort branch and URL param.
The deploy workflow fetches the TSV via the new
make fetch_pypi_downloads target with a daily actions/cache
fallback, mirroring the stars fetch, but non-fatal: the column
degrades to dashes when the fetch fails, unlike stars which the
build requires.
Co-Authored-By: Claude <noreply@anthropic.com>
- Wrap category pages in a self-contained @graph (WebSite + CollectionPage)
- Set canonical @id on CollectionPage to its URL (no hash fragment)
- Expand isPartOf to typed object {"@type": "WebSite", "@id": ...}
- Extract _website_node() and ISPARTOF_WEBSITE constants to avoid repetition
- Update tests to assert @graph structure on category pages
Co-Authored-By: Claude <noreply@anthropic.com>
Drops tests that either duplicate coverage already provided by adjacent
cases (single-word slugify, trailing-slash checks) or hard-code first-
category names and specific description strings that break whenever the
README content shifts.
Co-Authored-By: Claude <noreply@anthropic.com>
Adds a dedicated sponsorship page at /sponsorship/ built from the Jinja2
template, with hero stats, tier cards, and CSS. Updates the index.html
sponsor sidebar link to point to /sponsorship/ instead of the GitHub
SPONSORSHIP.md. Adds the URL to the sitemap and test fixtures.
Also renames .impeccable.md to DESIGN.md.
Co-Authored-By: Claude <noreply@anthropic.com>
Add search input, filter chips, no-results block, and back-to-top
button to category/group/subcategory pages. Pass filter_urls_json to
all page types so tag-chip navigation works site-wide. Fix JS so
filter-clear and no-results-clear redirect to / on non-index pages
instead of trying to filter a non-existent local table. Remove the
now-redundant .category-results CSS overrides.
Co-Authored-By: Claude <noreply@anthropic.com>
`| safe` bypasses Jinja autoescape. If a category name ever contained
"</script>", the literal substring would close the script block early,
leaking JSON content into the DOM and creating an XSS vector. Replace
"</" with "<\\/" (still valid JSON) and pass ensure_ascii=False so
non-ASCII names render readably. Also add a group_path() helper to
parallel category_path()/subcategory_path() and reuse category_urls
when seeding filter_urls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds filter_urls dict (categories, groups, subcategories) in build.py,
passes filter_urls_json to the template, and injects a JSON script block
before the results section in index.html. Covered by a new test that
verifies all three URL types are present and correctly resolved.
Co-Authored-By: Claude <noreply@anthropic.com>
Membership-only assertions wouldn't catch phantom URLs added by future
build changes. Tighten back to an exact-list assertion now that we know
the fixture's exact output, and assert lastmod count tracks loc count.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the .category-subtitle a underline style for visual cohesion in
the hero, and locks in the gating behavior with a negative assertion so
a regression that drops the page_kind guard would be caught.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Categories and groups will share the /categories/ URL namespace.
Fail the build with a clear error message if a future README change
introduces a collision.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Avoids a slug collision between the group "Miscellaneous" and the
category of the same name once both share the /categories/ URL
namespace introduced in the upcoming filter-URL refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add llms.txt Jinja2 template with a categories_md placeholder
- Extract categories body from README and inject it into the template
- Annotate bullet-entry lines with GitHub star counts (N GitHub stars)
for the main index.md and bare numbers for llms.txt
- Add TestAnnotateEntriesWithStars unit tests
Co-Authored-By: Claude <noreply@anthropic.com>