feat: link category intro picks to their table rows

Picks in a "How to choose" list were plain text, so a reader had to hunt for the tool's row in the table.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Vinta Chen
2026-10-16 19:45:38 +08:00
co-authored by Claude
parent abfab010e9
commit 1ebff58bf9
5 changed files with 118 additions and 9 deletions
+77 -5
View File
@@ -39,6 +39,30 @@ SPONSORSHIP_DESCRIPTION = "Sponsorship for awesome-python: tiers, audience, and
BUNDLED_PREFIX_RE = re.compile(r"^\(part of ")
# Category intros name projects as their docs do, README entries by their PyPI name
PICK_ALIASES = {
"Airflow": "apache-airflow",
"Beautiful Soup": "beautifulsoup4",
"Cacheops": "django-cacheops",
"Chroma": "chromadb",
"Coverage.py": "coverage",
"Faiss": "faiss-cpu",
"gRPC": "grpcio",
"Grappelli": "django-grappelli",
"Hydra": "hydra-core",
"Ibis": "ibis-framework",
"Material for MkDocs": "mkdocs-material",
"OpenCV": "opencv-python",
"Playwright": "playwright-python",
"PyAV": "av",
"python-oracledb": "oracledb",
"Sherlock": "sherlock-project",
"Silk": "django-silk",
"Surprise": "scikit-surprise",
"Unfold": "django-unfold",
"Vega-Altair": "altair",
}
SOURCE_TYPE_DOMAINS = {
"docs.python.org": BUILTIN_FILTER,
"gitlab.com": "GitLab",
@@ -253,7 +277,52 @@ def subcategory_meta_description(name: str, parent_name: str, entry_names: Seque
return f"The {name} picks in Awesome Python's {parent_name} list: {names}."
def load_category_intro(path: Path) -> tuple[str, str, str, str]:
def entry_name_key(name: str) -> str:
return re.sub(r"[^a-z0-9]", "", name.lower())
def link_pick_names(pick: str, slugs_by_key: dict[str, str]) -> str:
"""Link entry names in a "How to choose" pick to their rows, preferring the longest match.
Names match ignoring case and punctuation, so the README's PyPI-style
"django-ninja" matches the intro's "Django Ninja".
"""
words = list(re.finditer(r"`[^`]*`|[^\s,]+", pick))
parts, pos, i = [], 0, 0
while i < len(words):
for j in range(min(len(words), i + 4), i, -1):
start, end = words[i].start(), words[j - 1].end()
span = pick[start:end]
slug = slugs_by_key.get(entry_name_key(span))
# Code spans and dangling punctuation like "The Algorithms -" stay outside links
if slug and span[0].isalnum() and span[-1].isalnum() and "," not in span and "`" not in span:
parts.append(f"{pick[pos:start]}[{span}](#entry-{slug})")
pos, i = end, j
break
else:
i += 1
return "".join(parts) + pick[pos:]
def link_intro_picks(text: str, entry_names: Sequence[str]) -> str:
"""Link the picks in an intro's "How to choose:" list to their rows in the table below."""
head, marker, rest = text.partition("\nHow to choose:\n\n")
if not marker:
return text
slugs_by_key = {entry_name_key(name): slugify(name) for name in entry_names}
for alias, name in PICK_ALIASES.items():
if entry_name_key(name) in slugs_by_key:
slugs_by_key[entry_name_key(alias)] = slugs_by_key[entry_name_key(name)]
lines = rest.split("\n")
for i, line in enumerate(lines):
if not line.startswith("- "):
break
situation, _, pick = line.rpartition(": ")
lines[i] = f"{situation}: {link_pick_names(pick, slugs_by_key)}"
return head + marker + "\n".join(lines)
def load_category_intro(path: Path, entry_names: Sequence[str] = ()) -> tuple[str, str, str, str]:
"""Render a category intro file to HTML, split at the end of its "How to choose:" list.
Returns the part shown above the table, the guide shown below it, and the
@@ -264,10 +333,10 @@ def load_category_intro(path: Path) -> tuple[str, str, str, str]:
if not path.exists():
return "", "", "", ""
md = MarkdownIt("commonmark")
tokens = md.parse(path.read_text(encoding="utf-8"))
tokens = md.parse(link_intro_picks(path.read_text(encoding="utf-8"), entry_names))
for token in tokens:
for child in token.children or []:
if child.type == "link_open":
if child.type == "link_open" and not str(child.attrGet("href")).startswith("#"):
child.attrSet("target", "_blank")
child.attrSet("rel", "noopener")
lead = next(node for node in SyntaxTreeNode(tokens).children if node.type == "paragraph")
@@ -731,6 +800,7 @@ def build(repo_root: Path) -> None:
trim_blocks=True,
lstrip_blocks=True,
)
env.filters["slugify"] = slugify
site_dir = website / "output"
if site_dir.exists():
shutil.rmtree(site_dir)
@@ -776,7 +846,10 @@ def build(repo_root: Path) -> 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, guide_html, intro_lead, _ = load_category_intro(website / "data" / "category_intros" / f"{current_path.removeprefix('/categories/').strip('/')}.md")
page_entries = [entry for group in entry_groups for entry in group["entries"]] if entry_groups else entries
intro_html, guide_html, intro_lead, _ = load_category_intro(
website / "data" / "category_intros" / f"{current_path.removeprefix('/categories/').strip('/')}.md", [e["name"] for e in page_entries]
)
if intro_lead:
category_description = intro_lead
elif parent_name:
@@ -787,7 +860,6 @@ def build(repo_root: Path) -> None:
if parent_category:
breadcrumbs.append((parent_category["name"], category_public_url(parent_category)))
breadcrumbs.append((category["name"], category_url))
page_entries = [entry for group in entry_groups for entry in group["entries"]] if entry_groups else entries
category_json_ld = json.dumps(
build_category_json_ld(category_title.removesuffix(" - Awesome Python"), category_url, category_description, page_entries, breadcrumbs),
ensure_ascii=False,
+20 -2
View File
@@ -367,12 +367,25 @@ sortHeaders.forEach(function (th) {
});
});
function openEntryRow(row) {
if (row.hidden && searchInput) {
searchInput.value = "";
applyFilters();
}
row.classList.add("open");
row.setAttribute("aria-expanded", "true");
}
// Group headings are hidden while sorted flat or filtered out by search, so a link to one must restore them first
document.addEventListener("click", function (e) {
const link = e.target.closest('a[href*="#"]');
if (!link || link.pathname !== location.pathname) return;
const heading = document.getElementById(decodeURIComponent(link.hash.slice(1)));
const groupRow = heading ? heading.closest(".group-row") : null;
const target = document.getElementById(decodeURIComponent(link.hash.slice(1)));
if (target && target.matches("tr.row")) {
openEntryRow(target);
return;
}
const groupRow = target ? target.closest(".group-row") : null;
if (!groupRow) return;
if (activeSort.col !== "editorial") {
activeSort = defaultSort;
@@ -385,6 +398,11 @@ document.addEventListener("click", function (e) {
}
});
if (location.hash) {
const linkedRow = document.getElementById(decodeURIComponent(location.hash.slice(1)));
if (linkedRow && linkedRow.matches("tr.row")) openEntryRow(linkedRow);
}
if (searchInput) {
let searchTimer;
searchInput.addEventListener("input", function () {
+1
View File
@@ -837,6 +837,7 @@ kbd {
.row {
cursor: pointer;
scroll-margin-top: 4.5rem;
}
.group-row th {
+1
View File
@@ -81,6 +81,7 @@
{% set section_path = "/categories/" ~ parent_category.slug ~ "/" if parent_category else current_path %}
<tr
class="row"
{% if not group_categories %}id="entry-{{ entry.name | slugify }}"{% endif %}
tabindex="0"
aria-expanded="false"
aria-controls="expand-{{ index }}"
+19 -2
View File
@@ -1100,7 +1100,7 @@ class TestBuild:
parser.feed(category_html)
assert parser.meta_by_name["description"] == "Use w1 for most apps."
assert '<div class="category-intro"><p>Use <code>w1</code> for most apps.</p>' in category_html
assert "<li>Small apps: w1</li>" in category_html
assert '<li>Small apps: <a href="#entry-w1">w1</a></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):
@@ -1113,7 +1113,7 @@ class TestBuild:
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 '<li>Small apps: <a href="#entry-w1">w1</a></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
@@ -1599,3 +1599,20 @@ class TestLoadCategoryIntro:
def test_returns_empty_strings_without_intro_file(self, tmp_path):
assert load_category_intro(tmp_path / "missing.md") == ("", "", "", "")
def test_links_picks_to_entry_rows(self, tmp_path):
path = tmp_path / "widgets.md"
path.write_text(
"Use w1.\n\nHow to choose:\n\n- Small apps: Django Ninja, or `uv tool` on uv\n- Big apps: Django\n- Old apps: The Algorithms - Python\n\nSee [w1](https://w1.example).\n", encoding="utf-8"
)
intro_html, guide_html, _, _ = load_category_intro(path, ["django-ninja", "Django", "uv", "TheAlgorithms"])
assert '<li>Small apps: <a href="#entry-django-ninja">Django Ninja</a>, or <code>uv tool</code> on <a href="#entry-uv">uv</a></li>' in intro_html
assert '<li>Big apps: <a href="#entry-django">Django</a></li>' in intro_html
assert '<li>Old apps: <a href="#entry-thealgorithms">The Algorithms</a> - Python</li>' in intro_html
assert '<a href="https://w1.example" target="_blank" rel="noopener">w1</a>' in guide_html
def test_links_pick_aliases_to_entry_rows(self, tmp_path):
path = tmp_path / "widgets.md"
path.write_text("Use w1.\n\nHow to choose:\n\n- Vision: OpenCV\n", encoding="utf-8")
intro_html, _, _, _ = load_category_intro(path, ["opencv-python"])
assert '<li>Vision: <a href="#entry-opencv-python">OpenCV</a></li>' in intro_html