diff --git a/.eleventy.js b/.eleventy.js
index 88c2d5251..4aaff3896 100644
--- a/.eleventy.js
+++ b/.eleventy.js
@@ -40,6 +40,10 @@ module.exports = function (eleventyConfig) {
// eslint-disable-next-line no-unused-vars
const live_packages = Object.entries(data.packages).map(([id, pkg]) => pkg).filter(pkg => !pkg.removed);
+ // Load libraries data
+ const librariesData = JSON.parse(fs.readFileSync("libraries.json", "utf8"));
+ const libraries = librariesData.libraries || [];
+
eleventyConfig.addCollection("packages", () => {
return live_packages.map(pkg => ({
...pkg,
@@ -72,6 +76,27 @@ module.exports = function (eleventyConfig) {
}).slice(0,9);
});
+ // Add libraries collection
+ eleventyConfig.addCollection("libraries", () => {
+ return libraries.map(lib => {
+ // Extract unique Python versions from all releases
+ const pythonVersions = [...new Set(
+ (lib.releases || [])
+ .flatMap(release => release.python_versions || [])
+ )].sort();
+
+ return {
+ name: lib.name,
+ description: lib.description || '',
+ author: lib.author || '',
+ issues: lib.issues || '',
+ releases: lib.releases || [],
+ python_versions: pythonVersions,
+ permalink: `/library/${lib.name}/`
+ };
+ }).sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
+ });
+
// simple to date string for some dates without times
eleventyConfig.addFilter("date_format", (date) => {
if (typeof date !== "string" ) return date;
diff --git a/.gitignore b/.gitignore
index b00004829..2afeca44d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -52,3 +52,4 @@ _site/*
# Database #
######################
workspace.json
+libraries.json
diff --git a/Makefile b/Makefile
index a0fc8c115..d0ada1264 100644
--- a/Makefile
+++ b/Makefile
@@ -1,13 +1,19 @@
build:
npm install
curl -o workspace.json -L "https://github.com/packagecontrol/thecrawl/releases/download/crawler-status/workspace.json"
+ curl -o libraries.json -L "https://raw.githubusercontent.com/packagecontrol/channel/refs/heads/main/repository.json"
npx @11ty/eleventy
+fetch:
+ curl -o workspace.json -L "https://github.com/packagecontrol/thecrawl/releases/download/crawler-status/workspace.json"
+ curl -o libraries.json -L "https://raw.githubusercontent.com/packagecontrol/channel/refs/heads/main/repository.json"
+
lint:
npx eslint
clean:
rm -rf _site/*
+ rm -f libraries.json
serve:
open http://localhost:8080/
diff --git a/_includes/header.njk b/_includes/header.njk
index eeea09131..078bbdc2c 100644
--- a/_includes/header.njk
+++ b/_includes/header.njk
@@ -2,6 +2,7 @@
Submitting packages
Using package control
+ Libraries
+ By {{ lib.author }}
+ {% if lib.python_versions and lib.python_versions | length > 0 %}
+
+ Python versions:
+ {% for version in lib.python_versions %}
+ {{ version }} {{ ", " if not loop.last }}
+ {% endfor %}
+ {% endif %}
+
+
+{% if lib.releases | length > 1 %}
+ .*?<\/div>/s,
+ `
${pythonVersionsHtml}
`
+ );
+ } else {
+ // Remove the python-versions div if no versions
+ result = result.replace(/
.*?<\/div>/s, '');
+ }
+
+ return result;
+ }
+
+ render(library) {
+ return this.renderString(library);
+ }
+}
\ No newline at end of file
diff --git a/static/module/base-data.js b/static/module/base-data.js
new file mode 100644
index 000000000..74856bc3c
--- /dev/null
+++ b/static/module/base-data.js
@@ -0,0 +1,48 @@
+export class BaseData {
+ constructor(config = {}) {
+ this.data = null;
+ this.config = {
+ endpoint: '/searchindex.json',
+ contentType: 'application/json',
+ ...config
+ };
+ }
+
+ async get() {
+ if (this.data) {
+ return this.data;
+ }
+
+ try {
+ const response = await fetch(this.config.endpoint);
+ const contentType = response.headers.get("content-type") ?? '';
+
+ if (!response.ok || !contentType.includes(this.config.contentType)) {
+ throw new Error('bad response');
+ }
+
+ this.data = await response.json();
+ return this.data;
+ } catch (error) {
+ console.error(`Error fetching data from ${this.config.endpoint}:`, error);
+ return [];
+ }
+ }
+}
+
+// Specialized data classes
+export class PackageData extends BaseData {
+ constructor() {
+ super({
+ endpoint: '/packages/searchindex.json'
+ });
+ }
+}
+
+export class LibraryData extends BaseData {
+ constructor() {
+ super({
+ endpoint: '/libraries/searchindex.json'
+ });
+ }
+}
\ No newline at end of file
diff --git a/static/module/base-list.js b/static/module/base-list.js
new file mode 100644
index 000000000..91df7e939
--- /dev/null
+++ b/static/module/base-list.js
@@ -0,0 +1,194 @@
+import { Pagination } from './pagination.js';
+
+export class BaseList {
+ constructor(config = {}) {
+ this.config = {
+ // Default configuration
+ selectors: {
+ resultSection: 'section[name="result"]',
+ resultList: 'section[name="result"] ul',
+ allSection: 'section[name="all"]',
+ allList: null, // Must be provided
+ counter: '.counter'
+ },
+ itemsPerPage: 30,
+ itemName: 'items', // for pagination text
+ ...config
+ };
+
+ this.cardRenderer = config.cardRenderer;
+ this.pagination = new Pagination();
+ }
+
+ getSection(type = 'result') {
+ const selector = type === 'result'
+ ? this.config.selectors.resultSection
+ : this.config.selectors.allSection;
+ return document.querySelector(selector);
+ }
+
+ getList(type = 'result') {
+ const selector = type === 'result'
+ ? this.config.selectors.resultList
+ : this.config.selectors.allList;
+ return document.querySelector(selector);
+ }
+
+ setCounter(count = null) {
+ const counter = document.querySelector(this.config.selectors.counter);
+ if (counter) {
+ if (count !== null) {
+ counter.textContent = count;
+ } else {
+ const totalCount = counter.getAttribute('data-all') || '0';
+ counter.textContent = totalCount;
+ }
+ }
+ }
+
+ switchToResults() {
+ const resultSection = this.getSection('result');
+ const allSection = this.getSection('all');
+
+ if (resultSection) resultSection.style.display = 'block';
+ if (allSection) allSection.style.display = 'none';
+ }
+
+ revertToNormal() {
+ const resultSection = this.getSection('result');
+ const allSection = this.getSection('all');
+
+ if (resultSection) resultSection.style.display = 'none';
+ if (allSection) allSection.style.display = 'block';
+
+ this.setCounter();
+ }
+
+ clear() {
+ const resultsList = this.getList('result');
+ const allList = this.getList('all');
+
+ if (resultsList) resultsList.innerHTML = '';
+ if (allList) allList.innerHTML = '';
+ }
+
+ renderAll(items) {
+ const list = this.getList('all');
+ if (!list) return;
+
+ list.innerHTML = '';
+ items.forEach(item => {
+ const li = document.createElement('li');
+ li.innerHTML = this.cardRenderer.render(item);
+ list.appendChild(li);
+ });
+ }
+
+ renderPage(items, page = 1) {
+ const startIndex = (page - 1) * this.config.itemsPerPage;
+ const endIndex = startIndex + this.config.itemsPerPage;
+ const pageItems = items.slice(startIndex, endIndex);
+
+ const list = this.getList('result');
+ if (!list) return;
+
+ list.innerHTML = '';
+ pageItems.forEach(item => {
+ const li = document.createElement('li');
+ li.innerHTML = this.cardRenderer.render(item);
+ list.appendChild(li);
+ });
+
+ // Add pagination if needed
+ const totalPages = Math.ceil(items.length / this.config.itemsPerPage);
+ if (totalPages > 1) {
+ const paginationHtml = this.pagination.get(page, totalPages, this.config.itemName);
+ list.insertAdjacentHTML('afterend', paginationHtml);
+ }
+ }
+}
+
+// Specialized list classes
+export class PackageList extends BaseList {
+ constructor(cardRenderer) {
+ super({
+ selectors: {
+ resultSection: 'section[name="result"]',
+ resultList: 'section[name="result"] ul',
+ allSection: 'section[name="newest"], section[name="recent"]',
+ allList: null, // Packages use different approach
+ counter: 'h1 .counter'
+ },
+ itemsPerPage: 24,
+ itemName: 'packages',
+ cardRenderer
+ });
+ }
+
+ // Override switchToResults to handle multiple sections
+ switchToResults() {
+ const resultSection = this.getSection('result');
+ if (resultSection) resultSection.style.display = 'block';
+
+ // Hide all homepage sections (newest and recent)
+ document.querySelectorAll('section[name="newest"], section[name="recent"]').forEach(section => {
+ section.style.display = 'none';
+ });
+ }
+
+ // Override revertToNormal to handle multiple sections
+ revertToNormal() {
+ const resultSection = this.getSection('result');
+ if (resultSection) resultSection.style.display = 'none';
+
+ // Show all homepage sections (newest and recent)
+ document.querySelectorAll('section[name="newest"], section[name="recent"]').forEach(section => {
+ section.style.display = 'block';
+ });
+
+ this.setCounter();
+ }
+
+ // Override for packages special behavior
+ clear() {
+ const section = this.getSection('result');
+ if (section) {
+ section.querySelectorAll('li, .pagination').forEach(ui => ui.remove());
+ }
+ }
+
+ renderPage(items, page) {
+ this.clear();
+
+ const section = this.getSection('result');
+ const list = section?.querySelector('ul');
+ if (!list) return;
+
+ const pagination = new Pagination(items, page, section);
+
+ pagination.calculate().forEach(item => {
+ const li = document.createElement('li');
+ li.appendChild(this.cardRenderer.render(item));
+ list.appendChild(li);
+ });
+
+ pagination.render();
+ }
+}
+
+export class LibraryList extends BaseList {
+ constructor(cardRenderer) {
+ super({
+ selectors: {
+ resultSection: 'section[name="result"]',
+ resultList: 'section[name="result"] ul.libraries-list',
+ allSection: 'section[name="all"]',
+ allList: '#libraries-list',
+ counter: '.counter'
+ },
+ itemsPerPage: 30,
+ itemName: 'libraries',
+ cardRenderer
+ });
+ }
+}
\ No newline at end of file
diff --git a/static/module/base-search.js b/static/module/base-search.js
new file mode 100644
index 000000000..16e748c57
--- /dev/null
+++ b/static/module/base-search.js
@@ -0,0 +1,110 @@
+import minisearch from 'https://cdn.jsdelivr.net/npm/minisearch@7.1.2/+esm'
+
+export class BaseSearch {
+ constructor(value, data, config = {}) {
+ this.value = value;
+ this.data = data;
+ this.config = {
+ // Default configuration
+ filters: {
+ author: { enabled: true, property: 'author' },
+ ...config.filters
+ },
+ searchFields: ['name', 'description'],
+ idField: 'name',
+ ...config
+ };
+ }
+
+ get() {
+ let base = this.data;
+ let value = this.value;
+
+ // Apply configured filters
+ for (const [filterName, filterConfig] of Object.entries(this.config.filters)) {
+ if (!filterConfig.enabled) continue;
+
+ const regex = new RegExp(`${filterName}:"([^"]+)"`, 'i');
+ const match = value.match(regex);
+
+ if (match) {
+ base = this.applyFilter(base, filterConfig, match[1]);
+ value = value.replace(match[0], '');
+ }
+ }
+
+ if (!value.trim()) {
+ return base;
+ }
+
+ return this.performSearch(base, value.trim());
+ }
+
+ applyFilter(data, filterConfig, filterValue) {
+ const { property, type = 'includes' } = filterConfig;
+
+ return data.filter(item => {
+ const fieldValue = item[property] || '';
+
+ switch (type) {
+ case 'includes':
+ return fieldValue.toLowerCase().includes(filterValue.toLowerCase());
+ case 'array_includes':
+ return fieldValue.toLowerCase().split(',').includes(filterValue.toLowerCase());
+ case 'array_contains':
+ return fieldValue.toLowerCase().split(',').some(val =>
+ val.trim().includes(filterValue.toLowerCase())
+ );
+ default:
+ return fieldValue.toLowerCase().includes(filterValue.toLowerCase());
+ }
+ });
+ }
+
+ performSearch(base, searchValue) {
+ const minisrch = new minisearch({
+ idField: this.config.idField,
+ fields: this.config.searchFields,
+ searchOptions: {
+ boost: { [this.config.searchFields[0]]: 2 },
+ fuzzy: 0.2,
+ prefix: true
+ }
+ });
+
+ minisrch.addAll(base);
+ const results = minisrch.search(searchValue);
+ const resultIds = results.map(r => r.id);
+ const scores = results.reduce((acc, r) => {
+ acc[r.id] = r.score;
+ return acc;
+ }, {});
+
+ return base.filter(item => resultIds.includes(item[this.config.idField]))
+ .sort((a, b) => scores[b[this.config.idField]] - scores[a[this.config.idField]]);
+ }
+}
+
+// Specialized search classes
+export class PackageSearch extends BaseSearch {
+ constructor(value, data) {
+ super(value, data, {
+ filters: {
+ author: { enabled: true, property: 'author' },
+ label: { enabled: true, property: 'labels', type: 'array_contains' },
+ platform: { enabled: true, property: 'platforms', type: 'array_contains' }
+ }
+ });
+ }
+}
+
+export class LibrarySearch extends BaseSearch {
+ constructor(value, data) {
+ super(value, data, {
+ filters: {
+ author: { enabled: true, property: 'author' },
+ python: { enabled: true, property: 'python_versions', type: 'array_contains' }
+ }
+ });
+ }
+}
\ No newline at end of file
diff --git a/static/module/card.js b/static/module/card.js
deleted file mode 100644
index 0a73d679c..000000000
--- a/static/module/card.js
+++ /dev/null
@@ -1,69 +0,0 @@
-export class Card {
- pkg = {};
- clone;
-
- constructor (data) {
- this.pkg = data;
-
- const template = document.querySelector("template#package-card");
- this.clone = template.content.cloneNode(true);
- }
-
- render () {
- this.clone.querySelector('a').innerHTML = this.pkg.name;
- this.clone.querySelector('a').setAttribute('href', this.pkg.permalink);
- this.clone.querySelector('p').innerHTML = 'by ' + this.pkg.author;
-
- const dl = this.clone.querySelector('.stars').closest('dl')
- if (this.pkg.stars !== '0') {
- dl.setAttribute('title', this.pkg.stars + (this.pkg.stars < 2 ? ' star' : ' stars') + ' on GitHub');
- this.clone.querySelector('.stars').innerText = this.pkg.stars;
- } else {
- dl.remove();
- }
-
- const labels = this.clone.querySelector('ul.labels');
- this.platforms(labels);
- this.labels(labels);
-
- return this.clone;
- }
-
- platforms (parent) {
- if (this.pkg.platforms.length < 1) {
- return
- }
-
- this.pkg.platforms.split(',').forEach(item => {
- parent.appendChild(this.button(item));
- })
- }
-
- labels (parent) {
- if (this.pkg.labels.length < 1) {
- return
- }
-
- this.pkg.labels.split(',').forEach(item => {
- parent.appendChild(this.button(item));
- })
- }
-
- button (name) {
- const li = document.createElement('li');
- const a = document.createElement('a');
-
- if (['linux','macos','windows'].indexOf(name) >= 0) {
- a.classList.add('button', 'platform', 'platform-' + name);
- a.setAttribute('href', '/?q=' + encodeURI('platform::"' + name + '"'));
- } else {
- a.classList.add('button', 'label');
- a.setAttribute('href', '/?q=' + encodeURI('label:"' + name + '"'));
- }
-
- a.innerText = name;
- li.appendChild(a);
-
- return li;
- }
-}
diff --git a/static/module/data.js b/static/module/data.js
deleted file mode 100644
index 151bf8780..000000000
--- a/static/module/data.js
+++ /dev/null
@@ -1,23 +0,0 @@
-export class Data {
- data = null;
-
- async get() {
- if (this.data) {
- return this.data;
- }
-
- try {
- const response = await fetch('/packages/searchindex.json');
- const contentType = response.headers.get("content-type") ?? '';
-
- if (!response.ok || !contentType.includes('application/json')) {
- throw new Error('bad response');
- }
-
- this.data = response.json();
- return this.data;
- } catch (error) {
- console.error(error.message);
- }
- }
-}
diff --git a/static/module/list.js b/static/module/list.js
deleted file mode 100644
index a47f7889e..000000000
--- a/static/module/list.js
+++ /dev/null
@@ -1,66 +0,0 @@
-import { Card } from './card.js';
-import { Pagination } from './pagination.js';
-
-export class List {
- // the section where we'll render search results
- getSection() {
- return document.querySelector('section[name="result"]');
- }
-
- // the list inside that section
- getList() {
- return this.getSection().querySelector('ul');
- }
-
- setCounter(count = null) {
- const counter = document.querySelector('h1 .counter');
- counter.innerText = count ?? counter.dataset.all;
- }
-
- // reveal search results and hide the static homepage
- switchToResults() {
- document.querySelectorAll('section').forEach(section => {
- if (section.getAttribute('name') === 'result') {
- section.style.display = null;
- } else {
- section.style.display = 'none';
- }
- });
- }
-
- // revert to the static homepage
- revertToNormal() {
- document.querySelectorAll('section').forEach(section => {
- if (section.getAttribute('name') === 'result') {
- section.style.display = 'none';
- } else {
- section.style.display = null;
- }
- });
-
- this.setCounter();
- }
-
- // clear previous results and pagination ui
- clear() {
- this.getSection().querySelectorAll('li, .pagination').forEach(ui => {
- ui.remove();
- });
- }
-
- // render the current page of results and pagination
- renderPage(items, page) {
- this.clear();
-
- const pagination = new Pagination(items, page, this.getSection());
-
- // Render items for current page
- pagination.calculate().forEach(pkg => {
- const li = document.createElement('li');
- li.appendChild((new Card(pkg)).render());
- this.getList().appendChild(li);
- });
-
- pagination.render();
- }
-}
diff --git a/static/module/pagination.js b/static/module/pagination.js
index 462c8012f..d249dfa2f 100644
--- a/static/module/pagination.js
+++ b/static/module/pagination.js
@@ -1,10 +1,33 @@
export class Pagination {
- constructor(items, page, parent) {
+ constructor(items, page, parent, itemName = 'items') {
this.items = items;
this.currentPage = page;
this.totalPages = 0;
this.itemsPerPage = 24;
this.parent = parent;
+ this.itemName = itemName;
+ }
+
+ // Static method for creating pagination HTML without needing instance
+ static createPaginationHtml(currentPage, totalPages, totalItems, itemName = 'items') {
+ if (totalPages < 2) return '';
+
+ const pagination = document.createElement('div');
+ pagination.className = 'pagination';
+
+ const startItem = (currentPage - 1) * 24 + 1;
+ const endItem = Math.min(currentPage * 24, totalItems);
+
+ const info = document.createElement('div');
+ info.className = 'pagination-info';
+ info.textContent = `Showing ${startItem}-${endItem} of ${totalItems} ${itemName}`;
+ pagination.appendChild(info);
+
+ return pagination.outerHTML;
+ }
+
+ get(page, totalPages, itemName = 'items') {
+ return Pagination.createPaginationHtml(page, totalPages, this.items?.length || 0, itemName);
}
// calculate pagination and result the items of the current page
@@ -29,7 +52,8 @@ export class Pagination {
const endItem = Math.min(this.currentPage * this.itemsPerPage, this.items.length);
const info = document.createElement('div');
info.className = 'pagination-info';
- info.textContent = `Showing ${startItem}-${endItem} of ${this.items.length} packages`;
+ const itemName = this.itemName || 'items';
+ info.textContent = `Showing ${startItem}-${endItem} of ${this.items.length} ${itemName}`;
pagination.appendChild(info);
// controls
diff --git a/static/module/search.js b/static/module/search.js
deleted file mode 100644
index 2c36400a3..000000000
--- a/static/module/search.js
+++ /dev/null
@@ -1,91 +0,0 @@
-import minisearch from 'https://cdn.jsdelivr.net/npm/minisearch@7.1.2/+esm'
-
-export class Search {
- value = '';
- data = null;
-
- constructor(value, data) {
- this.value = value;
- this.data = data;
- }
-
- get() {
- let base = this.data;
- let value = this.value;
-
- // handle author filter
- const author = value.match(/author:"([^"]+)"/i);
- if (author) {
- base = base.filter(
- pkg => pkg.author.toLowerCase().includes(author[1].toLowerCase())
- );
-
- // remove this filter from the search string
- value = value.replace(author[0], '');
- }
-
- // handle label filter
- const label = value.match(/label:"([^"]+)"/i);
- if (label) {
- base = base.filter(pkg => {
- return pkg.labels.toLowerCase().split(',').indexOf(label[1].toLowerCase()) > -1;
- });
-
- // remove this filter from the search string
- value = value.replace(label[0], '');
- }
-
- // handle platform filter
- const platform = value.match(/platform:"([^"]+)"/i);
- if (platform) {
- base = base.filter(
- pkg => {
- if (!pkg.platforms) {
- return true;
- }
- return pkg.platforms.toLowerCase().split(',').indexOf(platform[1].toLowerCase()) > -1
- }
- );
-
- // remove this filter from the search string
- value = value.replace(platform[0], '');
- }
-
- if (!value.trim()) {
- // if after that filtering no search terms remain, just return what's left
- return base;
- }
-
- // use minisearch to find matches with some level of fuzziness
- // we sloppily reset the index each search
- // which is wasteful of cpu cycles I guess, but it seems fast enough :shrug:
- // https://github.com/lucaong/minisearch
- const minisrch = new minisearch({
- idField: 'name',
- fields: ['name', 'description'],
- searchOptions: {
- boost: { name: 2 },
- fuzzy: 0.2,
- prefix: true
- }
- });
-
- // start with all data post label/author filtering
- minisrch.addAll(base);
- // search and then map results so we can easily use them for output
- const miniresult = minisrch.search(value.trim())
- const minikeys = miniresult.map(mini => mini.id);
- const scores = miniresult.reduce((acc, mini) => {
- acc[mini.id] = mini.score;
- return acc;
- }, {});
-
- // return matches sorted by their score according to minisearch
- // where a high score means a better match
- return base.filter(
- pkg => minikeys.indexOf(pkg.name) > -1
- ).sort((a,b) => {
- return scores[b.name] - scores[a.name];
- });
- }
-}
diff --git a/static/module/sort.js b/static/module/sort.js
index 69e66e4fc..c14e85ba9 100644
--- a/static/module/sort.js
+++ b/static/module/sort.js
@@ -29,6 +29,8 @@ export class Sort {
case 'author-desc':
return sortedPackages.sort((a, b) => b.author.toLowerCase().localeCompare(a.author.toLowerCase()));
+
+
case 'relevance':
default:
return sortedPackages; // Return as-is for relevance or default
diff --git a/static/style/colors.css b/static/style/colors.css
index 78aa42f75..c2e238a6b 100644
--- a/static/style/colors.css
+++ b/static/style/colors.css
@@ -9,22 +9,22 @@
--background: #fff;
--background-slightly-darker: #fcfcfc;
- --background-darker: #f2f2f2;
+ --background-darker: #f5f5f5;
--background-darkest: #cacaca;
--foreground: #586e75;
--foreground-darker: #38464b;
- --foreground-lighter: #839496;
- --foreground-bright: #c1cfd4;
+ --foreground-lighter: #9AAFB5;
+ --foreground-bright: #C1CFD4;
--label-highlight: #38464b;
}
@media (prefers-color-scheme: dark) {
:root {
- --background: #2b303b;
- --background-slightly-darker: #383e4d;
- --background-darker: #3f5157;
+ --background: #2B303B;
+ --background-slightly-darker: #383E4D;
+ --background-darker: #3F5157;
--background-darkest: #586e75;
--foreground: #839496;
diff --git a/static/style/libraries.css b/static/style/libraries.css
new file mode 100644
index 000000000..a9c223327
--- /dev/null
+++ b/static/style/libraries.css
@@ -0,0 +1,109 @@
+/* Libraries-specific styles */
+.libraries-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.libraries-list > li {
+ display: flex;
+ margin: 0;
+ padding: 0;
+ border-bottom: 1px solid var(--background-darker);
+}
+
+.libraries-list > li:last-child {
+ border-bottom: none;
+}
+
+.library-item {
+ width: 100%;
+ padding: 1.2rem 0;
+}
+
+.library-item h3 {
+ margin: 0 0 0.5rem 0;
+ padding: 0;
+ font-size: 1.2rem;
+}
+
+.library-item h3 a {
+ color: var(--foreground);
+ text-decoration: none;
+}
+
+.library-item h3 a:hover {
+ color: var(--primary-accent-darker);
+ text-decoration: underline;
+}
+
+.library-item .description {
+ margin: 0 0 0.5rem 0;
+ color: var(--foreground-lighter);
+ line-height: 1.4;
+ font-size: 1rem;
+ font-style: normal;
+}
+
+.library-item .description::before,
+.library-item .description::after {
+ content: none;
+}
+
+.library-item .meta {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ font-size: 0.9rem;
+ color: var(--foreground-lighter);
+}
+
+.library-item .meta-second-line {
+ display: flex;
+ gap: 1rem;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.library-item .author {
+ font-style: italic;
+}
+
+.library-item .issues-link {
+ color: var(--primary-accent-darker);
+ text-decoration: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 0.85rem;
+}
+
+.library-item .issues-link:hover {
+ text-decoration: underline;
+}
+
+.library-item .issues-link svg {
+ width: 12px;
+ height: 12px;
+}
+
+.library-item .python-versions {
+ display: flex;
+ gap: 4px;
+ flex-wrap: wrap;
+ margin-top: 4px;
+}
+
+.library-item .py-version,
+.py-version {
+ background: var(--secondary-accent);
+ color: var(--background);
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-size: 0.75rem;
+ font-weight: 500;
+ display: inline-block;
+}
\ No newline at end of file
diff --git a/static/styles.css b/static/styles.css
index 4473d6bed..eaef0dfb6 100644
--- a/static/styles.css
+++ b/static/styles.css
@@ -6,6 +6,7 @@
@import url('style/grid.css');
@import url('style/pagination.css');
@import url('style/search.css');
+@import url('style/libraries.css');
html {
height: 100%;