diff --git a/website/build.py b/website/build.py index cf759283..f1d00c93 100644 --- a/website/build.py +++ b/website/build.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import TypedDict from jinja2 import Environment, FileSystemLoader -from readme_parser import parse_readme +from readme_parser import parse_readme, parse_sponsors class StarData(TypedDict): @@ -147,6 +147,7 @@ def build(repo_root: str) -> None: break parsed_groups = parse_readme(readme_text) + sponsors = parse_sponsors(readme_text) categories = [cat for g in parsed_groups for cat in g["categories"]] total_entries = sum(c["entry_count"] for c in categories) @@ -189,6 +190,7 @@ def build(repo_root: str) -> None: total_categories=len(categories), repo_stars=repo_stars, build_date=datetime.now(timezone.utc).strftime("%B %d, %Y"), + sponsors=sponsors, ), encoding="utf-8", ) diff --git a/website/readme_parser.py b/website/readme_parser.py index 4f36ed77..c736b7cc 100644 --- a/website/readme_parser.py +++ b/website/readme_parser.py @@ -37,6 +37,12 @@ class ParsedGroup(TypedDict): categories: list[ParsedSection] +class ParsedSponsor(TypedDict): + name: str + url: str + description: str # inline HTML, properly escaped + + # --- Slugify ---------------------------------------------------------------- _SLUG_NON_ALNUM_RE = re.compile(r"[^a-z0-9\s-]") @@ -350,6 +356,80 @@ def _parse_grouped_sections( return groups +_SPONSOR_SEP_RE = re.compile(r"^\s*[:\-\u2013\u2014]\s*") + + +def _find_link_deep(node: SyntaxTreeNode) -> SyntaxTreeNode | None: + """Find the first link anywhere in the subtree (including nested in strong/em).""" + for child in node.children: + if child.type == "link": + return child + found = _find_link_deep(child) + if found: + return found + return None + + +def _parse_sponsor_item(inline: SyntaxTreeNode) -> ParsedSponsor | None: + """Parse `**[name](url)**: description` (or `[name](url) - description`).""" + link = _find_link_deep(inline) + if link is None: + return None + name = render_inline_text(link.children) + url = link.attrGet("href") or "" + + split_idx = None + for i, child in enumerate(inline.children): + if child is link or _find_link_deep(child) is link: + split_idx = i + break + if split_idx is None: + return None + desc_html = render_inline_html(inline.children[split_idx + 1 :]) + desc_html = _SPONSOR_SEP_RE.sub("", desc_html) + return ParsedSponsor(name=name, url=url, description=desc_html) + + +def parse_sponsors(text: str) -> list[ParsedSponsor]: + """Parse the `# Sponsors` section of README.md into a list of sponsors. + + Expects bullets in the form `**[name](url)**: description`. + Returns [] if no Sponsors section exists. + """ + md = MarkdownIt("commonmark") + tokens = md.parse(text) + root = SyntaxTreeNode(tokens) + children = root.children + + start_idx = None + end_idx = len(children) + for i, node in enumerate(children): + if node.type == "heading" and node.tag == "h1": + title = _heading_text(node).strip().lower() + if start_idx is None and title == "sponsors": + start_idx = i + 1 + elif start_idx is not None: + end_idx = i + break + if start_idx is None: + return [] + + sponsors: list[ParsedSponsor] = [] + for node in children[start_idx:end_idx]: + if node.type != "bullet_list": + continue + for list_item in node.children: + if list_item.type != "list_item": + continue + inline = _find_inline(list_item) + if inline is None: + continue + sponsor = _parse_sponsor_item(inline) + if sponsor: + sponsors.append(sponsor) + return sponsors + + def parse_readme(text: str) -> list[ParsedGroup]: """Parse README.md text into grouped categories. diff --git a/website/static/style.css b/website/static/style.css index ee24ce94..2bae2c95 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -392,6 +392,109 @@ kbd { outline-offset: 3px; } +.sponsor-band { + padding-block: clamp(2.5rem, 5.5vw, 4rem); + background: + linear-gradient(180deg, var(--bg-paper-strong), var(--bg-paper)); + border-bottom: 1px solid var(--line); +} + +.sponsor-shell { + display: grid; + grid-template-columns: minmax(0, 14rem) minmax(0, 1fr); + gap: clamp(1.5rem, 5vw, 3.5rem); + align-items: start; +} + +.sponsor-meta { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.sponsor-meta .section-label { + margin-bottom: 0; +} + +.sponsor-become { + display: inline-flex; + align-items: center; + gap: 0.4rem; + align-self: start; + color: var(--ink-soft); + font-size: var(--text-sm); + font-weight: 700; + letter-spacing: 0.01em; + border-bottom: 1px solid var(--line-strong); + padding-bottom: 0.2rem; + transition: + color 180ms ease, + border-color 180ms ease; +} + +.sponsor-become:hover { + color: var(--accent-deep); + border-bottom-color: var(--accent); +} + +.sponsor-become-arrow { + transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.sponsor-become:hover .sponsor-become-arrow { + transform: translateX(0.3rem); +} + +.sponsor-list { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: clamp(1.5rem, 3vw, 2.25rem); +} + +.sponsor { + display: grid; + gap: 0.65rem; +} + +.sponsor-link { + display: inline-flex; + align-items: baseline; + gap: 0.5rem; + color: var(--ink); + transition: color 180ms ease; +} + +.sponsor-link:hover { + color: var(--accent-deep); +} + +.sponsor-name { + font-family: var(--font-display); + font-size: clamp(2.25rem, 4.2vw, 3.25rem); + font-weight: 600; + line-height: 0.95; + letter-spacing: -0.025em; +} + +.sponsor-desc { + color: var(--ink-soft); + font-size: clamp(1rem, 1.5vw, 1.1rem); + line-height: 1.55; +} + +.sponsor-desc a { + color: var(--accent-deep); + text-decoration: underline; + text-decoration-color: var(--accent-underline); + text-underline-offset: 0.18em; +} + +.sponsor-desc a:hover { + color: var(--accent); +} + .results-intro h2, .final-cta h2 { font-family: var(--font-display); @@ -1025,7 +1128,8 @@ th[data-sort].sort-asc::after { } .hero-grid, - .results-intro { + .results-intro, + .sponsor-shell { grid-template-columns: 1fr; } diff --git a/website/templates/index.html b/website/templates/index.html index 1003c1a7..87cfbf29 100644 --- a/website/templates/index.html +++ b/website/templates/index.html @@ -65,6 +65,40 @@ {% endblock %} {% block content %} +{% if sponsors %} + +{% endif %} +