From 79c6fc3ba984c7ab9b238a51b5348dcbbb8f3a88 Mon Sep 17 00:00:00 2001 From: 23f2000649-a11y <23f2000649@ds.study.iitm.ac.in> Date: Sat, 21 Mar 2026 15:44:11 +0530 Subject: [PATCH 001/119] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8bb0c24..7ae09ef 100644 --- a/README.md +++ b/README.md @@ -890,14 +890,14 @@ _Libraries for file manipulation._ _Libraries for manipulating images._ -- [pillow](https://github.com/python-pillow/Pillow) - Pillow is the friendly [PIL](http://www.pythonware.com/products/pil/) fork. +- [pillow](https://github.com/python-pillow/Pillow) - Pillow is the friendly [PIL](https://www.pythonware.com/products/pil/) fork. - [pymatting](https://github.com/pymatting/pymatting) - A library for alpha matting. - [python-barcode](https://github.com/WhyNotHugo/python-barcode) - Create barcodes in Python with no extra dependencies. - [python-qrcode](https://github.com/lincolnloop/python-qrcode) - A pure Python QR Code generator. - [pyvips](https://github.com/libvips/pyvips) - A fast image processing library with low memory needs. - [scikit-image](https://github.com/scikit-image/scikit-image) - A Python library for (scientific) image processing. - [thumbor](https://github.com/thumbor/thumbor) - A smart imaging service. It enables on-demand crop, re-sizing and flipping of images. -- [wand](https://github.com/emcconville/wand) - Python bindings for [MagickWand](http://www.imagemagick.org/script/magick-wand.php), C API for ImageMagick. +- [wand](https://github.com/emcconville/wand) - Python bindings for [MagickWand](https://www.imagemagick.org/script/magick-wand.php), C API for ImageMagick. ## Audio & Video Processing From 7d1d9e0af38803b45fabab78638d89a8962290a5 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 07:54:14 +0800 Subject: [PATCH 002/119] feat: redesign website homepage --- website/static/main.js | 33 +- website/static/style.css | 1192 +++++++++++++++++++++++----------- website/templates/base.html | 44 +- website/templates/index.html | 420 +++++++----- 4 files changed, 1116 insertions(+), 573 deletions(-) diff --git a/website/static/main.js b/website/static/main.js index 1b9cc7d..5c765f5 100644 --- a/website/static/main.js +++ b/website/static/main.js @@ -10,6 +10,37 @@ var rows = document.querySelectorAll('.table tbody tr.row'); var tags = document.querySelectorAll('.tag'); var tbody = document.querySelector('.table tbody'); +function initRevealSections() { + var sections = document.querySelectorAll('[data-reveal]'); + if (!sections.length) return; + + if (!('IntersectionObserver' in window)) { + sections.forEach(function (section) { + section.classList.add('is-visible'); + }); + return; + } + + var observer = new IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + if (!entry.isIntersecting) return; + entry.target.classList.add('is-visible'); + observer.unobserve(entry.target); + }); + }, { + threshold: 0.12, + rootMargin: '0px 0px -8% 0px', + }); + + sections.forEach(function (section, index) { + section.classList.add('will-reveal'); + section.style.transitionDelay = Math.min(index * 70, 180) + 'ms'; + observer.observe(section); + }); +} + +initRevealSections(); + // Relative time formatting function relativeTime(isoStr) { var date = new Date(isoStr); @@ -293,7 +324,7 @@ if (backToTop) { } }); backToTop.addEventListener('click', function () { - window.scrollTo({ top: 0 }); + window.scrollTo({ top: 0, behavior: 'smooth' }); }); } diff --git a/website/static/style.css b/website/static/style.css index 0a5571f..668652a 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -1,190 +1,493 @@ -/* === Reset & Base === */ -*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } - -:root { - --font-display: Georgia, "Noto Serif", "Times New Roman", serif; - --font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; - - --text-xs: 0.9375rem; - --text-sm: 1rem; - --text-base: 1.125rem; - - --bg: oklch(99.5% 0.003 240); - --bg-hover: oklch(97% 0.008 240); - --text: oklch(15% 0.005 240); - --text-secondary: oklch(35% 0.005 240); - --text-muted: oklch(50% 0.005 240); - --border: oklch(90% 0.005 240); - --border-strong: oklch(75% 0.008 240); - --border-heavy: oklch(25% 0.01 240); - --bg-input: oklch(94.5% 0.035 240); - --accent: oklch(42% 0.14 240); - --accent-hover: oklch(32% 0.16 240); - --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); +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; } -html { font-size: 16px; } +:root { + color-scheme: light; + --font-display: "Cormorant Garamond", Georgia, serif; + --font-body: "Manrope", "Avenir Next", "Segoe UI", sans-serif; + + --shell-max: 84rem; + --shell-pad: clamp(1.25rem, 3vw, 2.5rem); + + --bg-page: oklch(96.8% 0.018 80); + --bg-paper: oklch(98.6% 0.01 80); + --bg-paper-strong: oklch(95.7% 0.016 76); + --bg-hover: oklch(93.8% 0.026 72); + --ink: oklch(22% 0.02 55); + --ink-soft: oklch(38% 0.018 55); + --ink-muted: oklch(52% 0.02 55); + --line: oklch(83% 0.02 70); + --line-strong: oklch(64% 0.035 62); + --accent: oklch(58% 0.16 45); + --accent-deep: oklch(44% 0.15 42); + --accent-soft: oklch(92% 0.045 55); + --highlight: oklch(87% 0.08 78); + --hero-ink: oklch(15% 0.02 40); + --hero-shadow: oklch(8% 0.02 35 / 0.5); + --hero-text: oklch(97% 0.012 85); + --hero-muted: oklch(84% 0.02 82); + --hero-line: oklch(100% 0 0 / 0.16); + + --text-xs: 0.8rem; + --text-sm: 0.95rem; + --text-base: 1rem; + --text-lg: 1.125rem; +} + +html { + font-size: 16px; + scroll-behavior: smooth; +} body { - font-family: var(--font-body); - background: var(--bg); - color: var(--text); - line-height: 1.55; min-height: 100vh; display: flex; flex-direction: column; + font-family: var(--font-body); + line-height: 1.6; + color: var(--ink); + background: + radial-gradient(circle at top left, oklch(100% 0 0 / 0.72), transparent 28rem), + linear-gradient(180deg, oklch(95.2% 0.018 78), var(--bg-page) 24rem, oklch(98.4% 0.01 80)); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } -a { color: var(--accent); text-decoration: none; text-underline-offset: 0.15em; } -a:hover { color: var(--accent-hover); text-decoration: underline; } +main { + display: flex; + flex-direction: column; +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input { + font: inherit; +} + +img, +svg { + display: block; + max-width: 100%; +} + +kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.8em; + padding: 0.08rem 0.38rem; + border: 1px solid var(--line); + border-bottom-width: 2px; + border-radius: 999px; + background: var(--bg-paper); + font-size: 0.85em; + line-height: 1.2; +} + +.section-shell { + width: min(100%, calc(var(--shell-max) + (var(--shell-pad) * 2))); + margin: 0 auto; + padding-inline: var(--shell-pad); +} -/* === Skip Link === */ .skip-link { position: absolute; left: -9999px; top: 0; - padding: 0.5rem 1rem; - background: var(--text); - color: var(--bg); - font-size: var(--text-xs); + z-index: 300; + padding: 0.75rem 1rem; + color: var(--hero-text); + background: var(--hero-ink); + font-size: var(--text-sm); font-weight: 700; - z-index: 200; } -.skip-link:focus { left: 0; } +.skip-link:focus { + left: 1rem; + top: 1rem; +} -/* === Hero === */ .hero { - max-width: 1400px; - margin: 0 auto; - padding: 3.5rem 2rem 1.5rem; + position: relative; + width: 100%; + min-height: 100svh; + overflow: clip; + background: + radial-gradient(circle at 18% 18%, oklch(55% 0.14 45 / 0.34), transparent 22rem), + radial-gradient(circle at 78% 32%, oklch(62% 0.17 70 / 0.17), transparent 24rem), + linear-gradient(140deg, oklch(14% 0.03 32) 0%, oklch(19% 0.035 35) 52%, oklch(28% 0.05 42) 100%); + color: var(--hero-text); } -.hero-main { +.hero::before { + content: ""; + position: absolute; + inset: 0; + background: + linear-gradient(90deg, oklch(100% 0 0 / 0.03) 1px, transparent 1px), + linear-gradient(oklch(100% 0 0 / 0.03) 1px, transparent 1px); + background-size: 7rem 7rem; + mask-image: linear-gradient(180deg, oklch(0% 0 0 / 0.72), transparent 88%); + pointer-events: none; +} + +.hero-sheen, +.hero-noise { + position: absolute; + inset: 0; + pointer-events: none; +} + +.hero-sheen { + background: + linear-gradient(118deg, transparent 35%, oklch(100% 0 0 / 0.09) 49%, transparent 63%); + transform: translateX(-30%); + animation: sheen-drift 18s linear infinite; +} + +.hero-noise { + opacity: 0.1; + background-image: + radial-gradient(circle at 20% 20%, oklch(100% 0 0 / 0.65) 0.02rem, transparent 0.04rem), + radial-gradient(circle at 80% 30%, oklch(100% 0 0 / 0.55) 0.03rem, transparent 0.05rem); + background-size: 4rem 4rem, 5rem 5rem; +} + +.hero-shell { + position: relative; + z-index: 1; + width: min(100%, calc(var(--shell-max) + (var(--shell-pad) * 2))); + min-height: 100svh; + margin: 0 auto; + padding: 1.25rem var(--shell-pad) 2.4rem; display: flex; - flex-wrap: wrap; + flex-direction: column; + justify-content: space-between; + gap: 2rem; +} + +.hero-topbar { + display: flex; + align-items: center; justify-content: space-between; - align-items: flex-start; gap: 1rem; } -.hero-submit { - flex-shrink: 0; - padding: 0.4rem 1rem; - border: 1px solid var(--border-strong); - border-radius: 4px; - font-size: var(--text-sm); - color: var(--text); - text-decoration: none; - white-space: nowrap; - transition: border-color 0.2s, background 0.2s, color 0.2s; +.hero-brand-mini { + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--hero-muted); } -.hero-submit:hover { - border-color: var(--accent); - background: var(--accent-light); - color: var(--accent); - text-decoration: none; +.hero-brand-mini:hover, +.hero-topbar-link:hover, +.hero-sub a:hover, +.hero-scrollcue:hover { + color: var(--hero-text); } -.hero-submit:active { - transform: scale(0.97); +.hero-topbar-actions { + display: flex; + align-items: center; + gap: 0.75rem; } -.hero-submit:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; +.hero-topbar-link { + padding: 0.45rem 0.8rem; + border: 1px solid var(--hero-line); + border-radius: 999px; + color: var(--hero-muted); + font-size: var(--text-xs); + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + transition: + color 180ms ease, + border-color 180ms ease, + background-color 180ms ease, + transform 180ms ease; +} + +.hero-topbar-link-strong, +.hero-topbar-link:hover { + border-color: oklch(100% 0 0 / 0.28); + background: oklch(100% 0 0 / 0.06); +} + +.hero-topbar-link:active, +.hero-action:active, +.tag:active, +.filter-clear:active { + transform: translateY(1px); +} + +.hero-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + align-items: center; + gap: 0; + flex: 1; +} + +.hero-copy { + width: 100%; + max-width: none; + animation: hero-rise 700ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.hero-kicker, +.section-label { + margin-bottom: 0.9rem; + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0.22em; + text-transform: uppercase; +} + +.hero-kicker { + color: oklch(82% 0.04 72); +} + +.section-label { + color: var(--accent-deep); } .hero h1 { font-family: var(--font-display); - font-size: clamp(2rem, 5vw, 3rem); - font-weight: 400; - letter-spacing: -0.01em; - line-height: 1.1; + font-size: clamp(4rem, 10vw, 7.5rem); + line-height: 0.9; + font-weight: 600; + letter-spacing: -0.03em; text-wrap: balance; - color: var(--accent); - margin-bottom: 0.75rem; } .hero-sub { - font-size: var(--text-base); - color: var(--text-secondary); - line-height: 1.6; - margin-bottom: 0.5rem; + margin-top: 1.3rem; + color: var(--hero-muted); + font-size: clamp(1rem, 2vw, 1.18rem); text-wrap: pretty; } -.hero-sub a { color: var(--text-secondary); font-weight: 600; } -.hero-sub a:hover { color: var(--accent); } - -.hero-gh { - font-size: var(--text-sm); - color: var(--text-muted); - font-weight: 500; +.hero-sub a { + color: var(--hero-text); + text-decoration: underline; + text-decoration-color: oklch(100% 0 0 / 0.25); + text-underline-offset: 0.2em; } -.hero-gh:hover { color: var(--accent); } +.hero-actions, +.final-cta-actions { + display: flex; + flex-wrap: wrap; + gap: 0.85rem; +} + +.hero-actions { + margin-top: 1.75rem; +} + +.hero-actions, +.hero-metrics { + width: 100%; +} + +.hero-action { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 3rem; + padding: 0.85rem 1.25rem; + border-radius: 999px; + border: 1px solid transparent; + font-size: var(--text-sm); + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + transition: + transform 180ms ease, + color 180ms ease, + background-color 180ms ease, + border-color 180ms ease, + box-shadow 180ms ease; +} + +.hero-action-primary { + color: var(--hero-ink); + background: linear-gradient(135deg, oklch(83% 0.08 72), oklch(73% 0.14 58)); + box-shadow: 0 1.2rem 2.5rem -1.5rem oklch(0% 0 0 / 0.65); +} + +.hero-action-primary:hover { + box-shadow: 0 1.5rem 2.7rem -1.4rem oklch(0% 0 0 / 0.8); +} + +.hero-action-secondary { + color: var(--hero-text); + border-color: var(--hero-line); + background: oklch(100% 0 0 / 0.04); +} + +.hero-action-secondary:hover { + background: oklch(100% 0 0 / 0.08); + border-color: oklch(100% 0 0 / 0.28); +} + +.hero-action:focus-visible, +.hero-topbar-link:focus-visible, +.hero-scrollcue:focus-visible, +.search:focus-visible, +.filter-clear:focus-visible, +.tag:focus-visible, +.back-to-top:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; +} + +.hero-metrics { + margin-top: clamp(1.8rem, 4vw, 2.8rem); + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1.5rem; + max-width: none; +} + +.hero-metrics div { + padding-top: 0.9rem; + border-top: 1px solid var(--hero-line); +} + +.hero-metrics dt { + font-size: clamp(1.6rem, 3.2vw, 2.4rem); + font-weight: 800; + line-height: 1; + color: var(--hero-text); +} + +.hero-metrics dd { + margin-top: 0.3rem; + color: var(--hero-muted); + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.hero-scrollcue { + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: 0.65rem; + color: var(--hero-muted); + font-size: var(--text-xs); + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.hero-scrollcue::after { + content: ""; + width: 3rem; + height: 1px; + background: var(--hero-line); + animation: scroll-line 1.6s ease-in-out infinite; +} + +.results-intro h2, +.final-cta h2 { + font-family: var(--font-display); + font-size: clamp(2.2rem, 5vw, 4rem); + line-height: 0.94; + letter-spacing: -0.03em; + text-wrap: balance; +} + +.results-section { + padding-block: clamp(2.5rem, 6vw, 4.5rem) 0; +} + +.results-intro { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(18rem, 28rem); + gap: 1.25rem; + align-items: end; + padding-bottom: 1.75rem; +} + +.results-note { + color: var(--ink-soft); + font-size: var(--text-sm); + justify-self: end; + max-width: 28rem; +} -/* === Controls === */ .controls { - max-width: 1400px; - margin: 0 auto; - padding: 0 2rem 1rem; + display: grid; + gap: 0.85rem; + padding-bottom: 1.35rem; } .search-wrap { position: relative; - margin-bottom: 0.75rem; } .search-icon { position: absolute; - left: 1rem; + left: 1.15rem; top: 50%; transform: translateY(-50%); - color: var(--text-muted); + color: var(--ink-muted); pointer-events: none; } .search { width: 100%; - padding: 0.65rem 1rem 0.65rem 2.75rem; + min-height: 4.1rem; + padding: 1rem 1.15rem 1rem 3.2rem; border: 1px solid transparent; - border-radius: 4px; - background: var(--bg-input); - font-family: var(--font-body); - font-size: var(--text-sm); - color: var(--text); - transition: border-color 0.15s, background 0.15s; + border-radius: 999px; + background: linear-gradient(180deg, oklch(100% 0 0 / 0.82), oklch(98.6% 0.01 80)); + color: var(--ink); + font-size: clamp(1rem, 1.4vw, 1.06rem); + box-shadow: + inset 0 1px 0 oklch(100% 0 0 / 0.75), + 0 1.4rem 2.6rem -2.1rem oklch(18% 0.03 45 / 0.24); + transition: + border-color 180ms ease, + box-shadow 180ms ease, + background-color 180ms ease; } -.search::placeholder { color: var(--text-muted); } +.search::placeholder { + color: var(--ink-muted); +} .search:focus { - outline: 2px solid var(--accent); - outline-offset: 2px; - border-color: var(--accent); - background: var(--bg); + border-color: oklch(61% 0.14 48 / 0.45); + box-shadow: + inset 0 1px 0 oklch(100% 0 0 / 0.75), + 0 1.6rem 3rem -2rem oklch(34% 0.08 42 / 0.28); } .filter-bar { display: flex; align-items: center; gap: 0.75rem; - padding: 0.5rem 0; + min-height: 2.3rem; font-size: var(--text-sm); - color: var(--text-secondary); + color: var(--ink-soft); opacity: 0; - transform: translateY(-4px); + transform: translateY(-0.4rem); pointer-events: none; - transition: opacity 0.15s ease, transform 0.15s cubic-bezier(0.25, 1, 0.5, 1); + transition: + opacity 180ms ease, + transform 180ms cubic-bezier(0.22, 1, 0.36, 1); } .filter-bar.visible { @@ -194,44 +497,39 @@ a:hover { color: var(--accent-hover); text-decoration: underline; } } .filter-bar strong { - color: var(--text); + color: var(--ink); } .filter-clear { - background: none; - border: 1px solid var(--border); - border-radius: 4px; - padding: 0.35rem 0.65rem; - font-family: inherit; - font-size: var(--text-xs); - color: var(--text-muted); + border: 1px solid var(--line); + border-radius: 999px; + background: var(--bg-paper); + color: var(--ink-soft); + padding: 0.42rem 0.82rem; cursor: pointer; - transition: border-color 0.15s, color 0.15s; -} - -.filter-clear:active { - transform: scale(0.97); + transition: + border-color 180ms ease, + color 180ms ease, + background-color 180ms ease, + transform 180ms ease; } .filter-clear:hover { - border-color: var(--text-muted); - color: var(--text); + color: var(--ink); + background: var(--accent-soft); + border-color: oklch(68% 0.08 58 / 0.5); } -.filter-clear:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -/* === Table === */ .table-wrap { width: 100%; - padding: 0; + overflow-x: auto; + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); + scroll-margin-top: 1rem; } .table-wrap:focus { - outline: 2px solid var(--accent); - outline-offset: -2px; + outline: none; } .table { @@ -241,162 +539,182 @@ a:hover { color: var(--accent-hover); text-decoration: underline; } font-size: var(--text-sm); } -.table thead th { - text-align: left; - font-weight: 700; - font-size: var(--text-base); - color: var(--text); - padding: 0.65rem 0.75rem; - border-bottom: 2px solid var(--border-heavy); - position: sticky; - top: 0; - background: var(--bg); - z-index: 10; - white-space: nowrap; - box-shadow: 0 1px 0 var(--border); +.table thead th, +.table tbody td { + padding-inline: 0.9rem; } .table thead th:first-child, .table tbody td:first-child { - padding-left: max(2rem, calc(50vw - 700px + 2rem)); + padding-left: max(var(--shell-pad), calc(50vw - (var(--shell-max) / 2) + var(--shell-pad))); } .table thead th:last-child, .table tbody td:last-child { - padding-right: max(2rem, calc(50vw - 700px + 2rem)); + padding-right: max(var(--shell-pad), calc(50vw - (var(--shell-max) / 2) + var(--shell-pad))); +} + +.table thead th { + position: sticky; + top: 0; + z-index: 12; + padding-top: 1rem; + padding-bottom: 0.95rem; + text-align: left; + color: var(--ink); + font-size: var(--text-xs); + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; + white-space: nowrap; + border-bottom: 1px solid var(--line); + background: oklch(98.2% 0.012 80 / 0.92); + backdrop-filter: blur(14px); } .table tbody td { - padding: 0.7rem 0.75rem; - border-bottom: 1px solid var(--border); + padding-top: 1rem; + padding-bottom: 1rem; vertical-align: top; - transition: background 0.15s; + border-bottom: 1px solid var(--line); + transition: + background-color 180ms ease, + border-color 180ms ease; } -.table tbody tr.row:not(.open):hover td { - background: var(--bg-hover); +.table tbody tr[hidden] { + display: none; } -.table tbody tr[hidden] { display: none; } +.row { + cursor: pointer; +} + +.row:not(.open):hover td { + background: oklch(96.2% 0.02 76); +} + +.row:focus-visible td { + outline: none; + background: oklch(95.7% 0.026 68); + box-shadow: inset 3px 0 0 var(--accent); +} + +.row.open td { + background: linear-gradient(180deg, oklch(96.2% 0.03 76), oklch(95.4% 0.026 74)); + border-bottom-color: transparent; +} .col-num { - width: 3rem; - color: var(--text-muted); + width: 3.5rem; + color: var(--ink-muted); font-variant-numeric: tabular-nums; - text-align: left; } .col-name { - width: 30%; + width: 28%; overflow-wrap: anywhere; } .col-name > a { - font-weight: 500; - color: var(--accent); - text-decoration: none; + color: var(--ink); + font-size: clamp(1rem, 1.5vw, 1.08rem); + font-weight: 700; } -.col-name > a:hover { text-decoration: underline; color: var(--accent-hover); } +.col-name > a:hover { + color: var(--accent-deep); +} -/* === Sortable Headers === */ th[data-sort] { cursor: pointer; user-select: none; -} - -th[data-sort] { - transition: color 0.15s ease; + transition: color 180ms ease; } th[data-sort]:hover { - color: var(--accent); -} - -th[data-sort]:active { - color: var(--accent-hover); + color: var(--accent-deep); } th[data-sort]::after { - content: " ▼"; + content: " \2193"; opacity: 0; - transition: opacity 0.15s; + transition: opacity 180ms ease; } th[data-sort="name"]::after { - content: " ▲"; + content: " \2191"; } -th[data-sort]:hover::after { +th[data-sort]:hover::after, +th[data-sort].sort-asc::after, +th[data-sort].sort-desc::after { opacity: 1; } th[data-sort].sort-desc::after { - content: " ▼"; - opacity: 1; + content: " \2193"; } th[data-sort].sort-asc::after { - content: " ▲"; - opacity: 1; + content: " \2191"; } -/* === Stars Column === */ .col-stars { - width: 5rem; - font-variant-numeric: tabular-nums; - white-space: nowrap; - color: var(--text-secondary); + width: 7rem; text-align: right; + white-space: nowrap; + font-variant-numeric: tabular-nums; + color: var(--ink-soft); } -/* === Source Badges === */ .source-badge { - display: inline-block; - font-size: 0.75rem; - font-weight: 600; - letter-spacing: 0.03em; - color: var(--text-muted); - background: var(--bg-input); - padding: 0.15rem 0.45rem; - border-radius: 3px; + display: inline-flex; + align-items: center; + min-height: 1.8rem; + padding: 0.18rem 0.6rem; + border-radius: 999px; + background: var(--bg-paper-strong); + color: var(--ink-soft); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.col-commit { + width: 9rem; + white-space: nowrap; + color: var(--ink-muted); +} + +.col-cat { white-space: nowrap; } -.tag-source { - background: var(--bg-input); - color: var(--text-muted); - font-weight: 600; -} - -/* === Arrow Column === */ .col-arrow { - width: 2.5rem; + width: 3rem; text-align: center; } .arrow { display: inline-block; - font-size: 0.8rem; + color: var(--accent-deep); + font-size: 0.9rem; + transition: + transform 180ms ease, + color 180ms ease; +} + +.row:hover .arrow, +.row.open .arrow { color: var(--accent); - transition: transform 0.15s ease; } .row.open .arrow { transform: rotate(90deg); } -/* === Row Click === */ -.row { cursor: pointer; } -.row:active td { background: var(--bg-hover); } - -.row:focus-visible td { - outline: none; - background: var(--bg-hover); - box-shadow: inset 2px 0 0 var(--accent); -} - -/* === Expand Row === */ .expand-row { display: none; } @@ -405,157 +723,104 @@ th[data-sort].sort-asc::after { display: table-row; } -.row.open td { - background: var(--accent-light); - border-bottom-color: transparent; - padding-bottom: 0.1rem; -} - .expand-row td { - padding: 0.15rem 0.75rem 0.75rem; - background: var(--accent-light); - border-bottom: 1px solid var(--border); -} - -@keyframes expand-in { - from { - opacity: 0; - transform: translateY(-4px); - } - to { - opacity: 1; - transform: translateY(0); - } + padding-top: 0.1rem; + padding-bottom: 1.15rem; + background: linear-gradient(180deg, oklch(96.2% 0.03 76), oklch(95.4% 0.026 74)); + border-bottom: 1px solid var(--line); } .expand-content { - font-size: var(--text-sm); - color: var(--text-secondary); - line-height: 1.6; + color: var(--ink-soft); + line-height: 1.7; text-wrap: pretty; - animation: expand-in 0.2s cubic-bezier(0.25, 1, 0.5, 1); + animation: expand-in 220ms cubic-bezier(0.22, 1, 0.36, 1); } -.expand-tags { - display: flex; - gap: 0.4rem; - margin-bottom: 0.4rem; +.expand-desc a, +.expand-also-see a, +.expand-meta a, +.footer a { + color: var(--accent-deep); } -.expand-tag { - font-size: var(--text-xs); - color: var(--tag-text); - background: var(--bg); - padding: 0.15rem 0.4rem; - border-radius: 3px; -} - -.expand-also-see { - margin-top: 0.25rem; - font-size: var(--text-xs); - color: var(--text-muted); -} - -.expand-also-see a { +.expand-desc a:hover, +.expand-also-see a:hover, +.expand-meta a:hover, +.footer a:hover { color: var(--accent); - text-decoration: none; -} - -.expand-also-see a:hover { - text-decoration: underline; } +.expand-also-see, .expand-meta { - margin-top: 0.25rem; + margin-top: 0.45rem; font-size: var(--text-xs); - color: var(--text-muted); - font-weight: normal; -} - -.expand-meta a { - color: var(--accent); - text-decoration: none; -} - -.expand-meta a:hover { - text-decoration: underline; + color: var(--ink-muted); } .expand-sep { - margin: 0 0.25rem; - color: var(--border); + margin-inline: 0.25rem; + color: var(--line-strong); } -.col-cat { - white-space: nowrap; -} - -.col-cat .tag + .tag { - margin-left: 0.35rem; -} - -/* === 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: var(--tag-text); + border: 1px solid transparent; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent-deep); + padding: 0.34rem 0.68rem; + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.02em; cursor: pointer; - padding: 0.25rem 0.5rem; - border-radius: 3px; - white-space: nowrap; - transition: background 0.15s, color 0.15s; + transition: + color 180ms ease, + background-color 180ms ease, + border-color 180ms ease, + transform 180ms ease; +} + +.tag + .tag { + margin-left: 0.4rem; } -/* Expand touch target to 44x44px minimum */ .tag::after { content: ""; position: absolute; - inset: -0.5rem -0.25rem; -} - -.tag:active { - transform: scale(0.95); + inset: -0.45rem -0.2rem; } .tag:hover { - background: var(--tag-hover-bg); - color: var(--accent); + background: var(--highlight); + border-color: oklch(71% 0.09 62 / 0.45); + color: var(--ink); } -.tag:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 1px; +.tag-source { + background: var(--bg-paper-strong); + color: var(--ink-soft); } .tag.active { - background: var(--highlight); - color: var(--highlight-text); - font-weight: 600; + background: linear-gradient(135deg, oklch(82% 0.08 75), oklch(74% 0.11 58)); + color: var(--hero-ink); } -/* === Back to Top === */ .back-to-top { + border: 0; background: none; - border: none; - padding: 0; - font-family: var(--font-body); - font-size: 0.8rem; - font-weight: 600; - color: var(--accent); + color: var(--accent-deep); + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; cursor: pointer; opacity: 0; - transition: opacity 0.15s ease, color 0.15s; pointer-events: none; + transition: + opacity 180ms ease, + color 180ms ease; } .back-to-top.visible { @@ -564,92 +829,88 @@ th[data-sort].sort-asc::after { } .back-to-top:hover { - color: var(--accent-hover); + color: var(--accent); } -.back-to-top:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -/* === Noscript === */ -.noscript-msg { - text-align: center; - padding: 1rem; - color: var(--text-muted); -} - -/* === No Results === */ .no-results { - max-width: 1400px; - margin: 0 auto; - padding: 3rem 2rem; - font-size: var(--text-base); - color: var(--text-muted); + padding: 2.4rem var(--shell-pad) 0; text-align: center; + color: var(--ink-muted); + font-size: var(--text-lg); +} + +.final-cta { + padding-block: clamp(3rem, 7vw, 5.5rem); + display: grid; + gap: 1rem; +} + +.final-cta p { + color: var(--ink-soft); + font-size: clamp(1rem, 1.6vw, 1.08rem); +} + +.final-cta .hero-action-primary { + color: var(--hero-text); + background: linear-gradient(135deg, var(--accent), var(--accent-deep)); +} + +.final-cta .hero-action-secondary { + color: var(--ink); + background: transparent; + border-color: var(--line-strong); +} + +.final-cta .hero-action-secondary:hover { + background: var(--accent-soft); + border-color: var(--accent); } -/* === Footer === */ .footer { margin-top: auto; - border-top: none; - width: 100%; - padding: 1.25rem 2rem; - font-size: var(--text-xs); - color: var(--text-muted); - background: var(--bg-input); + border-top: 1px solid var(--line); + background: oklch(98.4% 0.01 80 / 0.88); + backdrop-filter: blur(14px); + padding: 1.2rem var(--shell-pad); display: flex; align-items: center; - justify-content: flex-end; - gap: 0.5rem; + justify-content: space-between; + gap: 1rem; + font-size: var(--text-xs); + color: var(--ink-muted); } -.footer a { color: var(--accent); text-decoration: none; } -.footer a:hover { color: var(--accent-hover); text-decoration: underline; } - -.footer-sep { color: var(--border-strong); } - -/* === Responsive === */ -@media (max-width: 900px) { - .col-commit { display: none; } - .tag-group { display: none; } - .col-name { width: 50%; } +.footer-copy, +.footer-links { + display: flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; } -@media (max-width: 640px) { - .table-wrap { overflow-x: auto; } - .table thead th { position: static; } - .hero { padding: 2rem 1.25rem 1rem; } - .controls { padding: 0 1.25rem 0.75rem; } - - .table { table-layout: auto; } - - .table thead th, - .table tbody td { - padding-left: 0.5rem; - padding-right: 0.5rem; - } - - .table thead th:first-child, - .table tbody td:first-child { padding-left: 0.25rem; } - - .table thead th:last-child, - .table tbody td:last-child { padding-right: 0.25rem; } - - .table thead th { font-size: var(--text-sm); } - - .col-num { width: 2rem; } - .col-stars { width: 4.75rem; } - .col-arrow { width: 1.25rem; } - .col-cat { display: none; } - .col-name { - width: auto; - white-space: normal; - } - .footer { padding: 1.25rem; justify-content: center; flex-wrap: wrap; } +.footer-sep { + color: var(--line-strong); +} + +.noscript-msg { + padding: 1rem var(--shell-pad) 0; + text-align: center; + color: var(--ink-muted); +} + +[data-reveal].will-reveal { + opacity: 0; + transform: translateY(1.8rem); +} + +[data-reveal].will-reveal.is-visible { + opacity: 1; + transform: translateY(0); + transition: + opacity 600ms ease, + transform 600ms cubic-bezier(0.22, 1, 0.36, 1); } -/* === Screen Reader Only === */ .sr-only { position: absolute; width: 1px; @@ -662,8 +923,149 @@ th[data-sort].sort-asc::after { border: 0; } -/* === Reduced Motion === */ +@keyframes hero-rise { + from { + opacity: 0; + transform: translateY(1.3rem); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes expand-in { + from { + opacity: 0; + transform: translateY(-0.4rem); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes scroll-line { + 0%, 100% { + transform: scaleX(0.4); + transform-origin: left; + } + 50% { + transform: scaleX(1); + transform-origin: left; + } +} + +@keyframes sheen-drift { + from { + transform: translateX(-30%); + } + to { + transform: translateX(35%); + } +} + +@media (max-width: 960px) { + .hero-shell { + min-height: auto; + padding-bottom: 2rem; + } + + .hero-grid, + .results-intro { + grid-template-columns: 1fr; + } + + .results-note { + justify-self: start; + } + + .col-commit { + display: none; + } + + .tag-group { + display: none; + } +} + +@media (max-width: 680px) { + .hero { + min-height: auto; + } + + .hero-topbar, + .footer { + align-items: flex-start; + flex-direction: column; + } + + .hero-topbar-actions, + .hero-actions, + .final-cta-actions { + width: 100%; + } + + .hero-topbar-link, + .hero-action { + width: 100%; + } + + .hero h1 { + font-size: clamp(3.2rem, 18vw, 4.8rem); + } + + .hero-metrics { + gap: 1rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .hero-metrics div { + min-width: 0; + } + + .search { + min-height: 3.5rem; + border-radius: 1.25rem; + } + + .table thead th { + position: static; + } + + .table thead th, + .table tbody td { + padding-inline: 0.55rem; + } + + .table thead th:first-child, + .table tbody td:first-child { + padding-left: 0.8rem; + } + + .table thead th:last-child, + .table tbody td:last-child { + padding-right: 0.8rem; + } + + .col-cat { + display: none; + } + + .col-stars { + width: 5.4rem; + } + + .col-arrow { + width: 1.8rem; + } +} + @media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; diff --git a/website/templates/base.html b/website/templates/base.html index ea7c7e5..6a23c71 100644 --- a/website/templates/base.html +++ b/website/templates/base.html @@ -18,6 +18,12 @@ + + + diff --git a/website/templates/index.html b/website/templates/index.html index 88a5337..3231c1a 100644 --- a/website/templates/index.html +++ b/website/templates/index.html @@ -51,21 +51,21 @@
{{ "{:,}".format(entries | length) }}
-
unique projects indexed
+
projects
{{ total_categories }}
-
categories to search
+
categories
{{ groups | length }}
-
editorial groupings
+
topic groups
- Scroll into the index + Jump to the list @@ -73,11 +73,11 @@
-

One searchable surface for the ecosystem

+

Search every project in one place

- Use / to focus search, tap a tag to filter, and open a row for - descriptions, related projects, and source details. + Press / to search. Tap a tag to filter. Click any row for + details.

@@ -127,7 +127,7 @@ Project Name GitHub Stars Last Commit - Category + Tags - {% endfor %} {% if entry.source_type == 'Built-in' %} + {% endfor %} + + {% if entry.source_type == 'Built-in' %} From 5a8c565a882eda8f956df09ad35a3f54ddef374b Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 15:24:24 +0800 Subject: [PATCH 023/119] style(css): add background color to final-cta section Co-Authored-By: Claude --- website/static/style.css | 1 + 1 file changed, 1 insertion(+) diff --git a/website/static/style.css b/website/static/style.css index 6f80e1a..1db22f5 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -835,6 +835,7 @@ th[data-sort].sort-asc::after { .final-cta { padding-block: clamp(3rem, 7vw, 5.5rem); + background: oklch(94% 0.025 72); display: grid; gap: 1rem; } From 0308fd1b3ca71ac1ed35fc593a9c68ba662e369e Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 15:26:36 +0800 Subject: [PATCH 024/119] feat: show category label on mobile in project name column On narrow screens the category column is hidden. This adds a .mobile-cat span inside the name cell that renders the first category below the project name, giving mobile users the context they were missing. Co-Authored-By: Claude --- website/static/style.css | 16 ++++++++++++++++ website/templates/index.html | 1 + 2 files changed, 17 insertions(+) diff --git a/website/static/style.css b/website/static/style.css index 1db22f5..16a16c1 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -611,6 +611,10 @@ kbd { white-space: nowrap; } +.mobile-cat { + display: none; +} + .col-name > a { color: var(--ink); font-size: clamp(1rem, 1.5vw, 1.08rem); @@ -1066,6 +1070,18 @@ th[data-sort].sort-asc::after { display: none; } + .col-name { + white-space: normal; + } + + .mobile-cat { + display: block; + margin-top: 0.25rem; + font-size: var(--text-tag); + font-weight: 600; + color: var(--ink-muted); + } + .col-stars { width: 5.4rem; } diff --git a/website/templates/index.html b/website/templates/index.html index 8181079..8896013 100644 --- a/website/templates/index.html +++ b/website/templates/index.html @@ -151,6 +151,7 @@ {{ entry.name }} + {{ entry.categories[0] }} {% if entry.stars is not none %}{{ "{:,}".format(entry.stars) }}{% From 97f18d295f1832fdb265b53e848650319e48c718 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 15:28:25 +0800 Subject: [PATCH 025/119] feat: add clear action to no-results message When a search or filter yields no results, the message now includes an inline button that resets both the search input and the active filter. Improves discoverability and reduces dead-end frustration. Co-Authored-By: Claude --- website/static/main.js | 10 ++++++++++ website/static/style.css | 20 ++++++++++++++++++++ website/templates/index.html | 5 ++++- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/website/static/main.js b/website/static/main.js index 97b56e2..223100e 100644 --- a/website/static/main.js +++ b/website/static/main.js @@ -270,6 +270,16 @@ if (filterClear) { }); } +// No-results clear +var noResultsClear = document.querySelector('.no-results-clear'); +if (noResultsClear) { + noResultsClear.addEventListener('click', function () { + if (searchInput) searchInput.value = ''; + activeFilter = null; + applyFilters(); + }); +} + // Column sorting document.querySelectorAll('th[data-sort]').forEach(function (th) { th.addEventListener('click', function () { diff --git a/website/static/style.css b/website/static/style.css index 16a16c1..da52515 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -837,6 +837,26 @@ th[data-sort].sort-asc::after { font-size: var(--text-lg); } +.no-results-hint { + margin-top: 0.5rem; + font-size: var(--text-sm); +} + +.no-results-clear { + background: none; + border: none; + color: var(--accent-deep); + font: inherit; + cursor: pointer; + text-decoration: underline; + text-decoration-color: oklch(58% 0.16 45 / 0.4); + text-underline-offset: 0.2em; +} + +.no-results-clear:hover { + color: var(--accent); +} + .final-cta { padding-block: clamp(3rem, 7vw, 5.5rem); background: oklch(94% 0.025 72); diff --git a/website/templates/index.html b/website/templates/index.html index 8896013..f3183f3 100644 --- a/website/templates/index.html +++ b/website/templates/index.html @@ -237,7 +237,10 @@ - +
From d3070b735e0d507c43f0cd79fd3d58b2492aa887 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 15:30:04 +0800 Subject: [PATCH 026/119] feat: add build date to footer Displays the UTC date the site was last built in the footer so visitors can see how fresh the data is. Co-Authored-By: Claude --- website/build.py | 2 ++ website/static/style.css | 11 +++++++++++ website/templates/base.html | 5 ++++- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/website/build.py b/website/build.py index 5ab9c9e..af10ebb 100644 --- a/website/build.py +++ b/website/build.py @@ -4,6 +4,7 @@ import json import re import shutil +from datetime import datetime, timezone from pathlib import Path from typing import TypedDict @@ -191,6 +192,7 @@ def build(repo_root: str) -> None: entries=entries, total_entries=total_entries, total_categories=len(categories), + build_date=datetime.now(timezone.utc).strftime("%B %d, %Y"), ), encoding="utf-8", ) diff --git a/website/static/style.css b/website/static/style.css index da52515..5133dab 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -908,12 +908,23 @@ th[data-sort].sort-asc::after { text-underline-offset: 0.2em; } +.footer-left { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + .footer-brand { font-weight: 700; letter-spacing: 0.03em; color: oklch(82% 0.02 75); } +.footer-date { + font-size: 0.7rem; + color: oklch(50% 0.02 55); +} + .footer-links { display: block; text-align: right; diff --git a/website/templates/base.html b/website/templates/base.html index 9a17e0c..cf48cfd 100644 --- a/website/templates/base.html +++ b/website/templates/base.html @@ -44,7 +44,10 @@
{% block content %}{% endblock %}
- Awesome Python +
From 38412182e7da1fb2e36e651e1b2c022e92ce2081 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 15:32:35 +0800 Subject: [PATCH 029/119] style: increase footer vertical padding from 2rem to 3rem Co-Authored-By: Claude --- website/static/style.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/static/style.css b/website/static/style.css index 5133dab..78a2c73 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -888,7 +888,7 @@ th[data-sort].sort-asc::after { .footer { margin-top: auto; background: oklch(16% 0.025 35); - padding: 2rem var(--shell-pad); + padding: 3rem var(--shell-pad); display: flex; align-items: center; justify-content: space-between; From 53d280ddcf4e8e646f1f1f421c5ee396ddede351 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 15:37:59 +0800 Subject: [PATCH 030/119] fix(css): scope final-cta grid to inner section-shell wrapper Move display:grid and gap from .final-cta to .final-cta > .section-shell so the grid context is applied to the correct container element, not the outer section. Wrap final-cta content in index.html with a section-shell div accordingly. Also fix .no-results bottom padding that was missing. Co-Authored-By: Claude --- website/static/style.css | 5 ++++- website/templates/index.html | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/website/static/style.css b/website/static/style.css index 78a2c73..98fa665 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -831,7 +831,7 @@ th[data-sort].sort-asc::after { } .no-results { - padding: 2.4rem var(--shell-pad) 0; + padding: 2.4rem var(--shell-pad); text-align: center; color: var(--ink-muted); font-size: var(--text-lg); @@ -860,6 +860,9 @@ th[data-sort].sort-asc::after { .final-cta { padding-block: clamp(3rem, 7vw, 5.5rem); background: oklch(94% 0.025 72); +} + +.final-cta > .section-shell { display: grid; gap: 1rem; } diff --git a/website/templates/index.html b/website/templates/index.html index d365de9..6abd906 100644 --- a/website/templates/index.html +++ b/website/templates/index.html @@ -243,7 +243,8 @@
-
+
+

Know a project that belongs here?

Tell us what it does and why it stands out.

@@ -263,5 +264,6 @@ >Star the repository
+
{% endblock %} From b12d80f67e7a43707ff77227c1a0f7f56f689158 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 15:42:35 +0800 Subject: [PATCH 031/119] fix(readme): correct playwright entry name to playwright-python The project name in the entry was 'playwright' but the GitHub repository is 'playwright-python', which is also how the Python package is referred to. Updated the display name to match. Co-Authored-By: Claude --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ed5b19c..d49b1a7 100644 --- a/README.md +++ b/README.md @@ -599,7 +599,7 @@ _Libraries for testing codebases and generating test data._ - [tox](https://github.com/tox-dev/tox) - Auto builds and tests distributions in multiple Python versions - GUI / Web Testing - [locust](https://github.com/locustio/locust) - Scalable user load testing tool written in Python. - - [playwright](https://github.com/microsoft/playwright-python) - Python version of the Playwright testing and automation library. + - [playwright-python](https://github.com/microsoft/playwright-python) - Python version of the Playwright testing and automation library. - [pyautogui](https://github.com/asweigart/pyautogui) - PyAutoGUI is a cross-platform GUI automation Python module for human beings. - [schemathesis](https://github.com/schemathesis/schemathesis) - A tool for automatic property-based testing of web applications built with Open API / Swagger specifications. - [selenium](https://github.com/SeleniumHQ/selenium) - Python bindings for [Selenium](https://selenium.dev/) [WebDriver](https://selenium.dev/documentation/webdriver/). From 5fc022d59589b6dd0fc9e37d833af6fb8538aa25 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 15:45:18 +0800 Subject: [PATCH 032/119] refactor(build): remove resources from build pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resources are no longer passed through parse_readme, group_categories, or the index template — they are replaced with empty lists and the unused variable is prefixed with an underscore. Co-Authored-By: Claude --- website/build.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/website/build.py b/website/build.py index af10ebb..29cbfc0 100644 --- a/website/build.py +++ b/website/build.py @@ -152,11 +152,11 @@ def build(repo_root: str) -> None: subtitle = stripped break - parsed_groups, resources = parse_readme(readme_text) + parsed_groups, _resources = parse_readme(readme_text) categories = [cat for g in parsed_groups for cat in g["categories"]] total_entries = sum(c["entry_count"] for c in categories) - groups = group_categories(parsed_groups, resources) + groups = group_categories(parsed_groups, []) entries = extract_entries(categories, groups) stars_data = load_stars(website / "data" / "github_stars.json") @@ -186,7 +186,7 @@ def build(repo_root: str) -> None: (site_dir / "index.html").write_text( tpl_index.render( categories=categories, - resources=resources, + resources=[], groups=groups, subtitle=subtitle, entries=entries, @@ -204,7 +204,7 @@ def build(repo_root: str) -> None: (site_dir / "llms.txt").write_text(readme_text, encoding="utf-8") - print(f"Built single page with {len(parsed_groups)} groups, {len(categories)} categories + {len(resources)} resources") + print(f"Built single page with {len(parsed_groups)} groups, {len(categories)} categories") print(f"Total entries: {total_entries}") print(f"Output: {site_dir}") From df2191fc053b3d746365208563cd3cbe9ade21b5 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 15:58:42 +0800 Subject: [PATCH 033/119] refactor(build): remove unused group_categories wrapper group_categories only ever appended a Resources group when the resources list was non-empty. All call sites passed an empty list, making it a no-op indirection. Inline parsed_groups directly and remove the dead code along with its tests. Co-Authored-By: Claude --- website/build.py | 29 ++++------------------------- website/tests/test_build.py | 35 ----------------------------------- 2 files changed, 4 insertions(+), 60 deletions(-) diff --git a/website/build.py b/website/build.py index 29cbfc0..2a6116a 100644 --- a/website/build.py +++ b/website/build.py @@ -9,26 +9,7 @@ from pathlib import Path from typing import TypedDict from jinja2 import Environment, FileSystemLoader -from readme_parser import parse_readme, slugify - - -def group_categories( - parsed_groups: list[dict], - resources: list[dict], -) -> list[dict]: - """Combine parsed groups with resources for template rendering.""" - groups = list(parsed_groups) - - if resources: - groups.append( - { - "name": "Resources", - "slug": slugify("Resources"), - "categories": list(resources), - } - ) - - return groups +from readme_parser import parse_readme class StarData(TypedDict): @@ -152,12 +133,11 @@ def build(repo_root: str) -> None: subtitle = stripped break - parsed_groups, _resources = parse_readme(readme_text) + parsed_groups, _ = parse_readme(readme_text) categories = [cat for g in parsed_groups for cat in g["categories"]] total_entries = sum(c["entry_count"] for c in categories) - groups = group_categories(parsed_groups, []) - entries = extract_entries(categories, groups) + entries = extract_entries(categories, parsed_groups) stars_data = load_stars(website / "data" / "github_stars.json") for entry in entries: @@ -186,8 +166,7 @@ def build(repo_root: str) -> None: (site_dir / "index.html").write_text( tpl_index.render( categories=categories, - resources=[], - groups=groups, + groups=parsed_groups, subtitle=subtitle, entries=entries, total_entries=total_entries, diff --git a/website/tests/test_build.py b/website/tests/test_build.py index 6302c3d..0e7eb48 100644 --- a/website/tests/test_build.py +++ b/website/tests/test_build.py @@ -8,7 +8,6 @@ from pathlib import Path from build import ( build, extract_github_repo, - group_categories, load_stars, sort_entries, ) @@ -42,40 +41,6 @@ class TestSlugify: assert slugify(" Date and Time ") == "date-and-time" -# --------------------------------------------------------------------------- -# group_categories -# --------------------------------------------------------------------------- - - -class TestGroupCategories: - def test_appends_resources(self): - parsed_groups = [ - {"name": "G1", "slug": "g1", "categories": [{"name": "Cat1"}]}, - ] - resources = [{"name": "Newsletters", "slug": "newsletters"}] - groups = group_categories(parsed_groups, resources) - group_names = [g["name"] for g in groups] - assert "G1" in group_names - assert "Resources" in group_names - - def test_no_resources_no_extra_group(self): - parsed_groups = [ - {"name": "G1", "slug": "g1", "categories": [{"name": "Cat1"}]}, - ] - groups = group_categories(parsed_groups, []) - assert len(groups) == 1 - assert groups[0]["name"] == "G1" - - def test_preserves_group_order(self): - parsed_groups = [ - {"name": "Second", "slug": "second", "categories": [{"name": "C2"}]}, - {"name": "First", "slug": "first", "categories": [{"name": "C1"}]}, - ] - groups = group_categories(parsed_groups, []) - assert groups[0]["name"] == "Second" - assert groups[1]["name"] == "First" - - # --------------------------------------------------------------------------- # build (integration) # --------------------------------------------------------------------------- From cd3c8ad0768575e03ae39f2652c97a2d26402ee6 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:03:47 +0800 Subject: [PATCH 034/119] refactor(css): consolidate --text-label and --text-tag into --text-xs Both variables mapped to near-identical small-text sizes already covered by --text-xs. Remove the redundant variables and migrate all call sites, including two remaining hardcoded rem values, to the shared token. Co-Authored-By: Claude --- website/static/style.css | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/website/static/style.css b/website/static/style.css index 98fa665..fbccd2d 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -35,8 +35,6 @@ --text-sm: 0.95rem; --text-base: 1rem; --text-lg: 1.125rem; - --text-label: 0.78rem; - --text-tag: 0.76rem; } html { @@ -183,7 +181,7 @@ kbd { } .hero-brand-mini { - font-size: var(--text-label); + font-size: var(--text-xs); font-weight: 800; letter-spacing: 0.04em; color: var(--hero-muted); @@ -250,7 +248,7 @@ kbd { .hero-kicker, .section-label { margin-bottom: 0.9rem; - font-size: var(--text-label); + font-size: var(--text-xs); font-weight: 800; letter-spacing: 0.04em; } @@ -678,7 +676,7 @@ th[data-sort].sort-asc::after { border-radius: 999px; background: var(--bg-paper-strong); color: var(--ink-soft); - font-size: 0.72rem; + font-size: var(--text-xs); font-weight: 700; letter-spacing: 0.02em; } @@ -774,7 +772,7 @@ th[data-sort].sort-asc::after { background: var(--accent-soft); color: var(--accent-deep); padding: 0.34rem 0.68rem; - font-size: var(--text-tag); + font-size: var(--text-xs); font-weight: 700; letter-spacing: 0.02em; cursor: pointer; @@ -810,7 +808,7 @@ th[data-sort].sort-asc::after { border: 0; background: none; color: var(--accent-deep); - font-size: 0.72rem; + font-size: var(--text-xs); font-weight: 800; letter-spacing: 0.03em; cursor: pointer; @@ -924,7 +922,7 @@ th[data-sort].sort-asc::after { } .footer-date { - font-size: 0.7rem; + font-size: var(--text-xs); color: oklch(50% 0.02 55); } @@ -1111,7 +1109,7 @@ th[data-sort].sort-asc::after { .mobile-cat { display: block; margin-top: 0.25rem; - font-size: var(--text-tag); + font-size: var(--text-xs); font-weight: 600; color: var(--ink-muted); } From 4bb9c1240b64a15b5a96769f57553491809d4cc6 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:10:14 +0800 Subject: [PATCH 035/119] fix(website): accessibility and defensive layout improvements - Add aria-sort attributes to table header on sort state changes - Replace .table-wrap:focus outline:none with focus-visible outline - Move noscript message above the fold into main, before content - Upgrade hero-topbar div to nav with aria-label for landmark semantics - Remove role=button from tr elements (invalid ARIA on native elements) - Fix back-to-top button label and text (was labelled 'Back to search') - Switch font-size from 16px to 100% to respect user browser preferences - Add overflow-wrap and word-break to .col-name and description cells Co-Authored-By: Claude --- website/static/main.js | 3 +++ website/static/style.css | 11 ++++++++--- website/templates/base.html | 15 +++++++++------ website/templates/index.html | 13 ++++++------- 4 files changed, 26 insertions(+), 16 deletions(-) diff --git a/website/static/main.js b/website/static/main.js index c1d8d01..83d5be0 100644 --- a/website/static/main.js +++ b/website/static/main.js @@ -212,6 +212,9 @@ function updateSortIndicators() { th.classList.remove('sort-asc', 'sort-desc'); if (activeSort && th.dataset.sort === activeSort.col) { th.classList.add('sort-' + activeSort.order); + th.setAttribute('aria-sort', activeSort.order === 'asc' ? 'ascending' : 'descending'); + } else { + th.removeAttribute('aria-sort'); } }); } diff --git a/website/static/style.css b/website/static/style.css index fbccd2d..26349eb 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -38,7 +38,7 @@ } html { - font-size: 16px; + font-size: 100%; scroll-behavior: smooth; } @@ -523,8 +523,9 @@ kbd { scroll-margin-top: 1rem; } -.table-wrap:focus { - outline: none; +.table-wrap:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; } .table { @@ -617,6 +618,8 @@ kbd { color: var(--ink); font-size: clamp(1rem, 1.5vw, 1.08rem); font-weight: 700; + overflow-wrap: break-word; + word-break: break-word; } .col-name > a:hover { @@ -734,6 +737,8 @@ th[data-sort].sort-asc::after { color: var(--ink-soft); line-height: 1.7; text-wrap: pretty; + overflow-wrap: break-word; + word-break: break-word; animation: expand-in 220ms cubic-bezier(0.22, 1, 0.36, 1); } diff --git a/website/templates/base.html b/website/templates/base.html index cf48cfd..317594d 100644 --- a/website/templates/base.html +++ b/website/templates/base.html @@ -41,7 +41,15 @@ -
{% block content %}{% endblock %}
+ {% block header %}{% endblock %} +
+ + {% block content %}{% endblock %} +
- diff --git a/website/templates/index.html b/website/templates/index.html index 6abd906..7168ba8 100644 --- a/website/templates/index.html +++ b/website/templates/index.html @@ -1,10 +1,10 @@ -{% extends "base.html" %} {% block content %} +{% extends "base.html" %} {% block header %}
-
+
+
@@ -68,7 +68,7 @@ Jump to the list
- +{% endblock %} {% block content %}
@@ -129,8 +129,8 @@ Last Commit Tags - @@ -139,7 +139,6 @@ {% for entry in entries %} Date: Sun, 22 Mar 2026 16:12:10 +0800 Subject: [PATCH 036/119] perf(fonts): trim Google Fonts to weights in use Narrowed the Cormorant Garamond request to 600 only and Manrope to 400/600/700/800, removing the unused 500 and 700 variants. Added font-weight: 600 to .final-cta h2 so the heading explicitly uses the retained weight. Co-Authored-By: Claude --- website/static/style.css | 1 + website/templates/base.html | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/website/static/style.css b/website/static/style.css index 26349eb..efc2afb 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -400,6 +400,7 @@ kbd { .final-cta h2 { font-family: var(--font-display); font-size: clamp(2.2rem, 4vw, 3.3rem); + font-weight: 600; line-height: 0.94; letter-spacing: -0.03em; } diff --git a/website/templates/base.html b/website/templates/base.html index 317594d..5bca928 100644 --- a/website/templates/base.html +++ b/website/templates/base.html @@ -21,7 +21,7 @@ From 80a5596b1169d946ba76290df5e8afa63a66d077 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:13:08 +0800 Subject: [PATCH 037/119] perf(hero): pause animations when hero scrolls out of view Uses IntersectionObserver to toggle an .offscreen class on the hero element, which sets animation-play-state: paused on the sheen, noise, and scroll-cue animations. Avoids unnecessary GPU work while the hero is not visible. Co-Authored-By: Claude --- website/static/main.js | 10 ++++++++++ website/static/style.css | 6 ++++++ 2 files changed, 16 insertions(+) diff --git a/website/static/main.js b/website/static/main.js index 83d5be0..da58fe2 100644 --- a/website/static/main.js +++ b/website/static/main.js @@ -41,6 +41,16 @@ function initRevealSections() { initRevealSections(); +// Pause hero animations when scrolled out of view +(function () { + var hero = document.querySelector('.hero'); + if (!hero || !('IntersectionObserver' in window)) return; + var observer = new IntersectionObserver(function (entries) { + hero.classList.toggle('offscreen', !entries[0].isIntersecting); + }); + observer.observe(hero); +})(); + // Relative time formatting function relativeTime(isoStr) { var date = new Date(isoStr); diff --git a/website/static/style.css b/website/static/style.css index efc2afb..17cd5fc 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -152,6 +152,12 @@ kbd { animation: sheen-drift 18s linear infinite; } +.hero.offscreen .hero-sheen, +.hero.offscreen .hero-noise, +.hero.offscreen .hero-scrollcue::after { + animation-play-state: paused; +} + .hero-noise { opacity: 0.1; background-image: From 50e27b992fd05d4994f2661b956437d6c60ca424 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:14:11 +0800 Subject: [PATCH 038/119] perf(css): add CSS containment to results section and detail panel Apply contain: layout style to .results-section and contain: layout style paint to the detail panel element to reduce browser layout recalculation scope during search interactions. Co-Authored-By: Claude --- website/static/style.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/website/static/style.css b/website/static/style.css index 17cd5fc..0e47751 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -413,6 +413,7 @@ kbd { .results-section { padding-block: clamp(2.5rem, 6vw, 4.5rem) 0; + contain: layout style; } .results-intro { @@ -746,6 +747,7 @@ th[data-sort].sort-asc::after { text-wrap: pretty; overflow-wrap: break-word; word-break: break-word; + contain: layout style paint; animation: expand-in 220ms cubic-bezier(0.22, 1, 0.36, 1); } From 302ae14c2d8d58749c6d3c91091757f6840c0003 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:14:56 +0800 Subject: [PATCH 039/119] refactor(css): remove backdrop-filter blur from table header Drops the blur(14px) backdrop-filter on the sticky table header and raises the background opacity from 0.92 to 0.97 so the header remains clearly readable without the compositing overhead. Co-Authored-By: Claude --- website/static/style.css | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/website/static/style.css b/website/static/style.css index 0e47751..078d334 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -571,8 +571,7 @@ kbd { letter-spacing: 0.03em; white-space: nowrap; border-bottom: 1px solid var(--line); - background: oklch(98.2% 0.012 80 / 0.92); - backdrop-filter: blur(14px); + background: oklch(98.2% 0.012 80 / 0.97); } .table tbody td { From 80a50511958318272d5728dfdbb0ae6c1cbad4a6 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:16:36 +0800 Subject: [PATCH 040/119] fix(css): increase expand meta/also-see font size to --text-sm --text-xs was too small for secondary metadata rows; bump to --text-sm for better readability. Co-Authored-By: Claude --- website/static/style.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/static/style.css b/website/static/style.css index 078d334..d9f5e46 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -769,7 +769,7 @@ th[data-sort].sort-asc::after { .expand-also-see, .expand-meta { margin-top: 0.45rem; - font-size: var(--text-xs); + font-size: var(--text-sm); color: var(--ink-muted); } From 6648961d7beff7038d90a1802474c764f5445886 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:19:38 +0800 Subject: [PATCH 041/119] fix(css): hide col-num and expand-row first-child at col-cat breakpoint Co-Authored-By: Claude --- website/static/style.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/website/static/style.css b/website/static/style.css index d9f5e46..ba5d011 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -1111,7 +1111,9 @@ th[data-sort].sort-asc::after { padding-right: 0.8rem; } - .col-cat { + .col-num, + .col-cat, + .expand-row td:first-child { display: none; } From 86aa6232609626ac98906ce0308768e4fd2e8197 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:20:14 +0800 Subject: [PATCH 042/119] fix(css): increase tag padding on mobile breakpoint Co-Authored-By: Claude --- website/static/style.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/website/static/style.css b/website/static/style.css index ba5d011..afe6bd1 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -1043,6 +1043,10 @@ th[data-sort].sort-asc::after { .tag-group { display: none; } + + .tag { + padding: 0.5rem 0.85rem; + } } @media (max-width: 680px) { From f2b635da19b90f0ca51650b891d62f2179b5ff81 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:20:52 +0800 Subject: [PATCH 043/119] fix(css): truncate long links in expand-meta and add mobile padding to expand-row .expand-meta links can overflow their container on narrow viewports. Apply ellipsis truncation to keep the row tidy. .expand-row td[colspan] gains symmetric inline padding on mobile to match the surrounding table spacing. Co-Authored-By: Claude --- website/static/style.css | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/website/static/style.css b/website/static/style.css index afe6bd1..206cc03 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -773,6 +773,15 @@ th[data-sort].sort-asc::after { color: var(--ink-muted); } +.expand-meta a { + display: inline-block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: bottom; +} + .expand-sep { margin-inline: 0.25rem; color: var(--line-strong); @@ -1133,6 +1142,11 @@ th[data-sort].sort-asc::after { color: var(--ink-muted); } + .expand-row td[colspan] { + padding-left: 0.8rem; + padding-right: 0.8rem; + } + .col-stars { width: 5.4rem; } From 58c0fd9e452a16e83536889bc1483f73a7a1955d Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:23:41 +0800 Subject: [PATCH 044/119] fix(css): extend focus-visible outline to no-results-clear and footer links Co-Authored-By: Claude --- website/static/style.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/website/static/style.css b/website/static/style.css index 206cc03..a1d8b38 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -352,7 +352,9 @@ kbd { .search:focus-visible, .filter-clear:focus-visible, .tag:focus-visible, -.back-to-top:focus-visible { +.back-to-top:focus-visible, +.no-results-clear:focus-visible, +.footer a:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } From 895da326f65b8f1415cbf4c22e0136479176852b Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:24:54 +0800 Subject: [PATCH 045/119] refactor(css): remove unused --bg-hover and --hero-shadow tokens Neither variable was referenced anywhere in the stylesheet. Removing dead tokens keeps the design token surface minimal. Co-Authored-By: Claude --- website/static/style.css | 2 -- 1 file changed, 2 deletions(-) diff --git a/website/static/style.css b/website/static/style.css index a1d8b38..275e181 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -15,7 +15,6 @@ --bg-page: oklch(96.8% 0.018 80); --bg-paper: oklch(98.6% 0.01 80); --bg-paper-strong: oklch(95.7% 0.016 76); - --bg-hover: oklch(93.8% 0.026 72); --ink: oklch(22% 0.02 55); --ink-soft: oklch(38% 0.018 55); --ink-muted: oklch(52% 0.02 55); @@ -26,7 +25,6 @@ --accent-soft: oklch(92% 0.045 55); --highlight: oklch(87% 0.08 78); --hero-ink: oklch(15% 0.02 40); - --hero-shadow: oklch(8% 0.02 35 / 0.5); --hero-text: oklch(97% 0.012 85); --hero-muted: oklch(88% 0.02 82); --hero-line: oklch(100% 0 0 / 0.16); From 7fa0a425dc59aa3683ee37d30e852a652efa095e Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:25:04 +0800 Subject: [PATCH 046/119] fix(css): remove outline:none suppression from .row:focus-visible td MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suppressing the outline on a focus-visible rule is self-defeating — it opts into the intent-based focus ring selector but then hides it. The row already receives a visible inset box-shadow on focus, so the outline:none was redundant and harmful to keyboard accessibility. Co-Authored-By: Claude --- website/static/style.css | 1 - 1 file changed, 1 deletion(-) diff --git a/website/static/style.css b/website/static/style.css index 275e181..c28e3a7 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -597,7 +597,6 @@ kbd { } .row:focus-visible td { - outline: none; background: oklch(95.7% 0.026 68); box-shadow: inset 3px 0 0 var(--accent); } From 3954a3e7423b8685e09feaf8151b0eb3478e9f71 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:26:27 +0800 Subject: [PATCH 047/119] refactor(css): extract footer color values into CSS custom properties Hardcoded oklch() values in footer rules are replaced with named tokens (--footer-bg, --footer-text, --footer-link, --footer-link-hover, --footer-muted, --footer-sep) declared in :root. No visual change. Co-Authored-By: Claude --- website/static/style.css | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/website/static/style.css b/website/static/style.css index c28e3a7..7ecd572 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -33,6 +33,13 @@ --text-sm: 0.95rem; --text-base: 1rem; --text-lg: 1.125rem; + + --footer-bg: oklch(16% 0.025 35); + --footer-text: oklch(72% 0.02 75); + --footer-link: oklch(82% 0.02 75); + --footer-link-hover: oklch(95% 0.01 80); + --footer-muted: oklch(50% 0.02 55); + --footer-sep: oklch(55% 0.02 55); } html { @@ -909,22 +916,22 @@ th[data-sort].sort-asc::after { .footer { margin-top: auto; - background: oklch(16% 0.025 35); + background: var(--footer-bg); padding: 3rem var(--shell-pad); display: flex; align-items: center; justify-content: space-between; gap: 1rem; font-size: var(--text-xs); - color: oklch(72% 0.02 75); + color: var(--footer-text); } .footer a { - color: oklch(82% 0.02 75); + color: var(--footer-link); } .footer a:hover { - color: oklch(95% 0.01 80); + color: var(--footer-link-hover); text-decoration: underline; text-decoration-color: oklch(95% 0.01 80 / 0.4); text-underline-offset: 0.2em; @@ -939,12 +946,12 @@ th[data-sort].sort-asc::after { .footer-brand { font-weight: 700; letter-spacing: 0.03em; - color: oklch(82% 0.02 75); + color: var(--footer-link); } .footer-date { font-size: var(--text-xs); - color: oklch(50% 0.02 55); + color: var(--footer-muted); } .footer-links { @@ -953,7 +960,7 @@ th[data-sort].sort-asc::after { } .footer-sep { - color: oklch(55% 0.02 55); + color: var(--footer-sep); } .noscript-msg { From 944787071535624b41c7d657ca3cbca1f11c1d1f Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:26:33 +0800 Subject: [PATCH 048/119] feat(html): add theme-color meta tag for browser UI chrome Sets the mobile browser toolbar color to match the dark footer/page background (#1c1410), preventing a jarring white chrome flash on load. Co-Authored-By: Claude --- website/templates/base.html | 1 + 1 file changed, 1 insertion(+) diff --git a/website/templates/base.html b/website/templates/base.html index 5bca928..5e7cea9 100644 --- a/website/templates/base.html +++ b/website/templates/base.html @@ -17,6 +17,7 @@ /> + From 321df7b78c07bdde862ebb24cd0dde2d31c7d333 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:28:44 +0800 Subject: [PATCH 049/119] refactor(hero): remove metrics block from hero section The projects/categories/topic groups stats added visual clutter to the hero without contributing to the core purpose of the section. Co-Authored-By: Claude --- website/static/style.css | 40 ------------------------------------ website/templates/index.html | 15 -------------- 2 files changed, 55 deletions(-) diff --git a/website/static/style.css b/website/static/style.css index 7ecd572..c77aa9e 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -306,11 +306,6 @@ kbd { margin-top: 1.75rem; } -.hero-actions, -.hero-metrics { - width: 100%; -} - .hero-action { display: inline-flex; align-items: center; @@ -364,33 +359,6 @@ kbd { outline-offset: 3px; } -.hero-metrics { - margin-top: clamp(1.8rem, 4vw, 2.8rem); - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 1.5rem; - max-width: none; -} - -.hero-metrics div { - padding-top: 0.9rem; - border-top: 1px solid var(--hero-line); -} - -.hero-metrics dt { - font-size: clamp(1.6rem, 3.2vw, 2.4rem); - font-weight: 800; - line-height: 1; - color: var(--hero-text); -} - -.hero-metrics dd { - margin-top: 0.3rem; - color: var(--hero-muted); - font-size: var(--text-xs); - letter-spacing: 0.02em; -} - .hero-scrollcue { align-self: flex-start; display: inline-flex; @@ -1094,14 +1062,6 @@ th[data-sort].sort-asc::after { font-size: clamp(3.6rem, 18vw, 5.2rem); } - .hero-metrics { - gap: 1rem; - } - - .hero-metrics div { - min-width: 0; - } - .search { min-height: 3.5rem; border-radius: 1.25rem; diff --git a/website/templates/index.html b/website/templates/index.html index 7168ba8..226c789 100644 --- a/website/templates/index.html +++ b/website/templates/index.html @@ -47,21 +47,6 @@ >View on GitHub
- -
-
-
{{ "{:,}".format(entries | length) }}
-
projects
-
-
-
{{ total_categories }}
-
categories
-
-
-
{{ groups | length }}
-
topic groups
-
-
From 014ba9e39407d1104d14ffe29a645fef8defe02d Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:29:47 +0800 Subject: [PATCH 050/119] refactor(hero): remove redundant scroll cue The "Jump to the list" anchor duplicated the "Browse the List" button. Removes the element, its CSS rules, the scroll-line keyframe animation, and cleans up the offscreen pause and focus-visible selector lists. Co-Authored-By: Claude Opus 4.6 (1M context) --- website/static/style.css | 36 ++---------------------------------- website/templates/index.html | 2 -- 2 files changed, 2 insertions(+), 36 deletions(-) diff --git a/website/static/style.css b/website/static/style.css index c77aa9e..73d147c 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -158,8 +158,7 @@ kbd { } .hero.offscreen .hero-sheen, -.hero.offscreen .hero-noise, -.hero.offscreen .hero-scrollcue::after { +.hero.offscreen .hero-noise { animation-play-state: paused; } @@ -198,8 +197,7 @@ kbd { color: var(--hero-muted); } -.hero-topbar-link:hover, -.hero-scrollcue:hover { +.hero-topbar-link:hover { color: var(--hero-text); } @@ -348,7 +346,6 @@ kbd { .hero-action:focus-visible, .hero-topbar-link:focus-visible, -.hero-scrollcue:focus-visible, .search:focus-visible, .filter-clear:focus-visible, .tag:focus-visible, @@ -359,24 +356,6 @@ kbd { outline-offset: 3px; } -.hero-scrollcue { - align-self: flex-start; - display: inline-flex; - align-items: center; - gap: 0.65rem; - color: var(--hero-muted); - font-size: var(--text-xs); - letter-spacing: 0.04em; -} - -.hero-scrollcue::after { - content: ""; - width: 3rem; - height: 1px; - background: var(--hero-line); - animation: scroll-line 1.6s ease-in-out infinite; -} - .results-intro h2, .final-cta h2 { font-family: var(--font-display); @@ -984,17 +963,6 @@ th[data-sort].sort-asc::after { } } -@keyframes scroll-line { - 0%, 100% { - transform: scaleX(0.4); - transform-origin: left; - } - 50% { - transform: scaleX(1); - transform-origin: left; - } -} - @keyframes sheen-drift { from { transform: translateX(-30%); diff --git a/website/templates/index.html b/website/templates/index.html index 226c789..cb8c276 100644 --- a/website/templates/index.html +++ b/website/templates/index.html @@ -49,8 +49,6 @@ - - Jump to the list {% endblock %} {% block content %} From a12fef4e54eb9f2c01a60f71e690eadd9fde9814 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:30:14 +0800 Subject: [PATCH 051/119] refactor(results): remove redundant "Library index" section label The heading "Search every project in one place" already communicates the section's purpose. The label above it was visual noise. Co-Authored-By: Claude Opus 4.6 (1M context) --- website/templates/index.html | 1 - 1 file changed, 1 deletion(-) diff --git a/website/templates/index.html b/website/templates/index.html index cb8c276..602df0b 100644 --- a/website/templates/index.html +++ b/website/templates/index.html @@ -55,7 +55,6 @@
-

Search every project in one place

From f11468b2629f3e398b1e04aab2477dcdb0773cc1 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:36:55 +0800 Subject: [PATCH 052/119] remove(readme): remove python-cqrs from Design Patterns Co-Authored-By: Claude --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index d49b1a7..061b899 100644 --- a/README.md +++ b/README.md @@ -543,7 +543,6 @@ _Python implementation of data structures, algorithms and design patterns. Also - [sortedcontainers](https://github.com/grantjenks/python-sortedcontainers) - Fast and pure-Python implementation of sorted collections. - [thealgorithms](https://github.com/TheAlgorithms/Python) - All Algorithms implemented in Python. - Design Patterns - - [python-cqrs](https://github.com/pypatterns/python-cqrs) - Event-Driven Architecture Framework with CQRS/CQS, Transaction Outbox, Saga orchestration. - [python-patterns](https://github.com/faif/python-patterns) - A collection of design patterns in Python. - [transitions](https://github.com/pytransitions/transitions) - A lightweight, object-oriented finite state machine implementation. From ef51d9a7aad0e19a7fee39aa1a07c5d047f55e4d Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:38:48 +0800 Subject: [PATCH 053/119] refactor(html-xml): replace cssutils with tinycss2 cssutils is unmaintained; tinycss2 is the actively maintained low-level CSS parser and generator for Python. Co-Authored-By: Claude --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 061b899..c4e24b5 100644 --- a/README.md +++ b/README.md @@ -832,7 +832,7 @@ _Libraries for parsing and manipulating plain texts._ _Libraries for working with HTML and XML._ - [beautifulsoup](https://www.crummy.com/software/BeautifulSoup/bs4/doc/) - Providing Pythonic idioms for iterating, searching, and modifying HTML or XML. -- [cssutils](https://github.com/jaraco/cssutils) - A CSS library for Python. +- [tinycss2](https://github.com/Kozea/tinycss2) - A low-level CSS parser and generator written in Python. - [justhtml](https://github.com/EmilStenstrom/justhtml/) - A pure Python HTML5 parser that just works. - [lxml](https://github.com/lxml/lxml) - A very fast, easy-to-use and versatile library for handling HTML and XML. - [markupsafe](https://github.com/pallets/markupsafe) - Implements a XML/HTML/XHTML Markup safe string for Python. From 8fbe0e03947d8be53ee857aea669864de302312b Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:41:36 +0800 Subject: [PATCH 054/119] fix(readme): sort tinycss2 alphabetically in HTML/XML section Co-Authored-By: Claude --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c4e24b5..231f65d 100644 --- a/README.md +++ b/README.md @@ -832,11 +832,11 @@ _Libraries for parsing and manipulating plain texts._ _Libraries for working with HTML and XML._ - [beautifulsoup](https://www.crummy.com/software/BeautifulSoup/bs4/doc/) - Providing Pythonic idioms for iterating, searching, and modifying HTML or XML. -- [tinycss2](https://github.com/Kozea/tinycss2) - A low-level CSS parser and generator written in Python. - [justhtml](https://github.com/EmilStenstrom/justhtml/) - A pure Python HTML5 parser that just works. - [lxml](https://github.com/lxml/lxml) - A very fast, easy-to-use and versatile library for handling HTML and XML. - [markupsafe](https://github.com/pallets/markupsafe) - Implements a XML/HTML/XHTML Markup safe string for Python. - [pyquery](https://github.com/gawel/pyquery) - A jQuery-like library for parsing HTML. +- [tinycss2](https://github.com/Kozea/tinycss2) - A low-level CSS parser and generator written in Python. - [xmltodict](https://github.com/martinblech/xmltodict) - Working with XML feel like you are working with JSON. ## File Format Processing From 1b5a0c2a3c151748e741095fcd1ba885f4110333 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:41:42 +0800 Subject: [PATCH 055/119] docs(claude): expand project structure and entry format docs Documents the website/ build pipeline, Makefile targets, pyproject.toml tooling, and tightens the entry format example and key rules. Co-Authored-By: Claude --- CLAUDE.md | 41 +++++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 81d1341..15b939a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,31 +2,36 @@ ## Repository Overview -This is the awesome-python repository - a curated list of Python frameworks, libraries, software and resources. The repository serves as a comprehensive directory about Python ecosystem. +A curated list of awesome Python frameworks, libraries, software and resources. Published at [awesome-python.com](https://awesome-python.com/). ## PR Review Guidelines -**For all PR review tasks, refer to [CONTRIBUTING.md](CONTRIBUTING.md)** which contains: +**Refer to [CONTRIBUTING.md](CONTRIBUTING.md)** for acceptance criteria, quality requirements, rejection rules, and entry format. -- Acceptance criteria (Industry Standard, Rising Star, Hidden Gem) -- Quality requirements -- Automatic rejection criteria -- Entry format reference -- PR description template +## Structure -## Architecture & Structure +- **README.md**: Source of truth. Hierarchical categories with alphabetically ordered entries. +- **CONTRIBUTING.md**: Submission guidelines and review criteria. +- **website/**: Static site generator that builds awesome-python.com from README.md. + - `build.py`: Parses README.md and renders HTML via Jinja2 templates. + - `fetch_github_stars.py`: Fetches star counts into `website/data/`. + - `readme_parser.py`: Markdown-to-structured-data parser. + - `templates/`, `static/`: Jinja2 templates and CSS/JS assets. + - `tests/`: Pytest tests for the build pipeline. +- **Makefile**: `make install`, `make build`, `make preview`, `make test`, `make fetch_github_stars`. +- **pyproject.toml**: Uses `uv` for dependency management. Python >=3.13. -The repository follows a single-file architecture: +## Entry Format -- **README.md**: All content in hierarchical structure (categories, subcategories, entries) -- **CONTRIBUTING.md**: Submission guidelines and review criteria -- **sort.py**: Script to enforce alphabetical ordering +```markdown +- [pypi-name](https://github.com/owner/repo) - Description ending with period. +``` -Entry format: `* [project-name](url) - Concise description ending with period.` +Use PyPI package name as display name. Use GitHub URLs when available. -## Key Considerations +## Key Rules -- This is a curated list, not a code project -- Quality over quantity - only "awesome" projects -- Alphabetical ordering within categories is mandatory -- README.md is the source of truth for all content +- Alphabetical ordering within categories is mandatory. +- Quality over quantity. Only "awesome" projects. +- One project per PR. +- README.md is the single source of content truth. From 7d1007d3739cd6268451f309ace51b6f045313c8 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:44:18 +0800 Subject: [PATCH 056/119] docs(claude): clarify repo description and entry naming rule Reword the overview from 'curated list' to 'opinionated list' to better reflect editorial intent. Rename 'pypi-name' placeholder to 'project-name' and add a rule for projects not published on PyPI. Co-Authored-By: Claude --- CLAUDE.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 15b939a..7210cc4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ ## Repository Overview -A curated list of awesome Python frameworks, libraries, software and resources. Published at [awesome-python.com](https://awesome-python.com/). +An opinionated list of Python frameworks, libraries, tools, and resources. Published at [awesome-python.com](https://awesome-python.com/). ## PR Review Guidelines @@ -24,10 +24,10 @@ A curated list of awesome Python frameworks, libraries, software and resources. ## Entry Format ```markdown -- [pypi-name](https://github.com/owner/repo) - Description ending with period. +- [project-name](https://github.com/owner/repo) - Description ending with period. ``` -Use PyPI package name as display name. Use GitHub URLs when available. +Use PyPI package name as display name. If not on PyPI, use the GitHub repo name. Use GitHub URLs when available. ## Key Rules From a92b1a6e867fd4088b521086c2cff1851c8cae6f Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 23:42:18 +0800 Subject: [PATCH 057/119] fix(css): fix hero topbar layout on small screens Split hero-topbar and footer selectors to apply distinct responsive styles. The topbar now uses a horizontal row layout with nowrap so the nav link does not stack vertically, while hero-topbar-actions and hero-topbar-link get auto widths to avoid stretching full-width. Co-Authored-By: Claude --- website/static/style.css | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/website/static/style.css b/website/static/style.css index 73d147c..7e0f847 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -1005,19 +1005,34 @@ th[data-sort].sort-asc::after { min-height: auto; } - .hero-topbar, + .hero-topbar { + align-items: center; + flex-direction: row; + flex-wrap: nowrap; + justify-content: space-between; + gap: 0.75rem; + } + .footer { align-items: flex-start; flex-direction: column; } - .hero-topbar-actions, .hero-actions, .final-cta-actions { width: 100%; } - .hero-topbar-link, + .hero-topbar-actions { + width: auto; + flex: 0 0 auto; + } + + .hero-topbar-link { + width: auto; + white-space: nowrap; + } + .hero-action { width: 100%; } From dbff2522c879813a202d87ba31cd08cdff88bcc9 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 23:52:31 +0800 Subject: [PATCH 058/119] fix(css): hide last column in expand-row on mobile Co-Authored-By: Claude --- website/static/style.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/website/static/style.css b/website/static/style.css index 7e0f847..38eeab2 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -1075,7 +1075,8 @@ th[data-sort].sort-asc::after { .col-num, .col-cat, - .expand-row td:first-child { + .expand-row td:first-child, + .expand-row td:last-child { display: none; } From 88031d78a5997b896e321feac08d5766b816a052 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 23:58:54 +0800 Subject: [PATCH 059/119] fix(css): center footer on mobile Consolidate two conflicting mobile footer rules into one, setting flex-direction to column, align-items to center, and text-align to center so the footer is symmetrically centered on narrow viewports. Co-Authored-By: Claude --- website/static/style.css | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/website/static/style.css b/website/static/style.css index 38eeab2..724c133 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -1014,8 +1014,9 @@ th[data-sort].sort-asc::after { } .footer { - align-items: flex-start; flex-direction: column; + align-items: center; + text-align: center; } .hero-actions, @@ -1037,10 +1038,6 @@ th[data-sort].sort-asc::after { width: 100%; } - .footer { - align-items: flex-end; - } - .hero h1 { font-size: clamp(3.6rem, 18vw, 5.2rem); } From 3395b2e4284609c0c0ded72839fe3dd3f30add13 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Mon, 23 Mar 2026 00:09:06 +0800 Subject: [PATCH 060/119] fix(css): enable table horizontal scroll at 768px breakpoint Moves .table-wrap overflow-x rule from the 680px breakpoint to 768px so the table becomes scrollable before it gets too narrow to read. Co-Authored-By: Claude --- website/static/style.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/website/static/style.css b/website/static/style.css index 724c133..5c3d796 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -998,6 +998,10 @@ th[data-sort].sort-asc::after { .tag { padding: 0.5rem 0.85rem; } + + .table-wrap { + overflow-x: auto; + } } @media (max-width: 680px) { @@ -1047,10 +1051,6 @@ th[data-sort].sort-asc::after { border-radius: 1.25rem; } - .table-wrap { - overflow-x: auto; - } - .table thead th { position: static; } From 25d3f307cc3ddbe61f5660a194e94ba5a801d509 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Mon, 23 Mar 2026 00:25:35 +0800 Subject: [PATCH 061/119] docs(readme): reword sponsorship tagline Replace 'where Python developers discover tools' with 'in front of Python developers' for cleaner, more direct phrasing. Co-Authored-By: Claude --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 231f65d..e396e42 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ An opinionated list of Python frameworks, libraries, tools, and resources. -> The **#10 most-starred repo on GitHub**. Put your product where Python developers discover tools. [Become a sponsor](SPONSORSHIP.md). +> The **#10 most-starred repo on GitHub**. Put your product in front of Python developers. [Become a sponsor](SPONSORSHIP.md). # Categories From 394803d2be09af2cd89897e9d012cfac83d4f66b Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Mon, 23 Mar 2026 00:58:31 +0800 Subject: [PATCH 062/119] docs(readme): add agent skills entries Add Python-focused descriptions for Trail of Bits, Sentry Skills, and Django AI Plugins in the AI and Agents section. --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index e396e42..b9e622a 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,10 @@ An opinionated list of Python frameworks, libraries, tools, and resources. _Libraries for building AI applications, LLM integrations, and autonomous agents._ +- Agent Skills + - [django-ai-plugins](https://github.com/vintasoftware/django-ai-plugins) - Django backend agent skills for Django, DRF, Celery, and Django-specific code review. + - [sentry-skills](https://github.com/getsentry/skills) - Python-focused engineering skills for code review, debugging, and backend workflows. + - [trailofbits](https://github.com/trailofbits/skills) - Python-friendly security skills for auditing, testing, and safer backend development. - Frameworks - [autogen](https://github.com/microsoft/autogen) - A programming framework for building agentic AI applications. - [crewai](https://github.com/crewAIInc/crewAI) - A framework for orchestrating role-playing autonomous AI agents for collaborative task solving. From 1c249d4b5fe59bd563120bbdc41606ca3426d2a2 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Mon, 23 Mar 2026 01:04:13 +0800 Subject: [PATCH 063/119] docs(readme): rename Testing Frameworks subcategory to Frameworks Shorter label that reads more naturally in the context of the Testing section, which already scopes it to testing. Co-Authored-By: Claude --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b9e622a..356099a 100644 --- a/README.md +++ b/README.md @@ -591,7 +591,7 @@ _Tools of static analysis, linters and code quality checkers. Also see [awesome- _Libraries for testing codebases and generating test data._ -- Testing Frameworks +- Frameworks - [hypothesis](https://github.com/HypothesisWorks/hypothesis) - Hypothesis is an advanced Quickcheck style property based testing library. - [pytest](https://github.com/pytest-dev/pytest) - A mature full-featured Python testing tool. - [robotframework](https://github.com/robotframework/robotframework) - A generic test automation framework. From f2b4a7bc83ef4a61a9e8bbd574dd302ac4926356 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Mon, 23 Mar 2026 01:04:20 +0800 Subject: [PATCH 064/119] feat(website): surface subcategory labels as filterable tags Entries nested under a plain-text subcategory heading (e.g. "Frameworks" inside Testing) now carry a subcategory field populated by the parser. The build pipeline collects these into a subcategories list on each merged entry, and the template renders them as tag-subcat buttons that plug into the existing data-cats filter mechanism. A dedicated .tag-subcat style distinguishes them visually from category tags, and both are hidden on mobile alongside .tag-group. Co-Authored-By: Claude --- website/build.py | 4 ++++ website/readme_parser.py | 11 +++++++++-- website/static/style.css | 9 ++++++++- website/templates/index.html | 10 +++++++++- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/website/build.py b/website/build.py index 2a6116a..821c681 100644 --- a/website/build.py +++ b/website/build.py @@ -102,6 +102,9 @@ def extract_entries( existing["categories"].append(cat["name"]) if group_name not in existing["groups"]: existing["groups"].append(group_name) + subcat = entry["subcategory"] + if subcat and subcat not in existing["subcategories"]: + existing["subcategories"].append(subcat) else: merged = { "name": entry["name"], @@ -109,6 +112,7 @@ def extract_entries( "description": entry["description"], "categories": [cat["name"]], "groups": [group_name], + "subcategories": [entry["subcategory"]] if entry["subcategory"] else [], "stars": None, "owner": None, "last_commit_at": None, diff --git a/website/readme_parser.py b/website/readme_parser.py index c0ecfc6..1068a33 100644 --- a/website/readme_parser.py +++ b/website/readme_parser.py @@ -20,6 +20,7 @@ class ParsedEntry(TypedDict): url: str description: str # inline HTML, properly escaped also_see: list[AlsoSee] + subcategory: str # sub-category label, empty if none class ParsedSection(TypedDict): @@ -178,7 +179,11 @@ def _extract_description_html(inline: SyntaxTreeNode, first_link: SyntaxTreeNode return _DESC_SEP_RE.sub("", html) -def _parse_list_entries(bullet_list: SyntaxTreeNode) -> list[ParsedEntry]: +def _parse_list_entries( + bullet_list: SyntaxTreeNode, + *, + subcategory: str = "", +) -> list[ParsedEntry]: """Extract entries from a bullet_list AST node. Handles three patterns: @@ -200,9 +205,10 @@ def _parse_list_entries(bullet_list: SyntaxTreeNode) -> list[ParsedEntry]: 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 + label = render_inline_text(inline.children) nested = _find_child(list_item, "bullet_list") if nested: - entries.extend(_parse_list_entries(nested)) + entries.extend(_parse_list_entries(nested, subcategory=label)) continue # Entry with a link @@ -231,6 +237,7 @@ def _parse_list_entries(bullet_list: SyntaxTreeNode) -> list[ParsedEntry]: url=url, description=desc_html, also_see=also_see, + subcategory=subcategory, )) return entries diff --git a/website/static/style.css b/website/static/style.css index 5c3d796..97d6b86 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -779,6 +779,12 @@ th[data-sort].sort-asc::after { color: var(--hero-ink); } +.tag-subcat { + background: var(--bg-paper-strong); + color: var(--ink-soft); + font-weight: 600; +} + .back-to-top { border: 0; background: none; @@ -991,7 +997,8 @@ th[data-sort].sort-asc::after { display: none; } - .tag-group { + .tag-group, + .tag-subcat { display: none; } diff --git a/website/templates/index.html b/website/templates/index.html index 602df0b..0689d26 100644 --- a/website/templates/index.html +++ b/website/templates/index.html @@ -121,7 +121,7 @@ {% for entry in entries %} {{ cat }} + {% endfor %} {% for subcat in entry.subcategories %} + {% endfor %} - {% endfor %} {% for subcat in entry.subcategories %} + {% for subcat in entry.subcategories %} + {% endfor %} {% for cat in entry.categories %} + {% endfor %} {% endfor %} {% for cat in entry.categories %} From 31fa9a4c3844f2fc0f816939d99c91ee40ca6ae6 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Mon, 23 Mar 2026 01:32:55 +0800 Subject: [PATCH 073/119] fix(css): reduce tag badge size and spacing --- website/static/style.css | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/website/static/style.css b/website/static/style.css index 5c3d796..daf018f 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -746,8 +746,8 @@ th[data-sort].sort-asc::after { border-radius: 999px; background: var(--accent-soft); color: var(--accent-deep); - padding: 0.34rem 0.68rem; - font-size: var(--text-xs); + padding: 0.14rem 0.48rem; + font-size: 0.6rem; font-weight: 700; letter-spacing: 0.02em; cursor: pointer; @@ -759,13 +759,13 @@ th[data-sort].sort-asc::after { } .tag + .tag { - margin-left: 0.4rem; + margin-left: 0.2rem; } .tag::after { content: ""; position: absolute; - inset: -0.45rem -0.2rem; + inset: -0.35rem -0.15rem; } .tag:hover { @@ -996,7 +996,7 @@ th[data-sort].sort-asc::after { } .tag { - padding: 0.5rem 0.85rem; + padding: 0.32rem 0.6rem; } .table-wrap { From 028c642a8e87ab3421caa7be1d0165d002729034 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Mon, 23 Mar 2026 01:34:33 +0800 Subject: [PATCH 074/119] refactor(js): replace var with let/const and use double quotes Modernize variable declarations and string literals in main.js for consistency and to signal immutability intent. Pure style change with no behavioral differences. Co-Authored-By: Claude --- website/static/main.js | 331 ++++++++++++++++++++++------------------- 1 file changed, 181 insertions(+), 150 deletions(-) diff --git a/website/static/main.js b/website/static/main.js index da58fe2..f1150b0 100644 --- a/website/static/main.js +++ b/website/static/main.js @@ -1,40 +1,43 @@ // 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 rows = document.querySelectorAll('.table tbody tr.row'); -var tags = document.querySelectorAll('.tag'); -var tbody = document.querySelector('.table tbody'); +let activeFilter = null; // { type: "cat"|"group", value: "..." } +let activeSort = { col: "stars", order: "desc" }; +const searchInput = document.querySelector(".search"); +const filterBar = document.querySelector(".filter-bar"); +const filterValue = document.querySelector(".filter-value"); +const filterClear = document.querySelector(".filter-clear"); +const noResults = document.querySelector(".no-results"); +const rows = document.querySelectorAll(".table tbody tr.row"); +const tags = document.querySelectorAll(".tag"); +const tbody = document.querySelector(".table tbody"); function initRevealSections() { - var sections = document.querySelectorAll('[data-reveal]'); + const sections = document.querySelectorAll("[data-reveal]"); if (!sections.length) return; - if (!('IntersectionObserver' in window)) { + if (!("IntersectionObserver" in window)) { sections.forEach(function (section) { - section.classList.add('is-visible'); + section.classList.add("is-visible"); }); return; } - var observer = new IntersectionObserver(function (entries) { - entries.forEach(function (entry) { - if (!entry.isIntersecting) return; - entry.target.classList.add('is-visible'); - observer.unobserve(entry.target); - }); - }, { - threshold: 0.12, - rootMargin: '0px 0px -8% 0px', - }); + const observer = new IntersectionObserver( + function (entries) { + entries.forEach(function (entry) { + if (!entry.isIntersecting) return; + entry.target.classList.add("is-visible"); + observer.unobserve(entry.target); + }); + }, + { + threshold: 0.12, + rootMargin: "0px 0px -8% 0px", + }, + ); sections.forEach(function (section, index) { - section.classList.add('will-reveal'); - section.style.transitionDelay = Math.min(index * 70, 180) + 'ms'; + section.classList.add("will-reveal"); + section.style.transitionDelay = Math.min(index * 70, 180) + "ms"; observer.observe(section); }); } @@ -43,34 +46,36 @@ initRevealSections(); // Pause hero animations when scrolled out of view (function () { - var hero = document.querySelector('.hero'); - if (!hero || !('IntersectionObserver' in window)) return; - var observer = new IntersectionObserver(function (entries) { - hero.classList.toggle('offscreen', !entries[0].isIntersecting); + const hero = document.querySelector(".hero"); + if (!hero || !("IntersectionObserver" in window)) return; + const observer = new IntersectionObserver(function (entries) { + hero.classList.toggle("offscreen", !entries[0].isIntersecting); }); observer.observe(hero); })(); // 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'; + const date = new Date(isoStr); + const now = new Date(); + const diffMs = now - date; + const diffHours = Math.floor(diffMs / 3600000); + const 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"; + const diffMonths = Math.floor(diffDays / 30); + if (diffMonths < 12) + return diffMonths === 1 ? "1 month ago" : diffMonths + " months ago"; + const 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'); +document.querySelectorAll(".col-commit[data-commit]").forEach(function (td) { + const time = td.querySelector("time"); if (time) time.textContent = relativeTime(td.dataset.commit); }); @@ -81,36 +86,37 @@ rows.forEach(function (row, i) { }); function collapseAll() { - var openRows = document.querySelectorAll('.table tbody tr.row.open'); + const openRows = document.querySelectorAll(".table tbody tr.row.open"); openRows.forEach(function (row) { - row.classList.remove('open'); - row.setAttribute('aria-expanded', 'false'); + row.classList.remove("open"); + row.setAttribute("aria-expanded", "false"); }); } function applyFilters() { - var query = searchInput ? searchInput.value.toLowerCase().trim() : ''; - var visibleCount = 0; + const query = searchInput ? searchInput.value.toLowerCase().trim() : ""; + let visibleCount = 0; // Collapse all expanded rows on filter/search change collapseAll(); rows.forEach(function (row) { - var show = true; + let show = true; // Category/group filter if (activeFilter) { - var attr = activeFilter.type === 'cat' ? row.dataset.cats : row.dataset.groups; - show = attr ? attr.split('||').indexOf(activeFilter.value) !== -1 : false; + const attr = + activeFilter.type === "cat" ? row.dataset.cats : row.dataset.groups; + show = attr ? attr.split("||").indexOf(activeFilter.value) !== -1 : false; } // Text search if (show && query) { if (!row._searchText) { - var text = row.textContent.toLowerCase(); - var next = row.nextElementSibling; - if (next && next.classList.contains('expand-row')) { - text += ' ' + next.textContent.toLowerCase(); + let text = row.textContent.toLowerCase(); + const next = row.nextElementSibling; + if (next && next.classList.contains("expand-row")) { + text += " " + next.textContent.toLowerCase(); } row._searchText = text; } @@ -121,7 +127,7 @@ function applyFilters() { if (show) { visibleCount++; - var numCell = row.cells[0]; + const numCell = row.cells[0]; if (numCell.textContent !== String(visibleCount)) { numCell.textContent = String(visibleCount); } @@ -132,19 +138,20 @@ function applyFilters() { // Update tag highlights tags.forEach(function (tag) { - var isActive = activeFilter - && tag.dataset.type === activeFilter.type - && tag.dataset.value === activeFilter.value; - tag.classList.toggle('active', isActive); + const isActive = + activeFilter && + tag.dataset.type === activeFilter.type && + tag.dataset.value === activeFilter.value; + tag.classList.toggle("active", isActive); }); // Filter bar if (filterBar) { if (activeFilter) { - filterBar.classList.add('visible'); + filterBar.classList.add("visible"); if (filterValue) filterValue.textContent = activeFilter.value; } else { - filterBar.classList.remove('visible'); + filterBar.classList.remove("visible"); } } @@ -152,40 +159,46 @@ function applyFilters() { } function updateURL() { - var params = new URLSearchParams(); - var query = searchInput ? searchInput.value.trim() : ''; - if (query) params.set('q', query); + const params = new URLSearchParams(); + const query = searchInput ? searchInput.value.trim() : ""; + if (query) params.set("q", query); if (activeFilter) { - params.set(activeFilter.type === 'cat' ? 'category' : 'group', activeFilter.value); + 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); + 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); + const 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 === "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); + if (col === "stars") { + const text = row + .querySelector(".col-stars") + .textContent.trim() + .replace(/,/g, ""); + const num = parseInt(text, 10); return isNaN(num) ? -1 : num; } - if (col === 'commit-time') { - var attr = row.querySelector('.col-commit').getAttribute('data-commit'); + if (col === "commit-time") { + const 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); - var col = activeSort.col; - var order = activeSort.order; + const arr = Array.prototype.slice.call(rows); + const col = activeSort.col; + const order = activeSort.order; // Cache sort values once to avoid DOM queries per comparison arr.forEach(function (row) { @@ -193,22 +206,22 @@ function sortRows() { }); arr.sort(function (a, b) { - var aVal = a._sortVal; - var bVal = b._sortVal; - if (col === 'name') { - var cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0; + const aVal = a._sortVal; + const bVal = b._sortVal; + if (col === "name") { + const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0; if (cmp === 0) return a._origIndex - b._origIndex; - return order === 'desc' ? -cmp : cmp; + return 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; + const cmp = aVal - bVal; if (cmp === 0) return a._origIndex - b._origIndex; - return order === 'desc' ? -cmp : cmp; + return order === "desc" ? -cmp : cmp; }); - var frag = document.createDocumentFragment(); + const frag = document.createDocumentFragment(); arr.forEach(function (row) { frag.appendChild(row); frag.appendChild(row._expandRow); @@ -218,40 +231,43 @@ function sortRows() { } function updateSortIndicators() { - document.querySelectorAll('th[data-sort]').forEach(function (th) { - th.classList.remove('sort-asc', 'sort-desc'); + 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); - th.setAttribute('aria-sort', activeSort.order === 'asc' ? 'ascending' : 'descending'); + th.classList.add("sort-" + activeSort.order); + th.setAttribute( + "aria-sort", + activeSort.order === "asc" ? "ascending" : "descending", + ); } else { - th.removeAttribute('aria-sort'); + th.removeAttribute("aria-sort"); } }); } // Expand/collapse: event delegation on tbody if (tbody) { - tbody.addEventListener('click', function (e) { + tbody.addEventListener("click", function (e) { // Don't toggle if clicking a link or tag button - if (e.target.closest('a') || e.target.closest('.tag')) return; + if (e.target.closest("a") || e.target.closest(".tag")) return; - var row = e.target.closest('tr.row'); + const row = e.target.closest("tr.row"); if (!row) return; - var isOpen = row.classList.contains('open'); + const isOpen = row.classList.contains("open"); if (isOpen) { - row.classList.remove('open'); - row.setAttribute('aria-expanded', 'false'); + row.classList.remove("open"); + row.setAttribute("aria-expanded", "false"); } else { - row.classList.add('open'); - row.setAttribute('aria-expanded', 'true'); + row.classList.add("open"); + row.setAttribute("aria-expanded", "true"); } }); // Keyboard: Enter or Space on focused .row toggles expand - tbody.addEventListener('keydown', function (e) { - if (e.key !== 'Enter' && e.key !== ' ') return; - var row = e.target.closest('tr.row'); + tbody.addEventListener("keydown", function (e) { + if (e.key !== "Enter" && e.key !== " ") return; + const row = e.target.closest("tr.row"); if (!row) return; e.preventDefault(); row.click(); @@ -260,13 +276,17 @@ if (tbody) { // Tag click: filter by category or group tags.forEach(function (tag) { - tag.addEventListener('click', function (e) { + tag.addEventListener("click", function (e) { e.preventDefault(); - var type = tag.dataset.type; - var value = tag.dataset.value; + const type = tag.dataset.type; + const value = tag.dataset.value; // Toggle: click same filter again to clear - if (activeFilter && activeFilter.type === type && activeFilter.value === value) { + if ( + activeFilter && + activeFilter.type === type && + activeFilter.value === value + ) { activeFilter = null; } else { activeFilter = { type: type, value: value }; @@ -277,31 +297,32 @@ tags.forEach(function (tag) { // Clear filter if (filterClear) { - filterClear.addEventListener('click', function () { + filterClear.addEventListener("click", function () { activeFilter = null; applyFilters(); }); } // No-results clear -var noResultsClear = document.querySelector('.no-results-clear'); +const noResultsClear = document.querySelector(".no-results-clear"); if (noResultsClear) { - noResultsClear.addEventListener('click', function () { - if (searchInput) searchInput.value = ''; + noResultsClear.addEventListener("click", function () { + if (searchInput) searchInput.value = ""; activeFilter = null; applyFilters(); }); } // 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'; +document.querySelectorAll("th[data-sort]").forEach(function (th) { + th.addEventListener("click", function () { + const col = th.dataset.sort; + const defaultOrder = col === "name" ? "asc" : "desc"; + const 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' }; + if (activeSort.order === defaultOrder) + activeSort = { col: col, order: altOrder }; + else activeSort = { col: "stars", order: "desc" }; } else { activeSort = { col: col, order: defaultOrder }; } @@ -312,20 +333,27 @@ document.querySelectorAll('th[data-sort]').forEach(function (th) { // Search input if (searchInput) { - var searchTimer; - searchInput.addEventListener('input', function () { + let searchTimer; + searchInput.addEventListener("input", function () { clearTimeout(searchTimer); searchTimer = setTimeout(applyFilters, 150); }); // Keyboard shortcuts - document.addEventListener('keydown', function (e) { - if (e.key === '/' && !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName) && !e.ctrlKey && !e.metaKey) { + document.addEventListener("keydown", function (e) { + if ( + e.key === "/" && + !["INPUT", "TEXTAREA", "SELECT"].includes( + document.activeElement.tagName, + ) && + !e.ctrlKey && + !e.metaKey + ) { e.preventDefault(); searchInput.focus(); } - if (e.key === 'Escape' && document.activeElement === searchInput) { - searchInput.value = ''; + if (e.key === "Escape" && document.activeElement === searchInput) { + searchInput.value = ""; activeFilter = null; applyFilters(); searchInput.blur(); @@ -334,24 +362,24 @@ if (searchInput) { } // Back to top -var backToTop = document.querySelector('.back-to-top'); -var resultsSection = document.querySelector('#library-index'); -var tableWrap = document.querySelector('.table-wrap'); -var stickyHeaderCell = backToTop ? backToTop.closest('th') : null; +const backToTop = document.querySelector(".back-to-top"); +const resultsSection = document.querySelector("#library-index"); +const tableWrap = document.querySelector(".table-wrap"); +const stickyHeaderCell = backToTop ? backToTop.closest("th") : null; function updateBackToTopVisibility() { if (!backToTop || !tableWrap || !stickyHeaderCell) return; - var tableRect = tableWrap.getBoundingClientRect(); - var headRect = stickyHeaderCell.getBoundingClientRect(); - var hasPassedHeader = tableRect.top <= 0 && headRect.bottom > 0; + const tableRect = tableWrap.getBoundingClientRect(); + const headRect = stickyHeaderCell.getBoundingClientRect(); + const hasPassedHeader = tableRect.top <= 0 && headRect.bottom > 0; - backToTop.classList.toggle('visible', hasPassedHeader); + backToTop.classList.toggle("visible", hasPassedHeader); } if (backToTop) { - var scrollTicking = false; - window.addEventListener('scroll', function () { + let scrollTicking = false; + window.addEventListener("scroll", function () { if (!scrollTicking) { requestAnimationFrame(function () { updateBackToTopVisibility(); @@ -361,12 +389,12 @@ if (backToTop) { } }); - window.addEventListener('resize', updateBackToTopVisibility); + window.addEventListener("resize", updateBackToTopVisibility); - backToTop.addEventListener('click', function () { - var target = searchInput || resultsSection; + backToTop.addEventListener("click", function () { + const target = searchInput || resultsSection; if (!target) return; - target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + target.scrollIntoView({ behavior: "smooth", block: "center" }); if (searchInput) searchInput.focus(); }); @@ -375,16 +403,19 @@ if (backToTop) { // 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'); + const params = new URLSearchParams(location.search); + const q = params.get("q"); + const cat = params.get("category"); + const group = params.get("group"); + const sort = params.get("sort"); + const 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')) { + 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) { From c5dd3060efddabb17770f8f4c09ce48a002416d9 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Mon, 23 Mar 2026 01:43:12 +0800 Subject: [PATCH 075/119] chore: add __pycache__ to .gitignore and remove sys.path hack in tests Co-Authored-By: Claude --- .gitignore | 1 + website/tests/test_fetch_github_stars.py | 3 --- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 8c0ca9e..ca26a6e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ # python .venv/ +__pycache__/ *.py[co] # website diff --git a/website/tests/test_fetch_github_stars.py b/website/tests/test_fetch_github_stars.py index 6c9eb38..ecdccf7 100644 --- a/website/tests/test_fetch_github_stars.py +++ b/website/tests/test_fetch_github_stars.py @@ -1,10 +1,7 @@ """Tests for fetch_github_stars module.""" import json -import os -import sys -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from fetch_github_stars import ( build_graphql_query, extract_github_repos, From 25a3f4d9037a796b8106e137286f6ba88b8d3cd6 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Mon, 23 Mar 2026 01:43:19 +0800 Subject: [PATCH 076/119] refactor(parser): remove resources parsing, preview, and content_html fields parse_readme now returns list[ParsedGroup] instead of a tuple. The resources section (Newsletters, Podcasts), preview string, and content_html rendering are no longer produced by the parser or consumed by the build. Removes _render_section_html, _group_by_h2, and the associated dead code and tests. Co-Authored-By: Claude --- website/build.py | 3 +- website/readme_parser.py | 132 +++----------------------- website/tests/test_build.py | 16 +--- website/tests/test_readme_parser.py | 139 ++++------------------------ 4 files changed, 37 insertions(+), 253 deletions(-) diff --git a/website/build.py b/website/build.py index 6ff46df..f205747 100644 --- a/website/build.py +++ b/website/build.py @@ -139,7 +139,7 @@ def build(repo_root: str) -> None: subtitle = stripped break - parsed_groups, _ = parse_readme(readme_text) + parsed_groups = parse_readme(readme_text) categories = [cat for g in parsed_groups for cat in g["categories"]] total_entries = sum(c["entry_count"] for c in categories) @@ -172,7 +172,6 @@ def build(repo_root: str) -> None: (site_dir / "index.html").write_text( tpl_index.render( categories=categories, - groups=parsed_groups, subtitle=subtitle, entries=entries, total_entries=total_entries, diff --git a/website/readme_parser.py b/website/readme_parser.py index 91b0faf..4f36ed7 100644 --- a/website/readme_parser.py +++ b/website/readme_parser.py @@ -29,8 +29,6 @@ class ParsedSection(TypedDict): description: str # plain text, links resolved to text entries: list[ParsedEntry] entry_count: int - preview: str - content_html: str # rendered HTML, properly escaped class ParsedGroup(TypedDict): @@ -258,69 +256,6 @@ def _parse_section_entries(content_nodes: list[SyntaxTreeNode]) -> list[ParsedEn return entries -# --- Content HTML rendering -------------------------------------------------- - - -def _render_bullet_list_html( - bullet_list: SyntaxTreeNode, - *, - is_sub: bool = False, -) -> str: - """Render a bullet_list node to HTML with entry/entry-sub/subcat classes.""" - out: list[str] = [] - - for list_item in bullet_list.children: - if list_item.type != "list_item": - continue - - inline = _find_inline(list_item) - if inline is None: - continue - - first_link = _find_first_link(inline) - - 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'

{label}
') - nested = _find_child(list_item, "bullet_list") - if nested: - out.append(_render_bullet_list_html(nested, is_sub=False)) - continue - - # Entry with a link - name = str(escape(render_inline_text(first_link.children))) - url = str(escape(first_link.attrGet("href") or "")) - - if is_sub: - out.append(f'
{name}
') - else: - desc = _extract_description_html(inline, first_link) - if desc: - out.append( - f'
{name}' - f'{desc}
' - ) - else: - out.append(f'
{name}
') - - # Nested items under an entry with a link are sub-entries - nested = _find_child(list_item, "bullet_list") - if nested: - out.append(_render_bullet_list_html(nested, is_sub=True)) - - return "\n".join(out) - - -def _render_section_html(content_nodes: list[SyntaxTreeNode]) -> str: - """Render a section's content nodes to HTML.""" - parts: list[str] = [] - for node in content_nodes: - if node.type == "bullet_list": - parts.append(_render_bullet_list_html(node)) - return "\n".join(parts) - - # --- Section splitting ------------------------------------------------------- @@ -330,45 +265,15 @@ def _build_section(name: str, body: list[SyntaxTreeNode]) -> ParsedSection: content_nodes = body[1:] if desc else body 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]) - content_html = _render_section_html(content_nodes) return ParsedSection( name=name, slug=slugify(name), description=desc, entries=entries, entry_count=entry_count, - preview=preview, - content_html=content_html, ) -def _group_by_h2( - nodes: list[SyntaxTreeNode], -) -> list[ParsedSection]: - """Group AST nodes into sections by h2 headings.""" - sections: list[ParsedSection] = [] - current_name: str | None = None - current_body: list[SyntaxTreeNode] = [] - - def flush() -> None: - nonlocal current_name - if current_name is None: - return - sections.append(_build_section(current_name, current_body)) - current_name = None - - for node in nodes: - if node.type == "heading" and node.tag == "h2": - flush() - current_name = _heading_text(node) - current_body = [] - elif current_name is not None: - current_body.append(node) - - flush() - return sections - def _is_bold_marker(node: SyntaxTreeNode) -> str | None: """Detect a bold-only paragraph used as a group marker. @@ -445,43 +350,30 @@ def _parse_grouped_sections( return groups -def parse_readme(text: str) -> tuple[list[ParsedGroup], list[ParsedSection]]: - """Parse README.md text into grouped categories and resources. +def parse_readme(text: str) -> list[ParsedGroup]: + """Parse README.md text into grouped categories. - Returns (groups, resources) where groups is a list of ParsedGroup dicts - containing nested categories, and resources is a flat list of ParsedSection. + Returns a list of ParsedGroup dicts containing nested categories. + Content between the thematic break (---) and # Resources or # Contributing + is parsed as categories grouped by bold markers (**Group Name**). """ md = MarkdownIt("commonmark") tokens = md.parse(text) root = SyntaxTreeNode(tokens) children = root.children - # Find thematic break (---), # Resources, and # Contributing in one pass + # Find thematic break (---) and section boundaries in one pass hr_idx = None - resources_idx = None - contributing_idx = None + cat_end_idx = None for i, node in enumerate(children): if hr_idx is None and node.type == "hr": hr_idx = i elif node.type == "heading" and node.tag == "h1": text_content = _heading_text(node) - if text_content == "Resources": - resources_idx = i - elif text_content == "Contributing": - contributing_idx = i + if cat_end_idx is None and text_content in ("Resources", "Contributing"): + cat_end_idx = i if hr_idx is None: - return [], [] + return [] - # Slice into category and resource ranges - cat_end = resources_idx or contributing_idx or len(children) - cat_nodes = children[hr_idx + 1 : cat_end] - - res_nodes: list[SyntaxTreeNode] = [] - if resources_idx is not None: - res_end = contributing_idx or len(children) - res_nodes = children[resources_idx + 1 : res_end] - - groups = _parse_grouped_sections(cat_nodes) - resources = _group_by_h2(res_nodes) - - return groups, resources + cat_nodes = children[hr_idx + 1 : cat_end_idx or len(children)] + return _parse_grouped_sections(cat_nodes) diff --git a/website/tests/test_build.py b/website/tests/test_build.py index 0e7eb48..c9d29f4 100644 --- a/website/tests/test_build.py +++ b/website/tests/test_build.py @@ -59,19 +59,13 @@ class TestBuild: ) (tpl_dir / "index.html").write_text( '{% extends "base.html" %}{% block content %}' - "{% for group in groups %}" - '
' - "

{{ group.name }}

" - "{% for cat in group.categories %}" - '
' - "{{ cat.name }}" - "{{ cat.preview }}" - "{{ cat.entry_count }}" - '' + "{% for entry in entries %}" + '
' + "{{ entry.name }}" + "{{ entry.categories | join(', ') }}" + "{{ entry.groups | join(', ') }}" "
" "{% endfor %}" - "
" - "{% endfor %}" "{% endblock %}", encoding="utf-8", ) diff --git a/website/tests/test_readme_parser.py b/website/tests/test_readme_parser.py index d365c45..cea5cbb 100644 --- a/website/tests/test_readme_parser.py +++ b/website/tests/test_readme_parser.py @@ -7,7 +7,6 @@ import pytest from readme_parser import ( _parse_section_entries, - _render_section_html, parse_readme, render_inline_html, render_inline_text, @@ -159,50 +158,39 @@ GROUPED_README = textwrap.dedent("""\ class TestParseReadmeSections: def test_ungrouped_categories_go_to_other(self): - groups, resources = parse_readme(MINIMAL_README) + groups = parse_readme(MINIMAL_README) assert len(groups) == 1 assert groups[0]["name"] == "Other" assert len(groups[0]["categories"]) == 2 def test_ungrouped_category_names(self): - groups, _ = parse_readme(MINIMAL_README) + groups = parse_readme(MINIMAL_README) cats = groups[0]["categories"] assert cats[0]["name"] == "Alpha" assert cats[1]["name"] == "Beta" - def test_resource_count(self): - _, resources = parse_readme(MINIMAL_README) - assert len(resources) == 2 - def test_category_slugs(self): - groups, _ = parse_readme(MINIMAL_README) + groups = parse_readme(MINIMAL_README) cats = groups[0]["categories"] assert cats[0]["slug"] == "alpha" assert cats[1]["slug"] == "beta" def test_category_description(self): - groups, _ = parse_readme(MINIMAL_README) + groups = parse_readme(MINIMAL_README) cats = groups[0]["categories"] assert cats[0]["description"] == "Libraries for alpha stuff." assert cats[1]["description"] == "Tools for beta." - def test_resource_names(self): - _, resources = parse_readme(MINIMAL_README) - assert resources[0]["name"] == "Newsletters" - assert resources[1]["name"] == "Podcasts" - def test_contributing_skipped(self): - groups, resources = parse_readme(MINIMAL_README) + groups = parse_readme(MINIMAL_README) all_names = [] for g in groups: all_names.extend(c["name"] for c in g["categories"]) - all_names.extend(r["name"] for r in resources) assert "Contributing" not in all_names def test_no_separator(self): - groups, resources = parse_readme("# Just a heading\n\nSome text.\n") + groups = parse_readme("# Just a heading\n\nSome text.\n") assert groups == [] - assert resources == [] def test_no_description(self): readme = textwrap.dedent("""\ @@ -224,7 +212,7 @@ class TestParseReadmeSections: Done. """) - groups, resources = parse_readme(readme) + groups = parse_readme(readme) cats = groups[0]["categories"] assert cats[0]["description"] == "" assert cats[0]["entries"][0]["name"] == "item" @@ -245,42 +233,37 @@ class TestParseReadmeSections: Done. """) - groups, _ = parse_readme(readme) + groups = parse_readme(readme) cats = groups[0]["categories"] assert cats[0]["description"] == "Algorithms. Also see awesome-algos." class TestParseGroupedReadme: def test_group_count(self): - groups, _ = parse_readme(GROUPED_README) + groups = parse_readme(GROUPED_README) assert len(groups) == 2 def test_group_names(self): - groups, _ = parse_readme(GROUPED_README) + groups = parse_readme(GROUPED_README) assert groups[0]["name"] == "Group One" assert groups[1]["name"] == "Group Two" def test_group_slugs(self): - groups, _ = parse_readme(GROUPED_README) + groups = parse_readme(GROUPED_README) assert groups[0]["slug"] == "group-one" assert groups[1]["slug"] == "group-two" def test_group_one_has_one_category(self): - groups, _ = parse_readme(GROUPED_README) + groups = parse_readme(GROUPED_README) assert len(groups[0]["categories"]) == 1 assert groups[0]["categories"][0]["name"] == "Alpha" def test_group_two_has_two_categories(self): - groups, _ = parse_readme(GROUPED_README) + groups = parse_readme(GROUPED_README) assert len(groups[1]["categories"]) == 2 assert groups[1]["categories"][0]["name"] == "Beta" assert groups[1]["categories"][1]["name"] == "Gamma" - def test_resources_still_parsed(self): - _, resources = parse_readme(GROUPED_README) - assert len(resources) == 1 - assert resources[0]["name"] == "Newsletters" - def test_empty_group_skipped(self): readme = textwrap.dedent("""\ # T @@ -299,7 +282,7 @@ class TestParseGroupedReadme: Done. """) - groups, _ = parse_readme(readme) + groups = parse_readme(readme) assert len(groups) == 1 assert groups[0]["name"] == "HasCats" @@ -319,7 +302,7 @@ class TestParseGroupedReadme: Done. """) - groups, _ = parse_readme(readme) + groups = parse_readme(readme) # "Note:" has text after the strong node, so it's not a group marker # Category goes into "Other" assert len(groups) == 1 @@ -345,7 +328,7 @@ class TestParseGroupedReadme: Done. """) - groups, _ = parse_readme(readme) + groups = parse_readme(readme) assert len(groups) == 2 assert groups[0]["name"] == "Other" assert groups[0]["categories"][0]["name"] == "Orphan" @@ -438,33 +421,11 @@ class TestParseSectionEntries: Done. """) - groups, _ = parse_readme(readme) + groups = parse_readme(readme) cats = groups[0]["categories"] # 2 main entries + 1 also_see = 3 assert cats[0]["entry_count"] == 3 - def test_preview_first_four_names(self): - readme = textwrap.dedent("""\ - # T - - --- - - ## Libs - - - [alpha](https://x.com) - A. - - [beta](https://x.com) - B. - - [gamma](https://x.com) - C. - - [delta](https://x.com) - D. - - [epsilon](https://x.com) - E. - - # Contributing - - Done. - """) - groups, _ = parse_readme(readme) - cats = groups[0]["categories"] - assert cats[0]["preview"] == "alpha, beta, gamma, delta" - def test_description_html_escapes_xss(self): nodes = _content_nodes('- [lib](https://x.com) - A lib.\n') entries = _parse_section_entries(nodes) @@ -472,58 +433,13 @@ class TestParseSectionEntries: assert "<script>" in entries[0]["description"] -class TestRenderSectionHtml: - def test_basic_entry(self): - nodes = _content_nodes("- [django](https://example.com) - A web framework.\n") - html = _render_section_html(nodes) - assert 'class="entry"' in html - assert 'href="https://example.com"' in html - assert "django" in html - assert "A web framework." in html - - def test_subcategory_label(self): - nodes = _content_nodes( - "- Synchronous\n - [django](https://x.com) - Framework.\n" - ) - html = _render_section_html(nodes) - assert 'class="subcat"' in html - assert "Synchronous" in html - assert 'class="entry"' in html - - def test_sub_entry(self): - nodes = _content_nodes( - "- [django](https://x.com) - Framework.\n" - " - [awesome-django](https://y.com)\n" - ) - html = _render_section_html(nodes) - assert 'class="entry-sub"' in html - assert "awesome-django" in html - - def test_link_only_entry(self): - nodes = _content_nodes("- [tool](https://x.com)\n") - html = _render_section_html(nodes) - assert 'class="entry"' in html - assert 'href="https://x.com"' in html - assert "tool" in html - - def test_xss_escaped_in_name(self): - nodes = _content_nodes('- [](https://x.com) - Bad.\n') - html = _render_section_html(nodes) - assert "onerror" not in html or "&" in html - - def test_xss_escaped_in_subcat(self): - nodes = _content_nodes("- \n") - html = _render_section_html(nodes) - assert "