mirror of
https://github.com/vinta/awesome-python.git
synced 2026-11-08 02:15:12 -06:00
Merge branch 'feature/intro-pick-links'
This commit is contained in:
+77
-5
@@ -39,6 +39,30 @@ SPONSORSHIP_DESCRIPTION = "Sponsorship for awesome-python: tiers, audience, and
|
||||
|
||||
BUNDLED_PREFIX_RE = re.compile(r"^\(part of ")
|
||||
|
||||
# Category intros name projects as their docs do, README entries by their PyPI name
|
||||
PICK_ALIASES = {
|
||||
"Airflow": "apache-airflow",
|
||||
"Beautiful Soup": "beautifulsoup4",
|
||||
"Cacheops": "django-cacheops",
|
||||
"Chroma": "chromadb",
|
||||
"Coverage.py": "coverage",
|
||||
"Faiss": "faiss-cpu",
|
||||
"gRPC": "grpcio",
|
||||
"Grappelli": "django-grappelli",
|
||||
"Hydra": "hydra-core",
|
||||
"Ibis": "ibis-framework",
|
||||
"Material for MkDocs": "mkdocs-material",
|
||||
"OpenCV": "opencv-python",
|
||||
"Playwright": "playwright-python",
|
||||
"PyAV": "av",
|
||||
"python-oracledb": "oracledb",
|
||||
"Sherlock": "sherlock-project",
|
||||
"Silk": "django-silk",
|
||||
"Surprise": "scikit-surprise",
|
||||
"Unfold": "django-unfold",
|
||||
"Vega-Altair": "altair",
|
||||
}
|
||||
|
||||
SOURCE_TYPE_DOMAINS = {
|
||||
"docs.python.org": BUILTIN_FILTER,
|
||||
"gitlab.com": "GitLab",
|
||||
@@ -253,7 +277,52 @@ def subcategory_meta_description(name: str, parent_name: str, entry_names: Seque
|
||||
return f"The {name} picks in Awesome Python's {parent_name} list: {names}."
|
||||
|
||||
|
||||
def load_category_intro(path: Path) -> tuple[str, str, str, str]:
|
||||
def entry_name_key(name: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]", "", name.lower())
|
||||
|
||||
|
||||
def link_pick_names(pick: str, slugs_by_key: dict[str, str]) -> str:
|
||||
"""Link entry names in a "How to choose" pick to their rows, preferring the longest match.
|
||||
|
||||
Names match ignoring case and punctuation, so the README's PyPI-style
|
||||
"django-ninja" matches the intro's "Django Ninja".
|
||||
"""
|
||||
words = list(re.finditer(r"`[^`]*`|[^\s,]+", pick))
|
||||
parts, pos, i = [], 0, 0
|
||||
while i < len(words):
|
||||
for j in range(min(len(words), i + 4), i, -1):
|
||||
start, end = words[i].start(), words[j - 1].end()
|
||||
span = pick[start:end]
|
||||
slug = slugs_by_key.get(entry_name_key(span))
|
||||
# Code spans and dangling punctuation like "The Algorithms -" stay outside links
|
||||
if slug and span[0].isalnum() and span[-1].isalnum() and "," not in span and "`" not in span:
|
||||
parts.append(f"{pick[pos:start]}[{span}](#entry-{slug})")
|
||||
pos, i = end, j
|
||||
break
|
||||
else:
|
||||
i += 1
|
||||
return "".join(parts) + pick[pos:]
|
||||
|
||||
|
||||
def link_intro_picks(text: str, entry_names: Sequence[str]) -> str:
|
||||
"""Link the picks in an intro's "How to choose:" list to their rows in the table below."""
|
||||
head, marker, rest = text.partition("\nHow to choose:\n\n")
|
||||
if not marker:
|
||||
return text
|
||||
slugs_by_key = {entry_name_key(name): slugify(name) for name in entry_names}
|
||||
for alias, name in PICK_ALIASES.items():
|
||||
if entry_name_key(name) in slugs_by_key:
|
||||
slugs_by_key[entry_name_key(alias)] = slugs_by_key[entry_name_key(name)]
|
||||
lines = rest.split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
if not line.startswith("- "):
|
||||
break
|
||||
situation, _, pick = line.rpartition(": ")
|
||||
lines[i] = f"{situation}: {link_pick_names(pick, slugs_by_key)}"
|
||||
return head + marker + "\n".join(lines)
|
||||
|
||||
|
||||
def load_category_intro(path: Path, entry_names: Sequence[str] = ()) -> tuple[str, str, str, str]:
|
||||
"""Render a category intro file to HTML, split at the end of its "How to choose:" list.
|
||||
|
||||
Returns the part shown above the table, the guide shown below it, and the
|
||||
@@ -264,10 +333,10 @@ def load_category_intro(path: Path) -> tuple[str, str, str, str]:
|
||||
if not path.exists():
|
||||
return "", "", "", ""
|
||||
md = MarkdownIt("commonmark")
|
||||
tokens = md.parse(path.read_text(encoding="utf-8"))
|
||||
tokens = md.parse(link_intro_picks(path.read_text(encoding="utf-8"), entry_names))
|
||||
for token in tokens:
|
||||
for child in token.children or []:
|
||||
if child.type == "link_open":
|
||||
if child.type == "link_open" and not str(child.attrGet("href")).startswith("#"):
|
||||
child.attrSet("target", "_blank")
|
||||
child.attrSet("rel", "noopener")
|
||||
lead = next(node for node in SyntaxTreeNode(tokens).children if node.type == "paragraph")
|
||||
@@ -731,6 +800,7 @@ def build(repo_root: Path) -> None:
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
env.filters["slugify"] = slugify
|
||||
site_dir = website / "output"
|
||||
if site_dir.exists():
|
||||
shutil.rmtree(site_dir)
|
||||
@@ -776,7 +846,10 @@ def build(repo_root: Path) -> 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, guide_html, intro_lead, _ = load_category_intro(website / "data" / "category_intros" / f"{current_path.removeprefix('/categories/').strip('/')}.md")
|
||||
page_entries = [entry for group in entry_groups for entry in group["entries"]] if entry_groups else entries
|
||||
intro_html, guide_html, intro_lead, _ = load_category_intro(
|
||||
website / "data" / "category_intros" / f"{current_path.removeprefix('/categories/').strip('/')}.md", [e["name"] for e in page_entries]
|
||||
)
|
||||
if intro_lead:
|
||||
category_description = intro_lead
|
||||
elif parent_name:
|
||||
@@ -787,7 +860,6 @@ def build(repo_root: Path) -> None:
|
||||
if parent_category:
|
||||
breadcrumbs.append((parent_category["name"], category_public_url(parent_category)))
|
||||
breadcrumbs.append((category["name"], category_url))
|
||||
page_entries = [entry for group in entry_groups for entry in group["entries"]] if entry_groups else entries
|
||||
category_json_ld = json.dumps(
|
||||
build_category_json_ld(category_title.removesuffix(" - Awesome Python"), category_url, category_description, page_entries, breadcrumbs),
|
||||
ensure_ascii=False,
|
||||
|
||||
+20
-2
@@ -367,12 +367,25 @@ sortHeaders.forEach(function (th) {
|
||||
});
|
||||
});
|
||||
|
||||
function openEntryRow(row) {
|
||||
if (row.hidden && searchInput) {
|
||||
searchInput.value = "";
|
||||
applyFilters();
|
||||
}
|
||||
row.classList.add("open");
|
||||
row.setAttribute("aria-expanded", "true");
|
||||
}
|
||||
|
||||
// Group headings are hidden while sorted flat or filtered out by search, so a link to one must restore them first
|
||||
document.addEventListener("click", function (e) {
|
||||
const link = e.target.closest('a[href*="#"]');
|
||||
if (!link || link.pathname !== location.pathname) return;
|
||||
const heading = document.getElementById(decodeURIComponent(link.hash.slice(1)));
|
||||
const groupRow = heading ? heading.closest(".group-row") : null;
|
||||
const target = document.getElementById(decodeURIComponent(link.hash.slice(1)));
|
||||
if (target && target.matches("tr.row")) {
|
||||
openEntryRow(target);
|
||||
return;
|
||||
}
|
||||
const groupRow = target ? target.closest(".group-row") : null;
|
||||
if (!groupRow) return;
|
||||
if (activeSort.col !== "editorial") {
|
||||
activeSort = defaultSort;
|
||||
@@ -385,6 +398,11 @@ document.addEventListener("click", function (e) {
|
||||
}
|
||||
});
|
||||
|
||||
if (location.hash) {
|
||||
const linkedRow = document.getElementById(decodeURIComponent(location.hash.slice(1)));
|
||||
if (linkedRow && linkedRow.matches("tr.row")) openEntryRow(linkedRow);
|
||||
}
|
||||
|
||||
if (searchInput) {
|
||||
let searchTimer;
|
||||
searchInput.addEventListener("input", function () {
|
||||
|
||||
@@ -837,6 +837,7 @@ kbd {
|
||||
|
||||
.row {
|
||||
cursor: pointer;
|
||||
scroll-margin-top: 4.5rem;
|
||||
}
|
||||
|
||||
.group-row th {
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
{% set section_path = "/categories/" ~ parent_category.slug ~ "/" if parent_category else current_path %}
|
||||
<tr
|
||||
class="row"
|
||||
{% if not group_categories %}id="entry-{{ entry.name | slugify }}"{% endif %}
|
||||
tabindex="0"
|
||||
aria-expanded="false"
|
||||
aria-controls="expand-{{ index }}"
|
||||
|
||||
@@ -1100,7 +1100,7 @@ class TestBuild:
|
||||
parser.feed(category_html)
|
||||
assert parser.meta_by_name["description"] == "Use w1 for most apps."
|
||||
assert '<div class="category-intro"><p>Use <code>w1</code> for most apps.</p>' in category_html
|
||||
assert "<li>Small apps: w1</li>" in category_html
|
||||
assert '<li>Small apps: <a href="#entry-w1">w1</a></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):
|
||||
@@ -1113,7 +1113,7 @@ class TestBuild:
|
||||
|
||||
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 '<li>Small apps: <a href="#entry-w1">w1</a></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
|
||||
@@ -1599,3 +1599,20 @@ class TestLoadCategoryIntro:
|
||||
|
||||
def test_returns_empty_strings_without_intro_file(self, tmp_path):
|
||||
assert load_category_intro(tmp_path / "missing.md") == ("", "", "", "")
|
||||
|
||||
def test_links_picks_to_entry_rows(self, tmp_path):
|
||||
path = tmp_path / "widgets.md"
|
||||
path.write_text(
|
||||
"Use w1.\n\nHow to choose:\n\n- Small apps: Django Ninja, or `uv tool` on uv\n- Big apps: Django\n- Old apps: The Algorithms - Python\n\nSee [w1](https://w1.example).\n", encoding="utf-8"
|
||||
)
|
||||
intro_html, guide_html, _, _ = load_category_intro(path, ["django-ninja", "Django", "uv", "TheAlgorithms"])
|
||||
assert '<li>Small apps: <a href="#entry-django-ninja">Django Ninja</a>, or <code>uv tool</code> on <a href="#entry-uv">uv</a></li>' in intro_html
|
||||
assert '<li>Big apps: <a href="#entry-django">Django</a></li>' in intro_html
|
||||
assert '<li>Old apps: <a href="#entry-thealgorithms">The Algorithms</a> - Python</li>' in intro_html
|
||||
assert '<a href="https://w1.example" target="_blank" rel="noopener">w1</a>' in guide_html
|
||||
|
||||
def test_links_pick_aliases_to_entry_rows(self, tmp_path):
|
||||
path = tmp_path / "widgets.md"
|
||||
path.write_text("Use w1.\n\nHow to choose:\n\n- Vision: OpenCV\n", encoding="utf-8")
|
||||
intro_html, _, _, _ = load_category_intro(path, ["opencv-python"])
|
||||
assert '<li>Vision: <a href="#entry-opencv-python">OpenCV</a></li>' in intro_html
|
||||
|
||||
Reference in New Issue
Block a user