mirror of
https://github.com/vinta/awesome-python.git
synced 2026-03-23 05:03:45 +08:00
feat: migrate README parser to markdown-it-py and refresh website
Switch readme_parser.py from regex-based parsing to markdown-it-py for more robust and maintainable Markdown AST traversal. Update build pipeline, templates, styles, and JS to support the new parser output. Refresh GitHub stars data and update tests to match new parser behavior. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
3
Makefile
3
Makefile
@@ -7,6 +7,9 @@ install:
|
||||
fetch_stats:
|
||||
uv run python website/fetch_github_stars.py
|
||||
|
||||
test:
|
||||
uv run pytest website/tests/ -v
|
||||
|
||||
build:
|
||||
uv run python website/build.py
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ dev = [
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["website/tests"]
|
||||
pythonpath = ["website"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 200
|
||||
|
||||
@@ -75,10 +75,11 @@ def group_categories(
|
||||
"""Organize categories and resources into thematic section groups."""
|
||||
cat_by_name = {c["name"]: c for c in categories}
|
||||
groups = []
|
||||
grouped_names: set[str] = set()
|
||||
|
||||
for group_name, cat_names in SECTION_GROUPS:
|
||||
grouped_names.update(cat_names)
|
||||
if group_name == "Resources":
|
||||
# Resources group uses parsed resources directly
|
||||
group_cats = list(resources)
|
||||
else:
|
||||
group_cats = [cat_by_name[n] for n in cat_names if n in cat_by_name]
|
||||
@@ -91,9 +92,6 @@ def group_categories(
|
||||
})
|
||||
|
||||
# Any categories not in a group go into "Other"
|
||||
grouped_names = set()
|
||||
for _, cat_names in SECTION_GROUPS:
|
||||
grouped_names.update(cat_names)
|
||||
ungrouped = [c for c in categories if c["name"] not in grouped_names]
|
||||
if ungrouped:
|
||||
groups.append({
|
||||
@@ -113,13 +111,13 @@ class Entry(TypedDict):
|
||||
group: str
|
||||
stars: int | None
|
||||
owner: str | None
|
||||
pushed_at: str | None
|
||||
last_commit_at: str | None
|
||||
|
||||
|
||||
class StarData(TypedDict):
|
||||
stars: int
|
||||
owner: str
|
||||
pushed_at: str
|
||||
last_commit_at: str
|
||||
fetched_at: str
|
||||
|
||||
|
||||
@@ -177,7 +175,7 @@ def extract_entries(
|
||||
"group": group_name,
|
||||
"stars": None,
|
||||
"owner": None,
|
||||
"pushed_at": None,
|
||||
"last_commit_at": None,
|
||||
"also_see": entry["also_see"],
|
||||
})
|
||||
return entries
|
||||
@@ -210,7 +208,7 @@ def build(repo_root: str) -> None:
|
||||
sd = stars_data[repo_key]
|
||||
entry["stars"] = sd["stars"]
|
||||
entry["owner"] = sd["owner"]
|
||||
entry["pushed_at"] = sd.get("pushed_at", "")
|
||||
entry["last_commit_at"] = sd.get("last_commit_at", "")
|
||||
|
||||
entries = sort_entries(entries)
|
||||
|
||||
@@ -220,7 +218,9 @@ def build(repo_root: str) -> None:
|
||||
)
|
||||
|
||||
site_dir = website / "output"
|
||||
site_dir.mkdir(parents=True, exist_ok=True)
|
||||
if site_dir.exists():
|
||||
shutil.rmtree(site_dir)
|
||||
site_dir.mkdir(parents=True)
|
||||
|
||||
tpl_index = env.get_template("index.html")
|
||||
(site_dir / "index.html").write_text(
|
||||
@@ -240,7 +240,6 @@ def build(repo_root: str) -> None:
|
||||
static_dst = site_dir / "static"
|
||||
if static_src.exists():
|
||||
shutil.copytree(static_src, static_dst, dirs_exist_ok=True)
|
||||
(site_dir / "CNAME").write_text("awesome-python.com\n", encoding="utf-8")
|
||||
|
||||
print(f"Built single page with {len(categories)} categories + {len(resources)} resources")
|
||||
print(f"Total entries: {total_entries}")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ DATA_DIR = Path(__file__).parent / "data"
|
||||
CACHE_FILE = DATA_DIR / "github_stars.json"
|
||||
README_PATH = Path(__file__).parent.parent / "README.md"
|
||||
GRAPHQL_URL = "https://api.github.com/graphql"
|
||||
BATCH_SIZE = 100
|
||||
BATCH_SIZE = 50
|
||||
|
||||
|
||||
def extract_github_repos(text: str) -> set[str]:
|
||||
@@ -50,7 +50,7 @@ def build_graphql_query(repos: list[str]) -> str:
|
||||
continue
|
||||
parts.append(
|
||||
f'repo_{i}: repository(owner: "{owner}", name: "{name}") '
|
||||
f"{{ stargazerCount pushedAt owner {{ login }} }}"
|
||||
f"{{ stargazerCount owner {{ login }} defaultBranchRef {{ target {{ ... on Commit {{ committedDate }} }} }} }}"
|
||||
)
|
||||
if not parts:
|
||||
return ""
|
||||
@@ -67,10 +67,12 @@ def parse_graphql_response(
|
||||
node = data.get(f"repo_{i}")
|
||||
if node is None:
|
||||
continue
|
||||
default_branch = node.get("defaultBranchRef") or {}
|
||||
target = default_branch.get("target") or {}
|
||||
result[repo] = {
|
||||
"stars": node.get("stargazerCount", 0),
|
||||
"owner": node.get("owner", {}).get("login", ""),
|
||||
"pushed_at": node.get("pushedAt", ""),
|
||||
"last_commit_at": target.get("committedDate", ""),
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -162,7 +164,7 @@ def main() -> None:
|
||||
cache[repo] = {
|
||||
"stars": results[repo]["stars"],
|
||||
"owner": results[repo]["owner"],
|
||||
"pushed_at": results[repo]["pushed_at"],
|
||||
"last_commit_at": results[repo]["last_commit_at"],
|
||||
"fetched_at": now_iso,
|
||||
}
|
||||
fetched_count += 1
|
||||
|
||||
@@ -26,7 +26,6 @@ class ParsedSection(TypedDict):
|
||||
name: str
|
||||
slug: str
|
||||
description: str # plain text, links resolved to text
|
||||
content: str # raw markdown (backward compat)
|
||||
entries: list[ParsedEntry]
|
||||
entry_count: int
|
||||
preview: str
|
||||
@@ -123,37 +122,25 @@ def _extract_description(nodes: list[SyntaxTreeNode]) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _nodes_to_raw_markdown(nodes: list[SyntaxTreeNode], source_lines: list[str]) -> str:
|
||||
"""Extract raw markdown text for AST nodes using source line mappings."""
|
||||
if not nodes:
|
||||
return ""
|
||||
start_line = None
|
||||
end_line = None
|
||||
for node in nodes:
|
||||
node_map = node.map
|
||||
if node_map is not None:
|
||||
if start_line is None or node_map[0] < start_line:
|
||||
start_line = node_map[0]
|
||||
if end_line is None or node_map[1] > end_line:
|
||||
end_line = node_map[1]
|
||||
if start_line is None:
|
||||
return ""
|
||||
return "\n".join(source_lines[start_line:end_line]).strip()
|
||||
|
||||
|
||||
# --- Entry extraction --------------------------------------------------------
|
||||
|
||||
_DESC_SEP_RE = re.compile(r"^\s*[-\u2013\u2014]\s*")
|
||||
|
||||
|
||||
def _find_child(node: SyntaxTreeNode, child_type: str) -> SyntaxTreeNode | None:
|
||||
"""Find first direct child of a given type."""
|
||||
for child in node.children:
|
||||
if child.type == child_type:
|
||||
return child
|
||||
return None
|
||||
|
||||
|
||||
def _find_inline(node: SyntaxTreeNode) -> SyntaxTreeNode | None:
|
||||
"""Find the inline node in a list_item's paragraph."""
|
||||
for child in node.children:
|
||||
if child.type == "paragraph":
|
||||
for sub in child.children:
|
||||
if sub.type == "inline":
|
||||
return sub
|
||||
return None
|
||||
para = _find_child(node, "paragraph")
|
||||
if para is None:
|
||||
return None
|
||||
return _find_child(para, "inline")
|
||||
|
||||
|
||||
def _find_first_link(inline: SyntaxTreeNode) -> SyntaxTreeNode | None:
|
||||
@@ -164,12 +151,9 @@ def _find_first_link(inline: SyntaxTreeNode) -> SyntaxTreeNode | None:
|
||||
return None
|
||||
|
||||
|
||||
def _find_child(node: SyntaxTreeNode, child_type: str) -> SyntaxTreeNode | None:
|
||||
"""Find first direct child of a given type."""
|
||||
for child in node.children:
|
||||
if child.type == child_type:
|
||||
return child
|
||||
return None
|
||||
def _is_leading_link(inline: SyntaxTreeNode, link: SyntaxTreeNode) -> bool:
|
||||
"""Check if the link is the first child of inline (a real entry, not a subcategory label)."""
|
||||
return bool(inline.children) and inline.children[0] is link
|
||||
|
||||
|
||||
def _extract_description_html(inline: SyntaxTreeNode, first_link: SyntaxTreeNode) -> str:
|
||||
@@ -208,8 +192,8 @@ def _parse_list_entries(bullet_list: SyntaxTreeNode) -> list[ParsedEntry]:
|
||||
|
||||
first_link = _find_first_link(inline)
|
||||
|
||||
if first_link is None:
|
||||
# Subcategory label — recurse into nested bullet_list
|
||||
if first_link is None or not _is_leading_link(inline, first_link):
|
||||
# Subcategory label (plain text or text-before-link) — recurse into nested list
|
||||
nested = _find_child(list_item, "bullet_list")
|
||||
if nested:
|
||||
entries.extend(_parse_list_entries(nested))
|
||||
@@ -276,8 +260,8 @@ def _render_bullet_list_html(
|
||||
|
||||
first_link = _find_first_link(inline)
|
||||
|
||||
if first_link is None:
|
||||
# Subcategory label
|
||||
if first_link is None or not _is_leading_link(inline, first_link):
|
||||
# Subcategory label (plain text or text-before-link)
|
||||
label = str(escape(render_inline_text(inline.children)))
|
||||
out.append(f'<div class="subcat">{label}</div>')
|
||||
nested = _find_child(list_item, "bullet_list")
|
||||
@@ -323,7 +307,6 @@ def _render_section_html(content_nodes: list[SyntaxTreeNode]) -> str:
|
||||
|
||||
def _group_by_h2(
|
||||
nodes: list[SyntaxTreeNode],
|
||||
source_lines: list[str],
|
||||
) -> list[ParsedSection]:
|
||||
"""Group AST nodes into sections by h2 headings."""
|
||||
sections: list[ParsedSection] = []
|
||||
@@ -336,7 +319,6 @@ def _group_by_h2(
|
||||
return
|
||||
desc = _extract_description(current_body)
|
||||
content_nodes = current_body[1:] if desc else current_body
|
||||
content = _nodes_to_raw_markdown(content_nodes, source_lines)
|
||||
entries = _parse_section_entries(content_nodes)
|
||||
entry_count = len(entries) + sum(len(e["also_see"]) for e in entries)
|
||||
preview = ", ".join(e["name"] for e in entries[:4])
|
||||
@@ -346,7 +328,6 @@ def _group_by_h2(
|
||||
name=current_name,
|
||||
slug=slugify(current_name),
|
||||
description=desc,
|
||||
content=content,
|
||||
entries=entries,
|
||||
entry_count=entry_count,
|
||||
preview=preview,
|
||||
@@ -374,7 +355,6 @@ def parse_readme(text: str) -> tuple[list[ParsedSection], list[ParsedSection]]:
|
||||
md = MarkdownIt("commonmark")
|
||||
tokens = md.parse(text)
|
||||
root = SyntaxTreeNode(tokens)
|
||||
source_lines = text.split("\n")
|
||||
children = root.children
|
||||
|
||||
# Find thematic break (---), # Resources, and # Contributing in one pass
|
||||
@@ -402,7 +382,7 @@ def parse_readme(text: str) -> tuple[list[ParsedSection], list[ParsedSection]]:
|
||||
res_end = contributing_idx or len(children)
|
||||
res_nodes = children[resources_idx + 1 : res_end]
|
||||
|
||||
categories = _group_by_h2(cat_nodes, source_lines)
|
||||
resources = _group_by_h2(res_nodes, source_lines)
|
||||
categories = _group_by_h2(cat_nodes)
|
||||
resources = _group_by_h2(res_nodes)
|
||||
|
||||
return categories, resources
|
||||
|
||||
@@ -1,15 +1,44 @@
|
||||
// State
|
||||
var activeFilter = null; // { type: "cat"|"group", value: "..." }
|
||||
var activeSort = { col: 'stars', order: 'desc' };
|
||||
var searchInput = document.querySelector('.search');
|
||||
var filterBar = document.querySelector('.filter-bar');
|
||||
var filterValue = document.querySelector('.filter-value');
|
||||
var filterClear = document.querySelector('.filter-clear');
|
||||
var noResults = document.querySelector('.no-results');
|
||||
var countEl = document.querySelector('.count');
|
||||
var rows = document.querySelectorAll('.table tbody tr.row');
|
||||
var tags = document.querySelectorAll('.tag');
|
||||
var tbody = document.querySelector('.table tbody');
|
||||
|
||||
// Relative time formatting
|
||||
function relativeTime(isoStr) {
|
||||
var date = new Date(isoStr);
|
||||
var now = new Date();
|
||||
var diffMs = now - date;
|
||||
var diffHours = Math.floor(diffMs / 3600000);
|
||||
var diffDays = Math.floor(diffMs / 86400000);
|
||||
if (diffHours < 1) return 'just now';
|
||||
if (diffHours < 24) return diffHours === 1 ? '1 hour ago' : diffHours + ' hours ago';
|
||||
if (diffDays === 1) return 'yesterday';
|
||||
if (diffDays < 30) return diffDays + ' days ago';
|
||||
var diffMonths = Math.floor(diffDays / 30);
|
||||
if (diffMonths < 12) return diffMonths === 1 ? '1 month ago' : diffMonths + ' months ago';
|
||||
var diffYears = Math.floor(diffDays / 365);
|
||||
return diffYears === 1 ? '1 year ago' : diffYears + ' years ago';
|
||||
}
|
||||
|
||||
// Format all commit date cells
|
||||
document.querySelectorAll('.col-commit[data-commit]').forEach(function (td) {
|
||||
var time = td.querySelector('time');
|
||||
if (time) time.textContent = relativeTime(td.dataset.commit);
|
||||
});
|
||||
|
||||
// Store original row order for sort reset
|
||||
rows.forEach(function (row, i) {
|
||||
row._origIndex = i;
|
||||
row._expandRow = row.nextElementSibling;
|
||||
});
|
||||
|
||||
function collapseAll() {
|
||||
var openRows = document.querySelectorAll('.table tbody tr.row.open');
|
||||
openRows.forEach(function (row) {
|
||||
@@ -46,16 +75,18 @@ function applyFilters() {
|
||||
show = row._searchText.includes(query);
|
||||
}
|
||||
|
||||
row.hidden = !show;
|
||||
if (row.hidden !== !show) row.hidden = !show;
|
||||
|
||||
if (show) {
|
||||
visibleCount++;
|
||||
row.querySelector('.col-num').textContent = String(visibleCount);
|
||||
var numCell = row.cells[0];
|
||||
if (numCell.textContent !== String(visibleCount)) {
|
||||
numCell.textContent = String(visibleCount);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (noResults) noResults.hidden = visibleCount > 0;
|
||||
if (countEl) countEl.textContent = visibleCount;
|
||||
|
||||
// Update tag highlights
|
||||
tags.forEach(function (tag) {
|
||||
@@ -74,6 +105,76 @@ function applyFilters() {
|
||||
filterBar.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
updateURL();
|
||||
}
|
||||
|
||||
function updateURL() {
|
||||
var params = new URLSearchParams();
|
||||
var query = searchInput ? searchInput.value.trim() : '';
|
||||
if (query) params.set('q', query);
|
||||
if (activeFilter) {
|
||||
params.set(activeFilter.type === 'cat' ? 'category' : 'group', activeFilter.value);
|
||||
}
|
||||
if (activeSort.col !== 'stars' || activeSort.order !== 'desc') {
|
||||
params.set('sort', activeSort.col);
|
||||
params.set('order', activeSort.order);
|
||||
}
|
||||
var qs = params.toString();
|
||||
history.replaceState(null, '', qs ? '?' + qs : location.pathname);
|
||||
}
|
||||
|
||||
function getSortValue(row, col) {
|
||||
if (col === 'name') {
|
||||
return row.querySelector('.col-name a').textContent.trim().toLowerCase();
|
||||
}
|
||||
if (col === 'stars') {
|
||||
var text = row.querySelector('.col-stars').textContent.trim().replace(/,/g, '');
|
||||
var num = parseInt(text, 10);
|
||||
return isNaN(num) ? -1 : num;
|
||||
}
|
||||
if (col === 'commit-time') {
|
||||
var attr = row.querySelector('.col-commit').getAttribute('data-commit');
|
||||
return attr ? new Date(attr).getTime() : 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function sortRows() {
|
||||
var arr = Array.prototype.slice.call(rows);
|
||||
if (activeSort) {
|
||||
arr.sort(function (a, b) {
|
||||
var aVal = getSortValue(a, activeSort.col);
|
||||
var bVal = getSortValue(b, activeSort.col);
|
||||
if (activeSort.col === 'name') {
|
||||
var cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
|
||||
if (cmp === 0) return a._origIndex - b._origIndex;
|
||||
return activeSort.order === 'desc' ? -cmp : cmp;
|
||||
}
|
||||
if (aVal <= 0 && bVal <= 0) return a._origIndex - b._origIndex;
|
||||
if (aVal <= 0) return 1;
|
||||
if (bVal <= 0) return -1;
|
||||
var cmp = aVal - bVal;
|
||||
if (cmp === 0) return a._origIndex - b._origIndex;
|
||||
return activeSort.order === 'desc' ? -cmp : cmp;
|
||||
});
|
||||
} else {
|
||||
arr.sort(function (a, b) { return a._origIndex - b._origIndex; });
|
||||
}
|
||||
arr.forEach(function (row) {
|
||||
tbody.appendChild(row);
|
||||
tbody.appendChild(row._expandRow);
|
||||
});
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function updateSortIndicators() {
|
||||
document.querySelectorAll('th[data-sort]').forEach(function (th) {
|
||||
th.classList.remove('sort-asc', 'sort-desc');
|
||||
if (activeSort && th.dataset.sort === activeSort.col) {
|
||||
th.classList.add('sort-' + activeSort.order);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Expand/collapse: event delegation on tbody
|
||||
@@ -130,6 +231,23 @@ if (filterClear) {
|
||||
});
|
||||
}
|
||||
|
||||
// Column sorting
|
||||
document.querySelectorAll('th[data-sort]').forEach(function (th) {
|
||||
th.addEventListener('click', function () {
|
||||
var col = th.dataset.sort;
|
||||
var defaultOrder = col === 'name' ? 'asc' : 'desc';
|
||||
var altOrder = defaultOrder === 'asc' ? 'desc' : 'asc';
|
||||
if (activeSort && activeSort.col === col) {
|
||||
if (activeSort.order === defaultOrder) activeSort = { col: col, order: altOrder };
|
||||
else activeSort = { col: 'stars', order: 'desc' };
|
||||
} else {
|
||||
activeSort = { col: col, order: defaultOrder };
|
||||
}
|
||||
sortRows();
|
||||
updateSortIndicators();
|
||||
});
|
||||
});
|
||||
|
||||
// Search input
|
||||
if (searchInput) {
|
||||
var searchTimer;
|
||||
@@ -152,3 +270,23 @@ if (searchInput) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Restore state from URL
|
||||
(function () {
|
||||
var params = new URLSearchParams(location.search);
|
||||
var q = params.get('q');
|
||||
var cat = params.get('category');
|
||||
var group = params.get('group');
|
||||
var sort = params.get('sort');
|
||||
var order = params.get('order');
|
||||
if (q && searchInput) searchInput.value = q;
|
||||
if (cat) activeFilter = { type: 'cat', value: cat };
|
||||
else if (group) activeFilter = { type: 'group', value: group };
|
||||
if ((sort === 'name' || sort === 'stars' || sort === 'commit-time') && (order === 'desc' || order === 'asc')) {
|
||||
activeSort = { col: sort, order: order };
|
||||
}
|
||||
if (q || cat || group || sort) {
|
||||
sortRows();
|
||||
}
|
||||
updateSortIndicators();
|
||||
})();
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
--accent-light: oklch(97% 0.015 240);
|
||||
--highlight: oklch(93% 0.10 90);
|
||||
--highlight-text: oklch(35% 0.10 90);
|
||||
--tag-text: oklch(45% 0.06 240);
|
||||
--tag-hover-bg: oklch(93% 0.025 240);
|
||||
}
|
||||
|
||||
html { font-size: 16px; }
|
||||
@@ -65,8 +67,10 @@ a:hover { color: var(--accent-hover); text-decoration: underline; }
|
||||
|
||||
.hero-main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.hero-submit {
|
||||
@@ -78,14 +82,21 @@ a:hover { color: var(--accent-hover); text-decoration: underline; }
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: border-color 0.2s, background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.hero-submit:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.hero-submit:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(2rem, 5vw, 3rem);
|
||||
@@ -144,6 +155,7 @@ a:hover { color: var(--accent-hover); text-decoration: underline; }
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text);
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.search::placeholder { color: var(--text-muted); }
|
||||
@@ -174,11 +186,12 @@ a:hover { color: var(--accent-hover); text-decoration: underline; }
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.15rem 0.5rem;
|
||||
padding: 0.35rem 0.65rem;
|
||||
font-family: inherit;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.filter-clear:hover {
|
||||
@@ -186,14 +199,11 @@ a:hover { color: var(--accent-hover); text-decoration: underline; }
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.stats {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
.filter-clear:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.stats strong { color: var(--text-secondary); }
|
||||
|
||||
/* === Table === */
|
||||
.table-wrap {
|
||||
width: 100%;
|
||||
@@ -241,6 +251,7 @@ a:hover { color: var(--accent-hover); text-decoration: underline; }
|
||||
padding: 0.7rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.table tbody tr.row:not(.open):hover td {
|
||||
@@ -258,9 +269,7 @@ a:hover { color: var(--accent-hover); text-decoration: underline; }
|
||||
|
||||
.col-name {
|
||||
width: 35%;
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.col-name > a {
|
||||
@@ -271,12 +280,47 @@ a:hover { color: var(--accent-hover); text-decoration: underline; }
|
||||
|
||||
.col-name > a:hover { text-decoration: underline; color: var(--accent-hover); }
|
||||
|
||||
/* === Sortable Headers === */
|
||||
th[data-sort] {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
th[data-sort]:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
th[data-sort]::after {
|
||||
content: " ▼";
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
th[data-sort="name"]::after {
|
||||
content: " ▲";
|
||||
}
|
||||
|
||||
th[data-sort]:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
th[data-sort].sort-desc::after {
|
||||
content: " ▼";
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
th[data-sort].sort-asc::after {
|
||||
content: " ▲";
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* === Stars Column === */
|
||||
.col-stars {
|
||||
width: 5rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
color: var(--text-secondary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* === Arrow Column === */
|
||||
@@ -299,6 +343,12 @@ a:hover { color: var(--accent-hover); text-decoration: underline; }
|
||||
/* === Row Click === */
|
||||
.row { cursor: pointer; }
|
||||
|
||||
.row:focus-visible td {
|
||||
outline: none;
|
||||
background: var(--bg-hover);
|
||||
box-shadow: inset 2px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
/* === Expand Row === */
|
||||
.expand-row {
|
||||
display: none;
|
||||
@@ -320,21 +370,33 @@ a:hover { color: var(--accent-hover); text-decoration: underline; }
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@keyframes expand-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.expand-content {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
animation: expand-in 0.2s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
}
|
||||
|
||||
.expand-tags-mobile {
|
||||
display: none;
|
||||
.expand-tags {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.expand-tag {
|
||||
font-size: var(--text-xs);
|
||||
color: oklch(45% 0.06 240);
|
||||
color: var(--tag-text);
|
||||
background: var(--bg);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
@@ -376,35 +438,63 @@ a:hover { color: var(--accent-hover); text-decoration: underline; }
|
||||
color: var(--border);
|
||||
}
|
||||
|
||||
.col-cat, .col-group {
|
||||
.col-cat {
|
||||
width: 13%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* === Last Commit Column === */
|
||||
.col-commit {
|
||||
width: 9rem;
|
||||
white-space: nowrap;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* === Tags === */
|
||||
.tag {
|
||||
position: relative;
|
||||
background: var(--accent-light);
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
font-size: var(--text-xs);
|
||||
color: oklch(45% 0.06 240);
|
||||
color: var(--tag-text);
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.35rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
/* Expand touch target to 44x44px minimum */
|
||||
.tag::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -0.5rem -0.25rem;
|
||||
}
|
||||
|
||||
.tag:hover {
|
||||
background: var(--accent-light);
|
||||
background: var(--tag-hover-bg);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tag:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.tag.active {
|
||||
background: var(--highlight);
|
||||
color: var(--highlight-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* === Noscript === */
|
||||
.noscript-msg {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* === No Results === */
|
||||
.no-results {
|
||||
max-width: 1400px;
|
||||
@@ -437,8 +527,7 @@ a:hover { color: var(--accent-hover); text-decoration: underline; }
|
||||
|
||||
/* === Responsive === */
|
||||
@media (max-width: 900px) {
|
||||
.col-group { display: none; }
|
||||
.expand-tags-mobile { display: flex; }
|
||||
.col-commit { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@@ -453,7 +542,7 @@ a:hover { color: var(--accent-hover); text-decoration: underline; }
|
||||
|
||||
.col-cat { display: none; }
|
||||
.col-name { white-space: normal; }
|
||||
.footer { padding: 1.25rem; flex-direction: column; gap: 0.5rem; }
|
||||
.footer { padding: 1.25rem; justify-content: center; flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
/* === Screen Reader Only === */
|
||||
@@ -472,6 +561,8 @@ a:hover { color: var(--accent-hover); text-decoration: underline; }
|
||||
/* === Reduced Motion === */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
</footer>
|
||||
|
||||
<noscript
|
||||
><p style="text-align: center; padding: 1rem; color: #666">
|
||||
><p class="noscript-msg">
|
||||
JavaScript is needed for search and filtering.
|
||||
</p></noscript
|
||||
>
|
||||
|
||||
@@ -67,10 +67,10 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-num"><span class="sr-only">#</span></th>
|
||||
<th class="col-name">Project Name</th>
|
||||
<th class="col-stars">GitHub Stars</th>
|
||||
<th class="col-name" data-sort="name">Project Name</th>
|
||||
<th class="col-stars" data-sort="stars">GitHub Stars</th>
|
||||
<th class="col-commit" data-sort="commit-time">Last Commit</th>
|
||||
<th class="col-cat">Category</th>
|
||||
<th class="col-group">Group</th>
|
||||
<th class="col-arrow"><span class="sr-only">Details</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -95,29 +95,24 @@
|
||||
{% if entry.stars is not none %}{{ "{:,}".format(entry.stars) }}{%
|
||||
else %}—{% endif %}
|
||||
</td>
|
||||
<td class="col-commit"
|
||||
{% if entry.last_commit_at %}data-commit="{{ entry.last_commit_at }}"{% endif %}
|
||||
>{% if entry.last_commit_at %}<time datetime="{{ entry.last_commit_at }}">{{ entry.last_commit_at[:10] }}</time>{% else %}—{% endif %}</td>
|
||||
<td class="col-cat">
|
||||
<button class="tag" data-type="cat" data-value="{{ entry.category }}">
|
||||
{{ entry.category }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="col-group">
|
||||
<button class="tag" data-type="group" data-value="{{ entry.group }}">
|
||||
{{ entry.group }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="col-arrow"><span class="arrow">→</span></td>
|
||||
</tr>
|
||||
<tr class="expand-row" id="expand-{{ loop.index }}">
|
||||
<td></td>
|
||||
<td colspan="5">
|
||||
<td colspan="3">
|
||||
<div class="expand-content">
|
||||
<div class="expand-tags-mobile">
|
||||
<span class="expand-tag">{{ entry.category }}</span>
|
||||
<span class="expand-tag">{{ entry.group }}</span>
|
||||
</div>
|
||||
{% if entry.description %}
|
||||
<div class="expand-desc">{{ entry.description | safe }}</div>
|
||||
{% endif %} {% if entry.also_see %}
|
||||
{% endif %}
|
||||
{% if entry.also_see %}
|
||||
<div class="expand-also-see">
|
||||
Also see: {% for see in entry.also_see %}<a
|
||||
href="{{ see.url }}"
|
||||
@@ -138,11 +133,16 @@
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>{{ entry.url | replace("https://", "") }}</a
|
||||
>{% if entry.pushed_at %}<span class="expand-sep">·</span
|
||||
>Last pushed {{ entry.pushed_at[:10] }}{% endif %}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="col-cat">
|
||||
<button class="tag" data-type="group" data-value="{{ entry.group }}">
|
||||
{{ entry.group }}
|
||||
</button>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
"""Tests for the build module."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from build import (
|
||||
build,
|
||||
extract_github_repo,
|
||||
@@ -149,27 +146,6 @@ class TestBuild:
|
||||
# No category sub-pages
|
||||
assert not (site / "categories").exists()
|
||||
|
||||
def test_build_creates_cname(self, tmp_path):
|
||||
readme = textwrap.dedent("""\
|
||||
# T
|
||||
|
||||
---
|
||||
|
||||
## Only
|
||||
|
||||
- [x](https://x.com) - X.
|
||||
|
||||
# Contributing
|
||||
|
||||
Done.
|
||||
""")
|
||||
self._make_repo(tmp_path, readme)
|
||||
build(str(tmp_path))
|
||||
|
||||
cname = tmp_path / "website" / "output" / "CNAME"
|
||||
assert cname.exists()
|
||||
assert "awesome-python.com" in cname.read_text()
|
||||
|
||||
def test_build_cleans_stale_output(self, tmp_path):
|
||||
readme = textwrap.dedent("""\
|
||||
# T
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"""Tests for the readme_parser module."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from readme_parser import (
|
||||
_parse_section_entries,
|
||||
_render_section_html,
|
||||
@@ -141,21 +139,11 @@ class TestParseReadmeSections:
|
||||
assert cats[0]["description"] == "Libraries for alpha stuff."
|
||||
assert cats[1]["description"] == "Tools for beta."
|
||||
|
||||
def test_category_content_has_entries(self):
|
||||
cats, _ = parse_readme(MINIMAL_README)
|
||||
assert "lib-a" in cats[0]["content"]
|
||||
assert "lib-b" in cats[0]["content"]
|
||||
|
||||
def test_resource_names(self):
|
||||
_, resources = parse_readme(MINIMAL_README)
|
||||
assert resources[0]["name"] == "Newsletters"
|
||||
assert resources[1]["name"] == "Podcasts"
|
||||
|
||||
def test_resource_content(self):
|
||||
_, resources = parse_readme(MINIMAL_README)
|
||||
assert "News One" in resources[0]["content"]
|
||||
assert "Pod One" in resources[1]["content"]
|
||||
|
||||
def test_contributing_skipped(self):
|
||||
cats, resources = parse_readme(MINIMAL_README)
|
||||
all_names = [c["name"] for c in cats] + [r["name"] for r in resources]
|
||||
@@ -188,7 +176,7 @@ class TestParseReadmeSections:
|
||||
""")
|
||||
cats, resources = parse_readme(readme)
|
||||
assert cats[0]["description"] == ""
|
||||
assert "item" in cats[0]["content"]
|
||||
assert cats[0]["entries"][0]["name"] == "item"
|
||||
|
||||
def test_description_with_link_stripped(self):
|
||||
readme = textwrap.dedent("""\
|
||||
@@ -251,6 +239,20 @@ class TestParseSectionEntries:
|
||||
assert entries[0]["name"] == "algos"
|
||||
assert entries[2]["name"] == "patterns"
|
||||
|
||||
def test_text_before_link_is_subcategory(self):
|
||||
nodes = _content_nodes(
|
||||
"- MySQL - [awesome-mysql](http://example.com/awesome-mysql/)\n"
|
||||
" - [mysqlclient](https://example.com/mysqlclient) - MySQL connector.\n"
|
||||
" - [pymysql](https://example.com/pymysql) - Pure Python MySQL driver.\n"
|
||||
)
|
||||
entries = _parse_section_entries(nodes)
|
||||
# awesome-mysql is a subcategory label, not an entry
|
||||
assert len(entries) == 2
|
||||
names = [e["name"] for e in entries]
|
||||
assert "awesome-mysql" not in names
|
||||
assert "mysqlclient" in names
|
||||
assert "pymysql" in names
|
||||
|
||||
def test_also_see_sub_entries(self):
|
||||
nodes = _content_nodes(
|
||||
"- [asyncio](https://docs.python.org/3/library/asyncio.html) - Async I/O.\n"
|
||||
|
||||
Reference in New Issue
Block a user