Merge branch 'feature/category-redesign' into feature/seo-redesign

This commit is contained in:
Vinta Chen
2026-09-26 23:34:26 +08:00
6 changed files with 460 additions and 103 deletions
+4 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
File diff suppressed because it is too large Load Diff
+123 -4
View File
@@ -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") == ("", "", "")