feat(website): generate static category pages

This commit is contained in:
Vinta Chen
2026-05-02 23:31:08 +08:00
parent 429c9b3d12
commit e11afd1730
7 changed files with 399 additions and 12 deletions
+32 -2
View File
@@ -84,6 +84,14 @@ def build_robots_txt() -> str:
)
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 write_sitemap_xml(path: Path, urls: Sequence[tuple[str, str]]) -> None:
ET.register_namespace("", SITEMAP_NS)
urlset = ET.Element(f"{{{SITEMAP_NS}}}urlset")
@@ -278,6 +286,7 @@ 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}
env = Environment(
loader=FileSystemLoader(website / "templates"),
@@ -302,10 +311,27 @@ def build(repo_root: Path) -> None:
repo_stars=repo_stars,
build_date=build_date.strftime("%B %d, %Y"),
sponsors=sponsors,
category_urls=category_urls,
),
encoding="utf-8",
)
tpl_category = env.get_template("category.html")
categories_dir = site_dir / "categories"
for category in categories:
category_entries = [entry for entry in entries if category["name"] in entry["categories"]]
page_dir = categories_dir / category["slug"]
page_dir.mkdir(parents=True, exist_ok=True)
(page_dir / "index.html").write_text(
tpl_category.render(
category=category,
category_url=category_public_url(category),
entries=category_entries,
total_categories=len(categories),
),
encoding="utf-8",
)
static_src = website / "static"
static_dst = site_dir / "static"
if static_src.exists():
@@ -317,11 +343,15 @@ def build(repo_root: Path) -> None:
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)] + [
(category_public_url(category), sitemap_date) for category in categories
]
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}")
+2
View File
@@ -202,6 +202,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;
+74
View File
@@ -376,18 +376,92 @@ kbd {
}
.hero-action:focus-visible,
.hero-brand-mini:focus-visible,
.hero-topbar-link:focus-visible,
.search:focus-visible,
.filter-clear:focus-visible,
.tag:focus-visible,
.back-to-top:focus-visible,
.no-results-clear:focus-visible,
.category-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-subtitle {
max-width: 68ch;
margin-top: 1.1rem;
color: var(--hero-muted);
font-size: clamp(1rem, 1.8vw, 1.18rem);
text-wrap: pretty;
}
.category-results {
padding-top: clamp(2.5rem, 5vw, 3.75rem);
}
.category-table .col-name {
width: min(42rem, 48vw);
white-space: normal;
}
.category-table .col-name > a {
display: inline-block;
}
.category-row-desc {
display: block;
max-width: 68ch;
margin-top: 0.32rem;
color: var(--ink-soft);
font-size: var(--text-sm);
font-weight: 500;
line-height: 1.55;
text-wrap: pretty;
}
.category-row-desc a {
color: var(--accent-deep);
text-decoration: underline;
text-decoration-color: var(--accent-underline);
text-underline-offset: 0.18em;
}
.category-row-desc a:hover {
color: var(--accent);
}
.category-table .expand-content {
padding-block: 0.25rem 0.15rem;
}
.sponsor-band {
padding-block: clamp(2.5rem, 5.5vw, 4rem);
background:
+6 -3
View File
@@ -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 }}" />
+195
View File
@@ -0,0 +1,195 @@
{% 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">
<h1>{{ category.name }}</h1>
{% if category.description %}
<p class="category-subtitle">{{ category.description }}</p>
{% endif %}
</div>
</div>
</header>
{% endblock %}
{% block content %}
<section class="results-section category-results" id="category-index">
<div class="results-intro section-shell" data-reveal>
<div>
<h2>Projects in {{ category.name }}</h2>
</div>
<p class="results-note">
Sorted by GitHub stars when available. Click any row for details.
</p>
</div>
<h2 class="sr-only">{{ category.name }} results</h2>
<div
class="table-wrap"
tabindex="0"
role="region"
aria-label="{{ category.name }} libraries table"
>
<table class="table category-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"><span class="sr-only">Details</span></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="category-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
>
{% if entry.description %}
<span class="category-row-desc">{{ entry.description | safe }}</span>
{% endif %}
<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 %}&mdash;{% 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 %}&mdash;{% endif %}
</td>
<td class="col-cat">
{% for subcat in entry.subcategories %}
<button class="tag" data-value="{{ subcat.value }}">
{{ subcat.name }}
</button>
{% endfor %}
<button class="tag active" data-value="{{ category.name }}">
{{ category.name }}
</button>
{% if entry.groups %}
<button class="tag tag-group" data-value="{{ entry.groups[0] }}">
{{ entry.groups[0] }}
</button>
{% endif %}
{% if entry.source_type == 'Built-in' %}
<button class="tag tag-source" data-value="Built-in">
Built-in
</button>
{% endif %}
</td>
<td class="col-arrow"><span class="arrow">&rarr;</span></td>
</tr>
<tr class="expand-row" id="category-expand-{{ loop.index }}">
<td></td>
<td colspan="4">
<div class="expand-content">
{% 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>
</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="/" class="hero-action hero-action-secondary">Browse all</a>
</div>
</div>
</section>
{% endblock %}
+6 -1
View File
@@ -215,7 +215,12 @@
{{ 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 }}"
>{{ cat }}</a
>
{% endfor %}
<button class="tag tag-group" data-value="{{ entry.groups[0] }}">
{{ entry.groups[0] }}
+84 -6
View File
@@ -109,6 +109,15 @@ 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 / "llms.txt").write_text(
"# Awesome Python\n"
"\n"
@@ -125,7 +134,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
@@ -164,8 +173,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("""\
@@ -205,12 +214,81 @@ class TestBuild:
lastmods = [lastmod.text 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/",
]
assert len(lastmods) == 2
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._
- [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."
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." 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 category-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