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:
Vinta Chen
2026-03-18 20:33:36 +08:00
parent 95b6b3cc69
commit 280f250ce0
12 changed files with 1599 additions and 883 deletions

View File

@@ -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

View File

@@ -25,6 +25,7 @@ dev = [
[tool.pytest.ini_options]
testpaths = ["website/tests"]
pythonpath = ["website"]
[tool.ruff]
line-length = 200

View File

@@ -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

View File

@@ -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

View File

@@ -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
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

View File

@@ -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();
})();

View File

@@ -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;
}
}

View File

@@ -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
>

View File

@@ -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 %}&mdash;{% 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 %}&mdash;{% 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">&rarr;</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">&middot;</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>

View File

@@ -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

View File

@@ -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"