feat: group category page rows by use case, restructure intro

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>
This commit is contained in:
Vinta Chen
2026-09-26 23:33:57 +08:00
co-authored by Claude
parent 528f9edbb7
commit 2b9469c0ae
5 changed files with 370 additions and 19 deletions
+55 -6
View File
@@ -66,6 +66,13 @@ class TemplateEntry(TypedDict):
also_see: list[AlsoSee]
class EntryGroup(TypedDict):
name: str # empty for a page with a single unnamed group
slug: str
url: str # links the group heading to its own page, empty when it has none
entries: list[TemplateEntry]
class SyntheticCategory(TypedDict):
name: str
slug: str
@@ -237,13 +244,16 @@ def category_meta_description(name: str, entry_count: int, description: str, par
return f"{count_sentence} Part of the Awesome Python catalog."
def load_category_intro(path: Path) -> tuple[str, str]:
"""Render a category intro file to HTML, plus its first paragraph as plain text for the meta description.
def load_category_intro(path: Path) -> tuple[str, str, str]:
"""Render a category intro file to HTML, split at the end of its "How to choose:" list.
Returns empty strings if the category has no intro file.
Returns the part shown above the table, the guide shown below it, and the
first paragraph as plain text for the meta description. A file without the
list keeps everything above the table. Returns empty strings if the category
has no intro file.
"""
if not path.exists():
return "", ""
return "", "", ""
md = MarkdownIt("commonmark")
tokens = md.parse(path.read_text(encoding="utf-8"))
for token in tokens:
@@ -252,7 +262,38 @@ def load_category_intro(path: Path) -> tuple[str, str]:
child.attrSet("target", "_blank")
child.attrSet("rel", "noopener")
lead = next(node for node in SyntaxTreeNode(tokens).children if node.type == "paragraph")
return md.renderer.render(tokens, md.options, {}), render_inline_text(lead.children[0].children)
split_at = len(tokens)
for i, token in enumerate(tokens):
if token.type == "inline" and token.level == 1 and token.content == "How to choose:" and i + 2 < len(tokens) and tokens[i + 2].type == "bullet_list_open":
split_at = next(j for j in range(i + 3, len(tokens)) if tokens[j].type == "bullet_list_close" and tokens[j].level == 0) + 1
break
render = md.renderer.render
return render(tokens[:split_at], md.options, {}), render(tokens[split_at:], md.options, {}), render_inline_text(lead.children[0].children)
def group_section_entries(section: ParsedSection, entries_by_key: dict[tuple[str, str], TemplateEntry]) -> list[EntryGroup]:
"""Group a section's entries by use case (subcategory), both in README order."""
groups: dict[str, EntryGroup] = {}
for parsed in section["entries"]:
name = parsed["subcategory"]
group = groups.setdefault(name, EntryGroup(name=name, slug=slugify(name) if name else "", url="", entries=[]))
group["entries"].append(entries_by_key[(parsed["url"], parsed["name"])])
return list(groups.values())
def group_entries_by_section(sections: Sequence[ParsedSection], entries_by_key: dict[tuple[str, str], TemplateEntry]) -> list[EntryGroup]:
"""Group a thematic group's entries by section, both in README order, listing each entry once."""
placed: set[tuple[str, str]] = set()
groups: list[EntryGroup] = []
for section in sections:
entries: list[TemplateEntry] = []
for parsed in section["entries"]:
key = (parsed["url"], parsed["name"])
if key not in placed:
placed.add(key)
entries.append(entries_by_key[key])
groups.append(EntryGroup(name=section["name"], slug=section["slug"], url=category_path(section), entries=entries))
return groups
def build_breadcrumb_json_ld(items: Sequence[tuple[str, str]]) -> dict:
@@ -692,11 +733,12 @@ def build(repo_root: Path) -> None:
page_dir: Path,
parent_category: ParsedSection | None = None,
group_categories: Sequence[ParsedSection] | None = None,
entry_groups: Sequence[EntryGroup] = (),
) -> None:
page_dir.mkdir(parents=True, exist_ok=True)
parent_name = parent_category["name"] if parent_category else None
category_title = category_meta_title(category["name"], parent_name)
intro_html, intro_lead = load_category_intro(website / "data" / "category_intros" / f"{current_path.removeprefix('/categories/').strip('/')}.md")
intro_html, guide_html, intro_lead = load_category_intro(website / "data" / "category_intros" / f"{current_path.removeprefix('/categories/').strip('/')}.md")
category_description = intro_lead or category_meta_description(category["name"], len(entries), category["description"], parent_name)
breadcrumbs = [("Awesome Python", SITE_URL)]
if parent_category:
@@ -713,7 +755,9 @@ def build(repo_root: Path) -> None:
category_url=category_url,
category_description=category_description,
intro_html=intro_html,
guide_html=guide_html,
entries=entries,
entry_groups=entry_groups,
total_categories=len(categories),
category_urls=category_urls,
current_path=current_path,
@@ -726,6 +770,8 @@ def build(repo_root: Path) -> None:
encoding="utf-8",
)
entries_by_key = {(e["url"], e["name"]): e for e in entries}
section_groups = {category["name"]: group_section_entries(category, entries_by_key) for category in categories}
for category in categories:
render_category(
category,
@@ -733,6 +779,7 @@ def build(repo_root: Path) -> None:
entries=[e for e in entries if category["name"] in e["categories"]],
current_path=category_path(category),
page_dir=categories_dir / category["slug"],
entry_groups=section_groups[category["name"]],
)
for group in parsed_groups:
@@ -743,6 +790,7 @@ def build(repo_root: Path) -> None:
current_path=group_path(group["slug"]),
page_dir=categories_dir / group["slug"],
group_categories=group["categories"],
entry_groups=group_entries_by_section(group["categories"], entries_by_key),
)
if builtin_entries:
@@ -792,6 +840,7 @@ def build(repo_root: Path) -> None:
current_path=subcategory_path(cat_slug, sub_slug),
page_dir=categories_dir / cat_slug / sub_slug,
parent_category=cat_by_slug[cat_slug],
entry_groups=[EntryGroup(name="", slug="", url="", entries=group["entries"]) for group in section_groups[cat_by_slug[cat_slug]["name"]] if group["name"] == sub_name],
)
redirects_file = website / "data" / "redirects.json"
+32 -3
View File
@@ -4,8 +4,14 @@ function getScrollBehavior() {
return reducedMotion.matches ? "auto" : "smooth";
}
const table = document.querySelector(".table");
// Category pages list rows in editorial (README) order until a column is sorted
const defaultSort =
table && table.dataset.defaultSort === "editorial"
? { col: "editorial", order: "asc" }
: { col: "downloads", order: "desc" };
let activeFilter = null;
let activeSort = { col: "downloads", order: "desc" };
let activeSort = defaultSort;
const searchInput = document.querySelector(".search");
const filterBar = document.querySelector(".filter-bar");
const filterValue = document.querySelector(".filter-value");
@@ -14,6 +20,7 @@ const noResults = document.querySelector(".no-results");
const rows = document.querySelectorAll(".table tbody tr.row");
const tags = document.querySelectorAll(".tag");
const tbody = document.querySelector(".table tbody");
const groupRows = document.querySelectorAll(".table tbody tr.group-row");
function initRevealSections() {
const sections = document.querySelectorAll("[data-reveal]");
@@ -111,6 +118,12 @@ document
time.textContent = relativeTime(time.getAttribute("datetime"));
});
let currentGroupRow = null;
Array.prototype.forEach.call(tbody ? tbody.rows : [], function (tr) {
if (tr.classList.contains("group-row")) currentGroupRow = tr;
else if (tr.classList.contains("row")) tr._groupRow = currentGroupRow;
});
rows.forEach(function (row, i) {
row._origIndex = i;
let next = row.nextElementSibling;
@@ -176,6 +189,15 @@ function applyFilters() {
}
});
groupRows.forEach(function (groupRow) {
groupRow.hidden = true;
});
if (activeSort.col === "editorial") {
rows.forEach(function (row) {
if (!row.hidden && row._groupRow) row._groupRow.hidden = false;
});
}
if (noResults) noResults.hidden = visibleCount > 0;
tags.forEach(function (tag) {
@@ -211,7 +233,7 @@ function buildQueryString() {
const params = new URLSearchParams();
const query = searchInput ? searchInput.value.trim() : "";
if (query) params.set("q", query);
if (activeSort.col !== "downloads" || activeSort.order !== "desc") {
if (activeSort.col !== defaultSort.col || activeSort.order !== defaultSort.order) {
params.set("sort", activeSort.col);
params.set("order", activeSort.order);
}
@@ -227,6 +249,8 @@ function updateURL() {
}
function getSortValue(row, col) {
// +1 keeps the first row above the "no value" cutoff in sortRows
if (col === "editorial") return row._origIndex + 1;
if (col === "name") {
return row.querySelector(".col-name a").textContent.trim().toLowerCase();
}
@@ -282,7 +306,12 @@ function sortRows() {
});
const frag = document.createDocumentFragment();
let lastGroupRow = null;
arr.forEach(function (row) {
if (col === "editorial" && row._groupRow && row._groupRow !== lastGroupRow) {
frag.appendChild(row._groupRow);
lastGroupRow = row._groupRow;
}
frag.appendChild(row);
if (row._descRow) frag.appendChild(row._descRow);
if (row._expandRow) frag.appendChild(row._expandRow);
@@ -392,7 +421,7 @@ sortHeaders.forEach(function (th) {
if (activeSort.col === col) {
if (activeSort.order === defaultOrder)
activeSort = { col: col, order: altOrder };
else activeSort = { col: "downloads", order: "desc" };
else activeSort = defaultSort;
} else {
activeSort = { col: col, order: defaultOrder };
}
+111 -7
View File
@@ -438,6 +438,7 @@ kbd {
.search:focus-visible,
.filter-clear:focus-visible,
.tag:focus-visible,
.jump-link:focus-visible,
.back-to-top:focus-visible,
.no-results-clear:focus-visible,
.table a:focus-visible,
@@ -459,9 +460,9 @@ kbd {
z-index: 1;
width: min(100%, calc(var(--shell-max) + (var(--shell-pad) * 2)));
margin: 0 auto;
padding: 1.25rem var(--shell-pad) clamp(3.75rem, 8vw, 6.75rem);
padding: 1.25rem var(--shell-pad) clamp(2.25rem, 5vw, 3.5rem);
display: grid;
gap: clamp(3rem, 8vw, 5.5rem);
gap: clamp(2rem, 5vw, 3rem);
}
.category-hero h1 {
@@ -497,7 +498,9 @@ kbd {
}
.category-subtitle a,
.category-intro a {
.category-intro a,
.guide-body a,
.jump-link {
color: var(--hero-text);
text-decoration: underline;
text-decoration-color: oklch(100% 0 0 / 0.32);
@@ -505,12 +508,14 @@ kbd {
}
.category-subtitle a:hover,
.category-intro a:hover {
.category-intro a:hover,
.guide-body a:hover,
.jump-link:hover {
text-decoration-color: oklch(100% 0 0 / 0.7);
}
.category-intro {
margin-top: 1.75rem;
margin-top: 1.25rem;
display: grid;
gap: 0.9rem;
color: var(--hero-muted);
@@ -530,10 +535,65 @@ kbd {
padding-left: 1.25rem;
}
.category-intro code {
.category-intro code,
.guide-body code {
font-size: 0.9em;
}
.jump-links {
display: flex;
flex-wrap: wrap;
gap: 0.45rem 1.6rem;
margin-top: 1.5rem;
}
.jump-link {
font-size: var(--text-base);
font-weight: 600;
}
/* The arrow marks an in-page jump; inline-block keeps it out of the underline */
.jump-link::after {
content: "\2193";
display: inline-block;
margin-left: 0.3rem;
color: var(--hero-kicker);
}
.guide-band {
padding-block: clamp(3rem, 7vw, 5.5rem);
background: linear-gradient(140deg, var(--hero-bg-start) 0%, var(--hero-bg-mid) 58%, var(--hero-bg-end) 100%);
color: var(--hero-text);
}
.guide-section {
display: grid;
gap: 1.5rem;
}
.guide-section h2 {
font-family: var(--font-display);
font-size: clamp(2.2rem, 4vw, 3.3rem);
font-weight: 600;
line-height: 0.94;
letter-spacing: -0.03em;
}
.guide-body {
display: grid;
gap: 1rem;
color: var(--hero-muted);
font-size: var(--text-lg);
line-height: 1.7;
text-wrap: pretty;
}
.guide-body ul {
display: grid;
gap: 0.35rem;
padding-left: 1.25rem;
}
.sponsor-band {
padding-block: clamp(2.5rem, 5.5vw, 4rem);
background:
@@ -651,6 +711,11 @@ kbd {
padding-bottom: 1.75rem;
}
.order-note {
color: var(--ink-soft);
font-size: var(--text-base);
}
.results-note {
color: var(--ink-soft);
font-size: var(--text-sm);
@@ -819,6 +884,44 @@ kbd {
cursor: pointer;
}
.group-row th {
padding-top: 2.25rem;
padding-bottom: 0.85rem;
padding-left: max(var(--shell-pad), calc(50vw - (var(--shell-max) / 2) + var(--shell-pad)));
text-align: left;
border-bottom: 1px solid var(--line-strong);
background: var(--bg-paper);
}
.group-row h2 {
display: flex;
align-items: baseline;
gap: 0.75rem;
font-family: var(--font-display);
font-size: clamp(1.7rem, 2.8vw, 2.2rem);
font-weight: 600;
line-height: 1;
scroll-margin-top: 4.5rem;
}
.group-row h2 a {
color: var(--ink);
}
.group-row h2 a:hover {
color: var(--accent-deep);
text-decoration: underline;
text-decoration-color: var(--accent-underline);
text-underline-offset: 0.2em;
}
.group-count {
font-family: var(--font-body);
font-size: var(--text-sm);
font-weight: 600;
color: var(--ink-muted);
}
.row:not(.open):hover td {
background: var(--row-hover);
}
@@ -1735,7 +1838,8 @@ th[data-sort].sort-asc::after {
}
.table thead th:first-child,
.table tbody td:first-child {
.table tbody td:first-child,
.group-row th {
padding-left: 0.8rem;
}
+52 -2
View File
@@ -40,6 +40,19 @@
{% if intro_html %}
<div class="category-intro">{{ intro_html | safe }}</div>
{% endif %}
{% set named_groups = entry_groups | selectattr("name") | list %}
{% if (named_groups and not group_categories) or guide_html %}
<nav class="jump-links" aria-label="On this page">
{% if not group_categories %}
{% for group in named_groups %}
<a class="jump-link" href="#{{ group.slug }}">{{ group.name }}</a>
{% endfor %}
{% endif %}
{% if guide_html %}
<a class="jump-link" href="#guide">{{ category.name }} guide</a>
{% endif %}
</nav>
{% endif %}
</div>
{% if group_categories %}
@@ -201,9 +214,17 @@
<script type="application/json" id="filter-urls">{{ filter_urls_json | safe }}</script>
<section class="results-section" id="library-index">
<div class="results-intro section-shell" data-reveal>
{% if entry_groups %}
{% set named_groups = entry_groups | selectattr("name") | list %}
<p class="order-note">
{% if group_categories %}Listed by section, in editorial order.{% elif named_groups %}Listed in editorial order, grouped by use case.{% else %}Listed in editorial order.{% endif %}
<strong>Click a column</strong> to re-sort the whole list.
</p>
{% else %}
<div>
<h2>Search every project in one place</h2>
<h2>{{ entries | length }} {{ category.name }} projects</h2>
</div>
{% endif %}
<p class="results-note">
Press <kbd>/</kbd> to search. Tap a tag to filter. Click any row for
details.
@@ -249,7 +270,7 @@
role="region"
aria-label="Libraries table"
>
<table class="table">
<table class="table"{% if entry_groups %} data-default-sort="editorial"{% endif %}>
<thead>
<tr>
<th class="col-num"><span class="sr-only">Row number</span></th>
@@ -274,9 +295,29 @@
</tr>
</thead>
<tbody>
{% if entry_groups %}
{% set row = namespace(index=0) %}
{% for group in entry_groups %}
{% if group.name %}
<tr class="group-row">
<th colspan="7" scope="rowgroup">
<h2 id="{{ group.slug }}">
{% if group.url %}<a href="{{ group.url }}">{{ group.name }}</a>{% else %}{{ group.name }}{% endif %}
<span class="group-count">{{ group.entries | length }} project{{ "s" if group.entries | length != 1 }}</span>
</h2>
</th>
</tr>
{% endif %}
{% for entry in group.entries %}
{% set row.index = row.index + 1 %}
{{ entry_rows(entry, row.index) }}
{% endfor %}
{% endfor %}
{% else %}
{% for entry in entries %}
{{ entry_rows(entry, loop.index) }}
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
@@ -290,6 +331,15 @@
</div>
</section>
{% if guide_html %}
<section class="guide-band" id="guide">
<div class="guide-section section-shell">
<h2>{{ category.name }} guide</h2>
<div class="guide-body">{{ guide_html | safe }}</div>
</div>
</section>
{% endif %}
<section class="final-cta" data-reveal>
<div class="section-shell">
<p class="section-label">Contribute</p>
+120 -1
View File
@@ -17,6 +17,7 @@ from build import (
detect_source_type,
extract_entries,
extract_github_repo,
load_category_intro,
load_downloads,
load_pypi_badges,
load_stars,
@@ -291,7 +292,7 @@ class TestBuild:
assert 'href="https://example.com/w1"' in category_html
assert "A widget." in category_html
assert 'href="https://github.com/owner/w2"' in category_html
assert '<table class="table">' in category_html
assert '<table class="table" data-default-sort="editorial">' in category_html
assert "42" in category_html
assert "2026-01-01T00:00:00+00:00" in category_html
@@ -1007,6 +1008,99 @@ class TestBuild:
assert "<li>Small apps: w1</li>" in category_html
assert '<a href="https://example.com/docs" target="_blank" rel="noopener">the docs</a>' in category_html
def test_build_renders_category_guide_below_table(self, tmp_path):
self._copy_real_templates(tmp_path)
(tmp_path / "README.md").write_text(self._REDIRECT_README, encoding="utf-8")
intros_dir = tmp_path / "website" / "data" / "category_intros"
intros_dir.mkdir(parents=True)
(intros_dir / "widgets.md").write_text("Use w1.\n\nHow to choose:\n\n- Small apps: w1\n\nSet up w1 once per process.\n", encoding="utf-8")
build(tmp_path)
category_html = (tmp_path / "website" / "output" / "categories" / "widgets" / "index.html").read_text(encoding="utf-8")
intro_html = category_html.split('<div class="category-intro">', 1)[1].split("</div>", 1)[0]
assert "<li>Small apps: w1</li>" in intro_html
assert "Set up w1" not in intro_html
guide_html = category_html.split('<section class="guide-band" id="guide">', 1)[1]
assert "<h2>Widgets guide</h2>" in guide_html
assert "<p>Set up w1 once per process.</p>" in guide_html
assert category_html.index('id="guide"') > category_html.index("</table>")
assert '<a class="jump-link" href="#guide">Widgets guide</a>' in category_html
def test_section_page_groups_rows_by_use_case_in_readme_order(self, tmp_path):
readme = textwrap.dedent("""\
# T
## Projects
**Tools**
### Widgets
- Small
- [w2](https://example.com/w2) - Second.
- [w1](https://example.com/w1) - First.
- Large
- [w3](https://example.com/w3) - Third.
- [sqlite3](https://docs.python.org/3/library/sqlite3.html) - Stdlib.
# Contributing
Done.
""")
self._copy_real_templates(tmp_path)
(tmp_path / "README.md").write_text(readme, encoding="utf-8")
build(tmp_path)
site = tmp_path / "website" / "output" / "categories"
html = (site / "widgets" / "index.html").read_text(encoding="utf-8")
assert 'data-default-sort="editorial"' in html
positions = [html.index(marker) for marker in ('<h2 id="small">', ">w2</a", ">w1</a", '<h2 id="large">', ">w3</a")]
assert positions == sorted(positions)
assert '<a class="jump-link" href="#small">Small</a>' in html
assert '<tr class="desc-row">' in html
subcategory_html = (site / "widgets" / "small" / "index.html").read_text(encoding="utf-8")
assert 'data-default-sort="editorial"' in subcategory_html
assert "group-row" not in subcategory_html
assert subcategory_html.index(">w2</a") < subcategory_html.index(">w1</a")
builtin_html = (site / "built-in" / "index.html").read_text(encoding="utf-8")
assert "data-default-sort" not in builtin_html
assert "group-row" not in builtin_html
def test_group_page_groups_rows_by_section_with_links(self, tmp_path):
readme = textwrap.dedent("""\
# T
## Projects
**AI & ML**
## Machine Learning
- [ml1](https://example.com/ml1) - ML.
## Deep Learning
- [dl1](https://example.com/dl1) - DL.
- [ml1](https://example.com/ml1) - ML again.
# Contributing
Done.
""")
self._copy_real_templates(tmp_path)
(tmp_path / "README.md").write_text(readme, encoding="utf-8")
build(tmp_path)
html = (tmp_path / "website" / "output" / "categories" / "ai-ml" / "index.html").read_text(encoding="utf-8")
assert 'data-default-sort="editorial"' in html
ml_heading = html.index('<a href="/categories/machine-learning/">Machine Learning</a>')
dl_heading = html.index('<a href="/categories/deep-learning/">Deep Learning</a>')
assert ml_heading < html.index(">ml1</a") < dl_heading < html.index(">dl1</a")
assert html.count(">ml1</a") == 1
assert 'class="jump-links"' not in html
def test_build_rejects_redirect_to_missing_page(self, tmp_path):
self._copy_real_templates(tmp_path)
(tmp_path / "README.md").write_text(self._REDIRECT_README, encoding="utf-8")
@@ -1348,3 +1442,28 @@ class TestLoadDownloads:
def test_missing_file_returns_empty(self, tmp_path):
assert load_downloads(tmp_path / "nope.tsv") == {}
# ---------------------------------------------------------------------------
# load_category_intro
# ---------------------------------------------------------------------------
class TestLoadCategoryIntro:
def test_splits_after_how_to_choose_list(self, tmp_path):
path = tmp_path / "widgets.md"
path.write_text("Use `w1` for most apps.\n\nHow to choose:\n\n- Small apps: w1\n- Big apps: w2\n\nConfigure w1 once.\n\nPin w2.\n", encoding="utf-8")
intro_html, guide_html, lead = load_category_intro(path)
assert intro_html == "<p>Use <code>w1</code> for most apps.</p>\n<p>How to choose:</p>\n<ul>\n<li>Small apps: w1</li>\n<li>Big apps: w2</li>\n</ul>\n"
assert guide_html == "<p>Configure w1 once.</p>\n<p>Pin w2.</p>\n"
assert lead == "Use w1 for most apps."
def test_keeps_everything_above_table_without_how_to_choose_list(self, tmp_path):
path = tmp_path / "widgets.md"
path.write_text("Use w1.\n\n- Small apps: w1\n\nConfigure w1 once.\n", encoding="utf-8")
intro_html, guide_html, _ = load_category_intro(path)
assert "Configure w1 once." in intro_html
assert guide_html == ""
def test_returns_empty_strings_without_intro_file(self, tmp_path):
assert load_category_intro(tmp_path / "missing.md") == ("", "", "")