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: 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 e7d8774..f7be6e3 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. @@ -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 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 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. @@ -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 (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. 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/personalization.md b/docs/personalization.md index 3a6d161..d37ad12 100644 --- a/docs/personalization.md +++ b/docs/personalization.md @@ -4,12 +4,13 @@ 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 - * `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 @@ -31,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', '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 @@ -87,3 +88,122 @@ 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. + +## 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 + +``` diff --git a/docs/tag-grouping-process.md b/docs/tag-grouping-process.md index b69a121..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 @@ -19,8 +24,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/index.html b/index.html index 0630243..9e61a46 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">
- +
@@ -99,15 +98,19 @@

- -
- - - - + +
+
+ + + + +
+
@@ -121,7 +124,7 @@

- +
diff --git a/main.js b/main.js index bf6c297..d47902b 100644 --- a/main.js +++ b/main.js @@ -6,12 +6,15 @@ // SECTION 1: CONFIGURATION AND STATE MANAGEMENT // -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 { 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/definePlatformVals.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. @@ -20,29 +23,12 @@ 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; -let 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 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, RELEASE_SUFFIX; let releasesMap = {}; @@ -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; - } + let items = [] - // hugging face api requests for datasets/models/spaces - const response = await fetch(`${API_BASE_URL}${repoType}?author=${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))]; + 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(() => ({})); } - - // 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); - } - - // 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; - 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 { @@ -703,7 +195,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' }, @@ -714,39 +206,35 @@ document.addEventListener('DOMContentLoaded', async () => { } // Assign module-scope variables used by all functions - ORGANIZATION_NAME = CONFIG.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; - - // 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`; + // 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 + // 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; } - // 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'); @@ -808,12 +296,7 @@ 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(); + 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') @@ -854,18 +337,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..0be73d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,458 +9,273 @@ "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": { "node": ">=24" } }, - "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" - ], + "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", - "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" - ], + "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": ">=18" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "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" - ], + "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", - "optional": true, - "os": [ - "linux" - ], + "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": ">=18" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "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" - ], + "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", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "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" - ], + "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", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } + "license": "MIT" }, - "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" - ], + "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", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" } }, - "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" - ], + "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, - "license": "MIT", - "optional": true, - "os": [ - "linux" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], + "license": "MIT-0", "engines": { - "node": ">=18" + "node": ">=20.19.0" } }, - "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" - ], + "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, - "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" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], - "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, - "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" - ], + "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, - "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" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], - "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, "engines": { - "node": ">=18" + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, - "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" - ], + "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, - "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" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], - "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], "engines": { - "node": ">=18" + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" } }, - "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" - ], + "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, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], - "engines": { - "node": ">=18" + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } } }, - "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" - ], + "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, - "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" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], - "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], "engines": { - "node": ">=18" + "node": ">=20.19.0" } }, - "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" + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" } }, - "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" - ], + "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-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/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, - "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" - ], + "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", - "optional": true, - "os": [ - "win32" - ], "engines": { - "node": ">=18" + "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": { @@ -513,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/@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/@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/@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" ], @@ -539,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" ], @@ -553,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" ], @@ -567,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" ], @@ -595,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" ], @@ -623,26 +437,15 @@ "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" ], @@ -651,54 +454,32 @@ "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" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "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==", + "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": [ - "loong64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "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==", - "cpu": [ - "ppc64" ], - "dev": true, - "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" ], @@ -707,40 +488,15 @@ "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" - ], - "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" ], @@ -749,12 +505,15 @@ "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" ], @@ -763,12 +522,15 @@ "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" ], @@ -777,26 +539,15 @@ "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" ], @@ -805,54 +556,51 @@ "optional": true, "os": [ "openharmony" - ] - }, - "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==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "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-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": [ - "ia32" + "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-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-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": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "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==", + "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" ], @@ -861,7 +609,17 @@ "optional": true, "os": [ "win32" - ] + ], + "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" }, "node_modules/@standard-schema/spec": { "version": "1.1.0", @@ -871,49 +629,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" ], @@ -928,9 +686,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" ], @@ -945,9 +703,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" ], @@ -962,9 +720,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" ], @@ -979,9 +737,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" ], @@ -996,9 +754,9 @@ } }, "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" ], @@ -1013,9 +771,9 @@ } }, "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" ], @@ -1030,9 +788,9 @@ } }, "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" ], @@ -1047,9 +805,9 @@ } }, "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" ], @@ -1064,9 +822,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", @@ -1082,85 +840,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" ], @@ -1175,9 +869,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" ], @@ -1192,18 +886,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": { @@ -1360,6 +1065,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 +1092,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", @@ -1388,19 +1138,32 @@ } }, "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" } }, + "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", @@ -1408,48 +1171,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", @@ -1510,10 +1231,30 @@ "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", - "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": { @@ -1521,21 +1262,72 @@ } }, "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": { + "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", - "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": { @@ -1549,23 +1341,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" ], @@ -1584,9 +1376,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" ], @@ -1605,9 +1397,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" ], @@ -1626,9 +1418,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" ], @@ -1647,9 +1439,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" ], @@ -1668,9 +1460,9 @@ } }, "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" ], @@ -1689,9 +1481,9 @@ } }, "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" ], @@ -1710,9 +1502,9 @@ } }, "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" ], @@ -1731,9 +1523,9 @@ } }, "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" ], @@ -1752,9 +1544,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" ], @@ -1773,9 +1565,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" ], @@ -1793,6 +1585,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,10 +1605,17 @@ "@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", - "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": [ { @@ -1833,6 +1642,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", @@ -1861,9 +1683,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": [ { @@ -1881,7 +1703,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -1889,49 +1711,71 @@ "node": "^10 || ^12 || >=14" } }, - "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/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/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": { + "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": { @@ -1965,17 +1809,24 @@ "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", - "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": { @@ -2004,14 +1855,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" @@ -2030,19 +1881,82 @@ "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/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", + "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", - "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" @@ -2058,9 +1972,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", @@ -2073,13 +1988,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { + "optional": true + }, + "jiti": { "optional": true }, - "lightningcss": { + "less": { "optional": true }, "sass": { @@ -2195,6 +2113,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 +2177,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..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 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 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", @@ -39,12 +39,13 @@ }, "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", - "vitest": "^4.1.5" + "vite": "^8.1.3", + "vitest": "^4.1.5", + "jsdom": "^29.1.1" } } diff --git a/public/config.yaml b/public/config.yaml index d15d0ee..2f05b58 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) @@ -21,9 +22,9 @@ 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", "codeberg", or "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 @@ -41,7 +42,8 @@ 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" # type: "datasets" diff --git a/scripts/export-tags.js b/scripts/export-tags.js index f3ec2ba..7e762f2 100644 --- a/scripts/export-tags.js +++ b/scripts/export-tags.js @@ -12,14 +12,14 @@ // 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/defineApiUrls.js'; -import { getPlatformDisplay } from '../src/defineRibbonVals.js'; -import { filterNewAdditionalEntries } from '../src/filterNewAdditionalEntries.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)); @@ -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) { @@ -35,21 +35,21 @@ if (errors.length) { } const CONFIG = rawConfig; -const { 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; // --------------------------------------------------------------------------- // 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 +59,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 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}`; @@ -78,26 +76,26 @@ 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) ) ); 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())); }); - 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) => { 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); @@ -145,7 +143,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 e868f25..ca68010 100644 --- a/scripts/fetch-releases.js +++ b/scripts/fetch-releases.js @@ -1,34 +1,40 @@ // 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'; 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/defineApiUrls.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); // 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) { 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 } = getPlatformApiUrls(CONFIG.PLATFORM, CONFIG.ORGANIZATION_NAME); let allOrgRepos = []; let nextUrl = `${ORG_API_URL}`; while (nextUrl) { @@ -47,29 +53,64 @@ 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) { +// 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}/releases/latest`, { 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.html_url, - publishedAt: data.published_at, - isNew: (Date.now() - new Date(data.published_at)) < 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 new file mode 100644 index 0000000..c918ac4 --- /dev/null +++ b/src/api/fetchCodeRepos.js @@ -0,0 +1,123 @@ +import { handleError } from '../ui/render.js'; +import { getPlatformVals } from '../utils/definePlatformVals.js'; +import { getPlatformDisplay } from '../utils/defineRibbonVals.js'; +import { normalizeTag, filterDisplayTags } from '../utils/normalizeTag.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', '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}`; + // get platform-specific keys + const { starsKey, profileRepo, fullNameKey, updatedAtKey, forkKey, urlKey, encodeRepoId } = getPlatformVals(platform); + 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[fullNameKey], 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}${encodeRepoId(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[fullNameKey])); + const orgNonForks = allRepos.filter(repo => + repo.name !== profileRepo && + !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] + .map(repo => { + const createdAt = new Date(repo.created_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()); + const tags = [...new Set(rawTags.flatMap(t => normalizeTag(t)).filter(Boolean))]; + const displayTags = filterDisplayTags(rawTags); + + const release = releasesMap[repo[fullNameKey]] ?? null; + + return { + id: repo[fullNameKey], // "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, // 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 + stars: repo[starsKey] ?? 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..c43c47d --- /dev/null +++ b/src/api/fetchStats.js @@ -0,0 +1,42 @@ +/** + * 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 + * @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, releaseSuffix) => { + // 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'); + } + } + }; + + // 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 { + // platform isn't passed, forks_count is shared + // 1. Get Stars & Forks + 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) + 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) { + 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/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/defineApiUrls.js b/src/defineApiUrls.js deleted file mode 100644 index 5b20e43..0000000 --- a/src/defineApiUrls.js +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Defines API URLs based on the selected platform (GitHub, note: GitLab and Codeberg 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} 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/defineRibbonVals.js b/src/defineRibbonVals.js deleted file mode 100644 index 9ab64a0..0000000 --- a/src/defineRibbonVals.js +++ /dev/null @@ -1,43 +0,0 @@ -/** - * A collection of platform-specific display information (e.g., SVG path data) - * for code developer platforms including GitHub, pending: GitLab and Codeberg. - * 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, - * displayName: DISPLAY_NAME, ribbonUrl: RIBBON_URL } - */ - -/** - * Utility function to get the full platform display information - * @param {string} platform - 'github', 'gitlab', or 'codeberg' - * @returns {object|null} - */ -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", - 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", - // 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/" - //} - }; - return platformDisplays[platform.toLowerCase()]; -} 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..c8c250d --- /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 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; + 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..6ef015d --- /dev/null +++ b/src/ui/render.js @@ -0,0 +1,169 @@ +/** + * 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'); + const resultCountElement = document.getElementById('resultCount'); + + if (resultCountElement) { + resultCountElement.textContent = `${items.length} result${items.length !== 1 ? 's' : ''}`; + } + + 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/utils/definePlatformVals.js b/src/utils/definePlatformVals.js new file mode 100644 index 0000000..07dd579 --- /dev/null +++ b/src/utils/definePlatformVals.js @@ -0,0 +1,81 @@ +/** + * 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', '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 + * @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', + updatedAtKey: 'updated_at', + forkKey: 'fork', //forks_count is shared + urlKey: 'html_url', + encodeRepoId: (ownerRepo) => ownerRepo + }, + gitlab: { + starsKey: 'star_count', + profileRepo: 'gitlab-profile', + 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', + 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', + encodeRepoId: (ownerRepo) => ownerRepo + } +}; + 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, GitLab, or Codeberg). + * 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', '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 + */ +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?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?type=public&limit=50`, + repo: "https://codeberg.org/api/v1/repos/", + releaseSuffix: "releases/latest" + } + }; + return platformApiUrls[platform.toLowerCase()]; +} diff --git a/src/utils/defineRibbonVals.js b/src/utils/defineRibbonVals.js new file mode 100644 index 0000000..a03249c --- /dev/null +++ b/src/utils/defineRibbonVals.js @@ -0,0 +1,41 @@ +/** + * A collection of platform-specific display information (e.g., SVGs and URLs) + * 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'; + * + * Input: platform (e.g., 'github'), defined from config.yaml and passed to this function. + * 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' + * @returns {object|null} + */ +export function getPlatformDisplay(platform) { + const platformDisplays = { + github: { + svg: githubSvg, + displayName: "GitHub", + ribbonUrl: "https://github.com/" + }, + gitlab: { + svg: gitlabSvg, + displayName: "GitLab", + ribbonUrl: "https://gitlab.com/" + }, + codeberg: { + svg: codebergSvg, + displayName: "Codeberg", + ribbonUrl: "https://codeberg.org/" + } + }; + return platformDisplays[platform.toLowerCase()]; +} 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/src/validateConfig.js b/src/validateConfig.js index 7cac8f3..6c861ce 100644 --- a/src/validateConfig.js +++ b/src/validateConfig.js @@ -14,15 +14,57 @@ export function validateConfig(config) { return errors; } - if (!config.ORGANIZATION_NAME) errors.push('ORGANIZATION_NAME'); - 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 */ - const supportedPlatforms = ['github']; - if (config.PLATFORM && !supportedPlatforms.includes(config.PLATFORM.toLowerCase())) { + /** + * 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 (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`); + } + } + // 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'); + } 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.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 new file mode 100644 index 0000000..ece9c1c --- /dev/null +++ b/tests/api/fetchCodeRepos.test.js @@ -0,0 +1,367 @@ +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', () => ({ + normalizeTag: vi.fn((tag) => [tag.toLowerCase()]), + filterDisplayTags: vi.fn((rawTags) => (rawTags || []).filter(t => !t.includes(':'))) +})); +vi.mock('../../src/ui/render.js', () => ({ + handleError: vi.fn() +})); + +const SUPPORTED_PLATFORMS = ['github', 'gitlab', 'codeberg']; +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, + updatedAtKey: platformVals.updatedAtKey, + urlKey: platformVals.urlKey, + encodeRepoId: platformVals.encodeRepoId // used only for GitLab Additional Repos + }; +}); + +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; + + beforeEach(() => { + global.fetch = vi.fn(); + }); + afterEach(() => { + global.fetch = originalFetch; + vi.clearAllMocks(); + }); + + const orgApiUrl = platformConfig.orgApiUrl; + const repoApiUrl = platformConfig.repoApiUrl; + const platform = platformConfig.name; + const starsKey = platformConfig.starsKey; + 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 = {}; + + // 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, 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([{ + [fullNameKey]: 'test-org/code-repo', + name: 'code-repo', + description: 'A test repository', + [forkKey]: getMockForkValue(false, platform), + [updatedAtKey]: now.toISOString(), + created_at: recentDateISO, + topics: ['python'], + [starsKey]: 42, + [urlKey]: 'http://example.com/test-org/code-repo' + }]) + }); + + // 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( + platform, + [], // No additional repos + orgApiUrl, + repoApiUrl, + refreshIntervalDays, + mockReleasesMap + ); + + 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].lastModified).toStrictEqual(now); + 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 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([{ + [fullNameKey]: 'test-org/repo-page1', + name: 'repo-page1', + created_at: recentDateISO, + [updatedAtKey]: 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([{ + [fullNameKey]: 'test-org/repo-page2', + name: 'repo-page2', + created_at: recentDateISO, + [updatedAtKey]: recentDateISO }]) + }); + } + }); + + const items = await fetchCodeRepos( + platform, + [], + 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 = { + [fullNameKey]: 'test-org/internal-additional', + name: 'internal-additional', + created_at: recentDateISO, + [updatedAtKey]: recentDateISO + }; + + const externalRepoId = 'external-org/external-additional'; + const expectedExternalRepoUrl = `${repoApiUrl}${encodeRepoId(externalRepoId)}`; + + const mockExternalRepo = { + [fullNameKey]: externalRepoId, + name: 'external-additional', + created_at: recentDateISO, + [updatedAtKey]: recentDateISO + }; + + global.fetch.mockImplementation((url) => { + if (url === orgApiUrl) { + return Promise.resolve({ + ok: true, + headers: { get: () => null }, + json: () => Promise.resolve([mockOrgRepo]) + }); + } + if (url === expectedExternalRepoUrl) { + return Promise.resolve({ + ok: true, + headers: { get: () => null }, + json: () => Promise.resolve(mockExternalRepo) + }); + } + }); + + const additionalRepos = [ + 'test-org/internal-additional', + externalRepoId + ]; + + const items = await fetchCodeRepos( + platform, + 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).toHaveBeenCalledWith( + `${expectedExternalRepoUrl}` + ); + 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(externalRepoId); + }); + + // 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 }, + json: () => Promise.resolve([ + { + [fullNameKey]: 'test-org/valid-repo', + name: 'valid-repo', + [forkKey]: getMockForkValue(false, platform), + created_at: recentDateISO, + [updatedAtKey]: recentDateISO + }, + { + [fullNameKey]: 'test-org/forked-repo', + name: 'forked-repo', + [forkKey]: getMockForkValue(true, platform), + created_at: recentDateISO, + [updatedAtKey]: recentDateISO + }, + { + [fullNameKey]: 'test-org/forked-in-list', + name: 'forked-in-list', + [forkKey]: getMockForkValue(true, platform), + created_at: recentDateISO, + [updatedAtKey]: recentDateISO + }, + { + [fullNameKey]: `test-org/${platformProfileRepo}`, + name: platformProfileRepo, + [forkKey]: getMockForkValue(false, platform), + created_at: recentDateISO, + [updatedAtKey]: recentDateISO + } + ]) + }); + + const additionalRepos = [ + 'test-org/forked-in-list' // This is a fork, but listed to include + ]; + + const items = await fetchCodeRepos( + platform, + 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/${platformProfileRepo}`); + }); + + // 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([ + { + [fullNameKey]: 'test-org/new-repo', + name: 'new-repo', + created_at: recentDateISO, + [updatedAtKey]: now.toISOString() + }, + { + [fullNameKey]: 'test-org/old-repo', + name: 'old-repo', + created_at: oldestDateISO, + [updatedAtKey]: oldDateISO + } + ]) + }); + + const items = await fetchCodeRepos( + platform, + [], + 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'); + + // 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); + }); + + // 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( + platform, + [], + orgApiUrl, + repoApiUrl, + refreshIntervalDays, + releasesMap + ); + + expect(handleError).toHaveBeenCalledWith( + expect.any(Error), + expect.stringContaining(`Failed to fetch code from ${platform}`) + ); + 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/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/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 = { diff --git a/tests/validateConfig.test.js b/tests/validateConfig.test.js index a9ee8c1..7354429 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,37 @@ describe('validateConfig', () => { expect(validateConfig({ ...VALID_CONFIG, ORGANIZATION_NAME: '' })).toContain('ORGANIZATION_NAME'); }); + 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 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', () => { + 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,7 +107,11 @@ 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' }); expect(errors.some(e => e.includes('PLATFORM'))).toBe(true); @@ -141,6 +177,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..34d5d49 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,5 +1,33 @@ import tailwindcss from '@tailwindcss/vite' import { defineConfig } from 'vite' +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 = 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); +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]