From f2b4a7bc83ef4a61a9e8bbd574dd302ac4926356 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Mon, 23 Mar 2026 01:04:20 +0800 Subject: [PATCH] feat(website): surface subcategory labels as filterable tags Entries nested under a plain-text subcategory heading (e.g. "Frameworks" inside Testing) now carry a subcategory field populated by the parser. The build pipeline collects these into a subcategories list on each merged entry, and the template renders them as tag-subcat buttons that plug into the existing data-cats filter mechanism. A dedicated .tag-subcat style distinguishes them visually from category tags, and both are hidden on mobile alongside .tag-group. Co-Authored-By: Claude --- website/build.py | 4 ++++ website/readme_parser.py | 11 +++++++++-- website/static/style.css | 9 ++++++++- website/templates/index.html | 10 +++++++++- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/website/build.py b/website/build.py index 2a6116a8..821c6814 100644 --- a/website/build.py +++ b/website/build.py @@ -102,6 +102,9 @@ def extract_entries( existing["categories"].append(cat["name"]) if group_name not in existing["groups"]: existing["groups"].append(group_name) + subcat = entry["subcategory"] + if subcat and subcat not in existing["subcategories"]: + existing["subcategories"].append(subcat) else: merged = { "name": entry["name"], @@ -109,6 +112,7 @@ def extract_entries( "description": entry["description"], "categories": [cat["name"]], "groups": [group_name], + "subcategories": [entry["subcategory"]] if entry["subcategory"] else [], "stars": None, "owner": None, "last_commit_at": None, diff --git a/website/readme_parser.py b/website/readme_parser.py index c0ecfc60..1068a339 100644 --- a/website/readme_parser.py +++ b/website/readme_parser.py @@ -20,6 +20,7 @@ class ParsedEntry(TypedDict): url: str description: str # inline HTML, properly escaped also_see: list[AlsoSee] + subcategory: str # sub-category label, empty if none class ParsedSection(TypedDict): @@ -178,7 +179,11 @@ def _extract_description_html(inline: SyntaxTreeNode, first_link: SyntaxTreeNode return _DESC_SEP_RE.sub("", html) -def _parse_list_entries(bullet_list: SyntaxTreeNode) -> list[ParsedEntry]: +def _parse_list_entries( + bullet_list: SyntaxTreeNode, + *, + subcategory: str = "", +) -> list[ParsedEntry]: """Extract entries from a bullet_list AST node. Handles three patterns: @@ -200,9 +205,10 @@ def _parse_list_entries(bullet_list: SyntaxTreeNode) -> list[ParsedEntry]: if first_link is None or not _is_leading_link(inline, first_link): # Subcategory label (plain text or text-before-link) — recurse into nested list + label = render_inline_text(inline.children) nested = _find_child(list_item, "bullet_list") if nested: - entries.extend(_parse_list_entries(nested)) + entries.extend(_parse_list_entries(nested, subcategory=label)) continue # Entry with a link @@ -231,6 +237,7 @@ def _parse_list_entries(bullet_list: SyntaxTreeNode) -> list[ParsedEntry]: url=url, description=desc_html, also_see=also_see, + subcategory=subcategory, )) return entries diff --git a/website/static/style.css b/website/static/style.css index 5c3d796f..97d6b864 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -779,6 +779,12 @@ th[data-sort].sort-asc::after { color: var(--hero-ink); } +.tag-subcat { + background: var(--bg-paper-strong); + color: var(--ink-soft); + font-weight: 600; +} + .back-to-top { border: 0; background: none; @@ -991,7 +997,8 @@ th[data-sort].sort-asc::after { display: none; } - .tag-group { + .tag-group, + .tag-subcat { display: none; } diff --git a/website/templates/index.html b/website/templates/index.html index 602df0bd..0689d260 100644 --- a/website/templates/index.html +++ b/website/templates/index.html @@ -121,7 +121,7 @@ {% for entry in entries %} {{ cat }} + {% endfor %} {% for subcat in entry.subcategories %} + {% endfor %}