mirror of
https://github.com/vinta/awesome-python.git
synced 2026-10-02 08:23:10 +08:00
Merge branch 'feature/category-redesign' into feature/seo-redesign
This commit is contained in:
@@ -140,7 +140,7 @@ Hard-won sizing rules (do not relax):
|
||||
Depth comes from **tonal layers**, not heavy shadows.
|
||||
|
||||
- The page is a quiet warm canvas (`--bg-page`). The content shell is slightly brighter paper (`--bg-paper`). The sponsor band, CTA backgrounds, and inline decorative blocks step up to `--bg-paper-strong`.
|
||||
- The hero is the one place that uses real atmosphere: subtle grid, slow sheen, warm radial gradients on a dark earthy ground (`--hero-bg-start` → `--hero-bg-mid` → `--hero-bg-end`). The sheen and any other motion respect `prefers-reduced-motion`.
|
||||
- The hero and the category guide band are the only places with real atmosphere: warm gradients on a dark earthy ground (`--hero-bg-start` → `--hero-bg-mid` → `--hero-bg-end`). Only the hero adds the subtle grid and slow sheen. The sheen and any other motion respect `prefers-reduced-motion`.
|
||||
- The footer is a single tonal block in `--footer-bg`, no internal gradients.
|
||||
- Two depth treatments are allowed and only these two. The search input combines a 1px inset highlight (`--search-inset`) with a soft warm drop shadow (`--search-shadow`, intensified by `--search-focus-shadow` on focus). The primary CTA button (`.hero-action-primary`) carries a warm drop shadow for press affordance. Both shadows are soft, warm-tinted, and tied to interactive elements. No new drop shadows on cards, panels, rows, or static decoration.
|
||||
- No glassmorphism as default decoration.
|
||||
@@ -160,8 +160,11 @@ The shape language is overwhelmingly **pill on small, zero radius on large**.
|
||||
The component vocabulary is small and table-led. Source of truth: `website/static/style.css`.
|
||||
|
||||
- **Table-driven index** (the hero of the page). Sticky header, sortable columns, click-to-expand rows that indent under the Name column. Modeled on placestoread.xyz. Not a card grid.
|
||||
- **Group rows**. Section, subcategory, and group pages list rows in README order. Section pages add one H2 row per use case; group pages add one per section, linking to that section's page. A note replaces the sort arrow until someone sorts a column, which flattens the list and hides the group rows.
|
||||
- **Filter tags** (`.tag`). `--accent-soft` background with `--accent-deep` text. Pill shape. Hover swaps to `--highlight` background with `--tag-hover-border` border and ink text. Active state uses the warm `--tag-active-start` → `--tag-active-end` gradient with hero-ink text. Tag variants (`tag-group`, `tag-source`) inherit the base `.tag` style today and differ only at narrow widths (`tag-group` hides under 960px). Add a new variant only when a real visual difference is needed.
|
||||
- **Hero**. Magazine-cover headline, dark earthy ground, kicker and proof microcopy, primary CTA button using `--hero-btn-start` / `--hero-btn-end`. Subtle grid plus slow sheen. Respects `prefers-reduced-motion`.
|
||||
- **Jump links**. One per use case in a category hero, plus one for the guide. Underlined like the intro's links, with a trailing ↓. Never tag-styled: tags filter the table or open another page, jump links only scroll.
|
||||
- **Guide band**. Everything in a category intro after its "How to choose" list. It sits below the table on the hero's dark gradient, with its H2 above the text.
|
||||
- **Sponsor band**. Sits in the README header on `--bg-paper-strong`. Editorial layout, not a logo wall. Sponsor links share the global accent treatment.
|
||||
- **CTA**. Warm `--cta-bg`, full-bleed within shell. The button itself uses accent tokens.
|
||||
- **Footer**. Dark warm charcoal, part of the same system. Footer links share the global hover and focus treatment.
|
||||
|
||||
+56
-7
@@ -66,6 +66,13 @@ class TemplateEntry(TypedDict):
|
||||
also_see: list[AlsoSee]
|
||||
|
||||
|
||||
class EntryGroup(TypedDict):
|
||||
name: str # empty for a page with a single unnamed group
|
||||
slug: str
|
||||
url: str # links the group heading to its own page, empty when it has none
|
||||
entries: list[TemplateEntry]
|
||||
|
||||
|
||||
class SyntheticCategory(TypedDict):
|
||||
name: str
|
||||
slug: str
|
||||
@@ -222,7 +229,7 @@ def category_meta_title(name: str, parent_name: str | None = None) -> str:
|
||||
if len(title) <= 60:
|
||||
return title
|
||||
return f"{name} - Awesome Python"
|
||||
title = f"{name} Python Libraries - Awesome Python"
|
||||
title = f"Python {name} Libraries - Awesome Python"
|
||||
if len(title) <= 60:
|
||||
return title
|
||||
return f"{name} - Awesome Python"
|
||||
@@ -237,13 +244,16 @@ 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.
|
||||
def load_category_intro(path: Path) -> tuple[str, str, str]:
|
||||
"""Render a category intro file to HTML, split at the end of its "How to choose:" list.
|
||||
|
||||
Returns empty strings if the category has no intro file.
|
||||
Returns the part shown above the table, the guide shown below it, and the
|
||||
first paragraph as plain text for the meta description. A file without the
|
||||
list keeps everything above the table. Returns empty strings if the category
|
||||
has no intro file.
|
||||
"""
|
||||
if not path.exists():
|
||||
return "", ""
|
||||
return "", "", ""
|
||||
md = MarkdownIt("commonmark")
|
||||
tokens = md.parse(path.read_text(encoding="utf-8"))
|
||||
for token in tokens:
|
||||
@@ -252,7 +262,38 @@ def load_category_intro(path: Path) -> tuple[str, str]:
|
||||
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)
|
||||
split_at = len(tokens)
|
||||
for i, token in enumerate(tokens):
|
||||
if token.type == "inline" and token.level == 1 and token.content == "How to choose:" and i + 2 < len(tokens) and tokens[i + 2].type == "bullet_list_open":
|
||||
split_at = next(j for j in range(i + 3, len(tokens)) if tokens[j].type == "bullet_list_close" and tokens[j].level == 0) + 1
|
||||
break
|
||||
render = md.renderer.render
|
||||
return render(tokens[:split_at], md.options, {}), render(tokens[split_at:], md.options, {}), render_inline_text(lead.children[0].children)
|
||||
|
||||
|
||||
def group_section_entries(section: ParsedSection, entries_by_key: dict[tuple[str, str], TemplateEntry]) -> list[EntryGroup]:
|
||||
"""Group a section's entries by use case (subcategory), both in README order."""
|
||||
groups: dict[str, EntryGroup] = {}
|
||||
for parsed in section["entries"]:
|
||||
name = parsed["subcategory"]
|
||||
group = groups.setdefault(name, EntryGroup(name=name, slug=slugify(name) if name else "", url="", entries=[]))
|
||||
group["entries"].append(entries_by_key[(parsed["url"], parsed["name"])])
|
||||
return list(groups.values())
|
||||
|
||||
|
||||
def group_entries_by_section(sections: Sequence[ParsedSection], entries_by_key: dict[tuple[str, str], TemplateEntry]) -> list[EntryGroup]:
|
||||
"""Group a thematic group's entries by section, both in README order, listing each entry once."""
|
||||
placed: set[tuple[str, str]] = set()
|
||||
groups: list[EntryGroup] = []
|
||||
for section in sections:
|
||||
entries: list[TemplateEntry] = []
|
||||
for parsed in section["entries"]:
|
||||
key = (parsed["url"], parsed["name"])
|
||||
if key not in placed:
|
||||
placed.add(key)
|
||||
entries.append(entries_by_key[key])
|
||||
groups.append(EntryGroup(name=section["name"], slug=section["slug"], url=category_path(section), entries=entries))
|
||||
return groups
|
||||
|
||||
|
||||
def build_breadcrumb_json_ld(items: Sequence[tuple[str, str]]) -> dict:
|
||||
@@ -692,11 +733,12 @@ def build(repo_root: Path) -> None:
|
||||
page_dir: Path,
|
||||
parent_category: ParsedSection | None = None,
|
||||
group_categories: Sequence[ParsedSection] | None = None,
|
||||
entry_groups: Sequence[EntryGroup] = (),
|
||||
) -> 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)
|
||||
intro_html, intro_lead = load_category_intro(website / "data" / "category_intros" / f"{current_path.removeprefix('/categories/').strip('/')}.md")
|
||||
intro_html, guide_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:
|
||||
@@ -713,7 +755,9 @@ def build(repo_root: Path) -> None:
|
||||
category_url=category_url,
|
||||
category_description=category_description,
|
||||
intro_html=intro_html,
|
||||
guide_html=guide_html,
|
||||
entries=entries,
|
||||
entry_groups=entry_groups,
|
||||
total_categories=len(categories),
|
||||
category_urls=category_urls,
|
||||
current_path=current_path,
|
||||
@@ -726,6 +770,8 @@ def build(repo_root: Path) -> None:
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
entries_by_key = {(e["url"], e["name"]): e for e in entries}
|
||||
section_groups = {category["name"]: group_section_entries(category, entries_by_key) for category in categories}
|
||||
for category in categories:
|
||||
render_category(
|
||||
category,
|
||||
@@ -733,6 +779,7 @@ def build(repo_root: Path) -> None:
|
||||
entries=[e for e in entries if category["name"] in e["categories"]],
|
||||
current_path=category_path(category),
|
||||
page_dir=categories_dir / category["slug"],
|
||||
entry_groups=section_groups[category["name"]],
|
||||
)
|
||||
|
||||
for group in parsed_groups:
|
||||
@@ -743,6 +790,7 @@ def build(repo_root: Path) -> None:
|
||||
current_path=group_path(group["slug"]),
|
||||
page_dir=categories_dir / group["slug"],
|
||||
group_categories=group["categories"],
|
||||
entry_groups=group_entries_by_section(group["categories"], entries_by_key),
|
||||
)
|
||||
|
||||
if builtin_entries:
|
||||
@@ -792,6 +840,7 @@ def build(repo_root: Path) -> None:
|
||||
current_path=subcategory_path(cat_slug, sub_slug),
|
||||
page_dir=categories_dir / cat_slug / sub_slug,
|
||||
parent_category=cat_by_slug[cat_slug],
|
||||
entry_groups=[EntryGroup(name="", slug="", url="", entries=group["entries"]) for group in section_groups[cat_by_slug[cat_slug]["name"]] if group["name"] == sub_name],
|
||||
)
|
||||
|
||||
redirects_file = website / "data" / "redirects.json"
|
||||
|
||||
+32
-3
@@ -4,8 +4,14 @@ function getScrollBehavior() {
|
||||
return reducedMotion.matches ? "auto" : "smooth";
|
||||
}
|
||||
|
||||
const table = document.querySelector(".table");
|
||||
// Category pages list rows in editorial (README) order until a column is sorted
|
||||
const defaultSort =
|
||||
table && table.dataset.defaultSort === "editorial"
|
||||
? { col: "editorial", order: "asc" }
|
||||
: { col: "downloads", order: "desc" };
|
||||
let activeFilter = null;
|
||||
let activeSort = { col: "downloads", order: "desc" };
|
||||
let activeSort = defaultSort;
|
||||
const searchInput = document.querySelector(".search");
|
||||
const filterBar = document.querySelector(".filter-bar");
|
||||
const filterValue = document.querySelector(".filter-value");
|
||||
@@ -14,6 +20,7 @@ const noResults = document.querySelector(".no-results");
|
||||
const rows = document.querySelectorAll(".table tbody tr.row");
|
||||
const tags = document.querySelectorAll(".tag");
|
||||
const tbody = document.querySelector(".table tbody");
|
||||
const groupRows = document.querySelectorAll(".table tbody tr.group-row");
|
||||
|
||||
function initRevealSections() {
|
||||
const sections = document.querySelectorAll("[data-reveal]");
|
||||
@@ -111,6 +118,12 @@ document
|
||||
time.textContent = relativeTime(time.getAttribute("datetime"));
|
||||
});
|
||||
|
||||
let currentGroupRow = null;
|
||||
Array.prototype.forEach.call(tbody ? tbody.rows : [], function (tr) {
|
||||
if (tr.classList.contains("group-row")) currentGroupRow = tr;
|
||||
else if (tr.classList.contains("row")) tr._groupRow = currentGroupRow;
|
||||
});
|
||||
|
||||
rows.forEach(function (row, i) {
|
||||
row._origIndex = i;
|
||||
let next = row.nextElementSibling;
|
||||
@@ -176,6 +189,15 @@ function applyFilters() {
|
||||
}
|
||||
});
|
||||
|
||||
groupRows.forEach(function (groupRow) {
|
||||
groupRow.hidden = true;
|
||||
});
|
||||
if (activeSort.col === "editorial") {
|
||||
rows.forEach(function (row) {
|
||||
if (!row.hidden && row._groupRow) row._groupRow.hidden = false;
|
||||
});
|
||||
}
|
||||
|
||||
if (noResults) noResults.hidden = visibleCount > 0;
|
||||
|
||||
tags.forEach(function (tag) {
|
||||
@@ -211,7 +233,7 @@ function buildQueryString() {
|
||||
const params = new URLSearchParams();
|
||||
const query = searchInput ? searchInput.value.trim() : "";
|
||||
if (query) params.set("q", query);
|
||||
if (activeSort.col !== "downloads" || activeSort.order !== "desc") {
|
||||
if (activeSort.col !== defaultSort.col || activeSort.order !== defaultSort.order) {
|
||||
params.set("sort", activeSort.col);
|
||||
params.set("order", activeSort.order);
|
||||
}
|
||||
@@ -227,6 +249,8 @@ function updateURL() {
|
||||
}
|
||||
|
||||
function getSortValue(row, col) {
|
||||
// +1 keeps the first row above the "no value" cutoff in sortRows
|
||||
if (col === "editorial") return row._origIndex + 1;
|
||||
if (col === "name") {
|
||||
return row.querySelector(".col-name a").textContent.trim().toLowerCase();
|
||||
}
|
||||
@@ -282,7 +306,12 @@ function sortRows() {
|
||||
});
|
||||
|
||||
const frag = document.createDocumentFragment();
|
||||
let lastGroupRow = null;
|
||||
arr.forEach(function (row) {
|
||||
if (col === "editorial" && row._groupRow && row._groupRow !== lastGroupRow) {
|
||||
frag.appendChild(row._groupRow);
|
||||
lastGroupRow = row._groupRow;
|
||||
}
|
||||
frag.appendChild(row);
|
||||
if (row._descRow) frag.appendChild(row._descRow);
|
||||
if (row._expandRow) frag.appendChild(row._expandRow);
|
||||
@@ -392,7 +421,7 @@ sortHeaders.forEach(function (th) {
|
||||
if (activeSort.col === col) {
|
||||
if (activeSort.order === defaultOrder)
|
||||
activeSort = { col: col, order: altOrder };
|
||||
else activeSort = { col: "downloads", order: "desc" };
|
||||
else activeSort = defaultSort;
|
||||
} else {
|
||||
activeSort = { col: col, order: defaultOrder };
|
||||
}
|
||||
|
||||
+111
-7
@@ -438,6 +438,7 @@ kbd {
|
||||
.search:focus-visible,
|
||||
.filter-clear:focus-visible,
|
||||
.tag:focus-visible,
|
||||
.jump-link:focus-visible,
|
||||
.back-to-top:focus-visible,
|
||||
.no-results-clear:focus-visible,
|
||||
.table a:focus-visible,
|
||||
@@ -459,9 +460,9 @@ kbd {
|
||||
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);
|
||||
padding: 1.25rem var(--shell-pad) clamp(2.25rem, 5vw, 3.5rem);
|
||||
display: grid;
|
||||
gap: clamp(3rem, 8vw, 5.5rem);
|
||||
gap: clamp(2rem, 5vw, 3rem);
|
||||
}
|
||||
|
||||
.category-hero h1 {
|
||||
@@ -497,7 +498,9 @@ kbd {
|
||||
}
|
||||
|
||||
.category-subtitle a,
|
||||
.category-intro a {
|
||||
.category-intro a,
|
||||
.guide-body a,
|
||||
.jump-link {
|
||||
color: var(--hero-text);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: oklch(100% 0 0 / 0.32);
|
||||
@@ -505,12 +508,14 @@ kbd {
|
||||
}
|
||||
|
||||
.category-subtitle a:hover,
|
||||
.category-intro a:hover {
|
||||
.category-intro a:hover,
|
||||
.guide-body a:hover,
|
||||
.jump-link:hover {
|
||||
text-decoration-color: oklch(100% 0 0 / 0.7);
|
||||
}
|
||||
|
||||
.category-intro {
|
||||
margin-top: 1.75rem;
|
||||
margin-top: 1.25rem;
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
color: var(--hero-muted);
|
||||
@@ -530,10 +535,65 @@ kbd {
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.category-intro code {
|
||||
.category-intro code,
|
||||
.guide-body code {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.jump-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem 1.6rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.jump-link {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* The arrow marks an in-page jump; inline-block keeps it out of the underline */
|
||||
.jump-link::after {
|
||||
content: "\2193";
|
||||
display: inline-block;
|
||||
margin-left: 0.3rem;
|
||||
color: var(--hero-kicker);
|
||||
}
|
||||
|
||||
.guide-band {
|
||||
padding-block: clamp(3rem, 7vw, 5.5rem);
|
||||
background: linear-gradient(140deg, var(--hero-bg-start) 0%, var(--hero-bg-mid) 58%, var(--hero-bg-end) 100%);
|
||||
color: var(--hero-text);
|
||||
}
|
||||
|
||||
.guide-section {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.guide-section h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(2.2rem, 4vw, 3.3rem);
|
||||
font-weight: 600;
|
||||
line-height: 0.94;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.guide-body {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
color: var(--hero-muted);
|
||||
font-size: var(--text-lg);
|
||||
line-height: 1.7;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.guide-body ul {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.sponsor-band {
|
||||
padding-block: clamp(2.5rem, 5.5vw, 4rem);
|
||||
background:
|
||||
@@ -651,6 +711,11 @@ kbd {
|
||||
padding-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.order-note {
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.results-note {
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--text-sm);
|
||||
@@ -819,6 +884,44 @@ kbd {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.group-row th {
|
||||
padding-top: 2.25rem;
|
||||
padding-bottom: 0.85rem;
|
||||
padding-left: max(var(--shell-pad), calc(50vw - (var(--shell-max) / 2) + var(--shell-pad)));
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--line-strong);
|
||||
background: var(--bg-paper);
|
||||
}
|
||||
|
||||
.group-row h2 {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.7rem, 2.8vw, 2.2rem);
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
scroll-margin-top: 4.5rem;
|
||||
}
|
||||
|
||||
.group-row h2 a {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.group-row h2 a:hover {
|
||||
color: var(--accent-deep);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--accent-underline);
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
.group-count {
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.row:not(.open):hover td {
|
||||
background: var(--row-hover);
|
||||
}
|
||||
@@ -1735,7 +1838,8 @@ th[data-sort].sort-asc::after {
|
||||
}
|
||||
|
||||
.table thead th:first-child,
|
||||
.table tbody td:first-child {
|
||||
.table tbody td:first-child,
|
||||
.group-row th {
|
||||
padding-left: 0.8rem;
|
||||
}
|
||||
|
||||
|
||||
+134
-81
File diff suppressed because it is too large
Load Diff
+123
-4
@@ -17,6 +17,7 @@ from build import (
|
||||
detect_source_type,
|
||||
extract_entries,
|
||||
extract_github_repo,
|
||||
load_category_intro,
|
||||
load_downloads,
|
||||
load_pypi_badges,
|
||||
load_stars,
|
||||
@@ -280,7 +281,7 @@ class TestBuild:
|
||||
|
||||
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.title.strip() == "Python Widgets Libraries - Awesome Python"
|
||||
assert parser.meta_by_name["description"] == "Widget libraries. Also see awesome-widgets. Explore 2 curated Python projects in 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/"
|
||||
@@ -291,7 +292,7 @@ class TestBuild:
|
||||
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 '<table class="table" data-default-sort="editorial">' in category_html
|
||||
assert "42" in category_html
|
||||
assert "2026-01-01T00:00:00+00:00" in category_html
|
||||
|
||||
@@ -622,7 +623,7 @@ class TestBuild:
|
||||
assert set(graph) == {"WebSite", "CollectionPage", "BreadcrumbList"}
|
||||
assert graph["WebSite"]["@id"] == "https://awesome-python.com/#website"
|
||||
collection = graph["CollectionPage"]
|
||||
assert collection["name"] == "Widgets Python Libraries"
|
||||
assert collection["name"] == "Python Widgets Libraries"
|
||||
assert collection["@id"] == "https://awesome-python.com/categories/widgets/"
|
||||
assert collection["url"] == "https://awesome-python.com/categories/widgets/"
|
||||
assert collection["description"] == "Widget libraries. Explore 2 curated Python projects in Widgets."
|
||||
@@ -672,7 +673,7 @@ class TestBuild:
|
||||
|
||||
graph = {node["@type"]: node for node in data["@graph"]}
|
||||
collection = graph["CollectionPage"]
|
||||
assert collection["name"] == "AI & ML Python Libraries"
|
||||
assert collection["name"] == "Python AI & ML Libraries"
|
||||
assert collection["@id"] == "https://awesome-python.com/categories/ai-ml/"
|
||||
assert collection["url"] == "https://awesome-python.com/categories/ai-ml/"
|
||||
assert collection["description"] == "Explore 1 curated Python projects in AI & ML. Part of the Awesome Python catalog."
|
||||
@@ -1007,6 +1008,99 @@ class TestBuild:
|
||||
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_renders_category_guide_below_table(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.\n\nHow to choose:\n\n- Small apps: w1\n\nSet up w1 once per process.\n", encoding="utf-8")
|
||||
build(tmp_path)
|
||||
|
||||
category_html = (tmp_path / "website" / "output" / "categories" / "widgets" / "index.html").read_text(encoding="utf-8")
|
||||
intro_html = category_html.split('<div class="category-intro">', 1)[1].split("</div>", 1)[0]
|
||||
assert "<li>Small apps: w1</li>" in intro_html
|
||||
assert "Set up w1" not in intro_html
|
||||
guide_html = category_html.split('<section class="guide-band" id="guide">', 1)[1]
|
||||
assert "<h2>Widgets guide</h2>" in guide_html
|
||||
assert "<p>Set up w1 once per process.</p>" in guide_html
|
||||
assert category_html.index('id="guide"') > category_html.index("</table>")
|
||||
assert '<a class="jump-link" href="#guide">Widgets guide</a>' in category_html
|
||||
|
||||
def test_section_page_groups_rows_by_use_case_in_readme_order(self, tmp_path):
|
||||
readme = textwrap.dedent("""\
|
||||
# T
|
||||
|
||||
## Projects
|
||||
|
||||
**Tools**
|
||||
|
||||
### Widgets
|
||||
|
||||
- Small
|
||||
- [w2](https://example.com/w2) - Second.
|
||||
- [w1](https://example.com/w1) - First.
|
||||
- Large
|
||||
- [w3](https://example.com/w3) - Third.
|
||||
- [sqlite3](https://docs.python.org/3/library/sqlite3.html) - Stdlib.
|
||||
|
||||
# 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" / "categories"
|
||||
html = (site / "widgets" / "index.html").read_text(encoding="utf-8")
|
||||
assert 'data-default-sort="editorial"' in html
|
||||
positions = [html.index(marker) for marker in ('<h2 id="small">', ">w2</a", ">w1</a", '<h2 id="large">', ">w3</a")]
|
||||
assert positions == sorted(positions)
|
||||
assert '<a class="jump-link" href="#small">Small</a>' in html
|
||||
assert '<tr class="desc-row">' in html
|
||||
|
||||
subcategory_html = (site / "widgets" / "small" / "index.html").read_text(encoding="utf-8")
|
||||
assert 'data-default-sort="editorial"' in subcategory_html
|
||||
assert "group-row" not in subcategory_html
|
||||
assert subcategory_html.index(">w2</a") < subcategory_html.index(">w1</a")
|
||||
|
||||
builtin_html = (site / "built-in" / "index.html").read_text(encoding="utf-8")
|
||||
assert "data-default-sort" not in builtin_html
|
||||
assert "group-row" not in builtin_html
|
||||
|
||||
def test_group_page_groups_rows_by_section_with_links(self, tmp_path):
|
||||
readme = textwrap.dedent("""\
|
||||
# T
|
||||
|
||||
## Projects
|
||||
|
||||
**AI & ML**
|
||||
|
||||
## Machine Learning
|
||||
|
||||
- [ml1](https://example.com/ml1) - ML.
|
||||
|
||||
## Deep Learning
|
||||
|
||||
- [dl1](https://example.com/dl1) - DL.
|
||||
- [ml1](https://example.com/ml1) - ML again.
|
||||
|
||||
# Contributing
|
||||
|
||||
Done.
|
||||
""")
|
||||
self._copy_real_templates(tmp_path)
|
||||
(tmp_path / "README.md").write_text(readme, encoding="utf-8")
|
||||
build(tmp_path)
|
||||
|
||||
html = (tmp_path / "website" / "output" / "categories" / "ai-ml" / "index.html").read_text(encoding="utf-8")
|
||||
assert 'data-default-sort="editorial"' in html
|
||||
ml_heading = html.index('<a href="/categories/machine-learning/">Machine Learning</a>')
|
||||
dl_heading = html.index('<a href="/categories/deep-learning/">Deep Learning</a>')
|
||||
assert ml_heading < html.index(">ml1</a") < dl_heading < html.index(">dl1</a")
|
||||
assert html.count(">ml1</a") == 1
|
||||
assert 'class="jump-links"' not in 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")
|
||||
@@ -1348,3 +1442,28 @@ class TestLoadDownloads:
|
||||
|
||||
def test_missing_file_returns_empty(self, tmp_path):
|
||||
assert load_downloads(tmp_path / "nope.tsv") == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_category_intro
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoadCategoryIntro:
|
||||
def test_splits_after_how_to_choose_list(self, tmp_path):
|
||||
path = tmp_path / "widgets.md"
|
||||
path.write_text("Use `w1` for most apps.\n\nHow to choose:\n\n- Small apps: w1\n- Big apps: w2\n\nConfigure w1 once.\n\nPin w2.\n", encoding="utf-8")
|
||||
intro_html, guide_html, lead = load_category_intro(path)
|
||||
assert intro_html == "<p>Use <code>w1</code> for most apps.</p>\n<p>How to choose:</p>\n<ul>\n<li>Small apps: w1</li>\n<li>Big apps: w2</li>\n</ul>\n"
|
||||
assert guide_html == "<p>Configure w1 once.</p>\n<p>Pin w2.</p>\n"
|
||||
assert lead == "Use w1 for most apps."
|
||||
|
||||
def test_keeps_everything_above_table_without_how_to_choose_list(self, tmp_path):
|
||||
path = tmp_path / "widgets.md"
|
||||
path.write_text("Use w1.\n\n- Small apps: w1\n\nConfigure w1 once.\n", encoding="utf-8")
|
||||
intro_html, guide_html, _ = load_category_intro(path)
|
||||
assert "Configure w1 once." in intro_html
|
||||
assert guide_html == ""
|
||||
|
||||
def test_returns_empty_strings_without_intro_file(self, tmp_path):
|
||||
assert load_category_intro(tmp_path / "missing.md") == ("", "", "")
|
||||
|
||||
Reference in New Issue
Block a user