mirror of
https://github.com/vinta/awesome-python.git
synced 2026-05-07 08:20:21 -05:00
Merge pull request #3107 from vinta/chore/refine-ui
chore: refine UI, add JSON-LD, polish category SEO
This commit is contained in:
@@ -75,6 +75,7 @@ Layout and sizing:
|
||||
- The impeccable skill reference rule "cap line length at ~65-75ch" does NOT apply here. Ignore it. Readability at wide widths is carried by vertical rhythm, leading, and the modular type scale instead.
|
||||
- If you believe a width cap is actually necessary for some specific element, ask first with a concrete reason before adding it.
|
||||
- Body type floor is 16px (`--text-base: 1rem`). Content-heavy passages may go to 1.125rem.
|
||||
- Absolute minimum font size is 12px (`0.75rem`) for ANY text, including pills, badges, tags, captions, footnotes. Anything smaller hits Chrome's default minimum-font-size floor and renders inconsistently across browsers and user accessibility settings. Use `var(--text-xs)` (`0.8rem`) as the smallest token.
|
||||
- When in doubt about any type size, pick one step larger than what the impeccable skill's scale references suggest. The user has repeatedly corrected sizes upward (11+ separate requests across 8 sessions). Never reduce an existing size unprompted. Footer, meta rows, expand content, labels, headings all trend too small by default.
|
||||
- Row numbers in the table: left-align, no leading zeros. The user tried zero-padding and rejected it.
|
||||
- Adjacent heading levels differ by at least 0.25rem of rendered size.
|
||||
|
||||
@@ -838,7 +838,7 @@ _Libraries for working with graphical user interface applications._
|
||||
- [nicegui](https://github.com/zauberzeug/nicegui) - An easy-to-use, Python-based UI framework, which shows up in your web browser.
|
||||
- [pywebview](https://github.com/r0x0r/pywebview/) - A lightweight cross-platform native wrapper around a webview component.
|
||||
- Terminal
|
||||
- [curses](https://docs.python.org/3/library/curses.html) - Built-in wrapper for [ncurses](http://www.gnu.org/software/ncurses/) used to create terminal GUI applications.
|
||||
- [curses](https://docs.python.org/3/library/curses.html) - (Python standard library) The built-in wrapper for [ncurses](http://www.gnu.org/software/ncurses/) used to create terminal GUI applications.
|
||||
- [urwid](https://github.com/urwid/urwid) - A library for creating terminal GUI applications with strong support for widgets, events, rich colors, etc.
|
||||
- Wrappers
|
||||
- [gooey](https://github.com/chriskiehl/Gooey) - Turn command line programs into a full GUI application with one line.
|
||||
|
||||
2
uv.lock
generated
2
uv.lock
generated
@@ -3,7 +3,7 @@ revision = 3
|
||||
requires-python = ">=3.13"
|
||||
|
||||
[options]
|
||||
exclude-newer = "2026-04-30T04:22:17.540198Z"
|
||||
exclude-newer = "2026-04-30T04:38:39.45925Z"
|
||||
exclude-newer-span = "P3D"
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -118,6 +118,84 @@ def build_robots_txt() -> str:
|
||||
return f"User-agent: *\nContent-Signal: search=yes, ai-input=yes, ai-train=yes\nAllow: /\n\nSitemap: {SITEMAP_URL}\n"
|
||||
|
||||
|
||||
WEBSITE_ID = f"{SITE_URL}#website"
|
||||
ISPARTOF_WEBSITE = {"@type": "WebSite", "@id": WEBSITE_ID}
|
||||
|
||||
|
||||
def _website_node() -> dict:
|
||||
return {
|
||||
"@type": "WebSite",
|
||||
"@id": WEBSITE_ID,
|
||||
"name": "Awesome Python",
|
||||
"url": SITE_URL,
|
||||
}
|
||||
|
||||
|
||||
def _item_list_payload(entries: Sequence[TemplateEntry]) -> dict:
|
||||
return {
|
||||
"@type": "ItemList",
|
||||
"numberOfItems": len(entries),
|
||||
"itemListElement": [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": i,
|
||||
"name": entry["name"],
|
||||
"url": entry["url"],
|
||||
}
|
||||
for i, entry in enumerate(entries, start=1)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_homepage_json_ld(entries: Sequence[TemplateEntry], total_categories: int) -> dict:
|
||||
description = (
|
||||
"An opinionated guide to the best Python frameworks, libraries, and tools. "
|
||||
f"Explore {len(entries)} curated projects across {total_categories} categories, "
|
||||
"from AI and agents to data science and web development."
|
||||
)
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
_website_node(),
|
||||
{
|
||||
"@type": "CollectionPage",
|
||||
"@id": SITE_URL,
|
||||
"name": "Awesome Python",
|
||||
"url": SITE_URL,
|
||||
"description": description,
|
||||
"isPartOf": ISPARTOF_WEBSITE,
|
||||
"mainEntity": _item_list_payload(entries),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def category_meta_description(name: str, entry_count: int, description: str) -> str:
|
||||
count_sentence = f"Explore {entry_count} curated Python projects in {name}."
|
||||
if description:
|
||||
lead = description if description.endswith((".", "!", "?")) else f"{description}."
|
||||
return f"{lead} {count_sentence}"
|
||||
return f"{count_sentence} Part of the Awesome Python catalog."
|
||||
|
||||
|
||||
def build_category_json_ld(name: str, url: str, description: str, entries: Sequence[TemplateEntry]) -> dict:
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
_website_node(),
|
||||
{
|
||||
"@type": "CollectionPage",
|
||||
"@id": url,
|
||||
"name": f"{name} Python Libraries",
|
||||
"url": url,
|
||||
"description": description,
|
||||
"isPartOf": ISPARTOF_WEBSITE,
|
||||
"mainEntity": _item_list_payload(entries),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def category_path(category: ParsedSection) -> str:
|
||||
return f"/categories/{category['slug']}/"
|
||||
|
||||
@@ -378,6 +456,10 @@ def build(repo_root: Path) -> None:
|
||||
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,
|
||||
).replace("</", "<\\/")
|
||||
|
||||
tpl_index = env.get_template("index.html")
|
||||
(site_dir / "index.html").write_text(
|
||||
@@ -393,6 +475,7 @@ def build(repo_root: Path) -> None:
|
||||
category_urls=category_urls,
|
||||
filter_urls=filter_urls,
|
||||
filter_urls_json=filter_urls_json,
|
||||
homepage_json_ld=homepage_json_ld,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
@@ -411,10 +494,20 @@ def build(repo_root: Path) -> None:
|
||||
group_categories: Sequence[ParsedSection] | None = None,
|
||||
) -> None:
|
||||
page_dir.mkdir(parents=True, exist_ok=True)
|
||||
category_description = category_meta_description(
|
||||
category["name"], len(entries), category["description"]
|
||||
)
|
||||
category_json_ld = json.dumps(
|
||||
build_category_json_ld(
|
||||
category["name"], category_url, category_description, entries
|
||||
),
|
||||
ensure_ascii=False,
|
||||
).replace("</", "<\\/")
|
||||
(page_dir / "index.html").write_text(
|
||||
tpl_category.render(
|
||||
category=category,
|
||||
category_url=category_url,
|
||||
category_description=category_description,
|
||||
entries=entries,
|
||||
total_categories=len(categories),
|
||||
category_urls=category_urls,
|
||||
@@ -423,6 +516,7 @@ def build(repo_root: Path) -> None:
|
||||
filter_urls_json=filter_urls_json,
|
||||
parent_category=parent_category,
|
||||
group_categories=group_categories,
|
||||
category_json_ld=category_json_ld,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
@@ -113,7 +113,12 @@ document
|
||||
|
||||
rows.forEach(function (row, i) {
|
||||
row._origIndex = i;
|
||||
row._expandRow = row.nextElementSibling;
|
||||
let next = row.nextElementSibling;
|
||||
if (next && next.classList.contains("desc-row")) {
|
||||
row._descRow = next;
|
||||
next = next.nextElementSibling;
|
||||
}
|
||||
row._expandRow = next;
|
||||
});
|
||||
|
||||
function collapseAll() {
|
||||
@@ -127,6 +132,7 @@ function collapseAll() {
|
||||
|
||||
function applyFilters() {
|
||||
const query = searchInput ? searchInput.value.toLowerCase().trim() : "";
|
||||
const descRowsVisible = !isIndexDocument || activeFilter !== null;
|
||||
let visibleCount = 0;
|
||||
|
||||
collapseAll();
|
||||
@@ -142,9 +148,11 @@ function applyFilters() {
|
||||
if (show && query) {
|
||||
if (!row._searchText) {
|
||||
let text = row.textContent.toLowerCase();
|
||||
const next = row.nextElementSibling;
|
||||
if (next && next.classList.contains("expand-row")) {
|
||||
text += " " + next.textContent.toLowerCase();
|
||||
if (row._descRow) {
|
||||
text += " " + row._descRow.textContent.toLowerCase();
|
||||
}
|
||||
if (row._expandRow) {
|
||||
text += " " + row._expandRow.textContent.toLowerCase();
|
||||
}
|
||||
row._searchText = text;
|
||||
}
|
||||
@@ -152,6 +160,12 @@ function applyFilters() {
|
||||
}
|
||||
|
||||
if (row.hidden !== !show) row.hidden = !show;
|
||||
if (row._descRow) {
|
||||
const descHidden = !show || !descRowsVisible;
|
||||
if (row._descRow.hidden !== descHidden) {
|
||||
row._descRow.hidden = descHidden;
|
||||
}
|
||||
}
|
||||
|
||||
if (show) {
|
||||
visibleCount++;
|
||||
@@ -262,7 +276,8 @@ function sortRows() {
|
||||
const frag = document.createDocumentFragment();
|
||||
arr.forEach(function (row) {
|
||||
frag.appendChild(row);
|
||||
frag.appendChild(row._expandRow);
|
||||
if (row._descRow) frag.appendChild(row._descRow);
|
||||
if (row._expandRow) frag.appendChild(row._expandRow);
|
||||
});
|
||||
tbody.appendChild(frag);
|
||||
applyFilters();
|
||||
@@ -291,7 +306,11 @@ if (tbody) {
|
||||
// Don't toggle if clicking a link or tag button
|
||||
if (e.target.closest("a") || e.target.closest(".tag")) return;
|
||||
|
||||
const row = e.target.closest("tr.row");
|
||||
let row = e.target.closest("tr.row");
|
||||
if (!row) {
|
||||
const descRow = e.target.closest("tr.desc-row");
|
||||
if (descRow) row = descRow.previousElementSibling;
|
||||
}
|
||||
if (!row) return;
|
||||
|
||||
const isOpen = row.classList.contains("open");
|
||||
@@ -328,7 +347,7 @@ tags.forEach(function (tag) {
|
||||
}
|
||||
applyFilters();
|
||||
} else if (url) {
|
||||
window.location.href = url;
|
||||
window.location.href = url + "#library-index";
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -788,6 +788,11 @@ kbd {
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
.row:has(+ .desc-row:not([hidden])) td {
|
||||
border-bottom-color: transparent;
|
||||
padding-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.row.open td {
|
||||
background: linear-gradient(180deg, var(--row-open-start), var(--row-open-end));
|
||||
border-bottom-color: transparent;
|
||||
@@ -919,14 +924,66 @@ th[data-sort].sort-asc::after {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.desc-row td {
|
||||
padding-top: 0;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
text-wrap: pretty;
|
||||
overflow-wrap: break-word;
|
||||
transition:
|
||||
background-color 180ms ease,
|
||||
border-color 180ms ease;
|
||||
}
|
||||
|
||||
.row:not(.open) + .desc-row td {
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.row + .desc-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row:not(.open):hover td,
|
||||
.row:not(.open):hover + .desc-row td {
|
||||
background: var(--row-hover);
|
||||
}
|
||||
|
||||
.row.open + .desc-row td {
|
||||
background: linear-gradient(180deg, var(--row-open-start), var(--row-open-end));
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.desc-text {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.desc-text a {
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.desc-text a:hover {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--accent-underline);
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
.expand-row {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row.open + .desc-row + .expand-row,
|
||||
.row.open + .expand-row {
|
||||
display: table-row;
|
||||
}
|
||||
|
||||
.desc-row:not([hidden]) + .expand-row .expand-desc {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.expand-row td {
|
||||
padding-top: 0.1rem;
|
||||
padding-bottom: 1.15rem;
|
||||
@@ -993,7 +1050,7 @@ th[data-sort].sort-asc::after {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-deep);
|
||||
padding: 0.14rem 0.48rem;
|
||||
font-size: 0.6rem;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
@@ -1426,8 +1483,8 @@ th[data-sort].sort-asc::after {
|
||||
|
||||
.footer-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.footer-brand {
|
||||
@@ -1561,7 +1618,7 @@ th[data-sort].sort-asc::after {
|
||||
|
||||
.tag {
|
||||
padding: 0.38rem 0.65rem;
|
||||
font-size: 0.65rem;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
gtag("js", new Date());
|
||||
gtag("config", "G-0LMLYE0HER");
|
||||
</script>
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<a href="#content" class="skip-link">Skip to content</a>
|
||||
@@ -69,7 +70,9 @@
|
||||
|
||||
<footer class="footer">
|
||||
<div class="footer-left">
|
||||
<span class="footer-brand">Awesome Python</span>
|
||||
<a href="/" class="footer-brand">Awesome Python</a>
|
||||
<span class="footer-sep">/</span>
|
||||
<a href="/sponsorship/">Sponsorship</a>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<span
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ category.name }} Python Libraries | Awesome Python{% endblock %}
|
||||
{% block description %}Explore {{ entries | length }} curated Python projects in {{ category.name }}. {% if category.description %}{{ category.description }}{% else %}Part of the Awesome Python catalog.{% endif %}{% endblock %}
|
||||
{% block title %}{{ category.name }} Python Libraries - Awesome Python{% endblock %}
|
||||
{% block description %}{{ category_description }}{% endblock %}
|
||||
{% block canonical_url %}{{ category_url }}{% endblock %}
|
||||
{% block alternate_links %}{% endblock %}
|
||||
{% block extra_head %}
|
||||
<script type="application/ld+json">{{ category_json_ld | safe }}</script>
|
||||
{% endblock %}
|
||||
{% block header %}
|
||||
<header class="category-hero">
|
||||
<div class="hero-sheen" aria-hidden="true"></div>
|
||||
@@ -169,9 +172,9 @@
|
||||
</td>
|
||||
<td class="col-cat">
|
||||
{% for subcat in entry.subcategories %}
|
||||
<button class="tag{% if subcat.url == current_path %} active{% endif %}" data-value="{{ subcat.value }}" data-url="{{ subcat.url }}">
|
||||
<a class="tag{% if subcat.url == current_path %} active{% endif %}" href="{{ subcat.url }}" data-value="{{ subcat.value }}" data-url="{{ subcat.url }}">
|
||||
{{ subcat.name }}
|
||||
</button>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% for cat in entry.categories %}
|
||||
<a
|
||||
@@ -184,33 +187,41 @@
|
||||
{% endfor %}
|
||||
{% if entry.groups %}
|
||||
{% set group_url = filter_urls[entry.groups[0]] %}
|
||||
<button
|
||||
<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] }}
|
||||
</button>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if entry.source_type == 'Built-in' %}
|
||||
<button
|
||||
<a
|
||||
class="tag tag-source{% if '/categories/built-in/' == current_path %} active{% endif %}"
|
||||
href="/categories/built-in/"
|
||||
data-value="Built-in"
|
||||
data-url="/categories/built-in/"
|
||||
>
|
||||
Built-in
|
||||
</button>
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="col-arrow"><span class="arrow">→</span></td>
|
||||
</tr>
|
||||
{% if entry.description %}
|
||||
<tr class="desc-row" aria-hidden="true">
|
||||
<td class="col-num"></td>
|
||||
<td colspan="5">
|
||||
<div class="desc-text">{{ entry.description | safe }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr class="expand-row" id="expand-{{ loop.index }}">
|
||||
<td></td>
|
||||
<td colspan="4">
|
||||
<div class="expand-content">
|
||||
{% if entry.description %}
|
||||
<div class="expand-desc">{{ entry.description | safe }}</div>
|
||||
{% endif %} {% if entry.also_see %}
|
||||
{% if entry.also_see %}
|
||||
<div class="expand-also-see">
|
||||
Also see: {% for see in entry.also_see %}<a
|
||||
href="{{ see.url }}"
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<script type="application/ld+json">{{ homepage_json_ld | safe }}</script>
|
||||
{% endblock %}
|
||||
{% block header %}
|
||||
<header class="hero">
|
||||
<div class="hero-sheen" aria-hidden="true"></div>
|
||||
@@ -220,9 +223,9 @@
|
||||
</td>
|
||||
<td class="col-cat">
|
||||
{% for subcat in entry.subcategories %}
|
||||
<button class="tag" data-value="{{ subcat.value }}" data-url="{{ subcat.url }}">
|
||||
<a class="tag" href="{{ subcat.url }}" data-value="{{ subcat.value }}" data-url="{{ subcat.url }}">
|
||||
{{ subcat.name }}
|
||||
</button>
|
||||
</a>
|
||||
{% endfor %} {% for cat in entry.categories %}
|
||||
<a
|
||||
class="tag"
|
||||
@@ -232,25 +235,35 @@
|
||||
>{{ cat }}</a
|
||||
>
|
||||
{% endfor %}
|
||||
<button
|
||||
<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] }}
|
||||
</button>
|
||||
</a>
|
||||
{% if entry.source_type == 'Built-in' %}
|
||||
<button
|
||||
<a
|
||||
class="tag tag-source"
|
||||
href="/categories/built-in/"
|
||||
data-value="Built-in"
|
||||
data-url="/categories/built-in/"
|
||||
>
|
||||
Built-in
|
||||
</button>
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="col-arrow"><span class="arrow">→</span></td>
|
||||
</tr>
|
||||
{% if entry.description %}
|
||||
<tr class="desc-row" aria-hidden="true" hidden>
|
||||
<td class="col-num"></td>
|
||||
<td colspan="5">
|
||||
<div class="desc-text">{{ entry.description | safe }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr class="expand-row" id="expand-{{ loop.index }}">
|
||||
<td></td>
|
||||
<td colspan="4">
|
||||
|
||||
@@ -268,8 +268,8 @@ 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.meta_by_name["description"] == "Explore 2 curated Python projects in Widgets. Widget libraries. Also see awesome-widgets."
|
||||
assert parser.title.strip() == "Widgets Python 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/"
|
||||
assert '<link rel="alternate" type="text/markdown" href="/index.md" />' not in category_html
|
||||
@@ -471,6 +471,143 @@ class TestBuild:
|
||||
assert 'id="hero-category-heading">Browse by category</h2>' in html
|
||||
assert 'class="hero-category-link" href="/categories/ai-and-agents/"' in html
|
||||
|
||||
def test_index_contains_homepage_json_ld(self, tmp_path):
|
||||
readme = (Path(__file__).parents[2] / "README.md").read_text(encoding="utf-8")
|
||||
(tmp_path / "README.md").write_text(readme, encoding="utf-8")
|
||||
self._copy_real_templates(tmp_path)
|
||||
|
||||
build(tmp_path)
|
||||
|
||||
parsed_groups = parse_readme(readme)
|
||||
categories = [cat for group in parsed_groups for cat in group["categories"]]
|
||||
entries = extract_entries(categories, parsed_groups)
|
||||
html = (tmp_path / "website" / "output" / "index.html").read_text(encoding="utf-8")
|
||||
|
||||
marker = '<script type="application/ld+json">'
|
||||
assert marker in html
|
||||
start = html.index(marker) + len(marker)
|
||||
end = html.index("</script>", start)
|
||||
block = html[start:end]
|
||||
assert "</script>" not in block
|
||||
data = json.loads(block)
|
||||
|
||||
assert data["@context"] == "https://schema.org"
|
||||
graph = {node["@type"]: node for node in data["@graph"]}
|
||||
assert set(graph) == {"WebSite", "CollectionPage"}
|
||||
assert graph["WebSite"]["url"] == "https://awesome-python.com/"
|
||||
assert graph["WebSite"]["name"] == "Awesome Python"
|
||||
assert graph["WebSite"]["@id"] == "https://awesome-python.com/#website"
|
||||
|
||||
collection = graph["CollectionPage"]
|
||||
assert collection["@id"] == "https://awesome-python.com/"
|
||||
assert collection["url"] == "https://awesome-python.com/"
|
||||
assert collection["isPartOf"] == {"@type": "WebSite", "@id": graph["WebSite"]["@id"]}
|
||||
expected_description = f"An opinionated guide to the best Python frameworks, libraries, and tools. Explore {len(entries)} curated projects across {len(categories)} categories, from AI and agents to data science and web development."
|
||||
assert collection["description"] == expected_description
|
||||
|
||||
item_list = collection["mainEntity"]
|
||||
assert item_list["@type"] == "ItemList"
|
||||
assert item_list["numberOfItems"] == len(entries)
|
||||
assert len(item_list["itemListElement"]) == len(entries)
|
||||
|
||||
positions = [item["position"] for item in item_list["itemListElement"]]
|
||||
assert positions == list(range(1, len(entries) + 1))
|
||||
assert all(item["@type"] == "ListItem" for item in item_list["itemListElement"])
|
||||
assert all(item["url"].startswith(("http://", "https://")) for item in item_list["itemListElement"])
|
||||
|
||||
rendered_names = {item["name"] for item in item_list["itemListElement"]}
|
||||
rendered_urls = {item["url"] for item in item_list["itemListElement"]}
|
||||
assert rendered_names == {e["name"] for e in entries}
|
||||
assert rendered_urls == {e["url"] for e in entries}
|
||||
|
||||
def test_category_page_contains_json_ld(self, tmp_path):
|
||||
readme = textwrap.dedent("""\
|
||||
# Awesome Python
|
||||
|
||||
Intro.
|
||||
|
||||
---
|
||||
|
||||
**Tools**
|
||||
|
||||
## Widgets
|
||||
|
||||
_Widget libraries._
|
||||
|
||||
- [w1](https://example.com/w1) - A widget.
|
||||
- [w2](https://github.com/owner/w2) - A starred widget.
|
||||
|
||||
# Contributing
|
||||
|
||||
Help!
|
||||
""")
|
||||
(tmp_path / "README.md").write_text(readme, encoding="utf-8")
|
||||
self._copy_real_templates(tmp_path)
|
||||
build(tmp_path)
|
||||
|
||||
category_html = (tmp_path / "website" / "output" / "categories" / "widgets" / "index.html").read_text(encoding="utf-8")
|
||||
marker = '<script type="application/ld+json">'
|
||||
assert marker in category_html
|
||||
start = category_html.index(marker) + len(marker)
|
||||
end = category_html.index("</script>", start)
|
||||
block = category_html[start:end]
|
||||
assert "</script>" not in block
|
||||
data = json.loads(block)
|
||||
|
||||
assert data["@context"] == "https://schema.org"
|
||||
graph = {node["@type"]: node for node in data["@graph"]}
|
||||
assert set(graph) == {"WebSite", "CollectionPage"}
|
||||
assert graph["WebSite"]["@id"] == "https://awesome-python.com/#website"
|
||||
collection = graph["CollectionPage"]
|
||||
assert collection["name"] == "Widgets Python 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."
|
||||
assert collection["isPartOf"] == {"@type": "WebSite", "@id": "https://awesome-python.com/#website"}
|
||||
|
||||
item_list = collection["mainEntity"]
|
||||
assert item_list["@type"] == "ItemList"
|
||||
assert item_list["numberOfItems"] == 2
|
||||
names = {item["name"] for item in item_list["itemListElement"]}
|
||||
urls = {item["url"] for item in item_list["itemListElement"]}
|
||||
assert names == {"w1", "w2"}
|
||||
assert urls == {"https://example.com/w1", "https://github.com/owner/w2"}
|
||||
positions = sorted(item["position"] for item in item_list["itemListElement"])
|
||||
assert positions == [1, 2]
|
||||
|
||||
def test_group_page_falls_back_to_default_description_in_json_ld(self, tmp_path):
|
||||
readme = textwrap.dedent("""\
|
||||
# T
|
||||
|
||||
---
|
||||
|
||||
**AI & ML**
|
||||
|
||||
## Deep Learning
|
||||
|
||||
- [dl1](https://example.com/dl1) - DL.
|
||||
|
||||
# Contributing
|
||||
|
||||
Done.
|
||||
""")
|
||||
self._copy_real_templates(tmp_path)
|
||||
(tmp_path / "README.md").write_text(readme, encoding="utf-8")
|
||||
build(tmp_path)
|
||||
|
||||
group_html = (tmp_path / "website" / "output" / "categories" / "ai-ml" / "index.html").read_text(encoding="utf-8")
|
||||
marker = '<script type="application/ld+json">'
|
||||
start = group_html.index(marker) + len(marker)
|
||||
end = group_html.index("</script>", start)
|
||||
data = json.loads(group_html[start:end])
|
||||
|
||||
graph = {node["@type"]: node for node in data["@graph"]}
|
||||
collection = graph["CollectionPage"]
|
||||
assert collection["name"] == "AI & ML Python 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."
|
||||
|
||||
def test_build_creates_subcategory_pages(self, tmp_path):
|
||||
readme = textwrap.dedent("""\
|
||||
# T
|
||||
|
||||
Reference in New Issue
Block a user