Merge branch 'group-intro-leads'

This commit is contained in:
Vinta Chen
2026-10-02 19:35:19 +08:00
4 changed files with 74 additions and 19 deletions
+18 -12
View File
@@ -72,6 +72,7 @@ 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
lead: str # the section intro's first paragraph as HTML, shown under the heading on group pages
entries: list[TemplateEntry]
@@ -249,16 +250,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, str]:
def load_category_intro(path: Path) -> 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
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.
first paragraph as plain text for the meta description and as HTML for
group pages. 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:
@@ -273,7 +274,12 @@ def load_category_intro(path: Path) -> tuple[str, str, str]:
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)
return (
render(tokens[:split_at], md.options, {}),
render(tokens[split_at:], md.options, {}),
render_inline_text(lead.children[0].children),
render(lead.to_tokens(), md.options, {}),
)
def group_section_entries(section: ParsedSection, entries_by_key: dict[tuple[str, str], TemplateEntry]) -> list[EntryGroup]:
@@ -282,12 +288,12 @@ def group_section_entries(section: ParsedSection, entries_by_key: dict[tuple[str
for parsed in section["entries"]:
name = parsed["subcategory"]
slug = slugify(name) if name else ""
group = groups.setdefault(name, EntryGroup(name=name, slug=slug, url=subcategory_path(section["slug"], slug) if name else "", entries=[]))
group = groups.setdefault(name, EntryGroup(name=name, slug=slug, url=subcategory_path(section["slug"], slug) if name else "", lead="", 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]:
def group_entries_by_section(sections: Sequence[ParsedSection], entries_by_key: dict[tuple[str, str], TemplateEntry], intros_dir: Path) -> 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] = []
@@ -298,7 +304,7 @@ def group_entries_by_section(sections: Sequence[ParsedSection], entries_by_key:
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))
groups.append(EntryGroup(name=section["name"], slug=section["slug"], url=category_path(section), lead=load_category_intro(intros_dir / f"{section['slug']}.md")[3], entries=entries))
return groups
@@ -753,7 +759,7 @@ 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")
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:
@@ -805,7 +811,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),
entry_groups=group_entries_by_section(group["categories"], entries_by_key, website / "data" / "category_intros"),
)
if builtin_entries:
@@ -855,7 +861,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],
entry_groups=[EntryGroup(name="", slug="", url="", lead="", 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"
+16 -4
View File
@@ -845,7 +845,7 @@ kbd {
.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)));
padding-inline: 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);
@@ -878,6 +878,15 @@ kbd {
display: none;
}
.group-lead {
margin-top: 0.75rem;
font-size: var(--text-lg);
font-weight: 400;
line-height: 1.55;
color: var(--ink-soft);
text-wrap: pretty;
}
.group-count {
font-family: var(--font-body);
font-size: var(--text-sm);
@@ -1074,11 +1083,13 @@ th[data-sort].sort-asc::after {
max-width: none;
}
.desc-text a {
.desc-text a,
.group-lead a {
color: var(--accent-deep);
}
.desc-text a:hover {
.desc-text a:hover,
.group-lead a:hover {
color: var(--accent);
text-decoration: underline;
text-decoration-color: var(--accent-underline);
@@ -1807,7 +1818,8 @@ th[data-sort].sort-asc::after {
}
.table thead th:last-child,
.table tbody td:last-child {
.table tbody td:last-child,
.group-row th {
padding-right: 0.8rem;
}
+3
View File
@@ -294,6 +294,9 @@
{% 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>
{% if group.lead %}
<div class="group-lead">{{ group.lead | safe }}</div>
{% endif %}
</th>
</tr>
{% endif %}
+37 -3
View File
@@ -1148,6 +1148,39 @@ class TestBuild:
assert "repeats-heading" not in html
assert 'class="jump-links"' not in html
def test_group_page_shows_section_intro_lead_under_section_heading(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.
# Contributing
Done.
""")
self._copy_real_templates(tmp_path)
(tmp_path / "README.md").write_text(readme, encoding="utf-8")
intros_dir = tmp_path / "website" / "data" / "category_intros"
intros_dir.mkdir(parents=True)
(intros_dir / "machine-learning.md").write_text("Start with [`ml1`](https://example.com/ml1).\n\nMore detail.\n", encoding="utf-8")
build(tmp_path)
html = (tmp_path / "website" / "output" / "categories" / "ai-ml" / "index.html").read_text(encoding="utf-8")
lead = html.index('<div class="group-lead"><p>Start with <a href="https://example.com/ml1" target="_blank" rel="noopener"><code>ml1</code></a>.</p>')
assert html.index('<a href="/categories/machine-learning/">Machine Learning</a>') < lead < html.index(">ml1</a")
assert "More detail." not in html
assert html.count('class="group-lead"') == 1
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")
@@ -1500,17 +1533,18 @@ 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)
intro_html, guide_html, lead, lead_html = 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."
assert lead_html == "<p>Use <code>w1</code> for most apps.</p>\n"
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)
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") == ("", "", "")
assert load_category_intro(tmp_path / "missing.md") == ("", "", "", "")