mirror of
https://github.com/vinta/awesome-python.git
synced 2026-05-07 08:20:21 -05:00
Merge pull request #3106 from vinta/feature/better-seo-2
Static category pages and path-based filter URLs
This commit is contained in:
9
Makefile
9
Makefile
@@ -10,6 +10,15 @@ fetch_github_stars:
|
||||
test:
|
||||
uv run pytest website/tests/ -v
|
||||
|
||||
lint:
|
||||
uv run ruff check .
|
||||
|
||||
format:
|
||||
uv run ruff format .
|
||||
|
||||
typecheck:
|
||||
uv run ty check website
|
||||
|
||||
build:
|
||||
uv run python website/build.py
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ An opinionated guide to the best Python frameworks, libraries, tools, and resour
|
||||
- [Penetration Testing](#penetration-testing)
|
||||
- [Web Security](#web-security)
|
||||
|
||||
**Miscellaneous**
|
||||
**Other**
|
||||
|
||||
- [Hardware](#hardware)
|
||||
- [Microsoft Windows](#microsoft-windows)
|
||||
@@ -1098,7 +1098,7 @@ _Libraries for application-layer web security._
|
||||
|
||||
- [secure](https://github.com/TypeError/secure) - HTTP security headers for Python web applications with ASGI and WSGI middleware.
|
||||
|
||||
**Miscellaneous**
|
||||
**Other**
|
||||
|
||||
## Hardware
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ Your sponsorship puts your product in front of developers at the exact moment th
|
||||
|
||||
## Get Started
|
||||
|
||||
Email [vinta.chen@gmail.com](mailto:vinta.chen@gmail.com?subject=awesome-python%20Sponsorship) with:
|
||||
Email [vinta.chen@gmail.com](mailto:vinta.chen@gmail.com?subject=Awesome%20Python%20Sponsorship) with:
|
||||
|
||||
- **Tier:** Headline Sponsor ($500/mo) or Featured Sponsor ($150/mo)
|
||||
- **Content:** Product name, URL, logo, and description (Headline tier) or `[Name](URL) - Description.` entry (Featured tier)
|
||||
|
||||
@@ -14,7 +14,7 @@ Repository = "https://github.com/vinta/awesome-python"
|
||||
|
||||
[dependency-groups]
|
||||
build = ["httpx==0.28.1", "jinja2==3.1.6", "markdown-it-py==4.0.0"]
|
||||
lint = ["ruff==0.15.6"]
|
||||
lint = ["ruff==0.15.6", "ty==0.0.33"]
|
||||
test = ["pytest==9.0.3"]
|
||||
dev = [
|
||||
{ include-group = "build" },
|
||||
@@ -23,16 +23,30 @@ dev = [
|
||||
"watchdog==6.0.0",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["website/tests"]
|
||||
pythonpath = ["website"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 200
|
||||
|
||||
[tool.uv]
|
||||
exclude-newer = "3 days"
|
||||
no-build = true
|
||||
|
||||
[tool.uv.pip]
|
||||
only-binary = [":all:"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 200
|
||||
|
||||
[tool.ty.environment]
|
||||
python-version = "3.13"
|
||||
root = ["website"]
|
||||
|
||||
[tool.ty.terminal]
|
||||
error-on-warning = true
|
||||
|
||||
[tool.ty.rules]
|
||||
division-by-zero = "error"
|
||||
possibly-missing-attribute = "error"
|
||||
possibly-missing-import = "error"
|
||||
possibly-unresolved-reference = "error"
|
||||
unused-ignore-comment = "error"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["website/tests"]
|
||||
pythonpath = ["website"]
|
||||
|
||||
34
uv.lock
generated
34
uv.lock
generated
@@ -3,7 +3,7 @@ revision = 3
|
||||
requires-python = ">=3.13"
|
||||
|
||||
[options]
|
||||
exclude-newer = "2026-04-18T18:21:23.412234Z"
|
||||
exclude-newer = "2026-04-30T04:22:17.540198Z"
|
||||
exclude-newer-span = "P3D"
|
||||
|
||||
[[package]]
|
||||
@@ -35,10 +35,12 @@ dev = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "pytest" },
|
||||
{ name = "ruff" },
|
||||
{ name = "ty" },
|
||||
{ name = "watchdog" },
|
||||
]
|
||||
lint = [
|
||||
{ name = "ruff" },
|
||||
{ name = "ty" },
|
||||
]
|
||||
test = [
|
||||
{ name = "pytest" },
|
||||
@@ -58,9 +60,13 @@ dev = [
|
||||
{ name = "markdown-it-py", specifier = "==4.0.0" },
|
||||
{ name = "pytest", specifier = "==9.0.3" },
|
||||
{ name = "ruff", specifier = "==0.15.6" },
|
||||
{ name = "ty", specifier = "==0.0.33" },
|
||||
{ name = "watchdog", specifier = "==6.0.0" },
|
||||
]
|
||||
lint = [{ name = "ruff", specifier = "==0.15.6" }]
|
||||
lint = [
|
||||
{ name = "ruff", specifier = "==0.15.6" },
|
||||
{ name = "ty", specifier = "==0.0.33" },
|
||||
]
|
||||
test = [{ name = "pytest", specifier = "==9.0.3" }]
|
||||
|
||||
[[package]]
|
||||
@@ -289,6 +295,30 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ty"
|
||||
version = "0.0.33"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/84/44/9478c50c266826c1bf30d1692e589755bffa8f1c0a3eb7af8a346c255991/ty-0.0.33.tar.gz", hash = "sha256:46d63bda07403322cb6c28ccfdd5536be916e13df725c29f7ccd0a21f06bd9e8", size = 5559373, upload-time = "2026-04-28T10:45:13.18Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/24/e287388c63a19191be26b32ff4dbd06029834068150ebe2532939bc4c851/ty-0.0.33-py3-none-linux_armv6l.whl", hash = "sha256:94d0a9d2234261a8911396d59e506b5923fe0971dbda43b9dcea287936887fcc", size = 11021308, upload-time = "2026-04-28T10:45:43.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/ca/ba1eed819895bd239fba8ee35dfcd5fcb266c203b0914a17a59579096bb5/ty-0.0.33-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e4a2b5ba078f90de342f56b5f7979bb77c9b9b1d8625a041352ffc6ee93c4073", size = 10777272, upload-time = "2026-04-28T10:45:32.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/a8/c3131d37b44b3fea1d6654a1c929a0cd0873822f77a90482b8ec28f6fbbd/ty-0.0.33-py3-none-macosx_11_0_arm64.whl", hash = "sha256:84ff5707825e9af9668d2bcf66975f93e520a63b524ab494e3a8265735be2563", size = 10201078, upload-time = "2026-04-28T10:45:23.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/db/d8e37ff0045810cc65e1ff36aa0da0a2253c05659787ac987df8a16c7897/ty-0.0.33-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e375285736f57886868e7af0b11c7b0ec5b6543fa15e7ad2a714fed9f077d4e0", size = 10732347, upload-time = "2026-04-28T10:45:21.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/1a/20e83a412506a918e4684fc67b567cf7cc13b105470b3428cb23c3d5aa13/ty-0.0.33-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5680f6350c3b4e46b8bff6d7bb132366ea239463d6cad4892725d06046e65464", size = 10808238, upload-time = "2026-04-28T10:45:38.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/4b/d0a39f4464dc6cb4cc2c159473ce216bd1846bfb684c0323a3cb36dce5c6/ty-0.0.33-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5535538bad8d0f7e62bcdff02197cdb30e41451d80b35d27e17d128f2e1dc5d", size = 11288348, upload-time = "2026-04-28T10:45:08.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/7e/f1745e0f9583363d7a83d9a4990fc244f76ecc30840ddad83dc16a33c52d/ty-0.0.33-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:da196c42bbbc069e1e21e3e52107c061aa9660352dae57a41930690b56e2c02d", size = 11789907, upload-time = "2026-04-28T10:45:19.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/71/25f39f46a12d662859d45bc648555d0661044eb43db6b5648c9947487da9/ty-0.0.33-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9281672921ef6d4460e03146b5e6c18cb1a3e3a3b8a1a88f6f33226d05a469b7", size = 11500774, upload-time = "2026-04-28T10:45:48.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/ec/136959ecbb7c71cb90537f5aea441c73f4ab24612868a6ecdc9d7444d32d/ty-0.0.33-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82c1b8f303f82da64e878108e764be3ecbcd7c9903ac0a7f7031614ed00b97ab", size = 11360314, upload-time = "2026-04-28T10:45:05.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/95/32809575c222f00beed498cb728e9290a0f5009f930025381bb7253b2206/ty-0.0.33-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:efe3af412c9ff67bce5fa37d0a2b0d8555c24072b145a5bac6c79637f1c83abe", size = 10707785, upload-time = "2026-04-28T10:45:10.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/89/c8e9531f7aa4a093359e15fa32c8e1277fbbe90d16894d7c6032d29f4b34/ty-0.0.33-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:aeec29c91ea768601747da546c3efc20b72c2fb1bd52bcc786a5c6eeff51d27b", size = 10834987, upload-time = "2026-04-28T10:45:40.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/16/9835fbcf5338af1a1917bd28fdb8a7193c210b83f243aa286fa9f79cb3ad/ty-0.0.33-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a535977c52bbb5f7e96b8b70a6ad375ad077f4a9ff2492508ea3816a2b403819", size = 10968968, upload-time = "2026-04-28T10:45:30.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/69/64c76aabc1bc70c7f24b686cd93c3407f8ea430905e395f59bf9603ef571/ty-0.0.33-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1d732facf39fcb221ba279d469c5040d37883e964f123b1563888efd34818180", size = 11458077, upload-time = "2026-04-28T10:45:45.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/84/fae27b0c4718776a298690d31ca4cc1995f2e3e1c63a7b59e84c41498e9a/ty-0.0.33-py3-none-win32.whl", hash = "sha256:d90960b574428dc252f85e8598ec5fcb7f619794196b2fc95a90da075ed4681c", size = 10345364, upload-time = "2026-04-28T10:45:16.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/a0/a2938b23ae3e1a09a2d7c189e2ac5f7113676bae4e0e23948b568e18e5f8/ty-0.0.33-py3-none-win_amd64.whl", hash = "sha256:c1c3aec62c44de610c6e95f0a4e97ac3dbc07934bfdbf1fd90d758c9ff72f48e", size = 11342470, upload-time = "2026-04-28T10:45:26.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/62/7fb948aace38d2f6329261bb33c035a8484549c74f1db28649c7a4c6fed9/ty-0.0.33-py3-none-win_arm64.whl", hash = "sha256:0d44f99ba1b441e55e2aa301b2ac0a21112784931b46a5f66f4ea9efe5620d97", size = 10742673, upload-time = "2026-04-28T10:45:35.555Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchdog"
|
||||
version = "6.0.0"
|
||||
|
||||
264
website/build.py
264
website/build.py
@@ -5,13 +5,14 @@ import json
|
||||
import re
|
||||
import shutil
|
||||
import xml.etree.ElementTree as ET
|
||||
from collections import Counter
|
||||
from collections.abc import Sequence
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import TypedDict
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from readme_parser import ParsedGroup, ParsedSection, parse_readme, parse_sponsors
|
||||
from readme_parser import AlsoSee, ParsedGroup, ParsedSection, parse_readme, parse_sponsors, slugify
|
||||
|
||||
GITHUB_REPO_URL_RE = re.compile(r"^https?://github\.com/([^/]+/[^/]+?)(?:\.git)?/?$")
|
||||
MARKDOWN_LINK_RE = re.compile(r"\[[^\]]+\]\(([^)\s]+)\)")
|
||||
@@ -20,6 +21,14 @@ SITE_URL = "https://awesome-python.com/"
|
||||
SITEMAP_URL = f"{SITE_URL}sitemap.xml"
|
||||
SITEMAP_NS = "http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
|
||||
BUILTIN_FILTER = "Built-in"
|
||||
BUILTIN_SLUG = "built-in"
|
||||
BUILTIN_PATH = f"/categories/{BUILTIN_SLUG}/"
|
||||
BUILTIN_PUBLIC_URL = f"{SITE_URL}categories/{BUILTIN_SLUG}/"
|
||||
|
||||
SPONSORSHIP_PATH = "/sponsorship/"
|
||||
SPONSORSHIP_PUBLIC_URL = f"{SITE_URL}sponsorship/"
|
||||
|
||||
SOURCE_TYPE_DOMAINS = {
|
||||
"docs.python.org": "Built-in",
|
||||
"gitlab.com": "GitLab",
|
||||
@@ -27,6 +36,37 @@ SOURCE_TYPE_DOMAINS = {
|
||||
}
|
||||
|
||||
|
||||
class TemplateSubcategory(TypedDict):
|
||||
name: str
|
||||
value: str
|
||||
slug: str
|
||||
url: str
|
||||
|
||||
|
||||
class TemplateEntry(TypedDict):
|
||||
name: str
|
||||
url: str
|
||||
description: str
|
||||
categories: list[str]
|
||||
groups: list[str]
|
||||
subcategories: list[TemplateSubcategory]
|
||||
stars: int | None
|
||||
owner: str | None
|
||||
last_commit_at: str | None
|
||||
source_type: str | None
|
||||
also_see: list[AlsoSee]
|
||||
|
||||
|
||||
class SyntheticCategory(TypedDict):
|
||||
name: str
|
||||
slug: str
|
||||
description: str
|
||||
description_html: str
|
||||
|
||||
|
||||
TemplateCategory = ParsedSection | SyntheticCategory
|
||||
|
||||
|
||||
def detect_source_type(url: str) -> str | None:
|
||||
"""Detect source type from URL domain. Returns None for GitHub URLs."""
|
||||
if GITHUB_REPO_URL_RE.match(url):
|
||||
@@ -55,13 +95,13 @@ def load_stars(path: Path) -> dict[str, dict]:
|
||||
return {}
|
||||
|
||||
|
||||
def sort_entries(entries: list[dict]) -> list[dict]:
|
||||
def sort_entries(entries: Sequence[TemplateEntry]) -> list[TemplateEntry]:
|
||||
"""Sort entries by stars descending, then name ascending.
|
||||
|
||||
Three tiers: starred entries first, stdlib second, other non-starred last.
|
||||
"""
|
||||
|
||||
def sort_key(entry: dict) -> tuple[int, int, int, str]:
|
||||
def sort_key(entry: TemplateEntry) -> tuple[int, int, int, str]:
|
||||
stars = entry["stars"]
|
||||
name = entry["name"].lower()
|
||||
if stars is not None:
|
||||
@@ -75,13 +115,35 @@ def sort_entries(entries: list[dict]) -> list[dict]:
|
||||
|
||||
|
||||
def build_robots_txt() -> str:
|
||||
return (
|
||||
"User-agent: *\n"
|
||||
"Content-Signal: search=yes, ai-input=yes, ai-train=yes\n"
|
||||
"Allow: /\n"
|
||||
"\n"
|
||||
f"Sitemap: {SITEMAP_URL}\n"
|
||||
)
|
||||
return f"User-agent: *\nContent-Signal: search=yes, ai-input=yes, ai-train=yes\nAllow: /\n\nSitemap: {SITEMAP_URL}\n"
|
||||
|
||||
|
||||
def category_path(category: ParsedSection) -> str:
|
||||
return f"/categories/{category['slug']}/"
|
||||
|
||||
|
||||
def category_public_url(category: ParsedSection) -> str:
|
||||
return f"{SITE_URL}categories/{category['slug']}/"
|
||||
|
||||
|
||||
def group_path(group_slug: str) -> str:
|
||||
return f"/categories/{group_slug}/"
|
||||
|
||||
|
||||
def group_public_url(group_slug: str) -> str:
|
||||
return f"{SITE_URL}categories/{group_slug}/"
|
||||
|
||||
|
||||
def subcategory_path(category_slug: str, subcategory_slug: str) -> str:
|
||||
return f"/categories/{category_slug}/{subcategory_slug}/"
|
||||
|
||||
|
||||
def subcategory_public_url(category_slug: str, subcategory_slug: str) -> str:
|
||||
return f"{SITE_URL}categories/{category_slug}/{subcategory_slug}/"
|
||||
|
||||
|
||||
def synthetic_category(name: str, slug: str) -> SyntheticCategory:
|
||||
return {"name": name, "slug": slug, "description": "", "description_html": ""}
|
||||
|
||||
|
||||
def write_sitemap_xml(path: Path, urls: Sequence[tuple[str, str]]) -> None:
|
||||
@@ -165,7 +227,7 @@ def annotate_entries_with_stars(
|
||||
if not entry or "stars" not in entry:
|
||||
continue
|
||||
stripped = line.rstrip("\n")
|
||||
ending = line[len(stripped):]
|
||||
ending = line[len(stripped) :]
|
||||
annotated = f"{stripped} ({format_stars(entry['stars'])}){ending}"
|
||||
break
|
||||
out.append(annotated)
|
||||
@@ -196,7 +258,7 @@ def remove_sponsors_section(markdown: str) -> str:
|
||||
def extract_entries(
|
||||
categories: list[ParsedSection],
|
||||
groups: list[ParsedGroup],
|
||||
) -> list[dict]:
|
||||
) -> list[TemplateEntry]:
|
||||
"""Flatten categories into individual library entries for table display.
|
||||
|
||||
Entries appearing in multiple categories are merged into a single entry
|
||||
@@ -204,27 +266,27 @@ def extract_entries(
|
||||
"""
|
||||
cat_to_group = {cat["name"]: group["name"] for group in groups for cat in group["categories"]}
|
||||
|
||||
seen: dict[tuple[str, str], dict[str, Any]] = {} # (url, name) -> entry
|
||||
entries: list[dict[str, Any]] = []
|
||||
seen: dict[tuple[str, str], TemplateEntry] = {} # (url, name) -> entry
|
||||
entries: list[TemplateEntry] = []
|
||||
for cat in categories:
|
||||
group_name = cat_to_group.get(cat["name"], "Other")
|
||||
for entry in cat["entries"]:
|
||||
key = (entry["url"], entry["name"])
|
||||
existing: dict[str, Any] | None = seen.get(key)
|
||||
existing = seen.get(key)
|
||||
if existing is None:
|
||||
existing = {
|
||||
"name": entry["name"],
|
||||
"url": entry["url"],
|
||||
"description": entry["description"],
|
||||
"categories": [],
|
||||
"groups": [],
|
||||
"subcategories": [],
|
||||
"stars": None,
|
||||
"owner": None,
|
||||
"last_commit_at": None,
|
||||
"source_type": detect_source_type(entry["url"]),
|
||||
"also_see": entry["also_see"],
|
||||
}
|
||||
existing = TemplateEntry(
|
||||
name=entry["name"],
|
||||
url=entry["url"],
|
||||
description=entry["description"],
|
||||
categories=[],
|
||||
groups=[],
|
||||
subcategories=[],
|
||||
stars=None,
|
||||
owner=None,
|
||||
last_commit_at=None,
|
||||
source_type=detect_source_type(entry["url"]),
|
||||
also_see=entry["also_see"],
|
||||
)
|
||||
seen[key] = existing
|
||||
entries.append(existing)
|
||||
if cat["name"] not in existing["categories"]:
|
||||
@@ -235,7 +297,15 @@ def extract_entries(
|
||||
if subcat:
|
||||
scoped = f"{cat['name']} > {subcat}"
|
||||
if not any(s["value"] == scoped for s in existing["subcategories"]):
|
||||
existing["subcategories"].append({"name": subcat, "value": scoped})
|
||||
sub_slug = slugify(subcat)
|
||||
existing["subcategories"].append(
|
||||
TemplateSubcategory(
|
||||
name=subcat,
|
||||
value=scoped,
|
||||
slug=sub_slug,
|
||||
url=f"/categories/{cat['slug']}/{sub_slug}/",
|
||||
)
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
@@ -255,6 +325,12 @@ def build(repo_root: Path) -> None:
|
||||
sponsors = parse_sponsors(readme_text)
|
||||
|
||||
categories = [cat for g in parsed_groups for cat in g["categories"]]
|
||||
cat_slugs = [cat["slug"] for cat in categories]
|
||||
group_slugs = [g["slug"] for g in parsed_groups]
|
||||
all_top_level_slugs = cat_slugs + group_slugs + [BUILTIN_SLUG]
|
||||
duplicates = {s for s, n in Counter(all_top_level_slugs).items() if n > 1}
|
||||
if duplicates:
|
||||
raise ValueError(f"slug collision in /categories/ namespace: {sorted(duplicates)}. Rename a category or group so their slugs differ.")
|
||||
total_entries = sum(c["entry_count"] for c in categories)
|
||||
entries = extract_entries(categories, parsed_groups)
|
||||
build_date = datetime.now(UTC)
|
||||
@@ -278,6 +354,17 @@ def build(repo_root: Path) -> None:
|
||||
entry["last_commit_at"] = sd.get("last_commit_at", "")
|
||||
|
||||
entries = sort_entries(entries)
|
||||
category_urls = {cat["name"]: category_path(cat) for cat in categories}
|
||||
|
||||
filter_urls: dict[str, str] = dict(category_urls)
|
||||
for group in parsed_groups:
|
||||
filter_urls[group["name"]] = group_path(group["slug"])
|
||||
for entry in entries:
|
||||
for sub in entry.get("subcategories", []):
|
||||
filter_urls[sub["value"]] = sub["url"]
|
||||
builtin_entries = [e for e in entries if e.get("source_type") == BUILTIN_FILTER]
|
||||
if builtin_entries:
|
||||
filter_urls[BUILTIN_FILTER] = BUILTIN_PATH
|
||||
|
||||
env = Environment(
|
||||
loader=FileSystemLoader(website / "templates"),
|
||||
@@ -285,12 +372,13 @@ def build(repo_root: Path) -> None:
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
|
||||
site_dir = website / "output"
|
||||
if site_dir.exists():
|
||||
shutil.rmtree(site_dir)
|
||||
site_dir.mkdir(parents=True)
|
||||
|
||||
filter_urls_json = json.dumps(filter_urls, sort_keys=True, ensure_ascii=False).replace("</", "<\\/")
|
||||
|
||||
tpl_index = env.get_template("index.html")
|
||||
(site_dir / "index.html").write_text(
|
||||
tpl_index.render(
|
||||
@@ -302,26 +390,130 @@ def build(repo_root: Path) -> None:
|
||||
repo_stars=repo_stars,
|
||||
build_date=build_date.strftime("%B %d, %Y"),
|
||||
sponsors=sponsors,
|
||||
category_urls=category_urls,
|
||||
filter_urls=filter_urls,
|
||||
filter_urls_json=filter_urls_json,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
tpl_category = env.get_template("category.html")
|
||||
categories_dir = site_dir / "categories"
|
||||
|
||||
def render_category(
|
||||
category: TemplateCategory,
|
||||
*,
|
||||
category_url: str,
|
||||
entries: Sequence[TemplateEntry],
|
||||
current_path: str,
|
||||
page_dir: Path,
|
||||
parent_category: ParsedSection | None = None,
|
||||
group_categories: Sequence[ParsedSection] | None = None,
|
||||
) -> None:
|
||||
page_dir.mkdir(parents=True, exist_ok=True)
|
||||
(page_dir / "index.html").write_text(
|
||||
tpl_category.render(
|
||||
category=category,
|
||||
category_url=category_url,
|
||||
entries=entries,
|
||||
total_categories=len(categories),
|
||||
category_urls=category_urls,
|
||||
current_path=current_path,
|
||||
filter_urls=filter_urls,
|
||||
filter_urls_json=filter_urls_json,
|
||||
parent_category=parent_category,
|
||||
group_categories=group_categories,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
for category in categories:
|
||||
render_category(
|
||||
category,
|
||||
category_url=category_public_url(category),
|
||||
entries=[e for e in entries if category["name"] in e["categories"]],
|
||||
current_path=category_path(category),
|
||||
page_dir=categories_dir / category["slug"],
|
||||
)
|
||||
|
||||
for group in parsed_groups:
|
||||
render_category(
|
||||
synthetic_category(group["name"], group["slug"]),
|
||||
category_url=group_public_url(group["slug"]),
|
||||
entries=[e for e in entries if group["name"] in e["groups"]],
|
||||
current_path=group_path(group["slug"]),
|
||||
page_dir=categories_dir / group["slug"],
|
||||
group_categories=group["categories"],
|
||||
)
|
||||
|
||||
if builtin_entries:
|
||||
render_category(
|
||||
synthetic_category(BUILTIN_FILTER, BUILTIN_SLUG),
|
||||
category_url=BUILTIN_PUBLIC_URL,
|
||||
entries=builtin_entries,
|
||||
current_path=BUILTIN_PATH,
|
||||
page_dir=categories_dir / BUILTIN_SLUG,
|
||||
)
|
||||
|
||||
sponsorship_dir = site_dir / "sponsorship"
|
||||
sponsorship_dir.mkdir(parents=True, exist_ok=True)
|
||||
tpl_sponsorship = env.get_template("sponsorship.html")
|
||||
hero_stats: list[str] = []
|
||||
if repo_stars:
|
||||
hero_stats.append(f"{repo_stars}+ stars on GitHub")
|
||||
hero_stats.append(f"Updated {build_date.strftime('%B %d, %Y')}")
|
||||
(sponsorship_dir / "index.html").write_text(
|
||||
tpl_sponsorship.render(hero_stats=hero_stats),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
subcat_to_entries: dict[str, list[TemplateEntry]] = {}
|
||||
subcat_meta: dict[str, tuple[str, str, str]] = {} # value -> (cat_slug, sub_slug, sub_name)
|
||||
cat_slug_by_url_prefix = {f"/categories/{c['slug']}/": c["slug"] for c in categories}
|
||||
cat_by_slug = {c["slug"]: c for c in categories}
|
||||
for entry in entries:
|
||||
for sub in entry.get("subcategories", []):
|
||||
value = sub["value"]
|
||||
subcat_to_entries.setdefault(value, []).append(entry)
|
||||
if value not in subcat_meta:
|
||||
for prefix, cat_slug in cat_slug_by_url_prefix.items():
|
||||
if sub["url"].startswith(prefix):
|
||||
subcat_meta[value] = (cat_slug, sub["slug"], sub["name"])
|
||||
break
|
||||
|
||||
for value, (cat_slug, sub_slug, sub_name) in subcat_meta.items():
|
||||
render_category(
|
||||
synthetic_category(sub_name, sub_slug),
|
||||
category_url=subcategory_public_url(cat_slug, sub_slug),
|
||||
entries=subcat_to_entries[value],
|
||||
current_path=subcategory_path(cat_slug, sub_slug),
|
||||
page_dir=categories_dir / cat_slug / sub_slug,
|
||||
parent_category=cat_by_slug[cat_slug],
|
||||
)
|
||||
|
||||
static_src = website / "static"
|
||||
static_dst = site_dir / "static"
|
||||
if static_src.exists():
|
||||
shutil.copytree(static_src, static_dst, dirs_exist_ok=True)
|
||||
|
||||
markdown_index = annotate_entries_with_stars(
|
||||
remove_sponsors_section(readme_text), stars_data
|
||||
)
|
||||
markdown_index = annotate_entries_with_stars(remove_sponsors_section(readme_text), stars_data)
|
||||
llms_template = (website / "templates" / "llms.txt").read_text(encoding="utf-8")
|
||||
llms_txt = build_llms_txt(llms_template, readme_text, stars_data)
|
||||
(site_dir / "robots.txt").write_text(build_robots_txt(), encoding="utf-8")
|
||||
write_sitemap_xml(site_dir / "sitemap.xml", [(SITE_URL, build_date.date().isoformat())])
|
||||
sitemap_date = build_date.date().isoformat()
|
||||
sitemap_urls = [(SITE_URL, sitemap_date)]
|
||||
sitemap_urls.extend((category_public_url(c), sitemap_date) for c in categories)
|
||||
sitemap_urls.extend((group_public_url(g["slug"]), sitemap_date) for g in parsed_groups)
|
||||
if builtin_entries:
|
||||
sitemap_urls.append((BUILTIN_PUBLIC_URL, sitemap_date))
|
||||
for cat_slug, sub_slug, _ in sorted(subcat_meta.values()):
|
||||
sitemap_urls.append((subcategory_public_url(cat_slug, sub_slug), sitemap_date))
|
||||
sitemap_urls.append((SPONSORSHIP_PUBLIC_URL, sitemap_date))
|
||||
write_sitemap_xml(site_dir / "sitemap.xml", sitemap_urls)
|
||||
(site_dir / "index.md").write_text(markdown_index, encoding="utf-8")
|
||||
(site_dir / "llms.txt").write_text(llms_txt, encoding="utf-8")
|
||||
|
||||
print(f"Built single page with {len(parsed_groups)} groups, {len(categories)} categories")
|
||||
print(f"Built site with {len(parsed_groups)} groups, {len(categories)} categories")
|
||||
print(f"Total entries: {total_entries}")
|
||||
print(f"Output: {site_dir}")
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ from itertools import batched
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from build import extract_github_repo, load_stars
|
||||
|
||||
CACHE_MAX_AGE_HOURS = 12
|
||||
@@ -53,10 +52,7 @@ def build_graphql_query(repos: Sequence[str]) -> str:
|
||||
owner, name = repo.split("/", 1)
|
||||
if not GITHUB_OWNER_RE.match(owner) or not GITHUB_NAME_RE.match(name):
|
||||
continue
|
||||
parts.append(
|
||||
f'repo_{i}: repository(owner: "{owner}", name: "{name}") '
|
||||
f"{{ stargazerCount owner {{ login }} defaultBranchRef {{ target {{ ... on Commit {{ committedDate }} }} }} }}"
|
||||
)
|
||||
parts.append(f'repo_{i}: repository(owner: "{owner}", name: "{name}") {{ stargazerCount owner {{ login }} defaultBranchRef {{ target {{ ... on Commit {{ committedDate }} }} }} }}')
|
||||
if not parts:
|
||||
return ""
|
||||
return "query { " + " ".join(parts) + " }"
|
||||
|
||||
@@ -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 <em> block.
|
||||
def _extract_description_children(nodes: list[SyntaxTreeNode]) -> list[SyntaxTreeNode]:
|
||||
"""Extract description children from the first paragraph if it's a single <em> 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 --------------------------------------------------------
|
||||
@@ -228,18 +229,22 @@ def _parse_list_entries(
|
||||
if sub_inline:
|
||||
sub_link = _find_child(sub_inline, "link")
|
||||
if sub_link:
|
||||
also_see.append(AlsoSee(
|
||||
name=render_inline_text(sub_link.children),
|
||||
url=_href(sub_link),
|
||||
))
|
||||
also_see.append(
|
||||
AlsoSee(
|
||||
name=render_inline_text(sub_link.children),
|
||||
url=_href(sub_link),
|
||||
)
|
||||
)
|
||||
|
||||
entries.append(ParsedEntry(
|
||||
name=name,
|
||||
url=url,
|
||||
description=desc_html,
|
||||
also_see=also_see,
|
||||
subcategory=subcategory,
|
||||
))
|
||||
entries.append(
|
||||
ParsedEntry(
|
||||
name=name,
|
||||
url=url,
|
||||
description=desc_html,
|
||||
also_see=also_see,
|
||||
subcategory=subcategory,
|
||||
)
|
||||
)
|
||||
|
||||
return entries
|
||||
|
||||
@@ -258,20 +263,22 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _is_bold_marker(node: SyntaxTreeNode) -> str | None:
|
||||
"""Detect a bold-only paragraph used as a group marker.
|
||||
|
||||
@@ -317,11 +324,13 @@ def _parse_grouped_sections(
|
||||
nonlocal current_group_name, current_group_cats
|
||||
if current_group_cats:
|
||||
name = current_group_name or "Other"
|
||||
groups.append(ParsedGroup(
|
||||
name=name,
|
||||
slug=slugify(name),
|
||||
categories=list(current_group_cats),
|
||||
))
|
||||
groups.append(
|
||||
ParsedGroup(
|
||||
name=name,
|
||||
slug=slugify(name),
|
||||
categories=list(current_group_cats),
|
||||
)
|
||||
)
|
||||
current_group_name = None
|
||||
current_group_cats = []
|
||||
|
||||
|
||||
@@ -59,6 +59,19 @@ document.querySelectorAll("[data-scroll-to]").forEach(function (link) {
|
||||
});
|
||||
});
|
||||
|
||||
// Land at #library-index without leaving the hash in the URL
|
||||
if (window.location.hash === "#library-index") {
|
||||
const target = document.getElementById("library-index");
|
||||
if (target) {
|
||||
target.scrollIntoView();
|
||||
}
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
window.location.pathname + window.location.search,
|
||||
);
|
||||
}
|
||||
|
||||
// Pause hero animations when scrolled out of view
|
||||
(function () {
|
||||
const hero = document.querySelector(".hero");
|
||||
@@ -167,19 +180,36 @@ function applyFilters() {
|
||||
updateURL();
|
||||
}
|
||||
|
||||
function updateURL() {
|
||||
const filterUrlsScript = document.getElementById("filter-urls");
|
||||
const filterToUrl = filterUrlsScript
|
||||
? JSON.parse(filterUrlsScript.textContent)
|
||||
: {};
|
||||
|
||||
const isIndexDocument =
|
||||
location.pathname === "/" || location.pathname === "/index.html";
|
||||
|
||||
const urlToFilter = {};
|
||||
Object.keys(filterToUrl).forEach(function (k) {
|
||||
urlToFilter[filterToUrl[k]] = k;
|
||||
});
|
||||
|
||||
function buildQueryString() {
|
||||
const params = new URLSearchParams();
|
||||
const query = searchInput ? searchInput.value.trim() : "";
|
||||
if (query) params.set("q", query);
|
||||
if (activeFilter) {
|
||||
params.set("filter", activeFilter);
|
||||
}
|
||||
if (activeSort.col !== "stars" || activeSort.order !== "desc") {
|
||||
params.set("sort", activeSort.col);
|
||||
params.set("order", activeSort.order);
|
||||
}
|
||||
const qs = params.toString();
|
||||
history.replaceState(null, "", qs ? "?" + qs : location.pathname);
|
||||
return qs ? "?" + qs : "";
|
||||
}
|
||||
|
||||
function updateURL() {
|
||||
if (!isIndexDocument) return;
|
||||
const path =
|
||||
activeFilter && filterToUrl[activeFilter] ? filterToUrl[activeFilter] : "/";
|
||||
history.replaceState(null, "", path + buildQueryString());
|
||||
}
|
||||
|
||||
function getSortValue(row, col) {
|
||||
@@ -202,6 +232,8 @@ function getSortValue(row, col) {
|
||||
}
|
||||
|
||||
function sortRows() {
|
||||
if (!tbody) return;
|
||||
|
||||
const arr = Array.prototype.slice.call(rows);
|
||||
const col = activeSort.col;
|
||||
const order = activeSort.order;
|
||||
@@ -286,13 +318,27 @@ tags.forEach(function (tag) {
|
||||
tag.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
const value = tag.dataset.value;
|
||||
activeFilter = activeFilter === value ? null : value;
|
||||
applyFilters();
|
||||
const url = tag.dataset.url;
|
||||
if (isIndexDocument) {
|
||||
activeFilter = activeFilter === value ? null : value;
|
||||
if (activeFilter && url) {
|
||||
history.pushState(null, "", url + buildQueryString());
|
||||
} else {
|
||||
history.pushState(null, "", "/" + buildQueryString());
|
||||
}
|
||||
applyFilters();
|
||||
} else if (url) {
|
||||
window.location.href = url;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (filterClear) {
|
||||
filterClear.addEventListener("click", function () {
|
||||
if (!isIndexDocument) {
|
||||
window.location.href = "/#library-index";
|
||||
return;
|
||||
}
|
||||
activeFilter = null;
|
||||
applyFilters();
|
||||
});
|
||||
@@ -301,6 +347,10 @@ if (filterClear) {
|
||||
const noResultsClear = document.querySelector(".no-results-clear");
|
||||
if (noResultsClear) {
|
||||
noResultsClear.addEventListener("click", function () {
|
||||
if (!isIndexDocument) {
|
||||
window.location.href = "/";
|
||||
return;
|
||||
}
|
||||
if (searchInput) searchInput.value = "";
|
||||
activeFilter = null;
|
||||
applyFilters();
|
||||
@@ -394,19 +444,29 @@ if (backToTop) {
|
||||
(function () {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const q = params.get("q");
|
||||
const filter = params.get("filter");
|
||||
const sort = params.get("sort");
|
||||
const order = params.get("order");
|
||||
if (q && searchInput) searchInput.value = q;
|
||||
if (filter) activeFilter = filter;
|
||||
if (
|
||||
(sort === "name" || sort === "stars" || sort === "commit-time") &&
|
||||
(order === "desc" || order === "asc")
|
||||
) {
|
||||
activeSort = { col: sort, order: order };
|
||||
}
|
||||
if (q || filter || sort) {
|
||||
const matched = urlToFilter[location.pathname];
|
||||
if (matched) activeFilter = matched;
|
||||
if (q || activeFilter || sort) {
|
||||
sortRows();
|
||||
}
|
||||
if (activeFilter) {
|
||||
applyFilters();
|
||||
}
|
||||
updateSortIndicators();
|
||||
})();
|
||||
|
||||
window.addEventListener("popstate", function () {
|
||||
if (!isIndexDocument) return;
|
||||
const matched = urlToFilter[location.pathname];
|
||||
activeFilter = matched || null;
|
||||
applyFilters();
|
||||
});
|
||||
|
||||
@@ -335,6 +335,49 @@ kbd {
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.hero-category-nav {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(10rem, 14rem) minmax(0, 1fr);
|
||||
gap: clamp(1.5rem, 4vw, 3rem);
|
||||
align-items: start;
|
||||
padding-top: 1.35rem;
|
||||
border-top: 1px solid var(--hero-line);
|
||||
animation: hero-rise 820ms cubic-bezier(0.22, 1, 0.36, 1) 110ms both;
|
||||
}
|
||||
|
||||
.hero-category-meta h2 {
|
||||
color: var(--hero-kicker);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.hero-category-links {
|
||||
list-style: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||
column-gap: clamp(1.25rem, 3vw, 2.5rem);
|
||||
row-gap: 0.28rem;
|
||||
}
|
||||
|
||||
.hero-category-link {
|
||||
color: var(--hero-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: transparent;
|
||||
text-underline-offset: 0.18em;
|
||||
transition:
|
||||
color 180ms ease,
|
||||
text-decoration-color 180ms ease;
|
||||
}
|
||||
|
||||
.hero-category-link:hover {
|
||||
color: var(--hero-text);
|
||||
text-decoration-color: oklch(100% 0 0 / 0.42);
|
||||
}
|
||||
|
||||
.hero-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -376,18 +419,81 @@ kbd {
|
||||
}
|
||||
|
||||
.hero-action:focus-visible,
|
||||
.hero-brand-mini:focus-visible,
|
||||
.hero-topbar-link:focus-visible,
|
||||
.hero-category-link:focus-visible,
|
||||
.search:focus-visible,
|
||||
.filter-clear:focus-visible,
|
||||
.tag:focus-visible,
|
||||
.back-to-top:focus-visible,
|
||||
.no-results-clear:focus-visible,
|
||||
.table a:focus-visible,
|
||||
.footer a:focus-visible,
|
||||
.sort-btn:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.category-hero {
|
||||
position: relative;
|
||||
overflow: clip;
|
||||
background: linear-gradient(140deg, var(--hero-bg-start) 0%, var(--hero-bg-mid) 58%, var(--hero-bg-end) 100%);
|
||||
color: var(--hero-text);
|
||||
}
|
||||
|
||||
.category-hero-shell {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(100%, calc(var(--shell-max) + (var(--shell-pad) * 2)));
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem var(--shell-pad) clamp(3.75rem, 8vw, 6.75rem);
|
||||
display: grid;
|
||||
gap: clamp(3rem, 8vw, 5.5rem);
|
||||
}
|
||||
|
||||
.category-hero h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(3.6rem, 9vw, 7rem);
|
||||
line-height: 0.9;
|
||||
font-weight: 600;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.category-breadcrumb {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--hero-muted);
|
||||
font-size: clamp(1rem, 1.5vw, 1.1rem);
|
||||
}
|
||||
|
||||
.category-breadcrumb a {
|
||||
color: var(--hero-text);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: oklch(100% 0 0 / 0.32);
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
.category-breadcrumb a:hover {
|
||||
text-decoration-color: oklch(100% 0 0 / 0.7);
|
||||
}
|
||||
|
||||
.category-subtitle {
|
||||
margin-top: 1.1rem;
|
||||
color: var(--hero-muted);
|
||||
font-size: clamp(1rem, 1.8vw, 1.18rem);
|
||||
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);
|
||||
}
|
||||
|
||||
.sponsor-band {
|
||||
padding-block: clamp(2.5rem, 5.5vw, 4rem);
|
||||
background:
|
||||
@@ -972,6 +1078,298 @@ th[data-sort].sort-asc::after {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sponsorship-hero .category-hero-shell {
|
||||
padding-bottom: clamp(3.25rem, 6vw, 5rem);
|
||||
gap: clamp(2rem, 5vw, 3.5rem);
|
||||
}
|
||||
|
||||
.sponsorship-hero-copy h1 {
|
||||
font-size: clamp(3.4rem, 8.5vw, 6.5rem);
|
||||
}
|
||||
|
||||
.sponsorship-proof {
|
||||
margin-top: 1.6rem;
|
||||
}
|
||||
|
||||
.sponsorship-proof .proof-sep {
|
||||
color: oklch(100% 0 0 / 0.32);
|
||||
margin-inline: 0.15rem;
|
||||
}
|
||||
|
||||
.sponsorship-hero .hero-actions {
|
||||
margin-top: 1.9rem;
|
||||
}
|
||||
|
||||
.sponsorship-section {
|
||||
padding-block: clamp(2.75rem, 5.5vw, 4.25rem);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.sponsorship-section:first-of-type {
|
||||
padding-top: clamp(3.25rem, 6vw, 4.75rem);
|
||||
}
|
||||
|
||||
.sponsorship-section:last-of-type {
|
||||
border-bottom: 0;
|
||||
padding-bottom: clamp(3.5rem, 7vw, 5.5rem);
|
||||
}
|
||||
|
||||
.sponsorship-getstarted {
|
||||
background: var(--cta-bg);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.sponsorship-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 16rem) minmax(0, 1fr);
|
||||
gap: clamp(1.75rem, 5vw, 4rem);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.sponsorship-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
position: sticky;
|
||||
top: 1.5rem;
|
||||
}
|
||||
|
||||
.sponsorship-meta .section-label {
|
||||
margin-bottom: 0;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.sponsorship-meta-note {
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.sponsorship-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.6rem;
|
||||
font-size: var(--text-lg);
|
||||
color: var(--ink-soft);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.sponsorship-body p {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.sponsorship-body code {
|
||||
font-family: ui-monospace, "SFMono-Regular", "Menlo", monospace;
|
||||
font-size: 0.92em;
|
||||
padding: 0.08rem 0.4rem;
|
||||
border-radius: 0.4rem;
|
||||
background: var(--bg-paper-strong);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.sponsorship-body a:not(.hero-action):not(.tier-cta) {
|
||||
color: var(--accent-deep);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--accent-underline);
|
||||
text-underline-offset: 0.2em;
|
||||
transition: color 180ms ease;
|
||||
}
|
||||
|
||||
.sponsorship-body a:not(.hero-action):not(.tier-cta):hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sponsorship-lede {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.55rem, 2.6vw, 2rem);
|
||||
line-height: 1.25;
|
||||
color: var(--ink);
|
||||
letter-spacing: -0.01em;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.sponsorship-facts {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 1.4rem;
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 1.6rem;
|
||||
}
|
||||
|
||||
.sponsorship-facts > div {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 12rem) minmax(0, 1fr);
|
||||
gap: clamp(1rem, 3vw, 2rem);
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.sponsorship-facts dt {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.sponsorship-facts dd {
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.tier-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: clamp(1.5rem, 3vw, 2.75rem);
|
||||
}
|
||||
|
||||
.tier {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding-block: 1.65rem;
|
||||
border-top: 1px solid var(--line-strong);
|
||||
}
|
||||
|
||||
.tier-eyebrow {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.tier-price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.55rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.tier-amount {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(3rem, 5.5vw, 4.5rem);
|
||||
font-weight: 600;
|
||||
line-height: 0.9;
|
||||
letter-spacing: -0.025em;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.tier-cadence {
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.tier-summary {
|
||||
font-size: var(--text-lg);
|
||||
color: var(--ink);
|
||||
line-height: 1.5;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.tier-includes {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 1.1rem;
|
||||
}
|
||||
|
||||
.tier-includes li {
|
||||
position: relative;
|
||||
padding-left: 1.4rem;
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.tier-includes li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.65rem;
|
||||
width: 0.55rem;
|
||||
height: 1px;
|
||||
background: var(--line-strong);
|
||||
}
|
||||
|
||||
.tier-cta {
|
||||
align-self: start;
|
||||
margin-top: 0.75rem;
|
||||
color: var(--accent-deep);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--accent-underline);
|
||||
text-underline-offset: 0.22em;
|
||||
transition: color 180ms ease, text-decoration-color 180ms ease;
|
||||
}
|
||||
|
||||
.tier-cta:hover {
|
||||
color: var(--accent);
|
||||
text-decoration-color: var(--accent);
|
||||
}
|
||||
|
||||
.past-sponsors {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.past-sponsors li {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.65rem;
|
||||
padding-block: 0.4rem;
|
||||
}
|
||||
|
||||
.past-sponsors a {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.6rem, 2.8vw, 2.1rem);
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--ink);
|
||||
transition: color 180ms ease;
|
||||
}
|
||||
|
||||
.past-sponsors a:hover {
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.past-sponsor-desc {
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.sponsorship-cta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.85rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.sponsorship-cta-row .hero-action-primary {
|
||||
color: var(--hero-text);
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-deep));
|
||||
}
|
||||
|
||||
.sponsorship-fineprint {
|
||||
font-size: var(--text-base);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.final-cta {
|
||||
padding-block: clamp(3rem, 7vw, 5.5rem);
|
||||
background: var(--cta-bg);
|
||||
@@ -1117,10 +1515,34 @@ th[data-sort].sort-asc::after {
|
||||
|
||||
.hero-grid,
|
||||
.results-intro,
|
||||
.sponsor-shell {
|
||||
.sponsor-shell,
|
||||
.sponsorship-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sponsorship-meta {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.tier-list {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.sponsorship-facts > div {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.hero-category-nav {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.95rem;
|
||||
}
|
||||
|
||||
.hero-category-links {
|
||||
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
|
||||
}
|
||||
|
||||
.results-note {
|
||||
justify-self: start;
|
||||
}
|
||||
@@ -1185,6 +1607,15 @@ th[data-sort].sort-asc::after {
|
||||
font-size: clamp(3.6rem, 18vw, 5.2rem);
|
||||
}
|
||||
|
||||
.hero-category-links {
|
||||
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
|
||||
column-gap: 0.75rem;
|
||||
}
|
||||
|
||||
.hero-category-link {
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.search {
|
||||
min-height: 3.5rem;
|
||||
border-radius: 1.25rem;
|
||||
|
||||
@@ -3,21 +3,24 @@
|
||||
<head>
|
||||
{% set default_meta_title = "Awesome Python" %}
|
||||
{% set default_meta_description = "An opinionated guide to the best Python frameworks, libraries, and tools. Explore " ~ (entries | length) ~ " curated projects across " ~ total_categories ~ " categories, from AI and agents to data science and web development." %}
|
||||
{% set canonical_url = "https://awesome-python.com/" %}
|
||||
{% set default_canonical_url = "https://awesome-python.com/" %}
|
||||
{% set social_image_url = "https://awesome-python.com/static/og-image.png" %}
|
||||
{% set meta_title %}{% block title %}{{ default_meta_title }}{% endblock %}{% endset %}
|
||||
{% set meta_description %}{% block description %}{{ default_meta_description }}{% endblock %}{% endset %}
|
||||
{% set canonical_url %}{% block canonical_url %}{{ default_canonical_url }}{% endblock %}{% endset %}
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{ meta_title | trim }}</title>
|
||||
<meta name="description" content="{{ meta_description | trim }}" />
|
||||
<link rel="canonical" href="{{ canonical_url }}" />
|
||||
<link rel="canonical" href="{{ canonical_url | trim }}" />
|
||||
{% block alternate_links %}
|
||||
<link rel="alternate" type="text/markdown" href="/index.md" />
|
||||
{% endblock %}
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="{{ meta_title | trim }}" />
|
||||
<meta property="og:description" content="{{ meta_description | trim }}" />
|
||||
<meta property="og:image" content="{{ social_image_url }}" />
|
||||
<meta property="og:url" content="{{ canonical_url }}" />
|
||||
<meta property="og:url" content="{{ canonical_url | trim }}" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="{{ meta_title | trim }}" />
|
||||
<meta name="twitter:description" content="{{ meta_description | trim }}" />
|
||||
|
||||
283
website/templates/category.html
Normal file
283
website/templates/category.html
Normal file
@@ -0,0 +1,283 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ category.name }} Python Libraries | Awesome Python{% endblock %}
|
||||
{% 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 header %}
|
||||
<header class="category-hero">
|
||||
<div class="hero-sheen" aria-hidden="true"></div>
|
||||
<div class="hero-noise" aria-hidden="true"></div>
|
||||
|
||||
<div class="category-hero-shell">
|
||||
<nav class="hero-topbar category-topbar" aria-label="Site">
|
||||
<a href="/" class="hero-brand-mini">Awesome Python</a>
|
||||
<div class="hero-topbar-actions">
|
||||
<a href="/#library-index" class="hero-topbar-link">All projects</a>
|
||||
<a
|
||||
href="https://github.com/vinta/awesome-python/blob/master/CONTRIBUTING.md"
|
||||
class="hero-topbar-link hero-topbar-link-strong"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>Submit a project</a
|
||||
>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="category-hero-copy">
|
||||
{% if parent_category %}
|
||||
<p class="category-breadcrumb">
|
||||
<a href="/categories/{{ parent_category.slug }}/">{{ parent_category.name }}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
<h1>{{ category.name }}</h1>
|
||||
{% if category.description_html %}
|
||||
<p class="category-subtitle">{{ category.description_html | safe }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if group_categories %}
|
||||
<nav class="hero-category-nav" aria-labelledby="hero-category-heading">
|
||||
<div class="hero-category-meta">
|
||||
<h2 id="hero-category-heading">Browse by category</h2>
|
||||
</div>
|
||||
<ul class="hero-category-links">
|
||||
{% for sub in group_categories %}
|
||||
<li>
|
||||
<a class="hero-category-link" href="{{ category_urls[sub.name] }}"
|
||||
>{{ sub.name }}</a
|
||||
>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<script type="application/json" id="filter-urls">{{ filter_urls_json | safe }}</script>
|
||||
<section class="results-section" id="library-index">
|
||||
<div class="results-intro section-shell" data-reveal>
|
||||
<div>
|
||||
<h2>Search every project in one place</h2>
|
||||
</div>
|
||||
<p class="results-note">
|
||||
Press <kbd>/</kbd> to search. Tap a tag to filter. Click any row for
|
||||
details.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="controls section-shell" data-reveal>
|
||||
<h2 class="sr-only">Search and filter</h2>
|
||||
<div class="search-wrap">
|
||||
<svg
|
||||
class="search-icon"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
class="search"
|
||||
placeholder="Search {{ entries | length }} projects in {{ category.name }}..."
|
||||
aria-label="Search projects"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-bar" aria-live="polite">
|
||||
<span>Filtering for <strong class="filter-value"></strong></span>
|
||||
<button class="filter-clear" aria-label="Clear filter">
|
||||
Clear filter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="sr-only">Results</h2>
|
||||
<div
|
||||
class="table-wrap"
|
||||
tabindex="0"
|
||||
role="region"
|
||||
aria-label="Libraries table"
|
||||
>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-num"><span class="sr-only">Row number</span></th>
|
||||
<th class="col-name" data-sort="name">
|
||||
<button type="button" class="sort-btn">Project Name</button>
|
||||
</th>
|
||||
<th class="col-stars" data-sort="stars">
|
||||
<button type="button" class="sort-btn">GitHub Stars</button>
|
||||
</th>
|
||||
<th class="col-commit" data-sort="commit-time">
|
||||
<button type="button" class="sort-btn">Last Commit</button>
|
||||
</th>
|
||||
<th class="col-cat">Tags</th>
|
||||
<th class="col-arrow">
|
||||
<button class="back-to-top" aria-label="Back to top">
|
||||
Top ↑
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in entries %}
|
||||
<tr
|
||||
class="row"
|
||||
data-tags="{{ entry.categories | join('||') }}{% if entry.subcategories %}||{{ entry.subcategories | map(attribute='value') | join('||') }}{% endif %}||{{ entry.groups | join('||') }}{% if entry.source_type == 'Built-in' %}||Built-in{% endif %}"
|
||||
tabindex="0"
|
||||
aria-expanded="false"
|
||||
aria-controls="expand-{{ loop.index }}"
|
||||
>
|
||||
<td class="col-num">{{ loop.index }}</td>
|
||||
<td class="col-name">
|
||||
<a href="{{ entry.url }}" target="_blank" rel="noopener"
|
||||
>{{ entry.name }}</a
|
||||
>
|
||||
<span class="mobile-cat"
|
||||
>{% if entry.subcategories %}{{ entry.subcategories[0].name }}{%
|
||||
else %}{{ category.name }}{% endif %}</span
|
||||
>
|
||||
</td>
|
||||
<td class="col-stars">
|
||||
{% if entry.stars is not none %}{{ "{:,}".format(entry.stars) }}{%
|
||||
elif entry.source_type %}<span class="source-badge"
|
||||
>{{ entry.source_type }}</span
|
||||
>{% else %}—{% endif %}
|
||||
</td>
|
||||
<td
|
||||
class="col-commit"
|
||||
{%
|
||||
if
|
||||
entry.last_commit_at
|
||||
%}data-commit="{{ entry.last_commit_at }}"
|
||||
{%
|
||||
endif
|
||||
%}
|
||||
>
|
||||
{% if entry.last_commit_at %}<time
|
||||
datetime="{{ entry.last_commit_at }}"
|
||||
>{{ entry.last_commit_at[:10] }}</time
|
||||
>{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="col-cat">
|
||||
{% for subcat in entry.subcategories %}
|
||||
<button class="tag{% if subcat.url == current_path %} active{% endif %}" data-value="{{ subcat.value }}" data-url="{{ subcat.url }}">
|
||||
{{ subcat.name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
{% for cat in entry.categories %}
|
||||
<a
|
||||
class="tag{% if category_urls[cat] == current_path %} active{% endif %}"
|
||||
href="{{ category_urls[cat] }}"
|
||||
data-value="{{ cat }}"
|
||||
data-url="{{ category_urls[cat] }}"
|
||||
>{{ cat }}</a
|
||||
>
|
||||
{% endfor %}
|
||||
{% if entry.groups %}
|
||||
{% set group_url = filter_urls[entry.groups[0]] %}
|
||||
<button
|
||||
class="tag tag-group{% if group_url == current_path %} active{% endif %}"
|
||||
data-value="{{ entry.groups[0] }}"
|
||||
data-url="{{ group_url }}"
|
||||
>
|
||||
{{ entry.groups[0] }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if entry.source_type == 'Built-in' %}
|
||||
<button
|
||||
class="tag tag-source{% if '/categories/built-in/' == current_path %} active{% endif %}"
|
||||
data-value="Built-in"
|
||||
data-url="/categories/built-in/"
|
||||
>
|
||||
Built-in
|
||||
</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="col-arrow"><span class="arrow">→</span></td>
|
||||
</tr>
|
||||
<tr class="expand-row" id="expand-{{ loop.index }}">
|
||||
<td></td>
|
||||
<td colspan="4">
|
||||
<div class="expand-content">
|
||||
{% if entry.description %}
|
||||
<div class="expand-desc">{{ entry.description | safe }}</div>
|
||||
{% endif %} {% if entry.also_see %}
|
||||
<div class="expand-also-see">
|
||||
Also see: {% for see in entry.also_see %}<a
|
||||
href="{{ see.url }}"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>{{ see.name }}</a
|
||||
>{% if not loop.last %}, {% endif %}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="expand-meta">
|
||||
{% if entry.owner %}<a
|
||||
href="https://github.com/{{ entry.owner }}"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>{{ entry.owner }}</a
|
||||
><span class="expand-sep">/</span>{% endif %}<a
|
||||
href="{{ entry.url }}"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>{{ entry.url | replace("https://", "") }}</a
|
||||
>
|
||||
{% if entry.last_commit_at %}<span class="expand-commit"
|
||||
><span class="expand-sep">/</span
|
||||
><time datetime="{{ entry.last_commit_at }}"
|
||||
>{{ entry.last_commit_at[:10] }}</time
|
||||
></span
|
||||
>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="no-results" hidden>
|
||||
<p>No projects match your search or filter.</p>
|
||||
<p class="no-results-hint">
|
||||
Try a broader term, or
|
||||
<button class="no-results-clear">browse all projects</button>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="final-cta" data-reveal>
|
||||
<div class="section-shell">
|
||||
<p class="section-label">Contribute</p>
|
||||
<h2>Know a project that belongs here?</h2>
|
||||
<p>Tell us what it does and why it stands out.</p>
|
||||
<div class="final-cta-actions">
|
||||
<a
|
||||
href="https://github.com/vinta/awesome-python/blob/master/CONTRIBUTING.md"
|
||||
class="hero-action hero-action-primary"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>Submit a project</a
|
||||
>
|
||||
<a
|
||||
href="https://github.com/vinta/awesome-python"
|
||||
class="hero-action hero-action-secondary"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>Star the repository</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -61,6 +61,21 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="hero-category-nav" aria-labelledby="hero-category-heading">
|
||||
<div class="hero-category-meta">
|
||||
<h2 id="hero-category-heading">Browse by category</h2>
|
||||
</div>
|
||||
<ul class="hero-category-links">
|
||||
{% for category in categories %}
|
||||
<li>
|
||||
<a class="hero-category-link" href="{{ category_urls[category.name] }}"
|
||||
>{{ category.name }}</a
|
||||
>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
{% endblock %}
|
||||
@@ -70,14 +85,7 @@
|
||||
<div class="section-shell sponsor-shell">
|
||||
<header class="sponsor-meta">
|
||||
<p class="section-label" id="sponsor-heading">Sponsors</p>
|
||||
<a
|
||||
class="sponsor-become"
|
||||
href="https://github.com/vinta/awesome-python/blob/master/SPONSORSHIP.md"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Become a sponsor
|
||||
</a>
|
||||
<a class="sponsor-become" href="/sponsorship/"> Become a sponsor </a>
|
||||
</header>
|
||||
<ul class="sponsor-list">
|
||||
{% for sponsor in sponsors %}
|
||||
@@ -98,6 +106,7 @@
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<script type="application/json" id="filter-urls">{{ filter_urls_json | safe }}</script>
|
||||
<section class="results-section" id="library-index">
|
||||
<div class="results-intro section-shell" data-reveal>
|
||||
<div>
|
||||
@@ -211,17 +220,31 @@
|
||||
</td>
|
||||
<td class="col-cat">
|
||||
{% for subcat in entry.subcategories %}
|
||||
<button class="tag" data-value="{{ subcat.value }}">
|
||||
<button class="tag" data-value="{{ subcat.value }}" data-url="{{ subcat.url }}">
|
||||
{{ subcat.name }}
|
||||
</button>
|
||||
{% endfor %} {% for cat in entry.categories %}
|
||||
<button class="tag" data-value="{{ cat }}">{{ cat }}</button>
|
||||
<a
|
||||
class="tag"
|
||||
href="{{ category_urls[cat] }}"
|
||||
data-value="{{ cat }}"
|
||||
data-url="{{ category_urls[cat] }}"
|
||||
>{{ cat }}</a
|
||||
>
|
||||
{% endfor %}
|
||||
<button class="tag tag-group" data-value="{{ entry.groups[0] }}">
|
||||
<button
|
||||
class="tag tag-group"
|
||||
data-value="{{ entry.groups[0] }}"
|
||||
data-url="{{ filter_urls[entry.groups[0]] }}"
|
||||
>
|
||||
{{ entry.groups[0] }}
|
||||
</button>
|
||||
{% if entry.source_type == 'Built-in' %}
|
||||
<button class="tag tag-source" data-value="Built-in">
|
||||
<button
|
||||
class="tag tag-source"
|
||||
data-value="Built-in"
|
||||
data-url="/categories/built-in/"
|
||||
>
|
||||
Built-in
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
250
website/templates/sponsorship.html
Normal file
250
website/templates/sponsorship.html
Normal file
@@ -0,0 +1,250 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Sponsor Awesome Python{% endblock %}
|
||||
{% block description %}Sponsorship for awesome-python: tiers, audience, and how to get your product in front of professional Python developers evaluating tools for production use.{% endblock %}
|
||||
{% block canonical_url %}https://awesome-python.com/sponsorship/{% endblock %}
|
||||
{% block alternate_links %}{% endblock %}
|
||||
{% block header %}
|
||||
<header class="category-hero sponsorship-hero">
|
||||
<div class="hero-sheen" aria-hidden="true"></div>
|
||||
<div class="hero-noise" aria-hidden="true"></div>
|
||||
|
||||
<div class="category-hero-shell">
|
||||
<nav class="hero-topbar category-topbar" aria-label="Site">
|
||||
<a href="/" class="hero-brand-mini">Awesome Python</a>
|
||||
<div class="hero-topbar-actions">
|
||||
<a
|
||||
href="https://github.com/vinta/awesome-python/blob/master/CONTRIBUTING.md"
|
||||
class="hero-topbar-link hero-topbar-link-strong"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>Submit a project</a
|
||||
>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="category-hero-copy sponsorship-hero-copy">
|
||||
<p class="hero-kicker">Sponsorship</p>
|
||||
<h1>Sponsor Awesome Python</h1>
|
||||
<p class="category-subtitle">
|
||||
The #10 most-starred repository on GitHub, and the list Python
|
||||
developers check when choosing what to use. Your sponsorship puts your
|
||||
product in front of them at the moment of decision.
|
||||
</p>
|
||||
|
||||
{% if hero_stats %}
|
||||
<p class="hero-proof sponsorship-proof">
|
||||
{% for stat in hero_stats %}{{ stat }}{% if not loop.last %}
|
||||
<span class="proof-sep">/</span> {% endif %}{% endfor %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="hero-actions">
|
||||
<a
|
||||
href="mailto:vinta.chen@gmail.com?subject=Awesome%20Python%20Sponsorship"
|
||||
class="hero-action hero-action-primary"
|
||||
>Email vinta.chen@gmail.com</a
|
||||
>
|
||||
<a
|
||||
href="https://github.com/vinta/awesome-python"
|
||||
class="hero-action hero-action-secondary"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>View on GitHub</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{% endblock %} {% block content %}
|
||||
<section class="sponsorship-section sponsorship-audience" data-reveal>
|
||||
<div class="section-shell sponsorship-shell">
|
||||
<header class="sponsorship-meta">
|
||||
<p class="section-label">Audience</p>
|
||||
</header>
|
||||
<div class="sponsorship-body">
|
||||
<p class="sponsorship-lede">
|
||||
Professional Python developers evaluating libraries and tools for
|
||||
production use. Not beginners browsing tutorials. People making adoption
|
||||
decisions.
|
||||
</p>
|
||||
<dl class="sponsorship-facts">
|
||||
<div>
|
||||
<dt>Who visits</dt>
|
||||
<dd>
|
||||
Mid to senior Python developers arriving with a specific question:
|
||||
a maintained ORM, a fast HTTP client, a task queue worth running in
|
||||
production.
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Where they come from</dt>
|
||||
<dd>
|
||||
Google Search, GitHub, Reddit, YouTube, ChatGPT and other LLMs,
|
||||
Hacker News.
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Why it works</dt>
|
||||
<dd>
|
||||
Ranks on the first page of Google for "best Python libraries".
|
||||
ChatGPT and other LLMs cite it when recommending Python tools.
|
||||
Developers send it to each other.
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="sponsorship-section sponsorship-tiers" data-reveal>
|
||||
<div class="section-shell sponsorship-shell">
|
||||
<header class="sponsorship-meta">
|
||||
<p class="section-label">Tiers</p>
|
||||
<p class="sponsorship-meta-note">
|
||||
One upfront payment per term. Setup takes less than 24 hours.
|
||||
</p>
|
||||
</header>
|
||||
<div class="sponsorship-body">
|
||||
<ol class="tier-list">
|
||||
<li class="tier">
|
||||
<p class="tier-eyebrow">Headline Sponsor</p>
|
||||
<p class="tier-price">
|
||||
<span class="tier-amount">$500</span>
|
||||
<span class="tier-cadence">/ month</span>
|
||||
</p>
|
||||
<p class="tier-summary">
|
||||
Logo pinned at the top of the README. Logo on the website.
|
||||
</p>
|
||||
<ul class="tier-includes">
|
||||
<li>
|
||||
Large logo and one-line description (max 120 characters) pinned at
|
||||
the very top of the README, above all project entries.
|
||||
</li>
|
||||
<li>Logo link in the sponsor section of awesome-python.com.</li>
|
||||
</ul>
|
||||
<a
|
||||
href="mailto:vinta.chen@gmail.com?subject=Awesome%20Python%20Sponsorship%20-%20Headline"
|
||||
class="tier-cta"
|
||||
>Email about Headline tier</a
|
||||
>
|
||||
</li>
|
||||
<li class="tier">
|
||||
<p class="tier-eyebrow">Featured Sponsor</p>
|
||||
<p class="tier-price">
|
||||
<span class="tier-amount">$150</span>
|
||||
<span class="tier-cadence">/ month</span>
|
||||
</p>
|
||||
<p class="tier-summary">
|
||||
Text link pinned at the top of the README. Text link on the website.
|
||||
</p>
|
||||
<ul class="tier-includes">
|
||||
<li>
|
||||
Text entry (<code>[Name](URL) - Description.</code>, max 120
|
||||
characters) pinned at the top of the README, directly below
|
||||
Headline sponsors.
|
||||
</li>
|
||||
<li>Text link in the sponsor section of awesome-python.com.</li>
|
||||
</ul>
|
||||
<a
|
||||
href="mailto:vinta.chen@gmail.com?subject=Awesome%20Python%20Sponsorship%20-%20Featured"
|
||||
class="tier-cta"
|
||||
>Email about Featured tier</a
|
||||
>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="sponsorship-section sponsorship-past" data-reveal>
|
||||
<div class="section-shell sponsorship-shell">
|
||||
<header class="sponsorship-meta">
|
||||
<p class="section-label">Previously sponsored by</p>
|
||||
</header>
|
||||
<div class="sponsorship-body">
|
||||
<ul class="past-sponsors">
|
||||
<li>
|
||||
<a href="https://www.warp.dev/" target="_blank" rel="noopener"
|
||||
>Warp</a
|
||||
>
|
||||
<span class="past-sponsor-desc"
|
||||
>The terminal for modern developers.</span
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="sponsorship-section sponsorship-getstarted" data-reveal>
|
||||
<div class="section-shell sponsorship-shell">
|
||||
<header class="sponsorship-meta">
|
||||
<p class="section-label">Get started</p>
|
||||
</header>
|
||||
<div class="sponsorship-body">
|
||||
<p class="sponsorship-lede">
|
||||
Email
|
||||
<a
|
||||
href="mailto:vinta.chen@gmail.com?subject=Awesome%20Python%20Sponsorship"
|
||||
>vinta.chen@gmail.com</a
|
||||
>
|
||||
with the four items below.
|
||||
</p>
|
||||
<dl class="sponsorship-facts">
|
||||
<div>
|
||||
<dt>Tier</dt>
|
||||
<dd>Headline Sponsor ($500/mo) or Featured Sponsor ($150/mo).</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Content</dt>
|
||||
<dd>
|
||||
Product name, URL, logo, and description (Headline tier), or
|
||||
<code>[Name](URL) - Description.</code> entry (Featured tier).
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Duration</dt>
|
||||
<dd>1, 3, 6 months, or longer.</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Payment method</dt>
|
||||
<dd>US bank transfer (ACH/wire) or PayPal.</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="sponsorship-cta-row">
|
||||
<a
|
||||
href="mailto:vinta.chen@gmail.com?subject=Awesome%20Python%20Sponsorship"
|
||||
class="hero-action hero-action-primary"
|
||||
>Email vinta.chen@gmail.com</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="sponsorship-section sponsorship-independence" data-reveal>
|
||||
<div class="section-shell sponsorship-shell">
|
||||
<header class="sponsorship-meta">
|
||||
<p class="section-label">Editorial independence</p>
|
||||
</header>
|
||||
<div class="sponsorship-body">
|
||||
<p>
|
||||
Sponsorship is logo and link placement in the README header. It does not
|
||||
influence which projects are listed. We curate listings on merit through
|
||||
the normal
|
||||
<a
|
||||
href="https://github.com/vinta/awesome-python/blob/master/CONTRIBUTING.md"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>contribution process</a
|
||||
>.
|
||||
</p>
|
||||
<p class="sponsorship-fineprint">
|
||||
We reserve the right to request changes to sponsor text, logos, or links
|
||||
that are misleading, off-topic, or incompatible with the README
|
||||
formatting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -8,7 +8,9 @@ from datetime import UTC, date, datetime
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from build import (
|
||||
TemplateEntry,
|
||||
annotate_entries_with_stars,
|
||||
build,
|
||||
detect_source_type,
|
||||
@@ -16,6 +18,7 @@ from build import (
|
||||
extract_github_repo,
|
||||
load_stars,
|
||||
sort_entries,
|
||||
subcategory_path,
|
||||
)
|
||||
from readme_parser import parse_readme, slugify
|
||||
|
||||
@@ -65,22 +68,18 @@ class TestSlugify:
|
||||
def test_uppercase_acronym(self):
|
||||
assert slugify("RESTful API") == "restful-api"
|
||||
|
||||
def test_all_caps(self):
|
||||
assert slugify("CMS") == "cms"
|
||||
|
||||
def test_hyphenated_input(self):
|
||||
assert slugify("Command-line Tools") == "command-line-tools"
|
||||
|
||||
def test_special_chars(self):
|
||||
assert slugify("Editor Plugins and IDEs") == "editor-plugins-and-ides"
|
||||
|
||||
def test_single_word(self):
|
||||
assert slugify("Audio") == "audio"
|
||||
|
||||
def test_extra_spaces(self):
|
||||
assert slugify(" Date and Time ") == "date-and-time"
|
||||
|
||||
|
||||
class TestSubcategoryPath:
|
||||
def test_builds_path(self):
|
||||
assert subcategory_path("web-frameworks", "synchronous") == "/categories/web-frameworks/synchronous/"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build (integration)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -109,14 +108,16 @@ class TestBuild:
|
||||
"{% endblock %}",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tpl_dir / "category.html").write_text(
|
||||
'{% extends "base.html" %}{% block content %}<h1>{{ category.name }}</h1>{% for entry in entries %}<a href="{{ entry.url }}">{{ entry.name }}</a>{% endfor %}{% endblock %}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tpl_dir / "sponsorship.html").write_text(
|
||||
'{% extends "base.html" %}{% block content %}<h1>Sponsor</h1>{% endblock %}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tpl_dir / "llms.txt").write_text(
|
||||
"# Awesome Python\n"
|
||||
"\n"
|
||||
"Use this list to find Python tools.\n"
|
||||
"\n"
|
||||
"# Categories\n"
|
||||
"\n"
|
||||
"{{ categories_md }}\n",
|
||||
"# Awesome Python\n\nUse this list to find Python tools.\n\n# Categories\n\n{{ categories_md }}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
@@ -125,7 +126,7 @@ class TestBuild:
|
||||
tpl_dir = tmp_path / "website" / "templates"
|
||||
shutil.copytree(real_tpl, tpl_dir)
|
||||
|
||||
def test_build_creates_single_page(self, tmp_path):
|
||||
def test_build_creates_homepage_and_category_pages(self, tmp_path):
|
||||
readme = textwrap.dedent("""\
|
||||
# Awesome Python
|
||||
|
||||
@@ -137,7 +138,7 @@ class TestBuild:
|
||||
|
||||
## Widgets
|
||||
|
||||
_Widget libraries._
|
||||
_Widget libraries. Also see [awesome-widgets](https://example.com/widgets)._
|
||||
|
||||
- [w1](https://example.com) - A widget.
|
||||
|
||||
@@ -164,8 +165,8 @@ class TestBuild:
|
||||
|
||||
site = tmp_path / "website" / "output"
|
||||
assert (site / "index.html").exists()
|
||||
# No category sub-pages
|
||||
assert not (site / "categories").exists()
|
||||
assert (site / "categories" / "widgets" / "index.html").exists()
|
||||
assert (site / "categories" / "gadgets" / "index.html").exists()
|
||||
|
||||
def test_build_creates_root_discovery_files(self, tmp_path):
|
||||
readme = textwrap.dedent("""\
|
||||
@@ -175,9 +176,13 @@ class TestBuild:
|
||||
|
||||
---
|
||||
|
||||
**Tools**
|
||||
|
||||
## Widgets
|
||||
|
||||
- [w1](https://example.com) - A widget.
|
||||
- Sync
|
||||
|
||||
- [w1](https://example.com) - A widget.
|
||||
|
||||
# Contributing
|
||||
|
||||
@@ -190,27 +195,93 @@ class TestBuild:
|
||||
|
||||
site = tmp_path / "website" / "output"
|
||||
robots = (site / "robots.txt").read_text(encoding="utf-8")
|
||||
assert robots == (
|
||||
"User-agent: *\n"
|
||||
"Content-Signal: search=yes, ai-input=yes, ai-train=yes\n"
|
||||
"Allow: /\n"
|
||||
"\n"
|
||||
"Sitemap: https://awesome-python.com/sitemap.xml\n"
|
||||
)
|
||||
assert robots == ("User-agent: *\nContent-Signal: search=yes, ai-input=yes, ai-train=yes\nAllow: /\n\nSitemap: https://awesome-python.com/sitemap.xml\n")
|
||||
|
||||
sitemap = ET.parse(site / "sitemap.xml")
|
||||
root = sitemap.getroot()
|
||||
ns = {"sitemap": "http://www.sitemaps.org/schemas/sitemap/0.9"}
|
||||
locs = [loc.text for loc in root.findall("sitemap:url/sitemap:loc", ns)]
|
||||
lastmods = [lastmod.text for lastmod in root.findall("sitemap:url/sitemap:lastmod", ns)]
|
||||
locs = [loc.text or "" for loc in root.findall("sitemap:url/sitemap:loc", ns)]
|
||||
lastmods = [lastmod.text or "" for lastmod in root.findall("sitemap:url/sitemap:lastmod", ns)]
|
||||
|
||||
assert root.tag == "{http://www.sitemaps.org/schemas/sitemap/0.9}urlset"
|
||||
assert locs == ["https://awesome-python.com/"]
|
||||
assert len(lastmods) == 1
|
||||
assert start_date <= date.fromisoformat(lastmods[0]) <= end_date
|
||||
assert locs == [
|
||||
"https://awesome-python.com/",
|
||||
"https://awesome-python.com/categories/widgets/",
|
||||
"https://awesome-python.com/categories/tools/",
|
||||
"https://awesome-python.com/categories/widgets/sync/",
|
||||
"https://awesome-python.com/sponsorship/",
|
||||
]
|
||||
assert len(lastmods) == len(locs)
|
||||
assert all(start_date <= date.fromisoformat(lastmod) <= end_date for lastmod in lastmods)
|
||||
assert all(loc.startswith("https://awesome-python.com/") for loc in locs)
|
||||
assert all("?" not in loc for loc in locs)
|
||||
|
||||
def test_build_creates_category_pages_with_metadata_and_links(self, tmp_path):
|
||||
readme = textwrap.dedent("""\
|
||||
# Awesome Python
|
||||
|
||||
Intro.
|
||||
|
||||
---
|
||||
|
||||
**Tools**
|
||||
|
||||
## Widgets
|
||||
|
||||
_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.
|
||||
|
||||
## Gadgets
|
||||
|
||||
_Gadget tools._
|
||||
|
||||
- [g1](https://example.com/g1) - A gadget.
|
||||
|
||||
# Contributing
|
||||
|
||||
Help!
|
||||
""")
|
||||
(tmp_path / "README.md").write_text(readme, encoding="utf-8")
|
||||
self._copy_real_templates(tmp_path)
|
||||
|
||||
data_dir = tmp_path / "website" / "data"
|
||||
data_dir.mkdir(parents=True)
|
||||
stars = {
|
||||
"owner/w2": {
|
||||
"stars": 42,
|
||||
"owner": "owner",
|
||||
"last_commit_at": "2026-01-01T00:00:00+00:00",
|
||||
"fetched_at": "2026-01-01T00:00:00+00:00",
|
||||
},
|
||||
}
|
||||
(data_dir / "github_stars.json").write_text(json.dumps(stars), encoding="utf-8")
|
||||
|
||||
build(tmp_path)
|
||||
|
||||
site = tmp_path / "website" / "output"
|
||||
index_html = (site / "index.html").read_text(encoding="utf-8")
|
||||
category_html = (site / "categories" / "widgets" / "index.html").read_text(encoding="utf-8")
|
||||
parser = HeadMetadataParser()
|
||||
parser.feed(category_html)
|
||||
|
||||
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. 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 '<link rel="alternate" type="text/markdown" href="/index.md" />' not in category_html
|
||||
assert "<h1>Widgets</h1>" in category_html
|
||||
assert 'Widget libraries. Also see <a href="https://example.com/widgets" target="_blank" rel="noopener">awesome-widgets</a>.' 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
|
||||
assert '<table class="table">' in category_html
|
||||
assert "42" in category_html
|
||||
assert "2026-01-01T00:00:00+00:00" in category_html
|
||||
|
||||
def test_build_creates_markdown_alternate_without_sponsors(self, tmp_path):
|
||||
readme = textwrap.dedent("""\
|
||||
# Awesome Python
|
||||
@@ -300,59 +371,6 @@ class TestBuild:
|
||||
|
||||
assert not (tmp_path / "website" / "output" / "categories" / "stale").exists()
|
||||
|
||||
def test_index_contains_category_names(self, tmp_path):
|
||||
readme = textwrap.dedent("""\
|
||||
# T
|
||||
|
||||
---
|
||||
|
||||
**Group A**
|
||||
|
||||
## Alpha
|
||||
|
||||
- [a](https://x.com) - A.
|
||||
|
||||
**Group B**
|
||||
|
||||
## Beta
|
||||
|
||||
- [b](https://x.com) - B.
|
||||
|
||||
# Contributing
|
||||
|
||||
Done.
|
||||
""")
|
||||
self._make_repo(tmp_path, readme)
|
||||
build(tmp_path)
|
||||
|
||||
index_html = (tmp_path / "website" / "output" / "index.html").read_text()
|
||||
assert "Alpha" in index_html
|
||||
assert "Beta" in index_html
|
||||
assert "Group A" in index_html
|
||||
assert "Group B" in index_html
|
||||
|
||||
def test_index_contains_preview_text(self, tmp_path):
|
||||
readme = textwrap.dedent("""\
|
||||
# T
|
||||
|
||||
---
|
||||
|
||||
## Stuff
|
||||
|
||||
- [django](https://x.com) - A framework.
|
||||
- [flask](https://x.com) - A micro.
|
||||
|
||||
# Contributing
|
||||
|
||||
Done.
|
||||
""")
|
||||
self._make_repo(tmp_path, readme)
|
||||
build(tmp_path)
|
||||
|
||||
index_html = (tmp_path / "website" / "output" / "index.html").read_text()
|
||||
assert "django" in index_html
|
||||
assert "flask" in index_html
|
||||
|
||||
def test_build_with_stars_sorts_by_stars(self, tmp_path):
|
||||
readme = textwrap.dedent("""\
|
||||
# T
|
||||
@@ -397,6 +415,26 @@ class TestBuild:
|
||||
# Expand content present
|
||||
assert "expand-content" in html
|
||||
|
||||
def test_build_fails_when_group_and_category_slug_collide(self, tmp_path):
|
||||
readme = textwrap.dedent("""\
|
||||
# T
|
||||
|
||||
---
|
||||
|
||||
**Widgets**
|
||||
|
||||
## Widgets
|
||||
|
||||
- [w1](https://example.com) - W.
|
||||
|
||||
# Contributing
|
||||
|
||||
Done.
|
||||
""")
|
||||
self._make_repo(tmp_path, readme)
|
||||
with pytest.raises(ValueError, match="slug collision"):
|
||||
build(tmp_path)
|
||||
|
||||
def test_index_contains_aligned_homepage_metadata(self, tmp_path):
|
||||
readme = (Path(__file__).parents[2] / "README.md").read_text(encoding="utf-8")
|
||||
(tmp_path / "README.md").write_text(readme, encoding="utf-8")
|
||||
@@ -430,6 +468,215 @@ class TestBuild:
|
||||
assert parser.meta_by_name["twitter:description"] == expected_description
|
||||
assert parser.meta_by_name["twitter:image"] == expected_image
|
||||
assert "<head>\n <meta charset" in html
|
||||
assert 'id="hero-category-heading">Browse by category</h2>' in html
|
||||
assert 'class="hero-category-link" href="/categories/ai-and-agents/"' in html
|
||||
|
||||
def test_build_creates_subcategory_pages(self, tmp_path):
|
||||
readme = textwrap.dedent("""\
|
||||
# T
|
||||
|
||||
---
|
||||
|
||||
**Web**
|
||||
|
||||
## Web Frameworks
|
||||
|
||||
- Synchronous
|
||||
|
||||
- [django](https://example.com/django) - Sync framework.
|
||||
|
||||
- Asynchronous
|
||||
|
||||
- [fastapi](https://example.com/fastapi) - Async framework.
|
||||
|
||||
# Contributing
|
||||
|
||||
Done.
|
||||
""")
|
||||
self._copy_real_templates(tmp_path)
|
||||
(tmp_path / "README.md").write_text(readme, encoding="utf-8")
|
||||
build(tmp_path)
|
||||
|
||||
site = tmp_path / "website" / "output"
|
||||
sync = (site / "categories" / "web-frameworks" / "synchronous" / "index.html").read_text(encoding="utf-8")
|
||||
async_ = (site / "categories" / "web-frameworks" / "asynchronous" / "index.html").read_text(encoding="utf-8")
|
||||
|
||||
assert "django" in sync
|
||||
assert "fastapi" not in sync
|
||||
assert "fastapi" in async_
|
||||
assert "django" not in async_
|
||||
|
||||
def test_subcategory_page_shows_breadcrumb(self, tmp_path):
|
||||
readme = textwrap.dedent("""\
|
||||
# T
|
||||
|
||||
---
|
||||
|
||||
**Web**
|
||||
|
||||
## Web Frameworks
|
||||
|
||||
- Synchronous
|
||||
|
||||
- [django](https://example.com/django) - Sync.
|
||||
|
||||
# Contributing
|
||||
|
||||
Done.
|
||||
""")
|
||||
self._copy_real_templates(tmp_path)
|
||||
(tmp_path / "README.md").write_text(readme, encoding="utf-8")
|
||||
build(tmp_path)
|
||||
|
||||
site = tmp_path / "website" / "output"
|
||||
sync = (site / "categories" / "web-frameworks" / "synchronous" / "index.html").read_text(encoding="utf-8")
|
||||
assert 'href="/categories/web-frameworks/"' in sync
|
||||
assert "Web Frameworks" in sync
|
||||
assert "<h1>Synchronous</h1>" in sync
|
||||
assert "category-breadcrumb" in sync
|
||||
|
||||
parent = (site / "categories" / "web-frameworks" / "index.html").read_text(encoding="utf-8")
|
||||
assert "category-breadcrumb" not in parent
|
||||
|
||||
def test_index_embeds_filter_urls_json(self, tmp_path):
|
||||
readme = textwrap.dedent("""\
|
||||
# T
|
||||
|
||||
---
|
||||
|
||||
**AI & ML**
|
||||
|
||||
## Deep Learning
|
||||
|
||||
- [dl1](https://example.com/dl1) - DL.
|
||||
|
||||
## Machine Learning
|
||||
|
||||
- Classical
|
||||
|
||||
- [ml1](https://example.com/ml1) - ML.
|
||||
|
||||
# Contributing
|
||||
|
||||
Done.
|
||||
""")
|
||||
self._copy_real_templates(tmp_path)
|
||||
(tmp_path / "README.md").write_text(readme, encoding="utf-8")
|
||||
build(tmp_path)
|
||||
|
||||
site = tmp_path / "website" / "output"
|
||||
index_html = (site / "index.html").read_text(encoding="utf-8")
|
||||
|
||||
marker = '<script type="application/json" id="filter-urls">'
|
||||
assert marker in index_html
|
||||
start = index_html.index(marker) + len(marker)
|
||||
end = index_html.index("</script>", start)
|
||||
data = json.loads(index_html[start:end])
|
||||
|
||||
assert data["Deep Learning"] == "/categories/deep-learning/"
|
||||
assert data["Machine Learning"] == "/categories/machine-learning/"
|
||||
assert data["AI & ML"] == "/categories/ai-ml/"
|
||||
assert data["Machine Learning > Classical"] == "/categories/machine-learning/classical/"
|
||||
|
||||
def test_filter_urls_json_escapes_closing_script_tag(self, tmp_path):
|
||||
readme = textwrap.dedent("""\
|
||||
# T
|
||||
|
||||
---
|
||||
|
||||
## Sneaky </script><script>x=1</script>
|
||||
|
||||
- [a](https://example.com) - A.
|
||||
|
||||
# Contributing
|
||||
|
||||
Done.
|
||||
""")
|
||||
self._copy_real_templates(tmp_path)
|
||||
(tmp_path / "README.md").write_text(readme, encoding="utf-8")
|
||||
build(tmp_path)
|
||||
|
||||
site = tmp_path / "website" / "output"
|
||||
index_html = (site / "index.html").read_text(encoding="utf-8")
|
||||
|
||||
marker = '<script type="application/json" id="filter-urls">'
|
||||
start = index_html.index(marker) + len(marker)
|
||||
end = index_html.index("</script>", start)
|
||||
block = index_html[start:end]
|
||||
assert "</script>" not in block
|
||||
data = json.loads(block)
|
||||
assert any("Sneaky" in key for key in data)
|
||||
|
||||
def test_build_creates_group_pages(self, tmp_path):
|
||||
readme = textwrap.dedent("""\
|
||||
# T
|
||||
|
||||
---
|
||||
|
||||
**AI & ML**
|
||||
|
||||
## Deep Learning
|
||||
|
||||
- [dl1](https://example.com/dl1) - DL.
|
||||
|
||||
## Machine Learning
|
||||
|
||||
- [ml1](https://example.com/ml1) - ML.
|
||||
|
||||
**Web Development**
|
||||
|
||||
## Web Frameworks
|
||||
|
||||
- [wf1](https://example.com/wf1) - WF.
|
||||
|
||||
# Contributing
|
||||
|
||||
Done.
|
||||
""")
|
||||
self._copy_real_templates(tmp_path)
|
||||
(tmp_path / "README.md").write_text(readme, encoding="utf-8")
|
||||
build(tmp_path)
|
||||
|
||||
site = tmp_path / "website" / "output"
|
||||
ai_ml = (site / "categories" / "ai-ml" / "index.html").read_text(encoding="utf-8")
|
||||
web_dev = (site / "categories" / "web-development" / "index.html").read_text(encoding="utf-8")
|
||||
|
||||
assert "dl1" in ai_ml
|
||||
assert "ml1" in ai_ml
|
||||
assert "wf1" not in ai_ml
|
||||
assert "wf1" in web_dev
|
||||
assert "dl1" not in web_dev
|
||||
|
||||
def test_tag_buttons_have_data_url(self, tmp_path):
|
||||
readme = textwrap.dedent("""\
|
||||
# T
|
||||
|
||||
---
|
||||
|
||||
**AI & ML**
|
||||
|
||||
## Deep Learning
|
||||
|
||||
- Vision
|
||||
|
||||
- [v1](https://example.com/v1) - Vision lib.
|
||||
|
||||
# Contributing
|
||||
|
||||
Done.
|
||||
""")
|
||||
self._copy_real_templates(tmp_path)
|
||||
(tmp_path / "README.md").write_text(readme, encoding="utf-8")
|
||||
build(tmp_path)
|
||||
|
||||
site = tmp_path / "website" / "output"
|
||||
index_html = (site / "index.html").read_text(encoding="utf-8")
|
||||
|
||||
assert 'data-value="Deep Learning"' in index_html
|
||||
assert 'data-url="/categories/deep-learning/"' in index_html
|
||||
assert 'data-value="AI & ML"' in index_html or 'data-value="AI & ML"' in index_html
|
||||
assert 'data-url="/categories/ai-ml/"' in index_html
|
||||
assert 'data-url="/categories/deep-learning/vision/"' in index_html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -489,45 +736,61 @@ class TestLoadStars:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _template_entry(name: str, stars: int | None, source_type: str | None = None) -> TemplateEntry:
|
||||
return TemplateEntry(
|
||||
name=name,
|
||||
url="",
|
||||
description="",
|
||||
categories=[],
|
||||
groups=[],
|
||||
subcategories=[],
|
||||
stars=stars,
|
||||
owner=None,
|
||||
last_commit_at=None,
|
||||
source_type=source_type,
|
||||
also_see=[],
|
||||
)
|
||||
|
||||
|
||||
class TestSortEntries:
|
||||
def test_sorts_by_stars_descending(self):
|
||||
entries = [
|
||||
{"name": "a", "stars": 100, "url": ""},
|
||||
{"name": "b", "stars": 500, "url": ""},
|
||||
{"name": "c", "stars": 200, "url": ""},
|
||||
_template_entry("a", 100),
|
||||
_template_entry("b", 500),
|
||||
_template_entry("c", 200),
|
||||
]
|
||||
result = sort_entries(entries)
|
||||
assert [e["name"] for e in result] == ["b", "c", "a"]
|
||||
|
||||
def test_equal_stars_sorted_alphabetically(self):
|
||||
entries = [
|
||||
{"name": "beta", "stars": 100, "url": ""},
|
||||
{"name": "alpha", "stars": 100, "url": ""},
|
||||
_template_entry("beta", 100),
|
||||
_template_entry("alpha", 100),
|
||||
]
|
||||
result = sort_entries(entries)
|
||||
assert [e["name"] for e in result] == ["alpha", "beta"]
|
||||
|
||||
def test_no_stars_go_to_bottom(self):
|
||||
entries = [
|
||||
{"name": "no-stars", "stars": None, "url": ""},
|
||||
{"name": "has-stars", "stars": 50, "url": ""},
|
||||
_template_entry("no-stars", None),
|
||||
_template_entry("has-stars", 50),
|
||||
]
|
||||
result = sort_entries(entries)
|
||||
assert [e["name"] for e in result] == ["has-stars", "no-stars"]
|
||||
|
||||
def test_no_stars_sorted_alphabetically(self):
|
||||
entries = [
|
||||
{"name": "zebra", "stars": None, "url": ""},
|
||||
{"name": "apple", "stars": None, "url": ""},
|
||||
_template_entry("zebra", None),
|
||||
_template_entry("apple", None),
|
||||
]
|
||||
result = sort_entries(entries)
|
||||
assert [e["name"] for e in result] == ["apple", "zebra"]
|
||||
|
||||
def test_builtin_between_starred_and_unstarred(self):
|
||||
entries = [
|
||||
{"name": "builtin", "stars": None, "source_type": "Built-in"},
|
||||
{"name": "starred", "stars": 100, "source_type": None},
|
||||
{"name": "unstarred", "stars": None, "source_type": None},
|
||||
_template_entry("builtin", None, "Built-in"),
|
||||
_template_entry("starred", 100),
|
||||
_template_entry("unstarred", None),
|
||||
]
|
||||
result = sort_entries(entries)
|
||||
assert [e["name"] for e in result] == ["starred", "builtin", "unstarred"]
|
||||
@@ -634,6 +897,36 @@ class TestExtractEntries:
|
||||
entries = extract_entries(categories, groups)
|
||||
assert entries[0]["source_type"] == "Built-in"
|
||||
|
||||
def test_subcategory_includes_slug_and_url(self):
|
||||
readme = textwrap.dedent("""\
|
||||
# T
|
||||
|
||||
---
|
||||
|
||||
**Tools**
|
||||
|
||||
## Web Frameworks
|
||||
|
||||
- Synchronous
|
||||
|
||||
- [django](https://example.com/django) - A framework.
|
||||
|
||||
# Contributing
|
||||
|
||||
Done.
|
||||
""")
|
||||
groups = parse_readme(readme)
|
||||
categories = [c for g in groups for c in g["categories"]]
|
||||
entries = extract_entries(categories, groups)
|
||||
assert entries[0]["subcategories"] == [
|
||||
{
|
||||
"name": "Synchronous",
|
||||
"value": "Web Frameworks > Synchronous",
|
||||
"slug": "synchronous",
|
||||
"url": "/categories/web-frameworks/synchronous/",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# annotate_entries_with_stars
|
||||
@@ -644,23 +937,15 @@ class TestAnnotateEntriesWithStars:
|
||||
def test_appends_star_count_to_bullet(self):
|
||||
markdown = "- [foo](https://github.com/owner/foo) - A foo.\n"
|
||||
stars = {"owner/foo": {"stars": 123, "owner": "owner"}}
|
||||
assert annotate_entries_with_stars(markdown, stars) == (
|
||||
"- [foo](https://github.com/owner/foo) - A foo. (123 GitHub stars)\n"
|
||||
)
|
||||
assert annotate_entries_with_stars(markdown, stars) == ("- [foo](https://github.com/owner/foo) - A foo. (123 GitHub stars)\n")
|
||||
|
||||
def test_uses_first_github_link(self):
|
||||
markdown = (
|
||||
"- [foo](https://github.com/owner/foo) - A foo. "
|
||||
"Also [bar](https://github.com/owner/bar).\n"
|
||||
)
|
||||
markdown = "- [foo](https://github.com/owner/foo) - A foo. Also [bar](https://github.com/owner/bar).\n"
|
||||
stars = {
|
||||
"owner/foo": {"stars": 10, "owner": "owner"},
|
||||
"owner/bar": {"stars": 99, "owner": "owner"},
|
||||
}
|
||||
assert annotate_entries_with_stars(markdown, stars) == (
|
||||
"- [foo](https://github.com/owner/foo) - A foo. "
|
||||
"Also [bar](https://github.com/owner/bar). (10 GitHub stars)\n"
|
||||
)
|
||||
assert annotate_entries_with_stars(markdown, stars) == ("- [foo](https://github.com/owner/foo) - A foo. Also [bar](https://github.com/owner/bar). (10 GitHub stars)\n")
|
||||
|
||||
def test_skips_entries_without_star_data(self):
|
||||
markdown = "- [foo](https://github.com/owner/foo) - A foo.\n"
|
||||
@@ -679,13 +964,9 @@ class TestAnnotateEntriesWithStars:
|
||||
def test_handles_indented_bullets(self):
|
||||
markdown = " - [foo](https://github.com/owner/foo)\n"
|
||||
stars = {"owner/foo": {"stars": 7, "owner": "owner"}}
|
||||
assert annotate_entries_with_stars(markdown, stars) == (
|
||||
" - [foo](https://github.com/owner/foo) (7 GitHub stars)\n"
|
||||
)
|
||||
assert annotate_entries_with_stars(markdown, stars) == (" - [foo](https://github.com/owner/foo) (7 GitHub stars)\n")
|
||||
|
||||
def test_preserves_lines_without_trailing_newline(self):
|
||||
markdown = "- [foo](https://github.com/owner/foo) - A foo."
|
||||
stars = {"owner/foo": {"stars": 5, "owner": "owner"}}
|
||||
assert annotate_entries_with_stars(markdown, stars) == (
|
||||
"- [foo](https://github.com/owner/foo) - A foo. (5 GitHub stars)"
|
||||
)
|
||||
assert annotate_entries_with_stars(markdown, stars) == ("- [foo](https://github.com/owner/foo) - A foo. (5 GitHub stars)")
|
||||
|
||||
@@ -17,41 +17,12 @@ class TestExtractGithubRepos:
|
||||
assert result == {"psf/requests"}
|
||||
|
||||
def test_multiple_repos(self):
|
||||
readme = (
|
||||
"* [requests](https://github.com/psf/requests) - HTTP.\n"
|
||||
"* [flask](https://github.com/pallets/flask) - Micro."
|
||||
)
|
||||
readme = "* [requests](https://github.com/psf/requests) - HTTP.\n* [flask](https://github.com/pallets/flask) - Micro."
|
||||
result = extract_github_repos(readme)
|
||||
assert result == {"psf/requests", "pallets/flask"}
|
||||
|
||||
def test_ignores_non_github_urls(self):
|
||||
readme = "* [pypy](https://foss.heptapod.net/pypy/pypy) - Fast Python."
|
||||
result = extract_github_repos(readme)
|
||||
assert result == set()
|
||||
|
||||
def test_ignores_github_io_urls(self):
|
||||
readme = "* [docs](https://user.github.io/project) - Docs site."
|
||||
result = extract_github_repos(readme)
|
||||
assert result == set()
|
||||
|
||||
def test_ignores_github_wiki_and_blob_urls(self):
|
||||
readme = (
|
||||
"* [wiki](https://github.com/org/repo/wiki) - Wiki.\n"
|
||||
"* [file](https://github.com/org/repo/blob/main/f.py) - File."
|
||||
)
|
||||
result = extract_github_repos(readme)
|
||||
assert result == set()
|
||||
|
||||
def test_handles_trailing_slash(self):
|
||||
readme = "* [lib](https://github.com/org/repo/) - Lib."
|
||||
result = extract_github_repos(readme)
|
||||
assert result == {"org/repo"}
|
||||
|
||||
def test_deduplicates(self):
|
||||
readme = (
|
||||
"* [a](https://github.com/org/repo) - A.\n"
|
||||
"* [b](https://github.com/org/repo) - B."
|
||||
)
|
||||
readme = "* [a](https://github.com/org/repo) - A.\n* [b](https://github.com/org/repo) - B."
|
||||
result = extract_github_repos(readme)
|
||||
assert result == {"org/repo"}
|
||||
|
||||
|
||||
@@ -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 <a href="https://example.com" target="_blank" rel="noopener">awesome-algos</a>.'
|
||||
|
||||
|
||||
class TestParseGroupedReadme:
|
||||
@@ -346,10 +350,7 @@ def _content_nodes(md_text: str) -> list[SyntaxTreeNode]:
|
||||
|
||||
class TestParseSectionEntries:
|
||||
def test_flat_entries(self):
|
||||
nodes = _content_nodes(
|
||||
"- [django](https://example.com/d) - A web framework.\n"
|
||||
"- [flask](https://example.com/f) - A micro framework.\n"
|
||||
)
|
||||
nodes = _content_nodes("- [django](https://example.com/d) - A web framework.\n- [flask](https://example.com/f) - A micro framework.\n")
|
||||
entries = _parse_section_entries(nodes)
|
||||
assert len(entries) == 2
|
||||
assert entries[0]["name"] == "django"
|
||||
@@ -366,13 +367,7 @@ class TestParseSectionEntries:
|
||||
assert entries[0]["description"] == ""
|
||||
|
||||
def test_subcategorized_entries(self):
|
||||
nodes = _content_nodes(
|
||||
"- Algorithms\n"
|
||||
" - [algos](https://x.com/a) - Algo lib.\n"
|
||||
" - [sorts](https://x.com/s) - Sort lib.\n"
|
||||
"- Design Patterns\n"
|
||||
" - [patterns](https://x.com/p) - Pattern lib.\n"
|
||||
)
|
||||
nodes = _content_nodes("- Algorithms\n - [algos](https://x.com/a) - Algo lib.\n - [sorts](https://x.com/s) - Sort lib.\n- Design Patterns\n - [patterns](https://x.com/p) - Pattern lib.\n")
|
||||
entries = _parse_section_entries(nodes)
|
||||
assert len(entries) == 3
|
||||
assert entries[0]["name"] == "algos"
|
||||
@@ -428,7 +423,7 @@ class TestParseSectionEntries:
|
||||
assert cats[0]["entry_count"] == 3
|
||||
|
||||
def test_description_html_escapes_xss(self):
|
||||
nodes = _content_nodes('- [lib](https://x.com) - A <script>alert(1)</script> lib.\n')
|
||||
nodes = _content_nodes("- [lib](https://x.com) - A <script>alert(1)</script> lib.\n")
|
||||
entries = _parse_section_entries(nodes)
|
||||
assert "<script>" not in entries[0]["description"]
|
||||
assert "<script>" in entries[0]["description"]
|
||||
@@ -445,9 +440,6 @@ class TestParseRealReadme:
|
||||
def test_at_least_11_groups(self):
|
||||
assert len(self.groups) >= 11
|
||||
|
||||
def test_first_group_is_ai_ml(self):
|
||||
assert self.groups[0]["name"] == "AI & ML"
|
||||
|
||||
def test_at_least_69_categories(self):
|
||||
assert len(self.cats) >= 69
|
||||
|
||||
@@ -455,38 +447,10 @@ class TestParseRealReadme:
|
||||
all_names = [c["name"] for c in self.cats]
|
||||
assert "Contributing" not in all_names
|
||||
|
||||
def test_first_category_is_ai_and_agents(self):
|
||||
assert self.cats[0]["name"] == "AI and Agents"
|
||||
assert self.cats[0]["slug"] == "ai-and-agents"
|
||||
|
||||
def test_web_apis_slug(self):
|
||||
slugs = [c["slug"] for c in self.cats]
|
||||
assert "web-apis" in slugs
|
||||
|
||||
def test_descriptions_extracted(self):
|
||||
ai = next(c for c in self.cats if c["name"] == "AI and Agents")
|
||||
assert "AI applications" in ai["description"]
|
||||
|
||||
def test_entry_counts_nonzero(self):
|
||||
for cat in self.cats:
|
||||
assert cat["entry_count"] > 0, f"{cat['name']} has 0 entries"
|
||||
|
||||
def test_async_has_also_see(self):
|
||||
async_cat = next(c for c in self.cats if c["name"] == "Asynchronous Programming")
|
||||
asyncio_entry = next(e for e in async_cat["entries"] if e["name"] == "asyncio")
|
||||
assert len(asyncio_entry["also_see"]) >= 1
|
||||
assert asyncio_entry["also_see"][0]["name"] == "awesome-asyncio"
|
||||
|
||||
def test_description_links_stripped_to_text(self):
|
||||
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"]
|
||||
|
||||
def test_miscellaneous_in_own_group(self):
|
||||
misc_group = next((g for g in self.groups if g["name"] == "Miscellaneous"), None)
|
||||
assert misc_group is not None
|
||||
assert any(c["name"] == "Miscellaneous" for c in misc_group["categories"])
|
||||
|
||||
def test_all_entries_have_nonempty_names(self):
|
||||
bad = []
|
||||
for cat in self.cats:
|
||||
|
||||
Reference in New Issue
Block a user