diff --git a/website/readme_parser.py b/website/readme_parser.py index 10d26886..79045768 100644 --- a/website/readme_parser.py +++ b/website/readme_parser.py @@ -27,6 +27,7 @@ class ParsedSection(TypedDict): name: str slug: str description: str # plain text, links resolved to text + description_html: str # inline HTML, properly escaped entries: list[ParsedEntry] entry_count: int @@ -113,22 +114,22 @@ def _heading_text(node: SyntaxTreeNode) -> str: return "" -def _extract_description(nodes: list[SyntaxTreeNode]) -> str: - """Extract description from the first paragraph if it's a single block. +def _extract_description_children(nodes: list[SyntaxTreeNode]) -> list[SyntaxTreeNode]: + """Extract description children from the first paragraph if it's a single block. Pattern: _Libraries for foo._ -> "Libraries for foo." """ if not nodes: - return "" + return [] first = nodes[0] if first.type != "paragraph": - return "" + return [] for child in first.children: if child.type == "inline" and len(child.children) == 1: em = child.children[0] if em.type == "em": - return render_inline_text(em.children) - return "" + return em.children + return [] # --- Entry extraction -------------------------------------------------------- @@ -258,14 +259,17 @@ def _parse_section_entries(content_nodes: list[SyntaxTreeNode]) -> list[ParsedEn def _build_section(name: str, body: list[SyntaxTreeNode]) -> ParsedSection: """Build a ParsedSection from a heading name and its body nodes.""" - desc = _extract_description(body) - content_nodes = body[1:] if desc else body + desc_children = _extract_description_children(body) + desc = render_inline_text(desc_children) if desc_children else "" + desc_html = render_inline_html(desc_children) if desc_children else "" + content_nodes = body[1:] if desc_children else body entries = _parse_section_entries(content_nodes) entry_count = len(entries) + sum(len(e["also_see"]) for e in entries) return ParsedSection( name=name, slug=slugify(name), description=desc, + description_html=desc_html, entries=entries, entry_count=entry_count, ) diff --git a/website/static/style.css b/website/static/style.css index 2adeca39..b2ba5e00 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -423,6 +423,17 @@ kbd { text-wrap: pretty; } +.category-subtitle a { + color: var(--hero-text); + text-decoration: underline; + text-decoration-color: oklch(100% 0 0 / 0.32); + text-underline-offset: 0.2em; +} + +.category-subtitle a:hover { + text-decoration-color: oklch(100% 0 0 / 0.7); +} + .category-results { padding-top: clamp(2.5rem, 5vw, 3.75rem); } diff --git a/website/templates/category.html b/website/templates/category.html index 992e18cc..d487e104 100644 --- a/website/templates/category.html +++ b/website/templates/category.html @@ -25,8 +25,8 @@

{{ category.name }}

- {% if category.description %} -

{{ category.description }}

+ {% if category.description_html %} +

{{ category.description_html | safe }}

{% endif %}
diff --git a/website/tests/test_build.py b/website/tests/test_build.py index c5169796..23b71d0a 100644 --- a/website/tests/test_build.py +++ b/website/tests/test_build.py @@ -146,7 +146,7 @@ class TestBuild: ## Widgets - _Widget libraries._ + _Widget libraries. Also see [awesome-widgets](https://example.com/widgets)._ - [w1](https://example.com) - A widget. @@ -235,7 +235,7 @@ class TestBuild: ## Widgets - _Widget libraries._ + _Widget libraries. Also see [awesome-widgets](https://example.com/widgets)._ - [w1](https://example.com/w1) - A widget. - [w2](https://github.com/owner/w2) - A starred widget. @@ -276,12 +276,12 @@ class TestBuild: assert 'href="/categories/widgets/"' in index_html assert 'data-value="Widgets"' in index_html assert parser.title.strip() == "Widgets Python Libraries | Awesome Python" - assert parser.meta_by_name["description"] == "Explore 2 curated Python projects in Widgets. Widget libraries." + assert parser.meta_by_name["description"] == "Explore 2 curated Python projects in Widgets. Widget libraries. Also see awesome-widgets." assert parser.links_by_rel["canonical"] == "https://awesome-python.com/categories/widgets/" assert parser.meta_by_property["og:url"] == "https://awesome-python.com/categories/widgets/" assert '' not in category_html assert "

Widgets

" in category_html - assert "Widget libraries." in category_html + assert 'Widget libraries. Also see awesome-widgets.' in category_html 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 diff --git a/website/tests/test_readme_parser.py b/website/tests/test_readme_parser.py index 0b4940a8..466d5fa6 100644 --- a/website/tests/test_readme_parser.py +++ b/website/tests/test_readme_parser.py @@ -180,7 +180,9 @@ class TestParseReadmeSections: groups = parse_readme(MINIMAL_README) cats = groups[0]["categories"] assert cats[0]["description"] == "Libraries for alpha stuff." + assert cats[0]["description_html"] == "Libraries for alpha stuff." assert cats[1]["description"] == "Tools for beta." + assert cats[1]["description_html"] == "Tools for beta." def test_contributing_skipped(self): groups = parse_readme(MINIMAL_README) @@ -216,6 +218,7 @@ class TestParseReadmeSections: groups = parse_readme(readme) cats = groups[0]["categories"] assert cats[0]["description"] == "" + assert cats[0]["description_html"] == "" assert cats[0]["entries"][0]["name"] == "item" def test_description_with_link_stripped(self): @@ -237,6 +240,7 @@ class TestParseReadmeSections: groups = parse_readme(readme) cats = groups[0]["categories"] assert cats[0]["description"] == "Algorithms. Also see awesome-algos." + assert cats[0]["description_html"] == 'Algorithms. Also see awesome-algos.' class TestParseGroupedReadme: @@ -481,6 +485,7 @@ class TestParseRealReadme: algos = next(c for c in self.cats if c["name"] == "Algorithms and Design Patterns") assert "awesome-algorithms" in algos["description"] assert "https://" not in algos["description"] + assert 'href="https://github.com/tayllan/awesome-algorithms"' in algos["description_html"] def test_miscellaneous_in_own_group(self): misc_group = next((g for g in self.groups if g["name"] == "Miscellaneous"), None)