mirror of
https://github.com/vinta/awesome-python.git
synced 2026-10-02 08:23:10 +08:00
feat: group category page rows by use case, restructure intro
Category pages sorted rows by downloads, which buried editorial leads (the Django ORM showed as row 7 of 7, tkinter as 14 of 14) against CONTRIBUTING.md's "position is the marker"; the full intro in the hero pushed the table 2.5 screens down on a phone; and every page repeated the same "Search every project in one place" H2. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+55
-6
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,19 @@
|
||||
{% if intro_html %}
|
||||
<div class="category-intro">{{ intro_html | safe }}</div>
|
||||
{% endif %}
|
||||
{% set named_groups = entry_groups | selectattr("name") | list %}
|
||||
{% if (named_groups and not group_categories) or guide_html %}
|
||||
<nav class="jump-links" aria-label="On this page">
|
||||
{% if not group_categories %}
|
||||
{% for group in named_groups %}
|
||||
<a class="jump-link" href="#{{ group.slug }}">{{ group.name }}</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if guide_html %}
|
||||
<a class="jump-link" href="#guide">{{ category.name }} guide</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if group_categories %}
|
||||
@@ -201,9 +214,17 @@
|
||||
<script type="application/json" id="filter-urls">{{ filter_urls_json | safe }}</script>
|
||||
<section class="results-section" id="library-index">
|
||||
<div class="results-intro section-shell" data-reveal>
|
||||
{% if entry_groups %}
|
||||
{% set named_groups = entry_groups | selectattr("name") | list %}
|
||||
<p class="order-note">
|
||||
{% if group_categories %}Listed by section, in editorial order.{% elif named_groups %}Listed in editorial order, grouped by use case.{% else %}Listed in editorial order.{% endif %}
|
||||
<strong>Click a column</strong> to re-sort the whole list.
|
||||
</p>
|
||||
{% else %}
|
||||
<div>
|
||||
<h2>Search every project in one place</h2>
|
||||
<h2>{{ entries | length }} {{ category.name }} projects</h2>
|
||||
</div>
|
||||
{% endif %}
|
||||
<p class="results-note">
|
||||
Press <kbd>/</kbd> to search. Tap a tag to filter. Click any row for
|
||||
details.
|
||||
@@ -249,7 +270,7 @@
|
||||
role="region"
|
||||
aria-label="Libraries table"
|
||||
>
|
||||
<table class="table">
|
||||
<table class="table"{% if entry_groups %} data-default-sort="editorial"{% endif %}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-num"><span class="sr-only">Row number</span></th>
|
||||
@@ -274,9 +295,29 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if entry_groups %}
|
||||
{% set row = namespace(index=0) %}
|
||||
{% for group in entry_groups %}
|
||||
{% if group.name %}
|
||||
<tr class="group-row">
|
||||
<th colspan="7" scope="rowgroup">
|
||||
<h2 id="{{ group.slug }}">
|
||||
{% if group.url %}<a href="{{ group.url }}">{{ group.name }}</a>{% else %}{{ group.name }}{% endif %}
|
||||
<span class="group-count">{{ group.entries | length }} project{{ "s" if group.entries | length != 1 }}</span>
|
||||
</h2>
|
||||
</th>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% for entry in group.entries %}
|
||||
{% set row.index = row.index + 1 %}
|
||||
{{ entry_rows(entry, row.index) }}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% for entry in entries %}
|
||||
{{ entry_rows(entry, loop.index) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -290,6 +331,15 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if guide_html %}
|
||||
<section class="guide-band" id="guide">
|
||||
<div class="guide-section section-shell">
|
||||
<h2>{{ category.name }} guide</h2>
|
||||
<div class="guide-body">{{ guide_html | safe }}</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="final-cta" data-reveal>
|
||||
<div class="section-shell">
|
||||
<p class="section-label">Contribute</p>
|
||||
|
||||
+120
-1
@@ -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,
|
||||
@@ -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
|
||||
|
||||
@@ -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