mirror of
https://github.com/vinta/awesome-python.git
synced 2026-10-02 08:23:10 +08:00
feat: render per-category intro text above the entry list
Category pages carried no text of their own beyond the README one-line description, and most meta descriptions fell back to a generic "Explore N curated Python projects" line, which correlated with weak search rankings for category queries. This adds optional per-category intro markdown files rendered under the H1, with the first paragraph used as the meta description and links opening in a new tab, starting with the ORM category. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ website/output/
|
||||
website/data/*
|
||||
!website/data/pypi_name_overrides.json
|
||||
!website/data/redirects.json
|
||||
!website/data/category_intros/
|
||||
|
||||
# agents
|
||||
.playwright-cli/
|
||||
|
||||
+24
-2
@@ -13,7 +13,9 @@ from typing import TypedDict
|
||||
|
||||
from fetch_pypi_downloads_via_clickpy import OVERRIDES_FILE, normalize
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from readme_parser import AlsoSee, ParsedGroup, ParsedSection, parse_readme, parse_sponsors, slugify
|
||||
from markdown_it import MarkdownIt
|
||||
from markdown_it.tree import SyntaxTreeNode
|
||||
from readme_parser import AlsoSee, ParsedGroup, ParsedSection, parse_readme, parse_sponsors, render_inline_text, slugify
|
||||
|
||||
GITHUB_REPO_URL_RE = re.compile(r"^https?://github\.com/([^/]+/[^/]+?)(?:\.git)?/?$")
|
||||
MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)\s]+)\)")
|
||||
@@ -235,6 +237,24 @@ def category_meta_description(name: str, entry_count: int, description: str, par
|
||||
return f"{count_sentence} Part of the Awesome Python catalog."
|
||||
|
||||
|
||||
def load_category_intro(path: Path) -> tuple[str, str]:
|
||||
"""Render a category intro file to HTML, plus its first paragraph as plain text for the meta description.
|
||||
|
||||
Returns empty strings if the category has no intro file.
|
||||
"""
|
||||
if not path.exists():
|
||||
return "", ""
|
||||
md = MarkdownIt("commonmark")
|
||||
tokens = md.parse(path.read_text(encoding="utf-8"))
|
||||
for token in tokens:
|
||||
for child in token.children or []:
|
||||
if child.type == "link_open":
|
||||
child.attrSet("target", "_blank")
|
||||
child.attrSet("rel", "noopener")
|
||||
lead = next(node for node in SyntaxTreeNode(tokens).children if node.type == "paragraph")
|
||||
return md.renderer.render(tokens, md.options, {}), render_inline_text(lead.children[0].children)
|
||||
|
||||
|
||||
def build_breadcrumb_json_ld(items: Sequence[tuple[str, str]]) -> dict:
|
||||
return {
|
||||
"@type": "BreadcrumbList",
|
||||
@@ -676,7 +696,8 @@ def build(repo_root: Path) -> None:
|
||||
page_dir.mkdir(parents=True, exist_ok=True)
|
||||
parent_name = parent_category["name"] if parent_category else None
|
||||
category_title = category_meta_title(category["name"], parent_name)
|
||||
category_description = category_meta_description(category["name"], len(entries), category["description"], parent_name)
|
||||
intro_html, intro_lead = load_category_intro(website / "data" / "category_intros" / f"{current_path.removeprefix('/categories/').strip('/')}.md")
|
||||
category_description = intro_lead or category_meta_description(category["name"], len(entries), category["description"], parent_name)
|
||||
breadcrumbs = [("Awesome Python", SITE_URL)]
|
||||
if parent_category:
|
||||
breadcrumbs.append((parent_category["name"], category_public_url(parent_category)))
|
||||
@@ -691,6 +712,7 @@ def build(repo_root: Path) -> None:
|
||||
category_title=category_title,
|
||||
category_url=category_url,
|
||||
category_description=category_description,
|
||||
intro_html=intro_html,
|
||||
entries=entries,
|
||||
total_categories=len(categories),
|
||||
category_urls=category_urls,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
Use SQLAlchemy for most projects, and the Django ORM inside Django. With FastAPI, use SQLModel, so your tables and API schemas share the same fields.
|
||||
|
||||
How to choose:
|
||||
|
||||
- Any framework, or full control over the SQL: SQLAlchemy
|
||||
- A Django project: the Django ORM, which needs Django's settings even outside a web app
|
||||
- A FastAPI app: SQLModel
|
||||
- A simple ORM with few concepts to learn: peewee
|
||||
- MongoDB: Beanie for async code, MongoEngine for sync code
|
||||
- DynamoDB: PynamoDB
|
||||
|
||||
With SQLAlchemy, write [typed models](https://docs.sqlalchemy.org/en/latest/orm/declarative_styles.html) with `DeclarativeBase`, `Mapped[]`, and `mapped_column()`. Create one `sessionmaker` at startup and open one session per request. Load relationships with `selectinload()`, and run migrations with Alembic, which comes from the SQLAlchemy project itself.
|
||||
|
||||
Sync code is the safe default, even in an async framework. FastAPI's own docs put it plainly: ["If you just don't know, use normal `def`."](https://fastapi.tiangolo.com/async/) If you do go async, give each task its own `AsyncSession`.
|
||||
|
||||
In Django, let `makemigrations` and `migrate` own the schema, and use `select_related()` or `prefetch_related()` whenever you touch related rows. Reach for raw SQL last: ["Explore the ORM before using raw SQL!"](https://docs.djangoproject.com/en/stable/topics/db/sql/)
|
||||
|
||||
With SQLModel, follow its FastAPI tutorial: a base model for the shared fields, a `table=True` model for the database, and [separate models for create, read, and update](https://sqlmodel.tiangolo.com/tutorial/fastapi/multiple-models/). When you outgrow it, plug SQLAlchemy in directly.
|
||||
|
||||
For MongoDB and DynamoDB, model around your queries, not your tables. MongoDB's rule is that ["data that's accessed together should be stored together."](https://www.mongodb.com/docs/manual/core/data-modeling-introduction/) AWS goes further: [don't design a DynamoDB schema](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-general-nosql-design.html) until you know the questions it needs to answer.
|
||||
@@ -496,17 +496,44 @@ kbd {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.category-subtitle a {
|
||||
.category-subtitle a,
|
||||
.category-intro 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 {
|
||||
.category-subtitle a:hover,
|
||||
.category-intro a:hover {
|
||||
text-decoration-color: oklch(100% 0 0 / 0.7);
|
||||
}
|
||||
|
||||
.category-intro {
|
||||
margin-top: 1.75rem;
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
color: var(--hero-muted);
|
||||
font-size: clamp(1.05rem, 1.8vw, 1.2rem);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.category-intro > p:first-child {
|
||||
color: var(--hero-text);
|
||||
font-size: clamp(1.2rem, 2.2vw, 1.45rem);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.category-intro ul {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.category-intro code {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.sponsor-band {
|
||||
padding-block: clamp(2.5rem, 5.5vw, 4rem);
|
||||
background:
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
{% if category.description_html %}
|
||||
<p class="category-subtitle">{{ category.description_html | safe }}</p>
|
||||
{% endif %}
|
||||
{% if intro_html %}
|
||||
<div class="category-intro">{{ intro_html | safe }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if group_categories %}
|
||||
|
||||
@@ -991,6 +991,22 @@ class TestBuild:
|
||||
assert '<meta name="robots" content="noindex">' in stub
|
||||
assert "old-widgets" not in (site / "sitemap.xml").read_text(encoding="utf-8")
|
||||
|
||||
def test_build_renders_category_intro_and_uses_lead_as_meta_description(self, tmp_path):
|
||||
self._copy_real_templates(tmp_path)
|
||||
(tmp_path / "README.md").write_text(self._REDIRECT_README, encoding="utf-8")
|
||||
intros_dir = tmp_path / "website" / "data" / "category_intros"
|
||||
intros_dir.mkdir(parents=True)
|
||||
(intros_dir / "widgets.md").write_text("Use `w1` for most apps.\n\nSee [the docs](https://example.com/docs).\n\nHow to choose:\n\n- Small apps: w1\n", encoding="utf-8")
|
||||
build(tmp_path)
|
||||
|
||||
category_html = (tmp_path / "website" / "output" / "categories" / "widgets" / "index.html").read_text(encoding="utf-8")
|
||||
parser = HeadMetadataParser()
|
||||
parser.feed(category_html)
|
||||
assert parser.meta_by_name["description"] == "Use w1 for most apps."
|
||||
assert '<div class="category-intro"><p>Use <code>w1</code> for most apps.</p>' in category_html
|
||||
assert "<li>Small apps: w1</li>" in category_html
|
||||
assert '<a href="https://example.com/docs" target="_blank" rel="noopener">the docs</a>' in category_html
|
||||
|
||||
def test_build_rejects_redirect_to_missing_page(self, tmp_path):
|
||||
self._copy_real_templates(tmp_path)
|
||||
(tmp_path / "README.md").write_text(self._REDIRECT_README, encoding="utf-8")
|
||||
|
||||
Reference in New Issue
Block a user