From 2b9469c0ae1c8f9fd3fb3f47d5fb4e7f2db3c0f1 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sat, 26 Sep 2026 23:33:57 +0800 Subject: [PATCH] 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 --- website/build.py | 61 ++++++++++++++-- website/static/main.js | 35 ++++++++- website/static/style.css | 118 +++++++++++++++++++++++++++++-- website/templates/category.html | 54 +++++++++++++- website/tests/test_build.py | 121 +++++++++++++++++++++++++++++++- 5 files changed, 370 insertions(+), 19 deletions(-) diff --git a/website/build.py b/website/build.py index fefc2f77..9164ca1a 100644 --- a/website/build.py +++ b/website/build.py @@ -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" diff --git a/website/static/main.js b/website/static/main.js index 11d43861..307e3f92 100644 --- a/website/static/main.js +++ b/website/static/main.js @@ -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 }; } diff --git a/website/static/style.css b/website/static/style.css index bdad3835..f7bddd67 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -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; } diff --git a/website/templates/category.html b/website/templates/category.html index 9fca1b26..a9618aef 100644 --- a/website/templates/category.html +++ b/website/templates/category.html @@ -40,6 +40,19 @@ {% if intro_html %}
{{ intro_html | safe }}
{% endif %} + {% set named_groups = entry_groups | selectattr("name") | list %} + {% if (named_groups and not group_categories) or guide_html %} + + {% endif %} {% if group_categories %} @@ -201,9 +214,17 @@
+ {% if entry_groups %} + {% set named_groups = entry_groups | selectattr("name") | list %} +

+ {% 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 %} + Click a column to re-sort the whole list. +

+ {% else %}
-

Search every project in one place

+

{{ entries | length }} {{ category.name }} projects

+ {% endif %}

Press / to search. Tap a tag to filter. Click any row for details. @@ -249,7 +270,7 @@ role="region" aria-label="Libraries table" > - +
@@ -274,9 +295,29 @@ + {% if entry_groups %} + {% set row = namespace(index=0) %} + {% for group in entry_groups %} + {% if group.name %} + + + + {% 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 %}
Row number
+

+ {% if group.url %}{{ group.name }}{% else %}{{ group.name }}{% endif %} + {{ group.entries | length }} project{{ "s" if group.entries | length != 1 }} +

+

@@ -290,6 +331,15 @@
+{% if guide_html %} +
+
+

{{ category.name }} guide

+
{{ guide_html | safe }}
+
+
+{% endif %} +
diff --git a/website/tests/test_build.py b/website/tests/test_build.py index d32486b3..cfebc616 100644 --- a/website/tests/test_build.py +++ b/website/tests/test_build.py @@ -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 '' in category_html + assert '
' 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 "
  • Small apps: w1
  • " in category_html assert 'the docs' 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('
    ', 1)[1].split("
    ", 1)[0] + assert "
  • Small apps: w1
  • " in intro_html + assert "Set up w1" not in intro_html + guide_html = category_html.split('
    ', 1)[1] + assert "

    Widgets guide

    " in guide_html + assert "

    Set up w1 once per process.

    " in guide_html + assert category_html.index('id="guide"') > category_html.index("
    ") + assert 'Widgets guide' 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 ('

    ', ">w2w1', ">w3Small' in html + assert '' 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(">w2w1Machine Learning') + dl_heading = html.index('Deep Learning') + assert ml_heading < html.index(">ml1dl1ml1Use w1 for most apps.

    \n

    How to choose:

    \n
      \n
    • Small apps: w1
    • \n
    • Big apps: w2
    • \n
    \n" + assert guide_html == "

    Configure w1 once.

    \n

    Pin w2.

    \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") == ("", "", "")