diff --git a/.impeccable.md b/DESIGN.md similarity index 100% rename from .impeccable.md rename to DESIGN.md diff --git a/Makefile b/Makefile index 5b782549..6430945e 100644 --- a/Makefile +++ b/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 diff --git a/README.md b/README.md index 107b6859..5e0652f1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/SPONSORSHIP.md b/SPONSORSHIP.md index b833748a..debd6f91 100644 --- a/SPONSORSHIP.md +++ b/SPONSORSHIP.md @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 06e008be..a1f09178 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/uv.lock b/uv.lock index 88d2b273..a097aafb 100644 --- a/uv.lock +++ b/uv.lock @@ -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" diff --git a/website/build.py b/website/build.py index f9e3aa55..1e5585c9 100644 --- a/website/build.py +++ b/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(" 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}") diff --git a/website/fetch_github_stars.py b/website/fetch_github_stars.py index c93ef4ec..48aaacf7 100644 --- a/website/fetch_github_stars.py +++ b/website/fetch_github_stars.py @@ -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) + " }" diff --git a/website/readme_parser.py b/website/readme_parser.py index 10d26886..a80acc32 100644 --- a/website/readme_parser.py +++ b/website/readme_parser.py @@ -27,6 +27,7 @@ class ParsedSection(TypedDict): name: str slug: str description: str # plain text, links resolved to text + description_html: str # inline HTML, properly escaped entries: list[ParsedEntry] entry_count: int @@ -113,22 +114,22 @@ def _heading_text(node: SyntaxTreeNode) -> str: return "" -def _extract_description(nodes: list[SyntaxTreeNode]) -> str: - """Extract description from the first paragraph if it's a single block. +def _extract_description_children(nodes: list[SyntaxTreeNode]) -> list[SyntaxTreeNode]: + """Extract description children from the first paragraph if it's a single block. Pattern: _Libraries for foo._ -> "Libraries for foo." """ if not nodes: - return "" + return [] first = nodes[0] if first.type != "paragraph": - return "" + return [] for child in first.children: if child.type == "inline" and len(child.children) == 1: em = child.children[0] if em.type == "em": - return render_inline_text(em.children) - return "" + return em.children + return [] # --- Entry extraction -------------------------------------------------------- @@ -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 = [] diff --git a/website/static/main.js b/website/static/main.js index 7353ff2c..5da89699 100644 --- a/website/static/main.js +++ b/website/static/main.js @@ -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(); +}); diff --git a/website/static/style.css b/website/static/style.css index ec395e98..53604de1 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -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; diff --git a/website/templates/base.html b/website/templates/base.html index af112095..22b56e98 100644 --- a/website/templates/base.html +++ b/website/templates/base.html @@ -3,21 +3,24 @@ {% 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_title | trim }} - + + {% block alternate_links %} + {% endblock %} - + diff --git a/website/templates/category.html b/website/templates/category.html new file mode 100644 index 00000000..799604d8 --- /dev/null +++ b/website/templates/category.html @@ -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 %} +
+ + + +
+ + +
+ {% if parent_category %} +

+ {{ parent_category.name }} +

+ {% endif %} +

{{ category.name }}

+ {% if category.description_html %} +

{{ category.description_html | safe }}

+ {% endif %} +
+ + {% if group_categories %} + + {% endif %} +
+
+{% endblock %} +{% block content %} + +
+
+
+

Search every project in one place

+
+

+ Press / to search. Tap a tag to filter. Click any row for + details. +

+
+ +
+

Search and filter

+
+ + + + + +
+
+ Filtering for + +
+
+ +

Results

+
+ + + + + + + + + + + + + {% for entry in entries %} + + + + + + + + + + + + + + {% endfor %} + +
Row number + + + + + + Tags + +
+
+ {% if entry.description %} +
{{ entry.description | safe }}
+ {% endif %} {% if entry.also_see %} +
+ Also see: {% for see in entry.also_see %}{{ see.name }}{% if not loop.last %}, {% endif %}{% endfor %} +
+ {% endif %} +
+ {% if entry.owner %}{{ entry.owner }}/{% endif %}{{ entry.url | replace("https://", "") }} + {% if entry.last_commit_at %}/{% endif %} +
+
+
+
+ + +
+ +
+
+ +

Know a project that belongs here?

+

Tell us what it does and why it stands out.

+ +
+
+{% endblock %} diff --git a/website/templates/index.html b/website/templates/index.html index 53e968d3..3fa763e6 100644 --- a/website/templates/index.html +++ b/website/templates/index.html @@ -61,6 +61,21 @@ {% endif %} + + {% endblock %} @@ -70,14 +85,7 @@