mirror of
https://github.com/vinta/awesome-python.git
synced 2026-05-06 14:17:15 -04:00
feat(website): generate static category pages
This commit is contained in:
+32
-2
@@ -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}")
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }}" />
|
||||
|
||||
@@ -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 %}—{% 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" 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">→</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 %}
|
||||
@@ -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] }}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user