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 <noreply@anthropic.com>
This commit is contained in:
Vinta Chen
2026-03-23 01:04:20 +08:00
parent 1c249d4b5f
commit f2b4a7bc83
4 changed files with 30 additions and 4 deletions

View File

@@ -102,6 +102,9 @@ def extract_entries(
existing["categories"].append(cat["name"]) existing["categories"].append(cat["name"])
if group_name not in existing["groups"]: if group_name not in existing["groups"]:
existing["groups"].append(group_name) existing["groups"].append(group_name)
subcat = entry["subcategory"]
if subcat and subcat not in existing["subcategories"]:
existing["subcategories"].append(subcat)
else: else:
merged = { merged = {
"name": entry["name"], "name": entry["name"],
@@ -109,6 +112,7 @@ def extract_entries(
"description": entry["description"], "description": entry["description"],
"categories": [cat["name"]], "categories": [cat["name"]],
"groups": [group_name], "groups": [group_name],
"subcategories": [entry["subcategory"]] if entry["subcategory"] else [],
"stars": None, "stars": None,
"owner": None, "owner": None,
"last_commit_at": None, "last_commit_at": None,

View File

@@ -20,6 +20,7 @@ class ParsedEntry(TypedDict):
url: str url: str
description: str # inline HTML, properly escaped description: str # inline HTML, properly escaped
also_see: list[AlsoSee] also_see: list[AlsoSee]
subcategory: str # sub-category label, empty if none
class ParsedSection(TypedDict): class ParsedSection(TypedDict):
@@ -178,7 +179,11 @@ def _extract_description_html(inline: SyntaxTreeNode, first_link: SyntaxTreeNode
return _DESC_SEP_RE.sub("", html) 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. """Extract entries from a bullet_list AST node.
Handles three patterns: 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): 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 # 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") nested = _find_child(list_item, "bullet_list")
if nested: if nested:
entries.extend(_parse_list_entries(nested)) entries.extend(_parse_list_entries(nested, subcategory=label))
continue continue
# Entry with a link # Entry with a link
@@ -231,6 +237,7 @@ def _parse_list_entries(bullet_list: SyntaxTreeNode) -> list[ParsedEntry]:
url=url, url=url,
description=desc_html, description=desc_html,
also_see=also_see, also_see=also_see,
subcategory=subcategory,
)) ))
return entries return entries

View File

@@ -779,6 +779,12 @@ th[data-sort].sort-asc::after {
color: var(--hero-ink); color: var(--hero-ink);
} }
.tag-subcat {
background: var(--bg-paper-strong);
color: var(--ink-soft);
font-weight: 600;
}
.back-to-top { .back-to-top {
border: 0; border: 0;
background: none; background: none;
@@ -991,7 +997,8 @@ th[data-sort].sort-asc::after {
display: none; display: none;
} }
.tag-group { .tag-group,
.tag-subcat {
display: none; display: none;
} }

View File

@@ -121,7 +121,7 @@
{% for entry in entries %} {% for entry in entries %}
<tr <tr
class="row" class="row"
data-cats="{{ entry.categories | join('||') }}{% if entry.source_type == 'Built-in' %}||Built-in{% endif %}" data-cats="{{ entry.categories | join('||') }}{% if entry.subcategories %}||{{ entry.subcategories | join('||') }}{% endif %}{% if entry.source_type == 'Built-in' %}||Built-in{% endif %}"
data-groups="{{ entry.groups | join('||') }}" data-groups="{{ entry.groups | join('||') }}"
tabindex="0" tabindex="0"
aria-expanded="false" aria-expanded="false"
@@ -160,6 +160,14 @@
<button class="tag" data-type="cat" data-value="{{ cat }}"> <button class="tag" data-type="cat" data-value="{{ cat }}">
{{ cat }} {{ cat }}
</button> </button>
{% endfor %} {% for subcat in entry.subcategories %}
<button
class="tag tag-subcat"
data-type="cat"
data-value="{{ subcat }}"
>
{{ subcat }}
</button>
{% endfor %} {% endfor %}
<button <button
class="tag tag-group" class="tag tag-group"