From 92e5f06a78bc3412ac83c515fad0f2e01e44d2ea Mon Sep 17 00:00:00 2001 From: Elizabeth Campolongo <38985481+egrace479@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:22:17 -0400 Subject: [PATCH 01/12] Set tests to run on 'dev' branch in workflow Pull from Imagomics catalog [PR 109](https://github.com/Imageomics/catalog/pull/109) Excluded agent md updates from the original commits. dev will now be used before main (cherry picked from commit 9a4b28089fe1711521e0f736603d5de74b5f1a5f) --- .github/workflows/test.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9c4e063..031e843 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,7 +2,9 @@ name: Run tests on: pull_request: - branches: [main] + branches: + - 'main' + - 'dev' jobs: test: From 69ff9f52ec32a6f0b4f842d1daa390bffbe8982f Mon Sep 17 00:00:00 2001 From: Elizabeth Campolongo <38985481+egrace479@users.noreply.github.com> Date: Tue, 16 Jun 2026 09:24:00 -0400 Subject: [PATCH 02/12] Allow for HF org with different name from code platform org Pull from Imageomics catalog [PR 110](https://github.com/Imageomics/catalog/pull/110) Excluded agents md updates from the original commits. * Allow for HF org with different name from codebase platform * Add terminal rendered error message in case of missing/incorrect config keys at Vite start * Improve validation (cherry picked from commit: 2f1f18541e2e769f7ada5f45ee4dcb4beb42a314) --- docs/personalization.md | 5 +++-- main.js | 22 +++++++++++++--------- public/config.yaml | 1 + scripts/export-tags.js | 4 ++-- src/validateConfig.js | 28 ++++++++++++++++++++++++---- tests/validateConfig.test.js | 27 +++++++++++++++++++++++++++ vite.config.js | 28 ++++++++++++++++++++++++++++ 7 files changed, 98 insertions(+), 17 deletions(-) diff --git a/docs/personalization.md b/docs/personalization.md index 3a6d161..cc26212 100644 --- a/docs/personalization.md +++ b/docs/personalization.md @@ -8,8 +8,9 @@ Welcome to your new catalog repo! The primary way to personalize this catalog is ### Organization & Repository Settings - * `ORGANIZATION_NAME`: Your GitHub/Hugging Face organization name (lowercase for API calls) - * `ORG_NAME`: Display name for your organization (can differ from API name); used as fallback site title if `CATALOG_TITLE` is not set + * `ORGANIZATION_NAME`: Your code platform (e.g., GitHub) organization name (for API calls) + * `HF_ORGANIZATION_NAME`: Your Hugging Face organization name (**case-sensitive**, for API calls) + * `ORG_NAME`: Display name for your organization (can differ from API name); used for logo alt-text and as fallback site title if `CATALOG_TITLE` is not set * `CATALOG_REPO_NAME`: Repository name for the catalog itself (used for stats badge) ### Branding diff --git a/main.js b/main.js index bf6c297..780d760 100644 --- a/main.js +++ b/main.js @@ -24,7 +24,7 @@ const configPromise = fetch('config.yaml') // Module-scope lets — assigned after config loads, used by all functions below let CONFIG; -let ORGANIZATION_NAME, CATALOG_REPO_NAME, PLATFORM, API_BASE_URL, REFRESH_INTERVAL_DAYS, ADDITIONAL_REPOS, ADDITIONAL_HF_REPOS; +let ORGANIZATION_NAME, HF_ORGANIZATION_NAME, CATALOG_REPO_NAME, PLATFORM, API_BASE_URL, REFRESH_INTERVAL_DAYS, ADDITIONAL_REPOS, ADDITIONAL_HF_REPOS; let ORG_API_URL, REPO_API_URL; // Build a reverse lookup from TAG_GROUPS (defined in tag-groups.js): raw tag → [canonical tags] @@ -279,7 +279,7 @@ const fetchHubItems = async (repoType) => { } // hugging face api requests for datasets/models/spaces - const response = await fetch(`${API_BASE_URL}${repoType}?author=${ORGANIZATION_NAME}&full=true`); + const response = await fetch(`${API_BASE_URL}${repoType}?author=${HF_ORGANIZATION_NAME}&full=true`); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } @@ -651,7 +651,7 @@ const initializeUIFromConfig = () => { // Set header title and description const headerTitle = document.getElementById('header-title'); if (headerTitle) { - headerTitle.textContent = CONFIG.CATALOG_TITLE; + headerTitle.textContent = CONFIG.CATALOG_TITLE || `${CONFIG.ORG_NAME} Catalog`; headerTitle.style.color = CONFIG.COLORS.primary; } @@ -703,7 +703,7 @@ document.addEventListener('DOMContentLoaded', async () => { setTimeout(() => errorDiv.remove(), 10000); // Fall back to defaults so the page isn't completely broken CONFIG = { - ORGANIZATION_NAME: '', CATALOG_REPO_NAME: '', ORG_NAME: '', + ORGANIZATION_NAME: '', HF_ORGANIZATION_NAME: '', CATALOG_REPO_NAME: '', ORG_NAME: '', CATALOG_TITLE: 'Catalog', CATALOG_DESCRIPTION: '', LOGO_URL: '', FAVICON_URL: '', COLORS: { primary: '#92991c', secondary: '#5d8095', accent: '#0097b2', accentDark: '#4fd1eb', tag: '#9bcb5e' }, @@ -715,6 +715,7 @@ document.addEventListener('DOMContentLoaded', async () => { // Assign module-scope variables used by all functions ORGANIZATION_NAME = CONFIG.ORGANIZATION_NAME; + HF_ORGANIZATION_NAME = CONFIG.HF_ORGANIZATION_NAME; CATALOG_REPO_NAME = CONFIG.CATALOG_REPO_NAME; PLATFORM = CONFIG.PLATFORM; ORG_API_URL = getPlatformApiUrls(PLATFORM, ORGANIZATION_NAME).org; @@ -724,6 +725,14 @@ document.addEventListener('DOMContentLoaded', async () => { ADDITIONAL_REPOS = CONFIG.ADDITIONAL_REPOS; ADDITIONAL_HF_REPOS = CONFIG.ADDITIONAL_HF_REPOS; + // Guard: if ORGANIZATION_NAME or HF_ORGANIZATION_NAME is missing (e.g. config.yaml failed to load), + // stop here — proceeding would fire requests like ?author=&full=true which + // could return unbounded results from the Hugging Face API. + if (!ORGANIZATION_NAME || !HF_ORGANIZATION_NAME){ + console.error("Organization name is missing for one or both APIs. Halting initialization."); + return; + } + // Apply CSS custom properties and document metadata document.title = CONFIG.CATALOG_TITLE || `${CONFIG.ORG_NAME} Catalog`; document.documentElement.style.setProperty('--color-primary', CONFIG.COLORS?.primary || '#92991c'); @@ -808,11 +817,6 @@ document.addEventListener('DOMContentLoaded', async () => { }); // Initialize the Catalog Badge (Stars/Forks/Version) - // Guard: if ORGANIZATION_NAME is missing (e.g. config.yaml failed to load), - // stop here — proceeding would fire requests like ?author=&full=true which - // could return unbounded results from the Hugging Face API. - if (!ORGANIZATION_NAME) return; - fetchCatalogStats(); // Load pre-built release data (written by scripts/fetch-releases.js at build time) diff --git a/public/config.yaml b/public/config.yaml index d15d0ee..5442c13 100644 --- a/public/config.yaml +++ b/public/config.yaml @@ -42,6 +42,7 @@ ADDITIONAL_REPOS: # Array of Hugging Face repos from outside the org to include. # Each entry must specify "repo" (owner/name) and "type" (datasets, models, or spaces). +# Organization names are case-sensitive in the Hugging Face API. # ADDITIONAL_HF_REPOS: # - repo: "user/dataset-name" # type: "datasets" diff --git a/scripts/export-tags.js b/scripts/export-tags.js index f3ec2ba..8e046ec 100644 --- a/scripts/export-tags.js +++ b/scripts/export-tags.js @@ -35,7 +35,7 @@ if (errors.length) { } const CONFIG = rawConfig; -const { ORGANIZATION_NAME, PLATFORM, API_BASE_URL, ADDITIONAL_REPOS } = CONFIG; +const { ORGANIZATION_NAME, HF_ORGANIZATION_NAME, PLATFORM, API_BASE_URL, ADDITIONAL_REPOS } = CONFIG; const ADDITIONAL_HF_REPOS = CONFIG.ADDITIONAL_HF_REPOS; // --------------------------------------------------------------------------- @@ -97,7 +97,7 @@ const collectCodePlatformTags = async () => { const collectHFTags = async (repoType) => { console.log(`Fetching HF ${repoType}...`); - let items = (await get(`${API_BASE_URL}${repoType}?author=${ORGANIZATION_NAME}&full=true`)).json; + let items = (await get(`${API_BASE_URL}${repoType}?author=${HF_ORGANIZATION_NAME}&full=true`)).json; // Fetch additional HF repos of this type const additionalForType = ADDITIONAL_HF_REPOS.filter(entry => entry.type === repoType); diff --git a/src/validateConfig.js b/src/validateConfig.js index 7cac8f3..d903afe 100644 --- a/src/validateConfig.js +++ b/src/validateConfig.js @@ -14,15 +14,35 @@ export function validateConfig(config) { return errors; } - if (!config.ORGANIZATION_NAME) errors.push('ORGANIZATION_NAME'); + /** Validate organization names, existence and allowed characters */ + const ghOrgRegex = /^[a-zA-Z0-9-]+$/; + const orgName = config.ORGANIZATION_NAME; + if (typeof orgName !== 'string' || !orgName.trim()) { + errors.push('ORGANIZATION_NAME'); + } else if (!ghOrgRegex.test(orgName)) { + errors.push(`ORGANIZATION_NAME (${orgName}) is invalid, only letters, numbers, and hyphens are allowed`); + } + const hfOrgRegex = /^[a-zA-Z0-9_\-]+$/; + const hfOrgName = config.HF_ORGANIZATION_NAME; + if (typeof hfOrgName !== 'string' || !hfOrgName.trim()) { + errors.push('HF_ORGANIZATION_NAME'); + } else if (!hfOrgRegex.test(hfOrgName)) { + errors.push(`HF_ORGANIZATION_NAME (${hfOrgName}) is invalid, only letters, numbers, hyphens, and underscores are allowed`); + } if (!config.ORG_NAME) errors.push('ORG_NAME'); if (!config.CATALOG_REPO_NAME) errors.push('CATALOG_REPO_NAME'); - if (!config.PLATFORM) errors.push('PLATFORM'); - /** Update to include 'codeberg' and 'gitlab' once supported */ + + /** Update to include 'codeberg' and 'gitlab' once supported + * PLATFORM will be parsed without whitespace, but must be string to avoid undefined errors + */ const supportedPlatforms = ['github']; - if (config.PLATFORM && !supportedPlatforms.includes(config.PLATFORM.toLowerCase())) { + const platform = config.PLATFORM; + if (!platform || typeof platform !== 'string') { + errors.push('PLATFORM'); + } else if (!supportedPlatforms.includes(platform.toLowerCase())) { errors.push(`PLATFORM must be one of: ${supportedPlatforms.join(', ')}`); } + if (!config.API_BASE_URL) errors.push('API_BASE_URL'); if (config.REFRESH_INTERVAL_DAYS == null) errors.push('REFRESH_INTERVAL_DAYS'); diff --git a/tests/validateConfig.test.js b/tests/validateConfig.test.js index a9ee8c1..089e2e6 100644 --- a/tests/validateConfig.test.js +++ b/tests/validateConfig.test.js @@ -3,6 +3,7 @@ import { validateConfig } from '../src/validateConfig.js'; const VALID_CONFIG = { ORGANIZATION_NAME: 'imageomics', + HF_ORGANIZATION_NAME: 'imageomics', ORG_NAME: 'Imageomics', CATALOG_REPO_NAME: 'catalog', PLATFORM: 'github', @@ -53,6 +54,27 @@ describe('validateConfig', () => { expect(validateConfig({ ...VALID_CONFIG, ORGANIZATION_NAME: '' })).toContain('ORGANIZATION_NAME'); }); + it('errors when ORGANIZATION_NAME is not in accepted format', () => { + expect( + validateConfig({ ...VALID_CONFIG, ORGANIZATION_NAME: 'abc center' }) + ).toContain('ORGANIZATION_NAME (abc center) is invalid, only letters, numbers, and hyphens are allowed'); + }); + + it('errors when HF_ORGANIZATION_NAME is missing', () => { + const { HF_ORGANIZATION_NAME: _, ...config } = VALID_CONFIG; + expect(validateConfig(config)).toContain('HF_ORGANIZATION_NAME'); + }); + + it('errors when HF_ORGANIZATION_NAME is empty string', () => { + expect(validateConfig({ ...VALID_CONFIG, HF_ORGANIZATION_NAME: '' })).toContain('HF_ORGANIZATION_NAME'); + }); + + it('errors when HF_ORGANIZATION_NAME is not in accepted format', () => { + expect( + validateConfig({ ...VALID_CONFIG, HF_ORGANIZATION_NAME: 'abc center' }) + ).toContain('HF_ORGANIZATION_NAME (abc center) is invalid, only letters, numbers, hyphens, and underscores are allowed'); + }); + it('errors when ORG_NAME is missing', () => { const { ORG_NAME: _, ...config } = VALID_CONFIG; expect(validateConfig(config)).toContain('ORG_NAME'); @@ -75,6 +97,10 @@ describe('validateConfig', () => { const { PLATFORM: _, ...config } = VALID_CONFIG; expect(validateConfig(config)).toContain('PLATFORM'); }); + + it('errors when PLATFORM is not a string', () => { + expect(validateConfig({ ...VALID_CONFIG, PLATFORM: 123 })).toContain('PLATFORM'); + }); it('errors when PLATFORM is unrecognized', () => { const errors = validateConfig({ ...VALID_CONFIG, PLATFORM: 'blah' }); @@ -141,6 +167,7 @@ describe('validateConfig', () => { }; const errors = validateConfig(config); expect(errors).toContain('ORGANIZATION_NAME'); + expect(errors).toContain('HF_ORGANIZATION_NAME'); expect(errors).toContain('ORG_NAME'); expect(errors).toContain('CATALOG_REPO_NAME'); expect(errors).toContain('PLATFORM'); diff --git a/vite.config.js b/vite.config.js index c216b73..fc44854 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,5 +1,33 @@ import tailwindcss from '@tailwindcss/vite' import { defineConfig } from 'vite' +import fs from 'fs'; +import jsYaml from 'js-yaml'; +import { validateConfig } from './src/validateConfig.js'; + +const CONFIG_PATH = './public/config.yaml'; +let parsedConfig; + +// Run config validation before Vite starts (dev/build/preview) +try { + const fileContents = fs.readFileSync(CONFIG_PATH, 'utf8'); + parsedConfig = jsYaml.load(fileContents); +} catch (error) { + console.error(`\n❌ [CONFIG ERROR] Failed to read or parse ${CONFIG_PATH}: ${error.message}\n`); + process.exit(1); // Stop Vite immediately if the file is completely unreadable or broken YAML +} + +// Any errors will be collected in the 'errors' array, to be printed out and cause a safe crash if not empty. +const errors = validateConfig(parsedConfig); +if (errors && errors.length > 0) { + console.error('\n❌ [CONFIG ERROR] Vite failed to start due to the following configuration errors:\n'); + + errors.forEach((error, index) => { + console.error(` ${index + 1}. ${error}`); + }); + + console.error(`\n Please fix the listed issue(s) in ${CONFIG_PATH} and try again.\n`); + process.exit(1); // Safely kills the npm run dev terminal process +} const repoName = process.env.GITHUB_REPOSITORY?.split('/')[1] From c00724e2698c3f67bc91f727660231d3ae8022b8 Mon Sep 17 00:00:00 2001 From: Elizabeth Campolongo <38985481+egrace479@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:40:33 -0400 Subject: [PATCH 03/12] Modularize the codebase Pulled from Imageomics catalog [PR 115](https://github.com/Imageomics/catalog/pull/115) Excluded agents md updates from the original commit. * Move type/platform-based repo fetching to dedicated api modules * Check releases are loaded before fetching code repos * Move rendering, URL management, and UI logic to external modules * Move tag lookup creation to tag-related utils file to reduce parameter passing internal variable only required for normalizing tags * Restructure repo to accommodate larger number of source files arrange by purpose * Update AGENTS repository layout description to reflect changes initial description updates generated with Gemini 3.1 Pro in a modularization discussion thread updated paths for test list * Add tests for new API fetch modules and URL management Tests constructed with aid of Gemini 3.1 Pro and Thinking Switch to jsdom testing strategy, copilot suggestion * Move HF default tag filtering to normalizeTag utility consolidates tag processing, creating single location for potential personalization of tags cherry-picked from commit: 76a1f4a16cdc1ba2e74d237d92e4ebc66c0b709c --- README.md | 7 +- docs/tag-grouping-process.md | 3 +- main.js | 635 ++---------------- package-lock.json | 533 +++++++++++++++ package.json | 3 +- scripts/export-tags.js | 6 +- scripts/fetch-releases.js | 2 +- src/api/fetchCodeRepos.js | 118 ++++ src/api/fetchHfRepos.js | 105 +++ src/api/fetchStats.js | 38 ++ src/normalizeTag.js | 18 - src/ui/initUserInterface.js | 106 +++ src/ui/render.js | 164 +++++ src/ui/urlManager.js | 85 +++ src/{ => utils}/defineApiUrls.js | 0 src/{ => utils}/defineRibbonVals.js | 0 src/{ => utils}/filterAndSort.js | 0 src/{ => utils}/filterNewAdditionalEntries.js | 0 src/utils/normalizeTag.js | 64 ++ tests/api/fetchCodeRepos.test.js | 310 +++++++++ tests/api/fetchHfRepos.test.js | 341 ++++++++++ tests/ui/urlManager.test.js | 105 +++ tests/{ => utils}/filterAndSort.test.js | 2 +- .../filterNewAdditionalEntries.test.js | 2 +- tests/{ => utils}/normalizeTag.test.js | 2 +- 25 files changed, 2031 insertions(+), 618 deletions(-) create mode 100644 src/api/fetchCodeRepos.js create mode 100644 src/api/fetchHfRepos.js create mode 100644 src/api/fetchStats.js delete mode 100644 src/normalizeTag.js create mode 100644 src/ui/initUserInterface.js create mode 100644 src/ui/render.js create mode 100644 src/ui/urlManager.js rename src/{ => utils}/defineApiUrls.js (100%) rename src/{ => utils}/defineRibbonVals.js (100%) rename src/{ => utils}/filterAndSort.js (100%) rename src/{ => utils}/filterNewAdditionalEntries.js (100%) create mode 100644 src/utils/normalizeTag.js create mode 100644 tests/api/fetchCodeRepos.test.js create mode 100644 tests/api/fetchHfRepos.test.js create mode 100644 tests/ui/urlManager.test.js rename tests/{ => utils}/filterAndSort.test.js (99%) rename tests/{ => utils}/filterNewAdditionalEntries.test.js (96%) rename tests/{ => utils}/normalizeTag.test.js (96%) diff --git a/README.md b/README.md index e7d8774..cec9eca 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,17 @@ The site runs based on four primary files: * `public/config.yaml`: Contains all customizable settings including organization names, colors, branding, and API settings. This is the main file to edit for personalization. Placed in `public/` so Vite copies it to `dist/` without bundling, keeping it editable after deployment. * `index.html`: The main HTML file that provides the structure of the webpage and links to the CSS and JavaScript files. Config values are applied dynamically from `config.yaml`. * `style.css`: Custom styling for the application, including color schemes, layout, and animations. Colors are set via CSS custom properties that are populated from `config.yaml`. -* `main.js`: Handles the application's logic, including config loading, API calls, data filtering, sorting, and dynamic rendering of the catalog items. Relies on the build-time Node script `fetch-releases.js` for version-tag badge feature. +* `main.js`: Application manager, handles config loading, event listeners, and coordinates UI updates based on API fetches for dynamic rendering of the catalog items. +* `src/`: Modular application logic, organized by purpose: + * `api/`: Data fetching modules for external platforms (GitHub and Hugging Face). + * `ui/`: DOM manipulation, HTML templating, and URL/State routing. + * `utils/`: Utility functions such as data filtering, sorting, and tag (keyword) normalization. Two additional files support the build tooling: * `package.json`: Declares npm dependencies (`vite`, `tailwindcss`, `@tailwindcss/vite`) and defines the `dev`, `build`, and `preview` scripts. * `vite.config.js`: Vite configuration that registers the Tailwind CSS plugin. +* `scripts/fetch-releases.js`: Build-time Node script to fetch GitHub repo release data for version-tag badge feature. ### Formatting Standard diff --git a/docs/tag-grouping-process.md b/docs/tag-grouping-process.md index b69a121..e73b013 100644 --- a/docs/tag-grouping-process.md +++ b/docs/tag-grouping-process.md @@ -19,8 +19,7 @@ const TAG_GROUPS = { - The **key** (`"canonical tag"`) is the display tag shown in the UI filter dropdown. - The **value array** lists every raw API tag that should be normalized to that key. - Raw tags not present in any array pass through unchanged and appear as-is in the UI. -- Raw tags that contain a colon (e.g. `license:mit`, `format:parquet`) are automatically - filtered out as Hugging Face system metadata so they never reach the UI. This can be changed in [src/normalizeTag.js](../src/normalizeTag.js), by removing the marked option line. +- Raw tags that contain a colon (e.g. `license:mit`, `format:parquet`) are automatically filtered out as Hugging Face system metadata so they never reach the UI. This can be changed in [src/normalizeTag.js](../src/normalizeTag.js), by removing the marked option lines in `normalizeTag` and `filterDisplayTags`. - Raw tags are maintained and matched-against for keyword searching and do appear in repo cards. --- diff --git a/main.js b/main.js index 780d760..7c91a0a 100644 --- a/main.js +++ b/main.js @@ -7,11 +7,14 @@ // import jsYaml from 'js-yaml'; -import { normalizeTag } from './src/normalizeTag.js'; -import { getPlatformApiUrls } from './src/defineApiUrls.js'; -import { getPlatformDisplay } from './src/defineRibbonVals.js'; -import { filterItems, sortItems } from './src/filterAndSort.js'; -import { filterNewAdditionalEntries } from './src/filterNewAdditionalEntries.js'; +import { initializeUIFromConfig, setThemeToggle } from './src/ui/initUserInterface.js'; +import { parseUrlParams, updateUrlParams, getCurrentState } from './src/ui/urlManager.js'; +import { renderItemList } from './src/ui/render.js'; +import { getPlatformApiUrls } from './src/utils/defineApiUrls.js'; +import { filterItems, sortItems } from './src/utils/filterAndSort.js'; +import { fetchCodeRepos } from './src/api/fetchCodeRepos.js'; +import { fetchHfRepos } from './src/api/fetchHfRepos.js'; +import { fetchCatalogStats } from './src/api/fetchStats.js'; // Start fetching config immediately when the module loads (before DOMContentLoaded) // so the fetch is in-flight while the DOM is being parsed. @@ -27,23 +30,6 @@ let CONFIG; let ORGANIZATION_NAME, HF_ORGANIZATION_NAME, CATALOG_REPO_NAME, PLATFORM, API_BASE_URL, REFRESH_INTERVAL_DAYS, ADDITIONAL_REPOS, ADDITIONAL_HF_REPOS; let ORG_API_URL, REPO_API_URL; -// Build a reverse lookup from TAG_GROUPS (defined in tag-groups.js): raw tag → [canonical tags] -// A raw tag may appear in multiple groups, so the value is an array. -const tagLookup = Object.create(null); -if (typeof TAG_GROUPS !== 'undefined') { - for (const [canonical, aliases] of Object.entries(TAG_GROUPS)) { - for (const alias of aliases) { - const key = alias.toLowerCase(); - if (tagLookup[key]) { - tagLookup[key].push(canonical); - } else { - tagLookup[key] = [canonical]; - } - } - } -} - - let releasesMap = {}; let allItems = { @@ -65,103 +51,8 @@ let fetchedData = { spaces: false }; -// Utility function to handle errors and display a user-friendly message -const handleError = (error, message) => { - console.error(message, error); - const itemList = document.getElementById('itemList'); - itemList.innerHTML = `
-

Error loading items.

-

${message}

-
`; -}; - -// -// SECTION 1B: URL PARAMETER HANDLING -// - -/** - * Parses URL parameters from both query string (?key=value) and hash (#key=value). - * Query parameters take precedence over hash parameters. - * @returns {Object} An object containing the parsed parameters. - */ -const parseUrlParams = () => { - const params = {}; - - // Parse query string parameters (e.g., ?type=datasets&q=fish) - const queryString = window.location.search; - const urlParams = new URLSearchParams(queryString); - - // Parse hash parameters (e.g., #type=datasets&q=fish) - const hash = window.location.hash.slice(1); // Remove the leading '#' - const hashParams = new URLSearchParams(hash); - - // Hash parameters first (lower precedence) - for (const [key, value] of hashParams) { - params[key] = value; - } - - // Query parameters override hash parameters (higher precedence) - for (const [key, value] of urlParams) { - params[key] = value; - } - - return params; -}; - -/** - * Updates the URL hash with the current filter state without triggering a page reload. - * @param {Object} state - The current state object with type, q, sort, tag properties. - */ -const updateUrlParams = (state) => { - const params = new URLSearchParams(); - - // Only add non-default values to the URL - if (state.type && state.type !== 'all') { - params.set('type', state.type); - } - if (state.q && state.q.trim() !== '') { - params.set('q', state.q); - } - if (state.sort && state.sort !== 'lastModified') { - params.set('sort', state.sort); - } - if (state.tag && state.tag !== '') { - params.set('tag', state.tag); - } - if (state.archived && state.archived !== 'active') { - params.set('archived', state.archived); - } - - const paramString = params.toString(); - const newHash = paramString ? `#${paramString}` : ''; - - // Build the new URL preserving the pathname and any query parameters when clearing hash - const baseUrl = window.location.pathname + window.location.search; - const newUrl = newHash ? baseUrl + newHash : baseUrl; - - // Update the URL without triggering a page reload - const currentUrl = window.location.pathname + window.location.search + window.location.hash; - if (currentUrl !== newUrl) { - history.replaceState(null, '', newUrl); - } -}; - -/** - * Gets the current filter state from form elements. - * @returns {Object} The current state object. - */ -const getCurrentState = () => { - return { - type: document.getElementById('repoType')?.value || 'all', - q: document.getElementById('searchInput')?.value || '', - sort: document.getElementById('sortBy')?.value || 'lastModified', - tag: document.getElementById('tagFilter')?.value || '', - archived: document.getElementById('archiveFilter')?.value || 'active' - }; -}; - // -// SECTION 2: DATA FETCHING LOGIC +// SECTION 2: DATA FETCHING // /** @@ -178,388 +69,47 @@ const fetchHubItems = async (repoType) => { const skeletons = document.querySelectorAll('.skeleton-card'); skeletons.forEach(s => s.classList.remove('hidden')); - try { - let items = []; - - // Platform-specific api requests for code - if (repoType === "code") { - if (fetchedData.code) return allItems.code // reuse if already fetched - - // Paginate through all public repos (Platform determines API max returns per page) - let allRepos = []; - let nextUrl = `${ORG_API_URL}`; - while (nextUrl) { - const ghResponse = await fetch(nextUrl); - - if (!ghResponse.ok) { - const platformDisplay = getPlatformDisplay(PLATFORM); - throw new Error(`${platformDisplay.displayName || PLATFORM} error: ${ghResponse.status}`); - } - - const page = await ghResponse.json(); - allRepos = allRepos.concat(page); - - // Parse the Link header to find the next page URL, if any - const linkHeader = ghResponse.headers.get('Link'); - const match = linkHeader && linkHeader.match(/<([^>]+)>;\s*rel="next"/); - nextUrl = match ? match[1] : null; - } - - // For org-owned entries in ADDITIONAL_REPOS, reuse data already in allRepos to avoid redundant API calls. - // Only fetch entries that belong to a different org (external repos). - const allReposByFullName = new Map(allRepos.map(r => [r.full_name, r])); - const toFetch = ADDITIONAL_REPOS.filter(ownerRepo => !allReposByFullName.has(ownerRepo)); - const fromAllRepos = ADDITIONAL_REPOS.map(ownerRepo => allReposByFullName.get(ownerRepo)).filter(Boolean); - - const fetchedExternalData = await Promise.all( - toFetch.map(ownerRepo => - fetch(`${REPO_API_URL}${ownerRepo}`) - .then(r => { - if (!r.ok) { - console.warn(`Failed to fetch additional repo "${ownerRepo}": HTTP ${r.status}`); - return null; - } - return r.json(); - }) - .catch(err => { - console.warn(`Network error fetching additional repo "${ownerRepo}":`, err); - return null; - }) - ) - ); - const additionalRepos = [...fromAllRepos, ...fetchedExternalData.filter(Boolean)]; - - // Keep only non-forks from org; deduplicate against additional repos by full_name - const orgRepoNames = new Set(additionalRepos.map(r => r.full_name)); - const orgNonForks = allRepos.filter(repo => repo.name !== ".github" && !repo.fork && !orgRepoNames.has(repo.full_name)); - - // Process additional repos and all remaining org non-forks to include metadata and 'new' flag as appropriate - items = [...additionalRepos, ...orgNonForks] - .map(repo => { - const createdAt = new Date(repo.created_at); - const lastModified = new Date(repo.updated_at); - const isNew = (new Date() - createdAt) / (1000 * 60 * 60 * 24) < REFRESH_INTERVAL_DAYS; - - const rawTags = (repo.topics || []).map(t => t.toLowerCase()); - const tags = [...new Set(rawTags.flatMap(t => normalizeTag(t, tagLookup)).filter(Boolean))]; - const displayTags = rawTags.filter(t => !t.includes(':')); - tags.forEach(tag => tagsMap.code.add(tag)); - - const release = releasesMap[repo.full_name] ?? null; - - return { - id: repo.full_name, // "Imageomics/", used as backup if can't get repo.name - repoType: "code", - createdAt, - lastModified, - isNew, - archived: repo.archived || false, - tags, - rawTags, - displayTags, - description: repo.description || "No description provided.", - html_url: repo.html_url, - hasNewRelease: release?.isNew ?? false, - latestReleaseUrl: release?.url ?? null, - latestReleaseTag: release?.tag ?? null, - cardData: { - pretty_name: repo.name, // , the one used for card title display - description: repo.description, - stars: repo.stargazers_count - } - }; - }); - - allItems.code = items; - fetchedData.code = true; - - skeletons.forEach(s => s.classList.add('hidden')); - - return items; - } - - // hugging face api requests for datasets/models/spaces - const response = await fetch(`${API_BASE_URL}${repoType}?author=${HF_ORGANIZATION_NAME}&full=true`); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - let hfItems = await response.json(); - - // Fetch additional HF repos of this type from outside the org - const additionalForType = ADDITIONAL_HF_REPOS.filter(entry => entry.type === repoType); - if (additionalForType.length) { - const existingIds = new Set(hfItems.map(item => item.id)); - const toFetch = filterNewAdditionalEntries(existingIds, additionalForType); - - const fetched = await Promise.all( - toFetch.map(entry => - fetch(`${API_BASE_URL}${repoType}/${entry.repo}`) - .then(r => { - if (!r.ok) { - console.warn(`Failed to fetch additional HF repo "${entry.repo}": HTTP ${r.status}`); - return null; - } - return r.json(); - }) - .catch(err => { - console.warn(`Network error fetching additional HF repo "${entry.repo}":`, err); - return null; - }) - ) - ); - hfItems = [...hfItems, ...fetched.filter(item => item && !existingIds.has(item.id))]; - } + let items = [] - // Step 2: If we are fetching models, get the full details for each one. - if (repoType === 'models') { - const detailPromises = hfItems.map(item => - fetch(`${API_BASE_URL}models/${item.id}`).then(res => { - if (!res.ok) { - console.error(`Failed to fetch details for ${item.id}`); - return null; // Return null for failed requests - } - return res.json(); - }) - ); - - // Wait for all detail requests to complete in parallel. - const detailedItems = await Promise.all(detailPromises); - - // Filter out any models that failed to fetch and assign the detailed list. - hfItems = detailedItems.filter(Boolean); + if (repoType === 'code') { + // Check that releasesMap is populated; if not, fetch it from releases.json + if (Object.keys(releasesMap).length === 0) { + releasesMap = await fetch('./releases.json') + .then(res => res.ok ? res.json() : {}) + .catch(() => ({})); } - - // Process the data to include metadata and a 'new' flag - const processedItems = hfItems.map(item => { - const createdAt = new Date(item.createdAt); - const lastModified = new Date(item.lastModified); - const isNew = (new Date() - createdAt) / (1000 * 60 * 60 * 24) < REFRESH_INTERVAL_DAYS; - - // Extract tags from the YAML metadata (handling different structures) - const rawTags = (item.cardData?.tags || item.tags || []).map(t => String(t).toLowerCase()); - const tags = [...new Set(rawTags.flatMap(t => normalizeTag(t, tagLookup)).filter(Boolean))]; - const displayTags = rawTags.filter(t => !t.includes(':')); - tags.forEach(tag => tagsMap[repoType].add(tag)); - - return { - ...item, - repoType, - createdAt, - lastModified, - isNew, - archived: false, - likes: item.likes || 0, - tags, - rawTags, - displayTags - }; - }); - - allItems[repoType] = processedItems; - fetchedData[repoType] = true; - - skeletons.forEach(s => s.classList.add('hidden')); - - return processedItems; - } catch (error) { - handleError(error, `Failed to fetch ${repoType}. Please check your network connection or the API.`); - return []; - } -}; - -/** - * Fetches statistics for the Catalog repository itself (Stars, Forks, Version) - * populates the badge in the top right corner. - */ -const fetchCatalogStats = async () => { - // Helper: Updates text, shows the specific stat, and ensures the divider is visible - const update = (textId, containerId, value) => { - const el = document.getElementById(textId); - const container = document.getElementById(containerId); - if (el && container && value !== undefined) { - el.innerText = value; - if (value != 0) { - container.classList.remove('hidden'); - container.classList.add('flex'); - } - } - }; - - try { - //TODO: Update stars and forks to support other platforms (GitLab, Codeberg) once implemented - // 1. Get Stars & Forks - const repo = await fetch(`${REPO_API_URL}${ORGANIZATION_NAME}/${CATALOG_REPO_NAME}`).then(r => r.ok ? r.json() : {}); - if (repo.stargazers_count !== undefined) update('gh-stars', 'gh-star-container', repo.stargazers_count); - if (repo.forks_count !== undefined) update('gh-forks', 'gh-fork-container', repo.forks_count); - - // 2. Get Version (Tag) - // TODO: Import from package.json - const release = await fetch(`${REPO_API_URL}${ORGANIZATION_NAME}/${CATALOG_REPO_NAME}/releases/latest`).then(r => r.ok ? r.json() : {}); - if (release.tag_name) update('gh-tag', 'gh-version-container', release.tag_name); - - } catch (e) { - console.warn("Could not fetch Code Repo stats", e); - } -}; - -// -// SECTION 3: RENDERING LOGIC -// - -/** - * Escapes HTML special characters in a string to prevent XSS. - * @param {string} str - The input string. - * @returns {string} The escaped string. - */ -const escapeHTML = (str) => { - if (!str) return ""; - return str.replace(/[&<>"']/g, (m) => ({ - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''' - }[m])); -}; - -/** - * Adds word break opportunities after underscores to allow proper text wrapping. - * @param {string} str - The input string. - * @returns {string} The string with tags inserted after underscores. - */ -const addWordBreakOpportunities = (str) => { - if (!str) return ""; - // Replace underscores with underscore + word break opportunity - return str.replace(/_/g, '_'); -}; - -/** - * Renders a single item card (code, dataset, model, or space) to HTML. - * @param {Object} item - The item object to render. - * @param {string} repoType - The type of repository. - * @returns {string} The HTML string for the item card. - */ -const renderHubItemCard = (item, repoType) => { - const lastUpdatedDate = new Date(item.lastModified).toLocaleDateString(); - const tagsHtml = (item.displayTags || item.rawTags || []).map(tag => - `${tag}` - ).join(''); - - // Use pretty_name for the heading, with a fallback - // HF API keys for CardData: https://huggingface.co/docs/huggingface_hub/main/en/package_reference/cards#huggingface_hub.CardData - // datasets have pretty_name, models have model_name, spaces have title - const prettyName = item.cardData?.pretty_name || item.cardData?.model_name || item.cardData?.title || item.id.split('/')[1]; - - // Use the description from cardData, with fallbacks - const displayDescription = item.cardData?.description || item.cardData?.model_description || item.description || 'No description provided.'; - - // Construct the correct URL based on the repository type - let itemUrl; - - switch (item.repoType) { - case "code": - itemUrl = item.html_url; - break; - case "datasets": - itemUrl = `https://huggingface.co/datasets/${item.id}`; - break; - case "spaces": - itemUrl = `https://huggingface.co/spaces/${item.id}`; - break; - case "models": - itemUrl = `https://huggingface.co/${item.id}`; - break; - default: - // fallback for "all" - itemUrl = `https://huggingface.co/${item.id}`; - break; + items = await fetchCodeRepos( + PLATFORM, + ADDITIONAL_REPOS, + ORG_API_URL, + REPO_API_URL, + REFRESH_INTERVAL_DAYS, + releasesMap + ); + } else { + items = await fetchHfRepos( + repoType, + ADDITIONAL_HF_REPOS, + API_BASE_URL, + HF_ORGANIZATION_NAME, + REFRESH_INTERVAL_DAYS + ); } - // stars for code repos - const badgeHtml = (() => { - if (item.isNew) { - return ` - New! - `; - } - - if (typeof item.cardData.stars === "number" && item.cardData.stars > 0) { - return ` - ⭐ ${item.cardData.stars} - `; - } - - if (typeof item.likes === "number" && item.likes > 0) { - return ` - - ❤️ ${item.likes} - `; - } - return ""; - })(); - - const escapedTitle = escapeHTML(prettyName); - const displayTitle = addWordBreakOpportunities(escapedTitle); - - return ` -
-
-
-

- - ${displayTitle} - -

-
- ${badgeHtml} - ${(item.repoType === 'code' && item.hasNewRelease) - ? ` - 🚀 ${escapeHTML(item.latestReleaseTag)} - ` - : ''} -
-
-
- -

- ${displayDescription} -

- -
-
- ${tagsHtml} -
-
- Updated: ${lastUpdatedDate} - ${item.archived ? `Archived` : ''} -
-
-
- `; -}; + // Store fetched items and mark as fetched + allItems[repoType] = items; + fetchedData[repoType] = true; + items.forEach(item => { + item.tags.forEach(tag => tagsMap[repoType].add(tag)); + }); -/** - * Renders the list of items to the DOM. - * @param {Array} items - The array of item objects to render. - * @param {string} repoType - The type of repository. - */ -const renderItemList = (items, repoType) => { - const itemListElement = document.getElementById('itemList'); - const emptyStateElement = document.getElementById('emptyState'); + skeletons.forEach(s => s.classList.add('hidden')); - if (items.length === 0) { - itemListElement.innerHTML = ''; - emptyStateElement.classList.remove('hidden'); - } else { - itemListElement.innerHTML = items.map(item => renderHubItemCard(item, repoType)).join(''); - emptyStateElement.classList.add('hidden'); - } + return items; }; // -// SECTION 4: SEARCH, FILTER, AND SORT LOGIC +// SECTION 3: SEARCH, FILTER, AND SORT LOGIC // /** @@ -592,7 +142,7 @@ const applyFiltersAndSort = async (updateUrl = true) => { const filtered = filterItems(currentItems, { searchTerm, tagFilter, archiveFilter }); const sorted = sortItems(filtered, sortBy); - renderItemList(sorted, repoType); + renderItemList(sorted); }; /** @@ -629,66 +179,8 @@ const populateTagFilter = (repoType) => { }; // -// SECTION 5: EVENT LISTENERS AND INITIALIZATION +// SECTION 4: EVENT LISTENERS AND INITIALIZATION // - -/** - * Initializes UI elements from configuration values. - * This sets up the header, logo, repo ribbon, and dynamic styles. - */ -const initializeUIFromConfig = () => { - // Set header logo - const logoImg = document.getElementById('logo-img'); - if (logoImg) { - logoImg.src = CONFIG.LOGO_URL; - logoImg.alt = CONFIG.ORG_NAME + ' Logo'; - - logoImg.onload = () => { - logoImg.classList.remove('opacity-0'); - }; - } - - // Set header title and description - const headerTitle = document.getElementById('header-title'); - if (headerTitle) { - headerTitle.textContent = CONFIG.CATALOG_TITLE || `${CONFIG.ORG_NAME} Catalog`; - headerTitle.style.color = CONFIG.COLORS.primary; - } - - const headerDesc = document.getElementById('header-description'); - if (headerDesc) { - headerDesc.textContent = CONFIG.CATALOG_DESCRIPTION; - } - - // Set Code Repo ribbon link, SVG path, display name, and colors - const repoRibbon = document.getElementById('repo-ribbon'); - const platformDisplay = getPlatformDisplay(PLATFORM); - if (repoRibbon && platformDisplay) { - repoRibbon.href = `${platformDisplay.ribbonUrl}${ORGANIZATION_NAME}/${CATALOG_REPO_NAME}`; - const pathElement = document.getElementById('repo-ribbon-icon'); - pathElement.setAttribute('d', platformDisplay.path); - const platformDisplayName = document.getElementById('platform-display-name'); - platformDisplayName.textContent = platformDisplay.displayName || PLATFORM; - repoRibbon.style.backgroundColor = CONFIG.COLORS.secondary; - repoRibbon.style.setProperty('--hover-color', CONFIG.COLORS.primary); - repoRibbon.addEventListener('mouseenter', function () { - this.style.backgroundColor = CONFIG.COLORS.primary; - }); - repoRibbon.addEventListener('mouseleave', function () { - this.style.backgroundColor = CONFIG.COLORS.secondary; - }); - } - - // Set focus ring colors for form inputs and link hover colors - const style = document.createElement('style'); - style.textContent = ` - .focus\\:ring-2:focus { --tw-ring-color: var(--color-accent) !important; } - .item-link:hover { color: var(--color-accent) !important; } - .dark .item-link:hover { color: var(--color-accent-dark) !important; } - `; - document.head.appendChild(style); -}; - document.addEventListener('DOMContentLoaded', async () => { // Load config before anything else try { @@ -732,30 +224,10 @@ document.addEventListener('DOMContentLoaded', async () => { console.error("Organization name is missing for one or both APIs. Halting initialization."); return; } - - // Apply CSS custom properties and document metadata - document.title = CONFIG.CATALOG_TITLE || `${CONFIG.ORG_NAME} Catalog`; - document.documentElement.style.setProperty('--color-primary', CONFIG.COLORS?.primary || '#92991c'); - document.documentElement.style.setProperty('--color-secondary', CONFIG.COLORS?.secondary || '#5d8095'); - document.documentElement.style.setProperty('--color-accent', CONFIG.COLORS?.accent || '#0097b2'); - document.documentElement.style.setProperty('--color-accent-dark', CONFIG.COLORS?.accentDark || '#4fd1eb'); - document.documentElement.style.setProperty('--color-tag', CONFIG.COLORS?.tag || '#9bcb5e'); - const fontFamily = CONFIG.FONT_FAMILY || 'Inter'; - document.documentElement.style.setProperty('--font-family', fontFamily.includes(' ') ? `"${fontFamily}"` : fontFamily); - - // Update Google Fonts link - if (CONFIG.FONT_FAMILY) { - const fontFamily = CONFIG.FONT_FAMILY.replace(/\s+/g, '+'); - const fontLink = document.getElementById('font-link'); - if (fontLink) fontLink.href = `https://fonts.googleapis.com/css2?family=${fontFamily}:wght@400;500;600;700&display=swap`; - } - - // Set favicon - const faviconLink = document.getElementById('favicon-link'); - if (faviconLink && CONFIG.FAVICON_URL) faviconLink.href = CONFIG.FAVICON_URL; // Initialize UI from config - initializeUIFromConfig(); + initializeUIFromConfig(CONFIG); + setThemeToggle(); const searchInput = document.getElementById('searchInput'); const sortBySelect = document.getElementById('sortBy'); @@ -817,7 +289,7 @@ document.addEventListener('DOMContentLoaded', async () => { }); // Initialize the Catalog Badge (Stars/Forks/Version) - fetchCatalogStats(); + fetchCatalogStats(REPO_API_URL, ORGANIZATION_NAME, CATALOG_REPO_NAME) // Load pre-built release data (written by scripts/fetch-releases.js at build time) releasesMap = await fetch('./releases.json') @@ -858,18 +330,3 @@ document.addEventListener('DOMContentLoaded', async () => { await applyFiltersAndSort(false); updateUrlParams(getCurrentState()); }); - -// -// THEME TOGGLE LOGIC -// -const themeToggleBtn = document.getElementById('themeToggleBtn'); - -themeToggleBtn.addEventListener('click', () => { - if (document.documentElement.classList.contains('dark')) { - document.documentElement.classList.remove('dark'); - localStorage.theme = 'light'; - } else { - document.documentElement.classList.add('dark'); - localStorage.theme = 'dark'; - } -}); diff --git a/package-lock.json b/package-lock.json index a5c8051..1269d22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.2.1", + "jsdom": "^29.1.1", "tailwindcss": "^4.2.1", "vite": "^7.3.2", "vitest": "^4.1.5" @@ -21,6 +22,210 @@ "node": ">=24" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -463,6 +668,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1360,6 +1583,16 @@ "node": ">=12" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1377,6 +1610,41 @@ "dev": true, "license": "MIT" }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1401,6 +1669,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", @@ -1510,6 +1791,26 @@ "dev": true, "license": "ISC" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -1532,6 +1833,47 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/lightningcss": { "version": "1.31.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", @@ -1793,6 +2135,16 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1803,6 +2155,13 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1833,6 +2192,19 @@ ], "license": "MIT" }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1889,6 +2261,26 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -1934,6 +2326,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1965,6 +2370,13 @@ "dev": true, "license": "MIT" }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", @@ -2030,6 +2442,62 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.6.tgz", + "integrity": "sha512-rbP0Gyx8b3Ae9yO//CU2wbSnQNoQ66m1nJdSbSHmnwKwzkkz/u8mERYU8T2rmlmy+bJvRNn84yNCW8gYqox44Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.6" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.6.tgz", + "integrity": "sha512-TkQNGJIhlEphpHCjKodMTSe23egUZr/g+flI2qkLgiJ/maAzSgXypSLRTNH3nCmqgayEmtcJBiLcfODSAr1xoA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/vite": { "version": "7.3.2", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", @@ -2195,6 +2663,54 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2211,6 +2727,23 @@ "engines": { "node": ">=8" } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index a05160c..bf477e4 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@tailwindcss/vite": "^4.2.1", "tailwindcss": "^4.2.1", "vite": "^7.3.2", - "vitest": "^4.1.5" + "vitest": "^4.1.5", + "jsdom": "^29.1.1" } } diff --git a/scripts/export-tags.js b/scripts/export-tags.js index 8e046ec..dc34b54 100644 --- a/scripts/export-tags.js +++ b/scripts/export-tags.js @@ -17,9 +17,9 @@ import path from 'path'; import { fileURLToPath } from 'url'; import jsYaml from 'js-yaml'; import { validateConfig } from '../src/validateConfig.js'; -import { getPlatformApiUrls } from '../src/defineApiUrls.js'; -import { getPlatformDisplay } from '../src/defineRibbonVals.js'; -import { filterNewAdditionalEntries } from '../src/filterNewAdditionalEntries.js'; +import { getPlatformApiUrls } from '../src/utils/defineApiUrls.js'; +import { getPlatformDisplay } from '../src/utils/defineRibbonVals.js'; +import { filterNewAdditionalEntries } from '../src/utils/filterNewAdditionalEntries.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); diff --git a/scripts/fetch-releases.js b/scripts/fetch-releases.js index e868f25..f24f2fc 100644 --- a/scripts/fetch-releases.js +++ b/scripts/fetch-releases.js @@ -6,7 +6,7 @@ import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import jsYaml from 'js-yaml'; import { validateConfig } from '../src/validateConfig.js'; -import { getPlatformApiUrls } from '../src/defineApiUrls.js'; +import { getPlatformApiUrls } from '../src/utils/defineApiUrls.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); diff --git a/src/api/fetchCodeRepos.js b/src/api/fetchCodeRepos.js new file mode 100644 index 0000000..a427a2b --- /dev/null +++ b/src/api/fetchCodeRepos.js @@ -0,0 +1,118 @@ +import { handleError } from '../ui/render.js'; +import { normalizeTag, filterDisplayTags } from '../utils/normalizeTag.js'; +import { getPlatformDisplay } from '../utils/defineRibbonVals.js'; + +/** + * Function for fetching code repositories from the specified platform (GitHub, GitLab, or Codeberg). + * Both org-owned (non-forks) and additional repos are fetched; metadata is processed for each repo. + * It also determines if a repo is "new" based on the provided refresh interval. + * @async + * @param {string} platform - 'github', pending: 'gitlab', or 'codeberg' + * @param {Array} additionalRepos - An array of additional "owner/repo" strings to include in addition to non-forked + * org repos. + * @param {string} orgApiUrl - The API URL for fetching organization repos + * @param {string} repoApiUrl - The API URL for fetching individual repo details + * @param {number} refreshIntervalDays - The cutoff in days for determining if a repo is "new" + * @param {Record} releasesMap - + * Release information for repos, keyed by full_name + * @returns {Promise} processedItems - A promise resolving to an array of code repositories + */ +export async function fetchCodeRepos( + platform, + additionalRepos, + orgApiUrl, + repoApiUrl, + refreshIntervalDays, + releasesMap +) { + + let allRepos = []; + let nextUrl = `${orgApiUrl}`; + try { + while (nextUrl) { + const ghResponse = await fetch(nextUrl); + + if (!ghResponse.ok) { + const platformDisplay = getPlatformDisplay(platform); + throw new Error(`${platformDisplay.displayName || platform} error: ${ghResponse.status}`); + } + + const page = await ghResponse.json(); + allRepos = allRepos.concat(page); + + // Parse the Link header to find the next page URL, if any + const linkHeader = ghResponse.headers.get('Link'); + const match = linkHeader && linkHeader.match(/<([^>]+)>;\s*rel="next"/); + nextUrl = match ? match[1] : null; + } + + // For org-owned entries in additionalRepos, reuse data already in allRepos to avoid redundant API calls. + // Only fetch entries that belong to a different org (external repos). + const allReposByFullName = new Map(allRepos.map(r => [r.full_name, r])); + const toFetch = additionalRepos.filter(ownerRepo => !allReposByFullName.has(ownerRepo)); + const fromAllRepos = additionalRepos.map(ownerRepo => allReposByFullName.get(ownerRepo)).filter(Boolean); + + const fetchedExternalData = await Promise.all( + toFetch.map(ownerRepo => + fetch(`${repoApiUrl}${ownerRepo}`) + .then(r => { + if (!r.ok) { + console.warn(`Failed to fetch additional repo "${ownerRepo}": HTTP ${r.status}`); + return null; + } + return r.json(); + }) + .catch(err => { + console.warn(`Network error fetching additional repo "${ownerRepo}":`, err); + return null; + }) + ) + ); + const filteredAdditionalRepos = [...fromAllRepos, ...fetchedExternalData.filter(Boolean)]; + + // Keep only non-forks from org; deduplicate against additional repos by full_name + const orgRepoNames = new Set(filteredAdditionalRepos.map(r => r.full_name)); + const orgNonForks = allRepos.filter(repo => repo.name !== ".github" && !repo.fork && !orgRepoNames.has(repo.full_name)); + + // Process additional repos and all remaining org non-forks to include metadata and 'new' flag as appropriate + let processedItems = [...filteredAdditionalRepos, ...orgNonForks] + .map(repo => { + const createdAt = new Date(repo.created_at); + const lastModified = new Date(repo.updated_at); + const isNew = (new Date() - createdAt) / (1000 * 60 * 60 * 24) < refreshIntervalDays; + + const rawTags = (repo.topics || []).map(t => t.toLowerCase()); + const tags = [...new Set(rawTags.flatMap(t => normalizeTag(t)).filter(Boolean))]; + const displayTags = filterDisplayTags(rawTags); + + const release = releasesMap[repo.full_name] ?? null; + + return { + id: repo.full_name, // "Imageomics/", used as backup if can't get repo.name + repoType: "code", + createdAt, + lastModified, + isNew, + archived: repo.archived || false, + tags, + rawTags, + displayTags, + description: repo.description || "No description provided.", + html_url: repo.html_url, + hasNewRelease: release?.isNew ?? false, + latestReleaseUrl: release?.url ?? null, + latestReleaseTag: release?.tag ?? null, + cardData: { + pretty_name: repo.name, // , the one used for card title display + description: repo.description, + stars: repo.stargazers_count || 0 + } + }; + }); + + return processedItems; + } catch (error) { + handleError(error, `Failed to fetch code from ${platform}. Please check your network connection or the API.`); + return []; + } +} diff --git a/src/api/fetchHfRepos.js b/src/api/fetchHfRepos.js new file mode 100644 index 0000000..45fb7da --- /dev/null +++ b/src/api/fetchHfRepos.js @@ -0,0 +1,105 @@ +import { normalizeTag, filterDisplayTags } from '../utils/normalizeTag.js'; +import { handleError } from '../ui/render.js'; +import { filterNewAdditionalEntries } from '../utils/filterNewAdditionalEntries.js'; + +/** + * Function for fetching Hugging Face repositories (models, datasets, or spaces) from the specified organization. + * It fetches both org-owned and additional repos, processes metadata, and determines if a repo is "new" based on the provided refresh interval. + * @async + * @param {string} repoType - The type of repository to fetch ("datasets", "models", or "spaces"). + * @param {Array} additionalHfRepos - Array of Hugging Face repos, by type to include in addition to org-owned repos + * @param {string} apiBaseUrl - The base URL for the Hugging Face API + * @param {string} hfOrgName - The Hugging Face organization name + * @param {number} refreshIntervalDays - The cutoff in days for determining if a repo is "new" + * @returns {Promise} processedItems - A promise resolving to an array of Hugging Face repositories with metadata + */ +export async function fetchHfRepos( + repoType, + additionalHfRepos, + apiBaseUrl, + hfOrgName, + refreshIntervalDays +) { + + try { + // hugging face api requests for datasets/models/spaces + const response = await fetch(`${apiBaseUrl}${repoType}?author=${hfOrgName}&full=true`); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + let hfItems = await response.json(); + + // Fetch additional HF repos of this type from outside the org + const additionalForType = additionalHfRepos.filter(entry => entry.type === repoType); + if (additionalForType.length) { + const existingIds = new Set(hfItems.map(item => item.id)); + const toFetch = filterNewAdditionalEntries(existingIds, additionalForType); + + const fetched = await Promise.all( + toFetch.map(entry => + fetch(`${apiBaseUrl}${repoType}/${entry.repo}`) + .then(r => { + if (!r.ok) { + console.warn(`Failed to fetch additional HF repo "${entry.repo}": HTTP ${r.status}`); + return null; + } + return r.json(); + }) + .catch(err => { + console.warn(`Network error fetching additional HF repo "${entry.repo}":`, err); + return null; + }) + ) + ); + hfItems = [...hfItems, ...fetched.filter(item => item && !existingIds.has(item.id))]; + } + + // Step 2: If we are fetching models, get the full details for each one. + if (repoType === 'models') { + const detailPromises = hfItems.map(item => + fetch(`${apiBaseUrl}models/${item.id}`).then(res => { + if (!res.ok) { + console.error(`Failed to fetch details for ${item.id}`); + return null; // Return null for failed requests + } + return res.json(); + }) + ); + + // Wait for all detail requests to complete in parallel. + const detailedItems = await Promise.all(detailPromises); + + // Filter out any models that failed to fetch and assign the detailed list. + hfItems = detailedItems.filter(Boolean); + } + // Process the data to include metadata and a 'new' flag + const processedItems = hfItems.map(item => { + const createdAt = new Date(item.createdAt); + const lastModified = new Date(item.lastModified); + const isNew = (new Date() - createdAt) / (1000 * 60 * 60 * 24) < refreshIntervalDays; + + // Extract tags from the YAML metadata (handling different structures) + const rawTags = (item.cardData?.tags || item.tags || []).map(t => String(t).toLowerCase()); + const tags = [...new Set(rawTags.flatMap(t => normalizeTag(t)).filter(Boolean))]; + const displayTags = filterDisplayTags(rawTags); + + return { + ...item, + repoType, + createdAt, + lastModified, + isNew, + archived: false, + likes: item.likes || 0, + tags, + rawTags, + displayTags + }; + }); + + return processedItems; + } catch (error) { + handleError(error, `Failed to fetch ${repoType} from Hugging Face. Please check your network connection or the API.`); + return []; + } +} diff --git a/src/api/fetchStats.js b/src/api/fetchStats.js new file mode 100644 index 0000000..fc0c0bd --- /dev/null +++ b/src/api/fetchStats.js @@ -0,0 +1,38 @@ +/** + * Fetches statistics for the Catalog repository itself (Stars, Forks, Version) + * populates the badge in the top right corner. + * @param {string} repoApiUrl - The base API URL for the code platform (e.g., GitHub API URL) + * @param {string} organizationName - The organization name for the catalog repository + * @param {string} catalogRepoName - The repository name for the catalog itself + * @returns {Promise} - A promise that resolves when the stats have been fetched and displayed + */ +export const fetchCatalogStats = async (repoApiUrl, organizationName, catalogRepoName) => { + // Helper: Updates text, shows the specific stat, and ensures the divider is visible + const update = (textId, containerId, value) => { + const el = document.getElementById(textId); + const container = document.getElementById(containerId); + if (el && container && value !== undefined) { + el.innerText = value; + if (value != 0) { + container.classList.remove('hidden'); + container.classList.add('flex'); + } + } + }; + + try { + //TODO: Update stars and forks to support other platforms (GitLab, Codeberg) once implemented + // 1. Get Stars & Forks + const repo = await fetch(`${repoApiUrl}${organizationName}/${catalogRepoName}`).then(r => r.ok ? r.json() : {}); + if (repo.stargazers_count !== undefined) update('gh-stars', 'gh-star-container', repo.stargazers_count); + if (repo.forks_count !== undefined) update('gh-forks', 'gh-fork-container', repo.forks_count); + + // 2. Get Version (Tag) + // TODO: Import from package.json + const release = await fetch(`${repoApiUrl}${organizationName}/${catalogRepoName}/releases/latest`).then(r => r.ok ? r.json() : {}); + if (release.tag_name) update('gh-tag', 'gh-version-container', release.tag_name); + + } catch (e) { + console.warn("Could not fetch Code Repo stats", e); + } +}; diff --git a/src/normalizeTag.js b/src/normalizeTag.js deleted file mode 100644 index 56d7ba0..0000000 --- a/src/normalizeTag.js +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Normalizes a raw tag string. - * - Returns null for Hugging Face system metadata tags (tags containing a colon). - * - Maps known aliases to their canonical tag(s) via tagLookup. - * - Falls back to the lowercased original if no mapping exists. - * @param {string} tag - * @param {Object} tagLookup - Reverse lookup map: lowercased alias → [canonical tags] - * @returns {string[]|null} - */ -export function normalizeTag(tag, tagLookup) { - const lower = String(tag).toLowerCase(); - // OPTION LINE -- REMOVE IF UNWANTED - // Removes Hugging Face auto-generated system tags (e.g. "license:mit", "format:parquet"). - // These are identified by the presence of a colon. To include auto-generated tags in the - // catalog, remove the following line. - if (lower.includes(':')) return null; - return tagLookup[lower] ?? [lower]; -} diff --git a/src/ui/initUserInterface.js b/src/ui/initUserInterface.js new file mode 100644 index 0000000..8dd1691 --- /dev/null +++ b/src/ui/initUserInterface.js @@ -0,0 +1,106 @@ +import { getPlatformDisplay } from '../utils/defineRibbonVals.js'; + +/** + * Initializes UI elements from configuration values. + * This sets up the header, logo, repo ribbon, and dynamic styles. + * @param {Object} config - The loaded configuration object. + */ +export const initializeUIFromConfig = (config) => { + // Apply CSS custom properties and document metadata + document.title = config.CATALOG_TITLE || `${config.ORG_NAME} Catalog`; + document.documentElement.style.setProperty('--color-primary', config.COLORS?.primary || '#92991c'); + document.documentElement.style.setProperty('--color-secondary', config.COLORS?.secondary || '#5d8095'); + document.documentElement.style.setProperty('--color-accent', config.COLORS?.accent || '#0097b2'); + document.documentElement.style.setProperty('--color-accent-dark', config.COLORS?.accentDark || '#4fd1eb'); + document.documentElement.style.setProperty('--color-tag', config.COLORS?.tag || '#9bcb5e'); + const fontFamily = config.FONT_FAMILY || 'Inter'; + document.documentElement.style.setProperty('--font-family', fontFamily.includes(' ') ? `"${fontFamily}"` : fontFamily); + + // Update Google Fonts link + if (config.FONT_FAMILY) { + const fontFamily = config.FONT_FAMILY.replace(/\s+/g, '+'); + const fontLink = document.getElementById('font-link'); + if (fontLink) fontLink.href = `https://fonts.googleapis.com/css2?family=${fontFamily}:wght@400;500;600;700&display=swap`; + } + + // Set favicon + const faviconLink = document.getElementById('favicon-link'); + if (faviconLink && config.FAVICON_URL) faviconLink.href = config.FAVICON_URL; + + // Set header logo + const logoImg = document.getElementById('logo-img'); + if (logoImg) { + logoImg.src = config.LOGO_URL; + logoImg.alt = config.ORG_NAME + ' Logo'; + + logoImg.onload = () => { + logoImg.classList.remove('opacity-0'); + }; + } + + // Set header title and description + const headerTitle = document.getElementById('header-title'); + if (headerTitle) { + headerTitle.textContent = config.CATALOG_TITLE || `${config.ORG_NAME} Catalog`; + headerTitle.style.color = config.COLORS.primary; + } + + const headerDesc = document.getElementById('header-description'); + if (headerDesc) { + headerDesc.textContent = config.CATALOG_DESCRIPTION; + } + + // Set Code Repo ribbon link, SVG path, display name, and colors + const repoRibbon = document.getElementById('repo-ribbon'); + const platformDisplay = getPlatformDisplay(config.PLATFORM); + if (repoRibbon && platformDisplay) { + repoRibbon.href = `${platformDisplay.ribbonUrl}${config.ORGANIZATION_NAME}/${config.CATALOG_REPO_NAME}`; + const pathElement = document.getElementById('repo-ribbon-icon'); + pathElement.setAttribute('d', platformDisplay.path); + const platformDisplayName = document.getElementById('platform-display-name'); + platformDisplayName.textContent = platformDisplay.displayName || config.PLATFORM; + repoRibbon.style.backgroundColor = config.COLORS.secondary; + repoRibbon.style.setProperty('--hover-color', config.COLORS.primary); + repoRibbon.addEventListener('mouseenter', function () { + this.style.backgroundColor = config.COLORS.primary; + }); + repoRibbon.addEventListener('mouseleave', function () { + this.style.backgroundColor = config.COLORS.secondary; + }); + } + + // Set focus ring colors for form inputs and link hover colors + const style = document.createElement('style'); + style.textContent = ` + .focus\\:ring-2:focus { --tw-ring-color: var(--color-accent) !important; } + .item-link:hover { color: var(--color-accent) !important; } + .dark .item-link:hover { color: var(--color-accent-dark) !important; } + `; + document.head.appendChild(style); +}; + +// +// THEME TOGGLE LOGIC +// Establishes a button to toggle between light and dark themes, storing the preference in localStorage. +export const setThemeToggle = () => { + const themeToggleBtn = document.getElementById('themeToggleBtn'); + + if ( + localStorage.theme === 'dark' || + (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches) + ) { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + + themeToggleBtn.addEventListener('click', () => { + if (document.documentElement.classList.contains('dark')) { + document.documentElement.classList.remove('dark'); + localStorage.theme = 'light'; + } else { + document.documentElement.classList.add('dark'); + localStorage.theme = 'dark'; + } + }); +}; diff --git a/src/ui/render.js b/src/ui/render.js new file mode 100644 index 0000000..754280f --- /dev/null +++ b/src/ui/render.js @@ -0,0 +1,164 @@ +/** + * This module contains functions for presentation of code repositories, datasets, models, or spaces in HTML. + * It includes functions for escaping HTML, adding word break opportunities, and rendering item cards. + */ + +/** + * Escapes HTML special characters in a string to prevent XSS. + * @param {string} str - The input string. + * @returns {string} The escaped string. + */ +const escapeHTML = (str) => { + if (!str) return ""; + return str.replace(/[&<>"']/g, (m) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }[m])); +}; + +/** + * Adds word break opportunities after underscores to allow proper text wrapping. + * @param {string} str - The input string. + * @returns {string} The string with tags inserted after underscores. + */ +const addWordBreakOpportunities = (str) => { + if (!str) return ""; + // Replace underscores with underscore + word break opportunity + return str.replace(/_/g, '_'); +}; + +/** + * Renders a single item card (code, dataset, model, or space) to HTML. + * @param {Object} item - The item object to render. + * @returns {string} The HTML string for the item card. + */ +const renderHubItemCard = (item) => { + const lastUpdatedDate = new Date(item.lastModified).toLocaleDateString(); + const tagsHtml = (item.displayTags || item.rawTags || []).map(tag => + `${escapeHTML(tag)}` + ).join(''); + + // Use pretty_name for the heading, with a fallback + // HF API keys for CardData: https://huggingface.co/docs/huggingface_hub/main/en/package_reference/cards#huggingface_hub.CardData + // datasets have pretty_name, models have model_name, spaces have title + const prettyName = item.cardData?.pretty_name || item.cardData?.model_name || item.cardData?.title || item.id.split('/')[1]; + + // Use the description from cardData, with fallbacks + const displayDescription = item.cardData?.description || item.cardData?.model_description || item.description || 'No description provided.'; + + // Construct the correct URL based on the repository type + let itemUrl; + + switch (item.repoType) { + case "code": + itemUrl = item.html_url; + break; + case "datasets": + itemUrl = `https://huggingface.co/datasets/${item.id}`; + break; + case "spaces": + itemUrl = `https://huggingface.co/spaces/${item.id}`; + break; + case "models": + itemUrl = `https://huggingface.co/${item.id}`; + break; + default: + // fallback for "all" + itemUrl = `https://huggingface.co/${item.id}`; + break; + } + + // stars for code repos + const badgeHtml = (() => { + if (item.isNew) { + return ` + New! + `; + } + + if (typeof item.cardData?.stars === "number" && item.cardData.stars > 0) { + return ` + ⭐ ${item.cardData.stars} + `; + } + + if (typeof item.likes === "number" && item.likes > 0) { + return ` + + ❤️ ${item.likes} + `; + } + return ""; + })(); + + const escapedTitle = escapeHTML(prettyName); + const displayTitle = addWordBreakOpportunities(escapedTitle); + + return ` +
+
+
+

+ + ${displayTitle} + +

+
+ ${badgeHtml} + ${(item.repoType === 'code' && item.hasNewRelease) + ? ` + 🚀 ${escapeHTML(item.latestReleaseTag)} + ` + : ''} +
+
+
+ +

+ ${escapeHTML(displayDescription)} +

+ +
+
+ ${tagsHtml} +
+
+ Updated: ${lastUpdatedDate} + ${item.archived ? `Archived` : ''} +
+
+
+ `; +}; + +/** + * Renders the list of items to the DOM. + * @param {Array} items - The array of item objects to render. + */ +export const renderItemList = (items) => { + const itemListElement = document.getElementById('itemList'); + const emptyStateElement = document.getElementById('emptyState'); + + if (items.length === 0) { + itemListElement.innerHTML = ''; + emptyStateElement.classList.remove('hidden'); + } else { + itemListElement.innerHTML = items.map(item => renderHubItemCard(item)).join(''); + emptyStateElement.classList.add('hidden'); + } +}; + +// Utility function to handle errors and display a user-friendly message +export const handleError = (error, message) => { + console.error(message, error); + const itemList = document.getElementById('itemList'); + itemList.innerHTML = `
+

Error loading items.

+

${escapeHTML(message)}

+
`; +}; diff --git a/src/ui/urlManager.js b/src/ui/urlManager.js new file mode 100644 index 0000000..0c38335 --- /dev/null +++ b/src/ui/urlManager.js @@ -0,0 +1,85 @@ +/** + * This module manages URL parameters for the catalog application: parsing, updating, and reflecting the + * current filter state in the URL for shareable searches. + */ + +/** + * Parses URL parameters from both query string (?key=value) and hash (#key=value). + * Query parameters take precedence over hash parameters. + * @returns {Object} An object containing the parsed parameters. + */ +export const parseUrlParams = () => { + const params = {}; + + // Parse query string parameters (e.g., ?type=datasets&q=fish) + const queryString = window.location.search; + const urlParams = new URLSearchParams(queryString); + + // Parse hash parameters (e.g., #type=datasets&q=fish) + const hash = window.location.hash.slice(1); // Remove the leading '#' + const hashParams = new URLSearchParams(hash); + + // Hash parameters first (lower precedence) + for (const [key, value] of hashParams) { + params[key] = value; + } + + // Query parameters override hash parameters (higher precedence) + for (const [key, value] of urlParams) { + params[key] = value; + } + + return params; +}; + +/** + * Updates the URL hash with the current filter state without triggering a page reload. + * @param {Object} state - The current state object with type, q, sort, tag, archived properties. + */ +export const updateUrlParams = (state) => { + const params = new URLSearchParams(); + + // Only add non-default values to the URL + if (state.type && state.type !== 'all') { + params.set('type', state.type); + } + if (state.q && state.q.trim() !== '') { + params.set('q', state.q); + } + if (state.sort && state.sort !== 'lastModified') { + params.set('sort', state.sort); + } + if (state.tag && state.tag !== '') { + params.set('tag', state.tag); + } + if (state.archived && state.archived !== 'active') { + params.set('archived', state.archived); + } + + const paramString = params.toString(); + const newHash = paramString ? `#${paramString}` : ''; + + // Build the new URL preserving the pathname and any query parameters when clearing hash + const baseUrl = window.location.pathname + window.location.search; + const newUrl = newHash ? baseUrl + newHash : baseUrl; + + // Update the URL without triggering a page reload + const currentUrl = window.location.pathname + window.location.search + window.location.hash; + if (currentUrl !== newUrl) { + history.replaceState(null, '', newUrl); + } +}; + +/** + * Gets the current filter state from form elements. + * @returns {Object} The current state object. + */ +export const getCurrentState = () => { + return { + type: document.getElementById('repoType')?.value || 'all', + q: document.getElementById('searchInput')?.value || '', + sort: document.getElementById('sortBy')?.value || 'lastModified', + tag: document.getElementById('tagFilter')?.value || '', + archived: document.getElementById('archiveFilter')?.value || 'active' + }; +}; diff --git a/src/defineApiUrls.js b/src/utils/defineApiUrls.js similarity index 100% rename from src/defineApiUrls.js rename to src/utils/defineApiUrls.js diff --git a/src/defineRibbonVals.js b/src/utils/defineRibbonVals.js similarity index 100% rename from src/defineRibbonVals.js rename to src/utils/defineRibbonVals.js diff --git a/src/filterAndSort.js b/src/utils/filterAndSort.js similarity index 100% rename from src/filterAndSort.js rename to src/utils/filterAndSort.js diff --git a/src/filterNewAdditionalEntries.js b/src/utils/filterNewAdditionalEntries.js similarity index 100% rename from src/filterNewAdditionalEntries.js rename to src/utils/filterNewAdditionalEntries.js diff --git a/src/utils/normalizeTag.js b/src/utils/normalizeTag.js new file mode 100644 index 0000000..6b89177 --- /dev/null +++ b/src/utils/normalizeTag.js @@ -0,0 +1,64 @@ +/** + * Tag (keyword) processing, including normalization and tag-group lookup. + * These functions are used to standardize tags across different repositories and platforms. + */ + +/** + * Builds a reverse lookup map from TAG_GROUPS (defined in tag-groups.js): raw tag → [canonical tags] + * A raw tag may appear in multiple groups, so the value is an array. + * @returns {Object} The reverse lookup map. + */ +export const buildTagLookup = () => { + const lookup = Object.create(null); + if (typeof TAG_GROUPS !== 'undefined') { + for (const [canonical, aliases] of Object.entries(TAG_GROUPS)) { + for (const alias of aliases) { + const key = alias.toLowerCase(); + if (lookup[key]) { + lookup[key].push(canonical); + } else { + lookup[key] = [canonical]; + } + } + } + } + return lookup; +}; + +const tagLookup = buildTagLookup(); + +/** + * Normalizes a raw tag string for the tag selection dropdown. + * - Returns null for Hugging Face system metadata tags (tags containing a colon). + * - Maps known aliases to their canonical tag(s) via tagLookup. + * - Falls back to the lowercased original if no mapping exists. + * @param {string} tag + * @param {Object} [lookup=tagLookup] - Optional reverse lookup map (alias → canonical tags). + * Defaults to the module's prebuilt lookup from TAG_GROUPS. Pass a custom lookup in tests. + * @returns {string[]|null} + */ +export function normalizeTag(tag, lookup = tagLookup) { + const lower = String(tag).toLowerCase(); + // OPTION LINE -- REMOVE IF UNWANTED + // Removes Hugging Face auto-generated system tags (e.g. "license:mit", "format:parquet") from dropdown filter. + // These are identified by the presence of a colon. To include auto-generated tags in the + // catalog, remove the following line and `.filter` line from the filterDisplayTags function. + if (lower.includes(':')) return null; + return lookup[lower] ?? [lower]; +} + +/** + * Sorts and filters tags for display in the repo cards. + * Currently excludes tags containing a colon (Hugging Face system metadata tags). + * @param {string[]} rawTags - Array of tags to sort and filter. + * @returns {string[]} Filtered and sorted array of tags for display. + */ +export function filterDisplayTags(rawTags) { + let displayTags = rawTags.sort() || []; + // OPTION LINE -- REMOVE IF UNWANTED + // Removes Hugging Face auto-generated system tags (e.g. "license:mit", "format:parquet") from display. + // These are identified by the presence of a colon. To include auto-generated tags in the repo cards, + // remove the following line. + displayTags = displayTags.filter(t => !t.includes(':')); + return displayTags; +} diff --git a/tests/api/fetchCodeRepos.test.js b/tests/api/fetchCodeRepos.test.js new file mode 100644 index 0000000..98c594b --- /dev/null +++ b/tests/api/fetchCodeRepos.test.js @@ -0,0 +1,310 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { fetchCodeRepos } from '../../src/api/fetchCodeRepos.js'; +import { handleError } from '../../src/ui/render.js'; + +// Mock the internal dependencies +vi.mock('../../src/utils/normalizeTag.js', () => ({ + normalizeTag: vi.fn((tag) => [tag.toLowerCase()]), + filterDisplayTags: vi.fn((rawTags) => (rawTags || []).filter(t => !t.includes(':'))) +})); +vi.mock('../../src/ui/render.js', () => ({ + handleError: vi.fn() +})); +vi.mock('../../src/utils/defineRibbonVals.js', () => ({ + getPlatformDisplay: vi.fn(() => 'GitHub') +})); + +describe('fetchCodeRepos', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = vi.fn(); + }); + afterEach(() => { + global.fetch = originalFetch; + vi.clearAllMocks(); + }); + + const orgApiUrl = 'https://api.github.com/orgs/test-org/repos'; + const repoApiUrl = 'https://api.github.com/repos/'; + const refreshIntervalDays = 30; + const releasesMap = {}; + + // Generate reliable relative timestamps so tests don't break as time passes + const now = new Date(); + const recentDateISO = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000).toISOString(); // 2 days ago + const oldDateISO = new Date(now.getTime() - 45 * 24 * 60 * 60 * 1000).toISOString(); // 45 days ago + const oldestDateISO = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000).toISOString(); // 90 days ago + + it('maps raw repo data and attaches release data from releasesMap', async () => { + // Mock a single page GitHub response + global.fetch.mockResolvedValueOnce({ + ok: true, + headers: { get: () => null }, // No 'link' header means no pagination + json: () => Promise.resolve([{ + full_name: 'test-org/code-repo', + name: 'code-repo', + updated_at: now.toISOString(), + created_at: recentDateISO, + topics: ['python'], + stargazers_count: 42, + }]) + }); + + // Mock releases map generated by scripts/fetch-releases.js + const mockReleasesMap = { + 'test-org/code-repo': { tag: 'v1.0', url: 'http://...', isNew: true } + }; + + const items = await fetchCodeRepos( + 'github', + [], // No additional repos + orgApiUrl, + repoApiUrl, + refreshIntervalDays, + mockReleasesMap + ); + + expect(items).toHaveLength(1); + expect(items[0].repoType).toBe('code'); + expect(items[0].hasNewRelease).toBe(true); + expect(items[0].latestReleaseTag).toBe('v1.0'); + expect(items[0].tags).toContain('python'); + expect(items[0].cardData.pretty_name).toBe('code-repo'); + expect(items[0].cardData.stars).toBe(42); + }); + + // Pagination test: simulate multiple pages of GitHub API results using the Link header + it('handles multi-page pagination', async () => { + global.fetch.mockImplementation((url) => { + if (url === orgApiUrl) { + // Page 1 setup with Link header pointing to Page 2 + return Promise.resolve({ + ok: true, + headers: { + get: (name) => name.toLowerCase() === 'link' + ? `<${orgApiUrl}?page=2>; rel="next"` + : null + }, + json: () => Promise.resolve([{ + full_name: 'test-org/repo-page1', + name: 'repo-page1', + created_at: recentDateISO, + updated_at: recentDateISO + }]) + }); + } else if (url === `${orgApiUrl}?page=2`) { + // Page 2 setup with no Link header (breaks loop) + return Promise.resolve({ + ok: true, + headers: { get: () => null }, + json: () => Promise.resolve([{ + full_name: 'test-org/repo-page2', + name: 'repo-page2', + created_at: recentDateISO, + updated_at: recentDateISO }]) + }); + } + }); + + const items = await fetchCodeRepos( + 'github', + [], + orgApiUrl, + repoApiUrl, + refreshIntervalDays, + releasesMap); + + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch).toHaveBeenNthCalledWith(1, orgApiUrl); + expect(global.fetch).toHaveBeenNthCalledWith(2, `${orgApiUrl}?page=2`); + expect(items).toHaveLength(2); + expect(items.map(i => i.id)).toEqual(['test-org/repo-page1', 'test-org/repo-page2']); + expect(items[0].cardData.pretty_name).toBe('repo-page1'); + expect(items[1].cardData.pretty_name).toBe('repo-page2'); + }); + + // Test additional repos fetching and merging with org repos + it('fetches additional org repos once and fetches external repos', async () => { + // Base organization discovery lists 'test-org/internal-additional' + const mockOrgRepo = { + full_name: 'test-org/internal-additional', + name: 'internal-additional', + created_at: recentDateISO, + updated_at: recentDateISO + }; + + const mockExternalRepo = { + full_name: 'external-org/external-additional', + name: 'external-additional', + created_at: recentDateISO, + updated_at: recentDateISO + }; + + global.fetch.mockImplementation((url) => { + if (url === orgApiUrl) { + return Promise.resolve({ + ok: true, + headers: { get: () => null }, + json: () => Promise.resolve([mockOrgRepo]) + }); + } + if (url === `${repoApiUrl}external-org/external-additional`) { + return Promise.resolve({ + ok: true, + headers: { get: () => null }, + json: () => Promise.resolve(mockExternalRepo) + }); + } + }); + + const additionalRepos = [ + 'test-org/internal-additional', + 'external-org/external-additional' + ]; + + const items = await fetchCodeRepos( + 'github', + additionalRepos, + orgApiUrl, + repoApiUrl, + refreshIntervalDays, + releasesMap + ); + + /** Fetch should only be triggered twice: Once for org list, once for external repo. + * The internal-additional repo is found inside the cache, + * should not hit the network a second time. + */ + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch).not.toHaveBeenCalledWith( + `${repoApiUrl}test-org/internal-additional` + ); + + /** Assert deduplication: 'test-org/internal-additional' in both core lists, + * shouldn't show up twice in processed outputs. + */ + expect(items).toHaveLength(2); + expect(items.map(i => i.id)).toContain('test-org/internal-additional'); + expect(items.map(i => i.id)).toContain('external-org/external-additional'); + }); + + // Test that forks and .github repos are excluded from the final list + it('excludes repository targets designated as forks and .github', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + headers: { get: () => null }, + json: () => Promise.resolve([ + { + full_name: 'test-org/valid-repo', + name: 'valid-repo', + fork: false, + created_at: recentDateISO, + updated_at: recentDateISO + }, + { + full_name: 'test-org/forked-repo', + name: 'forked-repo', + fork: true, + created_at: recentDateISO, + updated_at: recentDateISO + }, + { + full_name: 'test-org/forked-in-list', + name: 'forked-in-list', + fork: true, + created_at: recentDateISO, + updated_at: recentDateISO + }, + { + full_name: 'test-org/.github', + name: '.github', + fork: false, + created_at: recentDateISO, + updated_at: recentDateISO + } + ]) + }); + + const additionalRepos = [ + 'test-org/forked-in-list' // This is a fork, but listed to include + ]; + + const items = await fetchCodeRepos( + 'github', + additionalRepos, + orgApiUrl, + repoApiUrl, + refreshIntervalDays, + releasesMap); + + expect(items).toHaveLength(2); // Should only include valid-repo and forked-in-list + expect(items.map(i => i.id)).toContain('test-org/valid-repo'); + expect(items.map(i => i.id)).toContain('test-org/forked-in-list'); + expect(items.map(i => i.id)).not.toContain('test-org/forked-repo'); + expect(items.map(i => i.id)).not.toContain('test-org/.github'); + }); + + // Test that isNew flag is set correctly based on creation date and refresh interval + it('flags items as new based on evaluation thresholds', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + headers: { get: () => null }, + json: () => Promise.resolve([ + { + full_name: 'test-org/new-repo', + name: 'new-repo', + created_at: recentDateISO, + updated_at: now.toISOString() + }, + { + full_name: 'test-org/old-repo', + name: 'old-repo', + created_at: oldestDateISO, + updated_at: oldDateISO + } + ]) + }); + + const items = await fetchCodeRepos( + 'github', + [], + orgApiUrl, + repoApiUrl, + refreshIntervalDays, + releasesMap + ); + + const newRepo = items.find(i => i.id === 'test-org/new-repo'); + const oldRepo = items.find(i => i.id === 'test-org/old-repo'); + + expect(newRepo.isNew).toBe(true); + expect(oldRepo.isNew).toBe(false); + }); + + // Test that network errors are handled gracefully and trigger UI error handling + it('handles network errors gracefully', async () => { + // Force the main fetch to fail + global.fetch.mockResolvedValueOnce({ + ok: false, + status: 404, + headers: { get: () => null } + }); + + const items = await fetchCodeRepos( + 'github', + [], + orgApiUrl, + repoApiUrl, + refreshIntervalDays, + releasesMap + ); + + expect(handleError).toHaveBeenCalledWith( + expect.any(Error), + expect.stringContaining('Failed to fetch code from github') + ); + expect(items).toEqual([]); + }); + +}); diff --git a/tests/api/fetchHfRepos.test.js b/tests/api/fetchHfRepos.test.js new file mode 100644 index 0000000..ef6248a --- /dev/null +++ b/tests/api/fetchHfRepos.test.js @@ -0,0 +1,341 @@ +// tests/fetchHfRepos.test.js +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { fetchHfRepos } from '../../src/api/fetchHfRepos.js'; +import { handleError } from '../../src/ui/render.js'; + +// Mock the internal dependencies +vi.mock('../../src/utils/normalizeTag.js', () => ({ + normalizeTag: vi.fn((tag) => [tag.toLowerCase()]), + filterDisplayTags: vi.fn((rawTags) => (rawTags || []).filter(t => !t.includes(':'))) +})); + +vi.mock('../../src/ui/render.js', () => ({ + handleError: vi.fn() +})); + +vi.mock('../../src/utils/filterNewAdditionalEntries.js', () => ({ + // Pass the additional entries straight through + filterNewAdditionalEntries: vi.fn((existingIds, additional) => + additional.filter(entry => !existingIds.has(entry.repo))) +})); + +describe('fetchHfRepos', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + // Reset the fetch mock before each test + global.fetch = vi.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.clearAllMocks(); + }); + + const apiBaseUrl = 'https://huggingface.co/api/'; + const hfOrgName = 'test-org'; + const refreshIntervalDays = 30; + + // Generate reliable relative timestamps so tests don't break as time passes + const now = new Date(); + const recentDateISO = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000).toISOString(); // 2 days ago + const oldDateISO = new Date(now.getTime() - 45 * 24 * 60 * 60 * 1000).toISOString(); // 45 days ago + const oldestDateISO = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000).toISOString(); // 90 days ago + + // Test fetching datasets and checking the "new" flag based on creation date + it('fetches datasets and flags new repositories as expected', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve([ + { + id: 'test-org/new-dataset', + createdAt: recentDateISO, + lastModified: recentDateISO, + likes: 10, + tags: ['nlp'] + }, + { + id: 'test-org/old-dataset', + createdAt: oldestDateISO, + lastModified: oldDateISO, + likes: 5, + tags: ['vision'] + } + ]) + }); + + const items = await fetchHfRepos( + 'datasets', + [], + apiBaseUrl, + hfOrgName, + refreshIntervalDays + ); + + expect(items).toHaveLength(2); + // new dataset expected values + expect(items[0].id).toBe('test-org/new-dataset'); + expect(items[0].repoType).toBe('datasets'); + expect(items[0].likes).toBe(10); + expect(items[0].tags).toContain('nlp'); + expect(items[0].isNew).toBe(true); + // old dataset expected values + expect(items[1].id).toBe('test-org/old-dataset'); + expect(items[1].repoType).toBe('datasets'); + expect(items[1].likes).toBe(5); + expect(items[1].tags).toContain('vision'); + expect(items[1].isNew).toBe(false); + }); + + it('fetches datasets as expected without secondary detail fetch', async () => { + // Setup mock response for the main org datasets + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve([{ + id: 'test-org/dataset-1', + createdAt: recentDateISO, // 1 month ago + lastModified: now.toISOString(), + tags: ['nlp', 'task:text-classification'], + cardData: + { + pretty_name: 'Dataset 1', + description: 'Test dataset' + } + }]) + }); + + const items = await fetchHfRepos( + 'datasets', + [], // No additional repos + apiBaseUrl, + hfOrgName, + refreshIntervalDays + ); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledWith(`${apiBaseUrl}datasets?author=${hfOrgName}&full=true`); + expect(items).toHaveLength(1); + expect(items[0].repoType).toBe('datasets'); + expect(items[0].tags).toContain('nlp'); + expect(items[0].cardData.pretty_name).toBe('Dataset 1'); + expect(items[0].cardData.description).toBe('Test dataset'); + + // Assert dates are correctly converted to Date objects + expect(items[0].createdAt).toBeInstanceOf(Date); + expect(items[0].lastModified).toBeInstanceOf(Date); + }); + + it('makes secondary fetch calls when fetching models to get full details', async () => { + // We need a custom mock implementation because models trigger sequential fetch calls + global.fetch.mockImplementation((url) => { + if (url.includes('models?author=')) { + // Return the initial summary list + return Promise.resolve({ + ok: true, + json: () => Promise.resolve([{ id: 'test-org/model-1' }]) + }); + } + if (url.includes('models/test-org/model-1')) { + // Return the detailed model data + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + id: 'test-org/model-1', + createdAt: now.toISOString(), // Simulating a brand new model + lastModified: now.toISOString(), + cardData: + { + model_name: 'Model 1', + model_description: 'Test model', + tags: ['vision'] + } + }) + }); + } + }); + + const items = await fetchHfRepos( + 'models', + [], + apiBaseUrl, + hfOrgName, + refreshIntervalDays + ); + + // It should have called the main endpoint and the specific model endpoint + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch).toHaveBeenCalledWith(`${apiBaseUrl}models/test-org/model-1`); + + expect(items).toHaveLength(1); + expect(items[0].isNew).toBe(true); + expect(items[0].tags).toContain('vision'); + expect(items[0].cardData.model_name).toBe('Model 1'); + expect(items[0].cardData.model_description).toBe('Test model'); + }); + + // Test that the function handles partial failures gracefully + it('survives partial failures if one model secondary fetch fails but another succeeds', async () => { + global.fetch.mockImplementation((url) => { + if (url.includes('models?author=')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve([ + { id: 'test-org/good-model' }, + { id: 'test-org/broken-model' } + ]) + }); + } + if (url.includes('models/test-org/good-model')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + id: 'test-org/good-model', + createdAt: recentDateISO, + lastModified: recentDateISO + }) + }); + } + if (url.includes('models/test-org/broken-model')) { + // Simulate a broken/missing resource detail + return Promise.resolve({ ok: false, status: 404 }); + } + }); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const items = await fetchHfRepos( + 'models', + [], + apiBaseUrl, + hfOrgName, + refreshIntervalDays + ); + + // The broken model should be cleanly filtered out by detailedItems.filter(Boolean) + expect(items).toHaveLength(1); + expect(items[0].id).toBe('test-org/good-model'); + expect(items[0].isNew).toBe(true); + expect(items[0].repoType).toBe('models'); + + // Ensure the error was logged for the broken model + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining( + 'Failed to fetch details for test-org/broken-model' + )); + + consoleSpy.mockRestore(); + }); + + // Test that additional repos are fetched and merged correctly + it('fetches additional Hugging Face repos and merges them', async () => { + const additionalHfRepos = [ + { type: 'spaces', repo: 'external-user/cool-space' }, + { type: 'spaces', repo: 'test-org/space-1' } // Already in org, should be ignored + ]; + + global.fetch.mockImplementation((url) => { + if (url.includes('spaces?author=')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve([{ + id: 'test-org/space-1', + createdAt: recentDateISO, + lastModified: recentDateISO + }]) + }); + } + if (url.includes('spaces/external-user/cool-space')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + id: 'external-user/cool-space', + createdAt: oldDateISO, + lastModified: recentDateISO + }) + }); + } + }); + + const items = await fetchHfRepos( + 'spaces', + additionalHfRepos, + apiBaseUrl, + hfOrgName, + refreshIntervalDays + ); + + // 1 for org spaces, 1 for additional space, duplicate not fetched again + expect(items).toHaveLength(2); + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch).toHaveBeenCalledWith(`${apiBaseUrl}spaces/external-user/cool-space`); + expect(global.fetch).toHaveBeenCalledWith(`${apiBaseUrl}spaces?author=${hfOrgName}&full=true`); + + expect(items[0].id).toBe('test-org/space-1'); + expect(items[0].repoType).toBe('spaces'); + expect(items[0].isNew).toBe(true); + + expect(items[1].id).toBe('external-user/cool-space'); + expect(items[1].repoType).toBe('spaces'); + expect(items[1].isNew).toBe(false); + }); + + // Test that network errors in fetching additional repos are handled gracefully + it('logs warnings and ignores external additional repo if its fetch throws a network error', async () => { + const additionalHfRepos = [{ type: 'datasets', repo: 'external-user/broken-additional' }]; + + global.fetch.mockImplementation((url) => { + if (url.includes('datasets?author=')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve([{ + id: 'test-org/dataset-1', + createdAt: recentDateISO, + lastModified: recentDateISO + }]) + }); + } + if (url.includes('datasets/external-user/broken-additional')) { + // Hard crash fetch + return Promise.reject(new Error('Network disconnected')); + } + }); + + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const items = await fetchHfRepos( + 'datasets', + additionalHfRepos, + apiBaseUrl, + hfOrgName, + refreshIntervalDays + ); + + // System should catch the error safely, remain un-crashed, and return just the internal dataset + expect(items).toHaveLength(1); + expect(items[0].id).toBe('test-org/dataset-1'); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Network error fetching additional HF repo "external-user/broken-additional":'), + expect.any(Error) + ); + + consoleWarnSpy.mockRestore(); + }); + + it('handles network errors gracefully', async () => { + // Force the main fetch to fail + global.fetch.mockResolvedValueOnce({ + ok: false, + status: 500 + }); + + const items = await fetchHfRepos( + 'datasets', + [], + apiBaseUrl, + hfOrgName, + refreshIntervalDays + ); + + expect(items).toEqual([]); + expect(handleError).toHaveBeenCalled(); + }); +}); diff --git a/tests/ui/urlManager.test.js b/tests/ui/urlManager.test.js new file mode 100644 index 0000000..c37b996 --- /dev/null +++ b/tests/ui/urlManager.test.js @@ -0,0 +1,105 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { parseUrlParams, updateUrlParams, getCurrentState } from '../../src/ui/urlManager.js'; + +describe('urlManager with jsdom environment', () => { + // Reset the DOM and URL state before each test to ensure isolation + beforeEach(() => { + document.body.innerHTML = ''; + window.history.replaceState(null, '', '/'); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); + }); + + // Test URL parsing with both query and hash parameters, ensuring query takes precedence + it('parses URL params, prioritizing search query over hash', () => { + window.history.replaceState(null, '', '/?type=models&q=fish#type=datasets'); + + const params = parseUrlParams(); + + // ?type=models should override #type=datasets + expect(params).toEqual({ type: 'models', q: 'fish' }); + }); + + // Test URL state sync + it('updates URL params without refreshing the page', () => { + const mockState = { + type: 'datasets', + q: 'nlp', + sort: 'lastModified', + tag: '', + archived: 'active' + }; + + // Spy on replaceState to verify the function call signature + const replaceStateSpy = vi.spyOn(window.history, 'replaceState'); + + updateUrlParams(mockState); + + // Only non-default values should end up in the hash/search string + expect(replaceStateSpy).toHaveBeenCalledWith( + null, + '', + '/#type=datasets&q=nlp' + ); + expect(window.location.hash).toBe('#type=datasets&q=nlp'); + }); + + it('clears or avoids adding default values to the URL hash completely', () => { + // Start with a populated URL string + window.history.replaceState(null, '', '/#type=datasets&q=nlp'); + + const defaultState = { + type: 'all', + q: '', + sort: 'lastModified', + tag: '', + archived: 'active' + }; + + updateUrlParams(defaultState); + + // All defaults means hash string gets completely wiped out + expect(window.location.hash).toBe(''); + }); + + // Test dynamic state retrieval from DOM elements, simulating user input + it('maps active DOM values correctly into a state matrix via getCurrentState', () => { + // Inject real HTML nodes into JSDOM's virtual container layout + document.body.innerHTML = ` + + + + + + `; + + const activeState = getCurrentState(); + + expect(activeState).toEqual({ + type: 'spaces', + q: 'image segmentation', + sort: 'stars', + tag: 'pytorch', + archived: 'all' + }); + }); + + it('safely handles missing or empty DOM elements by applying built-in defaults', () => { + // Leaving document.body empty tests fallback properties + document.body.innerHTML = ''; + + const defaultState = getCurrentState(); + + expect(defaultState).toEqual({ + type: 'all', + q: '', + sort: 'lastModified', + tag: '', + archived: 'active' + }); + }); +}); diff --git a/tests/filterAndSort.test.js b/tests/utils/filterAndSort.test.js similarity index 99% rename from tests/filterAndSort.test.js rename to tests/utils/filterAndSort.test.js index f4ca2e3..d4fb2ae 100644 --- a/tests/filterAndSort.test.js +++ b/tests/utils/filterAndSort.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { filterItems, sortItems } from '../src/filterAndSort.js'; +import { filterItems, sortItems } from '../../src/utils/filterAndSort.js'; // Minimal item factory — only the fields filterItems/sortItems actually read. const makeItem = (overrides = {}) => ({ diff --git a/tests/filterNewAdditionalEntries.test.js b/tests/utils/filterNewAdditionalEntries.test.js similarity index 96% rename from tests/filterNewAdditionalEntries.test.js rename to tests/utils/filterNewAdditionalEntries.test.js index 4ccc0f0..3a2a7c7 100644 --- a/tests/filterNewAdditionalEntries.test.js +++ b/tests/utils/filterNewAdditionalEntries.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { filterNewAdditionalEntries } from '../src/filterNewAdditionalEntries.js'; +import { filterNewAdditionalEntries } from '../../src/utils/filterNewAdditionalEntries.js'; describe('filterNewAdditionalEntries', () => { it('returns all entries when existingIds is empty', () => { diff --git a/tests/normalizeTag.test.js b/tests/utils/normalizeTag.test.js similarity index 96% rename from tests/normalizeTag.test.js rename to tests/utils/normalizeTag.test.js index adbd15f..46ef0ed 100644 --- a/tests/normalizeTag.test.js +++ b/tests/utils/normalizeTag.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { normalizeTag } from '../src/normalizeTag.js'; +import { normalizeTag } from '../../src/utils/normalizeTag.js'; // tagLookup format: lowercased alias → [canonical tags] const LOOKUP = { From db3f37454f1303185bbded1d5025b06fee96bc5f Mon Sep 17 00:00:00 2001 From: egrace479 Date: Wed, 8 Jul 2026 09:52:32 -0400 Subject: [PATCH 04/12] Update packages to remove vulnerabilities Pulled from Imageomics catalog cherry-picked from commit: 939bfeb4f5b750fb74e9acfb08fa4c03673a3253 use named imports, error with jsyaml, standardize fs use --- main.js | 4 +- package-lock.json | 1368 ++++++++++-------------------- package.json | 6 +- scripts/export-tags.js | 8 +- scripts/fetch-releases.js | 4 +- tests/config.integration.test.js | 6 +- vite.config.js | 16 +- 7 files changed, 452 insertions(+), 960 deletions(-) diff --git a/main.js b/main.js index 7c91a0a..3fe7cb4 100644 --- a/main.js +++ b/main.js @@ -6,7 +6,7 @@ // SECTION 1: CONFIGURATION AND STATE MANAGEMENT // -import jsYaml from 'js-yaml'; +import { load } from 'js-yaml'; import { initializeUIFromConfig, setThemeToggle } from './src/ui/initUserInterface.js'; import { parseUrlParams, updateUrlParams, getCurrentState } from './src/ui/urlManager.js'; import { renderItemList } from './src/ui/render.js'; @@ -23,7 +23,7 @@ const configPromise = fetch('config.yaml') if (!r.ok) throw new Error(`Failed to load config.yaml: HTTP ${r.status}`); return r.text(); }) - .then(text => jsYaml.load(text)); + .then(text => load(text)); // Module-scope lets — assigned after config loads, used by all functions below let CONFIG; diff --git a/package-lock.json b/package-lock.json index 1269d22..35ea501 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,13 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "js-yaml": "^4.1.0" + "js-yaml": "^5.2.1" }, "devDependencies": { - "@tailwindcss/vite": "^4.2.1", + "@tailwindcss/vite": "^4.3.2", "jsdom": "^29.1.1", "tailwindcss": "^4.2.1", - "vite": "^7.3.2", + "vite": "^8.1.3", "vitest": "^4.1.5" }, "engines": { @@ -226,446 +226,38 @@ "node": ">=20.19.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.4.0" } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@exodus/bytes": { @@ -736,24 +328,39 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", - "cpu": [ - "arm" - ], + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "android" - ] + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", "cpu": [ "arm64" ], @@ -762,12 +369,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", "cpu": [ "arm64" ], @@ -776,12 +386,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", "cpu": [ "x64" ], @@ -790,26 +403,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", "cpu": [ "x64" ], @@ -818,26 +420,15 @@ "optional": true, "os": [ "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", - "cpu": [ - "arm" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", "cpu": [ "arm" ], @@ -846,180 +437,135 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", - "cpu": [ - "loong64" + "libc": [ + "glibc" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", - "cpu": [ - "loong64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", "cpu": [ - "ppc64" + "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", "cpu": [ "ppc64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", - "cpu": [ - "riscv64" + "libc": [ + "glibc" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", - "cpu": [ - "riscv64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", - "cpu": [ - "x64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", "cpu": [ "arm64" ], @@ -1028,40 +574,51 @@ "optional": true, "os": [ "openharmony" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", "cpu": [ - "arm64" + "wasm32" ], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", "cpu": [ - "ia32" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", "cpu": [ "x64" ], @@ -1070,21 +627,17 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", - "cpu": [ - "x64" ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "license": "MIT" }, "node_modules/@standard-schema/spec": { "version": "1.1.0", @@ -1094,49 +647,49 @@ "license": "MIT" }, "node_modules/@tailwindcss/node": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", - "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", - "jiti": "^2.6.1", - "lightningcss": "1.31.1", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.2.1" + "tailwindcss": "4.3.2" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", - "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.1", - "@tailwindcss/oxide-darwin-arm64": "4.2.1", - "@tailwindcss/oxide-darwin-x64": "4.2.1", - "@tailwindcss/oxide-freebsd-x64": "4.2.1", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", - "@tailwindcss/oxide-linux-x64-musl": "4.2.1", - "@tailwindcss/oxide-wasm32-wasi": "4.2.1", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", - "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", "cpu": [ "arm64" ], @@ -1151,9 +704,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", - "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", "cpu": [ "arm64" ], @@ -1168,9 +721,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", - "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", "cpu": [ "x64" ], @@ -1185,9 +738,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", - "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", "cpu": [ "x64" ], @@ -1202,9 +755,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", - "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", "cpu": [ "arm" ], @@ -1219,13 +772,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", - "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1236,13 +792,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", - "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1253,13 +812,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", - "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1270,13 +832,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", - "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1287,9 +852,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", - "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -1305,85 +870,21 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", - "@tybys/wasm-util": "^0.10.1", + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "engines": { "node": ">=14.0.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.8.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.8.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.1", - "dev": true, - "inBundle": true, - "license": "0BSD", - "optional": true - }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", - "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", "cpu": [ "arm64" ], @@ -1398,9 +899,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", - "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", "cpu": [ "x64" ], @@ -1415,18 +916,29 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", - "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.2.1", - "@tailwindcss/oxide": "4.2.1", - "tailwindcss": "4.2.1" + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" }, "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7" + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@types/chai": { @@ -1656,14 +1168,14 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", - "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" @@ -1689,48 +1201,6 @@ "dev": true, "license": "MIT" }, - "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" - } - }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1812,9 +1282,9 @@ "license": "MIT" }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", "bin": { @@ -1822,15 +1292,25 @@ } }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.1.tgz", + "integrity": "sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, "bin": { - "js-yaml": "bin/js-yaml.js" + "js-yaml": "bin/js-yaml.mjs" } }, "node_modules/jsdom": { @@ -1875,9 +1355,9 @@ } }, "node_modules/lightningcss": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", - "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -1891,23 +1371,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.31.1", - "lightningcss-darwin-arm64": "1.31.1", - "lightningcss-darwin-x64": "1.31.1", - "lightningcss-freebsd-x64": "1.31.1", - "lightningcss-linux-arm-gnueabihf": "1.31.1", - "lightningcss-linux-arm64-gnu": "1.31.1", - "lightningcss-linux-arm64-musl": "1.31.1", - "lightningcss-linux-x64-gnu": "1.31.1", - "lightningcss-linux-x64-musl": "1.31.1", - "lightningcss-win32-arm64-msvc": "1.31.1", - "lightningcss-win32-x64-msvc": "1.31.1" + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", - "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ "arm64" ], @@ -1926,9 +1406,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", - "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], @@ -1947,9 +1427,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", - "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ "x64" ], @@ -1968,9 +1448,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", - "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" ], @@ -1989,9 +1469,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", - "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ "arm" ], @@ -2010,13 +1490,16 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", - "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2031,13 +1514,16 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", - "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2052,13 +1538,16 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", - "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2073,13 +1562,16 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", - "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2094,9 +1586,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", - "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], @@ -2115,9 +1607,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", - "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], @@ -2163,9 +1655,9 @@ "license": "CC0-1.0" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "dev": true, "funding": [ { @@ -2233,9 +1725,9 @@ } }, "node_modules/postcss": { - "version": "8.5.12", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", - "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "dev": true, "funding": [ { @@ -2253,7 +1745,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2281,49 +1773,38 @@ "node": ">=0.10.0" } }, - "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "fsevents": "~2.3.2" + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" } }, "node_modules/saxes": { @@ -2378,16 +1859,16 @@ "license": "MIT" }, "node_modules/tailwindcss": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", - "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", "dev": true, "license": "MIT" }, "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", "engines": { @@ -2416,14 +1897,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -2488,6 +1969,14 @@ "node": ">=20" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/undici": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", @@ -2499,18 +1988,17 @@ } }, "node_modules/vite": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", - "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -2526,9 +2014,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -2541,13 +2030,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { "optional": true }, - "lightningcss": { + "jiti": { + "optional": true + }, + "less": { "optional": true }, "sass": { diff --git a/package.json b/package.json index bf477e4..5c40b60 100644 --- a/package.json +++ b/package.json @@ -39,12 +39,12 @@ }, "homepage": "https://github.com/ABC-Center/catalog#readme", "dependencies": { - "js-yaml": "^4.1.0" + "js-yaml": "^5.2.1" }, "devDependencies": { - "@tailwindcss/vite": "^4.2.1", + "@tailwindcss/vite": "^4.3.2", "tailwindcss": "^4.2.1", - "vite": "^7.3.2", + "vite": "^8.1.3", "vitest": "^4.1.5", "jsdom": "^29.1.1" } diff --git a/scripts/export-tags.js b/scripts/export-tags.js index dc34b54..7ab2497 100644 --- a/scripts/export-tags.js +++ b/scripts/export-tags.js @@ -12,10 +12,10 @@ // public/config.yaml // -import fs from 'fs'; +import { readFileSync, writeFileSync } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; -import jsYaml from 'js-yaml'; +import { load } from 'js-yaml'; import { validateConfig } from '../src/validateConfig.js'; import { getPlatformApiUrls } from '../src/utils/defineApiUrls.js'; import { getPlatformDisplay } from '../src/utils/defineRibbonVals.js'; @@ -27,7 +27,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Parse config.yaml to extract the CONFIG object values we need // --------------------------------------------------------------------------- const configPath = path.resolve(__dirname, '../public/config.yaml'); -const rawConfig = jsYaml.load(fs.readFileSync(configPath, 'utf8')); +const rawConfig = load(readFileSync(configPath, 'utf8')); const errors = validateConfig(rawConfig); if (errors.length) { @@ -145,7 +145,7 @@ const collectHFTags = async (repoType) => { const sorted = Array.from(allTags).sort(); const outPath = path.resolve(__dirname, 'tag-export.txt'); - fs.writeFileSync(outPath, sorted.join('\n') + '\n', 'utf8'); + writeFileSync(outPath, sorted.join('\n') + '\n', 'utf8'); console.log(`\nDone. ${sorted.length} unique tags written to scripts/tag-export.txt`); } catch (err) { diff --git a/scripts/fetch-releases.js b/scripts/fetch-releases.js index f24f2fc..933ce34 100644 --- a/scripts/fetch-releases.js +++ b/scripts/fetch-releases.js @@ -4,7 +4,7 @@ import { readFileSync, writeFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; -import jsYaml from 'js-yaml'; +import { load } from 'js-yaml'; import { validateConfig } from '../src/validateConfig.js'; import { getPlatformApiUrls } from '../src/utils/defineApiUrls.js'; @@ -13,7 +13,7 @@ const __dirname = dirname(__filename); // Load CONFIG from public/config.yaml const configPath = join(__dirname, '../public/config.yaml'); -const CONFIG = jsYaml.load(readFileSync(configPath, 'utf8')); +const CONFIG = load(readFileSync(configPath, 'utf8')); const errors = validateConfig(CONFIG); if (errors.length) { diff --git a/tests/config.integration.test.js b/tests/config.integration.test.js index e68abcc..25585de 100644 --- a/tests/config.integration.test.js +++ b/tests/config.integration.test.js @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, resolve } from 'path'; -import jsYaml from 'js-yaml'; +import { load } from 'js-yaml'; import { validateConfig } from '../src/validateConfig.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -11,14 +11,14 @@ const configPath = resolve(__dirname, '../public/config.yaml'); describe('public/config.yaml', () => { it('parses as a valid YAML object', () => { const raw = readFileSync(configPath, 'utf8'); - const config = jsYaml.load(raw); + const config = load(raw); expect(config).toBeTruthy(); expect(typeof config).toBe('object'); expect(Array.isArray(config)).toBe(false); }); it('passes all validation checks', () => { - const config = jsYaml.load(readFileSync(configPath, 'utf8')); + const config = load(readFileSync(configPath, 'utf8')); const errors = validateConfig(config); expect(errors).toEqual([]); }); diff --git a/vite.config.js b/vite.config.js index fc44854..34d5d49 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,30 +1,30 @@ import tailwindcss from '@tailwindcss/vite' import { defineConfig } from 'vite' -import fs from 'fs'; -import jsYaml from 'js-yaml'; -import { validateConfig } from './src/validateConfig.js'; +import { readFileSync } from 'fs'; +import { load } from 'js-yaml'; +import { validateConfig } from './src/validateConfig.js'; const CONFIG_PATH = './public/config.yaml'; let parsedConfig; // Run config validation before Vite starts (dev/build/preview) try { - const fileContents = fs.readFileSync(CONFIG_PATH, 'utf8'); - parsedConfig = jsYaml.load(fileContents); + const fileContents = readFileSync(CONFIG_PATH, 'utf8'); + parsedConfig = load(fileContents); } catch (error) { console.error(`\n❌ [CONFIG ERROR] Failed to read or parse ${CONFIG_PATH}: ${error.message}\n`); process.exit(1); // Stop Vite immediately if the file is completely unreadable or broken YAML } // Any errors will be collected in the 'errors' array, to be printed out and cause a safe crash if not empty. -const errors = validateConfig(parsedConfig); +const errors = validateConfig(parsedConfig); if (errors && errors.length > 0) { console.error('\n❌ [CONFIG ERROR] Vite failed to start due to the following configuration errors:\n'); - + errors.forEach((error, index) => { console.error(` ${index + 1}. ${error}`); }); - + console.error(`\n Please fix the listed issue(s) in ${CONFIG_PATH} and try again.\n`); process.exit(1); // Safely kills the npm run dev terminal process } From e57e1d936747f13e5e27cdf21fb2cf6bd01e0c32 Mon Sep 17 00:00:00 2001 From: Net Zhang <48858129+NetZissou@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:07:04 -0400 Subject: [PATCH 05/12] Update public/config.yaml add `ORGANIZATION_NAME` field in `public/config.yaml` and set value as "ABC-Center" Co-authored-by: Elizabeth Campolongo <38985481+egrace479@users.noreply.github.com> --- public/config.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/public/config.yaml b/public/config.yaml index 5442c13..c8b42d5 100644 --- a/public/config.yaml +++ b/public/config.yaml @@ -2,7 +2,8 @@ # Customize these values to personalize the catalog for your organization # Organization & Repository Settings -ORGANIZATION_NAME: ABC-Center # GitHub/Hugging Face organization name for API calls (case-sensitive for HF) +ORGANIZATION_NAME: ABC-Center # Codebase platform organization name (for API calls) +HF_ORGANIZATION_NAME: ABC-Center # Hugging Face organization name (case-sensitive, for API calls) ORG_NAME: ABC-Center # Display name for GitHub organization (can differ from API name) CATALOG_REPO_NAME: catalog # Repository name for the catalog itself (used for stats badge) From 2729f858d3f2cbbaba0e22d56741b2c964b07ff3 Mon Sep 17 00:00:00 2001 From: Elizabeth Campolongo <38985481+egrace479@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:48:32 -0400 Subject: [PATCH 06/12] Add Codeberg as a code platform option Pulled from Imageomics catalog [PR 121](https://github.com/Imageomics/catalog/pull/121) cherry-pick commit: 8315b22a4ad8106b93473d3c5c748fdac0697c97 * Switch to local SVG use for code platform logo Codeberg logo is too complex for simple svg path definition * Check release not undefined for ribbon, as with stars and forks, there may not be a release * Generalize tests for code fetching to cycle platforms Add profile repo key, Codeberg uses .profile like GitHub has .github --- README.md | 12 +- docs/personalization.md | 8 +- docs/tag-grouping-process.md | 7 +- index.html | 5 +- public/config.yaml | 2 +- src/api/fetchCodeRepos.js | 27 +++- src/api/fetchStats.js | 4 +- src/assets/GitHub_Invertocat.svg | 10 ++ src/assets/codeberg-logo_icon_white.svg | 164 ++++++++++++++++++++++++ src/ui/initUserInterface.js | 4 +- src/utils/defineApiUrls.js | 16 +-- src/utils/defineRibbonVals.js | 30 ++--- src/validateConfig.js | 6 +- tests/api/fetchCodeRepos.test.js | 62 ++++++--- 14 files changed, 294 insertions(+), 63 deletions(-) create mode 100755 src/assets/GitHub_Invertocat.svg create mode 100644 src/assets/codeberg-logo_icon_white.svg diff --git a/README.md b/README.md index cec9eca..b5972cb 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,13 @@ This repository was generated and personalized from the [Imageomics Catalog](htt The website is styled using the [tailwindcss](https://tailwindcss.com/) package. -* **Real-time Data Fetching:** Displays all public ABC Center repositories, fetched through the GitHub and Hugging Face APIs. Includes semantically meaningful virtual markers: +* **Real-time Data Fetching:** Displays all public organization repositories, fetched through the code platform and Hugging Face APIs. Includes semantically meaningful virtual markers: * "New" badge highlights products created within the last 30 days; - * "🚀 version-tag" badge indicates a new release within the last 2 weeks for GitHub repos, and links to that release; - * Star (⭐️) or like (❤️) counts displayed for GitHub or Hugging Face repos, respectively; - * Archived badge flags GitHub repos no longer under active development (read-only). + * "🚀 version-tag" badge indicates a new release within the last 2 weeks for code repos, and links to that release; + * Star (⭐️) or like (❤️) counts displayed for code platform or Hugging Face repos, respectively; + * Archived badge flags code repos no longer under active development (read-only). * **Search Functionality:** Quickly find items by keyword. -* **Filtering:** Filter by repository type (Code, Datasets, Models, Spaces) and tags. Optionally include archived GitHub repos. +* **Filtering:** Filter by repository type (Code, Datasets, Models, Spaces) and tags. Optionally include archived code repos. * **Sorting:** Sort items by last updated, date created, stars/likes ascending or descending, or alphabetically. * **URL Parameter Support:** Persist and share search states via URL hash (`#type=datasets&q=fish`) or query parameters (`?type=datasets`). Supports `type`, `q` (search query), `sort`, and `tag` parameters. * **Responsive Design:** The layout is optimized for use on computers and mobile devices. @@ -30,7 +30,7 @@ The site runs based on four primary files: * `style.css`: Custom styling for the application, including color schemes, layout, and animations. Colors are set via CSS custom properties that are populated from `config.yaml`. * `main.js`: Application manager, handles config loading, event listeners, and coordinates UI updates based on API fetches for dynamic rendering of the catalog items. * `src/`: Modular application logic, organized by purpose: - * `api/`: Data fetching modules for external platforms (GitHub and Hugging Face). + * `api/`: Data fetching modules for external platforms (code and Hugging Face). * `ui/`: DOM manipulation, HTML templating, and URL/State routing. * `utils/`: Utility functions such as data filtering, sorting, and tag (keyword) normalization. diff --git a/docs/personalization.md b/docs/personalization.md index cc26212..d8a0756 100644 --- a/docs/personalization.md +++ b/docs/personalization.md @@ -4,7 +4,7 @@ Welcome to your new catalog repo! The primary way to personalize this catalog is ## Primary Configuration File -[**`public/config.yaml`**](../public/config.yaml): This is the main file to edit. It contains all configuration options (e.g., organization names, colors, branding, and API settings) with inline comments explaining each setting. Replace Imageomics-specific values with those appropriate for your organzation and catalog repository. +[**`public/config.yaml`**](../public/config.yaml): This is the main file to edit. It contains all configuration options (e.g., organization names, colors, branding, and API settings) with inline comments explaining each setting. Replace Imageomics-specific values with those appropriate for your organization and catalog repository. ### Organization & Repository Settings @@ -32,7 +32,7 @@ Welcome to your new catalog repo! The primary way to personalize this catalog is ### API & Behavior Settings - * `PLATFORM`: Coding platform used (default: 'github', Codeberg and GitLab support in development) + * `PLATFORM`: Coding platform used: 'github' or 'codeberg' (default: 'github', GitLab support in development). Please see [notes on platform setup](#non-github-code-platform-setup) if using for non-GitHub code repositories * `API_BASE_URL`: Hugging Face API base URL (default: `"https://huggingface.co/api/"`) * `REFRESH_INTERVAL_DAYS`: Number of days to consider an item "new" (default: `30`) * `ADDITIONAL_REPOS`: Array of forked or non-org GitHub repositories to include, formatted `/` (non-forks are included by default). Use `[]` if there are none you wish to include @@ -88,3 +88,7 @@ When first setting up your catalog, run the export script to generate a full lis > **Required token**: The weekly tag scan workflow requires a fine-grained access token with **Pull requests: Read and write** permission on the catalog repo. Follow the instructions in [App Authentication](app-authentication.md) to create and install a private Catalog Automation App for token generation. See **[tag-grouping-process.md](tag-grouping-process.md)** for full setup instructions, conventions, and guidance on using AI assistance for the initial grouping pass. + +## Code Repository Platform Setup + +The default code repository platform for this catalog is GitHub. If you wish to use another supported platform (Codeberg), please note that the [tag export](../scripts/export-tags.js) and [fetch release](../scripts/fetch-releases.js) scripts may require header definition modifications to function properly. Workflows would also require token and other platform-specific updates if running from a non-GitHub repository. Otherwise, this app is set up to be able to run from Codeberg to fetch and display Codeberg repositories. diff --git a/docs/tag-grouping-process.md b/docs/tag-grouping-process.md index e73b013..452a227 100644 --- a/docs/tag-grouping-process.md +++ b/docs/tag-grouping-process.md @@ -2,11 +2,16 @@ ## What Are Tag Groups? -Tags on GitHub repos (topics) and Hugging Face repos (card metadata) are free-form text, so +Tags on code platform[^1] repos (topics) and Hugging Face repos (card metadata) are free-form text, so the same concept often appears under multiple spellings: `computer-vision`, `computer vision`, `cv`. Tag groups normalize this noise so the catalog filter dropdown shows one clean canonical tag instead of a dozen near-duplicates. +> [!NOTE] +> This workflow has only been run on GitHub. It may need platform-based adjustments to function as expected on other code platforms, such as Codeberg or GitLab. + +[^1]: GitHub, Codeberg, and GitLab. + Tag groups live in **`public/tag-groups.js`** as a plain JavaScript object: ```js diff --git a/index.html b/index.html index 0630243..2fd0815 100644 --- a/index.html +++ b/index.html @@ -28,9 +28,8 @@ class="fixed top-6 right-6 hidden md:flex items-center gap-3 text-white py-2 px-4 rounded-lg shadow-md transition-all z-50 no-underline group font-sans border border-white/10">
- +
diff --git a/public/config.yaml b/public/config.yaml index c8b42d5..64f9781 100644 --- a/public/config.yaml +++ b/public/config.yaml @@ -22,7 +22,7 @@ COLORS: tag: "#4fb797a1" # Tag background color (ABC turquoise 63% alpha) # API & Behavior Settings -# Codebase platform for API calls and link generation. Supported values: "github", pending: "codeberg" or "gitlab". +# Codebase platform for API calls and link generation. Supported values: "github" or "codeberg", pending: "gitlab". PLATFORM: github # Dataset, model, and space (demo) default: Hugging Face, other platforms would require a custom module API_BASE_URL: "https://huggingface.co/api/" diff --git a/src/api/fetchCodeRepos.js b/src/api/fetchCodeRepos.js index a427a2b..a0f87ea 100644 --- a/src/api/fetchCodeRepos.js +++ b/src/api/fetchCodeRepos.js @@ -2,6 +2,23 @@ import { handleError } from '../ui/render.js'; import { normalizeTag, filterDisplayTags } from '../utils/normalizeTag.js'; import { getPlatformDisplay } from '../utils/defineRibbonVals.js'; +// Define platform-specific values +const PLATFORM_CONFIGS = { + github: { + starsKey: 'stargazers_count', + profileRepo: '.github' + }, + /* gitlab: { + starsKey: 'star_count', + profileRepo: 'gitlab-profile', + //TODO + }, */ + codeberg: { + starsKey: 'stars_count', + profileRepo: '.profile' + } +}; + /** * Function for fetching code repositories from the specified platform (GitHub, GitLab, or Codeberg). * Both org-owned (non-forks) and additional repos are fetched; metadata is processed for each repo. @@ -28,6 +45,9 @@ export async function fetchCodeRepos( let allRepos = []; let nextUrl = `${orgApiUrl}`; + // get platform-specific keys + const platformConfig = PLATFORM_CONFIGS[platform.toLowerCase()]; + const { starsKey, profileRepo } = platformConfig; try { while (nextUrl) { const ghResponse = await fetch(nextUrl); @@ -72,7 +92,10 @@ export async function fetchCodeRepos( // Keep only non-forks from org; deduplicate against additional repos by full_name const orgRepoNames = new Set(filteredAdditionalRepos.map(r => r.full_name)); - const orgNonForks = allRepos.filter(repo => repo.name !== ".github" && !repo.fork && !orgRepoNames.has(repo.full_name)); + const orgNonForks = allRepos.filter(repo => + repo.name !== profileRepo && + !repo.fork && + !orgRepoNames.has(repo.full_name)); // Process additional repos and all remaining org non-forks to include metadata and 'new' flag as appropriate let processedItems = [...filteredAdditionalRepos, ...orgNonForks] @@ -105,7 +128,7 @@ export async function fetchCodeRepos( cardData: { pretty_name: repo.name, // , the one used for card title display description: repo.description, - stars: repo.stargazers_count || 0 + stars: repo[starsKey] ?? 0 } }; }); diff --git a/src/api/fetchStats.js b/src/api/fetchStats.js index fc0c0bd..76f6888 100644 --- a/src/api/fetchStats.js +++ b/src/api/fetchStats.js @@ -24,13 +24,13 @@ export const fetchCatalogStats = async (repoApiUrl, organizationName, catalogRep //TODO: Update stars and forks to support other platforms (GitLab, Codeberg) once implemented // 1. Get Stars & Forks const repo = await fetch(`${repoApiUrl}${organizationName}/${catalogRepoName}`).then(r => r.ok ? r.json() : {}); - if (repo.stargazers_count !== undefined) update('gh-stars', 'gh-star-container', repo.stargazers_count); + if (repo.stargazers_count !== undefined || repo.stars_count !== undefined) update('gh-stars', 'gh-star-container', repo.stargazers_count || repo.stars_count); if (repo.forks_count !== undefined) update('gh-forks', 'gh-fork-container', repo.forks_count); // 2. Get Version (Tag) // TODO: Import from package.json const release = await fetch(`${repoApiUrl}${organizationName}/${catalogRepoName}/releases/latest`).then(r => r.ok ? r.json() : {}); - if (release.tag_name) update('gh-tag', 'gh-version-container', release.tag_name); + if (release.tag_name !== undefined) update('gh-tag', 'gh-version-container', release.tag_name); } catch (e) { console.warn("Could not fetch Code Repo stats", e); diff --git a/src/assets/GitHub_Invertocat.svg b/src/assets/GitHub_Invertocat.svg new file mode 100755 index 0000000..5300969 --- /dev/null +++ b/src/assets/GitHub_Invertocat.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/codeberg-logo_icon_white.svg b/src/assets/codeberg-logo_icon_white.svg new file mode 100644 index 0000000..74a0fa9 --- /dev/null +++ b/src/assets/codeberg-logo_icon_white.svg @@ -0,0 +1,164 @@ + + + Codeberg logo + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + Codeberg logo + + + + Robert Martinez + + + + + Codeberg and the Codeberg Logo are trademarks of Codeberg e.V. + + + 2020-04-09 + + + Codeberg e.V. + + + codeberg.org + + + + + + + + + + + + + diff --git a/src/ui/initUserInterface.js b/src/ui/initUserInterface.js index 8dd1691..c8c250d 100644 --- a/src/ui/initUserInterface.js +++ b/src/ui/initUserInterface.js @@ -55,8 +55,8 @@ export const initializeUIFromConfig = (config) => { const platformDisplay = getPlatformDisplay(config.PLATFORM); if (repoRibbon && platformDisplay) { repoRibbon.href = `${platformDisplay.ribbonUrl}${config.ORGANIZATION_NAME}/${config.CATALOG_REPO_NAME}`; - const pathElement = document.getElementById('repo-ribbon-icon'); - pathElement.setAttribute('d', platformDisplay.path); + const svgElement = document.getElementById('repo-ribbon-icon-container'); + svgElement.innerHTML = platformDisplay.svg; const platformDisplayName = document.getElementById('platform-display-name'); platformDisplayName.textContent = platformDisplay.displayName || config.PLATFORM; repoRibbon.style.backgroundColor = config.COLORS.secondary; diff --git a/src/utils/defineApiUrls.js b/src/utils/defineApiUrls.js index 5b20e43..2d68025 100644 --- a/src/utils/defineApiUrls.js +++ b/src/utils/defineApiUrls.js @@ -1,17 +1,17 @@ /** - * Defines API URLs based on the selected platform (GitHub, note: GitLab and Codeberg support under development). + * Defines API URLs based on the selected platform (GitHub or Codeberg, note: GitLab support under development). * This allows the rest of the codebase to use these constants when making API calls, * abstracting away platform-specific URL structures. - * + * * Usage: import { getPlatformApiUrls } from './defineApiUrls.js'; - * + * * Input: platform and organizationName (e.g., 'github' and 'imageomics'), defined from config.yaml and passed to this function. * Output: platformApiUrls[platform] = { org: ORG_API_URL, repo: REPO_API_URL } */ /** * Utility function to get the platform-specific API URLs for organization repos and individual repo details. - * @param {string} platform - 'github', pending: 'gitlab', or 'codeberg' + * @param {string} platform - 'github' or 'codeberg', pending: 'gitlab' * @param {string} organizationName - The name of the organization (used in URL construction) * @returns {object} An object containing ORG_API_URL and REPO_API_URL */ @@ -25,10 +25,10 @@ export function getPlatformApiUrls(platform, organizationName) { // org: `https://gitlab.com/api/v4/groups/${organizationName}/projects?per_page=100`, // repo: "https://gitlab.com/api/v4/projects/" // }, - // codeberg: { - // org: `https://codeberg.org/api/v1/orgs/${organizationName}/repos?limit=50`, - // repo: "https://codeberg.org/api/v1/repos/" - // } + codeberg: { + org: `https://codeberg.org/api/v1/orgs/${organizationName}/repos?limit=50`, + repo: "https://codeberg.org/api/v1/repos/" + } }; return platformApiUrls[platform.toLowerCase()]; } diff --git a/src/utils/defineRibbonVals.js b/src/utils/defineRibbonVals.js index 9ab64a0..7a4486d 100644 --- a/src/utils/defineRibbonVals.js +++ b/src/utils/defineRibbonVals.js @@ -1,16 +1,19 @@ /** - * A collection of platform-specific display information (e.g., SVG path data) - * for code developer platforms including GitHub, pending: GitLab and Codeberg. + * A collection of platform-specific display information (e.g., SVGs and URLs) + * for code developer platforms including GitHub and Codeberg, pending: GitLab. * Used for linking to repo and rendering icon and platform name in the ribbon component of the UI. - * All paths are optimized for a 0 0 24 24 viewBox. * * Usage: import { getPlatformDisplay } from './defineRibbonVals.js'; * * Input: platform (e.g., 'github'), defined from config.yaml and passed to this function. - * Output: platformDisplays[platform] = { path: SVG_PATH_DATA, viewBox: VIEWBOX_DATA, + * Output: platformDisplays[platform] = { svg: CODE_PLATFORM_SVG, * displayName: DISPLAY_NAME, ribbonUrl: RIBBON_URL } */ +import githubSvg from '../assets/GitHub_Invertocat.svg?raw'; +// import gitlabSvg from '../assets/gitlab-logo-700-rgb.svg?raw'; +import codebergSvg from '../assets/codeberg-logo_icon_white.svg?raw'; + /** * Utility function to get the full platform display information * @param {string} platform - 'github', 'gitlab', or 'codeberg' @@ -19,25 +22,20 @@ export function getPlatformDisplay(platform) { const platformDisplays = { github: { - path: "M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z", - viewBox: "0 0 24 24", + svg: githubSvg, displayName: "GitHub", ribbonUrl: "https://github.com/" }, // gitlab: { - // path: "m23.71 11.83-2.13-6.52a.89.89 0 0 0-1.7 0l-2.13 6.52H6.25L4.12 5.31a.89.89 0 0 0-1.7 0L.29 11.83a1 1 0 0 0 .37 1.13l11.34 8.24 11.34-8.24a1 1 0 0 0 .37-1.13Z", - // viewBox: "0 0 24 24", - // color: "#FC6D26", + // svg: gitlabSvg, // displayName: "GitLab", // ribbonUrl: "https://gitlab.com/" //}, - // codeberg: { - // path: "M12.004 2.378a.636.636 0 0 0-.547.31L1.258 19.874a.636.636 0 0 0 .547.954h1.94c.143 0 .278-.074.354-.197L12 6.544l7.89 14.087a.406.406 0 0 0 .354.197h2.012a.636.636 0 0 0 .546-.954L12.551 2.688a.636.636 0 0 0-.547-.31Z", - // viewBox: "0 0 24 24", - // color: "#2185d0", - // displayName: "Codeberg", - // ribbonUrl: "https://codeberg.org/" - //} + codeberg: { + svg: codebergSvg, + displayName: "Codeberg", + ribbonUrl: "https://codeberg.org/" + } }; return platformDisplays[platform.toLowerCase()]; } diff --git a/src/validateConfig.js b/src/validateConfig.js index d903afe..78264d8 100644 --- a/src/validateConfig.js +++ b/src/validateConfig.js @@ -31,11 +31,11 @@ export function validateConfig(config) { } if (!config.ORG_NAME) errors.push('ORG_NAME'); if (!config.CATALOG_REPO_NAME) errors.push('CATALOG_REPO_NAME'); - - /** Update to include 'codeberg' and 'gitlab' once supported + + /** Update to include 'gitlab' once supported * PLATFORM will be parsed without whitespace, but must be string to avoid undefined errors */ - const supportedPlatforms = ['github']; + const supportedPlatforms = ['github', 'codeberg']; const platform = config.PLATFORM; if (!platform || typeof platform !== 'string') { errors.push('PLATFORM'); diff --git a/tests/api/fetchCodeRepos.test.js b/tests/api/fetchCodeRepos.test.js index 98c594b..55e815e 100644 --- a/tests/api/fetchCodeRepos.test.js +++ b/tests/api/fetchCodeRepos.test.js @@ -14,7 +14,32 @@ vi.mock('../../src/utils/defineRibbonVals.js', () => ({ getPlatformDisplay: vi.fn(() => 'GitHub') })); -describe('fetchCodeRepos', () => { +// define configs for each platform +const platformConfigs = [ + { + name: 'github', + orgApiUrl: 'https://api.github.com/orgs/test-org/repos', + repoApiUrl: 'https://api.github.com/repos/', + platformProfileRepo: '.github', + starsKey: 'stargazers_count' + }, + /* { + name: 'gitlab', + orgApiUrl: 'https://gitlab.com/api/v4/groups/test-org/projects', + repoApiUrl: 'https://gitlab.com/api/v4/projects/', + platformProfileRepo: 'gitlab-profile', + starsKey: 'star_count' + }, */ + { + name: 'codeberg', + orgApiUrl: 'https://codeberg.org/api/v1/orgs/test-org/repos', + repoApiUrl: 'https://codeberg.org/api/v1/repos/', + platformProfileRepo: '.profile', + starsKey: 'stars_count' + } +]; + +describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { const originalFetch = global.fetch; beforeEach(() => { @@ -25,8 +50,11 @@ describe('fetchCodeRepos', () => { vi.clearAllMocks(); }); - const orgApiUrl = 'https://api.github.com/orgs/test-org/repos'; - const repoApiUrl = 'https://api.github.com/repos/'; + const orgApiUrl = platformConfig.orgApiUrl; + const repoApiUrl = platformConfig.repoApiUrl; + const platform = platformConfig.name; + const starsKey = platformConfig.starsKey; + const platformProfileRepo = platformConfig.platformProfileRepo; const refreshIntervalDays = 30; const releasesMap = {}; @@ -37,7 +65,7 @@ describe('fetchCodeRepos', () => { const oldestDateISO = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000).toISOString(); // 90 days ago it('maps raw repo data and attaches release data from releasesMap', async () => { - // Mock a single page GitHub response + // Mock a single page platform response global.fetch.mockResolvedValueOnce({ ok: true, headers: { get: () => null }, // No 'link' header means no pagination @@ -47,7 +75,7 @@ describe('fetchCodeRepos', () => { updated_at: now.toISOString(), created_at: recentDateISO, topics: ['python'], - stargazers_count: 42, + [starsKey]: 42, }]) }); @@ -57,7 +85,7 @@ describe('fetchCodeRepos', () => { }; const items = await fetchCodeRepos( - 'github', + platform, [], // No additional repos orgApiUrl, repoApiUrl, @@ -108,7 +136,7 @@ describe('fetchCodeRepos', () => { }); const items = await fetchCodeRepos( - 'github', + platform, [], orgApiUrl, repoApiUrl, @@ -164,7 +192,7 @@ describe('fetchCodeRepos', () => { ]; const items = await fetchCodeRepos( - 'github', + platform, additionalRepos, orgApiUrl, repoApiUrl, @@ -189,8 +217,8 @@ describe('fetchCodeRepos', () => { expect(items.map(i => i.id)).toContain('external-org/external-additional'); }); - // Test that forks and .github repos are excluded from the final list - it('excludes repository targets designated as forks and .github', async () => { + // Test that forks and platform profile repos are excluded from the final list + it('excludes repository targets designated as forks and platform profile repos', async () => { global.fetch.mockResolvedValueOnce({ ok: true, headers: { get: () => null }, @@ -217,8 +245,8 @@ describe('fetchCodeRepos', () => { updated_at: recentDateISO }, { - full_name: 'test-org/.github', - name: '.github', + full_name: `test-org/${platformProfileRepo}`, + name: platformProfileRepo, fork: false, created_at: recentDateISO, updated_at: recentDateISO @@ -231,7 +259,7 @@ describe('fetchCodeRepos', () => { ]; const items = await fetchCodeRepos( - 'github', + platform, additionalRepos, orgApiUrl, repoApiUrl, @@ -242,7 +270,7 @@ describe('fetchCodeRepos', () => { expect(items.map(i => i.id)).toContain('test-org/valid-repo'); expect(items.map(i => i.id)).toContain('test-org/forked-in-list'); expect(items.map(i => i.id)).not.toContain('test-org/forked-repo'); - expect(items.map(i => i.id)).not.toContain('test-org/.github'); + expect(items.map(i => i.id)).not.toContain(`test-org/${platformProfileRepo}`); }); // Test that isNew flag is set correctly based on creation date and refresh interval @@ -267,7 +295,7 @@ describe('fetchCodeRepos', () => { }); const items = await fetchCodeRepos( - 'github', + platform, [], orgApiUrl, repoApiUrl, @@ -292,7 +320,7 @@ describe('fetchCodeRepos', () => { }); const items = await fetchCodeRepos( - 'github', + platform, [], orgApiUrl, repoApiUrl, @@ -302,7 +330,7 @@ describe('fetchCodeRepos', () => { expect(handleError).toHaveBeenCalledWith( expect.any(Error), - expect.stringContaining('Failed to fetch code from github') + expect.stringContaining(`Failed to fetch code from ${platform}`) ); expect(items).toEqual([]); }); From ce180ae8284b247acf47f617cac499b352ead4f6 Mon Sep 17 00:00:00 2001 From: Elizabeth Campolongo <38985481+egrace479@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:14:57 -0500 Subject: [PATCH 07/12] Struct/platform keys Pulled from Imageomics catalog [PR 126](https://github.com/Imageomics/catalog/pull/126) cherry-pick commit: 1a5e7bad2f362acdcaee06802ba7e541ff909354 * Consolidate platform-specific non-ui value fetches in dedicated file Reduces platform-specific edits required to token-handling in scripts * Remove duplicated key from return fallback defined at UI render * Revise data mapping test --- main.js | 33 ++++++---- scripts/export-tags.js | 7 ++- scripts/fetch-releases.js | 17 +++--- src/api/fetchCodeRepos.js | 40 ++++-------- src/api/fetchStats.js | 8 ++- src/utils/defineApiUrls.js | 34 ----------- src/utils/definePlatformVals.js | 78 ++++++++++++++++++++++++ tests/api/fetchCodeRepos.test.js | 101 ++++++++++++++++++------------- 8 files changed, 185 insertions(+), 133 deletions(-) delete mode 100644 src/utils/defineApiUrls.js create mode 100644 src/utils/definePlatformVals.js diff --git a/main.js b/main.js index 3fe7cb4..d47902b 100644 --- a/main.js +++ b/main.js @@ -10,7 +10,7 @@ import { load } from 'js-yaml'; import { initializeUIFromConfig, setThemeToggle } from './src/ui/initUserInterface.js'; import { parseUrlParams, updateUrlParams, getCurrentState } from './src/ui/urlManager.js'; import { renderItemList } from './src/ui/render.js'; -import { getPlatformApiUrls } from './src/utils/defineApiUrls.js'; +import { getPlatformApiUrls } from './src/utils/definePlatformVals.js'; import { filterItems, sortItems } from './src/utils/filterAndSort.js'; import { fetchCodeRepos } from './src/api/fetchCodeRepos.js'; import { fetchHfRepos } from './src/api/fetchHfRepos.js'; @@ -28,7 +28,7 @@ const configPromise = fetch('config.yaml') // Module-scope lets — assigned after config loads, used by all functions below let CONFIG; let ORGANIZATION_NAME, HF_ORGANIZATION_NAME, CATALOG_REPO_NAME, PLATFORM, API_BASE_URL, REFRESH_INTERVAL_DAYS, ADDITIONAL_REPOS, ADDITIONAL_HF_REPOS; -let ORG_API_URL, REPO_API_URL; +let ORG_API_URL, REPO_API_URL, RELEASE_SUFFIX; let releasesMap = {}; @@ -206,16 +206,23 @@ document.addEventListener('DOMContentLoaded', async () => { } // Assign module-scope variables used by all functions - ORGANIZATION_NAME = CONFIG.ORGANIZATION_NAME; - HF_ORGANIZATION_NAME = CONFIG.HF_ORGANIZATION_NAME; - CATALOG_REPO_NAME = CONFIG.CATALOG_REPO_NAME; - PLATFORM = CONFIG.PLATFORM; - ORG_API_URL = getPlatformApiUrls(PLATFORM, ORGANIZATION_NAME).org; - REPO_API_URL = getPlatformApiUrls(PLATFORM, ORGANIZATION_NAME).repo; - API_BASE_URL = CONFIG.API_BASE_URL; - REFRESH_INTERVAL_DAYS = CONFIG.REFRESH_INTERVAL_DAYS; - ADDITIONAL_REPOS = CONFIG.ADDITIONAL_REPOS; - ADDITIONAL_HF_REPOS = CONFIG.ADDITIONAL_HF_REPOS; + // Destructure CONFIG into individual variables for easier access + ({ + ORGANIZATION_NAME, + HF_ORGANIZATION_NAME, + CATALOG_REPO_NAME, + PLATFORM, + API_BASE_URL, + REFRESH_INTERVAL_DAYS, + ADDITIONAL_REPOS, + ADDITIONAL_HF_REPOS + } = CONFIG); + // Destructure platform-specific API URLs from getPlatformApiUrls + ({ + org: ORG_API_URL, + repo: REPO_API_URL, + releaseSuffix: RELEASE_SUFFIX + } = getPlatformApiUrls(PLATFORM, ORGANIZATION_NAME)); // Guard: if ORGANIZATION_NAME or HF_ORGANIZATION_NAME is missing (e.g. config.yaml failed to load), // stop here — proceeding would fire requests like ?author=&full=true which @@ -289,7 +296,7 @@ document.addEventListener('DOMContentLoaded', async () => { }); // Initialize the Catalog Badge (Stars/Forks/Version) - fetchCatalogStats(REPO_API_URL, ORGANIZATION_NAME, CATALOG_REPO_NAME) + fetchCatalogStats(REPO_API_URL, ORGANIZATION_NAME, CATALOG_REPO_NAME, RELEASE_SUFFIX) // Load pre-built release data (written by scripts/fetch-releases.js at build time) releasesMap = await fetch('./releases.json') diff --git a/scripts/export-tags.js b/scripts/export-tags.js index 7ab2497..ed18d6f 100644 --- a/scripts/export-tags.js +++ b/scripts/export-tags.js @@ -17,9 +17,9 @@ import path from 'path'; import { fileURLToPath } from 'url'; import { load } from 'js-yaml'; import { validateConfig } from '../src/validateConfig.js'; -import { getPlatformApiUrls } from '../src/utils/defineApiUrls.js'; import { getPlatformDisplay } from '../src/utils/defineRibbonVals.js'; import { filterNewAdditionalEntries } from '../src/utils/filterNewAdditionalEntries.js'; +import { getPlatformVals, getPlatformApiUrls } from '../src/utils/definePlatformVals.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -60,6 +60,7 @@ const get = async (url) => { // --------------------------------------------------------------------------- const allTags = new Set(); const { org: ORG_API_URL, repo: REPO_API_URL } = getPlatformApiUrls(PLATFORM, ORGANIZATION_NAME); +const { profileRepo, fullNameKey, forkKey } = getPlatformVals(PLATFORM); const collectCodePlatformTags = async () => { const platformDisplay = getPlatformDisplay(PLATFORM); @@ -85,8 +86,8 @@ const collectCodePlatformTags = async () => { ); const additionalRepos = additionalData.filter(Boolean); - const additionalNames = new Set(additionalRepos.map(r => r.full_name)); - const orgNonForks = allRepos.filter(r => r.name !== '.github' && !r.fork && !additionalNames.has(r.full_name)); + const additionalNames = new Set(additionalRepos.map(r => r[fullNameKey])); + const orgNonForks = allRepos.filter(r => r.name !== profileRepo && !r[forkKey] && !additionalNames.has(r[fullNameKey])); [...additionalRepos, ...orgNonForks].forEach(repo => { (repo.topics || []).forEach(t => allTags.add(t.toLowerCase())); diff --git a/scripts/fetch-releases.js b/scripts/fetch-releases.js index 933ce34..4d3f977 100644 --- a/scripts/fetch-releases.js +++ b/scripts/fetch-releases.js @@ -6,7 +6,7 @@ import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { load } from 'js-yaml'; import { validateConfig } from '../src/validateConfig.js'; -import { getPlatformApiUrls } from '../src/utils/defineApiUrls.js'; +import { getPlatformVals, getPlatformApiUrls } from '../src/utils/definePlatformVals.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -28,7 +28,8 @@ const headers = TOKEN const TWO_WEEKS_MS = 14 * 24 * 60 * 60 * 1000; // Step 1: Fetch all public org repos (paginated, same logic as main.js) -const { org: ORG_API_URL, repo: REPO_API_URL } = getPlatformApiUrls(CONFIG.PLATFORM, CONFIG.ORGANIZATION_NAME); +const { org: ORG_API_URL, repo: REPO_API_URL, releaseSuffix: RELEASE_SUFFIX } = getPlatformApiUrls(CONFIG.PLATFORM, CONFIG.ORGANIZATION_NAME); +const { profileRepo, fullNameKey, forkKey, urlKey, releasePublishedAtKey } = getPlatformVals(CONFIG.PLATFORM); let allOrgRepos = []; let nextUrl = `${ORG_API_URL}`; while (nextUrl) { @@ -47,24 +48,24 @@ while (nextUrl) { // Step 2: Collect all code repo IDs (mirrors main.js deduplication logic) const additionalRepoIds = CONFIG.ADDITIONAL_REPOS || []; const additionalRepoSet = new Set(additionalRepoIds); -const orgNonForks = allOrgRepos.filter(r => r.name !== '.github' && !r.fork && !additionalRepoSet.has(r.full_name)); +const orgNonForks = allOrgRepos.filter(r => r.name !== profileRepo && !r[forkKey] && !additionalRepoSet.has(r[fullNameKey])); const repoIds = [ ...additionalRepoIds, - ...orgNonForks.map(r => r.full_name), + ...orgNonForks.map(r => r[fullNameKey]), ]; // Step 3: Fetch latest release for each repo const releases = {}; for (const id of repoIds) { try { - const res = await fetch(`${REPO_API_URL}${id}/releases/latest`, { headers }); + const res = await fetch(`${REPO_API_URL}${id}/${RELEASE_SUFFIX}`, { headers }); if (!res.ok) { releases[id] = null; continue; } const data = await res.json(); releases[id] = { tag: data.tag_name, - url: data.html_url, - publishedAt: data.published_at, - isNew: (Date.now() - new Date(data.published_at)) < TWO_WEEKS_MS, + url: data[urlKey], + publishedAt: data[releasePublishedAtKey], + isNew: (Date.now() - new Date(data[releasePublishedAtKey])) < TWO_WEEKS_MS, }; } catch { releases[id] = null; diff --git a/src/api/fetchCodeRepos.js b/src/api/fetchCodeRepos.js index a0f87ea..5f6dba4 100644 --- a/src/api/fetchCodeRepos.js +++ b/src/api/fetchCodeRepos.js @@ -1,23 +1,7 @@ import { handleError } from '../ui/render.js'; -import { normalizeTag, filterDisplayTags } from '../utils/normalizeTag.js'; +import { getPlatformVals } from '../utils/definePlatformVals.js'; import { getPlatformDisplay } from '../utils/defineRibbonVals.js'; - -// Define platform-specific values -const PLATFORM_CONFIGS = { - github: { - starsKey: 'stargazers_count', - profileRepo: '.github' - }, - /* gitlab: { - starsKey: 'star_count', - profileRepo: 'gitlab-profile', - //TODO - }, */ - codeberg: { - starsKey: 'stars_count', - profileRepo: '.profile' - } -}; +import { normalizeTag, filterDisplayTags } from '../utils/normalizeTag.js'; /** * Function for fetching code repositories from the specified platform (GitHub, GitLab, or Codeberg). @@ -46,8 +30,7 @@ export async function fetchCodeRepos( let allRepos = []; let nextUrl = `${orgApiUrl}`; // get platform-specific keys - const platformConfig = PLATFORM_CONFIGS[platform.toLowerCase()]; - const { starsKey, profileRepo } = platformConfig; + const { starsKey, profileRepo, fullNameKey, forkKey, urlKey } = getPlatformVals(platform); try { while (nextUrl) { const ghResponse = await fetch(nextUrl); @@ -68,7 +51,7 @@ export async function fetchCodeRepos( // For org-owned entries in additionalRepos, reuse data already in allRepos to avoid redundant API calls. // Only fetch entries that belong to a different org (external repos). - const allReposByFullName = new Map(allRepos.map(r => [r.full_name, r])); + const allReposByFullName = new Map(allRepos.map(r => [r[fullNameKey], r])); const toFetch = additionalRepos.filter(ownerRepo => !allReposByFullName.has(ownerRepo)); const fromAllRepos = additionalRepos.map(ownerRepo => allReposByFullName.get(ownerRepo)).filter(Boolean); @@ -91,11 +74,11 @@ export async function fetchCodeRepos( const filteredAdditionalRepos = [...fromAllRepos, ...fetchedExternalData.filter(Boolean)]; // Keep only non-forks from org; deduplicate against additional repos by full_name - const orgRepoNames = new Set(filteredAdditionalRepos.map(r => r.full_name)); + const orgRepoNames = new Set(filteredAdditionalRepos.map(r => r[fullNameKey])); const orgNonForks = allRepos.filter(repo => repo.name !== profileRepo && - !repo.fork && - !orgRepoNames.has(repo.full_name)); + !repo[forkKey] && + !orgRepoNames.has(repo[fullNameKey])); // Process additional repos and all remaining org non-forks to include metadata and 'new' flag as appropriate let processedItems = [...filteredAdditionalRepos, ...orgNonForks] @@ -108,10 +91,10 @@ export async function fetchCodeRepos( const tags = [...new Set(rawTags.flatMap(t => normalizeTag(t)).filter(Boolean))]; const displayTags = filterDisplayTags(rawTags); - const release = releasesMap[repo.full_name] ?? null; + const release = releasesMap[repo[fullNameKey]] ?? null; return { - id: repo.full_name, // "Imageomics/", used as backup if can't get repo.name + id: repo[fullNameKey], // "Imageomics/", used as backup if can't get repo.name repoType: "code", createdAt, lastModified, @@ -120,14 +103,13 @@ export async function fetchCodeRepos( tags, rawTags, displayTags, - description: repo.description || "No description provided.", - html_url: repo.html_url, + description: repo.description, // fallback in display (render.js) + html_url: repo[urlKey], hasNewRelease: release?.isNew ?? false, latestReleaseUrl: release?.url ?? null, latestReleaseTag: release?.tag ?? null, cardData: { pretty_name: repo.name, // , the one used for card title display - description: repo.description, stars: repo[starsKey] ?? 0 } }; diff --git a/src/api/fetchStats.js b/src/api/fetchStats.js index 76f6888..a287db8 100644 --- a/src/api/fetchStats.js +++ b/src/api/fetchStats.js @@ -4,9 +4,10 @@ * @param {string} repoApiUrl - The base API URL for the code platform (e.g., GitHub API URL) * @param {string} organizationName - The organization name for the catalog repository * @param {string} catalogRepoName - The repository name for the catalog itself + * @param {string} releaseSuffix - The suffix used for fetching the latest release information (e.g., 'releases/latest') * @returns {Promise} - A promise that resolves when the stats have been fetched and displayed */ -export const fetchCatalogStats = async (repoApiUrl, organizationName, catalogRepoName) => { +export const fetchCatalogStats = async (repoApiUrl, organizationName, catalogRepoName, releaseSuffix) => { // Helper: Updates text, shows the specific stat, and ensures the divider is visible const update = (textId, containerId, value) => { const el = document.getElementById(textId); @@ -21,7 +22,8 @@ export const fetchCatalogStats = async (repoApiUrl, organizationName, catalogRep }; try { - //TODO: Update stars and forks to support other platforms (GitLab, Codeberg) once implemented + //TODO: Update stars and forks to support other platforms: Add another || star for GitLab, platform isn't passed + // forks_count is shared // 1. Get Stars & Forks const repo = await fetch(`${repoApiUrl}${organizationName}/${catalogRepoName}`).then(r => r.ok ? r.json() : {}); if (repo.stargazers_count !== undefined || repo.stars_count !== undefined) update('gh-stars', 'gh-star-container', repo.stargazers_count || repo.stars_count); @@ -29,7 +31,7 @@ export const fetchCatalogStats = async (repoApiUrl, organizationName, catalogRep // 2. Get Version (Tag) // TODO: Import from package.json - const release = await fetch(`${repoApiUrl}${organizationName}/${catalogRepoName}/releases/latest`).then(r => r.ok ? r.json() : {}); + const release = await fetch(`${repoApiUrl}${organizationName}/${catalogRepoName}/${releaseSuffix}`).then(r => r.ok ? r.json() : {}); if (release.tag_name !== undefined) update('gh-tag', 'gh-version-container', release.tag_name); } catch (e) { diff --git a/src/utils/defineApiUrls.js b/src/utils/defineApiUrls.js deleted file mode 100644 index 2d68025..0000000 --- a/src/utils/defineApiUrls.js +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Defines API URLs based on the selected platform (GitHub or Codeberg, note: GitLab support under development). - * This allows the rest of the codebase to use these constants when making API calls, - * abstracting away platform-specific URL structures. - * - * Usage: import { getPlatformApiUrls } from './defineApiUrls.js'; - * - * Input: platform and organizationName (e.g., 'github' and 'imageomics'), defined from config.yaml and passed to this function. - * Output: platformApiUrls[platform] = { org: ORG_API_URL, repo: REPO_API_URL } - */ - -/** - * Utility function to get the platform-specific API URLs for organization repos and individual repo details. - * @param {string} platform - 'github' or 'codeberg', pending: 'gitlab' - * @param {string} organizationName - The name of the organization (used in URL construction) - * @returns {object} An object containing ORG_API_URL and REPO_API_URL - */ -export function getPlatformApiUrls(platform, organizationName) { - const platformApiUrls = { - github: { - org: `https://api.github.com/orgs/${organizationName}/repos?type=public&per_page=100`, - repo: "https://api.github.com/repos/" - }, - // gitlab: { - // org: `https://gitlab.com/api/v4/groups/${organizationName}/projects?per_page=100`, - // repo: "https://gitlab.com/api/v4/projects/" - // }, - codeberg: { - org: `https://codeberg.org/api/v1/orgs/${organizationName}/repos?limit=50`, - repo: "https://codeberg.org/api/v1/repos/" - } - }; - return platformApiUrls[platform.toLowerCase()]; -} diff --git a/src/utils/definePlatformVals.js b/src/utils/definePlatformVals.js new file mode 100644 index 0000000..2cb9784 --- /dev/null +++ b/src/utils/definePlatformVals.js @@ -0,0 +1,78 @@ +/** + * Collection of functions to define platform-specific values for the catalog, such as API URLs, display values, and repo data keys. + * Each function returns dictionaries with the platform values that have been keyed by platform name (e.g., 'github', 'codeberg', 'gitlab') and potentially other instance-specific values (e.g., organizationName, repoApiUrl). + * Platform and organization name are defined from config.yaml and passed to the functions requiring them. +*/ + + +/** + * Utility function to get platform-specific values for repo data keys (e.g., star count and profile repo name). + * @param {string} platform + * @returns {Object} platformVals - An object containing platform-specific values for repo data keys (e.g., star count and profile repo name) + */ +export function getPlatformVals(platform) { + const platformVals = { + github: { + starsKey: 'stargazers_count', + profileRepo: '.github', + fullNameKey: 'full_name', + forkKey: 'fork', //forks_count is shared + urlKey: 'html_url', + releasePublishedAtKey: 'published_at' + }, + /* gitlab: { + starsKey: 'star_count', + profileRepo: 'gitlab-profile', + fullNameKey: 'name_with_namespace', + forkKey: 'forked_from_project', + urlKey: 'web_url', + releasePublishedAtKey: 'released_at' + }, */ + codeberg: { + starsKey: 'stars_count', + profileRepo: '.profile', + fullNameKey: 'full_name', + forkKey: 'fork', + urlKey: 'html_url', + releasePublishedAtKey: 'published_at' + } +}; + return platformVals[platform.toLowerCase()]; +} + + +/** + * Utility function to get the platform-specific API URLs for organization repos and individual repo details. + * Defines API URLs based on the selected platform (GitHub or Codeberg, note: GitLab support under development). + * This allows the rest of the codebase to use these constants when making API calls, + * abstracting away platform-specific URL structures. + * + * Usage: import { getPlatformApiUrls } from './definePlatformVals.js'; + * + * Input: platform and organizationName (e.g., 'github' and 'imageomics'), defined from config.yaml and passed to this function. + * Output: platformApiUrls[platform] = { org: ORG_API_URL, repo: REPO_API_URL, releaseSuffix: RELEASE_SUFFIX } + * + * @param {string} platform - 'github' or 'codeberg', pending: 'gitlab' + * @param {string} organizationName - The name of the organization (used in URL construction) + * @returns {object} An object containing ORG_API_URL, REPO_API_URL, and RELEASE_SUFFIX + */ +export function getPlatformApiUrls(platform, organizationName) { + const platformApiUrls = { + github: { + org: `https://api.github.com/orgs/${organizationName}/repos?type=public&per_page=100`, + repo: "https://api.github.com/repos/", + releaseSuffix: "releases/latest" + }, + // gitlab: { + // org: `https://gitlab.com/api/v4/groups/${organizationName}/projects?per_page=100`, + // repo: "https://gitlab.com/api/v4/projects/", + // releaseSuffix: "releases/permalink/latest" + // }, + codeberg: { + org: `https://codeberg.org/api/v1/orgs/${organizationName}/repos?limit=50`, + repo: "https://codeberg.org/api/v1/repos/", + releaseSuffix: "releases/latest" + } + }; + return platformApiUrls[platform.toLowerCase()]; +} diff --git a/tests/api/fetchCodeRepos.test.js b/tests/api/fetchCodeRepos.test.js index 55e815e..6ae0fdd 100644 --- a/tests/api/fetchCodeRepos.test.js +++ b/tests/api/fetchCodeRepos.test.js @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { fetchCodeRepos } from '../../src/api/fetchCodeRepos.js'; import { handleError } from '../../src/ui/render.js'; +import { getPlatformVals, getPlatformApiUrls } from '../../src/utils/definePlatformVals.js'; // Mock the internal dependencies vi.mock('../../src/utils/normalizeTag.js', () => ({ @@ -10,34 +11,34 @@ vi.mock('../../src/utils/normalizeTag.js', () => ({ vi.mock('../../src/ui/render.js', () => ({ handleError: vi.fn() })); -vi.mock('../../src/utils/defineRibbonVals.js', () => ({ - getPlatformDisplay: vi.fn(() => 'GitHub') -})); -// define configs for each platform -const platformConfigs = [ - { - name: 'github', - orgApiUrl: 'https://api.github.com/orgs/test-org/repos', - repoApiUrl: 'https://api.github.com/repos/', - platformProfileRepo: '.github', - starsKey: 'stargazers_count' - }, - /* { - name: 'gitlab', - orgApiUrl: 'https://gitlab.com/api/v4/groups/test-org/projects', - repoApiUrl: 'https://gitlab.com/api/v4/projects/', - platformProfileRepo: 'gitlab-profile', - starsKey: 'star_count' - }, */ - { - name: 'codeberg', - orgApiUrl: 'https://codeberg.org/api/v1/orgs/test-org/repos', - repoApiUrl: 'https://codeberg.org/api/v1/repos/', - platformProfileRepo: '.profile', - starsKey: 'stars_count' +const SUPPORTED_PLATFORMS = ['github', 'codeberg']; // 'gitlab' is pending, tests should work on implementation +const TEST_ORG = 'test-org'; + +const platformConfigs = SUPPORTED_PLATFORMS.map(platform => { + const platformVals = getPlatformVals(platform); + const urls = getPlatformApiUrls(platform, TEST_ORG); + + return { + name: platform, + orgApiUrl: urls.org, + repoApiUrl: urls.repo, + starsKey: platformVals.starsKey, + platformProfileRepo: platformVals.profileRepo, + forkKey: platformVals.forkKey, + fullNameKey: platformVals.fullNameKey, + urlKey: platformVals.urlKey, + }; +}); + +const getMockForkValue = (isFork, platform) => { + if (!isFork) { + // GitLab leaves non-forks as undefined; GitHub/Codeberg return false + return platform === 'gitlab' ? undefined : false; } -]; + // GitLab returns parent project object; GitHub/Codeberg return true + return platform === 'gitlab' ? { id: 999 } : true; +}; describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { const originalFetch = global.fetch; @@ -55,6 +56,9 @@ describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { const platform = platformConfig.name; const starsKey = platformConfig.starsKey; const platformProfileRepo = platformConfig.platformProfileRepo; + const forkKey = platformConfig.forkKey; + const fullNameKey = platformConfig.fullNameKey; + const urlKey = platformConfig.urlKey; const refreshIntervalDays = 30; const releasesMap = {}; @@ -64,18 +68,21 @@ describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { const oldDateISO = new Date(now.getTime() - 45 * 24 * 60 * 60 * 1000).toISOString(); // 45 days ago const oldestDateISO = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000).toISOString(); // 90 days ago - it('maps raw repo data and attaches release data from releasesMap', async () => { + it('maps raw repo data, resolves platform keys, and attaches release data', async () => { // Mock a single page platform response global.fetch.mockResolvedValueOnce({ ok: true, headers: { get: () => null }, // No 'link' header means no pagination json: () => Promise.resolve([{ - full_name: 'test-org/code-repo', + [fullNameKey]: 'test-org/code-repo', name: 'code-repo', + description: 'A test repository', + [forkKey]: getMockForkValue(false, platform), updated_at: now.toISOString(), created_at: recentDateISO, topics: ['python'], [starsKey]: 42, + [urlKey]: 'http://example.com/test-org/code-repo' }]) }); @@ -94,15 +101,23 @@ describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { ); expect(items).toHaveLength(1); + expect(items[0].id).toBe('test-org/code-repo'); expect(items[0].repoType).toBe('code'); + expect(items[0].description).toBe('A test repository'); + // fork is used for inclusion filtering only and is not returned by fetchCodeRepos expect(items[0].hasNewRelease).toBe(true); expect(items[0].latestReleaseTag).toBe('v1.0'); expect(items[0].tags).toContain('python'); + expect(items[0].html_url).toBe('http://example.com/test-org/code-repo'); + expect(items[0].cardData.pretty_name).toBe('code-repo'); expect(items[0].cardData.stars).toBe(42); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledWith(orgApiUrl); }); - // Pagination test: simulate multiple pages of GitHub API results using the Link header + // Pagination test: simulate multiple pages of API results using the Link header it('handles multi-page pagination', async () => { global.fetch.mockImplementation((url) => { if (url === orgApiUrl) { @@ -115,7 +130,7 @@ describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { : null }, json: () => Promise.resolve([{ - full_name: 'test-org/repo-page1', + [fullNameKey]: 'test-org/repo-page1', name: 'repo-page1', created_at: recentDateISO, updated_at: recentDateISO @@ -127,7 +142,7 @@ describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { ok: true, headers: { get: () => null }, json: () => Promise.resolve([{ - full_name: 'test-org/repo-page2', + [fullNameKey]: 'test-org/repo-page2', name: 'repo-page2', created_at: recentDateISO, updated_at: recentDateISO }]) @@ -156,14 +171,14 @@ describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { it('fetches additional org repos once and fetches external repos', async () => { // Base organization discovery lists 'test-org/internal-additional' const mockOrgRepo = { - full_name: 'test-org/internal-additional', + [fullNameKey]: 'test-org/internal-additional', name: 'internal-additional', created_at: recentDateISO, updated_at: recentDateISO }; const mockExternalRepo = { - full_name: 'external-org/external-additional', + [fullNameKey]: 'external-org/external-additional', name: 'external-additional', created_at: recentDateISO, updated_at: recentDateISO @@ -224,30 +239,30 @@ describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { headers: { get: () => null }, json: () => Promise.resolve([ { - full_name: 'test-org/valid-repo', + [fullNameKey]: 'test-org/valid-repo', name: 'valid-repo', - fork: false, + [forkKey]: getMockForkValue(false, platform), created_at: recentDateISO, updated_at: recentDateISO }, { - full_name: 'test-org/forked-repo', + [fullNameKey]: 'test-org/forked-repo', name: 'forked-repo', - fork: true, + [forkKey]: getMockForkValue(true, platform), created_at: recentDateISO, updated_at: recentDateISO }, { - full_name: 'test-org/forked-in-list', + [fullNameKey]: 'test-org/forked-in-list', name: 'forked-in-list', - fork: true, + [forkKey]: getMockForkValue(true, platform), created_at: recentDateISO, updated_at: recentDateISO }, { - full_name: `test-org/${platformProfileRepo}`, + [fullNameKey]: `test-org/${platformProfileRepo}`, name: platformProfileRepo, - fork: false, + [forkKey]: getMockForkValue(false, platform), created_at: recentDateISO, updated_at: recentDateISO } @@ -280,13 +295,13 @@ describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { headers: { get: () => null }, json: () => Promise.resolve([ { - full_name: 'test-org/new-repo', + [fullNameKey]: 'test-org/new-repo', name: 'new-repo', created_at: recentDateISO, updated_at: now.toISOString() }, { - full_name: 'test-org/old-repo', + [fullNameKey]: 'test-org/old-repo', name: 'old-repo', created_at: oldestDateISO, updated_at: oldDateISO From 5dda63ce7c079223db60045a455d6b3329843018 Mon Sep 17 00:00:00 2001 From: Elizabeth Campolongo <38985481+egrace479@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:13:26 -0500 Subject: [PATCH 08/12] Clarify demo (HF space) jargon Pulled from Imageomics catalog [PR 132](https://github.com/Imageomics/catalog/pull/132) cherry-pick commit: 7fb4eff9172509f30056ce0c8076560bb9a02a96 * Update terminology to be more general (use 'demos' instead of 'spaces') * Update documentation where relevant to match * removed contribution file & agent file, preserved ABC customization while changing "space" to "demo" --- .zenodo.json | 3 ++- CITATION.cff | 3 ++- README.md | 2 +- index.html | 2 +- package.json | 2 +- public/config.yaml | 4 ++-- 6 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.zenodo.json b/.zenodo.json index e13c82a..45c29e7 100644 --- a/.zenodo.json +++ b/.zenodo.json @@ -6,12 +6,13 @@ "affiliation": "The Ohio State University" } ], - "description": "

Catalog app for ABC Center code, data, models, and spaces hosted on GitHub and Hugging Face. Centralized resource to search ABC Center-affiliated products, the content is updated in real-time through calls to the GitHub and Hugging Face APIs. This repository was generated from the Imageomics Catalog Template.

\n

Catalog website: ABC-Center.github.io/catalog/.

\n

.

", + "description": "

Catalog app for ABC Center code, data, models, and demos hosted on GitHub and Hugging Face. Centralized resource to search ABC Center-affiliated products, the content is updated in real-time through calls to the GitHub and Hugging Face APIs. This repository was generated from the Imageomics Catalog Template.

\n

Catalog website: ABC-Center.github.io/catalog/.

\n

.

", "keywords": [ "code", "data", "models", "spaces", + "demos", "GitHub", "Hugging Face", "APIs", diff --git a/CITATION.cff b/CITATION.cff index a0cb884..be19394 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,4 +1,4 @@ -abstract: "Catalog app for ABC Center code, data, models, and spaces hosted on GitHub and Hugging Face. Centralized resource to search ABC Center-affiliated products, the content is updated in real-time through calls to the GitHub and Hugging Face APIs. This repository was generated from the Imageomics Catalog Template." +abstract: "Catalog app for ABC Center code, data, models, and demos hosted on GitHub and Hugging Face. Centralized resource to search ABC Center-affiliated products, the content is updated in real-time through calls to the GitHub and Hugging Face APIs. This repository was generated from the Imageomics Catalog Template." authors: - family-names: "Campolongo" given-names: "Elizabeth G." @@ -18,6 +18,7 @@ keywords: - data - models - spaces + - demos - "GitHub" - "Hugging Face" - APIs diff --git a/README.md b/README.md index b5972cb..5792f28 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # AI & Biodiversity Change Global Center Catalog -Repository for web-based ABC Center code, data, model, and spaces catalog. This catalog is designed to use the GitHub API for searching all code repositories created under the [ABC GitHub Organization](https://github.com/ABC-Center) and the Hugging Face API for searching all dataset, model, and spaces repositories created under the [ABC Hugging Face Organization](https://huggingface.co/ABC-Center). Non-ABC Org GitHub and Hugging Face repositories can be manually included through the `ADDITIONAL_REPOS` and `ADDITIONAL_HF_REPOS` parameters, respectively, in the [config file](public/config.yaml). +Repository for web-based catalog of ABC Center code, data, model, and demos. This catalog is designed to use the GitHub API for searching all code repositories created under the [ABC GitHub Organization](https://github.com/ABC-Center) and the Hugging Face API for searching all dataset, model, and spaces repositories created under the [ABC Hugging Face Organization](https://huggingface.co/ABC-Center). Non-ABC Org GitHub and Hugging Face repositories can be manually included through the `ADDITIONAL_REPOS` and `ADDITIONAL_HF_REPOS` parameters, respectively, in the [config file](public/config.yaml). This repository was generated and personalized from the [Imageomics Catalog](https://github.com/Imageomics/catalog). The following sections are pulled from the source repo. diff --git a/index.html b/index.html index 2fd0815..86f0d06 100644 --- a/index.html +++ b/index.html @@ -120,7 +120,7 @@

- +
diff --git a/package.json b/package.json index 5c40b60..83a4119 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "catalog", "version": "1.0.0", - "description": "Repository for web-based ABC-Center code, data, model, and spaces catalog. This catalog is designed to use the GitHub API for searching all code repositories created under the [ABC-Center GitHub Organization](https://github.com/ABC-Center) and the Hugging Face API for searching all dataset, model, and spaces repositories created under the [ABC-Center Hugging Face Organization](https://huggingface.co/ABC-Center).", + "description": "Repository for web-based ABC-Center code, data, model, and demoscatalog. This catalog is designed to use the GitHub API for searching all code repositories created under the [ABC-Center GitHub Organization](https://github.com/ABC-Center) and the Hugging Face API for searching all dataset, model, and spaces repositories created under the [ABC-Center Hugging Face Organization](https://huggingface.co/ABC-Center).", "main": "main.js", "scripts": { "dev": "vite", diff --git a/public/config.yaml b/public/config.yaml index 64f9781..8bfac6b 100644 --- a/public/config.yaml +++ b/public/config.yaml @@ -24,7 +24,7 @@ COLORS: # API & Behavior Settings # Codebase platform for API calls and link generation. Supported values: "github" or "codeberg", pending: "gitlab". PLATFORM: github -# Dataset, model, and space (demo) default: Hugging Face, other platforms would require a custom module +# Dataset, model, and demo default: Hugging Face, other platforms would require a custom module API_BASE_URL: "https://huggingface.co/api/" # Define "new" repository criteria REFRESH_INTERVAL_DAYS: 30 @@ -42,7 +42,7 @@ ADDITIONAL_REPOS: - "kitzeslab/Kitzes_MethodsEcolEvo_manuscript_2025" # Array of Hugging Face repos from outside the org to include. -# Each entry must specify "repo" (owner/name) and "type" (datasets, models, or spaces). +# Each entry must specify "repo" (owner/name) and "type" (datasets, models, or spaces/demos). # Organization names are case-sensitive in the Hugging Face API. # ADDITIONAL_HF_REPOS: # - repo: "user/dataset-name" From c2b5634d757db49446416e6abaf46b6a9d0fa709 Mon Sep 17 00:00:00 2001 From: Elizabeth Campolongo <38985481+egrace479@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:44:18 -0400 Subject: [PATCH 09/12] Add GitLab as a code repository platform option Pulled from Imageomics catalog [PR 134](https://github.com/Imageomics/catalog/pull/134) cherry-pick commit: c5ce9487905f266448c33398025f678ea75ab825 * Update documentation to reflect increased code platform options * ensure no private repos leak * Use platform-specific token auth in release fetch script * Move build-time helper functions to scripts utility * Make org name testing more robust based on platform differences * make build script requests more efficient and respectful * Include projects from subgroups, but exclude shared projects for GitLab consistent with the stated goal of the catalog and the way in which GitLab presents a group's projects --- README.md | 2 +- docs/personalization.md | 121 ++++++++++++++++++++++++++++- public/config.yaml | 2 +- scripts/export-tags.js | 21 +++-- scripts/fetch-releases.js | 80 ++++++++++++++----- scripts/platformScriptHelpers.js | 73 +++++++++++++++++ src/api/fetchCodeRepos.js | 8 +- src/api/fetchStats.js | 14 ++-- src/assets/gitlab-logo-700-rgb.svg | 1 + src/utils/definePlatformVals.js | 33 ++++---- src/utils/defineRibbonVals.js | 14 ++-- src/validateConfig.js | 54 +++++++++---- tests/api/fetchCodeRepos.test.js | 46 +++++++---- tests/validateConfig.test.js | 18 ++++- 14 files changed, 383 insertions(+), 104 deletions(-) create mode 100644 scripts/platformScriptHelpers.js create mode 100644 src/assets/gitlab-logo-700-rgb.svg diff --git a/README.md b/README.md index 5792f28..0b173f0 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ This repository was generated and personalized from the [Imageomics Catalog](htt The website is styled using the [tailwindcss](https://tailwindcss.com/) package. -* **Real-time Data Fetching:** Displays all public organization repositories, fetched through the code platform and Hugging Face APIs. Includes semantically meaningful virtual markers: +* **Real-time Data Fetching:** Displays all public organization repositories, fetched through the code platform (GitHub, GitLab, or Codeberg) and Hugging Face APIs. Includes semantically meaningful virtual markers: * "New" badge highlights products created within the last 30 days; * "🚀 version-tag" badge indicates a new release within the last 2 weeks for code repos, and links to that release; * Star (⭐️) or like (❤️) counts displayed for code platform or Hugging Face repos, respectively; diff --git a/docs/personalization.md b/docs/personalization.md index d8a0756..d37ad12 100644 --- a/docs/personalization.md +++ b/docs/personalization.md @@ -32,7 +32,7 @@ Welcome to your new catalog repo! The primary way to personalize this catalog is ### API & Behavior Settings - * `PLATFORM`: Coding platform used: 'github' or 'codeberg' (default: 'github', GitLab support in development). Please see [notes on platform setup](#non-github-code-platform-setup) if using for non-GitHub code repositories + * `PLATFORM`: Coding platform used: 'github', 'codeberg', or 'gitlab' (default: 'github'). Please see [notes on platform setup](#non-github-code-repository-platform-setup) if using for non-GitHub code repositories * `API_BASE_URL`: Hugging Face API base URL (default: `"https://huggingface.co/api/"`) * `REFRESH_INTERVAL_DAYS`: Number of days to consider an item "new" (default: `30`) * `ADDITIONAL_REPOS`: Array of forked or non-org GitHub repositories to include, formatted `/` (non-forks are included by default). Use `[]` if there are none you wish to include @@ -89,6 +89,121 @@ When first setting up your catalog, run the export script to generate a full lis See **[tag-grouping-process.md](tag-grouping-process.md)** for full setup instructions, conventions, and guidance on using AI assistance for the initial grouping pass. -## Code Repository Platform Setup +## Non-GitHub Code Repository Platform Setup + +The default code repository platform for this catalog is GitHub. If you wish to use another supported platform (Codeberg or GitLab), please note that the workflows will require token and potentially other platform-specific updates if running from a non-GitHub repository. Otherwise, this app is set up to be able to run from Codeberg or GitLab to fetch and display repositories from the respective platform. + +Example configs, as used in testing the Catalog template for non-GitHub code platforms, are provided below. + +### Sample Codeberg Config + +```yaml +# Configuration file for Catalog Template +# Customize these values to personalize the catalog for your organization + +# Organization & Repository Settings +ORGANIZATION_NAME: forgejo # Codebase platform organization name (for API calls) +HF_ORGANIZATION_NAME: imageomics # Hugging Face organization name (case-sensitive, for API calls) +ORG_NAME: Forgejo # Display name for Codebase platform organization (can differ from API name) +CATALOG_REPO_NAME: forgejo # Repository name for the catalog itself (used for stats badge) + +# Branding +CATALOG_TITLE: Fake Forgejo Catalog +CATALOG_DESCRIPTION: "Explore and discover public code, datasets, models, and spaces." +LOGO_URL: "https://github.com/Imageomics/Imageomics-guide/raw/3478acc0068a87a5604069d04a29bdb0795c2045/docs/logos/Imageomics_logo_butterfly.png" +FAVICON_URL: "https://github.com/Imageomics/Imageomics-guide/raw/3478acc0068a87a5604069d04a29bdb0795c2045/docs/logos/Imageomics_logo_butterfly.png" + +# Colors (CSS custom properties) +COLORS: + primary: "#92991c" # Primary brand color (Imageomics Green) + secondary: "#5d8095" # Secondary brand color (Imageomics Blue) + accent: "#0097b2" # Accent color (Dark Teal) + accentDark: "#4fd1eb" # Dark mode accent color (Light Cyan) + tag: "#9bcb5e" # Tag background color (Light Green) + +# API & Behavior Settings +# Codebase platform for API calls and link generation. Supported values: "github", "codeberg", or "gitlab". +PLATFORM: codeberg +# Dataset, model, and space (demo) default: Hugging Face, other platforms would require a custom module +API_BASE_URL: "https://huggingface.co/api/" +# Define "new" repository criteria +REFRESH_INTERVAL_DAYS: 30 + +# Array of "owner/repo" strings to include in addition to non-forked org repos. +# Use this for forked repos within the org and repos outside the org entirely. +ADDITIONAL_REPOS: [] + +# Array of Hugging Face repos from outside the org to include. +# Each entry must specify "repo" (owner/name) and "type" (datasets, models, or spaces). +# ADDITIONAL_HF_REPOS: +# - repo: "user/dataset-name" +# type: "datasets" +# - repo: "user/model-name" +# type: "models" +# - repo: "user/space-name" +# type: "spaces" +ADDITIONAL_HF_REPOS: + - repo: "yoohj0416/predictbeetle" + type: "models" + +# Typography +FONT_FAMILY: Inter # Font family for the site +``` + +### Sample GitLab Config + +```yaml +# Configuration file for Catalog Template +# Customize these values to personalize the catalog for your organization + +# Organization & Repository Settings +ORGANIZATION_NAME: GitLab-com # Codebase platform organization name (for API calls) +HF_ORGANIZATION_NAME: imageomics # Hugging Face organization name (case-sensitive, for API calls) +ORG_NAME: Imageomics # Display name for Codebase platform organization (can differ from API name) +CATALOG_REPO_NAME: www-gitlab-com # Repository name for the catalog itself (used for stats badge) + +# Branding +CATALOG_TITLE: Fake GitLab.com Catalog +CATALOG_DESCRIPTION: "Explore and discover public code, datasets, models, and demos." +LOGO_URL: "https://github.com/Imageomics/Imageomics-guide/raw/3478acc0068a87a5604069d04a29bdb0795c2045/docs/logos/Imageomics_logo_butterfly.png" +FAVICON_URL: "https://github.com/Imageomics/Imageomics-guide/raw/3478acc0068a87a5604069d04a29bdb0795c2045/docs/logos/Imageomics_logo_butterfly.png" + +# Colors (CSS custom properties) +COLORS: + primary: "#92991c" # Primary brand color (Imageomics Green) + secondary: "#5d8095" # Secondary brand color (Imageomics Blue) + accent: "#0097b2" # Accent color (Dark Teal) + accentDark: "#4fd1eb" # Dark mode accent color (Light Cyan) + tag: "#9bcb5e" # Tag background color (Light Green) + +# API & Behavior Settings +# Codebase platform for API calls and link generation. Supported values: "github", "codeberg", or "gitlab". +PLATFORM: gitlab +# Dataset, model, and demo default: Hugging Face, other platforms would require a custom module +API_BASE_URL: "https://huggingface.co/api/" +# Define "new" repository criteria +REFRESH_INTERVAL_DAYS: 30 + +# Array of "owner/repo" strings to include in addition to non-forked org repos. +# Use this for forked repos within the org and repos outside the org entirely. +ADDITIONAL_REPOS: + - "spectrelonewolf/proyectoprofesionalcore1" # ex: https://gitlab.com/spectrelonewolf/proyectoprofesionalcore1 + +# Array of Hugging Face repos from outside the org to include. +# Each entry must specify "repo" (owner/name) and "type" (datasets, models, or spaces/demos). +# Organization names are case-sensitive in the Hugging Face API. +# ADDITIONAL_HF_REPOS: +# - repo: "user/dataset-name" +# type: "datasets" +# - repo: "user/model-name" +# type: "models" +# - repo: "user/space-name" +# type: "spaces" +ADDITIONAL_HF_REPOS: + - repo: "yoohj0416/predictbeetle" + type: "models" + +# Typography +FONT_FAMILY: Inter # Font family for the site -The default code repository platform for this catalog is GitHub. If you wish to use another supported platform (Codeberg), please note that the [tag export](../scripts/export-tags.js) and [fetch release](../scripts/fetch-releases.js) scripts may require header definition modifications to function properly. Workflows would also require token and other platform-specific updates if running from a non-GitHub repository. Otherwise, this app is set up to be able to run from Codeberg to fetch and display Codeberg repositories. +``` diff --git a/public/config.yaml b/public/config.yaml index 8bfac6b..2f05b58 100644 --- a/public/config.yaml +++ b/public/config.yaml @@ -22,7 +22,7 @@ COLORS: tag: "#4fb797a1" # Tag background color (ABC turquoise 63% alpha) # API & Behavior Settings -# Codebase platform for API calls and link generation. Supported values: "github" or "codeberg", pending: "gitlab". +# Codebase platform for API calls and link generation. Supported values: "github", "codeberg", or "gitlab". PLATFORM: github # Dataset, model, and demo default: Hugging Face, other platforms would require a custom module API_BASE_URL: "https://huggingface.co/api/" diff --git a/scripts/export-tags.js b/scripts/export-tags.js index ed18d6f..3e1dce0 100644 --- a/scripts/export-tags.js +++ b/scripts/export-tags.js @@ -20,6 +20,7 @@ import { validateConfig } from '../src/validateConfig.js'; import { getPlatformDisplay } from '../src/utils/defineRibbonVals.js'; import { filterNewAdditionalEntries } from '../src/utils/filterNewAdditionalEntries.js'; import { getPlatformVals, getPlatformApiUrls } from '../src/utils/definePlatformVals.js'; +import { getPlatformHeaders } from './platformScriptHelpers.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -41,15 +42,15 @@ const ADDITIONAL_HF_REPOS = CONFIG.ADDITIONAL_HF_REPOS; // --------------------------------------------------------------------------- // Fetch helpers // --------------------------------------------------------------------------- -// Update this section as needed for non-GitHub code platforms (e.g., Codeberg or GitLab) -const GITHUB_TOKEN = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; +/* Update the corresponding workflow and token as needed for non-GitHub code platforms (e.g., Codeberg or GitLab) +* `headers['Accept']` is only needed for GitHub to avoid 403 errors on some endpoints. +*/ +const platform = (CONFIG.PLATFORM || 'github').toLowerCase(); +const { org: ORG_API_URL, repo: REPO_API_URL } = getPlatformApiUrls(platform, ORGANIZATION_NAME); +const { profileRepo, fullNameKey, forkKey, encodeRepoId } = getPlatformVals(platform); const get = async (url) => { - const headers = {}; - if (GITHUB_TOKEN && url.includes('api.github.com')) { - headers['Authorization'] = `Bearer ${GITHUB_TOKEN}`; - headers['Accept'] = 'application/vnd.github+json'; - } + const headers = getPlatformHeaders(url, platform, ORGANIZATION_NAME); const res = await fetch(url, { headers }); if (!res.ok) throw new Error(`HTTP ${res.status} — ${url}`); return { json: await res.json(), headers: res.headers }; @@ -59,11 +60,9 @@ const get = async (url) => { // Tag collection // --------------------------------------------------------------------------- const allTags = new Set(); -const { org: ORG_API_URL, repo: REPO_API_URL } = getPlatformApiUrls(PLATFORM, ORGANIZATION_NAME); -const { profileRepo, fullNameKey, forkKey } = getPlatformVals(PLATFORM); const collectCodePlatformTags = async () => { - const platformDisplay = getPlatformDisplay(PLATFORM); + const platformDisplay = getPlatformDisplay(platform); console.log(`Fetching ${platformDisplay.displayName || PLATFORM} repos...`); let allRepos = []; let nextUrl = `${ORG_API_URL}`; @@ -79,7 +78,7 @@ const collectCodePlatformTags = async () => { // Additional repos const additionalData = await Promise.all( ADDITIONAL_REPOS.map(ownerRepo => - get(`${REPO_API_URL}${ownerRepo}`) + get(`${REPO_API_URL}${encodeRepoId(ownerRepo)}`) .then(({ json }) => json) .catch(() => null) ) diff --git a/scripts/fetch-releases.js b/scripts/fetch-releases.js index 4d3f977..ca68010 100644 --- a/scripts/fetch-releases.js +++ b/scripts/fetch-releases.js @@ -1,5 +1,5 @@ // scripts/fetch-releases.js -// Build-time script: fetches the latest GitHub release for each code repo +// Build-time script: fetches the latest release for each code repo // and writes public/releases.json. Runs as part of `npm run build`. import { readFileSync, writeFileSync } from 'fs'; import { fileURLToPath } from 'url'; @@ -7,6 +7,7 @@ import { dirname, join } from 'path'; import { load } from 'js-yaml'; import { validateConfig } from '../src/validateConfig.js'; import { getPlatformVals, getPlatformApiUrls } from '../src/utils/definePlatformVals.js'; +import { getPlatformReleaseVals, getPlatformHeaders } from './platformScriptHelpers.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -20,16 +21,20 @@ if (errors.length) { throw new Error(`Invalid config at ${configPath}: ${errors.join('; ')}`); } -const TOKEN = process.env.GITHUB_TOKEN; -const headers = TOKEN - ? { Authorization: `Bearer ${TOKEN}`, 'User-Agent': 'catalog-build-script' } - : { 'User-Agent': 'catalog-build-script' }; +/** + * Define platform-specific variables needed for script to fetch releases, including API URLs, headers, and repo data keys. + * Update the corresponding workflow and token as needed for non-GitHub code platforms (e.g., Codeberg or GitLab). +*/ +const platform = (CONFIG.PLATFORM || 'github').toLowerCase(); + +const { org: ORG_API_URL, repo: REPO_API_URL, releaseSuffix: RELEASE_SUFFIX } = getPlatformApiUrls(platform, CONFIG.ORGANIZATION_NAME); +const { releasePublishedAtKey, getReleaseUrl } = getPlatformReleaseVals(platform); +const { profileRepo, fullNameKey, forkKey, encodeRepoId } = getPlatformVals(platform); +const headers = getPlatformHeaders(ORG_API_URL, platform, CONFIG.ORGANIZATION_NAME); const TWO_WEEKS_MS = 14 * 24 * 60 * 60 * 1000; // Step 1: Fetch all public org repos (paginated, same logic as main.js) -const { org: ORG_API_URL, repo: REPO_API_URL, releaseSuffix: RELEASE_SUFFIX } = getPlatformApiUrls(CONFIG.PLATFORM, CONFIG.ORGANIZATION_NAME); -const { profileRepo, fullNameKey, forkKey, urlKey, releasePublishedAtKey } = getPlatformVals(CONFIG.PLATFORM); let allOrgRepos = []; let nextUrl = `${ORG_API_URL}`; while (nextUrl) { @@ -54,23 +59,58 @@ const repoIds = [ ...orgNonForks.map(r => r[fullNameKey]), ]; -// Step 3: Fetch latest release for each repo -const releases = {}; -for (const id of repoIds) { +// Step 3: Fetch latest releases in parallel with bounded concurrency (max 5 concurrent requests), key-value mapping for each repo ID + +/** + * Zero-dependency bounded concurrency worker pool + * @param {*} items - object to fetch + * @param {int} concurrency - maximum number of concurrent requests + * @param {*} fn - function to apply to each item + * @returns {Promise} - A promise resolving to an array of results + */ +async function mapConcurrent(items, concurrency, fn) { + const results = new Array(items.length); + let index = 0; + const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => { + while (index < items.length) { + const i = index++; + results[i] = await fn(items[i]); + } + }); + await Promise.all(workers); + return results; +} + +/** + * Fetch the latest release for a given repo ID. + * @param {String} id - repo ID ('owner/repo' format) + * @returns {Promise<[String, Object|null]>} - A promise resolving to a tuple of [repo ID, release data or null if not found] + */ +async function fetchRepoRelease(id) { try { - const res = await fetch(`${REPO_API_URL}${id}/${RELEASE_SUFFIX}`, { headers }); - if (!res.ok) { releases[id] = null; continue; } + const res = await fetch(`${REPO_API_URL}${encodeRepoId(id)}/${RELEASE_SUFFIX}`, { headers }); + if (!res.ok) return [id, null]; + const data = await res.json(); - releases[id] = { + const publishedAt = data[releasePublishedAtKey]; + // gitlab's release API returns a different structure than GitHub/Codeberg + return [id, { tag: data.tag_name, - url: data[urlKey], - publishedAt: data[releasePublishedAtKey], - isNew: (Date.now() - new Date(data[releasePublishedAtKey])) < TWO_WEEKS_MS, - }; - } catch { - releases[id] = null; + url: getReleaseUrl(data, id), + publishedAt: publishedAt, + isNew: (Date.now() - new Date(publishedAt)) < TWO_WEEKS_MS, + }]; + } catch (err) { + console.warn(`[fetch-releases] Network error for repo "${id}":`, err.message); + return [id, null]; } -} +}; + +// fetch the repo releases +const releaseEntries = await mapConcurrent(repoIds, 5, fetchRepoRelease); + +// Convert [id, releaseData] pairs into an object for JSON output +const releases = Object.fromEntries(releaseEntries); writeFileSync(join(__dirname, '../public/releases.json'), JSON.stringify(releases)); console.log(`Wrote releases.json (${Object.keys(releases).length} repos)`); diff --git a/scripts/platformScriptHelpers.js b/scripts/platformScriptHelpers.js new file mode 100644 index 0000000..9354f32 --- /dev/null +++ b/scripts/platformScriptHelpers.js @@ -0,0 +1,73 @@ +/** + * Script utilities for platform-specific API calls and headers when exporting tags or fetching repo release data. + * Collection of functions to define platform-specific values needed only in build-time/workflow scripts. + * Each function returns dictionaries with the platform values that have been keyed by platform name (e.g., 'github', 'gitlab', 'codeberg') and potentially other instance-specific values (e.g., organizationName, repoApiUrl). + * Platform and organization name are defined from config.yaml and passed to the functions requiring them. +*/ + + +/** + * Utility function to get platform-specific values for repo data keys (e.g., star count and profile repo name). + * @param {string} platform - 'github', 'gitlab', or 'codeberg' + * @returns {Object} platformVals - An object containing platform-specific values for repo data keys (e.g., release URL and published date key) + */ +export function getPlatformReleaseVals(platform) { + const platformVals = { + github: { + getReleaseUrl: (data) => data.html_url, + releasePublishedAtKey: 'published_at' + }, + gitlab: { + getReleaseUrl: (data, repoId) => { + const tag = encodeURIComponent(data.tag_name); + return `https://gitlab.com/${repoId}/-/releases/${tag}`; + }, + releasePublishedAtKey: 'released_at' + }, + codeberg: { + getReleaseUrl: (data) => data.html_url, + releasePublishedAtKey: 'published_at' + } +}; + return platformVals[platform.toLowerCase()]; +} + +// Define platform-specific token authentication and API host information for scripts to use when making API calls. +const tokenAuthByPlatform = { + github: { + token: process.env.GITHUB_TOKEN || process.env.GH_TOKEN, + apiHost : 'api.github.com', + authScheme: 'Bearer', + }, + gitlab: { + token: process.env.GITLAB_TOKEN, + apiHost : 'gitlab.com/api/v4', + authScheme: 'Bearer', + }, + codeberg: { + token: process.env.CODEBERG_TOKEN, + apiHost : 'codeberg.org/api/v1', + authScheme: 'token' + }, +}; +/** + * Utility function for scripts to get platform-specific headers +* Update the corresponding workflow as needed for non-GitHub code platforms (e.g., Codeberg or GitLab) +* `headers['Accept']` is only needed for GitHub to avoid 403 errors on some endpoints. +* @param {string} url - URL to check for the platform API host +* @param {string} platform - 'github', 'gitlab', or 'codeberg' +* @param {string} orgName - The name of the organization as used in API calls +* @returns {object} headers - headers object to use for fetch requests +*/ +export function getPlatformHeaders(url, platform, orgName) { + const { token, apiHost, authScheme } = tokenAuthByPlatform[platform]; + const cleanedOrgName = orgName.replace(/[^a-zA-Z0-9_-]/g, ''); + const headers = { 'User-Agent': `${cleanedOrgName}-catalog-build-script` }; + if (token && apiHost && url.includes(apiHost)) { + headers['Authorization'] = `${authScheme} ${token}`; + if (platform === 'github') { + headers['Accept'] = 'application/vnd.github+json'; + } + } + return headers; +} diff --git a/src/api/fetchCodeRepos.js b/src/api/fetchCodeRepos.js index 5f6dba4..c918ac4 100644 --- a/src/api/fetchCodeRepos.js +++ b/src/api/fetchCodeRepos.js @@ -8,7 +8,7 @@ import { normalizeTag, filterDisplayTags } from '../utils/normalizeTag.js'; * Both org-owned (non-forks) and additional repos are fetched; metadata is processed for each repo. * It also determines if a repo is "new" based on the provided refresh interval. * @async - * @param {string} platform - 'github', pending: 'gitlab', or 'codeberg' + * @param {string} platform - 'github', 'gitlab', or 'codeberg' * @param {Array} additionalRepos - An array of additional "owner/repo" strings to include in addition to non-forked * org repos. * @param {string} orgApiUrl - The API URL for fetching organization repos @@ -30,7 +30,7 @@ export async function fetchCodeRepos( let allRepos = []; let nextUrl = `${orgApiUrl}`; // get platform-specific keys - const { starsKey, profileRepo, fullNameKey, forkKey, urlKey } = getPlatformVals(platform); + const { starsKey, profileRepo, fullNameKey, updatedAtKey, forkKey, urlKey, encodeRepoId } = getPlatformVals(platform); try { while (nextUrl) { const ghResponse = await fetch(nextUrl); @@ -57,7 +57,7 @@ export async function fetchCodeRepos( const fetchedExternalData = await Promise.all( toFetch.map(ownerRepo => - fetch(`${repoApiUrl}${ownerRepo}`) + fetch(`${repoApiUrl}${encodeRepoId(ownerRepo)}`) .then(r => { if (!r.ok) { console.warn(`Failed to fetch additional repo "${ownerRepo}": HTTP ${r.status}`); @@ -84,7 +84,7 @@ export async function fetchCodeRepos( let processedItems = [...filteredAdditionalRepos, ...orgNonForks] .map(repo => { const createdAt = new Date(repo.created_at); - const lastModified = new Date(repo.updated_at); + const lastModified = new Date(repo[updatedAtKey]); const isNew = (new Date() - createdAt) / (1000 * 60 * 60 * 24) < refreshIntervalDays; const rawTags = (repo.topics || []).map(t => t.toLowerCase()); diff --git a/src/api/fetchStats.js b/src/api/fetchStats.js index a287db8..c43c47d 100644 --- a/src/api/fetchStats.js +++ b/src/api/fetchStats.js @@ -21,17 +21,19 @@ export const fetchCatalogStats = async (repoApiUrl, organizationName, catalogRep } }; + // Need to URL-encode the owner/repo for GitLab API ('%2F' instead of '/'), but need '/' for GitHub and Codeberg. + const ownerRepo = repoApiUrl.includes("gitlab") ? `${organizationName}%2F${catalogRepoName}` : `${organizationName}/${catalogRepoName}`; + try { - //TODO: Update stars and forks to support other platforms: Add another || star for GitLab, platform isn't passed - // forks_count is shared + // platform isn't passed, forks_count is shared // 1. Get Stars & Forks - const repo = await fetch(`${repoApiUrl}${organizationName}/${catalogRepoName}`).then(r => r.ok ? r.json() : {}); - if (repo.stargazers_count !== undefined || repo.stars_count !== undefined) update('gh-stars', 'gh-star-container', repo.stargazers_count || repo.stars_count); + const repo = await fetch(`${repoApiUrl}${ownerRepo}`).then(r => r.ok ? r.json() : {}); + const star_count = repo.stargazers_count ?? repo.stars_count ?? repo.star_count; + if (star_count !== undefined) update('gh-stars', 'gh-star-container', star_count); if (repo.forks_count !== undefined) update('gh-forks', 'gh-fork-container', repo.forks_count); // 2. Get Version (Tag) - // TODO: Import from package.json - const release = await fetch(`${repoApiUrl}${organizationName}/${catalogRepoName}/${releaseSuffix}`).then(r => r.ok ? r.json() : {}); + const release = await fetch(`${repoApiUrl}${ownerRepo}/${releaseSuffix}`).then(r => r.ok ? r.json() : {}); if (release.tag_name !== undefined) update('gh-tag', 'gh-version-container', release.tag_name); } catch (e) { diff --git a/src/assets/gitlab-logo-700-rgb.svg b/src/assets/gitlab-logo-700-rgb.svg new file mode 100644 index 0000000..72e3232 --- /dev/null +++ b/src/assets/gitlab-logo-700-rgb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/utils/definePlatformVals.js b/src/utils/definePlatformVals.js index 2cb9784..07dd579 100644 --- a/src/utils/definePlatformVals.js +++ b/src/utils/definePlatformVals.js @@ -1,6 +1,6 @@ /** * Collection of functions to define platform-specific values for the catalog, such as API URLs, display values, and repo data keys. - * Each function returns dictionaries with the platform values that have been keyed by platform name (e.g., 'github', 'codeberg', 'gitlab') and potentially other instance-specific values (e.g., organizationName, repoApiUrl). + * Each function returns dictionaries with the platform values that have been keyed by platform name (e.g., 'github', 'gitlab', 'codeberg') and potentially other instance-specific values (e.g., organizationName, repoApiUrl). * Platform and organization name are defined from config.yaml and passed to the functions requiring them. */ @@ -16,25 +16,28 @@ export function getPlatformVals(platform) { starsKey: 'stargazers_count', profileRepo: '.github', fullNameKey: 'full_name', + updatedAtKey: 'updated_at', forkKey: 'fork', //forks_count is shared urlKey: 'html_url', - releasePublishedAtKey: 'published_at' + encodeRepoId: (ownerRepo) => ownerRepo }, - /* gitlab: { + gitlab: { starsKey: 'star_count', profileRepo: 'gitlab-profile', - fullNameKey: 'name_with_namespace', + fullNameKey: 'path_with_namespace', + updatedAtKey: 'last_activity_at', // key used by GitLab for project update ordering, may include broader group-based activity forkKey: 'forked_from_project', urlKey: 'web_url', - releasePublishedAtKey: 'released_at' - }, */ + encodeRepoId: (ownerRepo) => encodeURIComponent(ownerRepo) // GitLab requires URL-encoded 'owner/repo' format + }, codeberg: { starsKey: 'stars_count', profileRepo: '.profile', fullNameKey: 'full_name', + updatedAtKey: 'updated_at', forkKey: 'fork', urlKey: 'html_url', - releasePublishedAtKey: 'published_at' + encodeRepoId: (ownerRepo) => ownerRepo } }; return platformVals[platform.toLowerCase()]; @@ -43,7 +46,7 @@ export function getPlatformVals(platform) { /** * Utility function to get the platform-specific API URLs for organization repos and individual repo details. - * Defines API URLs based on the selected platform (GitHub or Codeberg, note: GitLab support under development). + * Defines API URLs based on the selected platform (GitHub, GitLab, or Codeberg). * This allows the rest of the codebase to use these constants when making API calls, * abstracting away platform-specific URL structures. * @@ -52,7 +55,7 @@ export function getPlatformVals(platform) { * Input: platform and organizationName (e.g., 'github' and 'imageomics'), defined from config.yaml and passed to this function. * Output: platformApiUrls[platform] = { org: ORG_API_URL, repo: REPO_API_URL, releaseSuffix: RELEASE_SUFFIX } * - * @param {string} platform - 'github' or 'codeberg', pending: 'gitlab' + * @param {string} platform - 'github', 'gitlab', or 'codeberg' * @param {string} organizationName - The name of the organization (used in URL construction) * @returns {object} An object containing ORG_API_URL, REPO_API_URL, and RELEASE_SUFFIX */ @@ -63,13 +66,13 @@ export function getPlatformApiUrls(platform, organizationName) { repo: "https://api.github.com/repos/", releaseSuffix: "releases/latest" }, - // gitlab: { - // org: `https://gitlab.com/api/v4/groups/${organizationName}/projects?per_page=100`, - // repo: "https://gitlab.com/api/v4/projects/", - // releaseSuffix: "releases/permalink/latest" - // }, + gitlab: { + org: `https://gitlab.com/api/v4/groups/${organizationName}/projects?visibility=public&include_subgroups=true&with_shared=false&per_page=100`, + repo: "https://gitlab.com/api/v4/projects/", + releaseSuffix: "releases/permalink/latest" + }, codeberg: { - org: `https://codeberg.org/api/v1/orgs/${organizationName}/repos?limit=50`, + org: `https://codeberg.org/api/v1/orgs/${organizationName}/repos?type=public&limit=50`, repo: "https://codeberg.org/api/v1/repos/", releaseSuffix: "releases/latest" } diff --git a/src/utils/defineRibbonVals.js b/src/utils/defineRibbonVals.js index 7a4486d..a03249c 100644 --- a/src/utils/defineRibbonVals.js +++ b/src/utils/defineRibbonVals.js @@ -1,6 +1,6 @@ /** * A collection of platform-specific display information (e.g., SVGs and URLs) - * for code developer platforms including GitHub and Codeberg, pending: GitLab. + * for code developer platforms including GitHub, GitLab, and Codeberg. * Used for linking to repo and rendering icon and platform name in the ribbon component of the UI. * * Usage: import { getPlatformDisplay } from './defineRibbonVals.js'; @@ -11,7 +11,7 @@ */ import githubSvg from '../assets/GitHub_Invertocat.svg?raw'; -// import gitlabSvg from '../assets/gitlab-logo-700-rgb.svg?raw'; +import gitlabSvg from '../assets/gitlab-logo-700-rgb.svg?raw'; import codebergSvg from '../assets/codeberg-logo_icon_white.svg?raw'; /** @@ -26,11 +26,11 @@ export function getPlatformDisplay(platform) { displayName: "GitHub", ribbonUrl: "https://github.com/" }, - // gitlab: { - // svg: gitlabSvg, - // displayName: "GitLab", - // ribbonUrl: "https://gitlab.com/" - //}, + gitlab: { + svg: gitlabSvg, + displayName: "GitLab", + ribbonUrl: "https://gitlab.com/" + }, codeberg: { svg: codebergSvg, displayName: "Codeberg", diff --git a/src/validateConfig.js b/src/validateConfig.js index 78264d8..6c861ce 100644 --- a/src/validateConfig.js +++ b/src/validateConfig.js @@ -14,15 +14,48 @@ export function validateConfig(config) { return errors; } - /** Validate organization names, existence and allowed characters */ - const ghOrgRegex = /^[a-zA-Z0-9-]+$/; + /** + * PLATFORM will be parsed without whitespace, but must be string to avoid undefined errors + */ + const supportedPlatforms = ['github', 'gitlab', 'codeberg']; + const platform = config.PLATFORM; + var validPlatform = ''; + if (!platform || typeof platform !== 'string') { + errors.push('PLATFORM'); + } else if (!supportedPlatforms.includes(platform.toLowerCase())) { + errors.push(`PLATFORM must be one of: ${supportedPlatforms.join(', ')}`); + } else { + validPlatform = platform.toLowerCase(); + } + + /** + * Validate organization names, existence and allowed characters + * + * GitHub org names allow only letters, numbers, and hyphens; GitLab and Codeberg also allow underscores. + * All require alphanumeric characters at start and end. + * See: https://docs.gitlab.com/user/reserved_names/ + * See: https://codeberg.org/forgejo-contrib/forgejo-cli/wiki/Organizations + */ const orgName = config.ORGANIZATION_NAME; + const ghOrgRegex = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/; + const glcbOrgRegex = /^[a-zA-Z0-9](?:[a-zA-Z0-9_\-]*[a-zA-Z0-9])?$/; if (typeof orgName !== 'string' || !orgName.trim()) { errors.push('ORGANIZATION_NAME'); - } else if (!ghOrgRegex.test(orgName)) { - errors.push(`ORGANIZATION_NAME (${orgName}) is invalid, only letters, numbers, and hyphens are allowed`); + } else if (validPlatform != '') { + // If PLATFORM is invalid, we cannot determine which regex to use for ORGANIZATION_NAME validation. + if (validPlatform == 'github') { + var codeRegex = ghOrgRegex; + var codeRegexError = 'only letters, numbers, and hyphens'; + } else { + var codeRegex = glcbOrgRegex; + var codeRegexError = 'only letters, numbers, hyphens, and underscores'; + } + if (!codeRegex.test(orgName)) { + errors.push(`ORGANIZATION_NAME (${orgName}) is invalid for ${validPlatform} API calls, ${codeRegexError} are allowed`); + } } - const hfOrgRegex = /^[a-zA-Z0-9_\-]+$/; + // Hugging Face org names allow letters, numbers, hyphens, and underscores. + const hfOrgRegex = /^[a-zA-Z0-9](?:[a-zA-Z0-9_\-]*[a-zA-Z0-9])?$/; const hfOrgName = config.HF_ORGANIZATION_NAME; if (typeof hfOrgName !== 'string' || !hfOrgName.trim()) { errors.push('HF_ORGANIZATION_NAME'); @@ -32,17 +65,6 @@ export function validateConfig(config) { if (!config.ORG_NAME) errors.push('ORG_NAME'); if (!config.CATALOG_REPO_NAME) errors.push('CATALOG_REPO_NAME'); - /** Update to include 'gitlab' once supported - * PLATFORM will be parsed without whitespace, but must be string to avoid undefined errors - */ - const supportedPlatforms = ['github', 'codeberg']; - const platform = config.PLATFORM; - if (!platform || typeof platform !== 'string') { - errors.push('PLATFORM'); - } else if (!supportedPlatforms.includes(platform.toLowerCase())) { - errors.push(`PLATFORM must be one of: ${supportedPlatforms.join(', ')}`); - } - if (!config.API_BASE_URL) errors.push('API_BASE_URL'); if (config.REFRESH_INTERVAL_DAYS == null) errors.push('REFRESH_INTERVAL_DAYS'); diff --git a/tests/api/fetchCodeRepos.test.js b/tests/api/fetchCodeRepos.test.js index 6ae0fdd..ece9c1c 100644 --- a/tests/api/fetchCodeRepos.test.js +++ b/tests/api/fetchCodeRepos.test.js @@ -12,7 +12,7 @@ vi.mock('../../src/ui/render.js', () => ({ handleError: vi.fn() })); -const SUPPORTED_PLATFORMS = ['github', 'codeberg']; // 'gitlab' is pending, tests should work on implementation +const SUPPORTED_PLATFORMS = ['github', 'gitlab', 'codeberg']; const TEST_ORG = 'test-org'; const platformConfigs = SUPPORTED_PLATFORMS.map(platform => { @@ -27,7 +27,9 @@ const platformConfigs = SUPPORTED_PLATFORMS.map(platform => { platformProfileRepo: platformVals.profileRepo, forkKey: platformVals.forkKey, fullNameKey: platformVals.fullNameKey, + updatedAtKey: platformVals.updatedAtKey, urlKey: platformVals.urlKey, + encodeRepoId: platformVals.encodeRepoId // used only for GitLab Additional Repos }; }); @@ -58,7 +60,9 @@ describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { const platformProfileRepo = platformConfig.platformProfileRepo; const forkKey = platformConfig.forkKey; const fullNameKey = platformConfig.fullNameKey; + const updatedAtKey = platformConfig.updatedAtKey; const urlKey = platformConfig.urlKey; + const encodeRepoId = platformConfig.encodeRepoId; const refreshIntervalDays = 30; const releasesMap = {}; @@ -78,7 +82,7 @@ describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { name: 'code-repo', description: 'A test repository', [forkKey]: getMockForkValue(false, platform), - updated_at: now.toISOString(), + [updatedAtKey]: now.toISOString(), created_at: recentDateISO, topics: ['python'], [starsKey]: 42, @@ -105,6 +109,7 @@ describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { expect(items[0].repoType).toBe('code'); expect(items[0].description).toBe('A test repository'); // fork is used for inclusion filtering only and is not returned by fetchCodeRepos + expect(items[0].lastModified).toStrictEqual(now); expect(items[0].hasNewRelease).toBe(true); expect(items[0].latestReleaseTag).toBe('v1.0'); expect(items[0].tags).toContain('python'); @@ -133,7 +138,7 @@ describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { [fullNameKey]: 'test-org/repo-page1', name: 'repo-page1', created_at: recentDateISO, - updated_at: recentDateISO + [updatedAtKey]: recentDateISO }]) }); } else if (url === `${orgApiUrl}?page=2`) { @@ -145,7 +150,7 @@ describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { [fullNameKey]: 'test-org/repo-page2', name: 'repo-page2', created_at: recentDateISO, - updated_at: recentDateISO }]) + [updatedAtKey]: recentDateISO }]) }); } }); @@ -174,14 +179,17 @@ describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { [fullNameKey]: 'test-org/internal-additional', name: 'internal-additional', created_at: recentDateISO, - updated_at: recentDateISO + [updatedAtKey]: recentDateISO }; + const externalRepoId = 'external-org/external-additional'; + const expectedExternalRepoUrl = `${repoApiUrl}${encodeRepoId(externalRepoId)}`; + const mockExternalRepo = { - [fullNameKey]: 'external-org/external-additional', + [fullNameKey]: externalRepoId, name: 'external-additional', created_at: recentDateISO, - updated_at: recentDateISO + [updatedAtKey]: recentDateISO }; global.fetch.mockImplementation((url) => { @@ -192,7 +200,7 @@ describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { json: () => Promise.resolve([mockOrgRepo]) }); } - if (url === `${repoApiUrl}external-org/external-additional`) { + if (url === expectedExternalRepoUrl) { return Promise.resolve({ ok: true, headers: { get: () => null }, @@ -203,7 +211,7 @@ describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { const additionalRepos = [ 'test-org/internal-additional', - 'external-org/external-additional' + externalRepoId ]; const items = await fetchCodeRepos( @@ -220,6 +228,9 @@ describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { * should not hit the network a second time. */ expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch).toHaveBeenCalledWith( + `${expectedExternalRepoUrl}` + ); expect(global.fetch).not.toHaveBeenCalledWith( `${repoApiUrl}test-org/internal-additional` ); @@ -229,7 +240,7 @@ describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { */ expect(items).toHaveLength(2); expect(items.map(i => i.id)).toContain('test-org/internal-additional'); - expect(items.map(i => i.id)).toContain('external-org/external-additional'); + expect(items.map(i => i.id)).toContain(externalRepoId); }); // Test that forks and platform profile repos are excluded from the final list @@ -243,28 +254,28 @@ describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { name: 'valid-repo', [forkKey]: getMockForkValue(false, platform), created_at: recentDateISO, - updated_at: recentDateISO + [updatedAtKey]: recentDateISO }, { [fullNameKey]: 'test-org/forked-repo', name: 'forked-repo', [forkKey]: getMockForkValue(true, platform), created_at: recentDateISO, - updated_at: recentDateISO + [updatedAtKey]: recentDateISO }, { [fullNameKey]: 'test-org/forked-in-list', name: 'forked-in-list', [forkKey]: getMockForkValue(true, platform), created_at: recentDateISO, - updated_at: recentDateISO + [updatedAtKey]: recentDateISO }, { [fullNameKey]: `test-org/${platformProfileRepo}`, name: platformProfileRepo, [forkKey]: getMockForkValue(false, platform), created_at: recentDateISO, - updated_at: recentDateISO + [updatedAtKey]: recentDateISO } ]) }); @@ -298,13 +309,13 @@ describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { [fullNameKey]: 'test-org/new-repo', name: 'new-repo', created_at: recentDateISO, - updated_at: now.toISOString() + [updatedAtKey]: now.toISOString() }, { [fullNameKey]: 'test-org/old-repo', name: 'old-repo', created_at: oldestDateISO, - updated_at: oldDateISO + [updatedAtKey]: oldDateISO } ]) }); @@ -321,6 +332,9 @@ describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { const newRepo = items.find(i => i.id === 'test-org/new-repo'); const oldRepo = items.find(i => i.id === 'test-org/old-repo'); + // check that the lastModified dates are set correctly based on the platform-specific updatedAtKey + expect(items[0].lastModified).toStrictEqual(now); + expect(items[1].lastModified).toStrictEqual(new Date(oldDateISO)); expect(newRepo.isNew).toBe(true); expect(oldRepo.isNew).toBe(false); }); diff --git a/tests/validateConfig.test.js b/tests/validateConfig.test.js index 089e2e6..7354429 100644 --- a/tests/validateConfig.test.js +++ b/tests/validateConfig.test.js @@ -54,10 +54,20 @@ describe('validateConfig', () => { expect(validateConfig({ ...VALID_CONFIG, ORGANIZATION_NAME: '' })).toContain('ORGANIZATION_NAME'); }); - it('errors when ORGANIZATION_NAME is not in accepted format', () => { + it('errors when ORGANIZATION_NAME is not in accepted format for selected platform: GitHub, but okay for other platforms', () => { expect( - validateConfig({ ...VALID_CONFIG, ORGANIZATION_NAME: 'abc center' }) - ).toContain('ORGANIZATION_NAME (abc center) is invalid, only letters, numbers, and hyphens are allowed'); + validateConfig({ ...VALID_CONFIG, ORGANIZATION_NAME: 'abc_center' }) + ).toContain('ORGANIZATION_NAME (abc_center) is invalid for github API calls, only letters, numbers, and hyphens are allowed'); + }); + + it('does not error when ORGANIZATION_NAME is in accepted format for selected platform: GitLab, but not for GitHub', () => { + const config = { ...VALID_CONFIG, PLATFORM: 'gitlab', ORGANIZATION_NAME: 'abc_center' }; + expect(validateConfig(config)).toEqual([]); + }); + + it('errors when ORGANIZATION_NAME is not in accepted format for selected platform: Codeberg, and other platforms', () => { + const config = { ...VALID_CONFIG, PLATFORM: 'codeberg', ORGANIZATION_NAME: 'abc center' }; + expect(validateConfig(config)).toContain('ORGANIZATION_NAME (abc center) is invalid for codeberg API calls, only letters, numbers, hyphens, and underscores are allowed'); }); it('errors when HF_ORGANIZATION_NAME is missing', () => { @@ -101,7 +111,7 @@ describe('validateConfig', () => { it('errors when PLATFORM is not a string', () => { expect(validateConfig({ ...VALID_CONFIG, PLATFORM: 123 })).toContain('PLATFORM'); }); - + it('errors when PLATFORM is unrecognized', () => { const errors = validateConfig({ ...VALID_CONFIG, PLATFORM: 'blah' }); expect(errors.some(e => e.includes('PLATFORM'))).toBe(true); From 2e3f1a5b49f7e0751934372978aa4537d23eae46 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:28:26 -0400 Subject: [PATCH 10/12] Add number of results display near search bar Pulled from Imageomics catalog [PR 135](https://github.com/Imageomics/catalog/pull/135) cherry-pick commit: 6933942875119ea87a1624cd80647032ceda30f0 * feat: display result count next to search bar, adjusts with all filters Co-authored-by: egrace479 <38985481+egrace479@users.noreply.github.com> * match formatting standard * Add polite screen-reader notice of result count --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: egrace479 <38985481+egrace479@users.noreply.github.com> --- index.html | 22 +++++++++++++--------- package-lock.json | 42 ------------------------------------------ src/ui/render.js | 5 +++++ 3 files changed, 18 insertions(+), 51 deletions(-) diff --git a/index.html b/index.html index 86f0d06..9e61a46 100644 --- a/index.html +++ b/index.html @@ -98,15 +98,19 @@

- -
- - - - + +
+
+ + + + +
+
diff --git a/package-lock.json b/package-lock.json index 35ea501..0be73d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -450,9 +450,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -470,9 +467,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -490,9 +484,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -510,9 +501,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -530,9 +518,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -550,9 +535,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -779,9 +761,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -799,9 +778,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -819,9 +795,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -839,9 +812,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1497,9 +1467,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1521,9 +1488,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1545,9 +1509,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1569,9 +1530,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/src/ui/render.js b/src/ui/render.js index 754280f..6ef015d 100644 --- a/src/ui/render.js +++ b/src/ui/render.js @@ -143,6 +143,11 @@ const renderHubItemCard = (item) => { export const renderItemList = (items) => { const itemListElement = document.getElementById('itemList'); const emptyStateElement = document.getElementById('emptyState'); + const resultCountElement = document.getElementById('resultCount'); + + if (resultCountElement) { + resultCountElement.textContent = `${items.length} result${items.length !== 1 ? 's' : ''}`; + } if (items.length === 0) { itemListElement.innerHTML = ''; From 9ef866cacfe218110b56ac10faa8226d8340dc54 Mon Sep 17 00:00:00 2001 From: Elizabeth Campolongo <38985481+egrace479@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:38:12 -0400 Subject: [PATCH 11/12] Use config platform name for logging process Pulled from Imageomics catalog [PR 139](https://github.com/Imageomics/catalog/pull/139) cherry-pick commit: 51800ecaa94cd7698b57b6e5f6d0f1629581b219 Hugging Face fetch is hard-coded as 'HF', and pulling from display is incompatible with the SVG use in a Node environment --- scripts/export-tags.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/scripts/export-tags.js b/scripts/export-tags.js index 3e1dce0..7e762f2 100644 --- a/scripts/export-tags.js +++ b/scripts/export-tags.js @@ -17,7 +17,6 @@ import path from 'path'; import { fileURLToPath } from 'url'; import { load } from 'js-yaml'; import { validateConfig } from '../src/validateConfig.js'; -import { getPlatformDisplay } from '../src/utils/defineRibbonVals.js'; import { filterNewAdditionalEntries } from '../src/utils/filterNewAdditionalEntries.js'; import { getPlatformVals, getPlatformApiUrls } from '../src/utils/definePlatformVals.js'; import { getPlatformHeaders } from './platformScriptHelpers.js'; @@ -36,7 +35,7 @@ if (errors.length) { } const CONFIG = rawConfig; -const { ORGANIZATION_NAME, HF_ORGANIZATION_NAME, PLATFORM, API_BASE_URL, ADDITIONAL_REPOS } = CONFIG; +const { ORGANIZATION_NAME, HF_ORGANIZATION_NAME, API_BASE_URL, ADDITIONAL_REPOS } = CONFIG; const ADDITIONAL_HF_REPOS = CONFIG.ADDITIONAL_HF_REPOS; // --------------------------------------------------------------------------- @@ -62,8 +61,7 @@ const get = async (url) => { const allTags = new Set(); const collectCodePlatformTags = async () => { - const platformDisplay = getPlatformDisplay(platform); - console.log(`Fetching ${platformDisplay.displayName || PLATFORM} repos...`); + console.log(`Fetching ${platform} repos...`); let allRepos = []; let nextUrl = `${ORG_API_URL}`; @@ -92,7 +90,7 @@ const collectCodePlatformTags = async () => { (repo.topics || []).forEach(t => allTags.add(t.toLowerCase())); }); - console.log(` ${platformDisplay.displayName || PLATFORM}: processed ${orgNonForks.length} org repos + ${additionalRepos.length} additional repos`); + console.log(` ${platform}: processed ${orgNonForks.length} org repos + ${additionalRepos.length} additional repos`); }; const collectHFTags = async (repoType) => { From c4a7ec5421d451b8203d1199a115f9f6e6bc74ca Mon Sep 17 00:00:00 2001 From: Net Zhang <48858129+NetZissou@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:45:14 -0400 Subject: [PATCH 12/12] Apply batched suggestions from code review Co-authored-by: Elizabeth Campolongo <38985481+egrace479@users.noreply.github.com> --- README.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0b173f0..f7be6e3 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ This repository was generated and personalized from the [Imageomics Catalog](htt The website is styled using the [tailwindcss](https://tailwindcss.com/) package. -* **Real-time Data Fetching:** Displays all public organization repositories, fetched through the code platform (GitHub, GitLab, or Codeberg) and Hugging Face APIs. Includes semantically meaningful virtual markers: +* **Real-time Data Fetching:** Displays all public ABC Center repositories, fetched through the code platform (GitHub) and Hugging Face APIs. Includes semantically meaningful virtual markers: * "New" badge highlights products created within the last 30 days; * "🚀 version-tag" badge indicates a new release within the last 2 weeks for code repos, and links to that release; * Star (⭐️) or like (❤️) counts displayed for code platform or Hugging Face repos, respectively; diff --git a/package.json b/package.json index 83a4119..0141e12 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "catalog", "version": "1.0.0", - "description": "Repository for web-based ABC-Center code, data, model, and demoscatalog. This catalog is designed to use the GitHub API for searching all code repositories created under the [ABC-Center GitHub Organization](https://github.com/ABC-Center) and the Hugging Face API for searching all dataset, model, and spaces repositories created under the [ABC-Center Hugging Face Organization](https://huggingface.co/ABC-Center).", + "description": "Repository for web-based ABC-Center code, data, model, and demos catalog. This catalog is designed to use the GitHub API for searching all code repositories created under the [ABC-Center GitHub Organization](https://github.com/ABC-Center) and the Hugging Face API for searching all dataset, model, and spaces repositories created under the [ABC-Center Hugging Face Organization](https://huggingface.co/ABC-Center).", "main": "main.js", "scripts": { "dev": "vite",