Merge branch 'feature/tag-navigation' into feature/seo-redesign

This commit is contained in:
Vinta Chen
2026-09-27 00:34:10 +08:00
6 changed files with 21 additions and 248 deletions
+2 -9
View File
@@ -276,7 +276,8 @@ def group_section_entries(section: ParsedSection, entries_by_key: dict[tuple[str
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=[]))
slug = slugify(name) if name else ""
group = groups.setdefault(name, EntryGroup(name=name, slug=slug, url=subcategory_path(section["slug"], slug) if name else "", entries=[]))
group["entries"].append(entries_by_key[(parsed["url"], parsed["name"])])
return list(groups.values())
@@ -678,12 +679,7 @@ def build(repo_root: Path) -> None:
filter_urls: dict[str, str] = dict(category_urls)
for group in parsed_groups:
filter_urls[group["name"]] = group_path(group["slug"])
for entry in entries:
for sub in entry.get("subcategories", []):
filter_urls[sub["value"]] = sub["url"]
builtin_entries = [e for e in entries if e.get("source_type") == BUILTIN_FILTER]
if builtin_entries:
filter_urls[BUILTIN_FILTER] = BUILTIN_PATH
env = Environment(
loader=FileSystemLoader(website / "templates"),
@@ -696,7 +692,6 @@ def build(repo_root: Path) -> None:
shutil.rmtree(site_dir)
site_dir.mkdir(parents=True)
filter_urls_json = json.dumps(filter_urls, sort_keys=True, ensure_ascii=False).replace("</", "<\\/")
homepage_json_ld = json.dumps(
build_homepage_json_ld(entries, len(categories)),
ensure_ascii=False,
@@ -715,7 +710,6 @@ def build(repo_root: Path) -> None:
sponsors=sponsors,
category_urls=category_urls,
filter_urls=filter_urls,
filter_urls_json=filter_urls_json,
homepage_json_ld=homepage_json_ld,
),
encoding="utf-8",
@@ -762,7 +756,6 @@ def build(repo_root: Path) -> None:
category_urls=category_urls,
current_path=current_path,
filter_urls=filter_urls,
filter_urls_json=filter_urls_json,
parent_category=parent_category,
group_categories=group_categories,
category_json_ld=category_json_ld,
+6 -85
View File
@@ -10,15 +10,10 @@ const defaultSort =
table && table.dataset.defaultSort === "editorial"
? { col: "editorial", order: "asc" }
: { col: "downloads", order: "desc" };
let activeFilter = null;
let activeSort = defaultSort;
const searchInput = document.querySelector(".search");
const filterBar = document.querySelector(".filter-bar");
const filterValue = document.querySelector(".filter-value");
const filterClear = document.querySelector(".filter-clear");
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");
@@ -145,7 +140,7 @@ function collapseAll() {
function applyFilters() {
const query = searchInput ? searchInput.value.toLowerCase().trim() : "";
const descRowsVisible = !isIndexDocument || activeFilter !== null;
const descRowsVisible = !isIndexDocument;
let visibleCount = 0;
collapseAll();
@@ -153,12 +148,7 @@ function applyFilters() {
rows.forEach(function (row) {
let show = true;
if (activeFilter) {
const rowTags = row.dataset.tags;
show = rowTags ? rowTags.split("||").includes(activeFilter) : false;
}
if (show && query) {
if (query) {
if (!row._searchText) {
let text = row.textContent.toLowerCase();
if (row._descRow) {
@@ -200,35 +190,12 @@ function applyFilters() {
if (noResults) noResults.hidden = visibleCount > 0;
tags.forEach(function (tag) {
tag.classList.toggle("active", activeFilter === tag.dataset.value);
});
if (filterBar) {
if (activeFilter) {
filterBar.classList.add("visible");
if (filterValue) filterValue.textContent = activeFilter;
} else {
filterBar.classList.remove("visible");
}
}
updateURL();
}
const filterUrlsScript = document.getElementById("filter-urls");
const filterToUrl = filterUrlsScript
? JSON.parse(filterUrlsScript.textContent)
: {};
const isIndexDocument =
location.pathname === "/" || location.pathname === "/index.html";
const urlToFilter = {};
Object.keys(filterToUrl).forEach(function (k) {
urlToFilter[filterToUrl[k]] = k;
});
function buildQueryString() {
const params = new URLSearchParams();
const query = searchInput ? searchInput.value.trim() : "";
@@ -243,9 +210,7 @@ function buildQueryString() {
function updateURL() {
if (!isIndexDocument) return;
const path =
activeFilter && filterToUrl[activeFilter] ? filterToUrl[activeFilter] : "/";
history.replaceState(null, "", path + buildQueryString());
history.replaceState(null, "", "/" + buildQueryString());
}
function getSortValue(row, col) {
@@ -340,8 +305,8 @@ function updateSortIndicators() {
// Expand/collapse: event delegation on tbody
if (tbody) {
tbody.addEventListener("click", function (e) {
// Don't toggle if clicking a link or tag button
if (e.target.closest("a") || e.target.closest(".tag")) return;
// Don't toggle if clicking a link
if (e.target.closest("a")) return;
let row = e.target.closest("tr.row");
if (!row) {
@@ -370,36 +335,6 @@ if (tbody) {
});
}
tags.forEach(function (tag) {
tag.addEventListener("click", function (e) {
e.preventDefault();
const value = tag.dataset.value;
const url = tag.dataset.url;
if (isIndexDocument) {
activeFilter = activeFilter === value ? null : value;
if (activeFilter && url) {
history.pushState(null, "", url + buildQueryString());
} else {
history.pushState(null, "", "/" + buildQueryString());
}
applyFilters();
} else if (url) {
window.location.href = url + "#library-index";
}
});
});
if (filterClear) {
filterClear.addEventListener("click", function () {
if (!isIndexDocument) {
window.location.href = "/#library-index";
return;
}
activeFilter = null;
applyFilters();
});
}
const noResultsClear = document.querySelector(".no-results-clear");
if (noResultsClear) {
noResultsClear.addEventListener("click", function () {
@@ -408,7 +343,6 @@ if (noResultsClear) {
return;
}
if (searchInput) searchInput.value = "";
activeFilter = null;
applyFilters();
});
}
@@ -451,7 +385,6 @@ if (searchInput) {
}
if (e.key === "Escape" && document.activeElement === searchInput) {
searchInput.value = "";
activeFilter = null;
applyFilters();
searchInput.blur();
}
@@ -512,20 +445,8 @@ if (backToTop) {
) {
activeSort = { col: sort, order: order };
}
const matched = urlToFilter[location.pathname];
if (matched) activeFilter = matched;
if (q || activeFilter || sort) {
if (q || sort) {
sortRows();
}
if (activeFilter) {
applyFilters();
}
updateSortIndicators();
})();
window.addEventListener("popstate", function () {
if (!isIndexDocument) return;
const matched = urlToFilter[location.pathname];
activeFilter = matched || null;
applyFilters();
});
+1 -48
View File
@@ -265,8 +265,7 @@ kbd {
.hero-topbar-link:active,
.hero-action:active,
.tag:active,
.filter-clear:active {
.tag:active {
transform: translateY(1px);
}
@@ -436,7 +435,6 @@ kbd {
.hero-topbar-link:focus-visible,
.hero-category-link:focus-visible,
.search:focus-visible,
.filter-clear:focus-visible,
.tag:focus-visible,
.jump-link:focus-visible,
.back-to-top:focus-visible,
@@ -771,51 +769,6 @@ kbd {
0 1.6rem 3rem -2rem var(--search-focus-shadow);
}
.filter-bar {
display: flex;
align-items: center;
gap: 0.75rem;
min-height: 2.3rem;
font-size: var(--text-sm);
color: var(--ink-soft);
opacity: 0;
transform: translateY(-0.4rem);
pointer-events: none;
transition:
opacity 180ms ease,
transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
.filter-bar.visible {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.filter-bar strong {
color: var(--ink);
}
.filter-clear {
border: 1px solid var(--line);
border-radius: 999px;
background: var(--bg-paper);
color: var(--ink-soft);
padding: 0.42rem 0.82rem;
cursor: pointer;
transition:
border-color 180ms ease,
color 180ms ease,
background-color 180ms ease,
transform 180ms ease;
}
.filter-clear:hover {
color: var(--ink);
background: var(--accent-soft);
border-color: oklch(68% 0.08 58 / 0.5);
}
.table-wrap {
width: 100%;
border-top: 1px solid var(--line);
+1 -15
View File
@@ -78,7 +78,6 @@
{% macro entry_rows(entry, index) %}
<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 == 'Stdlib' %}||Stdlib{% endif %}"
tabindex="0"
aria-expanded="false"
aria-controls="expand-{{ index }}"
@@ -124,7 +123,7 @@
</td>
<td class="col-cat">
{% for subcat in entry.subcategories %}
<a class="tag{% if subcat.url == current_path %} active{% endif %}" href="{{ subcat.url }}" data-value="{{ subcat.value }}" data-url="{{ subcat.url }}">
<a class="tag{% if subcat.url == current_path %} active{% endif %}" href="{{ category_urls[subcat.value.split(' > ')[0]] }}#{{ subcat.slug }}">
{{ subcat.name }}
</a>
{% endfor %}
@@ -132,8 +131,6 @@
<a
class="tag{% if category_urls[cat] == current_path %} active{% endif %}"
href="{{ category_urls[cat] }}"
data-value="{{ cat }}"
data-url="{{ category_urls[cat] }}"
>{{ cat }}</a
>
{% endfor %}
@@ -142,8 +139,6 @@
<a
class="tag tag-group{% if group_url == current_path %} active{% endif %}"
href="{{ group_url }}"
data-value="{{ entry.groups[0] }}"
data-url="{{ group_url }}"
>
{{ entry.groups[0] }}
</a>
@@ -152,8 +147,6 @@
<a
class="tag tag-source{% if '/categories/built-in/' == current_path %} active{% endif %}"
href="/categories/built-in/"
data-value="Stdlib"
data-url="/categories/built-in/"
>
Stdlib
</a>
@@ -211,7 +204,6 @@
<td></td>
</tr>
{% endmacro %}
<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 %}
@@ -255,12 +247,6 @@
aria-label="Search projects"
/>
</div>
<div class="filter-bar" aria-live="polite">
<span>Filtering for <strong class="filter-value"></strong></span>
<button class="filter-clear" aria-label="Clear filter">
Clear filter
</button>
</div>
</div>
<h2 class="sr-only">Results</h2>
+1 -15
View File
@@ -96,7 +96,6 @@
</section>
{% endif %}
<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>
<div>
@@ -132,12 +131,6 @@
aria-label="Search projects"
/>
</div>
<div class="filter-bar" aria-live="polite">
<span>Filtering for <strong class="filter-value"></strong></span>
<button class="filter-clear" aria-label="Clear filter">
Clear filter
</button>
</div>
</div>
<h2 class="sr-only">Results</h2>
@@ -175,7 +168,6 @@
{% 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 == 'Stdlib' %}||Stdlib{% endif %}"
tabindex="0"
aria-expanded="false"
aria-controls="expand-{{ loop.index }}"
@@ -221,23 +213,19 @@
</td>
<td class="col-cat">
{% for subcat in entry.subcategories %}
<a class="tag" href="{{ subcat.url }}" data-value="{{ subcat.value }}" data-url="{{ subcat.url }}">
<a class="tag" href="{{ category_urls[subcat.value.split(' > ')[0]] }}#{{ subcat.slug }}">
{{ subcat.name }}
</a>
{% endfor %} {% for cat in entry.categories %}
<a
class="tag"
href="{{ category_urls[cat] }}"
data-value="{{ cat }}"
data-url="{{ category_urls[cat] }}"
>{{ cat }}</a
>
{% endfor %}
<a
class="tag tag-group"
href="{{ filter_urls[entry.groups[0]] }}"
data-value="{{ entry.groups[0] }}"
data-url="{{ filter_urls[entry.groups[0]] }}"
>
{{ entry.groups[0] }}
</a>
@@ -245,8 +233,6 @@
<a
class="tag tag-source"
href="/categories/built-in/"
data-value="Stdlib"
data-url="/categories/built-in/"
>
Stdlib
</a>
+10 -76
View File
@@ -280,7 +280,6 @@ class TestBuild:
parser.feed(category_html)
assert 'href="/categories/widgets/"' in index_html
assert 'data-value="Widgets"' in index_html
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/"
@@ -816,75 +815,6 @@ class TestBuild:
{"@type": "ListItem", "position": 2, "name": "Sponsorship", "item": "https://awesome-python.com/sponsorship/"},
]
def test_index_embeds_filter_urls_json(self, tmp_path):
readme = textwrap.dedent("""\
# T
## Projects
**AI & ML**
## Deep Learning
- [dl1](https://example.com/dl1) - DL.
## Machine Learning
- Classical
- [ml1](https://example.com/ml1) - ML.
# 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"
index_html = (site / "index.html").read_text(encoding="utf-8")
marker = '<script type="application/json" id="filter-urls">'
assert marker in index_html
start = index_html.index(marker) + len(marker)
end = index_html.index("</script>", start)
data = json.loads(index_html[start:end])
assert data["Deep Learning"] == "/categories/deep-learning/"
assert data["Machine Learning"] == "/categories/machine-learning/"
assert data["AI & ML"] == "/categories/ai-ml/"
assert data["Machine Learning > Classical"] == "/categories/machine-learning/classical/"
def test_filter_urls_json_escapes_closing_script_tag(self, tmp_path):
readme = textwrap.dedent("""\
# T
## Projects
## Sneaky </script><script>x=1</script>
- [a](https://example.com) - A.
# 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"
index_html = (site / "index.html").read_text(encoding="utf-8")
marker = '<script type="application/json" id="filter-urls">'
start = index_html.index(marker) + len(marker)
end = index_html.index("</script>", start)
block = index_html[start:end]
assert "</script>" not in block
data = json.loads(block)
assert any("Sneaky" in key for key in data)
def test_build_creates_group_pages(self, tmp_path):
readme = textwrap.dedent("""\
# T
@@ -925,7 +855,7 @@ class TestBuild:
assert "wf1" in web_dev
assert "dl1" not in web_dev
def test_tag_buttons_have_data_url(self, tmp_path):
def test_tags_link_to_pages_and_subcategory_anchors(self, tmp_path):
readme = textwrap.dedent("""\
# T
@@ -950,11 +880,14 @@ class TestBuild:
site = tmp_path / "website" / "output"
index_html = (site / "index.html").read_text(encoding="utf-8")
assert 'data-value="Deep Learning"' in index_html
assert 'data-url="/categories/deep-learning/"' in index_html
assert 'data-value="AI &amp; ML"' in index_html or 'data-value="AI & ML"' in index_html
assert 'data-url="/categories/ai-ml/"' in index_html
assert 'data-url="/categories/deep-learning/vision/"' in index_html
category_html = (site / "categories" / "deep-learning" / "index.html").read_text(encoding="utf-8")
for html in (index_html, category_html):
assert 'href="/categories/deep-learning/#vision"' in html
assert 'href="/categories/ai-ml/"' in html
assert "data-url=" not in html
assert 'href="/categories/deep-learning/"' in index_html
assert 'id="vision"' in category_html
_REDIRECT_README = textwrap.dedent("""\
# Awesome Python
@@ -1057,6 +990,7 @@ class TestBuild:
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 '<a href="/categories/widgets/small/">Small</a>' in html
assert '<tr class="desc-row">' in html
subcategory_html = (site / "widgets" / "small" / "index.html").read_text(encoding="utf-8")