diff --git a/website/build.py b/website/build.py index 9d278f7d..5fd1b596 100644 --- a/website/build.py +++ b/website/build.py @@ -118,14 +118,11 @@ def build_robots_txt() -> str: return f"User-agent: *\nContent-Signal: search=yes, ai-input=yes, ai-train=yes\nAllow: /\n\nSitemap: {SITEMAP_URL}\n" -def build_homepage_json_ld(entries: Sequence[TemplateEntry], total_categories: int) -> dict: - description = ( - "An opinionated guide to the best Python frameworks, libraries, and tools. " - f"Explore {len(entries)} curated projects across {total_categories} categories, " - "from AI and agents to data science and web development." - ) - website_id = f"{SITE_URL}#website" - item_list = { +WEBSITE_ID = f"{SITE_URL}#website" + + +def _item_list_payload(entries: Sequence[TemplateEntry]) -> dict: + return { "@type": "ItemList", "numberOfItems": len(entries), "itemListElement": [ @@ -138,12 +135,20 @@ def build_homepage_json_ld(entries: Sequence[TemplateEntry], total_categories: i for i, entry in enumerate(entries, start=1) ], } + + +def build_homepage_json_ld(entries: Sequence[TemplateEntry], total_categories: int) -> dict: + description = ( + "An opinionated guide to the best Python frameworks, libraries, and tools. " + f"Explore {len(entries)} curated projects across {total_categories} categories, " + "from AI and agents to data science and web development." + ) return { "@context": "https://schema.org", "@graph": [ { "@type": "WebSite", - "@id": website_id, + "@id": WEBSITE_ID, "name": "Awesome Python", "url": SITE_URL, }, @@ -153,13 +158,30 @@ def build_homepage_json_ld(entries: Sequence[TemplateEntry], total_categories: i "name": "Awesome Python", "url": SITE_URL, "description": description, - "isPartOf": {"@id": website_id}, - "mainEntity": item_list, + "isPartOf": {"@id": WEBSITE_ID}, + "mainEntity": _item_list_payload(entries), }, ], } +def category_meta_description(name: str, entry_count: int, description: str) -> str: + suffix = description if description else "Part of the Awesome Python catalog." + return f"Explore {entry_count} curated Python projects in {name}. {suffix}" + + +def build_category_json_ld(name: str, url: str, description: str, entries: Sequence[TemplateEntry]) -> dict: + return { + "@context": "https://schema.org", + "@type": "CollectionPage", + "name": f"{name} Python Libraries", + "url": url, + "description": description, + "isPartOf": {"@id": WEBSITE_ID}, + "mainEntity": _item_list_payload(entries), + } + + def category_path(category: ParsedSection) -> str: return f"/categories/{category['slug']}/" @@ -458,6 +480,15 @@ def build(repo_root: Path) -> None: group_categories: Sequence[ParsedSection] | None = None, ) -> None: page_dir.mkdir(parents=True, exist_ok=True) + category_description = category_meta_description( + category["name"], len(entries), category["description"] + ) + category_json_ld = json.dumps( + build_category_json_ld( + category["name"], category_url, category_description, entries + ), + ensure_ascii=False, + ).replace(" None: filter_urls_json=filter_urls_json, parent_category=parent_category, group_categories=group_categories, + category_json_ld=category_json_ld, ), encoding="utf-8", ) diff --git a/website/templates/category.html b/website/templates/category.html index 43da9a97..9ef8e1ce 100644 --- a/website/templates/category.html +++ b/website/templates/category.html @@ -3,6 +3,9 @@ {% block description %}Explore {{ entries | length }} curated Python projects in {{ category.name }}. {% if category.description %}{{ category.description }}{% else %}Part of the Awesome Python catalog.{% endif %}{% endblock %} {% block canonical_url %}{{ category_url }}{% endblock %} {% block alternate_links %}{% endblock %} +{% block extra_head %} + +{% endblock %} {% block header %}
diff --git a/website/tests/test_build.py b/website/tests/test_build.py index 841ea414..864a8fdf 100644 --- a/website/tests/test_build.py +++ b/website/tests/test_build.py @@ -518,6 +518,88 @@ class TestBuild: assert rendered_names == {e["name"] for e in entries} assert rendered_urls == {e["url"] for e in entries} + def test_category_page_contains_json_ld(self, tmp_path): + readme = textwrap.dedent("""\ + # Awesome Python + + Intro. + + --- + + **Tools** + + ## Widgets + + _Widget libraries._ + + - [w1](https://example.com/w1) - A widget. + - [w2](https://github.com/owner/w2) - A starred widget. + + # Contributing + + Help! + """) + (tmp_path / "README.md").write_text(readme, encoding="utf-8") + self._copy_real_templates(tmp_path) + build(tmp_path) + + category_html = (tmp_path / "website" / "output" / "categories" / "widgets" / "index.html").read_text(encoding="utf-8") + marker = '", start) + block = category_html[start:end] + assert "" not in block + data = json.loads(block) + + assert data["@context"] == "https://schema.org" + assert data["@type"] == "CollectionPage" + assert data["name"] == "Widgets Python Libraries" + assert data["url"] == "https://awesome-python.com/categories/widgets/" + assert data["description"] == "Explore 2 curated Python projects in Widgets. Widget libraries." + assert data["isPartOf"] == {"@id": "https://awesome-python.com/#website"} + + item_list = data["mainEntity"] + assert item_list["@type"] == "ItemList" + assert item_list["numberOfItems"] == 2 + names = {item["name"] for item in item_list["itemListElement"]} + urls = {item["url"] for item in item_list["itemListElement"]} + assert names == {"w1", "w2"} + assert urls == {"https://example.com/w1", "https://github.com/owner/w2"} + positions = sorted(item["position"] for item in item_list["itemListElement"]) + assert positions == [1, 2] + + def test_group_page_falls_back_to_default_description_in_json_ld(self, tmp_path): + readme = textwrap.dedent("""\ + # T + + --- + + **AI & 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") + build(tmp_path) + + group_html = (tmp_path / "website" / "output" / "categories" / "ai-ml" / "index.html").read_text(encoding="utf-8") + marker = '", start) + data = json.loads(group_html[start:end]) + + assert data["@type"] == "CollectionPage" + assert data["name"] == "AI & ML Python Libraries" + assert data["url"] == "https://awesome-python.com/categories/ai-ml/" + assert data["description"] == "Explore 1 curated Python projects in AI & ML. Part of the Awesome Python catalog." + def test_build_creates_subcategory_pages(self, tmp_path): readme = textwrap.dedent("""\ # T